新增前后端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()
|
||||
}
|
||||
16
frontend/src/api/client.ts
Normal file
16
frontend/src/api/client.ts
Normal 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)
|
||||
},
|
||||
)
|
||||
26
frontend/src/api/novels.ts
Normal file
26
frontend/src/api/novels.ts
Normal 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}`)
|
||||
}
|
||||
33
frontend/src/api/outlines.ts
Normal file
33
frontend/src/api/outlines.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user