zen-gitsync 2.18.11 → 2.18.13

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 (94) hide show
  1. package/README.md +14 -14
  2. package/package.json +6 -1
  3. package/src/cli/ai/agent.js +1385 -1385
  4. package/src/cli/ai/context.js +431 -431
  5. package/src/cli/ai/context.test.js +376 -376
  6. package/src/cli/ai/turn.js +190 -190
  7. package/src/ui/public/assets/AgentView-CuEM6xzC.js +11 -0
  8. package/src/ui/public/assets/{AgentView-CbBLjlfr.css → AgentView-D6MJOrJt.css} +1 -1
  9. package/src/ui/public/assets/AppVersionBadge-BK1Fiv3b.js +2 -0
  10. package/src/ui/public/assets/BranchSelector-avxY_m05.js +1 -0
  11. package/src/ui/public/assets/CommitForm-hThTnoNt.js +14 -0
  12. package/src/ui/public/assets/CommonDialog-zaBdFGFH.js +1 -0
  13. package/src/ui/public/assets/CopySessionButton-CAxaqLQL.js +21 -0
  14. package/src/ui/public/assets/EditorView-B7Rq9QV1.css +1 -0
  15. package/src/ui/public/assets/EditorView-iSA2SXJR.js +37 -0
  16. package/src/ui/public/assets/{FlowExecutionViewer-D7FaZSHd.js → FlowExecutionViewer-C5ylYV2C.js} +1 -1
  17. package/src/ui/public/assets/FlowOrchestrationWorkspace-CiQ07ZQv.js +21 -0
  18. package/src/ui/public/assets/LogList-Sjb8vmvq.js +7 -0
  19. package/src/ui/public/assets/MindmapView-B5rChFMP.js +2 -0
  20. package/src/ui/public/assets/MonitorView-Dcc-i2Fo.js +3 -0
  21. package/src/ui/public/assets/ProjectStartupButton-fz6eLCZU.js +1 -0
  22. package/src/ui/public/assets/RecentDirectoriesChat-BDnNp9jh.js +3 -0
  23. package/src/ui/public/assets/RemoteManagerDialog-NA1jRC7O.js +4 -0
  24. package/src/ui/public/assets/RemoteRepoCard-CKm4KqHs.js +7 -0
  25. package/src/ui/public/assets/SourceMapView-Drfato_u.js +3 -0
  26. package/src/ui/public/assets/SvgIcon-DR_79iBR.js +1 -0
  27. package/src/ui/public/assets/UserInputNode-DroKvDOa.js +1 -0
  28. package/src/ui/public/assets/WorkbenchView-CW7OACCU.js +326 -0
  29. package/src/ui/public/assets/WorkbenchView-C_u6BptZ.css +1 -0
  30. package/src/ui/public/assets/_plugin-vue_export-helper-bQQyMRS6.js +19 -0
  31. package/src/ui/public/assets/agentConversations-wYAksLq1.js +10 -0
  32. package/src/ui/public/assets/{configStore-Z6Z8hVMO.js → configStore-BeHB-gV4.js} +1 -1
  33. package/src/ui/public/assets/{css.worker-BDCmgL4U.js → css.worker-DFdt6_zu.js} +9 -9
  34. package/src/ui/public/assets/{dagre-CiGYsZhd.js → dagre-CXi8gaG9.js} +4 -4
  35. package/src/ui/public/assets/editor.worker-O-CH8JUO.js +26 -0
  36. package/src/ui/public/assets/{element-plus-D-6gla1z.css → element-plus-DJ3ZQa98.css} +1 -1
  37. package/src/ui/public/assets/element-plus-DgSY_Wt4.js +48 -0
  38. package/src/ui/public/assets/{flow-mindmap-C-X_QPQI.js → flow-mindmap-BsTRkgat.js} +56 -56
  39. package/src/ui/public/assets/{html.worker-DG8hnSJR.js → html.worker-JUzsMP-j.js} +8 -8
  40. package/src/ui/public/assets/index-BRQstBzy.js +211 -0
  41. package/src/ui/public/assets/json.worker-B4dEhy0e.js +58 -0
  42. package/src/ui/public/assets/{monaco-CbN14ZVi.js → monaco-CnWeufxc.js} +6 -6
  43. package/src/ui/public/assets/{office-docx-8MF5-THx.js → office-docx-EUfAIM4w.js} +6 -6
  44. package/src/ui/public/assets/{office-excel-BTzQyK6y.js → office-excel-BZW0bXQo.js} +1 -1
  45. package/src/ui/public/assets/{office-pptx-DhxvWA0D.js → office-pptx-CBvDNZ4m.js} +3 -3
  46. package/src/ui/public/assets/{ts.worker-DgFMIcEs.js → ts.worker-CmEKzkmF.js} +49 -49
  47. package/src/ui/public/assets/useOrchestrator-Bhl7DGKW.js +1 -0
  48. package/src/ui/public/assets/{vendor-Bd8DyOF9.css → vendor-CSFGMcMB.css} +1 -1
  49. package/src/ui/public/assets/{vendor-Cd7TGb0b.js → vendor-CZlKgriV.js} +43 -43
  50. package/src/ui/public/assets/vue-flow-DawpR_uk.js +39 -0
  51. package/src/ui/public/index.html +13 -13
  52. package/src/ui/server/routes/config.js +1348 -1348
  53. package/src/ui/server/routes/fileOpen.js +85 -16
  54. package/src/ui/server/routes/workbench/agentChat.js +752 -752
  55. package/src/ui/server/routes/workbench/agentChatShared.test.js +496 -497
  56. package/src/ui/server/routes/workbench/agentRoutes.js +13 -1
  57. package/src/ui/server/routes/workbench/agentRoutes.test.js +75 -0
  58. package/src/ui/server/routes/workbench/dispatchInstruction.js +6 -7
  59. package/src/ui/server/routes/workbench/dispatchInstruction.test.js +3 -19
  60. package/src/ui/server/routes/workbench/index.js +8 -17
  61. package/src/ui/server/routes/workbench/orchestratorPrompt.test.js +2 -2
  62. package/src/ui/server/routes/workbench/orchestratorStore.js +2 -16
  63. package/src/ui/server/routes/workbench/projectRegistry.test.js +1 -5
  64. package/src/ui/server/routes/workbench/scheduleRoutes.js +2 -2
  65. package/src/ui/server/routes/workbench/shared.js +18 -1
  66. package/src/ui/public/assets/AgentView-D3I3D3YR.js +0 -11
  67. package/src/ui/public/assets/AppVersionBadge-CrDf0lYs.js +0 -2
  68. package/src/ui/public/assets/BranchSelector-DEHLVln4.js +0 -1
  69. package/src/ui/public/assets/CommitForm-D2rVFZf2.js +0 -14
  70. package/src/ui/public/assets/CommonDialog-CV7T8OCb.js +0 -1
  71. package/src/ui/public/assets/CopySessionButton-BbVtrOa7.js +0 -21
  72. package/src/ui/public/assets/EditorView-BiNRO222.css +0 -1
  73. package/src/ui/public/assets/EditorView-CiW0kkpS.js +0 -37
  74. package/src/ui/public/assets/FlowOrchestrationWorkspace-BR3cPhyU.js +0 -21
  75. package/src/ui/public/assets/LogList-D_Q3pyMF.js +0 -7
  76. package/src/ui/public/assets/MindmapView-A4QZ6_c3.js +0 -2
  77. package/src/ui/public/assets/MonitorView-BljWw76B.js +0 -3
  78. package/src/ui/public/assets/ProjectStartupButton-Be5DovFL.js +0 -1
  79. package/src/ui/public/assets/RecentDirectoriesChat-DN7ruCU7.js +0 -3
  80. package/src/ui/public/assets/RemoteManagerDialog-BSCNy4RC.js +0 -4
  81. package/src/ui/public/assets/RemoteRepoCard-CeCRQp9p.js +0 -7
  82. package/src/ui/public/assets/SourceMapView-B_S-vspy.js +0 -3
  83. package/src/ui/public/assets/SvgIcon-9IbiSszR.js +0 -1
  84. package/src/ui/public/assets/UserInputNode-DASGnUCl.js +0 -1
  85. package/src/ui/public/assets/WorkbenchView-B3v-0vWa.js +0 -305
  86. package/src/ui/public/assets/WorkbenchView-DO2ndsES.css +0 -1
  87. package/src/ui/public/assets/_plugin-vue_export-helper-zp6tdw8N.js +0 -19
  88. package/src/ui/public/assets/agentConversations-Cplq42kA.js +0 -10
  89. package/src/ui/public/assets/editor.worker-GCPm6rMv.js +0 -26
  90. package/src/ui/public/assets/element-plus-CJqN49tX.js +0 -48
  91. package/src/ui/public/assets/index-C90dYckz.js +0 -211
  92. package/src/ui/public/assets/json.worker-CxwlJRnL.js +0 -58
  93. package/src/ui/public/assets/useOrchestrator-C3wjJ7p4.js +0 -1
  94. package/src/ui/public/assets/vue-flow-x4TlHHzT.js +0 -39
@@ -1,752 +1,752 @@
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
- // Web 端智能体聊天引擎。
16
- //
17
- // 复用 CLI 侧 src/cli/ai/tools.js 的工具定义与执行器,
18
- // 但 LLM 流式 + 工具调用循环通过 SSE 事件推给前端,而非终端打印。
19
- //
20
- // SSE 事件类型:
21
- // - { type: 'meta', sessionId, isNew, title }
22
- // - { type: 'thinking', delta } — 推理过程增量
23
- // - { type: 'content', delta } — 正文增量
24
- // - { type: 'tool_call_start', toolCallId, name, argsPreview, arguments }
25
- // 两个字段服务的是工具块的两副面孔,别混用:
26
- // · argsPreview —— 收起态那一行摘要,故意截断(200 字),前端拿它当副标题;
27
- // · arguments —— 展开后「参数」框里的原文,**一律发全文**。
28
- // 全文要全给:展开态本来就是"我要看它到底传了什么",而会话历史里存的
29
- // (session.messages 的 tool_calls.function.arguments)本来就是全文 ——
30
- // 只发摘要会让「正在跑」和「刷新后重放」看到两份不同的参数
31
- // (useAgentChat 的历史回放分支直接读 arguments)。SSE 走本地回环,
32
- // 代价只是把同一份字符串多发一遍。
33
- // - { type: 'tool_output', toolCallId, chunk } — 命令执行中的增量输出(仅展示)
34
- // - { type: 'tool_result', toolCallId, name, result }
35
- // - { type: 'ask_user', interactionId, question, options, allowFreeText, multiple }
36
- // - { type: 'attempt', attempt } — 一次 LLM 请求**开始**(工具循环里每轮一条)。
37
- // 发它是为了让前端记一个"断点":这个位置上正文/思考已经有多长。
38
- // 自动重试时前端按这个断点回退,只丢掉失败那次尝试吐的字,
39
- // 本轮前面几轮(工具循环)说过的话留着。
40
- // **重试不重发它**:重试要丢的正是这一轮请求吐的全部内容,断点还是同一个。
41
- // - { type: 'retry', attempt, maxRetries, delayMs, reason }
42
- // 传输层正在自动重试(最多 10 次,见 cli/ai/transport.js)。前端收到就回退到断点、
43
- // 清掉错误态;不处理的话界面上会出现"半截答案 + 重来一遍"拼在一起的两段话。
44
- // - { type: 'done', content } — 本轮最终完成
45
- // - { type: 'error', error }
46
- //
47
- // 另外,轮次**跑完之后**路由层还会补发一条 { type: 'turn_done', turnIndex, durationMs }
48
- // (见 agentRoutes.js)—— 本轮用时的唯一权威值,前端拿它给回答气泡定格计时。
49
- // 不在这里发是因为它要覆盖中断/报错的轮次,得由真正知道本轮何时收尾的那一层给。
50
-
51
- import path from 'path';
52
- import os from 'os';
53
- import { logger } from './shared.js';
54
-
55
- // 从 CLI 侧导入工具定义、执行器与 LLM 传输层(同一 monorepo,路径可达)
56
- import { TOOL_DEFINITIONS, executeTool, normalizePlanSteps, summarizePlan, splitToolOutput, toolMessageContent } from '../../../../cli/ai/tools.js';
57
- import { prepareRequestMessages, measureContextUsage, resolveRequestBudget } from '../../../../cli/ai/context.js';
58
- import { streamChatOnce } from '../../../../cli/ai/transport.js';
59
- import { checkDangerousCommand } from '../../../../cli/ai/safety.js';
60
- import { guardCommand } from '../../../../cli/ai/platformGuard.js';
61
- import configManager from '../../../../config.js';
62
- // 用户固定提示词(g ai 默认提示词 + 派发提示词)的取数与拼装。与外部 CLI 引擎那条
63
- // 链路共用同一份(见 runExternalTurn 的调用点),免得两块提示词在两处各拼一遍。
64
- import { resolveChatPromptSection } from './chatPrompts.js';
65
- // 扩展层(Skill 清单 + MCP 工具)按 cwd 的加载缓存。CLI 在 agent.js 启动时加载;
66
- // Web 会话长命,没有"进程重启"这个自然边界,改成按 cwd 缓存 + 广场安装时失效。
67
- import { getWebExtensions } from './agentExtensions.js';
68
-
69
- // 单轮工具调用循环数的兜底值(防失控);实际值取全局配置 aiMaxToolIterations,
70
- // 与 CLI 侧 src/cli/ai/agent.js 共用同一个配置项。
71
- const DEFAULT_MAX_TOOL_ITERATIONS = 1000;
72
-
73
- // 读取全局配置里的单轮工具调用上限。
74
- // 读配置失败不该把整轮对话打挂 —— 退回默认值继续跑,比用户消息直接发不出去好。
75
- async function resolveMaxToolIterations() {
76
- try {
77
- const cfg = await configManager.loadConfig();
78
- const n = Number(cfg?.aiMaxToolIterations);
79
- if (Number.isFinite(n) && n > 0) return Math.floor(n);
80
- } catch (err) {
81
- logger.warn(`[agentChat] 读取 aiMaxToolIterations 失败,回退默认值: ${err?.message || err}`);
82
- }
83
- return DEFAULT_MAX_TOOL_ITERATIONS;
84
- }
85
-
86
- // ── 系统提示词构建 ──────────────────────────────────────────
87
- // 与 CLI agent.js 的 buildSystemPrompt 保持一致,但标注来源为 Web 端
88
- function buildWebSystemPrompt({ cwd, locale }) {
89
- const zh = !String(locale || '').startsWith('en');
90
- const now = new Date().toLocaleString();
91
- const isWin = process.platform === 'win32';
92
- const builtinToolCount = TOOL_DEFINITIONS.length;
93
- const shellDesc = isWin ? 'cmd.exe / PowerShell' : '/bin/sh';
94
-
95
- if (zh) {
96
- return `你是 "g ai" —— zen-gitsync 内置的编码智能体,通过工具在用户真实电脑上完成编码任务。当前用户通过 Web 界面与你对话。
97
-
98
- # 运行环境
99
- - 操作系统: ${process.platform}
100
- - Shell: ${shellDesc}
101
- - 当前工作目录: ${cwd}
102
- - 当前时间: ${now}
103
-
104
- # 平台兼容性(重要!)
105
- - 必须使用与当前 Shell 兼容的命令,禁止盲套 Unix 写法
106
- ${isWin ? `- 当前是 Windows,以下 Unix 命令**不存在**,用了必定报"不是内部或外部命令":
107
- tail / head / cat / grep / ls / sed / awk / wc / cut / uniq / xargs / which / touch
108
- 平台守卫会在执行前拦截这些命令,但请主动避免,不要浪费一轮调用
109
- - 跨平台替代方案:
110
- · 列目录 → list_files 工具 或 cmd 的 dir
111
- · 搜内容 → search_text 工具 或 cmd 的 findstr
112
- · 看文件 → read_file 工具 或 cmd 的 type
113
- · 看图片 → read_image 工具(截图/设计稿/示意图)。read_file 读图片只会得到乱码,别浪费那一轮
114
- · 看输出末尾 → PowerShell "命令 | Select-Object -Last N"
115
- · 文本处理 → node -e "..." 或 PowerShell
116
- · 查命令路径 → cmd 的 where(不是 which)
117
- - 必须跑 shell 时优先跨平台写法(如 node -e "..."),别用 Unix 专属命令` : `- 当前是 POSIX 环境,Unix 命令可用`}
118
-
119
- # 远程仓库(GitHub / Gitee)
120
- - 问"我有哪些项目""哪些项目需要 pull / 推送" → 用 list_projects 工具。它返回的就是 g ui
121
- 「最近项目」面板那份清单(最近目录 + 建过任务的目录,带分支/领先/落后/未提交数与任务进度),
122
- 口径与界面完全一致。**不要**用 list_files 自己扫盘数仓库 —— 那会把 node_modules 里的嵌套
123
- 仓库也算进来,数出来的个数跟界面对不上。领先/落后是本地快照,要真实值就带 refresh=true
124
- 先联网 fetch 一轮,再回答"要不要 pull"
125
- - 问"这个项目关联哪个远端" → run_command 跑 \`git remote -v\`:本地信息,不联网、不依赖任何 CLI,任何环境都能答
126
- - 问"我账号下有哪些仓库"或某仓库的 PR / Issue → 用官方 CLI。凭据由 CLI 自己保管:
127
- · GitHub → \`gh\`。列仓库 \`gh repo list --limit 50 --json name,visibility,updatedAt,primaryLanguage\`;
128
- 看详情 \`gh repo view <owner/repo>\`;PR \`gh pr list\`;登录态 \`gh auth status\`
129
- · Gitee → \`gitee\`。列仓库 \`gitee repo list\`;登录态 \`gitee auth status\`
130
- (未登录时退出码**仍是 0**,必须看 \`--json\` 里的 status 字段,只看退出码会误判成已登录)
131
- · 两者默认只覆盖**当前登录账号**;组织仓库、私有仓库可能需要更大的 token scope,拉不到就如实说明,
132
- 不要用其他途径绕过
133
- · 绝不向用户索要 token —— 也不要让用户把 token 粘进对话
134
- - CLI 可能没装、或没在服务端进程的 PATH 里(装完没重启服务就是这个表现,Web 端尤其常见):
135
- 报"不是内部或外部命令" / command not found 时,**不要换写法反复重试**,更不要凭印象编仓库名或目录名。
136
- 直接告诉用户未检测到该 CLI,可在「远程仓库」页一键安装/登录,或需要时重启服务让新装的 CLI 生效;
137
- 若只是想回答本项目的问题,退回 \`git remote -v\`
138
- - 克隆仓库 / 添加远端时**优先用 SSH**(用户偏好,本机已配好密钥,实测走 https 会弹凭据窗口把任务打断):
139
- · GitHub 用 \`git@github.com:owner/repo.git\`,Gitee 用 \`git@gitee.com:owner/repo.git\`
140
- · 用户给的是 \`https://...\` 或 \`gh repo clone owner/repo\` 时,先换算成 SSH 地址再执行 ——
141
- 走 https 会弹 Git Credential Manager 让用户输账号密码,任务就停在半路等输入
142
- · 已有仓库换协议:\`git remote set-url origin <ssh 地址>\`(先 \`git remote -v\` 看当前是什么)
143
- · 只有 SSH 真的不可用(报 \`Permission denied (publickey)\` / \`Host key verification failed\`)才退回 https,
144
- 并用一句话说明这次走的是 https、配好密钥后可改回;两边都失败就停下来问用户,不要反复重试
145
-
146
- # 定时任务(到点自动执行)
147
- - 用户要"每天/每周/到点自动做某事" → 用 schedule_task 登记定时任务。你**不能**自己定时,
148
- 只有登记成任务才会真的执行;不要口头承诺"我会定时做"
149
- - 执行者是 g ui 服务端的调度器:g ui 没运行的时间段任务不会执行,重新运行后按任务的
150
- on_missed 策略补跑一次或跳过 —— 回复用户时要如实说明这个边界
151
- - 每轮执行落进任务的专属会话(标题=任务名),用户随时能回看;创建成功后把任务名、
152
- 计划(cron)与下次执行时间回复给用户
153
- - prompt 要写成**自足的一轮请求**:未来那一轮读不到现在的对话上下文
154
- - 查看/删除/启停任务先用 action: list 拿任务 id
155
-
156
- # 权限(用户已明确授权,无需反复征求同意)
157
- - 工作目录内:读写文件、执行命令等所有操作直接执行
158
- - 其他目录:同样可以读取和修改
159
- - 唯一红线:不得破坏系统(格式化磁盘、删除根目录/系统目录、关机重启、写块设备等)。
160
- 安全守卫会拦截这类命令;被拦截时换安全方案,或告知用户需要他手动执行。
161
-
162
- # 工作方式
163
- - 先动手、后提问:能用工具查清的不要问用户(list_files / read_file / search_text / run_command)
164
- - 修改代码后主动验证:跑测试、构建或至少语法检查(用 run_command)
165
- - 编辑文件优先 edit_file 精确替换;先 read_file 看原文,old_string 必须与文件内容完全一致(含缩进换行)
166
- - 大文件用 offset/limit 分段读取,不要一次读爆上下文
167
- - git 操作用 run_command 执行
168
- - run_command 默认就在工作目录执行,不要再加 cd 前缀;默认超时 120 秒,长任务加大 timeout_seconds(最大 600)
169
- - 命令在 ${shellDesc} 下执行,注意语法兼容
170
-
171
- # 任务计划(多步任务必用)
172
- - 任务需要多步时(改代码、排查、调研、多文件改动),**动手之前**先调一次 update_plan,把要做的事
173
- 拆成 3-8 个可核对的步骤,让用户在你改任何东西之前就知道范围和验收标准
174
- - 单步小事(读个文件、答一个问题)不要列计划,那是噪音
175
- - 每次都传**完整**的当前计划(不是增量),顺序即执行顺序;同一时刻最多一个 in_progress
176
- - 完成一步就更新一次状态,别攒到最后一次性刷;计划与实际不符时直接改计划,不要硬着头皮往下走
177
- - 全部做完后把 steps 传空表示收尾
178
- - update_plan 只是给人看的进度板,不要为了"更新计划"去改文件或跑命令
179
-
180
- # 与用户交互
181
- - 需要向用户确认、提问或汇报重要决策时,直接用普通文本输出
182
- - 需要暂停当前任务并等待用户决定或补充信息时,调用 ask_user,不要猜测或只在普通文本里提问
183
- - 一次要让用户从多个选项里选好几项时(如"要我改哪几个文件"),传 multiple: true,用户会勾选后统一提交
184
- - 不要调用不存在的工具,可用工具只有上面列出的 ${builtinToolCount} 个
185
- - 图片有两条来路:
186
- · 用户随消息附带(附件框/粘贴) → 图片以 image_url 部件出现在 user 消息里,直接就能看到
187
- · 用户只给了**路径**(或你自己在翻文件时遇到图)→ 用 read_image 工具读它,别用 read_file
188
- (read_file 按 UTF-8 解码,图片只会是乱码),也别根据文件名猜内容
189
- 如果当前模型不支持视觉(带图请求报错),提醒用户换用支持视觉的模型
190
- - 发现高风险或状态不一致的情况时:先用文本说明发现和影响,停下来等用户指示,不要擅自继续破坏性操作
191
-
192
- # 输出
193
- - 你的文本输出直接显示在用户 Web 界面,用简体中文交流
194
- - 界面按 Markdown 渲染:标题 / 列表 / 表格 / 引用 / 代码块都会排版后显示,别用 ASCII 画表格
195
- - 想让用户**看到**图(截图/图表/示意图),就在回答正文里写 Markdown 图片 \`![说明](图片路径)\` ——
196
- 本机绝对路径、或相对当前工作目录的路径都可以,界面会自动加载渲染;
197
- 只贴一条路径、或把路径放进代码块,用户看到的只是一行字
198
- - 讲**流程 / 结构 / 时序 / 状态**这类"说出来不如画出来"的东西时,写 \`\`\`mermaid 代码块
199
- (flowchart / sequenceDiagram / stateDiagram-v2 / erDiagram …),界面会把它渲染成真正的图。
200
- 不要用纯文本箭头、缩进或 ASCII 框线凑一张图 —— 那种"图"在界面上只是一段等宽文本
201
- - 完成任务后用一两句话汇报结果,不要复述过程细节`;
202
- }
203
-
204
- return `You are "g ai" — the coding agent built into zen-gitsync. You use tools to perform coding tasks on the user's real machine. The user is interacting with you via a Web interface.
205
-
206
- # Environment
207
- - OS: ${process.platform}
208
- - Shell: ${shellDesc}
209
- - Working directory: ${cwd}
210
- - Current time: ${now}
211
-
212
- # Platform compatibility (important!)
213
- - You MUST use commands compatible with the current shell.
214
- ${isWin ? `- This is Windows. The following Unix commands do NOT exist here:
215
- tail / head / cat / grep / ls / sed / awk / wc / cut / uniq / xargs / which / touch
216
- A platform guard will block these before execution, but avoid them proactively.
217
- - Cross-platform alternatives:
218
- · List dirs → list_files tool, or cmd's dir
219
- · Search content → search_text tool, or cmd's findstr
220
- · Read files → read_file tool, or cmd's type
221
- · Look at an image → read_image tool (screenshots, mockups, diagrams). read_file only decodes text and returns garbage for images
222
- · Tail output → PowerShell "command | Select-Object -Last N"
223
- · Text processing → node -e "..." or PowerShell
224
- · Find executable → cmd's where (not which)` : `- POSIX environment: Unix commands are available`}
225
-
226
- # Remote repositories (GitHub / Gitee)
227
- - "Which projects do I have?" / "Which ones need a pull or push?" → use the list_projects tool.
228
- It returns exactly the list behind the GUI's "Recent projects" panel (recent directories plus any
229
- directory a task was created in, with branch / ahead / behind / uncommitted counts and task
230
- progress) — the same numbers the UI shows. Do NOT scan the disk with list_files to count repos:
231
- that also picks up nested repos inside node_modules and the totals will not match the UI.
232
- Ahead/behind comes from local refs, so pass refresh=true for a real answer about pulling
233
- - "Which remote does this project point at?" → run_command \`git remote -v\`: local info, no network, no CLI needed
234
- - "Which repos do I have?" or a repo's PRs / issues → use the official CLI. The CLI owns the credentials:
235
- · GitHub → \`gh\`. List repos \`gh repo list --limit 50 --json name,visibility,updatedAt,primaryLanguage\`;
236
- details \`gh repo view <owner/repo>\`; PRs \`gh pr list\`; auth state \`gh auth status\`
237
- · Gitee → \`gitee\`. List repos \`gitee repo list\`; auth state \`gitee auth status\`
238
- (the exit code is still 0 when logged out — read the status field from \`--json\`, or you will
239
- wrongly report "logged in")
240
- · Both default to the **currently authenticated account only**; org and private repos may need
241
- broader token scopes. If you cannot see them, say so plainly — do not route around it
242
- · Never ask the user for a token, and never have them paste one into the conversation
243
- - The CLI may not be installed, or missing from the server process's PATH (that is what "installed but
244
- the server cannot find it" looks like — common on the Web side):
245
- on "not recognized" / command not found, do NOT retry with a different spelling and do NOT invent
246
- repo or directory names. Tell the user the CLI was not found and point them to the "Remote
247
- repositories" page (one-click install / login), or restart the server so a freshly installed CLI
248
- becomes visible; if you only need to answer for this project, fall back to \`git remote -v\`
249
- - Cloning a repo or adding a remote: **prefer SSH** (the user's preference — keys are already set up,
250
- and https has been observed to pop a credential window that stalls the task):
251
- · GitHub → \`git@github.com:owner/repo.git\`; Gitee → \`git@gitee.com:owner/repo.git\`
252
- · If the user hands you an \`https://...\` URL (or \`gh repo clone owner/repo\`), convert it to the SSH
253
- form first — https pops up Git Credential Manager asking for a username/password and blocks the task
254
- · Switching an existing repo: \`git remote set-url origin <ssh url>\` (check \`git remote -v\` first)
255
- · Fall back to https only when SSH genuinely fails (\`Permission denied (publickey)\` /
256
- \`Host key verification failed\`), and say in one line that this one used https and can go back to SSH
257
- once the key is set up; if both fail, stop and ask the user — do not keep retrying
258
-
259
- # Scheduled tasks (run on a schedule)
260
- - When the user wants something done "every day / weekly / at a set time", register it with the
261
- schedule_task tool. You CANNOT schedule anything yourself — nothing runs unless it is registered
262
- as a task; never promise "I'll do it on schedule" without creating a task
263
- - The executor is the g ui server's scheduler: while g ui is not running, tasks do not fire; after a
264
- restart they catch up once or skip, according to the task's on_missed policy — state this
265
- boundary plainly in your reply
266
- - Each run lands in the task's dedicated session (title = task name) for the user to review later;
267
- once created, reply with the task name, its cron plan, and the next run time
268
- - Write the prompt as a self-contained request: the future run sees none of today's context
269
- - Use action: list first to get a task id before view / remove / enable / disable
270
-
271
- # Permissions (explicitly granted by the user)
272
- - Inside the working directory: read/write files and run commands directly
273
- - Other directories: may also be read and modified
274
- - Single red line: never destroy the system. A safety guard blocks such commands.
275
-
276
- # How you work
277
- - Act first, ask later: use tools to investigate before asking the user
278
- - 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.
279
- - After modifying code, verify: run tests, build, or at least syntax check
280
- - Prefer edit_file for precise replacements; read_file first to confirm original text
281
- - Use offset/limit for large files
282
- - git operations via run_command
283
- - run_command defaults to the working directory; default timeout 120s, max 600s
284
- - Images reach you two ways:
285
- · The user attached one to a message → it arrives as an image_url part in the user message; you can see it directly
286
- · The user only gave you a **path** (or you run into an image while exploring) → read it with the read_image tool, not read_file
287
- (read_file decodes UTF-8 and returns garbage for images), and never guess an image's content from its filename
288
- If the current model rejects images (no vision support), tell the user to switch to a vision-capable model
289
-
290
- # Task plan (required for multi-step work)
291
- - When a task takes several steps (code changes, debugging, research, multi-file edits), call update_plan
292
- **before touching anything**: break it into 3-8 verifiable steps so the user knows the scope and the
293
- acceptance criteria before you modify a single file
294
- - Do not plan single trivial actions (read one file, answer one question) — that is noise
295
- - Always send the **complete** current plan (not a delta), in execution order; at most one in_progress
296
- - Update the status right after finishing a step instead of batching everything at the end; when the
297
- plan no longer matches reality, rewrite the plan instead of pushing ahead
298
- - Send an empty steps list once everything is done
299
- - update_plan is a progress board for humans: never edit files or run commands just to "update the plan"
300
-
301
- # Output
302
- - Your text output is displayed in the user's Web UI
303
- - The UI renders Markdown: headings / lists / tables / quotes / code blocks are shown formatted — do not fake tables with ASCII art
304
- - To **show** the user an image (screenshot / chart / diagram), embed it in your reply as a Markdown
305
- image \`![caption](path)\` — a local absolute path, or a path relative to the working directory,
306
- both work and the UI loads and renders it; pasting a bare path, or wrapping it in a code block,
307
- only shows the user a line of text
308
- - For **flows / structure / sequences / state machines**, prefer a \`\`\`mermaid block
309
- (flowchart / sequenceDiagram / stateDiagram-v2 / erDiagram …): the UI renders it as a real diagram.
310
- Never hand-draw one out of plain-text arrows, indentation or box characters — on screen that is just monospaced text
311
- - After completing a task, briefly summarize the result`;
312
- }
313
-
314
- // ── LLM 流式调用 ─────────────────────────────────────────
315
- // 统一实现在 src/cli/ai/transport.js 的 streamChatOnce —— CLI 的 `g ai` 与这条
316
- // Web 链路共用同一份。比这里原先那份多出来的能力:请求 usage(带 stream_options
317
- // 降级重试)、校验 tool call index 边界、吃 evt.error,以及最要紧的一条 ——
318
- // 流被截断但 tool_calls 已经部分到达时抛"中断"、拒绝执行半截的工具调用。
319
- // 这里曾经是第二份实现,弱就弱在最后那条:半截参数有可能被拿去执行。
320
-
321
- // ── 消息准备(消毒 / 历史有界化 / 旧图片降级) ──────────────────
322
- // 统一实现在 src/cli/ai/context.js 的 prepareRequestMessages —— CLI 的 `g ai`
323
- // 与这条 Web 链路共用同一份,两侧不再各写一遍。
324
- //
325
- // 这里曾经是第二份实现:历史只按条数硬切(MAX_HISTORY_MESSAGES=40)+ 纯 splice 丢弃,
326
- // 而 CLI 侧早已改成「条数/字符双预算 + 丢弃项摘录成一条梗概」,两侧行为因此分叉 ——
327
- // Web 面板聊久了模型会直接失忆,CLI 还记得要点。现在收敛到一处,口径见 context.js。
328
-
329
- // ── 核心入口:运行一轮 agent 对话 ────────────────────────
330
- //
331
- // 参数:
332
- // { session, model, userMessage, cwd, locale, signal, send, onChild, askUser, listProjects }
333
- // - session: 从 agentSessionStore 读取的会话对象
334
- // - model: { baseURL, model, apiKey }
335
- // - userMessage: 用户输入文本
336
- // - images: base64 dataURL 数组(可选,多模态图片,随最新一条 user 消息发给模型)
337
- // - openFilePath: 文件空间里当前打开的文档(可选,注入请求副本,不落库)
338
- // - dirStatusBlock: 「切换工作目录」弹窗里那批目录的 Git 状态(可选,由 agentRoutes
339
- // 读配置 + 白名单过滤后用 buildDirStatusBlock 拼好传进来,同样只进请求副本)
340
- // - attachments: 非图片附件(可选) = [{ name, path }],path 是**服务端落盘后的绝对路径**,
341
- // 只把路径写进请求副本的 system 提示,内容由模型自己用工具读(见 utils/agentAttachments.js)
342
- // - cwd: 工作目录
343
- // - locale: 'zh' | 'en'
344
- // - signal: AbortSignal (客户端断开时触发)
345
- // - send: (obj) => void SSE 发送函数
346
- // - onChild: (child) => void 子进程回调(用于取消)
347
- // - askUser: (args, meta) => Promise<string> 等待用户回答
348
- // - listProjects: (args) => Promise<string> list_projects 工具的数据源
349
- // (由 agentRoutes 注入:最近目录/tasks.json/看板统计只有 GUI 侧拿得到)
350
- // - registerProject: (dir) => Promise<void> register_project 工具的实现,以及
351
- // run_command 里"建了仓库就顺手登记进常用目录"那一步的落点(同一个注入、同一个
352
- // 效果)。不注入 = 没有登记能力(CLI 与测试环境),对应工具回一句可照做的说明。
353
- // - dispatchTask: (payload) => Promise<string> dispatch_task 工具的实现 —— 派发一条
354
- // 工作台任务。同样由 agentRoutes 注入,但**只在主 Agent 控制台发起的对话里**注入
355
- // (别的入口不注入 = 那个入口没有派发能力,工具会回一句可照做的 unavailable)
356
- // - getContextBlock: ({locale}) => Promise<string> 工作区状态快照的摘要块
357
- // (由 agentRoutes 注入,实现在 routes/aiContext/:七个板块的摘要 + 落盘文件路径)
358
- // - requestBudget: { maxChars, maxMessages, maxUserChars } 每轮请求的上下文预算
359
- // (解析自全局配置 aiMaxRequestChars,见 cli/ai/context.js 的 resolveRequestBudget)。
360
- // 不传时 prepareRequestMessages 走默认值,行为与改造前一致。
361
- // - promptSection: 用户固定提示词(g ai 默认提示词 + 派发提示词,见 chatPrompts.js)。
362
- // **不传(undefined)= 自己现读**:这样定时任务那条链路(scheduler.js 直接调
363
- // 本函数)不必重复接一次线。显式传 '' = 本轮确定不带(调用方已经解析过)。
364
- // 只进请求副本,不落 session.messages —— 改设置立刻生效,历史会话不被腌进旧规则。
365
- // - resume: true 时**不再压入 user 消息**,直接拿着会话现有的尾巴继续跑。
366
- // 用于"重试上一轮":失败那轮留下的 user + 工具结果都还在,再发一遍 user
367
- // 会让模型看到两条同样的请求 + 已经跑完的工具结果。
368
- // - streamFn: LLM 传输函数,默认就是共享的 streamChatOnce。**只给测试留的接缝**
369
- // (单测要整体关掉自动重试,真等退避会让一个用例跑成分钟级),
370
- // 生产调用点一律不传 —— 传了就等于换了一份传输实现。
371
- //
372
- // 扩展层(Skill + MCP)不在参数里:按 cwd 从 agentExtensions.js 的进程内缓存取,
373
- // 整轮一次。三条路径都与 CLI turn.js 对齐 —— 工具表叠 extraTools、调用按 owns()
374
- // 分流、摘要注进请求副本(agentParity.test.js 有守卫)。
375
- //
376
- // 返回: { aborted: boolean }
377
- export async function runAgentTurn({ session, model, userMessage, images = [], cwd, locale, openFilePath, attachments = [], dirStatusBlock = '', signal, send, onChild, askUser, listProjects, registerProject, dispatchTask, getContextBlock, requestBudget = null, resume = false, streamFn = streamChatOnce, promptSection }) {
378
- const ctx = { cwd, locale, onChild, askUser, listProjects, registerProject, dispatchTask };
379
-
380
- // 确保 session.messages 存在
381
- if (!Array.isArray(session.messages)) session.messages = [];
382
-
383
- // 首轮:注入 system prompt
384
- if (session.messages.length === 0) {
385
- session.messages.push({
386
- role: 'system',
387
- content: buildWebSystemPrompt({ cwd, locale })
388
- });
389
- }
390
-
391
- // 追加 user 消息:有图片时组装 OpenAI 多模态 content 数组(与 CLI agent.js 一致),否则保持纯字符串。
392
- // resume 是"接着跑上一轮",会话尾巴上那条 user(或那批工具结果)就是要发的东西,不能重复压。
393
- if (resume) {
394
- // 尾巴上是一条**没有工具调用的 assistant**,说明上一轮其实跑完了(用户点的是操作栏那颗
395
- // 「重新生成」,不是错误气泡的「重试」)。留着它的话,模型会顺着自己刚写完的答案往下说,
396
- // 而且会话里会出现两条连着的 assistant —— 重开这条会话能看见两份答案。
397
- // 摘掉它,语义就干净了:拿同一个问题再问一次。
398
- const tail = session.messages[session.messages.length - 1];
399
- if (tail?.role === 'assistant' && !(tail.tool_calls?.length > 0)) session.messages.pop();
400
- } else {
401
- const imageParts = (Array.isArray(images) ? images : [])
402
- .filter(u => typeof u === 'string' && u.startsWith('data:image/'))
403
- .map(u => ({ type: 'image_url', image_url: { url: u } }));
404
- session.messages.push({
405
- role: 'user',
406
- content: imageParts.length > 0
407
- ? [{ type: 'text', text: userMessage || ' ' }, ...imageParts]
408
- : userMessage
409
- });
410
- }
411
-
412
- // 工作区状态快照:整轮只取一次(TTL 在生成器内部,见 aiContext/index.js),
413
- // 不放进循环 —— 否则每执行一次工具调用都要重拼一遍,而这轮对话里它不会变。
414
- // 取不到就空着跳过:快照是**锦上添花**,不能因为它挂了就让用户这条消息发不出去。
415
- let workspaceBlock = '';
416
- if (typeof getContextBlock === 'function') {
417
- try {
418
- workspaceBlock = (await getContextBlock({ locale })) || '';
419
- } catch (err) {
420
- logger.warn(`[agentChat] 取工作区状态快照失败,本轮跳过注入: ${err?.message || err}`);
421
- workspaceBlock = '';
422
- }
423
- }
424
-
425
- const maxIterations = await resolveMaxToolIterations();
426
-
427
- // 用户固定提示词(g ai 默认提示词 + 派发提示词):整轮只解析一次 —— 和快照同理,
428
- // 它在这一轮里不会变,而工具循环每轮都要重拼一次请求。
429
- // 调用方已经解析过(agentRoutes 为了外部引擎那条链路先解了一次)时不重复读文件;
430
- // 显式传 '' 同样尊重 —— 那是"确定没有",不是"没传"。
431
- let userPromptSection = promptSection;
432
- if (userPromptSection === undefined) {
433
- try {
434
- userPromptSection = await resolveChatPromptSection({ cwd, locale });
435
- } catch (err) {
436
- // 取不到就当没配:这是锦上添花,不能因为它挂掉就让用户这条消息发不出去
437
- logger.warn(`[agentChat] 取用户固定提示词失败,本轮跳过注入: ${err?.message || err}`);
438
- userPromptSection = '';
439
- }
440
- }
441
-
442
- // 预算缺省时在**这里**解析一次,与 prepareRequestMessages 的派生口径一致
443
- // (不能让它自己兜默认:那样 measureContextUsage 拿到的分母会和实际裁剪用的分母
444
- // 可能不同,UI 上的进度条就会说谎)。
445
- const budget = requestBudget || resolveRequestBudget();
446
-
447
- // 扩展层(Skill 清单 + MCP 工具):按 cwd 进程内缓存,整轮只取一次。
448
- // CLI 把 promptSuffix bake 进首条 system 消息(进程启动时);Web 会话是长命的
449
- // (可以活好几天,期间用户随时可能在广场装新的),所以这里每轮从缓存取、
450
- // 摘要注进**请求副本** —— 装完立刻生效、老会话也吃得到,也不把当时的清单腌进
451
- // 磁盘历史(口径同工作区快照:只进副本,不落 session.messages)。
452
- // 加载失败已在 agentExtensions.js 里降级成空扩展,这里不会抛。
453
- const extensions = await getWebExtensions({ cwd, locale });
454
-
455
- // MCP 工具按名字分流,口径与 CLI turn.js 逐字对齐:名字属于扩展就走扩展
456
- // (McpManager.call 内部已把失败翻成错误字符串,不会抛进循环),扩展返回
457
- // null/undefined(加载后被换掉等边角)时回落内置工具。没装扩展时 owns() 恒为
458
- // false,等价于直接调内置工具,零开销。
459
- const executeOne = extensions
460
- ? async (name, args, toolCtx) => {
461
- if (extensions.owns?.(name)) {
462
- const output = await extensions.execute(name, args, toolCtx);
463
- if (output !== null && output !== undefined) return output;
464
- }
465
- return executeTool(name, args, toolCtx);
466
- }
467
- : executeTool;
468
-
469
- for (let iter = 0; iter < maxIterations; iter++) {
470
- // 每轮都从完整会话记录重新构建一次请求副本:条数/token 双预算 → 被丢掉的旧消息
471
- // 摘录成一条梗概 → 旧图片降级 → provider 兼容消毒。
472
- // 只作用于副本,session.messages 保持完整(与 CLI 的磁盘口径一致)。
473
- // 预算来自 requestBudget(全局配置 aiMaxRequestTokens 的解析结果)。
474
- const messages = prepareRequestMessages(session.messages, { locale, ...budget });
475
- // 请求级上下文:用户固定提示词 + 工作区状态快照 + 常用目录状态 + 当前打开的文档
476
- // + 本轮附件路径 + 扩展层摘要(只改副本,不落 session.messages,下一轮不重复累积)
477
- injectRequestContext(messages, { cwd, openFilePath, attachments, locale, workspaceBlock, dirStatusBlock, promptSection: userPromptSection, extensionSection: extensionPromptSuffix(extensions, locale) });
478
-
479
- // 上下文占用:在**请求发出去之前**量一次,发给 UI 画圆环。
480
- // 为什么必须在发之前:provider 的真实 usage(input_tokens)要等响应回来才有,
481
- // 而用户想知道的是"这次会带多少过去"—— 那只能在发之前量。
482
- // 为什么每轮都发:工具循环里上下文是**持续增长**的,只发一次的话
483
- // 用户看到的永远是第一轮那个数字,直到对话结束才发现早就满了。
484
- const usage = measureContextUsage(messages, { ...budget, transcript: session.messages });
485
- send({ type: 'context', usage });
486
-
487
- let result;
488
- // 断点事件:告诉前端"从这里开始是这一次请求要写的内容"。
489
- // 前端收到自动重试时按它回退 —— 只丢失败那次吐的字,本轮前面几轮说过的话留着。
490
- send({ type: 'attempt', attempt: iter + 1 });
491
- try {
492
- result = await streamFn({
493
- model,
494
- messages,
495
- signal,
496
- // 内置工具是底座;extraTools 是本轮 cwd 的 MCP 工具叠加(transport 里合并,
497
- // 为空时回落纯内置表)。CLI turn.js 同一个参数,两边口径一致。
498
- extraTools: extensions?.tools,
499
- // 整轮对话(含后续工具调用产生的每一轮请求)复用同一个会话 ID
500
- sessionId: session.sessionId,
501
- onToken: ({ thinking, content }) => {
502
- if (thinking) send({ type: 'thinking', delta: thinking });
503
- if (content) send({ type: 'content', delta: content });
504
- },
505
- // 传输层自动重试(长静默 / 网关 5xx / 流中断,见 cli/ai/transport.js):
506
- // 先告诉前端回退到断点,下一次尝试的增量才不会和上一截拼在一起。
507
- onRetry: ({ attempt, maxRetries, delayMs, error }) => {
508
- send({ type: 'retry', attempt, maxRetries, delayMs, reason: error?.message || '' });
509
- },
510
- });
511
- } catch (err) {
512
- // 请求失败**不回滚**刚压入的 user 消息(2026-10-09 改)。
513
- // 原先是"末尾仍是 user 就弹掉",为的是让用户重发同一条时历史里不留两份。
514
- // 现在有正经的「重试」了(前端那颗按钮 → resume 续跑本轮),而续跑的全部意义
515
- // 就是"接着会话现有的尾巴跑":尾巴被弹掉的话,续跑会退化成**重跑上一轮**。
516
- // 留着它还有个更直接的好处:用户发出去的问题不再凭空消失(界面上一直看得见),
517
- // 带图的那些附件也还在请求副本里,重试不用靠前端重建。
518
- send({ type: 'error', error: `LLM 请求失败: ${err.message}` });
519
- return { aborted: false };
520
- }
521
-
522
- const { content, toolCalls } = result;
523
-
524
- // provider 报回来的**真实** token 用量,比 measureContextUsage 的估算准。
525
- // 回填进同一条 context 事件的两个字段(estimated / actual),UI 优先显示真实值 ——
526
- // 估算只用于"请求发出去之前"那段时间(以及 provider 不返回 usage 的场合)。
527
- //
528
- // 为什么以前没记:CLI 侧有 /stats(src/cli/ai/telemetry.js),Web 侧漏了,
529
- // 于是 28 个会话文件里 lastTurnStats / sessionStats 全是 null。
530
- // 这里补上的是**本轮的**真实输入 token;会话累计要等落盘,由路由层负责。
531
- if (result.usage) {
532
- send({ type: 'context', usage: { ...usage, actualInputTokens: result.usage.inputTokens ?? null } });
533
- }
534
-
535
- // 推理内容必须原样带回历史。DeepSeek 系 thinking 模式下带 tool_calls 的 assistant
536
- // 消息一旦缺 reasoning_content,下一轮回传就被上游 400 拒掉:
537
- // "The `reasoning_content` in the thinking mode must be passed back to the API"。
538
- // 口径与 CLI 侧 src/cli/ai/turn.js 的 assistant.reasoning_content 保持一致。
539
- const withReasoning = msg => (result.reasoning ? { ...msg, reasoning_content: result.reasoning } : msg);
540
-
541
- if (result.aborted) {
542
- // 用户点了"停止":把已经流出来的部分正文补进历史,否则磁盘上这一轮只剩一条
543
- // user 消息,重新打开会话时刚才生成的内容会整段丢失。
544
- // 半截的 tool_calls 已被 transport 在中止时丢弃,这里只补正文,不会有悬空引用。
545
- // 落盘由路由层统一负责(中止的轮次同样要写,见 agentRoutes.js)。
546
- if (content) {
547
- session.messages.push(withReasoning({ role: 'assistant', content }));
548
- }
549
- send({ type: 'error', error: '已取消' });
550
- return { aborted: true };
551
- }
552
-
553
- // 无工具调用:本轮结束
554
- if (toolCalls.length === 0) {
555
- session.messages.push(withReasoning({ role: 'assistant', content: content || null }));
556
- send({ type: 'done', content: content || '' });
557
- return { aborted: false };
558
- }
559
-
560
- // 有工具调用:assistant(带 tool_calls)入历史
561
- session.messages.push(withReasoning({
562
- role: 'assistant',
563
- content: content || null,
564
- tool_calls: toolCalls
565
- }));
566
-
567
- // 逐个执行工具
568
- for (const tc of toolCalls) {
569
- const name = tc.function?.name || '';
570
- const rawArgs = tc.function?.arguments || '';
571
- const toolCallId = tc.id || name;
572
-
573
- let args;
574
- try {
575
- args = rawArgs ? JSON.parse(rawArgs) : {};
576
- } catch {
577
- const errResult = `错误: 工具参数不是合法 JSON: ${rawArgs.slice(0, 200)}`;
578
- send({ type: 'tool_call_start', toolCallId, name, argsPreview: rawArgs.slice(0, 200), arguments: rawArgs });
579
- send({ type: 'tool_result', toolCallId, name, result: errResult });
580
- session.messages.push({ role: 'tool', tool_call_id: toolCallId, name, content: errResult });
581
- continue;
582
- }
583
-
584
- // 工具参数预览(前端收起态那一行副标题)。这里截断是**故意的** ——
585
- // 摘要只负责"一眼看出它在干嘛"。
586
- const argsPreview = summarizeArgs(name, args);
587
- send({ type: 'tool_call_start', toolCallId, name, argsPreview, arguments: rawArgs });
588
-
589
- const toolCtx = {
590
- ...ctx,
591
- signal,
592
- // run_command 执行期间的增量输出 → 前端实时显示。
593
- // 只用于展示,不进会话历史 —— 历史里存的仍是带 exit code 的最终结果。
594
- onOutput: (chunk) => {
595
- const text = String(chunk || '');
596
- if (text) send({ type: 'tool_output', toolCallId, name, chunk: text });
597
- },
598
- };
599
- if (name === 'ask_user' && typeof ctx.askUser === 'function') {
600
- toolCtx.askUser = askArgs => ctx.askUser(askArgs, {
601
- sessionId: session.sessionId,
602
- interactionId: toolCallId,
603
- send,
604
- signal,
605
- });
606
- }
607
- const output = await executeOne(name, args, toolCtx);
608
- // read_image 会返回 { text, images }。前端只吃文本(result 是字符串,
609
- // 直接把对象丢过去会渲染成 "[object Object]"),图片进会话历史里的
610
- // 多模态 tool 消息 —— 模型看得见图,用户看到的是"已读取图片 xxx.png"。
611
- const { text: outText, images: outImages } = splitToolOutput(output);
612
- send({ type: 'tool_result', toolCallId, name, result: outText });
613
- session.messages.push({ role: 'tool', tool_call_id: toolCallId, name, content: toolMessageContent(outText, outImages) });
614
- }
615
- // 工具结果全部入历史后继续循环,让模型基于结果决定下一步
616
- }
617
-
618
- // 达到最大迭代次数
619
- send({ type: 'done', content: `已达单轮最大工具调用次数(${maxIterations}),本轮结束。如需继续请再发一条消息。` });
620
- return { aborted: false };
621
- }
622
-
623
- // 扩展层摘要(Skill 清单 + MCP 连接状态)。取不到就当没有 —— 它是增量能力,
624
- // 不该因为摘要拼不出来影响整轮对话。口径与 CLI 的 extensionSuffix 同一份文本。
625
- function extensionPromptSuffix(extensions, locale) {
626
- try {
627
- return extensions?.promptSuffix?.(locale) || '';
628
- } catch {
629
- return '';
630
- }
631
- }
632
-
633
- // 工具参数简短摘要(给前端展示)
634
- function summarizeArgs(name, args) {
635
- try {
636
- switch (name) {
637
- case 'run_command':
638
- return String(args.command || '').slice(0, 200);
639
- case 'read_file':
640
- case 'read_image':
641
- case 'write_file':
642
- case 'edit_file':
643
- return String(args.path || '');
644
- case 'list_files':
645
- return String(args.path || '.');
646
- case 'search_text':
647
- return String(args.pattern || '');
648
- case 'update_plan': {
649
- const steps = normalizePlanSteps(args.steps ?? args.todos ?? args.plan);
650
- const why = String(args.explanation || '').replace(/\s+/g, ' ').trim();
651
- return [summarizePlan(steps), why].filter(Boolean).join(' — ').slice(0, 200);
652
- }
653
- default:
654
- return JSON.stringify(args).slice(0, 200);
655
- }
656
- } catch {
657
- return '';
658
- }
659
- }
660
-
661
- // ── 请求级上下文注入(用户固定提示词 / 工作区状态 / 常用目录状态 / 文件空间对话 / 本轮附件 / 扩展层摘要) ──
662
- // 把"用户配的两块固定提示词""工作区各板块的状态摘要""常用目录那批目录的 Git 状态"
663
- // "用户当前打开的文件""本轮附件的落盘路径"与"扩展层摘要(Skill + MCP)"追加到
664
- // **请求副本**的 system 消息末尾:只影响这一次请求,session.messages 与磁盘历史保持
665
- // 原样,下一轮也不会重复累积。
666
- //
667
- // ⚠️ 工作区快照**必须走这条副本路径,不能塞进 session.messages 里那条 system 消息**。
668
- // 那条只在首轮 push 一次(见上面 `session.messages.length === 0` 的判断)并会落盘,
669
- // 快照进去就等于永久停在"会话创建那天"——git 分支、任务进度全会是过期的,
670
- // 而且这种错不会报错、只会让模型理直气壮地给出错答案。有单测钉住这一点。
671
- // 常用目录状态同理,而且更严重:它说的是"这一刻"的领先/落后,几小时后必然不同。
672
- // 用户固定提示词同理,但理由换一条:**改设置要立刻生效**,而且它属于"每轮都在的规则",
673
- // 腌进历史会话等于让用户回看时看到一份过期规则(详见 chatPrompts.js 文件头)。
674
- // 扩展层摘要同理,而且它还多一条:Web 会话长命,bake 进去等于"这个会话再也吃不到
675
- // 新装的 MCP/Skill"——CLI 每启动一个进程重算一次所以没这个问题,Web 必须每轮注副本。
676
- //
677
- // 附件为什么只给路径、不给内容:见 utils/agentAttachments.js 的头注释 —— 非图片附件
678
- // 由服务端落盘,模型自己用 read / grep 按需取,比把几百 KB 文本内联进消息省得多。
679
- // 快照块同理,只给摘要与目录路径,板块正文由模型按需读。
680
- export function injectRequestContext(messages, { cwd, openFilePath, attachments = [], locale, workspaceBlock = '', dirStatusBlock = '', promptSection = '', extensionSection = '' }) {
681
- if (!Array.isArray(messages)) return;
682
- const en = String(locale || '').startsWith('en');
683
-
684
- const parts = [];
685
-
686
- // ⓪ 工作区状态快照(七个板块的摘要 + 落盘目录)。由 aiContext 生成,只在这个副本里。
687
- if (typeof workspaceBlock === 'string' && workspaceBlock.trim()) {
688
- parts.push(workspaceBlock.trim());
689
- }
690
-
691
- // ① 常用目录那批目录的 Git 状态(切换工作目录弹窗里的追问才有)。
692
- // 整块(含"屏幕上那段自动解读")由 agentRoutes 调 buildDirStatusBlock 拼好传进来 ——
693
- // 两条链路(内置引擎的副本 / 外部引擎的前缀)用的是同一个字符串,不在两处各拼一遍。
694
- if (typeof dirStatusBlock === 'string' && dirStatusBlock.trim()) {
695
- parts.push(dirStatusBlock.trim());
696
- }
697
-
698
- // ② 当前打开的文档(文件空间对话才有;项目外或等于根目录直接忽略)
699
- if (openFilePath) {
700
- const root = cwd || process.cwd();
701
- let rel = '';
702
- try {
703
- rel = path.relative(root, path.resolve(root, openFilePath));
704
- } catch {
705
- rel = '';
706
- }
707
- if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) {
708
- const file = rel.split(path.sep).join('/');
709
- parts.push(en
710
- ? `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.`
711
- : `用户此刻在文件空间打开的文件是 \`${file}\`(相对项目根目录)。用户说"这个文件/当前文件"时默认指它;请先用工具读取内容,不要臆测。`);
712
- }
713
- }
714
-
715
- // ③ 本轮附件:只给绝对路径,内容让模型自己去读
716
- const files = (Array.isArray(attachments) ? attachments : [])
717
- .filter(a => a && typeof a.path === 'string' && a.path)
718
- .slice(0, 20);
719
- if (files.length > 0) {
720
- const lines = files.map(a => `- \`${a.path}\`${a.name && a.name !== path.basename(a.path) ? ` (${a.name})` : ''}`);
721
- parts.push(en
722
- ? `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.`
723
- : `用户本轮附带了 ${files.length} 个文件,已保存到以下绝对路径:\n${lines.join('\n')}\n需要时用工具读取(内容没有内联在这里),不要臆测。`);
724
- }
725
-
726
- // ④ 扩展层摘要(Skill 清单 + MCP 连接状态)排在最后:与 CLI 一致 ——
727
- // 扩展是"增量能力",不该插在权限/环境/规则中间打断阅读。
728
- // promptSuffix 自带 `# 已安装的 Skill` / `# 已接入的 MCP 服务` 标题,
729
- // 且明说"这些外部工具已挂到你的工具列表里",正好接住基础提示词里那句
730
- // "可用工具只有上面列出的 N 个"(N=内置数)—— 两边不打架。
731
- const hasExtensionSection = typeof extensionSection === 'string' && extensionSection.trim();
732
-
733
- if (parts.length === 0 && !(typeof promptSection === 'string' && promptSection.trim()) && !hasExtensionSection) return;
734
-
735
- // 用户固定提示词单独一节,且排在"当前上下文"**前面**:它是每轮都成立的规则,
736
- // 而当前上下文是这一刻的快照 —— 规则在前、现场在后,读起来才是"先约束后事实"。
737
- let note = '';
738
- if (typeof promptSection === 'string' && promptSection.trim()) {
739
- note += `\n\n# ${en ? 'Standing instructions' : '用户固定提示词'}\n${promptSection.trim()}`;
740
- }
741
- if (parts.length > 0) {
742
- note += `\n\n# ${en ? 'Current context' : '当前上下文'}\n${parts.join('\n\n')}`;
743
- }
744
- if (hasExtensionSection) {
745
- note += `\n\n${extensionSection.trim()}`;
746
- }
747
- const sys = messages.find(m => m && m.role === 'system' && typeof m.content === 'string');
748
- if (sys) sys.content += note;
749
- else messages.unshift({ role: 'system', content: note.trim() });
750
- }
751
-
752
- export { buildWebSystemPrompt };
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
+ // Web 端智能体聊天引擎。
16
+ //
17
+ // 复用 CLI 侧 src/cli/ai/tools.js 的工具定义与执行器,
18
+ // 但 LLM 流式 + 工具调用循环通过 SSE 事件推给前端,而非终端打印。
19
+ //
20
+ // SSE 事件类型:
21
+ // - { type: 'meta', sessionId, isNew, title }
22
+ // - { type: 'thinking', delta } — 推理过程增量
23
+ // - { type: 'content', delta } — 正文增量
24
+ // - { type: 'tool_call_start', toolCallId, name, argsPreview, arguments }
25
+ // 两个字段服务的是工具块的两副面孔,别混用:
26
+ // · argsPreview —— 收起态那一行摘要,故意截断(200 字),前端拿它当副标题;
27
+ // · arguments —— 展开后「参数」框里的原文,**一律发全文**。
28
+ // 全文要全给:展开态本来就是"我要看它到底传了什么",而会话历史里存的
29
+ // (session.messages 的 tool_calls.function.arguments)本来就是全文 ——
30
+ // 只发摘要会让「正在跑」和「刷新后重放」看到两份不同的参数
31
+ // (useAgentChat 的历史回放分支直接读 arguments)。SSE 走本地回环,
32
+ // 代价只是把同一份字符串多发一遍。
33
+ // - { type: 'tool_output', toolCallId, chunk } — 命令执行中的增量输出(仅展示)
34
+ // - { type: 'tool_result', toolCallId, name, result }
35
+ // - { type: 'ask_user', interactionId, question, options, allowFreeText, multiple }
36
+ // - { type: 'attempt', attempt } — 一次 LLM 请求**开始**(工具循环里每轮一条)。
37
+ // 发它是为了让前端记一个"断点":这个位置上正文/思考已经有多长。
38
+ // 自动重试时前端按这个断点回退,只丢掉失败那次尝试吐的字,
39
+ // 本轮前面几轮(工具循环)说过的话留着。
40
+ // **重试不重发它**:重试要丢的正是这一轮请求吐的全部内容,断点还是同一个。
41
+ // - { type: 'retry', attempt, maxRetries, delayMs, reason }
42
+ // 传输层正在自动重试(最多 10 次,见 cli/ai/transport.js)。前端收到就回退到断点、
43
+ // 清掉错误态;不处理的话界面上会出现"半截答案 + 重来一遍"拼在一起的两段话。
44
+ // - { type: 'done', content } — 本轮最终完成
45
+ // - { type: 'error', error }
46
+ //
47
+ // 另外,轮次**跑完之后**路由层还会补发一条 { type: 'turn_done', turnIndex, durationMs }
48
+ // (见 agentRoutes.js)—— 本轮用时的唯一权威值,前端拿它给回答气泡定格计时。
49
+ // 不在这里发是因为它要覆盖中断/报错的轮次,得由真正知道本轮何时收尾的那一层给。
50
+
51
+ import path from 'path';
52
+ import os from 'os';
53
+ import { logger } from './shared.js';
54
+
55
+ // 从 CLI 侧导入工具定义、执行器与 LLM 传输层(同一 monorepo,路径可达)
56
+ import { TOOL_DEFINITIONS, executeTool, normalizePlanSteps, summarizePlan, splitToolOutput, toolMessageContent } from '../../../../cli/ai/tools.js';
57
+ import { prepareRequestMessages, measureContextUsage, resolveRequestBudget } from '../../../../cli/ai/context.js';
58
+ import { streamChatOnce } from '../../../../cli/ai/transport.js';
59
+ import { checkDangerousCommand } from '../../../../cli/ai/safety.js';
60
+ import { guardCommand } from '../../../../cli/ai/platformGuard.js';
61
+ import configManager from '../../../../config.js';
62
+ // 用户固定提示词(g ai 默认提示词 + 派发提示词)的取数与拼装。与外部 CLI 引擎那条
63
+ // 链路共用同一份(见 runExternalTurn 的调用点),免得两块提示词在两处各拼一遍。
64
+ import { resolveChatPromptSection } from './chatPrompts.js';
65
+ // 扩展层(Skill 清单 + MCP 工具)按 cwd 的加载缓存。CLI 在 agent.js 启动时加载;
66
+ // Web 会话长命,没有"进程重启"这个自然边界,改成按 cwd 缓存 + 广场安装时失效。
67
+ import { getWebExtensions } from './agentExtensions.js';
68
+
69
+ // 单轮工具调用循环数的兜底值(防失控);实际值取全局配置 aiMaxToolIterations,
70
+ // 与 CLI 侧 src/cli/ai/agent.js 共用同一个配置项。
71
+ const DEFAULT_MAX_TOOL_ITERATIONS = 1000;
72
+
73
+ // 读取全局配置里的单轮工具调用上限。
74
+ // 读配置失败不该把整轮对话打挂 —— 退回默认值继续跑,比用户消息直接发不出去好。
75
+ async function resolveMaxToolIterations() {
76
+ try {
77
+ const cfg = await configManager.loadConfig();
78
+ const n = Number(cfg?.aiMaxToolIterations);
79
+ if (Number.isFinite(n) && n > 0) return Math.floor(n);
80
+ } catch (err) {
81
+ logger.warn(`[agentChat] 读取 aiMaxToolIterations 失败,回退默认值: ${err?.message || err}`);
82
+ }
83
+ return DEFAULT_MAX_TOOL_ITERATIONS;
84
+ }
85
+
86
+ // ── 系统提示词构建 ──────────────────────────────────────────
87
+ // 与 CLI agent.js 的 buildSystemPrompt 保持一致,但标注来源为 Web 端
88
+ function buildWebSystemPrompt({ cwd, locale }) {
89
+ const zh = !String(locale || '').startsWith('en');
90
+ const now = new Date().toLocaleString();
91
+ const isWin = process.platform === 'win32';
92
+ const builtinToolCount = TOOL_DEFINITIONS.length;
93
+ const shellDesc = isWin ? 'cmd.exe / PowerShell' : '/bin/sh';
94
+
95
+ if (zh) {
96
+ return `你是 "g ai" —— zen-gitsync 内置的编码智能体,通过工具在用户真实电脑上完成编码任务。当前用户通过 Web 界面与你对话。
97
+
98
+ # 运行环境
99
+ - 操作系统: ${process.platform}
100
+ - Shell: ${shellDesc}
101
+ - 当前工作目录: ${cwd}
102
+ - 当前时间: ${now}
103
+
104
+ # 平台兼容性(重要!)
105
+ - 必须使用与当前 Shell 兼容的命令,禁止盲套 Unix 写法
106
+ ${isWin ? `- 当前是 Windows,以下 Unix 命令**不存在**,用了必定报"不是内部或外部命令":
107
+ tail / head / cat / grep / ls / sed / awk / wc / cut / uniq / xargs / which / touch
108
+ 平台守卫会在执行前拦截这些命令,但请主动避免,不要浪费一轮调用
109
+ - 跨平台替代方案:
110
+ · 列目录 → list_files 工具 或 cmd 的 dir
111
+ · 搜内容 → search_text 工具 或 cmd 的 findstr
112
+ · 看文件 → read_file 工具 或 cmd 的 type
113
+ · 看图片 → read_image 工具(截图/设计稿/示意图)。read_file 读图片只会得到乱码,别浪费那一轮
114
+ · 看输出末尾 → PowerShell "命令 | Select-Object -Last N"
115
+ · 文本处理 → node -e "..." 或 PowerShell
116
+ · 查命令路径 → cmd 的 where(不是 which)
117
+ - 必须跑 shell 时优先跨平台写法(如 node -e "..."),别用 Unix 专属命令` : `- 当前是 POSIX 环境,Unix 命令可用`}
118
+
119
+ # 远程仓库(GitHub / Gitee)
120
+ - 问"我有哪些项目""哪些项目需要 pull / 推送" → 用 list_projects 工具。它返回的就是 g ui
121
+ 「最近项目」面板那份清单(最近目录 + 建过任务的目录,带分支/领先/落后/未提交数与任务进度),
122
+ 口径与界面完全一致。**不要**用 list_files 自己扫盘数仓库 —— 那会把 node_modules 里的嵌套
123
+ 仓库也算进来,数出来的个数跟界面对不上。领先/落后是本地快照,要真实值就带 refresh=true
124
+ 先联网 fetch 一轮,再回答"要不要 pull"
125
+ - 问"这个项目关联哪个远端" → run_command 跑 \`git remote -v\`:本地信息,不联网、不依赖任何 CLI,任何环境都能答
126
+ - 问"我账号下有哪些仓库"或某仓库的 PR / Issue → 用官方 CLI。凭据由 CLI 自己保管:
127
+ · GitHub → \`gh\`。列仓库 \`gh repo list --limit 50 --json name,visibility,updatedAt,primaryLanguage\`;
128
+ 看详情 \`gh repo view <owner/repo>\`;PR \`gh pr list\`;登录态 \`gh auth status\`
129
+ · Gitee → \`gitee\`。列仓库 \`gitee repo list\`;登录态 \`gitee auth status\`
130
+ (未登录时退出码**仍是 0**,必须看 \`--json\` 里的 status 字段,只看退出码会误判成已登录)
131
+ · 两者默认只覆盖**当前登录账号**;组织仓库、私有仓库可能需要更大的 token scope,拉不到就如实说明,
132
+ 不要用其他途径绕过
133
+ · 绝不向用户索要 token —— 也不要让用户把 token 粘进对话
134
+ - CLI 可能没装、或没在服务端进程的 PATH 里(装完没重启服务就是这个表现,Web 端尤其常见):
135
+ 报"不是内部或外部命令" / command not found 时,**不要换写法反复重试**,更不要凭印象编仓库名或目录名。
136
+ 直接告诉用户未检测到该 CLI,可在「远程仓库」页一键安装/登录,或需要时重启服务让新装的 CLI 生效;
137
+ 若只是想回答本项目的问题,退回 \`git remote -v\`
138
+ - 克隆仓库 / 添加远端时**优先用 SSH**(用户偏好,本机已配好密钥,实测走 https 会弹凭据窗口把任务打断):
139
+ · GitHub 用 \`git@github.com:owner/repo.git\`,Gitee 用 \`git@gitee.com:owner/repo.git\`
140
+ · 用户给的是 \`https://...\` 或 \`gh repo clone owner/repo\` 时,先换算成 SSH 地址再执行 ——
141
+ 走 https 会弹 Git Credential Manager 让用户输账号密码,任务就停在半路等输入
142
+ · 已有仓库换协议:\`git remote set-url origin <ssh 地址>\`(先 \`git remote -v\` 看当前是什么)
143
+ · 只有 SSH 真的不可用(报 \`Permission denied (publickey)\` / \`Host key verification failed\`)才退回 https,
144
+ 并用一句话说明这次走的是 https、配好密钥后可改回;两边都失败就停下来问用户,不要反复重试
145
+
146
+ # 定时任务(到点自动执行)
147
+ - 用户要"每天/每周/到点自动做某事" → 用 schedule_task 登记定时任务。你**不能**自己定时,
148
+ 只有登记成任务才会真的执行;不要口头承诺"我会定时做"
149
+ - 执行者是 g ui 服务端的调度器:g ui 没运行的时间段任务不会执行,重新运行后按任务的
150
+ on_missed 策略补跑一次或跳过 —— 回复用户时要如实说明这个边界
151
+ - 每轮执行落进任务的专属会话(标题=任务名),用户随时能回看;创建成功后把任务名、
152
+ 计划(cron)与下次执行时间回复给用户
153
+ - prompt 要写成**自足的一轮请求**:未来那一轮读不到现在的对话上下文
154
+ - 查看/删除/启停任务先用 action: list 拿任务 id
155
+
156
+ # 权限(用户已明确授权,无需反复征求同意)
157
+ - 工作目录内:读写文件、执行命令等所有操作直接执行
158
+ - 其他目录:同样可以读取和修改
159
+ - 唯一红线:不得破坏系统(格式化磁盘、删除根目录/系统目录、关机重启、写块设备等)。
160
+ 安全守卫会拦截这类命令;被拦截时换安全方案,或告知用户需要他手动执行。
161
+
162
+ # 工作方式
163
+ - 先动手、后提问:能用工具查清的不要问用户(list_files / read_file / search_text / run_command)
164
+ - 修改代码后主动验证:跑测试、构建或至少语法检查(用 run_command)
165
+ - 编辑文件优先 edit_file 精确替换;先 read_file 看原文,old_string 必须与文件内容完全一致(含缩进换行)
166
+ - 大文件用 offset/limit 分段读取,不要一次读爆上下文
167
+ - git 操作用 run_command 执行
168
+ - run_command 默认就在工作目录执行,不要再加 cd 前缀;默认超时 120 秒,长任务加大 timeout_seconds(最大 600)
169
+ - 命令在 ${shellDesc} 下执行,注意语法兼容
170
+
171
+ # 任务计划(多步任务必用)
172
+ - 任务需要多步时(改代码、排查、调研、多文件改动),**动手之前**先调一次 update_plan,把要做的事
173
+ 拆成 3-8 个可核对的步骤,让用户在你改任何东西之前就知道范围和验收标准
174
+ - 单步小事(读个文件、答一个问题)不要列计划,那是噪音
175
+ - 每次都传**完整**的当前计划(不是增量),顺序即执行顺序;同一时刻最多一个 in_progress
176
+ - 完成一步就更新一次状态,别攒到最后一次性刷;计划与实际不符时直接改计划,不要硬着头皮往下走
177
+ - 全部做完后把 steps 传空表示收尾
178
+ - update_plan 只是给人看的进度板,不要为了"更新计划"去改文件或跑命令
179
+
180
+ # 与用户交互
181
+ - 需要向用户确认、提问或汇报重要决策时,直接用普通文本输出
182
+ - 需要暂停当前任务并等待用户决定或补充信息时,调用 ask_user,不要猜测或只在普通文本里提问
183
+ - 一次要让用户从多个选项里选好几项时(如"要我改哪几个文件"),传 multiple: true,用户会勾选后统一提交
184
+ - 不要调用不存在的工具,可用工具只有上面列出的 ${builtinToolCount} 个
185
+ - 图片有两条来路:
186
+ · 用户随消息附带(附件框/粘贴) → 图片以 image_url 部件出现在 user 消息里,直接就能看到
187
+ · 用户只给了**路径**(或你自己在翻文件时遇到图)→ 用 read_image 工具读它,别用 read_file
188
+ (read_file 按 UTF-8 解码,图片只会是乱码),也别根据文件名猜内容
189
+ 如果当前模型不支持视觉(带图请求报错),提醒用户换用支持视觉的模型
190
+ - 发现高风险或状态不一致的情况时:先用文本说明发现和影响,停下来等用户指示,不要擅自继续破坏性操作
191
+
192
+ # 输出
193
+ - 你的文本输出直接显示在用户 Web 界面,用简体中文交流
194
+ - 界面按 Markdown 渲染:标题 / 列表 / 表格 / 引用 / 代码块都会排版后显示,别用 ASCII 画表格
195
+ - 想让用户**看到**图(截图/图表/示意图),就在回答正文里写 Markdown 图片 \`![说明](图片路径)\` ——
196
+ 本机绝对路径、或相对当前工作目录的路径都可以,界面会自动加载渲染;
197
+ 只贴一条路径、或把路径放进代码块,用户看到的只是一行字
198
+ - 讲**流程 / 结构 / 时序 / 状态**这类"说出来不如画出来"的东西时,写 \`\`\`mermaid 代码块
199
+ (flowchart / sequenceDiagram / stateDiagram-v2 / erDiagram …),界面会把它渲染成真正的图。
200
+ 不要用纯文本箭头、缩进或 ASCII 框线凑一张图 —— 那种"图"在界面上只是一段等宽文本
201
+ - 完成任务后用一两句话汇报结果,不要复述过程细节`;
202
+ }
203
+
204
+ return `You are "g ai" — the coding agent built into zen-gitsync. You use tools to perform coding tasks on the user's real machine. The user is interacting with you via a Web interface.
205
+
206
+ # Environment
207
+ - OS: ${process.platform}
208
+ - Shell: ${shellDesc}
209
+ - Working directory: ${cwd}
210
+ - Current time: ${now}
211
+
212
+ # Platform compatibility (important!)
213
+ - You MUST use commands compatible with the current shell.
214
+ ${isWin ? `- This is Windows. The following Unix commands do NOT exist here:
215
+ tail / head / cat / grep / ls / sed / awk / wc / cut / uniq / xargs / which / touch
216
+ A platform guard will block these before execution, but avoid them proactively.
217
+ - Cross-platform alternatives:
218
+ · List dirs → list_files tool, or cmd's dir
219
+ · Search content → search_text tool, or cmd's findstr
220
+ · Read files → read_file tool, or cmd's type
221
+ · Look at an image → read_image tool (screenshots, mockups, diagrams). read_file only decodes text and returns garbage for images
222
+ · Tail output → PowerShell "command | Select-Object -Last N"
223
+ · Text processing → node -e "..." or PowerShell
224
+ · Find executable → cmd's where (not which)` : `- POSIX environment: Unix commands are available`}
225
+
226
+ # Remote repositories (GitHub / Gitee)
227
+ - "Which projects do I have?" / "Which ones need a pull or push?" → use the list_projects tool.
228
+ It returns exactly the list behind the GUI's "Recent projects" panel (recent directories plus any
229
+ directory a task was created in, with branch / ahead / behind / uncommitted counts and task
230
+ progress) — the same numbers the UI shows. Do NOT scan the disk with list_files to count repos:
231
+ that also picks up nested repos inside node_modules and the totals will not match the UI.
232
+ Ahead/behind comes from local refs, so pass refresh=true for a real answer about pulling
233
+ - "Which remote does this project point at?" → run_command \`git remote -v\`: local info, no network, no CLI needed
234
+ - "Which repos do I have?" or a repo's PRs / issues → use the official CLI. The CLI owns the credentials:
235
+ · GitHub → \`gh\`. List repos \`gh repo list --limit 50 --json name,visibility,updatedAt,primaryLanguage\`;
236
+ details \`gh repo view <owner/repo>\`; PRs \`gh pr list\`; auth state \`gh auth status\`
237
+ · Gitee → \`gitee\`. List repos \`gitee repo list\`; auth state \`gitee auth status\`
238
+ (the exit code is still 0 when logged out — read the status field from \`--json\`, or you will
239
+ wrongly report "logged in")
240
+ · Both default to the **currently authenticated account only**; org and private repos may need
241
+ broader token scopes. If you cannot see them, say so plainly — do not route around it
242
+ · Never ask the user for a token, and never have them paste one into the conversation
243
+ - The CLI may not be installed, or missing from the server process's PATH (that is what "installed but
244
+ the server cannot find it" looks like — common on the Web side):
245
+ on "not recognized" / command not found, do NOT retry with a different spelling and do NOT invent
246
+ repo or directory names. Tell the user the CLI was not found and point them to the "Remote
247
+ repositories" page (one-click install / login), or restart the server so a freshly installed CLI
248
+ becomes visible; if you only need to answer for this project, fall back to \`git remote -v\`
249
+ - Cloning a repo or adding a remote: **prefer SSH** (the user's preference — keys are already set up,
250
+ and https has been observed to pop a credential window that stalls the task):
251
+ · GitHub → \`git@github.com:owner/repo.git\`; Gitee → \`git@gitee.com:owner/repo.git\`
252
+ · If the user hands you an \`https://...\` URL (or \`gh repo clone owner/repo\`), convert it to the SSH
253
+ form first — https pops up Git Credential Manager asking for a username/password and blocks the task
254
+ · Switching an existing repo: \`git remote set-url origin <ssh url>\` (check \`git remote -v\` first)
255
+ · Fall back to https only when SSH genuinely fails (\`Permission denied (publickey)\` /
256
+ \`Host key verification failed\`), and say in one line that this one used https and can go back to SSH
257
+ once the key is set up; if both fail, stop and ask the user — do not keep retrying
258
+
259
+ # Scheduled tasks (run on a schedule)
260
+ - When the user wants something done "every day / weekly / at a set time", register it with the
261
+ schedule_task tool. You CANNOT schedule anything yourself — nothing runs unless it is registered
262
+ as a task; never promise "I'll do it on schedule" without creating a task
263
+ - The executor is the g ui server's scheduler: while g ui is not running, tasks do not fire; after a
264
+ restart they catch up once or skip, according to the task's on_missed policy — state this
265
+ boundary plainly in your reply
266
+ - Each run lands in the task's dedicated session (title = task name) for the user to review later;
267
+ once created, reply with the task name, its cron plan, and the next run time
268
+ - Write the prompt as a self-contained request: the future run sees none of today's context
269
+ - Use action: list first to get a task id before view / remove / enable / disable
270
+
271
+ # Permissions (explicitly granted by the user)
272
+ - Inside the working directory: read/write files and run commands directly
273
+ - Other directories: may also be read and modified
274
+ - Single red line: never destroy the system. A safety guard blocks such commands.
275
+
276
+ # How you work
277
+ - Act first, ask later: use tools to investigate before asking the user
278
+ - 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.
279
+ - After modifying code, verify: run tests, build, or at least syntax check
280
+ - Prefer edit_file for precise replacements; read_file first to confirm original text
281
+ - Use offset/limit for large files
282
+ - git operations via run_command
283
+ - run_command defaults to the working directory; default timeout 120s, max 600s
284
+ - Images reach you two ways:
285
+ · The user attached one to a message → it arrives as an image_url part in the user message; you can see it directly
286
+ · The user only gave you a **path** (or you run into an image while exploring) → read it with the read_image tool, not read_file
287
+ (read_file decodes UTF-8 and returns garbage for images), and never guess an image's content from its filename
288
+ If the current model rejects images (no vision support), tell the user to switch to a vision-capable model
289
+
290
+ # Task plan (required for multi-step work)
291
+ - When a task takes several steps (code changes, debugging, research, multi-file edits), call update_plan
292
+ **before touching anything**: break it into 3-8 verifiable steps so the user knows the scope and the
293
+ acceptance criteria before you modify a single file
294
+ - Do not plan single trivial actions (read one file, answer one question) — that is noise
295
+ - Always send the **complete** current plan (not a delta), in execution order; at most one in_progress
296
+ - Update the status right after finishing a step instead of batching everything at the end; when the
297
+ plan no longer matches reality, rewrite the plan instead of pushing ahead
298
+ - Send an empty steps list once everything is done
299
+ - update_plan is a progress board for humans: never edit files or run commands just to "update the plan"
300
+
301
+ # Output
302
+ - Your text output is displayed in the user's Web UI
303
+ - The UI renders Markdown: headings / lists / tables / quotes / code blocks are shown formatted — do not fake tables with ASCII art
304
+ - To **show** the user an image (screenshot / chart / diagram), embed it in your reply as a Markdown
305
+ image \`![caption](path)\` — a local absolute path, or a path relative to the working directory,
306
+ both work and the UI loads and renders it; pasting a bare path, or wrapping it in a code block,
307
+ only shows the user a line of text
308
+ - For **flows / structure / sequences / state machines**, prefer a \`\`\`mermaid block
309
+ (flowchart / sequenceDiagram / stateDiagram-v2 / erDiagram …): the UI renders it as a real diagram.
310
+ Never hand-draw one out of plain-text arrows, indentation or box characters — on screen that is just monospaced text
311
+ - After completing a task, briefly summarize the result`;
312
+ }
313
+
314
+ // ── LLM 流式调用 ─────────────────────────────────────────
315
+ // 统一实现在 src/cli/ai/transport.js 的 streamChatOnce —— CLI 的 `g ai` 与这条
316
+ // Web 链路共用同一份。比这里原先那份多出来的能力:请求 usage(带 stream_options
317
+ // 降级重试)、校验 tool call index 边界、吃 evt.error,以及最要紧的一条 ——
318
+ // 流被截断但 tool_calls 已经部分到达时抛"中断"、拒绝执行半截的工具调用。
319
+ // 这里曾经是第二份实现,弱就弱在最后那条:半截参数有可能被拿去执行。
320
+
321
+ // ── 消息准备(消毒 / 历史有界化 / 旧图片降级) ──────────────────
322
+ // 统一实现在 src/cli/ai/context.js 的 prepareRequestMessages —— CLI 的 `g ai`
323
+ // 与这条 Web 链路共用同一份,两侧不再各写一遍。
324
+ //
325
+ // 这里曾经是第二份实现:历史只按条数硬切(MAX_HISTORY_MESSAGES=40)+ 纯 splice 丢弃,
326
+ // 而 CLI 侧早已改成「条数/字符双预算 + 丢弃项摘录成一条梗概」,两侧行为因此分叉 ——
327
+ // Web 面板聊久了模型会直接失忆,CLI 还记得要点。现在收敛到一处,口径见 context.js。
328
+
329
+ // ── 核心入口:运行一轮 agent 对话 ────────────────────────
330
+ //
331
+ // 参数:
332
+ // { session, model, userMessage, cwd, locale, signal, send, onChild, askUser, listProjects }
333
+ // - session: 从 agentSessionStore 读取的会话对象
334
+ // - model: { baseURL, model, apiKey }
335
+ // - userMessage: 用户输入文本
336
+ // - images: base64 dataURL 数组(可选,多模态图片,随最新一条 user 消息发给模型)
337
+ // - openFilePath: 文件空间里当前打开的文档(可选,注入请求副本,不落库)
338
+ // - dirStatusBlock: 「切换工作目录」弹窗里那批目录的 Git 状态(可选,由 agentRoutes
339
+ // 读配置 + 白名单过滤后用 buildDirStatusBlock 拼好传进来,同样只进请求副本)
340
+ // - attachments: 非图片附件(可选) = [{ name, path }],path 是**服务端落盘后的绝对路径**,
341
+ // 只把路径写进请求副本的 system 提示,内容由模型自己用工具读(见 utils/agentAttachments.js)
342
+ // - cwd: 工作目录
343
+ // - locale: 'zh' | 'en'
344
+ // - signal: AbortSignal (客户端断开时触发)
345
+ // - send: (obj) => void SSE 发送函数
346
+ // - onChild: (child) => void 子进程回调(用于取消)
347
+ // - askUser: (args, meta) => Promise<string> 等待用户回答
348
+ // - listProjects: (args) => Promise<string> list_projects 工具的数据源
349
+ // (由 agentRoutes 注入:最近目录/tasks.json/看板统计只有 GUI 侧拿得到)
350
+ // - registerProject: (dir) => Promise<void> register_project 工具的实现,以及
351
+ // run_command 里"建了仓库就顺手登记进常用目录"那一步的落点(同一个注入、同一个
352
+ // 效果)。不注入 = 没有登记能力(CLI 与测试环境),对应工具回一句可照做的说明。
353
+ // - dispatchTask: (payload) => Promise<string> dispatch_task 工具的实现 —— 派发一条
354
+ // 工作台任务。同样由 agentRoutes 注入,但**只在主 Agent 控制台发起的对话里**注入
355
+ // (别的入口不注入 = 那个入口没有派发能力,工具会回一句可照做的 unavailable)
356
+ // - getContextBlock: ({locale}) => Promise<string> 工作区状态快照的摘要块
357
+ // (由 agentRoutes 注入,实现在 routes/aiContext/:七个板块的摘要 + 落盘文件路径)
358
+ // - requestBudget: { maxChars, maxMessages, maxUserChars } 每轮请求的上下文预算
359
+ // (解析自全局配置 aiMaxRequestChars,见 cli/ai/context.js 的 resolveRequestBudget)。
360
+ // 不传时 prepareRequestMessages 走默认值,行为与改造前一致。
361
+ // - promptSection: 用户固定提示词(g ai 默认提示词 + 派发提示词,见 chatPrompts.js)。
362
+ // **不传(undefined)= 自己现读**:这样定时任务那条链路(scheduler.js 直接调
363
+ // 本函数)不必重复接一次线。显式传 '' = 本轮确定不带(调用方已经解析过)。
364
+ // 只进请求副本,不落 session.messages —— 改设置立刻生效,历史会话不被腌进旧规则。
365
+ // - resume: true 时**不再压入 user 消息**,直接拿着会话现有的尾巴继续跑。
366
+ // 用于"重试上一轮":失败那轮留下的 user + 工具结果都还在,再发一遍 user
367
+ // 会让模型看到两条同样的请求 + 已经跑完的工具结果。
368
+ // - streamFn: LLM 传输函数,默认就是共享的 streamChatOnce。**只给测试留的接缝**
369
+ // (单测要整体关掉自动重试,真等退避会让一个用例跑成分钟级),
370
+ // 生产调用点一律不传 —— 传了就等于换了一份传输实现。
371
+ //
372
+ // 扩展层(Skill + MCP)不在参数里:按 cwd 从 agentExtensions.js 的进程内缓存取,
373
+ // 整轮一次。三条路径都与 CLI turn.js 对齐 —— 工具表叠 extraTools、调用按 owns()
374
+ // 分流、摘要注进请求副本(agentParity.test.js 有守卫)。
375
+ //
376
+ // 返回: { aborted: boolean }
377
+ export async function runAgentTurn({ session, model, userMessage, images = [], cwd, locale, openFilePath, attachments = [], dirStatusBlock = '', signal, send, onChild, askUser, listProjects, registerProject, dispatchTask, getContextBlock, requestBudget = null, resume = false, streamFn = streamChatOnce, promptSection }) {
378
+ const ctx = { cwd, locale, onChild, askUser, listProjects, registerProject, dispatchTask };
379
+
380
+ // 确保 session.messages 存在
381
+ if (!Array.isArray(session.messages)) session.messages = [];
382
+
383
+ // 首轮:注入 system prompt
384
+ if (session.messages.length === 0) {
385
+ session.messages.push({
386
+ role: 'system',
387
+ content: buildWebSystemPrompt({ cwd, locale })
388
+ });
389
+ }
390
+
391
+ // 追加 user 消息:有图片时组装 OpenAI 多模态 content 数组(与 CLI agent.js 一致),否则保持纯字符串。
392
+ // resume 是"接着跑上一轮",会话尾巴上那条 user(或那批工具结果)就是要发的东西,不能重复压。
393
+ if (resume) {
394
+ // 尾巴上是一条**没有工具调用的 assistant**,说明上一轮其实跑完了(用户点的是操作栏那颗
395
+ // 「重新生成」,不是错误气泡的「重试」)。留着它的话,模型会顺着自己刚写完的答案往下说,
396
+ // 而且会话里会出现两条连着的 assistant —— 重开这条会话能看见两份答案。
397
+ // 摘掉它,语义就干净了:拿同一个问题再问一次。
398
+ const tail = session.messages[session.messages.length - 1];
399
+ if (tail?.role === 'assistant' && !(tail.tool_calls?.length > 0)) session.messages.pop();
400
+ } else {
401
+ const imageParts = (Array.isArray(images) ? images : [])
402
+ .filter(u => typeof u === 'string' && u.startsWith('data:image/'))
403
+ .map(u => ({ type: 'image_url', image_url: { url: u } }));
404
+ session.messages.push({
405
+ role: 'user',
406
+ content: imageParts.length > 0
407
+ ? [{ type: 'text', text: userMessage || ' ' }, ...imageParts]
408
+ : userMessage
409
+ });
410
+ }
411
+
412
+ // 工作区状态快照:整轮只取一次(TTL 在生成器内部,见 aiContext/index.js),
413
+ // 不放进循环 —— 否则每执行一次工具调用都要重拼一遍,而这轮对话里它不会变。
414
+ // 取不到就空着跳过:快照是**锦上添花**,不能因为它挂了就让用户这条消息发不出去。
415
+ let workspaceBlock = '';
416
+ if (typeof getContextBlock === 'function') {
417
+ try {
418
+ workspaceBlock = (await getContextBlock({ locale })) || '';
419
+ } catch (err) {
420
+ logger.warn(`[agentChat] 取工作区状态快照失败,本轮跳过注入: ${err?.message || err}`);
421
+ workspaceBlock = '';
422
+ }
423
+ }
424
+
425
+ const maxIterations = await resolveMaxToolIterations();
426
+
427
+ // 用户固定提示词(g ai 默认提示词 + 派发提示词):整轮只解析一次 —— 和快照同理,
428
+ // 它在这一轮里不会变,而工具循环每轮都要重拼一次请求。
429
+ // 调用方已经解析过(agentRoutes 为了外部引擎那条链路先解了一次)时不重复读文件;
430
+ // 显式传 '' 同样尊重 —— 那是"确定没有",不是"没传"。
431
+ let userPromptSection = promptSection;
432
+ if (userPromptSection === undefined) {
433
+ try {
434
+ userPromptSection = await resolveChatPromptSection({ cwd, locale });
435
+ } catch (err) {
436
+ // 取不到就当没配:这是锦上添花,不能因为它挂掉就让用户这条消息发不出去
437
+ logger.warn(`[agentChat] 取用户固定提示词失败,本轮跳过注入: ${err?.message || err}`);
438
+ userPromptSection = '';
439
+ }
440
+ }
441
+
442
+ // 预算缺省时在**这里**解析一次,与 prepareRequestMessages 的派生口径一致
443
+ // (不能让它自己兜默认:那样 measureContextUsage 拿到的分母会和实际裁剪用的分母
444
+ // 可能不同,UI 上的进度条就会说谎)。
445
+ const budget = requestBudget || resolveRequestBudget();
446
+
447
+ // 扩展层(Skill 清单 + MCP 工具):按 cwd 进程内缓存,整轮只取一次。
448
+ // CLI 把 promptSuffix bake 进首条 system 消息(进程启动时);Web 会话是长命的
449
+ // (可以活好几天,期间用户随时可能在广场装新的),所以这里每轮从缓存取、
450
+ // 摘要注进**请求副本** —— 装完立刻生效、老会话也吃得到,也不把当时的清单腌进
451
+ // 磁盘历史(口径同工作区快照:只进副本,不落 session.messages)。
452
+ // 加载失败已在 agentExtensions.js 里降级成空扩展,这里不会抛。
453
+ const extensions = await getWebExtensions({ cwd, locale });
454
+
455
+ // MCP 工具按名字分流,口径与 CLI turn.js 逐字对齐:名字属于扩展就走扩展
456
+ // (McpManager.call 内部已把失败翻成错误字符串,不会抛进循环),扩展返回
457
+ // null/undefined(加载后被换掉等边角)时回落内置工具。没装扩展时 owns() 恒为
458
+ // false,等价于直接调内置工具,零开销。
459
+ const executeOne = extensions
460
+ ? async (name, args, toolCtx) => {
461
+ if (extensions.owns?.(name)) {
462
+ const output = await extensions.execute(name, args, toolCtx);
463
+ if (output !== null && output !== undefined) return output;
464
+ }
465
+ return executeTool(name, args, toolCtx);
466
+ }
467
+ : executeTool;
468
+
469
+ for (let iter = 0; iter < maxIterations; iter++) {
470
+ // 每轮都从完整会话记录重新构建一次请求副本:条数/token 双预算 → 被丢掉的旧消息
471
+ // 摘录成一条梗概 → 旧图片降级 → provider 兼容消毒。
472
+ // 只作用于副本,session.messages 保持完整(与 CLI 的磁盘口径一致)。
473
+ // 预算来自 requestBudget(全局配置 aiMaxRequestTokens 的解析结果)。
474
+ const messages = prepareRequestMessages(session.messages, { locale, ...budget });
475
+ // 请求级上下文:用户固定提示词 + 工作区状态快照 + 常用目录状态 + 当前打开的文档
476
+ // + 本轮附件路径 + 扩展层摘要(只改副本,不落 session.messages,下一轮不重复累积)
477
+ injectRequestContext(messages, { cwd, openFilePath, attachments, locale, workspaceBlock, dirStatusBlock, promptSection: userPromptSection, extensionSection: extensionPromptSuffix(extensions, locale) });
478
+
479
+ // 上下文占用:在**请求发出去之前**量一次,发给 UI 画圆环。
480
+ // 为什么必须在发之前:provider 的真实 usage(input_tokens)要等响应回来才有,
481
+ // 而用户想知道的是"这次会带多少过去"—— 那只能在发之前量。
482
+ // 为什么每轮都发:工具循环里上下文是**持续增长**的,只发一次的话
483
+ // 用户看到的永远是第一轮那个数字,直到对话结束才发现早就满了。
484
+ const usage = measureContextUsage(messages, { ...budget, transcript: session.messages });
485
+ send({ type: 'context', usage });
486
+
487
+ let result;
488
+ // 断点事件:告诉前端"从这里开始是这一次请求要写的内容"。
489
+ // 前端收到自动重试时按它回退 —— 只丢失败那次吐的字,本轮前面几轮说过的话留着。
490
+ send({ type: 'attempt', attempt: iter + 1 });
491
+ try {
492
+ result = await streamFn({
493
+ model,
494
+ messages,
495
+ signal,
496
+ // 内置工具是底座;extraTools 是本轮 cwd 的 MCP 工具叠加(transport 里合并,
497
+ // 为空时回落纯内置表)。CLI turn.js 同一个参数,两边口径一致。
498
+ extraTools: extensions?.tools,
499
+ // 整轮对话(含后续工具调用产生的每一轮请求)复用同一个会话 ID
500
+ sessionId: session.sessionId,
501
+ onToken: ({ thinking, content }) => {
502
+ if (thinking) send({ type: 'thinking', delta: thinking });
503
+ if (content) send({ type: 'content', delta: content });
504
+ },
505
+ // 传输层自动重试(长静默 / 网关 5xx / 流中断,见 cli/ai/transport.js):
506
+ // 先告诉前端回退到断点,下一次尝试的增量才不会和上一截拼在一起。
507
+ onRetry: ({ attempt, maxRetries, delayMs, error }) => {
508
+ send({ type: 'retry', attempt, maxRetries, delayMs, reason: error?.message || '' });
509
+ },
510
+ });
511
+ } catch (err) {
512
+ // 请求失败**不回滚**刚压入的 user 消息(2026-10-09 改)。
513
+ // 原先是"末尾仍是 user 就弹掉",为的是让用户重发同一条时历史里不留两份。
514
+ // 现在有正经的「重试」了(前端那颗按钮 → resume 续跑本轮),而续跑的全部意义
515
+ // 就是"接着会话现有的尾巴跑":尾巴被弹掉的话,续跑会退化成**重跑上一轮**。
516
+ // 留着它还有个更直接的好处:用户发出去的问题不再凭空消失(界面上一直看得见),
517
+ // 带图的那些附件也还在请求副本里,重试不用靠前端重建。
518
+ send({ type: 'error', error: `LLM 请求失败: ${err.message}` });
519
+ return { aborted: false };
520
+ }
521
+
522
+ const { content, toolCalls } = result;
523
+
524
+ // provider 报回来的**真实** token 用量,比 measureContextUsage 的估算准。
525
+ // 回填进同一条 context 事件的两个字段(estimated / actual),UI 优先显示真实值 ——
526
+ // 估算只用于"请求发出去之前"那段时间(以及 provider 不返回 usage 的场合)。
527
+ //
528
+ // 为什么以前没记:CLI 侧有 /stats(src/cli/ai/telemetry.js),Web 侧漏了,
529
+ // 于是 28 个会话文件里 lastTurnStats / sessionStats 全是 null。
530
+ // 这里补上的是**本轮的**真实输入 token;会话累计要等落盘,由路由层负责。
531
+ if (result.usage) {
532
+ send({ type: 'context', usage: { ...usage, actualInputTokens: result.usage.inputTokens ?? null } });
533
+ }
534
+
535
+ // 推理内容必须原样带回历史。DeepSeek 系 thinking 模式下带 tool_calls 的 assistant
536
+ // 消息一旦缺 reasoning_content,下一轮回传就被上游 400 拒掉:
537
+ // "The `reasoning_content` in the thinking mode must be passed back to the API"。
538
+ // 口径与 CLI 侧 src/cli/ai/turn.js 的 assistant.reasoning_content 保持一致。
539
+ const withReasoning = msg => (result.reasoning ? { ...msg, reasoning_content: result.reasoning } : msg);
540
+
541
+ if (result.aborted) {
542
+ // 用户点了"停止":把已经流出来的部分正文补进历史,否则磁盘上这一轮只剩一条
543
+ // user 消息,重新打开会话时刚才生成的内容会整段丢失。
544
+ // 半截的 tool_calls 已被 transport 在中止时丢弃,这里只补正文,不会有悬空引用。
545
+ // 落盘由路由层统一负责(中止的轮次同样要写,见 agentRoutes.js)。
546
+ if (content) {
547
+ session.messages.push(withReasoning({ role: 'assistant', content }));
548
+ }
549
+ send({ type: 'error', error: '已取消' });
550
+ return { aborted: true };
551
+ }
552
+
553
+ // 无工具调用:本轮结束
554
+ if (toolCalls.length === 0) {
555
+ session.messages.push(withReasoning({ role: 'assistant', content: content || null }));
556
+ send({ type: 'done', content: content || '' });
557
+ return { aborted: false };
558
+ }
559
+
560
+ // 有工具调用:assistant(带 tool_calls)入历史
561
+ session.messages.push(withReasoning({
562
+ role: 'assistant',
563
+ content: content || null,
564
+ tool_calls: toolCalls
565
+ }));
566
+
567
+ // 逐个执行工具
568
+ for (const tc of toolCalls) {
569
+ const name = tc.function?.name || '';
570
+ const rawArgs = tc.function?.arguments || '';
571
+ const toolCallId = tc.id || name;
572
+
573
+ let args;
574
+ try {
575
+ args = rawArgs ? JSON.parse(rawArgs) : {};
576
+ } catch {
577
+ const errResult = `错误: 工具参数不是合法 JSON: ${rawArgs.slice(0, 200)}`;
578
+ send({ type: 'tool_call_start', toolCallId, name, argsPreview: rawArgs.slice(0, 200), arguments: rawArgs });
579
+ send({ type: 'tool_result', toolCallId, name, result: errResult });
580
+ session.messages.push({ role: 'tool', tool_call_id: toolCallId, name, content: errResult });
581
+ continue;
582
+ }
583
+
584
+ // 工具参数预览(前端收起态那一行副标题)。这里截断是**故意的** ——
585
+ // 摘要只负责"一眼看出它在干嘛"。
586
+ const argsPreview = summarizeArgs(name, args);
587
+ send({ type: 'tool_call_start', toolCallId, name, argsPreview, arguments: rawArgs });
588
+
589
+ const toolCtx = {
590
+ ...ctx,
591
+ signal,
592
+ // run_command 执行期间的增量输出 → 前端实时显示。
593
+ // 只用于展示,不进会话历史 —— 历史里存的仍是带 exit code 的最终结果。
594
+ onOutput: (chunk) => {
595
+ const text = String(chunk || '');
596
+ if (text) send({ type: 'tool_output', toolCallId, name, chunk: text });
597
+ },
598
+ };
599
+ if (name === 'ask_user' && typeof ctx.askUser === 'function') {
600
+ toolCtx.askUser = askArgs => ctx.askUser(askArgs, {
601
+ sessionId: session.sessionId,
602
+ interactionId: toolCallId,
603
+ send,
604
+ signal,
605
+ });
606
+ }
607
+ const output = await executeOne(name, args, toolCtx);
608
+ // read_image 会返回 { text, images }。前端只吃文本(result 是字符串,
609
+ // 直接把对象丢过去会渲染成 "[object Object]"),图片进会话历史里的
610
+ // 多模态 tool 消息 —— 模型看得见图,用户看到的是"已读取图片 xxx.png"。
611
+ const { text: outText, images: outImages } = splitToolOutput(output);
612
+ send({ type: 'tool_result', toolCallId, name, result: outText });
613
+ session.messages.push({ role: 'tool', tool_call_id: toolCallId, name, content: toolMessageContent(outText, outImages) });
614
+ }
615
+ // 工具结果全部入历史后继续循环,让模型基于结果决定下一步
616
+ }
617
+
618
+ // 达到最大迭代次数
619
+ send({ type: 'done', content: `已达单轮最大工具调用次数(${maxIterations}),本轮结束。如需继续请再发一条消息。` });
620
+ return { aborted: false };
621
+ }
622
+
623
+ // 扩展层摘要(Skill 清单 + MCP 连接状态)。取不到就当没有 —— 它是增量能力,
624
+ // 不该因为摘要拼不出来影响整轮对话。口径与 CLI 的 extensionSuffix 同一份文本。
625
+ function extensionPromptSuffix(extensions, locale) {
626
+ try {
627
+ return extensions?.promptSuffix?.(locale) || '';
628
+ } catch {
629
+ return '';
630
+ }
631
+ }
632
+
633
+ // 工具参数简短摘要(给前端展示)
634
+ function summarizeArgs(name, args) {
635
+ try {
636
+ switch (name) {
637
+ case 'run_command':
638
+ return String(args.command || '').slice(0, 200);
639
+ case 'read_file':
640
+ case 'read_image':
641
+ case 'write_file':
642
+ case 'edit_file':
643
+ return String(args.path || '');
644
+ case 'list_files':
645
+ return String(args.path || '.');
646
+ case 'search_text':
647
+ return String(args.pattern || '');
648
+ case 'update_plan': {
649
+ const steps = normalizePlanSteps(args.steps ?? args.todos ?? args.plan);
650
+ const why = String(args.explanation || '').replace(/\s+/g, ' ').trim();
651
+ return [summarizePlan(steps), why].filter(Boolean).join(' — ').slice(0, 200);
652
+ }
653
+ default:
654
+ return JSON.stringify(args).slice(0, 200);
655
+ }
656
+ } catch {
657
+ return '';
658
+ }
659
+ }
660
+
661
+ // ── 请求级上下文注入(用户固定提示词 / 工作区状态 / 常用目录状态 / 文件空间对话 / 本轮附件 / 扩展层摘要) ──
662
+ // 把"用户配的两块固定提示词""工作区各板块的状态摘要""常用目录那批目录的 Git 状态"
663
+ // "用户当前打开的文件""本轮附件的落盘路径"与"扩展层摘要(Skill + MCP)"追加到
664
+ // **请求副本**的 system 消息末尾:只影响这一次请求,session.messages 与磁盘历史保持
665
+ // 原样,下一轮也不会重复累积。
666
+ //
667
+ // ⚠️ 工作区快照**必须走这条副本路径,不能塞进 session.messages 里那条 system 消息**。
668
+ // 那条只在首轮 push 一次(见上面 `session.messages.length === 0` 的判断)并会落盘,
669
+ // 快照进去就等于永久停在"会话创建那天"——git 分支、任务进度全会是过期的,
670
+ // 而且这种错不会报错、只会让模型理直气壮地给出错答案。有单测钉住这一点。
671
+ // 常用目录状态同理,而且更严重:它说的是"这一刻"的领先/落后,几小时后必然不同。
672
+ // 用户固定提示词同理,但理由换一条:**改设置要立刻生效**,而且它属于"每轮都在的规则",
673
+ // 腌进历史会话等于让用户回看时看到一份过期规则(详见 chatPrompts.js 文件头)。
674
+ // 扩展层摘要同理,而且它还多一条:Web 会话长命,bake 进去等于"这个会话再也吃不到
675
+ // 新装的 MCP/Skill"——CLI 每启动一个进程重算一次所以没这个问题,Web 必须每轮注副本。
676
+ //
677
+ // 附件为什么只给路径、不给内容:见 utils/agentAttachments.js 的头注释 —— 非图片附件
678
+ // 由服务端落盘,模型自己用 read / grep 按需取,比把几百 KB 文本内联进消息省得多。
679
+ // 快照块同理,只给摘要与目录路径,板块正文由模型按需读。
680
+ export function injectRequestContext(messages, { cwd, openFilePath, attachments = [], locale, workspaceBlock = '', dirStatusBlock = '', promptSection = '', extensionSection = '' }) {
681
+ if (!Array.isArray(messages)) return;
682
+ const en = String(locale || '').startsWith('en');
683
+
684
+ const parts = [];
685
+
686
+ // ⓪ 工作区状态快照(七个板块的摘要 + 落盘目录)。由 aiContext 生成,只在这个副本里。
687
+ if (typeof workspaceBlock === 'string' && workspaceBlock.trim()) {
688
+ parts.push(workspaceBlock.trim());
689
+ }
690
+
691
+ // ① 常用目录那批目录的 Git 状态(切换工作目录弹窗里的追问才有)。
692
+ // 整块(含"屏幕上那段自动解读")由 agentRoutes 调 buildDirStatusBlock 拼好传进来 ——
693
+ // 两条链路(内置引擎的副本 / 外部引擎的前缀)用的是同一个字符串,不在两处各拼一遍。
694
+ if (typeof dirStatusBlock === 'string' && dirStatusBlock.trim()) {
695
+ parts.push(dirStatusBlock.trim());
696
+ }
697
+
698
+ // ② 当前打开的文档(文件空间对话才有;项目外或等于根目录直接忽略)
699
+ if (openFilePath) {
700
+ const root = cwd || process.cwd();
701
+ let rel = '';
702
+ try {
703
+ rel = path.relative(root, path.resolve(root, openFilePath));
704
+ } catch {
705
+ rel = '';
706
+ }
707
+ if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) {
708
+ const file = rel.split(path.sep).join('/');
709
+ parts.push(en
710
+ ? `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.`
711
+ : `用户此刻在文件空间打开的文件是 \`${file}\`(相对项目根目录)。用户说"这个文件/当前文件"时默认指它;请先用工具读取内容,不要臆测。`);
712
+ }
713
+ }
714
+
715
+ // ③ 本轮附件:只给绝对路径,内容让模型自己去读
716
+ const files = (Array.isArray(attachments) ? attachments : [])
717
+ .filter(a => a && typeof a.path === 'string' && a.path)
718
+ .slice(0, 20);
719
+ if (files.length > 0) {
720
+ const lines = files.map(a => `- \`${a.path}\`${a.name && a.name !== path.basename(a.path) ? ` (${a.name})` : ''}`);
721
+ parts.push(en
722
+ ? `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.`
723
+ : `用户本轮附带了 ${files.length} 个文件,已保存到以下绝对路径:\n${lines.join('\n')}\n需要时用工具读取(内容没有内联在这里),不要臆测。`);
724
+ }
725
+
726
+ // ④ 扩展层摘要(Skill 清单 + MCP 连接状态)排在最后:与 CLI 一致 ——
727
+ // 扩展是"增量能力",不该插在权限/环境/规则中间打断阅读。
728
+ // promptSuffix 自带 `# 已安装的 Skill` / `# 已接入的 MCP 服务` 标题,
729
+ // 且明说"这些外部工具已挂到你的工具列表里",正好接住基础提示词里那句
730
+ // "可用工具只有上面列出的 N 个"(N=内置数)—— 两边不打架。
731
+ const hasExtensionSection = typeof extensionSection === 'string' && extensionSection.trim();
732
+
733
+ if (parts.length === 0 && !(typeof promptSection === 'string' && promptSection.trim()) && !hasExtensionSection) return;
734
+
735
+ // 用户固定提示词单独一节,且排在"当前上下文"**前面**:它是每轮都成立的规则,
736
+ // 而当前上下文是这一刻的快照 —— 规则在前、现场在后,读起来才是"先约束后事实"。
737
+ let note = '';
738
+ if (typeof promptSection === 'string' && promptSection.trim()) {
739
+ note += `\n\n# ${en ? 'Standing instructions' : '用户固定提示词'}\n${promptSection.trim()}`;
740
+ }
741
+ if (parts.length > 0) {
742
+ note += `\n\n# ${en ? 'Current context' : '当前上下文'}\n${parts.join('\n\n')}`;
743
+ }
744
+ if (hasExtensionSection) {
745
+ note += `\n\n${extensionSection.trim()}`;
746
+ }
747
+ const sys = messages.find(m => m && m.role === 'system' && typeof m.content === 'string');
748
+ if (sys) sys.content += note;
749
+ else messages.unshift({ role: 'system', content: note.trim() });
750
+ }
751
+
752
+ export { buildWebSystemPrompt };