zen-gitsync 2.15.34 → 2.16.2
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.
- package/README.md +37 -3
- package/package.json +1 -1
- package/src/cli/ai/agent.js +493 -83
- package/src/cli/ai/agent.test.js +190 -0
- package/src/cli/ai/images.js +211 -0
- package/src/cli/ai/images.test.js +98 -0
- package/src/cli/ai/modelSetup.js +594 -0
- package/src/cli/ai/modelSetup.test.js +496 -0
- package/src/cli/ai/platformGuard.js +200 -0
- package/src/cli/ai/platformGuard.test.js +253 -0
- package/src/cli/ai/streamFilter.js +82 -0
- package/src/cli/ai/streamFilter.test.js +88 -0
- package/src/cli/ai/termui.js +453 -0
- package/src/cli/ai/termui.test.js +253 -0
- package/src/cli/ai/tools.js +54 -18
- package/src/cli/ai/tools.test.js +28 -0
- package/src/ui/public/assets/AppVersionBadge-BTznOVSJ.js +2 -0
- package/src/ui/public/assets/AppVersionBadge-CS7Trvp-.css +1 -0
- package/src/ui/public/assets/BranchSelector-B0eLkAdv.css +1 -0
- package/src/ui/public/assets/BranchSelector-BISqgXoZ.js +1 -0
- package/src/ui/public/assets/CommitForm-C7FmI0Sl.js +6 -0
- package/src/ui/public/assets/CommitForm-DdK8dymc.css +1 -0
- package/src/ui/public/assets/CommonDialog-BA4xg5l3.js +1 -0
- package/src/ui/public/assets/CommonDialog-ChSKsEz2.css +1 -0
- package/src/ui/public/assets/EditorView-BVod-rjn.css +1 -0
- package/src/ui/public/assets/EditorView-CSWy4q7G.js +0 -0
- package/src/ui/public/assets/FlowExecutionViewer-CZdF1f1e.js +1 -0
- package/src/ui/public/assets/FlowExecutionViewer-jNHI5zdm.css +1 -0
- package/src/ui/public/assets/FlowOrchestrationWorkspace-BECFOAcQ.js +21 -0
- package/src/ui/public/assets/FlowOrchestrationWorkspace-CYGp-PqS.css +1 -0
- package/src/ui/public/assets/LogList-CeFFytjB.css +1 -0
- package/src/ui/public/assets/LogList-DYoGtOou.js +15 -0
- package/src/ui/public/assets/MindmapView-3rMfOoNm.css +1 -0
- package/src/ui/public/assets/MindmapView-CU3tXgbR.js +1 -0
- package/src/ui/public/assets/MonitorView-CSAqU7oV.js +1 -0
- package/src/ui/public/assets/MonitorView-TaS4kL0Y.css +1 -0
- package/src/ui/public/assets/ProjectStartupButton-2By0-fuq.js +1 -0
- package/src/ui/public/assets/ProjectStartupButton-QLPltCK5.css +1 -0
- package/src/ui/public/assets/RemoteRepoCard-BRGNlbeO.css +1 -0
- package/src/ui/public/assets/RemoteRepoCard-DHsJwTEX.js +5 -0
- package/src/ui/public/assets/SourceMapView-BNRjvgzH.css +1 -0
- package/src/ui/public/assets/SourceMapView-DskfUia5.js +3 -0
- package/src/ui/public/assets/SvgIcon-BqWp2WS8.js +1 -0
- package/src/ui/public/assets/{SvgIcon-BQ6bR7l0.css → SvgIcon-CDNsk4-Q.css} +1 -1
- package/src/ui/public/assets/UserInputNode-BVgQkB2l.css +1 -0
- package/src/ui/public/assets/UserInputNode-Ccnh0e-0.js +1 -0
- package/src/ui/public/assets/WorkbenchView-1q0xaqyV.js +28 -0
- package/src/ui/public/assets/{WorkbenchView-CQfjF0Gr.css → WorkbenchView-DbF4hH5s.css} +1 -1
- package/src/ui/public/assets/{_plugin-vue_export-helper-B7s-K_Aj.js → _plugin-vue_export-helper-BQRl7WAH.js} +1 -1
- package/src/ui/public/assets/{configStore-DPGao81A.js → configStore-BkxDLXGa.js} +1 -1
- package/src/ui/public/assets/element-plus-DNnePjco.js +32 -0
- package/src/ui/public/assets/{flow-mindmap-5SDymYbY.js → flow-mindmap-DRmzYE4F.js} +1 -1
- package/src/ui/public/assets/index-Br2VZQBT.js +68 -0
- package/src/ui/public/assets/{index-CWoujklW.css → index-CaIu73UM.css} +1 -1
- package/src/ui/public/assets/socket-io-pmu5tRPo.js +1 -0
- package/src/ui/public/assets/{vendor-BteTDIoK.js → vendor-CcK8n8T4.js} +44 -37
- package/src/ui/public/assets/vue-flow-pAz--378.js +39 -0
- package/src/ui/public/index.html +12 -12
- package/src/ui/public/assets/AppVersionBadge-DL0Y81fU.js +0 -2
- package/src/ui/public/assets/AppVersionBadge-Dj6H729N.css +0 -1
- package/src/ui/public/assets/BranchSelector-B_VMrSKv.js +0 -1
- package/src/ui/public/assets/BranchSelector-T_rJm-JY.css +0 -1
- package/src/ui/public/assets/CommitForm-BNuwp3ha.js +0 -6
- package/src/ui/public/assets/CommitForm-CUvl0VcF.css +0 -1
- package/src/ui/public/assets/CommonDialog-C2dbNSrw.js +0 -1
- package/src/ui/public/assets/CommonDialog-Cyh3xu0p.css +0 -1
- package/src/ui/public/assets/EditorView-BdTQsQw7.js +0 -0
- package/src/ui/public/assets/EditorView-CRYGVw_r.css +0 -1
- package/src/ui/public/assets/FlowExecutionViewer-B5kh7VxS.css +0 -1
- package/src/ui/public/assets/FlowExecutionViewer-D5DBcy8S.js +0 -1
- package/src/ui/public/assets/FlowOrchestrationWorkspace-BAQDU9Jd.css +0 -1
- package/src/ui/public/assets/FlowOrchestrationWorkspace-omHN8HaT.js +0 -21
- package/src/ui/public/assets/LogList-DJYpDUTZ.css +0 -1
- package/src/ui/public/assets/LogList-Dc1RMEFq.js +0 -7
- package/src/ui/public/assets/MindmapView-BPTh-XAf.js +0 -1
- package/src/ui/public/assets/MindmapView-CWoqfZRL.css +0 -1
- package/src/ui/public/assets/MonitorView-DxFogQIt.css +0 -1
- package/src/ui/public/assets/MonitorView-RTbnLZlO.js +0 -1
- package/src/ui/public/assets/ProjectStartupButton-B7zvqRj_.css +0 -1
- package/src/ui/public/assets/ProjectStartupButton-CcQ8SaPb.js +0 -1
- package/src/ui/public/assets/RemoteRepoCard-Cr1yQ4pF.js +0 -1
- package/src/ui/public/assets/RemoteRepoCard-d0klonVb.css +0 -1
- package/src/ui/public/assets/SourceMapView-BbiYmCHB.js +0 -3
- package/src/ui/public/assets/SourceMapView-DG_a_076.css +0 -1
- package/src/ui/public/assets/SvgIcon-D3jL_b3c.js +0 -1
- package/src/ui/public/assets/UserInputNode-DCk_cvaT.js +0 -1
- package/src/ui/public/assets/UserInputNode-DDtvv9Iq.css +0 -1
- package/src/ui/public/assets/WorkbenchView-KX3nKGZc.js +0 -12
- package/src/ui/public/assets/element-plus-BAcS68uS.js +0 -24
- package/src/ui/public/assets/index-B8oXZO4w.js +0 -38
- package/src/ui/public/assets/socket-io-BC2cap_-.js +0 -1
- package/src/ui/public/assets/vue-flow-CtZ_ZWKZ.js +0 -39
package/src/cli/ai/agent.js
CHANGED
|
@@ -21,9 +21,15 @@
|
|
|
21
21
|
// isDefault 优先,否则取第一个);--model=<序号|名称> 或会话内 /model 切换
|
|
22
22
|
// - OpenAI 兼容流式 function calling:模型可以调用 run_command /
|
|
23
23
|
// read_file / write_file / edit_file / list_files / search_text
|
|
24
|
+
// - 图片:Alt+V 粘贴剪贴板图片、/image <路径> 附加本地图片,
|
|
25
|
+
// 以多模态(image_url)消息发给模型(需模型支持视觉)
|
|
24
26
|
// - 权限:启动目录内全开放,其他目录可读写;唯一红线由 safety.js 拦截
|
|
25
27
|
// (格式化磁盘、删根目录、关机等系统级破坏操作)
|
|
26
28
|
//
|
|
29
|
+
// 视觉风格(termui.js):对标 Codex CLI / Claude Code / OpenCode —
|
|
30
|
+
// 盒式输入框、ora 等待 spinner、✻ 思考灰斜体、⏺ 工具块 + ⎿ 结果槽、
|
|
31
|
+
// 正文逐行轻量 markdown(**bold** / `code` / # 标题 / ``` 围栏 / - 列表)
|
|
32
|
+
//
|
|
27
33
|
// 使用方式:
|
|
28
34
|
// g ai 交互模式(REPL)
|
|
29
35
|
// g ai "帮我修个 bug" 单发模式:执行一轮后退出
|
|
@@ -38,9 +44,21 @@ import readline from 'node:readline'
|
|
|
38
44
|
import chalk from 'chalk'
|
|
39
45
|
import config from '../../config.js'
|
|
40
46
|
import { getCwd } from '../../utils/index.js'
|
|
41
|
-
import { boxenAdaptive } from '../ui.js'
|
|
42
47
|
import { registerCleanup } from '../cleanup.js'
|
|
43
48
|
import { TOOL_DEFINITIONS, executeTool } from './tools.js'
|
|
49
|
+
import { createThinkFilter } from './streamFilter.js'
|
|
50
|
+
import {
|
|
51
|
+
printBanner, printHelpPanel,
|
|
52
|
+
filterSlashCommands, renderSlashHintBody,
|
|
53
|
+
startSpinner, createAssistantWriter,
|
|
54
|
+
summarizeToolArgs, printToolHeader, printToolResult,
|
|
55
|
+
printOk, printWarn, printError, printDim,
|
|
56
|
+
} from './termui.js'
|
|
57
|
+
import { readClipboardImage, checkImageFile, imageToDataUrl, formatBytes } from './images.js'
|
|
58
|
+
import { runModelSetup, collectModelInput, buildModelConfig } from './modelSetup.js'
|
|
59
|
+
|
|
60
|
+
// truncateDisplay 已迁移到 termui.js;这里 re-export 保持既有测试/外部引用不断
|
|
61
|
+
export { truncateDisplay } from './termui.js'
|
|
44
62
|
|
|
45
63
|
// ──────────────────────────────────────────────
|
|
46
64
|
// 常量
|
|
@@ -48,8 +66,6 @@ import { TOOL_DEFINITIONS, executeTool } from './tools.js'
|
|
|
48
66
|
const MAX_TOOL_ITERATIONS = 40 // 单轮用户输入允许的最大工具调用循环数(防失控)
|
|
49
67
|
const MAX_HISTORY_MESSAGES = 40 // 历史消息上限(超出后从最旧的整段对话裁剪)
|
|
50
68
|
const LLM_TIMEOUT_MS = 300000 // 单次 LLM 请求超时(5 分钟,长推理模型够用)
|
|
51
|
-
const DISPLAY_ARGS_LIMIT = 160 // 终端回显工具参数的截断长度
|
|
52
|
-
const DISPLAY_RESULT_LIMIT = 600 // 终端回显工具结果的截断长度(完整结果仍进上下文)
|
|
53
69
|
|
|
54
70
|
// ──────────────────────────────────────────────
|
|
55
71
|
// i18n 字符串表(CLI 其他部分以中文为主,这里按 locale 给双语)
|
|
@@ -58,12 +74,15 @@ const STRINGS = {
|
|
|
58
74
|
zh: {
|
|
59
75
|
noModel: '未配置 AI 模型,请先在 GUI 通用设置中添加模型(运行 g ui 打开设置)',
|
|
60
76
|
unknownModel: (q) => `未找到匹配的模型: ${q},使用默认模型`,
|
|
61
|
-
waiting: '
|
|
77
|
+
waiting: '思考中…',
|
|
62
78
|
llmError: (msg) => `LLM 请求失败: ${msg}`,
|
|
63
79
|
toolIterLimit: (n) => `已达单轮最大工具调用次数(${n}),本轮结束。如需继续请再发一条消息。`,
|
|
80
|
+
toolRunning: (name) => `执行 ${name}…`,
|
|
64
81
|
busy: '智能体正在执行中,请稍候(Ctrl+C 结束会话)…',
|
|
65
82
|
bye: '已退出 g ai',
|
|
66
83
|
cleared: '对话历史已清空',
|
|
84
|
+
thinkOn: '思考过程显示: 开',
|
|
85
|
+
thinkOff: '思考过程显示: 关(模型仍会思考,只是不回显)',
|
|
67
86
|
modelSwitched: (label) => `已切换模型: ${label}`,
|
|
68
87
|
invalidModelIndex: '无效的模型序号',
|
|
69
88
|
cdOk: (p) => `工作目录已切换: ${p}`,
|
|
@@ -71,21 +90,39 @@ const STRINGS = {
|
|
|
71
90
|
currentModel: '当前模型',
|
|
72
91
|
availableModels: '可用模型',
|
|
73
92
|
oneShotNoModel: '未配置模型,无法启动',
|
|
93
|
+
oneShotDone: '单发模式完成 · 需要多轮连续对话请直接运行 g ai(不带参数)',
|
|
74
94
|
helpTitle: 'g ai 命令',
|
|
75
|
-
bannerTip: '/help 查看命令 · /exit 退出 · Ctrl+C 结束会话',
|
|
95
|
+
bannerTip: 'Alt+V 粘贴图片 · /help 查看命令 · /exit 退出 · Ctrl+C 结束会话',
|
|
76
96
|
prompt: '❯ ',
|
|
77
97
|
bannerModel: '模型',
|
|
78
98
|
bannerCwd: '目录',
|
|
99
|
+
thinkingLabel: '✻ 思考',
|
|
100
|
+
chars: '字符',
|
|
101
|
+
imagePasting: '正在读取剪贴板图片…',
|
|
102
|
+
imageAttached: (n, size) => `📎 图片 #${n} 已附加(${size}),将随下一条消息发送`,
|
|
103
|
+
imageEmpty: '剪贴板中没有图片;可先截图再按 Alt+V,或用 /image <路径> 附加本地图片',
|
|
104
|
+
imageBadPath: (p) => `图片不存在或格式不支持: ${p}(支持 png/jpg/jpeg/gif/webp/bmp)`,
|
|
105
|
+
imageListTitle: '待发送图片',
|
|
106
|
+
imageListEmpty: '当前没有待发送的图片(Alt+V 粘贴或 /image <路径> 附加)',
|
|
107
|
+
imageCleared: '已清除待发送的图片',
|
|
108
|
+
imageSending: (n) => `📎 附带 ${n} 张图片`,
|
|
109
|
+
addModelTitle: '添加模型配置',
|
|
110
|
+
addModelSaved: (name) => `✓ 模型 "${name}" 已添加并切换为当前模型`,
|
|
111
|
+
addModelCancelled: '已取消添加模型',
|
|
112
|
+
addModelSaveError: (msg) => `保存配置失败: ${msg}`,
|
|
79
113
|
},
|
|
80
114
|
en: {
|
|
81
115
|
noModel: 'No AI model configured. Add one in GUI settings first (run `g ui`).',
|
|
82
116
|
unknownModel: (q) => `No model matching "${q}", falling back to default`,
|
|
83
|
-
waiting: '
|
|
117
|
+
waiting: 'Thinking…',
|
|
84
118
|
llmError: (msg) => `LLM request failed: ${msg}`,
|
|
85
119
|
toolIterLimit: (n) => `Hit max tool iterations (${n}) for this turn. Send another message to continue.`,
|
|
120
|
+
toolRunning: (name) => `Running ${name}…`,
|
|
86
121
|
busy: 'Agent is working, please wait (Ctrl+C to end session)…',
|
|
87
122
|
bye: 'Bye',
|
|
88
123
|
cleared: 'Conversation cleared',
|
|
124
|
+
thinkOn: 'Thinking display: on',
|
|
125
|
+
thinkOff: 'Thinking display: off (model still thinks, just hidden)',
|
|
89
126
|
modelSwitched: (label) => `Switched model: ${label}`,
|
|
90
127
|
invalidModelIndex: 'Invalid model index',
|
|
91
128
|
cdOk: (p) => `Working directory changed: ${p}`,
|
|
@@ -93,11 +130,26 @@ const STRINGS = {
|
|
|
93
130
|
currentModel: 'Current model',
|
|
94
131
|
availableModels: 'Available models',
|
|
95
132
|
oneShotNoModel: 'No model configured, aborting',
|
|
133
|
+
oneShotDone: 'One-shot done · for multi-turn chat run `g ai` with no arguments',
|
|
96
134
|
helpTitle: 'g ai commands',
|
|
97
|
-
bannerTip: '/help for commands · /exit to quit · Ctrl+C to end session',
|
|
135
|
+
bannerTip: 'Alt+V paste image · /help for commands · /exit to quit · Ctrl+C to end session',
|
|
98
136
|
prompt: '❯ ',
|
|
99
137
|
bannerModel: 'Model',
|
|
100
138
|
bannerCwd: 'CWD',
|
|
139
|
+
thinkingLabel: '✻ Thinking',
|
|
140
|
+
chars: 'chars',
|
|
141
|
+
imagePasting: 'Reading clipboard image…',
|
|
142
|
+
imageAttached: (n, size) => `📎 Image #${n} attached (${size}); sent with your next message`,
|
|
143
|
+
imageEmpty: 'No image in clipboard; take a screenshot first, or use /image <path>',
|
|
144
|
+
imageBadPath: (p) => `Not a supported image: ${p} (png/jpg/jpeg/gif/webp/bmp)`,
|
|
145
|
+
imageListTitle: 'Pending images',
|
|
146
|
+
imageListEmpty: 'No pending images (Alt+V to paste, or /image <path>)',
|
|
147
|
+
imageCleared: 'Pending images cleared',
|
|
148
|
+
imageSending: (n) => `📎 ${n} image(s) attached`,
|
|
149
|
+
addModelTitle: 'Add Model Configuration',
|
|
150
|
+
addModelSaved: (name) => `✓ Model "${name}" added and switched to`,
|
|
151
|
+
addModelCancelled: 'Add model cancelled',
|
|
152
|
+
addModelSaveError: (msg) => `Failed to save: ${msg}`,
|
|
101
153
|
},
|
|
102
154
|
}
|
|
103
155
|
|
|
@@ -116,6 +168,7 @@ function modelLabel(m) {
|
|
|
116
168
|
function buildSystemPrompt({ cwd, locale, shellDesc }) {
|
|
117
169
|
const zh = !String(locale || '').startsWith('en')
|
|
118
170
|
const now = new Date().toLocaleString()
|
|
171
|
+
const isWin = process.platform === 'win32'
|
|
119
172
|
if (zh) {
|
|
120
173
|
return `你是 "g ai" —— zen-gitsync CLI 内置的终端编码智能体,通过工具在用户真实电脑上完成编码任务。
|
|
121
174
|
|
|
@@ -125,6 +178,20 @@ function buildSystemPrompt({ cwd, locale, shellDesc }) {
|
|
|
125
178
|
- 当前工作目录: ${cwd}(用户在此启动 g ai,也是所有相对路径的基准)
|
|
126
179
|
- 当前时间: ${now}
|
|
127
180
|
|
|
181
|
+
# 平台兼容性(重要!)
|
|
182
|
+
- 必须使用与当前 Shell 兼容的命令,禁止盲套 Unix 写法
|
|
183
|
+
${isWin ? `- 当前是 Windows cmd.exe,以下 Unix 命令**不存在**,用了必定报"不是内部或外部命令":
|
|
184
|
+
tail / head / cat / grep / ls / sed / awk / wc / cut / uniq / xargs / which / touch
|
|
185
|
+
平台守卫会在执行前拦截这些命令,但请主动避免,不要浪费一轮调用
|
|
186
|
+
- 跨平台替代方案:
|
|
187
|
+
· 列目录 → list_files 工具 或 cmd 的 dir
|
|
188
|
+
· 搜内容 → search_text 工具 或 cmd 的 findstr
|
|
189
|
+
· 看文件 → read_file 工具 或 cmd 的 type
|
|
190
|
+
· 看输出末尾 → PowerShell "命令 | Select-Object -Last N"
|
|
191
|
+
· 文本处理 → node -e "..." 或 PowerShell
|
|
192
|
+
· 查命令路径 → cmd 的 where(不是 which)
|
|
193
|
+
- 必须跑 shell 时优先跨平台写法(如 node -e "..."),别用 Unix 专属命令` : `- 当前是 POSIX 环境,Unix 命令可用`}
|
|
194
|
+
|
|
128
195
|
# 权限(用户已明确授权,无需反复征求同意)
|
|
129
196
|
- 工作目录内:读写文件、执行命令等所有操作直接执行
|
|
130
197
|
- 其他目录:同样可以读取和修改
|
|
@@ -137,8 +204,17 @@ function buildSystemPrompt({ cwd, locale, shellDesc }) {
|
|
|
137
204
|
- 编辑文件优先 edit_file 精确替换;先 read_file 看原文,old_string 必须与文件内容完全一致(含缩进换行)
|
|
138
205
|
- 大文件用 offset/limit 分段读取,不要一次读爆上下文
|
|
139
206
|
- git 操作用 run_command 执行;提交代码可以用 git 命令,也可以用本 CLI 的 g -y(默认信息提交并推送)或 g --ai(AI 生成提交信息)
|
|
140
|
-
- run_command
|
|
207
|
+
- run_command 默认就在工作目录执行,不要再加 cd / cd /d 前缀;默认超时 120 秒,长任务加大 timeout_seconds(最大 600)
|
|
141
208
|
- 命令在 ${shellDesc} 下执行,注意语法兼容
|
|
209
|
+
- 本项目跑测试用 npm test(node --test 不支持直接传目录路径)
|
|
210
|
+
|
|
211
|
+
# 与用户交互
|
|
212
|
+
- 需要向用户确认、提问或汇报重要决策时,直接用普通文本输出 —— 用户能实时看到你的文本;
|
|
213
|
+
不要调用不存在的工具,可用工具只有上面列出的 6 个
|
|
214
|
+
- 用户可能通过 Alt+V 或 /image 附加图片:图片以 image_url 部件出现在 user 消息里;
|
|
215
|
+
如果当前模型不支持视觉(带图请求报错),提醒用户换用支持视觉的模型
|
|
216
|
+
- 发现高风险或状态不一致的情况(例如版本号 / git tag / CHANGELOG 对不上、发布前环境异常、
|
|
217
|
+
仓库状态与预期不符)时:先用文本说明发现和影响,停下来等用户指示,不要擅自继续破坏性操作
|
|
142
218
|
|
|
143
219
|
# 输出
|
|
144
220
|
- 你的文本输出直接显示在用户终端,用简体中文交流
|
|
@@ -152,6 +228,20 @@ function buildSystemPrompt({ cwd, locale, shellDesc }) {
|
|
|
152
228
|
- Working directory: ${cwd} (where the user launched g ai; base for all relative paths)
|
|
153
229
|
- Current time: ${now}
|
|
154
230
|
|
|
231
|
+
# Platform compatibility (important!)
|
|
232
|
+
- You MUST use commands compatible with the current shell. Do NOT blindly copy Unix patterns.
|
|
233
|
+
${isWin ? `- This is Windows cmd.exe. The following Unix commands do NOT exist here and will fail with "not recognized":
|
|
234
|
+
tail / head / cat / grep / ls / sed / awk / wc / cut / uniq / xargs / which / touch
|
|
235
|
+
A platform guard will block these before execution, but avoid them proactively — don't waste a turn.
|
|
236
|
+
- Cross-platform alternatives:
|
|
237
|
+
· List dirs → list_files tool, or cmd's dir
|
|
238
|
+
· Search content → search_text tool, or cmd's findstr
|
|
239
|
+
· Read files → read_file tool, or cmd's type
|
|
240
|
+
· Tail output → PowerShell "command | Select-Object -Last N"
|
|
241
|
+
· Text processing → node -e "..." or PowerShell
|
|
242
|
+
· Find executable → cmd's where (not which)
|
|
243
|
+
- For shell one-offs, prefer cross-platform forms like node -e "..."` : `- POSIX environment: Unix commands are available`}
|
|
244
|
+
|
|
155
245
|
# Permissions (explicitly granted by the user — do not keep asking)
|
|
156
246
|
- Inside the working directory: read/write files and run commands directly
|
|
157
247
|
- Other directories: may also be read and modified
|
|
@@ -163,8 +253,14 @@ function buildSystemPrompt({ cwd, locale, shellDesc }) {
|
|
|
163
253
|
- Prefer edit_file for precise replacements; read_file first, old_string must match the file exactly
|
|
164
254
|
- Read large files in segments (offset/limit)
|
|
165
255
|
- Git operations go through run_command; to commit, use git commands or this CLI's g -y / g --ai
|
|
166
|
-
- run_command
|
|
256
|
+
- run_command already executes in the working directory — do NOT prefix with cd; default timeout 120s, raise timeout_seconds (max 600) for long tasks
|
|
167
257
|
- Commands run under ${shellDesc}; keep syntax compatible
|
|
258
|
+
- Run this project's tests with npm test (node --test does not accept a bare directory)
|
|
259
|
+
|
|
260
|
+
# Talking to the user
|
|
261
|
+
- 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
|
|
262
|
+
- 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
|
|
263
|
+
- 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
|
|
168
264
|
|
|
169
265
|
# Output
|
|
170
266
|
- Your text output goes straight to the user's terminal; reply in English
|
|
@@ -271,41 +367,104 @@ function trimHistory(messages) {
|
|
|
271
367
|
}
|
|
272
368
|
|
|
273
369
|
// ──────────────────────────────────────────────
|
|
274
|
-
//
|
|
370
|
+
// 消息消毒:确保发给 LLM 的消息数组里没有空内容。
|
|
371
|
+
//
|
|
372
|
+
// 背景:部分 LLM provider(如 Moonshot/Kimi、智谱、火山引擎等)对 assistant
|
|
373
|
+
// 消息的 content 字段校验严格 —— 当模型只返回 tool_calls 而没有文本时,
|
|
374
|
+
// content 为空字符串 "" 会被拒绝并报 "chat content is empty (2013)"。
|
|
375
|
+
// OpenAI 官方规范允许 assistant 消息在带 tool_calls 时 content 为 null,
|
|
376
|
+
// 本函数将空字符串统一转为 null,并对 tool/user 消息做兜底防止空内容。
|
|
275
377
|
// ──────────────────────────────────────────────
|
|
276
|
-
function
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
378
|
+
export function sanitizeMessages(messages) {
|
|
379
|
+
for (const m of messages) {
|
|
380
|
+
if (typeof m.content !== 'string') continue
|
|
381
|
+
if (m.content === '') {
|
|
382
|
+
if (m.role === 'assistant') {
|
|
383
|
+
// assistant 带 tool_calls 时 content 用 null(OpenAI 规范);
|
|
384
|
+
// 不带 tool_calls 的空内容也用 null,provider 通常可接受
|
|
385
|
+
m.content = null
|
|
386
|
+
} else if (m.role === 'tool') {
|
|
387
|
+
m.content = '(no output)'
|
|
388
|
+
} else if (m.role === 'user') {
|
|
389
|
+
m.content = ' '
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
return messages
|
|
280
394
|
}
|
|
281
395
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
396
|
+
// ──────────────────────────────────────────────
|
|
397
|
+
// 多模态历史:base64 图片很占上下文,只保留"最近一条带图消息"里的图片,
|
|
398
|
+
// 更早消息里的 image_url 部件降级为文字占位(模型仍知道这里曾有图)
|
|
399
|
+
// ──────────────────────────────────────────────
|
|
400
|
+
export function stripStaleImages(messages, locale) {
|
|
401
|
+
const placeholder = String(locale || '').startsWith('en')
|
|
402
|
+
? '[image omitted from history]'
|
|
403
|
+
: '[图片已从历史中省略]'
|
|
404
|
+
let seenLatest = false
|
|
405
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
406
|
+
const m = messages[i]
|
|
407
|
+
if (m?.role !== 'user' || !Array.isArray(m.content)) continue
|
|
408
|
+
const hasImage = m.content.some(p => p?.type === 'image_url')
|
|
409
|
+
if (!hasImage) continue
|
|
410
|
+
if (!seenLatest) { seenLatest = true; continue }
|
|
411
|
+
m.content = m.content.map(p => p?.type === 'image_url'
|
|
412
|
+
? { type: 'text', text: placeholder }
|
|
413
|
+
: p)
|
|
414
|
+
}
|
|
287
415
|
}
|
|
288
416
|
|
|
289
417
|
// ──────────────────────────────────────────────
|
|
290
418
|
// 单轮 agent 循环:用户一句话 → 流式输出 → 工具调用 → 再调用模型 … 直到模型给出最终文本
|
|
419
|
+
// images: [{path, bytes}] 待发送图片(可为空数组)
|
|
291
420
|
// ──────────────────────────────────────────────
|
|
292
|
-
async function runAgentTurn(state, userText, t) {
|
|
293
|
-
|
|
421
|
+
async function runAgentTurn(state, userText, t, images = []) {
|
|
422
|
+
// 有图片:编码为 OpenAI 多模态 content parts;没图片保持纯字符串
|
|
423
|
+
if (images.length > 0) {
|
|
424
|
+
const parts = [{ type: 'text', text: userText }]
|
|
425
|
+
for (const img of images) {
|
|
426
|
+
try {
|
|
427
|
+
const url = await imageToDataUrl(img.path)
|
|
428
|
+
parts.push({ type: 'image_url', image_url: { url } })
|
|
429
|
+
} catch (err) {
|
|
430
|
+
printWarn(t.imageBadPath(img.path))
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
state.messages.push({ role: 'user', content: parts })
|
|
434
|
+
} else {
|
|
435
|
+
state.messages.push({ role: 'user', content: userText })
|
|
436
|
+
}
|
|
437
|
+
// 旧消息里的图片降级为占位文字,防止 base64 随对话轮次累积撑爆上下文
|
|
438
|
+
stripStaleImages(state.messages, state.locale)
|
|
294
439
|
|
|
295
440
|
for (let iter = 0; iter < MAX_TOOL_ITERATIONS; iter++) {
|
|
296
441
|
trimHistory(state.messages)
|
|
297
442
|
|
|
298
|
-
// 首个 token
|
|
299
|
-
|
|
300
|
-
let
|
|
301
|
-
const
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
443
|
+
// 首个 token 到达前转 spinner,到达后停掉并让位给流式渲染
|
|
444
|
+
const spinner = startSpinner(t.waiting)
|
|
445
|
+
let spinnerStopped = false
|
|
446
|
+
const stopSpinner = () => {
|
|
447
|
+
if (!spinnerStopped) { spinner.stop(); spinnerStopped = true }
|
|
448
|
+
}
|
|
449
|
+
// 每次 LLM 调用一个 writer:思考灰斜体、正文 ⏺ + 逐行 markdown
|
|
450
|
+
const writer = createAssistantWriter({
|
|
451
|
+
showThinking: state.showThinking,
|
|
452
|
+
thinkingHeader: t.thinkingLabel,
|
|
453
|
+
})
|
|
454
|
+
// MiniMax 系把 <think> 标签内联在 content 流里,用过滤器剥离并置灰显示
|
|
455
|
+
// (历史里仍保存模型原始输出,见 streamChatOnce 返回的 content)
|
|
456
|
+
const thinkFilter = createThinkFilter()
|
|
457
|
+
const renderSeg = (seg) => {
|
|
458
|
+
const kind = seg.content !== undefined ? 'content' : 'thinking'
|
|
459
|
+
const text = seg.content ?? seg.thinking
|
|
460
|
+
if (!text) return
|
|
461
|
+
stopSpinner()
|
|
462
|
+
if (kind === 'thinking') writer.writeThinking(text)
|
|
463
|
+
else writer.writeContent(text)
|
|
307
464
|
}
|
|
308
|
-
|
|
465
|
+
|
|
466
|
+
// 发送前消毒:确保历史里没有空 content 的消息(部分 provider 报 2013)
|
|
467
|
+
sanitizeMessages(state.messages)
|
|
309
468
|
|
|
310
469
|
let result
|
|
311
470
|
try {
|
|
@@ -314,21 +473,16 @@ async function runAgentTurn(state, userText, t) {
|
|
|
314
473
|
messages: state.messages,
|
|
315
474
|
signal: state.abortController?.signal,
|
|
316
475
|
onToken: ({ thinking, content }) => {
|
|
317
|
-
if (thinking) {
|
|
318
|
-
|
|
319
|
-
wroteThinking = true
|
|
320
|
-
process.stdout.write(chalk.dim(thinking))
|
|
321
|
-
}
|
|
322
|
-
if (content) {
|
|
323
|
-
clearWaiting()
|
|
324
|
-
if (wroteThinking) { process.stdout.write('\n'); wroteThinking = false }
|
|
325
|
-
process.stdout.write(content)
|
|
326
|
-
}
|
|
476
|
+
if (thinking) renderSeg({ thinking })
|
|
477
|
+
if (content) for (const seg of thinkFilter.feed(content)) renderSeg(seg)
|
|
327
478
|
},
|
|
328
479
|
})
|
|
480
|
+
// 流结束,冲刷过滤器里残留的半拉标签/未闭合 think 块
|
|
481
|
+
for (const seg of thinkFilter.flush()) renderSeg(seg)
|
|
329
482
|
} catch (err) {
|
|
330
|
-
|
|
331
|
-
|
|
483
|
+
stopSpinner()
|
|
484
|
+
writer.finish()
|
|
485
|
+
printError(t.llmError(err.message))
|
|
332
486
|
// 请求失败时,如果最后一条仍是本轮塞进去的 user 消息则撤掉,
|
|
333
487
|
// 避免历史里留一条没有回应的消息;若已进入工具循环(末尾是 tool
|
|
334
488
|
// 结果),消息链本身是完整的,保持不动
|
|
@@ -336,10 +490,11 @@ async function runAgentTurn(state, userText, t) {
|
|
|
336
490
|
if (last?.role === 'user') state.messages.pop()
|
|
337
491
|
return
|
|
338
492
|
}
|
|
339
|
-
|
|
493
|
+
stopSpinner()
|
|
494
|
+
writer.finish()
|
|
340
495
|
|
|
341
496
|
if (result.aborted) {
|
|
342
|
-
|
|
497
|
+
printWarn('⛔ 已中止')
|
|
343
498
|
return
|
|
344
499
|
}
|
|
345
500
|
|
|
@@ -347,38 +502,48 @@ async function runAgentTurn(state, userText, t) {
|
|
|
347
502
|
|
|
348
503
|
// 无工具调用:本轮结束,assistant 文本入历史
|
|
349
504
|
if (toolCalls.length === 0) {
|
|
350
|
-
|
|
351
|
-
state.messages.push({ role: 'assistant', content: content || '' })
|
|
505
|
+
state.messages.push({ role: 'assistant', content: content || null })
|
|
352
506
|
return
|
|
353
507
|
}
|
|
354
508
|
|
|
355
509
|
// 有工具调用:assistant(带 tool_calls)入历史,然后逐个执行
|
|
356
|
-
|
|
357
|
-
state.messages.push({ role: 'assistant', content: content ||
|
|
510
|
+
// content 为 null 而非空字符串:部分 provider 拒绝空字符串内容(2013 错误)
|
|
511
|
+
state.messages.push({ role: 'assistant', content: content || null, tool_calls: toolCalls })
|
|
358
512
|
|
|
359
513
|
for (const tc of toolCalls) {
|
|
360
514
|
const name = tc.function?.name || ''
|
|
361
515
|
const rawArgs = tc.function?.arguments || ''
|
|
362
|
-
printToolCall(name, rawArgs)
|
|
363
516
|
|
|
364
517
|
let args
|
|
365
518
|
try {
|
|
366
519
|
args = rawArgs ? JSON.parse(rawArgs) : {}
|
|
367
520
|
} catch {
|
|
521
|
+
printToolHeader(name, printDimInline(rawArgs))
|
|
368
522
|
const errResult = `错误: 工具参数不是合法 JSON: ${rawArgs.slice(0, 200)}`
|
|
369
523
|
printToolResult(errResult)
|
|
370
524
|
state.messages.push({ role: 'tool', tool_call_id: tc.id || name, name, content: errResult })
|
|
371
525
|
continue
|
|
372
526
|
}
|
|
373
527
|
|
|
528
|
+
printToolHeader(name, summarizeToolArgs(name, args, { chars: t.chars }))
|
|
529
|
+
// 长命令执行期间给个 spinner,让用户知道没有卡死
|
|
530
|
+
const toolSpinner = startSpinner(t.toolRunning(name))
|
|
374
531
|
const output = await executeTool(name, args, state.ctx)
|
|
532
|
+
toolSpinner.stop()
|
|
375
533
|
printToolResult(output)
|
|
376
534
|
state.messages.push({ role: 'tool', tool_call_id: tc.id || name, name, content: output })
|
|
377
535
|
}
|
|
378
536
|
// 工具结果全部入历史后继续循环,让模型基于结果决定下一步
|
|
379
537
|
}
|
|
380
538
|
|
|
381
|
-
|
|
539
|
+
printWarn(t.toolIterLimit(MAX_TOOL_ITERATIONS))
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// JSON 解析失败时的参数回显:单行截断,不进 summarizeToolArgs(它没有结构化参数可用)
|
|
543
|
+
function printDimInline(rawArgs) {
|
|
544
|
+
let preview = String(rawArgs || '').replace(/\s+/g, ' ').trim()
|
|
545
|
+
if (preview.length > 160) preview = preview.slice(0, 160) + '…'
|
|
546
|
+
return preview
|
|
382
547
|
}
|
|
383
548
|
|
|
384
549
|
// ──────────────────────────────────────────────
|
|
@@ -387,27 +552,35 @@ async function runAgentTurn(state, userText, t) {
|
|
|
387
552
|
function printSlashHelp(t, locale) {
|
|
388
553
|
const zh = !String(locale || '').startsWith('en')
|
|
389
554
|
const lines = zh ? [
|
|
390
|
-
`${t.helpTitle}:`,
|
|
391
555
|
' /help 显示本帮助',
|
|
392
556
|
' /model 列出可用模型;/model <序号> 切换模型',
|
|
557
|
+
' /addmodel 添加新的模型配置(交互式向导)',
|
|
393
558
|
' /cd <路径> 切换智能体工作目录',
|
|
559
|
+
' /image [路径] 查看待发送图片;/image <路径> 附加本地图片;/image clear 清除',
|
|
560
|
+
' /think 开关思考过程显示',
|
|
394
561
|
' /clear 清空对话历史',
|
|
395
562
|
' /exit, /quit 退出',
|
|
396
563
|
'',
|
|
564
|
+
' Alt+V 粘贴剪贴板图片,随下一条消息发送(需视觉模型)',
|
|
565
|
+
'',
|
|
397
566
|
'权限说明: 工作目录内全部操作直接执行;其他目录可读写;',
|
|
398
567
|
'仅系统级破坏命令(格式化、删根目录、关机等)会被安全守卫拦截。',
|
|
399
568
|
] : [
|
|
400
|
-
`${t.helpTitle}:`,
|
|
401
569
|
' /help Show this help',
|
|
402
570
|
' /model List models; /model <n> to switch',
|
|
571
|
+
' /addmodel Add a new model (interactive wizard)',
|
|
403
572
|
' /cd <path> Change agent working directory',
|
|
573
|
+
' /image [path] List pending images; attach a file; /image clear to reset',
|
|
574
|
+
' /think Toggle thinking display',
|
|
404
575
|
' /clear Clear conversation history',
|
|
405
576
|
' /exit, /quit Quit',
|
|
406
577
|
'',
|
|
578
|
+
' Alt+V Paste clipboard image (needs a vision-capable model)',
|
|
579
|
+
'',
|
|
407
580
|
'Permissions: full access in the working directory; other dirs readable/writable;',
|
|
408
581
|
'only system-destroying commands (format, rm -rf /, shutdown, ...) are blocked.',
|
|
409
582
|
]
|
|
410
|
-
|
|
583
|
+
printHelpPanel(t.helpTitle, lines)
|
|
411
584
|
}
|
|
412
585
|
|
|
413
586
|
async function handleSlashCommand(state, input, t) {
|
|
@@ -418,7 +591,40 @@ async function handleSlashCommand(state, input, t) {
|
|
|
418
591
|
if (cmd === '/help') { printSlashHelp(t, state.locale); return 'ok' }
|
|
419
592
|
if (cmd === '/clear') {
|
|
420
593
|
state.messages = [state.messages[0]]
|
|
421
|
-
|
|
594
|
+
printOk(t.cleared)
|
|
595
|
+
return 'ok'
|
|
596
|
+
}
|
|
597
|
+
if (cmd === '/think') {
|
|
598
|
+
state.showThinking = !state.showThinking
|
|
599
|
+
printOk(state.showThinking ? t.thinkOn : t.thinkOff)
|
|
600
|
+
return 'ok'
|
|
601
|
+
}
|
|
602
|
+
if (cmd === '/image') {
|
|
603
|
+
if (arg === 'clear') {
|
|
604
|
+
state.pendingImages = []
|
|
605
|
+
printOk(t.imageCleared)
|
|
606
|
+
return 'ok'
|
|
607
|
+
}
|
|
608
|
+
if (!arg) {
|
|
609
|
+
if (state.pendingImages.length === 0) {
|
|
610
|
+
printDim(t.imageListEmpty)
|
|
611
|
+
} else {
|
|
612
|
+
console.log(chalk.bold(t.imageListTitle) + ':')
|
|
613
|
+
state.pendingImages.forEach((img, i) => {
|
|
614
|
+
console.log(` ${i + 1}. ${img.path} ${chalk.dim('(' + formatBytes(img.bytes) + ')')}`)
|
|
615
|
+
})
|
|
616
|
+
}
|
|
617
|
+
return 'ok'
|
|
618
|
+
}
|
|
619
|
+
const { resolve } = await import('node:path')
|
|
620
|
+
const target = resolve(state.ctx.cwd, arg)
|
|
621
|
+
const img = await checkImageFile(target)
|
|
622
|
+
if (!img) {
|
|
623
|
+
printError(t.imageBadPath(target))
|
|
624
|
+
return 'ok'
|
|
625
|
+
}
|
|
626
|
+
state.pendingImages.push(img)
|
|
627
|
+
printOk(t.imageAttached(state.pendingImages.length, formatBytes(img.bytes)))
|
|
422
628
|
return 'ok'
|
|
423
629
|
}
|
|
424
630
|
if (cmd === '/model') {
|
|
@@ -434,11 +640,52 @@ async function handleSlashCommand(state, input, t) {
|
|
|
434
640
|
}
|
|
435
641
|
const idx = Number.parseInt(arg, 10)
|
|
436
642
|
if (!Number.isFinite(idx) || idx < 1 || idx > state.models.length) {
|
|
437
|
-
|
|
643
|
+
printError(t.invalidModelIndex)
|
|
438
644
|
return 'ok'
|
|
439
645
|
}
|
|
440
646
|
state.model = state.models[idx - 1]
|
|
441
|
-
|
|
647
|
+
printOk(t.modelSwitched(modelLabel(state.model)))
|
|
648
|
+
return 'ok'
|
|
649
|
+
}
|
|
650
|
+
if (cmd === '/addmodel') {
|
|
651
|
+
// 智能体执行中不允许启动向导:向导的 rl.question 会与流式输出交错,体验混乱
|
|
652
|
+
if (state.busy) {
|
|
653
|
+
printDim(t.busy)
|
|
654
|
+
return 'ok'
|
|
655
|
+
}
|
|
656
|
+
state.inWizard = true
|
|
657
|
+
try {
|
|
658
|
+
console.log(chalk.cyan.bold('\n' + t.addModelTitle))
|
|
659
|
+
// 复用 modelSetup 的交互式收集逻辑(服务商 → 接口 → 模型名 → Key → 显示名 → 测试)
|
|
660
|
+
const collected = await collectModelInput({
|
|
661
|
+
locale: state.locale,
|
|
662
|
+
rl: state.rl,
|
|
663
|
+
cancelMessage: t.addModelCancelled,
|
|
664
|
+
})
|
|
665
|
+
if (!collected) return 'ok'
|
|
666
|
+
const { baseURL, model, apiKey, displayName } = collected
|
|
667
|
+
// 构建模型配置(isDefault=false:/addmodel 只是追加,不改默认模型)
|
|
668
|
+
const newModel = buildModelConfig({ baseURL, model, apiKey, name: displayName, isDefault: false })
|
|
669
|
+
// 持久化到 ~/.git-commit-tool.json 顶层 models 数组
|
|
670
|
+
const cfg = await config.loadConfig()
|
|
671
|
+
const models = Array.isArray(cfg.models) ? cfg.models : []
|
|
672
|
+
models.push(newModel)
|
|
673
|
+
cfg.models = models
|
|
674
|
+
try {
|
|
675
|
+
await config.saveConfig(cfg)
|
|
676
|
+
} catch (err) {
|
|
677
|
+
printError(t.addModelSaveError(err.message))
|
|
678
|
+
return 'ok'
|
|
679
|
+
}
|
|
680
|
+
// 同步 REPL 状态:更新模型列表 + 自动切换到新模型(用户刚添加,大概率想立刻用)
|
|
681
|
+
state.models = models
|
|
682
|
+
state.model = newModel
|
|
683
|
+
printOk(t.addModelSaved(displayName || model))
|
|
684
|
+
} catch (err) {
|
|
685
|
+
printError(err.message)
|
|
686
|
+
} finally {
|
|
687
|
+
state.inWizard = false
|
|
688
|
+
}
|
|
442
689
|
return 'ok'
|
|
443
690
|
}
|
|
444
691
|
if (cmd === '/cd') {
|
|
@@ -452,13 +699,13 @@ async function handleSlashCommand(state, input, t) {
|
|
|
452
699
|
state.ctx.cwd = target
|
|
453
700
|
// 同步更新 system prompt 里的 cwd 说明(重建首条消息)
|
|
454
701
|
state.messages[0] = { role: 'system', content: buildSystemPrompt({ cwd: target, locale: state.locale, shellDesc: state.shellDesc }) }
|
|
455
|
-
|
|
702
|
+
printOk(t.cdOk(target))
|
|
456
703
|
} catch {
|
|
457
|
-
|
|
704
|
+
printError(t.cdFail(target))
|
|
458
705
|
}
|
|
459
706
|
return 'ok'
|
|
460
707
|
}
|
|
461
|
-
|
|
708
|
+
printWarn(`未知命令: ${cmd},输入 /help 查看可用命令`)
|
|
462
709
|
return 'ok'
|
|
463
710
|
}
|
|
464
711
|
|
|
@@ -476,11 +723,15 @@ export async function runAiAgent(argv = []) {
|
|
|
476
723
|
return
|
|
477
724
|
}
|
|
478
725
|
|
|
479
|
-
|
|
726
|
+
let models = Array.isArray(cfg.models) ? cfg.models : []
|
|
480
727
|
if (models.length === 0) {
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
728
|
+
// 未配置模型:启动交互式配置向导,参照 g ui 设置里的"添加模型"
|
|
729
|
+
const setupResult = await runModelSetup({ locale })
|
|
730
|
+
if (!setupResult) {
|
|
731
|
+
process.exitCode = 1
|
|
732
|
+
return
|
|
733
|
+
}
|
|
734
|
+
models = setupResult.models
|
|
484
735
|
}
|
|
485
736
|
|
|
486
737
|
// --model=<序号|名称> 选择模型;默认 isDefault,否则第一个
|
|
@@ -494,7 +745,7 @@ export async function runAiAgent(argv = []) {
|
|
|
494
745
|
} else {
|
|
495
746
|
const found = models.find(m => modelLabel(m).toLowerCase().includes(String(q).toLowerCase()))
|
|
496
747
|
if (found) model = found
|
|
497
|
-
else
|
|
748
|
+
else printWarn(t.unknownModel(q))
|
|
498
749
|
}
|
|
499
750
|
}
|
|
500
751
|
|
|
@@ -520,6 +771,11 @@ export async function runAiAgent(argv = []) {
|
|
|
520
771
|
currentChild: null,
|
|
521
772
|
abortController: null,
|
|
522
773
|
busy: false,
|
|
774
|
+
showThinking: true, // /think 切换:是否回显模型的思考过程
|
|
775
|
+
pendingImages: [], // Alt+V / /image 附加的待发送图片 [{path, bytes}]
|
|
776
|
+
pasting: false, // 剪贴板读取进行中(防 Alt+V 连打并发)
|
|
777
|
+
inWizard: false, // /addmodel 交互式向导进行中:忽略 REPL 的 line 事件
|
|
778
|
+
rl: null, // REPL readline 引用(供 /addmodel 复用做 rl.question)
|
|
523
779
|
}
|
|
524
780
|
|
|
525
781
|
// SIGINT 时中止进行中的 LLM 请求 + 正在跑的子命令
|
|
@@ -534,60 +790,214 @@ export async function runAiAgent(argv = []) {
|
|
|
534
790
|
if (oneShot) {
|
|
535
791
|
console.log(chalk.dim(`[${t.bannerModel}] ${modelLabel(model)} · [${t.bannerCwd}] ${cwd}`))
|
|
536
792
|
await runAgentTurn(state, oneShot, t)
|
|
793
|
+
printDim(t.oneShotDone)
|
|
537
794
|
return
|
|
538
795
|
}
|
|
539
796
|
|
|
540
797
|
// ── 交互模式(REPL)──
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
798
|
+
printBanner({
|
|
799
|
+
title: 'Zen GitSync — AI Agent',
|
|
800
|
+
modelLabel: modelLabel(model),
|
|
801
|
+
baseURL: model.baseURL || '',
|
|
802
|
+
cwd,
|
|
803
|
+
modelText: t.bannerModel,
|
|
804
|
+
cwdText: t.bannerCwd,
|
|
805
|
+
tip: t.bannerTip,
|
|
806
|
+
})
|
|
548
807
|
|
|
549
808
|
const rl = readline.createInterface({
|
|
550
809
|
input: process.stdin,
|
|
551
810
|
output: process.stdout,
|
|
552
|
-
prompt: chalk.
|
|
811
|
+
prompt: chalk.cyan.bold(t.prompt),
|
|
553
812
|
historySize: 200,
|
|
554
813
|
})
|
|
814
|
+
state.rl = rl // 供 /addmodel 等需要 rl.question 的斜杠命令复用
|
|
815
|
+
|
|
816
|
+
// 输入提示符管理。
|
|
817
|
+
//
|
|
818
|
+
// readline 的 _refreshLine() 用 prevRows 追踪“提示符占了几行”,
|
|
819
|
+
// 下次刷新时 moveCursor(0, -prevRows) 上移光标。但模型输出直接写
|
|
820
|
+
// stdout,readline 不知道这些行,导致 prevRows 过期 → 光标错位。
|
|
821
|
+
//
|
|
822
|
+
// 修复:每次画提示符前,先写 \r\n 确保光标在新行第 0 列,
|
|
823
|
+
// 然后重置 prevRows=0 告诉 readline “当前行就是提示符行”。
|
|
824
|
+
// 这样 _refreshLine() 的 clearScreenDown() 只清当前空行,不影响模型输出。
|
|
825
|
+
|
|
826
|
+
const showPrompt = () => {
|
|
827
|
+
if (rl.closed) return
|
|
828
|
+
// 双保险:确保 stdin 处于 flowing 模式(ora 的 stdin-discarder 可能在
|
|
829
|
+
// stop() 时 pause 了 stdin,即使 discardStdin:false 已设,仍防止其他
|
|
830
|
+
// 库意外 pause)
|
|
831
|
+
if (process.stdin.isTTY && !process.stdin.readableFlowing) {
|
|
832
|
+
process.stdin.resume()
|
|
833
|
+
}
|
|
834
|
+
if (process.stdout.isTTY) {
|
|
835
|
+
// 确保光标在新行第 0 列(\r 回行首,\n 换行)
|
|
836
|
+
process.stdout.write('\r\n')
|
|
837
|
+
// 重置 readline 内部光标追踪
|
|
838
|
+
rl.prevRows = 0
|
|
839
|
+
}
|
|
840
|
+
rl.prompt()
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// 安全版:防止 finally 块里的异常变成 unhandledRejection 杀掉会话
|
|
844
|
+
const safeShowPrompt = () => {
|
|
845
|
+
try {
|
|
846
|
+
showPrompt()
|
|
847
|
+
} catch (_) {
|
|
848
|
+
try { rl.prompt() } catch { /* 给予宽容 */ }
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// 仅重画提示符,不写 \r\n(用于空输入、busy 等不需要换行的场景)
|
|
853
|
+
const safeRefreshPrompt = () => {
|
|
854
|
+
try {
|
|
855
|
+
if (rl.closed) return
|
|
856
|
+
rl.prevRows = 0
|
|
857
|
+
rl.prompt()
|
|
858
|
+
} catch (_) {
|
|
859
|
+
try { rl.prompt() } catch { /* noop */ }
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// readline 的 clearLine() 已经写了 \r\n,不需要我们再补换行。
|
|
864
|
+
const closeInputFrame = () => {}
|
|
865
|
+
|
|
866
|
+
// 在提示符上方插一行通知(粘贴进度等)。
|
|
867
|
+
const notifyAbovePrompt = (text) => {
|
|
868
|
+
console.log(text)
|
|
869
|
+
if (!state.busy) safeShowPrompt()
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// ── 斜杠命令即时提示 ──
|
|
873
|
+
// 在提示符下方浮现匹配到的命令,随输入过滤;不移动光标(用 DECSC/DECRC 保存-恢复)。
|
|
874
|
+
// slashHintRows 记录当前提示占了几行,下次刷新/提交时据此擦除,避免残影。
|
|
875
|
+
let slashHintRows = 0
|
|
876
|
+
const eraseSlashHint = () => {
|
|
877
|
+
if (!process.stdout.isTTY || slashHintRows === 0) return
|
|
878
|
+
// 保存光标 → 下移到提示区逐行清空 → 恢复光标
|
|
879
|
+
let seq = '\x1b7'
|
|
880
|
+
for (let i = 0; i < slashHintRows; i++) seq += '\x1b[1B\x1b[2K'
|
|
881
|
+
seq += '\x1b8'
|
|
882
|
+
process.stdout.write(seq)
|
|
883
|
+
slashHintRows = 0
|
|
884
|
+
}
|
|
885
|
+
const renderSlashHint = () => {
|
|
886
|
+
if (!process.stdout.isTTY || state.busy || state.inWizard) { return }
|
|
887
|
+
const matches = filterSlashCommands(rl.line, state.locale)
|
|
888
|
+
const body = renderSlashHintBody(matches)
|
|
889
|
+
// 先擦掉旧提示(行数可能变化),再画新的
|
|
890
|
+
eraseSlashHint()
|
|
891
|
+
if (!body) return
|
|
892
|
+
const rows = body.split('\n')
|
|
893
|
+
// 保存光标,逐行下移打印(每行先清行防止与旧内容叠字),最后恢复光标回输入行
|
|
894
|
+
let seq = '\x1b7'
|
|
895
|
+
for (const r of rows) seq += '\x1b[1B\x1b[2K\r' + r
|
|
896
|
+
seq += '\x1b8'
|
|
897
|
+
process.stdout.write(seq)
|
|
898
|
+
slashHintRows = rows.length
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
// Alt+V 粘贴剪贴板图片(node 把 ESC+v 解析为 meta+v;部分终端不发 Alt,可用 /image 兜底)
|
|
902
|
+
const pasteFromClipboard = async () => {
|
|
903
|
+
if (state.pasting) return
|
|
904
|
+
state.pasting = true
|
|
905
|
+
try {
|
|
906
|
+
notifyAbovePrompt(chalk.dim(t.imagePasting))
|
|
907
|
+
const img = await readClipboardImage()
|
|
908
|
+
if (!img) {
|
|
909
|
+
notifyAbovePrompt(chalk.yellow(t.imageEmpty))
|
|
910
|
+
} else {
|
|
911
|
+
state.pendingImages.push(img)
|
|
912
|
+
notifyAbovePrompt(chalk.green(t.imageAttached(state.pendingImages.length, formatBytes(img.bytes))))
|
|
913
|
+
}
|
|
914
|
+
} catch (err) {
|
|
915
|
+
// 不能让粘贴异常变成 unhandledRejection 杀掉会话
|
|
916
|
+
printError(`粘贴图片失败: ${err.message}`)
|
|
917
|
+
} finally {
|
|
918
|
+
state.pasting = false
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
rl.input.on('keypress', (ch, key) => {
|
|
922
|
+
// Enter 提交由 line 处理器负责擦除提示,这里不重画(此刻 readline 已换行,
|
|
923
|
+
// 光标不在输入行,重画会错位)
|
|
924
|
+
if (!(key && (key.name === 'return' || key.name === 'enter'))) {
|
|
925
|
+
// 每次按键后即时刷新斜杠命令提示(readline 已先处理完本次按键,rl.line 为最新值)。
|
|
926
|
+
// 非 slash / 无匹配时 renderSlashHint 会把上一次的提示擦掉。
|
|
927
|
+
renderSlashHint()
|
|
928
|
+
}
|
|
929
|
+
if (!key) return
|
|
930
|
+
const isAltV = (key.meta && key.name === 'v')
|
|
931
|
+
|| (typeof key.sequence === 'string' && key.sequence.length === 2
|
|
932
|
+
&& key.sequence.charCodeAt(0) === 27 && key.sequence[1] === 'v')
|
|
933
|
+
if (isAltV) pasteFromClipboard()
|
|
934
|
+
})
|
|
935
|
+
|
|
936
|
+
// 初始提示符:banner 后直接画(不需要 \r\n)
|
|
937
|
+
rl.prevRows = 0
|
|
555
938
|
rl.prompt()
|
|
556
939
|
|
|
557
940
|
rl.on('line', async (line) => {
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
if (
|
|
561
|
-
|
|
562
|
-
|
|
941
|
+
// 提交时光标已落到提示符下一行(即提示区起点),直接清到屏幕末尾擦掉整块提示,
|
|
942
|
+
// 比 DECSC/DECRC 相对定位更稳(此时光标不在输入行)
|
|
943
|
+
if (process.stdout.isTTY && slashHintRows > 0) {
|
|
944
|
+
process.stdout.write('\x1b[0J')
|
|
945
|
+
slashHintRows = 0
|
|
563
946
|
}
|
|
947
|
+
// /addmodel 等交互式向导进行中时,用户的回答由向导自身的 rl.question 处理,
|
|
948
|
+
// 不应进入 REPL 的正常输入流程(否则会把向导的答案当成命令/消息发给模型)
|
|
949
|
+
if (state.inWizard) return
|
|
950
|
+
const input = line.trim()
|
|
564
951
|
|
|
565
952
|
if (input.startsWith('/')) {
|
|
566
953
|
const r = await handleSlashCommand(state, input, t)
|
|
567
954
|
if (r === 'exit') { rl.close(); return }
|
|
568
|
-
|
|
955
|
+
safeRefreshPrompt()
|
|
956
|
+
return
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
if (!input) { safeRefreshPrompt(); return }
|
|
960
|
+
if (state.busy) {
|
|
961
|
+
printDim(t.busy)
|
|
962
|
+
safeRefreshPrompt()
|
|
569
963
|
return
|
|
570
964
|
}
|
|
571
965
|
|
|
966
|
+
// 取出待发送图片(取出即清空队列,用户每条消息独立决定带不带图)
|
|
967
|
+
const images = state.pendingImages.splice(0)
|
|
968
|
+
if (images.length > 0) printDim(t.imageSending(images.length))
|
|
969
|
+
|
|
572
970
|
state.busy = true
|
|
573
971
|
state.abortController = new AbortController()
|
|
574
972
|
try {
|
|
575
|
-
await runAgentTurn(state, input, t)
|
|
973
|
+
await runAgentTurn(state, input, t, images)
|
|
576
974
|
} catch (err) {
|
|
577
|
-
|
|
975
|
+
printError(err.message)
|
|
578
976
|
} finally {
|
|
579
977
|
state.busy = false
|
|
580
978
|
state.abortController = null
|
|
581
|
-
|
|
979
|
+
safeShowPrompt()
|
|
582
980
|
}
|
|
583
981
|
})
|
|
584
982
|
|
|
585
983
|
rl.on('close', () => {
|
|
586
|
-
|
|
984
|
+
printWarn('\n' + t.bye)
|
|
587
985
|
})
|
|
588
986
|
|
|
987
|
+
// 全局安全网:任何未捕获的 Promise rejection / 异常都不应该静默杀掉
|
|
988
|
+
// 整个 REPL 会话。这里记录到 stderr 但不退出,让 readline 继续运行。
|
|
989
|
+
const rejectionHandler = (reason) => {
|
|
990
|
+
const msg = reason instanceof Error ? reason.message : String(reason)
|
|
991
|
+
printError(`内部错误(已捕获,会话继续): ${msg}`)
|
|
992
|
+
safeShowPrompt()
|
|
993
|
+
}
|
|
994
|
+
process.on('unhandledRejection', rejectionHandler)
|
|
995
|
+
|
|
589
996
|
// 等 readline 关闭后返回(进程随后自然退出)
|
|
590
997
|
await new Promise((resolve) => rl.on('close', resolve))
|
|
998
|
+
|
|
999
|
+
// 清理:移除本次会话注册的全局处理器
|
|
1000
|
+
process.removeListener('unhandledRejection', rejectionHandler)
|
|
591
1001
|
}
|
|
592
1002
|
|
|
593
1003
|
export default { runAiAgent }
|