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.
439 lines
13 KiB
439 lines
13 KiB
import { parseJsonEventStream, uiMessageChunkSchema } from 'ai'
|
|
|
|
export type MessagePartType = 'text' | 'reasoning' | 'tool-call' | 'tool-result' | 'tool-approval'
|
|
|
|
export interface MessagePart {
|
|
id: string
|
|
type: MessagePartType
|
|
text?: string
|
|
toolName?: string
|
|
toolCallId?: string
|
|
args?: unknown
|
|
result?: unknown
|
|
state?: 'call' | 'result' | 'approval-requested' | 'approval-responded'
|
|
approvalId?: string
|
|
approved?: boolean
|
|
approvalReason?: string
|
|
isAutomaticApproval?: boolean
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
function toUIMessageParts(msg: LlmChatMessage): any[] {
|
|
if (!msg.parts) return msg.content ? [{ type: 'text', text: msg.content }] : []
|
|
const parts: any[] = []
|
|
for (const p of msg.parts) {
|
|
if (p.type === 'text' && p.text) {
|
|
parts.push({ type: 'text', text: p.text })
|
|
} else if (p.type === 'reasoning' && p.text) {
|
|
parts.push({ type: 'reasoning', text: p.text })
|
|
} else if (p.type === 'tool-call') {
|
|
if (p.state === 'approval-requested') {
|
|
parts.push({
|
|
type: `tool-${p.toolName}`,
|
|
toolCallId: p.toolCallId,
|
|
state: 'approval-requested',
|
|
input: p.args,
|
|
approval: { id: p.approvalId ?? '' },
|
|
})
|
|
} else if (p.state === 'approval-responded') {
|
|
parts.push({
|
|
type: `tool-${p.toolName}`,
|
|
toolCallId: p.toolCallId,
|
|
state: 'approval-responded',
|
|
input: p.args,
|
|
approval: {
|
|
id: p.approvalId ?? '',
|
|
approved: p.approved ?? false,
|
|
reason: p.approvalReason,
|
|
},
|
|
})
|
|
} else if (p.state === 'result') {
|
|
parts.push({
|
|
type: `tool-${p.toolName}`,
|
|
toolCallId: p.toolCallId,
|
|
state: 'output-available',
|
|
input: p.args,
|
|
output: p.result,
|
|
})
|
|
} else {
|
|
parts.push({
|
|
type: `tool-${p.toolName}`,
|
|
toolCallId: p.toolCallId,
|
|
state: 'input-available',
|
|
input: p.args,
|
|
})
|
|
}
|
|
} else if (p.type === 'tool-approval') {
|
|
if (p.state === 'approval-responded') {
|
|
parts.push({
|
|
type: `tool-${p.toolName}`,
|
|
toolCallId: p.toolCallId,
|
|
state: 'approval-responded',
|
|
input: p.args,
|
|
approval: {
|
|
id: p.approvalId ?? '',
|
|
approved: p.approved ?? false,
|
|
reason: p.approvalReason,
|
|
},
|
|
})
|
|
}
|
|
}
|
|
}
|
|
return parts
|
|
}
|
|
|
|
function buildRequestBody(extraMessages?: LlmChatMessage[]) {
|
|
const allMessages = extraMessages ? [...messages.value, ...extraMessages] : messages.value
|
|
const uiMessages: any[] = []
|
|
|
|
if (systemPrompt?.()) {
|
|
uiMessages.push({ role: 'system', parts: [{ type: 'text', text: systemPrompt() }] })
|
|
}
|
|
|
|
for (const m of allMessages) {
|
|
if (!m.content && (!m.parts || m.parts.length === 0)) continue
|
|
uiMessages.push({
|
|
role: m.role,
|
|
parts: toUIMessageParts(m),
|
|
})
|
|
}
|
|
|
|
return {
|
|
modelId: modelId(),
|
|
messages: uiMessages,
|
|
enableThinking: enableThinking?.() ?? false,
|
|
enableTools: enableTools?.() ?? false,
|
|
}
|
|
}
|
|
|
|
async function processStream(res: Response, assistantIdx: number) {
|
|
if (!res.body) throw new Error('响应体为空')
|
|
|
|
let reasoningStartTime: number | null = null
|
|
|
|
const chunkStream = parseJsonEventStream({
|
|
stream: res.body,
|
|
schema: uiMessageChunkSchema,
|
|
})
|
|
|
|
const reader = chunkStream.getReader()
|
|
for (;;) {
|
|
const { done, value: parsed } = await reader.read()
|
|
if (done) break
|
|
if (!parsed.success) continue
|
|
const chunk = parsed.value
|
|
|
|
switch (chunk.type) {
|
|
case 'reasoning-start': {
|
|
const msg = messages.value[assistantIdx]
|
|
if (!msg) break
|
|
if (reasoningStartTime === null) reasoningStartTime = Date.now()
|
|
appendPart(msg, { id: generateId(), type: 'reasoning', text: '', reasoningLoading: true })
|
|
break
|
|
}
|
|
case 'reasoning-delta': {
|
|
const msg = messages.value[assistantIdx]
|
|
if (!msg) break
|
|
const part = getOrCreateLastPart(msg, 'reasoning')
|
|
if (part) {
|
|
part.text = (part.text ?? '') + chunk.delta
|
|
}
|
|
break
|
|
}
|
|
case 'reasoning-end': {
|
|
const msg = messages.value[assistantIdx]
|
|
if (msg) updateLastReasoningDuration(msg)
|
|
break
|
|
}
|
|
case 'text-delta': {
|
|
const msg = messages.value[assistantIdx]
|
|
if (!msg) break
|
|
updateLastReasoningDuration(msg)
|
|
let part = getOrCreateLastPart(msg, 'text')
|
|
if (!part) {
|
|
part = { id: generateId(), type: 'text', text: '' }
|
|
appendPart(msg, part)
|
|
}
|
|
part.text = (part.text ?? '') + chunk.delta
|
|
msg.content += chunk.delta
|
|
break
|
|
}
|
|
case 'error': {
|
|
errorMessage.value = chunk.errorText || '流式响应出错'
|
|
break
|
|
}
|
|
case 'tool-input-available': {
|
|
const msg = messages.value[assistantIdx]
|
|
if (!msg) break
|
|
updateLastReasoningDuration(msg)
|
|
appendPart(msg, {
|
|
id: generateId(),
|
|
type: 'tool-call',
|
|
toolName: chunk.toolName,
|
|
toolCallId: chunk.toolCallId,
|
|
args: chunk.input,
|
|
state: 'call',
|
|
})
|
|
break
|
|
}
|
|
case 'tool-output-available': {
|
|
const msg = messages.value[assistantIdx]
|
|
if (!msg || !msg.parts) break
|
|
const callPart = msg.parts.find(p => p.type === 'tool-call' && p.toolCallId === chunk.toolCallId)
|
|
if (callPart) {
|
|
callPart.result = chunk.output
|
|
callPart.state = 'result'
|
|
}
|
|
break
|
|
}
|
|
case 'tool-output-denied': {
|
|
const msg = messages.value[assistantIdx]
|
|
if (!msg || !msg.parts) break
|
|
const callPart = msg.parts.find(p => p.type === 'tool-call' && p.toolCallId === chunk.toolCallId)
|
|
if (callPart) {
|
|
callPart.state = 'result'
|
|
callPart.result = '工具执行被拒绝'
|
|
}
|
|
break
|
|
}
|
|
case 'tool-approval-request': {
|
|
const msg = messages.value[assistantIdx]
|
|
if (!msg) break
|
|
updateLastReasoningDuration(msg)
|
|
const isAutomatic = !!(chunk as any).isAutomatic
|
|
const existingPart = msg.parts?.find(p => p.type === 'tool-call' && p.toolCallId === chunk.toolCallId)
|
|
if (existingPart) {
|
|
existingPart.state = 'approval-requested'
|
|
existingPart.approvalId = chunk.approvalId
|
|
existingPart.isAutomaticApproval = isAutomatic
|
|
} else {
|
|
appendPart(msg, {
|
|
id: generateId(),
|
|
type: 'tool-call',
|
|
toolName: (chunk as any).toolName,
|
|
toolCallId: chunk.toolCallId,
|
|
args: (chunk as any).input,
|
|
state: 'approval-requested',
|
|
approvalId: chunk.approvalId,
|
|
isAutomaticApproval: isAutomatic,
|
|
})
|
|
}
|
|
break
|
|
}
|
|
case 'tool-approval-response': {
|
|
const msg = messages.value[assistantIdx]
|
|
if (!msg || !msg.parts) break
|
|
const part = msg.parts.find(p => p.type === 'tool-call' && p.approvalId === chunk.approvalId)
|
|
if (part) {
|
|
part.state = 'approval-responded'
|
|
part.approved = chunk.approved
|
|
if (chunk.reason) part.approvalReason = chunk.reason
|
|
}
|
|
break
|
|
}
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
const msg = messages.value[assistantIdx]
|
|
if (msg) updateLastReasoningDuration(msg)
|
|
}
|
|
|
|
function validateAssistantContent(assistantIdx: number) {
|
|
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')
|
|
const hasPendingApproval = finalMsg.parts?.some(p => p.state === 'approval-requested' && !p.isAutomaticApproval)
|
|
if (hasPendingApproval) return
|
|
if (!hasText && !hasToolCall) {
|
|
errorMessage.value = '模型未返回任何内容(可能已达到工具调用次数上限或模型无响应)'
|
|
messages.value.splice(assistantIdx, 1)
|
|
} else if (!hasText && hasToolCall) {
|
|
finalMsg.parts?.push({
|
|
id: generateId(),
|
|
type: 'text',
|
|
text: '(已达到工具调用次数上限,模型未能生成最终回答。以上是工具调用的尝试记录。)',
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
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()
|
|
|
|
try {
|
|
const res = await fetch(apiEndpoint, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(buildRequestBody()),
|
|
signal: abortController.signal,
|
|
})
|
|
|
|
if (!res.ok) {
|
|
const errText = await res.text()
|
|
throw new Error(errText || `请求失败 (${res.status})`)
|
|
}
|
|
|
|
await processStream(res, assistantIdx)
|
|
validateAssistantContent(assistantIdx)
|
|
} 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
|
|
}
|
|
}
|
|
|
|
async function respondToApproval(toolCallId: string, approved: boolean, reason?: string) {
|
|
if (isLoading.value) return
|
|
|
|
const assistantMsg = messages.value.find(m =>
|
|
m.parts?.some(p => p.toolCallId === toolCallId && p.state === 'approval-requested'),
|
|
)
|
|
if (!assistantMsg) return
|
|
|
|
const approvalPart = assistantMsg.parts?.find(p => p.toolCallId === toolCallId && p.state === 'approval-requested')
|
|
if (!approvalPart) return
|
|
|
|
approvalPart.state = 'approval-responded'
|
|
approvalPart.approved = approved
|
|
approvalPart.approvalReason = reason
|
|
|
|
isLoading.value = true
|
|
abortController = new AbortController()
|
|
|
|
try {
|
|
const res = await fetch(apiEndpoint, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(buildRequestBody()),
|
|
signal: abortController.signal,
|
|
})
|
|
|
|
if (!res.ok) {
|
|
const errText = await res.text()
|
|
throw new Error(errText || `请求失败 (${res.status})`)
|
|
}
|
|
|
|
await processStream(res, messages.value.indexOf(assistantMsg))
|
|
validateAssistantContent(messages.value.indexOf(assistantMsg))
|
|
} catch (err: any) {
|
|
if (err.name !== 'AbortError') {
|
|
errorMessage.value = err.message || '请求失败'
|
|
}
|
|
} 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,
|
|
respondToApproval,
|
|
}
|
|
}
|
|
|