zen-gitsync 2.15.33 → 2.16.1

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 (93) hide show
  1. package/README.md +25 -0
  2. package/package.json +2 -1
  3. package/scripts/run-tests.cjs +1 -0
  4. package/src/cli/ai/agent.js +845 -0
  5. package/src/cli/ai/agent.test.js +114 -0
  6. package/src/cli/ai/images.js +211 -0
  7. package/src/cli/ai/images.test.js +98 -0
  8. package/src/cli/ai/safety.js +215 -0
  9. package/src/cli/ai/safety.test.js +158 -0
  10. package/src/cli/ai/streamFilter.js +82 -0
  11. package/src/cli/ai/streamFilter.test.js +88 -0
  12. package/src/cli/ai/termui.js +378 -0
  13. package/src/cli/ai/termui.test.js +215 -0
  14. package/src/cli/ai/tools.js +482 -0
  15. package/src/cli/ai/tools.test.js +190 -0
  16. package/src/gitCommit.js +9 -0
  17. package/src/ui/public/assets/AppVersionBadge-CS7Trvp-.css +1 -0
  18. package/src/ui/public/assets/AppVersionBadge-DJyfXR_Q.js +2 -0
  19. package/src/ui/public/assets/BranchSelector-B0eLkAdv.css +1 -0
  20. package/src/ui/public/assets/BranchSelector-ClnVPC0Q.js +1 -0
  21. package/src/ui/public/assets/CommitForm-BihM26_4.js +6 -0
  22. package/src/ui/public/assets/CommitForm-DdK8dymc.css +1 -0
  23. package/src/ui/public/assets/CommonDialog-CYoIF2fg.js +1 -0
  24. package/src/ui/public/assets/CommonDialog-ChSKsEz2.css +1 -0
  25. package/src/ui/public/assets/EditorView-BVod-rjn.css +1 -0
  26. package/src/ui/public/assets/EditorView-DYJQT-Xj.js +0 -0
  27. package/src/ui/public/assets/FlowExecutionViewer-CPblwVA1.js +1 -0
  28. package/src/ui/public/assets/FlowExecutionViewer-jNHI5zdm.css +1 -0
  29. package/src/ui/public/assets/FlowOrchestrationWorkspace-CYGp-PqS.css +1 -0
  30. package/src/ui/public/assets/FlowOrchestrationWorkspace-Cc-Tnhng.js +21 -0
  31. package/src/ui/public/assets/LogList-CeFFytjB.css +1 -0
  32. package/src/ui/public/assets/LogList-va6ANtbL.js +15 -0
  33. package/src/ui/public/assets/MindmapView-3rMfOoNm.css +1 -0
  34. package/src/ui/public/assets/MindmapView-C_9P5ijq.js +1 -0
  35. package/src/ui/public/assets/MonitorView-DRPECOVW.js +1 -0
  36. package/src/ui/public/assets/MonitorView-TaS4kL0Y.css +1 -0
  37. package/src/ui/public/assets/ProjectStartupButton-DSovbKPE.js +1 -0
  38. package/src/ui/public/assets/ProjectStartupButton-QLPltCK5.css +1 -0
  39. package/src/ui/public/assets/RemoteRepoCard-BRGNlbeO.css +1 -0
  40. package/src/ui/public/assets/RemoteRepoCard-C_cBtHcN.js +5 -0
  41. package/src/ui/public/assets/SourceMapView-BNRjvgzH.css +1 -0
  42. package/src/ui/public/assets/SourceMapView-ZiCL8mS8.js +3 -0
  43. package/src/ui/public/assets/SvgIcon-C9YQSvGU.js +1 -0
  44. package/src/ui/public/assets/{SvgIcon-BQ6bR7l0.css → SvgIcon-CDNsk4-Q.css} +1 -1
  45. package/src/ui/public/assets/UserInputNode-BVgQkB2l.css +1 -0
  46. package/src/ui/public/assets/UserInputNode-CR6o8zJG.js +1 -0
  47. package/src/ui/public/assets/WorkbenchView-CJJrBqBH.js +28 -0
  48. package/src/ui/public/assets/{WorkbenchView-CQfjF0Gr.css → WorkbenchView-DbF4hH5s.css} +1 -1
  49. package/src/ui/public/assets/{_plugin-vue_export-helper-CzpRIaQR.js → _plugin-vue_export-helper-CHQqCylN.js} +1 -1
  50. package/src/ui/public/assets/{configStore-DaWjJLTW.js → configStore-Bw9U4AHx.js} +1 -1
  51. package/src/ui/public/assets/element-plus-CyGhXd7X.js +25 -0
  52. package/src/ui/public/assets/{flow-mindmap-2CcykUk_.js → flow-mindmap-CUvJaYn2.js} +1 -1
  53. package/src/ui/public/assets/index-BzNpkvm5.js +68 -0
  54. package/src/ui/public/assets/{index-CWoujklW.css → index-CaIu73UM.css} +1 -1
  55. package/src/ui/public/assets/socket-io-BSgn8Jx2.js +1 -0
  56. package/src/ui/public/assets/{vendor-CG4K5XlB.js → vendor-CU5NYUKJ.js} +44 -37
  57. package/src/ui/public/assets/vue-flow-CWmfpfPN.js +39 -0
  58. package/src/ui/public/index.html +12 -12
  59. package/src/utils/index.js +5 -0
  60. package/src/ui/public/assets/AppVersionBadge-Ckwa7eM9.js +0 -2
  61. package/src/ui/public/assets/AppVersionBadge-Dj6H729N.css +0 -1
  62. package/src/ui/public/assets/BranchSelector-T_rJm-JY.css +0 -1
  63. package/src/ui/public/assets/BranchSelector-e0465kxE.js +0 -1
  64. package/src/ui/public/assets/CommitForm-862NAe0q.js +0 -6
  65. package/src/ui/public/assets/CommitForm-CUvl0VcF.css +0 -1
  66. package/src/ui/public/assets/CommonDialog-BVPvUoQ0.js +0 -1
  67. package/src/ui/public/assets/CommonDialog-Cyh3xu0p.css +0 -1
  68. package/src/ui/public/assets/EditorView-CRYGVw_r.css +0 -1
  69. package/src/ui/public/assets/EditorView-sZoyx-nK.js +0 -0
  70. package/src/ui/public/assets/FlowExecutionViewer-B5kh7VxS.css +0 -1
  71. package/src/ui/public/assets/FlowExecutionViewer-CowPPIpm.js +0 -1
  72. package/src/ui/public/assets/FlowOrchestrationWorkspace-BAQDU9Jd.css +0 -1
  73. package/src/ui/public/assets/FlowOrchestrationWorkspace-Dno2fn7O.js +0 -21
  74. package/src/ui/public/assets/LogList-DJYpDUTZ.css +0 -1
  75. package/src/ui/public/assets/LogList-Dy42jDbI.js +0 -7
  76. package/src/ui/public/assets/MindmapView-CWoqfZRL.css +0 -1
  77. package/src/ui/public/assets/MindmapView-Ds2XDHgS.js +0 -1
  78. package/src/ui/public/assets/MonitorView-DxFogQIt.css +0 -1
  79. package/src/ui/public/assets/MonitorView-Dy158z7E.js +0 -1
  80. package/src/ui/public/assets/ProjectStartupButton-B7zvqRj_.css +0 -1
  81. package/src/ui/public/assets/ProjectStartupButton-eozWTyGl.js +0 -1
  82. package/src/ui/public/assets/RemoteRepoCard-CTfNrrx4.js +0 -1
  83. package/src/ui/public/assets/RemoteRepoCard-d0klonVb.css +0 -1
  84. package/src/ui/public/assets/SourceMapView-Bl5jEKf6.js +0 -3
  85. package/src/ui/public/assets/SourceMapView-DG_a_076.css +0 -1
  86. package/src/ui/public/assets/SvgIcon-D7FdXvWv.js +0 -1
  87. package/src/ui/public/assets/UserInputNode-Bcxe_5-C.js +0 -1
  88. package/src/ui/public/assets/UserInputNode-DDtvv9Iq.css +0 -1
  89. package/src/ui/public/assets/WorkbenchView-CZSPrO3Q.js +0 -12
  90. package/src/ui/public/assets/element-plus-d0668myq.js +0 -17
  91. package/src/ui/public/assets/index-CG24iOR5.js +0 -38
  92. package/src/ui/public/assets/socket-io-B1foIbJS.js +0 -1
  93. package/src/ui/public/assets/vue-flow-CVRzDji7.js +0 -39
@@ -0,0 +1,845 @@
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
+ // g ai — 终端交互式 AI 编码智能体。
16
+ //
17
+ // 入口: runAiAgent(argv)(由 gitCommit.js 在 argv[2]==='ai' 时动态 import)。
18
+ //
19
+ // 能力:
20
+ // - 默认使用 g ui 里配置的模型(~/.git-commit-tool.json 顶层 models,
21
+ // isDefault 优先,否则取第一个);--model=<序号|名称> 或会话内 /model 切换
22
+ // - OpenAI 兼容流式 function calling:模型可以调用 run_command /
23
+ // read_file / write_file / edit_file / list_files / search_text
24
+ // - 图片:Alt+V 粘贴剪贴板图片、/image <路径> 附加本地图片,
25
+ // 以多模态(image_url)消息发给模型(需模型支持视觉)
26
+ // - 权限:启动目录内全开放,其他目录可读写;唯一红线由 safety.js 拦截
27
+ // (格式化磁盘、删根目录、关机等系统级破坏操作)
28
+ //
29
+ // 视觉风格(termui.js):对标 Codex CLI / Claude Code / OpenCode —
30
+ // 盒式输入框、ora 等待 spinner、✻ 思考灰斜体、⏺ 工具块 + ⎿ 结果槽、
31
+ // 正文逐行轻量 markdown(**bold** / `code` / # 标题 / ``` 围栏 / - 列表)
32
+ //
33
+ // 使用方式:
34
+ // g ai 交互模式(REPL)
35
+ // g ai "帮我修个 bug" 单发模式:执行一轮后退出
36
+ // g ai --model=2 指定配置里的第 2 个模型
37
+ //
38
+ // 实现注意:
39
+ // - 本模块属于 CLI 侧,不依赖 GUI 服务器代码(src/ui/server/**)
40
+ // - Ctrl+C 行为与 CLI 其他模式一致:由 gitCommit.js 的 setupSigintHandler
41
+ // 统一 drain(trackChild 杀子进程 + cleanup 任务),整个会话退出
42
+
43
+ import readline from 'node:readline'
44
+ import chalk from 'chalk'
45
+ import config from '../../config.js'
46
+ import { getCwd } from '../../utils/index.js'
47
+ import { registerCleanup } from '../cleanup.js'
48
+ import { TOOL_DEFINITIONS, executeTool } from './tools.js'
49
+ import { createThinkFilter } from './streamFilter.js'
50
+ import {
51
+ printBanner, printHelpPanel,
52
+ startSpinner, createAssistantWriter,
53
+ summarizeToolArgs, printToolHeader, printToolResult,
54
+ printOk, printWarn, printError, printDim,
55
+ } from './termui.js'
56
+ import { readClipboardImage, checkImageFile, imageToDataUrl, formatBytes } from './images.js'
57
+
58
+ // truncateDisplay 已迁移到 termui.js;这里 re-export 保持既有测试/外部引用不断
59
+ export { truncateDisplay } from './termui.js'
60
+
61
+ // ──────────────────────────────────────────────
62
+ // 常量
63
+ // ──────────────────────────────────────────────
64
+ const MAX_TOOL_ITERATIONS = 40 // 单轮用户输入允许的最大工具调用循环数(防失控)
65
+ const MAX_HISTORY_MESSAGES = 40 // 历史消息上限(超出后从最旧的整段对话裁剪)
66
+ const LLM_TIMEOUT_MS = 300000 // 单次 LLM 请求超时(5 分钟,长推理模型够用)
67
+
68
+ // ──────────────────────────────────────────────
69
+ // i18n 字符串表(CLI 其他部分以中文为主,这里按 locale 给双语)
70
+ // ──────────────────────────────────────────────
71
+ const STRINGS = {
72
+ zh: {
73
+ noModel: '未配置 AI 模型,请先在 GUI 通用设置中添加模型(运行 g ui 打开设置)',
74
+ unknownModel: (q) => `未找到匹配的模型: ${q},使用默认模型`,
75
+ waiting: '思考中…',
76
+ llmError: (msg) => `LLM 请求失败: ${msg}`,
77
+ toolIterLimit: (n) => `已达单轮最大工具调用次数(${n}),本轮结束。如需继续请再发一条消息。`,
78
+ toolRunning: (name) => `执行 ${name}…`,
79
+ busy: '智能体正在执行中,请稍候(Ctrl+C 结束会话)…',
80
+ bye: '已退出 g ai',
81
+ cleared: '对话历史已清空',
82
+ thinkOn: '思考过程显示: 开',
83
+ thinkOff: '思考过程显示: 关(模型仍会思考,只是不回显)',
84
+ modelSwitched: (label) => `已切换模型: ${label}`,
85
+ invalidModelIndex: '无效的模型序号',
86
+ cdOk: (p) => `工作目录已切换: ${p}`,
87
+ cdFail: (p) => `目录不存在: ${p}`,
88
+ currentModel: '当前模型',
89
+ availableModels: '可用模型',
90
+ oneShotNoModel: '未配置模型,无法启动',
91
+ oneShotDone: '单发模式完成 · 需要多轮连续对话请直接运行 g ai(不带参数)',
92
+ helpTitle: 'g ai 命令',
93
+ bannerTip: 'Alt+V 粘贴图片 · /help 查看命令 · /exit 退出 · Ctrl+C 结束会话',
94
+ prompt: '❯ ',
95
+ bannerModel: '模型',
96
+ bannerCwd: '目录',
97
+ thinkingLabel: '✻ 思考',
98
+ chars: '字符',
99
+ imagePasting: '正在读取剪贴板图片…',
100
+ imageAttached: (n, size) => `📎 图片 #${n} 已附加(${size}),将随下一条消息发送`,
101
+ imageEmpty: '剪贴板中没有图片;可先截图再按 Alt+V,或用 /image <路径> 附加本地图片',
102
+ imageBadPath: (p) => `图片不存在或格式不支持: ${p}(支持 png/jpg/jpeg/gif/webp/bmp)`,
103
+ imageListTitle: '待发送图片',
104
+ imageListEmpty: '当前没有待发送的图片(Alt+V 粘贴或 /image <路径> 附加)',
105
+ imageCleared: '已清除待发送的图片',
106
+ imageSending: (n) => `📎 附带 ${n} 张图片`,
107
+ },
108
+ en: {
109
+ noModel: 'No AI model configured. Add one in GUI settings first (run `g ui`).',
110
+ unknownModel: (q) => `No model matching "${q}", falling back to default`,
111
+ waiting: 'Thinking…',
112
+ llmError: (msg) => `LLM request failed: ${msg}`,
113
+ toolIterLimit: (n) => `Hit max tool iterations (${n}) for this turn. Send another message to continue.`,
114
+ toolRunning: (name) => `Running ${name}…`,
115
+ busy: 'Agent is working, please wait (Ctrl+C to end session)…',
116
+ bye: 'Bye',
117
+ cleared: 'Conversation cleared',
118
+ thinkOn: 'Thinking display: on',
119
+ thinkOff: 'Thinking display: off (model still thinks, just hidden)',
120
+ modelSwitched: (label) => `Switched model: ${label}`,
121
+ invalidModelIndex: 'Invalid model index',
122
+ cdOk: (p) => `Working directory changed: ${p}`,
123
+ cdFail: (p) => `Directory not found: ${p}`,
124
+ currentModel: 'Current model',
125
+ availableModels: 'Available models',
126
+ oneShotNoModel: 'No model configured, aborting',
127
+ oneShotDone: 'One-shot done · for multi-turn chat run `g ai` with no arguments',
128
+ helpTitle: 'g ai commands',
129
+ bannerTip: 'Alt+V paste image · /help for commands · /exit to quit · Ctrl+C to end session',
130
+ prompt: '❯ ',
131
+ bannerModel: 'Model',
132
+ bannerCwd: 'CWD',
133
+ thinkingLabel: '✻ Thinking',
134
+ chars: 'chars',
135
+ imagePasting: 'Reading clipboard image…',
136
+ imageAttached: (n, size) => `📎 Image #${n} attached (${size}); sent with your next message`,
137
+ imageEmpty: 'No image in clipboard; take a screenshot first, or use /image <path>',
138
+ imageBadPath: (p) => `Not a supported image: ${p} (png/jpg/jpeg/gif/webp/bmp)`,
139
+ imageListTitle: 'Pending images',
140
+ imageListEmpty: 'No pending images (Alt+V to paste, or /image <path>)',
141
+ imageCleared: 'Pending images cleared',
142
+ imageSending: (n) => `📎 ${n} image(s) attached`,
143
+ },
144
+ }
145
+
146
+ function makeStrings(locale) {
147
+ return String(locale || '').startsWith('en') ? STRINGS.en : STRINGS.zh
148
+ }
149
+
150
+ // 模型的展示名:优先 model 字段,其次 name,最后 baseURL
151
+ function modelLabel(m) {
152
+ return m?.model || m?.name || m?.baseURL || '(unknown)'
153
+ }
154
+
155
+ // ──────────────────────────────────────────────
156
+ // 系统 prompt — 明确告知环境、权限边界与工作方式
157
+ // ──────────────────────────────────────────────
158
+ function buildSystemPrompt({ cwd, locale, shellDesc }) {
159
+ const zh = !String(locale || '').startsWith('en')
160
+ const now = new Date().toLocaleString()
161
+ const isWin = process.platform === 'win32'
162
+ if (zh) {
163
+ return `你是 "g ai" —— zen-gitsync CLI 内置的终端编码智能体,通过工具在用户真实电脑上完成编码任务。
164
+
165
+ # 运行环境
166
+ - 操作系统: ${process.platform}
167
+ - Shell: ${shellDesc}${isWin ? `
168
+ - 注意:Windows cmd 没有 head/grep/ls/sed/cat/tail 等 Unix 命令,直接跑会报"不是内部或外部命令"。
169
+ 列目录用 list_files、搜内容用 search_text、看文件用 read_file,优先用工具而不是 shell;
170
+ 必须跑 shell 时优先跨平台写法(如 node -e "..."),别用 Unix 专属命令` : ''}
171
+ - 当前工作目录: ${cwd}(用户在此启动 g ai,也是所有相对路径的基准)
172
+ - 当前时间: ${now}
173
+
174
+ # 权限(用户已明确授权,无需反复征求同意)
175
+ - 工作目录内:读写文件、执行命令等所有操作直接执行
176
+ - 其他目录:同样可以读取和修改
177
+ - 唯一红线:不得破坏系统(格式化磁盘、删除根目录/系统目录、关机重启、写块设备等)。
178
+ 安全守卫会拦截这类命令;被拦截时换安全方案,或告知用户需要他手动执行。
179
+
180
+ # 工作方式
181
+ - 先动手、后提问:能用工具查清的不要问用户(list_files / read_file / search_text / run_command)
182
+ - 修改代码后主动验证:跑测试、构建或至少语法检查(用 run_command)
183
+ - 编辑文件优先 edit_file 精确替换;先 read_file 看原文,old_string 必须与文件内容完全一致(含缩进换行)
184
+ - 大文件用 offset/limit 分段读取,不要一次读爆上下文
185
+ - git 操作用 run_command 执行;提交代码可以用 git 命令,也可以用本 CLI 的 g -y(默认信息提交并推送)或 g --ai(AI 生成提交信息)
186
+ - run_command 默认就在工作目录执行,不要再加 cd / cd /d 前缀;默认超时 120 秒,长任务加大 timeout_seconds(最大 600)
187
+ - 命令在 ${shellDesc} 下执行,注意语法兼容
188
+ - 本项目跑测试用 npm test(node --test 不支持直接传目录路径)
189
+
190
+ # 与用户交互
191
+ - 需要向用户确认、提问或汇报重要决策时,直接用普通文本输出 —— 用户能实时看到你的文本;
192
+ 不要调用不存在的工具,可用工具只有上面列出的 6 个
193
+ - 用户可能通过 Alt+V 或 /image 附加图片:图片以 image_url 部件出现在 user 消息里;
194
+ 如果当前模型不支持视觉(带图请求报错),提醒用户换用支持视觉的模型
195
+ - 发现高风险或状态不一致的情况(例如版本号 / git tag / CHANGELOG 对不上、发布前环境异常、
196
+ 仓库状态与预期不符)时:先用文本说明发现和影响,停下来等用户指示,不要擅自继续破坏性操作
197
+
198
+ # 输出
199
+ - 你的文本输出直接显示在用户终端,用简体中文交流
200
+ - 完成任务后用一两句话汇报结果,不要复述过程细节`
201
+ }
202
+ return `You are "g ai" — the terminal coding agent built into the zen-gitsync CLI. You use tools to perform coding tasks on the user's real machine.
203
+
204
+ # Environment
205
+ - OS: ${process.platform}
206
+ - Shell: ${shellDesc}${isWin ? `
207
+ - Note: Windows cmd has NO Unix commands (head/grep/ls/sed/cat/tail); running them fails with "not recognized".
208
+ Use the tools instead: list_files for directories, search_text for content, read_file for files.
209
+ For shell one-offs, prefer cross-platform forms like node -e "..."` : ''}
210
+ - Working directory: ${cwd} (where the user launched g ai; base for all relative paths)
211
+ - Current time: ${now}
212
+
213
+ # Permissions (explicitly granted by the user — do not keep asking)
214
+ - Inside the working directory: read/write files and run commands directly
215
+ - Other directories: may also be read and modified
216
+ - Single red line: never destroy the system (format disks, delete root/system dirs, shutdown/reboot, write block devices). A safety guard blocks such commands; if blocked, find a safe alternative or ask the user to run it manually.
217
+
218
+ # How to work
219
+ - Act first, ask later: use tools (list_files / read_file / search_text / run_command) instead of asking the user
220
+ - After editing code, verify: run tests, builds, or at least a syntax check via run_command
221
+ - Prefer edit_file for precise replacements; read_file first, old_string must match the file exactly
222
+ - Read large files in segments (offset/limit)
223
+ - Git operations go through run_command; to commit, use git commands or this CLI's g -y / g --ai
224
+ - run_command already executes in the working directory — do NOT prefix with cd; default timeout 120s, raise timeout_seconds (max 600) for long tasks
225
+ - Commands run under ${shellDesc}; keep syntax compatible
226
+ - Run this project's tests with npm test (node --test does not accept a bare directory)
227
+
228
+ # Talking to the user
229
+ - When you need to confirm something, ask a question, or report an important decision, just write plain text — the user sees your output in real time. Never call tools that do not exist; only the 6 tools listed above are available
230
+ - The user can attach images (Alt+V or /image); they arrive as image_url parts in your user messages. If the current model rejects images (no vision support), tell the user to switch to a vision-capable model
231
+ - When you spot high-risk or inconsistent state (version number / git tag / CHANGELOG mismatch, abnormal release environment, unexpected repo state), explain the finding and its impact in text, then STOP and wait for the user's decision instead of proceeding with destructive operations
232
+
233
+ # Output
234
+ - Your text output goes straight to the user's terminal; reply in English
235
+ - Report results in one or two sentences; do not narrate the process`
236
+ }
237
+
238
+ // ──────────────────────────────────────────────
239
+ // LLM 流式调用(OpenAI 兼容 + function calling)
240
+ // 返回 { content, toolCalls, aborted }
241
+ // ──────────────────────────────────────────────
242
+ async function streamChatOnce({ model, messages, signal, onToken }) {
243
+ const url = `${String(model.baseURL || '').replace(/\/$/, '')}/chat/completions`
244
+ const headers = { 'Content-Type': 'application/json' }
245
+ if (model.apiKey) headers['Authorization'] = `Bearer ${model.apiKey}`
246
+
247
+ const body = JSON.stringify({
248
+ model: model.model,
249
+ messages,
250
+ tools: TOOL_DEFINITIONS,
251
+ temperature: 0.3,
252
+ stream: true,
253
+ })
254
+
255
+ const controller = new AbortController()
256
+ const timer = setTimeout(() => controller.abort(), LLM_TIMEOUT_MS)
257
+ const onExternalAbort = () => controller.abort()
258
+ if (signal) {
259
+ if (signal.aborted) controller.abort()
260
+ else signal.addEventListener('abort', onExternalAbort)
261
+ }
262
+
263
+ let content = ''
264
+ // tool_calls 按 index 累积:provider 分片推送 id / name / arguments
265
+ const toolCalls = []
266
+ let aborted = false
267
+ try {
268
+ const resp = await fetch(url, { method: 'POST', headers, body, signal: controller.signal })
269
+ if (!resp.ok || !resp.body) {
270
+ const errText = await resp.text().catch(() => '')
271
+ const snippet = errText.length > 300 ? errText.slice(0, 300) + '…' : errText
272
+ // 400 且提到 tools/functions:大概率是模型不支持 function calling
273
+ if (resp.status === 400 && /tool|function/i.test(snippet)) {
274
+ throw new Error(`HTTP 400: 当前模型可能不支持 function calling(${snippet})。请在 g ui 中换用支持工具调用的模型(如 deepseek / qwen / gpt 系列)。`)
275
+ }
276
+ throw new Error(`HTTP ${resp.status}: ${snippet || resp.statusText}`)
277
+ }
278
+
279
+ const decoder = new TextDecoder('utf-8')
280
+ let buf = ''
281
+ for await (const chunk of resp.body) {
282
+ buf += decoder.decode(chunk, { stream: true })
283
+ const lines = buf.split('\n')
284
+ buf = lines.pop() ?? ''
285
+ for (const line of lines) {
286
+ const trimmed = line.trim()
287
+ if (!trimmed || !trimmed.startsWith('data:')) continue
288
+ const payload = trimmed.slice(5).trim()
289
+ if (payload === '[DONE]') continue
290
+ let evt
291
+ try { evt = JSON.parse(payload) } catch { continue }
292
+ const delta = evt.choices?.[0]?.delta || {}
293
+
294
+ // thinking(各 provider 字段名不同,逐一尝试)
295
+ const thinkingChunk = delta.reasoning_content || delta.reasoning || delta.reasoning_text || ''
296
+ if (thinkingChunk) onToken({ thinking: thinkingChunk })
297
+
298
+ if (delta.content) {
299
+ content += delta.content
300
+ onToken({ content: delta.content })
301
+ }
302
+
303
+ for (const tc of delta.tool_calls || []) {
304
+ const i = tc.index ?? 0
305
+ if (!toolCalls[i]) toolCalls[i] = { id: '', type: 'function', function: { name: '', arguments: '' } }
306
+ if (tc.id) toolCalls[i].id += tc.id
307
+ if (tc.function?.name) toolCalls[i].function.name += tc.function.name
308
+ if (tc.function?.arguments) toolCalls[i].function.arguments += tc.function.arguments
309
+ }
310
+ }
311
+ }
312
+ } catch (err) {
313
+ if (err?.name === 'AbortError' || controller.signal.aborted) {
314
+ aborted = true
315
+ } else {
316
+ throw err
317
+ }
318
+ } finally {
319
+ clearTimeout(timer)
320
+ if (signal) signal.removeEventListener('abort', onExternalAbort)
321
+ }
322
+ return { content, toolCalls: toolCalls.filter(Boolean), aborted }
323
+ }
324
+
325
+ // ──────────────────────────────────────────────
326
+ // 历史裁剪:保留 system + 最近 MAX_HISTORY_MESSAGES 条
327
+ // 切口必须落在 user 消息上,避免把 assistant(tool_calls) 与其 tool 结果从中间撕开
328
+ // ──────────────────────────────────────────────
329
+ function trimHistory(messages) {
330
+ if (messages.length <= MAX_HISTORY_MESSAGES + 1) return
331
+ let cut = messages.length - MAX_HISTORY_MESSAGES
332
+ while (cut < messages.length && messages[cut].role !== 'user') cut++
333
+ if (cut <= 1) return
334
+ messages.splice(1, cut - 1)
335
+ }
336
+
337
+ // ──────────────────────────────────────────────
338
+ // 多模态历史:base64 图片很占上下文,只保留"最近一条带图消息"里的图片,
339
+ // 更早消息里的 image_url 部件降级为文字占位(模型仍知道这里曾有图)
340
+ // ──────────────────────────────────────────────
341
+ export function stripStaleImages(messages, locale) {
342
+ const placeholder = String(locale || '').startsWith('en')
343
+ ? '[image omitted from history]'
344
+ : '[图片已从历史中省略]'
345
+ let seenLatest = false
346
+ for (let i = messages.length - 1; i >= 0; i--) {
347
+ const m = messages[i]
348
+ if (m?.role !== 'user' || !Array.isArray(m.content)) continue
349
+ const hasImage = m.content.some(p => p?.type === 'image_url')
350
+ if (!hasImage) continue
351
+ if (!seenLatest) { seenLatest = true; continue }
352
+ m.content = m.content.map(p => p?.type === 'image_url'
353
+ ? { type: 'text', text: placeholder }
354
+ : p)
355
+ }
356
+ }
357
+
358
+ // ──────────────────────────────────────────────
359
+ // 单轮 agent 循环:用户一句话 → 流式输出 → 工具调用 → 再调用模型 … 直到模型给出最终文本
360
+ // images: [{path, bytes}] 待发送图片(可为空数组)
361
+ // ──────────────────────────────────────────────
362
+ async function runAgentTurn(state, userText, t, images = []) {
363
+ // 有图片:编码为 OpenAI 多模态 content parts;没图片保持纯字符串
364
+ if (images.length > 0) {
365
+ const parts = [{ type: 'text', text: userText }]
366
+ for (const img of images) {
367
+ try {
368
+ const url = await imageToDataUrl(img.path)
369
+ parts.push({ type: 'image_url', image_url: { url } })
370
+ } catch (err) {
371
+ printWarn(t.imageBadPath(img.path))
372
+ }
373
+ }
374
+ state.messages.push({ role: 'user', content: parts })
375
+ } else {
376
+ state.messages.push({ role: 'user', content: userText })
377
+ }
378
+ // 旧消息里的图片降级为占位文字,防止 base64 随对话轮次累积撑爆上下文
379
+ stripStaleImages(state.messages, state.locale)
380
+
381
+ for (let iter = 0; iter < MAX_TOOL_ITERATIONS; iter++) {
382
+ trimHistory(state.messages)
383
+
384
+ // 首个 token 到达前转 spinner,到达后停掉并让位给流式渲染
385
+ const spinner = startSpinner(t.waiting)
386
+ let spinnerStopped = false
387
+ const stopSpinner = () => {
388
+ if (!spinnerStopped) { spinner.stop(); spinnerStopped = true }
389
+ }
390
+ // 每次 LLM 调用一个 writer:思考灰斜体、正文 ⏺ + 逐行 markdown
391
+ const writer = createAssistantWriter({
392
+ showThinking: state.showThinking,
393
+ thinkingHeader: t.thinkingLabel,
394
+ })
395
+ // MiniMax 系把 <think> 标签内联在 content 流里,用过滤器剥离并置灰显示
396
+ // (历史里仍保存模型原始输出,见 streamChatOnce 返回的 content)
397
+ const thinkFilter = createThinkFilter()
398
+ const renderSeg = (seg) => {
399
+ const kind = seg.content !== undefined ? 'content' : 'thinking'
400
+ const text = seg.content ?? seg.thinking
401
+ if (!text) return
402
+ stopSpinner()
403
+ if (kind === 'thinking') writer.writeThinking(text)
404
+ else writer.writeContent(text)
405
+ }
406
+
407
+ let result
408
+ try {
409
+ result = await streamChatOnce({
410
+ model: state.model,
411
+ messages: state.messages,
412
+ signal: state.abortController?.signal,
413
+ onToken: ({ thinking, content }) => {
414
+ if (thinking) renderSeg({ thinking })
415
+ if (content) for (const seg of thinkFilter.feed(content)) renderSeg(seg)
416
+ },
417
+ })
418
+ // 流结束,冲刷过滤器里残留的半拉标签/未闭合 think 块
419
+ for (const seg of thinkFilter.flush()) renderSeg(seg)
420
+ } catch (err) {
421
+ stopSpinner()
422
+ writer.finish()
423
+ printError(t.llmError(err.message))
424
+ // 请求失败时,如果最后一条仍是本轮塞进去的 user 消息则撤掉,
425
+ // 避免历史里留一条没有回应的消息;若已进入工具循环(末尾是 tool
426
+ // 结果),消息链本身是完整的,保持不动
427
+ const last = state.messages[state.messages.length - 1]
428
+ if (last?.role === 'user') state.messages.pop()
429
+ return
430
+ }
431
+ stopSpinner()
432
+ writer.finish()
433
+
434
+ if (result.aborted) {
435
+ printWarn('⛔ 已中止')
436
+ return
437
+ }
438
+
439
+ const { content, toolCalls } = result
440
+
441
+ // 无工具调用:本轮结束,assistant 文本入历史
442
+ if (toolCalls.length === 0) {
443
+ state.messages.push({ role: 'assistant', content: content || '' })
444
+ return
445
+ }
446
+
447
+ // 有工具调用:assistant(带 tool_calls)入历史,然后逐个执行
448
+ state.messages.push({ role: 'assistant', content: content || '', tool_calls: toolCalls })
449
+
450
+ for (const tc of toolCalls) {
451
+ const name = tc.function?.name || ''
452
+ const rawArgs = tc.function?.arguments || ''
453
+
454
+ let args
455
+ try {
456
+ args = rawArgs ? JSON.parse(rawArgs) : {}
457
+ } catch {
458
+ printToolHeader(name, printDimInline(rawArgs))
459
+ const errResult = `错误: 工具参数不是合法 JSON: ${rawArgs.slice(0, 200)}`
460
+ printToolResult(errResult)
461
+ state.messages.push({ role: 'tool', tool_call_id: tc.id || name, name, content: errResult })
462
+ continue
463
+ }
464
+
465
+ printToolHeader(name, summarizeToolArgs(name, args, { chars: t.chars }))
466
+ // 长命令执行期间给个 spinner,让用户知道没有卡死
467
+ const toolSpinner = startSpinner(t.toolRunning(name))
468
+ const output = await executeTool(name, args, state.ctx)
469
+ toolSpinner.stop()
470
+ printToolResult(output)
471
+ state.messages.push({ role: 'tool', tool_call_id: tc.id || name, name, content: output })
472
+ }
473
+ // 工具结果全部入历史后继续循环,让模型基于结果决定下一步
474
+ }
475
+
476
+ printWarn(t.toolIterLimit(MAX_TOOL_ITERATIONS))
477
+ }
478
+
479
+ // JSON 解析失败时的参数回显:单行截断,不进 summarizeToolArgs(它没有结构化参数可用)
480
+ function printDimInline(rawArgs) {
481
+ let preview = String(rawArgs || '').replace(/\s+/g, ' ').trim()
482
+ if (preview.length > 160) preview = preview.slice(0, 160) + '…'
483
+ return preview
484
+ }
485
+
486
+ // ──────────────────────────────────────────────
487
+ // 斜杠命令
488
+ // ──────────────────────────────────────────────
489
+ function printSlashHelp(t, locale) {
490
+ const zh = !String(locale || '').startsWith('en')
491
+ const lines = zh ? [
492
+ ' /help 显示本帮助',
493
+ ' /model 列出可用模型;/model <序号> 切换模型',
494
+ ' /cd <路径> 切换智能体工作目录',
495
+ ' /image [路径] 查看待发送图片;/image <路径> 附加本地图片;/image clear 清除',
496
+ ' /think 开关思考过程显示',
497
+ ' /clear 清空对话历史',
498
+ ' /exit, /quit 退出',
499
+ '',
500
+ ' Alt+V 粘贴剪贴板图片,随下一条消息发送(需视觉模型)',
501
+ '',
502
+ '权限说明: 工作目录内全部操作直接执行;其他目录可读写;',
503
+ '仅系统级破坏命令(格式化、删根目录、关机等)会被安全守卫拦截。',
504
+ ] : [
505
+ ' /help Show this help',
506
+ ' /model List models; /model <n> to switch',
507
+ ' /cd <path> Change agent working directory',
508
+ ' /image [path] List pending images; attach a file; /image clear to reset',
509
+ ' /think Toggle thinking display',
510
+ ' /clear Clear conversation history',
511
+ ' /exit, /quit Quit',
512
+ '',
513
+ ' Alt+V Paste clipboard image (needs a vision-capable model)',
514
+ '',
515
+ 'Permissions: full access in the working directory; other dirs readable/writable;',
516
+ 'only system-destroying commands (format, rm -rf /, shutdown, ...) are blocked.',
517
+ ]
518
+ printHelpPanel(t.helpTitle, lines)
519
+ }
520
+
521
+ async function handleSlashCommand(state, input, t) {
522
+ const [cmd, ...rest] = input.split(/\s+/)
523
+ const arg = rest.join(' ').trim()
524
+
525
+ if (cmd === '/exit' || cmd === '/quit') return 'exit'
526
+ if (cmd === '/help') { printSlashHelp(t, state.locale); return 'ok' }
527
+ if (cmd === '/clear') {
528
+ state.messages = [state.messages[0]]
529
+ printOk(t.cleared)
530
+ return 'ok'
531
+ }
532
+ if (cmd === '/think') {
533
+ state.showThinking = !state.showThinking
534
+ printOk(state.showThinking ? t.thinkOn : t.thinkOff)
535
+ return 'ok'
536
+ }
537
+ if (cmd === '/image') {
538
+ if (arg === 'clear') {
539
+ state.pendingImages = []
540
+ printOk(t.imageCleared)
541
+ return 'ok'
542
+ }
543
+ if (!arg) {
544
+ if (state.pendingImages.length === 0) {
545
+ printDim(t.imageListEmpty)
546
+ } else {
547
+ console.log(chalk.bold(t.imageListTitle) + ':')
548
+ state.pendingImages.forEach((img, i) => {
549
+ console.log(` ${i + 1}. ${img.path} ${chalk.dim('(' + formatBytes(img.bytes) + ')')}`)
550
+ })
551
+ }
552
+ return 'ok'
553
+ }
554
+ const { resolve } = await import('node:path')
555
+ const target = resolve(state.ctx.cwd, arg)
556
+ const img = await checkImageFile(target)
557
+ if (!img) {
558
+ printError(t.imageBadPath(target))
559
+ return 'ok'
560
+ }
561
+ state.pendingImages.push(img)
562
+ printOk(t.imageAttached(state.pendingImages.length, formatBytes(img.bytes)))
563
+ return 'ok'
564
+ }
565
+ if (cmd === '/model') {
566
+ if (!arg) {
567
+ console.log(chalk.bold(t.availableModels) + ':')
568
+ state.models.forEach((m, i) => {
569
+ const current = m === state.model ? chalk.green(' ← ' + t.currentModel) : ''
570
+ const keyHint = m.apiKey ? '' : chalk.yellow(' (无 apiKey)')
571
+ console.log(` ${i + 1}. ${modelLabel(m)} ${chalk.dim(m.baseURL || '')}${keyHint}${current}`)
572
+ })
573
+ console.log(chalk.dim('/model <序号> 切换'))
574
+ return 'ok'
575
+ }
576
+ const idx = Number.parseInt(arg, 10)
577
+ if (!Number.isFinite(idx) || idx < 1 || idx > state.models.length) {
578
+ printError(t.invalidModelIndex)
579
+ return 'ok'
580
+ }
581
+ state.model = state.models[idx - 1]
582
+ printOk(t.modelSwitched(modelLabel(state.model)))
583
+ return 'ok'
584
+ }
585
+ if (cmd === '/cd') {
586
+ if (!arg) { console.log(state.ctx.cwd); return 'ok' }
587
+ const { resolve } = await import('node:path')
588
+ const { promises: fsp } = await import('node:fs')
589
+ const target = resolve(state.ctx.cwd, arg)
590
+ try {
591
+ const st = await fsp.stat(target)
592
+ if (!st.isDirectory()) throw new Error('not a dir')
593
+ state.ctx.cwd = target
594
+ // 同步更新 system prompt 里的 cwd 说明(重建首条消息)
595
+ state.messages[0] = { role: 'system', content: buildSystemPrompt({ cwd: target, locale: state.locale, shellDesc: state.shellDesc }) }
596
+ printOk(t.cdOk(target))
597
+ } catch {
598
+ printError(t.cdFail(target))
599
+ }
600
+ return 'ok'
601
+ }
602
+ printWarn(`未知命令: ${cmd},输入 /help 查看可用命令`)
603
+ return 'ok'
604
+ }
605
+
606
+ // ──────────────────────────────────────────────
607
+ // 主入口
608
+ // ──────────────────────────────────────────────
609
+ export async function runAiAgent(argv = []) {
610
+ const cfg = await config.loadConfig()
611
+ const locale = cfg.locale || 'zh-CN'
612
+ const t = makeStrings(locale)
613
+
614
+ // g ai --help:智能体专属帮助,不触发主 CLI 的 showHelp
615
+ if (argv.includes('--help') || argv.includes('-h')) {
616
+ printSlashHelp(t, locale)
617
+ return
618
+ }
619
+
620
+ const models = Array.isArray(cfg.models) ? cfg.models : []
621
+ if (models.length === 0) {
622
+ console.error(chalk.red(t.noModel))
623
+ process.exitCode = 1
624
+ return
625
+ }
626
+
627
+ // --model=<序号|名称> 选择模型;默认 isDefault,否则第一个
628
+ let model = models.find(m => m.isDefault) || models[0]
629
+ const modelArg = argv.find(a => a.startsWith('--model='))
630
+ if (modelArg) {
631
+ const q = modelArg.split('=')[1]
632
+ const idx = Number.parseInt(q, 10)
633
+ if (Number.isFinite(idx) && idx >= 1 && idx <= models.length) {
634
+ model = models[idx - 1]
635
+ } else {
636
+ const found = models.find(m => modelLabel(m).toLowerCase().includes(String(q).toLowerCase()))
637
+ if (found) model = found
638
+ else printWarn(t.unknownModel(q))
639
+ }
640
+ }
641
+
642
+ const cwd = getCwd()
643
+ const shellDesc = process.platform === 'win32'
644
+ ? 'cmd.exe (Windows CMD)'
645
+ : (process.env.SHELL || '/bin/sh')
646
+
647
+ const state = {
648
+ messages: [{ role: 'system', content: buildSystemPrompt({ cwd, locale, shellDesc }) }],
649
+ ctx: {
650
+ cwd,
651
+ onChild: (child) => {
652
+ state.currentChild = child
653
+ // 子进程退出后清掉引用,避免 SIGINT 时 kill 一个已结束的进程
654
+ child.once?.('exit', () => { if (state.currentChild === child) state.currentChild = null })
655
+ },
656
+ },
657
+ models,
658
+ model,
659
+ locale,
660
+ shellDesc,
661
+ currentChild: null,
662
+ abortController: null,
663
+ busy: false,
664
+ showThinking: true, // /think 切换:是否回显模型的思考过程
665
+ pendingImages: [], // Alt+V / /image 附加的待发送图片 [{path, bytes}]
666
+ pasting: false, // 剪贴板读取进行中(防 Alt+V 连打并发)
667
+ }
668
+
669
+ // SIGINT 时中止进行中的 LLM 请求 + 正在跑的子命令
670
+ // (进程退出与全局子进程 drain 由 gitCommit.js 的统一 handler 负责,这里只做快速止血)
671
+ registerCleanup('aiAgent', () => {
672
+ try { state.abortController?.abort() } catch (_) {}
673
+ try { state.currentChild?.kill('SIGTERM') } catch (_) {}
674
+ })
675
+
676
+ // 单发模式:g ai "帮我改个 bug"(位置参数拼成 prompt,跑一轮就退出)
677
+ const oneShot = argv.filter(a => !a.startsWith('-')).join(' ').trim()
678
+ if (oneShot) {
679
+ console.log(chalk.dim(`[${t.bannerModel}] ${modelLabel(model)} · [${t.bannerCwd}] ${cwd}`))
680
+ await runAgentTurn(state, oneShot, t)
681
+ printDim(t.oneShotDone)
682
+ return
683
+ }
684
+
685
+ // ── 交互模式(REPL)──
686
+ printBanner({
687
+ title: 'g ai — AI Agent',
688
+ modelLabel: modelLabel(model),
689
+ baseURL: model.baseURL || '',
690
+ cwd,
691
+ modelText: t.bannerModel,
692
+ cwdText: t.bannerCwd,
693
+ tip: t.bannerTip,
694
+ })
695
+
696
+ const rl = readline.createInterface({
697
+ input: process.stdin,
698
+ output: process.stdout,
699
+ prompt: chalk.cyan.bold(t.prompt),
700
+ historySize: 200,
701
+ })
702
+
703
+ // 输入提示符管理。
704
+ //
705
+ // readline 的 _refreshLine() 用 prevRows 追踪“提示符占了几行”,
706
+ // 下次刷新时 moveCursor(0, -prevRows) 上移光标。但模型输出直接写
707
+ // stdout,readline 不知道这些行,导致 prevRows 过期 → 光标错位。
708
+ //
709
+ // 修复:每次画提示符前,先写 \r\n 确保光标在新行第 0 列,
710
+ // 然后重置 prevRows=0 告诉 readline “当前行就是提示符行”。
711
+ // 这样 _refreshLine() 的 clearScreenDown() 只清当前空行,不影响模型输出。
712
+
713
+ const showPrompt = () => {
714
+ if (rl.closed) return
715
+ // 双保险:确保 stdin 处于 flowing 模式(ora 的 stdin-discarder 可能在
716
+ // stop() 时 pause 了 stdin,即使 discardStdin:false 已设,仍防止其他
717
+ // 库意外 pause)
718
+ if (process.stdin.isTTY && !process.stdin.readableFlowing) {
719
+ process.stdin.resume()
720
+ }
721
+ if (process.stdout.isTTY) {
722
+ // 确保光标在新行第 0 列(\r 回行首,\n 换行)
723
+ process.stdout.write('\r\n')
724
+ // 重置 readline 内部光标追踪
725
+ rl.prevRows = 0
726
+ }
727
+ rl.prompt()
728
+ }
729
+
730
+ // 安全版:防止 finally 块里的异常变成 unhandledRejection 杀掉会话
731
+ const safeShowPrompt = () => {
732
+ try {
733
+ showPrompt()
734
+ } catch (_) {
735
+ try { rl.prompt() } catch { /* 给予宽容 */ }
736
+ }
737
+ }
738
+
739
+ // 仅重画提示符,不写 \r\n(用于空输入、busy 等不需要换行的场景)
740
+ const safeRefreshPrompt = () => {
741
+ try {
742
+ if (rl.closed) return
743
+ rl.prevRows = 0
744
+ rl.prompt()
745
+ } catch (_) {
746
+ try { rl.prompt() } catch { /* noop */ }
747
+ }
748
+ }
749
+
750
+ // readline 的 clearLine() 已经写了 \r\n,不需要我们再补换行。
751
+ const closeInputFrame = () => {}
752
+
753
+ // 在提示符上方插一行通知(粘贴进度等)。
754
+ const notifyAbovePrompt = (text) => {
755
+ console.log(text)
756
+ if (!state.busy) safeShowPrompt()
757
+ }
758
+
759
+ // Alt+V 粘贴剪贴板图片(node 把 ESC+v 解析为 meta+v;部分终端不发 Alt,可用 /image 兜底)
760
+ const pasteFromClipboard = async () => {
761
+ if (state.pasting) return
762
+ state.pasting = true
763
+ try {
764
+ notifyAbovePrompt(chalk.dim(t.imagePasting))
765
+ const img = await readClipboardImage()
766
+ if (!img) {
767
+ notifyAbovePrompt(chalk.yellow(t.imageEmpty))
768
+ } else {
769
+ state.pendingImages.push(img)
770
+ notifyAbovePrompt(chalk.green(t.imageAttached(state.pendingImages.length, formatBytes(img.bytes))))
771
+ }
772
+ } catch (err) {
773
+ // 不能让粘贴异常变成 unhandledRejection 杀掉会话
774
+ printError(`粘贴图片失败: ${err.message}`)
775
+ } finally {
776
+ state.pasting = false
777
+ }
778
+ }
779
+ rl.input.on('keypress', (ch, key) => {
780
+ if (!key) return
781
+ const isAltV = (key.meta && key.name === 'v')
782
+ || (typeof key.sequence === 'string' && key.sequence.length === 2
783
+ && key.sequence.charCodeAt(0) === 27 && key.sequence[1] === 'v')
784
+ if (isAltV) pasteFromClipboard()
785
+ })
786
+
787
+ // 初始提示符:banner 后直接画(不需要 \r\n)
788
+ rl.prevRows = 0
789
+ rl.prompt()
790
+
791
+ rl.on('line', async (line) => {
792
+ const input = line.trim()
793
+
794
+ if (input.startsWith('/')) {
795
+ const r = await handleSlashCommand(state, input, t)
796
+ if (r === 'exit') { rl.close(); return }
797
+ safeRefreshPrompt()
798
+ return
799
+ }
800
+
801
+ if (!input) { safeRefreshPrompt(); return }
802
+ if (state.busy) {
803
+ printDim(t.busy)
804
+ safeRefreshPrompt()
805
+ return
806
+ }
807
+
808
+ // 取出待发送图片(取出即清空队列,用户每条消息独立决定带不带图)
809
+ const images = state.pendingImages.splice(0)
810
+ if (images.length > 0) printDim(t.imageSending(images.length))
811
+
812
+ state.busy = true
813
+ state.abortController = new AbortController()
814
+ try {
815
+ await runAgentTurn(state, input, t, images)
816
+ } catch (err) {
817
+ printError(err.message)
818
+ } finally {
819
+ state.busy = false
820
+ state.abortController = null
821
+ safeShowPrompt()
822
+ }
823
+ })
824
+
825
+ rl.on('close', () => {
826
+ printWarn('\n' + t.bye)
827
+ })
828
+
829
+ // 全局安全网:任何未捕获的 Promise rejection / 异常都不应该静默杀掉
830
+ // 整个 REPL 会话。这里记录到 stderr 但不退出,让 readline 继续运行。
831
+ const rejectionHandler = (reason) => {
832
+ const msg = reason instanceof Error ? reason.message : String(reason)
833
+ printError(`内部错误(已捕获,会话继续): ${msg}`)
834
+ safeShowPrompt()
835
+ }
836
+ process.on('unhandledRejection', rejectionHandler)
837
+
838
+ // 等 readline 关闭后返回(进程随后自然退出)
839
+ await new Promise((resolve) => rl.on('close', resolve))
840
+
841
+ // 清理:移除本次会话注册的全局处理器
842
+ process.removeListener('unhandledRejection', rejectionHandler)
843
+ }
844
+
845
+ export default { runAiAgent }