zen-gitsync 2.17.15 → 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 (132) hide show
  1. package/README.md +6 -6
  2. package/package.json +1 -1
  3. package/src/cli/ai/agent.js +24 -2
  4. package/src/cli/ai/runtime.test.js +1 -1
  5. package/src/cli/ai/tools.js +7 -1
  6. package/src/cli/ai/tools.test.js +11 -0
  7. package/src/config.js +4 -4
  8. package/src/paths.js +3 -0
  9. package/src/ui/public/assets/AgentView-CXZUWLPL.css +1 -0
  10. package/src/ui/public/assets/AgentView-Dhs3eKFY.js +7 -0
  11. package/src/ui/public/assets/AppVersionBadge-D70AjIBo.js +2 -0
  12. package/src/ui/public/assets/AppVersionBadge-DDYQqoy7.css +1 -0
  13. package/src/ui/public/assets/BranchSelector-F4ibldMN.css +1 -0
  14. package/src/ui/public/assets/BranchSelector-t3fnGS_5.js +1 -0
  15. package/src/ui/public/assets/CommitForm-4kI2csgs.css +1 -0
  16. package/src/ui/public/assets/CommitForm-DmlTnTwc.js +6 -0
  17. package/src/ui/public/assets/CommonDialog-CecgvEzd.js +1 -0
  18. package/src/ui/public/assets/CommonDialog-w6EmBEXv.css +1 -0
  19. package/src/ui/public/assets/EditorView-DBqhjm84.css +1 -0
  20. package/src/ui/public/assets/EditorView-DVRemzZU.js +16 -0
  21. package/src/ui/public/assets/FileDiffViewer-DH8Y2lUO.js +15 -0
  22. package/src/ui/public/assets/FileDiffViewer-n5ea9qMe.css +1 -0
  23. package/src/ui/public/assets/{FlowExecutionViewer-BmfstdaT.js → FlowExecutionViewer-Cie2_TG4.js} +1 -1
  24. package/src/ui/public/assets/FlowOrchestrationWorkspace-Bbf8X1KP.js +21 -0
  25. package/src/ui/public/assets/FlowOrchestrationWorkspace-DwuSSxJ1.css +1 -0
  26. package/src/ui/public/assets/IconButton-BEiZI4F6.js +1 -0
  27. package/src/ui/public/assets/IconButton-RlXqKOeu.css +1 -0
  28. package/src/ui/public/assets/LogList-DvuC03Tx.js +7 -0
  29. package/src/ui/public/assets/LogList-XspUhx-C.css +1 -0
  30. package/src/ui/public/assets/MindmapView-B6rITyNs.js +2 -0
  31. package/src/ui/public/assets/MindmapView-Diq2Cu4O.css +1 -0
  32. package/src/ui/public/assets/MonitorView-DwUwJXnY.css +1 -0
  33. package/src/ui/public/assets/MonitorView-q92EZvay.js +1 -0
  34. package/src/ui/public/assets/ProjectStartupButton-CB8IOg1E.css +1 -0
  35. package/src/ui/public/assets/ProjectStartupButton-RiIi8TwW.js +1 -0
  36. package/src/ui/public/assets/RemoteManagerDialog-D4WphMtO.css +1 -0
  37. package/src/ui/public/assets/RemoteManagerDialog-qtBotYAQ.js +4 -0
  38. package/src/ui/public/assets/RemoteRepoCard-DvtzfwHV.js +7 -0
  39. package/src/ui/public/assets/RemoteRepoCard-o01IrbVz.css +1 -0
  40. package/src/ui/public/assets/SourceMapView-CfDydtsu.css +1 -0
  41. package/src/ui/public/assets/SourceMapView-EEWAmy4U.js +3 -0
  42. package/src/ui/public/assets/SvgIcon-Bkc_oGUI.js +1 -0
  43. package/src/ui/public/assets/TemplateManager-9e4amMlI.js +1 -0
  44. package/src/ui/public/assets/TemplateManager-DO7EPfj8.css +1 -0
  45. package/src/ui/public/assets/UserInputNode-4FoJblJi.js +1 -0
  46. package/src/ui/public/assets/UserInputNode-DDNSarag.css +1 -0
  47. package/src/ui/public/assets/WorkbenchView-BFaTinH8.js +56 -0
  48. package/src/ui/public/assets/WorkbenchView-BU2kJCAA.css +1 -0
  49. package/src/ui/public/assets/{_plugin-vue_export-helper-B1mHBne9.js → _plugin-vue_export-helper-BLGowG5t.js} +7 -7
  50. package/src/ui/public/assets/agentConversations-BQCIv4u1.js +5 -0
  51. package/src/ui/public/assets/configStore-BUXv5i5j.js +1 -0
  52. package/src/ui/public/assets/element-plus-C1xzgNSR.css +1 -0
  53. package/src/ui/public/assets/{element-plus-DxBPQwL7.js → element-plus-D7LDHcAn.js} +3 -3
  54. package/src/ui/public/assets/flow-mindmap-zXXF3qDh.js +139 -0
  55. package/src/ui/public/assets/gitStore-BGbEYd0U.js +6 -0
  56. package/src/ui/public/assets/index-DIU5200G.css +1 -0
  57. package/src/ui/public/assets/index-Di5cimoI.js +115 -0
  58. package/src/ui/public/assets/{office-docx-CZXLkCf5.js → office-docx-C8sYW8O1.js} +5 -5
  59. package/src/ui/public/assets/{office-excel-CBKszjVA.js → office-excel-DpZn5My2.js} +1 -1
  60. package/src/ui/public/assets/{office-pptx-NeUSnN3k.js → office-pptx-BnYBTwT7.js} +1 -1
  61. package/src/ui/public/assets/previewSandbox-CxLaNg4X.css +1 -0
  62. package/src/ui/public/assets/previewSandbox-DEQpyVNu.js +0 -0
  63. package/src/ui/public/assets/toolsStore-DMCSd287.js +1 -0
  64. package/src/ui/public/assets/{useThemeObserver-E7Hk50l3.js → useThemeObserver-QpveGpnn.js} +1 -1
  65. package/src/ui/public/assets/{vendor-C5qX_I8r.css → vendor-C-GCxMMu.css} +1 -1
  66. package/src/ui/public/assets/{vendor-IQQMq-1c.js → vendor-CLmPUj3K.js} +43 -43
  67. package/src/ui/public/assets/vue-flow-CxnoC7Uq.js +39 -0
  68. package/src/ui/public/fonts/jetbrains-mono-latin.woff2 +0 -0
  69. package/src/ui/public/fonts/plus-jakarta-sans-latin-ext.woff2 +0 -0
  70. package/src/ui/public/fonts/plus-jakarta-sans-latin-italic.woff2 +0 -0
  71. package/src/ui/public/fonts/plus-jakarta-sans-latin.woff2 +0 -0
  72. package/src/ui/public/index.html +28 -27
  73. package/src/ui/server/routes/workbench/agentChat.js +69 -12
  74. package/src/ui/server/routes/workbench/agentChatContext.test.js +106 -0
  75. package/src/ui/server/routes/workbench/agentChatShared.test.js +33 -3
  76. package/src/ui/server/routes/workbench/agentMarketplace.js +23 -9
  77. package/src/ui/server/routes/workbench/agentMarketplace.test.js +32 -0
  78. package/src/ui/server/routes/workbench/agentRoutes.js +57 -15
  79. package/src/ui/server/routes/workbench/agentRoutes.test.js +142 -2
  80. package/src/ui/server/routes/workbench/taskRunner.codex.test.js +173 -0
  81. package/src/ui/server/routes/workbench/taskRunner.js +259 -10
  82. package/src/ui/server/routes/workbench/taskRunner.opencode.test.js +4 -2
  83. package/src/ui/server/utils/agentAttachments.js +169 -0
  84. package/src/ui/server/utils/agentAttachments.test.js +140 -0
  85. package/src/ui/public/assets/AgentView-Bz9LFamz.js +0 -5
  86. package/src/ui/public/assets/AgentView-vvVhbIQ5.css +0 -1
  87. package/src/ui/public/assets/AppVersionBadge-CJrDr7GV.css +0 -1
  88. package/src/ui/public/assets/AppVersionBadge-D5iXcAEV.js +0 -2
  89. package/src/ui/public/assets/BranchSelector-B0eLkAdv.css +0 -1
  90. package/src/ui/public/assets/BranchSelector-DP4u1f_B.js +0 -1
  91. package/src/ui/public/assets/CommitForm-CQ0K8Yy2.css +0 -1
  92. package/src/ui/public/assets/CommitForm-boOw8uGS.js +0 -6
  93. package/src/ui/public/assets/CommonDialog-B_qUsIGr.js +0 -1
  94. package/src/ui/public/assets/CommonDialog-jP0E9mco.css +0 -1
  95. package/src/ui/public/assets/EditorView-DZMLvQds.css +0 -1
  96. package/src/ui/public/assets/EditorView-ZULtfIzl.js +0 -1
  97. package/src/ui/public/assets/FileDiffViewer-B9EkuKdF.css +0 -1
  98. package/src/ui/public/assets/FileDiffViewer-C4xxq5S0.js +0 -15
  99. package/src/ui/public/assets/FlowOrchestrationWorkspace-28kErqrn.js +0 -21
  100. package/src/ui/public/assets/FlowOrchestrationWorkspace-CYGp-PqS.css +0 -1
  101. package/src/ui/public/assets/IconButton-DcOAqpq0.js +0 -1
  102. package/src/ui/public/assets/IconButton-tjkOjzkC.css +0 -1
  103. package/src/ui/public/assets/LogList-BlJplIi1.css +0 -1
  104. package/src/ui/public/assets/LogList-FEP7EeJt.js +0 -7
  105. package/src/ui/public/assets/MindmapView-8X6z245q.css +0 -1
  106. package/src/ui/public/assets/MindmapView-NDJvfSCn.js +0 -2
  107. package/src/ui/public/assets/MonitorView-DU4aBCK0.js +0 -1
  108. package/src/ui/public/assets/MonitorView-DetlFFXq.css +0 -1
  109. package/src/ui/public/assets/ProjectStartupButton-JQk4Etsu.js +0 -1
  110. package/src/ui/public/assets/ProjectStartupButton-QLPltCK5.css +0 -1
  111. package/src/ui/public/assets/RemoteManagerDialog-BauAeH2w.css +0 -1
  112. package/src/ui/public/assets/RemoteManagerDialog-Di2WizMS.js +0 -4
  113. package/src/ui/public/assets/RemoteRepoCard-CG-jARTe.css +0 -1
  114. package/src/ui/public/assets/RemoteRepoCard-DxB8Q8yE.js +0 -7
  115. package/src/ui/public/assets/SourceMapView-CN4Hkyp4.css +0 -1
  116. package/src/ui/public/assets/SourceMapView-CfmrPMBm.js +0 -3
  117. package/src/ui/public/assets/SvgIcon-yefEMV0-.js +0 -1
  118. package/src/ui/public/assets/TemplateManager-CCy_WcSK.js +0 -1
  119. package/src/ui/public/assets/TemplateManager-DMuG4i3G.css +0 -1
  120. package/src/ui/public/assets/UserInputNode-3Gv_9YZx.js +0 -1
  121. package/src/ui/public/assets/UserInputNode-BVgQkB2l.css +0 -1
  122. package/src/ui/public/assets/WorkbenchView-B1BJhWHi.css +0 -1
  123. package/src/ui/public/assets/WorkbenchView-Dgp6A83v.js +0 -56
  124. package/src/ui/public/assets/configStore-BDxel6Co.js +0 -1
  125. package/src/ui/public/assets/element-plus-Cwwq-Gje.css +0 -1
  126. package/src/ui/public/assets/flow-mindmap-BqFb1WO5.js +0 -139
  127. package/src/ui/public/assets/gitStore-D5CWxPLv.js +0 -6
  128. package/src/ui/public/assets/index-CmbatbSR.css +0 -1
  129. package/src/ui/public/assets/index-gpuX9fhG.js +0 -111
  130. package/src/ui/public/assets/previewSandbox-CgdwPcuW.css +0 -1
  131. package/src/ui/public/assets/previewSandbox-qLKxghfr.js +0 -0
  132. 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-gpuX9fhG.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-IQQMq-1c.js">
18
- <link rel="modulepreload" crossorigin href="/assets/_plugin-vue_export-helper-B1mHBne9.js">
19
- <link rel="modulepreload" crossorigin href="/assets/SvgIcon-yefEMV0-.js">
20
- <link rel="modulepreload" crossorigin href="/assets/IconButton-DcOAqpq0.js">
21
- <link rel="modulepreload" crossorigin href="/assets/configStore-BDxel6Co.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-D5CWxPLv.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-qLKxghfr.js">
27
- <link rel="modulepreload" crossorigin href="/assets/FileDiffViewer-C4xxq5S0.js">
28
- <link rel="modulepreload" crossorigin href="/assets/CommonDialog-B_qUsIGr.js">
29
- <link rel="modulepreload" crossorigin href="/assets/TemplateManager-CCy_WcSK.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-C5qX_I8r.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>
@@ -24,14 +24,13 @@
24
24
  // - { type: 'tool_call_start', toolCallId, name, argsPreview }
25
25
  // - { type: 'tool_output', toolCallId, chunk } — 命令执行中的增量输出(仅展示)
26
26
  // - { type: 'tool_result', toolCallId, name, result }
27
- // - { type: 'ask_user', interactionId, question, options, allowFreeText }
27
+ // - { type: 'ask_user', interactionId, question, options, allowFreeText, multiple }
28
28
  // - { type: 'done', content } — 本轮最终完成
29
29
  // - { type: 'error', error }
30
30
 
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';
@@ -135,6 +134,7 @@ ${isWin ? `- 当前是 Windows,以下 Unix 命令**不存在**,用了必定报"
135
134
  # 与用户交互
136
135
  - 需要向用户确认、提问或汇报重要决策时,直接用普通文本输出
137
136
  - 需要暂停当前任务并等待用户决定或补充信息时,调用 ask_user,不要猜测或只在普通文本里提问
137
+ - 一次要让用户从多个选项里选好几项时(如"要我改哪几个文件"),传 multiple: true,用户会勾选后统一提交
138
138
  - 不要调用不存在的工具,可用工具只有上面列出的 ${builtinToolCount} 个
139
139
  - 用户可能随消息附带图片:图片以 image_url 部件出现在 user 消息里;如果当前模型不支持视觉(带图请求报错),提醒用户换用支持视觉的模型
140
140
  - 发现高风险或状态不一致的情况时:先用文本说明发现和影响,停下来等用户指示,不要擅自继续破坏性操作
@@ -205,7 +205,7 @@ ${isWin ? `- This is Windows. The following Unix commands do NOT exist here:
205
205
 
206
206
  # How you work
207
207
  - Act first, ask later: use tools to investigate before asking the user
208
- - When a decision or missing detail must come from the user, call ask_user. Do not guess or merely describe a question in plain text.
208
+ - When a decision or missing detail must come from the user, call ask_user. Do not guess or merely describe a question in plain text. Pass multiple: true when the user should pick several options at once.
209
209
  - After modifying code, verify: run tests, build, or at least syntax check
210
210
  - Prefer edit_file for precise replacements; read_file first to confirm original text
211
211
  - Use offset/limit for large files
@@ -241,6 +241,9 @@ ${isWin ? `- This is Windows. The following Unix commands do NOT exist here:
241
241
  // - model: { baseURL, model, apiKey }
242
242
  // - userMessage: 用户输入文本
243
243
  // - images: base64 dataURL 数组(可选,多模态图片,随最新一条 user 消息发给模型)
244
+ // - openFilePath: 文件空间里当前打开的文档(可选,注入请求副本,不落库)
245
+ // - attachments: 非图片附件(可选) = [{ name, path }],path 是**服务端落盘后的绝对路径**,
246
+ // 只把路径写进请求副本的 system 提示,内容由模型自己用工具读(见 utils/agentAttachments.js)
244
247
  // - cwd: 工作目录
245
248
  // - locale: 'zh' | 'en'
246
249
  // - signal: AbortSignal (客户端断开时触发)
@@ -251,7 +254,7 @@ ${isWin ? `- This is Windows. The following Unix commands do NOT exist here:
251
254
  // (由 agentRoutes 注入:最近目录/tasks.json/看板统计只有 GUI 侧拿得到)
252
255
  //
253
256
  // 返回: { aborted: boolean }
254
- 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 }) {
255
258
  const ctx = { cwd, locale, onChild, askUser, listProjects };
256
259
 
257
260
  // 确保 session.messages 存在
@@ -283,6 +286,8 @@ export async function runAgentTurn({ session, model, userMessage, images = [], c
283
286
  // 摘录成一条梗概 → 旧图片降级 → provider 兼容消毒。
284
287
  // 只作用于副本,session.messages 保持完整(与 CLI 的磁盘口径一致)。
285
288
  const messages = prepareRequestMessages(session.messages, { locale });
289
+ // 请求级上下文:当前打开的文档 + 本轮附件路径(只改副本,不落 session.messages,下一轮不重复累积)
290
+ injectRequestContext(messages, { cwd, openFilePath, attachments, locale });
286
291
 
287
292
  let result;
288
293
  try {
@@ -305,14 +310,6 @@ export async function runAgentTurn({ session, model, userMessage, images = [], c
305
310
  return { aborted: false };
306
311
  }
307
312
 
308
- if (result.aborted) {
309
- // 保存当前会话状态
310
- session.updatedAt = new Date().toISOString();
311
- await agentSessionStore.write(session.sessionId, session).catch(() => {});
312
- send({ type: 'error', error: '已取消' });
313
- return { aborted: true };
314
- }
315
-
316
313
  const { content, toolCalls } = result;
317
314
 
318
315
  // 推理内容必须原样带回历史。DeepSeek 系 thinking 模式下带 tool_calls 的 assistant
@@ -321,6 +318,18 @@ export async function runAgentTurn({ session, model, userMessage, images = [], c
321
318
  // 口径与 CLI 侧 src/cli/ai/turn.js 的 assistant.reasoning_content 保持一致。
322
319
  const withReasoning = msg => (result.reasoning ? { ...msg, reasoning_content: result.reasoning } : msg);
323
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
+
324
333
  // 无工具调用:本轮结束
325
334
  if (toolCalls.length === 0) {
326
335
  session.messages.push(withReasoning({ role: 'assistant', content: content || null }));
@@ -408,4 +417,52 @@ function summarizeArgs(name, args) {
408
417
  }
409
418
  }
410
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
+
411
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(
@@ -225,6 +225,15 @@ function isSafeSubpath(value) {
225
225
  return SAFE_SUBPATH.test(String(value || ''));
226
226
  }
227
227
 
228
+ // 本地关键词匹配:与 awesomeSkills 同一套语义(小写子串)。
229
+ // 静态来源(builtin / Anthropic 官方仓库)也必须过这一关 ——
230
+ // 它们不走目录站的 query 参数,漏掉过滤就等于搜索框对这两组完全失效。
231
+ function matchesQuery(query, ...fields) {
232
+ const q = String(query || '').trim().toLowerCase();
233
+ if (!q) return true;
234
+ return fields.join(' ').toLowerCase().includes(q);
235
+ }
236
+
228
237
  function resolveProjectPath(value, fallback) {
229
238
  const raw = String(value || fallback || '').trim();
230
239
  if (!raw) throw new HttpError(400, '项目路径不能为空');
@@ -292,15 +301,17 @@ async function cached(key, loader) {
292
301
  // ── provider: Skill ───────────────────────────────────────────
293
302
 
294
303
  // builtin:纯静态,不联网
295
- function builtinSkills() {
296
- return BUILTIN_SKILLS.map(item => ({ ...item, type: 'skill' }));
304
+ function builtinSkills(query) {
305
+ return BUILTIN_SKILLS
306
+ .filter(item => matchesQuery(query, item.name, item.description, ...(item.tags || [])))
307
+ .map(item => ({ ...item, type: 'skill' }));
297
308
  }
298
309
 
299
310
  // Anthropic 官方仓库:用 GitHub contents API 列出 skills/ 下的目录。
300
311
  // 这是所有 skill 的"源头",目录结构稳定(path = skills/<name>)。
301
312
  // contents API 不返回描述,所以用内置表里的描述补上(两边都以目录名为键),
302
313
  // 补不到的条目在前端只显示名字,不会假装有描述。
303
- async function officialSkills() {
314
+ async function officialSkills(query) {
304
315
  const data = await fetchJson('https://api.github.com/repos/anthropics/skills/contents/skills', { headers: githubHeaders() });
305
316
  if (!Array.isArray(data)) throw new Error('GitHub 返回结构异常');
306
317
  const described = new Map(ANTHROPIC_SKILLS);
@@ -315,7 +326,8 @@ async function officialSkills() {
315
326
  subpath: `skills/${entry.name}`,
316
327
  upstream: entry.html_url,
317
328
  tags: ['official'],
318
- }));
329
+ }))
330
+ .filter(item => matchesQuery(query, item.name, item.description));
319
331
  }
320
332
 
321
333
  // SkillsMP:第三方 skill 目录站,给的是 owner/repo + 仓库内路径,可直接安装。
@@ -467,8 +479,10 @@ function normalizeNpmSearch(data, type) {
467
479
 
468
480
  // ── provider: MCP ─────────────────────────────────────────────
469
481
 
470
- function builtinMcps() {
471
- return BUILTIN_MCPS.map(item => ({ ...item, type: 'mcp', command: 'npx' }));
482
+ function builtinMcps(query) {
483
+ return BUILTIN_MCPS
484
+ .filter(item => matchesQuery(query, item.name, item.description, item.package, ...(item.tags || [])))
485
+ .map(item => ({ ...item, type: 'mcp', command: 'npx' }));
472
486
  }
473
487
 
474
488
  // MCP 官方注册表:canonical 数据源,直接给出 npm identifier 与所需环境变量,
@@ -562,15 +576,15 @@ async function npmMcps(query) {
562
576
 
563
577
  const PROVIDERS = {
564
578
  skill: {
565
- builtin: () => builtinSkills(),
566
- official: () => officialSkills(),
579
+ builtin: ({ query }) => builtinSkills(query),
580
+ official: ({ query }) => officialSkills(query),
567
581
  skillsmp: ({ query }) => skillsmpSkills(query),
568
582
  awesome: ({ query }) => awesomeSkills(query),
569
583
  github: ({ query }) => (query ? githubSkills(query) : Promise.resolve([])),
570
584
  npm: ({ query }) => npmSkills(query),
571
585
  },
572
586
  mcp: {
573
- builtin: () => builtinMcps(),
587
+ builtin: ({ query }) => builtinMcps(query),
574
588
  registry: ({ query }) => registryMcps(query),
575
589
  smithery: ({ query }) => smitheryMcps(query),
576
590
  npm: ({ query }) => npmMcps(query),
@@ -91,6 +91,38 @@ test('catalog: 只查内置来源时完全离线,并保留按来源分组的形
91
91
  }
92
92
  });
93
93
 
94
+ test('catalog: 内置来源也按关键词过滤(skill / MCP 都不例外)', async () => {
95
+ const { call } = harness();
96
+
97
+ const all = await call('GET /api/agent/marketplace/catalog', {
98
+ query: { type: 'skill', sources: 'builtin', cwd: sandboxProject },
99
+ });
100
+ const unfiltered = all.payload.groups[0].items.length;
101
+
102
+ const { payload } = await call('GET /api/agent/marketplace/catalog', {
103
+ query: { type: 'skill', sources: 'builtin', cwd: sandboxProject, q: 'pdf' },
104
+ });
105
+ const [group] = payload.groups;
106
+ assert.equal(group.status, 'ok');
107
+ assert.ok(group.items.length > 0, 'pdf 是内置条目,不该被过滤掉');
108
+ assert.ok(group.items.length < unfiltered, '关键词必须真的收窄结果');
109
+ assert.equal(group.items.some(item => item.name === 'docx'), false, '关键词 pdf 不该命中 docx');
110
+ for (const item of group.items) {
111
+ assert.match(`${item.name} ${item.description}`, /pdf/i, `${item.name} 与关键词无关`);
112
+ }
113
+
114
+ const { payload: mcp } = await call('GET /api/agent/marketplace/catalog', {
115
+ query: { type: 'mcp', sources: 'builtin', cwd: sandboxProject, q: 'memory' },
116
+ });
117
+ assert.deepEqual(mcp.groups[0].items.map(item => item.name), ['Memory']);
118
+
119
+ // 清空关键词必须回到全量,否则用户删掉搜索词后会看到一片空
120
+ const { payload: cleared } = await call('GET /api/agent/marketplace/catalog', {
121
+ query: { type: 'mcp', sources: 'builtin', cwd: sandboxProject, q: '' },
122
+ });
123
+ assert.ok(cleared.groups[0].items.length > 1, '空关键词应返回全部内置 MCP');
124
+ });
125
+
94
126
  test('catalog: 未指定来源时返回全部来源,每个来源是独立的一组', async () => {
95
127
  const { call } = harness();
96
128
  const { payload } = await call('GET /api/agent/marketplace/catalog', {