@veltra/ai 1.4.0

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.
Files changed (71) hide show
  1. package/dist/chat/index.d.ts +5 -0
  2. package/dist/chat/transports/index.d.ts +2 -0
  3. package/dist/chat/transports/openai.d.ts +23 -0
  4. package/dist/chat/transports/openai.js +176 -0
  5. package/dist/chat/transports/openai.js.map +1 -0
  6. package/dist/chat/types.d.ts +125 -0
  7. package/dist/chat/use-chat.d.ts +25 -0
  8. package/dist/chat/use-chat.js +214 -0
  9. package/dist/chat/use-chat.js.map +1 -0
  10. package/dist/components/ai-chat/ai-chat.js +88 -0
  11. package/dist/components/ai-chat/ai-chat.js.map +1 -0
  12. package/dist/components/ai-chat/ai-chat.vue.d.ts +36 -0
  13. package/dist/components/ai-chat/ask-question.js +187 -0
  14. package/dist/components/ai-chat/ask-question.js.map +1 -0
  15. package/dist/components/ai-chat/chat-input.js +143 -0
  16. package/dist/components/ai-chat/chat-input.js.map +1 -0
  17. package/dist/components/ai-chat/di.js +6 -0
  18. package/dist/components/ai-chat/di.js.map +1 -0
  19. package/dist/components/ai-chat/index.d.ts +2 -0
  20. package/dist/components/ai-chat/message-item.js +120 -0
  21. package/dist/components/ai-chat/message-item.js.map +1 -0
  22. package/dist/components/ai-chat/message-list.js +75 -0
  23. package/dist/components/ai-chat/message-list.js.map +1 -0
  24. package/dist/components/ai-chat/style.d.ts +5 -0
  25. package/dist/components/ai-chat/style.js +6 -0
  26. package/dist/components/ai-chat/style2.css +416 -0
  27. package/dist/components/ai-chat/tool-call.js +151 -0
  28. package/dist/components/ai-chat/tool-call.js.map +1 -0
  29. package/dist/components/index.d.ts +3 -0
  30. package/dist/index.d.ts +11 -0
  31. package/dist/index.js +5 -0
  32. package/dist/style.d.ts +1 -0
  33. package/dist/style.js +1 -0
  34. package/dist/tools/ask-question/ask-question.d.ts +45 -0
  35. package/dist/tools/ask-question/ask-question.js +65 -0
  36. package/dist/tools/ask-question/ask-question.js.map +1 -0
  37. package/dist/tools/ask-question/deferred.js +22 -0
  38. package/dist/tools/ask-question/deferred.js.map +1 -0
  39. package/dist/tools/ask-question/index.d.ts +2 -0
  40. package/dist/tools/index.d.ts +3 -0
  41. package/dist/types/ai-chat.d.ts +49 -0
  42. package/dist/types/index.d.ts +2 -0
  43. package/package.json +40 -0
  44. package/src/chat/__test__/use-chat.test.ts +246 -0
  45. package/src/chat/index.ts +3 -0
  46. package/src/chat/transports/index.ts +1 -0
  47. package/src/chat/transports/openai.ts +237 -0
  48. package/src/chat/types.ts +136 -0
  49. package/src/chat/use-chat.ts +245 -0
  50. package/src/components/ai-chat/__test__/ai-chat.test.ts +316 -0
  51. package/src/components/ai-chat/ai-chat.vue +67 -0
  52. package/src/components/ai-chat/ask-question.vue +232 -0
  53. package/src/components/ai-chat/chat-input.vue +127 -0
  54. package/src/components/ai-chat/di.ts +15 -0
  55. package/src/components/ai-chat/index.ts +1 -0
  56. package/src/components/ai-chat/message-item.vue +120 -0
  57. package/src/components/ai-chat/message-list.vue +81 -0
  58. package/src/components/ai-chat/style.scss +439 -0
  59. package/src/components/ai-chat/style.ts +6 -0
  60. package/src/components/ai-chat/tool-call.vue +147 -0
  61. package/src/components/index.ts +1 -0
  62. package/src/env.d.ts +12 -0
  63. package/src/index.ts +4 -0
  64. package/src/style.ts +1 -0
  65. package/src/tools/ask-question/__test__/ask-question.test.ts +198 -0
  66. package/src/tools/ask-question/ask-question.ts +114 -0
  67. package/src/tools/ask-question/deferred.ts +27 -0
  68. package/src/tools/ask-question/index.ts +1 -0
  69. package/src/tools/index.ts +1 -0
  70. package/src/types/ai-chat.ts +56 -0
  71. package/src/types/index.ts +1 -0
@@ -0,0 +1,237 @@
1
+ import type {
2
+ ChatMessage,
3
+ ChatTool,
4
+ ChatTransport,
5
+ ChatTransportHandlers,
6
+ ChatTransportRequest
7
+ } from '../types'
8
+
9
+ /** createOpenAITransport 的配置项 */
10
+ export interface OpenAITransportOptions {
11
+ /** chat completions 端点,如 https://api.openai.com/v1/chat/completions */
12
+ endpoint: string
13
+ /** API Key(Bearer 鉴权) */
14
+ apiKey?: string
15
+ /** 模型名 */
16
+ model: string
17
+ /** 额外请求头 */
18
+ headers?: Record<string, string>
19
+ /** 额外请求体字段,如 temperature、top_p */
20
+ body?: Record<string, unknown>
21
+ }
22
+
23
+ interface OpenAIMessage {
24
+ role: string
25
+ content?: unknown
26
+ tool_calls?: unknown[]
27
+ tool_call_id?: string
28
+ }
29
+
30
+ function toOpenAIMessages(messages: ChatMessage[], systemPrompt?: string): OpenAIMessage[] {
31
+ const result: OpenAIMessage[] = []
32
+
33
+ if (systemPrompt) {
34
+ result.push({ role: 'system', content: systemPrompt })
35
+ }
36
+
37
+ for (const msg of messages) {
38
+ if (msg.role === 'user') {
39
+ if (msg.attachments?.length) {
40
+ const parts: unknown[] = []
41
+ if (msg.content) parts.push({ type: 'text', text: msg.content })
42
+ for (const att of msg.attachments) {
43
+ parts.push({ type: 'image_url', image_url: { url: att.dataUrl } })
44
+ }
45
+ result.push({ role: 'user', content: parts })
46
+ } else {
47
+ result.push({ role: 'user', content: msg.content })
48
+ }
49
+ } else if (msg.role === 'assistant') {
50
+ const item: OpenAIMessage = { role: 'assistant', content: msg.content || '' }
51
+ if (msg.toolCalls?.length) {
52
+ item.tool_calls = msg.toolCalls.map((call) => ({
53
+ id: call.id,
54
+ type: 'function',
55
+ function: { name: call.name, arguments: call.arguments }
56
+ }))
57
+ }
58
+ result.push(item)
59
+ } else if (msg.role === 'tool') {
60
+ result.push({ role: 'tool', tool_call_id: msg.toolCallId, content: msg.content })
61
+ }
62
+ }
63
+
64
+ return result
65
+ }
66
+
67
+ function toOpenAITools(tools: ChatTool[]): unknown[] {
68
+ return tools.map((tool) => ({
69
+ type: 'function',
70
+ function: { name: tool.name, description: tool.description, parameters: tool.parameters }
71
+ }))
72
+ }
73
+
74
+ interface AccumulatedToolCall {
75
+ id: string
76
+ name: string
77
+ arguments: string
78
+ }
79
+
80
+ /**
81
+ * 逐行解析 SSE 流。
82
+ * 兼容 OpenAI 及 DeepSeek 等 reasoning_content 风格的兼容端点。
83
+ */
84
+ async function parseSSE(
85
+ body: ReadableStream<Uint8Array>,
86
+ signal: AbortSignal,
87
+ handlers: ChatTransportHandlers
88
+ ): Promise<void> {
89
+ const reader = body.getReader()
90
+ const decoder = new TextDecoder()
91
+ let buffer = ''
92
+
93
+ // 按 OpenAI 流式协议中 tool_calls 的 index 累积参数片段
94
+ const accumulated = new Map<number, AccumulatedToolCall>()
95
+ let lastIndex = -1
96
+
97
+ const flushToolCalls = () => {
98
+ if (!handlers.onToolCall) return
99
+ for (const call of accumulated.values()) {
100
+ if (call.id && call.name) {
101
+ handlers.onToolCall({ id: call.id, name: call.name, arguments: call.arguments })
102
+ }
103
+ }
104
+ accumulated.clear()
105
+ }
106
+
107
+ const handleData = (data: string) => {
108
+ if (data === '[DONE]') return
109
+
110
+ let chunk: any
111
+ try {
112
+ chunk = JSON.parse(data)
113
+ } catch {
114
+ return
115
+ }
116
+
117
+ const delta = chunk.choices?.[0]?.delta
118
+ if (!delta) return
119
+
120
+ if (typeof delta.content === 'string' && delta.content) {
121
+ handlers.onTextDelta(delta.content)
122
+ }
123
+
124
+ // 兼容 reasoning_content(DeepSeek)与 reasoning(部分 OpenAI 兼容端点)
125
+ const reasoning = delta.reasoning_content ?? delta.reasoning
126
+ if (typeof reasoning === 'string' && reasoning) {
127
+ handlers.onReasoningDelta?.(reasoning)
128
+ }
129
+
130
+ if (Array.isArray(delta.tool_calls)) {
131
+ for (const tc of delta.tool_calls) {
132
+ const index: number = tc.index ?? 0
133
+ // index 回退说明新的一轮 tool_calls 开始,先把已累积的抛出去
134
+ if (index <= lastIndex && accumulated.size > 0) flushToolCalls()
135
+ lastIndex = Math.max(lastIndex, index)
136
+
137
+ let call = accumulated.get(index)
138
+ if (!call) {
139
+ call = { id: '', name: '', arguments: '' }
140
+ accumulated.set(index, call)
141
+ }
142
+ if (tc.id) call.id = tc.id
143
+ if (tc.function?.name) call.name += tc.function.name
144
+ if (tc.function?.arguments) call.arguments += tc.function.arguments
145
+ }
146
+ }
147
+ }
148
+
149
+ try {
150
+ // 递归泵取代替 while 循环,避免 await-in-loop
151
+ const pump = async (): Promise<void> => {
152
+ const { done, value } = await reader.read()
153
+ if (done || signal.aborted) return
154
+
155
+ buffer += decoder.decode(value, { stream: true })
156
+
157
+ let newlineIndex: number
158
+ while ((newlineIndex = buffer.indexOf('\n')) !== -1) {
159
+ const line = buffer.slice(0, newlineIndex).trim()
160
+ buffer = buffer.slice(newlineIndex + 1)
161
+ if (line.startsWith('data:')) {
162
+ handleData(line.slice(5).trim())
163
+ }
164
+ }
165
+
166
+ return pump()
167
+ }
168
+
169
+ await pump()
170
+
171
+ if (buffer.trim().startsWith('data:')) {
172
+ handleData(buffer.trim().slice(5).trim())
173
+ }
174
+ } finally {
175
+ reader.releaseLock()
176
+ }
177
+
178
+ flushToolCalls()
179
+ }
180
+
181
+ /**
182
+ * 创建 OpenAI 兼容协议的传输层(chat/completions SSE)。
183
+ * 手写 fetch + SSE 解析,无任何第三方依赖。
184
+ */
185
+ export function createOpenAITransport(options: OpenAITransportOptions): ChatTransport {
186
+ const { endpoint, apiKey, model, headers, body } = options
187
+
188
+ return async (request: ChatTransportRequest, handlers: ChatTransportHandlers) => {
189
+ const { messages, systemPrompt, tools, signal } = request
190
+
191
+ let response: Response
192
+ try {
193
+ response = await fetch(endpoint, {
194
+ method: 'POST',
195
+ signal,
196
+ headers: {
197
+ 'Content-Type': 'application/json',
198
+ ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
199
+ ...headers
200
+ },
201
+ body: JSON.stringify({
202
+ model,
203
+ stream: true,
204
+ ...body,
205
+ messages: toOpenAIMessages(messages, systemPrompt),
206
+ ...(tools?.length ? { tools: toOpenAITools(tools) } : {})
207
+ })
208
+ })
209
+ } catch (error) {
210
+ if (!signal.aborted) {
211
+ handlers.onError?.(error instanceof Error ? error : new Error(String(error)))
212
+ }
213
+ return
214
+ }
215
+
216
+ if (!response.ok) {
217
+ const text = await response.text().catch(() => '')
218
+ handlers.onError?.(
219
+ new Error(`请求失败(${response.status}):${text || response.statusText}`)
220
+ )
221
+ return
222
+ }
223
+
224
+ if (!response.body) {
225
+ handlers.onError?.(new Error('响应不包含可读取的流'))
226
+ return
227
+ }
228
+
229
+ try {
230
+ await parseSSE(response.body, signal, handlers)
231
+ } catch (error) {
232
+ if (!signal.aborted) {
233
+ handlers.onError?.(error instanceof Error ? error : new Error(String(error)))
234
+ }
235
+ }
236
+ }
237
+ }
@@ -0,0 +1,136 @@
1
+ import type { Component } from 'vue'
2
+
3
+ /** 聊天附件(首版仅支持图片) */
4
+ export interface ChatAttachment {
5
+ /** 文件名 */
6
+ name: string
7
+ /** MIME 类型 */
8
+ mimeType: string
9
+ /** 字节大小 */
10
+ size: number
11
+ /** base64 data URL */
12
+ dataUrl: string
13
+ }
14
+
15
+ /** 工具调用状态 */
16
+ export type ToolCallStatus =
17
+ | 'pending'
18
+ | 'awaiting-confirm'
19
+ | 'running'
20
+ | 'success'
21
+ | 'error'
22
+ | 'rejected'
23
+
24
+ /** 一次工具调用 */
25
+ export interface ChatToolCall {
26
+ /** 调用 id(由模型生成) */
27
+ id: string
28
+ /** 工具名 */
29
+ name: string
30
+ /** 模型输出的原始 JSON 参数串 */
31
+ arguments: string
32
+ /** 调用状态 */
33
+ status: ToolCallStatus
34
+ /** 序列化后的工具执行结果 */
35
+ result?: string
36
+ /** 执行失败信息 */
37
+ error?: string
38
+ }
39
+
40
+ /** 聊天消息 */
41
+ export interface ChatMessage {
42
+ /** 消息 id */
43
+ id: string
44
+ /** 角色 */
45
+ role: 'user' | 'assistant' | 'tool'
46
+ /** markdown 内容 */
47
+ content: string
48
+ /** 思考过程(reasoning) */
49
+ reasoning?: string
50
+ /** 用户消息携带的附件 */
51
+ attachments?: ChatAttachment[]
52
+ /** assistant 消息上的工具调用列表 */
53
+ toolCalls?: ChatToolCall[]
54
+ /** role 为 tool 时关联的工具调用 id */
55
+ toolCallId?: string
56
+ /** 消息状态,仅 assistant 消息使用 */
57
+ status?: 'streaming' | 'done' | 'error' | 'aborted'
58
+ }
59
+
60
+ /** 工具执行上下文 */
61
+ export interface ChatToolContext {
62
+ /** 本次工具调用 */
63
+ toolCall: ChatToolCall
64
+ /** 中断信号(用户点击停止时触发) */
65
+ signal: AbortSignal
66
+ }
67
+
68
+ /**
69
+ * 对话工具定义。
70
+ * 在不同场景传入不同的工具数组,即可赋予对话助手不同的能力。
71
+ */
72
+ export interface ChatTool<A = any> {
73
+ /** 工具名(传给模型,需唯一) */
74
+ name: string
75
+ /** 工具描述(传给模型) */
76
+ description: string
77
+ /** 参数 JSON Schema(原样传给模型) */
78
+ parameters: Record<string, unknown>
79
+ /** 执行前是否需要用户在 UI 中确认 */
80
+ needsConfirm?: boolean
81
+ /** 工具图标组件,缺省用内置状态图标(状态颜色/加载旋转仍作用于图标容器) */
82
+ icon?: Component
83
+ /** 工具显示名,缺省取 name */
84
+ label?: string
85
+ /**
86
+ * 自定义工具卡片内容渲染(组件或渲染函数),props 为 ChatToolRenderProps。
87
+ * 设置后替换卡片 body 的默认参数/结果展示;优先级高于 tool-<name> 插槽。
88
+ */
89
+ render?: Component
90
+ /** 执行完成后是否自动折叠。缺省:设置了 render 时为 false,否则为 true */
91
+ autoCollapse?: boolean
92
+ /** 工具实现,返回值(或 Promise 返回值)会被 JSON 序列化后回灌给模型 */
93
+ execute: (args: A, ctx: ChatToolContext) => unknown
94
+ }
95
+
96
+ /** 工具自定义渲染组件 / 渲染函数的 props */
97
+ export interface ChatToolRenderProps {
98
+ /** 本次工具调用(含 status/arguments/result/error,可自行渲染进度与错误) */
99
+ toolCall: ChatToolCall
100
+ }
101
+
102
+ /** transport 请求参数 */
103
+ export interface ChatTransportRequest {
104
+ /** 完整消息历史(内部模型,transport 自行转换为 wire 格式) */
105
+ messages: ChatMessage[]
106
+ /** 系统提示词 */
107
+ systemPrompt?: string
108
+ /** 可用工具 */
109
+ tools?: ChatTool[]
110
+ /** 中断信号 */
111
+ signal: AbortSignal
112
+ }
113
+
114
+ /** transport 流式事件回调 */
115
+ export interface ChatTransportHandlers {
116
+ /** 文本增量 */
117
+ onTextDelta(delta: string): void
118
+ /** 思考内容增量 */
119
+ onReasoningDelta?(delta: string): void
120
+ /**
121
+ * 一次完整的工具调用。
122
+ * 流式参数片段由 transport 内部累积,参数完整后才通过该回调抛出。
123
+ */
124
+ onToolCall?(call: { id: string; name: string; arguments: string }): void
125
+ /** 请求错误 */
126
+ onError?(error: Error): void
127
+ }
128
+
129
+ /**
130
+ * 对话传输层抽象。组件不关心具体的 LLM 协议,
131
+ * 可传入内置的 createOpenAITransport(),也可自行实现接入任意后端。
132
+ */
133
+ export type ChatTransport = (
134
+ request: ChatTransportRequest,
135
+ handlers: ChatTransportHandlers
136
+ ) => Promise<void> | void
@@ -0,0 +1,245 @@
1
+ import { useModel } from '@veltra/compositions'
2
+ import { reactive, ref, type Ref } from 'vue'
3
+
4
+ import type { AiChatEmits, AiChatProps } from '../types'
5
+ import type { ChatAttachment, ChatMessage, ChatToolCall } from './types'
6
+
7
+ export interface UseChatOptions {
8
+ props: AiChatProps
9
+ emit: AiChatEmits
10
+ }
11
+
12
+ const uid = () => {
13
+ return typeof crypto !== 'undefined' && crypto.randomUUID
14
+ ? crypto.randomUUID()
15
+ : `${Date.now()}-${Math.random().toString(36).slice(2)}`
16
+ }
17
+
18
+ function serializeResult(result: unknown): string {
19
+ if (typeof result === 'string') return result
20
+ try {
21
+ return JSON.stringify(result) ?? 'null'
22
+ } catch {
23
+ return String(result)
24
+ }
25
+ }
26
+
27
+ /**
28
+ * AI 对话核心状态机:消息管理、流式追加、工具调用循环编排。
29
+ * 与 UI 解耦,ai-chat.vue 只负责渲染与交互转发。
30
+ */
31
+ export function useChat(options: UseChatOptions) {
32
+ const { props, emit } = options
33
+
34
+ const messages = useModel({
35
+ props,
36
+ propName: 'messages',
37
+ emit,
38
+ local: true,
39
+ defaultValue: []
40
+ }) as Ref<ChatMessage[]>
41
+
42
+ /** 是否正在生成中(含工具执行与多轮循环) */
43
+ const running = ref(false)
44
+
45
+ let abortController: AbortController | null = null
46
+ /** needsConfirm 工具的挂起确认器,key 为 toolCallId */
47
+ const confirmResolvers = new Map<string, (approved: boolean) => void>()
48
+
49
+ /** 向父组件同步消息快照(流式 delta 过于频繁,不同步,只在关键节点同步) */
50
+ const snapshot = () => {
51
+ emit('update:messages', messages.value.slice())
52
+ }
53
+
54
+ /** 发送一条用户消息并启动对话循环 */
55
+ const send = (content: string, attachments?: ChatAttachment[]) => {
56
+ if (running.value) return
57
+ if (!content.trim() && !attachments?.length) return
58
+
59
+ const message = reactive<ChatMessage>({ id: uid(), role: 'user', content, attachments })
60
+ messages.value.push(message)
61
+ snapshot()
62
+ emit('send', message)
63
+
64
+ void runConversation()
65
+ }
66
+
67
+ /** 执行单个工具调用(含确认门控),结果以 tool 消息追加 */
68
+ const executeToolCall = async (toolCall: ChatToolCall, signal: AbortSignal) => {
69
+ const appendToolMessage = (content: string) => {
70
+ messages.value.push(
71
+ reactive<ChatMessage>({ id: uid(), role: 'tool', toolCallId: toolCall.id, content })
72
+ )
73
+ }
74
+
75
+ const tool = props.tools?.find((t) => t.name === toolCall.name)
76
+
77
+ if (!tool) {
78
+ toolCall.status = 'error'
79
+ toolCall.error = `未找到名为 "${toolCall.name}" 的工具`
80
+ appendToolMessage(`Error: tool "${toolCall.name}" is not available.`)
81
+ return
82
+ }
83
+
84
+ if (tool.needsConfirm) {
85
+ toolCall.status = 'awaiting-confirm'
86
+ const approved = await new Promise<boolean>((resolve) => {
87
+ confirmResolvers.set(toolCall.id, resolve)
88
+ signal.addEventListener('abort', () => resolve(false), { once: true })
89
+ })
90
+ confirmResolvers.delete(toolCall.id)
91
+
92
+ if (!approved) {
93
+ toolCall.status = 'rejected'
94
+ appendToolMessage(`The user rejected the execution of tool "${toolCall.name}".`)
95
+ return
96
+ }
97
+ }
98
+
99
+ toolCall.status = 'running'
100
+ try {
101
+ const args = toolCall.arguments ? JSON.parse(toolCall.arguments) : {}
102
+ const result = await tool.execute(args, { toolCall, signal })
103
+ toolCall.status = 'success'
104
+ toolCall.result = serializeResult(result)
105
+ appendToolMessage(toolCall.result)
106
+ } catch (error) {
107
+ toolCall.status = 'error'
108
+ toolCall.error = error instanceof Error ? error.message : String(error)
109
+ appendToolMessage(`Error: ${toolCall.error}`)
110
+ }
111
+ }
112
+
113
+ /** 对话主循环入口:生成 → 工具执行 → 再生成,直至模型不再调用工具 */
114
+ const runConversation = async () => {
115
+ if (!props.transport) return
116
+
117
+ running.value = true
118
+ const controller = new AbortController()
119
+ abortController = controller
120
+
121
+ try {
122
+ await runRound(controller)
123
+ } finally {
124
+ running.value = false
125
+ abortController = null
126
+ snapshot()
127
+ }
128
+ }
129
+
130
+ /** 单轮生成;存在工具调用时执行后递归进入下一轮(递归避免 await-in-loop) */
131
+ const runRound = async (controller: AbortController): Promise<void> => {
132
+ if (!props.transport) return
133
+ const { signal } = controller
134
+
135
+ const assistant = reactive<ChatMessage>({
136
+ id: uid(),
137
+ role: 'assistant',
138
+ content: '',
139
+ status: 'streaming',
140
+ toolCalls: []
141
+ })
142
+ messages.value.push(assistant)
143
+
144
+ let requestError: Error | null = null
145
+
146
+ try {
147
+ await props.transport(
148
+ {
149
+ // 排除刚追加的 assistant 占位消息
150
+ messages: messages.value.slice(0, -1),
151
+ systemPrompt: props.systemPrompt,
152
+ tools: props.tools,
153
+ signal
154
+ },
155
+ {
156
+ onTextDelta: (delta) => {
157
+ assistant.content += delta
158
+ },
159
+ onReasoningDelta: (delta) => {
160
+ assistant.reasoning = (assistant.reasoning ?? '') + delta
161
+ },
162
+ onToolCall: (call) => {
163
+ const toolCall = reactive<ChatToolCall>({ ...call, status: 'pending' })
164
+ assistant.toolCalls!.push(toolCall)
165
+ emit('tool-call', toolCall)
166
+ },
167
+ onError: (error) => {
168
+ requestError = error
169
+ }
170
+ }
171
+ )
172
+ } catch (error) {
173
+ requestError = error instanceof Error ? error : new Error(String(error))
174
+ }
175
+
176
+ if (signal.aborted) {
177
+ assistant.status = 'aborted'
178
+ return
179
+ }
180
+
181
+ if (requestError) {
182
+ assistant.status = 'error'
183
+ emit('error', requestError)
184
+ return
185
+ }
186
+
187
+ assistant.status = 'done'
188
+
189
+ const toolCalls = assistant.toolCalls ?? []
190
+ if (!toolCalls.length) {
191
+ emit('finish', assistant)
192
+ return
193
+ }
194
+
195
+ // 工具串行执行(保持结果消息与调用顺序一致),用 Promise 链避免 await-in-loop
196
+ await toolCalls.reduce<Promise<void>>(
197
+ (prev, toolCall) =>
198
+ prev.then(() => (signal.aborted ? undefined : executeToolCall(toolCall, signal))),
199
+ Promise.resolve()
200
+ )
201
+ snapshot()
202
+
203
+ if (signal.aborted) return
204
+
205
+ return runRound(controller)
206
+ }
207
+
208
+ /** 中断当前生成,挂起的工具确认按拒绝处理 */
209
+ const abort = () => {
210
+ abortController?.abort()
211
+ }
212
+
213
+ /** 响应 needsConfirm 工具的用户确认 */
214
+ const respondToolCall = (toolCallId: string, approved: boolean) => {
215
+ confirmResolvers.get(toolCallId)?.(approved)
216
+ }
217
+
218
+ /** 重新生成:移除最后一条用户消息之后的所有消息,重新跑对话循环 */
219
+ const regenerate = () => {
220
+ if (running.value) return
221
+
222
+ const list = messages.value
223
+ let lastUserIndex = -1
224
+ for (let i = list.length - 1; i >= 0; i--) {
225
+ if (list[i]?.role === 'user') {
226
+ lastUserIndex = i
227
+ break
228
+ }
229
+ }
230
+ if (lastUserIndex === -1 || lastUserIndex === list.length - 1) return
231
+
232
+ messages.value = list.slice(0, lastUserIndex + 1)
233
+ snapshot()
234
+ void runConversation()
235
+ }
236
+
237
+ /** 清空消息,生成中则先中断 */
238
+ const clear = () => {
239
+ abort()
240
+ messages.value = []
241
+ snapshot()
242
+ }
243
+
244
+ return { messages, running, send, abort, regenerate, clear, respondToolCall }
245
+ }