@veltra/ai 2.1.0 → 2.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/chat/use-chat.js
CHANGED
|
@@ -75,9 +75,15 @@ function useChat(options) {
|
|
|
75
75
|
}
|
|
76
76
|
syncReasoningForModel(current);
|
|
77
77
|
}, { immediate: true });
|
|
78
|
-
/**
|
|
78
|
+
/**
|
|
79
|
+
* 向父组件同步消息快照(流式 delta 过于频繁,不同步,只在关键节点同步)。
|
|
80
|
+
* 必须经 setter 赋值触发 emit,而非直接 emit 切片:setter 让本地数组与下发的
|
|
81
|
+
* 快照保持同一引用,受控父级(v-model:messages)把快照回写到 props 时,
|
|
82
|
+
* useModel 同步回来的仍是这个数组(流式期间新增的 assistant 占位已 push 在其上);
|
|
83
|
+
* 若直接 emit 切片,本地数组与快照是两个对象,回显的旧快照会把本地新增的消息冲掉。
|
|
84
|
+
*/
|
|
79
85
|
const snapshot = () => {
|
|
80
|
-
|
|
86
|
+
messages.value = messages.value.slice();
|
|
81
87
|
};
|
|
82
88
|
/**
|
|
83
89
|
* 发送一条用户消息。会话进行中时进入待发送队列(不丢失),
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"use-chat.js","names":[],"sources":["../../src/chat/use-chat.ts"],"sourcesContent":["import { useModel } from '@veltra/compositions'\nimport { reactive, ref, watch, type Ref } from 'vue'\n\nimport { createBuiltinTools } from '../tools'\nimport type { AiChatEmits, AiChatProps } from '../types'\nimport type {\n ChatAttachment,\n ChatMessage,\n ChatQueuedMessage,\n ChatTool,\n ChatToolCall\n} from './types'\n\nexport interface UseChatOptions {\n props: AiChatProps\n emit: AiChatEmits\n}\n\nconst uid = () => {\n return typeof crypto !== 'undefined' && crypto.randomUUID\n ? crypto.randomUUID()\n : `${Date.now()}-${Math.random().toString(36).slice(2)}`\n}\n\nfunction serializeResult(result: unknown): string {\n if (typeof result === 'string') return result\n try {\n return JSON.stringify(result) ?? 'null'\n } catch {\n return String(result)\n }\n}\n\n/** 内置工具 + 用户工具,同名时内置优先 */\nfunction resolveTools(userTools?: ChatTool[]): ChatTool[] {\n const builtins = createBuiltinTools()\n const names = new Set(builtins.map((t) => t.name))\n return [...builtins, ...(userTools ?? []).filter((t) => !names.has(t.name))]\n}\n\n/**\n * AI 对话核心状态机:消息管理、流式追加、工具调用循环编排。\n * 与 UI 解耦,ai-chat.vue 只负责渲染与交互转发。\n * 始终注入内置工具(如 askQuestion),无需经 props 传入。\n */\nexport function useChat(options: UseChatOptions) {\n const { props, emit } = options\n\n const messages = useModel({\n props,\n propName: 'messages',\n emit,\n local: true,\n defaultValue: []\n }) as Ref<ChatMessage[]>\n\n const model = useModel({ props, propName: 'model', emit, local: true }) as Ref<string | undefined>\n\n const reasoningLevel = useModel({ props, propName: 'reasoningLevel', emit, local: true }) as Ref<\n string | undefined\n >\n\n /** 是否正在生成中(含工具执行与多轮循环) */\n const running = ref(false)\n\n /** 待发送队列:会话进行中提交的新消息按序排队,收尾后先进先出自动接续 */\n const queue = ref<ChatQueuedMessage[]>([])\n\n let abortController: AbortController | null = null\n /** needsConfirm 工具的挂起确认器,key 为 toolCallId */\n const confirmResolvers = new Map<string, (approved: boolean) => void>()\n /** 当前会话是否自然完成(finish);决定收尾后是否自动消耗队列 */\n let finishedNaturally = false\n /** startQueued 触发的中断需要在收尾后立即接续队首 */\n let resumeAfterAbort = false\n\n /** 按当前模型校正推理等级(无 levels 则清空;值不合法则落到默认/首项) */\n const syncReasoningForModel = (modelId: string | undefined) => {\n const option = props.models?.find((m) => m.id === modelId)\n if (!option?.reasoningLevels?.length) {\n if (reasoningLevel.value !== undefined) {\n reasoningLevel.value = undefined\n }\n return\n }\n const levels = option.reasoningLevels\n if (reasoningLevel.value && levels.some((l) => l.value === reasoningLevel.value)) return\n reasoningLevel.value = option.defaultReasoningLevel ?? levels[0]?.value\n }\n\n // models / model 变化时:确保有合法选中模型,并校正推理等级\n watch(\n () => [props.models, model.value] as const,\n ([models, current]) => {\n if (!models?.length) return\n if (!current || !models.some((m) => m.id === current)) {\n model.value = models[0]!.id\n return\n }\n syncReasoningForModel(current)\n },\n { immediate: true }\n )\n\n /** 向父组件同步消息快照(流式 delta 过于频繁,不同步,只在关键节点同步) */\n const snapshot = () => {\n emit('update:messages', messages.value.slice())\n }\n\n /**\n * 发送一条用户消息。会话进行中时进入待发送队列(不丢失),\n * 会话自然结束后按 FIFO 自动接续;空闲时立即开始新会话。\n */\n const send = (content: string, attachments?: ChatAttachment[]) => {\n if (!content.trim() && !attachments?.length) return\n enqueue(content, attachments)\n }\n\n /**\n * 向队列插入一条消息;beforeId 指定插到某条之前(缺省追加到尾部)。\n * 空闲时立即消耗队首(保持先来先发的合理顺序)。\n */\n const enqueue = (\n content: string,\n attachments?: ChatAttachment[],\n beforeId?: string\n ): ChatQueuedMessage => {\n const item: ChatQueuedMessage = { id: uid(), content, attachments }\n const anchorIndex = beforeId ? queue.value.findIndex((q) => q.id === beforeId) : -1\n if (anchorIndex === -1) {\n queue.value.push(item)\n } else {\n queue.value.splice(anchorIndex, 0, item)\n }\n if (!running.value) drainQueue()\n return item\n }\n\n /** 消耗队首并开始新一轮会话 */\n const drainQueue = () => {\n const next = queue.value.shift()\n if (next) startUserTurn(next.content, next.attachments)\n }\n\n /** 立即执行队列中的某条:中断当前会话,该条插队为下一条(其余保持原顺序) */\n const startQueued = (id: string) => {\n const item = removeQueued(id)\n if (!item) return\n\n if (!running.value) {\n startUserTurn(item.content, item.attachments)\n return\n }\n\n // 插回队首并中断当前会话,收尾逻辑会自动接上它\n queue.value.unshift(item)\n resumeAfterAbort = true\n abort()\n }\n\n /** 从队列移除某条(返回被移除项;编辑场景由 UI 取回内容) */\n const removeQueued = (id: string): ChatQueuedMessage | undefined => {\n const index = queue.value.findIndex((item) => item.id === id)\n if (index === -1) return undefined\n const [item] = queue.value.splice(index, 1)\n return item\n }\n\n /** 推送用户消息并启动对话循环 */\n const startUserTurn = (content: string, attachments?: ChatAttachment[]) => {\n const message = reactive<ChatMessage>({ id: uid(), role: 'user', content, attachments })\n messages.value.push(message)\n snapshot()\n emit('send', message)\n\n void runConversation()\n }\n\n /** 执行单个工具调用(含确认门控),结果以 tool 消息追加 */\n const executeToolCall = async (\n toolCall: ChatToolCall,\n signal: AbortSignal,\n tools: ChatTool[]\n ) => {\n const appendToolMessage = (content: string) => {\n messages.value.push(\n reactive<ChatMessage>({ id: uid(), role: 'tool', toolCallId: toolCall.id, content })\n )\n }\n\n const tool = tools.find((t) => t.name === toolCall.name)\n\n if (!tool) {\n toolCall.status = 'error'\n toolCall.error = `未找到名为 \"${toolCall.name}\" 的工具`\n appendToolMessage(`Error: tool \"${toolCall.name}\" is not available.`)\n return\n }\n\n if (tool.needsConfirm) {\n toolCall.status = 'awaiting-confirm'\n const approved = await new Promise<boolean>((resolve) => {\n confirmResolvers.set(toolCall.id, resolve)\n signal.addEventListener('abort', () => resolve(false), { once: true })\n })\n confirmResolvers.delete(toolCall.id)\n\n if (!approved) {\n toolCall.status = 'rejected'\n appendToolMessage(`The user rejected the execution of tool \"${toolCall.name}\".`)\n return\n }\n }\n\n toolCall.status = 'running'\n try {\n const args = toolCall.arguments ? JSON.parse(toolCall.arguments) : {}\n const result = await tool.execute(args, { toolCall, signal })\n toolCall.status = 'success'\n toolCall.result = serializeResult(result)\n appendToolMessage(toolCall.result)\n } catch (error) {\n toolCall.status = 'error'\n toolCall.error = error instanceof Error ? error.message : String(error)\n appendToolMessage(`Error: ${toolCall.error}`)\n }\n }\n\n /** 对话主循环入口:生成 → 工具执行 → 再生成,直至模型不再调用工具 */\n const runConversation = async () => {\n if (!props.transport) return\n\n running.value = true\n finishedNaturally = false\n const controller = new AbortController()\n abortController = controller\n\n try {\n await runRound(controller, 0)\n } finally {\n running.value = false\n abortController = null\n snapshot()\n\n // 队列接续:自然完成或 startQueued 中断插队时自动发下一条;\n // 手动停止 / 出错时保留队列,交由用户处置\n const shouldResume = finishedNaturally || resumeAfterAbort\n finishedNaturally = false\n resumeAfterAbort = false\n if (shouldResume) drainQueue()\n }\n }\n\n /** 单轮生成;存在工具调用时执行后递归进入下一轮(递归避免 await-in-loop) */\n const runRound = async (controller: AbortController, depth: number): Promise<void> => {\n if (!props.transport) return\n const { signal } = controller\n const tools = resolveTools(props.tools)\n\n const assistant = reactive<ChatMessage>({\n id: uid(),\n role: 'assistant',\n content: '',\n status: 'streaming',\n toolCalls: []\n })\n messages.value.push(assistant)\n\n let requestError: Error | null = null\n\n try {\n await props.transport(\n {\n // 排除刚追加的 assistant 占位消息\n messages: messages.value.slice(0, -1),\n systemPrompt: props.systemPrompt,\n tools,\n model: model.value,\n reasoningLevel: reasoningLevel.value,\n signal\n },\n {\n onTextDelta: (delta) => {\n assistant.content += delta\n },\n onReasoningDelta: (delta) => {\n assistant.reasoning = (assistant.reasoning ?? '') + delta\n },\n onToolCall: (call) => {\n const toolCall = reactive<ChatToolCall>({ ...call, status: 'pending' })\n assistant.toolCalls!.push(toolCall)\n emit('tool-call', toolCall)\n },\n onError: (error) => {\n requestError = error\n }\n }\n )\n } catch (error) {\n requestError = error instanceof Error ? error : new Error(String(error))\n }\n\n if (signal.aborted) {\n assistant.status = 'aborted'\n return\n }\n\n if (requestError) {\n assistant.status = 'error'\n emit('error', requestError)\n return\n }\n\n assistant.status = 'done'\n\n const toolCalls = assistant.toolCalls ?? []\n if (!toolCalls.length) {\n completeAsFinish(assistant)\n return\n }\n\n // 工具串行执行(保持结果消息与调用顺序一致),用 Promise 链避免 await-in-loop\n await toolCalls.reduce<Promise<void>>(\n (prev, toolCall) =>\n prev.then(() => (signal.aborted ? undefined : executeToolCall(toolCall, signal, tools))),\n Promise.resolve()\n )\n snapshot()\n\n if (signal.aborted) return\n\n // 终结工具执行成功:工具 UI 即最终答复,不再回灌模型生成文字\n const hitTerminal = toolCalls.some(\n (call) => call.status === 'success' && tools.some((t) => t.name === call.name && t.terminal)\n )\n if (hitTerminal) {\n completeAsFinish(assistant)\n return\n }\n\n // 达到最大轮次上限:停止继续请求,防止模型失控循环调用工具\n if (depth + 1 >= (props.maxToolRounds ?? 10)) {\n completeAsFinish(assistant)\n return\n }\n\n return runRound(controller, depth + 1)\n }\n\n /** 标记本轮对话自然完成(收尾后据此自动消耗待发送队列) */\n const completeAsFinish = (assistant: ChatMessage) => {\n finishedNaturally = true\n emit('finish', assistant)\n }\n\n /** 中断当前生成,挂起的工具确认按拒绝处理 */\n const abort = () => {\n abortController?.abort()\n }\n\n /** 响应 needsConfirm 工具的用户确认 */\n const respondToolCall = (toolCallId: string, approved: boolean) => {\n confirmResolvers.get(toolCallId)?.(approved)\n }\n\n /** 重新生成:移除最后一条用户消息之后的所有消息,重新跑对话循环 */\n const regenerate = () => {\n if (running.value) return\n\n const list = messages.value\n let lastUserIndex = -1\n for (let i = list.length - 1; i >= 0; i--) {\n if (list[i]?.role === 'user') {\n lastUserIndex = i\n break\n }\n }\n if (lastUserIndex === -1 || lastUserIndex === list.length - 1) return\n\n messages.value = list.slice(0, lastUserIndex + 1)\n snapshot()\n void runConversation()\n }\n\n /** 清空消息与待发送队列,生成中则先中断 */\n const clear = () => {\n abort()\n queue.value = []\n messages.value = []\n snapshot()\n }\n\n return {\n messages,\n model,\n reasoningLevel,\n running,\n queue,\n send,\n abort,\n regenerate,\n clear,\n respondToolCall,\n enqueue,\n startQueued,\n removeQueued\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"use-chat.js","names":[],"sources":["../../src/chat/use-chat.ts"],"sourcesContent":["import { useModel } from '@veltra/compositions'\nimport { reactive, ref, watch, type Ref } from 'vue'\n\nimport { createBuiltinTools } from '../tools'\nimport type { AiChatEmits, AiChatProps } from '../types'\nimport type {\n ChatAttachment,\n ChatMessage,\n ChatQueuedMessage,\n ChatTool,\n ChatToolCall\n} from './types'\n\nexport interface UseChatOptions {\n props: AiChatProps\n emit: AiChatEmits\n}\n\nconst uid = () => {\n return typeof crypto !== 'undefined' && crypto.randomUUID\n ? crypto.randomUUID()\n : `${Date.now()}-${Math.random().toString(36).slice(2)}`\n}\n\nfunction serializeResult(result: unknown): string {\n if (typeof result === 'string') return result\n try {\n return JSON.stringify(result) ?? 'null'\n } catch {\n return String(result)\n }\n}\n\n/** 内置工具 + 用户工具,同名时内置优先 */\nfunction resolveTools(userTools?: ChatTool[]): ChatTool[] {\n const builtins = createBuiltinTools()\n const names = new Set(builtins.map((t) => t.name))\n return [...builtins, ...(userTools ?? []).filter((t) => !names.has(t.name))]\n}\n\n/**\n * AI 对话核心状态机:消息管理、流式追加、工具调用循环编排。\n * 与 UI 解耦,ai-chat.vue 只负责渲染与交互转发。\n * 始终注入内置工具(如 askQuestion),无需经 props 传入。\n */\nexport function useChat(options: UseChatOptions) {\n const { props, emit } = options\n\n const messages = useModel({\n props,\n propName: 'messages',\n emit,\n local: true,\n defaultValue: []\n }) as Ref<ChatMessage[]>\n\n const model = useModel({ props, propName: 'model', emit, local: true }) as Ref<string | undefined>\n\n const reasoningLevel = useModel({ props, propName: 'reasoningLevel', emit, local: true }) as Ref<\n string | undefined\n >\n\n /** 是否正在生成中(含工具执行与多轮循环) */\n const running = ref(false)\n\n /** 待发送队列:会话进行中提交的新消息按序排队,收尾后先进先出自动接续 */\n const queue = ref<ChatQueuedMessage[]>([])\n\n let abortController: AbortController | null = null\n /** needsConfirm 工具的挂起确认器,key 为 toolCallId */\n const confirmResolvers = new Map<string, (approved: boolean) => void>()\n /** 当前会话是否自然完成(finish);决定收尾后是否自动消耗队列 */\n let finishedNaturally = false\n /** startQueued 触发的中断需要在收尾后立即接续队首 */\n let resumeAfterAbort = false\n\n /** 按当前模型校正推理等级(无 levels 则清空;值不合法则落到默认/首项) */\n const syncReasoningForModel = (modelId: string | undefined) => {\n const option = props.models?.find((m) => m.id === modelId)\n if (!option?.reasoningLevels?.length) {\n if (reasoningLevel.value !== undefined) {\n reasoningLevel.value = undefined\n }\n return\n }\n const levels = option.reasoningLevels\n if (reasoningLevel.value && levels.some((l) => l.value === reasoningLevel.value)) return\n reasoningLevel.value = option.defaultReasoningLevel ?? levels[0]?.value\n }\n\n // models / model 变化时:确保有合法选中模型,并校正推理等级\n watch(\n () => [props.models, model.value] as const,\n ([models, current]) => {\n if (!models?.length) return\n if (!current || !models.some((m) => m.id === current)) {\n model.value = models[0]!.id\n return\n }\n syncReasoningForModel(current)\n },\n { immediate: true }\n )\n\n /**\n * 向父组件同步消息快照(流式 delta 过于频繁,不同步,只在关键节点同步)。\n * 必须经 setter 赋值触发 emit,而非直接 emit 切片:setter 让本地数组与下发的\n * 快照保持同一引用,受控父级(v-model:messages)把快照回写到 props 时,\n * useModel 同步回来的仍是这个数组(流式期间新增的 assistant 占位已 push 在其上);\n * 若直接 emit 切片,本地数组与快照是两个对象,回显的旧快照会把本地新增的消息冲掉。\n */\n const snapshot = () => {\n messages.value = messages.value.slice()\n }\n\n /**\n * 发送一条用户消息。会话进行中时进入待发送队列(不丢失),\n * 会话自然结束后按 FIFO 自动接续;空闲时立即开始新会话。\n */\n const send = (content: string, attachments?: ChatAttachment[]) => {\n if (!content.trim() && !attachments?.length) return\n enqueue(content, attachments)\n }\n\n /**\n * 向队列插入一条消息;beforeId 指定插到某条之前(缺省追加到尾部)。\n * 空闲时立即消耗队首(保持先来先发的合理顺序)。\n */\n const enqueue = (\n content: string,\n attachments?: ChatAttachment[],\n beforeId?: string\n ): ChatQueuedMessage => {\n const item: ChatQueuedMessage = { id: uid(), content, attachments }\n const anchorIndex = beforeId ? queue.value.findIndex((q) => q.id === beforeId) : -1\n if (anchorIndex === -1) {\n queue.value.push(item)\n } else {\n queue.value.splice(anchorIndex, 0, item)\n }\n if (!running.value) drainQueue()\n return item\n }\n\n /** 消耗队首并开始新一轮会话 */\n const drainQueue = () => {\n const next = queue.value.shift()\n if (next) startUserTurn(next.content, next.attachments)\n }\n\n /** 立即执行队列中的某条:中断当前会话,该条插队为下一条(其余保持原顺序) */\n const startQueued = (id: string) => {\n const item = removeQueued(id)\n if (!item) return\n\n if (!running.value) {\n startUserTurn(item.content, item.attachments)\n return\n }\n\n // 插回队首并中断当前会话,收尾逻辑会自动接上它\n queue.value.unshift(item)\n resumeAfterAbort = true\n abort()\n }\n\n /** 从队列移除某条(返回被移除项;编辑场景由 UI 取回内容) */\n const removeQueued = (id: string): ChatQueuedMessage | undefined => {\n const index = queue.value.findIndex((item) => item.id === id)\n if (index === -1) return undefined\n const [item] = queue.value.splice(index, 1)\n return item\n }\n\n /** 推送用户消息并启动对话循环 */\n const startUserTurn = (content: string, attachments?: ChatAttachment[]) => {\n const message = reactive<ChatMessage>({ id: uid(), role: 'user', content, attachments })\n messages.value.push(message)\n snapshot()\n emit('send', message)\n\n void runConversation()\n }\n\n /** 执行单个工具调用(含确认门控),结果以 tool 消息追加 */\n const executeToolCall = async (\n toolCall: ChatToolCall,\n signal: AbortSignal,\n tools: ChatTool[]\n ) => {\n const appendToolMessage = (content: string) => {\n messages.value.push(\n reactive<ChatMessage>({ id: uid(), role: 'tool', toolCallId: toolCall.id, content })\n )\n }\n\n const tool = tools.find((t) => t.name === toolCall.name)\n\n if (!tool) {\n toolCall.status = 'error'\n toolCall.error = `未找到名为 \"${toolCall.name}\" 的工具`\n appendToolMessage(`Error: tool \"${toolCall.name}\" is not available.`)\n return\n }\n\n if (tool.needsConfirm) {\n toolCall.status = 'awaiting-confirm'\n const approved = await new Promise<boolean>((resolve) => {\n confirmResolvers.set(toolCall.id, resolve)\n signal.addEventListener('abort', () => resolve(false), { once: true })\n })\n confirmResolvers.delete(toolCall.id)\n\n if (!approved) {\n toolCall.status = 'rejected'\n appendToolMessage(`The user rejected the execution of tool \"${toolCall.name}\".`)\n return\n }\n }\n\n toolCall.status = 'running'\n try {\n const args = toolCall.arguments ? JSON.parse(toolCall.arguments) : {}\n const result = await tool.execute(args, { toolCall, signal })\n toolCall.status = 'success'\n toolCall.result = serializeResult(result)\n appendToolMessage(toolCall.result)\n } catch (error) {\n toolCall.status = 'error'\n toolCall.error = error instanceof Error ? error.message : String(error)\n appendToolMessage(`Error: ${toolCall.error}`)\n }\n }\n\n /** 对话主循环入口:生成 → 工具执行 → 再生成,直至模型不再调用工具 */\n const runConversation = async () => {\n if (!props.transport) return\n\n running.value = true\n finishedNaturally = false\n const controller = new AbortController()\n abortController = controller\n\n try {\n await runRound(controller, 0)\n } finally {\n running.value = false\n abortController = null\n snapshot()\n\n // 队列接续:自然完成或 startQueued 中断插队时自动发下一条;\n // 手动停止 / 出错时保留队列,交由用户处置\n const shouldResume = finishedNaturally || resumeAfterAbort\n finishedNaturally = false\n resumeAfterAbort = false\n if (shouldResume) drainQueue()\n }\n }\n\n /** 单轮生成;存在工具调用时执行后递归进入下一轮(递归避免 await-in-loop) */\n const runRound = async (controller: AbortController, depth: number): Promise<void> => {\n if (!props.transport) return\n const { signal } = controller\n const tools = resolveTools(props.tools)\n\n const assistant = reactive<ChatMessage>({\n id: uid(),\n role: 'assistant',\n content: '',\n status: 'streaming',\n toolCalls: []\n })\n messages.value.push(assistant)\n\n let requestError: Error | null = null\n\n try {\n await props.transport(\n {\n // 排除刚追加的 assistant 占位消息\n messages: messages.value.slice(0, -1),\n systemPrompt: props.systemPrompt,\n tools,\n model: model.value,\n reasoningLevel: reasoningLevel.value,\n signal\n },\n {\n onTextDelta: (delta) => {\n assistant.content += delta\n },\n onReasoningDelta: (delta) => {\n assistant.reasoning = (assistant.reasoning ?? '') + delta\n },\n onToolCall: (call) => {\n const toolCall = reactive<ChatToolCall>({ ...call, status: 'pending' })\n assistant.toolCalls!.push(toolCall)\n emit('tool-call', toolCall)\n },\n onError: (error) => {\n requestError = error\n }\n }\n )\n } catch (error) {\n requestError = error instanceof Error ? error : new Error(String(error))\n }\n\n if (signal.aborted) {\n assistant.status = 'aborted'\n return\n }\n\n if (requestError) {\n assistant.status = 'error'\n emit('error', requestError)\n return\n }\n\n assistant.status = 'done'\n\n const toolCalls = assistant.toolCalls ?? []\n if (!toolCalls.length) {\n completeAsFinish(assistant)\n return\n }\n\n // 工具串行执行(保持结果消息与调用顺序一致),用 Promise 链避免 await-in-loop\n await toolCalls.reduce<Promise<void>>(\n (prev, toolCall) =>\n prev.then(() => (signal.aborted ? undefined : executeToolCall(toolCall, signal, tools))),\n Promise.resolve()\n )\n snapshot()\n\n if (signal.aborted) return\n\n // 终结工具执行成功:工具 UI 即最终答复,不再回灌模型生成文字\n const hitTerminal = toolCalls.some(\n (call) => call.status === 'success' && tools.some((t) => t.name === call.name && t.terminal)\n )\n if (hitTerminal) {\n completeAsFinish(assistant)\n return\n }\n\n // 达到最大轮次上限:停止继续请求,防止模型失控循环调用工具\n if (depth + 1 >= (props.maxToolRounds ?? 10)) {\n completeAsFinish(assistant)\n return\n }\n\n return runRound(controller, depth + 1)\n }\n\n /** 标记本轮对话自然完成(收尾后据此自动消耗待发送队列) */\n const completeAsFinish = (assistant: ChatMessage) => {\n finishedNaturally = true\n emit('finish', assistant)\n }\n\n /** 中断当前生成,挂起的工具确认按拒绝处理 */\n const abort = () => {\n abortController?.abort()\n }\n\n /** 响应 needsConfirm 工具的用户确认 */\n const respondToolCall = (toolCallId: string, approved: boolean) => {\n confirmResolvers.get(toolCallId)?.(approved)\n }\n\n /** 重新生成:移除最后一条用户消息之后的所有消息,重新跑对话循环 */\n const regenerate = () => {\n if (running.value) return\n\n const list = messages.value\n let lastUserIndex = -1\n for (let i = list.length - 1; i >= 0; i--) {\n if (list[i]?.role === 'user') {\n lastUserIndex = i\n break\n }\n }\n if (lastUserIndex === -1 || lastUserIndex === list.length - 1) return\n\n messages.value = list.slice(0, lastUserIndex + 1)\n snapshot()\n void runConversation()\n }\n\n /** 清空消息与待发送队列,生成中则先中断 */\n const clear = () => {\n abort()\n queue.value = []\n messages.value = []\n snapshot()\n }\n\n return {\n messages,\n model,\n reasoningLevel,\n running,\n queue,\n send,\n abort,\n regenerate,\n clear,\n respondToolCall,\n enqueue,\n startQueued,\n removeQueued\n }\n}\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
-
import { nextTick } from 'vue'
|
|
3
|
-
|
|
4
|
-
import type { AiChatEmits } from '../../types'
|
|
5
|
-
import type {
|
|
2
|
+
import { nextTick, reactive } from 'vue'
|
|
3
|
+
|
|
4
|
+
import type { AiChatEmits, AiChatProps } from '../../types'
|
|
5
|
+
import type {
|
|
6
|
+
ChatMessage,
|
|
7
|
+
ChatTool,
|
|
8
|
+
ChatTransport,
|
|
9
|
+
ChatTransportHandlers,
|
|
10
|
+
ChatTransportRequest
|
|
11
|
+
} from '../types'
|
|
6
12
|
import { useChat } from '../use-chat'
|
|
7
13
|
|
|
8
14
|
const createEmit = () => vi.fn() as unknown as AiChatEmits & ReturnType<typeof vi.fn>
|
|
@@ -529,4 +535,36 @@ describe('useChat', () => {
|
|
|
529
535
|
expect(requests).toHaveLength(3)
|
|
530
536
|
expect(chat.running.value).toBe(false)
|
|
531
537
|
})
|
|
538
|
+
|
|
539
|
+
it('受控 v-model:messages:父级回显快照不会冲掉流式中的 assistant 消息', async () => {
|
|
540
|
+
// 模拟受控父级:收到 update:messages 后在后续微任务把快照写回 props(v-model 回显路径)。
|
|
541
|
+
// 时序上回显必然晚于本地 push assistant 占位(同一同步块内),还原真实场景的竞态
|
|
542
|
+
const props = reactive<AiChatProps>({
|
|
543
|
+
transport: async (_req, handlers) => {
|
|
544
|
+
// 让回显先于文本到达(与真实父级 patch 时序一致)
|
|
545
|
+
await Promise.resolve()
|
|
546
|
+
handlers.onTextDelta('回答')
|
|
547
|
+
}
|
|
548
|
+
})
|
|
549
|
+
const emit = ((event: string, payload: unknown) => {
|
|
550
|
+
if (event === 'update:messages') {
|
|
551
|
+
const value = payload as ChatMessage[]
|
|
552
|
+
void Promise.resolve().then(() => {
|
|
553
|
+
props.messages = value
|
|
554
|
+
})
|
|
555
|
+
}
|
|
556
|
+
}) as unknown as AiChatEmits
|
|
557
|
+
|
|
558
|
+
const chat = useChat({ props, emit })
|
|
559
|
+
chat.send('hi')
|
|
560
|
+
|
|
561
|
+
await vi.waitFor(() => {
|
|
562
|
+
expect(chat.messages.value[1]?.status).toBe('done')
|
|
563
|
+
})
|
|
564
|
+
|
|
565
|
+
expect(chat.messages.value.map((m) => m.role)).toEqual(['user', 'assistant'])
|
|
566
|
+
expect(chat.messages.value[1].content).toBe('回答')
|
|
567
|
+
// 最终同步给父级的快照也包含 assistant 消息
|
|
568
|
+
expect(props.messages?.some((m) => m.role === 'assistant' && m.content === '回答')).toBe(true)
|
|
569
|
+
})
|
|
532
570
|
})
|
package/src/chat/use-chat.ts
CHANGED
|
@@ -102,9 +102,15 @@ export function useChat(options: UseChatOptions) {
|
|
|
102
102
|
{ immediate: true }
|
|
103
103
|
)
|
|
104
104
|
|
|
105
|
-
/**
|
|
105
|
+
/**
|
|
106
|
+
* 向父组件同步消息快照(流式 delta 过于频繁,不同步,只在关键节点同步)。
|
|
107
|
+
* 必须经 setter 赋值触发 emit,而非直接 emit 切片:setter 让本地数组与下发的
|
|
108
|
+
* 快照保持同一引用,受控父级(v-model:messages)把快照回写到 props 时,
|
|
109
|
+
* useModel 同步回来的仍是这个数组(流式期间新增的 assistant 占位已 push 在其上);
|
|
110
|
+
* 若直接 emit 切片,本地数组与快照是两个对象,回显的旧快照会把本地新增的消息冲掉。
|
|
111
|
+
*/
|
|
106
112
|
const snapshot = () => {
|
|
107
|
-
|
|
113
|
+
messages.value = messages.value.slice()
|
|
108
114
|
}
|
|
109
115
|
|
|
110
116
|
/**
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from 'vitest'
|
|
2
2
|
import { createApp, defineComponent, h, nextTick, ref, type PropType } from 'vue'
|
|
3
3
|
|
|
4
|
-
import type { ChatTool, ChatToolCall, ChatTransport } from '../../../chat/types'
|
|
4
|
+
import type { ChatMessage, ChatTool, ChatToolCall, ChatTransport } from '../../../chat/types'
|
|
5
5
|
import type { ChatModelOption } from '../../../providers'
|
|
6
6
|
import type { AiChatExposed } from '../../../types'
|
|
7
7
|
import UAiChat from '../ai-chat.vue'
|
|
@@ -624,6 +624,45 @@ describe('UAiChat', () => {
|
|
|
624
624
|
unmount()
|
|
625
625
|
})
|
|
626
626
|
|
|
627
|
+
it('受控 v-model:messages 绑定下流式回复正常渲染', async () => {
|
|
628
|
+
const messages = ref<ChatMessage[]>([])
|
|
629
|
+
const chatRef = ref<AiChatExposed>()
|
|
630
|
+
const transport: ChatTransport = async (_req, handlers) => {
|
|
631
|
+
// 留出父级 patch props 的时间窗,还原真实的回显时序
|
|
632
|
+
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
633
|
+
handlers.onTextDelta('受控回答')
|
|
634
|
+
}
|
|
635
|
+
const host = document.createElement('div')
|
|
636
|
+
document.body.appendChild(host)
|
|
637
|
+
|
|
638
|
+
const app = createApp({
|
|
639
|
+
setup() {
|
|
640
|
+
return () =>
|
|
641
|
+
h(UAiChat, {
|
|
642
|
+
ref: chatRef,
|
|
643
|
+
transport,
|
|
644
|
+
messages: messages.value,
|
|
645
|
+
'onUpdate:messages': (value: ChatMessage[]) => {
|
|
646
|
+
messages.value = value
|
|
647
|
+
}
|
|
648
|
+
})
|
|
649
|
+
}
|
|
650
|
+
})
|
|
651
|
+
app.mount(host)
|
|
652
|
+
|
|
653
|
+
chatRef.value?.send('你好')
|
|
654
|
+
await vi.waitFor(() => {
|
|
655
|
+
expect(host.querySelector('.md-stub')?.textContent).toContain('受控回答')
|
|
656
|
+
})
|
|
657
|
+
// 父级持有的快照最终也包含 assistant 回复
|
|
658
|
+
expect(messages.value.some((m) => m.role === 'assistant' && m.content === '受控回答')).toBe(
|
|
659
|
+
true
|
|
660
|
+
)
|
|
661
|
+
|
|
662
|
+
app.unmount()
|
|
663
|
+
host.remove()
|
|
664
|
+
})
|
|
665
|
+
|
|
627
666
|
it('生成中空输入显示停止,有内容则显示入队发送', async () => {
|
|
628
667
|
const transport: ChatTransport = (req) => {
|
|
629
668
|
return new Promise<void>((resolve) => {
|