zen-gitsync 2.16.1 → 2.16.3

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 (85) hide show
  1. package/README.md +28 -3
  2. package/package.json +1 -1
  3. package/src/cli/ai/agent.js +181 -21
  4. package/src/cli/ai/agent.test.js +77 -1
  5. package/src/cli/ai/modelSetup.js +594 -0
  6. package/src/cli/ai/modelSetup.test.js +496 -0
  7. package/src/cli/ai/platformGuard.js +200 -0
  8. package/src/cli/ai/platformGuard.test.js +253 -0
  9. package/src/cli/ai/termui.js +135 -37
  10. package/src/cli/ai/termui.test.js +48 -8
  11. package/src/cli/ai/tools.js +8 -0
  12. package/src/gitCommit.js +484 -484
  13. package/src/ui/public/assets/AppVersionBadge-nwlsGMPO.js +2 -0
  14. package/src/ui/public/assets/BranchSelector-bUBqRWF2.js +1 -0
  15. package/src/ui/public/assets/CommitForm-DyHOStnk.js +6 -0
  16. package/src/ui/public/assets/CommonDialog-BA4xg5l3.js +1 -0
  17. package/src/ui/public/assets/EditorView-D939kLwW.js +0 -0
  18. package/src/ui/public/assets/{FlowExecutionViewer-CPblwVA1.js → FlowExecutionViewer-CZdF1f1e.js} +1 -1
  19. package/src/ui/public/assets/FlowOrchestrationWorkspace-BOn17akW.js +21 -0
  20. package/src/ui/public/assets/LogList-xk8W-v1F.js +15 -0
  21. package/src/ui/public/assets/{MindmapView-C_9P5ijq.js → MindmapView-CU3tXgbR.js} +1 -1
  22. package/src/ui/public/assets/MonitorView-CSAqU7oV.js +1 -0
  23. package/src/ui/public/assets/ProjectStartupButton-2By0-fuq.js +1 -0
  24. package/src/ui/public/assets/{RemoteRepoCard-C_cBtHcN.js → RemoteRepoCard-Cx5q9Yxb.js} +2 -2
  25. package/src/ui/public/assets/SourceMapView-uEieIRHF.js +3 -0
  26. package/src/ui/public/assets/{SvgIcon-C9YQSvGU.js → SvgIcon-BqWp2WS8.js} +1 -1
  27. package/src/ui/public/assets/UserInputNode-Ccnh0e-0.js +1 -0
  28. package/src/ui/public/assets/WorkbenchView-DBoFN37v.js +28 -0
  29. package/src/ui/public/assets/{_plugin-vue_export-helper-CHQqCylN.js → _plugin-vue_export-helper-BQRl7WAH.js} +1 -1
  30. package/src/ui/public/assets/configStore-BkxDLXGa.js +1 -0
  31. package/src/ui/public/assets/{css.worker-CvXBzhp8.js → css.worker-BKx7BC4h.js} +7 -7
  32. package/src/ui/public/assets/dagre-CTEVx1fc.js +1 -0
  33. package/src/ui/public/assets/editor.worker-DQJwLooG.js +26 -0
  34. package/src/ui/public/assets/element-plus-CQrjg9us.css +1 -0
  35. package/src/ui/public/assets/element-plus-DNnePjco.js +32 -0
  36. package/src/ui/public/assets/{flow-mindmap-CUvJaYn2.js → flow-mindmap-DRmzYE4F.js} +6 -6
  37. package/src/ui/public/assets/{html.worker-BO6WuOEO.js → html.worker-ChRLLxCa.js} +4 -4
  38. package/src/ui/public/assets/index-Dh9-lDzZ.js +68 -0
  39. package/src/ui/public/assets/{json.worker-BkJRGcCJ.js → json.worker-DR52qCex.js} +5 -5
  40. package/src/ui/public/assets/{monaco-BFLoT2VD.js → monaco-BvgtFLbw.js} +30 -30
  41. package/src/ui/public/assets/rolldown-runtime-DAXXjFlN.js +1 -0
  42. package/src/ui/public/assets/{socket-io-BSgn8Jx2.js → socket-io-pmu5tRPo.js} +1 -1
  43. package/src/ui/public/assets/{ts.worker-B0J26iPs.js → ts.worker-Cqvg7802.js} +18 -18
  44. package/src/ui/public/assets/{vendor-CU5NYUKJ.js → vendor-CcK8n8T4.js} +10 -10
  45. package/src/ui/public/assets/vue-flow-pAz--378.js +39 -0
  46. package/src/ui/public/favicon.svg +75 -75
  47. package/src/ui/public/index.html +30 -30
  48. package/src/ui/public/logo.svg +74 -74
  49. package/src/ui/server/.claude/codediff.txt +8 -0
  50. package/src/ui/server/middleware/requestLogger.js +53 -53
  51. package/src/ui/server/routes/branchStatus.js +105 -105
  52. package/src/ui/server/routes/fs.js +791 -791
  53. package/src/ui/server/routes/git/diff.js +426 -426
  54. package/src/ui/server/routes/git/diffUtils.js +149 -149
  55. package/src/ui/server/routes/git/stash.js +550 -550
  56. package/src/ui/server/routes/git/tags.js +176 -176
  57. package/src/ui/server/routes/git.js +181 -181
  58. package/src/ui/server/routes/gitOps.js +1255 -1255
  59. package/src/ui/server/routes/process.js +86 -86
  60. package/src/ui/server/routes/status.js +67 -67
  61. package/src/ui/server/routes/workbench/pdfText.js +145 -145
  62. package/src/ui/server/utils/createSavePortToFile.js +48 -48
  63. package/src/ui/server/utils/instanceRegistry.js +282 -282
  64. package/src/ui/server/utils/startServerOnAvailablePort.js +116 -116
  65. package/src/utils/index.js +1173 -1173
  66. package/src/ui/public/assets/AppVersionBadge-DJyfXR_Q.js +0 -2
  67. package/src/ui/public/assets/BranchSelector-ClnVPC0Q.js +0 -1
  68. package/src/ui/public/assets/CommitForm-BihM26_4.js +0 -6
  69. package/src/ui/public/assets/CommonDialog-CYoIF2fg.js +0 -1
  70. package/src/ui/public/assets/EditorView-DYJQT-Xj.js +0 -0
  71. package/src/ui/public/assets/FlowOrchestrationWorkspace-Cc-Tnhng.js +0 -21
  72. package/src/ui/public/assets/LogList-va6ANtbL.js +0 -15
  73. package/src/ui/public/assets/MonitorView-DRPECOVW.js +0 -1
  74. package/src/ui/public/assets/ProjectStartupButton-DSovbKPE.js +0 -1
  75. package/src/ui/public/assets/SourceMapView-ZiCL8mS8.js +0 -3
  76. package/src/ui/public/assets/UserInputNode-CR6o8zJG.js +0 -1
  77. package/src/ui/public/assets/WorkbenchView-CJJrBqBH.js +0 -28
  78. package/src/ui/public/assets/configStore-Bw9U4AHx.js +0 -1
  79. package/src/ui/public/assets/dagre-CWIGcX9d.js +0 -1
  80. package/src/ui/public/assets/editor.worker-Cn2oRESe.js +0 -26
  81. package/src/ui/public/assets/element-plus-CyGhXd7X.js +0 -25
  82. package/src/ui/public/assets/element-plus-D3Dn7nX0.css +0 -1
  83. package/src/ui/public/assets/index-BzNpkvm5.js +0 -68
  84. package/src/ui/public/assets/rolldown-runtime-CMxvf4Kt.js +0 -1
  85. package/src/ui/public/assets/vue-flow-CWmfpfPN.js +0 -39
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # zen-gitsync
1
+ # zen-gitsync
2
2
 
3
3
  [English](#zen-gitsync) | [中文](#zh)
4
4
 
@@ -410,7 +410,9 @@ rm -f package-lock.json # when running on pnpm
410
410
 
411
411
  ### AI coding agent (terminal):
412
412
  Launch an interactive AI agent that writes code, runs commands, and commits for you.
413
- It uses the default model configured in `g ui` (Settings → AI models).
413
+ It uses the default model configured in `g ui` (Settings → AI models). If no model is
414
+ configured yet, `g ai` launches an interactive setup wizard — pick a provider, choose a
415
+ model, enter your API key, test the connection, and you're ready to go.
414
416
 
415
417
  ```bash
416
418
  $ g ai # interactive REPL
@@ -418,7 +420,7 @@ $ g ai "fix the failing test" # one-shot task, then exit
418
420
  $ g ai --model=2 # pick the 2nd configured model (index or name)
419
421
  ```
420
422
 
421
- In-session commands: `/help`, `/model`, `/cd <path>`, `/image [path]`, `/think`, `/clear`, `/exit`.
423
+ In-session commands: `/help`, `/model`, `/addmodel`, `/cd <path>`, `/image [path]`, `/think`, `/clear`, `/exit`.
422
424
 
423
425
  Images: press `Alt+V` in the REPL to paste a clipboard image (screenshot), or attach a
424
426
  local file with `/image <path>`; images are sent as multimodal `image_url` parts with your
@@ -919,6 +921,29 @@ git add --renormalize .
919
921
 
920
922
  ## 命令行
921
923
 
924
+ ### AI 编码智能体(终端):
925
+ 启动交互式 AI 智能体,自动写代码、跑命令、提交代码。
926
+ 默认使用 `g ui` 中配置的模型(设置 → AI 模型)。如果尚未配置任何模型,`g ai` 会启动
927
+ 交互式配置向导 —— 选择服务商、选择模型、输入 API Key、测试连接,完成后即可直接使用。
928
+
929
+ ```bash
930
+ $ g ai # 交互式 REPL
931
+ $ g ai "修复失败的测试" # 单发模式:执行一轮后退出
932
+ $ g ai --model=2 # 使用第 2 个已配置的模型(序号或名称)
933
+ ```
934
+
935
+ 会话内命令:`/help`、`/model`、`/addmodel`、`/cd <路径>`、`/image [路径]`、`/think`、`/clear`、`/exit`。
936
+
937
+ 图片:在 REPL 中按 `Alt+V` 粘贴剪贴板图片(截图),或用 `/image <路径>` 附加本地图片;
938
+ 图片以多模态 `image_url` 部件随下一条消息发送(需视觉模型)。单独 `/image` 查看待发送图片,
939
+ `/image clear` 清除。
940
+
941
+ 终端 UI 对标 Codex / Claude Code 风格:盒式输入框、等待 spinner、灰斜体流式思考、
942
+ `⏺` 工具块 + 智能参数摘要、轻量 Markdown 渲染(加粗、行内代码、标题、代码块)。
943
+
944
+ 权限模型:启动目录内所有操作直接执行;其他目录同样可读写;仅系统级破坏命令
945
+ (格式化磁盘、`rm -rf /`、关机等)由内置安全守卫硬拦截。
946
+
922
947
  #### 交互式提交:
923
948
  ```bash
924
949
  $ g
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zen-gitsync",
3
- "version": "2.16.1",
3
+ "version": "2.16.3",
4
4
  "description": "Auto commit, scheduled sync, and visual GUI for Git. Run `g` in any repo for one-key commit & push, AI commit messages, scheduled background sync, and a drag-and-drop workflow builder.",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -27,8 +27,8 @@
27
27
  // (格式化磁盘、删根目录、关机等系统级破坏操作)
28
28
  //
29
29
  // 视觉风格(termui.js):对标 Codex CLI / Claude Code / OpenCode —
30
- // 盒式输入框、ora 等待 spinner、✻ 思考灰斜体、⏺ 工具块 + 结果槽、
31
- // 正文逐行轻量 markdown(**bold** / `code` / # 标题 / ``` 围栏 / - 列表)
30
+ // 盒式输入框、ora 等待 spinner、✻ 思考(橙黄斜体)、⚙ 工具头(青) + 结果槽、
31
+ // 正文 ➤ 子弹头(绿)+ 逐行轻量 markdown(**bold** / `code` / # 标题 / ``` 围栏 / - 列表)
32
32
  //
33
33
  // 使用方式:
34
34
  // g ai 交互模式(REPL)
@@ -49,11 +49,13 @@ import { TOOL_DEFINITIONS, executeTool } from './tools.js'
49
49
  import { createThinkFilter } from './streamFilter.js'
50
50
  import {
51
51
  printBanner, printHelpPanel,
52
+ filterSlashCommands, renderSlashHintBody,
52
53
  startSpinner, createAssistantWriter,
53
54
  summarizeToolArgs, printToolHeader, printToolResult,
54
55
  printOk, printWarn, printError, printDim,
55
56
  } from './termui.js'
56
57
  import { readClipboardImage, checkImageFile, imageToDataUrl, formatBytes } from './images.js'
58
+ import { runModelSetup, collectModelInput, buildModelConfig } from './modelSetup.js'
57
59
 
58
60
  // truncateDisplay 已迁移到 termui.js;这里 re-export 保持既有测试/外部引用不断
59
61
  export { truncateDisplay } from './termui.js'
@@ -94,7 +96,7 @@ const STRINGS = {
94
96
  prompt: '❯ ',
95
97
  bannerModel: '模型',
96
98
  bannerCwd: '目录',
97
- thinkingLabel: '思考',
99
+ thinkingLabel: '思考',
98
100
  chars: '字符',
99
101
  imagePasting: '正在读取剪贴板图片…',
100
102
  imageAttached: (n, size) => `📎 图片 #${n} 已附加(${size}),将随下一条消息发送`,
@@ -104,6 +106,10 @@ const STRINGS = {
104
106
  imageListEmpty: '当前没有待发送的图片(Alt+V 粘贴或 /image <路径> 附加)',
105
107
  imageCleared: '已清除待发送的图片',
106
108
  imageSending: (n) => `📎 附带 ${n} 张图片`,
109
+ addModelTitle: '添加模型配置',
110
+ addModelSaved: (name) => `✓ 模型 "${name}" 已添加并切换为当前模型`,
111
+ addModelCancelled: '已取消添加模型',
112
+ addModelSaveError: (msg) => `保存配置失败: ${msg}`,
107
113
  },
108
114
  en: {
109
115
  noModel: 'No AI model configured. Add one in GUI settings first (run `g ui`).',
@@ -130,7 +136,7 @@ const STRINGS = {
130
136
  prompt: '❯ ',
131
137
  bannerModel: 'Model',
132
138
  bannerCwd: 'CWD',
133
- thinkingLabel: 'Thinking',
139
+ thinkingLabel: 'Thinking',
134
140
  chars: 'chars',
135
141
  imagePasting: 'Reading clipboard image…',
136
142
  imageAttached: (n, size) => `📎 Image #${n} attached (${size}); sent with your next message`,
@@ -140,6 +146,10 @@ const STRINGS = {
140
146
  imageListEmpty: 'No pending images (Alt+V to paste, or /image <path>)',
141
147
  imageCleared: 'Pending images cleared',
142
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}`,
143
153
  },
144
154
  }
145
155
 
@@ -164,13 +174,24 @@ function buildSystemPrompt({ cwd, locale, shellDesc }) {
164
174
 
165
175
  # 运行环境
166
176
  - 操作系统: ${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 专属命令` : ''}
177
+ - Shell: ${shellDesc}
171
178
  - 当前工作目录: ${cwd}(用户在此启动 g ai,也是所有相对路径的基准)
172
179
  - 当前时间: ${now}
173
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
+
174
195
  # 权限(用户已明确授权,无需反复征求同意)
175
196
  - 工作目录内:读写文件、执行命令等所有操作直接执行
176
197
  - 其他目录:同样可以读取和修改
@@ -203,13 +224,24 @@ function buildSystemPrompt({ cwd, locale, shellDesc }) {
203
224
 
204
225
  # Environment
205
226
  - 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 "..."` : ''}
227
+ - Shell: ${shellDesc}
210
228
  - Working directory: ${cwd} (where the user launched g ai; base for all relative paths)
211
229
  - Current time: ${now}
212
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
+
213
245
  # Permissions (explicitly granted by the user — do not keep asking)
214
246
  - Inside the working directory: read/write files and run commands directly
215
247
  - Other directories: may also be read and modified
@@ -334,6 +366,33 @@ function trimHistory(messages) {
334
366
  messages.splice(1, cut - 1)
335
367
  }
336
368
 
369
+ // ──────────────────────────────────────────────
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 消息做兜底防止空内容。
377
+ // ──────────────────────────────────────────────
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
394
+ }
395
+
337
396
  // ──────────────────────────────────────────────
338
397
  // 多模态历史:base64 图片很占上下文,只保留"最近一条带图消息"里的图片,
339
398
  // 更早消息里的 image_url 部件降级为文字占位(模型仍知道这里曾有图)
@@ -387,7 +446,7 @@ async function runAgentTurn(state, userText, t, images = []) {
387
446
  const stopSpinner = () => {
388
447
  if (!spinnerStopped) { spinner.stop(); spinnerStopped = true }
389
448
  }
390
- // 每次 LLM 调用一个 writer:思考灰斜体、正文 + 逐行 markdown
449
+ // 每次 LLM 调用一个 writer:思考(橙黄斜体)+ 正文 ➤ 子弹头 + 逐行 markdown
391
450
  const writer = createAssistantWriter({
392
451
  showThinking: state.showThinking,
393
452
  thinkingHeader: t.thinkingLabel,
@@ -404,6 +463,9 @@ async function runAgentTurn(state, userText, t, images = []) {
404
463
  else writer.writeContent(text)
405
464
  }
406
465
 
466
+ // 发送前消毒:确保历史里没有空 content 的消息(部分 provider 报 2013)
467
+ sanitizeMessages(state.messages)
468
+
407
469
  let result
408
470
  try {
409
471
  result = await streamChatOnce({
@@ -440,12 +502,13 @@ async function runAgentTurn(state, userText, t, images = []) {
440
502
 
441
503
  // 无工具调用:本轮结束,assistant 文本入历史
442
504
  if (toolCalls.length === 0) {
443
- state.messages.push({ role: 'assistant', content: content || '' })
505
+ state.messages.push({ role: 'assistant', content: content || null })
444
506
  return
445
507
  }
446
508
 
447
509
  // 有工具调用:assistant(带 tool_calls)入历史,然后逐个执行
448
- state.messages.push({ role: 'assistant', content: content || '', tool_calls: toolCalls })
510
+ // content null 而非空字符串:部分 provider 拒绝空字符串内容(2013 错误)
511
+ state.messages.push({ role: 'assistant', content: content || null, tool_calls: toolCalls })
449
512
 
450
513
  for (const tc of toolCalls) {
451
514
  const name = tc.function?.name || ''
@@ -491,6 +554,7 @@ function printSlashHelp(t, locale) {
491
554
  const lines = zh ? [
492
555
  ' /help 显示本帮助',
493
556
  ' /model 列出可用模型;/model <序号> 切换模型',
557
+ ' /addmodel 添加新的模型配置(交互式向导)',
494
558
  ' /cd <路径> 切换智能体工作目录',
495
559
  ' /image [路径] 查看待发送图片;/image <路径> 附加本地图片;/image clear 清除',
496
560
  ' /think 开关思考过程显示',
@@ -504,6 +568,7 @@ function printSlashHelp(t, locale) {
504
568
  ] : [
505
569
  ' /help Show this help',
506
570
  ' /model List models; /model <n> to switch',
571
+ ' /addmodel Add a new model (interactive wizard)',
507
572
  ' /cd <path> Change agent working directory',
508
573
  ' /image [path] List pending images; attach a file; /image clear to reset',
509
574
  ' /think Toggle thinking display',
@@ -582,6 +647,47 @@ async function handleSlashCommand(state, input, t) {
582
647
  printOk(t.modelSwitched(modelLabel(state.model)))
583
648
  return 'ok'
584
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
+ }
689
+ return 'ok'
690
+ }
585
691
  if (cmd === '/cd') {
586
692
  if (!arg) { console.log(state.ctx.cwd); return 'ok' }
587
693
  const { resolve } = await import('node:path')
@@ -617,11 +723,15 @@ export async function runAiAgent(argv = []) {
617
723
  return
618
724
  }
619
725
 
620
- const models = Array.isArray(cfg.models) ? cfg.models : []
726
+ let models = Array.isArray(cfg.models) ? cfg.models : []
621
727
  if (models.length === 0) {
622
- console.error(chalk.red(t.noModel))
623
- process.exitCode = 1
624
- return
728
+ // 未配置模型:启动交互式配置向导,参照 g ui 设置里的"添加模型"
729
+ const setupResult = await runModelSetup({ locale })
730
+ if (!setupResult) {
731
+ process.exitCode = 1
732
+ return
733
+ }
734
+ models = setupResult.models
625
735
  }
626
736
 
627
737
  // --model=<序号|名称> 选择模型;默认 isDefault,否则第一个
@@ -664,6 +774,8 @@ export async function runAiAgent(argv = []) {
664
774
  showThinking: true, // /think 切换:是否回显模型的思考过程
665
775
  pendingImages: [], // Alt+V / /image 附加的待发送图片 [{path, bytes}]
666
776
  pasting: false, // 剪贴板读取进行中(防 Alt+V 连打并发)
777
+ inWizard: false, // /addmodel 交互式向导进行中:忽略 REPL 的 line 事件
778
+ rl: null, // REPL readline 引用(供 /addmodel 复用做 rl.question)
667
779
  }
668
780
 
669
781
  // SIGINT 时中止进行中的 LLM 请求 + 正在跑的子命令
@@ -684,7 +796,7 @@ export async function runAiAgent(argv = []) {
684
796
 
685
797
  // ── 交互模式(REPL)──
686
798
  printBanner({
687
- title: 'g ai — AI Agent',
799
+ title: 'Zen GitSync — AI Agent',
688
800
  modelLabel: modelLabel(model),
689
801
  baseURL: model.baseURL || '',
690
802
  cwd,
@@ -696,9 +808,12 @@ export async function runAiAgent(argv = []) {
696
808
  const rl = readline.createInterface({
697
809
  input: process.stdin,
698
810
  output: process.stdout,
699
- prompt: chalk.cyan.bold(t.prompt),
811
+ // prompt: ❯ 用青色加粗;末尾追加 ANSI 亮白+加粗码,
812
+ // 让用户输入的文本继承亮白色高亮(黑底上最清晰)
813
+ prompt: chalk.cyan.bold(t.prompt) + '\x1b[1;97m',
700
814
  historySize: 200,
701
815
  })
816
+ state.rl = rl // 供 /addmodel 等需要 rl.question 的斜杠命令复用
702
817
 
703
818
  // 输入提示符管理。
704
819
  //
@@ -756,6 +871,35 @@ export async function runAiAgent(argv = []) {
756
871
  if (!state.busy) safeShowPrompt()
757
872
  }
758
873
 
874
+ // ── 斜杠命令即时提示 ──
875
+ // 在提示符下方浮现匹配到的命令,随输入过滤;不移动光标(用 DECSC/DECRC 保存-恢复)。
876
+ // slashHintRows 记录当前提示占了几行,下次刷新/提交时据此擦除,避免残影。
877
+ let slashHintRows = 0
878
+ const eraseSlashHint = () => {
879
+ if (!process.stdout.isTTY || slashHintRows === 0) return
880
+ // 保存光标 → 下移到提示区逐行清空 → 恢复光标
881
+ let seq = '\x1b7'
882
+ for (let i = 0; i < slashHintRows; i++) seq += '\x1b[1B\x1b[2K'
883
+ seq += '\x1b8'
884
+ process.stdout.write(seq)
885
+ slashHintRows = 0
886
+ }
887
+ const renderSlashHint = () => {
888
+ if (!process.stdout.isTTY || state.busy || state.inWizard) { return }
889
+ const matches = filterSlashCommands(rl.line, state.locale)
890
+ const body = renderSlashHintBody(matches)
891
+ // 先擦掉旧提示(行数可能变化),再画新的
892
+ eraseSlashHint()
893
+ if (!body) return
894
+ const rows = body.split('\n')
895
+ // 保存光标,逐行下移打印(每行先清行防止与旧内容叠字),最后恢复光标回输入行
896
+ let seq = '\x1b7'
897
+ for (const r of rows) seq += '\x1b[1B\x1b[2K\r' + r
898
+ seq += '\x1b8'
899
+ process.stdout.write(seq)
900
+ slashHintRows = rows.length
901
+ }
902
+
759
903
  // Alt+V 粘贴剪贴板图片(node 把 ESC+v 解析为 meta+v;部分终端不发 Alt,可用 /image 兜底)
760
904
  const pasteFromClipboard = async () => {
761
905
  if (state.pasting) return
@@ -777,6 +921,13 @@ export async function runAiAgent(argv = []) {
777
921
  }
778
922
  }
779
923
  rl.input.on('keypress', (ch, key) => {
924
+ // Enter 提交由 line 处理器负责擦除提示,这里不重画(此刻 readline 已换行,
925
+ // 光标不在输入行,重画会错位)
926
+ if (!(key && (key.name === 'return' || key.name === 'enter'))) {
927
+ // 每次按键后即时刷新斜杠命令提示(readline 已先处理完本次按键,rl.line 为最新值)。
928
+ // 非 slash / 无匹配时 renderSlashHint 会把上一次的提示擦掉。
929
+ renderSlashHint()
930
+ }
780
931
  if (!key) return
781
932
  const isAltV = (key.meta && key.name === 'v')
782
933
  || (typeof key.sequence === 'string' && key.sequence.length === 2
@@ -789,6 +940,15 @@ export async function runAiAgent(argv = []) {
789
940
  rl.prompt()
790
941
 
791
942
  rl.on('line', async (line) => {
943
+ // 提交时光标已落到提示符下一行(即提示区起点),直接清到屏幕末尾擦掉整块提示,
944
+ // 比 DECSC/DECRC 相对定位更稳(此时光标不在输入行)
945
+ if (process.stdout.isTTY && slashHintRows > 0) {
946
+ process.stdout.write('\x1b[0J')
947
+ slashHintRows = 0
948
+ }
949
+ // /addmodel 等交互式向导进行中时,用户的回答由向导自身的 rl.question 处理,
950
+ // 不应进入 REPL 的正常输入流程(否则会把向导的答案当成命令/消息发给模型)
951
+ if (state.inWizard) return
792
952
  const input = line.trim()
793
953
 
794
954
  if (input.startsWith('/')) {
@@ -17,7 +17,7 @@
17
17
  // 且切口把 "src/cli/ai/agent.js" 从中间切断显示成 "rc/cli/ai/agent.js"。
18
18
  import { test } from 'node:test'
19
19
  import assert from 'node:assert/strict'
20
- import { truncateDisplay, stripStaleImages } from './agent.js'
20
+ import { truncateDisplay, stripStaleImages, sanitizeMessages } from './agent.js'
21
21
 
22
22
  const LIMIT = 600
23
23
 
@@ -112,3 +112,79 @@ test('stripStaleImages: en locale 用英文占位符', () => {
112
112
  assert.equal(messages[0].content[0].text, '[image omitted from history]')
113
113
  assert.equal(messages[1].content[0].type, 'image_url')
114
114
  })
115
+
116
+ // ── sanitizeMessages:空 content 消毒(防止 provider 报 2013) ──
117
+ test('sanitizeMessages: assistant 空 content 转 null(带 tool_calls 场景)', () => {
118
+ const messages = [
119
+ { role: 'system', content: 'sys' },
120
+ { role: 'user', content: 'hi' },
121
+ { role: 'assistant', content: '', tool_calls: [{ id: 'tc1', type: 'function', function: { name: 'read_file', arguments: '{}' } }] },
122
+ { role: 'tool', tool_call_id: 'tc1', name: 'read_file', content: 'file contents...' },
123
+ ]
124
+ sanitizeMessages(messages)
125
+ assert.equal(messages[2].content, null, 'assistant 空 content 应转为 null')
126
+ assert.ok(messages[2].tool_calls, 'tool_calls 应保留不动')
127
+ })
128
+
129
+ test('sanitizeMessages: assistant 空 content 转 null(无 tool_calls)', () => {
130
+ const messages = [
131
+ { role: 'assistant', content: '' },
132
+ ]
133
+ sanitizeMessages(messages)
134
+ assert.equal(messages[0].content, null)
135
+ })
136
+
137
+ test('sanitizeMessages: tool 空 content 兜底为 (no output)', () => {
138
+ const messages = [
139
+ { role: 'tool', tool_call_id: 'tc1', name: 'run_command', content: '' },
140
+ ]
141
+ sanitizeMessages(messages)
142
+ assert.equal(messages[0].content, '(no output)')
143
+ })
144
+
145
+ test('sanitizeMessages: user 空 content 兜底为空格', () => {
146
+ const messages = [
147
+ { role: 'user', content: '' },
148
+ ]
149
+ sanitizeMessages(messages)
150
+ assert.equal(messages[0].content, ' ')
151
+ })
152
+
153
+ test('sanitizeMessages: 非空 content 不受影响', () => {
154
+ const messages = [
155
+ { role: 'system', content: 'sys' },
156
+ { role: 'user', content: 'hello' },
157
+ { role: 'assistant', content: 'world' },
158
+ { role: 'tool', tool_call_id: 'tc1', name: 'read_file', content: 'output' },
159
+ ]
160
+ const snapshot = JSON.parse(JSON.stringify(messages))
161
+ sanitizeMessages(messages)
162
+ assert.deepEqual(messages, snapshot)
163
+ })
164
+
165
+ test('sanitizeMessages: 数组 content(多模态)不受影响', () => {
166
+ const messages = [
167
+ { role: 'user', content: [{ type: 'text', text: 'hi' }, { type: 'image_url', image_url: { url: 'data:...' } }] },
168
+ ]
169
+ const snapshot = JSON.parse(JSON.stringify(messages))
170
+ sanitizeMessages(messages)
171
+ assert.deepEqual(messages, snapshot)
172
+ })
173
+
174
+ test('sanitizeMessages: 模拟真实工具调用流程后消息数组无空 content', () => {
175
+ // 模拟:模型只返回 tool_calls 没有文本 → 工具执行 → 下次请求
176
+ const messages = [
177
+ { role: 'system', content: 'sys prompt' },
178
+ { role: 'user', content: '帮我读一下文件' },
179
+ { role: 'assistant', content: '', tool_calls: [{ id: 'tc1', type: 'function', function: { name: 'read_file', arguments: '{"path":"a.js"}' } }] },
180
+ { role: 'tool', tool_call_id: 'tc1', name: 'read_file', content: '1→hello' },
181
+ ]
182
+ sanitizeMessages(messages)
183
+ // 消毒后所有消息的 content 都不应是空字符串
184
+ for (const m of messages) {
185
+ if (typeof m.content === 'string') {
186
+ assert.ok(m.content.length > 0, `role=${m.role} 的 content 不应为空字符串`)
187
+ }
188
+ }
189
+ assert.equal(messages[2].content, null, 'assistant 空 content 应为 null')
190
+ })