You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

236 lines
6.8 KiB

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<LlmChatMessage[]>([])
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,
}
}