zen-gitsync 2.17.46 → 2.17.47

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 (54) hide show
  1. package/package.json +1 -1
  2. package/src/cli/ai/agent.js +1342 -1342
  3. package/src/cli/ai/context.js +431 -253
  4. package/src/cli/ai/context.test.js +376 -258
  5. package/src/cli/ai/runtime.test.js +7 -4
  6. package/src/cli/ai/turn.js +166 -166
  7. package/src/config.js +906 -871
  8. package/src/ui/public/assets/{AgentEngineSelector-D6rlSfMD.js → AgentEngineSelector-CKiVaqrY.js} +1 -1
  9. package/src/ui/public/assets/{AgentView-h2YnbB7J.css → AgentView-CRqQYAzh.css} +1 -1
  10. package/src/ui/public/assets/{AgentView-BFRGoIVb.js → AgentView-c1frCBc3.js} +1 -1
  11. package/src/ui/public/assets/{AppVersionBadge-BnPsn1X5.js → AppVersionBadge-IWS9XBUV.js} +2 -2
  12. package/src/ui/public/assets/{BranchSelector-D30GJwUl.js → BranchSelector-C_mK2mkS.js} +1 -1
  13. package/src/ui/public/assets/{CommitForm-Dvg-RUZI.js → CommitForm-C6I9rQXn.js} +1 -1
  14. package/src/ui/public/assets/{CommonDialog-BzLau2RJ.js → CommonDialog-2zVHBuqT.js} +1 -1
  15. package/src/ui/public/assets/EditorView-DUJrWJgh.js +1 -0
  16. package/src/ui/public/assets/{EditorView-8Rb4n-Wh.css → EditorView-DqagGedH.css} +1 -1
  17. package/src/ui/public/assets/{FlowExecutionViewer-VL-rUElj.js → FlowExecutionViewer-0Xw0oDen.js} +1 -1
  18. package/src/ui/public/assets/{FlowOrchestrationWorkspace-BPNlHRVu.js → FlowOrchestrationWorkspace-BKUspY-s.js} +1 -1
  19. package/src/ui/public/assets/{LogList-COwGjQl6.js → LogList-pbwn5n2S.js} +1 -1
  20. package/src/ui/public/assets/{MindmapView-BVuqPD_H.js → MindmapView-DYh6nuuF.js} +1 -1
  21. package/src/ui/public/assets/{MonitorView-hqe-4xd0.js → MonitorView-CsJyUL9v.js} +1 -1
  22. package/src/ui/public/assets/{ProjectStartupButton-RBc-DJBR.js → ProjectStartupButton-BhFQfAX1.js} +1 -1
  23. package/src/ui/public/assets/{RecentDirectoriesChat-DFljcYFH.js → RecentDirectoriesChat-DM6sRO9L.js} +1 -1
  24. package/src/ui/public/assets/{RemoteManagerDialog-D6Rbjchl.js → RemoteManagerDialog-CIZagKkC.js} +1 -1
  25. package/src/ui/public/assets/{RemoteRepoCard-l0NpXgvB.js → RemoteRepoCard-Dj-B-6WU.js} +1 -1
  26. package/src/ui/public/assets/{SourceMapView-CeVystt0.js → SourceMapView-pIlmQzYw.js} +1 -1
  27. package/src/ui/public/assets/{SvgIcon-B-xDJQA1.js → SvgIcon-CaefOO1F.js} +1 -1
  28. package/src/ui/public/assets/{UserInputNode-DJglU68l.js → UserInputNode-1--I25vm.js} +1 -1
  29. package/src/ui/public/assets/{WorkbenchView-BSQ87CDi.css → WorkbenchView-BLzcZqy0.css} +1 -1
  30. package/src/ui/public/assets/WorkbenchView-bpzL6m84.js +20 -0
  31. package/src/ui/public/assets/{_plugin-vue_export-helper-Dz1ARW9a.js → _plugin-vue_export-helper-UTW98kcD.js} +5 -5
  32. package/src/ui/public/assets/agentConversations-B9LNft3v.js +8 -0
  33. package/src/ui/public/assets/{configStore-CMlW8sMa.js → configStore-H-n3ukZ_.js} +1 -1
  34. package/src/ui/public/assets/{dagre-DyI0XLXY.js → dagre-Br4Eexe7.js} +3 -3
  35. package/src/ui/public/assets/{element-plus-DrCk0pcV.js → element-plus-CZdvD4lq.js} +1 -1
  36. package/src/ui/public/assets/{flow-mindmap-Cx5RNYN9.js → flow-mindmap-4kZ-2J2Y.js} +1 -1
  37. package/src/ui/public/assets/{index-u9LyUFkr.css → index-BgN6Z2X9.css} +1 -1
  38. package/src/ui/public/assets/{index-BQxIFopZ.js → index-CD7otmb_.js} +9 -9
  39. package/src/ui/public/assets/{monaco-Dfcwm1aX.js → monaco-CoYCcK3Q.js} +1 -1
  40. package/src/ui/public/assets/{office-docx-C0unxEAm.js → office-docx-DndLP6GO.js} +1 -1
  41. package/src/ui/public/assets/{office-excel-DPdyr_DJ.js → office-excel-Jt5zzo6D.js} +1 -1
  42. package/src/ui/public/assets/{office-pptx-eojGAs9D.js → office-pptx-J8tzRTjU.js} +1 -1
  43. package/src/ui/public/assets/{vendor-yntih_em.js → vendor-BD09UDuP.js} +500 -500
  44. package/src/ui/public/assets/vendor-DNiTBOIX.css +1 -0
  45. package/src/ui/public/assets/{vue-flow-De8mr_Fv.js → vue-flow-CkM05TWt.js} +1 -1
  46. package/src/ui/public/index.html +13 -13
  47. package/src/ui/server/routes/config.js +1321 -1314
  48. package/src/ui/server/routes/workbench/agentChat.js +594 -571
  49. package/src/ui/server/routes/workbench/agentChatShared.test.js +302 -233
  50. package/src/ui/server/routes/workbench/agentRoutes.js +631 -631
  51. package/src/ui/public/assets/EditorView-D4ZQutRw.js +0 -1
  52. package/src/ui/public/assets/WorkbenchView-BpH5kNht.js +0 -20
  53. package/src/ui/public/assets/agentConversations-B8nY45V4.js +0 -7
  54. package/src/ui/public/assets/vendor-CP-WuGZG.css +0 -1
@@ -1,571 +1,594 @@
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: 'done', content } — 本轮最终完成
37
- // - { type: 'error', error }
38
-
39
- import path from 'path';
40
- import os from 'os';
41
- import { logger } from './shared.js';
42
-
43
- // 从 CLI 侧导入工具定义、执行器与 LLM 传输层(同一 monorepo,路径可达)
44
- import { TOOL_DEFINITIONS, executeTool, normalizePlanSteps, summarizePlan, splitToolOutput, toolMessageContent } from '../../../../cli/ai/tools.js';
45
- import { prepareRequestMessages } from '../../../../cli/ai/context.js';
46
- import { streamChatOnce } from '../../../../cli/ai/transport.js';
47
- import { checkDangerousCommand } from '../../../../cli/ai/safety.js';
48
- import { guardCommand } from '../../../../cli/ai/platformGuard.js';
49
- import configManager from '../../../../config.js';
50
-
51
- // 单轮工具调用循环数的兜底值(防失控);实际值取全局配置 aiMaxToolIterations,
52
- // 与 CLI 侧 src/cli/ai/agent.js 共用同一个配置项。
53
- const DEFAULT_MAX_TOOL_ITERATIONS = 1000;
54
-
55
- // 读取全局配置里的单轮工具调用上限。
56
- // 读配置失败不该把整轮对话打挂 —— 退回默认值继续跑,比用户消息直接发不出去好。
57
- async function resolveMaxToolIterations() {
58
- try {
59
- const cfg = await configManager.loadConfig();
60
- const n = Number(cfg?.aiMaxToolIterations);
61
- if (Number.isFinite(n) && n > 0) return Math.floor(n);
62
- } catch (err) {
63
- logger.warn(`[agentChat] 读取 aiMaxToolIterations 失败,回退默认值: ${err?.message || err}`);
64
- }
65
- return DEFAULT_MAX_TOOL_ITERATIONS;
66
- }
67
-
68
- // ── 系统提示词构建 ──────────────────────────────────────────
69
- // 与 CLI agent.js 的 buildSystemPrompt 保持一致,但标注来源为 Web 端
70
- function buildWebSystemPrompt({ cwd, locale }) {
71
- const zh = !String(locale || '').startsWith('en');
72
- const now = new Date().toLocaleString();
73
- const isWin = process.platform === 'win32';
74
- const builtinToolCount = TOOL_DEFINITIONS.length;
75
- const shellDesc = isWin ? 'cmd.exe / PowerShell' : '/bin/sh';
76
-
77
- if (zh) {
78
- return `你是 "g ai" —— zen-gitsync 内置的编码智能体,通过工具在用户真实电脑上完成编码任务。当前用户通过 Web 界面与你对话。
79
-
80
- # 运行环境
81
- - 操作系统: ${process.platform}
82
- - Shell: ${shellDesc}
83
- - 当前工作目录: ${cwd}
84
- - 当前时间: ${now}
85
-
86
- # 平台兼容性(重要!)
87
- - 必须使用与当前 Shell 兼容的命令,禁止盲套 Unix 写法
88
- ${isWin ? `- 当前是 Windows,以下 Unix 命令**不存在**,用了必定报"不是内部或外部命令":
89
- tail / head / cat / grep / ls / sed / awk / wc / cut / uniq / xargs / which / touch
90
- 平台守卫会在执行前拦截这些命令,但请主动避免,不要浪费一轮调用
91
- - 跨平台替代方案:
92
- · 列目录 → list_files 工具 或 cmd 的 dir
93
- · 搜内容 → search_text 工具 或 cmd 的 findstr
94
- · 看文件 → read_file 工具 或 cmd 的 type
95
- · 看图片 → read_image 工具(截图/设计稿/示意图)。read_file 读图片只会得到乱码,别浪费那一轮
96
- · 看输出末尾 → PowerShell "命令 | Select-Object -Last N"
97
- · 文本处理 → node -e "..." 或 PowerShell
98
- · 查命令路径 → cmd 的 where(不是 which)
99
- - 必须跑 shell 时优先跨平台写法(如 node -e "..."),别用 Unix 专属命令` : `- 当前是 POSIX 环境,Unix 命令可用`}
100
-
101
- # 远程仓库(GitHub / Gitee)
102
- - 问"我有哪些项目""哪些项目需要 pull / 推送" → 用 list_projects 工具。它返回的就是 g ui
103
- 「最近项目」面板那份清单(最近目录 + 建过任务的目录,带分支/领先/落后/未提交数与任务进度),
104
- 口径与界面完全一致。**不要**用 list_files 自己扫盘数仓库 —— 那会把 node_modules 里的嵌套
105
- 仓库也算进来,数出来的个数跟界面对不上。领先/落后是本地快照,要真实值就带 refresh=true
106
- 先联网 fetch 一轮,再回答"要不要 pull"
107
- - 问"这个项目关联哪个远端" → run_command 跑 \`git remote -v\`:本地信息,不联网、不依赖任何 CLI,任何环境都能答
108
- - 问"我账号下有哪些仓库"或某仓库的 PR / Issue → 用官方 CLI。凭据由 CLI 自己保管:
109
- · GitHub → \`gh\`。列仓库 \`gh repo list --limit 50 --json name,visibility,updatedAt,primaryLanguage\`;
110
- 看详情 \`gh repo view <owner/repo>\`;PR \`gh pr list\`;登录态 \`gh auth status\`
111
- · Gitee → \`gitee\`。列仓库 \`gitee repo list\`;登录态 \`gitee auth status\`
112
- (未登录时退出码**仍是 0**,必须看 \`--json\` 里的 status 字段,只看退出码会误判成已登录)
113
- · 两者默认只覆盖**当前登录账号**;组织仓库、私有仓库可能需要更大的 token scope,拉不到就如实说明,
114
- 不要用其他途径绕过
115
- · 绝不向用户索要 token —— 也不要让用户把 token 粘进对话
116
- - CLI 可能没装、或没在服务端进程的 PATH 里(装完没重启服务就是这个表现,Web 端尤其常见):
117
- 报"不是内部或外部命令" / command not found 时,**不要换写法反复重试**,更不要凭印象编仓库名或目录名。
118
- 直接告诉用户未检测到该 CLI,可在「远程仓库」页一键安装/登录,或需要时重启服务让新装的 CLI 生效;
119
- 若只是想回答本项目的问题,退回 \`git remote -v\`
120
- - 克隆仓库 / 添加远端时**优先用 SSH**(用户偏好,本机已配好密钥,实测走 https 会弹凭据窗口把任务打断):
121
- · GitHub 用 \`git@github.com:owner/repo.git\`,Gitee 用 \`git@gitee.com:owner/repo.git\`
122
- · 用户给的是 \`https://...\` 或 \`gh repo clone owner/repo\` 时,先换算成 SSH 地址再执行 ——
123
- 走 https 会弹 Git Credential Manager 让用户输账号密码,任务就停在半路等输入
124
- · 已有仓库换协议:\`git remote set-url origin <ssh 地址>\`(先 \`git remote -v\` 看当前是什么)
125
- · 只有 SSH 真的不可用(报 \`Permission denied (publickey)\` / \`Host key verification failed\`)才退回 https,
126
- 并用一句话说明这次走的是 https、配好密钥后可改回;两边都失败就停下来问用户,不要反复重试
127
-
128
- # 权限(用户已明确授权,无需反复征求同意)
129
- - 工作目录内:读写文件、执行命令等所有操作直接执行
130
- - 其他目录:同样可以读取和修改
131
- - 唯一红线:不得破坏系统(格式化磁盘、删除根目录/系统目录、关机重启、写块设备等)。
132
- 安全守卫会拦截这类命令;被拦截时换安全方案,或告知用户需要他手动执行。
133
-
134
- # 工作方式
135
- - 先动手、后提问:能用工具查清的不要问用户(list_files / read_file / search_text / run_command)
136
- - 修改代码后主动验证:跑测试、构建或至少语法检查(用 run_command)
137
- - 编辑文件优先 edit_file 精确替换;先 read_file 看原文,old_string 必须与文件内容完全一致(含缩进换行)
138
- - 大文件用 offset/limit 分段读取,不要一次读爆上下文
139
- - git 操作用 run_command 执行
140
- - run_command 默认就在工作目录执行,不要再加 cd 前缀;默认超时 120 秒,长任务加大 timeout_seconds(最大 600)
141
- - 命令在 ${shellDesc} 下执行,注意语法兼容
142
-
143
- # 任务计划(多步任务必用)
144
- - 任务需要多步时(改代码、排查、调研、多文件改动),**动手之前**先调一次 update_plan,把要做的事
145
- 拆成 3-8 个可核对的步骤,让用户在你改任何东西之前就知道范围和验收标准
146
- - 单步小事(读个文件、答一个问题)不要列计划,那是噪音
147
- - 每次都传**完整**的当前计划(不是增量),顺序即执行顺序;同一时刻最多一个 in_progress
148
- - 完成一步就更新一次状态,别攒到最后一次性刷;计划与实际不符时直接改计划,不要硬着头皮往下走
149
- - 全部做完后把 steps 传空表示收尾
150
- - update_plan 只是给人看的进度板,不要为了"更新计划"去改文件或跑命令
151
-
152
- # 与用户交互
153
- - 需要向用户确认、提问或汇报重要决策时,直接用普通文本输出
154
- - 需要暂停当前任务并等待用户决定或补充信息时,调用 ask_user,不要猜测或只在普通文本里提问
155
- - 一次要让用户从多个选项里选好几项时(如"要我改哪几个文件"),传 multiple: true,用户会勾选后统一提交
156
- - 不要调用不存在的工具,可用工具只有上面列出的 ${builtinToolCount} 个
157
- - 图片有两条来路:
158
- · 用户随消息附带(附件框/粘贴) → 图片以 image_url 部件出现在 user 消息里,直接就能看到
159
- · 用户只给了**路径**(或你自己在翻文件时遇到图)→ 用 read_image 工具读它,别用 read_file
160
- (read_file 按 UTF-8 解码,图片只会是乱码),也别根据文件名猜内容
161
- 如果当前模型不支持视觉(带图请求报错),提醒用户换用支持视觉的模型
162
- - 发现高风险或状态不一致的情况时:先用文本说明发现和影响,停下来等用户指示,不要擅自继续破坏性操作
163
-
164
- # 输出
165
- - 你的文本输出直接显示在用户 Web 界面,用简体中文交流
166
- - 界面按 Markdown 渲染:标题 / 列表 / 表格 / 引用 / 代码块都会排版后显示,别用 ASCII 画表格
167
- - 讲**流程 / 结构 / 时序 / 状态**这类"说出来不如画出来"的东西时,写 \`\`\`mermaid 代码块
168
- (flowchart / sequenceDiagram / stateDiagram-v2 / erDiagram …),界面会把它渲染成真正的图。
169
- 不要用纯文本箭头、缩进或 ASCII 框线凑一张图 —— 那种"图"在界面上只是一段等宽文本
170
- - 完成任务后用一两句话汇报结果,不要复述过程细节`;
171
- }
172
-
173
- 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.
174
-
175
- # Environment
176
- - OS: ${process.platform}
177
- - Shell: ${shellDesc}
178
- - Working directory: ${cwd}
179
- - Current time: ${now}
180
-
181
- # Platform compatibility (important!)
182
- - You MUST use commands compatible with the current shell.
183
- ${isWin ? `- This is Windows. The following Unix commands do NOT exist here:
184
- tail / head / cat / grep / ls / sed / awk / wc / cut / uniq / xargs / which / touch
185
- A platform guard will block these before execution, but avoid them proactively.
186
- - Cross-platform alternatives:
187
- · List dirs → list_files tool, or cmd's dir
188
- · Search content → search_text tool, or cmd's findstr
189
- · Read files → read_file tool, or cmd's type
190
- · Look at an image → read_image tool (screenshots, mockups, diagrams). read_file only decodes text and returns garbage for images
191
- · Tail output → PowerShell "command | Select-Object -Last N"
192
- · Text processing → node -e "..." or PowerShell
193
- · Find executable → cmd's where (not which)` : `- POSIX environment: Unix commands are available`}
194
-
195
- # Remote repositories (GitHub / Gitee)
196
- - "Which projects do I have?" / "Which ones need a pull or push?" → use the list_projects tool.
197
- It returns exactly the list behind the GUI's "Recent projects" panel (recent directories plus any
198
- directory a task was created in, with branch / ahead / behind / uncommitted counts and task
199
- progress) — the same numbers the UI shows. Do NOT scan the disk with list_files to count repos:
200
- that also picks up nested repos inside node_modules and the totals will not match the UI.
201
- Ahead/behind comes from local refs, so pass refresh=true for a real answer about pulling
202
- - "Which remote does this project point at?" → run_command \`git remote -v\`: local info, no network, no CLI needed
203
- - "Which repos do I have?" or a repo's PRs / issues → use the official CLI. The CLI owns the credentials:
204
- · GitHub → \`gh\`. List repos \`gh repo list --limit 50 --json name,visibility,updatedAt,primaryLanguage\`;
205
- details \`gh repo view <owner/repo>\`; PRs \`gh pr list\`; auth state \`gh auth status\`
206
- · Gitee → \`gitee\`. List repos \`gitee repo list\`; auth state \`gitee auth status\`
207
- (the exit code is still 0 when logged out — read the status field from \`--json\`, or you will
208
- wrongly report "logged in")
209
- · Both default to the **currently authenticated account only**; org and private repos may need
210
- broader token scopes. If you cannot see them, say so plainly — do not route around it
211
- · Never ask the user for a token, and never have them paste one into the conversation
212
- - The CLI may not be installed, or missing from the server process's PATH (that is what "installed but
213
- the server cannot find it" looks like — common on the Web side):
214
- on "not recognized" / command not found, do NOT retry with a different spelling and do NOT invent
215
- repo or directory names. Tell the user the CLI was not found and point them to the "Remote
216
- repositories" page (one-click install / login), or restart the server so a freshly installed CLI
217
- becomes visible; if you only need to answer for this project, fall back to \`git remote -v\`
218
- - Cloning a repo or adding a remote: **prefer SSH** (the user's preference — keys are already set up,
219
- and https has been observed to pop a credential window that stalls the task):
220
- · GitHub → \`git@github.com:owner/repo.git\`; Gitee → \`git@gitee.com:owner/repo.git\`
221
- · If the user hands you an \`https://...\` URL (or \`gh repo clone owner/repo\`), convert it to the SSH
222
- form first — https pops up Git Credential Manager asking for a username/password and blocks the task
223
- · Switching an existing repo: \`git remote set-url origin <ssh url>\` (check \`git remote -v\` first)
224
- · Fall back to https only when SSH genuinely fails (\`Permission denied (publickey)\` /
225
- \`Host key verification failed\`), and say in one line that this one used https and can go back to SSH
226
- once the key is set up; if both fail, stop and ask the user — do not keep retrying
227
-
228
- # Permissions (explicitly granted by the user)
229
- - Inside the working directory: read/write files and run commands directly
230
- - Other directories: may also be read and modified
231
- - Single red line: never destroy the system. A safety guard blocks such commands.
232
-
233
- # How you work
234
- - Act first, ask later: use tools to investigate before asking the user
235
- - 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.
236
- - After modifying code, verify: run tests, build, or at least syntax check
237
- - Prefer edit_file for precise replacements; read_file first to confirm original text
238
- - Use offset/limit for large files
239
- - git operations via run_command
240
- - run_command defaults to the working directory; default timeout 120s, max 600s
241
- - Images reach you two ways:
242
- · The user attached one to a message → it arrives as an image_url part in the user message; you can see it directly
243
- · 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
244
- (read_file decodes UTF-8 and returns garbage for images), and never guess an image's content from its filename
245
- If the current model rejects images (no vision support), tell the user to switch to a vision-capable model
246
-
247
- # Task plan (required for multi-step work)
248
- - When a task takes several steps (code changes, debugging, research, multi-file edits), call update_plan
249
- **before touching anything**: break it into 3-8 verifiable steps so the user knows the scope and the
250
- acceptance criteria before you modify a single file
251
- - Do not plan single trivial actions (read one file, answer one question) — that is noise
252
- - Always send the **complete** current plan (not a delta), in execution order; at most one in_progress
253
- - Update the status right after finishing a step instead of batching everything at the end; when the
254
- plan no longer matches reality, rewrite the plan instead of pushing ahead
255
- - Send an empty steps list once everything is done
256
- - update_plan is a progress board for humans: never edit files or run commands just to "update the plan"
257
-
258
- # Output
259
- - Your text output is displayed in the user's Web UI
260
- - The UI renders Markdown: headings / lists / tables / quotes / code blocks are shown formatted — do not fake tables with ASCII art
261
- - For **flows / structure / sequences / state machines**, prefer a \`\`\`mermaid block
262
- (flowchart / sequenceDiagram / stateDiagram-v2 / erDiagram …): the UI renders it as a real diagram.
263
- Never hand-draw one out of plain-text arrows, indentation or box characters — on screen that is just monospaced text
264
- - After completing a task, briefly summarize the result`;
265
- }
266
-
267
- // ── LLM 流式调用 ─────────────────────────────────────────
268
- // 统一实现在 src/cli/ai/transport.js 的 streamChatOnce —— CLI 的 `g ai` 与这条
269
- // Web 链路共用同一份。比这里原先那份多出来的能力:请求 usage(带 stream_options
270
- // 降级重试)、校验 tool call index 边界、吃 evt.error,以及最要紧的一条 ——
271
- // 流被截断但 tool_calls 已经部分到达时抛"中断"、拒绝执行半截的工具调用。
272
- // 这里曾经是第二份实现,弱就弱在最后那条:半截参数有可能被拿去执行。
273
-
274
- // ── 消息准备(消毒 / 历史有界化 / 旧图片降级) ──────────────────
275
- // 统一实现在 src/cli/ai/context.js 的 prepareRequestMessages —— CLI 的 `g ai`
276
- // 与这条 Web 链路共用同一份,两侧不再各写一遍。
277
- //
278
- // 这里曾经是第二份实现:历史只按条数硬切(MAX_HISTORY_MESSAGES=40)+ 纯 splice 丢弃,
279
- // 而 CLI 侧早已改成「条数/字符双预算 + 丢弃项摘录成一条梗概」,两侧行为因此分叉 ——
280
- // Web 面板聊久了模型会直接失忆,CLI 还记得要点。现在收敛到一处,口径见 context.js。
281
-
282
- // ── 核心入口:运行一轮 agent 对话 ────────────────────────
283
- //
284
- // 参数:
285
- // { session, model, userMessage, cwd, locale, signal, send, onChild, askUser, listProjects }
286
- // - session: 从 agentSessionStore 读取的会话对象
287
- // - model: { baseURL, model, apiKey }
288
- // - userMessage: 用户输入文本
289
- // - images: base64 dataURL 数组(可选,多模态图片,随最新一条 user 消息发给模型)
290
- // - openFilePath: 文件空间里当前打开的文档(可选,注入请求副本,不落库)
291
- // - dirStatusBlock: 「切换工作目录」弹窗里那批目录的 Git 状态(可选,由 agentRoutes
292
- // 读配置 + 白名单过滤后用 buildDirStatusBlock 拼好传进来,同样只进请求副本)
293
- // - attachments: 非图片附件(可选) = [{ name, path }],path 是**服务端落盘后的绝对路径**,
294
- // 只把路径写进请求副本的 system 提示,内容由模型自己用工具读(见 utils/agentAttachments.js)
295
- // - cwd: 工作目录
296
- // - locale: 'zh' | 'en'
297
- // - signal: AbortSignal (客户端断开时触发)
298
- // - send: (obj) => void SSE 发送函数
299
- // - onChild: (child) => void 子进程回调(用于取消)
300
- // - askUser: (args, meta) => Promise<string> 等待用户回答
301
- // - listProjects: (args) => Promise<string> list_projects 工具的数据源
302
- // (由 agentRoutes 注入:最近目录/tasks.json/看板统计只有 GUI 侧拿得到)
303
- // - dispatchTask: (payload) => Promise<string> dispatch_task 工具的实现 —— 派发一条
304
- // 工作台任务。同样由 agentRoutes 注入,但**只在主 Agent 控制台发起的对话里**注入
305
- // (别的入口不注入 = 那个入口没有派发能力,工具会回一句可照做的 unavailable)
306
- // - getContextBlock: ({locale}) => Promise<string> 工作区状态快照的摘要块
307
- // (由 agentRoutes 注入,实现在 routes/aiContext/:七个板块的摘要 + 落盘文件路径)
308
- // - requestBudget: { maxChars, maxMessages, maxUserChars } 每轮请求的上下文预算
309
- // (解析自全局配置 aiMaxRequestChars,见 cli/ai/context.js 的 resolveRequestBudget)。
310
- // 不传时 prepareRequestMessages 走默认值,行为与改造前一致。
311
- //
312
- // 返回: { aborted: boolean }
313
- export async function runAgentTurn({ session, model, userMessage, images = [], cwd, locale, openFilePath, attachments = [], dirStatusBlock = '', signal, send, onChild, askUser, listProjects, dispatchTask, getContextBlock, requestBudget = null }) {
314
- const ctx = { cwd, locale, onChild, askUser, listProjects, dispatchTask };
315
-
316
- // 确保 session.messages 存在
317
- if (!Array.isArray(session.messages)) session.messages = [];
318
-
319
- // 首轮:注入 system prompt
320
- if (session.messages.length === 0) {
321
- session.messages.push({
322
- role: 'system',
323
- content: buildWebSystemPrompt({ cwd, locale })
324
- });
325
- }
326
-
327
- // 追加 user 消息:有图片时组装 OpenAI 多模态 content 数组(与 CLI agent.js 一致),否则保持纯字符串
328
- const imageParts = (Array.isArray(images) ? images : [])
329
- .filter(u => typeof u === 'string' && u.startsWith('data:image/'))
330
- .map(u => ({ type: 'image_url', image_url: { url: u } }));
331
- session.messages.push({
332
- role: 'user',
333
- content: imageParts.length > 0
334
- ? [{ type: 'text', text: userMessage || ' ' }, ...imageParts]
335
- : userMessage
336
- });
337
-
338
- // 工作区状态快照:整轮只取一次(TTL 在生成器内部,见 aiContext/index.js),
339
- // 不放进循环 —— 否则每执行一次工具调用都要重拼一遍,而这轮对话里它不会变。
340
- // 取不到就空着跳过:快照是**锦上添花**,不能因为它挂了就让用户这条消息发不出去。
341
- let workspaceBlock = '';
342
- if (typeof getContextBlock === 'function') {
343
- try {
344
- workspaceBlock = (await getContextBlock({ locale })) || '';
345
- } catch (err) {
346
- logger.warn(`[agentChat] 取工作区状态快照失败,本轮跳过注入: ${err?.message || err}`);
347
- workspaceBlock = '';
348
- }
349
- }
350
-
351
- const maxIterations = await resolveMaxToolIterations();
352
-
353
- for (let iter = 0; iter < maxIterations; iter++) {
354
- // 每轮都从完整会话记录重新构建一次请求副本:条数/字符双预算 → 被丢掉的旧消息
355
- // 摘录成一条梗概 → 旧图片降级 → provider 兼容消毒。
356
- // 只作用于副本,session.messages 保持完整(与 CLI 的磁盘口径一致)。
357
- // 预算来自 requestBudget(全局配置 aiMaxRequestChars 的解析结果);缺省时走
358
- // prepareRequestMessages 的默认值(80,000 字符 / 40 条,与历史行为一致)。
359
- const messages = prepareRequestMessages(session.messages, { locale, ...(requestBudget || {}) });
360
- // 请求级上下文:工作区状态快照 + 常用目录状态 + 当前打开的文档 + 本轮附件路径
361
- // (只改副本,不落 session.messages,下一轮不重复累积)
362
- injectRequestContext(messages, { cwd, openFilePath, attachments, locale, workspaceBlock, dirStatusBlock });
363
-
364
- let result;
365
- try {
366
- result = await streamChatOnce({
367
- model,
368
- messages,
369
- signal,
370
- // 整轮对话(含后续工具调用产生的每一轮请求)复用同一个会话 ID
371
- sessionId: session.sessionId,
372
- onToken: ({ thinking, content }) => {
373
- if (thinking) send({ type: 'thinking', delta: thinking });
374
- if (content) send({ type: 'content', delta: content });
375
- },
376
- });
377
- } catch (err) {
378
- // 请求失败时撤掉本轮塞入的 user 消息(如果末尾仍是 user)
379
- const last = session.messages[session.messages.length - 1];
380
- if (last?.role === 'user') session.messages.pop();
381
- send({ type: 'error', error: `LLM 请求失败: ${err.message}` });
382
- return { aborted: false };
383
- }
384
-
385
- const { content, toolCalls } = result;
386
-
387
- // 推理内容必须原样带回历史。DeepSeek 系 thinking 模式下带 tool_calls 的 assistant
388
- // 消息一旦缺 reasoning_content,下一轮回传就被上游 400 拒掉:
389
- // "The `reasoning_content` in the thinking mode must be passed back to the API"。
390
- // 口径与 CLI 侧 src/cli/ai/turn.js 的 assistant.reasoning_content 保持一致。
391
- const withReasoning = msg => (result.reasoning ? { ...msg, reasoning_content: result.reasoning } : msg);
392
-
393
- if (result.aborted) {
394
- // 用户点了"停止":把已经流出来的部分正文补进历史,否则磁盘上这一轮只剩一条
395
- // user 消息,重新打开会话时刚才生成的内容会整段丢失。
396
- // 半截的 tool_calls 已被 transport 在中止时丢弃,这里只补正文,不会有悬空引用。
397
- // 落盘由路由层统一负责(中止的轮次同样要写,见 agentRoutes.js)。
398
- if (content) {
399
- session.messages.push(withReasoning({ role: 'assistant', content }));
400
- }
401
- send({ type: 'error', error: '已取消' });
402
- return { aborted: true };
403
- }
404
-
405
- // 无工具调用:本轮结束
406
- if (toolCalls.length === 0) {
407
- session.messages.push(withReasoning({ role: 'assistant', content: content || null }));
408
- send({ type: 'done', content: content || '' });
409
- return { aborted: false };
410
- }
411
-
412
- // 有工具调用:assistant(带 tool_calls)入历史
413
- session.messages.push(withReasoning({
414
- role: 'assistant',
415
- content: content || null,
416
- tool_calls: toolCalls
417
- }));
418
-
419
- // 逐个执行工具
420
- for (const tc of toolCalls) {
421
- const name = tc.function?.name || '';
422
- const rawArgs = tc.function?.arguments || '';
423
- const toolCallId = tc.id || name;
424
-
425
- let args;
426
- try {
427
- args = rawArgs ? JSON.parse(rawArgs) : {};
428
- } catch {
429
- const errResult = `错误: 工具参数不是合法 JSON: ${rawArgs.slice(0, 200)}`;
430
- send({ type: 'tool_call_start', toolCallId, name, argsPreview: rawArgs.slice(0, 200), arguments: rawArgs });
431
- send({ type: 'tool_result', toolCallId, name, result: errResult });
432
- session.messages.push({ role: 'tool', tool_call_id: toolCallId, name, content: errResult });
433
- continue;
434
- }
435
-
436
- // 工具参数预览(前端收起态那一行副标题)。这里截断是**故意的** ——
437
- // 摘要只负责"一眼看出它在干嘛"。
438
- const argsPreview = summarizeArgs(name, args);
439
- send({ type: 'tool_call_start', toolCallId, name, argsPreview, arguments: rawArgs });
440
-
441
- const toolCtx = {
442
- ...ctx,
443
- signal,
444
- // run_command 执行期间的增量输出 → 前端实时显示。
445
- // 只用于展示,不进会话历史 —— 历史里存的仍是带 exit code 的最终结果。
446
- onOutput: (chunk) => {
447
- const text = String(chunk || '');
448
- if (text) send({ type: 'tool_output', toolCallId, name, chunk: text });
449
- },
450
- };
451
- if (name === 'ask_user' && typeof ctx.askUser === 'function') {
452
- toolCtx.askUser = askArgs => ctx.askUser(askArgs, {
453
- sessionId: session.sessionId,
454
- interactionId: toolCallId,
455
- send,
456
- signal,
457
- });
458
- }
459
- const output = await executeTool(name, args, toolCtx);
460
- // read_image 会返回 { text, images }。前端只吃文本(result 是字符串,
461
- // 直接把对象丢过去会渲染成 "[object Object]"),图片进会话历史里的
462
- // 多模态 tool 消息 —— 模型看得见图,用户看到的是"已读取图片 xxx.png"。
463
- const { text: outText, images: outImages } = splitToolOutput(output);
464
- send({ type: 'tool_result', toolCallId, name, result: outText });
465
- session.messages.push({ role: 'tool', tool_call_id: toolCallId, name, content: toolMessageContent(outText, outImages) });
466
- }
467
- // 工具结果全部入历史后继续循环,让模型基于结果决定下一步
468
- }
469
-
470
- // 达到最大迭代次数
471
- send({ type: 'done', content: `已达单轮最大工具调用次数(${maxIterations}),本轮结束。如需继续请再发一条消息。` });
472
- return { aborted: false };
473
- }
474
-
475
- // 工具参数简短摘要(给前端展示)
476
- function summarizeArgs(name, args) {
477
- try {
478
- switch (name) {
479
- case 'run_command':
480
- return String(args.command || '').slice(0, 200);
481
- case 'read_file':
482
- case 'read_image':
483
- case 'write_file':
484
- case 'edit_file':
485
- return String(args.path || '');
486
- case 'list_files':
487
- return String(args.path || '.');
488
- case 'search_text':
489
- return String(args.pattern || '');
490
- case 'update_plan': {
491
- const steps = normalizePlanSteps(args.steps ?? args.todos ?? args.plan);
492
- const why = String(args.explanation || '').replace(/\s+/g, ' ').trim();
493
- return [summarizePlan(steps), why].filter(Boolean).join(' — ').slice(0, 200);
494
- }
495
- default:
496
- return JSON.stringify(args).slice(0, 200);
497
- }
498
- } catch {
499
- return '';
500
- }
501
- }
502
-
503
- // ── 请求级上下文注入(工作区状态 / 常用目录状态 / 文件空间对话 / 本轮附件) ──
504
- // 把"工作区各板块的状态摘要""常用目录那批目录的 Git 状态""用户当前打开的文件"与
505
- // "本轮附件的落盘路径"追加到**请求副本**的 system 消息末尾:只影响这一次请求,
506
- // session.messages 与磁盘历史保持原样,下一轮也不会重复累积。
507
- //
508
- // ⚠️ 工作区快照**必须走这条副本路径,不能塞进 session.messages 里那条 system 消息**。
509
- // 那条只在首轮 push 一次(见上面 `session.messages.length === 0` 的判断)并会落盘,
510
- // 快照进去就等于永久停在"会话创建那天"——git 分支、任务进度全会是过期的,
511
- // 而且这种错不会报错、只会让模型理直气壮地给出错答案。有单测钉住这一点。
512
- // 常用目录状态同理,而且更严重:它说的是"这一刻"的领先/落后,几小时后必然不同。
513
- //
514
- // 附件为什么只给路径、不给内容:见 utils/agentAttachments.js 的头注释 —— 非图片附件
515
- // 由服务端落盘,模型自己用 read / grep 按需取,比把几百 KB 文本内联进消息省得多。
516
- // 快照块同理,只给摘要与目录路径,板块正文由模型按需读。
517
- export function injectRequestContext(messages, { cwd, openFilePath, attachments = [], locale, workspaceBlock = '', dirStatusBlock = '' }) {
518
- if (!Array.isArray(messages)) return;
519
- const en = String(locale || '').startsWith('en');
520
-
521
- const parts = [];
522
-
523
- // ⓪ 工作区状态快照(七个板块的摘要 + 落盘目录)。由 aiContext 生成,只在这个副本里。
524
- if (typeof workspaceBlock === 'string' && workspaceBlock.trim()) {
525
- parts.push(workspaceBlock.trim());
526
- }
527
-
528
- // ① 常用目录那批目录的 Git 状态(切换工作目录弹窗里的追问才有)。
529
- // 整块(含"屏幕上那段自动解读")由 agentRoutes 调 buildDirStatusBlock 拼好传进来 ——
530
- // 两条链路(内置引擎的副本 / 外部引擎的前缀)用的是同一个字符串,不在两处各拼一遍。
531
- if (typeof dirStatusBlock === 'string' && dirStatusBlock.trim()) {
532
- parts.push(dirStatusBlock.trim());
533
- }
534
-
535
- // ② 当前打开的文档(文件空间对话才有;项目外或等于根目录直接忽略)
536
- if (openFilePath) {
537
- const root = cwd || process.cwd();
538
- let rel = '';
539
- try {
540
- rel = path.relative(root, path.resolve(root, openFilePath));
541
- } catch {
542
- rel = '';
543
- }
544
- if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) {
545
- const file = rel.split(path.sep).join('/');
546
- parts.push(en
547
- ? `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.`
548
- : `用户此刻在文件空间打开的文件是 \`${file}\`(相对项目根目录)。用户说"这个文件/当前文件"时默认指它;请先用工具读取内容,不要臆测。`);
549
- }
550
- }
551
-
552
- // ③ 本轮附件:只给绝对路径,内容让模型自己去读
553
- const files = (Array.isArray(attachments) ? attachments : [])
554
- .filter(a => a && typeof a.path === 'string' && a.path)
555
- .slice(0, 20);
556
- if (files.length > 0) {
557
- const lines = files.map(a => `- \`${a.path}\`${a.name && a.name !== path.basename(a.path) ? ` (${a.name})` : ''}`);
558
- parts.push(en
559
- ? `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.`
560
- : `用户本轮附带了 ${files.length} 个文件,已保存到以下绝对路径:\n${lines.join('\n')}\n需要时用工具读取(内容没有内联在这里),不要臆测。`);
561
- }
562
-
563
- if (parts.length === 0) return;
564
-
565
- const note = `\n\n# ${en ? 'Current context' : '当前上下文'}\n${parts.join('\n\n')}`;
566
- const sys = messages.find(m => m && m.role === 'system' && typeof m.content === 'string');
567
- if (sys) sys.content += note;
568
- else messages.unshift({ role: 'system', content: note.trim() });
569
- }
570
-
571
- 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: 'done', content } — 本轮最终完成
37
+ // - { type: 'error', error }
38
+
39
+ import path from 'path';
40
+ import os from 'os';
41
+ import { logger } from './shared.js';
42
+
43
+ // 从 CLI 侧导入工具定义、执行器与 LLM 传输层(同一 monorepo,路径可达)
44
+ import { TOOL_DEFINITIONS, executeTool, normalizePlanSteps, summarizePlan, splitToolOutput, toolMessageContent } from '../../../../cli/ai/tools.js';
45
+ import { prepareRequestMessages, measureContextUsage, resolveRequestBudget } from '../../../../cli/ai/context.js';
46
+ import { streamChatOnce } from '../../../../cli/ai/transport.js';
47
+ import { checkDangerousCommand } from '../../../../cli/ai/safety.js';
48
+ import { guardCommand } from '../../../../cli/ai/platformGuard.js';
49
+ import configManager from '../../../../config.js';
50
+
51
+ // 单轮工具调用循环数的兜底值(防失控);实际值取全局配置 aiMaxToolIterations,
52
+ // 与 CLI 侧 src/cli/ai/agent.js 共用同一个配置项。
53
+ const DEFAULT_MAX_TOOL_ITERATIONS = 1000;
54
+
55
+ // 读取全局配置里的单轮工具调用上限。
56
+ // 读配置失败不该把整轮对话打挂 —— 退回默认值继续跑,比用户消息直接发不出去好。
57
+ async function resolveMaxToolIterations() {
58
+ try {
59
+ const cfg = await configManager.loadConfig();
60
+ const n = Number(cfg?.aiMaxToolIterations);
61
+ if (Number.isFinite(n) && n > 0) return Math.floor(n);
62
+ } catch (err) {
63
+ logger.warn(`[agentChat] 读取 aiMaxToolIterations 失败,回退默认值: ${err?.message || err}`);
64
+ }
65
+ return DEFAULT_MAX_TOOL_ITERATIONS;
66
+ }
67
+
68
+ // ── 系统提示词构建 ──────────────────────────────────────────
69
+ // 与 CLI agent.js 的 buildSystemPrompt 保持一致,但标注来源为 Web 端
70
+ function buildWebSystemPrompt({ cwd, locale }) {
71
+ const zh = !String(locale || '').startsWith('en');
72
+ const now = new Date().toLocaleString();
73
+ const isWin = process.platform === 'win32';
74
+ const builtinToolCount = TOOL_DEFINITIONS.length;
75
+ const shellDesc = isWin ? 'cmd.exe / PowerShell' : '/bin/sh';
76
+
77
+ if (zh) {
78
+ return `你是 "g ai" —— zen-gitsync 内置的编码智能体,通过工具在用户真实电脑上完成编码任务。当前用户通过 Web 界面与你对话。
79
+
80
+ # 运行环境
81
+ - 操作系统: ${process.platform}
82
+ - Shell: ${shellDesc}
83
+ - 当前工作目录: ${cwd}
84
+ - 当前时间: ${now}
85
+
86
+ # 平台兼容性(重要!)
87
+ - 必须使用与当前 Shell 兼容的命令,禁止盲套 Unix 写法
88
+ ${isWin ? `- 当前是 Windows,以下 Unix 命令**不存在**,用了必定报"不是内部或外部命令":
89
+ tail / head / cat / grep / ls / sed / awk / wc / cut / uniq / xargs / which / touch
90
+ 平台守卫会在执行前拦截这些命令,但请主动避免,不要浪费一轮调用
91
+ - 跨平台替代方案:
92
+ · 列目录 → list_files 工具 或 cmd 的 dir
93
+ · 搜内容 → search_text 工具 或 cmd 的 findstr
94
+ · 看文件 → read_file 工具 或 cmd 的 type
95
+ · 看图片 → read_image 工具(截图/设计稿/示意图)。read_file 读图片只会得到乱码,别浪费那一轮
96
+ · 看输出末尾 → PowerShell "命令 | Select-Object -Last N"
97
+ · 文本处理 → node -e "..." 或 PowerShell
98
+ · 查命令路径 → cmd 的 where(不是 which)
99
+ - 必须跑 shell 时优先跨平台写法(如 node -e "..."),别用 Unix 专属命令` : `- 当前是 POSIX 环境,Unix 命令可用`}
100
+
101
+ # 远程仓库(GitHub / Gitee)
102
+ - 问"我有哪些项目""哪些项目需要 pull / 推送" → 用 list_projects 工具。它返回的就是 g ui
103
+ 「最近项目」面板那份清单(最近目录 + 建过任务的目录,带分支/领先/落后/未提交数与任务进度),
104
+ 口径与界面完全一致。**不要**用 list_files 自己扫盘数仓库 —— 那会把 node_modules 里的嵌套
105
+ 仓库也算进来,数出来的个数跟界面对不上。领先/落后是本地快照,要真实值就带 refresh=true
106
+ 先联网 fetch 一轮,再回答"要不要 pull"
107
+ - 问"这个项目关联哪个远端" → run_command 跑 \`git remote -v\`:本地信息,不联网、不依赖任何 CLI,任何环境都能答
108
+ - 问"我账号下有哪些仓库"或某仓库的 PR / Issue → 用官方 CLI。凭据由 CLI 自己保管:
109
+ · GitHub → \`gh\`。列仓库 \`gh repo list --limit 50 --json name,visibility,updatedAt,primaryLanguage\`;
110
+ 看详情 \`gh repo view <owner/repo>\`;PR \`gh pr list\`;登录态 \`gh auth status\`
111
+ · Gitee → \`gitee\`。列仓库 \`gitee repo list\`;登录态 \`gitee auth status\`
112
+ (未登录时退出码**仍是 0**,必须看 \`--json\` 里的 status 字段,只看退出码会误判成已登录)
113
+ · 两者默认只覆盖**当前登录账号**;组织仓库、私有仓库可能需要更大的 token scope,拉不到就如实说明,
114
+ 不要用其他途径绕过
115
+ · 绝不向用户索要 token —— 也不要让用户把 token 粘进对话
116
+ - CLI 可能没装、或没在服务端进程的 PATH 里(装完没重启服务就是这个表现,Web 端尤其常见):
117
+ 报"不是内部或外部命令" / command not found 时,**不要换写法反复重试**,更不要凭印象编仓库名或目录名。
118
+ 直接告诉用户未检测到该 CLI,可在「远程仓库」页一键安装/登录,或需要时重启服务让新装的 CLI 生效;
119
+ 若只是想回答本项目的问题,退回 \`git remote -v\`
120
+ - 克隆仓库 / 添加远端时**优先用 SSH**(用户偏好,本机已配好密钥,实测走 https 会弹凭据窗口把任务打断):
121
+ · GitHub 用 \`git@github.com:owner/repo.git\`,Gitee 用 \`git@gitee.com:owner/repo.git\`
122
+ · 用户给的是 \`https://...\` 或 \`gh repo clone owner/repo\` 时,先换算成 SSH 地址再执行 ——
123
+ 走 https 会弹 Git Credential Manager 让用户输账号密码,任务就停在半路等输入
124
+ · 已有仓库换协议:\`git remote set-url origin <ssh 地址>\`(先 \`git remote -v\` 看当前是什么)
125
+ · 只有 SSH 真的不可用(报 \`Permission denied (publickey)\` / \`Host key verification failed\`)才退回 https,
126
+ 并用一句话说明这次走的是 https、配好密钥后可改回;两边都失败就停下来问用户,不要反复重试
127
+
128
+ # 权限(用户已明确授权,无需反复征求同意)
129
+ - 工作目录内:读写文件、执行命令等所有操作直接执行
130
+ - 其他目录:同样可以读取和修改
131
+ - 唯一红线:不得破坏系统(格式化磁盘、删除根目录/系统目录、关机重启、写块设备等)。
132
+ 安全守卫会拦截这类命令;被拦截时换安全方案,或告知用户需要他手动执行。
133
+
134
+ # 工作方式
135
+ - 先动手、后提问:能用工具查清的不要问用户(list_files / read_file / search_text / run_command)
136
+ - 修改代码后主动验证:跑测试、构建或至少语法检查(用 run_command)
137
+ - 编辑文件优先 edit_file 精确替换;先 read_file 看原文,old_string 必须与文件内容完全一致(含缩进换行)
138
+ - 大文件用 offset/limit 分段读取,不要一次读爆上下文
139
+ - git 操作用 run_command 执行
140
+ - run_command 默认就在工作目录执行,不要再加 cd 前缀;默认超时 120 秒,长任务加大 timeout_seconds(最大 600)
141
+ - 命令在 ${shellDesc} 下执行,注意语法兼容
142
+
143
+ # 任务计划(多步任务必用)
144
+ - 任务需要多步时(改代码、排查、调研、多文件改动),**动手之前**先调一次 update_plan,把要做的事
145
+ 拆成 3-8 个可核对的步骤,让用户在你改任何东西之前就知道范围和验收标准
146
+ - 单步小事(读个文件、答一个问题)不要列计划,那是噪音
147
+ - 每次都传**完整**的当前计划(不是增量),顺序即执行顺序;同一时刻最多一个 in_progress
148
+ - 完成一步就更新一次状态,别攒到最后一次性刷;计划与实际不符时直接改计划,不要硬着头皮往下走
149
+ - 全部做完后把 steps 传空表示收尾
150
+ - update_plan 只是给人看的进度板,不要为了"更新计划"去改文件或跑命令
151
+
152
+ # 与用户交互
153
+ - 需要向用户确认、提问或汇报重要决策时,直接用普通文本输出
154
+ - 需要暂停当前任务并等待用户决定或补充信息时,调用 ask_user,不要猜测或只在普通文本里提问
155
+ - 一次要让用户从多个选项里选好几项时(如"要我改哪几个文件"),传 multiple: true,用户会勾选后统一提交
156
+ - 不要调用不存在的工具,可用工具只有上面列出的 ${builtinToolCount} 个
157
+ - 图片有两条来路:
158
+ · 用户随消息附带(附件框/粘贴) → 图片以 image_url 部件出现在 user 消息里,直接就能看到
159
+ · 用户只给了**路径**(或你自己在翻文件时遇到图)→ 用 read_image 工具读它,别用 read_file
160
+ (read_file 按 UTF-8 解码,图片只会是乱码),也别根据文件名猜内容
161
+ 如果当前模型不支持视觉(带图请求报错),提醒用户换用支持视觉的模型
162
+ - 发现高风险或状态不一致的情况时:先用文本说明发现和影响,停下来等用户指示,不要擅自继续破坏性操作
163
+
164
+ # 输出
165
+ - 你的文本输出直接显示在用户 Web 界面,用简体中文交流
166
+ - 界面按 Markdown 渲染:标题 / 列表 / 表格 / 引用 / 代码块都会排版后显示,别用 ASCII 画表格
167
+ - 讲**流程 / 结构 / 时序 / 状态**这类"说出来不如画出来"的东西时,写 \`\`\`mermaid 代码块
168
+ (flowchart / sequenceDiagram / stateDiagram-v2 / erDiagram …),界面会把它渲染成真正的图。
169
+ 不要用纯文本箭头、缩进或 ASCII 框线凑一张图 —— 那种"图"在界面上只是一段等宽文本
170
+ - 完成任务后用一两句话汇报结果,不要复述过程细节`;
171
+ }
172
+
173
+ 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.
174
+
175
+ # Environment
176
+ - OS: ${process.platform}
177
+ - Shell: ${shellDesc}
178
+ - Working directory: ${cwd}
179
+ - Current time: ${now}
180
+
181
+ # Platform compatibility (important!)
182
+ - You MUST use commands compatible with the current shell.
183
+ ${isWin ? `- This is Windows. The following Unix commands do NOT exist here:
184
+ tail / head / cat / grep / ls / sed / awk / wc / cut / uniq / xargs / which / touch
185
+ A platform guard will block these before execution, but avoid them proactively.
186
+ - Cross-platform alternatives:
187
+ · List dirs → list_files tool, or cmd's dir
188
+ · Search content → search_text tool, or cmd's findstr
189
+ · Read files → read_file tool, or cmd's type
190
+ · Look at an image → read_image tool (screenshots, mockups, diagrams). read_file only decodes text and returns garbage for images
191
+ · Tail output → PowerShell "command | Select-Object -Last N"
192
+ · Text processing → node -e "..." or PowerShell
193
+ · Find executable → cmd's where (not which)` : `- POSIX environment: Unix commands are available`}
194
+
195
+ # Remote repositories (GitHub / Gitee)
196
+ - "Which projects do I have?" / "Which ones need a pull or push?" → use the list_projects tool.
197
+ It returns exactly the list behind the GUI's "Recent projects" panel (recent directories plus any
198
+ directory a task was created in, with branch / ahead / behind / uncommitted counts and task
199
+ progress) — the same numbers the UI shows. Do NOT scan the disk with list_files to count repos:
200
+ that also picks up nested repos inside node_modules and the totals will not match the UI.
201
+ Ahead/behind comes from local refs, so pass refresh=true for a real answer about pulling
202
+ - "Which remote does this project point at?" → run_command \`git remote -v\`: local info, no network, no CLI needed
203
+ - "Which repos do I have?" or a repo's PRs / issues → use the official CLI. The CLI owns the credentials:
204
+ · GitHub → \`gh\`. List repos \`gh repo list --limit 50 --json name,visibility,updatedAt,primaryLanguage\`;
205
+ details \`gh repo view <owner/repo>\`; PRs \`gh pr list\`; auth state \`gh auth status\`
206
+ · Gitee → \`gitee\`. List repos \`gitee repo list\`; auth state \`gitee auth status\`
207
+ (the exit code is still 0 when logged out — read the status field from \`--json\`, or you will
208
+ wrongly report "logged in")
209
+ · Both default to the **currently authenticated account only**; org and private repos may need
210
+ broader token scopes. If you cannot see them, say so plainly — do not route around it
211
+ · Never ask the user for a token, and never have them paste one into the conversation
212
+ - The CLI may not be installed, or missing from the server process's PATH (that is what "installed but
213
+ the server cannot find it" looks like — common on the Web side):
214
+ on "not recognized" / command not found, do NOT retry with a different spelling and do NOT invent
215
+ repo or directory names. Tell the user the CLI was not found and point them to the "Remote
216
+ repositories" page (one-click install / login), or restart the server so a freshly installed CLI
217
+ becomes visible; if you only need to answer for this project, fall back to \`git remote -v\`
218
+ - Cloning a repo or adding a remote: **prefer SSH** (the user's preference — keys are already set up,
219
+ and https has been observed to pop a credential window that stalls the task):
220
+ · GitHub → \`git@github.com:owner/repo.git\`; Gitee → \`git@gitee.com:owner/repo.git\`
221
+ · If the user hands you an \`https://...\` URL (or \`gh repo clone owner/repo\`), convert it to the SSH
222
+ form first — https pops up Git Credential Manager asking for a username/password and blocks the task
223
+ · Switching an existing repo: \`git remote set-url origin <ssh url>\` (check \`git remote -v\` first)
224
+ · Fall back to https only when SSH genuinely fails (\`Permission denied (publickey)\` /
225
+ \`Host key verification failed\`), and say in one line that this one used https and can go back to SSH
226
+ once the key is set up; if both fail, stop and ask the user — do not keep retrying
227
+
228
+ # Permissions (explicitly granted by the user)
229
+ - Inside the working directory: read/write files and run commands directly
230
+ - Other directories: may also be read and modified
231
+ - Single red line: never destroy the system. A safety guard blocks such commands.
232
+
233
+ # How you work
234
+ - Act first, ask later: use tools to investigate before asking the user
235
+ - 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.
236
+ - After modifying code, verify: run tests, build, or at least syntax check
237
+ - Prefer edit_file for precise replacements; read_file first to confirm original text
238
+ - Use offset/limit for large files
239
+ - git operations via run_command
240
+ - run_command defaults to the working directory; default timeout 120s, max 600s
241
+ - Images reach you two ways:
242
+ · The user attached one to a message → it arrives as an image_url part in the user message; you can see it directly
243
+ · 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
244
+ (read_file decodes UTF-8 and returns garbage for images), and never guess an image's content from its filename
245
+ If the current model rejects images (no vision support), tell the user to switch to a vision-capable model
246
+
247
+ # Task plan (required for multi-step work)
248
+ - When a task takes several steps (code changes, debugging, research, multi-file edits), call update_plan
249
+ **before touching anything**: break it into 3-8 verifiable steps so the user knows the scope and the
250
+ acceptance criteria before you modify a single file
251
+ - Do not plan single trivial actions (read one file, answer one question) — that is noise
252
+ - Always send the **complete** current plan (not a delta), in execution order; at most one in_progress
253
+ - Update the status right after finishing a step instead of batching everything at the end; when the
254
+ plan no longer matches reality, rewrite the plan instead of pushing ahead
255
+ - Send an empty steps list once everything is done
256
+ - update_plan is a progress board for humans: never edit files or run commands just to "update the plan"
257
+
258
+ # Output
259
+ - Your text output is displayed in the user's Web UI
260
+ - The UI renders Markdown: headings / lists / tables / quotes / code blocks are shown formatted — do not fake tables with ASCII art
261
+ - For **flows / structure / sequences / state machines**, prefer a \`\`\`mermaid block
262
+ (flowchart / sequenceDiagram / stateDiagram-v2 / erDiagram …): the UI renders it as a real diagram.
263
+ Never hand-draw one out of plain-text arrows, indentation or box characters — on screen that is just monospaced text
264
+ - After completing a task, briefly summarize the result`;
265
+ }
266
+
267
+ // ── LLM 流式调用 ─────────────────────────────────────────
268
+ // 统一实现在 src/cli/ai/transport.js 的 streamChatOnce —— CLI 的 `g ai` 与这条
269
+ // Web 链路共用同一份。比这里原先那份多出来的能力:请求 usage(带 stream_options
270
+ // 降级重试)、校验 tool call index 边界、吃 evt.error,以及最要紧的一条 ——
271
+ // 流被截断但 tool_calls 已经部分到达时抛"中断"、拒绝执行半截的工具调用。
272
+ // 这里曾经是第二份实现,弱就弱在最后那条:半截参数有可能被拿去执行。
273
+
274
+ // ── 消息准备(消毒 / 历史有界化 / 旧图片降级) ──────────────────
275
+ // 统一实现在 src/cli/ai/context.js 的 prepareRequestMessages —— CLI 的 `g ai`
276
+ // 与这条 Web 链路共用同一份,两侧不再各写一遍。
277
+ //
278
+ // 这里曾经是第二份实现:历史只按条数硬切(MAX_HISTORY_MESSAGES=40)+ 纯 splice 丢弃,
279
+ // 而 CLI 侧早已改成「条数/字符双预算 + 丢弃项摘录成一条梗概」,两侧行为因此分叉 ——
280
+ // Web 面板聊久了模型会直接失忆,CLI 还记得要点。现在收敛到一处,口径见 context.js。
281
+
282
+ // ── 核心入口:运行一轮 agent 对话 ────────────────────────
283
+ //
284
+ // 参数:
285
+ // { session, model, userMessage, cwd, locale, signal, send, onChild, askUser, listProjects }
286
+ // - session: 从 agentSessionStore 读取的会话对象
287
+ // - model: { baseURL, model, apiKey }
288
+ // - userMessage: 用户输入文本
289
+ // - images: base64 dataURL 数组(可选,多模态图片,随最新一条 user 消息发给模型)
290
+ // - openFilePath: 文件空间里当前打开的文档(可选,注入请求副本,不落库)
291
+ // - dirStatusBlock: 「切换工作目录」弹窗里那批目录的 Git 状态(可选,由 agentRoutes
292
+ // 读配置 + 白名单过滤后用 buildDirStatusBlock 拼好传进来,同样只进请求副本)
293
+ // - attachments: 非图片附件(可选) = [{ name, path }],path 是**服务端落盘后的绝对路径**,
294
+ // 只把路径写进请求副本的 system 提示,内容由模型自己用工具读(见 utils/agentAttachments.js)
295
+ // - cwd: 工作目录
296
+ // - locale: 'zh' | 'en'
297
+ // - signal: AbortSignal (客户端断开时触发)
298
+ // - send: (obj) => void SSE 发送函数
299
+ // - onChild: (child) => void 子进程回调(用于取消)
300
+ // - askUser: (args, meta) => Promise<string> 等待用户回答
301
+ // - listProjects: (args) => Promise<string> list_projects 工具的数据源
302
+ // (由 agentRoutes 注入:最近目录/tasks.json/看板统计只有 GUI 侧拿得到)
303
+ // - dispatchTask: (payload) => Promise<string> dispatch_task 工具的实现 —— 派发一条
304
+ // 工作台任务。同样由 agentRoutes 注入,但**只在主 Agent 控制台发起的对话里**注入
305
+ // (别的入口不注入 = 那个入口没有派发能力,工具会回一句可照做的 unavailable)
306
+ // - getContextBlock: ({locale}) => Promise<string> 工作区状态快照的摘要块
307
+ // (由 agentRoutes 注入,实现在 routes/aiContext/:七个板块的摘要 + 落盘文件路径)
308
+ // - requestBudget: { maxChars, maxMessages, maxUserChars } 每轮请求的上下文预算
309
+ // (解析自全局配置 aiMaxRequestChars,见 cli/ai/context.js 的 resolveRequestBudget)。
310
+ // 不传时 prepareRequestMessages 走默认值,行为与改造前一致。
311
+ //
312
+ // 返回: { aborted: boolean }
313
+ export async function runAgentTurn({ session, model, userMessage, images = [], cwd, locale, openFilePath, attachments = [], dirStatusBlock = '', signal, send, onChild, askUser, listProjects, dispatchTask, getContextBlock, requestBudget = null }) {
314
+ const ctx = { cwd, locale, onChild, askUser, listProjects, dispatchTask };
315
+
316
+ // 确保 session.messages 存在
317
+ if (!Array.isArray(session.messages)) session.messages = [];
318
+
319
+ // 首轮:注入 system prompt
320
+ if (session.messages.length === 0) {
321
+ session.messages.push({
322
+ role: 'system',
323
+ content: buildWebSystemPrompt({ cwd, locale })
324
+ });
325
+ }
326
+
327
+ // 追加 user 消息:有图片时组装 OpenAI 多模态 content 数组(与 CLI agent.js 一致),否则保持纯字符串
328
+ const imageParts = (Array.isArray(images) ? images : [])
329
+ .filter(u => typeof u === 'string' && u.startsWith('data:image/'))
330
+ .map(u => ({ type: 'image_url', image_url: { url: u } }));
331
+ session.messages.push({
332
+ role: 'user',
333
+ content: imageParts.length > 0
334
+ ? [{ type: 'text', text: userMessage || ' ' }, ...imageParts]
335
+ : userMessage
336
+ });
337
+
338
+ // 工作区状态快照:整轮只取一次(TTL 在生成器内部,见 aiContext/index.js),
339
+ // 不放进循环 —— 否则每执行一次工具调用都要重拼一遍,而这轮对话里它不会变。
340
+ // 取不到就空着跳过:快照是**锦上添花**,不能因为它挂了就让用户这条消息发不出去。
341
+ let workspaceBlock = '';
342
+ if (typeof getContextBlock === 'function') {
343
+ try {
344
+ workspaceBlock = (await getContextBlock({ locale })) || '';
345
+ } catch (err) {
346
+ logger.warn(`[agentChat] 取工作区状态快照失败,本轮跳过注入: ${err?.message || err}`);
347
+ workspaceBlock = '';
348
+ }
349
+ }
350
+
351
+ const maxIterations = await resolveMaxToolIterations();
352
+
353
+ // 预算缺省时在**这里**解析一次,与 prepareRequestMessages 的派生口径一致
354
+ // (不能让它自己兜默认:那样 measureContextUsage 拿到的分母会和实际裁剪用的分母
355
+ // 可能不同,UI 上的进度条就会说谎)。
356
+ const budget = requestBudget || resolveRequestBudget();
357
+
358
+ for (let iter = 0; iter < maxIterations; iter++) {
359
+ // 每轮都从完整会话记录重新构建一次请求副本:条数/token 双预算 → 被丢掉的旧消息
360
+ // 摘录成一条梗概 → 旧图片降级 → provider 兼容消毒。
361
+ // 只作用于副本,session.messages 保持完整(与 CLI 的磁盘口径一致)。
362
+ // 预算来自 requestBudget(全局配置 aiMaxRequestTokens 的解析结果)。
363
+ const messages = prepareRequestMessages(session.messages, { locale, ...budget });
364
+ // 请求级上下文:工作区状态快照 + 常用目录状态 + 当前打开的文档 + 本轮附件路径
365
+ // (只改副本,不落 session.messages,下一轮不重复累积)
366
+ injectRequestContext(messages, { cwd, openFilePath, attachments, locale, workspaceBlock, dirStatusBlock });
367
+
368
+ // 上下文占用:在**请求发出去之前**量一次,发给 UI 画圆环。
369
+ // 为什么必须在发之前:provider 的真实 usage(input_tokens)要等响应回来才有,
370
+ // 而用户想知道的是"这次会带多少过去"—— 那只能在发之前量。
371
+ // 为什么每轮都发:工具循环里上下文是**持续增长**的,只发一次的话
372
+ // 用户看到的永远是第一轮那个数字,直到对话结束才发现早就满了。
373
+ const usage = measureContextUsage(messages, { ...budget, transcript: session.messages });
374
+ send({ type: 'context', usage });
375
+
376
+ let result;
377
+ try {
378
+ result = await streamChatOnce({
379
+ model,
380
+ messages,
381
+ signal,
382
+ // 整轮对话(含后续工具调用产生的每一轮请求)复用同一个会话 ID
383
+ sessionId: session.sessionId,
384
+ onToken: ({ thinking, content }) => {
385
+ if (thinking) send({ type: 'thinking', delta: thinking });
386
+ if (content) send({ type: 'content', delta: content });
387
+ },
388
+ });
389
+ } catch (err) {
390
+ // 请求失败时撤掉本轮塞入的 user 消息(如果末尾仍是 user)
391
+ const last = session.messages[session.messages.length - 1];
392
+ if (last?.role === 'user') session.messages.pop();
393
+ send({ type: 'error', error: `LLM 请求失败: ${err.message}` });
394
+ return { aborted: false };
395
+ }
396
+
397
+ const { content, toolCalls } = result;
398
+
399
+ // provider 报回来的**真实** token 用量,比 measureContextUsage 的估算准。
400
+ // 回填进同一条 context 事件的两个字段(estimated / actual),UI 优先显示真实值 ——
401
+ // 估算只用于"请求发出去之前"那段时间(以及 provider 不返回 usage 的场合)。
402
+ //
403
+ // 为什么以前没记:CLI 侧有 /stats(src/cli/ai/telemetry.js),Web 侧漏了,
404
+ // 于是 28 个会话文件里 lastTurnStats / sessionStats 全是 null。
405
+ // 这里补上的是**本轮的**真实输入 token;会话累计要等落盘,由路由层负责。
406
+ if (result.usage) {
407
+ send({ type: 'context', usage: { ...usage, actualInputTokens: result.usage.inputTokens ?? null } });
408
+ }
409
+
410
+ // 推理内容必须原样带回历史。DeepSeek 系 thinking 模式下带 tool_calls 的 assistant
411
+ // 消息一旦缺 reasoning_content,下一轮回传就被上游 400 拒掉:
412
+ // "The `reasoning_content` in the thinking mode must be passed back to the API"。
413
+ // 口径与 CLI 侧 src/cli/ai/turn.js 的 assistant.reasoning_content 保持一致。
414
+ const withReasoning = msg => (result.reasoning ? { ...msg, reasoning_content: result.reasoning } : msg);
415
+
416
+ if (result.aborted) {
417
+ // 用户点了"停止":把已经流出来的部分正文补进历史,否则磁盘上这一轮只剩一条
418
+ // user 消息,重新打开会话时刚才生成的内容会整段丢失。
419
+ // 半截的 tool_calls 已被 transport 在中止时丢弃,这里只补正文,不会有悬空引用。
420
+ // 落盘由路由层统一负责(中止的轮次同样要写,见 agentRoutes.js)。
421
+ if (content) {
422
+ session.messages.push(withReasoning({ role: 'assistant', content }));
423
+ }
424
+ send({ type: 'error', error: '已取消' });
425
+ return { aborted: true };
426
+ }
427
+
428
+ // 无工具调用:本轮结束
429
+ if (toolCalls.length === 0) {
430
+ session.messages.push(withReasoning({ role: 'assistant', content: content || null }));
431
+ send({ type: 'done', content: content || '' });
432
+ return { aborted: false };
433
+ }
434
+
435
+ // 有工具调用:assistant(带 tool_calls)入历史
436
+ session.messages.push(withReasoning({
437
+ role: 'assistant',
438
+ content: content || null,
439
+ tool_calls: toolCalls
440
+ }));
441
+
442
+ // 逐个执行工具
443
+ for (const tc of toolCalls) {
444
+ const name = tc.function?.name || '';
445
+ const rawArgs = tc.function?.arguments || '';
446
+ const toolCallId = tc.id || name;
447
+
448
+ let args;
449
+ try {
450
+ args = rawArgs ? JSON.parse(rawArgs) : {};
451
+ } catch {
452
+ const errResult = `错误: 工具参数不是合法 JSON: ${rawArgs.slice(0, 200)}`;
453
+ send({ type: 'tool_call_start', toolCallId, name, argsPreview: rawArgs.slice(0, 200), arguments: rawArgs });
454
+ send({ type: 'tool_result', toolCallId, name, result: errResult });
455
+ session.messages.push({ role: 'tool', tool_call_id: toolCallId, name, content: errResult });
456
+ continue;
457
+ }
458
+
459
+ // 工具参数预览(前端收起态那一行副标题)。这里截断是**故意的** ——
460
+ // 摘要只负责"一眼看出它在干嘛"。
461
+ const argsPreview = summarizeArgs(name, args);
462
+ send({ type: 'tool_call_start', toolCallId, name, argsPreview, arguments: rawArgs });
463
+
464
+ const toolCtx = {
465
+ ...ctx,
466
+ signal,
467
+ // run_command 执行期间的增量输出 → 前端实时显示。
468
+ // 只用于展示,不进会话历史 —— 历史里存的仍是带 exit code 的最终结果。
469
+ onOutput: (chunk) => {
470
+ const text = String(chunk || '');
471
+ if (text) send({ type: 'tool_output', toolCallId, name, chunk: text });
472
+ },
473
+ };
474
+ if (name === 'ask_user' && typeof ctx.askUser === 'function') {
475
+ toolCtx.askUser = askArgs => ctx.askUser(askArgs, {
476
+ sessionId: session.sessionId,
477
+ interactionId: toolCallId,
478
+ send,
479
+ signal,
480
+ });
481
+ }
482
+ const output = await executeTool(name, args, toolCtx);
483
+ // read_image 会返回 { text, images }。前端只吃文本(result 是字符串,
484
+ // 直接把对象丢过去会渲染成 "[object Object]"),图片进会话历史里的
485
+ // 多模态 tool 消息 —— 模型看得见图,用户看到的是"已读取图片 xxx.png"。
486
+ const { text: outText, images: outImages } = splitToolOutput(output);
487
+ send({ type: 'tool_result', toolCallId, name, result: outText });
488
+ session.messages.push({ role: 'tool', tool_call_id: toolCallId, name, content: toolMessageContent(outText, outImages) });
489
+ }
490
+ // 工具结果全部入历史后继续循环,让模型基于结果决定下一步
491
+ }
492
+
493
+ // 达到最大迭代次数
494
+ send({ type: 'done', content: `已达单轮最大工具调用次数(${maxIterations}),本轮结束。如需继续请再发一条消息。` });
495
+ return { aborted: false };
496
+ }
497
+
498
+ // 工具参数简短摘要(给前端展示)
499
+ function summarizeArgs(name, args) {
500
+ try {
501
+ switch (name) {
502
+ case 'run_command':
503
+ return String(args.command || '').slice(0, 200);
504
+ case 'read_file':
505
+ case 'read_image':
506
+ case 'write_file':
507
+ case 'edit_file':
508
+ return String(args.path || '');
509
+ case 'list_files':
510
+ return String(args.path || '.');
511
+ case 'search_text':
512
+ return String(args.pattern || '');
513
+ case 'update_plan': {
514
+ const steps = normalizePlanSteps(args.steps ?? args.todos ?? args.plan);
515
+ const why = String(args.explanation || '').replace(/\s+/g, ' ').trim();
516
+ return [summarizePlan(steps), why].filter(Boolean).join(' — ').slice(0, 200);
517
+ }
518
+ default:
519
+ return JSON.stringify(args).slice(0, 200);
520
+ }
521
+ } catch {
522
+ return '';
523
+ }
524
+ }
525
+
526
+ // ── 请求级上下文注入(工作区状态 / 常用目录状态 / 文件空间对话 / 本轮附件) ──
527
+ // 把"工作区各板块的状态摘要""常用目录那批目录的 Git 状态""用户当前打开的文件"与
528
+ // "本轮附件的落盘路径"追加到**请求副本**的 system 消息末尾:只影响这一次请求,
529
+ // session.messages 与磁盘历史保持原样,下一轮也不会重复累积。
530
+ //
531
+ // ⚠️ 工作区快照**必须走这条副本路径,不能塞进 session.messages 里那条 system 消息**。
532
+ // 那条只在首轮 push 一次(见上面 `session.messages.length === 0` 的判断)并会落盘,
533
+ // 快照进去就等于永久停在"会话创建那天"——git 分支、任务进度全会是过期的,
534
+ // 而且这种错不会报错、只会让模型理直气壮地给出错答案。有单测钉住这一点。
535
+ // 常用目录状态同理,而且更严重:它说的是"这一刻"的领先/落后,几小时后必然不同。
536
+ //
537
+ // 附件为什么只给路径、不给内容:见 utils/agentAttachments.js 的头注释 —— 非图片附件
538
+ // 由服务端落盘,模型自己用 read / grep 按需取,比把几百 KB 文本内联进消息省得多。
539
+ // 快照块同理,只给摘要与目录路径,板块正文由模型按需读。
540
+ export function injectRequestContext(messages, { cwd, openFilePath, attachments = [], locale, workspaceBlock = '', dirStatusBlock = '' }) {
541
+ if (!Array.isArray(messages)) return;
542
+ const en = String(locale || '').startsWith('en');
543
+
544
+ const parts = [];
545
+
546
+ // ⓪ 工作区状态快照(七个板块的摘要 + 落盘目录)。由 aiContext 生成,只在这个副本里。
547
+ if (typeof workspaceBlock === 'string' && workspaceBlock.trim()) {
548
+ parts.push(workspaceBlock.trim());
549
+ }
550
+
551
+ // ① 常用目录那批目录的 Git 状态(切换工作目录弹窗里的追问才有)。
552
+ // 整块(含"屏幕上那段自动解读")由 agentRoutes 调 buildDirStatusBlock 拼好传进来 ——
553
+ // 两条链路(内置引擎的副本 / 外部引擎的前缀)用的是同一个字符串,不在两处各拼一遍。
554
+ if (typeof dirStatusBlock === 'string' && dirStatusBlock.trim()) {
555
+ parts.push(dirStatusBlock.trim());
556
+ }
557
+
558
+ // ② 当前打开的文档(文件空间对话才有;项目外或等于根目录直接忽略)
559
+ if (openFilePath) {
560
+ const root = cwd || process.cwd();
561
+ let rel = '';
562
+ try {
563
+ rel = path.relative(root, path.resolve(root, openFilePath));
564
+ } catch {
565
+ rel = '';
566
+ }
567
+ if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) {
568
+ const file = rel.split(path.sep).join('/');
569
+ parts.push(en
570
+ ? `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.`
571
+ : `用户此刻在文件空间打开的文件是 \`${file}\`(相对项目根目录)。用户说"这个文件/当前文件"时默认指它;请先用工具读取内容,不要臆测。`);
572
+ }
573
+ }
574
+
575
+ // ③ 本轮附件:只给绝对路径,内容让模型自己去读
576
+ const files = (Array.isArray(attachments) ? attachments : [])
577
+ .filter(a => a && typeof a.path === 'string' && a.path)
578
+ .slice(0, 20);
579
+ if (files.length > 0) {
580
+ const lines = files.map(a => `- \`${a.path}\`${a.name && a.name !== path.basename(a.path) ? ` (${a.name})` : ''}`);
581
+ parts.push(en
582
+ ? `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.`
583
+ : `用户本轮附带了 ${files.length} 个文件,已保存到以下绝对路径:\n${lines.join('\n')}\n需要时用工具读取(内容没有内联在这里),不要臆测。`);
584
+ }
585
+
586
+ if (parts.length === 0) return;
587
+
588
+ const note = `\n\n# ${en ? 'Current context' : '当前上下文'}\n${parts.join('\n\n')}`;
589
+ const sys = messages.find(m => m && m.role === 'system' && typeof m.content === 'string');
590
+ if (sys) sys.content += note;
591
+ else messages.unshift({ role: 'system', content: note.trim() });
592
+ }
593
+
594
+ export { buildWebSystemPrompt };