zen-gitsync 2.17.16 → 2.17.17

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 (120) hide show
  1. package/package.json +1 -1
  2. package/src/paths.js +3 -0
  3. package/src/ui/public/assets/AgentView-CXZUWLPL.css +1 -0
  4. package/src/ui/public/assets/AgentView-Dhs3eKFY.js +7 -0
  5. package/src/ui/public/assets/AppVersionBadge-D70AjIBo.js +2 -0
  6. package/src/ui/public/assets/AppVersionBadge-DDYQqoy7.css +1 -0
  7. package/src/ui/public/assets/BranchSelector-F4ibldMN.css +1 -0
  8. package/src/ui/public/assets/BranchSelector-t3fnGS_5.js +1 -0
  9. package/src/ui/public/assets/CommitForm-4kI2csgs.css +1 -0
  10. package/src/ui/public/assets/CommitForm-DmlTnTwc.js +6 -0
  11. package/src/ui/public/assets/CommonDialog-CecgvEzd.js +1 -0
  12. package/src/ui/public/assets/CommonDialog-w6EmBEXv.css +1 -0
  13. package/src/ui/public/assets/EditorView-DBqhjm84.css +1 -0
  14. package/src/ui/public/assets/EditorView-DVRemzZU.js +16 -0
  15. package/src/ui/public/assets/FileDiffViewer-DH8Y2lUO.js +15 -0
  16. package/src/ui/public/assets/FileDiffViewer-n5ea9qMe.css +1 -0
  17. package/src/ui/public/assets/{FlowExecutionViewer-Drw4hlJG.js → FlowExecutionViewer-Cie2_TG4.js} +1 -1
  18. package/src/ui/public/assets/FlowOrchestrationWorkspace-Bbf8X1KP.js +21 -0
  19. package/src/ui/public/assets/FlowOrchestrationWorkspace-DwuSSxJ1.css +1 -0
  20. package/src/ui/public/assets/IconButton-BEiZI4F6.js +1 -0
  21. package/src/ui/public/assets/IconButton-RlXqKOeu.css +1 -0
  22. package/src/ui/public/assets/LogList-DvuC03Tx.js +7 -0
  23. package/src/ui/public/assets/LogList-XspUhx-C.css +1 -0
  24. package/src/ui/public/assets/MindmapView-B6rITyNs.js +2 -0
  25. package/src/ui/public/assets/MindmapView-Diq2Cu4O.css +1 -0
  26. package/src/ui/public/assets/MonitorView-DwUwJXnY.css +1 -0
  27. package/src/ui/public/assets/MonitorView-q92EZvay.js +1 -0
  28. package/src/ui/public/assets/ProjectStartupButton-CB8IOg1E.css +1 -0
  29. package/src/ui/public/assets/ProjectStartupButton-RiIi8TwW.js +1 -0
  30. package/src/ui/public/assets/RemoteManagerDialog-D4WphMtO.css +1 -0
  31. package/src/ui/public/assets/RemoteManagerDialog-qtBotYAQ.js +4 -0
  32. package/src/ui/public/assets/RemoteRepoCard-DvtzfwHV.js +7 -0
  33. package/src/ui/public/assets/RemoteRepoCard-o01IrbVz.css +1 -0
  34. package/src/ui/public/assets/SourceMapView-CfDydtsu.css +1 -0
  35. package/src/ui/public/assets/SourceMapView-EEWAmy4U.js +3 -0
  36. package/src/ui/public/assets/SvgIcon-Bkc_oGUI.js +1 -0
  37. package/src/ui/public/assets/TemplateManager-9e4amMlI.js +1 -0
  38. package/src/ui/public/assets/TemplateManager-DO7EPfj8.css +1 -0
  39. package/src/ui/public/assets/UserInputNode-4FoJblJi.js +1 -0
  40. package/src/ui/public/assets/UserInputNode-DDNSarag.css +1 -0
  41. package/src/ui/public/assets/WorkbenchView-BFaTinH8.js +56 -0
  42. package/src/ui/public/assets/WorkbenchView-BU2kJCAA.css +1 -0
  43. package/src/ui/public/assets/{_plugin-vue_export-helper-dg2VUnSQ.js → _plugin-vue_export-helper-BLGowG5t.js} +7 -7
  44. package/src/ui/public/assets/agentConversations-BQCIv4u1.js +5 -0
  45. package/src/ui/public/assets/{configStore-DtjAc3w9.js → configStore-BUXv5i5j.js} +1 -1
  46. package/src/ui/public/assets/element-plus-C1xzgNSR.css +1 -0
  47. package/src/ui/public/assets/{element-plus-DxBPQwL7.js → element-plus-D7LDHcAn.js} +3 -3
  48. package/src/ui/public/assets/flow-mindmap-zXXF3qDh.js +139 -0
  49. package/src/ui/public/assets/gitStore-BGbEYd0U.js +6 -0
  50. package/src/ui/public/assets/index-DIU5200G.css +1 -0
  51. package/src/ui/public/assets/index-Di5cimoI.js +115 -0
  52. package/src/ui/public/assets/{office-docx-CZXLkCf5.js → office-docx-C8sYW8O1.js} +5 -5
  53. package/src/ui/public/assets/{office-excel-CBKszjVA.js → office-excel-DpZn5My2.js} +1 -1
  54. package/src/ui/public/assets/{office-pptx-NeUSnN3k.js → office-pptx-BnYBTwT7.js} +1 -1
  55. package/src/ui/public/assets/previewSandbox-CxLaNg4X.css +1 -0
  56. package/src/ui/public/assets/previewSandbox-DEQpyVNu.js +0 -0
  57. package/src/ui/public/assets/toolsStore-DMCSd287.js +1 -0
  58. package/src/ui/public/assets/{useThemeObserver-E7Hk50l3.js → useThemeObserver-QpveGpnn.js} +1 -1
  59. package/src/ui/public/assets/{vendor-DnB9EJa3.css → vendor-C-GCxMMu.css} +1 -1
  60. package/src/ui/public/assets/{vendor-bdHhLiED.js → vendor-CLmPUj3K.js} +8 -8
  61. package/src/ui/public/assets/vue-flow-CxnoC7Uq.js +39 -0
  62. package/src/ui/public/fonts/jetbrains-mono-latin.woff2 +0 -0
  63. package/src/ui/public/fonts/plus-jakarta-sans-latin-ext.woff2 +0 -0
  64. package/src/ui/public/fonts/plus-jakarta-sans-latin-italic.woff2 +0 -0
  65. package/src/ui/public/fonts/plus-jakarta-sans-latin.woff2 +0 -0
  66. package/src/ui/public/index.html +28 -27
  67. package/src/ui/server/routes/workbench/agentChat.js +66 -10
  68. package/src/ui/server/routes/workbench/agentChatContext.test.js +106 -0
  69. package/src/ui/server/routes/workbench/agentChatShared.test.js +33 -3
  70. package/src/ui/server/routes/workbench/agentRoutes.js +43 -8
  71. package/src/ui/server/routes/workbench/agentRoutes.test.js +95 -1
  72. package/src/ui/server/utils/agentAttachments.js +169 -0
  73. package/src/ui/server/utils/agentAttachments.test.js +140 -0
  74. package/src/ui/public/assets/AgentView-DOyN97Ve.js +0 -5
  75. package/src/ui/public/assets/AgentView-DkM2KXuc.css +0 -1
  76. package/src/ui/public/assets/AppVersionBadge-CJrDr7GV.css +0 -1
  77. package/src/ui/public/assets/AppVersionBadge-oON3sAj4.js +0 -2
  78. package/src/ui/public/assets/BranchSelector-B0eLkAdv.css +0 -1
  79. package/src/ui/public/assets/BranchSelector-Cpj7eGqY.js +0 -1
  80. package/src/ui/public/assets/CommitForm-CQ0K8Yy2.css +0 -1
  81. package/src/ui/public/assets/CommitForm-D_ovYhVw.js +0 -6
  82. package/src/ui/public/assets/CommonDialog-ChZbqfRF.js +0 -1
  83. package/src/ui/public/assets/CommonDialog-jP0E9mco.css +0 -1
  84. package/src/ui/public/assets/EditorView-BPzpI0GV.js +0 -1
  85. package/src/ui/public/assets/EditorView-DZMLvQds.css +0 -1
  86. package/src/ui/public/assets/FileDiffViewer-B9EkuKdF.css +0 -1
  87. package/src/ui/public/assets/FileDiffViewer-B_qEKA9L.js +0 -15
  88. package/src/ui/public/assets/FlowOrchestrationWorkspace-C7yTD1gj.js +0 -21
  89. package/src/ui/public/assets/FlowOrchestrationWorkspace-CYGp-PqS.css +0 -1
  90. package/src/ui/public/assets/IconButton-DEARmMkZ.js +0 -1
  91. package/src/ui/public/assets/IconButton-tjkOjzkC.css +0 -1
  92. package/src/ui/public/assets/LogList-BA_gowxE.js +0 -7
  93. package/src/ui/public/assets/LogList-BlJplIi1.css +0 -1
  94. package/src/ui/public/assets/MindmapView-8X6z245q.css +0 -1
  95. package/src/ui/public/assets/MindmapView-K-XcLLXr.js +0 -2
  96. package/src/ui/public/assets/MonitorView-C0RL5K9W.js +0 -1
  97. package/src/ui/public/assets/MonitorView-DetlFFXq.css +0 -1
  98. package/src/ui/public/assets/ProjectStartupButton-DC36_vvU.js +0 -1
  99. package/src/ui/public/assets/ProjectStartupButton-QLPltCK5.css +0 -1
  100. package/src/ui/public/assets/RemoteManagerDialog-BauAeH2w.css +0 -1
  101. package/src/ui/public/assets/RemoteManagerDialog-Bkqja660.js +0 -4
  102. package/src/ui/public/assets/RemoteRepoCard-Bsm7cj_H.js +0 -7
  103. package/src/ui/public/assets/RemoteRepoCard-CG-jARTe.css +0 -1
  104. package/src/ui/public/assets/SourceMapView-CN4Hkyp4.css +0 -1
  105. package/src/ui/public/assets/SourceMapView-Y5NKdIk6.js +0 -3
  106. package/src/ui/public/assets/SvgIcon-D_mFHXP4.js +0 -1
  107. package/src/ui/public/assets/TemplateManager-BzKVXZhy.js +0 -1
  108. package/src/ui/public/assets/TemplateManager-DMuG4i3G.css +0 -1
  109. package/src/ui/public/assets/UserInputNode-BPLK4aYx.js +0 -1
  110. package/src/ui/public/assets/UserInputNode-BVgQkB2l.css +0 -1
  111. package/src/ui/public/assets/WorkbenchView-B1BJhWHi.css +0 -1
  112. package/src/ui/public/assets/WorkbenchView-xbGdiVMQ.js +0 -56
  113. package/src/ui/public/assets/element-plus-Cwwq-Gje.css +0 -1
  114. package/src/ui/public/assets/flow-mindmap-BqFb1WO5.js +0 -139
  115. package/src/ui/public/assets/gitStore-BZo9_zyy.js +0 -6
  116. package/src/ui/public/assets/index-CHuCYLWm.js +0 -111
  117. package/src/ui/public/assets/index-CmbatbSR.css +0 -1
  118. package/src/ui/public/assets/previewSandbox-BTTTXooN.js +0 -0
  119. package/src/ui/public/assets/previewSandbox-CgdwPcuW.css +0 -1
  120. package/src/ui/public/assets/vue-flow-fLUQ0_kE.js +0 -39
@@ -5,41 +5,42 @@
5
5
  <meta name="google" content="notranslate" />
6
6
  <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
7
7
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
8
+ <!-- 浏览器 chrome 配色与页面背景对齐(浅色 --bg-page / 深色 --bg-page-dark) -->
9
+ <meta name="theme-color" content="#f0f2f5" />
10
+ <meta name="theme-color" content="#141820" media="(prefers-color-scheme: dark)" />
8
11
  <title>Zen GitSync</title>
9
- <!-- Premium fonts: Plus Jakarta Sans (UI) + JetBrains Mono (code) -->
10
- <link rel="preconnect" href="https://fonts.googleapis.com" />
11
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
12
- <link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,300;0,400;0,500;0,600;0,700;0,800;1,400&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
13
- <script type="module" crossorigin src="/assets/index-CHuCYLWm.js"></script>
12
+ <!-- 字体改为自托管(@font-face 见 src/main.css),不再依赖 Google Fonts 外链 -->
13
+ <script type="module" crossorigin src="/assets/index-Di5cimoI.js"></script>
14
14
  <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-C0FnF6B9.js">
15
- <link rel="modulepreload" crossorigin href="/assets/element-plus-DxBPQwL7.js">
15
+ <link rel="modulepreload" crossorigin href="/assets/element-plus-D7LDHcAn.js">
16
16
  <link rel="modulepreload" crossorigin href="/assets/monaco-BScz9v63.js">
17
- <link rel="modulepreload" crossorigin href="/assets/vendor-bdHhLiED.js">
18
- <link rel="modulepreload" crossorigin href="/assets/_plugin-vue_export-helper-dg2VUnSQ.js">
19
- <link rel="modulepreload" crossorigin href="/assets/SvgIcon-D_mFHXP4.js">
20
- <link rel="modulepreload" crossorigin href="/assets/IconButton-DEARmMkZ.js">
21
- <link rel="modulepreload" crossorigin href="/assets/configStore-DtjAc3w9.js">
17
+ <link rel="modulepreload" crossorigin href="/assets/vendor-CLmPUj3K.js">
18
+ <link rel="modulepreload" crossorigin href="/assets/_plugin-vue_export-helper-BLGowG5t.js">
19
+ <link rel="modulepreload" crossorigin href="/assets/SvgIcon-Bkc_oGUI.js">
20
+ <link rel="modulepreload" crossorigin href="/assets/IconButton-BEiZI4F6.js">
21
+ <link rel="modulepreload" crossorigin href="/assets/configStore-BUXv5i5j.js">
22
22
  <link rel="modulepreload" crossorigin href="/assets/socket-io-BFqQXqAp.js">
23
- <link rel="modulepreload" crossorigin href="/assets/gitStore-BZo9_zyy.js">
24
- <link rel="modulepreload" crossorigin href="/assets/useThemeObserver-E7Hk50l3.js">
25
- <link rel="modulepreload" crossorigin href="/assets/flow-mindmap-BqFb1WO5.js">
26
- <link rel="modulepreload" crossorigin href="/assets/previewSandbox-BTTTXooN.js">
27
- <link rel="modulepreload" crossorigin href="/assets/FileDiffViewer-B_qEKA9L.js">
28
- <link rel="modulepreload" crossorigin href="/assets/CommonDialog-ChZbqfRF.js">
29
- <link rel="modulepreload" crossorigin href="/assets/TemplateManager-BzKVXZhy.js">
30
- <link rel="modulepreload" crossorigin href="/assets/vue-flow-fLUQ0_kE.js">
31
- <link rel="stylesheet" crossorigin href="/assets/element-plus-Cwwq-Gje.css">
23
+ <link rel="modulepreload" crossorigin href="/assets/gitStore-BGbEYd0U.js">
24
+ <link rel="modulepreload" crossorigin href="/assets/useThemeObserver-QpveGpnn.js">
25
+ <link rel="modulepreload" crossorigin href="/assets/flow-mindmap-zXXF3qDh.js">
26
+ <link rel="modulepreload" crossorigin href="/assets/previewSandbox-DEQpyVNu.js">
27
+ <link rel="modulepreload" crossorigin href="/assets/CommonDialog-CecgvEzd.js">
28
+ <link rel="modulepreload" crossorigin href="/assets/FileDiffViewer-DH8Y2lUO.js">
29
+ <link rel="modulepreload" crossorigin href="/assets/toolsStore-DMCSd287.js">
30
+ <link rel="modulepreload" crossorigin href="/assets/TemplateManager-9e4amMlI.js">
31
+ <link rel="modulepreload" crossorigin href="/assets/vue-flow-CxnoC7Uq.js">
32
+ <link rel="stylesheet" crossorigin href="/assets/element-plus-C1xzgNSR.css">
32
33
  <link rel="stylesheet" crossorigin href="/assets/monaco-B2lPvkrc.css">
33
- <link rel="stylesheet" crossorigin href="/assets/vendor-DnB9EJa3.css">
34
+ <link rel="stylesheet" crossorigin href="/assets/vendor-C-GCxMMu.css">
34
35
  <link rel="stylesheet" crossorigin href="/assets/SvgIcon-CDNsk4-Q.css">
35
- <link rel="stylesheet" crossorigin href="/assets/IconButton-tjkOjzkC.css">
36
+ <link rel="stylesheet" crossorigin href="/assets/IconButton-RlXqKOeu.css">
36
37
  <link rel="stylesheet" crossorigin href="/assets/flow-mindmap-DnzM9SlK.css">
37
- <link rel="stylesheet" crossorigin href="/assets/previewSandbox-CgdwPcuW.css">
38
- <link rel="stylesheet" crossorigin href="/assets/FileDiffViewer-B9EkuKdF.css">
39
- <link rel="stylesheet" crossorigin href="/assets/CommonDialog-jP0E9mco.css">
40
- <link rel="stylesheet" crossorigin href="/assets/TemplateManager-DMuG4i3G.css">
38
+ <link rel="stylesheet" crossorigin href="/assets/previewSandbox-CxLaNg4X.css">
39
+ <link rel="stylesheet" crossorigin href="/assets/CommonDialog-w6EmBEXv.css">
40
+ <link rel="stylesheet" crossorigin href="/assets/FileDiffViewer-n5ea9qMe.css">
41
+ <link rel="stylesheet" crossorigin href="/assets/TemplateManager-DO7EPfj8.css">
41
42
  <link rel="stylesheet" crossorigin href="/assets/vue-flow-CavUTEzp.css">
42
- <link rel="stylesheet" crossorigin href="/assets/index-CmbatbSR.css">
43
+ <link rel="stylesheet" crossorigin href="/assets/index-DIU5200G.css">
43
44
  </head>
44
45
  <body>
45
46
  <div id="app"></div>
@@ -31,7 +31,6 @@
31
31
  import path from 'path';
32
32
  import os from 'os';
33
33
  import { logger } from './shared.js';
34
- import { agentSessionStore } from './agentSessionStore.js';
35
34
 
36
35
  // 从 CLI 侧导入工具定义、执行器与 LLM 传输层(同一 monorepo,路径可达)
37
36
  import { TOOL_DEFINITIONS, executeTool } from '../../../../cli/ai/tools.js';
@@ -242,6 +241,9 @@ ${isWin ? `- This is Windows. The following Unix commands do NOT exist here:
242
241
  // - model: { baseURL, model, apiKey }
243
242
  // - userMessage: 用户输入文本
244
243
  // - images: base64 dataURL 数组(可选,多模态图片,随最新一条 user 消息发给模型)
244
+ // - openFilePath: 文件空间里当前打开的文档(可选,注入请求副本,不落库)
245
+ // - attachments: 非图片附件(可选) = [{ name, path }],path 是**服务端落盘后的绝对路径**,
246
+ // 只把路径写进请求副本的 system 提示,内容由模型自己用工具读(见 utils/agentAttachments.js)
245
247
  // - cwd: 工作目录
246
248
  // - locale: 'zh' | 'en'
247
249
  // - signal: AbortSignal (客户端断开时触发)
@@ -252,7 +254,7 @@ ${isWin ? `- This is Windows. The following Unix commands do NOT exist here:
252
254
  // (由 agentRoutes 注入:最近目录/tasks.json/看板统计只有 GUI 侧拿得到)
253
255
  //
254
256
  // 返回: { aborted: boolean }
255
- export async function runAgentTurn({ session, model, userMessage, images = [], cwd, locale, signal, send, onChild, askUser, listProjects }) {
257
+ export async function runAgentTurn({ session, model, userMessage, images = [], cwd, locale, openFilePath, attachments = [], signal, send, onChild, askUser, listProjects }) {
256
258
  const ctx = { cwd, locale, onChild, askUser, listProjects };
257
259
 
258
260
  // 确保 session.messages 存在
@@ -284,6 +286,8 @@ export async function runAgentTurn({ session, model, userMessage, images = [], c
284
286
  // 摘录成一条梗概 → 旧图片降级 → provider 兼容消毒。
285
287
  // 只作用于副本,session.messages 保持完整(与 CLI 的磁盘口径一致)。
286
288
  const messages = prepareRequestMessages(session.messages, { locale });
289
+ // 请求级上下文:当前打开的文档 + 本轮附件路径(只改副本,不落 session.messages,下一轮不重复累积)
290
+ injectRequestContext(messages, { cwd, openFilePath, attachments, locale });
287
291
 
288
292
  let result;
289
293
  try {
@@ -306,14 +310,6 @@ export async function runAgentTurn({ session, model, userMessage, images = [], c
306
310
  return { aborted: false };
307
311
  }
308
312
 
309
- if (result.aborted) {
310
- // 保存当前会话状态
311
- session.updatedAt = new Date().toISOString();
312
- await agentSessionStore.write(session.sessionId, session).catch(() => {});
313
- send({ type: 'error', error: '已取消' });
314
- return { aborted: true };
315
- }
316
-
317
313
  const { content, toolCalls } = result;
318
314
 
319
315
  // 推理内容必须原样带回历史。DeepSeek 系 thinking 模式下带 tool_calls 的 assistant
@@ -322,6 +318,18 @@ export async function runAgentTurn({ session, model, userMessage, images = [], c
322
318
  // 口径与 CLI 侧 src/cli/ai/turn.js 的 assistant.reasoning_content 保持一致。
323
319
  const withReasoning = msg => (result.reasoning ? { ...msg, reasoning_content: result.reasoning } : msg);
324
320
 
321
+ if (result.aborted) {
322
+ // 用户点了"停止":把已经流出来的部分正文补进历史,否则磁盘上这一轮只剩一条
323
+ // user 消息,重新打开会话时刚才生成的内容会整段丢失。
324
+ // 半截的 tool_calls 已被 transport 在中止时丢弃,这里只补正文,不会有悬空引用。
325
+ // 落盘由路由层统一负责(中止的轮次同样要写,见 agentRoutes.js)。
326
+ if (content) {
327
+ session.messages.push(withReasoning({ role: 'assistant', content }));
328
+ }
329
+ send({ type: 'error', error: '已取消' });
330
+ return { aborted: true };
331
+ }
332
+
325
333
  // 无工具调用:本轮结束
326
334
  if (toolCalls.length === 0) {
327
335
  session.messages.push(withReasoning({ role: 'assistant', content: content || null }));
@@ -409,4 +417,52 @@ function summarizeArgs(name, args) {
409
417
  }
410
418
  }
411
419
 
420
+ // ── 请求级上下文注入(文件空间对话 / 本轮附件) ──────────────
421
+ // 把"用户当前打开的文件"与"本轮附件的落盘路径"追加到**请求副本**的 system 消息末尾:
422
+ // 只影响这一次请求,session.messages 与磁盘历史保持原样,下一轮也不会重复累积。
423
+ //
424
+ // 附件为什么只给路径、不给内容:见 utils/agentAttachments.js 的头注释 —— 非图片附件
425
+ // 由服务端落盘,模型自己用 read / grep 按需取,比把几百 KB 文本内联进消息省得多。
426
+ export function injectRequestContext(messages, { cwd, openFilePath, attachments = [], locale }) {
427
+ if (!Array.isArray(messages)) return;
428
+ const en = String(locale || '').startsWith('en');
429
+
430
+ const parts = [];
431
+
432
+ // ① 当前打开的文档(文件空间对话才有;项目外或等于根目录直接忽略)
433
+ if (openFilePath) {
434
+ const root = cwd || process.cwd();
435
+ let rel = '';
436
+ try {
437
+ rel = path.relative(root, path.resolve(root, openFilePath));
438
+ } catch {
439
+ rel = '';
440
+ }
441
+ if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) {
442
+ const file = rel.split(path.sep).join('/');
443
+ parts.push(en
444
+ ? `The file the user currently has open in the editor is \`${file}\` (relative to the project root). When the user says "this file" / "the current file" / "here", that is what they mean; read it with your tools before assuming its content.`
445
+ : `用户此刻在文件空间打开的文件是 \`${file}\`(相对项目根目录)。用户说"这个文件/当前文件"时默认指它;请先用工具读取内容,不要臆测。`);
446
+ }
447
+ }
448
+
449
+ // ② 本轮附件:只给绝对路径,内容让模型自己去读
450
+ const files = (Array.isArray(attachments) ? attachments : [])
451
+ .filter(a => a && typeof a.path === 'string' && a.path)
452
+ .slice(0, 20);
453
+ if (files.length > 0) {
454
+ const lines = files.map(a => `- \`${a.path}\`${a.name && a.name !== path.basename(a.path) ? ` (${a.name})` : ''}`);
455
+ parts.push(en
456
+ ? `The user attached ${files.length} file(s) this turn; they were saved to these absolute paths:\n${lines.join('\n')}\nRead them with your tools when relevant (they are NOT inlined here). Do not guess their contents.`
457
+ : `用户本轮附带了 ${files.length} 个文件,已保存到以下绝对路径:\n${lines.join('\n')}\n需要时用工具读取(内容没有内联在这里),不要臆测。`);
458
+ }
459
+
460
+ if (parts.length === 0) return;
461
+
462
+ const note = `\n\n# ${en ? 'Current context' : '当前上下文'}\n${parts.join('\n\n')}`;
463
+ const sys = messages.find(m => m && m.role === 'system' && typeof m.content === 'string');
464
+ if (sys) sys.content += note;
465
+ else messages.unshift({ role: 'system', content: note.trim() });
466
+ }
467
+
412
468
  export { buildWebSystemPrompt };
@@ -0,0 +1,106 @@
1
+ // injectRequestContext:把"当前打开的文档"与"本轮附件路径"注进**请求副本**。
2
+ // 关键口径:只改请求副本、不碰 session.messages;越界路径忽略;下一轮不会重复累积;
3
+ // 附件只给绝对路径、不给内容(内容由模型自己用 read / grep 取)。
4
+ import { test } from 'node:test'
5
+ import assert from 'node:assert/strict'
6
+ import path from 'node:path'
7
+ import { injectRequestContext } from './agentChat.js'
8
+
9
+ const ROOT = path.resolve('C:/proj')
10
+
11
+ test('injectRequestContext: 相对项目根的斜杠路径追加进 system 消息', () => {
12
+ const messages = [
13
+ { role: 'system', content: 'rules' },
14
+ { role: 'user', content: 'hi' }
15
+ ]
16
+ injectRequestContext(messages, { cwd: ROOT, openFilePath: path.join(ROOT, 'src', 'a.md'), locale: 'zh' })
17
+ assert.match(messages[0].content, /^rules\n\n# 当前上下文/)
18
+ assert.match(messages[0].content, /src\/a\.md/)
19
+ assert.equal(messages[1].content, 'hi', '其它消息不受影响')
20
+ })
21
+
22
+ test('injectRequestContext: 项目外路径 / 空值一律忽略', () => {
23
+ const outside = [{ role: 'system', content: 'rules' }]
24
+ injectRequestContext(outside, { cwd: ROOT, openFilePath: 'D:/other/x.md', locale: 'zh' })
25
+ assert.equal(outside[0].content, 'rules', '项目外路径不注入')
26
+
27
+ const none = [{ role: 'system', content: 'rules' }]
28
+ injectRequestContext(none, { cwd: ROOT, openFilePath: '', locale: 'zh' })
29
+ assert.equal(none[0].content, 'rules', '没传 openFilePath 时不动消息')
30
+ })
31
+
32
+ test('injectRequestContext: 没有 system 消息时补一条,英文 locale 用英文说明', () => {
33
+ const messages = [{ role: 'user', content: 'hi' }]
34
+ injectRequestContext(messages, { cwd: ROOT, openFilePath: 'README.md', locale: 'en' })
35
+ assert.equal(messages.length, 2)
36
+ assert.equal(messages[0].role, 'system')
37
+ assert.match(messages[0].content, /README\.md/)
38
+ assert.match(messages[0].content, /Current context/)
39
+ })
40
+
41
+ test('injectRequestContext: 每轮都基于副本重新注入,不会重复累积', () => {
42
+ const session = [{ role: 'system', content: 'rules' }]
43
+ const first = session.map(m => ({ ...m }))
44
+ const second = session.map(m => ({ ...m }))
45
+ injectRequestContext(first, { cwd: ROOT, openFilePath: 'a.md', locale: 'zh' })
46
+ injectRequestContext(second, { cwd: ROOT, openFilePath: 'a.md', locale: 'zh' })
47
+ assert.equal(session[0].content, 'rules', '原始会话记录保持干净')
48
+ assert.equal((first[0].content.match(/# 当前上下文/g) || []).length, 1)
49
+ assert.equal((second[0].content.match(/# 当前上下文/g) || []).length, 1)
50
+ })
51
+
52
+ test('injectRequestContext: 附件只注入绝对路径,内容不进消息体', () => {
53
+ const messages = [{ role: 'system', content: 'rules' }, { role: 'user', content: '看下这个日志' }]
54
+ injectRequestContext(messages, {
55
+ cwd: ROOT,
56
+ locale: 'zh',
57
+ attachments: [
58
+ { name: '错误日志.log', path: 'C:\\Users\\x\\.zen-gitsync\\agent-attachments\\20260927-193000-ab12\\错误日志.log' }
59
+ ]
60
+ })
61
+ assert.match(messages[0].content, /^rules\n\n# 当前上下文/)
62
+ assert.match(messages[0].content, /用户本轮附带了 1 个文件/)
63
+ assert.match(messages[0].content, /agent-attachments.+错误日志\.log/)
64
+ assert.match(messages[0].content, /不要臆测/)
65
+ // 消息体本身没有任何文件内容
66
+ assert.equal(messages[1].content, '看下这个日志')
67
+ })
68
+
69
+ test('injectRequestContext: 文档 + 附件可以同时注入,各成一段', () => {
70
+ const messages = [{ role: 'system', content: 'rules' }]
71
+ injectRequestContext(messages, {
72
+ cwd: ROOT,
73
+ openFilePath: 'src/a.md',
74
+ locale: 'en',
75
+ attachments: [
76
+ { name: 'a.log', path: '/tmp/att/a.log' },
77
+ { name: 'b.csv', path: '/tmp/att/b.csv' }
78
+ ]
79
+ })
80
+ const sys = messages[0].content
81
+ assert.match(sys, /# Current context/)
82
+ assert.match(sys, /src\/a\.md/)
83
+ assert.match(sys, /attached 2 file/)
84
+ assert.match(sys, /\/tmp\/att\/a\.log/)
85
+ assert.match(sys, /\/tmp\/att\/b\.csv/)
86
+ // 只加一个标题段
87
+ assert.equal((sys.match(/# Current context/g) || []).length, 1)
88
+ })
89
+
90
+ test('injectRequestContext: 脏附件数据(缺 path / 空数组)不会破坏消息,也不留空段', () => {
91
+ const dirty = [{ role: 'system', content: 'rules' }]
92
+ injectRequestContext(dirty, { cwd: ROOT, locale: 'zh', attachments: [{ name: 'x.txt' }, null, 'oops'] })
93
+ assert.equal(dirty[0].content, 'rules', '没有可用 path 时不注入')
94
+
95
+ const empty = [{ role: 'system', content: 'rules' }]
96
+ injectRequestContext(empty, { cwd: ROOT, locale: 'zh', attachments: [] })
97
+ assert.equal(empty[0].content, 'rules')
98
+ })
99
+
100
+ test('injectRequestContext: 附件条数超过 20 时截断(防御性上限)', () => {
101
+ const messages = [{ role: 'system', content: 'rules' }]
102
+ const many = Array.from({ length: 25 }, (_, i) => ({ name: `f${i}.txt`, path: `/tmp/att/f${i}.txt` }))
103
+ injectRequestContext(messages, { cwd: ROOT, locale: 'zh', attachments: many })
104
+ assert.match(messages[0].content, /用户本轮附带了 20 个文件/)
105
+ assert.ok(!messages[0].content.includes('f24.txt'), '第 21 个之后不该出现')
106
+ })
@@ -45,16 +45,18 @@ const longSession = () => {
45
45
  }
46
46
 
47
47
  // 跑一轮真实的 runAgentTurn,桩掉 globalThis.fetch —— 抓住发出去的请求体与产生的事件
48
- async function runWeb({ session, userMessage, respond, locale = 'zh-CN' }) {
48
+ // (respond 收到 fetch 的 init,方便测试按 signal 模拟"读到一半被中止")
49
+ async function runWeb({ session, userMessage, respond, locale = 'zh-CN', signal, onEvent }) {
49
50
  const { runAgentTurn } = await import('./agentChat.js')
50
51
  const events = []
51
52
  let sent
52
53
  const original = globalThis.fetch
53
- globalThis.fetch = async (_url, init) => { sent = JSON.parse(init.body); return respond() }
54
+ globalThis.fetch = async (_url, init) => { sent = JSON.parse(init.body); return respond(init) }
54
55
  try {
55
56
  const result = await runAgentTurn({
56
57
  session, model, userMessage, locale, cwd: process.cwd(),
57
- signal: new AbortController().signal, send: e => events.push(e),
58
+ signal: signal ?? new AbortController().signal,
59
+ send: e => { events.push(e); onEvent?.(e) },
58
60
  })
59
61
  return { sent, events, result }
60
62
  } finally {
@@ -134,6 +136,34 @@ test('流被截断(没有 finish_reason 也没有 [DONE])时不执行半截
134
136
  assert.equal(session.messages.some(m => m.role === 'tool'), false, '被截断的调用不得留下工具结果')
135
137
  })
136
138
 
139
+ // 回归:用户点"停止"(中止正在跑的这一轮)时,已经流出来的正文必须留在会话记录里。
140
+ // 之前中止分支只把当时的状态写盘、不回填正文,重新打开这条会话时刚才生成的内容整段消失。
141
+ test('中止时已流出的正文要留在会话记录里', async () => {
142
+ const session = newSession('ag-sandbox-aborted', withSystem())
143
+ const controller = new AbortController()
144
+
145
+ // 吐一段正文后挂住(模拟"停在生成中");真 fetch 在 signal abort 时会打断 body 读取,
146
+ // 桩里照做才能真的走到中止分支
147
+ const hanging = init => new Response(new ReadableStream({
148
+ start(stream) {
149
+ const bytes = new TextEncoder()
150
+ stream.enqueue(bytes.encode(event({ choices: [{ delta: { content: '已经写了一半' } }] })))
151
+ init?.signal?.addEventListener('abort', () => stream.error(new DOMException('Aborted', 'AbortError')))
152
+ },
153
+ }), { status: 200, headers: { 'content-type': 'text/event-stream' } })
154
+
155
+ const { result } = await runWeb({
156
+ session, userMessage: '写一半就停', respond: hanging, signal: controller.signal,
157
+ // 正文事件到达 = 这段已经被读进来,此刻中止最贴近用户点"停止"的时机
158
+ onEvent: e => { if (e.type === 'content') controller.abort() },
159
+ })
160
+
161
+ assert.equal(result.aborted, true)
162
+ const last = session.messages[session.messages.length - 1]
163
+ assert.equal(last.role, 'assistant')
164
+ assert.equal(last.content, '已经写了一半')
165
+ })
166
+
137
167
  test('400 且正文提到 tool/function 时提示换模型,而不是甩网关 JSON', async () => {
138
168
  const session = newSession('ag-sandbox-nofc', withSystem())
139
169
  const noFunctionCalling = () => new Response(
@@ -25,6 +25,7 @@ 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 { saveAgentAttachments, MAX_ATTACHMENTS } from '../../utils/agentAttachments.js';
28
29
  import { registerAgentMarketplaceRoutes } from './agentMarketplace.js';
29
30
  import { createProjectListProvider } from './projectTool.js';
30
31
  import { nowIso } from './shared.js';
@@ -51,6 +52,9 @@ function sessionBelongsTo(sessionCwd, projectCwd) {
51
52
  // matching response. The entry is removed on every completion or disconnect.
52
53
  const pendingAgentQuestions = new Map();
53
54
 
55
+ /** 正在进行中的会话 id 集合:同一会话同一时刻只允许一个生成中的轮次 */
56
+ const activeSessionTurns = new Set();
57
+
54
58
  function interactionKey(sessionId, interactionId) {
55
59
  return `${String(sessionId)}:${String(interactionId)}`;
56
60
  }
@@ -187,10 +191,19 @@ export function registerAgentRoutes({ app, getCurrentProjectPath, configManager
187
191
  .filter(u => typeof u === 'string' && /^data:image\/[\w.+-]+;base64,/.test(u))
188
192
  .slice(0, 10);
189
193
 
190
- if (!userMessage && images.length === 0) {
194
+ // 非图片附件:前端只传文件名 + 字节,服务端落盘到数据目录,只把**绝对路径**交给模型。
195
+ // 内容不进消息体(省 token,模型自己用 read/grep 按需取),校验见 utils/agentAttachments.js。
196
+ const rawAttachments = (Array.isArray(req.body?.attachments) ? req.body.attachments : [])
197
+ .slice(0, MAX_ATTACHMENTS)
198
+ .map(a => ({ name: a?.name, dataUrl: a?.dataUrl }));
199
+
200
+ if (!userMessage && images.length === 0 && rawAttachments.length === 0) {
191
201
  return res.status(400).json({ success: false, error: '消息内容不能为空' });
192
202
  }
193
203
 
204
+ // 文件空间对话:客户端把"当前打开的文档"带上来,服务端只在请求副本里注入上下文(不落库)
205
+ const openFilePath = String(req.body?.openFilePath || '').trim().slice(0, 512);
206
+
194
207
  // SSE 头
195
208
  res.set({
196
209
  'Content-Type': 'text/event-stream',
@@ -207,6 +220,15 @@ export function registerAgentRoutes({ app, getCurrentProjectPath, configManager
207
220
  let finished = false;
208
221
  let activeChild = null;
209
222
 
223
+ // 同一会话同一时刻只允许一个进行中的生成:跨视图(对话 Tab / 文件空间面板)
224
+ // 或前端连点都靠这道闸挡住,否则两个流各自的会话快照会互相覆盖。
225
+ if (sessionIdInput && activeSessionTurns.has(sessionIdInput)) {
226
+ send({ type: 'error', error: '该会话正在生成中,请等它结束后再发送' });
227
+ finished = true;
228
+ return res.end();
229
+ }
230
+ let activeSessionKey = null;
231
+
210
232
  // 客户端断开
211
233
  if (req.socket) {
212
234
  req.socket.once('close', () => {
@@ -264,6 +286,19 @@ export function registerAgentRoutes({ app, getCurrentProjectPath, configManager
264
286
  isNew = true;
265
287
  }
266
288
 
289
+ // 本轮占用该会话(finally 里释放)
290
+ activeSessionKey = session.sessionId;
291
+ activeSessionTurns.add(activeSessionKey);
292
+
293
+ // 附件落盘(拿到绝对路径后再把路径交给模型)。落盘失败不该拖垮整轮对话:
294
+ // 拿不到路径最多是模型看不到附件,用户仍能正常对话。
295
+ const attachments = await saveAgentAttachments(rawAttachments, {
296
+ onSkip: (info) => console.warn('[agent] 附件被忽略:', info.name, info.reason)
297
+ }).catch((err) => {
298
+ console.warn('[agent] 附件落盘失败:', err?.message || err);
299
+ return [];
300
+ });
301
+
267
302
  // 获取模型配置
268
303
  let model;
269
304
  try {
@@ -297,13 +332,15 @@ export function registerAgentRoutes({ app, getCurrentProjectPath, configManager
297
332
  });
298
333
 
299
334
  // 运行 agent 循环
300
- const { aborted } = await runAgentTurn({
335
+ await runAgentTurn({
301
336
  session,
302
337
  model,
303
338
  userMessage,
304
339
  images,
305
340
  cwd,
306
341
  locale,
342
+ openFilePath,
343
+ attachments,
307
344
  signal: abortController.signal,
308
345
  send,
309
346
  onChild: (child) => { activeChild = child; },
@@ -320,17 +357,13 @@ export function registerAgentRoutes({ app, getCurrentProjectPath, configManager
320
357
  listProjects
321
358
  });
322
359
 
323
- if (aborted) {
324
- finished = true;
325
- return res.end();
326
- }
327
-
328
360
  // 更新标题(新会话从第一条 user 消息自动生成)
329
361
  if (isNew) {
330
362
  session.title = autoTitle(session.messages);
331
363
  }
332
364
 
333
- // 持久化
365
+ // 持久化。中止(用户点"停止")的轮次同样要落盘 —— 磁盘上没有这条会话时,
366
+ // 前端停止后立刻刷新列表会把左栏这一条整个吞掉(表现为"一停止任务就没了")。
334
367
  session.updatedAt = nowIso();
335
368
  await writeSession(session.sessionId, session);
336
369
  enforceRetention().catch(() => {});
@@ -341,6 +374,8 @@ export function registerAgentRoutes({ app, getCurrentProjectPath, configManager
341
374
  send({ type: 'error', error: '智能体对话失败: ' + (err?.message || String(err)) });
342
375
  finished = true;
343
376
  res.end();
377
+ } finally {
378
+ if (activeSessionKey) activeSessionTurns.delete(activeSessionKey);
344
379
  }
345
380
  });
346
381
  }
@@ -1,7 +1,29 @@
1
1
  import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
+ import { mkdtempSync, readdirSync, readFileSync } from 'node:fs';
4
+ import os from 'node:os';
3
5
  import path from 'node:path';
4
- import { registerAgentRoutes, waitForAgentAnswer } from './agentRoutes.js';
6
+
7
+ // agentSessionStore 的落盘目录在模块加载时由 os.homedir() 求值,必须在 import 之前
8
+ // 把 HOME 指到沙箱,否则这个文件会往真实 ~/.zen-gitsync/agent-sessions 里写会话。
9
+ const SANDBOX = mkdtempSync(path.join(os.tmpdir(), 'zen-agent-routes-'));
10
+ process.env.USERPROFILE = SANDBOX;
11
+ process.env.HOME = SANDBOX;
12
+ delete process.env.HOMEDRIVE;
13
+ delete process.env.HOMEPATH;
14
+
15
+ const { registerAgentRoutes, waitForAgentAnswer } = await import('./agentRoutes.js');
16
+
17
+ const event = data => `data: ${JSON.stringify(data)}\n\n`;
18
+
19
+ // 轮询等待条件成立(SSE 是边跑边写出来的,没有可 await 的句柄)
20
+ async function waitUntil(check, timeoutMs = 2000) {
21
+ const start = Date.now();
22
+ while (!check()) {
23
+ if (Date.now() - start > timeoutMs) throw new Error('等待超时');
24
+ await new Promise(resolve => setTimeout(resolve, 5));
25
+ }
26
+ }
5
27
 
6
28
  test('agent respond resolves a pending ask_user question and validates answers', async () => {
7
29
  let respondHandler;
@@ -133,3 +155,75 @@ test('agent chat rejects a cwd that differs from the active server project', asy
133
155
  assert.match(output, /PROJECT_MISMATCH/);
134
156
  assert.match(output, /项目不一致/);
135
157
  });
158
+
159
+ // 回归:用户点"停止"(客户端断开连接 → 服务端 abort)后,这条会话仍要落盘。
160
+ // 之前中止分支直接 res.end(),磁盘上没有会话文件,前端停止后立刻刷新列表
161
+ // 就把左栏这一条整个吞掉了 —— 用户看到的是"一停止任务就没了"。
162
+ test('停止(客户端断开)后会话仍要落盘,并带上自动标题与已流出的正文', async () => {
163
+ const activeCwd = path.resolve('active-project');
164
+ let chatHandler;
165
+ const app = {
166
+ get() {},
167
+ delete() {},
168
+ put() {},
169
+ post(route, handler) {
170
+ if (route === '/api/agent/chat') chatHandler = handler;
171
+ }
172
+ };
173
+ registerAgentRoutes({
174
+ app,
175
+ getCurrentProjectPath: () => activeCwd,
176
+ configManager: {
177
+ readRawConfigFile: async () => ({
178
+ models: [{ model: 'test', name: 'T', baseURL: 'https://example.invalid/v1', apiKey: 'k', isDefault: true }]
179
+ })
180
+ }
181
+ });
182
+
183
+ // 模型流:吐一段正文后挂住(模拟"停在生成中")。真 fetch 在 signal abort 时会打断
184
+ // body 读取,桩里照做才能真的走到中止分支
185
+ const hanging = init => new Response(new ReadableStream({
186
+ start(stream) {
187
+ const bytes = new TextEncoder();
188
+ stream.enqueue(bytes.encode(event({ choices: [{ delta: { content: '已经写了一半' } }] })));
189
+ init?.signal?.addEventListener('abort', () => stream.error(new DOMException('Aborted', 'AbortError')));
190
+ }
191
+ }), { status: 200, headers: { 'content-type': 'text/event-stream' } });
192
+
193
+ const originalFetch = globalThis.fetch;
194
+ globalThis.fetch = async (_url, init) => hanging(init);
195
+
196
+ let onSocketClose;
197
+ let output = '';
198
+ let ended = false;
199
+ const req = {
200
+ body: { userMessage: '请列出当前项目的目录结构', cwd: activeCwd },
201
+ headers: {},
202
+ socket: { once(_evt, cb) { onSocketClose = cb; } }
203
+ };
204
+ const res = {
205
+ set() {},
206
+ flushHeaders() {},
207
+ write(chunk) { output += chunk; },
208
+ end() { ended = true; }
209
+ };
210
+
211
+ try {
212
+ const route = chatHandler(req, res);
213
+ await waitUntil(() => output.includes('"type":"content"'));
214
+ onSocketClose();
215
+ await route;
216
+ } finally {
217
+ globalThis.fetch = originalFetch;
218
+ }
219
+
220
+ assert.equal(ended, true, '中止后也要正常收尾');
221
+ const dir = path.join(SANDBOX, '.zen-gitsync', 'agent-sessions');
222
+ const files = readdirSync(dir).filter(f => f.endsWith('.json'));
223
+ assert.equal(files.length, 1, '被停止的会话必须落盘,否则左栏这一条会消失');
224
+ const saved = JSON.parse(readFileSync(path.join(dir, files[0]), 'utf-8'));
225
+ assert.equal(saved.title, '请列出当前项目的目录结构');
226
+ const last = saved.messages[saved.messages.length - 1];
227
+ assert.equal(last.role, 'assistant');
228
+ assert.equal(last.content, '已经写了一半');
229
+ });