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
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,
|
|
}
|
|
}
|
|
|