zen-gitsync 2.17.6 → 2.17.8
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 +39 -2
- package/package.json +1 -1
- package/scripts/release.js +139 -48
- package/src/cli/ai/agent.js +108 -324
- package/src/cli/ai/cli.integration.test.js +124 -0
- package/src/cli/ai/context.js +90 -0
- package/src/cli/ai/modelSetup.js +38 -0
- package/src/cli/ai/runtime.test.js +181 -0
- package/src/cli/ai/safety.js +27 -4
- package/src/cli/ai/safety.test.js +13 -0
- package/src/cli/ai/telemetry.js +43 -0
- package/src/cli/ai/termui.js +181 -51
- package/src/cli/ai/termui.test.js +165 -16
- package/src/cli/ai/tools.js +27 -5
- package/src/cli/ai/tools.test.js +23 -0
- package/src/cli/ai/transport.js +89 -0
- package/src/cli/ai/turn.js +126 -0
- package/src/config.js +34 -4
- package/src/ui/public/assets/AgentView-CdJaeRh0.js +5 -0
- package/src/ui/public/assets/AgentView-DAY4S5WN.css +1 -0
- package/src/ui/public/assets/{AppVersionBadge-DV6KuLqb.js → AppVersionBadge-CiijSYfM.js} +2 -2
- package/src/ui/public/assets/{BranchSelector-DGXDAOkR.js → BranchSelector-BS7ajwvv.js} +1 -1
- package/src/ui/public/assets/{CommitForm-BYBj7UUa.js → CommitForm-CEOSqXCA.js} +3 -3
- package/src/ui/public/assets/{CommonDialog-DWMCeujE.js → CommonDialog-CAaEi-4T.js} +1 -1
- package/src/ui/public/assets/{EditorView-D-TEQZso.js → EditorView-DyaFyRDD.js} +1 -1
- package/src/ui/public/assets/{FileDiffViewer-C4ZANajI.js → FileDiffViewer-BvX9cQjR.js} +1 -1
- package/src/ui/public/assets/{FlowExecutionViewer-DUv7mBuG.js → FlowExecutionViewer-uXrzZjsW.js} +1 -1
- package/src/ui/public/assets/{FlowOrchestrationWorkspace-BiSuvzL7.js → FlowOrchestrationWorkspace-DIBpUSt_.js} +1 -1
- package/src/ui/public/assets/{IconButton-BltPKVbV.js → IconButton-CnoW2on6.js} +1 -1
- package/src/ui/public/assets/{LogList-BY1Haq9r.js → LogList-D2espSVo.js} +1 -1
- package/src/ui/public/assets/{MindmapView-BKsvCnYv.js → MindmapView-CZF-rZVs.js} +1 -1
- package/src/ui/public/assets/{MonitorView-B3_NaJRy.js → MonitorView-DjLr7qOG.js} +1 -1
- package/src/ui/public/assets/{ProjectStartupButton-DP3eDVRp.js → ProjectStartupButton-DzsU-QIO.js} +1 -1
- package/src/ui/public/assets/{RemoteManagerDialog-Bl9b_iRv.js → RemoteManagerDialog-CuuG2ucT.js} +1 -1
- package/src/ui/public/assets/{RemoteRepoCard-TQ3DxDnq.js → RemoteRepoCard-BGf8L0of.js} +1 -1
- package/src/ui/public/assets/{SourceMapView-BF-Mzv3V.js → SourceMapView-CFb02m6z.js} +1 -1
- package/src/ui/public/assets/{SvgIcon-DxazW7dI.js → SvgIcon-SRAeYjnN.js} +1 -1
- package/src/ui/public/assets/{TemplateManager-BN6gWFWX.js → TemplateManager-gedqAXl8.js} +1 -1
- package/src/ui/public/assets/{UserInputNode-CPwvIfP6.js → UserInputNode-BOCuMQW6.js} +1 -1
- package/src/ui/public/assets/WorkbenchView-CRoR21LQ.js +13 -0
- package/src/ui/public/assets/WorkbenchView-v3QFn6Ln.css +1 -0
- package/src/ui/public/assets/_plugin-vue_export-helper-C8OE7U9H.js +19 -0
- package/src/ui/public/assets/configStore-CuMElTgV.js +1 -0
- package/src/ui/public/assets/{gitStore-CYrdjXPU.js → gitStore-CpcS-MzM.js} +1 -1
- package/src/ui/public/assets/index-cApp6w0b.css +1 -0
- package/src/ui/public/assets/index-yXm9JLGv.js +24 -0
- package/src/ui/public/assets/{previewSandbox-D_j5PWX1.js → previewSandbox-DkXYEpLP.js} +0 -0
- package/src/ui/public/assets/{toolsStore-BiC2mmC3.js → toolsStore-DGxlRe3Y.js} +1 -1
- package/src/ui/public/assets/vendor-4ojsjDYc.css +1 -0
- package/src/ui/public/assets/{vendor-92fKgtnz.js → vendor-dO4B7wQf.js} +28 -28
- package/src/ui/public/index.html +14 -14
- package/src/ui/server/routes/config.js +8 -3
- package/src/ui/server/routes/fileOpen.js +48 -2
- package/src/ui/server/routes/fs.js +37 -0
- package/src/ui/server/routes/gAiLauncher.test.js +70 -0
- package/src/ui/server/routes/workbench/index.js +37 -6
- package/src/ui/server/routes/workbench/jobStore.js +4 -0
- package/src/ui/server/routes/workbench/taskRunner.js +276 -96
- package/src/ui/server/routes/workbench/taskRunner.opencode.test.js +126 -0
- package/src/ui/server/utils/directoryFetch.js +209 -0
- package/src/ui/server/utils/directoryFetch.test.js +232 -0
- package/src/ui/public/assets/AgentView-BC3F0F6m.js +0 -4
- package/src/ui/public/assets/AgentView-CzUeyQfR.css +0 -1
- package/src/ui/public/assets/WorkbenchView-CDu61WWs.js +0 -13
- package/src/ui/public/assets/WorkbenchView-DEejub9Z.css +0 -1
- package/src/ui/public/assets/_plugin-vue_export-helper-B7A-NgBp.js +0 -19
- package/src/ui/public/assets/configStore-Dx84lvTX.js +0 -1
- package/src/ui/public/assets/index-DNHOYMCg.js +0 -24
- package/src/ui/public/assets/index-Dsfmp6w8.css +0 -1
- package/src/ui/public/assets/vendor-B1UrVTTI.css +0 -1
package/src/cli/ai/agent.js
CHANGED
|
@@ -37,39 +37,29 @@
|
|
|
37
37
|
//
|
|
38
38
|
// 实现注意:
|
|
39
39
|
// - 本模块属于 CLI 侧,不依赖 GUI 服务器代码(src/ui/server/**)
|
|
40
|
-
// - Ctrl+C
|
|
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 {
|
|
49
|
-
import {
|
|
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
|
-
|
|
54
|
-
summarizeToolArgs, printToolHeader, printToolResult,
|
|
55
|
-
formatDuration,
|
|
53
|
+
printTurnSummary,
|
|
56
54
|
printOk, printWarn, printError, printDim,
|
|
57
55
|
} from './termui.js'
|
|
58
|
-
import { readClipboardImage, checkImageFile,
|
|
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
|
-
import { buildAiChatRequest, describeAiHttpError } from '../../utils/aiEndpoint.js'
|
|
62
59
|
|
|
63
60
|
// truncateDisplay 已迁移到 termui.js;这里 re-export 保持既有测试/外部引用不断
|
|
64
61
|
export { truncateDisplay } from './termui.js'
|
|
65
62
|
|
|
66
|
-
// ──────────────────────────────────────────────
|
|
67
|
-
// 常量
|
|
68
|
-
// ──────────────────────────────────────────────
|
|
69
|
-
const DEFAULT_MAX_TOOL_ITERATIONS = 200 // 单轮工具调用循环数兜底值(防失控);实际值取配置里的 aiMaxToolIterations
|
|
70
|
-
const MAX_HISTORY_MESSAGES = 40 // 历史消息上限(超出后从最旧的整段对话裁剪)
|
|
71
|
-
const LLM_TIMEOUT_MS = 300000 // 单次 LLM 请求超时(5 分钟,长推理模型够用)
|
|
72
|
-
|
|
73
63
|
// ──────────────────────────────────────────────
|
|
74
64
|
// i18n 字符串表(CLI 其他部分以中文为主,这里按 locale 给双语)
|
|
75
65
|
// ──────────────────────────────────────────────
|
|
@@ -81,7 +71,7 @@ const STRINGS = {
|
|
|
81
71
|
llmError: (msg) => `LLM 请求失败: ${msg}`,
|
|
82
72
|
toolIterLimit: (n) => `已达单轮最大工具调用次数(${n}),本轮结束。如需继续请再发一条消息。`,
|
|
83
73
|
toolRunning: (name) => `执行 ${name}…`,
|
|
84
|
-
busy: '
|
|
74
|
+
busy: '任务执行中,请稍候,或按 Ctrl+C 停止当前任务。',
|
|
85
75
|
bye: '已退出 g ai',
|
|
86
76
|
cleared: '对话历史已清空',
|
|
87
77
|
newConversation: '已开启新对话',
|
|
@@ -97,11 +87,14 @@ const STRINGS = {
|
|
|
97
87
|
oneShotNoModel: '未配置模型,无法启动',
|
|
98
88
|
oneShotDone: '单发模式完成 · 需要多轮连续对话请直接运行 g ai(不带参数)',
|
|
99
89
|
helpTitle: 'g ai 命令',
|
|
100
|
-
bannerTip: 'Alt+V
|
|
90
|
+
bannerTip: 'Alt+V 贴图 · /help 帮助 · Ctrl+C 停止当前任务 · /exit 退出',
|
|
101
91
|
prompt: '❯ ',
|
|
102
92
|
bannerModel: '模型',
|
|
103
93
|
bannerCwd: '目录',
|
|
104
94
|
thinkingLabel: '思考',
|
|
95
|
+
answerLabel: '回答',
|
|
96
|
+
thinkingHint: '… 已显示前 12 行;输入 /think full 开启后续完整思考',
|
|
97
|
+
emptyResponse: '模型没有返回回答,请重试或切换模型。',
|
|
105
98
|
chars: '字符',
|
|
106
99
|
imagePasting: '正在读取剪贴板图片…',
|
|
107
100
|
imageAttached: (n, size) => `📎 图片 #${n} 已附加(${size}),将随下一条消息发送`,
|
|
@@ -127,7 +120,7 @@ const STRINGS = {
|
|
|
127
120
|
llmError: (msg) => `LLM request failed: ${msg}`,
|
|
128
121
|
toolIterLimit: (n) => `Hit max tool iterations (${n}) for this turn. Send another message to continue.`,
|
|
129
122
|
toolRunning: (name) => `Running ${name}…`,
|
|
130
|
-
busy: 'Agent is working
|
|
123
|
+
busy: 'Agent is working. Wait or press Ctrl+C to stop the task.',
|
|
131
124
|
bye: 'Bye',
|
|
132
125
|
cleared: 'Conversation cleared',
|
|
133
126
|
newConversation: 'Started a new conversation',
|
|
@@ -143,11 +136,14 @@ const STRINGS = {
|
|
|
143
136
|
oneShotNoModel: 'No model configured, aborting',
|
|
144
137
|
oneShotDone: 'One-shot done · for multi-turn chat run `g ai` with no arguments',
|
|
145
138
|
helpTitle: 'g ai commands',
|
|
146
|
-
bannerTip: 'Alt+V
|
|
139
|
+
bannerTip: 'Alt+V image · /help commands · Ctrl+C stop task · /exit quit',
|
|
147
140
|
prompt: '❯ ',
|
|
148
141
|
bannerModel: 'Model',
|
|
149
142
|
bannerCwd: 'CWD',
|
|
150
143
|
thinkingLabel: 'Thinking',
|
|
144
|
+
answerLabel: 'Answer',
|
|
145
|
+
thinkingHint: '… first 12 lines shown; enter /think full for future reasoning',
|
|
146
|
+
emptyResponse: 'The model returned no answer. Retry or switch models.',
|
|
151
147
|
chars: 'chars',
|
|
152
148
|
imagePasting: 'Reading clipboard image…',
|
|
153
149
|
imageAttached: (n, size) => `📎 Image #${n} attached (${size}); sent with your next message`,
|
|
@@ -180,6 +176,10 @@ function modelLabel(m) {
|
|
|
180
176
|
// ──────────────────────────────────────────────
|
|
181
177
|
// 系统 prompt — 明确告知环境、权限边界与工作方式
|
|
182
178
|
// ──────────────────────────────────────────────
|
|
179
|
+
async function buildProjectPrompt(options) {
|
|
180
|
+
return buildSystemPrompt(options) + await loadProjectInstructions(options.cwd)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
183
|
function buildSystemPrompt({ cwd, locale, shellDesc }) {
|
|
184
184
|
const zh = !String(locale || '').startsWith('en')
|
|
185
185
|
const now = new Date().toLocaleString()
|
|
@@ -211,7 +211,7 @@ ${isWin ? `- 当前是 Windows cmd.exe,以下 Unix 命令**不存在**,用了必
|
|
|
211
211
|
- 工作目录内:读写文件、执行命令等所有操作直接执行
|
|
212
212
|
- 其他目录:同样可以读取和修改
|
|
213
213
|
- 唯一红线:不得破坏系统(格式化磁盘、删除根目录/系统目录、关机重启、写块设备等)。
|
|
214
|
-
|
|
214
|
+
命令守卫只能识别部分危险写法,不是沙箱;不得通过脚本或嵌套 shell 绕过限制。
|
|
215
215
|
|
|
216
216
|
# 工作方式
|
|
217
217
|
- 先动手、后提问:能用工具查清的不要问用户(list_files / read_file / search_text / run_command)
|
|
@@ -221,7 +221,8 @@ ${isWin ? `- 当前是 Windows cmd.exe,以下 Unix 命令**不存在**,用了必
|
|
|
221
221
|
- git 操作用 run_command 执行;提交代码可以用 git 命令,也可以用本 CLI 的 g -y(默认信息提交并推送)或 g --ai(AI 生成提交信息)
|
|
222
222
|
- run_command 默认就在工作目录执行,不要再加 cd / cd /d 前缀;默认超时 120 秒,长任务加大 timeout_seconds(最大 600)
|
|
223
223
|
- 命令在 ${shellDesc} 下执行,注意语法兼容
|
|
224
|
-
-
|
|
224
|
+
- 先读取当前项目的规范、依赖与测试配置,再选择对应的验证命令,不要假定所有项目都使用 npm
|
|
225
|
+
- 用户要求概述或简短回答时,只读取必要的结构与入口文件,控制探索范围
|
|
225
226
|
|
|
226
227
|
# 与用户交互
|
|
227
228
|
- 需要向用户确认、提问或汇报重要决策时,直接用普通文本输出 —— 用户能实时看到你的文本;
|
|
@@ -260,7 +261,7 @@ ${isWin ? `- This is Windows cmd.exe. The following Unix commands do NOT exist h
|
|
|
260
261
|
# Permissions (explicitly granted by the user — do not keep asking)
|
|
261
262
|
- Inside the working directory: read/write files and run commands directly
|
|
262
263
|
- Other directories: may also be read and modified
|
|
263
|
-
-
|
|
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.
|
|
264
265
|
|
|
265
266
|
# How to work
|
|
266
267
|
- Act first, ask later: use tools (list_files / read_file / search_text / run_command) instead of asking the user
|
|
@@ -270,7 +271,8 @@ ${isWin ? `- This is Windows cmd.exe. The following Unix commands do NOT exist h
|
|
|
270
271
|
- Git operations go through run_command; to commit, use git commands or this CLI's g -y / g --ai
|
|
271
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
|
|
272
273
|
- Commands run under ${shellDesc}; keep syntax compatible
|
|
273
|
-
-
|
|
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
|
|
274
276
|
|
|
275
277
|
# Talking to the user
|
|
276
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
|
|
@@ -283,130 +285,7 @@ ${isWin ? `- This is Windows cmd.exe. The following Unix commands do NOT exist h
|
|
|
283
285
|
}
|
|
284
286
|
|
|
285
287
|
// ──────────────────────────────────────────────
|
|
286
|
-
//
|
|
287
|
-
// 返回 { content, toolCalls, aborted }
|
|
288
|
-
// ──────────────────────────────────────────────
|
|
289
|
-
async function streamChatOnce({ model, messages, signal, onToken, sessionId }) {
|
|
290
|
-
// sessionId = 会话 ID:OpenCode 网关靠它做路由与提示缓存,同一条会话的所有请求要复用
|
|
291
|
-
const { url, headers } = buildAiChatRequest({
|
|
292
|
-
baseURL: model.baseURL,
|
|
293
|
-
model: model.model,
|
|
294
|
-
apiKey: model.apiKey,
|
|
295
|
-
sessionId,
|
|
296
|
-
})
|
|
297
|
-
|
|
298
|
-
const body = JSON.stringify({
|
|
299
|
-
model: model.model,
|
|
300
|
-
messages,
|
|
301
|
-
tools: TOOL_DEFINITIONS,
|
|
302
|
-
temperature: 0.3,
|
|
303
|
-
stream: true,
|
|
304
|
-
})
|
|
305
|
-
|
|
306
|
-
const controller = new AbortController()
|
|
307
|
-
const timer = setTimeout(() => controller.abort(), LLM_TIMEOUT_MS)
|
|
308
|
-
const onExternalAbort = () => controller.abort()
|
|
309
|
-
if (signal) {
|
|
310
|
-
if (signal.aborted) controller.abort()
|
|
311
|
-
else signal.addEventListener('abort', onExternalAbort)
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
let content = ''
|
|
315
|
-
// tool_calls 按 index 累积:provider 分片推送 id / name / arguments
|
|
316
|
-
const toolCalls = []
|
|
317
|
-
let aborted = false
|
|
318
|
-
try {
|
|
319
|
-
const resp = await fetch(url, { method: 'POST', headers, body, signal: controller.signal })
|
|
320
|
-
if (!resp.ok || !resp.body) {
|
|
321
|
-
const errText = await resp.text().catch(() => '')
|
|
322
|
-
// 抽 error.message:provider 的说明都在里面,整坨 JSON 打到终端没法看
|
|
323
|
-
const snippet = describeAiHttpError(errText, resp.status)
|
|
324
|
-
// 400 且提到 tools/functions:大概率是模型不支持 function calling
|
|
325
|
-
if (resp.status === 400 && /tool|function/i.test(snippet)) {
|
|
326
|
-
throw new Error(`HTTP 400: 当前模型可能不支持 function calling(${snippet})。请在 g ui 中换用支持工具调用的模型(如 deepseek / qwen / gpt 系列)。`)
|
|
327
|
-
}
|
|
328
|
-
throw new Error(`HTTP ${resp.status}: ${snippet || resp.statusText}`)
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
const decoder = new TextDecoder('utf-8')
|
|
332
|
-
let buf = ''
|
|
333
|
-
for await (const chunk of resp.body) {
|
|
334
|
-
buf += decoder.decode(chunk, { stream: true })
|
|
335
|
-
const lines = buf.split('\n')
|
|
336
|
-
buf = lines.pop() ?? ''
|
|
337
|
-
for (const line of lines) {
|
|
338
|
-
const trimmed = line.trim()
|
|
339
|
-
if (!trimmed || !trimmed.startsWith('data:')) continue
|
|
340
|
-
const payload = trimmed.slice(5).trim()
|
|
341
|
-
if (payload === '[DONE]') continue
|
|
342
|
-
let evt
|
|
343
|
-
try { evt = JSON.parse(payload) } catch { continue }
|
|
344
|
-
const delta = evt.choices?.[0]?.delta || {}
|
|
345
|
-
|
|
346
|
-
// thinking(各 provider 字段名不同,逐一尝试)
|
|
347
|
-
const thinkingChunk = delta.reasoning_content || delta.reasoning || delta.reasoning_text || ''
|
|
348
|
-
if (thinkingChunk) onToken({ thinking: thinkingChunk })
|
|
349
|
-
|
|
350
|
-
if (delta.content) {
|
|
351
|
-
content += delta.content
|
|
352
|
-
onToken({ content: delta.content })
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
for (const tc of delta.tool_calls || []) {
|
|
356
|
-
const i = tc.index ?? 0
|
|
357
|
-
if (!toolCalls[i]) toolCalls[i] = { id: '', type: 'function', function: { name: '', arguments: '' } }
|
|
358
|
-
if (tc.id) toolCalls[i].id += tc.id
|
|
359
|
-
if (tc.function?.name) toolCalls[i].function.name += tc.function.name
|
|
360
|
-
if (tc.function?.arguments) toolCalls[i].function.arguments += tc.function.arguments
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
} catch (err) {
|
|
365
|
-
if (err?.name === 'AbortError' || controller.signal.aborted) {
|
|
366
|
-
aborted = true
|
|
367
|
-
} else {
|
|
368
|
-
throw err
|
|
369
|
-
}
|
|
370
|
-
} finally {
|
|
371
|
-
clearTimeout(timer)
|
|
372
|
-
if (signal) signal.removeEventListener('abort', onExternalAbort)
|
|
373
|
-
}
|
|
374
|
-
return { content, toolCalls: toolCalls.filter(Boolean), aborted }
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
// ──────────────────────────────────────────────
|
|
378
|
-
// 历史裁剪:保留 system + 最近 MAX_HISTORY_MESSAGES 条
|
|
379
|
-
// 切口必须落在 user 消息上,避免把 assistant(tool_calls) 与其 tool 结果从中间撕开
|
|
380
|
-
// ──────────────────────────────────────────────
|
|
381
|
-
function trimHistory(messages) {
|
|
382
|
-
if (messages.length <= MAX_HISTORY_MESSAGES + 1) return
|
|
383
|
-
let cut = messages.length - MAX_HISTORY_MESSAGES
|
|
384
|
-
// 至少向后扫 5 条,跳过孤立的 tool / assistant(tool_calls),
|
|
385
|
-
// 找到真正的 user 节点再切,防止撕裂 tool 调用链
|
|
386
|
-
let safety = 0
|
|
387
|
-
while (cut < messages.length && messages[cut].role !== 'user' && safety < 8) {
|
|
388
|
-
cut++
|
|
389
|
-
safety++
|
|
390
|
-
}
|
|
391
|
-
if (cut <= 1 || messages[cut]?.role !== 'user') return
|
|
392
|
-
messages.splice(1, cut - 1)
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
// ──────────────────────────────────────────────
|
|
396
|
-
// 消息消毒:确保发给 LLM 的消息数组里没有空内容。
|
|
397
|
-
//
|
|
398
|
-
// 背景:部分 LLM provider(如 Moonshot/Kimi、智谱、火山引擎、MiniMax 等)
|
|
399
|
-
// 对 assistant 历史消息 content 校验严格 —— 当某轮 assistant 只返回
|
|
400
|
-
// tool_calls 而没有文本、或正文被 trim 后只剩空白时,provider 会拒绝
|
|
401
|
-
// 并报 "chat content is empty (2013)"。OpenAI 官方规范允许 assistant
|
|
402
|
-
// 消息在带 tool_calls 时 content 为 null,但 provider 实现不一致:
|
|
403
|
-
//
|
|
404
|
-
// - assistant 带 tool_calls → content 强制 null(即使有字符串)
|
|
405
|
-
// - assistant 不带 tool_calls 且 content 全空白 → null(避免触发 2013)
|
|
406
|
-
// - user content 全空白 → 用单个空格 ' ' 占位(provider 通常可接受)
|
|
407
|
-
// - tool content 全空白 → '(no output)' 占位(防止序列化时被丢)
|
|
408
|
-
//
|
|
409
|
-
// CLI 与 Web 端共用本逻辑(两份实现必须保持一致)。
|
|
288
|
+
// 消息兼容处理。只作用于请求副本,保留完整会话记录。
|
|
410
289
|
// ──────────────────────────────────────────────
|
|
411
290
|
export function sanitizeMessages(messages) {
|
|
412
291
|
for (const m of messages) {
|
|
@@ -459,152 +338,6 @@ export function stripStaleImages(messages, locale) {
|
|
|
459
338
|
// 单轮 agent 循环:用户一句话 → 流式输出 → 工具调用 → 再调用模型 … 直到模型给出最终文本
|
|
460
339
|
// images: [{path, bytes}] 待发送图片(可为空数组)
|
|
461
340
|
// ──────────────────────────────────────────────
|
|
462
|
-
async function runAgentTurn(state, userText, t, images = []) {
|
|
463
|
-
// 有图片:编码为 OpenAI 多模态 content parts;没图片保持纯字符串
|
|
464
|
-
if (images.length > 0) {
|
|
465
|
-
const parts = [{ type: 'text', text: userText }]
|
|
466
|
-
for (const img of images) {
|
|
467
|
-
try {
|
|
468
|
-
const url = await imageToDataUrl(img.path)
|
|
469
|
-
parts.push({ type: 'image_url', image_url: { url } })
|
|
470
|
-
} catch (err) {
|
|
471
|
-
printWarn(t.imageBadPath(img.path))
|
|
472
|
-
}
|
|
473
|
-
}
|
|
474
|
-
state.messages.push({ role: 'user', content: parts })
|
|
475
|
-
} else {
|
|
476
|
-
state.messages.push({ role: 'user', content: userText })
|
|
477
|
-
}
|
|
478
|
-
// 旧消息里的图片降级为占位文字,防止 base64 随对话轮次累积撑爆上下文
|
|
479
|
-
stripStaleImages(state.messages, state.locale)
|
|
480
|
-
|
|
481
|
-
const maxIterations = Number.isFinite(state.maxToolIterations) && state.maxToolIterations > 0
|
|
482
|
-
? state.maxToolIterations
|
|
483
|
-
: DEFAULT_MAX_TOOL_ITERATIONS
|
|
484
|
-
|
|
485
|
-
for (let iter = 0; iter < maxIterations; iter++) {
|
|
486
|
-
trimHistory(state.messages)
|
|
487
|
-
|
|
488
|
-
// 首个 token 到达前转 spinner,到达后停掉并让位给流式渲染
|
|
489
|
-
const spinner = startSpinner(t.waiting)
|
|
490
|
-
let spinnerStopped = false
|
|
491
|
-
const stopSpinner = () => {
|
|
492
|
-
if (!spinnerStopped) { spinner.stop(); spinnerStopped = true }
|
|
493
|
-
}
|
|
494
|
-
// 每次 LLM 调用一个 writer:思考(橙黄斜体)+ 正文 ➤ 子弹头 + 逐行 markdown
|
|
495
|
-
const writer = createAssistantWriter({
|
|
496
|
-
showThinking: state.showThinking,
|
|
497
|
-
thinkingHeader: t.thinkingLabel,
|
|
498
|
-
})
|
|
499
|
-
// MiniMax 系把 <think> 标签内联在 content 流里,用过滤器剥离并置灰显示
|
|
500
|
-
// (历史里仍保存模型原始输出,见 streamChatOnce 返回的 content)
|
|
501
|
-
const thinkFilter = createThinkFilter()
|
|
502
|
-
const renderSeg = (seg) => {
|
|
503
|
-
const kind = seg.content !== undefined ? 'content' : 'thinking'
|
|
504
|
-
const text = seg.content ?? seg.thinking
|
|
505
|
-
if (!text) return
|
|
506
|
-
stopSpinner()
|
|
507
|
-
if (kind === 'thinking') writer.writeThinking(text)
|
|
508
|
-
else writer.writeContent(text)
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
// 发送前消毒:确保历史里没有空 content 的消息(部分 provider 报 2013)
|
|
512
|
-
sanitizeMessages(state.messages)
|
|
513
|
-
|
|
514
|
-
const llmStart = performance.now()
|
|
515
|
-
let result
|
|
516
|
-
try {
|
|
517
|
-
result = await streamChatOnce({
|
|
518
|
-
model: state.model,
|
|
519
|
-
messages: state.messages,
|
|
520
|
-
signal: state.abortController?.signal,
|
|
521
|
-
// 同一会话(含工具调用产生的每一轮请求)复用同一个会话 ID
|
|
522
|
-
sessionId: state.sessionId,
|
|
523
|
-
onToken: ({ thinking, content }) => {
|
|
524
|
-
if (thinking) renderSeg({ thinking })
|
|
525
|
-
if (content) for (const seg of thinkFilter.feed(content)) renderSeg(seg)
|
|
526
|
-
},
|
|
527
|
-
})
|
|
528
|
-
// 流结束,冲刷过滤器里残留的半拉标签/未闭合 think 块
|
|
529
|
-
for (const seg of thinkFilter.flush()) renderSeg(seg)
|
|
530
|
-
} catch (err) {
|
|
531
|
-
stopSpinner()
|
|
532
|
-
writer.finish()
|
|
533
|
-
printError(t.llmError(err.message))
|
|
534
|
-
// 请求失败时,如果最后一条仍是本轮塞进去的 user 消息则撤掉,
|
|
535
|
-
// 避免历史里留一条没有回应的消息;若已进入工具循环(末尾是 tool
|
|
536
|
-
// 结果),消息链本身是完整的,保持不动
|
|
537
|
-
const last = state.messages[state.messages.length - 1]
|
|
538
|
-
if (last?.role === 'user') state.messages.pop()
|
|
539
|
-
return
|
|
540
|
-
}
|
|
541
|
-
const llmDuration = performance.now() - llmStart
|
|
542
|
-
stopSpinner()
|
|
543
|
-
writer.finish()
|
|
544
|
-
|
|
545
|
-
if (result.aborted) {
|
|
546
|
-
printWarn('⛔ 已中止')
|
|
547
|
-
return
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
const { content, toolCalls } = result
|
|
551
|
-
|
|
552
|
-
// 无工具调用:本轮结束,assistant 文本入历史
|
|
553
|
-
if (toolCalls.length === 0) {
|
|
554
|
-
state.messages.push({ role: 'assistant', content: content || null })
|
|
555
|
-
return
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
// 有工具调用:assistant(带 tool_calls)入历史,然后逐个执行
|
|
559
|
-
// content 为 null 而非空字符串:部分 provider 拒绝空字符串内容(2013 错误)
|
|
560
|
-
state.messages.push({ role: 'assistant', content: content || null, tool_calls: toolCalls })
|
|
561
|
-
|
|
562
|
-
// LLM 耗时在进入工具执行前打印(仅本轮有工具调用时显示)
|
|
563
|
-
if (llmDuration > 0) {
|
|
564
|
-
printDim(` ⏱ LLM ${formatDuration(llmDuration)}`)
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
for (const tc of toolCalls) {
|
|
568
|
-
const name = tc.function?.name || ''
|
|
569
|
-
const rawArgs = tc.function?.arguments || ''
|
|
570
|
-
|
|
571
|
-
let args
|
|
572
|
-
try {
|
|
573
|
-
args = rawArgs ? JSON.parse(rawArgs) : {}
|
|
574
|
-
} catch {
|
|
575
|
-
printToolHeader(name, printDimInline(rawArgs))
|
|
576
|
-
const errResult = `错误: 工具参数不是合法 JSON: ${rawArgs.slice(0, 200)}`
|
|
577
|
-
printToolResult(errResult)
|
|
578
|
-
state.messages.push({ role: 'tool', tool_call_id: tc.id || name, name, content: errResult })
|
|
579
|
-
continue
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
printToolHeader(name, summarizeToolArgs(name, args, { chars: t.chars }))
|
|
583
|
-
// 长命令执行期间给个 spinner,让用户知道没有卡死
|
|
584
|
-
const toolSpinner = startSpinner(t.toolRunning(name))
|
|
585
|
-
const toolStart = performance.now()
|
|
586
|
-
const output = await executeTool(name, args, state.ctx)
|
|
587
|
-
const toolDuration = performance.now() - toolStart
|
|
588
|
-
toolSpinner.stop()
|
|
589
|
-
printToolResult(output, undefined, toolDuration)
|
|
590
|
-
state.messages.push({ role: 'tool', tool_call_id: tc.id || name, name, content: output })
|
|
591
|
-
}
|
|
592
|
-
// 工具结果全部入历史后继续循环,让模型基于结果决定下一步
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
printWarn(t.toolIterLimit(maxIterations))
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
// JSON 解析失败时的参数回显:单行截断,不进 summarizeToolArgs(它没有结构化参数可用)
|
|
599
|
-
function printDimInline(rawArgs) {
|
|
600
|
-
let preview = String(rawArgs || '').replace(/\s+/g, ' ').trim()
|
|
601
|
-
if (preview.length > 160) preview = preview.slice(0, 160) + '…'
|
|
602
|
-
return preview
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
// ──────────────────────────────────────────────
|
|
606
|
-
// 斜杠命令
|
|
607
|
-
// ──────────────────────────────────────────────
|
|
608
341
|
function printSlashHelp(t, locale) {
|
|
609
342
|
const zh = !String(locale || '').startsWith('en')
|
|
610
343
|
const lines = zh ? [
|
|
@@ -613,7 +346,11 @@ function printSlashHelp(t, locale) {
|
|
|
613
346
|
' /addmodel 添加新的模型配置(交互式向导)',
|
|
614
347
|
' /cd <路径> 切换智能体工作目录',
|
|
615
348
|
' /image [路径] 查看待发送图片;/image <路径> 附加本地图片;/image clear 清除',
|
|
616
|
-
' /think
|
|
349
|
+
' /think full 完整显示后续思考(默认,模型返回的内容)',
|
|
350
|
+
' /think compact 预览前 12 行思考',
|
|
351
|
+
' /think off 隐藏思考;/think 切换完整与隐藏',
|
|
352
|
+
' /tools [模式] compact 精简 / full 完整工具输出',
|
|
353
|
+
' /stats 查看上一轮与会话累计用量、耗时',
|
|
617
354
|
' /new 开启新对话',
|
|
618
355
|
' /resume 选择并恢复之前的对话',
|
|
619
356
|
' /clear 清空对话历史',
|
|
@@ -622,14 +359,18 @@ function printSlashHelp(t, locale) {
|
|
|
622
359
|
' Alt+V 粘贴剪贴板图片,随下一条消息发送(需视觉模型)',
|
|
623
360
|
'',
|
|
624
361
|
'权限说明: 工作目录内全部操作直接执行;其他目录可读写;',
|
|
625
|
-
'
|
|
362
|
+
'命令守卫识别部分系统级危险操作,不是隔离沙箱。',
|
|
626
363
|
] : [
|
|
627
364
|
' /help Show this help',
|
|
628
365
|
' /model List models; /model <n> to switch',
|
|
629
366
|
' /addmodel Add a new model (interactive wizard)',
|
|
630
367
|
' /cd <path> Change agent working directory',
|
|
631
368
|
' /image [path] List pending images; attach a file; /image clear to reset',
|
|
632
|
-
' /think
|
|
369
|
+
' /think full Show future thinking returned by the model in full (default)',
|
|
370
|
+
' /think compact Preview the first 12 thinking lines',
|
|
371
|
+
' /think off Hide thinking; /think toggles full / hidden',
|
|
372
|
+
' /tools [mode] compact / full tool output',
|
|
373
|
+
' /stats Last turn timing and session token usage',
|
|
633
374
|
' /new Start a new conversation',
|
|
634
375
|
' /resume Choose and resume a previous conversation',
|
|
635
376
|
' /clear Clear conversation history',
|
|
@@ -638,22 +379,30 @@ function printSlashHelp(t, locale) {
|
|
|
638
379
|
' Alt+V Paste clipboard image (needs a vision-capable model)',
|
|
639
380
|
'',
|
|
640
381
|
'Permissions: full access in the working directory; other dirs readable/writable;',
|
|
641
|
-
'
|
|
382
|
+
'the command guard detects some dangerous operations; it is not a sandbox.',
|
|
642
383
|
]
|
|
643
384
|
printHelpPanel(t.helpTitle, lines)
|
|
644
385
|
}
|
|
645
386
|
|
|
646
|
-
async function handleSlashCommand(state, input, t) {
|
|
387
|
+
export async function handleSlashCommand(state, input, t) {
|
|
647
388
|
const [cmd, ...rest] = input.split(/\s+/)
|
|
648
389
|
const arg = rest.join(' ').trim()
|
|
390
|
+
const zh = !String(state.locale).startsWith('en')
|
|
391
|
+
if (state.busy && !['/help', '/stats', '/think', '/tools', '/exit', '/quit'].includes(cmd)) {
|
|
392
|
+
printDim(t.busy)
|
|
393
|
+
return 'ok'
|
|
394
|
+
}
|
|
649
395
|
|
|
650
396
|
if (cmd === '/exit' || cmd === '/quit') return 'exit'
|
|
651
397
|
if (cmd === '/help') { printSlashHelp(t, state.locale); return 'ok' }
|
|
652
398
|
if (cmd === '/clear' || cmd === '/new') {
|
|
399
|
+
await state.persistSession?.()
|
|
653
400
|
state.messages = [state.messages[0]]
|
|
654
401
|
// 开新会话 ID,旧会话保留在磁盘上
|
|
655
402
|
state.sessionId = genSessionId()
|
|
656
403
|
state.sessionCreatedAt = new Date().toISOString()
|
|
404
|
+
state.lastTurnStats = null
|
|
405
|
+
state.sessionStats = null
|
|
657
406
|
printOk(cmd === '/new' ? t.newConversation : t.cleared)
|
|
658
407
|
return 'ok'
|
|
659
408
|
}
|
|
@@ -713,10 +462,12 @@ async function handleSlashCommand(state, input, t) {
|
|
|
713
462
|
}
|
|
714
463
|
state.sessionId = session.sessionId
|
|
715
464
|
state.sessionCreatedAt = session.createdAt || new Date().toISOString()
|
|
716
|
-
state.messages = session.messages
|
|
465
|
+
state.messages = repairToolHistory(session.messages)
|
|
466
|
+
state.lastTurnStats = session.lastTurnStats || null
|
|
467
|
+
state.sessionStats = session.sessionStats || null
|
|
717
468
|
const systemMessage = {
|
|
718
469
|
role: 'system',
|
|
719
|
-
content:
|
|
470
|
+
content: await buildProjectPrompt({ cwd: state.ctx.cwd, locale: state.locale, shellDesc: state.shellDesc }),
|
|
720
471
|
}
|
|
721
472
|
if (state.messages[0]?.role === 'system') state.messages[0] = systemMessage
|
|
722
473
|
else state.messages.unshift(systemMessage)
|
|
@@ -732,8 +483,25 @@ async function handleSlashCommand(state, input, t) {
|
|
|
732
483
|
return 'ok'
|
|
733
484
|
}
|
|
734
485
|
if (cmd === '/think') {
|
|
735
|
-
|
|
736
|
-
|
|
486
|
+
const next = arg || (state.showThinking ? 'off' : 'full')
|
|
487
|
+
if (!['off', 'compact', 'full'].includes(next)) {
|
|
488
|
+
printWarn('/think compact | full | off')
|
|
489
|
+
return 'ok'
|
|
490
|
+
}
|
|
491
|
+
state.showThinking = next !== 'off'
|
|
492
|
+
state.thinkingMode = next
|
|
493
|
+
printOk(zh ? `后续请求的思考显示:${({ off: '隐藏', compact: '预览前 12 行', full: '完整' })[next]}` : `Thinking for future requests: ${next}`)
|
|
494
|
+
return 'ok'
|
|
495
|
+
}
|
|
496
|
+
if (cmd === '/tools') {
|
|
497
|
+
if (arg && !['full', 'compact'].includes(arg)) { printWarn('/tools compact | full'); return 'ok' }
|
|
498
|
+
state.fullTools = arg ? arg === 'full' : !state.fullTools
|
|
499
|
+
printOk(zh ? `后续工具结果:${state.fullTools ? '完整' : '精简'}` : `Future tool output: ${state.fullTools ? 'full' : 'compact'}`)
|
|
500
|
+
return 'ok'
|
|
501
|
+
}
|
|
502
|
+
if (cmd === '/stats') {
|
|
503
|
+
if (state.lastTurnStats) printTurnSummary(state.lastTurnStats, { locale: state.locale, session: state.sessionStats })
|
|
504
|
+
else printDim(zh ? '完成一轮对话后显示用量与耗时。' : 'Usage and timing appear after a turn completes.')
|
|
737
505
|
return 'ok'
|
|
738
506
|
}
|
|
739
507
|
if (cmd === '/image') {
|
|
@@ -835,7 +603,8 @@ async function handleSlashCommand(state, input, t) {
|
|
|
835
603
|
if (!st.isDirectory()) throw new Error('not a dir')
|
|
836
604
|
state.ctx.cwd = target
|
|
837
605
|
// 同步更新 system prompt 里的 cwd 说明(重建首条消息)
|
|
838
|
-
state.messages[0] = { role: 'system', content:
|
|
606
|
+
state.messages[0] = { role: 'system', content: await buildProjectPrompt({ cwd: target, locale: state.locale, shellDesc: state.shellDesc }) }
|
|
607
|
+
await state.persistSession?.()
|
|
839
608
|
printOk(t.cdOk(target))
|
|
840
609
|
} catch {
|
|
841
610
|
printError(t.cdFail(target))
|
|
@@ -896,7 +665,7 @@ export async function runAiAgent(argv = []) {
|
|
|
896
665
|
// 在 Web UI 智能体 tab 中可见(带 CLI 标记)
|
|
897
666
|
sessionId: genSessionId(),
|
|
898
667
|
sessionCreatedAt: new Date().toISOString(),
|
|
899
|
-
messages: [{ role: 'system', content:
|
|
668
|
+
messages: [{ role: 'system', content: await buildProjectPrompt({ cwd, locale, shellDesc }) }],
|
|
900
669
|
ctx: {
|
|
901
670
|
cwd,
|
|
902
671
|
onChild: (child) => {
|
|
@@ -910,13 +679,18 @@ export async function runAiAgent(argv = []) {
|
|
|
910
679
|
locale,
|
|
911
680
|
shellDesc,
|
|
912
681
|
// 单轮工具调用上限:来自全局配置 aiMaxToolIterations(loadConfig 已规范化),
|
|
913
|
-
// 读不到时由 runAgentTurn
|
|
682
|
+
// 读不到时由 runAgentTurn 兜底为 200
|
|
914
683
|
maxToolIterations: cfg.aiMaxToolIterations,
|
|
915
684
|
currentChild: null,
|
|
916
685
|
abortController: null,
|
|
917
686
|
cancelRequested: false,
|
|
918
687
|
busy: false,
|
|
919
688
|
showThinking: true, // /think 切换:是否回显模型的思考过程
|
|
689
|
+
thinkingMode: 'full',
|
|
690
|
+
fullTools: false,
|
|
691
|
+
lastTurnStats: null,
|
|
692
|
+
sessionStats: null,
|
|
693
|
+
prepareMessages: (messages) => { stripStaleImages(messages, locale); sanitizeMessages(messages) },
|
|
920
694
|
pendingImages: [], // Alt+V / /image 附加的待发送图片 [{path, bytes}]
|
|
921
695
|
pasting: false, // 剪贴板读取进行中(防 Alt+V 连打并发)
|
|
922
696
|
inWizard: false, // /addmodel 交互式向导进行中:忽略 REPL 的 line 事件
|
|
@@ -924,41 +698,52 @@ export async function runAiAgent(argv = []) {
|
|
|
924
698
|
}
|
|
925
699
|
|
|
926
700
|
// 持久化当前会话到磁盘(供 Web UI 读取)
|
|
701
|
+
let saving = Promise.resolve()
|
|
927
702
|
async function persistSession() {
|
|
703
|
+
if (state.messages.length <= 1) return
|
|
928
704
|
try {
|
|
929
705
|
const title = autoTitle(state.messages)
|
|
930
|
-
|
|
706
|
+
const sessionId = state.sessionId
|
|
707
|
+
const snapshot = JSON.parse(JSON.stringify({
|
|
931
708
|
version: 1,
|
|
932
709
|
sessionId: state.sessionId,
|
|
933
710
|
title,
|
|
934
711
|
source: 'cli',
|
|
935
|
-
cwd,
|
|
712
|
+
cwd: state.ctx.cwd,
|
|
936
713
|
model: modelLabel(state.model),
|
|
937
714
|
createdAt: state.sessionCreatedAt,
|
|
938
715
|
updatedAt: new Date().toISOString(),
|
|
939
|
-
messages: state.messages,
|
|
940
|
-
|
|
716
|
+
messages: repairToolHistory(state.messages),
|
|
717
|
+
lastTurnStats: state.lastTurnStats,
|
|
718
|
+
sessionStats: state.sessionStats,
|
|
719
|
+
}))
|
|
720
|
+
saving = saving.catch(() => {}).then(() => writeSession(sessionId, snapshot))
|
|
721
|
+
await saving
|
|
941
722
|
enforceRetention().catch(() => {})
|
|
942
723
|
} catch (err) {
|
|
943
724
|
// 持久化失败不影响 CLI 正常使用
|
|
944
725
|
printDim(`(会话保存失败: ${err.message})`)
|
|
945
726
|
}
|
|
946
727
|
}
|
|
728
|
+
state.persistSession = persistSession
|
|
947
729
|
|
|
948
730
|
// SIGINT 时中止进行中的 LLM 请求 + 正在跑的子命令
|
|
949
731
|
// (进程退出与全局子进程 drain 由 gitCommit.js 的统一 handler 负责,这里只做快速止血)
|
|
950
|
-
registerCleanup('aiAgent', () => {
|
|
732
|
+
registerCleanup('aiAgent', async () => {
|
|
733
|
+
state.cancelRequested = true
|
|
951
734
|
try { state.abortController?.abort() } catch (_) {}
|
|
952
|
-
|
|
735
|
+
await terminateCommand(state.currentChild)
|
|
736
|
+
await persistSession()
|
|
953
737
|
})
|
|
954
738
|
|
|
955
739
|
// 单发模式:g ai "帮我改个 bug"(位置参数拼成 prompt,跑一轮就退出)
|
|
956
740
|
const oneShot = argv.filter(a => !a.startsWith('-')).join(' ').trim()
|
|
957
741
|
if (oneShot) {
|
|
958
742
|
console.log(chalk.dim(`[${t.bannerModel}] ${modelLabel(model)} · [${t.bannerCwd}] ${cwd}`))
|
|
959
|
-
|
|
960
|
-
await
|
|
961
|
-
printDim(t.oneShotDone)
|
|
743
|
+
state.abortController = new AbortController()
|
|
744
|
+
const stats = await runAgentTurn(state, oneShot, t)
|
|
745
|
+
if (stats.status === 'completed') printDim(t.oneShotDone)
|
|
746
|
+
else process.exitCode = stats.status === 'cancelled' ? 130 : 1
|
|
962
747
|
return
|
|
963
748
|
}
|
|
964
749
|
|
|
@@ -976,9 +761,8 @@ export async function runAiAgent(argv = []) {
|
|
|
976
761
|
const rl = readline.createInterface({
|
|
977
762
|
input: process.stdin,
|
|
978
763
|
output: process.stdout,
|
|
979
|
-
//
|
|
980
|
-
|
|
981
|
-
prompt: chalk.cyan.bold(t.prompt) + '\x1b[1;97m',
|
|
764
|
+
// 颜色闭合,避免提示符的样式泄漏到后续输出。
|
|
765
|
+
prompt: chalk.cyan.bold(t.prompt + ' '),
|
|
982
766
|
historySize: 200,
|
|
983
767
|
})
|
|
984
768
|
state.rl = rl // 供 /addmodel 等需要 rl.question 的斜杠命令复用
|
|
@@ -1030,9 +814,6 @@ export async function runAiAgent(argv = []) {
|
|
|
1030
814
|
}
|
|
1031
815
|
}
|
|
1032
816
|
|
|
1033
|
-
// readline 的 clearLine() 已经写了 \r\n,不需要我们再补换行。
|
|
1034
|
-
const closeInputFrame = () => {}
|
|
1035
|
-
|
|
1036
817
|
// 在提示符上方插一行通知(粘贴进度等)。
|
|
1037
818
|
const notifyAbovePrompt = (text) => {
|
|
1038
819
|
console.log(text)
|
|
@@ -1137,7 +918,7 @@ export async function runAiAgent(argv = []) {
|
|
|
1137
918
|
neutralizeKeyForReadline(key)
|
|
1138
919
|
state.cancelRequested = true
|
|
1139
920
|
try { state.abortController?.abort() } catch (_) {}
|
|
1140
|
-
|
|
921
|
+
void terminateCommand(state.currentChild)
|
|
1141
922
|
return
|
|
1142
923
|
}
|
|
1143
924
|
if (slashHintRows > 0) {
|
|
@@ -1266,8 +1047,6 @@ export async function runAiAgent(argv = []) {
|
|
|
1266
1047
|
state.abortController = new AbortController()
|
|
1267
1048
|
try {
|
|
1268
1049
|
await runAgentTurn(state, input, t, images)
|
|
1269
|
-
// 每轮对话结束后持久化到磁盘,供 Web UI 读取
|
|
1270
|
-
await persistSession()
|
|
1271
1050
|
} catch (err) {
|
|
1272
1051
|
if (state.cancelRequested) printDim(t.operationCancelled)
|
|
1273
1052
|
else printError(err.message)
|
|
@@ -1280,6 +1059,11 @@ export async function runAiAgent(argv = []) {
|
|
|
1280
1059
|
})
|
|
1281
1060
|
|
|
1282
1061
|
rl.on('close', () => {
|
|
1062
|
+
if (state.busy) {
|
|
1063
|
+
state.cancelRequested = true
|
|
1064
|
+
state.abortController?.abort()
|
|
1065
|
+
void terminateCommand(state.currentChild)
|
|
1066
|
+
}
|
|
1283
1067
|
rl.input.removeListener('keypress', interceptInteractiveControlKeys)
|
|
1284
1068
|
printWarn('\n' + t.bye)
|
|
1285
1069
|
})
|