@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.
- package/dist/chat/index.d.ts +5 -0
- package/dist/chat/transports/index.d.ts +2 -0
- package/dist/chat/transports/openai.d.ts +23 -0
- package/dist/chat/transports/openai.js +176 -0
- package/dist/chat/transports/openai.js.map +1 -0
- package/dist/chat/types.d.ts +125 -0
- package/dist/chat/use-chat.d.ts +25 -0
- package/dist/chat/use-chat.js +214 -0
- package/dist/chat/use-chat.js.map +1 -0
- package/dist/components/ai-chat/ai-chat.js +88 -0
- package/dist/components/ai-chat/ai-chat.js.map +1 -0
- package/dist/components/ai-chat/ai-chat.vue.d.ts +36 -0
- package/dist/components/ai-chat/ask-question.js +187 -0
- package/dist/components/ai-chat/ask-question.js.map +1 -0
- package/dist/components/ai-chat/chat-input.js +143 -0
- package/dist/components/ai-chat/chat-input.js.map +1 -0
- package/dist/components/ai-chat/di.js +6 -0
- package/dist/components/ai-chat/di.js.map +1 -0
- package/dist/components/ai-chat/index.d.ts +2 -0
- package/dist/components/ai-chat/message-item.js +120 -0
- package/dist/components/ai-chat/message-item.js.map +1 -0
- package/dist/components/ai-chat/message-list.js +75 -0
- package/dist/components/ai-chat/message-list.js.map +1 -0
- package/dist/components/ai-chat/style.d.ts +5 -0
- package/dist/components/ai-chat/style.js +6 -0
- package/dist/components/ai-chat/style2.css +416 -0
- package/dist/components/ai-chat/tool-call.js +151 -0
- package/dist/components/ai-chat/tool-call.js.map +1 -0
- package/dist/components/index.d.ts +3 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +5 -0
- package/dist/style.d.ts +1 -0
- package/dist/style.js +1 -0
- package/dist/tools/ask-question/ask-question.d.ts +45 -0
- package/dist/tools/ask-question/ask-question.js +65 -0
- package/dist/tools/ask-question/ask-question.js.map +1 -0
- package/dist/tools/ask-question/deferred.js +22 -0
- package/dist/tools/ask-question/deferred.js.map +1 -0
- package/dist/tools/ask-question/index.d.ts +2 -0
- package/dist/tools/index.d.ts +3 -0
- package/dist/types/ai-chat.d.ts +49 -0
- package/dist/types/index.d.ts +2 -0
- package/package.json +40 -0
- package/src/chat/__test__/use-chat.test.ts +246 -0
- package/src/chat/index.ts +3 -0
- package/src/chat/transports/index.ts +1 -0
- package/src/chat/transports/openai.ts +237 -0
- package/src/chat/types.ts +136 -0
- package/src/chat/use-chat.ts +245 -0
- package/src/components/ai-chat/__test__/ai-chat.test.ts +316 -0
- package/src/components/ai-chat/ai-chat.vue +67 -0
- package/src/components/ai-chat/ask-question.vue +232 -0
- package/src/components/ai-chat/chat-input.vue +127 -0
- package/src/components/ai-chat/di.ts +15 -0
- package/src/components/ai-chat/index.ts +1 -0
- package/src/components/ai-chat/message-item.vue +120 -0
- package/src/components/ai-chat/message-list.vue +81 -0
- package/src/components/ai-chat/style.scss +439 -0
- package/src/components/ai-chat/style.ts +6 -0
- package/src/components/ai-chat/tool-call.vue +147 -0
- package/src/components/index.ts +1 -0
- package/src/env.d.ts +12 -0
- package/src/index.ts +4 -0
- package/src/style.ts +1 -0
- package/src/tools/ask-question/__test__/ask-question.test.ts +198 -0
- package/src/tools/ask-question/ask-question.ts +114 -0
- package/src/tools/ask-question/deferred.ts +27 -0
- package/src/tools/ask-question/index.ts +1 -0
- package/src/tools/index.ts +1 -0
- package/src/types/ai-chat.ts +56 -0
- 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
|
+
}
|