新增前后端AI聊天模块,包括流式对话、配置管理和UI组件优化。
This commit is contained in:
123
frontend/src/api/chat.ts
Normal file
123
frontend/src/api/chat.ts
Normal 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()
|
||||
}
|
||||
Reference in New Issue
Block a user