feat: 联调流式对话
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
import { FASTAPI_BASE_URL, authHeaders, readError, type ApiEnvelope } from './session'
|
||||
|
||||
export type ExamItem = {
|
||||
item_code: string
|
||||
item_name: string
|
||||
item_type: string
|
||||
}
|
||||
|
||||
export type ExamResult = ExamItem & {
|
||||
result_text: string
|
||||
result_structured?: Record<string, unknown>
|
||||
is_key?: boolean
|
||||
is_abnormal?: boolean
|
||||
context_written?: boolean
|
||||
already_ordered?: boolean
|
||||
}
|
||||
|
||||
type ExamListResponse = {
|
||||
items: ExamItem[]
|
||||
}
|
||||
|
||||
type ExamKind = 'physical-exams' | 'auxiliary-exams'
|
||||
|
||||
export function fetchPhysicalExamItems(sessionId: number) {
|
||||
return fetchExamItems(sessionId, 'physical-exams')
|
||||
}
|
||||
|
||||
export function fetchAuxiliaryExamItems(sessionId: number) {
|
||||
return fetchExamItems(sessionId, 'auxiliary-exams')
|
||||
}
|
||||
|
||||
export function orderPhysicalExamResult(sessionId: number, itemCode: string) {
|
||||
return orderExamResult(sessionId, 'physical-exams', itemCode)
|
||||
}
|
||||
|
||||
export function orderAuxiliaryExamResult(sessionId: number, itemCode: string) {
|
||||
return orderExamResult(sessionId, 'auxiliary-exams', itemCode)
|
||||
}
|
||||
|
||||
function assertSessionId(sessionId: number) {
|
||||
if (!Number.isInteger(sessionId) || sessionId <= 0) {
|
||||
throw new Error('未找到当前会话,请先生成模拟场景')
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchExamItems(sessionId: number, kind: ExamKind) {
|
||||
assertSessionId(sessionId)
|
||||
const response = await fetch(`${FASTAPI_BASE_URL}/sessions/${sessionId}/${kind}`, {
|
||||
method: 'GET',
|
||||
headers: authHeaders()
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await readError(response))
|
||||
}
|
||||
|
||||
const result = (await response.json()) as ApiEnvelope<ExamListResponse>
|
||||
if (result.code !== 'OK' || !Array.isArray(result.data?.items)) {
|
||||
throw new Error(result.message || '检查列表加载失败')
|
||||
}
|
||||
|
||||
return result.data.items
|
||||
}
|
||||
|
||||
async function orderExamResult(sessionId: number, kind: ExamKind, itemCode: string) {
|
||||
assertSessionId(sessionId)
|
||||
const response = await fetch(`${FASTAPI_BASE_URL}/sessions/${sessionId}/${kind}/${encodeURIComponent(itemCode)}`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders()
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await readError(response))
|
||||
}
|
||||
|
||||
const result = (await response.json()) as ApiEnvelope<ExamResult>
|
||||
if (result.code !== 'OK' || !result.data?.item_code) {
|
||||
throw new Error(result.message || '检查结果获取失败')
|
||||
}
|
||||
|
||||
return result.data
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
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<string, unknown>) => 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<Record<string, unknown>>
|
||||
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<string, unknown>) {
|
||||
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<string, unknown>).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<string, unknown> : {})
|
||||
}
|
||||
}
|
||||
|
||||
function readDelta(data: unknown) {
|
||||
if (typeof data === 'string') return data
|
||||
if (!data || typeof data !== 'object') return ''
|
||||
|
||||
const payload = data as Record<string, unknown>
|
||||
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<string, unknown>
|
||||
const nestedDelta = nested.delta || nested.content || nested.answer || nested.text
|
||||
if (typeof nestedDelta === 'string') return nestedDelta
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
Reference in New Issue
Block a user