webaiapi 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +184 -2
- package/bin/webapi.mjs +45 -0
- package/dist/browser/auth-capture.d.ts +110 -0
- package/dist/browser/auth-capture.d.ts.map +1 -0
- package/dist/browser/auth-capture.js +487 -0
- package/dist/browser/auth-capture.js.map +1 -0
- package/dist/browser/credentials.d.ts +102 -0
- package/dist/browser/credentials.d.ts.map +1 -0
- package/dist/browser/credentials.js +209 -0
- package/dist/browser/credentials.js.map +1 -0
- package/dist/browser/js-eval.d.ts +44 -0
- package/dist/browser/js-eval.d.ts.map +1 -0
- package/dist/browser/js-eval.js +20 -0
- package/dist/browser/js-eval.js.map +1 -0
- package/dist/browser/login-flow.d.ts +171 -0
- package/dist/browser/login-flow.d.ts.map +1 -0
- package/dist/browser/login-flow.js +606 -0
- package/dist/browser/login-flow.js.map +1 -0
- package/dist/browser/manager.d.ts +144 -0
- package/dist/browser/manager.d.ts.map +1 -0
- package/dist/browser/manager.js +586 -0
- package/dist/browser/manager.js.map +1 -0
- package/dist/browser/platforms.d.ts +96 -0
- package/dist/browser/platforms.d.ts.map +1 -0
- package/dist/browser/platforms.js +288 -0
- package/dist/browser/platforms.js.map +1 -0
- package/dist/browser/sse-capture.d.ts +23 -0
- package/dist/browser/sse-capture.d.ts.map +1 -0
- package/dist/browser/sse-capture.js +199 -0
- package/dist/browser/sse-capture.js.map +1 -0
- package/dist/browser/stealth.d.ts +12 -0
- package/dist/browser/stealth.d.ts.map +1 -0
- package/dist/browser/stealth.js +12 -0
- package/dist/browser/stealth.js.map +1 -0
- package/dist/cli.d.ts +12 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +441 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +90 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +246 -0
- package/dist/config.js.map +1 -0
- package/dist/controllers/history-db.d.ts +56 -0
- package/dist/controllers/history-db.d.ts.map +1 -0
- package/dist/controllers/history-db.js +260 -0
- package/dist/controllers/history-db.js.map +1 -0
- package/dist/controllers/request-pool.d.ts +78 -0
- package/dist/controllers/request-pool.d.ts.map +1 -0
- package/dist/controllers/request-pool.js +222 -0
- package/dist/controllers/request-pool.js.map +1 -0
- package/dist/controllers/router.d.ts +172 -0
- package/dist/controllers/router.d.ts.map +1 -0
- package/dist/controllers/router.js +569 -0
- package/dist/controllers/router.js.map +1 -0
- package/dist/index.d.ts +72 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +61 -0
- package/dist/index.js.map +1 -0
- package/dist/log/api-logger.d.ts +55 -0
- package/dist/log/api-logger.d.ts.map +1 -0
- package/dist/log/api-logger.js +264 -0
- package/dist/log/api-logger.js.map +1 -0
- package/dist/log/debug-logger.d.ts +49 -0
- package/dist/log/debug-logger.d.ts.map +1 -0
- package/dist/log/debug-logger.js +285 -0
- package/dist/log/debug-logger.js.map +1 -0
- package/dist/log/logger.d.ts +16 -0
- package/dist/log/logger.d.ts.map +1 -0
- package/dist/log/logger.js +35 -0
- package/dist/log/logger.js.map +1 -0
- package/dist/postinstall.d.ts +3 -0
- package/dist/postinstall.d.ts.map +1 -0
- package/dist/postinstall.js +90 -0
- package/dist/postinstall.js.map +1 -0
- package/dist/providers/base.d.ts +83 -0
- package/dist/providers/base.d.ts.map +1 -0
- package/dist/providers/base.js +51 -0
- package/dist/providers/base.js.map +1 -0
- package/dist/providers/chatglm-dom-extract.d.ts +24 -0
- package/dist/providers/chatglm-dom-extract.d.ts.map +1 -0
- package/dist/providers/chatglm-dom-extract.js +175 -0
- package/dist/providers/chatglm-dom-extract.js.map +1 -0
- package/dist/providers/chatglm-dom.d.ts +110 -0
- package/dist/providers/chatglm-dom.d.ts.map +1 -0
- package/dist/providers/chatglm-dom.js +384 -0
- package/dist/providers/chatglm-dom.js.map +1 -0
- package/dist/providers/chatglm-sse.d.ts +119 -0
- package/dist/providers/chatglm-sse.d.ts.map +1 -0
- package/dist/providers/chatglm-sse.js +241 -0
- package/dist/providers/chatglm-sse.js.map +1 -0
- package/dist/providers/chatglm.d.ts +288 -0
- package/dist/providers/chatglm.d.ts.map +1 -0
- package/dist/providers/chatglm.js +1631 -0
- package/dist/providers/chatglm.js.map +1 -0
- package/dist/routes/chat.d.ts +117 -0
- package/dist/routes/chat.d.ts.map +1 -0
- package/dist/routes/chat.js +1346 -0
- package/dist/routes/chat.js.map +1 -0
- package/dist/routes/compress.d.ts +142 -0
- package/dist/routes/compress.d.ts.map +1 -0
- package/dist/routes/compress.js +551 -0
- package/dist/routes/compress.js.map +1 -0
- package/dist/routes/failover.d.ts +34 -0
- package/dist/routes/failover.d.ts.map +1 -0
- package/dist/routes/failover.js +221 -0
- package/dist/routes/failover.js.map +1 -0
- package/dist/routes/helpers.d.ts +174 -0
- package/dist/routes/helpers.d.ts.map +1 -0
- package/dist/routes/helpers.js +525 -0
- package/dist/routes/helpers.js.map +1 -0
- package/dist/routes/meta.d.ts +31 -0
- package/dist/routes/meta.d.ts.map +1 -0
- package/dist/routes/meta.js +184 -0
- package/dist/routes/meta.js.map +1 -0
- package/dist/routes/sse.d.ts +82 -0
- package/dist/routes/sse.d.ts.map +1 -0
- package/dist/routes/sse.js +77 -0
- package/dist/routes/sse.js.map +1 -0
- package/dist/server/app.d.ts +24 -0
- package/dist/server/app.d.ts.map +1 -0
- package/dist/server/app.js +292 -0
- package/dist/server/app.js.map +1 -0
- package/dist/server/middleware/api-key.d.ts +61 -0
- package/dist/server/middleware/api-key.d.ts.map +1 -0
- package/dist/server/middleware/api-key.js +95 -0
- package/dist/server/middleware/api-key.js.map +1 -0
- package/dist/server/middleware/rate-limit.d.ts +67 -0
- package/dist/server/middleware/rate-limit.d.ts.map +1 -0
- package/dist/server/middleware/rate-limit.js +134 -0
- package/dist/server/middleware/rate-limit.js.map +1 -0
- package/dist/server/middleware/timeout.d.ts +34 -0
- package/dist/server/middleware/timeout.d.ts.map +1 -0
- package/dist/server/middleware/timeout.js +64 -0
- package/dist/server/middleware/timeout.js.map +1 -0
- package/dist/server/types.d.ts +20 -0
- package/dist/server/types.d.ts.map +1 -0
- package/dist/server/types.js +2 -0
- package/dist/server/types.js.map +1 -0
- package/dist/tools/build.d.ts +14 -0
- package/dist/tools/build.d.ts.map +1 -0
- package/dist/tools/build.js +105 -0
- package/dist/tools/build.js.map +1 -0
- package/dist/tools/clean.d.ts +32 -0
- package/dist/tools/clean.d.ts.map +1 -0
- package/dist/tools/clean.js +65 -0
- package/dist/tools/clean.js.map +1 -0
- package/dist/tools/convert.d.ts +50 -0
- package/dist/tools/convert.d.ts.map +1 -0
- package/dist/tools/convert.js +289 -0
- package/dist/tools/convert.js.map +1 -0
- package/dist/tools/names.d.ts +138 -0
- package/dist/tools/names.d.ts.map +1 -0
- package/dist/tools/names.js +578 -0
- package/dist/tools/names.js.map +1 -0
- package/dist/tools/parse.d.ts +31 -0
- package/dist/tools/parse.d.ts.map +1 -0
- package/dist/tools/parse.js +273 -0
- package/dist/tools/parse.js.map +1 -0
- package/dist/tools/placeholder.d.ts +21 -0
- package/dist/tools/placeholder.d.ts.map +1 -0
- package/dist/tools/placeholder.js +135 -0
- package/dist/tools/placeholder.js.map +1 -0
- package/dist/tools/prompts.d.ts +42 -0
- package/dist/tools/prompts.d.ts.map +1 -0
- package/dist/tools/prompts.js +69 -0
- package/dist/tools/prompts.js.map +1 -0
- package/dist/tools/protocol.d.ts +58 -0
- package/dist/tools/protocol.d.ts.map +1 -0
- package/dist/tools/protocol.js +287 -0
- package/dist/tools/protocol.js.map +1 -0
- package/dist/tools/repair.d.ts +83 -0
- package/dist/tools/repair.d.ts.map +1 -0
- package/dist/tools/repair.js +510 -0
- package/dist/tools/repair.js.map +1 -0
- package/dist/tools/workspace.d.ts +40 -0
- package/dist/tools/workspace.d.ts.map +1 -0
- package/dist/tools/workspace.js +224 -0
- package/dist/tools/workspace.js.map +1 -0
- package/dist/types/message.d.ts +87 -0
- package/dist/types/message.d.ts.map +1 -0
- package/dist/types/message.js +85 -0
- package/dist/types/message.js.map +1 -0
- package/dist/types/openai.d.ts +100 -0
- package/dist/types/openai.d.ts.map +1 -0
- package/dist/types/openai.js +66 -0
- package/dist/types/openai.js.map +1 -0
- package/dist/utils/errors.d.ts +82 -0
- package/dist/utils/errors.d.ts.map +1 -0
- package/dist/utils/errors.js +115 -0
- package/dist/utils/errors.js.map +1 -0
- package/dist/utils/mutex.d.ts +31 -0
- package/dist/utils/mutex.d.ts.map +1 -0
- package/dist/utils/mutex.js +61 -0
- package/dist/utils/mutex.js.map +1 -0
- package/dist/utils/prompt.d.ts +150 -0
- package/dist/utils/prompt.d.ts.map +1 -0
- package/dist/utils/prompt.js +514 -0
- package/dist/utils/prompt.js.map +1 -0
- package/dist/utils/text.d.ts +30 -0
- package/dist/utils/text.d.ts.map +1 -0
- package/dist/utils/text.js +70 -0
- package/dist/utils/text.js.map +1 -0
- package/package.json +67 -4
- package/postinstall.mjs +34 -0
|
@@ -0,0 +1,1346 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/v1/chat/completions` —— `legacy-python/src/api/routes.py` 第 1308~3022 行的移植
|
|
3
|
+
* (Python 侧是**一个 1,717 行的函数**,这里按阶段拆开)。
|
|
4
|
+
*
|
|
5
|
+
* ## 阶段划分(与 Python 的代码顺序一一对应)
|
|
6
|
+
* 1. {@link preparePrompt}:入场(日志 / 期限)→ 会话 ID → 工作目录 → 工具文本 →
|
|
7
|
+
* 轮数上限检查 → 提示词拼装 → 增量 → 超长压缩;
|
|
8
|
+
* 2. {@link parseAndRecoverToolCalls}:解析工具调用 + 四层降级恢复
|
|
9
|
+
* (SSE 原文 → DOM 损坏降级 → 文本转换器 → 两轮策略 reparse → 自驱动续跑);
|
|
10
|
+
* 3. {@link streamResponse}:流式分支;
|
|
11
|
+
* 4. {@link nonStreamResponse}:非流式分支。
|
|
12
|
+
*
|
|
13
|
+
* ## 一条贯穿全文件的规则
|
|
14
|
+
* **优先用 SSE 文本,而不是流式增量拼接的 `fullResponse`。**
|
|
15
|
+
* `fullResponse` 是逐块拼接的,模型中途改写回答时会变成
|
|
16
|
+
* 「草稿1 + 草稿2 + 最终答案」的乱码;而 SSE 文本是未经渲染的最终报文。
|
|
17
|
+
* 详见 `docs/hard-constraints.md`。
|
|
18
|
+
*/
|
|
19
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
20
|
+
import { existsSync, statSync } from 'node:fs';
|
|
21
|
+
import { basename, dirname, join } from 'node:path';
|
|
22
|
+
import { getDefaultWorkspacePaths } from '../config.js';
|
|
23
|
+
import { getHistoryDb } from '../controllers/history-db.js';
|
|
24
|
+
import { getRequestPool } from '../controllers/request-pool.js';
|
|
25
|
+
import { getModelRouter } from '../controllers/router.js';
|
|
26
|
+
import { logAiPlatformResponseComplete, logApiError, logApiRequest, logApiResponse, logRoundStart, } from '../log/api-logger.js';
|
|
27
|
+
import { logAiRequest, logAiResponse, logClientRequest, logClientResponse, logError as debugLogError, } from '../log/debug-logger.js';
|
|
28
|
+
import { logger } from '../log/logger.js';
|
|
29
|
+
import { getLastRawSse } from '../providers/chatglm-sse.js';
|
|
30
|
+
import { formatMessagesAsText, formatToolsAsText } from '../tools/protocol.js';
|
|
31
|
+
import { parseToolCallsFromText } from '../tools/parse.js';
|
|
32
|
+
import { buildActionRecoveryPrompt } from '../tools/prompts.js';
|
|
33
|
+
import { directConvertToToolCalls, convertTextToToolCalls } from '../tools/convert.js';
|
|
34
|
+
import { filterPlaceholderToolCalls } from '../tools/placeholder.js';
|
|
35
|
+
import { createToolMaps, filterUnknownToolCalls, repairToolCallBySignature, rerouteCommandMisuse, } from '../tools/names.js';
|
|
36
|
+
import { DOM_CORRUPTION_INDICATOR, DOM_PREVIEW_KEY_INDICATOR, } from '../tools/repair.js';
|
|
37
|
+
import { FABRICATED_PATH_PREFIXES, extractFilePathsFromMessages, extractWorkspacePath, } from '../tools/workspace.js';
|
|
38
|
+
import { getTextContent, messageToDict, } from '../types/message.js';
|
|
39
|
+
import { errorMessage } from '../utils/errors.js';
|
|
40
|
+
import { buildReparseInput, streamWithFailover } from './failover.js';
|
|
41
|
+
import { BROWSER_PROMPT_MAX_LEN, CONTINUE_MAX_BUDGET, CONTINUE_MIN_BUDGET, TOTAL_TIME_BUDGET, TOOL_ROUND_LIMIT, detectMismatchedAnswer, extractUserQuestion, formatRecentProgress, getSessionDelta, isReadOnlyTool, looksUnfinishedAnswer, rememberSessionSend, stripPlatformToolNoise, truncatePromptForBrowser, } from './helpers.js';
|
|
42
|
+
import { markRequestTs, schedulePrecompress, shrinkPromptViaCompression, } from './compress.js';
|
|
43
|
+
import { DONE, contentChunk, roleChunk, toolCallsChunk } from './sse.js';
|
|
44
|
+
/* ==================================================================== */
|
|
45
|
+
/* 阶段 1:请求准备 */
|
|
46
|
+
/* ==================================================================== */
|
|
47
|
+
/**
|
|
48
|
+
* 入场准备:日志 → 会话 ID → 工作目录 → 工具文本 → 轮数上限 → 提示词 → 压缩。
|
|
49
|
+
*/
|
|
50
|
+
export async function preparePrompt(rawRequest, request) {
|
|
51
|
+
const requestId = `chatcmpl-${randomUUID().replace(/-/g, '').slice(0, 12)}`;
|
|
52
|
+
// 记录「最近一次收到请求」的时间:后台预压缩靠它判断该不该让位
|
|
53
|
+
markRequestTs();
|
|
54
|
+
/* 本请求的绝对截止时间。
|
|
55
|
+
* ⚠️ 与 Python 的差异:Python 用 `now + 270s`(假设中间件是 300s)。
|
|
56
|
+
* 这里额外与超时中间件写下的 `deadlineTs` 取最小值 —— 中间件配成更短的值时
|
|
57
|
+
* 也能正确收敛,不会出现「Provider 还在跑、中间件已经掐断」的死角。 */
|
|
58
|
+
const ownDeadline = Date.now() + TOTAL_TIME_BUDGET * 1000;
|
|
59
|
+
const deadlineTs = request.deadlineTs ? Math.min(request.deadlineTs, ownDeadline) : ownDeadline;
|
|
60
|
+
const messages = rawRequest.messages;
|
|
61
|
+
const messageDicts = messages.map((m) => messageToDict(m));
|
|
62
|
+
logClientRequest({
|
|
63
|
+
requestId,
|
|
64
|
+
model: rawRequest.model,
|
|
65
|
+
messages: messageDicts,
|
|
66
|
+
stream: rawRequest.stream,
|
|
67
|
+
tools: rawRequest.tools,
|
|
68
|
+
extra: { workspace_path: rawRequest.workspace_path },
|
|
69
|
+
});
|
|
70
|
+
logApiRequest({
|
|
71
|
+
requestId,
|
|
72
|
+
model: rawRequest.model,
|
|
73
|
+
messages: messageDicts,
|
|
74
|
+
stream: rawRequest.stream,
|
|
75
|
+
tools: rawRequest.tools,
|
|
76
|
+
workspacePath: rawRequest.workspace_path ?? '',
|
|
77
|
+
});
|
|
78
|
+
logger.info(`[${requestId}] ========== 收到请求 ==========`);
|
|
79
|
+
logger.info(`[${requestId}] 模型: ${rawRequest.model}, 流式: ${rawRequest.stream}, ` +
|
|
80
|
+
`消息数: ${messages.length}, 工具数: ${rawRequest.tools?.length ?? 0}`);
|
|
81
|
+
// 详细消息结构(前 5 条)
|
|
82
|
+
const summaryParts = [];
|
|
83
|
+
messages.slice(0, 5).forEach((m, i) => {
|
|
84
|
+
const md = messageToDict(m);
|
|
85
|
+
const role = md.role || '?';
|
|
86
|
+
const preview = String(md.content ?? '').slice(0, 50);
|
|
87
|
+
const hasTc = md.tool_calls ? '✓tc' : '';
|
|
88
|
+
summaryParts.push(`${i}:${role}${hasTc}(${preview}...)`);
|
|
89
|
+
});
|
|
90
|
+
if (messages.length > 5)
|
|
91
|
+
summaryParts.push(`... 还有 ${messages.length - 5} 条消息`);
|
|
92
|
+
logger.info(`[${requestId}] 消息结构: ${summaryParts.join(' > ')}`);
|
|
93
|
+
if (rawRequest.tools && rawRequest.tools.length > 0) {
|
|
94
|
+
const names = rawRequest.tools.map((t) => t.function?.name ?? '?');
|
|
95
|
+
logger.info(`[${requestId}] 工具: ${names.slice(0, 5).join(', ')}${names.length > 5 ? '...' : ''}`);
|
|
96
|
+
}
|
|
97
|
+
const modelRouter = getModelRouter();
|
|
98
|
+
const historyDb = getHistoryDb();
|
|
99
|
+
const lastMessage = messages.length > 0 ? messages[messages.length - 1] : null;
|
|
100
|
+
const lastMessageDict = lastMessage ? messageToDict(lastMessage) : null;
|
|
101
|
+
let messageText = lastMessageDict ? getTextContent(lastMessageDict.content) : '';
|
|
102
|
+
/* ===== conversation_id =====
|
|
103
|
+
* 优先用客户端传入,否则基于首条 user 消息 hash。
|
|
104
|
+
* ⚠️ 必须在构建提示词**之前**算出来:会话复用(只补发增量)靠它判断
|
|
105
|
+
* 这批消息是否属于同一个网页会话。 */
|
|
106
|
+
let conversationId = rawRequest.conversation_id ?? null;
|
|
107
|
+
if (!conversationId && messages.length > 1) {
|
|
108
|
+
const firstUser = messages.find((m) => messageToDict(m).role === 'user');
|
|
109
|
+
if (firstUser) {
|
|
110
|
+
const firstContent = getTextContent(messageToDict(firstUser).content);
|
|
111
|
+
conversationId = `conv-${createHash('md5').update(firstContent, 'utf8').digest('hex').slice(0, 12)}`;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/* ===== 工作目录 =====
|
|
115
|
+
* 优先级:请求参数 > 系统消息 > 已知路径推导 > 配置默认值。 */
|
|
116
|
+
const knownFilePaths = extractFilePathsFromMessages(messages);
|
|
117
|
+
let workspacePath = '';
|
|
118
|
+
if (rawRequest.workspace_path) {
|
|
119
|
+
workspacePath = rawRequest.workspace_path;
|
|
120
|
+
logger.info(`[tool_calls] 使用请求参数中的工作目录: ${workspacePath}`);
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
workspacePath = extractWorkspacePath(messages);
|
|
124
|
+
if (workspacePath) {
|
|
125
|
+
logger.info(`[tool_calls] 检测到工作目录: ${workspacePath}`);
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
// 调试:把系统消息里与路径有关的内容打出来(帮助理解客户端格式)
|
|
129
|
+
for (const m of messages) {
|
|
130
|
+
const md = messageToDict(m);
|
|
131
|
+
if (md.role !== 'system')
|
|
132
|
+
continue;
|
|
133
|
+
const content = String(md.content ?? '');
|
|
134
|
+
logger.info(`[tool_calls] 系统消息长度=${content.length}, 前1000字符: ${content.slice(0, 1000)}`);
|
|
135
|
+
for (const kw of ['working', 'director', 'workspace', 'project', '环境', '目录', 'Environ']) {
|
|
136
|
+
const re = new RegExp(kw, 'gi');
|
|
137
|
+
let match;
|
|
138
|
+
while ((match = re.exec(content)) !== null) {
|
|
139
|
+
const start = Math.max(0, match.index - 50);
|
|
140
|
+
const end = Math.min(content.length, match.index + kw.length + 100);
|
|
141
|
+
const snippet = content.slice(start, end).replace(/\n/g, '\\n');
|
|
142
|
+
logger.info(`[tool_calls] 系统消息路径线索 (关键词=${kw}): ...${snippet}...`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
break; // 只看第一个 system 消息
|
|
146
|
+
}
|
|
147
|
+
// 从已知文件路径推导工作目录(只取公共父目录,且目录必须真实存在)
|
|
148
|
+
if (knownFilePaths.length > 0) {
|
|
149
|
+
const dirs = new Set();
|
|
150
|
+
for (const p of knownFilePaths) {
|
|
151
|
+
const d = dirname(p);
|
|
152
|
+
if (existsSync(d) &&
|
|
153
|
+
statSync(d).isDirectory() &&
|
|
154
|
+
!FABRICATED_PATH_PREFIXES.some((fp) => d.startsWith(fp.replace(/\/+$/, '')))) {
|
|
155
|
+
dirs.add(d);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (dirs.size > 0) {
|
|
159
|
+
const sortedDirs = [...dirs].sort((a, b) => b.length - a.length);
|
|
160
|
+
let candidate = sortedDirs[0];
|
|
161
|
+
for (const d of sortedDirs.slice(1)) {
|
|
162
|
+
while (candidate && !d.startsWith(candidate)) {
|
|
163
|
+
candidate = dirname(candidate);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
// 最终验证:候选目录必须深度 >= 3(避免 `/` 或 `/home`)
|
|
167
|
+
if (candidate &&
|
|
168
|
+
existsSync(candidate) &&
|
|
169
|
+
statSync(candidate).isDirectory() &&
|
|
170
|
+
(candidate.match(/\//g)?.length ?? 0) >= 3) {
|
|
171
|
+
workspacePath = candidate;
|
|
172
|
+
logger.info(`[tool_calls] 从已知路径推导工作目录: ${workspacePath}`);
|
|
173
|
+
}
|
|
174
|
+
else {
|
|
175
|
+
logger.warn(`[tool_calls] 推导的工作目录无效或太浅: ${candidate}, 跳过`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
// 最终兜底:配置的默认工作目录
|
|
182
|
+
if (!workspacePath) {
|
|
183
|
+
const defaultPaths = getDefaultWorkspacePaths();
|
|
184
|
+
if (defaultPaths.length > 0) {
|
|
185
|
+
workspacePath = defaultPaths[0];
|
|
186
|
+
logger.info(`[tool_calls] 无法从请求中提取工作目录,使用配置的默认值: ${workspacePath}`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (knownFilePaths.length > 0) {
|
|
190
|
+
logger.info(`[tool_calls] 检测到 ${knownFilePaths.length} 个已知文件路径`);
|
|
191
|
+
}
|
|
192
|
+
// ===== 工具描述注入 =====
|
|
193
|
+
let toolsText = '';
|
|
194
|
+
if (rawRequest.tools && rawRequest.tools.length > 0) {
|
|
195
|
+
toolsText = formatToolsAsText(rawRequest.tools, {
|
|
196
|
+
workspacePath,
|
|
197
|
+
knownFilePaths: knownFilePaths.length > 0 ? knownFilePaths : undefined,
|
|
198
|
+
});
|
|
199
|
+
logger.info(`请求包含 ${rawRequest.tools.length} 个工具定义,已注入工具描述`);
|
|
200
|
+
}
|
|
201
|
+
/* ===== 工具调用轮数统计 =====
|
|
202
|
+
* 只统计**最后一次用户消息之后**的轮数,避免历史消息误触发。 */
|
|
203
|
+
let lastUserIdx = -1;
|
|
204
|
+
messages.forEach((m, i) => {
|
|
205
|
+
if (messageToDict(m).role === 'user')
|
|
206
|
+
lastUserIdx = i;
|
|
207
|
+
});
|
|
208
|
+
let toolRoundCount = 0;
|
|
209
|
+
if (lastUserIdx >= 0) {
|
|
210
|
+
for (const m of messages.slice(lastUserIdx + 1)) {
|
|
211
|
+
const md = messageToDict(m);
|
|
212
|
+
if (md.role === 'assistant' && md.tool_calls)
|
|
213
|
+
toolRoundCount += 1;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
logger.info(`[tool_calls] 消息结构: ${messageDicts
|
|
217
|
+
.map((md) => `${md.role}${md.tool_calls ? '✓tc' : ''}`)
|
|
218
|
+
.join(' > ')}, last_user_idx=${lastUserIdx}, tool_round_count=${toolRoundCount}`);
|
|
219
|
+
/* ===== 轮数上限截停 =====
|
|
220
|
+
* ⚠️ 结束语必须**如实说明被截停**:旧文案是「操作已完成。」,
|
|
221
|
+
* 实测用户「帮我修复这几处」时读了 8 个文件就撞上限被截停,
|
|
222
|
+
* 客户端显示「操作已完成。」但**一个文件都没改** —— 严重误导。 */
|
|
223
|
+
if (toolRoundCount >= TOOL_ROUND_LIMIT) {
|
|
224
|
+
logger.warn(`[tool_calls] 当前用户请求下工具调用已达 ${toolRoundCount} 轮` +
|
|
225
|
+
`(上限 ${TOOL_ROUND_LIMIT}),返回截停说明`);
|
|
226
|
+
return {
|
|
227
|
+
kind: 'limit_exceeded',
|
|
228
|
+
toolRoundCount,
|
|
229
|
+
message: `⚠️ 本轮工具调用已达上限(${toolRoundCount} 轮),` +
|
|
230
|
+
'任务可能尚未完成。请回复「继续」让我接着处理,' +
|
|
231
|
+
'或把任务拆小一点(例如一次只让我改一个文件)。',
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
/* ===== 提示词拼装 ===== */
|
|
235
|
+
let roundInstruction = ''; // 工具结果后的附加指令(增量提示词也要带上)
|
|
236
|
+
if (messages.length > 1 || toolsText) {
|
|
237
|
+
const contextText = formatMessagesAsText(messages);
|
|
238
|
+
messageText = toolsText ? `${toolsText}\n\n${contextText}` : contextText;
|
|
239
|
+
if (toolRoundCount > 0) {
|
|
240
|
+
/* 检查之前的工具调用是否全为只读。
|
|
241
|
+
* ⚠️ 必须用统一的 `isReadOnlyTool()`:旧实现在这里另抄了一份硬编码名单,
|
|
242
|
+
* 漏了 read_file / list_dir / read_lints 等真实工具名,判定会失真。 */
|
|
243
|
+
let allReadOnly = true;
|
|
244
|
+
if (lastUserIdx >= 0) {
|
|
245
|
+
for (const m of messages.slice(lastUserIdx + 1)) {
|
|
246
|
+
const md = messageToDict(m);
|
|
247
|
+
if (md.role !== 'assistant' || !md.tool_calls)
|
|
248
|
+
continue;
|
|
249
|
+
for (const tc of md.tool_calls) {
|
|
250
|
+
if (!isReadOnlyTool(tc.function?.name ?? '')) {
|
|
251
|
+
allReadOnly = false;
|
|
252
|
+
break;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
if (allReadOnly && toolRoundCount >= 3) {
|
|
258
|
+
/* 连续读了 3 轮却没修改 → 催一下,避免「只读不写」空转。
|
|
259
|
+
* ⚠️ 两个坑:
|
|
260
|
+
* 1. 不要硬编码工具名(曾写死 SearchReplace,而 CodeBuddy 的真实工具是
|
|
261
|
+
* replace_in_file,模型会照抄出一个客户端没有的工具名)。
|
|
262
|
+
* 2. 不要说「不要再读取」——模型会理解成「必须收尾」,
|
|
263
|
+
* 于是交一份「需先补读 X」的计划书,回合直接结束、什么都没改。 */
|
|
264
|
+
roundInstruction =
|
|
265
|
+
'\n\n[重要] 你已经连续读取了多轮文件,但还没有做任何修改。' +
|
|
266
|
+
'**本次会话没有轮数上限**,你随时可以继续调用工具,' +
|
|
267
|
+
'不要在回答里声称『轮数已达上限』或『待下一轮』。' +
|
|
268
|
+
'如果信息已经足够,请立刻调用「修改文件」类工具(见上方工具列表)落地修改;' +
|
|
269
|
+
'确实还缺关键文件就继续读取(可一次并行读多个),不要停下来。' +
|
|
270
|
+
'禁止只输出计划、清单或『需先补读…』这类说明文字。' +
|
|
271
|
+
// ⚠️ 2026-10-14 实测:模型会把这段指令**原文抄进回答**
|
|
272
|
+
// (「仔细看看,系统注释说:[重要] 上述工具调用已经执行完毕…」),整轮空转。
|
|
273
|
+
'**不要引用或复述本段说明,直接动手。**';
|
|
274
|
+
logger.info(`[tool_calls] 检测到 ${toolRoundCount} 轮只读不写,注入强制修改指令`);
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
roundInstruction =
|
|
278
|
+
'\n\n[重要] 上述工具调用已经执行完毕,结果如上所示。' +
|
|
279
|
+
'**本次会话没有轮数上限**(不要在回答里声称『工具轮数已达上限』' +
|
|
280
|
+
'『待下一轮执行』『需先补读』),请继续用工具推进任务:' +
|
|
281
|
+
'还需要看文件就直接调用读取工具(可一次并行读多个);' +
|
|
282
|
+
'需要修改就直接调用修改/写入工具;' +
|
|
283
|
+
'只有在**全部工作确实完成**之后才输出总结文字;' +
|
|
284
|
+
'不要把计划清单写进项目文件、也不要把它当成最终回答。' +
|
|
285
|
+
// ⚠️ 2026-10-14:模型会复述本段(连说三遍「搜索结果与本任务无关,忽略」)。
|
|
286
|
+
'**不要引用或复述本段说明,直接动手。**';
|
|
287
|
+
}
|
|
288
|
+
messageText += roundInstruction;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
else if (toolsText) {
|
|
292
|
+
messageText = `${toolsText}\n\n${messageText}`;
|
|
293
|
+
}
|
|
294
|
+
/* ===== 同一会话只补发增量 =====
|
|
295
|
+
* 网页会话里已经有上一次发进去的完整提示词与模型自己的回复,
|
|
296
|
+
* 因此这次只需要发「新增消息 + 工具协议」。
|
|
297
|
+
* 只发增量是**有前提**的:由 provider 确认它确实复用了同一个网页会话,
|
|
298
|
+
* 否则它会退回发完整提示词。 */
|
|
299
|
+
let deltaText = '';
|
|
300
|
+
const deltaMessages = getSessionDelta(messages, conversationId);
|
|
301
|
+
if (deltaMessages.length > 0) {
|
|
302
|
+
const deltaContext = formatMessagesAsText(deltaMessages);
|
|
303
|
+
deltaText = toolsText ? `${toolsText}\n\n${deltaContext}` : deltaContext;
|
|
304
|
+
deltaText += roundInstruction;
|
|
305
|
+
if (deltaText.length > BROWSER_PROMPT_MAX_LEN) {
|
|
306
|
+
deltaText = truncatePromptForBrowser(deltaText);
|
|
307
|
+
}
|
|
308
|
+
logger.info(`[session] 会话 ${conversationId} 可复用:本轮只补发增量 ` +
|
|
309
|
+
`${deltaMessages.length} 条消息(${deltaText.length} 字符,` +
|
|
310
|
+
`完整提示词 ${messageText.length} 字符)`);
|
|
311
|
+
}
|
|
312
|
+
/* ===== 超长压缩 =====
|
|
313
|
+
* 记下**压缩前**的原文:① 预压缩是否值得做,要看压缩前的长度
|
|
314
|
+
* (压缩后必然掉到上限以下,用它判断会永远为假);
|
|
315
|
+
* ② 后台预压缩的切分/缓存前缀必须基于原文,否则与下一轮的口径不一致。 */
|
|
316
|
+
const rawMessageText = messageText;
|
|
317
|
+
if (messageText.length > BROWSER_PROMPT_MAX_LEN) {
|
|
318
|
+
messageText = await shrinkPromptViaCompression(messageText, modelRouter, rawRequest.model, historyDb, requestId, conversationId ?? '', deadlineTs);
|
|
319
|
+
}
|
|
320
|
+
else {
|
|
321
|
+
messageText = truncatePromptForBrowser(messageText);
|
|
322
|
+
}
|
|
323
|
+
return {
|
|
324
|
+
kind: 'ok',
|
|
325
|
+
prepared: {
|
|
326
|
+
requestId,
|
|
327
|
+
deadlineTs,
|
|
328
|
+
request: rawRequest,
|
|
329
|
+
conversationId,
|
|
330
|
+
messageText,
|
|
331
|
+
rawMessageText,
|
|
332
|
+
deltaText,
|
|
333
|
+
workspacePath,
|
|
334
|
+
knownFilePaths,
|
|
335
|
+
toolsText,
|
|
336
|
+
toolRoundCount,
|
|
337
|
+
lastMessage,
|
|
338
|
+
},
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
/* ==================================================================== */
|
|
342
|
+
/* 阶段 2:工具调用解析与降级恢复 */
|
|
343
|
+
/* ==================================================================== */
|
|
344
|
+
/** 重复调用抑制的阈值:历史里同一「工具名 + 参数」出现次数达到它就拦下。 */
|
|
345
|
+
const REPEAT_CALL_LIMIT = 2;
|
|
346
|
+
/** 调用指纹:`工具名|规范化后的参数 JSON`(忽略键顺序与空白)。 */
|
|
347
|
+
function toolCallFingerprint(tc) {
|
|
348
|
+
const name = tc?.function?.name ?? '';
|
|
349
|
+
const raw = tc?.function?.arguments;
|
|
350
|
+
let args = typeof raw === 'string' ? raw : JSON.stringify(raw ?? {});
|
|
351
|
+
try {
|
|
352
|
+
args = JSON.stringify(JSON.parse(args));
|
|
353
|
+
}
|
|
354
|
+
catch {
|
|
355
|
+
// 参数不是合法 JSON(半截/脏数据)→ 用原文比对
|
|
356
|
+
}
|
|
357
|
+
return `${name}|${args}`;
|
|
358
|
+
}
|
|
359
|
+
/** 统计客户端历史里**已经执行过**的工具调用(指纹 → 次数)。 */
|
|
360
|
+
function countHistoryToolCalls(messages) {
|
|
361
|
+
const counts = new Map();
|
|
362
|
+
for (const m of messages) {
|
|
363
|
+
const md = messageToDict(m);
|
|
364
|
+
const tcs = md.tool_calls;
|
|
365
|
+
if (!Array.isArray(tcs))
|
|
366
|
+
continue;
|
|
367
|
+
for (const tc of tcs) {
|
|
368
|
+
const key = toolCallFingerprint(tc);
|
|
369
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return counts;
|
|
373
|
+
}
|
|
374
|
+
/** 客户端可执行的标准工具名(`arguments` 为空时用来判断"是否值得保留")。 */
|
|
375
|
+
const TRAE_TOOL_NAMES = new Set([
|
|
376
|
+
'Write', 'SearchReplace', 'Read', 'AskFollowupQuestion',
|
|
377
|
+
'insert_content', 'search_content', 'list_dir',
|
|
378
|
+
'write_to_file', 'write_file', 'create_file',
|
|
379
|
+
'edit_file', 'replace_in_file',
|
|
380
|
+
]);
|
|
381
|
+
/** 标准名 → 别名(用于套用「客户端实际工具名」映射)。 */
|
|
382
|
+
const ALIAS_TO_STD = {
|
|
383
|
+
write_file: 'Write',
|
|
384
|
+
WriteFile: 'Write',
|
|
385
|
+
create_file: 'Write',
|
|
386
|
+
edit_file: 'SearchReplace',
|
|
387
|
+
EditFile: 'SearchReplace',
|
|
388
|
+
edit: 'SearchReplace',
|
|
389
|
+
replace: 'SearchReplace',
|
|
390
|
+
replace_in_file: 'SearchReplace',
|
|
391
|
+
};
|
|
392
|
+
/** JS 版 `str.count` 的等价物。 */
|
|
393
|
+
function contains(haystack, needle) {
|
|
394
|
+
return needle !== '' && haystack.includes(needle);
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* 把 SSE 原始 `tool_calls` 转成 OpenAI 格式。
|
|
398
|
+
*
|
|
399
|
+
* 过滤规则(全部来自实测):
|
|
400
|
+
* - `finish` 是 SSE 结束信号;`search` 是 ChatGLM 内置搜索;
|
|
401
|
+
* `open` 空参数/仅含导航信息的是内部噪声 —— 三者都必须丢弃,
|
|
402
|
+
* 否则会把它们当成客户端工具下发(客户端不认识 → 卡住)。
|
|
403
|
+
* - ChatGLM 有时把**整个 tool_call JSON 塞进 `name` 字段**
|
|
404
|
+
* (`name = '{"name": "Write", "arguments": {...}}'`),要拆出来。
|
|
405
|
+
* - `arguments` 为空的畸形调用,只有标准工具名才保留。
|
|
406
|
+
* - 最后套用「标准名 → 客户端实际工具名」映射。
|
|
407
|
+
*/
|
|
408
|
+
function sseToolCallsToOpenAi(rawToolCalls, toolMaps) {
|
|
409
|
+
const out = [];
|
|
410
|
+
rawToolCalls.forEach((tc, i) => {
|
|
411
|
+
if (!tc || typeof tc !== 'object' || !('name' in tc))
|
|
412
|
+
return;
|
|
413
|
+
const nameRaw = typeof tc.name === 'string' ? tc.name : '';
|
|
414
|
+
const argsRaw = tc.arguments;
|
|
415
|
+
let actualName = nameRaw;
|
|
416
|
+
let actualArgs = argsRaw;
|
|
417
|
+
// ---- 过滤 ChatGLM 内部工具调用 ----
|
|
418
|
+
if (nameRaw === 'finish')
|
|
419
|
+
return;
|
|
420
|
+
if (nameRaw === 'search')
|
|
421
|
+
return;
|
|
422
|
+
if (nameRaw === 'open') {
|
|
423
|
+
const s = typeof argsRaw === 'string' ? argsRaw : '';
|
|
424
|
+
if (!s || s === '{}' || s === '{')
|
|
425
|
+
return;
|
|
426
|
+
try {
|
|
427
|
+
const parsed = typeof argsRaw === 'string' ? JSON.parse(argsRaw) : argsRaw;
|
|
428
|
+
if (parsed && typeof parsed === 'object') {
|
|
429
|
+
const rec = parsed;
|
|
430
|
+
if (rec['open'] || rec['ref_id'])
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
catch {
|
|
435
|
+
// 解析不了就按「不是内部调用」处理
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
// ---- 处理 `name` 是 JSON 字符串的畸形格式 ----
|
|
439
|
+
if (nameRaw.startsWith('{')) {
|
|
440
|
+
try {
|
|
441
|
+
const parsedName = JSON.parse(nameRaw);
|
|
442
|
+
if (parsedName && typeof parsedName['name'] === 'string') {
|
|
443
|
+
actualName = parsedName['name'];
|
|
444
|
+
if (parsedName['arguments']) {
|
|
445
|
+
actualArgs =
|
|
446
|
+
typeof parsedName['arguments'] === 'string'
|
|
447
|
+
? parsedName['arguments']
|
|
448
|
+
: JSON.stringify(parsedName['arguments']);
|
|
449
|
+
}
|
|
450
|
+
else if (argsRaw) {
|
|
451
|
+
actualArgs = typeof argsRaw === 'string' ? argsRaw : JSON.stringify(argsRaw);
|
|
452
|
+
}
|
|
453
|
+
else {
|
|
454
|
+
actualArgs = '';
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
catch {
|
|
459
|
+
// 保持原样
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
const argsStr = typeof actualArgs === 'string' ? actualArgs : JSON.stringify(actualArgs ?? '');
|
|
463
|
+
// ---- 空 arguments + 非标准名称 → 丢弃 ----
|
|
464
|
+
if (!argsStr || argsStr === '{}' || argsStr === '{') {
|
|
465
|
+
if (!TRAE_TOOL_NAMES.has(actualName))
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
// ---- 套用动态工具名映射(标准名 → 客户端实际工具名)----
|
|
469
|
+
const stdName = ALIAS_TO_STD[actualName] ?? actualName;
|
|
470
|
+
if (stdName === 'Write' && toolMaps['write'])
|
|
471
|
+
actualName = toolMaps['write'];
|
|
472
|
+
else if (stdName === 'SearchReplace' && toolMaps['edit'])
|
|
473
|
+
actualName = toolMaps['edit'];
|
|
474
|
+
else if (stdName === 'Read' && toolMaps['read'])
|
|
475
|
+
actualName = toolMaps['read'];
|
|
476
|
+
out.push({
|
|
477
|
+
id: tc.id ?? `call_${i}`,
|
|
478
|
+
type: 'function',
|
|
479
|
+
function: { name: actualName, arguments: argsStr },
|
|
480
|
+
});
|
|
481
|
+
});
|
|
482
|
+
return out;
|
|
483
|
+
}
|
|
484
|
+
/** 工具调用里是否带「真正的文件内容」(判断哪一路解析更完整)。 */
|
|
485
|
+
function toolCallsHaveContent(toolCalls) {
|
|
486
|
+
return toolCalls.some((tc) => {
|
|
487
|
+
const args = tc.function?.arguments ?? '';
|
|
488
|
+
return contains(args, 'content') || contains(args, 'old_str');
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* 解析并恢复工具调用。
|
|
493
|
+
*
|
|
494
|
+
* @param parseSource 首选解析源(SSE 全文,最保真)
|
|
495
|
+
* @param fullResponse 流式增量拼接结果(兜底)
|
|
496
|
+
* @param fromSse parseSource 是否来自 SSE
|
|
497
|
+
*/
|
|
498
|
+
export async function parseAndRecoverToolCalls(params) {
|
|
499
|
+
const { parseSource, fullResponse, fromSse, currentPlatform, prepared, modelRouter, historyDb, toolMaps, options, } = params;
|
|
500
|
+
const { request: rawRequest, deadlineTs, workspacePath, knownFilePaths, lastMessage } = prepared;
|
|
501
|
+
const tools = (rawRequest.tools ?? []);
|
|
502
|
+
const requestId = prepared.requestId;
|
|
503
|
+
/* 「用户原始请求」**必须用专门的抽取函数**。
|
|
504
|
+
* ⚠️ 2026-10-14 实测:多轮工具调用时 `messages[-1]` 是 tool 结果,
|
|
505
|
+
* 于是转换轮收到的是 `# 用户原始请求\n已写入 /home/amble/object/btc/README.md(1231 字符)`
|
|
506
|
+
* —— 转换器完全猜不到用户要什么。见 `docs/hard-constraints.md`「日志」一节。 */
|
|
507
|
+
const userRequest = extractUserQuestion(rawRequest.messages) ||
|
|
508
|
+
(lastMessage ? getTextContent(messageToDict(lastMessage).content) : '');
|
|
509
|
+
let source = parseSource;
|
|
510
|
+
let sourceFromSse = fromSse;
|
|
511
|
+
let parsed;
|
|
512
|
+
try {
|
|
513
|
+
parsed = parseToolCallsFromText(source, toolMaps);
|
|
514
|
+
}
|
|
515
|
+
catch (error) {
|
|
516
|
+
logger.warn(`[tool_calls] 解析失败: ${errorMessage(error)}`);
|
|
517
|
+
parsed = { text: source, toolCalls: [] };
|
|
518
|
+
}
|
|
519
|
+
let parsedText = parsed.text;
|
|
520
|
+
let parsedToolCalls = parsed.toolCalls;
|
|
521
|
+
logger.info(`[tool_calls] parse_source(来自SSE=${sourceFromSse}, len=${source.length}) 开头: ` +
|
|
522
|
+
`${JSON.stringify(source.slice(0, 120))}`);
|
|
523
|
+
logger.info(`[tool_calls] parsed_text(len=${parsedText.length}) 开头: ` +
|
|
524
|
+
`${JSON.stringify(parsedText.slice(0, 120))}, 拼接(full_response)开头: ` +
|
|
525
|
+
`${JSON.stringify(fullResponse.slice(0, 120))}`);
|
|
526
|
+
/* ===== 步骤 2:DOM 损坏降级 ===== */
|
|
527
|
+
const isDomCorrupted = !sourceFromSse &&
|
|
528
|
+
(DOM_CORRUPTION_INDICATOR.test(source) || DOM_PREVIEW_KEY_INDICATOR.test(source));
|
|
529
|
+
logger.info(`[tool_calls] DOM 损坏检测: is_corrupted=${isDomCorrupted}, platform=${currentPlatform}`);
|
|
530
|
+
if (isDomCorrupted && currentPlatform && currentPlatform.includes('chatglm')) {
|
|
531
|
+
const rawSse = getLastRawSse();
|
|
532
|
+
// 策略 1:直接使用 SSE 中的 tool_calls 数据
|
|
533
|
+
const rawToolCalls = (rawSse.toolCalls ?? []);
|
|
534
|
+
let sseTcs = [];
|
|
535
|
+
if (rawToolCalls.length > 0) {
|
|
536
|
+
logger.info(`[tool_calls] 检测到 DOM 损坏,使用 SSE 原始 tool_calls (${rawToolCalls.length}个)`);
|
|
537
|
+
sseTcs = sseToolCallsToOpenAi(rawToolCalls, toolMaps.name);
|
|
538
|
+
if (sseTcs.length > 0) {
|
|
539
|
+
// 只有 SSE 版更完整(带 content/old_str)或渲染版没有内容时才替代
|
|
540
|
+
const sseHasContent = toolCallsHaveContent(sseTcs);
|
|
541
|
+
const renderedHasContent = parsedToolCalls.length > 0 && toolCallsHaveContent(parsedToolCalls);
|
|
542
|
+
if (sseHasContent || !renderedHasContent) {
|
|
543
|
+
logger.info(`[tool_calls] SSE tool_calls 更完整,替代渲染解析 ` +
|
|
544
|
+
`(SSE: ${sseTcs.length}个, 渲染: ${parsedToolCalls.length}个)`);
|
|
545
|
+
parsedToolCalls = sseTcs;
|
|
546
|
+
parsedText = '';
|
|
547
|
+
}
|
|
548
|
+
else {
|
|
549
|
+
logger.info('[tool_calls] SSE tool_calls 未更完整,保留渲染解析');
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
// 策略 2:用 SSE 原始文本(或思考文本)重新解析
|
|
554
|
+
if (parsedToolCalls.length === 0 || !toolCallsHaveContent(parsedToolCalls)) {
|
|
555
|
+
const sseFallbackText = rawSse.text || rawSse.think || '';
|
|
556
|
+
if (sseFallbackText.length > 10) {
|
|
557
|
+
const label = rawSse.text ? 'SSE文本' : 'SSE思考文本';
|
|
558
|
+
logger.info(`[tool_calls] 尝试用原始 ${label} 降级解析 (长度=${sseFallbackText.length})`);
|
|
559
|
+
logger.info(`[tool_calls] 原始 ${label} 前500字符: ${JSON.stringify(sseFallbackText.slice(0, 500))}`);
|
|
560
|
+
const rawParsed = parseToolCallsFromText(sseFallbackText, toolMaps);
|
|
561
|
+
const rawToolCalls2 = rawParsed.toolCalls;
|
|
562
|
+
if (rawToolCalls2.length > 0) {
|
|
563
|
+
if (toolCallsHaveContent(rawToolCalls2)) {
|
|
564
|
+
logger.info(`[tool_calls] 原始 ${label} 解析更完整,替代渲染解析 (原始: ${rawToolCalls2.length}个)`);
|
|
565
|
+
parsedText = rawParsed.text;
|
|
566
|
+
parsedToolCalls = rawToolCalls2;
|
|
567
|
+
}
|
|
568
|
+
else {
|
|
569
|
+
logger.info(`[tool_calls] 原始 ${label} 解析未更完整,保留渲染解析`);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
else {
|
|
573
|
+
logger.info(`[tool_calls] 原始 ${label} 也未解析到工具调用`);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
/* 「全部工具调用被过滤」时的回退正文。
|
|
579
|
+
* ⚠️ 必须在步骤 3/4 **之前**取快照:reparse / 自驱动续跑成功后会把 parsedText
|
|
580
|
+
* 改写成「正在执行 N 个操作:…」摘要,那种摘要**不能**当回退正文下发
|
|
581
|
+
* (客户端会把它当最终回答显示后停住)。 */
|
|
582
|
+
const fallbackBodyText = parsedText.trim();
|
|
583
|
+
/** 去掉工具调用块后的纯文本(回退用 —— `parsed.text` 已剔除命中区间)。 */
|
|
584
|
+
const stripToolCallBlocks = (text) => text ? parseToolCallsFromText(text, toolMaps).text.trim() : '';
|
|
585
|
+
logAiResponse({
|
|
586
|
+
requestId,
|
|
587
|
+
platform: currentPlatform ?? rawRequest.model,
|
|
588
|
+
rawText: fullResponse,
|
|
589
|
+
parsedToolCalls: parsedToolCalls,
|
|
590
|
+
parsedText,
|
|
591
|
+
});
|
|
592
|
+
logAiPlatformResponseComplete({
|
|
593
|
+
requestId,
|
|
594
|
+
platform: currentPlatform ?? 'unknown',
|
|
595
|
+
roundNum: 1,
|
|
596
|
+
rawText: fullResponse,
|
|
597
|
+
});
|
|
598
|
+
logger.info(`[tool_calls] AI回复长度=${fullResponse.length}, 前500字符: ` +
|
|
599
|
+
`${JSON.stringify(fullResponse.slice(0, 500))}`);
|
|
600
|
+
if (parsedToolCalls.length > 0) {
|
|
601
|
+
logger.info(`[tool_calls] parse_tool_calls 解析到 ${parsedToolCalls.length} 个: ` +
|
|
602
|
+
`${parsedToolCalls.map((tc) => tc.function.name).join(', ')}`);
|
|
603
|
+
}
|
|
604
|
+
/* ⚠️ 两个「文本 → 工具调用」转换器统一用**最干净的那份文本**:SSE 全文。
|
|
605
|
+
* 绝不能传 `fullResponse`:它是逐块拼接的结果,模型改写回答时会变成
|
|
606
|
+
* 「草稿1 + 草稿2 + 最终答案」的乱码,而这些转换器在**没提取到工具调用时
|
|
607
|
+
* 会把输入文本原样返回** → 直接覆盖掉前面已解析好的干净 parsedText。 */
|
|
608
|
+
const convertSource = sourceFromSse && source ? source : fullResponse;
|
|
609
|
+
/* ===== 步骤 3:文本转换器 ===== */
|
|
610
|
+
if (parsedToolCalls.length === 0 && tools.length > 0 && convertSource) {
|
|
611
|
+
const directResult = directConvertToToolCalls(convertSource, tools, userRequest);
|
|
612
|
+
const directTcs = directResult.toolCalls;
|
|
613
|
+
if (directTcs.length > 0) {
|
|
614
|
+
// 只有真的提取到工具调用才采纳它的文本,避免污染 parsedText
|
|
615
|
+
parsedText = directResult.text;
|
|
616
|
+
parsedToolCalls = directTcs;
|
|
617
|
+
logger.info(`[tool_calls] 直接转换:${parsedToolCalls.length} 个工具调用`);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
if (parsedToolCalls.length === 0 && tools.length > 0 && convertSource) {
|
|
621
|
+
const convResult = convertTextToToolCalls(convertSource, tools);
|
|
622
|
+
const convTcs = convResult.toolCalls;
|
|
623
|
+
if (convTcs.length > 0) {
|
|
624
|
+
parsedText = convResult.text;
|
|
625
|
+
parsedToolCalls = convTcs;
|
|
626
|
+
logger.info(`[tool_calls] 文本转换器:${parsedToolCalls.length} 个工具调用`);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
/* ==================================================================== */
|
|
630
|
+
/* 步骤 4:过滤(占位符 / 未知工具名 / 签名改派 / 命令误填) */
|
|
631
|
+
/* */
|
|
632
|
+
/* ⚠️ 必须在「动作恢复轮」**之前**跑:只有过滤完才知道本轮到底有没有可 */
|
|
633
|
+
/* 下发的调用,也才能把「模型调了客户端没有的工具」当成触发信号。 */
|
|
634
|
+
/* ==================================================================== */
|
|
635
|
+
const beforeFilter = parsedToolCalls.length;
|
|
636
|
+
/** 被「客户端没有这个工具」丢掉的调用名 / 原文(恢复轮要用)。 */
|
|
637
|
+
let droppedUnknownNames = [];
|
|
638
|
+
let droppedUnknownCalls = [];
|
|
639
|
+
/** 被「重复调用抑制」丢掉的调用原文(回退正文要用)。 */
|
|
640
|
+
let suppressedRepeats = [];
|
|
641
|
+
/** 客户端历史里已执行过的调用指纹 → 次数(重复调用抑制用)。 */
|
|
642
|
+
const historyCallCounts = countHistoryToolCalls(prepared.request.messages);
|
|
643
|
+
/** 统一的过滤链:占位符 → 未知工具名 → 签名改派 → 命令误填 → 重复调用抑制。 */
|
|
644
|
+
const filterToolCalls = (calls) => {
|
|
645
|
+
let current = calls;
|
|
646
|
+
const droppedNames = [];
|
|
647
|
+
if (current.length > 0) {
|
|
648
|
+
// 占位符过滤:丢弃参数为示例文本(如 "真实文件路径"/"文件内容")的调用
|
|
649
|
+
current = filterPlaceholderToolCalls(current).valid;
|
|
650
|
+
}
|
|
651
|
+
if (current.length > 0) {
|
|
652
|
+
// 未知工具名过滤:AI 编造的工具名(如 "工具名称")客户端无法执行
|
|
653
|
+
const unknown = filterUnknownToolCalls(current, tools);
|
|
654
|
+
current = unknown.valid;
|
|
655
|
+
droppedNames.push(...unknown.dropped.map((tc) => tc.function?.name ?? ''));
|
|
656
|
+
droppedUnknownCalls.push(...unknown.dropped.map((tc) => `${tc.function?.name ?? '?'}(${tc.function?.arguments ?? ''})`));
|
|
657
|
+
}
|
|
658
|
+
if (current.length > 0) {
|
|
659
|
+
/* 按参数签名纠正选错的工具:模型想创建文件却调 edit_file 并套用
|
|
660
|
+
* write_file 的签名(实测:edit_file(path, content))—— 客户端会拒收
|
|
661
|
+
* 「未知参数 content」。若恰好只有一个已声明工具的签名装得下这些参数
|
|
662
|
+
* (write_file),就改派过去,省掉一轮注定失败的往返。 */
|
|
663
|
+
const { repaired, reroutes } = repairToolCallBySignature(current, tools);
|
|
664
|
+
current = repaired;
|
|
665
|
+
for (const r of reroutes) {
|
|
666
|
+
logger.warn(`[tool_calls] 按参数签名改派工具: ${r.from} → ${r.to}` +
|
|
667
|
+
`(未知参数 ${r.unknownKeys.join(', ')})`);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
if (current.length > 0) {
|
|
671
|
+
/* 「拿文件路径参数当命令用」→ 改派给命令执行工具。
|
|
672
|
+
* 实测(2026-10-14):模型把 `cd /x && ./start.sh` 塞进 `write_file` 的 path,
|
|
673
|
+
* 客户端真的在工作目录里创建了一个同名文件,之后又 rm/rmdir 收拾垃圾。 */
|
|
674
|
+
const { repaired, reroutes } = rerouteCommandMisuse(current, tools);
|
|
675
|
+
current = repaired;
|
|
676
|
+
for (const r of reroutes) {
|
|
677
|
+
logger.warn(`[tool_calls] 路径参数里装的是 shell 命令,改派工具: ${r.from} → ${r.to}` +
|
|
678
|
+
`(命令=${JSON.stringify(r.commandPreview)})`);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
/* 重复调用抑制(2026-10-14,止住真实死循环):
|
|
682
|
+
* 客户端历史里「同一工具 + 同一参数」已经执行过 ≥2 次,说明模型在原地打转
|
|
683
|
+
* (实测:`bash ./start.sh status` 被反复重发,每次还配同一段收尾结论)。
|
|
684
|
+
* 再下发只会让客户端的 agent 循环继续转 → 抑制它,让本轮以文本结束。
|
|
685
|
+
* 阈值取 2:允许模型复核一次,第三次起才拦。 */
|
|
686
|
+
if (current.length > 0) {
|
|
687
|
+
const kept = [];
|
|
688
|
+
for (const tc of current) {
|
|
689
|
+
const seen = historyCallCounts.get(toolCallFingerprint(tc)) ?? 0;
|
|
690
|
+
if (seen >= REPEAT_CALL_LIMIT) {
|
|
691
|
+
suppressedRepeats.push(`${tc.function?.name ?? '?'}(${tc.function?.arguments ?? ''})`);
|
|
692
|
+
continue;
|
|
693
|
+
}
|
|
694
|
+
kept.push(tc);
|
|
695
|
+
}
|
|
696
|
+
if (kept.length !== current.length) {
|
|
697
|
+
logger.warn(`[tool_calls] 抑制 ${current.length - kept.length} 个重复调用` +
|
|
698
|
+
`(同一工具+参数在客户端历史里已执行 ≥${REPEAT_CALL_LIMIT} 次)`);
|
|
699
|
+
}
|
|
700
|
+
current = kept;
|
|
701
|
+
}
|
|
702
|
+
return { calls: current, droppedNames };
|
|
703
|
+
};
|
|
704
|
+
{
|
|
705
|
+
const filtered = filterToolCalls(parsedToolCalls);
|
|
706
|
+
parsedToolCalls = filtered.calls;
|
|
707
|
+
droppedUnknownNames = filtered.droppedNames;
|
|
708
|
+
}
|
|
709
|
+
/* ==================================================================== */
|
|
710
|
+
/* 步骤 5:动作恢复轮(**唯一一层兜底**) */
|
|
711
|
+
/* */
|
|
712
|
+
/* ⚠️ 触发条件**只剩两个**(2026-10-14 实测踩坑后收紧): */
|
|
713
|
+
/* ① 调了客户端没有的工具(明确无效,必须救); */
|
|
714
|
+
/* ② 回答像计划书 / 只说不做(`looksUnfinishedAnswer`)。 */
|
|
715
|
+
/* */
|
|
716
|
+
/* 原来还有一条「本轮一个工具调用都没有 → 强制再驱动一轮」,它会把**正常 */
|
|
717
|
+
/* 收尾**的回答(「无需进一步操作」「已确认无变化」)也判成「没推进任务」 */
|
|
718
|
+
/* 并逼模型再动手 —— 实测直接造成死循环:每轮都重发同一句收尾 + 再跑一次 */
|
|
719
|
+
/* `status`。原生模型的做法是:没有工具调用 = 本轮结束(stop)。 */
|
|
720
|
+
/* 详见 docs/native-parity.md「取舍与代价」。 */
|
|
721
|
+
/* ==================================================================== */
|
|
722
|
+
const recoveryMaterial = stripPlatformToolNoise(buildReparseInput(source, fullResponse));
|
|
723
|
+
const isUnfinished = looksUnfinishedAnswer(parsedText);
|
|
724
|
+
const needsRecovery = parsedToolCalls.length === 0 &&
|
|
725
|
+
tools.length > 0 &&
|
|
726
|
+
(droppedUnknownNames.length > 0 || isUnfinished);
|
|
727
|
+
if (needsRecovery) {
|
|
728
|
+
const remaining = deadlineTs - Date.now();
|
|
729
|
+
const uniqueNames = [...new Set(droppedUnknownNames)];
|
|
730
|
+
if (remaining < CONTINUE_MIN_BUDGET * 1000) {
|
|
731
|
+
logger.warn(`[tool_calls] 需要动作恢复轮,但剩余时间(${(remaining / 1000).toFixed(0)}s) 不足 ` +
|
|
732
|
+
`${CONTINUE_MIN_BUDGET.toFixed(0)}s,跳过(直接回文本回答)`);
|
|
733
|
+
}
|
|
734
|
+
else {
|
|
735
|
+
logger.warn('[tool_calls] 触发动作恢复轮(唯一一层兜底):' +
|
|
736
|
+
(uniqueNames.length ? `模型调了客户端没有的工具 ${uniqueNames.join(', ')};` : '') +
|
|
737
|
+
`材料 ${recoveryMaterial.length} 字符;` +
|
|
738
|
+
(isUnfinished ? '回答疑似「只说不做」' : '本轮无工具调用'));
|
|
739
|
+
try {
|
|
740
|
+
const recoveryPrompt = buildActionRecoveryPrompt(tools, {
|
|
741
|
+
// 优先给「材料」(SSE 回答 + 思考 + DOM 拼接,已去噪):模型可能已经把
|
|
742
|
+
// 完整内容写在文字里,恢复轮要能原样搬进工具参数。
|
|
743
|
+
answer: recoveryMaterial || parsedText,
|
|
744
|
+
invalidNames: droppedUnknownNames,
|
|
745
|
+
invalidCalls: droppedUnknownCalls,
|
|
746
|
+
recentProgress: formatRecentProgress(prepared.request.messages),
|
|
747
|
+
// ⚠️ 必须带工作目录:不带时模型会编造 `/workspace/xxx.py` 这种路径。
|
|
748
|
+
currentWorkingDir: workspacePath || process.cwd(),
|
|
749
|
+
});
|
|
750
|
+
const recoveryRequest = {
|
|
751
|
+
message: recoveryPrompt,
|
|
752
|
+
model: rawRequest.model,
|
|
753
|
+
stream: false,
|
|
754
|
+
deadlineTs: Math.min(deadlineTs, Date.now() + CONTINUE_MAX_BUDGET * 1000),
|
|
755
|
+
// 用**独立页面**跑:否则会把主页面上的会话切走,主会话上下文丢失。
|
|
756
|
+
context: { use_aux_page: true },
|
|
757
|
+
};
|
|
758
|
+
let recoveryResponse = '';
|
|
759
|
+
for await (const [chunk] of streamWithFailover(modelRouter, rawRequest.model, recoveryRequest, historyDb, `${requestId}#recover`)) {
|
|
760
|
+
recoveryResponse += chunk.content ?? '';
|
|
761
|
+
}
|
|
762
|
+
// 这一轮的产出同样要过全部过滤器(编造的名字 / 占位符不能下发)
|
|
763
|
+
const recovered = filterToolCalls(parseToolCallsFromText(recoveryResponse, toolMaps).toolCalls);
|
|
764
|
+
if (recovered.calls.length > 0) {
|
|
765
|
+
parsedToolCalls = recovered.calls;
|
|
766
|
+
// ⚠️ **不合成正文**:正文只放模型自己的话(原生模型的行为),
|
|
767
|
+
// 这里原来会写「正在执行 N 个操作:…」——那是我们自己造的假回复。
|
|
768
|
+
logger.info(`[tool_calls] 动作恢复轮成功:${recovered.calls.length} 个工具调用 ` +
|
|
769
|
+
`${recovered.calls.map((tc) => tc.function.name).join(', ')}`);
|
|
770
|
+
}
|
|
771
|
+
else {
|
|
772
|
+
logger.warn(`[tool_calls] 动作恢复轮仍未产出可执行的工具调用(回答长度=${recoveryResponse.length})`);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
catch (error) {
|
|
776
|
+
logger.warn(`[tool_calls] 动作恢复轮失败: ${errorMessage(error)}`);
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
/* ===== 步骤 6:路径纠正(仅流式分支)===== */
|
|
781
|
+
if (options.fixPaths && parsedToolCalls.length > 0 && (workspacePath || knownFilePaths.length > 0)) {
|
|
782
|
+
for (const tc of parsedToolCalls) {
|
|
783
|
+
const argsStr = tc.function.arguments;
|
|
784
|
+
if (!argsStr)
|
|
785
|
+
continue;
|
|
786
|
+
let args;
|
|
787
|
+
try {
|
|
788
|
+
args = JSON.parse(argsStr);
|
|
789
|
+
}
|
|
790
|
+
catch {
|
|
791
|
+
continue;
|
|
792
|
+
}
|
|
793
|
+
const filePath = String(args['file_path'] ?? args['filePath'] ?? args['path'] ?? '');
|
|
794
|
+
if (!filePath)
|
|
795
|
+
continue;
|
|
796
|
+
// 检测编造路径(排除已以 workspace_path 开头的路径)
|
|
797
|
+
const isFabricated = !(workspacePath && filePath.startsWith(workspacePath)) &&
|
|
798
|
+
FABRICATED_PATH_PREFIXES.some((prefix) => filePath.startsWith(prefix));
|
|
799
|
+
if (!isFabricated || !workspacePath || workspacePath.length <= 5)
|
|
800
|
+
continue;
|
|
801
|
+
const filename = basename(filePath);
|
|
802
|
+
// 优先从已知路径里找同名文件
|
|
803
|
+
let corrected = knownFilePaths.find((kp) => basename(kp) === filename) ?? null;
|
|
804
|
+
if (!corrected) {
|
|
805
|
+
corrected = join(workspacePath, filename);
|
|
806
|
+
// 验证纠正后的路径是否存在(避免编造新路径)
|
|
807
|
+
if (!existsSync(corrected)) {
|
|
808
|
+
logger.warn(`[tool_calls] 纠正后的路径不存在: ${corrected}, 保持原路径 ${filePath}`);
|
|
809
|
+
continue;
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
logger.warn(`[tool_calls] 路径校验:检测到编造路径 ${filePath},纠正为 ${corrected}`);
|
|
813
|
+
if ('file_path' in args)
|
|
814
|
+
args['file_path'] = corrected;
|
|
815
|
+
if ('filePath' in args)
|
|
816
|
+
args['filePath'] = corrected;
|
|
817
|
+
if ('path' in args)
|
|
818
|
+
args['path'] = corrected;
|
|
819
|
+
tc.function.arguments = JSON.stringify(args);
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
/* ===== 步骤 7:SearchReplace 参数校验(仅流式分支)===== */
|
|
823
|
+
if (options.validateSearchReplace && parsedToolCalls.length > 0) {
|
|
824
|
+
const valid = [];
|
|
825
|
+
for (const tc of parsedToolCalls) {
|
|
826
|
+
if (tc.function.name === 'SearchReplace') {
|
|
827
|
+
let args = {};
|
|
828
|
+
try {
|
|
829
|
+
args = JSON.parse(tc.function.arguments);
|
|
830
|
+
}
|
|
831
|
+
catch {
|
|
832
|
+
args = {};
|
|
833
|
+
}
|
|
834
|
+
if (!args['old_str']) {
|
|
835
|
+
logger.warn('[tool_calls] SearchReplace old_str 为空,丢弃该工具调用');
|
|
836
|
+
continue;
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
valid.push(tc);
|
|
840
|
+
}
|
|
841
|
+
if (valid.length < parsedToolCalls.length)
|
|
842
|
+
parsedToolCalls = valid;
|
|
843
|
+
}
|
|
844
|
+
/* ===== 回退正文(全部被过滤时)===== */
|
|
845
|
+
if (beforeFilter > 0 && parsedToolCalls.length === 0) {
|
|
846
|
+
/* 全部被过滤 → 必须回退为**真实文本**。
|
|
847
|
+
* 绝不能保留「正在执行 N 个操作:X」这类摘要:客户端会把它当作最终回答显示
|
|
848
|
+
* 然后停止,不再继续执行任何工具(表现为「返回工具调用后客户端没反应」)。
|
|
849
|
+
* ⚠️ 与原设计不同处(2026-10-14):原实现把 `source`(SSE 原文)整段下发,
|
|
850
|
+
* 而整段可能**就是** `<tool_call>{…}</tool_call>` —— 客户端会把这段 XML
|
|
851
|
+
* 当正文显示、然后结束回合,用户看到的是「对话无故停止」。
|
|
852
|
+
* 现在改为:① 用已剔除工具块的正文;② 工具名不存在时补一句可操作的说明。 */
|
|
853
|
+
logger.warn('[tool_calls] 全部工具调用均被过滤,回退为完整文本响应');
|
|
854
|
+
const body = fallbackBodyText ||
|
|
855
|
+
stripToolCallBlocks(source) ||
|
|
856
|
+
stripToolCallBlocks(fullResponse);
|
|
857
|
+
if (droppedUnknownNames.length > 0) {
|
|
858
|
+
const uniqueNames = [...new Set(droppedUnknownNames)];
|
|
859
|
+
parsedText =
|
|
860
|
+
`⚠️ 模型调用了本环境不存在的工具「${uniqueNames.join(', ')}」` +
|
|
861
|
+
'(它不是客户端提供的工具),本轮未执行任何操作。请回复「继续」让我用可用工具重做。' +
|
|
862
|
+
(body ? `\n\n${body}` : '');
|
|
863
|
+
}
|
|
864
|
+
else if (suppressedRepeats.length > 0 && body) {
|
|
865
|
+
/* 重复调用被抑制:模型自己的收尾/结论就是答案,**不加任何前缀**。 */
|
|
866
|
+
parsedText = body;
|
|
867
|
+
}
|
|
868
|
+
else {
|
|
869
|
+
parsedText =
|
|
870
|
+
body ||
|
|
871
|
+
'⚠️ 本轮模型只输出了工具调用的**格式示例/占位符文本**,未执行任何操作。' +
|
|
872
|
+
'请回复「继续」让我重做。';
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
/* ===== 步骤 9:批量下发策略 =====
|
|
876
|
+
* - **全是只读操作**(read_file / list_dir / search_content …)→ 全部下发,
|
|
877
|
+
* 客户端会并行执行。这类调用不改变任何状态,不存在互相影响;
|
|
878
|
+
* 实测模型一次就想读 4 个文件,若只发第一个,24 轮上限几下就被耗光。
|
|
879
|
+
* - 只要包含**写/改/执行**类 → 仍然只发第一个:
|
|
880
|
+
* 同一回复里第二个 SearchReplace 的 `old_str` 可能因为第一个的修改而不再匹配,
|
|
881
|
+
* 必须让 AI 看到第一个的结果后再生成第二个。 */
|
|
882
|
+
if (parsedToolCalls.length > 1) {
|
|
883
|
+
const names = parsedToolCalls.map((tc) => tc.function.name);
|
|
884
|
+
if (names.every((n) => isReadOnlyTool(n))) {
|
|
885
|
+
logger.info(`[tool_calls] ${parsedToolCalls.length} 个调用均为只读操作(${names.join(', ')}),` +
|
|
886
|
+
'全部下发(客户端并行执行)');
|
|
887
|
+
}
|
|
888
|
+
else {
|
|
889
|
+
logger.info(`[tool_calls] 有 ${parsedToolCalls.length} 个工具调用(含写操作,names=${names.join(', ')}),` +
|
|
890
|
+
`只发第一个 (${names[0]}),其余丢弃`);
|
|
891
|
+
parsedToolCalls = parsedToolCalls.slice(0, 1);
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
/* ⚠️ 与原设计不同处(2026-10-14,见 docs/native-parity.md):
|
|
895
|
+
* 这里原来会在「有工具调用且正文 > 200 字符」时把正文改写成
|
|
896
|
+
* 「正在执行 N 个操作:a, b」——那是我们自己造的假回复(原生模型只回自己的话)。
|
|
897
|
+
* 现在**不做任何改写**:正文就是模型写的文字(可能是空的)。 */
|
|
898
|
+
/* ===== 步骤 10:保险 —— 检测「回答与本项目无关」=====
|
|
899
|
+
* 只对「无工具调用的纯文本回答」做检查(有工具调用时会话在正常推进)。
|
|
900
|
+
* ⚠️ 与原设计不同处:**只打日志,不再往正文里塞 `[提示] …` 前缀** ——
|
|
901
|
+
* 原生模型的 content 里不会出现我们造的文案。 */
|
|
902
|
+
if (parsedText && parsedToolCalls.length === 0) {
|
|
903
|
+
const mismatchHint = detectMismatchedAnswer(parsedText, workspacePath);
|
|
904
|
+
if (mismatchHint) {
|
|
905
|
+
logger.warn(`[guard] 回答与本项目不匹配(工作目录=${workspacePath}),仅记录日志(不改写正文)。` +
|
|
906
|
+
`回答开头: ${JSON.stringify(parsedText.slice(0, 120))}`);
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
logger.info(`[tool_calls] 最终结果: 文本长度=${parsedText.length}, 工具调用数=${parsedToolCalls.length}`);
|
|
910
|
+
return {
|
|
911
|
+
parsedText,
|
|
912
|
+
parsedToolCalls,
|
|
913
|
+
finishReason: parsedToolCalls.length > 0 ? 'tool_calls' : 'stop',
|
|
914
|
+
};
|
|
915
|
+
}
|
|
916
|
+
/* ==================================================================== */
|
|
917
|
+
/* 阶段 3:流式分支 */
|
|
918
|
+
/* ==================================================================== */
|
|
919
|
+
/** 从 prepare 结果构造发给 Provider 的 `ChatRequest`。 */
|
|
920
|
+
function buildChatRequest(prepared, extraContext = {}) {
|
|
921
|
+
return {
|
|
922
|
+
message: prepared.messageText,
|
|
923
|
+
model: prepared.request.model,
|
|
924
|
+
conversation_id: prepared.conversationId ?? undefined,
|
|
925
|
+
stream: prepared.request.stream,
|
|
926
|
+
deadlineTs: prepared.deadlineTs,
|
|
927
|
+
/* 会话复用三元组:
|
|
928
|
+
* session_key —— 逻辑会话 ID(provider 用它判断能否沿用当前网页会话)
|
|
929
|
+
* delta_message —— 只含「新增消息」的提示词
|
|
930
|
+
* full_message —— 完整提示词;provider 无法复用网页会话时改发它,
|
|
931
|
+
* 保证「增量协议」永远不会丢掉上下文 */
|
|
932
|
+
context: {
|
|
933
|
+
session_key: prepared.conversationId,
|
|
934
|
+
delta_message: prepared.deltaText,
|
|
935
|
+
full_message: prepared.messageText,
|
|
936
|
+
...extraContext,
|
|
937
|
+
},
|
|
938
|
+
};
|
|
939
|
+
}
|
|
940
|
+
/**
|
|
941
|
+
* 流式生成 SSE 文本(每个元素是一段可以直接写进连接的字符串)。
|
|
942
|
+
*
|
|
943
|
+
* 抽成生成器是为了能单独测试 —— 它不碰 `reply`。
|
|
944
|
+
*
|
|
945
|
+
* ⚠️ 三条不可违反的输出顺序:
|
|
946
|
+
* 1. 有工具调用时:`role` → `content` → `tool_calls` → `finish(tool_calls)` → `[DONE]`;
|
|
947
|
+
* 2. `finish_reason` **只能出现一次**;
|
|
948
|
+
* 3. `tool_calls` 必须在 `[DONE]` **之前**(客户端收到 `[DONE]` 就不再解析)。
|
|
949
|
+
*/
|
|
950
|
+
export async function* streamChatCompletion(prepared, modelRouter = getModelRouter(), historyDb = getHistoryDb()) {
|
|
951
|
+
const { requestId, request: rawRequest, conversationId, messageText, rawMessageText } = prepared;
|
|
952
|
+
const model = rawRequest.model;
|
|
953
|
+
const hasTools = Boolean(rawRequest.tools && rawRequest.tools.length > 0);
|
|
954
|
+
const toolMaps = createToolMaps((rawRequest.tools ?? []));
|
|
955
|
+
let fullResponse = '';
|
|
956
|
+
let currentPlatform = null;
|
|
957
|
+
let parsedText = '';
|
|
958
|
+
let parsedToolCalls = [];
|
|
959
|
+
let parseSource = '';
|
|
960
|
+
let parseFromSse = false;
|
|
961
|
+
const startTime = Date.now();
|
|
962
|
+
logRoundStart(requestId, 1, extractUserQuestion(rawRequest.messages));
|
|
963
|
+
try {
|
|
964
|
+
logAiRequest({
|
|
965
|
+
requestId,
|
|
966
|
+
platform: model,
|
|
967
|
+
messages: rawRequest.messages,
|
|
968
|
+
workspacePath: prepared.workspacePath,
|
|
969
|
+
});
|
|
970
|
+
const chatRequest = buildChatRequest(prepared);
|
|
971
|
+
// 有工具时**先缓冲完整回复**,解析出 tool_calls 后再发(保证在 [DONE] 前发 tool_calls 分片)
|
|
972
|
+
for await (const [chunk, platform] of streamWithFailover(modelRouter, model, chatRequest, historyDb, requestId)) {
|
|
973
|
+
currentPlatform = platform;
|
|
974
|
+
fullResponse += chunk.content ?? '';
|
|
975
|
+
}
|
|
976
|
+
if (hasTools) {
|
|
977
|
+
// 解析源:只要 SSE 回答非空就用它 —— 它是未经渲染的**最终回答**(type=text),
|
|
978
|
+
// 而 fullResponse 可能混入 DOM 里的**思考过程**(type=think)。
|
|
979
|
+
// ⚠️ 绝不能按长度取舍:曾出现 SSE 回答仅 77 字符、而 DOM 拼接出 527 字符思考文本,
|
|
980
|
+
// 于是错误地选用了思考文本。
|
|
981
|
+
parseSource = fullResponse;
|
|
982
|
+
parseFromSse = false;
|
|
983
|
+
const sseText = getLastRawSse().text;
|
|
984
|
+
if (sseText) {
|
|
985
|
+
parseSource = sseText;
|
|
986
|
+
parseFromSse = true;
|
|
987
|
+
logger.info(`[tool_calls] 使用 SSE 保真文本解析工具调用 ` +
|
|
988
|
+
`(SSE回答=${sseText.length} vs 拼接=${fullResponse.length})`);
|
|
989
|
+
}
|
|
990
|
+
const outcome = await parseAndRecoverToolCalls({
|
|
991
|
+
parseSource,
|
|
992
|
+
fullResponse,
|
|
993
|
+
fromSse: parseFromSse,
|
|
994
|
+
currentPlatform,
|
|
995
|
+
prepared,
|
|
996
|
+
modelRouter,
|
|
997
|
+
historyDb,
|
|
998
|
+
toolMaps,
|
|
999
|
+
options: { fixPaths: true, validateSearchReplace: true },
|
|
1000
|
+
});
|
|
1001
|
+
parsedText = outcome.parsedText;
|
|
1002
|
+
parsedToolCalls = outcome.parsedToolCalls;
|
|
1003
|
+
/* ===== 空响应检测 =====
|
|
1004
|
+
* 文本=0 且工具调用=0 时**不能静默结束**(旧实现只发一个空 delta):
|
|
1005
|
+
* 客户端收到空回复会表现为「AI 没反应」,用户无法区分「卡住」与「失败」。 */
|
|
1006
|
+
if (!parsedText && parsedToolCalls.length === 0 && !fullResponse) {
|
|
1007
|
+
logger.warn('[tool_calls] 检测到空响应(首轮无正文、兜底轮也没恢复出内容),向客户端说明原因后结束');
|
|
1008
|
+
yield contentChunk({
|
|
1009
|
+
id: requestId,
|
|
1010
|
+
model,
|
|
1011
|
+
role: 'assistant',
|
|
1012
|
+
// ⚠️ 与原生对齐:不写「[平台] …」这类内部前缀(平台名在日志里)。
|
|
1013
|
+
content: '本轮未产出任何可用内容(模型没有生成正文,也没有给出工具调用)。' +
|
|
1014
|
+
'请重试;若反复出现,把请求拆小一点。',
|
|
1015
|
+
});
|
|
1016
|
+
yield contentChunk({ id: requestId, model, content: '', finishReason: 'stop' });
|
|
1017
|
+
yield DONE;
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
1020
|
+
// 发送:role → content → tool_calls → finish
|
|
1021
|
+
if (parsedText || parsedToolCalls.length > 0) {
|
|
1022
|
+
yield roleChunk({ id: requestId, model });
|
|
1023
|
+
}
|
|
1024
|
+
if (parsedText) {
|
|
1025
|
+
yield contentChunk({ id: requestId, model, content: parsedText });
|
|
1026
|
+
}
|
|
1027
|
+
if (parsedToolCalls.length > 0) {
|
|
1028
|
+
// 所有 tool_calls 合成**一个** chunk(客户端只显示一次工具调用)
|
|
1029
|
+
yield toolCallsChunk({ id: requestId, model, toolCalls: parsedToolCalls });
|
|
1030
|
+
logger.info(`解析到 ${parsedToolCalls.length} 个工具调用`);
|
|
1031
|
+
}
|
|
1032
|
+
else if (!parsedText) {
|
|
1033
|
+
// 没有工具调用也没有解析文本 → 直接发原始文本
|
|
1034
|
+
yield roleChunk({ id: requestId, model });
|
|
1035
|
+
yield contentChunk({
|
|
1036
|
+
id: requestId,
|
|
1037
|
+
model,
|
|
1038
|
+
content: parseFromSse && parseSource ? parseSource : fullResponse,
|
|
1039
|
+
});
|
|
1040
|
+
}
|
|
1041
|
+
yield contentChunk({
|
|
1042
|
+
id: requestId,
|
|
1043
|
+
model,
|
|
1044
|
+
content: '',
|
|
1045
|
+
finishReason: parsedToolCalls.length > 0 ? 'tool_calls' : 'stop',
|
|
1046
|
+
});
|
|
1047
|
+
yield DONE;
|
|
1048
|
+
}
|
|
1049
|
+
else {
|
|
1050
|
+
// 无工具:正常逐 chunk 转发(延迟更低)
|
|
1051
|
+
for await (const [chunk, platform] of streamWithFailover(modelRouter, model, chatRequest, historyDb, requestId)) {
|
|
1052
|
+
currentPlatform = platform;
|
|
1053
|
+
fullResponse += chunk.content ?? '';
|
|
1054
|
+
yield contentChunk({ id: requestId, model, content: chunk.content ?? '' });
|
|
1055
|
+
}
|
|
1056
|
+
yield contentChunk({ id: requestId, model, content: '', finishReason: 'stop' });
|
|
1057
|
+
yield DONE;
|
|
1058
|
+
}
|
|
1059
|
+
/* ===== 记录历史 =====
|
|
1060
|
+
* 优先存 SSE 全文:`fullResponse` 是流式增量拼接的结果,
|
|
1061
|
+
* 模型中途改写回答时会把「旧草稿 + 最终答案」都留在里面,存进历史会污染后续轮次。 */
|
|
1062
|
+
if (conversationId && fullResponse) {
|
|
1063
|
+
const historyText = parseFromSse && parseSource ? parseSource : fullResponse;
|
|
1064
|
+
await historyDb.addMessage({
|
|
1065
|
+
conversationId,
|
|
1066
|
+
role: 'user',
|
|
1067
|
+
content: messageText,
|
|
1068
|
+
platform: currentPlatform,
|
|
1069
|
+
model,
|
|
1070
|
+
});
|
|
1071
|
+
await historyDb.addMessage({
|
|
1072
|
+
conversationId,
|
|
1073
|
+
role: 'assistant',
|
|
1074
|
+
content: historyText,
|
|
1075
|
+
platform: currentPlatform,
|
|
1076
|
+
model,
|
|
1077
|
+
});
|
|
1078
|
+
// ⚠️ 只在**发送成功**后记录(走到这里说明流已正常结束),下次同会话只补发增量
|
|
1079
|
+
if (currentPlatform) {
|
|
1080
|
+
rememberSessionSend(conversationId, rawRequest.messages, currentPlatform);
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
const finishReason = parsedToolCalls.length > 0 ? 'tool_calls' : 'stop';
|
|
1084
|
+
logger.info(`[${requestId}] ========== 响应完成 ==========`);
|
|
1085
|
+
logger.info(`[${requestId}] 平台: ${currentPlatform}, 完成原因: ${finishReason}, ` +
|
|
1086
|
+
`响应长度: ${fullResponse.length}, 工具调用数: ${parsedToolCalls.length}`);
|
|
1087
|
+
for (const tc of parsedToolCalls) {
|
|
1088
|
+
logger.info(`[${requestId}] 工具: ${tc.function.name}`);
|
|
1089
|
+
}
|
|
1090
|
+
/* ===== 后台预压缩 =====
|
|
1091
|
+
* 此刻客户端正在执行工具(读文件通常要几秒到几十秒),正好拿这段空档把本轮历史
|
|
1092
|
+
* 折进摘要缓存,让下一轮的压缩耗时降为 0(原本要 25~117s)。 */
|
|
1093
|
+
schedulePrecompress(rawMessageText, conversationId, modelRouter, model, historyDb, requestId);
|
|
1094
|
+
const duration = (Date.now() - startTime) / 1000;
|
|
1095
|
+
logClientResponse({
|
|
1096
|
+
requestId,
|
|
1097
|
+
model: currentPlatform ?? model,
|
|
1098
|
+
finishReason,
|
|
1099
|
+
content: hasTools ? parsedText : fullResponse,
|
|
1100
|
+
toolCalls: hasTools ? parsedToolCalls : undefined,
|
|
1101
|
+
duration,
|
|
1102
|
+
});
|
|
1103
|
+
logApiResponse({
|
|
1104
|
+
requestId,
|
|
1105
|
+
model: currentPlatform ?? model,
|
|
1106
|
+
content: hasTools ? parsedText : fullResponse,
|
|
1107
|
+
toolCalls: hasTools ? parsedToolCalls : undefined,
|
|
1108
|
+
finishReason,
|
|
1109
|
+
duration,
|
|
1110
|
+
});
|
|
1111
|
+
}
|
|
1112
|
+
catch (error) {
|
|
1113
|
+
const message = errorMessage(error);
|
|
1114
|
+
logger.error(`流式响应失败: ${message}`);
|
|
1115
|
+
debugLogError(requestId, message, `平台=${currentPlatform}, 已收到=${fullResponse.length}字符`);
|
|
1116
|
+
logApiError(requestId, message, `平台=${currentPlatform}, 已收到=${fullResponse.length}字符`);
|
|
1117
|
+
yield contentChunk({ id: requestId, model, content: `[ERROR] ${message}`, finishReason: 'stop' });
|
|
1118
|
+
yield DONE;
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
/* ==================================================================== */
|
|
1122
|
+
/* 阶段 4:非流式分支 */
|
|
1123
|
+
/* ==================================================================== */
|
|
1124
|
+
/**
|
|
1125
|
+
* 非流式聊天。
|
|
1126
|
+
*
|
|
1127
|
+
* ⚠️ 与流式分支的**故意保留**的差异(Python 原样如此,移植时未改动):
|
|
1128
|
+
* - 非流式**不做**路径纠正、**不做** SearchReplace 校验、**不做**自驱动续跑。
|
|
1129
|
+
* 流式分支才跑那三步。若将来要统一,请注意这属于行为变更。
|
|
1130
|
+
*/
|
|
1131
|
+
export async function nonStreamChatCompletion(prepared, modelRouter = getModelRouter(), historyDb = getHistoryDb()) {
|
|
1132
|
+
const { requestId, request: rawRequest, conversationId, messageText, rawMessageText } = prepared;
|
|
1133
|
+
const model = rawRequest.model;
|
|
1134
|
+
const toolMaps = createToolMaps((rawRequest.tools ?? []));
|
|
1135
|
+
const requestPool = getRequestPool();
|
|
1136
|
+
const chatRequest = buildChatRequest(prepared);
|
|
1137
|
+
await requestPool.addRequest({
|
|
1138
|
+
requestId,
|
|
1139
|
+
platform: model,
|
|
1140
|
+
model,
|
|
1141
|
+
message: chatRequest.message,
|
|
1142
|
+
conversationId,
|
|
1143
|
+
});
|
|
1144
|
+
let currentPlatform = null;
|
|
1145
|
+
try {
|
|
1146
|
+
logRoundStart(requestId, 1, extractUserQuestion(rawRequest.messages));
|
|
1147
|
+
let fullResponse = '';
|
|
1148
|
+
logger.info(`[${requestId}] 开始调用 _stream_with_failover (非流式), model=${model}`);
|
|
1149
|
+
for await (const [chunk, platform] of streamWithFailover(modelRouter, model, chatRequest, historyDb, requestId)) {
|
|
1150
|
+
currentPlatform = platform;
|
|
1151
|
+
fullResponse += chunk.content ?? '';
|
|
1152
|
+
}
|
|
1153
|
+
logger.info(`[${requestId}] _stream_with_failover 结束,full_response_len=${fullResponse.length}`);
|
|
1154
|
+
await requestPool.markCompleted(requestId, fullResponse);
|
|
1155
|
+
if (conversationId && fullResponse) {
|
|
1156
|
+
await historyDb.addMessage({
|
|
1157
|
+
conversationId,
|
|
1158
|
+
role: 'user',
|
|
1159
|
+
content: messageText,
|
|
1160
|
+
platform: currentPlatform,
|
|
1161
|
+
model,
|
|
1162
|
+
});
|
|
1163
|
+
await historyDb.addMessage({
|
|
1164
|
+
conversationId,
|
|
1165
|
+
role: 'assistant',
|
|
1166
|
+
content: fullResponse,
|
|
1167
|
+
platform: currentPlatform,
|
|
1168
|
+
model,
|
|
1169
|
+
});
|
|
1170
|
+
if (currentPlatform) {
|
|
1171
|
+
rememberSessionSend(conversationId, rawRequest.messages, currentPlatform);
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
// 解析源:优先 SSE 保真文本
|
|
1175
|
+
let parseSource = fullResponse;
|
|
1176
|
+
let parseFromSse = false;
|
|
1177
|
+
const sseText = getLastRawSse().text;
|
|
1178
|
+
if (sseText) {
|
|
1179
|
+
parseSource = sseText;
|
|
1180
|
+
parseFromSse = true;
|
|
1181
|
+
logger.info(`[tool_calls] 使用 SSE 保真文本解析工具调用 ` +
|
|
1182
|
+
`(SSE回答=${sseText.length} vs 拼接=${fullResponse.length})`);
|
|
1183
|
+
}
|
|
1184
|
+
const outcome = await parseAndRecoverToolCalls({
|
|
1185
|
+
parseSource,
|
|
1186
|
+
fullResponse,
|
|
1187
|
+
fromSse: parseFromSse,
|
|
1188
|
+
currentPlatform,
|
|
1189
|
+
prepared,
|
|
1190
|
+
modelRouter,
|
|
1191
|
+
historyDb,
|
|
1192
|
+
toolMaps,
|
|
1193
|
+
options: { fixPaths: false, validateSearchReplace: false },
|
|
1194
|
+
});
|
|
1195
|
+
const responseMessage = { role: 'assistant' };
|
|
1196
|
+
if (outcome.parsedText) {
|
|
1197
|
+
responseMessage['content'] = outcome.parsedText;
|
|
1198
|
+
}
|
|
1199
|
+
else if (outcome.parsedToolCalls.length === 0) {
|
|
1200
|
+
// 同样优先 SSE 全文(fullResponse 可能是改写草稿的拼接乱码)
|
|
1201
|
+
responseMessage['content'] = parseFromSse && parseSource ? parseSource : fullResponse;
|
|
1202
|
+
}
|
|
1203
|
+
if (outcome.parsedToolCalls.length > 0) {
|
|
1204
|
+
responseMessage['tool_calls'] = outcome.parsedToolCalls;
|
|
1205
|
+
}
|
|
1206
|
+
logApiResponse({
|
|
1207
|
+
requestId,
|
|
1208
|
+
model,
|
|
1209
|
+
content: responseMessage['content'] ?? null,
|
|
1210
|
+
toolCalls: responseMessage['tool_calls'] ?? null,
|
|
1211
|
+
finishReason: outcome.finishReason,
|
|
1212
|
+
});
|
|
1213
|
+
logAiPlatformResponseComplete({
|
|
1214
|
+
requestId,
|
|
1215
|
+
platform: currentPlatform ?? 'unknown',
|
|
1216
|
+
roundNum: 1,
|
|
1217
|
+
rawText: fullResponse,
|
|
1218
|
+
});
|
|
1219
|
+
schedulePrecompress(rawMessageText, conversationId, modelRouter, model, historyDb, requestId);
|
|
1220
|
+
return {
|
|
1221
|
+
id: requestId,
|
|
1222
|
+
object: 'chat.completion',
|
|
1223
|
+
created: Math.floor(Date.now() / 1000),
|
|
1224
|
+
model,
|
|
1225
|
+
choices: [
|
|
1226
|
+
{
|
|
1227
|
+
index: 0,
|
|
1228
|
+
message: responseMessage,
|
|
1229
|
+
finish_reason: outcome.finishReason,
|
|
1230
|
+
logprobs: null,
|
|
1231
|
+
},
|
|
1232
|
+
],
|
|
1233
|
+
usage: {
|
|
1234
|
+
prompt_tokens: 0,
|
|
1235
|
+
completion_tokens: 0,
|
|
1236
|
+
total_tokens: 0,
|
|
1237
|
+
},
|
|
1238
|
+
};
|
|
1239
|
+
}
|
|
1240
|
+
catch (error) {
|
|
1241
|
+
const message = errorMessage(error);
|
|
1242
|
+
await requestPool.markFailed(requestId, message);
|
|
1243
|
+
logApiError(requestId, message, `模型=${model}, 平台=${currentPlatform}`);
|
|
1244
|
+
throw error;
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
/* ==================================================================== */
|
|
1248
|
+
/* 路由注册 */
|
|
1249
|
+
/* ==================================================================== */
|
|
1250
|
+
/**
|
|
1251
|
+
* 注册 `/chat/completions`。
|
|
1252
|
+
*
|
|
1253
|
+
* 参数校验用 zod(等价于 FastAPI 的 pydantic 校验);失败返回 422 与
|
|
1254
|
+
* 与 Python 一致的错误体形状。
|
|
1255
|
+
*/
|
|
1256
|
+
export function registerChatRoutes(app) {
|
|
1257
|
+
app.post('/chat/completions', async (request, reply) => {
|
|
1258
|
+
/* ===== 参数校验 =====
|
|
1259
|
+
* 用 `safeParse` 而不是 Fastify 的 schema:需要复刻 Python 的错误体形状
|
|
1260
|
+
* (`{error: {message, type, code, param}}`,`param` 取出错字段名)。 */
|
|
1261
|
+
const { chatCompletionRequestSchema } = await import('../types/openai.js');
|
|
1262
|
+
const parsed = chatCompletionRequestSchema.safeParse(request.body ?? {});
|
|
1263
|
+
if (!parsed.success) {
|
|
1264
|
+
const first = parsed.error.issues[0];
|
|
1265
|
+
return reply.code(422).send({
|
|
1266
|
+
error: {
|
|
1267
|
+
message: `请求参数错误: ${first?.message ?? '未知错误'}`,
|
|
1268
|
+
type: 'invalid_request_error',
|
|
1269
|
+
code: null,
|
|
1270
|
+
param: String(first?.path?.[first.path.length - 1] ?? ''),
|
|
1271
|
+
},
|
|
1272
|
+
});
|
|
1273
|
+
}
|
|
1274
|
+
const body = parsed.data;
|
|
1275
|
+
try {
|
|
1276
|
+
const result = await preparePrompt(body, request);
|
|
1277
|
+
// ===== 轮数上限截停 =====
|
|
1278
|
+
if (result.kind === 'limit_exceeded') {
|
|
1279
|
+
if (body.stream) {
|
|
1280
|
+
reply.raw.writeHead(200, {
|
|
1281
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
1282
|
+
'cache-control': 'no-cache',
|
|
1283
|
+
connection: 'keep-alive',
|
|
1284
|
+
'x-accel-buffering': 'no',
|
|
1285
|
+
});
|
|
1286
|
+
reply.hijack();
|
|
1287
|
+
reply.raw.write(contentChunk({ id: 'limit', model: body.model, role: 'assistant', content: result.message }));
|
|
1288
|
+
reply.raw.write(DONE);
|
|
1289
|
+
reply.raw.end();
|
|
1290
|
+
return reply;
|
|
1291
|
+
}
|
|
1292
|
+
return reply.code(200).send({
|
|
1293
|
+
id: 'limit',
|
|
1294
|
+
object: 'chat.completion',
|
|
1295
|
+
created: Math.floor(Date.now() / 1000),
|
|
1296
|
+
model: body.model,
|
|
1297
|
+
choices: [
|
|
1298
|
+
{
|
|
1299
|
+
index: 0,
|
|
1300
|
+
message: { role: 'assistant', content: result.message },
|
|
1301
|
+
finish_reason: 'stop',
|
|
1302
|
+
},
|
|
1303
|
+
],
|
|
1304
|
+
});
|
|
1305
|
+
}
|
|
1306
|
+
const prepared = result.prepared;
|
|
1307
|
+
// ===== 流式 =====
|
|
1308
|
+
if (body.stream) {
|
|
1309
|
+
reply.raw.writeHead(200, {
|
|
1310
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
1311
|
+
'cache-control': 'no-cache',
|
|
1312
|
+
connection: 'keep-alive',
|
|
1313
|
+
// 反向代理(nginx)默认会缓冲响应,SSE 必须显式关闭缓冲
|
|
1314
|
+
'x-accel-buffering': 'no',
|
|
1315
|
+
});
|
|
1316
|
+
reply.hijack();
|
|
1317
|
+
try {
|
|
1318
|
+
for await (const piece of streamChatCompletion(prepared)) {
|
|
1319
|
+
if (reply.raw.writableEnded)
|
|
1320
|
+
break;
|
|
1321
|
+
reply.raw.write(piece);
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
catch (error) {
|
|
1325
|
+
logger.error(`[${prepared.requestId}] SSE 写出失败: ${errorMessage(error)}`);
|
|
1326
|
+
}
|
|
1327
|
+
finally {
|
|
1328
|
+
if (!reply.raw.writableEnded)
|
|
1329
|
+
reply.raw.end();
|
|
1330
|
+
}
|
|
1331
|
+
return reply;
|
|
1332
|
+
}
|
|
1333
|
+
// ===== 非流式 =====
|
|
1334
|
+
const response = await nonStreamChatCompletion(prepared);
|
|
1335
|
+
return reply.code(200).send(response);
|
|
1336
|
+
}
|
|
1337
|
+
catch (error) {
|
|
1338
|
+
const message = errorMessage(error);
|
|
1339
|
+
logger.error(`聊天完成失败: ${message}`);
|
|
1340
|
+
debugLogError('unknown', message, `模型=${body.model}, 消息数=${body.messages.length}`);
|
|
1341
|
+
logApiError('unknown', message, `模型=${body.model}, 消息数=${body.messages.length}`);
|
|
1342
|
+
return reply.code(500).send({ error: { message, type: 'server_error', code: 'internal_error' } });
|
|
1343
|
+
}
|
|
1344
|
+
});
|
|
1345
|
+
}
|
|
1346
|
+
//# sourceMappingURL=chat.js.map
|