zen-gitsync 2.17.46 → 2.17.48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.md +16 -2
  2. package/package.json +2 -1
  3. package/src/cli/ai/agent.js +1342 -1342
  4. package/src/cli/ai/context.js +431 -253
  5. package/src/cli/ai/context.test.js +376 -258
  6. package/src/cli/ai/mcp.js +323 -27
  7. package/src/cli/ai/mcp.test.js +202 -2
  8. package/src/cli/ai/runtime.test.js +7 -4
  9. package/src/cli/ai/turn.js +166 -166
  10. package/src/config.js +906 -871
  11. package/src/ui/public/assets/{AgentEngineSelector-D6rlSfMD.js → AgentEngineSelector-B829U5mr.js} +1 -1
  12. package/src/ui/public/assets/AgentView-r3_ClUmr.js +1 -0
  13. package/src/ui/public/assets/{AgentView-h2YnbB7J.css → AgentView-uHf-KMCH.css} +1 -1
  14. package/src/ui/public/assets/{AppVersionBadge-BnPsn1X5.js → AppVersionBadge-NtghusOI.js} +2 -2
  15. package/src/ui/public/assets/{BranchSelector-D30GJwUl.js → BranchSelector-D0claXAs.js} +1 -1
  16. package/src/ui/public/assets/{CommitForm-Dvg-RUZI.js → CommitForm-BIq6-sqk.js} +1 -1
  17. package/src/ui/public/assets/{CommonDialog-BzLau2RJ.js → CommonDialog-NqZURH2F.js} +1 -1
  18. package/src/ui/public/assets/EditorView-0T5LR5Hz.css +1 -0
  19. package/src/ui/public/assets/EditorView-Cny0lDhG.js +1 -0
  20. package/src/ui/public/assets/{FlowExecutionViewer-VL-rUElj.js → FlowExecutionViewer-DxDKzmaP.js} +1 -1
  21. package/src/ui/public/assets/{FlowOrchestrationWorkspace-BPNlHRVu.js → FlowOrchestrationWorkspace-B3CAJZ-U.js} +1 -1
  22. package/src/ui/public/assets/{LogList-COwGjQl6.js → LogList-D5sS4Il3.js} +1 -1
  23. package/src/ui/public/assets/{MindmapView-BVuqPD_H.js → MindmapView-6bc0EAJM.js} +1 -1
  24. package/src/ui/public/assets/{MonitorView-hqe-4xd0.js → MonitorView-BnUV4RaV.js} +1 -1
  25. package/src/ui/public/assets/{ProjectStartupButton-RBc-DJBR.js → ProjectStartupButton-Cbw079DB.js} +1 -1
  26. package/src/ui/public/assets/RecentDirectoriesChat-BCZsJR7n.js +1 -0
  27. package/src/ui/public/assets/RecentDirectoriesChat-THkqWEtO.css +1 -0
  28. package/src/ui/public/assets/{RemoteManagerDialog-D6Rbjchl.js → RemoteManagerDialog-BWWPEnSd.js} +1 -1
  29. package/src/ui/public/assets/{RemoteRepoCard-l0NpXgvB.js → RemoteRepoCard-BhA3-4PZ.js} +1 -1
  30. package/src/ui/public/assets/{SourceMapView-CeVystt0.js → SourceMapView-DHDmNzSh.js} +1 -1
  31. package/src/ui/public/assets/{SvgIcon-B-xDJQA1.js → SvgIcon-DcVasCdo.js} +1 -1
  32. package/src/ui/public/assets/{UserInputNode-DJglU68l.js → UserInputNode-B313MadX.js} +1 -1
  33. package/src/ui/public/assets/WorkbenchView-NCE_bk5a.js +20 -0
  34. package/src/ui/public/assets/WorkbenchView-rscs76HF.css +1 -0
  35. package/src/ui/public/assets/_plugin-vue_export-helper-Bsx6Zj3t.js +19 -0
  36. package/src/ui/public/assets/agentConversations-BAhGYmeY.js +8 -0
  37. package/src/ui/public/assets/{configStore-CMlW8sMa.js → configStore-DQSKV2_Y.js} +1 -1
  38. package/src/ui/public/assets/{dagre-DyI0XLXY.js → dagre-BmYeUlEa.js} +3 -3
  39. package/src/ui/public/assets/{element-plus-DrCk0pcV.js → element-plus-DTtm3XCd.js} +1 -1
  40. package/src/ui/public/assets/{flow-mindmap-Cx5RNYN9.js → flow-mindmap-JxOspgwx.js} +1 -1
  41. package/src/ui/public/assets/{index-u9LyUFkr.css → index-BgN6Z2X9.css} +1 -1
  42. package/src/ui/public/assets/{index-BQxIFopZ.js → index-DQuqmPuL.js} +9 -9
  43. package/src/ui/public/assets/{monaco-Dfcwm1aX.js → monaco-D3R9OM26.js} +1 -1
  44. package/src/ui/public/assets/{office-docx-C0unxEAm.js → office-docx-CO7mIaWO.js} +1 -1
  45. package/src/ui/public/assets/{office-excel-DPdyr_DJ.js → office-excel-gA4KEkK4.js} +1 -1
  46. package/src/ui/public/assets/{office-pptx-eojGAs9D.js → office-pptx-CQK4l_1Q.js} +1 -1
  47. package/src/ui/public/assets/{vendor-yntih_em.js → vendor-O17eRefg.js} +222 -222
  48. package/src/ui/public/assets/vendor-U3ZXMgeO.css +1 -0
  49. package/src/ui/public/assets/{vue-flow-De8mr_Fv.js → vue-flow-BG6ZF1It.js} +1 -1
  50. package/src/ui/public/index.html +13 -13
  51. package/src/ui/server/routes/config.js +1321 -1314
  52. package/src/ui/server/routes/workbench/agentChat.js +594 -571
  53. package/src/ui/server/routes/workbench/agentChatShared.test.js +302 -233
  54. package/src/ui/server/routes/workbench/agentMarketplace.js +65 -30
  55. package/src/ui/server/routes/workbench/agentMarketplace.test.js +28 -4
  56. package/src/ui/server/routes/workbench/agentRoutes.js +631 -631
  57. package/src/ui/server/routes/workbench/liveJobs.js +4 -0
  58. package/src/ui/server/routes/workbench/liveJobs.test.js +11 -0
  59. package/src/ui/server/routes/workbench/taskRunner.js +44 -7
  60. package/src/ui/server/routes/workbench/taskRunner.thinking.test.js +115 -0
  61. package/src/ui/public/assets/AgentView-BFRGoIVb.js +0 -1
  62. package/src/ui/public/assets/EditorView-8Rb4n-Wh.css +0 -1
  63. package/src/ui/public/assets/EditorView-D4ZQutRw.js +0 -1
  64. package/src/ui/public/assets/RecentDirectoriesChat-BxbpKrXc.css +0 -1
  65. package/src/ui/public/assets/RecentDirectoriesChat-DFljcYFH.js +0 -1
  66. package/src/ui/public/assets/WorkbenchView-BSQ87CDi.css +0 -1
  67. package/src/ui/public/assets/WorkbenchView-BpH5kNht.js +0 -20
  68. package/src/ui/public/assets/_plugin-vue_export-helper-Dz1ARW9a.js +0 -19
  69. package/src/ui/public/assets/agentConversations-B8nY45V4.js +0 -7
  70. package/src/ui/public/assets/vendor-CP-WuGZG.css +0 -1
@@ -1,631 +1,631 @@
1
- // Copyright 2026 xz333221
2
- //
3
- // Licensed under the Apache License, Version 2.0 (the "License");
4
- // you may not use this file except in compliance with the License.
5
- // You may obtain a copy of the License at
6
- //
7
- // http://www.apache.org/licenses/LICENSE-2.0
8
- //
9
- // Unless required by applicable law or agreed to in writing, software
10
- // distributed under the License is distributed on an "AS IS" BASIS,
11
- // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
- // See the License for the specific language governing permissions and
13
- // limitations under the License.
14
- //
15
- // 智能体路由入口。
16
- //
17
- // 路由清单:
18
- // GET /api/agent/sessions — 列出会话(仅 metadata;?cwd= 按项目过滤,空 cwd 视为当前项目)
19
- // GET /api/agent/sessions/:id — 获取会话详情(含完整消息)
20
- // DELETE /api/agent/sessions/:id — 删除会话
21
- // PUT /api/agent/sessions/:id — 重命名会话
22
- // POST /api/agent/chat — SSE 流式聊天(含工具调用;body.cwd 必须与服务端当前项目一致)
23
-
24
- import path from 'node:path';
25
- import { asyncRoute, HttpError } from '../../utils/asyncRoute.js';
26
- import { agentSessionStore } from './agentSessionStore.js';
27
- import { runAgentTurn } from './agentChat.js';
28
- import { runExternalTurn } from './runExternalTurn.js';
29
- import { normalizeAgentEngine, isExternalEngine, engineLabel } from './agentEngines.js';
30
- import { saveAgentAttachments, MAX_ATTACHMENTS } from '../../utils/agentAttachments.js';
31
- import { registerAgentMarketplaceRoutes } from './agentMarketplace.js';
32
- import { createProjectListProvider } from './projectTool.js';
33
- import { createDispatcher } from './dispatchInstruction.js';
34
- import { createWorkspaceSnapshotter } from '../aiContext/wiring.js';
35
- import { normalizeItems, filterToRecentDirs, buildDirStatusBlock } from '../recentDirectoriesAiSummary.js';
36
- import { nowIso, logger } from './shared.js';
37
- // 请求预算的解析公式与 CLI `g ai` 共用同一份(cli/ai/context.js),Web 侧只管把
38
- // 配置值喂进去、把结果传给 agentChat —— 2026-10-07 起每轮请求的字符预算可配。
39
- import { resolveRequestBudget } from '../../../../cli/ai/context.js';
40
-
41
- const { genSessionId, autoTitle, read: readSession, write: writeSession, delete: deleteSession, listMeta: listSessionsMeta, enforceRetention, rename: renameSession } = agentSessionStore;
42
-
43
- // 项目路径规范化:Windows 路径大小写不敏感,POSIX 路径保留大小写语义。
44
- function normalizeCwd(p) {
45
- const value = String(p || '').trim();
46
- if (!value) return '';
47
- const normalized = path.resolve(value).replace(/\\/g, '/').replace(/\/+$/, '');
48
- return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
49
- }
50
-
51
- // 历史存量会话没有 cwd,暂时沿用升级前的可见性,避免升级后无法在 UI 中找回。
52
- function sessionBelongsTo(sessionCwd, projectCwd) {
53
- const s = normalizeCwd(sessionCwd);
54
- const project = normalizeCwd(projectCwd);
55
- if (!s) return true;
56
- return Boolean(project && s === project);
57
- }
58
-
59
- // An ask_user call keeps its chat SSE request open until this map receives the
60
- // matching response. The entry is removed on every completion or disconnect.
61
- const pendingAgentQuestions = new Map();
62
-
63
- /** 正在进行中的会话 id 集合:同一会话同一时刻只允许一个生成中的轮次 */
64
- const activeSessionTurns = new Set();
65
-
66
- function interactionKey(sessionId, interactionId) {
67
- return `${String(sessionId)}:${String(interactionId)}`;
68
- }
69
-
70
- export function waitForAgentAnswer({ sessionId, interactionId, question, options, allowFreeText, multiple, send, signal }) {
71
- const key = interactionKey(sessionId, interactionId);
72
- return new Promise((resolve) => {
73
- let settled = false;
74
- const cleanup = () => {
75
- pendingAgentQuestions.delete(key);
76
- signal?.removeEventListener?.('abort', onAbort);
77
- };
78
- const settle = (answer) => {
79
- if (settled) return;
80
- settled = true;
81
- cleanup();
82
- resolve(answer);
83
- };
84
- const onAbort = () => settle('Cancelled by user; the question was not answered.');
85
-
86
- pendingAgentQuestions.set(key, {
87
- question,
88
- options,
89
- allowFreeText,
90
- multiple: multiple === true,
91
- settle,
92
- });
93
- signal?.addEventListener?.('abort', onAbort, { once: true });
94
- send({ type: 'ask_user', interactionId, question, options, allowFreeText, multiple: multiple === true });
95
- if (signal?.aborted) onAbort();
96
- });
97
- }
98
-
99
- export function submitAgentAnswer({ sessionId, interactionId, answer }) {
100
- const key = interactionKey(sessionId, interactionId);
101
- const pending = pendingAgentQuestions.get(key);
102
- if (!pending) return { ok: false, status: 404, error: 'No pending question for this session.' };
103
-
104
- // 单选发字符串、多选发数组;这里统一归一成"非空的字符串列表"
105
- const values = (Array.isArray(answer) ? answer : [answer])
106
- .map((v) => String(v ?? '').trim())
107
- .filter(Boolean);
108
- if (!values.length) return { ok: false, status: 400, error: 'Answer cannot be empty.' };
109
- if (!pending.allowFreeText && pending.options.length > 0) {
110
- const unknown = values.filter((v) => !pending.options.includes(v));
111
- if (unknown.length) return { ok: false, status: 400, error: 'Choose one of the listed options.' };
112
- }
113
- // 多选回给模型的是 JSON 数组字符串(单选/自由输入保持原样字符串)
114
- pending.settle(pending.multiple ? JSON.stringify(values) : values[0]);
115
- return { ok: true };
116
- }
117
-
118
- /**
119
- * 注册智能体路由。
120
- * @param {Object} deps
121
- * @param {import('express').Express} deps.app
122
- * @param {() => string} deps.getCurrentProjectPath
123
- * @param {Object} deps.configManager
124
- */
125
- /**
126
- * 落点依据 → 一句话。给**模型**看的那份(前端 OrchesratorConsole 的 targetReason 是
127
- * 界面用的,走 i18n 且只有中英两版)。两边都要有,因为读者不同:模型读的是工具结果,
128
- * 用户读的是活动流。文案不共用,判断依据是同一组 source 取值(targetResolver 定义)。
129
- */
130
- function targetSourceLabel(source) {
131
- switch (source) {
132
- case 'explicit': return '派发时显式指定了项目';
133
- case 'mention': return '指令里点明了这个项目';
134
- case 'agent': return '按指令语义判断';
135
- case 'default': return '没识别出项目,用了默认项目';
136
- default: return '未知';
137
- }
138
- }
139
-
140
- /**
141
- * dispatch_task 工具的实现:补上控制台当前的选择 → 交给共用派发器 → 压成一行
142
- * **给模型看**的字符串。
143
- *
144
- * 为什么结果要写得这么具体:它会作为工具结果进入会话历史,之后每一轮模型都读得到。
145
- * 只说"成功"的话,模型不知道任务落在哪个项目、有没有真跑起来,于是要么重复派发,
146
- * 要么向用户描述成"我已经在本地跑完了"(实际是另一个进程在跑)。所以三件事必须写清:
147
- * 落到哪个项目、有没有真的开始执行、凭什么这么落点。
148
- *
149
- * 失败一律转成字符串(工具的契约就是"结果永远是字符串,不向外抛"):
150
- * HttpError 的 message 本来就是给人看的中文("项目目录不存在:xxx"),直接透出。
151
- *
152
- * 导出只为单测(agentRoutes.test.js 钉住这几句文案与透传口径)——
153
- * 与 waitForAgentAnswer / submitAgentAnswer 同一取舍。
154
- */
155
- export async function runDispatchTask({ dispatch, payload, defaults }) {
156
- try {
157
- const result = await dispatch({
158
- text: payload.text,
159
- projectPath: payload.projectPath,
160
- autoRun: payload.autoRun,
161
- // 工具没显式表态时跟随控制台的勾选;显式 false 才关掉(tools.js 已保证)
162
- useDefaultPrompt: payload.useDefaultPrompt === undefined
163
- ? defaults.useDefaultPrompt
164
- : payload.useDefaultPrompt,
165
- executor: payload.executor || defaults.executor,
166
- });
167
-
168
- const project = result.target?.name || result.target?.path?.split(/[\\/]/).filter(Boolean).pop() || '';
169
- const head = `已派发:任务「${result.task.title}」→ 项目「${project}」`;
170
- const facts = `taskId=${result.task.id};落点依据:${targetSourceLabel(result.target?.source)}`;
171
-
172
- if (!result.ran) {
173
- return `${head},但**没有开始执行**(${result.instruction?.reason || '未开启自动执行'})。${facts}。`
174
- + '它在看板的「待处理」列里,等用户自己点执行。'
175
- }
176
- return `${head},已交给 ${engineLabel(result.executor)} 开始执行。${facts}。`
177
- + '它在独立进程里跑、不占用本次对话,跑完会有任务完成提示;'
178
- + '不要等它结束,也不要为同一件事重复派发。'
179
- } catch (err) {
180
- return `派发失败:${err?.message || String(err)}`
181
- }
182
- }
183
-
184
- export function registerAgentRoutes({ app, getCurrentProjectPath, configManager, snapshotter }) {
185
-
186
- registerAgentMarketplaceRoutes({ app, getCurrentProjectPath });
187
-
188
- // list_projects 的数据源。在这里建一次、复用:它要读最近目录(配置)、tasks.json
189
- // 与看板统计,这些依赖只有本层拿得到 —— 与 taskRunner 的 setEnvContextProvider 同理。
190
- // 不给它加缓存:agent 一轮里最多问一两次,而"项目状态"本来就要现读才准。
191
- const listProjects = createProjectListProvider({ configManager, getCurrentProjectPath });
192
-
193
- // dispatch_task 的实现 —— 与 POST /api/workbench/orchestrator/dispatch 共用**同一份**
194
- // 派发实现(见 dispatchInstruction.js 的文件头):落点怎么判、预设提示词附加哪一级、
195
- // 指令流水记什么、执行器怎么回落,只有一套。这里只是把同一套依赖再绑一次
196
- // (派发器无状态,两个实例等价)。
197
- const { dispatchInstruction } = createDispatcher({ configManager, getCurrentProjectPath });
198
-
199
- // 工作区状态快照(七个板块的摘要 + 落盘文件路径)。同样在这里建一次、复用 ——
200
- // 它要读配置、tasks.json、执行记录、git 状态、监控与思维导图,这些依赖本层一并拿得最全。
201
- //
202
- // `snapshotter` 是个**只为测试留的注入口**(与 remoteRepos 的 *Impl 同一套做法):
203
- // 生产不传,走真实实现;单测传一个空壳,免得跑一次对话就在测试机上 spawn `gh`
204
- // 和 PowerShell、还去连一次网。默认值写成参数默认值,不传时才构造。
205
- const workspaceSnapshotter = snapshotter || createWorkspaceSnapshotter({ configManager, getCurrentProjectPath });
206
- const getContextBlock = ({ locale } = {}) => workspaceSnapshotter.getBlock({ locale });
207
-
208
- // ════════════════════════════════════════════════════════════════════════
209
- // §1. 会话列表
210
- // ════════════════════════════════════════════════════════════════════════
211
- /**
212
- * 预热工作区状态快照 —— 这是"初始加载"该做的事,但**不是**把七个界面板块都挂起来。
213
- *
214
- * 用户打开智能体页时前端会拉这个会话列表,此刻就顺手在后台把快照生成好;
215
- * 等他打完字发出去,快照早已就绪,那一轮就能带上完整上下文。
216
- * 刻意不 await:它要跑 gh/gitee/git/PowerShell,等它会让会话列表白屏几秒。
217
- * 也刻意不放在 registerAgentRoutes 里:启动时预热会让"打开服务"这条路径凭空多几次
218
- * 子进程与网络调用,而用户可能压根不会打开智能体页。
219
- */
220
- function warmSnapshotInBackground() {
221
- try {
222
- if (typeof workspaceSnapshotter?.warm === 'function') workspaceSnapshotter.warm().catch(() => {});
223
- } catch (err) {
224
- logger.warn(`[agentRoutes] 预热工作区快照失败: ${err?.message || err}`);
225
- }
226
- }
227
-
228
- /**
229
- * POST /api/ai-context/refresh body: { sections?: string[], force?: boolean }
230
- *
231
- * 给前端"切到某个面板就更新对应板块"用的定向刷新口。**按板块**而不是全量:
232
- * 七个板块的取数成本差两个数量级,切个 Git 面板没必要顺带联网拉一遍仓库列表。
233
- *
234
- * 前端一律 fire-and-forget 调它(不等响应) —— 一次全量要 6~7 秒,没有哪个 UI 该等它。
235
- * 真正拖慢也不怕:force 仍受各板块自己的 forceTtlMs 地板限制,联网板块最多 60s 一次。
236
- *
237
- * 顺带把各板块状态回给调用方(采集时间/是否新鲜),排查"为什么模型看到的是旧的"时很好用。
238
- */
239
- app.post('/api/ai-context/refresh', asyncRoute(async (req, res) => {
240
- const body = req.body || {};
241
- const ids = Array.isArray(body.sections) ? body.sections.map(String) : undefined;
242
- const force = body.force === true;
243
- const state = await workspaceSnapshotter.refreshSections(ids, { force });
244
- res.json({ success: true, ...state });
245
- }));
246
-
247
- /** GET 版本:只读回各板块状态,不触发任何取数(给未来做面板用) */
248
- app.get('/api/ai-context/state', asyncRoute(async (_req, res) => {
249
- res.json({ success: true, ...workspaceSnapshotter.getState() });
250
- }));
251
-
252
- app.get('/api/agent/sessions', asyncRoute(async (req, res) => {
253
- warmSnapshotInBackground();
254
- let sessions = await listSessionsMeta();
255
- // 按项目隔离:前端传当前项目路径时只返回该项目的会话
256
- const cwdFilter = String(req.query?.cwd || '').trim();
257
- if (cwdFilter) {
258
- sessions = sessions.filter(s => sessionBelongsTo(s.cwd, cwdFilter));
259
- }
260
- res.json({ success: true, sessions });
261
- }));
262
-
263
- // ════════════════════════════════════════════════════════════════════════
264
- // §2. 会话详情
265
- // ════════════════════════════════════════════════════════════════════════
266
- app.get('/api/agent/sessions/:sessionId', asyncRoute(async (req, res) => {
267
- const session = await readSession(req.params.sessionId);
268
- res.json({ success: true, session });
269
- }));
270
-
271
- // ════════════════════════════════════════════════════════════════════════
272
- // §3. 删除会话
273
- // ════════════════════════════════════════════════════════════════════════
274
- app.delete('/api/agent/sessions/:sessionId', asyncRoute(async (req, res) => {
275
- await deleteSession(req.params.sessionId);
276
- res.json({ success: true });
277
- }));
278
-
279
- // ════════════════════════════════════════════════════════════════════════
280
- // §4. 重命名会话
281
- // ════════════════════════════════════════════════════════════════════════
282
- app.put('/api/agent/sessions/:sessionId', asyncRoute(async (req, res) => {
283
- const title = String(req.body?.title || '').trim();
284
- if (!title) throw new HttpError(400, '标题不能为空');
285
- const session = await renameSession(req.params.sessionId, title);
286
- res.json({ success: true, session });
287
- }));
288
-
289
- // Submit the answer for an ask_user tool call while its SSE request is paused.
290
- app.post('/api/agent/respond', async (req, res) => {
291
- const sessionId = String(req.body?.sessionId || '').trim();
292
- const interactionId = String(req.body?.interactionId || '').trim();
293
- if (!sessionId || !interactionId) {
294
- return res.status(400).json({ success: false, error: 'Missing sessionId or interactionId.' });
295
- }
296
- const result = submitAgentAnswer({
297
- sessionId,
298
- interactionId,
299
- answer: req.body?.answer,
300
- });
301
- if (!result.ok) return res.status(result.status).json({ success: false, error: result.error });
302
- return res.json({ success: true });
303
- });
304
-
305
- // ════════════════════════════════════════════════════════════════════════
306
- // §5. SSE 流式聊天(含工具调用循环)
307
- // ════════════════════════════════════════════════════════════════════════
308
- app.post('/api/agent/chat', async (req, res) => {
309
- const userMessage = String(req.body?.userMessage || '').trim();
310
- const sessionIdInput = String(req.body?.sessionId || '').trim();
311
- const locale = String(req.body?.locale || req.headers['accept-language'] || 'zh').startsWith('en') ? 'en' : 'zh';
312
-
313
- // 图片附件(base64 dataURL 数组):前端已限制只能选图片,这里再做一层白名单校验
314
- const images = (Array.isArray(req.body?.images) ? req.body.images : [])
315
- .filter(u => typeof u === 'string' && /^data:image\/[\w.+-]+;base64,/.test(u))
316
- .slice(0, 10);
317
-
318
- // 非图片附件:前端只传文件名 + 字节,服务端落盘到数据目录,只把**绝对路径**交给模型。
319
- // 内容不进消息体(省 token,模型自己用 read/grep 按需取),校验见 utils/agentAttachments.js。
320
- const rawAttachments = (Array.isArray(req.body?.attachments) ? req.body.attachments : [])
321
- .slice(0, MAX_ATTACHMENTS)
322
- .map(a => ({ name: a?.name, dataUrl: a?.dataUrl }));
323
-
324
- if (!userMessage && images.length === 0 && rawAttachments.length === 0) {
325
- return res.status(400).json({ success: false, error: '消息内容不能为空' });
326
- }
327
-
328
- // 文件空间对话:客户端把"当前打开的文档"带上来,服务端只在请求副本里注入上下文(不落库)
329
- const openFilePath = String(req.body?.openFilePath || '').trim().slice(0, 512);
330
-
331
- // 切换工作目录弹窗里的 g ai 追问:客户端把"常用目录那批目录的状态"带上来。
332
- // 与 /api/recent_directories/summary 完全是同一份数据形状,走同一个归一化函数;
333
- // 真正拼块要等拿到 configManager(需要读最近目录白名单),见下面 cwd 那一段。
334
- const dirStatusItems = normalizeItems(req.body?.dirStatus);
335
- // 界面上那段自动解读的原文:用户说"那第二个呢"时指的就是它(详见 injectRequestContext)
336
- const dirSummary = String(req.body?.dirSummary || '').trim().slice(0, 2000);
337
-
338
- // SSE 头
339
- res.set({
340
- 'Content-Type': 'text/event-stream',
341
- 'Cache-Control': 'no-cache, no-transform',
342
- 'Connection': 'keep-alive',
343
- 'X-Accel-Buffering': 'no'
344
- });
345
- res.flushHeaders?.();
346
- const send = (obj) => {
347
- try { res.write(`data: ${JSON.stringify(obj)}\n\n`); } catch {}
348
- };
349
-
350
- const abortController = new AbortController();
351
- let finished = false;
352
- let activeChild = null;
353
-
354
- // 同一会话同一时刻只允许一个进行中的生成:跨视图(对话 Tab / 文件空间面板)
355
- // 或前端连点都靠这道闸挡住,否则两个流各自的会话快照会互相覆盖。
356
- if (sessionIdInput && activeSessionTurns.has(sessionIdInput)) {
357
- send({ type: 'error', error: '该会话正在生成中,请等它结束后再发送' });
358
- finished = true;
359
- return res.end();
360
- }
361
- let activeSessionKey = null;
362
-
363
- // 客户端断开
364
- if (req.socket) {
365
- req.socket.once('close', () => {
366
- if (!finished) {
367
- abortController.abort();
368
- if (activeChild) {
369
- try { activeChild.kill('SIGTERM'); } catch {}
370
- }
371
- }
372
- });
373
- }
374
-
375
- try {
376
- // 加载或新建 session
377
- let session;
378
- let isNew = false;
379
- const currentProjectCwd = typeof getCurrentProjectPath === 'function'
380
- ? getCurrentProjectPath()
381
- : process.cwd();
382
- const bodyCwd = String(req.body?.cwd || '').trim();
383
- if (bodyCwd && normalizeCwd(bodyCwd) !== normalizeCwd(currentProjectCwd)) {
384
- send({ type: 'error', error: '当前页面项目与服务端项目不一致,请刷新后重试', code: 'PROJECT_MISMATCH' });
385
- finished = true;
386
- return res.end();
387
- }
388
-
389
- if (sessionIdInput) {
390
- try {
391
- session = await readSession(sessionIdInput);
392
- } catch (err) {
393
- if (err.statusCode === 404) {
394
- send({ type: 'error', error: '会话不存在' });
395
- finished = true;
396
- return res.end();
397
- }
398
- throw err;
399
- }
400
- if (session.cwd && normalizeCwd(session.cwd) !== normalizeCwd(currentProjectCwd)) {
401
- send({ type: 'error', error: '该会话属于其他项目,无法在当前项目中继续', code: 'PROJECT_MISMATCH' });
402
- finished = true;
403
- return res.end();
404
- }
405
- // 引擎在会话**建立时就锁死**,中途不许换。
406
- // 为什么:三家 CLI 的续聊标识互不通用(claude --resume / opencode --session /
407
- // codex exec resume),而且历史消息格式不同(我们存的是 OpenAI chat completions
408
- // 形状,喂给 claude 没有意义)。允许中途切换的表现是"看起来切了,上下文却串了"——
409
- // 与工作台 taskExecutor.ts 注释里那条"续哪个执行器由上一轮 job.agent 决定"同源。
410
- const requestedEngine = req.body?.engine === undefined
411
- ? null
412
- : normalizeAgentEngine(req.body?.engine);
413
- const sessionEngine = normalizeAgentEngine(session.engine);
414
- if (requestedEngine && requestedEngine !== sessionEngine && Array.isArray(session.messages) && session.messages.length > 0) {
415
- send({
416
- type: 'error',
417
- error: `该会话由 ${engineLabel(sessionEngine)} 运行,无法中途切换引擎。请新建会话再选 ${engineLabel(requestedEngine)}。`,
418
- code: 'ENGINE_LOCKED'
419
- });
420
- finished = true;
421
- return res.end();
422
- }
423
- session.engine = sessionEngine;
424
- } else {
425
- session = {
426
- version: 1,
427
- sessionId: genSessionId(),
428
- title: '(新会话)',
429
- source: 'web',
430
- cwd: currentProjectCwd,
431
- model: '',
432
- // 引擎:gai(内置,默认)| claude | opencode | codex
433
- engine: normalizeAgentEngine(req.body?.engine),
434
- // 外部 CLI 自己的续聊标识(claude session_id / opencode sessionID /
435
- // codex thread_id)。g ai 用不到,留着空串。
436
- engineSessionId: '',
437
- createdAt: nowIso(),
438
- updatedAt: nowIso(),
439
- messages: []
440
- };
441
- isNew = true;
442
- }
443
-
444
- // 本轮占用该会话(finally 里释放)
445
- activeSessionKey = session.sessionId;
446
- activeSessionTurns.add(activeSessionKey);
447
-
448
- // 附件落盘(拿到绝对路径后再把路径交给模型)。落盘失败不该拖垮整轮对话:
449
- // 拿不到路径最多是模型看不到附件,用户仍能正常对话。
450
- const attachments = await saveAgentAttachments(rawAttachments, {
451
- onSkip: (info) => console.warn('[agent] 附件被忽略:', info.name, info.reason)
452
- }).catch((err) => {
453
- console.warn('[agent] 附件落盘失败:', err?.message || err);
454
- return [];
455
- });
456
-
457
- // 获取模型配置。**只有内置 g ai 需要** —— 外部 CLI 用自己的模型与配置
458
- // (claude/opencode/codex 各自的 CLI 配置;本机上 Codex 甚至是走本地代理跑
459
- // deepseek-v4.1-flash)。为外部引擎要求"先在设置里配模型"是错的:
460
- // 明明能跑,却先被一道无关的校验拦住。
461
- const useExternal = isExternalEngine(session.engine);
462
- let model = null;
463
- let requestBudget = null;
464
- if (!useExternal) {
465
- try {
466
- if (!configManager) throw new Error('configManager 不可用');
467
- const rawConfig = await configManager.readRawConfigFile();
468
- const models = Array.isArray(rawConfig.models) ? rawConfig.models : [];
469
- model = models.find(m => m.isDefault) || models[0];
470
- // 请求预算:全局配置 aiMaxRequestChars(缺省/越界由 resolveRequestBudget
471
- // 兜底夹取),与 CLI `g ai`、设置页保存回执共用同一份解析 —— 别在这里另算。
472
- requestBudget = resolveRequestBudget(rawConfig.aiMaxRequestChars);
473
- } catch (err) {
474
- send({ type: 'error', error: '读取 AI 配置失败: ' + err.message });
475
- finished = true;
476
- return res.end();
477
- }
478
- if (!model) {
479
- send({ type: 'error', error: '未配置 AI 模型,请先在通用设置中添加模型' });
480
- finished = true;
481
- return res.end();
482
- }
483
- // 更新 session 的 model 信息
484
- session.model = `${model.model || ''} (${model.name || ''})`;
485
- } else {
486
- // 外部引擎:不落具体模型名(它随 CLI 自己的配置变),只记引擎,便于列表里辨认
487
- session.model = '';
488
- }
489
-
490
- // 工作目录
491
- const cwd = session.cwd || (typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : process.cwd());
492
-
493
- // 常用目录状态块:白名单过滤(只认配置里的最近目录)+ 拼成事实块。
494
- // 与 /summary 的差别:那里读不到白名单就**拒答**(它的全部意义就是解读这批目录),
495
- // 这里读不到只是**少一段上下文** —— 用户是在正常聊天,不该因为读不到目录列表而发不出消息。
496
- let dirStatusBlock = '';
497
- if (dirStatusItems.length > 0) {
498
- try {
499
- const recentDirs = (await configManager?.getRecentDirectories?.()) || [];
500
- const targets = filterToRecentDirs(dirStatusItems, recentDirs);
501
- if (targets.length > 0) dirStatusBlock = buildDirStatusBlock(targets, locale, dirSummary);
502
- } catch (err) {
503
- logger.warn(`[agent/chat] 常用目录状态注入失败,本轮跳过: ${err?.message || err}`);
504
- }
505
- }
506
-
507
- // 推 meta
508
- send({
509
- type: 'meta',
510
- sessionId: session.sessionId,
511
- isNew,
512
- title: isNew ? autoTitle([{ role: 'user', content: userMessage }]) : session.title
513
- });
514
-
515
- // dispatch_task 的注入闸门:**只有主 Agent 控制台发起的对话**才有派发能力
516
- // (前端在请求体里带 allowDispatch: true)。
517
- //
518
- // 为什么不无条件开放:派发是真有副作用的动作 —— 建任务 + 默认立刻起一个本地 CLI
519
- // 进程。在「智能体」页或编辑器面板里聊天时,用户预期的是"回答我",让模型突然
520
- // 往外派活属于越权;这两个入口的行为保持改造前不变(工具表里有它,但调用会拿到
521
- // 一句明确的 unavailable,见 tools.js 的 toolDispatchTask)。
522
- const dispatchTask = req.body?.allowDispatch === true
523
- ? (payload) => runDispatchTask({
524
- dispatch: dispatchInstruction,
525
- payload,
526
- // 控制台当前的选择:勾选框(要不要带预设提示词)+ 选中的执行器。
527
- // 用户界面上做过的选择不能因为"这活是 agent 派的"就失效。
528
- defaults: {
529
- executor: typeof req.body?.dispatchExecutor === 'string' ? req.body.dispatchExecutor : '',
530
- useDefaultPrompt: req.body?.dispatchUseDefaultPrompt !== false,
531
- },
532
- })
533
- : undefined;
534
-
535
- if (useExternal) {
536
- // 外部 CLI 引擎:CLI 自己跑循环,服务端只做 spawn + 事件翻译。
537
- // 工作区状态块走 prompt 前缀(见 runExternalTurn 文件头:往项目里写
538
- // AGENTS.md/CLAUDE.md 会弄脏用户工作区,且三家读取口径实测不一致)。
539
- let promptPrefix = '';
540
- if (typeof getContextBlock === 'function') {
541
- try { promptPrefix = await getContextBlock({ locale }); } catch (err) { logger.warn(`[agent] 外部引擎状态块获取失败: ${err?.message || err}`); }
542
- }
543
- // 常用目录状态块与内置引擎走同一份字符串(外部引擎没有"请求副本"可注入,
544
- // 这段前缀只作用在本轮 prompt 上,不会写进项目里的任何文件)
545
- if (dirStatusBlock) {
546
- promptPrefix = promptPrefix ? `${dirStatusBlock}\n\n${promptPrefix}` : dirStatusBlock;
547
- }
548
- // 图片附件转成文件路径交给 CLI(它们收路径不收 base64)。走同一套落盘校验。
549
- const imageAttachments = images.map((u, i) => ({ name: `pasted-${i + 1}.png`, dataUrl: u }));
550
- const imageFiles = imageAttachments.length > 0
551
- ? await saveAgentAttachments(imageAttachments, {
552
- onSkip: (info) => console.warn('[agent] 图片附件被忽略:', info.name, info.reason)
553
- }).catch((err) => {
554
- console.warn('[agent] 图片附件落盘失败:', err?.message || err);
555
- return [];
556
- })
557
- : [];
558
- const filePaths = [...attachments.map(a => a.path), ...imageFiles.map(f => f.path)];
559
-
560
- await runExternalTurn({
561
- session,
562
- engine: session.engine,
563
- userMessage,
564
- cwd,
565
- promptPrefix,
566
- filePaths,
567
- signal: abortController.signal,
568
- send,
569
- onChild: (child) => { activeChild = child; },
570
- });
571
- } else {
572
- // 运行 agent 循环(内置 g ai:服务端跑 LLM + 工具)
573
- await runAgentTurn({
574
- session,
575
- model,
576
- userMessage,
577
- images,
578
- cwd,
579
- locale,
580
- openFilePath,
581
- attachments,
582
- dirStatusBlock,
583
- signal: abortController.signal,
584
- send,
585
- onChild: (child) => { activeChild = child; },
586
- askUser: (args, meta = {}) => waitForAgentAnswer({
587
- sessionId: session.sessionId,
588
- interactionId: meta.interactionId,
589
- question: args.question,
590
- options: Array.isArray(args.options) ? args.options : [],
591
- allowFreeText: args.allowFreeText !== false,
592
- multiple: args.multiple === true,
593
- send,
594
- signal: abortController.signal,
595
- }),
596
- dispatchTask,
597
- listProjects,
598
- getContextBlock,
599
- // 每轮请求的上下文预算(解析结果)。agentChat 拿它调 prepareRequestMessages,
600
- // 与 CLI turn.js 同一条口径。
601
- requestBudget
602
- });
603
- }
604
-
605
- // 更新标题(新会话从第一条 user 消息自动生成)
606
- if (isNew) {
607
- session.title = autoTitle(session.messages);
608
- }
609
-
610
- // 持久化。中止(用户点"停止")的轮次同样要落盘 —— 磁盘上没有这条会话时,
611
- // 前端停止后立刻刷新列表会把左栏这一条整个吞掉(表现为"一停止任务就没了")。
612
- session.updatedAt = nowIso();
613
- await writeSession(session.sessionId, session);
614
- enforceRetention().catch(() => {});
615
-
616
- finished = true;
617
- res.end();
618
- } catch (err) {
619
- send({ type: 'error', error: '智能体对话失败: ' + (err?.message || String(err)) });
620
- finished = true;
621
- res.end();
622
- } finally {
623
- if (activeSessionKey) activeSessionTurns.delete(activeSessionKey);
624
- }
625
- });
626
-
627
- // 把生成器交出去:调用方(server/index.js)要在服务启动后预热一次快照,
628
- // 覆盖"g ui 刚启动"这个时机。预热调用刻意留在**生产入口**而不是这里 ——
629
- // 单测会调 registerAgentRoutes 四次,预热写在这里等于测试机上 spawn 四轮 gh。
630
- return { snapshotter: workspaceSnapshotter };
631
- }
1
+ // Copyright 2026 xz333221
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+ //
15
+ // 智能体路由入口。
16
+ //
17
+ // 路由清单:
18
+ // GET /api/agent/sessions — 列出会话(仅 metadata;?cwd= 按项目过滤,空 cwd 视为当前项目)
19
+ // GET /api/agent/sessions/:id — 获取会话详情(含完整消息)
20
+ // DELETE /api/agent/sessions/:id — 删除会话
21
+ // PUT /api/agent/sessions/:id — 重命名会话
22
+ // POST /api/agent/chat — SSE 流式聊天(含工具调用;body.cwd 必须与服务端当前项目一致)
23
+
24
+ import path from 'node:path';
25
+ import { asyncRoute, HttpError } from '../../utils/asyncRoute.js';
26
+ import { agentSessionStore } from './agentSessionStore.js';
27
+ import { runAgentTurn } from './agentChat.js';
28
+ import { runExternalTurn } from './runExternalTurn.js';
29
+ import { normalizeAgentEngine, isExternalEngine, engineLabel } from './agentEngines.js';
30
+ import { saveAgentAttachments, MAX_ATTACHMENTS } from '../../utils/agentAttachments.js';
31
+ import { registerAgentMarketplaceRoutes } from './agentMarketplace.js';
32
+ import { createProjectListProvider } from './projectTool.js';
33
+ import { createDispatcher } from './dispatchInstruction.js';
34
+ import { createWorkspaceSnapshotter } from '../aiContext/wiring.js';
35
+ import { normalizeItems, filterToRecentDirs, buildDirStatusBlock } from '../recentDirectoriesAiSummary.js';
36
+ import { nowIso, logger } from './shared.js';
37
+ // 请求预算的解析公式与 CLI `g ai` 共用同一份(cli/ai/context.js),Web 侧只管把
38
+ // 配置值喂进去、把结果传给 agentChat —— 2026-10-07 起每轮请求的字符预算可配。
39
+ import { resolveRequestBudget } from '../../../../cli/ai/context.js';
40
+
41
+ const { genSessionId, autoTitle, read: readSession, write: writeSession, delete: deleteSession, listMeta: listSessionsMeta, enforceRetention, rename: renameSession } = agentSessionStore;
42
+
43
+ // 项目路径规范化:Windows 路径大小写不敏感,POSIX 路径保留大小写语义。
44
+ function normalizeCwd(p) {
45
+ const value = String(p || '').trim();
46
+ if (!value) return '';
47
+ const normalized = path.resolve(value).replace(/\\/g, '/').replace(/\/+$/, '');
48
+ return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
49
+ }
50
+
51
+ // 历史存量会话没有 cwd,暂时沿用升级前的可见性,避免升级后无法在 UI 中找回。
52
+ function sessionBelongsTo(sessionCwd, projectCwd) {
53
+ const s = normalizeCwd(sessionCwd);
54
+ const project = normalizeCwd(projectCwd);
55
+ if (!s) return true;
56
+ return Boolean(project && s === project);
57
+ }
58
+
59
+ // An ask_user call keeps its chat SSE request open until this map receives the
60
+ // matching response. The entry is removed on every completion or disconnect.
61
+ const pendingAgentQuestions = new Map();
62
+
63
+ /** 正在进行中的会话 id 集合:同一会话同一时刻只允许一个生成中的轮次 */
64
+ const activeSessionTurns = new Set();
65
+
66
+ function interactionKey(sessionId, interactionId) {
67
+ return `${String(sessionId)}:${String(interactionId)}`;
68
+ }
69
+
70
+ export function waitForAgentAnswer({ sessionId, interactionId, question, options, allowFreeText, multiple, send, signal }) {
71
+ const key = interactionKey(sessionId, interactionId);
72
+ return new Promise((resolve) => {
73
+ let settled = false;
74
+ const cleanup = () => {
75
+ pendingAgentQuestions.delete(key);
76
+ signal?.removeEventListener?.('abort', onAbort);
77
+ };
78
+ const settle = (answer) => {
79
+ if (settled) return;
80
+ settled = true;
81
+ cleanup();
82
+ resolve(answer);
83
+ };
84
+ const onAbort = () => settle('Cancelled by user; the question was not answered.');
85
+
86
+ pendingAgentQuestions.set(key, {
87
+ question,
88
+ options,
89
+ allowFreeText,
90
+ multiple: multiple === true,
91
+ settle,
92
+ });
93
+ signal?.addEventListener?.('abort', onAbort, { once: true });
94
+ send({ type: 'ask_user', interactionId, question, options, allowFreeText, multiple: multiple === true });
95
+ if (signal?.aborted) onAbort();
96
+ });
97
+ }
98
+
99
+ export function submitAgentAnswer({ sessionId, interactionId, answer }) {
100
+ const key = interactionKey(sessionId, interactionId);
101
+ const pending = pendingAgentQuestions.get(key);
102
+ if (!pending) return { ok: false, status: 404, error: 'No pending question for this session.' };
103
+
104
+ // 单选发字符串、多选发数组;这里统一归一成"非空的字符串列表"
105
+ const values = (Array.isArray(answer) ? answer : [answer])
106
+ .map((v) => String(v ?? '').trim())
107
+ .filter(Boolean);
108
+ if (!values.length) return { ok: false, status: 400, error: 'Answer cannot be empty.' };
109
+ if (!pending.allowFreeText && pending.options.length > 0) {
110
+ const unknown = values.filter((v) => !pending.options.includes(v));
111
+ if (unknown.length) return { ok: false, status: 400, error: 'Choose one of the listed options.' };
112
+ }
113
+ // 多选回给模型的是 JSON 数组字符串(单选/自由输入保持原样字符串)
114
+ pending.settle(pending.multiple ? JSON.stringify(values) : values[0]);
115
+ return { ok: true };
116
+ }
117
+
118
+ /**
119
+ * 注册智能体路由。
120
+ * @param {Object} deps
121
+ * @param {import('express').Express} deps.app
122
+ * @param {() => string} deps.getCurrentProjectPath
123
+ * @param {Object} deps.configManager
124
+ */
125
+ /**
126
+ * 落点依据 → 一句话。给**模型**看的那份(前端 OrchesratorConsole 的 targetReason 是
127
+ * 界面用的,走 i18n 且只有中英两版)。两边都要有,因为读者不同:模型读的是工具结果,
128
+ * 用户读的是活动流。文案不共用,判断依据是同一组 source 取值(targetResolver 定义)。
129
+ */
130
+ function targetSourceLabel(source) {
131
+ switch (source) {
132
+ case 'explicit': return '派发时显式指定了项目';
133
+ case 'mention': return '指令里点明了这个项目';
134
+ case 'agent': return '按指令语义判断';
135
+ case 'default': return '没识别出项目,用了默认项目';
136
+ default: return '未知';
137
+ }
138
+ }
139
+
140
+ /**
141
+ * dispatch_task 工具的实现:补上控制台当前的选择 → 交给共用派发器 → 压成一行
142
+ * **给模型看**的字符串。
143
+ *
144
+ * 为什么结果要写得这么具体:它会作为工具结果进入会话历史,之后每一轮模型都读得到。
145
+ * 只说"成功"的话,模型不知道任务落在哪个项目、有没有真跑起来,于是要么重复派发,
146
+ * 要么向用户描述成"我已经在本地跑完了"(实际是另一个进程在跑)。所以三件事必须写清:
147
+ * 落到哪个项目、有没有真的开始执行、凭什么这么落点。
148
+ *
149
+ * 失败一律转成字符串(工具的契约就是"结果永远是字符串,不向外抛"):
150
+ * HttpError 的 message 本来就是给人看的中文("项目目录不存在:xxx"),直接透出。
151
+ *
152
+ * 导出只为单测(agentRoutes.test.js 钉住这几句文案与透传口径)——
153
+ * 与 waitForAgentAnswer / submitAgentAnswer 同一取舍。
154
+ */
155
+ export async function runDispatchTask({ dispatch, payload, defaults }) {
156
+ try {
157
+ const result = await dispatch({
158
+ text: payload.text,
159
+ projectPath: payload.projectPath,
160
+ autoRun: payload.autoRun,
161
+ // 工具没显式表态时跟随控制台的勾选;显式 false 才关掉(tools.js 已保证)
162
+ useDefaultPrompt: payload.useDefaultPrompt === undefined
163
+ ? defaults.useDefaultPrompt
164
+ : payload.useDefaultPrompt,
165
+ executor: payload.executor || defaults.executor,
166
+ });
167
+
168
+ const project = result.target?.name || result.target?.path?.split(/[\\/]/).filter(Boolean).pop() || '';
169
+ const head = `已派发:任务「${result.task.title}」→ 项目「${project}」`;
170
+ const facts = `taskId=${result.task.id};落点依据:${targetSourceLabel(result.target?.source)}`;
171
+
172
+ if (!result.ran) {
173
+ return `${head},但**没有开始执行**(${result.instruction?.reason || '未开启自动执行'})。${facts}。`
174
+ + '它在看板的「待处理」列里,等用户自己点执行。'
175
+ }
176
+ return `${head},已交给 ${engineLabel(result.executor)} 开始执行。${facts}。`
177
+ + '它在独立进程里跑、不占用本次对话,跑完会有任务完成提示;'
178
+ + '不要等它结束,也不要为同一件事重复派发。'
179
+ } catch (err) {
180
+ return `派发失败:${err?.message || String(err)}`
181
+ }
182
+ }
183
+
184
+ export function registerAgentRoutes({ app, getCurrentProjectPath, configManager, snapshotter }) {
185
+
186
+ registerAgentMarketplaceRoutes({ app, getCurrentProjectPath });
187
+
188
+ // list_projects 的数据源。在这里建一次、复用:它要读最近目录(配置)、tasks.json
189
+ // 与看板统计,这些依赖只有本层拿得到 —— 与 taskRunner 的 setEnvContextProvider 同理。
190
+ // 不给它加缓存:agent 一轮里最多问一两次,而"项目状态"本来就要现读才准。
191
+ const listProjects = createProjectListProvider({ configManager, getCurrentProjectPath });
192
+
193
+ // dispatch_task 的实现 —— 与 POST /api/workbench/orchestrator/dispatch 共用**同一份**
194
+ // 派发实现(见 dispatchInstruction.js 的文件头):落点怎么判、预设提示词附加哪一级、
195
+ // 指令流水记什么、执行器怎么回落,只有一套。这里只是把同一套依赖再绑一次
196
+ // (派发器无状态,两个实例等价)。
197
+ const { dispatchInstruction } = createDispatcher({ configManager, getCurrentProjectPath });
198
+
199
+ // 工作区状态快照(七个板块的摘要 + 落盘文件路径)。同样在这里建一次、复用 ——
200
+ // 它要读配置、tasks.json、执行记录、git 状态、监控与思维导图,这些依赖本层一并拿得最全。
201
+ //
202
+ // `snapshotter` 是个**只为测试留的注入口**(与 remoteRepos 的 *Impl 同一套做法):
203
+ // 生产不传,走真实实现;单测传一个空壳,免得跑一次对话就在测试机上 spawn `gh`
204
+ // 和 PowerShell、还去连一次网。默认值写成参数默认值,不传时才构造。
205
+ const workspaceSnapshotter = snapshotter || createWorkspaceSnapshotter({ configManager, getCurrentProjectPath });
206
+ const getContextBlock = ({ locale } = {}) => workspaceSnapshotter.getBlock({ locale });
207
+
208
+ // ════════════════════════════════════════════════════════════════════════
209
+ // §1. 会话列表
210
+ // ════════════════════════════════════════════════════════════════════════
211
+ /**
212
+ * 预热工作区状态快照 —— 这是"初始加载"该做的事,但**不是**把七个界面板块都挂起来。
213
+ *
214
+ * 用户打开智能体页时前端会拉这个会话列表,此刻就顺手在后台把快照生成好;
215
+ * 等他打完字发出去,快照早已就绪,那一轮就能带上完整上下文。
216
+ * 刻意不 await:它要跑 gh/gitee/git/PowerShell,等它会让会话列表白屏几秒。
217
+ * 也刻意不放在 registerAgentRoutes 里:启动时预热会让"打开服务"这条路径凭空多几次
218
+ * 子进程与网络调用,而用户可能压根不会打开智能体页。
219
+ */
220
+ function warmSnapshotInBackground() {
221
+ try {
222
+ if (typeof workspaceSnapshotter?.warm === 'function') workspaceSnapshotter.warm().catch(() => {});
223
+ } catch (err) {
224
+ logger.warn(`[agentRoutes] 预热工作区快照失败: ${err?.message || err}`);
225
+ }
226
+ }
227
+
228
+ /**
229
+ * POST /api/ai-context/refresh body: { sections?: string[], force?: boolean }
230
+ *
231
+ * 给前端"切到某个面板就更新对应板块"用的定向刷新口。**按板块**而不是全量:
232
+ * 七个板块的取数成本差两个数量级,切个 Git 面板没必要顺带联网拉一遍仓库列表。
233
+ *
234
+ * 前端一律 fire-and-forget 调它(不等响应) —— 一次全量要 6~7 秒,没有哪个 UI 该等它。
235
+ * 真正拖慢也不怕:force 仍受各板块自己的 forceTtlMs 地板限制,联网板块最多 60s 一次。
236
+ *
237
+ * 顺带把各板块状态回给调用方(采集时间/是否新鲜),排查"为什么模型看到的是旧的"时很好用。
238
+ */
239
+ app.post('/api/ai-context/refresh', asyncRoute(async (req, res) => {
240
+ const body = req.body || {};
241
+ const ids = Array.isArray(body.sections) ? body.sections.map(String) : undefined;
242
+ const force = body.force === true;
243
+ const state = await workspaceSnapshotter.refreshSections(ids, { force });
244
+ res.json({ success: true, ...state });
245
+ }));
246
+
247
+ /** GET 版本:只读回各板块状态,不触发任何取数(给未来做面板用) */
248
+ app.get('/api/ai-context/state', asyncRoute(async (_req, res) => {
249
+ res.json({ success: true, ...workspaceSnapshotter.getState() });
250
+ }));
251
+
252
+ app.get('/api/agent/sessions', asyncRoute(async (req, res) => {
253
+ warmSnapshotInBackground();
254
+ let sessions = await listSessionsMeta();
255
+ // 按项目隔离:前端传当前项目路径时只返回该项目的会话
256
+ const cwdFilter = String(req.query?.cwd || '').trim();
257
+ if (cwdFilter) {
258
+ sessions = sessions.filter(s => sessionBelongsTo(s.cwd, cwdFilter));
259
+ }
260
+ res.json({ success: true, sessions });
261
+ }));
262
+
263
+ // ════════════════════════════════════════════════════════════════════════
264
+ // §2. 会话详情
265
+ // ════════════════════════════════════════════════════════════════════════
266
+ app.get('/api/agent/sessions/:sessionId', asyncRoute(async (req, res) => {
267
+ const session = await readSession(req.params.sessionId);
268
+ res.json({ success: true, session });
269
+ }));
270
+
271
+ // ════════════════════════════════════════════════════════════════════════
272
+ // §3. 删除会话
273
+ // ════════════════════════════════════════════════════════════════════════
274
+ app.delete('/api/agent/sessions/:sessionId', asyncRoute(async (req, res) => {
275
+ await deleteSession(req.params.sessionId);
276
+ res.json({ success: true });
277
+ }));
278
+
279
+ // ════════════════════════════════════════════════════════════════════════
280
+ // §4. 重命名会话
281
+ // ════════════════════════════════════════════════════════════════════════
282
+ app.put('/api/agent/sessions/:sessionId', asyncRoute(async (req, res) => {
283
+ const title = String(req.body?.title || '').trim();
284
+ if (!title) throw new HttpError(400, '标题不能为空');
285
+ const session = await renameSession(req.params.sessionId, title);
286
+ res.json({ success: true, session });
287
+ }));
288
+
289
+ // Submit the answer for an ask_user tool call while its SSE request is paused.
290
+ app.post('/api/agent/respond', async (req, res) => {
291
+ const sessionId = String(req.body?.sessionId || '').trim();
292
+ const interactionId = String(req.body?.interactionId || '').trim();
293
+ if (!sessionId || !interactionId) {
294
+ return res.status(400).json({ success: false, error: 'Missing sessionId or interactionId.' });
295
+ }
296
+ const result = submitAgentAnswer({
297
+ sessionId,
298
+ interactionId,
299
+ answer: req.body?.answer,
300
+ });
301
+ if (!result.ok) return res.status(result.status).json({ success: false, error: result.error });
302
+ return res.json({ success: true });
303
+ });
304
+
305
+ // ════════════════════════════════════════════════════════════════════════
306
+ // §5. SSE 流式聊天(含工具调用循环)
307
+ // ════════════════════════════════════════════════════════════════════════
308
+ app.post('/api/agent/chat', async (req, res) => {
309
+ const userMessage = String(req.body?.userMessage || '').trim();
310
+ const sessionIdInput = String(req.body?.sessionId || '').trim();
311
+ const locale = String(req.body?.locale || req.headers['accept-language'] || 'zh').startsWith('en') ? 'en' : 'zh';
312
+
313
+ // 图片附件(base64 dataURL 数组):前端已限制只能选图片,这里再做一层白名单校验
314
+ const images = (Array.isArray(req.body?.images) ? req.body.images : [])
315
+ .filter(u => typeof u === 'string' && /^data:image\/[\w.+-]+;base64,/.test(u))
316
+ .slice(0, 10);
317
+
318
+ // 非图片附件:前端只传文件名 + 字节,服务端落盘到数据目录,只把**绝对路径**交给模型。
319
+ // 内容不进消息体(省 token,模型自己用 read/grep 按需取),校验见 utils/agentAttachments.js。
320
+ const rawAttachments = (Array.isArray(req.body?.attachments) ? req.body.attachments : [])
321
+ .slice(0, MAX_ATTACHMENTS)
322
+ .map(a => ({ name: a?.name, dataUrl: a?.dataUrl }));
323
+
324
+ if (!userMessage && images.length === 0 && rawAttachments.length === 0) {
325
+ return res.status(400).json({ success: false, error: '消息内容不能为空' });
326
+ }
327
+
328
+ // 文件空间对话:客户端把"当前打开的文档"带上来,服务端只在请求副本里注入上下文(不落库)
329
+ const openFilePath = String(req.body?.openFilePath || '').trim().slice(0, 512);
330
+
331
+ // 切换工作目录弹窗里的 g ai 追问:客户端把"常用目录那批目录的状态"带上来。
332
+ // 与 /api/recent_directories/summary 完全是同一份数据形状,走同一个归一化函数;
333
+ // 真正拼块要等拿到 configManager(需要读最近目录白名单),见下面 cwd 那一段。
334
+ const dirStatusItems = normalizeItems(req.body?.dirStatus);
335
+ // 界面上那段自动解读的原文:用户说"那第二个呢"时指的就是它(详见 injectRequestContext)
336
+ const dirSummary = String(req.body?.dirSummary || '').trim().slice(0, 2000);
337
+
338
+ // SSE 头
339
+ res.set({
340
+ 'Content-Type': 'text/event-stream',
341
+ 'Cache-Control': 'no-cache, no-transform',
342
+ 'Connection': 'keep-alive',
343
+ 'X-Accel-Buffering': 'no'
344
+ });
345
+ res.flushHeaders?.();
346
+ const send = (obj) => {
347
+ try { res.write(`data: ${JSON.stringify(obj)}\n\n`); } catch {}
348
+ };
349
+
350
+ const abortController = new AbortController();
351
+ let finished = false;
352
+ let activeChild = null;
353
+
354
+ // 同一会话同一时刻只允许一个进行中的生成:跨视图(对话 Tab / 文件空间面板)
355
+ // 或前端连点都靠这道闸挡住,否则两个流各自的会话快照会互相覆盖。
356
+ if (sessionIdInput && activeSessionTurns.has(sessionIdInput)) {
357
+ send({ type: 'error', error: '该会话正在生成中,请等它结束后再发送' });
358
+ finished = true;
359
+ return res.end();
360
+ }
361
+ let activeSessionKey = null;
362
+
363
+ // 客户端断开
364
+ if (req.socket) {
365
+ req.socket.once('close', () => {
366
+ if (!finished) {
367
+ abortController.abort();
368
+ if (activeChild) {
369
+ try { activeChild.kill('SIGTERM'); } catch {}
370
+ }
371
+ }
372
+ });
373
+ }
374
+
375
+ try {
376
+ // 加载或新建 session
377
+ let session;
378
+ let isNew = false;
379
+ const currentProjectCwd = typeof getCurrentProjectPath === 'function'
380
+ ? getCurrentProjectPath()
381
+ : process.cwd();
382
+ const bodyCwd = String(req.body?.cwd || '').trim();
383
+ if (bodyCwd && normalizeCwd(bodyCwd) !== normalizeCwd(currentProjectCwd)) {
384
+ send({ type: 'error', error: '当前页面项目与服务端项目不一致,请刷新后重试', code: 'PROJECT_MISMATCH' });
385
+ finished = true;
386
+ return res.end();
387
+ }
388
+
389
+ if (sessionIdInput) {
390
+ try {
391
+ session = await readSession(sessionIdInput);
392
+ } catch (err) {
393
+ if (err.statusCode === 404) {
394
+ send({ type: 'error', error: '会话不存在' });
395
+ finished = true;
396
+ return res.end();
397
+ }
398
+ throw err;
399
+ }
400
+ if (session.cwd && normalizeCwd(session.cwd) !== normalizeCwd(currentProjectCwd)) {
401
+ send({ type: 'error', error: '该会话属于其他项目,无法在当前项目中继续', code: 'PROJECT_MISMATCH' });
402
+ finished = true;
403
+ return res.end();
404
+ }
405
+ // 引擎在会话**建立时就锁死**,中途不许换。
406
+ // 为什么:三家 CLI 的续聊标识互不通用(claude --resume / opencode --session /
407
+ // codex exec resume),而且历史消息格式不同(我们存的是 OpenAI chat completions
408
+ // 形状,喂给 claude 没有意义)。允许中途切换的表现是"看起来切了,上下文却串了"——
409
+ // 与工作台 taskExecutor.ts 注释里那条"续哪个执行器由上一轮 job.agent 决定"同源。
410
+ const requestedEngine = req.body?.engine === undefined
411
+ ? null
412
+ : normalizeAgentEngine(req.body?.engine);
413
+ const sessionEngine = normalizeAgentEngine(session.engine);
414
+ if (requestedEngine && requestedEngine !== sessionEngine && Array.isArray(session.messages) && session.messages.length > 0) {
415
+ send({
416
+ type: 'error',
417
+ error: `该会话由 ${engineLabel(sessionEngine)} 运行,无法中途切换引擎。请新建会话再选 ${engineLabel(requestedEngine)}。`,
418
+ code: 'ENGINE_LOCKED'
419
+ });
420
+ finished = true;
421
+ return res.end();
422
+ }
423
+ session.engine = sessionEngine;
424
+ } else {
425
+ session = {
426
+ version: 1,
427
+ sessionId: genSessionId(),
428
+ title: '(新会话)',
429
+ source: 'web',
430
+ cwd: currentProjectCwd,
431
+ model: '',
432
+ // 引擎:gai(内置,默认)| claude | opencode | codex
433
+ engine: normalizeAgentEngine(req.body?.engine),
434
+ // 外部 CLI 自己的续聊标识(claude session_id / opencode sessionID /
435
+ // codex thread_id)。g ai 用不到,留着空串。
436
+ engineSessionId: '',
437
+ createdAt: nowIso(),
438
+ updatedAt: nowIso(),
439
+ messages: []
440
+ };
441
+ isNew = true;
442
+ }
443
+
444
+ // 本轮占用该会话(finally 里释放)
445
+ activeSessionKey = session.sessionId;
446
+ activeSessionTurns.add(activeSessionKey);
447
+
448
+ // 附件落盘(拿到绝对路径后再把路径交给模型)。落盘失败不该拖垮整轮对话:
449
+ // 拿不到路径最多是模型看不到附件,用户仍能正常对话。
450
+ const attachments = await saveAgentAttachments(rawAttachments, {
451
+ onSkip: (info) => console.warn('[agent] 附件被忽略:', info.name, info.reason)
452
+ }).catch((err) => {
453
+ console.warn('[agent] 附件落盘失败:', err?.message || err);
454
+ return [];
455
+ });
456
+
457
+ // 获取模型配置。**只有内置 g ai 需要** —— 外部 CLI 用自己的模型与配置
458
+ // (claude/opencode/codex 各自的 CLI 配置;本机上 Codex 甚至是走本地代理跑
459
+ // deepseek-v4.1-flash)。为外部引擎要求"先在设置里配模型"是错的:
460
+ // 明明能跑,却先被一道无关的校验拦住。
461
+ const useExternal = isExternalEngine(session.engine);
462
+ let model = null;
463
+ let requestBudget = null;
464
+ if (!useExternal) {
465
+ try {
466
+ if (!configManager) throw new Error('configManager 不可用');
467
+ const rawConfig = await configManager.readRawConfigFile();
468
+ const models = Array.isArray(rawConfig.models) ? rawConfig.models : [];
469
+ model = models.find(m => m.isDefault) || models[0];
470
+ // 请求预算:全局配置 aiMaxRequestTokens(缺省/越界由 resolveRequestBudget
471
+ // 兜底夹取),与 CLI `g ai`、设置页保存回执共用同一份解析 —— 别在这里另算。
472
+ requestBudget = resolveRequestBudget(rawConfig.aiMaxRequestTokens);
473
+ } catch (err) {
474
+ send({ type: 'error', error: '读取 AI 配置失败: ' + err.message });
475
+ finished = true;
476
+ return res.end();
477
+ }
478
+ if (!model) {
479
+ send({ type: 'error', error: '未配置 AI 模型,请先在通用设置中添加模型' });
480
+ finished = true;
481
+ return res.end();
482
+ }
483
+ // 更新 session 的 model 信息
484
+ session.model = `${model.model || ''} (${model.name || ''})`;
485
+ } else {
486
+ // 外部引擎:不落具体模型名(它随 CLI 自己的配置变),只记引擎,便于列表里辨认
487
+ session.model = '';
488
+ }
489
+
490
+ // 工作目录
491
+ const cwd = session.cwd || (typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : process.cwd());
492
+
493
+ // 常用目录状态块:白名单过滤(只认配置里的最近目录)+ 拼成事实块。
494
+ // 与 /summary 的差别:那里读不到白名单就**拒答**(它的全部意义就是解读这批目录),
495
+ // 这里读不到只是**少一段上下文** —— 用户是在正常聊天,不该因为读不到目录列表而发不出消息。
496
+ let dirStatusBlock = '';
497
+ if (dirStatusItems.length > 0) {
498
+ try {
499
+ const recentDirs = (await configManager?.getRecentDirectories?.()) || [];
500
+ const targets = filterToRecentDirs(dirStatusItems, recentDirs);
501
+ if (targets.length > 0) dirStatusBlock = buildDirStatusBlock(targets, locale, dirSummary);
502
+ } catch (err) {
503
+ logger.warn(`[agent/chat] 常用目录状态注入失败,本轮跳过: ${err?.message || err}`);
504
+ }
505
+ }
506
+
507
+ // 推 meta
508
+ send({
509
+ type: 'meta',
510
+ sessionId: session.sessionId,
511
+ isNew,
512
+ title: isNew ? autoTitle([{ role: 'user', content: userMessage }]) : session.title
513
+ });
514
+
515
+ // dispatch_task 的注入闸门:**只有主 Agent 控制台发起的对话**才有派发能力
516
+ // (前端在请求体里带 allowDispatch: true)。
517
+ //
518
+ // 为什么不无条件开放:派发是真有副作用的动作 —— 建任务 + 默认立刻起一个本地 CLI
519
+ // 进程。在「智能体」页或编辑器面板里聊天时,用户预期的是"回答我",让模型突然
520
+ // 往外派活属于越权;这两个入口的行为保持改造前不变(工具表里有它,但调用会拿到
521
+ // 一句明确的 unavailable,见 tools.js 的 toolDispatchTask)。
522
+ const dispatchTask = req.body?.allowDispatch === true
523
+ ? (payload) => runDispatchTask({
524
+ dispatch: dispatchInstruction,
525
+ payload,
526
+ // 控制台当前的选择:勾选框(要不要带预设提示词)+ 选中的执行器。
527
+ // 用户界面上做过的选择不能因为"这活是 agent 派的"就失效。
528
+ defaults: {
529
+ executor: typeof req.body?.dispatchExecutor === 'string' ? req.body.dispatchExecutor : '',
530
+ useDefaultPrompt: req.body?.dispatchUseDefaultPrompt !== false,
531
+ },
532
+ })
533
+ : undefined;
534
+
535
+ if (useExternal) {
536
+ // 外部 CLI 引擎:CLI 自己跑循环,服务端只做 spawn + 事件翻译。
537
+ // 工作区状态块走 prompt 前缀(见 runExternalTurn 文件头:往项目里写
538
+ // AGENTS.md/CLAUDE.md 会弄脏用户工作区,且三家读取口径实测不一致)。
539
+ let promptPrefix = '';
540
+ if (typeof getContextBlock === 'function') {
541
+ try { promptPrefix = await getContextBlock({ locale }); } catch (err) { logger.warn(`[agent] 外部引擎状态块获取失败: ${err?.message || err}`); }
542
+ }
543
+ // 常用目录状态块与内置引擎走同一份字符串(外部引擎没有"请求副本"可注入,
544
+ // 这段前缀只作用在本轮 prompt 上,不会写进项目里的任何文件)
545
+ if (dirStatusBlock) {
546
+ promptPrefix = promptPrefix ? `${dirStatusBlock}\n\n${promptPrefix}` : dirStatusBlock;
547
+ }
548
+ // 图片附件转成文件路径交给 CLI(它们收路径不收 base64)。走同一套落盘校验。
549
+ const imageAttachments = images.map((u, i) => ({ name: `pasted-${i + 1}.png`, dataUrl: u }));
550
+ const imageFiles = imageAttachments.length > 0
551
+ ? await saveAgentAttachments(imageAttachments, {
552
+ onSkip: (info) => console.warn('[agent] 图片附件被忽略:', info.name, info.reason)
553
+ }).catch((err) => {
554
+ console.warn('[agent] 图片附件落盘失败:', err?.message || err);
555
+ return [];
556
+ })
557
+ : [];
558
+ const filePaths = [...attachments.map(a => a.path), ...imageFiles.map(f => f.path)];
559
+
560
+ await runExternalTurn({
561
+ session,
562
+ engine: session.engine,
563
+ userMessage,
564
+ cwd,
565
+ promptPrefix,
566
+ filePaths,
567
+ signal: abortController.signal,
568
+ send,
569
+ onChild: (child) => { activeChild = child; },
570
+ });
571
+ } else {
572
+ // 运行 agent 循环(内置 g ai:服务端跑 LLM + 工具)
573
+ await runAgentTurn({
574
+ session,
575
+ model,
576
+ userMessage,
577
+ images,
578
+ cwd,
579
+ locale,
580
+ openFilePath,
581
+ attachments,
582
+ dirStatusBlock,
583
+ signal: abortController.signal,
584
+ send,
585
+ onChild: (child) => { activeChild = child; },
586
+ askUser: (args, meta = {}) => waitForAgentAnswer({
587
+ sessionId: session.sessionId,
588
+ interactionId: meta.interactionId,
589
+ question: args.question,
590
+ options: Array.isArray(args.options) ? args.options : [],
591
+ allowFreeText: args.allowFreeText !== false,
592
+ multiple: args.multiple === true,
593
+ send,
594
+ signal: abortController.signal,
595
+ }),
596
+ dispatchTask,
597
+ listProjects,
598
+ getContextBlock,
599
+ // 每轮请求的上下文预算(解析结果)。agentChat 拿它调 prepareRequestMessages,
600
+ // 与 CLI turn.js 同一条口径。
601
+ requestBudget
602
+ });
603
+ }
604
+
605
+ // 更新标题(新会话从第一条 user 消息自动生成)
606
+ if (isNew) {
607
+ session.title = autoTitle(session.messages);
608
+ }
609
+
610
+ // 持久化。中止(用户点"停止")的轮次同样要落盘 —— 磁盘上没有这条会话时,
611
+ // 前端停止后立刻刷新列表会把左栏这一条整个吞掉(表现为"一停止任务就没了")。
612
+ session.updatedAt = nowIso();
613
+ await writeSession(session.sessionId, session);
614
+ enforceRetention().catch(() => {});
615
+
616
+ finished = true;
617
+ res.end();
618
+ } catch (err) {
619
+ send({ type: 'error', error: '智能体对话失败: ' + (err?.message || String(err)) });
620
+ finished = true;
621
+ res.end();
622
+ } finally {
623
+ if (activeSessionKey) activeSessionTurns.delete(activeSessionKey);
624
+ }
625
+ });
626
+
627
+ // 把生成器交出去:调用方(server/index.js)要在服务启动后预热一次快照,
628
+ // 覆盖"g ui 刚启动"这个时机。预热调用刻意留在**生产入口**而不是这里 ——
629
+ // 单测会调 registerAgentRoutes 四次,预热写在这里等于测试机上 spawn 四轮 gh。
630
+ return { snapshotter: workspaceSnapshotter };
631
+ }