|
|
|
@ -1,6 +1,6 @@ |
|
|
|
import { processDataStream } from 'ai' |
|
|
|
import { parseJsonEventStream, uiMessageChunkSchema } from 'ai' |
|
|
|
|
|
|
|
export type MessagePartType = 'text' | 'reasoning' | 'tool-call' | 'tool-result' |
|
|
|
export type MessagePartType = 'text' | 'reasoning' | 'tool-call' | 'tool-result' | 'tool-approval' |
|
|
|
|
|
|
|
export interface MessagePart { |
|
|
|
id: string |
|
|
|
@ -10,7 +10,11 @@ export interface MessagePart { |
|
|
|
toolCallId?: string |
|
|
|
args?: unknown |
|
|
|
result?: unknown |
|
|
|
state?: 'call' | 'result' |
|
|
|
state?: 'call' | 'result' | 'approval-requested' | 'approval-responded' |
|
|
|
approvalId?: string |
|
|
|
approved?: boolean |
|
|
|
approvalReason?: string |
|
|
|
isAutomaticApproval?: boolean |
|
|
|
reasoningLoading?: boolean |
|
|
|
reasoningDuration?: number |
|
|
|
} |
|
|
|
@ -73,6 +77,248 @@ export function useLlmChat(options: UseLlmChatOptions) { |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
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() |
|
|
|
@ -95,23 +341,11 @@ export function useLlmChat(options: UseLlmChatOptions) { |
|
|
|
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, |
|
|
|
}), |
|
|
|
body: JSON.stringify(buildRequestBody()), |
|
|
|
signal: abortController.signal, |
|
|
|
}) |
|
|
|
|
|
|
|
@ -120,83 +354,8 @@ export function useLlmChat(options: UseLlmChatOptions) { |
|
|
|
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: '(已达到工具调用次数上限,模型未能生成最终回答。以上是工具调用的尝试记录。)', |
|
|
|
}) |
|
|
|
} |
|
|
|
} |
|
|
|
await processStream(res, assistantIdx) |
|
|
|
validateAssistantContent(assistantIdx) |
|
|
|
} catch (err: any) { |
|
|
|
if (err.name === 'AbortError') { |
|
|
|
// user stopped
|
|
|
|
@ -213,6 +372,49 @@ export function useLlmChat(options: UseLlmChatOptions) { |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
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() |
|
|
|
@ -232,5 +434,6 @@ export function useLlmChat(options: UseLlmChatOptions) { |
|
|
|
sendMessage, |
|
|
|
stopGeneration, |
|
|
|
clearChat, |
|
|
|
respondToApproval, |
|
|
|
} |
|
|
|
} |
|
|
|
|