import { FASTAPI_BASE_URL, authHeaders, readError, type ApiEnvelope } from './session' export type LearningAssistantSession = { assistant_session_id: number | string title?: string } type ChatStreamPayload = { question: string top_k?: number score_threshold?: number } type StreamCallbacks = { onDelta: (delta: string) => void onDone?: (meta: Record) => void } export async function createLearningAssistantSession(title?: string) { const body = title?.trim() ? { title: title.trim().slice(0, 100) } : {} const response = await fetch(`${FASTAPI_BASE_URL}/learning-assistant/sessions`, { method: 'POST', headers: authHeaders(), body: JSON.stringify(body) }) if (!response.ok) { throw new Error(await readError(response)) } const result = (await response.json()) as ApiEnvelope> if (result.code !== 'OK' || !result.data) { throw new Error(result.message || '新建会话失败') } const assistantSessionId = readAssistantSessionId(result.data) if (!assistantSessionId) { throw new Error('新建会话返回缺少 assistant_session_id') } return { ...result.data, assistant_session_id: assistantSessionId } as LearningAssistantSession } export async function streamLearningAssistantChat( assistantSessionId: number | string, payload: ChatStreamPayload, callbacks: StreamCallbacks, signal?: AbortSignal ) { const response = await fetch(`${FASTAPI_BASE_URL}/learning-assistant/sessions/${assistantSessionId}/chat/stream`, { method: 'POST', headers: authHeaders('text/event-stream'), body: JSON.stringify(payload), signal }) if (!response.ok || !response.body) { throw new Error(await readError(response)) } const reader = response.body.getReader() const decoder = new TextDecoder() let buffer = '' while (true) { const { value, done } = await reader.read() if (done) break buffer += decoder.decode(value, { stream: true }) const blocks = buffer.split('\n\n') buffer = blocks.pop() || '' for (const block of blocks) { handleStreamBlock(block, callbacks) } } if (buffer.trim()) { handleStreamBlock(buffer, callbacks) } } function readAssistantSessionId(data: Record) { const value = data.assistant_session_id || data.session_id || data.id if (typeof value === 'number' || typeof value === 'string') return value return '' } function handleStreamBlock(block: string, callbacks: StreamCallbacks) { const event = block.match(/^event:\s*(.+)$/m)?.[1] || '' const rawLines = block .split('\n') .filter(line => line.startsWith('data:')) .map(line => line.replace(/^data:\s?/, '')) if (rawLines.length === 0) return const rawData = rawLines.join('\n') if (rawData === '[DONE]') { callbacks.onDone?.({}) return } let data: unknown = rawData try { data = JSON.parse(rawData) } catch {} if (event === 'error') { const message = typeof data === 'object' && data ? (data as Record).message : data throw new Error(typeof message === 'string' ? message : 'AI 学习助手回复失败') } const delta = readDelta(data) if (delta) callbacks.onDelta(delta) if (event === 'done' || event === 'message_done') { callbacks.onDone?.(typeof data === 'object' && data ? data as Record : {}) } } function readDelta(data: unknown) { if (typeof data === 'string') return data if (!data || typeof data !== 'object') return '' const payload = data as Record const delta = payload.delta || payload.content || payload.answer || payload.text if (typeof delta === 'string') return delta const nestedData = payload.data if (nestedData && typeof nestedData === 'object') { const nested = nestedData as Record const nestedDelta = nested.delta || nested.content || nested.answer || nested.text if (typeof nestedDelta === 'string') return nestedDelta } return '' }