import { processDataStream } from 'ai' export type MessagePartType = 'text' | 'reasoning' | 'tool-call' | 'tool-result' export interface MessagePart { id: string type: MessagePartType text?: string toolName?: string toolCallId?: string args?: unknown result?: unknown state?: 'call' | 'result' reasoningLoading?: boolean reasoningDuration?: number } export interface LlmChatMessage { id: string role: 'user' | 'assistant' content: string parts?: MessagePart[] } export interface UseLlmChatOptions { modelId: () => number | null apiEndpoint?: string systemPrompt?: () => string enableThinking?: () => boolean enableTools?: () => boolean } export function useLlmChat(options: UseLlmChatOptions) { const { modelId, apiEndpoint = '/api/llm/chat', systemPrompt, enableThinking, enableTools, } = options const messages = ref([]) const isLoading = ref(false) const errorMessage = ref('') let abortController: AbortController | null = null function generateId(): string { return Date.now().toString(36) + Math.random().toString(36).slice(2) } function getOrCreateLastPart(msg: LlmChatMessage, type: MessagePartType): MessagePart | null { if (!msg.parts) msg.parts = [] const last = msg.parts[msg.parts.length - 1] if (last && last.type === type) return last return null } function appendPart(msg: LlmChatMessage, part: MessagePart) { if (!msg.parts) msg.parts = [] msg.parts.push(part) } function updateLastReasoningDuration(msg: LlmChatMessage) { if (!msg.parts) return for (let i = msg.parts.length - 1; i >= 0; i--) { const p = msg.parts[i] if (!p) continue if (p.type === 'reasoning' && p.reasoningLoading) { p.reasoningLoading = false return } } } async function sendMessage(text: string) { const trimmed = text.trim() const mid = modelId() if (!trimmed || mid === null || isLoading.value) return errorMessage.value = '' const userMsg: LlmChatMessage = { id: generateId(), role: 'user', content: trimmed } messages.value.push(userMsg) const assistantMsg: LlmChatMessage = { id: generateId(), role: 'assistant', content: '', parts: [], } messages.value.push(assistantMsg) const assistantIdx = messages.value.length - 1 isLoading.value = true abortController = new AbortController() let reasoningStartTime: number | null = null try { const res = await fetch(apiEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ modelId: mid, messages: [ ...(systemPrompt?.() ? [{ role: 'system' as const, content: systemPrompt() }] : []), ...messages.value .filter(m => m.content) .map(m => ({ role: m.role, content: m.content })), ], enableThinking: enableThinking?.() ?? false, enableTools: enableTools?.() ?? false, }), signal: abortController.signal, }) if (!res.ok) { const errText = await res.text() throw new Error(errText || `请求失败 (${res.status})`) } if (!res.body) { throw new Error('响应体为空') } await processDataStream({ stream: res.body, onReasoningPart: (text) => { const msg = messages.value[assistantIdx] if (!msg) return let part = getOrCreateLastPart(msg, 'reasoning') if (!part) { if (reasoningStartTime === null) reasoningStartTime = Date.now() part = { id: generateId(), type: 'reasoning', text: '', reasoningLoading: true } appendPart(msg, part) } part.text = (part.text ?? '') + text }, onTextPart: (text) => { const msg = messages.value[assistantIdx] if (!msg) return updateLastReasoningDuration(msg) let part = getOrCreateLastPart(msg, 'text') if (!part) { part = { id: generateId(), type: 'text', text: '' } appendPart(msg, part) } part.text = (part.text ?? '') + text msg.content += text }, onErrorPart: (error) => { errorMessage.value = error || '流式响应出错' }, onToolCallPart: (part) => { const msg = messages.value[assistantIdx] if (!msg) return updateLastReasoningDuration(msg) appendPart(msg, { id: generateId(), type: 'tool-call', toolName: part.toolName, toolCallId: part.toolCallId, args: part.args, state: 'call', }) }, onToolResultPart: (part) => { const msg = messages.value[assistantIdx] if (!msg || !msg.parts) return const callPart = msg.parts.find(p => p.type === 'tool-call' && p.toolCallId === part.toolCallId) if (callPart) { callPart.result = part.result callPart.state = 'result' } }, }) const msg = messages.value[assistantIdx] if (msg) updateLastReasoningDuration(msg) // 检查是否有实际内容产出 const finalMsg = messages.value[assistantIdx] if (finalMsg && !errorMessage.value) { const hasText = finalMsg.parts?.some(p => p.type === 'text' && p.text) const hasToolCall = finalMsg.parts?.some(p => p.type === 'tool-call') if (!hasText && !hasToolCall) { // 完全无内容,移除空消息 errorMessage.value = '模型未返回任何内容(可能已达到工具调用次数上限或模型无响应)' messages.value.splice(assistantIdx, 1) } else if (!hasText && hasToolCall) { // 有工具调用但无最终文本回答(maxSteps 用完),保留工具记录,追加提示 finalMsg.parts?.push({ id: generateId(), type: 'text', text: '(已达到工具调用次数上限,模型未能生成最终回答。以上是工具调用的尝试记录。)', }) } } } catch (err: any) { if (err.name === 'AbortError') { // user stopped } else { errorMessage.value = err.message || '请求失败' const msg = messages.value[assistantIdx] if (msg && !msg.content && (!msg.parts || msg.parts.length === 0)) { messages.value.splice(assistantIdx, 1) } } } finally { isLoading.value = false abortController = null } } function stopGeneration() { if (abortController) { abortController.abort() abortController = null } } function clearChat() { messages.value = [] errorMessage.value = '' } return { messages, isLoading, errorMessage, sendMessage, stopGeneration, clearChat, } }