zen-gitsync 2.17.5 → 2.17.7

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 (71) hide show
  1. package/README.md +35 -2
  2. package/package.json +1 -1
  3. package/scripts/release.js +139 -48
  4. package/src/aiCommit.js +6 -3
  5. package/src/cli/ai/agent.js +106 -311
  6. package/src/cli/ai/cli.integration.test.js +99 -0
  7. package/src/cli/ai/context.js +90 -0
  8. package/src/cli/ai/modelSetup.js +44 -8
  9. package/src/cli/ai/runtime.test.js +152 -0
  10. package/src/cli/ai/safety.js +27 -4
  11. package/src/cli/ai/safety.test.js +13 -0
  12. package/src/cli/ai/telemetry.js +43 -0
  13. package/src/cli/ai/termui.js +154 -41
  14. package/src/cli/ai/termui.test.js +63 -14
  15. package/src/cli/ai/tools.js +27 -5
  16. package/src/cli/ai/tools.test.js +23 -0
  17. package/src/cli/ai/transport.js +89 -0
  18. package/src/cli/ai/turn.js +126 -0
  19. package/src/config.js +88 -5
  20. package/src/ui/public/assets/{AgentView-DJOdvJEV.js → AgentView-CZc191Xm.js} +3 -3
  21. package/src/ui/public/assets/AgentView-CzUeyQfR.css +1 -0
  22. package/src/ui/public/assets/{AppVersionBadge-D2Li5_R-.js → AppVersionBadge-Gs5lUl7P.js} +2 -2
  23. package/src/ui/public/assets/{BranchSelector-CSd5uaVm.js → BranchSelector-IjaFk0S-.js} +1 -1
  24. package/src/ui/public/assets/{CommitForm-Ckg9-zay.js → CommitForm-C-sowOXH.js} +3 -3
  25. package/src/ui/public/assets/{CommonDialog-B8XOXtz_.js → CommonDialog-QyptFD1R.js} +1 -1
  26. package/src/ui/public/assets/{EditorView-Crj8S2TI.js → EditorView-CvvluFCY.js} +1 -1
  27. package/src/ui/public/assets/{FileDiffViewer-DL07IMz5.js → FileDiffViewer-BXVsdCxE.js} +1 -1
  28. package/src/ui/public/assets/{FlowExecutionViewer-DvVT19Qv.js → FlowExecutionViewer-CRxipXru.js} +1 -1
  29. package/src/ui/public/assets/{FlowOrchestrationWorkspace-kRhK9IyL.js → FlowOrchestrationWorkspace-AE1ZHTKF.js} +1 -1
  30. package/src/ui/public/assets/{IconButton-pqSYKRT8.js → IconButton-BmSueF_H.js} +1 -1
  31. package/src/ui/public/assets/{LogList-BrN6qtFb.js → LogList-iaVA-_aC.js} +1 -1
  32. package/src/ui/public/assets/{MindmapView-CSsT0NDZ.js → MindmapView-DcG4jpCh.js} +1 -1
  33. package/src/ui/public/assets/{MonitorView-edRyBybQ.js → MonitorView-DO59syvl.js} +1 -1
  34. package/src/ui/public/assets/{ProjectStartupButton-DZ8VgU79.js → ProjectStartupButton-DYppceyw.js} +1 -1
  35. package/src/ui/public/assets/{RemoteManagerDialog-pu9szuYB.js → RemoteManagerDialog-CDbxG6pZ.js} +1 -1
  36. package/src/ui/public/assets/{RemoteRepoCard-BSFehgY5.js → RemoteRepoCard-Cko2KB8w.js} +1 -1
  37. package/src/ui/public/assets/{SourceMapView-DbEydLSU.js → SourceMapView-CwWPVdz-.js} +1 -1
  38. package/src/ui/public/assets/{SvgIcon-BVCbrPws.js → SvgIcon-Cf7nOKc8.js} +1 -1
  39. package/src/ui/public/assets/{TemplateManager-DPyYaGCE.js → TemplateManager-DWPDjWxM.js} +1 -1
  40. package/src/ui/public/assets/{UserInputNode-DXHeCjzG.js → UserInputNode-YOziN-5A.js} +1 -1
  41. package/src/ui/public/assets/WorkbenchView-DNnEpIYy.js +13 -0
  42. package/src/ui/public/assets/WorkbenchView-v3QFn6Ln.css +1 -0
  43. package/src/ui/public/assets/_plugin-vue_export-helper-CQjg8MXm.js +19 -0
  44. package/src/ui/public/assets/configStore-C7HOx5S1.js +1 -0
  45. package/src/ui/public/assets/{gitStore-BJbNtjxA.js → gitStore-DIsBd6n4.js} +1 -1
  46. package/src/ui/public/assets/index-FOe1cHkh.css +1 -0
  47. package/src/ui/public/assets/index-MmcnT2Dn.js +24 -0
  48. package/src/ui/public/assets/{previewSandbox-BZV6Eqhv.js → previewSandbox-TKucqLxt.js} +0 -0
  49. package/src/ui/public/assets/{toolsStore-Bu8Ujw_A.js → toolsStore-BiC2mmC3.js} +1 -1
  50. package/src/ui/public/assets/{vendor-CwZs6S_i.js → vendor-92fKgtnz.js} +7 -7
  51. package/src/ui/public/assets/{vendor-_6pgW9Z2.css → vendor-B1UrVTTI.css} +1 -1
  52. package/src/ui/public/index.html +14 -14
  53. package/src/ui/server/routes/codeAnalysis.js +6 -3
  54. package/src/ui/server/routes/config.js +40 -9
  55. package/src/ui/server/routes/fileOpen.js +48 -2
  56. package/src/ui/server/routes/gAiLauncher.test.js +70 -0
  57. package/src/ui/server/routes/workbench/agentChat.js +34 -8
  58. package/src/ui/server/routes/workbench/index.js +37 -6
  59. package/src/ui/server/routes/workbench/jobStore.js +4 -0
  60. package/src/ui/server/routes/workbench/llmClient.js +15 -8
  61. package/src/ui/server/routes/workbench/taskRunner.js +276 -96
  62. package/src/ui/server/routes/workbench/taskRunner.opencode.test.js +126 -0
  63. package/src/utils/aiEndpoint.js +231 -0
  64. package/src/utils/aiEndpoint.test.js +175 -0
  65. package/src/ui/public/assets/AgentView-BsN9y4Kg.css +0 -1
  66. package/src/ui/public/assets/WorkbenchView-CKXtZAJn.css +0 -1
  67. package/src/ui/public/assets/WorkbenchView-DGcirla3.js +0 -13
  68. package/src/ui/public/assets/_plugin-vue_export-helper-C-YBnnP5.js +0 -19
  69. package/src/ui/public/assets/configStore-DoobmT7N.js +0 -1
  70. package/src/ui/public/assets/index-ClqvC4kJ.css +0 -1
  71. package/src/ui/public/assets/index-ndDaGoRO.js +0 -24
@@ -37,38 +37,29 @@
37
37
  //
38
38
  // 实现注意:
39
39
  // - 本模块属于 CLI 侧,不依赖 GUI 服务器代码(src/ui/server/**)
40
- // - Ctrl+C 行为与 CLI 其他模式一致:由 gitCommit.js 的 setupSigintHandler
41
- // 统一 drain(trackChild 杀子进程 + cleanup 任务),整个会话退出
40
+ // - Ctrl+C 执行中只取消当前任务;空闲时交给主 CLI 清理并退出
42
41
 
43
42
  import readline from 'node:readline'
44
43
  import chalk from 'chalk'
45
44
  import config from '../../config.js'
46
45
  import { getCwd } from '../../utils/index.js'
47
46
  import { registerCleanup } from '../cleanup.js'
48
- import { TOOL_DEFINITIONS, executeTool } from './tools.js'
49
- import { createThinkFilter } from './streamFilter.js'
47
+ import { terminateCommand } from './tools.js'
48
+ import { runAgentTurn } from './turn.js'
49
+ import { repairToolHistory, loadProjectInstructions } from './context.js'
50
50
  import {
51
51
  printBanner, printHelpPanel,
52
52
  filterSlashCommands, renderSlashHintBody, parseKeyForSlashHint,
53
- startSpinner, createAssistantWriter,
54
- summarizeToolArgs, printToolHeader, printToolResult,
55
- formatDuration,
53
+ printTurnSummary,
56
54
  printOk, printWarn, printError, printDim,
57
55
  } from './termui.js'
58
- import { readClipboardImage, checkImageFile, imageToDataUrl, formatBytes } from './images.js'
56
+ import { readClipboardImage, checkImageFile, formatBytes } from './images.js'
59
57
  import { runModelSetup, collectModelInput, buildModelConfig, selectFromList } from './modelSetup.js'
60
58
  import { genSessionId, autoTitle, writeSession, enforceRetention, listSessions } from './sessionStore.js'
61
59
 
62
60
  // truncateDisplay 已迁移到 termui.js;这里 re-export 保持既有测试/外部引用不断
63
61
  export { truncateDisplay } from './termui.js'
64
62
 
65
- // ──────────────────────────────────────────────
66
- // 常量
67
- // ──────────────────────────────────────────────
68
- const MAX_TOOL_ITERATIONS = 40 // 单轮用户输入允许的最大工具调用循环数(防失控)
69
- const MAX_HISTORY_MESSAGES = 40 // 历史消息上限(超出后从最旧的整段对话裁剪)
70
- const LLM_TIMEOUT_MS = 300000 // 单次 LLM 请求超时(5 分钟,长推理模型够用)
71
-
72
63
  // ──────────────────────────────────────────────
73
64
  // i18n 字符串表(CLI 其他部分以中文为主,这里按 locale 给双语)
74
65
  // ──────────────────────────────────────────────
@@ -80,7 +71,7 @@ const STRINGS = {
80
71
  llmError: (msg) => `LLM 请求失败: ${msg}`,
81
72
  toolIterLimit: (n) => `已达单轮最大工具调用次数(${n}),本轮结束。如需继续请再发一条消息。`,
82
73
  toolRunning: (name) => `执行 ${name}…`,
83
- busy: '智能体正在执行中,请稍候(Ctrl+C 结束会话)…',
74
+ busy: '任务执行中,请稍候,或按 Ctrl+C 停止当前任务。',
84
75
  bye: '已退出 g ai',
85
76
  cleared: '对话历史已清空',
86
77
  newConversation: '已开启新对话',
@@ -96,11 +87,14 @@ const STRINGS = {
96
87
  oneShotNoModel: '未配置模型,无法启动',
97
88
  oneShotDone: '单发模式完成 · 需要多轮连续对话请直接运行 g ai(不带参数)',
98
89
  helpTitle: 'g ai 命令',
99
- bannerTip: 'Alt+V 粘贴图片 · /help 查看命令 · /exit 退出 · Ctrl+C 结束会话',
90
+ bannerTip: 'Alt+V 贴图 · /help 帮助 · Ctrl+C 停止当前任务 · /exit 退出',
100
91
  prompt: '❯ ',
101
92
  bannerModel: '模型',
102
93
  bannerCwd: '目录',
103
94
  thinkingLabel: '思考',
95
+ answerLabel: '回答',
96
+ thinkingHint: '… 预览已收起,/think full 显示后续完整思考',
97
+ emptyResponse: '模型没有返回回答,请重试或切换模型。',
104
98
  chars: '字符',
105
99
  imagePasting: '正在读取剪贴板图片…',
106
100
  imageAttached: (n, size) => `📎 图片 #${n} 已附加(${size}),将随下一条消息发送`,
@@ -126,7 +120,7 @@ const STRINGS = {
126
120
  llmError: (msg) => `LLM request failed: ${msg}`,
127
121
  toolIterLimit: (n) => `Hit max tool iterations (${n}) for this turn. Send another message to continue.`,
128
122
  toolRunning: (name) => `Running ${name}…`,
129
- busy: 'Agent is working, please wait (Ctrl+C to end session)…',
123
+ busy: 'Agent is working. Wait or press Ctrl+C to stop the task.',
130
124
  bye: 'Bye',
131
125
  cleared: 'Conversation cleared',
132
126
  newConversation: 'Started a new conversation',
@@ -142,11 +136,14 @@ const STRINGS = {
142
136
  oneShotNoModel: 'No model configured, aborting',
143
137
  oneShotDone: 'One-shot done · for multi-turn chat run `g ai` with no arguments',
144
138
  helpTitle: 'g ai commands',
145
- bannerTip: 'Alt+V paste image · /help for commands · /exit to quit · Ctrl+C to end session',
139
+ bannerTip: 'Alt+V image · /help commands · Ctrl+C stop task · /exit quit',
146
140
  prompt: '❯ ',
147
141
  bannerModel: 'Model',
148
142
  bannerCwd: 'CWD',
149
143
  thinkingLabel: 'Thinking',
144
+ answerLabel: 'Answer',
145
+ thinkingHint: '… preview hidden; /think full for future reasoning',
146
+ emptyResponse: 'The model returned no answer. Retry or switch models.',
150
147
  chars: 'chars',
151
148
  imagePasting: 'Reading clipboard image…',
152
149
  imageAttached: (n, size) => `📎 Image #${n} attached (${size}); sent with your next message`,
@@ -179,6 +176,10 @@ function modelLabel(m) {
179
176
  // ──────────────────────────────────────────────
180
177
  // 系统 prompt — 明确告知环境、权限边界与工作方式
181
178
  // ──────────────────────────────────────────────
179
+ async function buildProjectPrompt(options) {
180
+ return buildSystemPrompt(options) + await loadProjectInstructions(options.cwd)
181
+ }
182
+
182
183
  function buildSystemPrompt({ cwd, locale, shellDesc }) {
183
184
  const zh = !String(locale || '').startsWith('en')
184
185
  const now = new Date().toLocaleString()
@@ -210,7 +211,7 @@ ${isWin ? `- 当前是 Windows cmd.exe,以下 Unix 命令**不存在**,用了必
210
211
  - 工作目录内:读写文件、执行命令等所有操作直接执行
211
212
  - 其他目录:同样可以读取和修改
212
213
  - 唯一红线:不得破坏系统(格式化磁盘、删除根目录/系统目录、关机重启、写块设备等)。
213
- 安全守卫会拦截这类命令;被拦截时换安全方案,或告知用户需要他手动执行。
214
+ 命令守卫只能识别部分危险写法,不是沙箱;不得通过脚本或嵌套 shell 绕过限制。
214
215
 
215
216
  # 工作方式
216
217
  - 先动手、后提问:能用工具查清的不要问用户(list_files / read_file / search_text / run_command)
@@ -220,7 +221,8 @@ ${isWin ? `- 当前是 Windows cmd.exe,以下 Unix 命令**不存在**,用了必
220
221
  - git 操作用 run_command 执行;提交代码可以用 git 命令,也可以用本 CLI 的 g -y(默认信息提交并推送)或 g --ai(AI 生成提交信息)
221
222
  - run_command 默认就在工作目录执行,不要再加 cd / cd /d 前缀;默认超时 120 秒,长任务加大 timeout_seconds(最大 600)
222
223
  - 命令在 ${shellDesc} 下执行,注意语法兼容
223
- - 本项目跑测试用 npm test(node --test 不支持直接传目录路径)
224
+ - 先读取当前项目的规范、依赖与测试配置,再选择对应的验证命令,不要假定所有项目都使用 npm
225
+ - 用户要求概述或简短回答时,只读取必要的结构与入口文件,控制探索范围
224
226
 
225
227
  # 与用户交互
226
228
  - 需要向用户确认、提问或汇报重要决策时,直接用普通文本输出 —— 用户能实时看到你的文本;
@@ -259,7 +261,7 @@ ${isWin ? `- This is Windows cmd.exe. The following Unix commands do NOT exist h
259
261
  # Permissions (explicitly granted by the user — do not keep asking)
260
262
  - Inside the working directory: read/write files and run commands directly
261
263
  - Other directories: may also be read and modified
262
- - 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.
264
+ - Never destroy the system (format disks, delete root/system dirs, shutdown/reboot, write block devices). The command guard is not a sandbox; never bypass it with scripts or nested shells.
263
265
 
264
266
  # How to work
265
267
  - Act first, ask later: use tools (list_files / read_file / search_text / run_command) instead of asking the user
@@ -269,7 +271,8 @@ ${isWin ? `- This is Windows cmd.exe. The following Unix commands do NOT exist h
269
271
  - Git operations go through run_command; to commit, use git commands or this CLI's g -y / g --ai
270
272
  - run_command already executes in the working directory — do NOT prefix with cd; default timeout 120s, raise timeout_seconds (max 600) for long tasks
271
273
  - Commands run under ${shellDesc}; keep syntax compatible
272
- - Run this project's tests with npm test (node --test does not accept a bare directory)
274
+ - Read this project's instructions, dependencies and test configuration before choosing verification commands; do not assume npm
275
+ - For a brief overview, inspect only the necessary structure and entry points; keep exploration proportional to the request
273
276
 
274
277
  # Talking to the user
275
278
  - 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
@@ -282,125 +285,7 @@ ${isWin ? `- This is Windows cmd.exe. The following Unix commands do NOT exist h
282
285
  }
283
286
 
284
287
  // ──────────────────────────────────────────────
285
- // LLM 流式调用(OpenAI 兼容 + function calling)
286
- // 返回 { content, toolCalls, aborted }
287
- // ──────────────────────────────────────────────
288
- async function streamChatOnce({ model, messages, signal, onToken }) {
289
- const url = `${String(model.baseURL || '').replace(/\/$/, '')}/chat/completions`
290
- const headers = { 'Content-Type': 'application/json' }
291
- if (model.apiKey) headers['Authorization'] = `Bearer ${model.apiKey}`
292
-
293
- const body = JSON.stringify({
294
- model: model.model,
295
- messages,
296
- tools: TOOL_DEFINITIONS,
297
- temperature: 0.3,
298
- stream: true,
299
- })
300
-
301
- const controller = new AbortController()
302
- const timer = setTimeout(() => controller.abort(), LLM_TIMEOUT_MS)
303
- const onExternalAbort = () => controller.abort()
304
- if (signal) {
305
- if (signal.aborted) controller.abort()
306
- else signal.addEventListener('abort', onExternalAbort)
307
- }
308
-
309
- let content = ''
310
- // tool_calls 按 index 累积:provider 分片推送 id / name / arguments
311
- const toolCalls = []
312
- let aborted = false
313
- try {
314
- const resp = await fetch(url, { method: 'POST', headers, body, signal: controller.signal })
315
- if (!resp.ok || !resp.body) {
316
- const errText = await resp.text().catch(() => '')
317
- const snippet = errText.length > 300 ? errText.slice(0, 300) + '…' : errText
318
- // 400 且提到 tools/functions:大概率是模型不支持 function calling
319
- if (resp.status === 400 && /tool|function/i.test(snippet)) {
320
- throw new Error(`HTTP 400: 当前模型可能不支持 function calling(${snippet})。请在 g ui 中换用支持工具调用的模型(如 deepseek / qwen / gpt 系列)。`)
321
- }
322
- throw new Error(`HTTP ${resp.status}: ${snippet || resp.statusText}`)
323
- }
324
-
325
- const decoder = new TextDecoder('utf-8')
326
- let buf = ''
327
- for await (const chunk of resp.body) {
328
- buf += decoder.decode(chunk, { stream: true })
329
- const lines = buf.split('\n')
330
- buf = lines.pop() ?? ''
331
- for (const line of lines) {
332
- const trimmed = line.trim()
333
- if (!trimmed || !trimmed.startsWith('data:')) continue
334
- const payload = trimmed.slice(5).trim()
335
- if (payload === '[DONE]') continue
336
- let evt
337
- try { evt = JSON.parse(payload) } catch { continue }
338
- const delta = evt.choices?.[0]?.delta || {}
339
-
340
- // thinking(各 provider 字段名不同,逐一尝试)
341
- const thinkingChunk = delta.reasoning_content || delta.reasoning || delta.reasoning_text || ''
342
- if (thinkingChunk) onToken({ thinking: thinkingChunk })
343
-
344
- if (delta.content) {
345
- content += delta.content
346
- onToken({ content: delta.content })
347
- }
348
-
349
- for (const tc of delta.tool_calls || []) {
350
- const i = tc.index ?? 0
351
- if (!toolCalls[i]) toolCalls[i] = { id: '', type: 'function', function: { name: '', arguments: '' } }
352
- if (tc.id) toolCalls[i].id += tc.id
353
- if (tc.function?.name) toolCalls[i].function.name += tc.function.name
354
- if (tc.function?.arguments) toolCalls[i].function.arguments += tc.function.arguments
355
- }
356
- }
357
- }
358
- } catch (err) {
359
- if (err?.name === 'AbortError' || controller.signal.aborted) {
360
- aborted = true
361
- } else {
362
- throw err
363
- }
364
- } finally {
365
- clearTimeout(timer)
366
- if (signal) signal.removeEventListener('abort', onExternalAbort)
367
- }
368
- return { content, toolCalls: toolCalls.filter(Boolean), aborted }
369
- }
370
-
371
- // ──────────────────────────────────────────────
372
- // 历史裁剪:保留 system + 最近 MAX_HISTORY_MESSAGES 条
373
- // 切口必须落在 user 消息上,避免把 assistant(tool_calls) 与其 tool 结果从中间撕开
374
- // ──────────────────────────────────────────────
375
- function trimHistory(messages) {
376
- if (messages.length <= MAX_HISTORY_MESSAGES + 1) return
377
- let cut = messages.length - MAX_HISTORY_MESSAGES
378
- // 至少向后扫 5 条,跳过孤立的 tool / assistant(tool_calls),
379
- // 找到真正的 user 节点再切,防止撕裂 tool 调用链
380
- let safety = 0
381
- while (cut < messages.length && messages[cut].role !== 'user' && safety < 8) {
382
- cut++
383
- safety++
384
- }
385
- if (cut <= 1 || messages[cut]?.role !== 'user') return
386
- messages.splice(1, cut - 1)
387
- }
388
-
389
- // ──────────────────────────────────────────────
390
- // 消息消毒:确保发给 LLM 的消息数组里没有空内容。
391
- //
392
- // 背景:部分 LLM provider(如 Moonshot/Kimi、智谱、火山引擎、MiniMax 等)
393
- // 对 assistant 历史消息 content 校验严格 —— 当某轮 assistant 只返回
394
- // tool_calls 而没有文本、或正文被 trim 后只剩空白时,provider 会拒绝
395
- // 并报 "chat content is empty (2013)"。OpenAI 官方规范允许 assistant
396
- // 消息在带 tool_calls 时 content 为 null,但 provider 实现不一致:
397
- //
398
- // - assistant 带 tool_calls → content 强制 null(即使有字符串)
399
- // - assistant 不带 tool_calls 且 content 全空白 → null(避免触发 2013)
400
- // - user content 全空白 → 用单个空格 ' ' 占位(provider 通常可接受)
401
- // - tool content 全空白 → '(no output)' 占位(防止序列化时被丢)
402
- //
403
- // CLI 与 Web 端共用本逻辑(两份实现必须保持一致)。
288
+ // 消息兼容处理。只作用于请求副本,保留完整会话记录。
404
289
  // ──────────────────────────────────────────────
405
290
  export function sanitizeMessages(messages) {
406
291
  for (const m of messages) {
@@ -453,146 +338,6 @@ export function stripStaleImages(messages, locale) {
453
338
  // 单轮 agent 循环:用户一句话 → 流式输出 → 工具调用 → 再调用模型 … 直到模型给出最终文本
454
339
  // images: [{path, bytes}] 待发送图片(可为空数组)
455
340
  // ──────────────────────────────────────────────
456
- async function runAgentTurn(state, userText, t, images = []) {
457
- // 有图片:编码为 OpenAI 多模态 content parts;没图片保持纯字符串
458
- if (images.length > 0) {
459
- const parts = [{ type: 'text', text: userText }]
460
- for (const img of images) {
461
- try {
462
- const url = await imageToDataUrl(img.path)
463
- parts.push({ type: 'image_url', image_url: { url } })
464
- } catch (err) {
465
- printWarn(t.imageBadPath(img.path))
466
- }
467
- }
468
- state.messages.push({ role: 'user', content: parts })
469
- } else {
470
- state.messages.push({ role: 'user', content: userText })
471
- }
472
- // 旧消息里的图片降级为占位文字,防止 base64 随对话轮次累积撑爆上下文
473
- stripStaleImages(state.messages, state.locale)
474
-
475
- for (let iter = 0; iter < MAX_TOOL_ITERATIONS; iter++) {
476
- trimHistory(state.messages)
477
-
478
- // 首个 token 到达前转 spinner,到达后停掉并让位给流式渲染
479
- const spinner = startSpinner(t.waiting)
480
- let spinnerStopped = false
481
- const stopSpinner = () => {
482
- if (!spinnerStopped) { spinner.stop(); spinnerStopped = true }
483
- }
484
- // 每次 LLM 调用一个 writer:思考(橙黄斜体)+ 正文 ➤ 子弹头 + 逐行 markdown
485
- const writer = createAssistantWriter({
486
- showThinking: state.showThinking,
487
- thinkingHeader: t.thinkingLabel,
488
- })
489
- // MiniMax 系把 <think> 标签内联在 content 流里,用过滤器剥离并置灰显示
490
- // (历史里仍保存模型原始输出,见 streamChatOnce 返回的 content)
491
- const thinkFilter = createThinkFilter()
492
- const renderSeg = (seg) => {
493
- const kind = seg.content !== undefined ? 'content' : 'thinking'
494
- const text = seg.content ?? seg.thinking
495
- if (!text) return
496
- stopSpinner()
497
- if (kind === 'thinking') writer.writeThinking(text)
498
- else writer.writeContent(text)
499
- }
500
-
501
- // 发送前消毒:确保历史里没有空 content 的消息(部分 provider 报 2013)
502
- sanitizeMessages(state.messages)
503
-
504
- const llmStart = performance.now()
505
- let result
506
- try {
507
- result = await streamChatOnce({
508
- model: state.model,
509
- messages: state.messages,
510
- signal: state.abortController?.signal,
511
- onToken: ({ thinking, content }) => {
512
- if (thinking) renderSeg({ thinking })
513
- if (content) for (const seg of thinkFilter.feed(content)) renderSeg(seg)
514
- },
515
- })
516
- // 流结束,冲刷过滤器里残留的半拉标签/未闭合 think 块
517
- for (const seg of thinkFilter.flush()) renderSeg(seg)
518
- } catch (err) {
519
- stopSpinner()
520
- writer.finish()
521
- printError(t.llmError(err.message))
522
- // 请求失败时,如果最后一条仍是本轮塞进去的 user 消息则撤掉,
523
- // 避免历史里留一条没有回应的消息;若已进入工具循环(末尾是 tool
524
- // 结果),消息链本身是完整的,保持不动
525
- const last = state.messages[state.messages.length - 1]
526
- if (last?.role === 'user') state.messages.pop()
527
- return
528
- }
529
- const llmDuration = performance.now() - llmStart
530
- stopSpinner()
531
- writer.finish()
532
-
533
- if (result.aborted) {
534
- printWarn('⛔ 已中止')
535
- return
536
- }
537
-
538
- const { content, toolCalls } = result
539
-
540
- // 无工具调用:本轮结束,assistant 文本入历史
541
- if (toolCalls.length === 0) {
542
- state.messages.push({ role: 'assistant', content: content || null })
543
- return
544
- }
545
-
546
- // 有工具调用:assistant(带 tool_calls)入历史,然后逐个执行
547
- // content 为 null 而非空字符串:部分 provider 拒绝空字符串内容(2013 错误)
548
- state.messages.push({ role: 'assistant', content: content || null, tool_calls: toolCalls })
549
-
550
- // LLM 耗时在进入工具执行前打印(仅本轮有工具调用时显示)
551
- if (llmDuration > 0) {
552
- printDim(` ⏱ LLM ${formatDuration(llmDuration)}`)
553
- }
554
-
555
- for (const tc of toolCalls) {
556
- const name = tc.function?.name || ''
557
- const rawArgs = tc.function?.arguments || ''
558
-
559
- let args
560
- try {
561
- args = rawArgs ? JSON.parse(rawArgs) : {}
562
- } catch {
563
- printToolHeader(name, printDimInline(rawArgs))
564
- const errResult = `错误: 工具参数不是合法 JSON: ${rawArgs.slice(0, 200)}`
565
- printToolResult(errResult)
566
- state.messages.push({ role: 'tool', tool_call_id: tc.id || name, name, content: errResult })
567
- continue
568
- }
569
-
570
- printToolHeader(name, summarizeToolArgs(name, args, { chars: t.chars }))
571
- // 长命令执行期间给个 spinner,让用户知道没有卡死
572
- const toolSpinner = startSpinner(t.toolRunning(name))
573
- const toolStart = performance.now()
574
- const output = await executeTool(name, args, state.ctx)
575
- const toolDuration = performance.now() - toolStart
576
- toolSpinner.stop()
577
- printToolResult(output, undefined, toolDuration)
578
- state.messages.push({ role: 'tool', tool_call_id: tc.id || name, name, content: output })
579
- }
580
- // 工具结果全部入历史后继续循环,让模型基于结果决定下一步
581
- }
582
-
583
- printWarn(t.toolIterLimit(MAX_TOOL_ITERATIONS))
584
- }
585
-
586
- // JSON 解析失败时的参数回显:单行截断,不进 summarizeToolArgs(它没有结构化参数可用)
587
- function printDimInline(rawArgs) {
588
- let preview = String(rawArgs || '').replace(/\s+/g, ' ').trim()
589
- if (preview.length > 160) preview = preview.slice(0, 160) + '…'
590
- return preview
591
- }
592
-
593
- // ──────────────────────────────────────────────
594
- // 斜杠命令
595
- // ──────────────────────────────────────────────
596
341
  function printSlashHelp(t, locale) {
597
342
  const zh = !String(locale || '').startsWith('en')
598
343
  const lines = zh ? [
@@ -601,7 +346,9 @@ function printSlashHelp(t, locale) {
601
346
  ' /addmodel 添加新的模型配置(交互式向导)',
602
347
  ' /cd <路径> 切换智能体工作目录',
603
348
  ' /image [路径] 查看待发送图片;/image <路径> 附加本地图片;/image clear 清除',
604
- ' /think 开关思考过程显示',
349
+ ' /think [模式] compact 预览 / full 完整 / off 隐藏思考',
350
+ ' /tools [模式] compact 精简 / full 完整工具输出',
351
+ ' /stats 查看上一轮与会话累计用量、耗时',
605
352
  ' /new 开启新对话',
606
353
  ' /resume 选择并恢复之前的对话',
607
354
  ' /clear 清空对话历史',
@@ -610,14 +357,16 @@ function printSlashHelp(t, locale) {
610
357
  ' Alt+V 粘贴剪贴板图片,随下一条消息发送(需视觉模型)',
611
358
  '',
612
359
  '权限说明: 工作目录内全部操作直接执行;其他目录可读写;',
613
- '仅系统级破坏命令(格式化、删根目录、关机等)会被安全守卫拦截。',
360
+ '命令守卫识别部分系统级危险操作,不是隔离沙箱。',
614
361
  ] : [
615
362
  ' /help Show this help',
616
363
  ' /model List models; /model <n> to switch',
617
364
  ' /addmodel Add a new model (interactive wizard)',
618
365
  ' /cd <path> Change agent working directory',
619
366
  ' /image [path] List pending images; attach a file; /image clear to reset',
620
- ' /think Toggle thinking display',
367
+ ' /think [mode] compact / full / off reasoning display',
368
+ ' /tools [mode] compact / full tool output',
369
+ ' /stats Last turn timing and session token usage',
621
370
  ' /new Start a new conversation',
622
371
  ' /resume Choose and resume a previous conversation',
623
372
  ' /clear Clear conversation history',
@@ -626,22 +375,30 @@ function printSlashHelp(t, locale) {
626
375
  ' Alt+V Paste clipboard image (needs a vision-capable model)',
627
376
  '',
628
377
  'Permissions: full access in the working directory; other dirs readable/writable;',
629
- 'only system-destroying commands (format, rm -rf /, shutdown, ...) are blocked.',
378
+ 'the command guard detects some dangerous operations; it is not a sandbox.',
630
379
  ]
631
380
  printHelpPanel(t.helpTitle, lines)
632
381
  }
633
382
 
634
- async function handleSlashCommand(state, input, t) {
383
+ export async function handleSlashCommand(state, input, t) {
635
384
  const [cmd, ...rest] = input.split(/\s+/)
636
385
  const arg = rest.join(' ').trim()
386
+ const zh = !String(state.locale).startsWith('en')
387
+ if (state.busy && !['/help', '/stats', '/think', '/tools', '/exit', '/quit'].includes(cmd)) {
388
+ printDim(t.busy)
389
+ return 'ok'
390
+ }
637
391
 
638
392
  if (cmd === '/exit' || cmd === '/quit') return 'exit'
639
393
  if (cmd === '/help') { printSlashHelp(t, state.locale); return 'ok' }
640
394
  if (cmd === '/clear' || cmd === '/new') {
395
+ await state.persistSession?.()
641
396
  state.messages = [state.messages[0]]
642
397
  // 开新会话 ID,旧会话保留在磁盘上
643
398
  state.sessionId = genSessionId()
644
399
  state.sessionCreatedAt = new Date().toISOString()
400
+ state.lastTurnStats = null
401
+ state.sessionStats = null
645
402
  printOk(cmd === '/new' ? t.newConversation : t.cleared)
646
403
  return 'ok'
647
404
  }
@@ -701,10 +458,12 @@ async function handleSlashCommand(state, input, t) {
701
458
  }
702
459
  state.sessionId = session.sessionId
703
460
  state.sessionCreatedAt = session.createdAt || new Date().toISOString()
704
- state.messages = session.messages.map(message => ({ ...message }))
461
+ state.messages = repairToolHistory(session.messages)
462
+ state.lastTurnStats = session.lastTurnStats || null
463
+ state.sessionStats = session.sessionStats || null
705
464
  const systemMessage = {
706
465
  role: 'system',
707
- content: buildSystemPrompt({ cwd: state.ctx.cwd, locale: state.locale, shellDesc: state.shellDesc }),
466
+ content: await buildProjectPrompt({ cwd: state.ctx.cwd, locale: state.locale, shellDesc: state.shellDesc }),
708
467
  }
709
468
  if (state.messages[0]?.role === 'system') state.messages[0] = systemMessage
710
469
  else state.messages.unshift(systemMessage)
@@ -720,8 +479,25 @@ async function handleSlashCommand(state, input, t) {
720
479
  return 'ok'
721
480
  }
722
481
  if (cmd === '/think') {
723
- state.showThinking = !state.showThinking
724
- printOk(state.showThinking ? t.thinkOn : t.thinkOff)
482
+ const next = arg || (state.showThinking ? 'off' : 'compact')
483
+ if (!['off', 'compact', 'full'].includes(next)) {
484
+ printWarn('/think compact | full | off')
485
+ return 'ok'
486
+ }
487
+ state.showThinking = next !== 'off'
488
+ state.thinkingMode = next
489
+ printOk(zh ? `思考显示:${({ off: '隐藏', compact: '预览', full: '完整' })[next]}` : `Thinking: ${next}`)
490
+ return 'ok'
491
+ }
492
+ if (cmd === '/tools') {
493
+ if (arg && !['full', 'compact'].includes(arg)) { printWarn('/tools compact | full'); return 'ok' }
494
+ state.fullTools = arg ? arg === 'full' : !state.fullTools
495
+ printOk(zh ? `后续工具结果:${state.fullTools ? '完整' : '精简'}` : `Future tool output: ${state.fullTools ? 'full' : 'compact'}`)
496
+ return 'ok'
497
+ }
498
+ if (cmd === '/stats') {
499
+ if (state.lastTurnStats) printTurnSummary(state.lastTurnStats, { locale: state.locale, session: state.sessionStats })
500
+ else printDim(zh ? '完成一轮对话后显示用量与耗时。' : 'Usage and timing appear after a turn completes.')
725
501
  return 'ok'
726
502
  }
727
503
  if (cmd === '/image') {
@@ -823,7 +599,8 @@ async function handleSlashCommand(state, input, t) {
823
599
  if (!st.isDirectory()) throw new Error('not a dir')
824
600
  state.ctx.cwd = target
825
601
  // 同步更新 system prompt 里的 cwd 说明(重建首条消息)
826
- state.messages[0] = { role: 'system', content: buildSystemPrompt({ cwd: target, locale: state.locale, shellDesc: state.shellDesc }) }
602
+ state.messages[0] = { role: 'system', content: await buildProjectPrompt({ cwd: target, locale: state.locale, shellDesc: state.shellDesc }) }
603
+ await state.persistSession?.()
827
604
  printOk(t.cdOk(target))
828
605
  } catch {
829
606
  printError(t.cdFail(target))
@@ -884,7 +661,7 @@ export async function runAiAgent(argv = []) {
884
661
  // 在 Web UI 智能体 tab 中可见(带 CLI 标记)
885
662
  sessionId: genSessionId(),
886
663
  sessionCreatedAt: new Date().toISOString(),
887
- messages: [{ role: 'system', content: buildSystemPrompt({ cwd, locale, shellDesc }) }],
664
+ messages: [{ role: 'system', content: await buildProjectPrompt({ cwd, locale, shellDesc }) }],
888
665
  ctx: {
889
666
  cwd,
890
667
  onChild: (child) => {
@@ -897,11 +674,19 @@ export async function runAiAgent(argv = []) {
897
674
  model,
898
675
  locale,
899
676
  shellDesc,
677
+ // 单轮工具调用上限:来自全局配置 aiMaxToolIterations(loadConfig 已规范化),
678
+ // 读不到时由 runAgentTurn 兜底为 200
679
+ maxToolIterations: cfg.aiMaxToolIterations,
900
680
  currentChild: null,
901
681
  abortController: null,
902
682
  cancelRequested: false,
903
683
  busy: false,
904
684
  showThinking: true, // /think 切换:是否回显模型的思考过程
685
+ thinkingMode: 'compact',
686
+ fullTools: false,
687
+ lastTurnStats: null,
688
+ sessionStats: null,
689
+ prepareMessages: (messages) => { stripStaleImages(messages, locale); sanitizeMessages(messages) },
905
690
  pendingImages: [], // Alt+V / /image 附加的待发送图片 [{path, bytes}]
906
691
  pasting: false, // 剪贴板读取进行中(防 Alt+V 连打并发)
907
692
  inWizard: false, // /addmodel 交互式向导进行中:忽略 REPL 的 line 事件
@@ -909,41 +694,52 @@ export async function runAiAgent(argv = []) {
909
694
  }
910
695
 
911
696
  // 持久化当前会话到磁盘(供 Web UI 读取)
697
+ let saving = Promise.resolve()
912
698
  async function persistSession() {
699
+ if (state.messages.length <= 1) return
913
700
  try {
914
701
  const title = autoTitle(state.messages)
915
- await writeSession(state.sessionId, {
702
+ const sessionId = state.sessionId
703
+ const snapshot = JSON.parse(JSON.stringify({
916
704
  version: 1,
917
705
  sessionId: state.sessionId,
918
706
  title,
919
707
  source: 'cli',
920
- cwd,
708
+ cwd: state.ctx.cwd,
921
709
  model: modelLabel(state.model),
922
710
  createdAt: state.sessionCreatedAt,
923
711
  updatedAt: new Date().toISOString(),
924
- messages: state.messages,
925
- })
712
+ messages: repairToolHistory(state.messages),
713
+ lastTurnStats: state.lastTurnStats,
714
+ sessionStats: state.sessionStats,
715
+ }))
716
+ saving = saving.catch(() => {}).then(() => writeSession(sessionId, snapshot))
717
+ await saving
926
718
  enforceRetention().catch(() => {})
927
719
  } catch (err) {
928
720
  // 持久化失败不影响 CLI 正常使用
929
721
  printDim(`(会话保存失败: ${err.message})`)
930
722
  }
931
723
  }
724
+ state.persistSession = persistSession
932
725
 
933
726
  // SIGINT 时中止进行中的 LLM 请求 + 正在跑的子命令
934
727
  // (进程退出与全局子进程 drain 由 gitCommit.js 的统一 handler 负责,这里只做快速止血)
935
- registerCleanup('aiAgent', () => {
728
+ registerCleanup('aiAgent', async () => {
729
+ state.cancelRequested = true
936
730
  try { state.abortController?.abort() } catch (_) {}
937
- try { state.currentChild?.kill('SIGTERM') } catch (_) {}
731
+ await terminateCommand(state.currentChild)
732
+ await persistSession()
938
733
  })
939
734
 
940
735
  // 单发模式:g ai "帮我改个 bug"(位置参数拼成 prompt,跑一轮就退出)
941
736
  const oneShot = argv.filter(a => !a.startsWith('-')).join(' ').trim()
942
737
  if (oneShot) {
943
738
  console.log(chalk.dim(`[${t.bannerModel}] ${modelLabel(model)} · [${t.bannerCwd}] ${cwd}`))
944
- await runAgentTurn(state, oneShot, t)
945
- await persistSession()
946
- printDim(t.oneShotDone)
739
+ state.abortController = new AbortController()
740
+ const stats = await runAgentTurn(state, oneShot, t)
741
+ if (stats.status === 'completed') printDim(t.oneShotDone)
742
+ else process.exitCode = stats.status === 'cancelled' ? 130 : 1
947
743
  return
948
744
  }
949
745
 
@@ -961,9 +757,8 @@ export async function runAiAgent(argv = []) {
961
757
  const rl = readline.createInterface({
962
758
  input: process.stdin,
963
759
  output: process.stdout,
964
- // prompt: ❯ 用青色加粗;末尾追加 ANSI 亮白+加粗码,
965
- // 让用户输入的文本继承亮白色高亮(黑底上最清晰)
966
- prompt: chalk.cyan.bold(t.prompt) + '\x1b[1;97m',
760
+ // 颜色闭合,避免提示符的样式泄漏到后续输出。
761
+ prompt: chalk.cyan.bold(t.prompt + ' '),
967
762
  historySize: 200,
968
763
  })
969
764
  state.rl = rl // 供 /addmodel 等需要 rl.question 的斜杠命令复用
@@ -1015,9 +810,6 @@ export async function runAiAgent(argv = []) {
1015
810
  }
1016
811
  }
1017
812
 
1018
- // readline 的 clearLine() 已经写了 \r\n,不需要我们再补换行。
1019
- const closeInputFrame = () => {}
1020
-
1021
813
  // 在提示符上方插一行通知(粘贴进度等)。
1022
814
  const notifyAbovePrompt = (text) => {
1023
815
  console.log(text)
@@ -1122,7 +914,7 @@ export async function runAiAgent(argv = []) {
1122
914
  neutralizeKeyForReadline(key)
1123
915
  state.cancelRequested = true
1124
916
  try { state.abortController?.abort() } catch (_) {}
1125
- try { state.currentChild?.kill('SIGTERM') } catch (_) {}
917
+ void terminateCommand(state.currentChild)
1126
918
  return
1127
919
  }
1128
920
  if (slashHintRows > 0) {
@@ -1251,8 +1043,6 @@ export async function runAiAgent(argv = []) {
1251
1043
  state.abortController = new AbortController()
1252
1044
  try {
1253
1045
  await runAgentTurn(state, input, t, images)
1254
- // 每轮对话结束后持久化到磁盘,供 Web UI 读取
1255
- await persistSession()
1256
1046
  } catch (err) {
1257
1047
  if (state.cancelRequested) printDim(t.operationCancelled)
1258
1048
  else printError(err.message)
@@ -1265,6 +1055,11 @@ export async function runAiAgent(argv = []) {
1265
1055
  })
1266
1056
 
1267
1057
  rl.on('close', () => {
1058
+ if (state.busy) {
1059
+ state.cancelRequested = true
1060
+ state.abortController?.abort()
1061
+ void terminateCommand(state.currentChild)
1062
+ }
1268
1063
  rl.input.removeListener('keypress', interceptInteractiveControlKeys)
1269
1064
  printWarn('\n' + t.bye)
1270
1065
  })