@huyooo/ai-chat-core 0.2.19 → 0.2.21
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/events.d.ts +452 -0
- package/dist/events.js +1 -0
- package/dist/index.d.ts +202 -550
- package/dist/index.js +1 -1
- package/package.json +23 -4
- package/src/agent.ts +399 -0
- package/src/constants.ts +125 -0
- package/src/events.ts +797 -0
- package/src/index.ts +309 -0
- package/src/internal/update-plan.ts +2 -0
- package/src/internal/web-search.ts +78 -0
- package/src/mcp/client-manager.ts +301 -0
- package/src/mcp/index.ts +2 -0
- package/src/mcp/types.ts +43 -0
- package/src/providers/context-compressor.ts +149 -0
- package/src/providers/index.ts +120 -0
- package/src/providers/model-registry.ts +320 -0
- package/src/providers/orchestrator.ts +761 -0
- package/src/providers/protocols/anthropic.ts +406 -0
- package/src/providers/protocols/ark.ts +362 -0
- package/src/providers/protocols/deepseek.ts +344 -0
- package/src/providers/protocols/error-utils.ts +74 -0
- package/src/providers/protocols/gemini.ts +350 -0
- package/src/providers/protocols/index.ts +36 -0
- package/src/providers/protocols/openai.ts +420 -0
- package/src/providers/protocols/qwen.ts +326 -0
- package/src/providers/protocols/types.ts +189 -0
- package/src/providers/types.ts +272 -0
- package/src/providers/unified-adapter.ts +367 -0
- package/src/router.ts +72 -0
- package/src/test-utils/mock-sse.ts +32 -0
- package/src/tools.ts +162 -0
- package/src/types.ts +531 -0
- package/src/utils.ts +86 -0
|
@@ -0,0 +1,761 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chat Orchestrator
|
|
3
|
+
*
|
|
4
|
+
* 统一处理:
|
|
5
|
+
* 1. 工具调用循环 (while iterations < MAX)
|
|
6
|
+
* 2. 消息历史维护 (assistant + tool 消息)
|
|
7
|
+
* 3. 事件发射 (ChatEvent)
|
|
8
|
+
* 4. 错误处理
|
|
9
|
+
*
|
|
10
|
+
* 所有 Provider 通过此类执行,确保行为一致
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { ChatEvent } from '../events';
|
|
14
|
+
import type { ChatMessage, ToolDefinition, SideEffect, ToolResult } from '../types';
|
|
15
|
+
import type {
|
|
16
|
+
ProviderAdapter,
|
|
17
|
+
StandardMessage,
|
|
18
|
+
StreamChunk,
|
|
19
|
+
ToolCallRequest,
|
|
20
|
+
OrchestratorConfig,
|
|
21
|
+
OrchestratorContext,
|
|
22
|
+
OrchestratorOptions,
|
|
23
|
+
SearchResultItem,
|
|
24
|
+
AutoRunConfig,
|
|
25
|
+
} from './types';
|
|
26
|
+
import {
|
|
27
|
+
createTextDelta,
|
|
28
|
+
createThinkingStart,
|
|
29
|
+
createThinkingDelta,
|
|
30
|
+
createThinkingEnd,
|
|
31
|
+
createSearchStart,
|
|
32
|
+
createSearchResult,
|
|
33
|
+
createSearchEnd,
|
|
34
|
+
createToolCallStart,
|
|
35
|
+
createToolCallOutput,
|
|
36
|
+
createToolCallResult,
|
|
37
|
+
createToolCallRequest,
|
|
38
|
+
createDone,
|
|
39
|
+
createAbort,
|
|
40
|
+
createApiError,
|
|
41
|
+
createStepStart,
|
|
42
|
+
createStepEnd,
|
|
43
|
+
createAgentStatus,
|
|
44
|
+
} from '../events';
|
|
45
|
+
import type { ToolApprovalRequestEvent } from '../events';
|
|
46
|
+
import { DebugLogger } from '../utils';
|
|
47
|
+
import { compactMessages } from './context-compressor';
|
|
48
|
+
import { WEB_SEARCH_TOOL_NAME } from '../internal/web-search';
|
|
49
|
+
|
|
50
|
+
// 创建模块专用 logger
|
|
51
|
+
const logger = DebugLogger.module('Orchestrator');
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
function normalizeSearchResults(items: SearchResultItem[]): SearchResultItem[] {
|
|
55
|
+
const byUrl = new Map<string, SearchResultItem>();
|
|
56
|
+
for (const it of items) {
|
|
57
|
+
const url = String(it?.url ?? '').trim();
|
|
58
|
+
if (!url) continue;
|
|
59
|
+
|
|
60
|
+
const snippet = String(it?.snippet ?? '').trim();
|
|
61
|
+
let title = String(it?.title ?? '').trim();
|
|
62
|
+
if (!title) {
|
|
63
|
+
try {
|
|
64
|
+
title = new URL(url).hostname;
|
|
65
|
+
} catch {
|
|
66
|
+
title = url;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const prev = byUrl.get(url);
|
|
71
|
+
if (!prev) {
|
|
72
|
+
byUrl.set(url, { title, url, snippet });
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (!prev.snippet && snippet) prev.snippet = snippet;
|
|
76
|
+
if (!prev.title && title) prev.title = title;
|
|
77
|
+
}
|
|
78
|
+
return Array.from(byUrl.values());
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 默认最大迭代次数(安全兜底)
|
|
83
|
+
*
|
|
84
|
+
* 不是正常终止条件——模型不调工具时循环自然 break。
|
|
85
|
+
*
|
|
86
|
+
* 正常终止由以下机制保证:
|
|
87
|
+
* 1. 模型不再调工具 → break
|
|
88
|
+
* 2. 用户取消 → abort signal
|
|
89
|
+
* 3. context window 超限 → API 报错 → error handler
|
|
90
|
+
* 4. 重复工具调用检测 → 注入提示终止循环
|
|
91
|
+
*
|
|
92
|
+
* 设为 25,作为异常情况的安全兜底(正常任务极少超过 10 轮)。
|
|
93
|
+
* 调用方可通过 OrchestratorConfig.maxIterations 覆盖。
|
|
94
|
+
*/
|
|
95
|
+
const DEFAULT_MAX_ITERATIONS = 25;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 重复工具调用检测阈值
|
|
99
|
+
*
|
|
100
|
+
* 同名同参数的工具连续调用超过此次数时,注入提示让模型停止循环。
|
|
101
|
+
*/
|
|
102
|
+
const DUPLICATE_TOOL_CALL_THRESHOLD = 2;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Chat Orchestrator
|
|
106
|
+
*
|
|
107
|
+
* 核心职责:统一处理工具调用循环,所有 Provider 共享相同的逻辑
|
|
108
|
+
*/
|
|
109
|
+
export class ChatOrchestrator {
|
|
110
|
+
private config: OrchestratorConfig;
|
|
111
|
+
|
|
112
|
+
constructor(config: OrchestratorConfig) {
|
|
113
|
+
this.config = {
|
|
114
|
+
maxIterations: DEFAULT_MAX_ITERATIONS,
|
|
115
|
+
...config,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 执行聊天
|
|
121
|
+
*
|
|
122
|
+
* @param adapter Provider 适配器
|
|
123
|
+
* @param message 用户消息
|
|
124
|
+
* @param context Orchestrator 上下文
|
|
125
|
+
* @param options 选项
|
|
126
|
+
*/
|
|
127
|
+
async *chat(
|
|
128
|
+
adapter: ProviderAdapter,
|
|
129
|
+
message: string,
|
|
130
|
+
context: OrchestratorContext,
|
|
131
|
+
options: OrchestratorOptions
|
|
132
|
+
): AsyncGenerator<ChatEvent> {
|
|
133
|
+
const startedAt = Date.now();
|
|
134
|
+
const maxIterations = this.config.maxIterations ?? DEFAULT_MAX_ITERATIONS;
|
|
135
|
+
|
|
136
|
+
// 构建标准化消息列表
|
|
137
|
+
const messages = this.buildMessages(context, message);
|
|
138
|
+
|
|
139
|
+
// 初始压缩:前端传入的历史可能就已经超长
|
|
140
|
+
if (compactMessages(messages)) {
|
|
141
|
+
logger.info('初始历史已压缩');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// 全局状态
|
|
145
|
+
let iterations = 0;
|
|
146
|
+
let finalText = '';
|
|
147
|
+
let searchResults: SearchResultItem[] = [];
|
|
148
|
+
let searchStartedAt = 0;
|
|
149
|
+
let searchStarted = false;
|
|
150
|
+
let searchEnded = false;
|
|
151
|
+
|
|
152
|
+
// 重复工具调用检测:记录上一轮的工具调用签名(name + args hash)
|
|
153
|
+
let lastToolCallSignature = '';
|
|
154
|
+
let duplicateToolCallCount = 0;
|
|
155
|
+
|
|
156
|
+
// Token 使用累积(多轮工具调用时累加)
|
|
157
|
+
let totalUsage: { promptTokens: number; completionTokens: number; totalTokens: number; reasoningTokens: number; cachedTokens: number } = {
|
|
158
|
+
promptTokens: 0, completionTokens: 0, totalTokens: 0, reasoningTokens: 0, cachedTokens: 0,
|
|
159
|
+
};
|
|
160
|
+
let hasUsage = false;
|
|
161
|
+
|
|
162
|
+
// 工具调用循环
|
|
163
|
+
while (iterations < maxIterations) {
|
|
164
|
+
if (context.signal.aborted) {
|
|
165
|
+
yield createAbort('请求已取消');
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
iterations++;
|
|
170
|
+
|
|
171
|
+
// Context 压缩:如果消息历史过长,压缩中间部分
|
|
172
|
+
if (iterations > 1) {
|
|
173
|
+
compactMessages(messages);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const stepStartedAt = Date.now();
|
|
177
|
+
logger.info(`======= 第 ${iterations} 轮开始 =======`);
|
|
178
|
+
|
|
179
|
+
// 发射步骤开始事件(前端可用于展示 Agent 执行进度)
|
|
180
|
+
yield createStepStart(iterations);
|
|
181
|
+
// 告知前端当前阶段:模型正在思考(首次请求 / 工具执行完后新一轮)
|
|
182
|
+
yield createAgentStatus('thinking');
|
|
183
|
+
|
|
184
|
+
// ========== 每轮独立的状态 ==========
|
|
185
|
+
let thinkingText = '';
|
|
186
|
+
let thinkingStartedAt = 0;
|
|
187
|
+
let thinkingStarted = false;
|
|
188
|
+
let thinkingComplete = false;
|
|
189
|
+
|
|
190
|
+
// ========== 调试日志:chunk 计数 ==========
|
|
191
|
+
const chunkCounts: Record<string, number> = {};
|
|
192
|
+
let textStartedInOrchestrator = false;
|
|
193
|
+
const logChunk = (type: string) => {
|
|
194
|
+
chunkCounts[type] = (chunkCounts[type] || 0) + 1;
|
|
195
|
+
};
|
|
196
|
+
// ==========================================
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
const pendingToolCalls: ToolCallRequest[] = [];
|
|
200
|
+
let hasToolCalls = false;
|
|
201
|
+
|
|
202
|
+
// 是否启用思考(每轮都可以启用)
|
|
203
|
+
// 强制转为 boolean,避免上层传入 'false' / 1 等导致逻辑误判
|
|
204
|
+
const enableThinkingThisRound = options.enableThinking === true;
|
|
205
|
+
// Web Search:允许在多轮中发生(模型可能在后续轮次再次检索)
|
|
206
|
+
const enableSearchThisRound = options.enableSearch === true;
|
|
207
|
+
|
|
208
|
+
logger.debug('调用 adapter.streamOnce', { enableThinking: enableThinkingThisRound, enableSearch: enableSearchThisRound });
|
|
209
|
+
|
|
210
|
+
const stream = adapter.streamOnce(messages, context.tools, {
|
|
211
|
+
model: options.model,
|
|
212
|
+
enableThinking: enableThinkingThisRound,
|
|
213
|
+
enableSearch: enableSearchThisRound,
|
|
214
|
+
signal: context.signal,
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
for await (const chunk of stream) {
|
|
218
|
+
logChunk(chunk.type);
|
|
219
|
+
switch (chunk.type) {
|
|
220
|
+
case 'text':
|
|
221
|
+
if (chunk.text) {
|
|
222
|
+
// 首次 text,记录状态并清除 thinking loading(文字流接管)
|
|
223
|
+
if (!textStartedInOrchestrator) {
|
|
224
|
+
textStartedInOrchestrator = true;
|
|
225
|
+
logger.debug('首次收到 text', { thinkingStarted, thinkingComplete });
|
|
226
|
+
yield createAgentStatus(null);
|
|
227
|
+
}
|
|
228
|
+
// 如果还在思考中,先结束思考(仅在启用 thinking 时)
|
|
229
|
+
if (enableThinkingThisRound && thinkingStarted && !thinkingComplete) {
|
|
230
|
+
logger.debug('text 触发 thinking_end');
|
|
231
|
+
thinkingComplete = true;
|
|
232
|
+
yield createThinkingEnd(thinkingStartedAt);
|
|
233
|
+
}
|
|
234
|
+
finalText += chunk.text;
|
|
235
|
+
yield createTextDelta(chunk.text);
|
|
236
|
+
}
|
|
237
|
+
break;
|
|
238
|
+
|
|
239
|
+
case 'thinking':
|
|
240
|
+
// Gate:未启用 thinking 时,忽略所有 thinking 输出(用于统一 ask/normal 体验)
|
|
241
|
+
if (!enableThinkingThisRound) break;
|
|
242
|
+
if (chunk.thinking) {
|
|
243
|
+
if (thinkingComplete) {
|
|
244
|
+
// ⚠️ 异常:thinking_done 后还收到 thinking
|
|
245
|
+
logger.warn('⚠️ thinkingComplete=true 但收到 thinking', chunk.thinking.slice(0, 30));
|
|
246
|
+
logger.warn('当前状态', { textStarted: textStartedInOrchestrator, chunkCounts });
|
|
247
|
+
} else if (textStartedInOrchestrator) {
|
|
248
|
+
// ⚠️ 异常:text 开始后还收到 thinking
|
|
249
|
+
logger.warn('⚠️ text 开始后收到 thinking', chunk.thinking.slice(0, 30));
|
|
250
|
+
} else {
|
|
251
|
+
// 兼容:部分 provider 的第一段 thinking 可能以换行开头(UI 使用 pre-wrap 会显示成“顶部空一行”)
|
|
252
|
+
// 只在“第一段 thinking”做一次性处理,避免破坏中间格式
|
|
253
|
+
let delta = chunk.thinking;
|
|
254
|
+
if (!thinkingStarted) {
|
|
255
|
+
delta = delta.replace(/^(?:\r?\n)+/, '');
|
|
256
|
+
if (!delta) break; // 第一段只有换行:等待后续真正内容再开始
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// 正常处理
|
|
260
|
+
if (!thinkingStarted) {
|
|
261
|
+
thinkingStarted = true;
|
|
262
|
+
thinkingStartedAt = Date.now();
|
|
263
|
+
logger.debug('发送 thinking_start');
|
|
264
|
+
yield createAgentStatus(null); // thinking spinner 接管,清除 loading
|
|
265
|
+
yield createThinkingStart();
|
|
266
|
+
}
|
|
267
|
+
thinkingText += delta;
|
|
268
|
+
yield createThinkingDelta(delta);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
break;
|
|
272
|
+
|
|
273
|
+
case 'thinking_done':
|
|
274
|
+
// Gate:未启用 thinking 时,忽略 thinking_done
|
|
275
|
+
if (!enableThinkingThisRound) break;
|
|
276
|
+
logger.info('收到 thinking_done', { thinkingStarted, thinkingComplete });
|
|
277
|
+
if (thinkingStarted && !thinkingComplete) {
|
|
278
|
+
thinkingComplete = true;
|
|
279
|
+
logger.debug('发送 thinking_end');
|
|
280
|
+
yield createThinkingEnd(thinkingStartedAt);
|
|
281
|
+
} else if (!thinkingStarted) {
|
|
282
|
+
logger.warn('⚠️ 收到 thinking_done 但 thinkingStarted=false');
|
|
283
|
+
}
|
|
284
|
+
break;
|
|
285
|
+
|
|
286
|
+
case 'tool_call':
|
|
287
|
+
if (chunk.toolCall) {
|
|
288
|
+
// Gate:如果当前没有任何可用工具定义,忽略模型产生的工具调用(避免 ask 模式误入工具循环)
|
|
289
|
+
if (!context.tools || context.tools.length === 0) {
|
|
290
|
+
logger.warn('收到 tool_call 但当前未注入工具,已忽略', { toolName: chunk.toolCall.name });
|
|
291
|
+
break;
|
|
292
|
+
}
|
|
293
|
+
pendingToolCalls.push(chunk.toolCall);
|
|
294
|
+
hasToolCalls = true;
|
|
295
|
+
}
|
|
296
|
+
break;
|
|
297
|
+
|
|
298
|
+
case 'search_result':
|
|
299
|
+
// Gate:未启用搜索时,忽略 search_result
|
|
300
|
+
if (!enableSearchThisRound) break;
|
|
301
|
+
if (chunk.searchResults) {
|
|
302
|
+
// 首次收到搜索结果时,发送 search_start 事件
|
|
303
|
+
if (!searchStarted) {
|
|
304
|
+
searchStarted = true;
|
|
305
|
+
searchStartedAt = Date.now();
|
|
306
|
+
yield createAgentStatus(null); // 搜索 spinner 接管,清除 loading
|
|
307
|
+
yield createSearchStart(message);
|
|
308
|
+
}
|
|
309
|
+
searchResults = normalizeSearchResults(chunk.searchResults);
|
|
310
|
+
yield createSearchResult(searchResults, searchStartedAt);
|
|
311
|
+
}
|
|
312
|
+
break;
|
|
313
|
+
|
|
314
|
+
case 'done':
|
|
315
|
+
logger.info('收到 done', { finishReason: chunk.finishReason, usage: chunk.usage });
|
|
316
|
+
logger.info(`第 ${iterations} 轮 chunk 统计`, chunkCounts);
|
|
317
|
+
if (chunk.finishReason === 'tool_calls') {
|
|
318
|
+
hasToolCalls = true;
|
|
319
|
+
}
|
|
320
|
+
// 累积 Token 使用统计(多轮工具调用时累加)
|
|
321
|
+
if (chunk.usage) {
|
|
322
|
+
hasUsage = true;
|
|
323
|
+
totalUsage.promptTokens += chunk.usage.promptTokens || 0;
|
|
324
|
+
totalUsage.completionTokens += chunk.usage.completionTokens || 0;
|
|
325
|
+
totalUsage.totalTokens += chunk.usage.totalTokens || 0;
|
|
326
|
+
totalUsage.reasoningTokens += chunk.usage.reasoningTokens || 0;
|
|
327
|
+
totalUsage.cachedTokens += chunk.usage.cachedTokens || 0;
|
|
328
|
+
}
|
|
329
|
+
break;
|
|
330
|
+
|
|
331
|
+
case 'error':
|
|
332
|
+
logger.error('收到 error', chunk.error);
|
|
333
|
+
yield createApiError(chunk.error ?? '未知错误');
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
logger.info(`第 ${iterations} 轮 for-await 循环结束`);
|
|
339
|
+
logger.debug('状态', { thinkingStarted, thinkingComplete });
|
|
340
|
+
|
|
341
|
+
// 确保思考状态完成(仅在启用 thinking 时)
|
|
342
|
+
if (enableThinkingThisRound && thinkingStarted && !thinkingComplete) {
|
|
343
|
+
logger.debug('补发 thinking_end');
|
|
344
|
+
thinkingComplete = true;
|
|
345
|
+
yield createThinkingEnd(thinkingStartedAt);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// 处理工具调用
|
|
349
|
+
if (hasToolCalls && pendingToolCalls.length > 0) {
|
|
350
|
+
// ========== 重复工具调用检测 ==========
|
|
351
|
+
// 生成当前轮的工具调用签名(所有工具名 + 参数拼接后的字符串)
|
|
352
|
+
const currentSignature = pendingToolCalls
|
|
353
|
+
.map(tc => `${tc.name}:${tc.arguments}`)
|
|
354
|
+
.sort()
|
|
355
|
+
.join('|');
|
|
356
|
+
|
|
357
|
+
if (currentSignature === lastToolCallSignature) {
|
|
358
|
+
duplicateToolCallCount++;
|
|
359
|
+
} else {
|
|
360
|
+
duplicateToolCallCount = 0;
|
|
361
|
+
lastToolCallSignature = currentSignature;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (duplicateToolCallCount >= DUPLICATE_TOOL_CALL_THRESHOLD) {
|
|
365
|
+
logger.warn('检测到重复工具调用,注入终止提示', {
|
|
366
|
+
signature: currentSignature.slice(0, 200),
|
|
367
|
+
count: duplicateToolCallCount + 1,
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
// 不再执行工具,而是注入一条提示消息让模型停止循环
|
|
371
|
+
const toolNames = pendingToolCalls.map(tc => tc.name).join(', ');
|
|
372
|
+
messages.push({
|
|
373
|
+
role: 'assistant',
|
|
374
|
+
content: finalText,
|
|
375
|
+
toolCalls: pendingToolCalls,
|
|
376
|
+
});
|
|
377
|
+
for (const toolCall of pendingToolCalls) {
|
|
378
|
+
messages.push({
|
|
379
|
+
role: 'tool',
|
|
380
|
+
content: `[系统提示] 你已经连续 ${duplicateToolCallCount + 1} 次使用相同参数调用 ${toolCall.name},任务应已完成。请直接回复用户执行结果,不要再调用工具。`,
|
|
381
|
+
toolCallId: toolCall.id,
|
|
382
|
+
toolName: toolCall.name,
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// 发射步骤结束事件,进入下一轮让模型生成总结
|
|
387
|
+
yield createStepEnd(iterations, stepStartedAt);
|
|
388
|
+
finalText = '';
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
// ========== 重复检测结束 ==========
|
|
392
|
+
|
|
393
|
+
// 检查是否有客户端工具调用(优先使用 context 中的配置,兼容 config)
|
|
394
|
+
const clientToolNames = context.clientToolNames || this.config.clientToolNames;
|
|
395
|
+
const clientToolCalls = clientToolNames
|
|
396
|
+
? pendingToolCalls.filter(tc => clientToolNames.has(tc.name))
|
|
397
|
+
: [];
|
|
398
|
+
|
|
399
|
+
// 如果有客户端工具调用,透传给客户端,结束本轮
|
|
400
|
+
if (clientToolCalls.length > 0) {
|
|
401
|
+
logger.info('检测到客户端工具调用,透传给客户端', clientToolCalls.map(tc => tc.name));
|
|
402
|
+
|
|
403
|
+
// 发送所有客户端工具调用请求
|
|
404
|
+
for (const toolCall of clientToolCalls) {
|
|
405
|
+
let args: Record<string, unknown>;
|
|
406
|
+
try {
|
|
407
|
+
args = JSON.parse(toolCall.arguments || '{}');
|
|
408
|
+
} catch {
|
|
409
|
+
args = {};
|
|
410
|
+
}
|
|
411
|
+
yield createToolCallRequest(toolCall.id, toolCall.name, args);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// 结束本轮对话,让客户端执行后发新请求继续
|
|
415
|
+
const duration = Date.now() - startedAt;
|
|
416
|
+
yield createDone(finalText, undefined, duration);
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// 工具执行阶段:工具卡片/搜索卡片接管进度显示,清除 loading
|
|
421
|
+
yield createAgentStatus(null);
|
|
422
|
+
|
|
423
|
+
// 1. 添加 assistant 消息(包含 tool_calls)到消息历史
|
|
424
|
+
const assistantMessage: StandardMessage = {
|
|
425
|
+
role: 'assistant',
|
|
426
|
+
content: finalText,
|
|
427
|
+
toolCalls: pendingToolCalls,
|
|
428
|
+
};
|
|
429
|
+
messages.push(assistantMessage);
|
|
430
|
+
|
|
431
|
+
// 2. 执行云端工具并添加 tool 消息
|
|
432
|
+
for (const toolCall of pendingToolCalls) {
|
|
433
|
+
const toolStartedAt = Date.now();
|
|
434
|
+
|
|
435
|
+
// 解析参数
|
|
436
|
+
let args: Record<string, unknown>;
|
|
437
|
+
try {
|
|
438
|
+
args = JSON.parse(toolCall.arguments || '{}');
|
|
439
|
+
} catch {
|
|
440
|
+
// 参数解析失败,添加错误消息
|
|
441
|
+
messages.push({
|
|
442
|
+
role: 'tool',
|
|
443
|
+
content: '参数解析错误',
|
|
444
|
+
toolCallId: toolCall.id,
|
|
445
|
+
});
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// web_search_ai 参数兜底:
|
|
450
|
+
// OpenRouter 等模型有时会发起空 arguments 的 function_call(arguments: ""),导致 query 缺失。
|
|
451
|
+
// 为保证链路可用:当 query 缺失时,默认使用本轮用户消息作为 query。
|
|
452
|
+
if (toolCall.name === WEB_SEARCH_TOOL_NAME) {
|
|
453
|
+
const q = typeof args.query === 'string' ? args.query : '';
|
|
454
|
+
if (!q.trim()) {
|
|
455
|
+
args.query = message;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// 获取最新的自动运行配置(优先使用动态回调)
|
|
460
|
+
const autoRunConfig = this.config.getAutoRunConfig
|
|
461
|
+
? await this.config.getAutoRunConfig()
|
|
462
|
+
: (options.autoRunConfig || this.config.autoRunConfig);
|
|
463
|
+
|
|
464
|
+
// 手动批准模式:需要用户确认
|
|
465
|
+
logger.debug('检查工具批准', {
|
|
466
|
+
toolName: toolCall.name,
|
|
467
|
+
autoRunConfigMode: autoRunConfig?.mode,
|
|
468
|
+
hasCallback: !!this.config.onToolApprovalRequest,
|
|
469
|
+
});
|
|
470
|
+
if (autoRunConfig?.mode === 'manual' && this.config.onToolApprovalRequest) {
|
|
471
|
+
logger.info('发送工具批准请求', toolCall.name);
|
|
472
|
+
// 发送批准请求事件
|
|
473
|
+
const approvalRequest: ToolApprovalRequestEvent = {
|
|
474
|
+
type: 'tool_approval_request',
|
|
475
|
+
data: {
|
|
476
|
+
id: toolCall.id,
|
|
477
|
+
name: toolCall.name,
|
|
478
|
+
args,
|
|
479
|
+
requestedAt: Date.now(),
|
|
480
|
+
},
|
|
481
|
+
};
|
|
482
|
+
yield approvalRequest;
|
|
483
|
+
|
|
484
|
+
// 等待用户批准
|
|
485
|
+
const approved = await this.config.onToolApprovalRequest({
|
|
486
|
+
id: toolCall.id,
|
|
487
|
+
name: toolCall.name,
|
|
488
|
+
args,
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
if (!approved) {
|
|
492
|
+
// 用户跳过执行
|
|
493
|
+
const result = JSON.stringify({ skipped: true, message: '用户跳过了此工具' });
|
|
494
|
+
if (toolCall.name === WEB_SEARCH_TOOL_NAME && enableSearchThisRound) {
|
|
495
|
+
// 用 search_end 表示“跳过搜索”
|
|
496
|
+
if (!searchStarted) {
|
|
497
|
+
searchStarted = true;
|
|
498
|
+
searchStartedAt = Date.now();
|
|
499
|
+
const q = typeof args.query === 'string' ? args.query : message;
|
|
500
|
+
yield createSearchStart(q);
|
|
501
|
+
}
|
|
502
|
+
searchEnded = true;
|
|
503
|
+
yield createSearchEnd(false, searchStartedAt, `用户跳过了 ${WEB_SEARCH_TOOL_NAME}`);
|
|
504
|
+
} else {
|
|
505
|
+
yield createToolCallResult(toolCall.id, toolCall.name, result, false, toolStartedAt);
|
|
506
|
+
}
|
|
507
|
+
messages.push({
|
|
508
|
+
role: 'tool',
|
|
509
|
+
content: result,
|
|
510
|
+
toolCallId: toolCall.id,
|
|
511
|
+
toolName: toolCall.name,
|
|
512
|
+
});
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const isWebSearchTool = toolCall.name === WEB_SEARCH_TOOL_NAME;
|
|
518
|
+
// web_search_ai 走专属事件;其他工具(含 update_plan)走通用 tool_call_start
|
|
519
|
+
if (!isWebSearchTool) {
|
|
520
|
+
yield createToolCallStart(toolCall.id, toolCall.name, args);
|
|
521
|
+
} else if (enableSearchThisRound) {
|
|
522
|
+
if (!searchStarted) {
|
|
523
|
+
searchStarted = true;
|
|
524
|
+
searchStartedAt = Date.now();
|
|
525
|
+
const q = typeof args.query === 'string' ? args.query : message;
|
|
526
|
+
yield createSearchStart(q);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// 执行工具
|
|
531
|
+
let result: string;
|
|
532
|
+
let dynamicSideEffects: SideEffect[] | undefined;
|
|
533
|
+
let success = true;
|
|
534
|
+
try {
|
|
535
|
+
// stdout/stderr 流式输出:通过 hooks 推送到事件队列,再在等待工具完成期间持续 yield
|
|
536
|
+
const eventQueue: ChatEvent[] = [];
|
|
537
|
+
let wake: (() => void) | null = null;
|
|
538
|
+
const notify = () => wake?.();
|
|
539
|
+
const pushEvent = (ev: ChatEvent) => {
|
|
540
|
+
eventQueue.push(ev);
|
|
541
|
+
notify();
|
|
542
|
+
};
|
|
543
|
+
|
|
544
|
+
let toolDone = false;
|
|
545
|
+
let toolValue: string | ToolResult | undefined;
|
|
546
|
+
let toolError: unknown;
|
|
547
|
+
|
|
548
|
+
const hooks = {
|
|
549
|
+
toolCallId: toolCall.id,
|
|
550
|
+
toolName: toolCall.name,
|
|
551
|
+
onStdout: (chunk: string) => {
|
|
552
|
+
if (chunk) pushEvent(createToolCallOutput(toolCall.id, toolCall.name, 'stdout', chunk));
|
|
553
|
+
},
|
|
554
|
+
onStderr: (chunk: string) => {
|
|
555
|
+
if (chunk) pushEvent(createToolCallOutput(toolCall.id, toolCall.name, 'stderr', chunk));
|
|
556
|
+
},
|
|
557
|
+
};
|
|
558
|
+
|
|
559
|
+
this.config.executeTool(toolCall.name, args, context.signal, hooks)
|
|
560
|
+
.then((v) => {
|
|
561
|
+
toolValue = v;
|
|
562
|
+
toolDone = true;
|
|
563
|
+
notify();
|
|
564
|
+
})
|
|
565
|
+
.catch((e) => {
|
|
566
|
+
toolError = e;
|
|
567
|
+
toolDone = true;
|
|
568
|
+
notify();
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
// 在工具执行期间,把 stdout/stderr 事件不断 yield 出去
|
|
572
|
+
while (!toolDone || eventQueue.length > 0) {
|
|
573
|
+
while (eventQueue.length > 0) {
|
|
574
|
+
const ev = eventQueue.shift();
|
|
575
|
+
if (ev) yield ev;
|
|
576
|
+
}
|
|
577
|
+
if (toolDone) break;
|
|
578
|
+
await new Promise<void>((r) => (wake = r));
|
|
579
|
+
wake = null;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
if (toolError) throw toolError;
|
|
583
|
+
const executeResult = toolValue as string | ToolResult;
|
|
584
|
+
// 处理返回值:可能是字符串或 ToolResult
|
|
585
|
+
if (typeof executeResult === 'string') {
|
|
586
|
+
result = executeResult;
|
|
587
|
+
} else {
|
|
588
|
+
result = executeResult.result;
|
|
589
|
+
dynamicSideEffects = executeResult.sideEffects;
|
|
590
|
+
}
|
|
591
|
+
} catch (error) {
|
|
592
|
+
success = false;
|
|
593
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
594
|
+
result = `错误: ${errorMessage}`;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// 获取工具定义:用于副作用和结果类型
|
|
598
|
+
const tool = this.config.tools?.get(toolCall.name);
|
|
599
|
+
const sideEffects = dynamicSideEffects ?? tool?.sideEffects;
|
|
600
|
+
// 成功时传递 resultType,用于前端生成对应类型的 Part
|
|
601
|
+
const resultType = success ? tool?.resultType : undefined;
|
|
602
|
+
|
|
603
|
+
// 发射工具结果事件(包含副作用信息和结果类型)
|
|
604
|
+
// update_plan 走通用 tool_call_result(前端通过 resultType:'plan' 渲染 PlanPart)
|
|
605
|
+
if (!isWebSearchTool) {
|
|
606
|
+
yield createToolCallResult(toolCall.id, toolCall.name, result, success, toolStartedAt, undefined, sideEffects, resultType);
|
|
607
|
+
} else if (enableSearchThisRound) {
|
|
608
|
+
// web_search_ai:解析工具结果并产出 search_result / search_end
|
|
609
|
+
let parsedResults: SearchResultItem[] = [];
|
|
610
|
+
let parseError: string | undefined;
|
|
611
|
+
try {
|
|
612
|
+
const obj = JSON.parse(result || '{}') as any;
|
|
613
|
+
const arr = Array.isArray(obj?.results) ? obj.results : [];
|
|
614
|
+
parsedResults = arr
|
|
615
|
+
.map((r: any) => ({
|
|
616
|
+
title: typeof r?.title === 'string' ? r.title : '',
|
|
617
|
+
url: typeof r?.url === 'string' ? r.url : '',
|
|
618
|
+
snippet: typeof r?.snippet === 'string' ? r.snippet : '',
|
|
619
|
+
}))
|
|
620
|
+
.filter((r: SearchResultItem) => !!r.url);
|
|
621
|
+
if (typeof obj?.error === 'string' && obj.error) {
|
|
622
|
+
parseError = obj.error;
|
|
623
|
+
success = false;
|
|
624
|
+
}
|
|
625
|
+
} catch {
|
|
626
|
+
parseError = `${WEB_SEARCH_TOOL_NAME} 返回结果解析失败`;
|
|
627
|
+
success = false;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
if (parsedResults.length > 0) {
|
|
631
|
+
searchResults = normalizeSearchResults(parsedResults);
|
|
632
|
+
yield createSearchResult(searchResults, searchStartedAt);
|
|
633
|
+
}
|
|
634
|
+
searchEnded = true;
|
|
635
|
+
yield createSearchEnd(success, searchStartedAt, parseError);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// 添加 tool 消息到历史
|
|
639
|
+
messages.push({
|
|
640
|
+
role: 'tool',
|
|
641
|
+
content: result,
|
|
642
|
+
toolCallId: toolCall.id,
|
|
643
|
+
toolName: toolCall.name, // Gemini 需要工具名称
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
// 工具执行后检查取消信号:如果用户在工具执行过程中取消,立即终止
|
|
647
|
+
if (context.signal.aborted) {
|
|
648
|
+
yield createAbort('请求已取消');
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// 发射步骤结束事件
|
|
654
|
+
yield createStepEnd(iterations, stepStartedAt);
|
|
655
|
+
|
|
656
|
+
// 清空累积文本,进入下一轮
|
|
657
|
+
finalText = '';
|
|
658
|
+
continue;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// 没有工具调用,响应完成
|
|
662
|
+
yield createStepEnd(iterations, stepStartedAt);
|
|
663
|
+
break;
|
|
664
|
+
|
|
665
|
+
} catch (error) {
|
|
666
|
+
// 异常路径也要关闭 step 事件,确保前端 step 计数正确
|
|
667
|
+
yield createStepEnd(iterations, stepStartedAt);
|
|
668
|
+
|
|
669
|
+
if (context.signal.aborted) {
|
|
670
|
+
yield createAbort('请求已取消');
|
|
671
|
+
} else {
|
|
672
|
+
yield createApiError(error instanceof Error ? error.message : String(error));
|
|
673
|
+
}
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// 安全兜底:如果达到最大迭代次数,记录警告
|
|
679
|
+
if (iterations >= maxIterations) {
|
|
680
|
+
logger.warn(`达到最大迭代次数 ${maxIterations},强制结束工具调用循环`);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// 确保搜索状态完成
|
|
684
|
+
if (searchStarted && !searchEnded) {
|
|
685
|
+
yield createSearchEnd(true, searchStartedAt);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// 注意:不再更新 context.history
|
|
689
|
+
// 无状态架构:前端负责保存消息到数据库,下次请求时重新构建历史
|
|
690
|
+
|
|
691
|
+
// 发射完成事件(包含累积的 Token 使用统计)
|
|
692
|
+
const duration = Date.now() - startedAt;
|
|
693
|
+
const usage = hasUsage ? {
|
|
694
|
+
promptTokens: totalUsage.promptTokens,
|
|
695
|
+
completionTokens: totalUsage.completionTokens,
|
|
696
|
+
totalTokens: totalUsage.totalTokens,
|
|
697
|
+
...(totalUsage.reasoningTokens > 0 ? { reasoningTokens: totalUsage.reasoningTokens } : {}),
|
|
698
|
+
...(totalUsage.cachedTokens > 0 ? { cachedTokens: totalUsage.cachedTokens } : {}),
|
|
699
|
+
} : undefined;
|
|
700
|
+
yield createDone(finalText, usage, duration);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* 构建标准化消息列表
|
|
705
|
+
*/
|
|
706
|
+
private buildMessages(context: OrchestratorContext, message: string): StandardMessage[] {
|
|
707
|
+
const messages: StandardMessage[] = [];
|
|
708
|
+
|
|
709
|
+
// 系统提示
|
|
710
|
+
if (context.systemPrompt) {
|
|
711
|
+
messages.push({
|
|
712
|
+
role: 'system',
|
|
713
|
+
content: context.systemPrompt,
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// 历史消息
|
|
718
|
+
for (const msg of context.history) {
|
|
719
|
+
const standardMsg: StandardMessage = {
|
|
720
|
+
role: msg.role as 'user' | 'assistant' | 'tool',
|
|
721
|
+
content: msg.content,
|
|
722
|
+
};
|
|
723
|
+
|
|
724
|
+
// 处理 assistant 消息的工具调用
|
|
725
|
+
if (msg.tool_calls) {
|
|
726
|
+
standardMsg.toolCalls = msg.tool_calls.map(tc => ({
|
|
727
|
+
id: tc.id,
|
|
728
|
+
name: tc.function.name,
|
|
729
|
+
arguments: tc.function.arguments,
|
|
730
|
+
// 保留 thought_signature(Gemini 模型需要)
|
|
731
|
+
thought_signature: tc.thought_signature,
|
|
732
|
+
}));
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// 处理 tool 消息的 tool_call_id
|
|
736
|
+
if (msg.role === 'tool' && msg.tool_call_id) {
|
|
737
|
+
standardMsg.toolCallId = msg.tool_call_id;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
messages.push(standardMsg);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// 当前用户消息(跳过空消息,用于工具调用后继续对话)
|
|
744
|
+
if (message) {
|
|
745
|
+
messages.push({
|
|
746
|
+
role: 'user',
|
|
747
|
+
content: message,
|
|
748
|
+
images: context.images,
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
return messages;
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* 创建 Orchestrator 实例
|
|
758
|
+
*/
|
|
759
|
+
export function createOrchestrator(config: OrchestratorConfig): ChatOrchestrator {
|
|
760
|
+
return new ChatOrchestrator(config);
|
|
761
|
+
}
|