新增前后端AI聊天模块,包括流式对话、配置管理和UI组件优化。

This commit is contained in:
2026-03-19 01:35:13 +08:00
commit ba6ceb94c8
66 changed files with 8631 additions and 0 deletions

123
frontend/src/api/chat.ts Normal file
View File

@@ -0,0 +1,123 @@
import { http } from './client'
import type { AIConfig, AIConfigSave, ChatMessageList } from '@/types/chat'
// ── AI配置 ──
export async function getAIConfig(): Promise<AIConfig> {
const { data } = await http.get<AIConfig>('/ai/config')
return data
}
export async function saveAIConfig(payload: AIConfigSave): Promise<AIConfig> {
const { data } = await http.put<AIConfig>('/ai/config', payload)
return data
}
export async function testAIConfig(payload: AIConfigSave): Promise<{ ok: boolean; message: string }> {
const { data } = await http.post<{ ok: boolean; message: string }>('/ai/config/test', payload)
return data
}
// ── 聊天消息 ──
export async function fetchMessages(novelId?: number): Promise<ChatMessageList> {
const params: Record<string, unknown> = {}
if (novelId !== undefined) params.novel_id = novelId
const { data } = await http.get<ChatMessageList>('/chat/messages', { params })
return data
}
export async function clearMessages(novelId?: number): Promise<void> {
const params: Record<string, unknown> = {}
if (novelId !== undefined) params.novel_id = novelId
await http.delete('/chat/messages', { params })
}
// ── 流式聊天 ──
export interface TokenUsage {
prompt_tokens: number
completion_tokens: number
total_tokens: number
}
export interface ContextMessage {
role: string
content: string
}
export interface StreamChatOptions {
messages: { role: string; content: string }[]
novelId?: number
onChunk: (text: string) => void
onError: (error: string) => void
onDone: (usage?: TokenUsage) => void
onContext?: (context: ContextMessage[]) => void
signal?: AbortSignal
}
export async function streamChat(options: StreamChatOptions): Promise<void> {
const { messages, novelId, onChunk, onError, onDone, onContext, signal } = options
const body = JSON.stringify({
messages,
novel_id: novelId ?? null,
})
const resp = await fetch('/api/chat/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
signal,
})
if (!resp.ok || !resp.body) {
onError(`请求失败: ${resp.status}`)
onDone()
return
}
const reader = resp.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
for (const line of lines) {
if (!line.startsWith('data: ')) continue
try {
const event = JSON.parse(line.slice(6))
if (event.done) {
onDone(event.usage ?? undefined)
return
}
if (event.error) {
onError(event.error)
onDone()
return
}
if (event.context && onContext) {
onContext(event.context)
}
if (event.content) {
onChunk(event.content)
}
} catch {
// 忽略解析错误
}
}
}
} catch (e) {
if ((e as Error).name !== 'AbortError') {
onError((e as Error).message)
}
}
onDone()
}

View File

@@ -0,0 +1,16 @@
import axios from 'axios'
export const http = axios.create({
baseURL: '/api',
timeout: 10000,
headers: { 'Content-Type': 'application/json' },
})
http.interceptors.response.use(
(res) => res,
(error) => {
const message = error.response?.data?.detail || error.message || '请求失败'
console.error('[API Error]', message)
return Promise.reject(error)
},
)

View File

@@ -0,0 +1,26 @@
import { http } from './client'
import type { Novel, NovelCreate, NovelUpdate, NovelList } from '@/types/novel'
export async function fetchNovels(skip = 0, limit = 20): Promise<NovelList> {
const { data } = await http.get<NovelList>('/novels', { params: { skip, limit } })
return data
}
export async function fetchNovel(id: number): Promise<Novel> {
const { data } = await http.get<Novel>(`/novels/${id}`)
return data
}
export async function createNovel(payload: NovelCreate): Promise<Novel> {
const { data } = await http.post<Novel>('/novels', payload)
return data
}
export async function updateNovel(id: number, payload: NovelUpdate): Promise<Novel> {
const { data } = await http.patch<Novel>(`/novels/${id}`, payload)
return data
}
export async function deleteNovel(id: number): Promise<void> {
await http.delete(`/novels/${id}`)
}

View File

@@ -0,0 +1,33 @@
import { http } from './client'
import type { Outline, OutlineCreate, OutlineUpdate, OutlineList, OutlineReorder } from '@/types/outline'
const base = (novelId: number) => `/novels/${novelId}/outlines`
export async function fetchOutlines(novelId: number): Promise<OutlineList> {
const { data } = await http.get<OutlineList>(base(novelId))
return data
}
export async function fetchOutline(novelId: number, outlineId: number): Promise<Outline> {
const { data } = await http.get<Outline>(`${base(novelId)}/${outlineId}`)
return data
}
export async function createOutline(novelId: number, payload: OutlineCreate): Promise<Outline> {
const { data } = await http.post<Outline>(base(novelId), payload)
return data
}
export async function updateOutline(novelId: number, outlineId: number, payload: OutlineUpdate): Promise<Outline> {
const { data } = await http.patch<Outline>(`${base(novelId)}/${outlineId}`, payload)
return data
}
export async function deleteOutline(novelId: number, outlineId: number): Promise<void> {
await http.delete(`${base(novelId)}/${outlineId}`)
}
export async function reorderOutlines(novelId: number, orderedIds: number[]): Promise<OutlineList> {
const { data } = await http.put<OutlineList>(`${base(novelId)}/reorder`, { ordered_ids: orderedIds })
return data
}