模型推理全异步化改造: - 计算节点 InferenceSession 改为后台线程异步加载模型,load 立即返回, 加载期间事件循环保持响应(/inference/status 与 /health 不阻塞) - 后端模型加载改为异步派发 + 轮询对账器(reconcile_inference_loads), 任务状态由 starting 自动推进到 ready/error,解决多节点启动超时 (timeout of 120000ms exceeded) - 推理删除/卸载改为任务感知 + 短超时,删除先删记录再 best-effort 卸载, 不再被不可达节点阻塞;同节点新模型替换旧任务标记失效 - 流式对话透传 task_id/node_id 路由到真正加载模型的算力节点, useStreamChat 解析 SSE 错误帧以干净文案展示 - 对话历史按任务 id 本地持久化,退出重进可恢复;移除页脚提示文本 - 新增后端推理异步加载与计算节点异步状态机单元测试 Co-Authored-By: Claude <noreply@anthropic.com>
182 lines
5.2 KiB
TypeScript
182 lines
5.2 KiB
TypeScript
import { ref } from 'vue'
|
||
import { streamChat, streamChatReal } from '@/api/modules/compare'
|
||
|
||
export interface StreamMessage {
|
||
/** 用户问题 */
|
||
question: string
|
||
/** 完整回答(含 think 标签原始内容) */
|
||
fullContent: string
|
||
/** 去除 think 标签后的展示内容 */
|
||
displayContent: string
|
||
/** 思考过程内容 */
|
||
thinkContent: string
|
||
/** 是否正在思考(think 标签未闭合) */
|
||
isThinking: boolean
|
||
/** 是否流式中 */
|
||
isStreaming: boolean
|
||
/** 是否已完成 */
|
||
done: boolean
|
||
/** 错误信息 */
|
||
error?: string
|
||
}
|
||
|
||
export interface SendOptions {
|
||
/** 是否使用 mock 模式(默认 true,向后兼容) */
|
||
useMock?: boolean
|
||
}
|
||
|
||
/**
|
||
* 流式对话 composable
|
||
* 移植自原 model-chat.html:
|
||
* - fetch + body.getReader() + TextDecoder
|
||
* - <think>...</think> 标签解析(思考过程可折叠)
|
||
* - 50ms 节流更新
|
||
*/
|
||
export function useStreamChat() {
|
||
const message = ref<StreamMessage>({
|
||
question: '',
|
||
fullContent: '',
|
||
displayContent: '',
|
||
thinkContent: '',
|
||
isThinking: false,
|
||
isStreaming: false,
|
||
done: false,
|
||
})
|
||
const loading = ref(false)
|
||
|
||
/** 从 SSE 帧中提取错误信息(后端/计算节点错误以 data: {"error": "..."} 形式下发) */
|
||
function extractSseError(buffer: string): string | null {
|
||
const trimmed = buffer.trim()
|
||
if (!trimmed.startsWith('data: ')) return null
|
||
const lines = trimmed.split(/\r?\n/)
|
||
for (let i = lines.length - 1; i >= 0; i--) {
|
||
const line = lines[i].trim()
|
||
if (!line.startsWith('data: ')) continue
|
||
try {
|
||
const obj = JSON.parse(line.slice(6))
|
||
if (obj && typeof obj.error === 'string' && obj.error) return obj.error
|
||
} catch {
|
||
/* 非 JSON 的 data 行忽略 */
|
||
}
|
||
}
|
||
return trimmed
|
||
}
|
||
|
||
/** 从内容中解析 think 标签 */
|
||
function parseContent(content: string) {
|
||
const thinkRegex = /<think>([\s\S]*?)(<\/think>)?/g
|
||
let think = ''
|
||
let display = content
|
||
let isThinking = false
|
||
|
||
let match
|
||
// 检查是否有未闭合的 think 标签
|
||
const openTags = (content.match(/<think>/g) || []).length
|
||
const closeTags = (content.match(/<\/think>/g) || []).length
|
||
isThinking = openTags > closeTags
|
||
|
||
// 提取所有 think 内容
|
||
while ((match = thinkRegex.exec(content)) !== null) {
|
||
think += match[1]
|
||
}
|
||
// 去除 think 标签得到展示内容
|
||
display = content.replace(/<think>[\s\S]*?(<\/think>|$)/g, '').trim()
|
||
|
||
return { think: think.trim(), display, isThinking }
|
||
}
|
||
|
||
/**
|
||
* 发起流式对话
|
||
* @param payload 后端请求体 { port, model_name, model_path, system_prompt, user_question, ... }
|
||
* @param options 可选配置 { useMock?: boolean }
|
||
*/
|
||
async function send(payload: any, options?: SendOptions) {
|
||
const useMock = options?.useMock ?? true
|
||
loading.value = true
|
||
message.value = {
|
||
question: payload.user_question || '',
|
||
fullContent: '',
|
||
displayContent: '',
|
||
thinkContent: '',
|
||
isThinking: false,
|
||
isStreaming: true,
|
||
done: false,
|
||
}
|
||
|
||
let lastUpdate = 0
|
||
const UPDATE_INTERVAL = 50 // 50ms 节流
|
||
|
||
try {
|
||
const response = useMock
|
||
? await streamChat(payload)
|
||
: await streamChatReal(payload)
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP ${response.status}`)
|
||
}
|
||
|
||
const reader = response.body?.getReader()
|
||
if (!reader) throw new Error('无法读取响应流')
|
||
|
||
const decoder = new TextDecoder('utf-8')
|
||
let buffer = ''
|
||
|
||
while (true) {
|
||
const { done, value } = await reader.read()
|
||
if (done) break
|
||
|
||
buffer += decoder.decode(value, { stream: true })
|
||
message.value.fullContent = buffer
|
||
|
||
const now = Date.now()
|
||
if (now - lastUpdate >= UPDATE_INTERVAL) {
|
||
lastUpdate = now
|
||
const parsed = parseContent(buffer)
|
||
message.value.thinkContent = parsed.think
|
||
message.value.displayContent = parsed.display
|
||
message.value.isThinking = parsed.isThinking
|
||
}
|
||
}
|
||
|
||
// 最终更新
|
||
// 若整段响应是 SSE 错误帧,提取 error 字段以干净文案展示
|
||
const sseError = extractSseError(buffer)
|
||
if (sseError) {
|
||
message.value.isThinking = false
|
||
message.value.isStreaming = false
|
||
message.value.done = true
|
||
message.value.error = sseError
|
||
message.value.displayContent = sseError
|
||
return
|
||
}
|
||
const parsed = parseContent(buffer)
|
||
message.value.thinkContent = parsed.think
|
||
message.value.displayContent = parsed.display
|
||
message.value.isThinking = false
|
||
message.value.isStreaming = false
|
||
message.value.done = true
|
||
} catch (e: any) {
|
||
message.value.isStreaming = false
|
||
message.value.done = true
|
||
message.value.error = e.message || '流式请求失败'
|
||
message.value.displayContent = message.value.fullContent || message.value.error || '请求失败'
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
function reset() {
|
||
message.value = {
|
||
question: '',
|
||
fullContent: '',
|
||
displayContent: '',
|
||
thinkContent: '',
|
||
isThinking: false,
|
||
isStreaming: false,
|
||
done: false,
|
||
}
|
||
}
|
||
|
||
return { message, loading, send, reset }
|
||
}
|