zen-gitsync 2.17.17 → 2.17.18

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 (62) hide show
  1. package/README.md +2 -0
  2. package/package.json +1 -1
  3. package/src/paths.js +10 -0
  4. package/src/ui/public/assets/{AgentView-CXZUWLPL.css → AgentView-AMFKSDeH.css} +1 -1
  5. package/src/ui/public/assets/AgentView-DQAB0ypS.js +13 -0
  6. package/src/ui/public/assets/{AppVersionBadge-D70AjIBo.js → AppVersionBadge-B5VG1_Lu.js} +2 -2
  7. package/src/ui/public/assets/{BranchSelector-t3fnGS_5.js → BranchSelector-Cj6athRO.js} +1 -1
  8. package/src/ui/public/assets/{CommitForm-DmlTnTwc.js → CommitForm-a2tMsfF9.js} +1 -1
  9. package/src/ui/public/assets/{CommonDialog-CecgvEzd.js → CommonDialog-I-GCqRMC.js} +1 -1
  10. package/src/ui/public/assets/EditorView-wTi53Nxc.js +16 -0
  11. package/src/ui/public/assets/{FileDiffViewer-DH8Y2lUO.js → FileDiffViewer-B7i4BmbH.js} +1 -1
  12. package/src/ui/public/assets/{FlowExecutionViewer-Cie2_TG4.js → FlowExecutionViewer-xZjrf8e1.js} +1 -1
  13. package/src/ui/public/assets/{FlowOrchestrationWorkspace-Bbf8X1KP.js → FlowOrchestrationWorkspace-R0QKmUpG.js} +1 -1
  14. package/src/ui/public/assets/{IconButton-BEiZI4F6.js → IconButton-B6eqvssJ.js} +1 -1
  15. package/src/ui/public/assets/{LogList-DvuC03Tx.js → LogList-BPqurE9g.js} +1 -1
  16. package/src/ui/public/assets/{MindmapView-B6rITyNs.js → MindmapView-NdFNlkTF.js} +1 -1
  17. package/src/ui/public/assets/{MonitorView-q92EZvay.js → MonitorView-DuSZa8a3.js} +1 -1
  18. package/src/ui/public/assets/{ProjectStartupButton-RiIi8TwW.js → ProjectStartupButton-BrfKVP-w.js} +1 -1
  19. package/src/ui/public/assets/{RemoteManagerDialog-qtBotYAQ.js → RemoteManagerDialog-BI7fRsOQ.js} +1 -1
  20. package/src/ui/public/assets/{RemoteRepoCard-DvtzfwHV.js → RemoteRepoCard-B4E8Ud3F.js} +1 -1
  21. package/src/ui/public/assets/{SourceMapView-EEWAmy4U.js → SourceMapView-B0OzHjxP.js} +1 -1
  22. package/src/ui/public/assets/{SvgIcon-Bkc_oGUI.js → SvgIcon-B2h7B2yF.js} +1 -1
  23. package/src/ui/public/assets/{TemplateManager-9e4amMlI.js → TemplateManager-mIWE3Qf8.js} +1 -1
  24. package/src/ui/public/assets/{UserInputNode-4FoJblJi.js → UserInputNode-BafC-B2U.js} +1 -1
  25. package/src/ui/public/assets/{WorkbenchView-BFaTinH8.js → WorkbenchView-BseGz8hJ.js} +5 -5
  26. package/src/ui/public/assets/{_plugin-vue_export-helper-BLGowG5t.js → _plugin-vue_export-helper-Dvuzpv2Y.js} +4 -4
  27. package/src/ui/public/assets/agentConversations-BEKqkGcm.js +5 -0
  28. package/src/ui/public/assets/{configStore-BUXv5i5j.js → configStore-CX0FkQ94.js} +1 -1
  29. package/src/ui/public/assets/{gitStore-BGbEYd0U.js → gitStore-CMuWWr2d.js} +1 -1
  30. package/src/ui/public/assets/{index-Di5cimoI.js → index-CB2cIiHl.js} +12 -12
  31. package/src/ui/public/assets/{previewSandbox-DEQpyVNu.js → previewSandbox-DxR4iN9P.js} +0 -0
  32. package/src/ui/public/index.html +10 -10
  33. package/src/ui/server/index.js +28 -2
  34. package/src/ui/server/routes/aiContext/collectors.js +360 -0
  35. package/src/ui/server/routes/aiContext/collectors.test.js +297 -0
  36. package/src/ui/server/routes/aiContext/index.js +444 -0
  37. package/src/ui/server/routes/aiContext/index.test.js +433 -0
  38. package/src/ui/server/routes/aiContext/jobRefresh.js +110 -0
  39. package/src/ui/server/routes/aiContext/jobRefresh.test.js +191 -0
  40. package/src/ui/server/routes/aiContext/render.js +366 -0
  41. package/src/ui/server/routes/aiContext/render.test.js +255 -0
  42. package/src/ui/server/routes/aiContext/wiring.js +80 -0
  43. package/src/ui/server/routes/fs.js +43 -0
  44. package/src/ui/server/routes/mindmap.js +36 -24
  45. package/src/ui/server/routes/monitor.js +44 -35
  46. package/src/ui/server/routes/remoteRepos.js +57 -48
  47. package/src/ui/server/routes/workbench/agentChat.js +35 -7
  48. package/src/ui/server/routes/workbench/agentChatContext.test.js +66 -1
  49. package/src/ui/server/routes/workbench/agentEngines.js +77 -0
  50. package/src/ui/server/routes/workbench/agentEngines.test.js +65 -0
  51. package/src/ui/server/routes/workbench/agentRoutes.js +169 -44
  52. package/src/ui/server/routes/workbench/agentRoutes.test.js +66 -4
  53. package/src/ui/server/routes/workbench/agentSessionStore.js +3 -0
  54. package/src/ui/server/routes/workbench/envContext.js +61 -64
  55. package/src/ui/server/routes/workbench/envContext.test.js +133 -63
  56. package/src/ui/server/routes/workbench/index.js +78 -32
  57. package/src/ui/server/routes/workbench/runExternalTurn.js +279 -0
  58. package/src/ui/server/routes/workbench/runExternalTurn.test.js +275 -0
  59. package/src/ui/server/routes/workbench/shared.js +17 -0
  60. package/src/ui/public/assets/AgentView-Dhs3eKFY.js +0 -7
  61. package/src/ui/public/assets/EditorView-DVRemzZU.js +0 -16
  62. package/src/ui/public/assets/agentConversations-BQCIv4u1.js +0 -5
@@ -252,9 +252,11 @@ ${isWin ? `- This is Windows. The following Unix commands do NOT exist here:
252
252
  // - askUser: (args, meta) => Promise<string> 等待用户回答
253
253
  // - listProjects: (args) => Promise<string> list_projects 工具的数据源
254
254
  // (由 agentRoutes 注入:最近目录/tasks.json/看板统计只有 GUI 侧拿得到)
255
+ // - getContextBlock: ({locale}) => Promise<string> 工作区状态快照的摘要块
256
+ // (由 agentRoutes 注入,实现在 routes/aiContext/:七个板块的摘要 + 落盘文件路径)
255
257
  //
256
258
  // 返回: { aborted: boolean }
257
- export async function runAgentTurn({ session, model, userMessage, images = [], cwd, locale, openFilePath, attachments = [], signal, send, onChild, askUser, listProjects }) {
259
+ export async function runAgentTurn({ session, model, userMessage, images = [], cwd, locale, openFilePath, attachments = [], signal, send, onChild, askUser, listProjects, getContextBlock }) {
258
260
  const ctx = { cwd, locale, onChild, askUser, listProjects };
259
261
 
260
262
  // 确保 session.messages 存在
@@ -279,6 +281,19 @@ export async function runAgentTurn({ session, model, userMessage, images = [], c
279
281
  : userMessage
280
282
  });
281
283
 
284
+ // 工作区状态快照:整轮只取一次(TTL 在生成器内部,见 aiContext/index.js),
285
+ // 不放进循环 —— 否则每执行一次工具调用都要重拼一遍,而这轮对话里它不会变。
286
+ // 取不到就空着跳过:快照是**锦上添花**,不能因为它挂了就让用户这条消息发不出去。
287
+ let workspaceBlock = '';
288
+ if (typeof getContextBlock === 'function') {
289
+ try {
290
+ workspaceBlock = (await getContextBlock({ locale })) || '';
291
+ } catch (err) {
292
+ logger.warn(`[agentChat] 取工作区状态快照失败,本轮跳过注入: ${err?.message || err}`);
293
+ workspaceBlock = '';
294
+ }
295
+ }
296
+
282
297
  const maxIterations = await resolveMaxToolIterations();
283
298
 
284
299
  for (let iter = 0; iter < maxIterations; iter++) {
@@ -286,8 +301,9 @@ export async function runAgentTurn({ session, model, userMessage, images = [], c
286
301
  // 摘录成一条梗概 → 旧图片降级 → provider 兼容消毒。
287
302
  // 只作用于副本,session.messages 保持完整(与 CLI 的磁盘口径一致)。
288
303
  const messages = prepareRequestMessages(session.messages, { locale });
289
- // 请求级上下文:当前打开的文档 + 本轮附件路径(只改副本,不落 session.messages,下一轮不重复累积)
290
- injectRequestContext(messages, { cwd, openFilePath, attachments, locale });
304
+ // 请求级上下文:工作区状态快照 + 当前打开的文档 + 本轮附件路径
305
+ // (只改副本,不落 session.messages,下一轮不重复累积)
306
+ injectRequestContext(messages, { cwd, openFilePath, attachments, locale, workspaceBlock });
291
307
 
292
308
  let result;
293
309
  try {
@@ -417,18 +433,30 @@ function summarizeArgs(name, args) {
417
433
  }
418
434
  }
419
435
 
420
- // ── 请求级上下文注入(文件空间对话 / 本轮附件) ──────────────
421
- // 把"用户当前打开的文件"与"本轮附件的落盘路径"追加到**请求副本**的 system 消息末尾:
422
- // 只影响这一次请求,session.messages 与磁盘历史保持原样,下一轮也不会重复累积。
436
+ // ── 请求级上下文注入(工作区状态 / 文件空间对话 / 本轮附件) ──────────────
437
+ // 把"工作区各板块的状态摘要""用户当前打开的文件"与"本轮附件的落盘路径"追加到
438
+ // **请求副本**的 system 消息末尾:只影响这一次请求,session.messages 与磁盘历史保持原样,
439
+ // 下一轮也不会重复累积。
440
+ //
441
+ // ⚠️ 工作区快照**必须走这条副本路径,不能塞进 session.messages 里那条 system 消息**。
442
+ // 那条只在首轮 push 一次(见上面 `session.messages.length === 0` 的判断)并会落盘,
443
+ // 快照进去就等于永久停在"会话创建那天"——git 分支、任务进度全会是过期的,
444
+ // 而且这种错不会报错、只会让模型理直气壮地给出错答案。有单测钉住这一点。
423
445
  //
424
446
  // 附件为什么只给路径、不给内容:见 utils/agentAttachments.js 的头注释 —— 非图片附件
425
447
  // 由服务端落盘,模型自己用 read / grep 按需取,比把几百 KB 文本内联进消息省得多。
426
- export function injectRequestContext(messages, { cwd, openFilePath, attachments = [], locale }) {
448
+ // 快照块同理,只给摘要与目录路径,板块正文由模型按需读。
449
+ export function injectRequestContext(messages, { cwd, openFilePath, attachments = [], locale, workspaceBlock = '' }) {
427
450
  if (!Array.isArray(messages)) return;
428
451
  const en = String(locale || '').startsWith('en');
429
452
 
430
453
  const parts = [];
431
454
 
455
+ // ⓪ 工作区状态快照(七个板块的摘要 + 落盘目录)。由 aiContext 生成,只在这个副本里。
456
+ if (typeof workspaceBlock === 'string' && workspaceBlock.trim()) {
457
+ parts.push(workspaceBlock.trim());
458
+ }
459
+
432
460
  // ① 当前打开的文档(文件空间对话才有;项目外或等于根目录直接忽略)
433
461
  if (openFilePath) {
434
462
  const root = cwd || process.cwd();
@@ -4,7 +4,7 @@
4
4
  import { test } from 'node:test'
5
5
  import assert from 'node:assert/strict'
6
6
  import path from 'node:path'
7
- import { injectRequestContext } from './agentChat.js'
7
+ import { injectRequestContext, buildWebSystemPrompt } from './agentChat.js'
8
8
 
9
9
  const ROOT = path.resolve('C:/proj')
10
10
 
@@ -104,3 +104,68 @@ test('injectRequestContext: 附件条数超过 20 时截断(防御性上限)', (
104
104
  assert.match(messages[0].content, /用户本轮附带了 20 个文件/)
105
105
  assert.ok(!messages[0].content.includes('f24.txt'), '第 21 个之后不该出现')
106
106
  })
107
+
108
+ // ── 工作区状态快照(七个板块) ────────────────────────────────
109
+ // 这一段守的是两条容易静默出错的规则:
110
+ // 1) 快照只进**请求副本**,绝不进那条会落盘的 system 消息 —— 否则它会永久停在
111
+ // 会话创建那天(首轮之后不再重建),而且不报错、只是答案悄悄过期。
112
+ // 2) 快照空 / 取不到时不留下空段、不影响其它上下文。
113
+
114
+ const SNAPSHOT = '[工作区状态 · 由 zen-gitsync 服务端自动生成,不是用户输入的内容]\n\n- 当前项目 Git 状态 | git-current.md | 分支 main,工作区干净'
115
+
116
+ test('injectRequestContext: 工作区快照注入请求副本,且排在其它上下文之前', () => {
117
+ const messages = [{ role: 'system', content: 'rules' }, { role: 'user', content: 'hi' }]
118
+ injectRequestContext(messages, { cwd: ROOT, openFilePath: 'src/a.md', locale: 'zh', workspaceBlock: SNAPSHOT })
119
+ const sys = messages[0].content
120
+ assert.match(sys, /^rules\n\n# 当前上下文/)
121
+ assert.ok(sys.includes('工作区状态'), '快照必须在')
122
+ assert.ok(sys.indexOf('工作区状态') < sys.indexOf('src/a.md'), '快照应排在"当前打开的文件"之前')
123
+ assert.equal(messages[1].content, 'hi', '其它消息不受影响')
124
+ })
125
+
126
+ test('注入路径不会污染会话记录:快照不进那条会落盘的 system 消息', () => {
127
+ // 首轮 push 进 session.messages 的 system 提示词(会落盘、之后不再重建)
128
+ const persistent = buildWebSystemPrompt({ cwd: ROOT, locale: 'zh' })
129
+ assert.ok(!persistent.includes('工作区状态'), '落库的 system 里不许有快照');
130
+ assert.ok(!persistent.includes('快照目录'), '落库的 system 里不许有快照路径');
131
+
132
+ // 每轮从完整会话记录重建副本 → 快照出现且只出现一次,不会跨轮累积
133
+ const session = [{ role: 'system', content: 'rules' }]
134
+ for (let i = 0; i < 3; i++) {
135
+ const copy = session.map(m => ({ ...m }))
136
+ injectRequestContext(copy, { cwd: ROOT, locale: 'zh', workspaceBlock: SNAPSHOT })
137
+ assert.equal((copy[0].content.match(/工作区状态/g) || []).length, 1)
138
+ }
139
+ assert.equal(session[0].content, 'rules', '原始会话记录保持干净')
140
+ })
141
+
142
+ test('injectRequestContext: 快照为空 / 只有空白时不注入、不留空段', () => {
143
+ const empty = [{ role: 'system', content: 'rules' }]
144
+ injectRequestContext(empty, { cwd: ROOT, locale: 'zh', workspaceBlock: '' })
145
+ assert.equal(empty[0].content, 'rules')
146
+
147
+ const blank = [{ role: 'system', content: 'rules' }]
148
+ injectRequestContext(blank, { cwd: ROOT, locale: 'zh', workspaceBlock: ' \n ' })
149
+ assert.equal(blank[0].content, 'rules')
150
+
151
+ // 没传这个参数时(老调用点)行为与从前完全一致
152
+ const legacy = [{ role: 'system', content: 'rules' }]
153
+ injectRequestContext(legacy, { cwd: ROOT, locale: 'zh' })
154
+ assert.equal(legacy[0].content, 'rules')
155
+ })
156
+
157
+ test('injectRequestContext: 快照 + 文档 + 附件三者可以共存,共用一个标题段', () => {
158
+ const messages = [{ role: 'system', content: 'rules' }]
159
+ injectRequestContext(messages, {
160
+ cwd: ROOT,
161
+ locale: 'zh',
162
+ workspaceBlock: SNAPSHOT,
163
+ openFilePath: 'src/a.md',
164
+ attachments: [{ name: 'a.log', path: '/tmp/att/a.log' }]
165
+ })
166
+ const sys = messages[0].content
167
+ assert.equal((sys.match(/# 当前上下文/g) || []).length, 1, '只加一个标题段')
168
+ assert.ok(sys.includes('工作区状态'))
169
+ assert.ok(sys.includes('src/a.md'))
170
+ assert.ok(sys.includes('用户本轮附带了 1 个文件'))
171
+ })
@@ -0,0 +1,77 @@
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
+ //
19
+ // gai ── 内置。**服务端**跑循环:LLM 流式 + 服务端执行工具(src/cli/ai/tools.js),
20
+ // 前端只渲染事件。默认引擎,历史行为。
21
+ //
22
+ // claude / opencode / codex ── 外部 CLI。**CLI 自己**跑循环:它有自己的工具集、
23
+ // 自己的系统提示词、自己的权限模型。服务端退化成一根管子 —— spawn 出来、
24
+ // 把它的 NDJSON 翻译成同一组 SSE 事件。见 runExternalTurn.js。
25
+ //
26
+ // ⚠️ 这儿**不新写一份 id 清单**:外部三家直接复用 config.js 的 TASK_EXECUTORS。
27
+ // 本仓库为"同一份口径在多个地方各写一遍"吃过亏(见 agentParity.test.js 的注释:
28
+ // 提示词四处、路径归一三处,分叉的失效方式是「不报错,两个入口表现不一样」)。
29
+ // 工作台那份是任务执行器,这里是对话引擎,但**底层是同一批 CLI**,加第四家时
30
+ // 应当只改 config.js 一处。有单测钉住这条。
31
+
32
+ import { TASK_EXECUTORS } from '../../../../config.js';
33
+
34
+ /** 内置引擎 id。用 'gai' 而不是 'g-ai'/'zen':前端路由与 i18n key 都用它。 */
35
+ export const BUILTIN_ENGINE = 'gai';
36
+
37
+ /** 外部 CLI 引擎(顺序继承 config.js,与工作台执行器列表一致) */
38
+ export const EXTERNAL_ENGINES = TASK_EXECUTORS;
39
+
40
+ /** 全部可选引擎,内置排第一(前端下拉默认选中第一项) */
41
+ export const AGENT_ENGINES = [BUILTIN_ENGINE, ...EXTERNAL_ENGINES];
42
+
43
+ /**
44
+ * 展示名。产品名中英文一致、不走 i18n —— 与前端 utils/taskExecutor.ts 的
45
+ * TASK_EXECUTOR_OPTIONS 同一条判断("Claude Code" 在中文界面里也不译)。
46
+ */
47
+ export const ENGINE_LABELS = {
48
+ [BUILTIN_ENGINE]: 'g ai',
49
+ claude: 'Claude Code',
50
+ opencode: 'OpenCode',
51
+ codex: 'Codex',
52
+ };
53
+
54
+ export function isAgentEngine(value) {
55
+ return typeof value === 'string' && AGENT_ENGINES.includes(value);
56
+ }
57
+
58
+ export function isExternalEngine(value) {
59
+ return typeof value === 'string' && EXTERNAL_ENGINES.includes(value);
60
+ }
61
+
62
+ /**
63
+ * 归一化:非法/缺省一律回落内置引擎。
64
+ *
65
+ * 为什么回落而不是报错:body.engine 来自网络,老前端不会带这个字段。
66
+ * 报错会让所有老客户端在升级服务端后立刻不能聊天 —— 回落才是安全的兼容方向
67
+ * (与 taskRunner.normalizeTaskExecutor 的取舍一致)。
68
+ */
69
+ export function normalizeAgentEngine(value) {
70
+ if (typeof value !== 'string') return BUILTIN_ENGINE;
71
+ const v = value.trim().toLowerCase();
72
+ return isAgentEngine(v) ? v : BUILTIN_ENGINE;
73
+ }
74
+
75
+ export function engineLabel(id) {
76
+ return ENGINE_LABELS[normalizeAgentEngine(id)];
77
+ }
@@ -0,0 +1,65 @@
1
+ // 引擎注册表单测。
2
+ //
3
+ // 最要紧的一条是**不新写清单**:外部三家必须与 config.js 的 TASK_EXECUTORS 同源。
4
+ // 本仓库为"同一份口径各写一遍"吃过亏(提示词四处、路径归一三处),分叉的失效方式是
5
+ // 「不报错,两个入口表现不一样」—— 加第四家 CLI 时若只改了工作台,这里就会静默漏掉。
6
+ import { test } from 'node:test';
7
+ import assert from 'node:assert/strict';
8
+
9
+ import {
10
+ AGENT_ENGINES,
11
+ BUILTIN_ENGINE,
12
+ ENGINE_LABELS,
13
+ EXTERNAL_ENGINES,
14
+ engineLabel,
15
+ isAgentEngine,
16
+ isExternalEngine,
17
+ normalizeAgentEngine,
18
+ } from './agentEngines.js';
19
+ import { TASK_EXECUTORS } from '../../../../config.js';
20
+
21
+ test('外部引擎清单与 config.js 同源(加第四家只该改一处)', () => {
22
+ assert.deepEqual(EXTERNAL_ENGINES, TASK_EXECUTORS, '外部引擎必须直接复用 TASK_EXECUTORS');
23
+ assert.equal(EXTERNAL_ENGINES, TASK_EXECUTORS, '应当是同一个数组引用,不是复制一份');
24
+ });
25
+
26
+ test('内置引擎排第一(前端下拉默认选首项)', () => {
27
+ assert.equal(AGENT_ENGINES[0], BUILTIN_ENGINE);
28
+ assert.equal(BUILTIN_ENGINE, 'gai');
29
+ assert.equal(new Set(AGENT_ENGINES).size, AGENT_ENGINES.length, 'id 不能重复');
30
+ });
31
+
32
+ test('每个引擎都有展示名', () => {
33
+ for (const id of AGENT_ENGINES) {
34
+ assert.ok(ENGINE_LABELS[id], `${id} 缺展示名`);
35
+ }
36
+ });
37
+
38
+ test('normalizeAgentEngine:非法值一律回落内置引擎(老前端不带这个字段)', () => {
39
+ assert.equal(normalizeAgentEngine(undefined), 'gai');
40
+ assert.equal(normalizeAgentEngine(null), 'gai');
41
+ assert.equal(normalizeAgentEngine(''), 'gai');
42
+ assert.equal(normalizeAgentEngine(' '), 'gai');
43
+ assert.equal(normalizeAgentEngine('CLAUDE'), 'claude', '要容错大小写与首尾空格');
44
+ assert.equal(normalizeAgentEngine(' claude '), 'claude');
45
+ assert.equal(normalizeAgentEngine('gemini'), 'gai', '未知引擎回落,而不是报错');
46
+ assert.equal(normalizeAgentEngine(42), 'gai');
47
+ });
48
+
49
+ test('isAgentEngine / isExternalEngine 的边界', () => {
50
+ assert.equal(isAgentEngine('gai'), true);
51
+ assert.equal(isAgentEngine('claude'), true);
52
+ assert.equal(isAgentEngine('nope'), false);
53
+ assert.equal(isAgentEngine(undefined), false);
54
+ // gai 是内置的,不是外部引擎 —— 两条链路的分派全靠这个判据
55
+ assert.equal(isExternalEngine('gai'), false);
56
+ assert.equal(isExternalEngine('claude'), true);
57
+ assert.equal(isExternalEngine('opencode'), true);
58
+ assert.equal(isExternalEngine('codex'), true);
59
+ });
60
+
61
+ test('engineLabel:未知值回落内置引擎名(不显示 undefined)', () => {
62
+ assert.equal(engineLabel('claude'), 'Claude Code');
63
+ assert.equal(engineLabel('gai'), 'g ai');
64
+ assert.equal(engineLabel('whatever'), 'g ai');
65
+ });
@@ -25,10 +25,13 @@ import path from 'node:path';
25
25
  import { asyncRoute, HttpError } from '../../utils/asyncRoute.js';
26
26
  import { agentSessionStore } from './agentSessionStore.js';
27
27
  import { runAgentTurn } from './agentChat.js';
28
+ import { runExternalTurn } from './runExternalTurn.js';
29
+ import { normalizeAgentEngine, isExternalEngine, engineLabel } from './agentEngines.js';
28
30
  import { saveAgentAttachments, MAX_ATTACHMENTS } from '../../utils/agentAttachments.js';
29
31
  import { registerAgentMarketplaceRoutes } from './agentMarketplace.js';
30
32
  import { createProjectListProvider } from './projectTool.js';
31
- import { nowIso } from './shared.js';
33
+ import { createWorkspaceSnapshotter } from '../aiContext/wiring.js';
34
+ import { nowIso, logger } from './shared.js';
32
35
 
33
36
  const { genSessionId, autoTitle, read: readSession, write: writeSession, delete: deleteSession, listMeta: listSessionsMeta, enforceRetention, rename: renameSession } = agentSessionStore;
34
37
 
@@ -114,7 +117,7 @@ export function submitAgentAnswer({ sessionId, interactionId, answer }) {
114
117
  * @param {() => string} deps.getCurrentProjectPath
115
118
  * @param {Object} deps.configManager
116
119
  */
117
- export function registerAgentRoutes({ app, getCurrentProjectPath, configManager }) {
120
+ export function registerAgentRoutes({ app, getCurrentProjectPath, configManager, snapshotter }) {
118
121
 
119
122
  registerAgentMarketplaceRoutes({ app, getCurrentProjectPath });
120
123
 
@@ -123,10 +126,61 @@ export function registerAgentRoutes({ app, getCurrentProjectPath, configManager
123
126
  // 不给它加缓存:agent 一轮里最多问一两次,而"项目状态"本来就要现读才准。
124
127
  const listProjects = createProjectListProvider({ configManager, getCurrentProjectPath });
125
128
 
129
+ // 工作区状态快照(七个板块的摘要 + 落盘文件路径)。同样在这里建一次、复用 ——
130
+ // 它要读配置、tasks.json、执行记录、git 状态、监控与思维导图,这些依赖本层一并拿得最全。
131
+ //
132
+ // `snapshotter` 是个**只为测试留的注入口**(与 remoteRepos 的 *Impl 同一套做法):
133
+ // 生产不传,走真实实现;单测传一个空壳,免得跑一次对话就在测试机上 spawn `gh`
134
+ // 和 PowerShell、还去连一次网。默认值写成参数默认值,不传时才构造。
135
+ const workspaceSnapshotter = snapshotter || createWorkspaceSnapshotter({ configManager, getCurrentProjectPath });
136
+ const getContextBlock = ({ locale } = {}) => workspaceSnapshotter.getBlock({ locale });
137
+
126
138
  // ════════════════════════════════════════════════════════════════════════
127
139
  // §1. 会话列表
128
140
  // ════════════════════════════════════════════════════════════════════════
141
+ /**
142
+ * 预热工作区状态快照 —— 这是"初始加载"该做的事,但**不是**把七个界面板块都挂起来。
143
+ *
144
+ * 用户打开智能体页时前端会拉这个会话列表,此刻就顺手在后台把快照生成好;
145
+ * 等他打完字发出去,快照早已就绪,那一轮就能带上完整上下文。
146
+ * 刻意不 await:它要跑 gh/gitee/git/PowerShell,等它会让会话列表白屏几秒。
147
+ * 也刻意不放在 registerAgentRoutes 里:启动时预热会让"打开服务"这条路径凭空多几次
148
+ * 子进程与网络调用,而用户可能压根不会打开智能体页。
149
+ */
150
+ function warmSnapshotInBackground() {
151
+ try {
152
+ if (typeof workspaceSnapshotter?.warm === 'function') workspaceSnapshotter.warm().catch(() => {});
153
+ } catch (err) {
154
+ logger.warn(`[agentRoutes] 预热工作区快照失败: ${err?.message || err}`);
155
+ }
156
+ }
157
+
158
+ /**
159
+ * POST /api/ai-context/refresh body: { sections?: string[], force?: boolean }
160
+ *
161
+ * 给前端"切到某个面板就更新对应板块"用的定向刷新口。**按板块**而不是全量:
162
+ * 七个板块的取数成本差两个数量级,切个 Git 面板没必要顺带联网拉一遍仓库列表。
163
+ *
164
+ * 前端一律 fire-and-forget 调它(不等响应) —— 一次全量要 6~7 秒,没有哪个 UI 该等它。
165
+ * 真正拖慢也不怕:force 仍受各板块自己的 forceTtlMs 地板限制,联网板块最多 60s 一次。
166
+ *
167
+ * 顺带把各板块状态回给调用方(采集时间/是否新鲜),排查"为什么模型看到的是旧的"时很好用。
168
+ */
169
+ app.post('/api/ai-context/refresh', asyncRoute(async (req, res) => {
170
+ const body = req.body || {};
171
+ const ids = Array.isArray(body.sections) ? body.sections.map(String) : undefined;
172
+ const force = body.force === true;
173
+ const state = await workspaceSnapshotter.refreshSections(ids, { force });
174
+ res.json({ success: true, ...state });
175
+ }));
176
+
177
+ /** GET 版本:只读回各板块状态,不触发任何取数(给未来做面板用) */
178
+ app.get('/api/ai-context/state', asyncRoute(async (_req, res) => {
179
+ res.json({ success: true, ...workspaceSnapshotter.getState() });
180
+ }));
181
+
129
182
  app.get('/api/agent/sessions', asyncRoute(async (req, res) => {
183
+ warmSnapshotInBackground();
130
184
  let sessions = await listSessionsMeta();
131
185
  // 按项目隔离:前端传当前项目路径时只返回该项目的会话
132
186
  const cwdFilter = String(req.query?.cwd || '').trim();
@@ -271,6 +325,25 @@ export function registerAgentRoutes({ app, getCurrentProjectPath, configManager
271
325
  finished = true;
272
326
  return res.end();
273
327
  }
328
+ // 引擎在会话**建立时就锁死**,中途不许换。
329
+ // 为什么:三家 CLI 的续聊标识互不通用(claude --resume / opencode --session /
330
+ // codex exec resume),而且历史消息格式不同(我们存的是 OpenAI chat completions
331
+ // 形状,喂给 claude 没有意义)。允许中途切换的表现是"看起来切了,上下文却串了"——
332
+ // 与工作台 taskExecutor.ts 注释里那条"续哪个执行器由上一轮 job.agent 决定"同源。
333
+ const requestedEngine = req.body?.engine === undefined
334
+ ? null
335
+ : normalizeAgentEngine(req.body?.engine);
336
+ const sessionEngine = normalizeAgentEngine(session.engine);
337
+ if (requestedEngine && requestedEngine !== sessionEngine && Array.isArray(session.messages) && session.messages.length > 0) {
338
+ send({
339
+ type: 'error',
340
+ error: `该会话由 ${engineLabel(sessionEngine)} 运行,无法中途切换引擎。请新建会话再选 ${engineLabel(requestedEngine)}。`,
341
+ code: 'ENGINE_LOCKED'
342
+ });
343
+ finished = true;
344
+ return res.end();
345
+ }
346
+ session.engine = sessionEngine;
274
347
  } else {
275
348
  session = {
276
349
  version: 1,
@@ -279,6 +352,11 @@ export function registerAgentRoutes({ app, getCurrentProjectPath, configManager
279
352
  source: 'web',
280
353
  cwd: currentProjectCwd,
281
354
  model: '',
355
+ // 引擎:gai(内置,默认)| claude | opencode | codex
356
+ engine: normalizeAgentEngine(req.body?.engine),
357
+ // 外部 CLI 自己的续聊标识(claude session_id / opencode sessionID /
358
+ // codex thread_id)。g ai 用不到,留着空串。
359
+ engineSessionId: '',
282
360
  createdAt: nowIso(),
283
361
  updatedAt: nowIso(),
284
362
  messages: []
@@ -299,27 +377,35 @@ export function registerAgentRoutes({ app, getCurrentProjectPath, configManager
299
377
  return [];
300
378
  });
301
379
 
302
- // 获取模型配置
303
- let model;
304
- try {
305
- if (!configManager) throw new Error('configManager 不可用');
306
- const rawConfig = await configManager.readRawConfigFile();
307
- const models = Array.isArray(rawConfig.models) ? rawConfig.models : [];
308
- model = models.find(m => m.isDefault) || models[0];
309
- } catch (err) {
310
- send({ type: 'error', error: '读取 AI 配置失败: ' + err.message });
311
- finished = true;
312
- return res.end();
313
- }
314
- if (!model) {
315
- send({ type: 'error', error: '未配置 AI 模型,请先在通用设置中添加模型' });
316
- finished = true;
317
- return res.end();
380
+ // 获取模型配置。**只有内置 g ai 需要** —— 外部 CLI 用自己的模型与配置
381
+ // (claude/opencode/codex 各自的 CLI 配置;本机上 Codex 甚至是走本地代理跑
382
+ // deepseek-v4.1-flash)。为外部引擎要求"先在设置里配模型"是错的:
383
+ // 明明能跑,却先被一道无关的校验拦住。
384
+ const useExternal = isExternalEngine(session.engine);
385
+ let model = null;
386
+ if (!useExternal) {
387
+ try {
388
+ if (!configManager) throw new Error('configManager 不可用');
389
+ const rawConfig = await configManager.readRawConfigFile();
390
+ const models = Array.isArray(rawConfig.models) ? rawConfig.models : [];
391
+ model = models.find(m => m.isDefault) || models[0];
392
+ } catch (err) {
393
+ send({ type: 'error', error: '读取 AI 配置失败: ' + err.message });
394
+ finished = true;
395
+ return res.end();
396
+ }
397
+ if (!model) {
398
+ send({ type: 'error', error: '未配置 AI 模型,请先在通用设置中添加模型' });
399
+ finished = true;
400
+ return res.end();
401
+ }
402
+ // 更新 session 的 model 信息
403
+ session.model = `${model.model || ''} (${model.name || ''})`;
404
+ } else {
405
+ // 外部引擎:不落具体模型名(它随 CLI 自己的配置变),只记引擎,便于列表里辨认
406
+ session.model = '';
318
407
  }
319
408
 
320
- // 更新 session 的 model 信息
321
- session.model = `${model.model || ''} (${model.name || ''})`;
322
-
323
409
  // 工作目录
324
410
  const cwd = session.cwd || (typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : process.cwd());
325
411
 
@@ -331,31 +417,65 @@ export function registerAgentRoutes({ app, getCurrentProjectPath, configManager
331
417
  title: isNew ? autoTitle([{ role: 'user', content: userMessage }]) : session.title
332
418
  });
333
419
 
334
- // 运行 agent 循环
335
- await runAgentTurn({
336
- session,
337
- model,
338
- userMessage,
339
- images,
340
- cwd,
341
- locale,
342
- openFilePath,
343
- attachments,
344
- signal: abortController.signal,
345
- send,
346
- onChild: (child) => { activeChild = child; },
347
- askUser: (args, meta = {}) => waitForAgentAnswer({
348
- sessionId: session.sessionId,
349
- interactionId: meta.interactionId,
350
- question: args.question,
351
- options: Array.isArray(args.options) ? args.options : [],
352
- allowFreeText: args.allowFreeText !== false,
353
- multiple: args.multiple === true,
420
+ if (useExternal) {
421
+ // 外部 CLI 引擎:CLI 自己跑循环,服务端只做 spawn + 事件翻译。
422
+ // 工作区状态块走 prompt 前缀(见 runExternalTurn 文件头:往项目里写
423
+ // AGENTS.md/CLAUDE.md 会弄脏用户工作区,且三家读取口径实测不一致)。
424
+ let promptPrefix = '';
425
+ if (typeof getContextBlock === 'function') {
426
+ try { promptPrefix = await getContextBlock({ locale }); } catch (err) { logger.warn(`[agent] 外部引擎状态块获取失败: ${err?.message || err}`); }
427
+ }
428
+ // 图片附件转成文件路径交给 CLI(它们收路径不收 base64)。走同一套落盘校验。
429
+ const imageAttachments = images.map((u, i) => ({ name: `pasted-${i + 1}.png`, dataUrl: u }));
430
+ const imageFiles = imageAttachments.length > 0
431
+ ? await saveAgentAttachments(imageAttachments, {
432
+ onSkip: (info) => console.warn('[agent] 图片附件被忽略:', info.name, info.reason)
433
+ }).catch((err) => {
434
+ console.warn('[agent] 图片附件落盘失败:', err?.message || err);
435
+ return [];
436
+ })
437
+ : [];
438
+ const filePaths = [...attachments.map(a => a.path), ...imageFiles.map(f => f.path)];
439
+
440
+ await runExternalTurn({
441
+ session,
442
+ engine: session.engine,
443
+ userMessage,
444
+ cwd,
445
+ promptPrefix,
446
+ filePaths,
447
+ signal: abortController.signal,
354
448
  send,
449
+ onChild: (child) => { activeChild = child; },
450
+ });
451
+ } else {
452
+ // 运行 agent 循环(内置 g ai:服务端跑 LLM + 工具)
453
+ await runAgentTurn({
454
+ session,
455
+ model,
456
+ userMessage,
457
+ images,
458
+ cwd,
459
+ locale,
460
+ openFilePath,
461
+ attachments,
355
462
  signal: abortController.signal,
356
- }),
357
- listProjects
358
- });
463
+ send,
464
+ onChild: (child) => { activeChild = child; },
465
+ askUser: (args, meta = {}) => waitForAgentAnswer({
466
+ sessionId: session.sessionId,
467
+ interactionId: meta.interactionId,
468
+ question: args.question,
469
+ options: Array.isArray(args.options) ? args.options : [],
470
+ allowFreeText: args.allowFreeText !== false,
471
+ multiple: args.multiple === true,
472
+ send,
473
+ signal: abortController.signal,
474
+ }),
475
+ listProjects,
476
+ getContextBlock
477
+ });
478
+ }
359
479
 
360
480
  // 更新标题(新会话从第一条 user 消息自动生成)
361
481
  if (isNew) {
@@ -378,4 +498,9 @@ export function registerAgentRoutes({ app, getCurrentProjectPath, configManager
378
498
  if (activeSessionKey) activeSessionTurns.delete(activeSessionKey);
379
499
  }
380
500
  });
501
+
502
+ // 把生成器交出去:调用方(server/index.js)要在服务启动后预热一次快照,
503
+ // 覆盖"g ui 刚启动"这个时机。预热调用刻意留在**生产入口**而不是这里 ——
504
+ // 单测会调 registerAgentRoutes 四次,预热写在这里等于测试机上 spawn 四轮 gh。
505
+ return { snapshotter: workspaceSnapshotter };
381
506
  }