zen-gitsync 2.17.46 → 2.17.47
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/package.json +1 -1
- package/src/cli/ai/agent.js +1342 -1342
- package/src/cli/ai/context.js +431 -253
- package/src/cli/ai/context.test.js +376 -258
- package/src/cli/ai/runtime.test.js +7 -4
- package/src/cli/ai/turn.js +166 -166
- package/src/config.js +906 -871
- package/src/ui/public/assets/{AgentEngineSelector-D6rlSfMD.js → AgentEngineSelector-CKiVaqrY.js} +1 -1
- package/src/ui/public/assets/{AgentView-h2YnbB7J.css → AgentView-CRqQYAzh.css} +1 -1
- package/src/ui/public/assets/{AgentView-BFRGoIVb.js → AgentView-c1frCBc3.js} +1 -1
- package/src/ui/public/assets/{AppVersionBadge-BnPsn1X5.js → AppVersionBadge-IWS9XBUV.js} +2 -2
- package/src/ui/public/assets/{BranchSelector-D30GJwUl.js → BranchSelector-C_mK2mkS.js} +1 -1
- package/src/ui/public/assets/{CommitForm-Dvg-RUZI.js → CommitForm-C6I9rQXn.js} +1 -1
- package/src/ui/public/assets/{CommonDialog-BzLau2RJ.js → CommonDialog-2zVHBuqT.js} +1 -1
- package/src/ui/public/assets/EditorView-DUJrWJgh.js +1 -0
- package/src/ui/public/assets/{EditorView-8Rb4n-Wh.css → EditorView-DqagGedH.css} +1 -1
- package/src/ui/public/assets/{FlowExecutionViewer-VL-rUElj.js → FlowExecutionViewer-0Xw0oDen.js} +1 -1
- package/src/ui/public/assets/{FlowOrchestrationWorkspace-BPNlHRVu.js → FlowOrchestrationWorkspace-BKUspY-s.js} +1 -1
- package/src/ui/public/assets/{LogList-COwGjQl6.js → LogList-pbwn5n2S.js} +1 -1
- package/src/ui/public/assets/{MindmapView-BVuqPD_H.js → MindmapView-DYh6nuuF.js} +1 -1
- package/src/ui/public/assets/{MonitorView-hqe-4xd0.js → MonitorView-CsJyUL9v.js} +1 -1
- package/src/ui/public/assets/{ProjectStartupButton-RBc-DJBR.js → ProjectStartupButton-BhFQfAX1.js} +1 -1
- package/src/ui/public/assets/{RecentDirectoriesChat-DFljcYFH.js → RecentDirectoriesChat-DM6sRO9L.js} +1 -1
- package/src/ui/public/assets/{RemoteManagerDialog-D6Rbjchl.js → RemoteManagerDialog-CIZagKkC.js} +1 -1
- package/src/ui/public/assets/{RemoteRepoCard-l0NpXgvB.js → RemoteRepoCard-Dj-B-6WU.js} +1 -1
- package/src/ui/public/assets/{SourceMapView-CeVystt0.js → SourceMapView-pIlmQzYw.js} +1 -1
- package/src/ui/public/assets/{SvgIcon-B-xDJQA1.js → SvgIcon-CaefOO1F.js} +1 -1
- package/src/ui/public/assets/{UserInputNode-DJglU68l.js → UserInputNode-1--I25vm.js} +1 -1
- package/src/ui/public/assets/{WorkbenchView-BSQ87CDi.css → WorkbenchView-BLzcZqy0.css} +1 -1
- package/src/ui/public/assets/WorkbenchView-bpzL6m84.js +20 -0
- package/src/ui/public/assets/{_plugin-vue_export-helper-Dz1ARW9a.js → _plugin-vue_export-helper-UTW98kcD.js} +5 -5
- package/src/ui/public/assets/agentConversations-B9LNft3v.js +8 -0
- package/src/ui/public/assets/{configStore-CMlW8sMa.js → configStore-H-n3ukZ_.js} +1 -1
- package/src/ui/public/assets/{dagre-DyI0XLXY.js → dagre-Br4Eexe7.js} +3 -3
- package/src/ui/public/assets/{element-plus-DrCk0pcV.js → element-plus-CZdvD4lq.js} +1 -1
- package/src/ui/public/assets/{flow-mindmap-Cx5RNYN9.js → flow-mindmap-4kZ-2J2Y.js} +1 -1
- package/src/ui/public/assets/{index-u9LyUFkr.css → index-BgN6Z2X9.css} +1 -1
- package/src/ui/public/assets/{index-BQxIFopZ.js → index-CD7otmb_.js} +9 -9
- package/src/ui/public/assets/{monaco-Dfcwm1aX.js → monaco-CoYCcK3Q.js} +1 -1
- package/src/ui/public/assets/{office-docx-C0unxEAm.js → office-docx-DndLP6GO.js} +1 -1
- package/src/ui/public/assets/{office-excel-DPdyr_DJ.js → office-excel-Jt5zzo6D.js} +1 -1
- package/src/ui/public/assets/{office-pptx-eojGAs9D.js → office-pptx-J8tzRTjU.js} +1 -1
- package/src/ui/public/assets/{vendor-yntih_em.js → vendor-BD09UDuP.js} +500 -500
- package/src/ui/public/assets/vendor-DNiTBOIX.css +1 -0
- package/src/ui/public/assets/{vue-flow-De8mr_Fv.js → vue-flow-CkM05TWt.js} +1 -1
- package/src/ui/public/index.html +13 -13
- package/src/ui/server/routes/config.js +1321 -1314
- package/src/ui/server/routes/workbench/agentChat.js +594 -571
- package/src/ui/server/routes/workbench/agentChatShared.test.js +302 -233
- package/src/ui/server/routes/workbench/agentRoutes.js +631 -631
- package/src/ui/public/assets/EditorView-D4ZQutRw.js +0 -1
- package/src/ui/public/assets/WorkbenchView-BpH5kNht.js +0 -20
- package/src/ui/public/assets/agentConversations-B8nY45V4.js +0 -7
- package/src/ui/public/assets/vendor-CP-WuGZG.css +0 -1
package/src/cli/ai/agent.js
CHANGED
|
@@ -1,1342 +1,1342 @@
|
|
|
1
|
-
// Copyright 2026 xz333221
|
|
2
|
-
//
|
|
3
|
-
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
-
// you may not use this file except in compliance with the License.
|
|
5
|
-
// You may obtain a copy of the License at
|
|
6
|
-
//
|
|
7
|
-
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
-
//
|
|
9
|
-
// Unless required by applicable law or agreed to in writing, software
|
|
10
|
-
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
-
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
-
// See the License for the specific language governing permissions and
|
|
13
|
-
// limitations under the License.
|
|
14
|
-
//
|
|
15
|
-
// g ai — 终端交互式 AI 编码智能体。
|
|
16
|
-
//
|
|
17
|
-
// 入口: runAiAgent(argv)(由 gitCommit.js 在 argv[2]==='ai' 时动态 import)。
|
|
18
|
-
//
|
|
19
|
-
// 能力:
|
|
20
|
-
// - 默认使用 g ui 里配置的模型(~/.zen-gitsync/config.json 顶层 models,
|
|
21
|
-
// isDefault 优先,否则取第一个);--model=<序号|名称> 或会话内 /model 切换
|
|
22
|
-
// - OpenAI 兼容流式 function calling:模型可以调用 run_command /
|
|
23
|
-
// read_file / write_file / edit_file / list_files / search_text / ask_user
|
|
24
|
-
// - 图片:Alt+V 粘贴剪贴板图片、/image <路径> 附加本地图片,
|
|
25
|
-
// 以多模态(image_url)消息发给模型(需模型支持视觉)
|
|
26
|
-
// - 权限:启动目录内全开放,其他目录可读写;唯一红线由 safety.js 拦截
|
|
27
|
-
// (格式化磁盘、删根目录、关机等系统级破坏操作)
|
|
28
|
-
//
|
|
29
|
-
// 视觉风格(termui.js):对标 Codex CLI / Claude Code / OpenCode —
|
|
30
|
-
// 盒式输入框、ora 等待 spinner、✻ 思考(橙黄斜体)、⚙ 工具头(青) + │ 结果槽、
|
|
31
|
-
// 正文 ➤ 子弹头(绿)+ 逐行轻量 markdown(**bold** / `code` / # 标题 / ``` 围栏 / - 列表)
|
|
32
|
-
//
|
|
33
|
-
// 使用方式:
|
|
34
|
-
// g ai 交互模式(REPL)
|
|
35
|
-
// g ai "帮我修个 bug" 单发模式:执行一轮后退出
|
|
36
|
-
// g ai --model=2 指定配置里的第 2 个模型
|
|
37
|
-
//
|
|
38
|
-
// 实现注意:
|
|
39
|
-
// - 本模块属于 CLI 侧,不依赖 GUI 服务器代码(src/ui/server/**)
|
|
40
|
-
// - Ctrl+C 执行中只取消当前任务;空闲时交给主 CLI 清理并退出
|
|
41
|
-
|
|
42
|
-
import readline from 'node:readline'
|
|
43
|
-
import chalk from 'chalk'
|
|
44
|
-
import config from '../../config.js'
|
|
45
|
-
import { getCwd } from '../../utils/index.js'
|
|
46
|
-
import { registerCleanup } from '../cleanup.js'
|
|
47
|
-
import { terminateCommand, TOOL_DEFINITIONS } from './tools.js'
|
|
48
|
-
import { runAgentTurn } from './turn.js'
|
|
49
|
-
import { AgentExtensions } from './extensions.js'
|
|
50
|
-
import { repairToolHistory, loadProjectInstructions, resolveRequestBudget } from './context.js'
|
|
51
|
-
import {
|
|
52
|
-
printBanner, printHelpPanel,
|
|
53
|
-
filterSlashCommands, renderSlashHintBody, parseKeyForSlashHint,
|
|
54
|
-
printTurnSummary, renderPasteEcho,
|
|
55
|
-
printOk, printWarn, printError, printDim,
|
|
56
|
-
} from './termui.js'
|
|
57
|
-
import { readClipboardImage, checkImageFile, formatBytes } from './images.js'
|
|
58
|
-
import { installPasteCapture, enableBracketedPaste, disableBracketedPaste } from './paste.js'
|
|
59
|
-
import { runModelSetup, collectModelInput, buildModelConfig, selectFromList } from './modelSetup.js'
|
|
60
|
-
import { genSessionId, autoTitle, writeSession, enforceRetention, listSessions } from './sessionStore.js'
|
|
61
|
-
|
|
62
|
-
// truncateDisplay 已迁移到 termui.js;这里 re-export 保持既有测试/外部引用不断
|
|
63
|
-
export { truncateDisplay } from './termui.js'
|
|
64
|
-
|
|
65
|
-
// ──────────────────────────────────────────────
|
|
66
|
-
// i18n 字符串表(CLI 其他部分以中文为主,这里按 locale 给双语)
|
|
67
|
-
// ──────────────────────────────────────────────
|
|
68
|
-
const STRINGS = {
|
|
69
|
-
zh: {
|
|
70
|
-
noModel: '未配置 AI 模型,请先在 GUI 通用设置中添加模型(运行 g ui 打开设置)',
|
|
71
|
-
unknownModel: (q) => `未找到匹配的模型: ${q},使用默认模型`,
|
|
72
|
-
waiting: '思考中…',
|
|
73
|
-
llmError: (msg) => `LLM 请求失败: ${msg}`,
|
|
74
|
-
toolIterLimit: (n) => `已达单轮最大工具调用次数(${n}),本轮结束。如需继续请再发一条消息。`,
|
|
75
|
-
toolRunning: (name) => `执行 ${name}…`,
|
|
76
|
-
busy: '任务执行中,请稍候,或按 Ctrl+C 停止当前任务。',
|
|
77
|
-
bye: '已退出 g ai',
|
|
78
|
-
cleared: '对话历史已清空',
|
|
79
|
-
newConversation: '已开启新对话',
|
|
80
|
-
operationCancelled: '已取消当前操作',
|
|
81
|
-
thinkOn: '思考过程显示: 开',
|
|
82
|
-
thinkOff: '思考过程显示: 关(模型仍会思考,只是不回显)',
|
|
83
|
-
modelSwitched: (label) => `已切换模型: ${label}`,
|
|
84
|
-
invalidModelIndex: '无效的模型序号',
|
|
85
|
-
cdOk: (p) => `工作目录已切换: ${p}`,
|
|
86
|
-
cdFail: (p) => `目录不存在: ${p}`,
|
|
87
|
-
currentModel: '当前模型',
|
|
88
|
-
availableModels: '可用模型',
|
|
89
|
-
oneShotNoModel: '未配置模型,无法启动',
|
|
90
|
-
oneShotDone: '单发模式完成 · 需要多轮连续对话请直接运行 g ai(不带参数)',
|
|
91
|
-
helpTitle: 'g ai 命令',
|
|
92
|
-
bannerTip: 'Alt+V 贴图 · /help 帮助 · Ctrl+C 停止当前任务 · /exit 退出',
|
|
93
|
-
prompt: '❯ ',
|
|
94
|
-
bannerModel: '模型',
|
|
95
|
-
bannerCwd: '目录',
|
|
96
|
-
thinkingLabel: '思考',
|
|
97
|
-
answerLabel: '回答',
|
|
98
|
-
thinkingHint: '… 已显示前 12 行;输入 /think full 开启后续完整思考',
|
|
99
|
-
emptyResponse: '模型没有返回回答,请重试或切换模型。',
|
|
100
|
-
chars: '字符',
|
|
101
|
-
extLoadingMcp: (n) => `正在连接 ${n} 个 MCP 服务…`,
|
|
102
|
-
extLoaded: (skills, tools) => `扩展已就绪: ${skills} 个 Skill · ${tools} 个 MCP 工具(/skills 查看)`,
|
|
103
|
-
extNone: '',
|
|
104
|
-
extSkillTitle: '已安装的 Skill',
|
|
105
|
-
extSkillEmpty: '没有装 Skill。可以在 g ui → 智能体 → Skill 广场 里挑一个装到当前项目或 g ai。',
|
|
106
|
-
extMcpTitle: '已接入的 MCP 服务',
|
|
107
|
-
extMcpEmpty: '没有接入 MCP 服务。可以在 g ui → 智能体 → MCP 广场 里挑一个装到当前项目或 g ai。',
|
|
108
|
-
extMcpFailed: '连接失败',
|
|
109
|
-
imagePasting: '正在读取剪贴板图片…',
|
|
110
|
-
imageAttached: (n, size) => `📎 图片 #${n} 已附加(${size}),将随下一条消息发送`,
|
|
111
|
-
imageEmpty: '剪贴板中没有图片;可先截图再按 Alt+V,或用 /image <路径> 附加本地图片',
|
|
112
|
-
imageBadPath: (p) => `图片不存在或格式不支持: ${p}(支持 png/jpg/jpeg/gif/webp/bmp)`,
|
|
113
|
-
imageListTitle: '待发送图片',
|
|
114
|
-
imageListEmpty: '当前没有待发送的图片(Alt+V 粘贴或 /image <路径> 附加)',
|
|
115
|
-
imageCleared: '已清除待发送的图片',
|
|
116
|
-
imageSending: (n) => `📎 附带 ${n} 张图片`,
|
|
117
|
-
pasteToken: (n, lines) => `[粘贴 #${n} · ${lines} 行]`,
|
|
118
|
-
pasteExpired: (n) => `有 ${n} 段粘贴内容已从内存回收,无法还原;如仍需要请重新粘贴`,
|
|
119
|
-
addModelTitle: '添加模型配置',
|
|
120
|
-
addModelSaved: (name) => `✓ 模型 "${name}" 已添加并切换为当前模型`,
|
|
121
|
-
addModelCancelled: '已取消添加模型',
|
|
122
|
-
addModelSaveError: (msg) => `保存配置失败: ${msg}`,
|
|
123
|
-
resumeTitle: '选择要恢复的对话',
|
|
124
|
-
resumeEmpty: '没有可恢复的历史对话',
|
|
125
|
-
resumeCancelled: '已取消恢复对话',
|
|
126
|
-
resumeDone: (title, count) => `已恢复对话: ${title} (${count} 条消息)`,
|
|
127
|
-
},
|
|
128
|
-
en: {
|
|
129
|
-
noModel: 'No AI model configured. Add one in GUI settings first (run `g ui`).',
|
|
130
|
-
unknownModel: (q) => `No model matching "${q}", falling back to default`,
|
|
131
|
-
waiting: 'Thinking…',
|
|
132
|
-
llmError: (msg) => `LLM request failed: ${msg}`,
|
|
133
|
-
toolIterLimit: (n) => `Hit max tool iterations (${n}) for this turn. Send another message to continue.`,
|
|
134
|
-
toolRunning: (name) => `Running ${name}…`,
|
|
135
|
-
busy: 'Agent is working. Wait or press Ctrl+C to stop the task.',
|
|
136
|
-
bye: 'Bye',
|
|
137
|
-
cleared: 'Conversation cleared',
|
|
138
|
-
newConversation: 'Started a new conversation',
|
|
139
|
-
operationCancelled: 'Current operation cancelled',
|
|
140
|
-
thinkOn: 'Thinking display: on',
|
|
141
|
-
thinkOff: 'Thinking display: off (model still thinks, just hidden)',
|
|
142
|
-
modelSwitched: (label) => `Switched model: ${label}`,
|
|
143
|
-
invalidModelIndex: 'Invalid model index',
|
|
144
|
-
cdOk: (p) => `Working directory changed: ${p}`,
|
|
145
|
-
cdFail: (p) => `Directory not found: ${p}`,
|
|
146
|
-
currentModel: 'Current model',
|
|
147
|
-
availableModels: 'Available models',
|
|
148
|
-
oneShotNoModel: 'No model configured, aborting',
|
|
149
|
-
oneShotDone: 'One-shot done · for multi-turn chat run `g ai` with no arguments',
|
|
150
|
-
helpTitle: 'g ai commands',
|
|
151
|
-
bannerTip: 'Alt+V image · /help commands · Ctrl+C stop task · /exit quit',
|
|
152
|
-
prompt: '❯ ',
|
|
153
|
-
bannerModel: 'Model',
|
|
154
|
-
bannerCwd: 'CWD',
|
|
155
|
-
thinkingLabel: 'Thinking',
|
|
156
|
-
answerLabel: 'Answer',
|
|
157
|
-
thinkingHint: '… first 12 lines shown; enter /think full for future reasoning',
|
|
158
|
-
emptyResponse: 'The model returned no answer. Retry or switch models.',
|
|
159
|
-
chars: 'chars',
|
|
160
|
-
extLoadingMcp: (n) => `Connecting to ${n} MCP server(s)…`,
|
|
161
|
-
extLoaded: (skills, tools) => `Extensions ready: ${skills} skill(s) · ${tools} MCP tool(s) (/skills for details)`,
|
|
162
|
-
extNone: '',
|
|
163
|
-
extSkillTitle: 'Installed skills',
|
|
164
|
-
extSkillEmpty: 'No skills installed. Browse g ui → Agent → Skill marketplace to install one into this project or g ai.',
|
|
165
|
-
extMcpTitle: 'Connected MCP servers',
|
|
166
|
-
extMcpEmpty: 'No MCP servers connected. Browse g ui → Agent → MCP marketplace to install one.',
|
|
167
|
-
extMcpFailed: 'failed',
|
|
168
|
-
imagePasting: 'Reading clipboard image…',
|
|
169
|
-
imageAttached: (n, size) => `📎 Image #${n} attached (${size}); sent with your next message`,
|
|
170
|
-
imageEmpty: 'No image in clipboard; take a screenshot first, or use /image <path>',
|
|
171
|
-
imageBadPath: (p) => `Not a supported image: ${p} (png/jpg/jpeg/gif/webp/bmp)`,
|
|
172
|
-
imageListTitle: 'Pending images',
|
|
173
|
-
imageListEmpty: 'No pending images (Alt+V to paste, or /image <path>)',
|
|
174
|
-
imageCleared: 'Pending images cleared',
|
|
175
|
-
imageSending: (n) => `📎 ${n} image(s) attached`,
|
|
176
|
-
pasteToken: (n, lines) => `[paste #${n} · ${lines} lines]`,
|
|
177
|
-
pasteExpired: (n) => `${n} pasted block(s) were dropped from memory and could not be restored; paste again if still needed`,
|
|
178
|
-
addModelTitle: 'Add Model Configuration',
|
|
179
|
-
addModelSaved: (name) => `✓ Model "${name}" added and switched to`,
|
|
180
|
-
addModelCancelled: 'Add model cancelled',
|
|
181
|
-
addModelSaveError: (msg) => `Failed to save: ${msg}`,
|
|
182
|
-
resumeTitle: 'Choose a conversation to resume',
|
|
183
|
-
resumeEmpty: 'No previous conversations to resume',
|
|
184
|
-
resumeCancelled: 'Resume cancelled',
|
|
185
|
-
resumeDone: (title, count) => `Resumed: ${title} (${count} messages)`,
|
|
186
|
-
},
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
function makeStrings(locale) {
|
|
190
|
-
return String(locale || '').startsWith('en') ? STRINGS.en : STRINGS.zh
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
// 模型的展示名:优先 model 字段,其次 name,最后 baseURL
|
|
194
|
-
function modelLabel(m) {
|
|
195
|
-
return m?.model || m?.name || m?.baseURL || '(unknown)'
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
// ──────────────────────────────────────────────
|
|
199
|
-
// 系统 prompt — 明确告知环境、权限边界与工作方式
|
|
200
|
-
// ──────────────────────────────────────────────
|
|
201
|
-
// 顺序固定:基础人设 → 项目说明(AGENTS.md/CLAUDE.md)→ 扩展能力(skill 清单 + MCP 工具)。
|
|
202
|
-
// 把扩展放最后,是因为它是"增量能力",不该插在权限与环境说明中间打断阅读。
|
|
203
|
-
// state 里存着已加载的 AgentExtensions;没加载(如单测直接调)时 extra 为空,行为与改动前一致。
|
|
204
|
-
function extensionSuffix(state) {
|
|
205
|
-
try {
|
|
206
|
-
return state?.extensions?.promptSuffix?.(state.locale) || ''
|
|
207
|
-
} catch {
|
|
208
|
-
return ''
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
async function buildProjectPrompt(options) {
|
|
213
|
-
return buildSystemPrompt(options) + await loadProjectInstructions(options.cwd) + (options.extra || '')
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
function buildSystemPrompt({ cwd, locale, shellDesc }) {
|
|
217
|
-
const zh = !String(locale || '').startsWith('en')
|
|
218
|
-
const now = new Date().toLocaleString()
|
|
219
|
-
const isWin = process.platform === 'win32'
|
|
220
|
-
// 工具数量动态取自 tools.js —— 之前这里写死过"6 个",加 ask_user 时没人想起来改,
|
|
221
|
-
// 就变成了对模型撒谎。数字跟着定义走,以后再加工具不会漏。
|
|
222
|
-
const builtinToolCount = TOOL_DEFINITIONS.length
|
|
223
|
-
if (zh) {
|
|
224
|
-
return `你是 "g ai" —— zen-gitsync CLI 内置的终端编码智能体,通过工具在用户真实电脑上完成编码任务。
|
|
225
|
-
|
|
226
|
-
# 运行环境
|
|
227
|
-
- 操作系统: ${process.platform}
|
|
228
|
-
- Shell: ${shellDesc}
|
|
229
|
-
- 当前工作目录: ${cwd}(用户在此启动 g ai,也是所有相对路径的基准)
|
|
230
|
-
- 当前时间: ${now}
|
|
231
|
-
|
|
232
|
-
# 平台兼容性(重要!)
|
|
233
|
-
- 必须使用与当前 Shell 兼容的命令,禁止盲套 Unix 写法
|
|
234
|
-
${isWin ? `- 当前是 Windows cmd.exe,以下 Unix 命令**不存在**,用了必定报"不是内部或外部命令":
|
|
235
|
-
tail / head / cat / grep / ls / sed / awk / wc / cut / uniq / xargs / which / touch
|
|
236
|
-
平台守卫会在执行前拦截这些命令,但请主动避免,不要浪费一轮调用
|
|
237
|
-
- 跨平台替代方案:
|
|
238
|
-
· 列目录 → list_files 工具 或 cmd 的 dir
|
|
239
|
-
· 搜内容 → search_text 工具 或 cmd 的 findstr
|
|
240
|
-
· 看文件 → read_file 工具 或 cmd 的 type
|
|
241
|
-
· 看图片 → read_image 工具(截图/设计稿/示意图)。read_file 读图片只会得到乱码,别浪费那一轮
|
|
242
|
-
· 看输出末尾 → PowerShell "命令 | Select-Object -Last N"
|
|
243
|
-
· 文本处理 → node -e "..." 或 PowerShell
|
|
244
|
-
· 查命令路径 → cmd 的 where(不是 which)
|
|
245
|
-
- 必须跑 shell 时优先跨平台写法(如 node -e "..."),别用 Unix 专属命令` : `- 当前是 POSIX 环境,Unix 命令可用`}
|
|
246
|
-
|
|
247
|
-
# 远程仓库(GitHub / Gitee)
|
|
248
|
-
- 问"这个项目关联哪个远端" → run_command 跑 \`git remote -v\`:本地信息,不联网、不依赖任何 CLI,任何环境都能答
|
|
249
|
-
- 问"我账号下有哪些仓库"或某仓库的 PR / Issue → 用官方 CLI。凭据由 CLI 自己保管:
|
|
250
|
-
· GitHub → \`gh\`。列仓库 \`gh repo list --limit 50 --json name,visibility,updatedAt,primaryLanguage\`;
|
|
251
|
-
看详情 \`gh repo view <owner/repo>\`;PR \`gh pr list\`;登录态 \`gh auth status\`
|
|
252
|
-
· Gitee → \`gitee\`。列仓库 \`gitee repo list\`;登录态 \`gitee auth status\`
|
|
253
|
-
(未登录时退出码**仍是 0**,必须看 \`--json\` 里的 status 字段,只看退出码会误判成已登录)
|
|
254
|
-
· 两者默认只覆盖**当前登录账号**;组织仓库、私有仓库可能需要更大的 token scope,拉不到就如实说明,
|
|
255
|
-
不要用其他途径绕过
|
|
256
|
-
· 绝不向用户索要 token —— 也不要让用户把 token 粘进对话
|
|
257
|
-
- CLI 可能没装、或没在当前进程的 PATH 里(装完没重启服务就是这个表现):
|
|
258
|
-
报"不是内部或外部命令" / command not found 时,**不要换写法反复重试**,更不要凭印象编仓库名或目录名。
|
|
259
|
-
直接告诉用户未检测到该 CLI,可在 ZenGitSync 的「远程仓库」页一键安装/登录;
|
|
260
|
-
若只是想回答本项目的问题,退回 \`git remote -v\`
|
|
261
|
-
- 克隆仓库 / 添加远端时**优先用 SSH**(用户偏好,本机已配好密钥,实测走 https 会弹凭据窗口把任务打断):
|
|
262
|
-
· GitHub 用 \`git@github.com:owner/repo.git\`,Gitee 用 \`git@gitee.com:owner/repo.git\`
|
|
263
|
-
· 用户给的是 \`https://...\` 或 \`gh repo clone owner/repo\` 时,先换算成 SSH 地址再执行 ——
|
|
264
|
-
走 https 会弹 Git Credential Manager 让用户输账号密码,任务就停在半路等输入
|
|
265
|
-
· 已有仓库换协议:\`git remote set-url origin <ssh 地址>\`(先 \`git remote -v\` 看当前是什么)
|
|
266
|
-
· 只有 SSH 真的不可用(报 \`Permission denied (publickey)\` / \`Host key verification failed\`)才退回 https,
|
|
267
|
-
并用一句话说明这次走的是 https、配好密钥后可改回;两边都失败就停下来问用户,不要反复重试
|
|
268
|
-
|
|
269
|
-
# 权限(用户已明确授权,无需反复征求同意)
|
|
270
|
-
- 工作目录内:读写文件、执行命令等所有操作直接执行
|
|
271
|
-
- 其他目录:同样可以读取和修改
|
|
272
|
-
- 唯一红线:不得破坏系统(格式化磁盘、删除根目录/系统目录、关机重启、写块设备等)。
|
|
273
|
-
命令守卫只能识别部分危险写法,不是沙箱;不得通过脚本或嵌套 shell 绕过限制。
|
|
274
|
-
|
|
275
|
-
# 工作方式
|
|
276
|
-
- 先动手、后提问:能用工具查清的不要问用户(list_files / read_file / search_text / run_command)
|
|
277
|
-
- 修改代码后主动验证:跑测试、构建或至少语法检查(用 run_command)
|
|
278
|
-
- 编辑文件优先 edit_file 精确替换;先 read_file 看原文,old_string 必须与文件内容完全一致(含缩进换行)
|
|
279
|
-
- 大文件用 offset/limit 分段读取,不要一次读爆上下文
|
|
280
|
-
- git 操作用 run_command 执行;提交代码可以用 git 命令,也可以用本 CLI 的 g -y(默认信息提交并推送)或 g --ai(AI 生成提交信息)
|
|
281
|
-
- run_command 默认就在工作目录执行,不要再加 cd / cd /d 前缀;默认超时 120 秒,长任务加大 timeout_seconds(最大 600)
|
|
282
|
-
- 命令在 ${shellDesc} 下执行,注意语法兼容
|
|
283
|
-
- 先读取当前项目的规范、依赖与测试配置,再选择对应的验证命令,不要假定所有项目都使用 npm
|
|
284
|
-
- 用户要求概述或简短回答时,只读取必要的结构与入口文件,控制探索范围
|
|
285
|
-
|
|
286
|
-
# 任务计划(多步任务必用)
|
|
287
|
-
- 任务需要多步时(改代码、排查、调研、多文件改动),**动手之前**先调一次 update_plan,把要做的事
|
|
288
|
-
拆成 3-8 个可核对的步骤,让用户在你改任何东西之前就知道范围和验收标准
|
|
289
|
-
- 单步小事(读个文件、答一个问题)不要列计划,那是噪音
|
|
290
|
-
- 每次都传**完整**的当前计划(不是增量),顺序即执行顺序;同一时刻最多一个 in_progress
|
|
291
|
-
- 完成一步就更新一次状态,别攒到最后一次性刷;计划与实际不符时直接改计划,不要硬着头皮往下走
|
|
292
|
-
- 全部做完后把 steps 传空表示收尾
|
|
293
|
-
- update_plan 只是给人看的进度板,不要为了"更新计划"去改文件或跑命令
|
|
294
|
-
|
|
295
|
-
# 与用户交互
|
|
296
|
-
- 需要向用户确认、提问或汇报重要决策时,直接用普通文本输出 —— 用户能实时看到你的文本;
|
|
297
|
-
需要暂停当前任务并等待用户决定或补充信息时,调用 ask_user,不要猜测或只在普通文本里提问
|
|
298
|
-
不要调用不存在的工具,内置工具就是上面列出的 ${builtinToolCount} 个(若用户装了 MCP,你的工具表里还会多出 mcp__ 开头的工具)
|
|
299
|
-
- 图片有两条来路:
|
|
300
|
-
· 用户通过 Alt+V 或 /image 附加 → 图片以 image_url 部件出现在 user 消息里,直接就能看到
|
|
301
|
-
· 用户只给了**路径**(或你自己在翻文件时遇到图)→ 用 read_image 工具读它,别用 read_file
|
|
302
|
-
(read_file 按 UTF-8 解码,图片只会是乱码),也别根据文件名猜内容
|
|
303
|
-
如果当前模型不支持视觉(带图请求报错),提醒用户换用支持视觉的模型
|
|
304
|
-
- 发现高风险或状态不一致的情况(例如版本号 / git tag / CHANGELOG 对不上、发布前环境异常、
|
|
305
|
-
仓库状态与预期不符)时:先用文本说明发现和影响,停下来等用户指示,不要擅自继续破坏性操作
|
|
306
|
-
|
|
307
|
-
# 输出
|
|
308
|
-
- 你的文本输出直接显示在用户终端,用简体中文交流
|
|
309
|
-
- 完成任务后用一两句话汇报结果,不要复述过程细节`
|
|
310
|
-
}
|
|
311
|
-
return `You are "g ai" — the terminal coding agent built into the zen-gitsync CLI. You use tools to perform coding tasks on the user's real machine.
|
|
312
|
-
|
|
313
|
-
# Environment
|
|
314
|
-
- OS: ${process.platform}
|
|
315
|
-
- Shell: ${shellDesc}
|
|
316
|
-
- Working directory: ${cwd} (where the user launched g ai; base for all relative paths)
|
|
317
|
-
- Current time: ${now}
|
|
318
|
-
|
|
319
|
-
# Platform compatibility (important!)
|
|
320
|
-
- You MUST use commands compatible with the current shell. Do NOT blindly copy Unix patterns.
|
|
321
|
-
${isWin ? `- This is Windows cmd.exe. The following Unix commands do NOT exist here and will fail with "not recognized":
|
|
322
|
-
tail / head / cat / grep / ls / sed / awk / wc / cut / uniq / xargs / which / touch
|
|
323
|
-
A platform guard will block these before execution, but avoid them proactively — don't waste a turn.
|
|
324
|
-
- Cross-platform alternatives:
|
|
325
|
-
· List dirs → list_files tool, or cmd's dir
|
|
326
|
-
· Search content → search_text tool, or cmd's findstr
|
|
327
|
-
· Read files → read_file tool, or cmd's type
|
|
328
|
-
· Look at an image → read_image tool (screenshots, mockups, diagrams). read_file only decodes text and returns garbage for images
|
|
329
|
-
· Tail output → PowerShell "command | Select-Object -Last N"
|
|
330
|
-
· Text processing → node -e "..." or PowerShell
|
|
331
|
-
· Find executable → cmd's where (not which)
|
|
332
|
-
- For shell one-offs, prefer cross-platform forms like node -e "..."` : `- POSIX environment: Unix commands are available`}
|
|
333
|
-
|
|
334
|
-
# Remote repositories (GitHub / Gitee)
|
|
335
|
-
- "Which remote does this project point at?" → run_command \`git remote -v\`: local info, no network, no CLI needed
|
|
336
|
-
- "Which repos do I have?" or a repo's PRs / issues → use the official CLI. The CLI owns the credentials:
|
|
337
|
-
· GitHub → \`gh\`. List repos \`gh repo list --limit 50 --json name,visibility,updatedAt,primaryLanguage\`;
|
|
338
|
-
details \`gh repo view <owner/repo>\`; PRs \`gh pr list\`; auth state \`gh auth status\`
|
|
339
|
-
· Gitee → \`gitee\`. List repos \`gitee repo list\`; auth state \`gitee auth status\`
|
|
340
|
-
(the exit code is still 0 when logged out — read the status field from \`--json\`, or you will
|
|
341
|
-
wrongly report "logged in")
|
|
342
|
-
· Both default to the **currently authenticated account only**; org and private repos may need
|
|
343
|
-
broader token scopes. If you cannot see them, say so plainly — do not route around it
|
|
344
|
-
· Never ask the user for a token, and never have them paste one into the conversation
|
|
345
|
-
- The CLI may not be installed, or missing from this process's PATH (that is what "installed but the
|
|
346
|
-
server cannot find it" looks like):
|
|
347
|
-
on "not recognized" / command not found, do NOT retry with a different spelling and do NOT invent
|
|
348
|
-
repo or directory names. Tell the user the CLI was not found and point them to ZenGitSync's
|
|
349
|
-
"Remote repositories" page (one-click install / login); if you only need to answer for this project,
|
|
350
|
-
fall back to \`git remote -v\`
|
|
351
|
-
- Cloning a repo or adding a remote: **prefer SSH** (the user's preference — keys are already set up,
|
|
352
|
-
and https has been observed to pop a credential window that stalls the task):
|
|
353
|
-
· GitHub → \`git@github.com:owner/repo.git\`; Gitee → \`git@gitee.com:owner/repo.git\`
|
|
354
|
-
· If the user hands you an \`https://...\` URL (or \`gh repo clone owner/repo\`), convert it to the SSH
|
|
355
|
-
form first — https pops up Git Credential Manager asking for a username/password and blocks the task
|
|
356
|
-
· Switching an existing repo: \`git remote set-url origin <ssh url>\` (check \`git remote -v\` first)
|
|
357
|
-
· Fall back to https only when SSH genuinely fails (\`Permission denied (publickey)\` /
|
|
358
|
-
\`Host key verification failed\`), and say in one line that this one used https and can go back to SSH
|
|
359
|
-
once the key is set up; if both fail, stop and ask the user — do not keep retrying
|
|
360
|
-
|
|
361
|
-
# Permissions (explicitly granted by the user — do not keep asking)
|
|
362
|
-
- Inside the working directory: read/write files and run commands directly
|
|
363
|
-
- Other directories: may also be read and modified
|
|
364
|
-
- 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.
|
|
365
|
-
|
|
366
|
-
# How to work
|
|
367
|
-
- Act first, ask later: use tools (list_files / read_file / search_text / run_command) instead of asking the user
|
|
368
|
-
- When a decision or missing detail must come from the user, call ask_user. Do not guess or merely describe a question in plain text.
|
|
369
|
-
- After editing code, verify: run tests, builds, or at least a syntax check via run_command
|
|
370
|
-
- Prefer edit_file for precise replacements; read_file first, old_string must match the file exactly
|
|
371
|
-
- Read large files in segments (offset/limit)
|
|
372
|
-
- Git operations go through run_command; to commit, use git commands or this CLI's g -y / g --ai
|
|
373
|
-
- run_command already executes in the working directory — do NOT prefix with cd; default timeout 120s, raise timeout_seconds (max 600) for long tasks
|
|
374
|
-
- Commands run under ${shellDesc}; keep syntax compatible
|
|
375
|
-
- Read this project's instructions, dependencies and test configuration before choosing verification commands; do not assume npm
|
|
376
|
-
- For a brief overview, inspect only the necessary structure and entry points; keep exploration proportional to the request
|
|
377
|
-
|
|
378
|
-
# Task plan (required for multi-step work)
|
|
379
|
-
- When a task takes several steps (code changes, debugging, research, multi-file edits), call update_plan
|
|
380
|
-
**before touching anything**: break it into 3-8 verifiable steps so the user knows the scope and the
|
|
381
|
-
acceptance criteria before you modify a single file
|
|
382
|
-
- Do not plan single trivial actions (read one file, answer one question) — that is noise
|
|
383
|
-
- Always send the **complete** current plan (not a delta), in execution order; at most one in_progress
|
|
384
|
-
- Update the status right after finishing a step instead of batching everything at the end; when the
|
|
385
|
-
plan no longer matches reality, rewrite the plan instead of pushing ahead
|
|
386
|
-
- Send an empty steps list once everything is done
|
|
387
|
-
- update_plan is a progress board for humans: never edit files or run commands just to "update the plan"
|
|
388
|
-
|
|
389
|
-
# Talking to the user
|
|
390
|
-
- 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. Use ask_user when the task must pause for an answer. Never call tools that do not exist; the built-in set is the ${builtinToolCount} tools listed above (if the user installed MCP servers, extra tools prefixed with mcp__ will also appear in your tool list)
|
|
391
|
-
- Images reach you two ways:
|
|
392
|
-
· The user attached one via Alt+V or /image → it arrives as an image_url part in the user message; you can see it directly
|
|
393
|
-
· The user only gave you a **path** (or you run into an image while exploring) → read it with the read_image tool, not read_file
|
|
394
|
-
(read_file decodes UTF-8 and returns garbage for images), and never guess an image's content from its filename
|
|
395
|
-
If the current model rejects images (no vision support), tell the user to switch to a vision-capable model
|
|
396
|
-
- When you spot high-risk or inconsistent state (version number / git tag / CHANGELOG mismatch, abnormal release environment, unexpected repo state), explain the finding and its impact in text, then STOP and wait for the user's decision instead of proceeding with destructive operations
|
|
397
|
-
|
|
398
|
-
# Output
|
|
399
|
-
- Your text output goes straight to the user's terminal; reply in English
|
|
400
|
-
- Report results in one or two sentences; do not narrate the process`
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
// ──────────────────────────────────────────────
|
|
404
|
-
// 消息准备(消毒 / 旧图片降级)统一实现在 context.js —— CLI 与 Web 智能体面板
|
|
405
|
-
// 共用同一份,避免两侧各写一遍后行为分叉。这里 re-export 保持既有引用与测试不断。
|
|
406
|
-
// ──────────────────────────────────────────────
|
|
407
|
-
export { sanitizeMessages, stripStaleImages } from './context.js'
|
|
408
|
-
|
|
409
|
-
// ──────────────────────────────────────────────
|
|
410
|
-
// 单轮 agent 循环:用户一句话 → 流式输出 → 工具调用 → 再调用模型 … 直到模型给出最终文本
|
|
411
|
-
// images: [{path, bytes}] 待发送图片(可为空数组)
|
|
412
|
-
// ──────────────────────────────────────────────
|
|
413
|
-
function printSlashHelp(t, locale) {
|
|
414
|
-
const zh = !String(locale || '').startsWith('en')
|
|
415
|
-
const lines = zh ? [
|
|
416
|
-
' /help 显示本帮助',
|
|
417
|
-
' /model 列出可用模型;/model <序号> 切换模型',
|
|
418
|
-
' /addmodel 添加新的模型配置(交互式向导)',
|
|
419
|
-
' /cd <路径> 切换智能体工作目录',
|
|
420
|
-
' /image [路径] 查看待发送图片;/image <路径> 附加本地图片;/image clear 清除',
|
|
421
|
-
' /think full 完整显示后续思考(默认,模型返回的内容)',
|
|
422
|
-
' /think compact 预览前 12 行思考',
|
|
423
|
-
' /think off 隐藏思考;/think 切换完整与隐藏',
|
|
424
|
-
' /tools [模式] compact 精简 / full 完整工具输出',
|
|
425
|
-
' /skills 查看已安装的 Skill 与已接入的 MCP 服务',
|
|
426
|
-
' /stats 查看上一轮与会话累计用量、耗时',
|
|
427
|
-
' /new 开启新对话',
|
|
428
|
-
' /resume 选择并恢复之前的对话',
|
|
429
|
-
' /clear 清空对话历史',
|
|
430
|
-
' /exit, /quit 退出',
|
|
431
|
-
'',
|
|
432
|
-
' Alt+V 粘贴剪贴板图片,随下一条消息发送(需视觉模型)',
|
|
433
|
-
' 多行粘贴 直接粘贴多行文本:整段作为一条消息发送,输入行里显示 [粘贴 #N] 占位符',
|
|
434
|
-
'',
|
|
435
|
-
'权限说明: 工作目录内全部操作直接执行;其他目录可读写;',
|
|
436
|
-
'命令守卫识别部分系统级危险操作,不是隔离沙箱。',
|
|
437
|
-
] : [
|
|
438
|
-
' /help Show this help',
|
|
439
|
-
' /model List models; /model <n> to switch',
|
|
440
|
-
' /addmodel Add a new model (interactive wizard)',
|
|
441
|
-
' /cd <path> Change agent working directory',
|
|
442
|
-
' /image [path] List pending images; attach a file; /image clear to reset',
|
|
443
|
-
' /think full Show future thinking returned by the model in full (default)',
|
|
444
|
-
' /think compact Preview the first 12 thinking lines',
|
|
445
|
-
' /think off Hide thinking; /think toggles full / hidden',
|
|
446
|
-
' /tools [mode] compact / full tool output',
|
|
447
|
-
' /skills List installed skills and connected MCP servers',
|
|
448
|
-
' /stats Last turn timing and session token usage',
|
|
449
|
-
' /new Start a new conversation',
|
|
450
|
-
' /resume Choose and resume a previous conversation',
|
|
451
|
-
' /clear Clear conversation history',
|
|
452
|
-
' /exit, /quit Quit',
|
|
453
|
-
'',
|
|
454
|
-
' Alt+V Paste clipboard image (needs a vision-capable model)',
|
|
455
|
-
' Multi-line paste Paste a block as one message; the input line shows a [paste #N] placeholder',
|
|
456
|
-
'',
|
|
457
|
-
'Permissions: full access in the working directory; other dirs readable/writable;',
|
|
458
|
-
'the command guard detects some dangerous operations; it is not a sandbox.',
|
|
459
|
-
]
|
|
460
|
-
printHelpPanel(t.helpTitle, lines)
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
export async function handleSlashCommand(state, input, t) {
|
|
464
|
-
const [cmd, ...rest] = input.split(/\s+/)
|
|
465
|
-
const arg = rest.join(' ').trim()
|
|
466
|
-
const zh = !String(state.locale).startsWith('en')
|
|
467
|
-
if (state.busy && !['/help', '/stats', '/think', '/tools', '/skills', '/mcp', '/exit', '/quit'].includes(cmd)) {
|
|
468
|
-
printDim(t.busy)
|
|
469
|
-
return 'ok'
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
if (cmd === '/exit' || cmd === '/quit') return 'exit'
|
|
473
|
-
if (cmd === '/help') { printSlashHelp(t, state.locale); return 'ok' }
|
|
474
|
-
if (cmd === '/clear' || cmd === '/new') {
|
|
475
|
-
await state.persistSession?.()
|
|
476
|
-
state.messages = [state.messages[0]]
|
|
477
|
-
// 开新会话 ID,旧会话保留在磁盘上
|
|
478
|
-
state.sessionId = genSessionId()
|
|
479
|
-
state.sessionCreatedAt = new Date().toISOString()
|
|
480
|
-
state.lastTurnStats = null
|
|
481
|
-
state.sessionStats = null
|
|
482
|
-
printOk(cmd === '/new' ? t.newConversation : t.cleared)
|
|
483
|
-
return 'ok'
|
|
484
|
-
}
|
|
485
|
-
if (cmd === '/resume') {
|
|
486
|
-
if (state.busy) {
|
|
487
|
-
printDim(t.busy)
|
|
488
|
-
return 'ok'
|
|
489
|
-
}
|
|
490
|
-
const sessions = (await listSessions())
|
|
491
|
-
.filter(session => session.sessionId !== state.sessionId && session.messages.length > 1)
|
|
492
|
-
if (sessions.length === 0) {
|
|
493
|
-
printDim(t.resumeEmpty)
|
|
494
|
-
return 'ok'
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
const zh = !String(state.locale || '').startsWith('en')
|
|
498
|
-
const cleanLabel = (value, fallback) => String(value || fallback).replace(/[\r\n\x00-\x1f\x7f]/g, ' ').trim()
|
|
499
|
-
const items = sessions.map((session) => {
|
|
500
|
-
const title = cleanLabel(session.title, zh ? '(未命名对话)' : '(untitled)')
|
|
501
|
-
const updated = new Date(session.updatedAt || session.createdAt || 0)
|
|
502
|
-
const date = Number.isNaN(updated.getTime()) ? '' : updated.toLocaleString(state.locale)
|
|
503
|
-
const count = session.messages.filter(message => message?.role !== 'system').length
|
|
504
|
-
return {
|
|
505
|
-
label: `${title} ${chalk.dim(`${date} · ${count} ${zh ? '条消息' : 'messages'}`)}`,
|
|
506
|
-
value: session,
|
|
507
|
-
}
|
|
508
|
-
})
|
|
509
|
-
const selectStrings = {
|
|
510
|
-
navHint: zh ? '↑↓ 切换,Enter 确认' : '↑↓ navigate, Enter confirm',
|
|
511
|
-
selectPrompt: (min, max) => zh ? `请选择(${min}-${max}):` : `Choose (${min}-${max}):`,
|
|
512
|
-
invalidChoice: (min, max) => zh ? `请输入 ${min}-${max} 之间的序号` : `Enter a number from ${min} to ${max}`,
|
|
513
|
-
}
|
|
514
|
-
const asker = {
|
|
515
|
-
ask(prompt) {
|
|
516
|
-
return new Promise(resolve => state.rl.question(`${prompt} `, answer => resolve(String(answer || '').trim())))
|
|
517
|
-
},
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
state.inWizard = true
|
|
521
|
-
try {
|
|
522
|
-
const selected = await selectFromList({
|
|
523
|
-
rl: state.rl,
|
|
524
|
-
asker,
|
|
525
|
-
title: t.resumeTitle,
|
|
526
|
-
items,
|
|
527
|
-
t: selectStrings,
|
|
528
|
-
})
|
|
529
|
-
if (!selected?.value) return 'ok'
|
|
530
|
-
|
|
531
|
-
const session = selected.value
|
|
532
|
-
const { promises: fsp } = await import('node:fs')
|
|
533
|
-
if (session.cwd) {
|
|
534
|
-
try {
|
|
535
|
-
const stat = await fsp.stat(session.cwd)
|
|
536
|
-
if (stat.isDirectory()) state.ctx.cwd = session.cwd
|
|
537
|
-
} catch { /* 已删除的旧目录不阻断恢复,沿用当前目录 */ }
|
|
538
|
-
}
|
|
539
|
-
state.sessionId = session.sessionId
|
|
540
|
-
state.sessionCreatedAt = session.createdAt || new Date().toISOString()
|
|
541
|
-
state.messages = repairToolHistory(session.messages)
|
|
542
|
-
state.lastTurnStats = session.lastTurnStats || null
|
|
543
|
-
state.sessionStats = session.sessionStats || null
|
|
544
|
-
const systemMessage = {
|
|
545
|
-
role: 'system',
|
|
546
|
-
content: await buildProjectPrompt({ cwd: state.ctx.cwd, locale: state.locale, shellDesc: state.shellDesc, extra: extensionSuffix(state) }),
|
|
547
|
-
}
|
|
548
|
-
if (state.messages[0]?.role === 'system') state.messages[0] = systemMessage
|
|
549
|
-
else state.messages.unshift(systemMessage)
|
|
550
|
-
state.pendingImages = []
|
|
551
|
-
const count = state.messages.filter(message => message?.role !== 'system').length
|
|
552
|
-
printOk(t.resumeDone(cleanLabel(session.title, session.sessionId), count))
|
|
553
|
-
} catch (err) {
|
|
554
|
-
if (err?.cancelled) printDim(t.resumeCancelled)
|
|
555
|
-
else printError(err.message)
|
|
556
|
-
} finally {
|
|
557
|
-
state.inWizard = false
|
|
558
|
-
}
|
|
559
|
-
return 'ok'
|
|
560
|
-
}
|
|
561
|
-
if (cmd === '/think') {
|
|
562
|
-
const next = arg || (state.showThinking ? 'off' : 'full')
|
|
563
|
-
if (!['off', 'compact', 'full'].includes(next)) {
|
|
564
|
-
printWarn('/think compact | full | off')
|
|
565
|
-
return 'ok'
|
|
566
|
-
}
|
|
567
|
-
state.showThinking = next !== 'off'
|
|
568
|
-
state.thinkingMode = next
|
|
569
|
-
printOk(zh ? `后续请求的思考显示:${({ off: '隐藏', compact: '预览前 12 行', full: '完整' })[next]}` : `Thinking for future requests: ${next}`)
|
|
570
|
-
return 'ok'
|
|
571
|
-
}
|
|
572
|
-
if (cmd === '/tools') {
|
|
573
|
-
if (arg && !['full', 'compact'].includes(arg)) { printWarn('/tools compact | full'); return 'ok' }
|
|
574
|
-
state.fullTools = arg ? arg === 'full' : !state.fullTools
|
|
575
|
-
printOk(zh ? `后续工具结果:${state.fullTools ? '完整' : '精简'}` : `Future tool output: ${state.fullTools ? 'full' : 'compact'}`)
|
|
576
|
-
return 'ok'
|
|
577
|
-
}
|
|
578
|
-
if (cmd === '/skills' || cmd === '/mcp') {
|
|
579
|
-
const info = state.extensions?.summary(state.locale) || { skills: [], servers: [] }
|
|
580
|
-
console.log(chalk.bold(`\n ${t.extSkillTitle}`))
|
|
581
|
-
if (info.skills.length === 0) printDim(t.extSkillEmpty)
|
|
582
|
-
for (const skill of info.skills) {
|
|
583
|
-
const scope = skill.scope === 'project' ? (zh ? '项目' : 'project') : (zh ? '全局' : 'global')
|
|
584
|
-
console.log(` · ${chalk.cyan(skill.name)} ${chalk.dim(`[${scope}]`)}${skill.description ? chalk.dim(` — ${skill.description}`) : ''}`)
|
|
585
|
-
}
|
|
586
|
-
console.log(chalk.bold(`\n ${t.extMcpTitle}`))
|
|
587
|
-
if (info.servers.length === 0) printDim(t.extMcpEmpty)
|
|
588
|
-
for (const server of info.servers) {
|
|
589
|
-
if (server.error) {
|
|
590
|
-
console.log(` · ${chalk.red(server.id)} ${chalk.dim(`— ${t.extMcpFailed}: ${server.error}`)}`)
|
|
591
|
-
} else {
|
|
592
|
-
console.log(` · ${chalk.cyan(server.id)} ${chalk.dim(`— ${server.tools} ${zh ? '个工具' : 'tools'} (${server.command})`)}`)
|
|
593
|
-
}
|
|
594
|
-
}
|
|
595
|
-
console.log('')
|
|
596
|
-
return 'ok'
|
|
597
|
-
}
|
|
598
|
-
if (cmd === '/stats') {
|
|
599
|
-
if (state.lastTurnStats) printTurnSummary(state.lastTurnStats, { locale: state.locale, session: state.sessionStats })
|
|
600
|
-
else printDim(zh ? '完成一轮对话后显示用量与耗时。' : 'Usage and timing appear after a turn completes.')
|
|
601
|
-
return 'ok'
|
|
602
|
-
}
|
|
603
|
-
if (cmd === '/image') {
|
|
604
|
-
if (arg === 'clear') {
|
|
605
|
-
state.pendingImages = []
|
|
606
|
-
printOk(t.imageCleared)
|
|
607
|
-
return 'ok'
|
|
608
|
-
}
|
|
609
|
-
if (!arg) {
|
|
610
|
-
if (state.pendingImages.length === 0) {
|
|
611
|
-
printDim(t.imageListEmpty)
|
|
612
|
-
} else {
|
|
613
|
-
console.log(chalk.bold(t.imageListTitle) + ':')
|
|
614
|
-
state.pendingImages.forEach((img, i) => {
|
|
615
|
-
console.log(` ${i + 1}. ${img.path} ${chalk.dim('(' + formatBytes(img.bytes) + ')')}`)
|
|
616
|
-
})
|
|
617
|
-
}
|
|
618
|
-
return 'ok'
|
|
619
|
-
}
|
|
620
|
-
const { resolve } = await import('node:path')
|
|
621
|
-
const target = resolve(state.ctx.cwd, arg)
|
|
622
|
-
const img = await checkImageFile(target)
|
|
623
|
-
if (!img) {
|
|
624
|
-
printError(t.imageBadPath(target))
|
|
625
|
-
return 'ok'
|
|
626
|
-
}
|
|
627
|
-
state.pendingImages.push(img)
|
|
628
|
-
printOk(t.imageAttached(state.pendingImages.length, formatBytes(img.bytes)))
|
|
629
|
-
return 'ok'
|
|
630
|
-
}
|
|
631
|
-
if (cmd === '/model') {
|
|
632
|
-
if (!arg) {
|
|
633
|
-
console.log(chalk.bold(t.availableModels) + ':')
|
|
634
|
-
state.models.forEach((m, i) => {
|
|
635
|
-
const current = m === state.model ? chalk.green(' ← ' + t.currentModel) : ''
|
|
636
|
-
const keyHint = m.apiKey ? '' : chalk.yellow(' (无 apiKey)')
|
|
637
|
-
console.log(` ${i + 1}. ${modelLabel(m)} ${chalk.dim(m.baseURL || '')}${keyHint}${current}`)
|
|
638
|
-
})
|
|
639
|
-
console.log(chalk.dim('/model <序号> 切换'))
|
|
640
|
-
return 'ok'
|
|
641
|
-
}
|
|
642
|
-
const idx = Number.parseInt(arg, 10)
|
|
643
|
-
if (!Number.isFinite(idx) || idx < 1 || idx > state.models.length) {
|
|
644
|
-
printError(t.invalidModelIndex)
|
|
645
|
-
return 'ok'
|
|
646
|
-
}
|
|
647
|
-
state.model = state.models[idx - 1]
|
|
648
|
-
printOk(t.modelSwitched(modelLabel(state.model)))
|
|
649
|
-
return 'ok'
|
|
650
|
-
}
|
|
651
|
-
if (cmd === '/addmodel') {
|
|
652
|
-
// 智能体执行中不允许启动向导:向导的 rl.question 会与流式输出交错,体验混乱
|
|
653
|
-
if (state.busy) {
|
|
654
|
-
printDim(t.busy)
|
|
655
|
-
return 'ok'
|
|
656
|
-
}
|
|
657
|
-
state.inWizard = true
|
|
658
|
-
try {
|
|
659
|
-
console.log(chalk.cyan.bold('\n' + t.addModelTitle))
|
|
660
|
-
// 复用 modelSetup 的交互式收集逻辑(服务商 → 接口 → 模型名 → Key → 显示名 → 测试)
|
|
661
|
-
const collected = await collectModelInput({
|
|
662
|
-
locale: state.locale,
|
|
663
|
-
rl: state.rl,
|
|
664
|
-
cancelMessage: t.addModelCancelled,
|
|
665
|
-
})
|
|
666
|
-
if (!collected) return 'ok'
|
|
667
|
-
const { baseURL, model, apiKey, displayName } = collected
|
|
668
|
-
// 构建模型配置(isDefault=false:/addmodel 只是追加,不改默认模型)
|
|
669
|
-
const newModel = buildModelConfig({ baseURL, model, apiKey, name: displayName, isDefault: false })
|
|
670
|
-
// 持久化到 ~/.zen-gitsync/config.json 顶层 models 数组
|
|
671
|
-
const cfg = await config.loadConfig()
|
|
672
|
-
const models = Array.isArray(cfg.models) ? cfg.models : []
|
|
673
|
-
models.push(newModel)
|
|
674
|
-
cfg.models = models
|
|
675
|
-
try {
|
|
676
|
-
await config.saveConfig(cfg)
|
|
677
|
-
} catch (err) {
|
|
678
|
-
printError(t.addModelSaveError(err.message))
|
|
679
|
-
return 'ok'
|
|
680
|
-
}
|
|
681
|
-
// 同步 REPL 状态:更新模型列表 + 自动切换到新模型(用户刚添加,大概率想立刻用)
|
|
682
|
-
state.models = models
|
|
683
|
-
state.model = newModel
|
|
684
|
-
printOk(t.addModelSaved(displayName || model))
|
|
685
|
-
} catch (err) {
|
|
686
|
-
printError(err.message)
|
|
687
|
-
} finally {
|
|
688
|
-
state.inWizard = false
|
|
689
|
-
}
|
|
690
|
-
return 'ok'
|
|
691
|
-
}
|
|
692
|
-
if (cmd === '/cd') {
|
|
693
|
-
if (!arg) { console.log(state.ctx.cwd); return 'ok' }
|
|
694
|
-
const { resolve } = await import('node:path')
|
|
695
|
-
const { promises: fsp } = await import('node:fs')
|
|
696
|
-
const target = resolve(state.ctx.cwd, arg)
|
|
697
|
-
try {
|
|
698
|
-
const st = await fsp.stat(target)
|
|
699
|
-
if (!st.isDirectory()) throw new Error('not a dir')
|
|
700
|
-
state.ctx.cwd = target
|
|
701
|
-
// 项目级 skill 与项目级 mcp.json 跟着工作目录走:先重载扩展(会关掉旧目录起的 MCP 子进程),
|
|
702
|
-
// 再重建首条 system 消息 —— 顺序反了会把旧目录的 skill 清单写进新目录的提示词。
|
|
703
|
-
await state.extensions?.reload({ cwd: target, locale: state.locale, onWarn: printWarn })
|
|
704
|
-
state.messages[0] = { role: 'system', content: await buildProjectPrompt({ cwd: target, locale: state.locale, shellDesc: state.shellDesc, extra: extensionSuffix(state) }) }
|
|
705
|
-
await state.persistSession?.()
|
|
706
|
-
printOk(t.cdOk(target))
|
|
707
|
-
} catch {
|
|
708
|
-
printError(t.cdFail(target))
|
|
709
|
-
}
|
|
710
|
-
return 'ok'
|
|
711
|
-
}
|
|
712
|
-
printWarn(`未知命令: ${cmd},输入 /help 查看可用命令`)
|
|
713
|
-
return 'ok'
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
// ──────────────────────────────────────────────
|
|
717
|
-
// 主入口
|
|
718
|
-
// ──────────────────────────────────────────────
|
|
719
|
-
export async function runAiAgent(argv = []) {
|
|
720
|
-
const cfg = await config.loadConfig()
|
|
721
|
-
const locale = cfg.locale || 'zh-CN'
|
|
722
|
-
const t = makeStrings(locale)
|
|
723
|
-
|
|
724
|
-
// g ai --help:智能体专属帮助,不触发主 CLI 的 showHelp
|
|
725
|
-
if (argv.includes('--help') || argv.includes('-h')) {
|
|
726
|
-
printSlashHelp(t, locale)
|
|
727
|
-
return
|
|
728
|
-
}
|
|
729
|
-
|
|
730
|
-
let models = Array.isArray(cfg.models) ? cfg.models : []
|
|
731
|
-
if (models.length === 0) {
|
|
732
|
-
// 未配置模型:启动交互式配置向导,参照 g ui 设置里的"添加模型"
|
|
733
|
-
const setupResult = await runModelSetup({ locale })
|
|
734
|
-
if (!setupResult) {
|
|
735
|
-
process.exitCode = 1
|
|
736
|
-
return
|
|
737
|
-
}
|
|
738
|
-
models = setupResult.models
|
|
739
|
-
}
|
|
740
|
-
|
|
741
|
-
// --model=<序号|名称> 选择模型;默认 isDefault,否则第一个
|
|
742
|
-
let model = models.find(m => m.isDefault) || models[0]
|
|
743
|
-
const modelArg = argv.find(a => a.startsWith('--model='))
|
|
744
|
-
if (modelArg) {
|
|
745
|
-
const q = modelArg.split('=')[1]
|
|
746
|
-
const idx = Number.parseInt(q, 10)
|
|
747
|
-
if (Number.isFinite(idx) && idx >= 1 && idx <= models.length) {
|
|
748
|
-
model = models[idx - 1]
|
|
749
|
-
} else {
|
|
750
|
-
const found = models.find(m => modelLabel(m).toLowerCase().includes(String(q).toLowerCase()))
|
|
751
|
-
if (found) model = found
|
|
752
|
-
else printWarn(t.unknownModel(q))
|
|
753
|
-
}
|
|
754
|
-
}
|
|
755
|
-
|
|
756
|
-
const cwd = getCwd()
|
|
757
|
-
const shellDesc = process.platform === 'win32'
|
|
758
|
-
? 'cmd.exe (Windows CMD)'
|
|
759
|
-
: (process.env.SHELL || '/bin/sh')
|
|
760
|
-
|
|
761
|
-
// 加载扩展(Skill 清单 + MCP 服务)。MCP 要起子进程,npx 首次还得下载包,
|
|
762
|
-
// 所以这一步可能明显停顿 —— 有 MCP 时先提示一句。任何失败都只降级并打印原因。
|
|
763
|
-
const extensions = await AgentExtensions.load({
|
|
764
|
-
cwd,
|
|
765
|
-
locale,
|
|
766
|
-
onWarn: printWarn,
|
|
767
|
-
onNotice: (count) => printDim(t.extLoadingMcp(count)),
|
|
768
|
-
})
|
|
769
|
-
|
|
770
|
-
const state = {
|
|
771
|
-
// 会话持久化:CLI 对话也保存到 ~/.zen-gitsync/agent-sessions/,
|
|
772
|
-
// 在 Web UI 智能体 tab 中可见(带 CLI 标记)
|
|
773
|
-
sessionId: genSessionId(),
|
|
774
|
-
sessionCreatedAt: new Date().toISOString(),
|
|
775
|
-
messages: [{ role: 'system', content: await buildProjectPrompt({ cwd, locale, shellDesc, extra: extensions.promptSuffix(locale) }) }],
|
|
776
|
-
ctx: {
|
|
777
|
-
cwd,
|
|
778
|
-
onChild: (child) => {
|
|
779
|
-
state.currentChild = child
|
|
780
|
-
// 子进程退出后清掉引用,避免 SIGINT 时 kill 一个已结束的进程
|
|
781
|
-
child.once?.('exit', () => { if (state.currentChild === child) state.currentChild = null })
|
|
782
|
-
},
|
|
783
|
-
},
|
|
784
|
-
models,
|
|
785
|
-
model,
|
|
786
|
-
locale,
|
|
787
|
-
shellDesc,
|
|
788
|
-
// 单轮工具调用上限:来自全局配置 aiMaxToolIterations(loadConfig 已规范化),
|
|
789
|
-
// 读不到时由 runAgentTurn 兜底为 200
|
|
790
|
-
maxToolIterations: cfg.aiMaxToolIterations,
|
|
791
|
-
// 单轮请求的上下文预算(
|
|
792
|
-
//
|
|
793
|
-
//
|
|
794
|
-
requestBudget: resolveRequestBudget(cfg.
|
|
795
|
-
currentChild: null,
|
|
796
|
-
abortController: null,
|
|
797
|
-
cancelRequested: false,
|
|
798
|
-
busy: false,
|
|
799
|
-
showThinking: true, // /think 切换:是否回显模型的思考过程
|
|
800
|
-
thinkingMode: 'full',
|
|
801
|
-
fullTools: false,
|
|
802
|
-
lastTurnStats: null,
|
|
803
|
-
sessionStats: null,
|
|
804
|
-
pendingImages: [], // Alt+V / /image 附加的待发送图片 [{path, bytes}]
|
|
805
|
-
pasting: false, // 剪贴板读取进行中(防 Alt+V 连打并发)
|
|
806
|
-
inWizard: false, // /addmodel 交互式向导进行中:忽略 REPL 的 line 事件
|
|
807
|
-
rl: null, // REPL readline 引用(供 /addmodel 复用做 rl.question)
|
|
808
|
-
extensions, // 已加载的 Skill + MCP 扩展(见 extensions.js)
|
|
809
|
-
}
|
|
810
|
-
|
|
811
|
-
// 持久化当前会话到磁盘(供 Web UI 读取)
|
|
812
|
-
let saving = Promise.resolve()
|
|
813
|
-
async function persistSession() {
|
|
814
|
-
if (state.messages.length <= 1) return
|
|
815
|
-
try {
|
|
816
|
-
const title = autoTitle(state.messages)
|
|
817
|
-
const sessionId = state.sessionId
|
|
818
|
-
const snapshot = JSON.parse(JSON.stringify({
|
|
819
|
-
version: 1,
|
|
820
|
-
sessionId: state.sessionId,
|
|
821
|
-
title,
|
|
822
|
-
source: 'cli',
|
|
823
|
-
cwd: state.ctx.cwd,
|
|
824
|
-
model: modelLabel(state.model),
|
|
825
|
-
createdAt: state.sessionCreatedAt,
|
|
826
|
-
updatedAt: new Date().toISOString(),
|
|
827
|
-
messages: repairToolHistory(state.messages),
|
|
828
|
-
lastTurnStats: state.lastTurnStats,
|
|
829
|
-
sessionStats: state.sessionStats,
|
|
830
|
-
}))
|
|
831
|
-
saving = saving.catch(() => {}).then(() => writeSession(sessionId, snapshot))
|
|
832
|
-
await saving
|
|
833
|
-
enforceRetention().catch(() => {})
|
|
834
|
-
} catch (err) {
|
|
835
|
-
// 持久化失败不影响 CLI 正常使用
|
|
836
|
-
printDim(`(会话保存失败: ${err.message})`)
|
|
837
|
-
}
|
|
838
|
-
}
|
|
839
|
-
state.persistSession = persistSession
|
|
840
|
-
|
|
841
|
-
// SIGINT 时中止进行中的 LLM 请求 + 正在跑的子命令
|
|
842
|
-
// (进程退出与全局子进程 drain 由 gitCommit.js 的统一 handler 负责,这里只做快速止血)
|
|
843
|
-
registerCleanup('aiAgent', async () => {
|
|
844
|
-
state.cancelRequested = true
|
|
845
|
-
try { state.abortController?.abort() } catch (_) {}
|
|
846
|
-
await terminateCommand(state.currentChild)
|
|
847
|
-
// Ctrl+C 硬退时终端仍停在 bracketed paste 模式 → 之后 shell 里粘贴会带出 200~/201~ 乱码。
|
|
848
|
-
// 这个开关是终端层的,进程死了没人替它关,必须在这里补一刀。
|
|
849
|
-
disableBracketedPaste()
|
|
850
|
-
// MCP 子进程必须显式收掉:它们挂在 stdio 上,不收会让进程无法自然退出
|
|
851
|
-
await extensions.close()
|
|
852
|
-
await persistSession()
|
|
853
|
-
})
|
|
854
|
-
|
|
855
|
-
// 扩展摘要:一条 dim 行,没有装扩展时完全静默(extNone 为空串)
|
|
856
|
-
const extSummary = extensions.skills.length || extensions.tools.length
|
|
857
|
-
? t.extLoaded(extensions.skills.length, extensions.tools.length)
|
|
858
|
-
: t.extNone
|
|
859
|
-
|
|
860
|
-
// 单发模式:g ai "帮我改个 bug"(位置参数拼成 prompt,跑一轮就退出)
|
|
861
|
-
const oneShot = argv.filter(a => !a.startsWith('-')).join(' ').trim()
|
|
862
|
-
if (oneShot) {
|
|
863
|
-
console.log(chalk.dim(`[${t.bannerModel}] ${modelLabel(model)} · [${t.bannerCwd}] ${cwd}`))
|
|
864
|
-
if (extSummary) printDim(extSummary)
|
|
865
|
-
state.abortController = new AbortController()
|
|
866
|
-
const stats = await runAgentTurn(state, oneShot, t)
|
|
867
|
-
if (stats.status === 'completed') printDim(t.oneShotDone)
|
|
868
|
-
else process.exitCode = stats.status === 'cancelled' ? 130 : 1
|
|
869
|
-
return
|
|
870
|
-
}
|
|
871
|
-
|
|
872
|
-
// ── 交互模式(REPL)──
|
|
873
|
-
printBanner({
|
|
874
|
-
title: 'Zen GitSync — AI Agent',
|
|
875
|
-
modelLabel: modelLabel(model),
|
|
876
|
-
baseURL: model.baseURL || '',
|
|
877
|
-
cwd,
|
|
878
|
-
modelText: t.bannerModel,
|
|
879
|
-
cwdText: t.bannerCwd,
|
|
880
|
-
tip: t.bannerTip,
|
|
881
|
-
})
|
|
882
|
-
if (extSummary) printDim(extSummary)
|
|
883
|
-
|
|
884
|
-
const rl = readline.createInterface({
|
|
885
|
-
input: process.stdin,
|
|
886
|
-
output: process.stdout,
|
|
887
|
-
// 颜色闭合,避免提示符的样式泄漏到后续输出。
|
|
888
|
-
prompt: chalk.cyan.bold(t.prompt + ' '),
|
|
889
|
-
historySize: 200,
|
|
890
|
-
})
|
|
891
|
-
state.rl = rl // 供 /addmodel 等需要 rl.question 的斜杠命令复用
|
|
892
|
-
|
|
893
|
-
// 多行粘贴:终端里粘一整段文本时,readline 会把每个 \n 当 Enter —— 一次粘贴变成多次提交,
|
|
894
|
-
// 第 2 条起还会撞上下面的 busy 分支被丢掉。这里在数据进 readline 之前把整段捕获成占位符,
|
|
895
|
-
// 提交时再还原(详见 paste.js)。必须在处理 line 事件之前装好。
|
|
896
|
-
const pasteCapture = installPasteCapture(rl, {
|
|
897
|
-
formatToken: ({ index, lines }) => t.pasteToken(index, lines),
|
|
898
|
-
})
|
|
899
|
-
|
|
900
|
-
// ask_user pauses the tool loop inside the same readline session. The REPL
|
|
901
|
-
// line handler is bypassed while this small wizard owns the input line.
|
|
902
|
-
state.ctx.askUser = ({ question, options = [], allowFreeText = true, multiple = false }) => {
|
|
903
|
-
const zh = !String(state.locale || '').startsWith('en')
|
|
904
|
-
const safeOptions = Array.isArray(options) ? options.filter(Boolean).slice(0, 20) : []
|
|
905
|
-
// 多选只在真的给了选项时才有意义(与 tools.js 的归一化口径一致)
|
|
906
|
-
const multi = multiple === true && safeOptions.length > 0
|
|
907
|
-
state.inWizard = true
|
|
908
|
-
if (safeOptions.length > 0) {
|
|
909
|
-
console.log(chalk.cyan(`\n${question}`))
|
|
910
|
-
safeOptions.forEach((option, index) => console.log(` ${chalk.cyan(String(index + 1))}. ${option}`))
|
|
911
|
-
} else {
|
|
912
|
-
console.log(chalk.cyan(`\n${question}`))
|
|
913
|
-
}
|
|
914
|
-
|
|
915
|
-
return new Promise(resolve => {
|
|
916
|
-
let settled = false
|
|
917
|
-
const questionController = new AbortController()
|
|
918
|
-
const parentSignal = state.abortController?.signal
|
|
919
|
-
const cleanup = () => {
|
|
920
|
-
rl.removeListener?.('close', onClose)
|
|
921
|
-
rl.removeListener?.('operationCancel', onCancel)
|
|
922
|
-
rl.input?.removeListener?.('keypress', onKeypressCancel)
|
|
923
|
-
parentSignal?.removeEventListener?.('abort', onParentAbort)
|
|
924
|
-
}
|
|
925
|
-
const finish = answer => {
|
|
926
|
-
if (settled) return
|
|
927
|
-
settled = true
|
|
928
|
-
cleanup()
|
|
929
|
-
state.inWizard = false
|
|
930
|
-
resolve(answer)
|
|
931
|
-
}
|
|
932
|
-
const cancel = () => {
|
|
933
|
-
if (settled) return
|
|
934
|
-
questionController.abort()
|
|
935
|
-
finish('Cancelled by user; the question was not answered.')
|
|
936
|
-
}
|
|
937
|
-
const onClose = () => cancel()
|
|
938
|
-
const onCancel = () => {
|
|
939
|
-
state.cancelRequested = true
|
|
940
|
-
try { state.abortController?.abort() } catch (_) {}
|
|
941
|
-
cancel()
|
|
942
|
-
}
|
|
943
|
-
const onParentAbort = () => cancel()
|
|
944
|
-
const onKeypressCancel = (_str, key) => {
|
|
945
|
-
if (!key?.ctrl || key.name !== 'c') return
|
|
946
|
-
key.name = 'escape'
|
|
947
|
-
key.sequence = ''
|
|
948
|
-
key.ctrl = false
|
|
949
|
-
key.meta = false
|
|
950
|
-
key.shift = false
|
|
951
|
-
onCancel()
|
|
952
|
-
}
|
|
953
|
-
|
|
954
|
-
rl.once('close', onClose)
|
|
955
|
-
rl.once('operationCancel', onCancel)
|
|
956
|
-
rl.input?.prependListener?.('keypress', onKeypressCancel)
|
|
957
|
-
parentSignal?.addEventListener?.('abort', onParentAbort, { once: true })
|
|
958
|
-
|
|
959
|
-
const promptAnswer = () => {
|
|
960
|
-
const hint = safeOptions.length > 0
|
|
961
|
-
? (multi
|
|
962
|
-
? (zh ? '请输入序号(逗号分隔可多选)' : 'Choose numbers, comma-separated for multiple')
|
|
963
|
-
: allowFreeText ? (zh ? '请输入序号或直接输入回答' : 'Choose a number or type an answer') : (zh ? '请输入序号' : 'Choose a number'))
|
|
964
|
-
: (zh ? '请输入回答' : 'Type your answer')
|
|
965
|
-
rl.question(`${hint}: `, { signal: questionController.signal }, answer => {
|
|
966
|
-
if (settled) return
|
|
967
|
-
const trimmed = String(answer || '').trim()
|
|
968
|
-
if (multi) {
|
|
969
|
-
// 逗号分隔的序号 → 选项列表(JSON 数组字符串回给模型,与 Web 端口径一致);
|
|
970
|
-
// 整行不是合法序号串时,允许自由输入的话就把它当作唯一一项
|
|
971
|
-
const parts = trimmed.split(/[,,]/).map(part => part.trim()).filter(Boolean)
|
|
972
|
-
const indexes = parts.map(part => Number.parseInt(part, 10))
|
|
973
|
-
const allIndexes = parts.length > 0 && indexes.every((n, i) => Number.isInteger(n) && String(n) === parts[i] && n >= 1 && n <= safeOptions.length)
|
|
974
|
-
if (allIndexes) {
|
|
975
|
-
finish(JSON.stringify(indexes.map(n => safeOptions[n - 1])))
|
|
976
|
-
return
|
|
977
|
-
}
|
|
978
|
-
if (allowFreeText && trimmed) {
|
|
979
|
-
finish(JSON.stringify([trimmed]))
|
|
980
|
-
return
|
|
981
|
-
}
|
|
982
|
-
console.log(chalk.yellow(zh ? `请输入 1-${safeOptions.length} 之间的序号,多个用逗号分隔` : `Enter numbers from 1 to ${safeOptions.length}, comma-separated`))
|
|
983
|
-
promptAnswer()
|
|
984
|
-
return
|
|
985
|
-
}
|
|
986
|
-
if (safeOptions.length > 0) {
|
|
987
|
-
const index = Number.parseInt(trimmed, 10)
|
|
988
|
-
if (Number.isInteger(index) && index >= 1 && index <= safeOptions.length) {
|
|
989
|
-
finish(safeOptions[index - 1])
|
|
990
|
-
return
|
|
991
|
-
}
|
|
992
|
-
if (!allowFreeText || !trimmed) {
|
|
993
|
-
console.log(chalk.yellow(zh ? `请输入 1-${safeOptions.length} 之间的序号` : `Enter a number from 1 to ${safeOptions.length}`))
|
|
994
|
-
promptAnswer()
|
|
995
|
-
return
|
|
996
|
-
}
|
|
997
|
-
}
|
|
998
|
-
if (!trimmed) {
|
|
999
|
-
console.log(chalk.yellow(zh ? '回答不能为空' : 'Answer cannot be empty'))
|
|
1000
|
-
promptAnswer()
|
|
1001
|
-
return
|
|
1002
|
-
}
|
|
1003
|
-
finish(trimmed)
|
|
1004
|
-
})
|
|
1005
|
-
}
|
|
1006
|
-
promptAnswer()
|
|
1007
|
-
})
|
|
1008
|
-
}
|
|
1009
|
-
|
|
1010
|
-
// 输入提示符管理。
|
|
1011
|
-
//
|
|
1012
|
-
// readline 的 _refreshLine() 用 prevRows 追踪“提示符占了几行”,
|
|
1013
|
-
// 下次刷新时 moveCursor(0, -prevRows) 上移光标。但模型输出直接写
|
|
1014
|
-
// stdout,readline 不知道这些行,导致 prevRows 过期 → 光标错位。
|
|
1015
|
-
//
|
|
1016
|
-
// 修复:每次画提示符前,先写 \r\n 确保光标在新行第 0 列,
|
|
1017
|
-
// 然后重置 prevRows=0 告诉 readline “当前行就是提示符行”。
|
|
1018
|
-
// 这样 _refreshLine() 的 clearScreenDown() 只清当前空行,不影响模型输出。
|
|
1019
|
-
|
|
1020
|
-
const showPrompt = () => {
|
|
1021
|
-
if (rl.closed) return
|
|
1022
|
-
// 双保险:确保 stdin 处于 flowing 模式(ora 的 stdin-discarder 可能在
|
|
1023
|
-
// stop() 时 pause 了 stdin,即使 discardStdin:false 已设,仍防止其他
|
|
1024
|
-
// 库意外 pause)
|
|
1025
|
-
if (process.stdin.isTTY && !process.stdin.readableFlowing) {
|
|
1026
|
-
process.stdin.resume()
|
|
1027
|
-
}
|
|
1028
|
-
// bracketed paste 是终端层的开关,跑过的外部命令(全屏 TUI 之类)退出时可能把它关掉,
|
|
1029
|
-
// 之后粘贴又会退回"每个 \n 一次提交"。它是幂等的,每次画提示符时重申一次。
|
|
1030
|
-
enableBracketedPaste()
|
|
1031
|
-
if (process.stdout.isTTY) {
|
|
1032
|
-
// 确保光标在新行第 0 列(\r 回行首,\n 换行)
|
|
1033
|
-
process.stdout.write('\r\n')
|
|
1034
|
-
// 重置 readline 内部光标追踪
|
|
1035
|
-
rl.prevRows = 0
|
|
1036
|
-
}
|
|
1037
|
-
rl.prompt()
|
|
1038
|
-
}
|
|
1039
|
-
|
|
1040
|
-
// 安全版:防止 finally 块里的异常变成 unhandledRejection 杀掉会话
|
|
1041
|
-
const safeShowPrompt = () => {
|
|
1042
|
-
try {
|
|
1043
|
-
showPrompt()
|
|
1044
|
-
} catch (_) {
|
|
1045
|
-
try { rl.prompt() } catch { /* 给予宽容 */ }
|
|
1046
|
-
}
|
|
1047
|
-
}
|
|
1048
|
-
|
|
1049
|
-
// 仅重画提示符,不写 \r\n(用于空输入、busy 等不需要换行的场景)
|
|
1050
|
-
const safeRefreshPrompt = () => {
|
|
1051
|
-
try {
|
|
1052
|
-
if (rl.closed) return
|
|
1053
|
-
enableBracketedPaste()
|
|
1054
|
-
rl.prevRows = 0
|
|
1055
|
-
rl.prompt()
|
|
1056
|
-
} catch (_) {
|
|
1057
|
-
try { rl.prompt() } catch { /* noop */ }
|
|
1058
|
-
}
|
|
1059
|
-
}
|
|
1060
|
-
|
|
1061
|
-
// 在提示符上方插一行通知(粘贴进度等)。
|
|
1062
|
-
const notifyAbovePrompt = (text) => {
|
|
1063
|
-
console.log(text)
|
|
1064
|
-
if (!state.busy) safeShowPrompt()
|
|
1065
|
-
}
|
|
1066
|
-
|
|
1067
|
-
// ── 斜杠命令即时提示(支持 ↑↓ 切换 / Tab、Enter 补全) ──
|
|
1068
|
-
// 在提示符下方浮现匹配到的命令,随输入过滤;不移动光标(用 DECSC/DECRC 保存-恢复)。
|
|
1069
|
-
// slashHintRows 记录当前提示占了几行,下次刷新/提交时据此擦除,避免残影。
|
|
1070
|
-
// slashHintBaseLine:本次 hint 显示时"输入行的基线值" — ↑↓ 时 readline 已经把
|
|
1071
|
-
// rl.line 改成历史上一行,我们用基线回滚 + _refreshLine() 重绘,让用户视觉上看
|
|
1072
|
-
// 不到"按 ↑ 输入行变了",只看到 hint 高亮跳了一格。
|
|
1073
|
-
let slashHintRows = 0
|
|
1074
|
-
let slashHintBaseLine = ''
|
|
1075
|
-
let slashHintMatches = []
|
|
1076
|
-
let slashHintIndex = 0
|
|
1077
|
-
const eraseSlashHint = () => {
|
|
1078
|
-
if (!process.stdout.isTTY || slashHintRows === 0) return
|
|
1079
|
-
// 保存光标 → 下移到提示区逐行清空 → 恢复光标
|
|
1080
|
-
let seq = '\x1b7'
|
|
1081
|
-
for (let i = 0; i < slashHintRows; i++) seq += '\x1b[1B\x1b[2K'
|
|
1082
|
-
seq += '\x1b8'
|
|
1083
|
-
process.stdout.write(seq)
|
|
1084
|
-
slashHintRows = 0
|
|
1085
|
-
}
|
|
1086
|
-
const drawSlashHint = () => {
|
|
1087
|
-
const body = renderSlashHintBody(slashHintMatches, slashHintIndex, state.locale)
|
|
1088
|
-
const hadVisibleHint = slashHintRows > 0
|
|
1089
|
-
eraseSlashHint()
|
|
1090
|
-
if (!body) return
|
|
1091
|
-
const rows = body.split('\n')
|
|
1092
|
-
if (!hadVisibleHint) {
|
|
1093
|
-
// CSI B(光标下移)到达终端底部后只会停在最后一行,不会自动滚屏,
|
|
1094
|
-
// 所以候选项会被裁掉。第一次显示提示时先用换行真实预留空间,
|
|
1095
|
-
// 再上移相同行数回到输入行;无论中途是否发生滚屏,都能回到原内容所在行。
|
|
1096
|
-
let reserve = ''
|
|
1097
|
-
for (let i = 0; i < rows.length; i++) reserve += '\n\x1b[2K'
|
|
1098
|
-
reserve += `\x1b[${rows.length}A`
|
|
1099
|
-
process.stdout.write(reserve)
|
|
1100
|
-
}
|
|
1101
|
-
// 保存光标,逐行下移打印(每行先清行防止与旧内容叠字),最后恢复光标回输入行
|
|
1102
|
-
let seq = '\x1b7'
|
|
1103
|
-
for (const r of rows) seq += '\x1b[1B\x1b[2K\r' + r
|
|
1104
|
-
seq += '\x1b8'
|
|
1105
|
-
process.stdout.write(seq)
|
|
1106
|
-
slashHintRows = rows.length
|
|
1107
|
-
}
|
|
1108
|
-
// 重置 hint 上下文:重新计算匹配 + 重画。输入字符变化时调用。
|
|
1109
|
-
const refreshSlashHint = () => {
|
|
1110
|
-
if (!process.stdout.isTTY || state.busy || state.inWizard) {
|
|
1111
|
-
eraseSlashHint()
|
|
1112
|
-
slashHintMatches = []
|
|
1113
|
-
slashHintIndex = 0
|
|
1114
|
-
slashHintBaseLine = ''
|
|
1115
|
-
return
|
|
1116
|
-
}
|
|
1117
|
-
slashHintBaseLine = rl.line
|
|
1118
|
-
slashHintMatches = filterSlashCommands(rl.line, state.locale)
|
|
1119
|
-
// 匹配列表缩短时夹紧 index(增删交替时不会越界)
|
|
1120
|
-
slashHintIndex = slashHintIndex < slashHintMatches.length ? slashHintIndex : 0
|
|
1121
|
-
drawSlashHint()
|
|
1122
|
-
}
|
|
1123
|
-
// ↑↓ / Shift+Tab:readline 已经把 rl.line 改成历史行或没动 → 强制恢复基线并重绘输入行
|
|
1124
|
-
const restoreInputLineToBaseline = () => {
|
|
1125
|
-
if (rl.line === slashHintBaseLine) return
|
|
1126
|
-
rl.line = slashHintBaseLine
|
|
1127
|
-
rl.cursor = slashHintBaseLine.length
|
|
1128
|
-
if (typeof rl._refreshLine === 'function') rl._refreshLine()
|
|
1129
|
-
}
|
|
1130
|
-
// Tab / Enter:把 rl.line 替换为补全文本 + 清掉 hint 状态。
|
|
1131
|
-
// Enter 会先被 readline 提交,因此由下方 line handler 在处理命令前调用本函数。
|
|
1132
|
-
const applyHintCompletion = (newLine) => {
|
|
1133
|
-
rl.line = newLine
|
|
1134
|
-
rl.cursor = newLine.length
|
|
1135
|
-
eraseSlashHint()
|
|
1136
|
-
slashHintMatches = []
|
|
1137
|
-
slashHintIndex = 0
|
|
1138
|
-
slashHintRows = 0
|
|
1139
|
-
slashHintBaseLine = newLine
|
|
1140
|
-
if (typeof rl._refreshLine === 'function') rl._refreshLine()
|
|
1141
|
-
}
|
|
1142
|
-
|
|
1143
|
-
const neutralizeKeyForReadline = (key) => {
|
|
1144
|
-
key.name = 'escape'
|
|
1145
|
-
key.sequence = ''
|
|
1146
|
-
key.ctrl = false
|
|
1147
|
-
key.meta = false
|
|
1148
|
-
key.shift = false
|
|
1149
|
-
}
|
|
1150
|
-
|
|
1151
|
-
// readline 自己会优先处理 Enter/Ctrl+C。用 prependListener 在它之前完成补全
|
|
1152
|
-
// 或取消当前操作,再把本次按键变成 readline 会忽略的 Esc。
|
|
1153
|
-
const interceptInteractiveControlKeys = (_ch, key) => {
|
|
1154
|
-
if (!key) return
|
|
1155
|
-
if (key.ctrl && key.name === 'c') {
|
|
1156
|
-
if (state.inWizard) {
|
|
1157
|
-
neutralizeKeyForReadline(key)
|
|
1158
|
-
rl.emit('operationCancel')
|
|
1159
|
-
return
|
|
1160
|
-
}
|
|
1161
|
-
if (state.busy) {
|
|
1162
|
-
neutralizeKeyForReadline(key)
|
|
1163
|
-
state.cancelRequested = true
|
|
1164
|
-
try { state.abortController?.abort() } catch (_) {}
|
|
1165
|
-
void terminateCommand(state.currentChild)
|
|
1166
|
-
return
|
|
1167
|
-
}
|
|
1168
|
-
if (slashHintRows > 0) {
|
|
1169
|
-
neutralizeKeyForReadline(key)
|
|
1170
|
-
applyHintCompletion('')
|
|
1171
|
-
return
|
|
1172
|
-
}
|
|
1173
|
-
return
|
|
1174
|
-
}
|
|
1175
|
-
if (key.name !== 'return' && key.name !== 'enter') return
|
|
1176
|
-
if (slashHintRows === 0 || slashHintMatches.length === 0 || state.busy || state.inWizard) return
|
|
1177
|
-
const selected = slashHintMatches[slashHintIndex]
|
|
1178
|
-
if (!selected) return
|
|
1179
|
-
applyHintCompletion(selected.cmd + ' ')
|
|
1180
|
-
neutralizeKeyForReadline(key)
|
|
1181
|
-
}
|
|
1182
|
-
rl.input.prependListener('keypress', interceptInteractiveControlKeys)
|
|
1183
|
-
|
|
1184
|
-
// Alt+V 粘贴剪贴板图片(node 把 ESC+v 解析为 meta+v;部分终端不发 Alt,可用 /image 兜底)
|
|
1185
|
-
const pasteFromClipboard = async () => {
|
|
1186
|
-
if (state.pasting) return
|
|
1187
|
-
state.pasting = true
|
|
1188
|
-
try {
|
|
1189
|
-
notifyAbovePrompt(chalk.dim(t.imagePasting))
|
|
1190
|
-
const img = await readClipboardImage()
|
|
1191
|
-
if (!img) {
|
|
1192
|
-
notifyAbovePrompt(chalk.yellow(t.imageEmpty))
|
|
1193
|
-
} else {
|
|
1194
|
-
state.pendingImages.push(img)
|
|
1195
|
-
notifyAbovePrompt(chalk.green(t.imageAttached(state.pendingImages.length, formatBytes(img.bytes))))
|
|
1196
|
-
}
|
|
1197
|
-
} catch (err) {
|
|
1198
|
-
// 不能让粘贴异常变成 unhandledRejection 杀掉会话
|
|
1199
|
-
printError(`粘贴图片失败: ${err.message}`)
|
|
1200
|
-
} finally {
|
|
1201
|
-
state.pasting = false
|
|
1202
|
-
}
|
|
1203
|
-
}
|
|
1204
|
-
rl.input.on('keypress', (ch, key) => {
|
|
1205
|
-
if (!key) return
|
|
1206
|
-
|
|
1207
|
-
// ── 斜杠命令提示专用键盘:↑↓/Tab/Enter/Shift+Tab/Esc ──
|
|
1208
|
-
// 仅在 hint 已显示且有匹配项时拦截,readline 的 default 行为(历史浏览等)
|
|
1209
|
-
// 我们在 handler 里事后回滚 rl.line 来抵消
|
|
1210
|
-
const action = parseKeyForSlashHint(key)
|
|
1211
|
-
const hintActive = action === 'prev' || action === 'next' || action === 'complete' || action === 'cancel'
|
|
1212
|
-
? (slashHintRows > 0 && slashHintMatches.length > 0 && !state.busy && !state.inWizard)
|
|
1213
|
-
: false
|
|
1214
|
-
|
|
1215
|
-
if (hintActive) {
|
|
1216
|
-
if (action === 'prev' || action === 'next') {
|
|
1217
|
-
const n = slashHintMatches.length
|
|
1218
|
-
slashHintIndex = action === 'prev'
|
|
1219
|
-
? (slashHintIndex - 1 + n) % n
|
|
1220
|
-
: (slashHintIndex + 1) % n
|
|
1221
|
-
restoreInputLineToBaseline()
|
|
1222
|
-
drawSlashHint()
|
|
1223
|
-
return // 不再走 default update + Alt+V 分支
|
|
1224
|
-
}
|
|
1225
|
-
if (action === 'complete') {
|
|
1226
|
-
// Tab:把输入行替换为选中命令 + 末尾空格(便于继续打参数),
|
|
1227
|
-
// 光标跳到末尾;之后不再触发 hint(已经精确选了某个命令)
|
|
1228
|
-
const sel = slashHintMatches[slashHintIndex]
|
|
1229
|
-
const newLine = sel.cmd + ' '
|
|
1230
|
-
applyHintCompletion(newLine)
|
|
1231
|
-
return
|
|
1232
|
-
}
|
|
1233
|
-
if (action === 'cancel') {
|
|
1234
|
-
// Esc:只清 hint,不动输入行
|
|
1235
|
-
eraseSlashHint()
|
|
1236
|
-
slashHintMatches = []
|
|
1237
|
-
slashHintIndex = 0
|
|
1238
|
-
return
|
|
1239
|
-
}
|
|
1240
|
-
}
|
|
1241
|
-
|
|
1242
|
-
// Enter 走默认 readline 提交,line 事件里把选中项恢复到输入框;
|
|
1243
|
-
// 其他普通按键:重算匹配 + 重画 hint
|
|
1244
|
-
if (!(key.name === 'return' || key.name === 'enter')) {
|
|
1245
|
-
refreshSlashHint()
|
|
1246
|
-
}
|
|
1247
|
-
|
|
1248
|
-
// Alt+V 粘贴剪贴板图片(node 把 ESC+v 解析为 meta+v;部分终端不发 Alt,可用 /image 兜底)
|
|
1249
|
-
const isAltV = (key.meta && key.name === 'v')
|
|
1250
|
-
|| (typeof key.sequence === 'string' && key.sequence.length === 2
|
|
1251
|
-
&& key.sequence.charCodeAt(0) === 27 && key.sequence[1] === 'v')
|
|
1252
|
-
if (isAltV) pasteFromClipboard()
|
|
1253
|
-
})
|
|
1254
|
-
|
|
1255
|
-
// 初始提示符:banner 后直接画(不需要 \r\n)
|
|
1256
|
-
rl.prevRows = 0
|
|
1257
|
-
rl.prompt()
|
|
1258
|
-
|
|
1259
|
-
rl.on('line', async (line) => {
|
|
1260
|
-
// 提交时光标已落到提示符下一行(即提示区起点),直接清到屏幕末尾擦掉整块提示,
|
|
1261
|
-
// 比 DECSC/DECRC 相对定位更稳(此时光标不在输入行)
|
|
1262
|
-
if (process.stdout.isTTY && slashHintRows > 0) {
|
|
1263
|
-
process.stdout.write('\x1b[0J')
|
|
1264
|
-
slashHintRows = 0
|
|
1265
|
-
}
|
|
1266
|
-
// /addmodel 等交互式向导进行中时,用户的回答由向导自身的 rl.question 处理,
|
|
1267
|
-
// 不应进入 REPL 的正常输入流程(否则会把向导的答案当成命令/消息发给模型)
|
|
1268
|
-
if (state.inWizard) return
|
|
1269
|
-
// 把输入行里的粘贴占位符还原成原文(带真实换行)—— 在判断是不是斜杠命令之前,
|
|
1270
|
-
// 这样 `请写进 .env [粘贴 #1]` 这种混排也能正确展开
|
|
1271
|
-
const pasted = pasteCapture.expand(line)
|
|
1272
|
-
if (pasted.stale.length > 0) printWarn(t.pasteExpired(pasted.stale.length))
|
|
1273
|
-
const input = pasted.text.trim()
|
|
1274
|
-
|
|
1275
|
-
if (input.startsWith('/')) {
|
|
1276
|
-
const r = await handleSlashCommand(state, input, t)
|
|
1277
|
-
if (r === 'exit') { rl.close(); return }
|
|
1278
|
-
safeRefreshPrompt()
|
|
1279
|
-
return
|
|
1280
|
-
}
|
|
1281
|
-
|
|
1282
|
-
if (!input) { safeRefreshPrompt(); return }
|
|
1283
|
-
if (state.busy) {
|
|
1284
|
-
printDim(t.busy)
|
|
1285
|
-
safeRefreshPrompt()
|
|
1286
|
-
return
|
|
1287
|
-
}
|
|
1288
|
-
|
|
1289
|
-
// 取出待发送图片(取出即清空队列,用户每条消息独立决定带不带图)
|
|
1290
|
-
const images = state.pendingImages.splice(0)
|
|
1291
|
-
if (images.length > 0) printDim(t.imageSending(images.length))
|
|
1292
|
-
|
|
1293
|
-
// 用了粘贴的这条消息:输入行里只有占位符,这里把真正发出去的内容亮一遍,
|
|
1294
|
-
// 否则回看终端记录只剩一个 [粘贴 #N],等于把内容藏起来了。
|
|
1295
|
-
// 只在 TTY 里回显 —— 管道/CI 下 readline 本来就不回显输入行(没有占位符这回事),
|
|
1296
|
-
// 再补一块只会粘在提示符后面变成半行。
|
|
1297
|
-
if (pasted.used > 0 && process.stdout.isTTY) process.stdout.write(renderPasteEcho(input, { locale: state.locale }))
|
|
1298
|
-
|
|
1299
|
-
state.busy = true
|
|
1300
|
-
state.cancelRequested = false
|
|
1301
|
-
state.abortController = new AbortController()
|
|
1302
|
-
try {
|
|
1303
|
-
await runAgentTurn(state, input, t, images)
|
|
1304
|
-
} catch (err) {
|
|
1305
|
-
if (state.cancelRequested) printDim(t.operationCancelled)
|
|
1306
|
-
else printError(err.message)
|
|
1307
|
-
} finally {
|
|
1308
|
-
state.busy = false
|
|
1309
|
-
state.cancelRequested = false
|
|
1310
|
-
state.abortController = null
|
|
1311
|
-
safeShowPrompt()
|
|
1312
|
-
}
|
|
1313
|
-
})
|
|
1314
|
-
|
|
1315
|
-
rl.on('close', () => {
|
|
1316
|
-
if (state.busy) {
|
|
1317
|
-
state.cancelRequested = true
|
|
1318
|
-
state.abortController?.abort()
|
|
1319
|
-
void terminateCommand(state.currentChild)
|
|
1320
|
-
}
|
|
1321
|
-
rl.input.removeListener('keypress', interceptInteractiveControlKeys)
|
|
1322
|
-
pasteCapture.dispose() // 卸载数据改写 + 把终端从 bracketed paste 模式里放出来
|
|
1323
|
-
printWarn('\n' + t.bye)
|
|
1324
|
-
})
|
|
1325
|
-
|
|
1326
|
-
// 全局安全网:任何未捕获的 Promise rejection / 异常都不应该静默杀掉
|
|
1327
|
-
// 整个 REPL 会话。这里记录到 stderr 但不退出,让 readline 继续运行。
|
|
1328
|
-
const rejectionHandler = (reason) => {
|
|
1329
|
-
const msg = reason instanceof Error ? reason.message : String(reason)
|
|
1330
|
-
printError(`内部错误(已捕获,会话继续): ${msg}`)
|
|
1331
|
-
safeShowPrompt()
|
|
1332
|
-
}
|
|
1333
|
-
process.on('unhandledRejection', rejectionHandler)
|
|
1334
|
-
|
|
1335
|
-
// 等 readline 关闭后返回(进程随后自然退出)
|
|
1336
|
-
await new Promise((resolve) => rl.on('close', resolve))
|
|
1337
|
-
|
|
1338
|
-
// 清理:移除本次会话注册的全局处理器
|
|
1339
|
-
process.removeListener('unhandledRejection', rejectionHandler)
|
|
1340
|
-
}
|
|
1341
|
-
|
|
1342
|
-
export default { runAiAgent }
|
|
1
|
+
// Copyright 2026 xz333221
|
|
2
|
+
//
|
|
3
|
+
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
// you may not use this file except in compliance with the License.
|
|
5
|
+
// You may obtain a copy of the License at
|
|
6
|
+
//
|
|
7
|
+
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
//
|
|
9
|
+
// Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
// See the License for the specific language governing permissions and
|
|
13
|
+
// limitations under the License.
|
|
14
|
+
//
|
|
15
|
+
// g ai — 终端交互式 AI 编码智能体。
|
|
16
|
+
//
|
|
17
|
+
// 入口: runAiAgent(argv)(由 gitCommit.js 在 argv[2]==='ai' 时动态 import)。
|
|
18
|
+
//
|
|
19
|
+
// 能力:
|
|
20
|
+
// - 默认使用 g ui 里配置的模型(~/.zen-gitsync/config.json 顶层 models,
|
|
21
|
+
// isDefault 优先,否则取第一个);--model=<序号|名称> 或会话内 /model 切换
|
|
22
|
+
// - OpenAI 兼容流式 function calling:模型可以调用 run_command /
|
|
23
|
+
// read_file / write_file / edit_file / list_files / search_text / ask_user
|
|
24
|
+
// - 图片:Alt+V 粘贴剪贴板图片、/image <路径> 附加本地图片,
|
|
25
|
+
// 以多模态(image_url)消息发给模型(需模型支持视觉)
|
|
26
|
+
// - 权限:启动目录内全开放,其他目录可读写;唯一红线由 safety.js 拦截
|
|
27
|
+
// (格式化磁盘、删根目录、关机等系统级破坏操作)
|
|
28
|
+
//
|
|
29
|
+
// 视觉风格(termui.js):对标 Codex CLI / Claude Code / OpenCode —
|
|
30
|
+
// 盒式输入框、ora 等待 spinner、✻ 思考(橙黄斜体)、⚙ 工具头(青) + │ 结果槽、
|
|
31
|
+
// 正文 ➤ 子弹头(绿)+ 逐行轻量 markdown(**bold** / `code` / # 标题 / ``` 围栏 / - 列表)
|
|
32
|
+
//
|
|
33
|
+
// 使用方式:
|
|
34
|
+
// g ai 交互模式(REPL)
|
|
35
|
+
// g ai "帮我修个 bug" 单发模式:执行一轮后退出
|
|
36
|
+
// g ai --model=2 指定配置里的第 2 个模型
|
|
37
|
+
//
|
|
38
|
+
// 实现注意:
|
|
39
|
+
// - 本模块属于 CLI 侧,不依赖 GUI 服务器代码(src/ui/server/**)
|
|
40
|
+
// - Ctrl+C 执行中只取消当前任务;空闲时交给主 CLI 清理并退出
|
|
41
|
+
|
|
42
|
+
import readline from 'node:readline'
|
|
43
|
+
import chalk from 'chalk'
|
|
44
|
+
import config from '../../config.js'
|
|
45
|
+
import { getCwd } from '../../utils/index.js'
|
|
46
|
+
import { registerCleanup } from '../cleanup.js'
|
|
47
|
+
import { terminateCommand, TOOL_DEFINITIONS } from './tools.js'
|
|
48
|
+
import { runAgentTurn } from './turn.js'
|
|
49
|
+
import { AgentExtensions } from './extensions.js'
|
|
50
|
+
import { repairToolHistory, loadProjectInstructions, resolveRequestBudget } from './context.js'
|
|
51
|
+
import {
|
|
52
|
+
printBanner, printHelpPanel,
|
|
53
|
+
filterSlashCommands, renderSlashHintBody, parseKeyForSlashHint,
|
|
54
|
+
printTurnSummary, renderPasteEcho,
|
|
55
|
+
printOk, printWarn, printError, printDim,
|
|
56
|
+
} from './termui.js'
|
|
57
|
+
import { readClipboardImage, checkImageFile, formatBytes } from './images.js'
|
|
58
|
+
import { installPasteCapture, enableBracketedPaste, disableBracketedPaste } from './paste.js'
|
|
59
|
+
import { runModelSetup, collectModelInput, buildModelConfig, selectFromList } from './modelSetup.js'
|
|
60
|
+
import { genSessionId, autoTitle, writeSession, enforceRetention, listSessions } from './sessionStore.js'
|
|
61
|
+
|
|
62
|
+
// truncateDisplay 已迁移到 termui.js;这里 re-export 保持既有测试/外部引用不断
|
|
63
|
+
export { truncateDisplay } from './termui.js'
|
|
64
|
+
|
|
65
|
+
// ──────────────────────────────────────────────
|
|
66
|
+
// i18n 字符串表(CLI 其他部分以中文为主,这里按 locale 给双语)
|
|
67
|
+
// ──────────────────────────────────────────────
|
|
68
|
+
const STRINGS = {
|
|
69
|
+
zh: {
|
|
70
|
+
noModel: '未配置 AI 模型,请先在 GUI 通用设置中添加模型(运行 g ui 打开设置)',
|
|
71
|
+
unknownModel: (q) => `未找到匹配的模型: ${q},使用默认模型`,
|
|
72
|
+
waiting: '思考中…',
|
|
73
|
+
llmError: (msg) => `LLM 请求失败: ${msg}`,
|
|
74
|
+
toolIterLimit: (n) => `已达单轮最大工具调用次数(${n}),本轮结束。如需继续请再发一条消息。`,
|
|
75
|
+
toolRunning: (name) => `执行 ${name}…`,
|
|
76
|
+
busy: '任务执行中,请稍候,或按 Ctrl+C 停止当前任务。',
|
|
77
|
+
bye: '已退出 g ai',
|
|
78
|
+
cleared: '对话历史已清空',
|
|
79
|
+
newConversation: '已开启新对话',
|
|
80
|
+
operationCancelled: '已取消当前操作',
|
|
81
|
+
thinkOn: '思考过程显示: 开',
|
|
82
|
+
thinkOff: '思考过程显示: 关(模型仍会思考,只是不回显)',
|
|
83
|
+
modelSwitched: (label) => `已切换模型: ${label}`,
|
|
84
|
+
invalidModelIndex: '无效的模型序号',
|
|
85
|
+
cdOk: (p) => `工作目录已切换: ${p}`,
|
|
86
|
+
cdFail: (p) => `目录不存在: ${p}`,
|
|
87
|
+
currentModel: '当前模型',
|
|
88
|
+
availableModels: '可用模型',
|
|
89
|
+
oneShotNoModel: '未配置模型,无法启动',
|
|
90
|
+
oneShotDone: '单发模式完成 · 需要多轮连续对话请直接运行 g ai(不带参数)',
|
|
91
|
+
helpTitle: 'g ai 命令',
|
|
92
|
+
bannerTip: 'Alt+V 贴图 · /help 帮助 · Ctrl+C 停止当前任务 · /exit 退出',
|
|
93
|
+
prompt: '❯ ',
|
|
94
|
+
bannerModel: '模型',
|
|
95
|
+
bannerCwd: '目录',
|
|
96
|
+
thinkingLabel: '思考',
|
|
97
|
+
answerLabel: '回答',
|
|
98
|
+
thinkingHint: '… 已显示前 12 行;输入 /think full 开启后续完整思考',
|
|
99
|
+
emptyResponse: '模型没有返回回答,请重试或切换模型。',
|
|
100
|
+
chars: '字符',
|
|
101
|
+
extLoadingMcp: (n) => `正在连接 ${n} 个 MCP 服务…`,
|
|
102
|
+
extLoaded: (skills, tools) => `扩展已就绪: ${skills} 个 Skill · ${tools} 个 MCP 工具(/skills 查看)`,
|
|
103
|
+
extNone: '',
|
|
104
|
+
extSkillTitle: '已安装的 Skill',
|
|
105
|
+
extSkillEmpty: '没有装 Skill。可以在 g ui → 智能体 → Skill 广场 里挑一个装到当前项目或 g ai。',
|
|
106
|
+
extMcpTitle: '已接入的 MCP 服务',
|
|
107
|
+
extMcpEmpty: '没有接入 MCP 服务。可以在 g ui → 智能体 → MCP 广场 里挑一个装到当前项目或 g ai。',
|
|
108
|
+
extMcpFailed: '连接失败',
|
|
109
|
+
imagePasting: '正在读取剪贴板图片…',
|
|
110
|
+
imageAttached: (n, size) => `📎 图片 #${n} 已附加(${size}),将随下一条消息发送`,
|
|
111
|
+
imageEmpty: '剪贴板中没有图片;可先截图再按 Alt+V,或用 /image <路径> 附加本地图片',
|
|
112
|
+
imageBadPath: (p) => `图片不存在或格式不支持: ${p}(支持 png/jpg/jpeg/gif/webp/bmp)`,
|
|
113
|
+
imageListTitle: '待发送图片',
|
|
114
|
+
imageListEmpty: '当前没有待发送的图片(Alt+V 粘贴或 /image <路径> 附加)',
|
|
115
|
+
imageCleared: '已清除待发送的图片',
|
|
116
|
+
imageSending: (n) => `📎 附带 ${n} 张图片`,
|
|
117
|
+
pasteToken: (n, lines) => `[粘贴 #${n} · ${lines} 行]`,
|
|
118
|
+
pasteExpired: (n) => `有 ${n} 段粘贴内容已从内存回收,无法还原;如仍需要请重新粘贴`,
|
|
119
|
+
addModelTitle: '添加模型配置',
|
|
120
|
+
addModelSaved: (name) => `✓ 模型 "${name}" 已添加并切换为当前模型`,
|
|
121
|
+
addModelCancelled: '已取消添加模型',
|
|
122
|
+
addModelSaveError: (msg) => `保存配置失败: ${msg}`,
|
|
123
|
+
resumeTitle: '选择要恢复的对话',
|
|
124
|
+
resumeEmpty: '没有可恢复的历史对话',
|
|
125
|
+
resumeCancelled: '已取消恢复对话',
|
|
126
|
+
resumeDone: (title, count) => `已恢复对话: ${title} (${count} 条消息)`,
|
|
127
|
+
},
|
|
128
|
+
en: {
|
|
129
|
+
noModel: 'No AI model configured. Add one in GUI settings first (run `g ui`).',
|
|
130
|
+
unknownModel: (q) => `No model matching "${q}", falling back to default`,
|
|
131
|
+
waiting: 'Thinking…',
|
|
132
|
+
llmError: (msg) => `LLM request failed: ${msg}`,
|
|
133
|
+
toolIterLimit: (n) => `Hit max tool iterations (${n}) for this turn. Send another message to continue.`,
|
|
134
|
+
toolRunning: (name) => `Running ${name}…`,
|
|
135
|
+
busy: 'Agent is working. Wait or press Ctrl+C to stop the task.',
|
|
136
|
+
bye: 'Bye',
|
|
137
|
+
cleared: 'Conversation cleared',
|
|
138
|
+
newConversation: 'Started a new conversation',
|
|
139
|
+
operationCancelled: 'Current operation cancelled',
|
|
140
|
+
thinkOn: 'Thinking display: on',
|
|
141
|
+
thinkOff: 'Thinking display: off (model still thinks, just hidden)',
|
|
142
|
+
modelSwitched: (label) => `Switched model: ${label}`,
|
|
143
|
+
invalidModelIndex: 'Invalid model index',
|
|
144
|
+
cdOk: (p) => `Working directory changed: ${p}`,
|
|
145
|
+
cdFail: (p) => `Directory not found: ${p}`,
|
|
146
|
+
currentModel: 'Current model',
|
|
147
|
+
availableModels: 'Available models',
|
|
148
|
+
oneShotNoModel: 'No model configured, aborting',
|
|
149
|
+
oneShotDone: 'One-shot done · for multi-turn chat run `g ai` with no arguments',
|
|
150
|
+
helpTitle: 'g ai commands',
|
|
151
|
+
bannerTip: 'Alt+V image · /help commands · Ctrl+C stop task · /exit quit',
|
|
152
|
+
prompt: '❯ ',
|
|
153
|
+
bannerModel: 'Model',
|
|
154
|
+
bannerCwd: 'CWD',
|
|
155
|
+
thinkingLabel: 'Thinking',
|
|
156
|
+
answerLabel: 'Answer',
|
|
157
|
+
thinkingHint: '… first 12 lines shown; enter /think full for future reasoning',
|
|
158
|
+
emptyResponse: 'The model returned no answer. Retry or switch models.',
|
|
159
|
+
chars: 'chars',
|
|
160
|
+
extLoadingMcp: (n) => `Connecting to ${n} MCP server(s)…`,
|
|
161
|
+
extLoaded: (skills, tools) => `Extensions ready: ${skills} skill(s) · ${tools} MCP tool(s) (/skills for details)`,
|
|
162
|
+
extNone: '',
|
|
163
|
+
extSkillTitle: 'Installed skills',
|
|
164
|
+
extSkillEmpty: 'No skills installed. Browse g ui → Agent → Skill marketplace to install one into this project or g ai.',
|
|
165
|
+
extMcpTitle: 'Connected MCP servers',
|
|
166
|
+
extMcpEmpty: 'No MCP servers connected. Browse g ui → Agent → MCP marketplace to install one.',
|
|
167
|
+
extMcpFailed: 'failed',
|
|
168
|
+
imagePasting: 'Reading clipboard image…',
|
|
169
|
+
imageAttached: (n, size) => `📎 Image #${n} attached (${size}); sent with your next message`,
|
|
170
|
+
imageEmpty: 'No image in clipboard; take a screenshot first, or use /image <path>',
|
|
171
|
+
imageBadPath: (p) => `Not a supported image: ${p} (png/jpg/jpeg/gif/webp/bmp)`,
|
|
172
|
+
imageListTitle: 'Pending images',
|
|
173
|
+
imageListEmpty: 'No pending images (Alt+V to paste, or /image <path>)',
|
|
174
|
+
imageCleared: 'Pending images cleared',
|
|
175
|
+
imageSending: (n) => `📎 ${n} image(s) attached`,
|
|
176
|
+
pasteToken: (n, lines) => `[paste #${n} · ${lines} lines]`,
|
|
177
|
+
pasteExpired: (n) => `${n} pasted block(s) were dropped from memory and could not be restored; paste again if still needed`,
|
|
178
|
+
addModelTitle: 'Add Model Configuration',
|
|
179
|
+
addModelSaved: (name) => `✓ Model "${name}" added and switched to`,
|
|
180
|
+
addModelCancelled: 'Add model cancelled',
|
|
181
|
+
addModelSaveError: (msg) => `Failed to save: ${msg}`,
|
|
182
|
+
resumeTitle: 'Choose a conversation to resume',
|
|
183
|
+
resumeEmpty: 'No previous conversations to resume',
|
|
184
|
+
resumeCancelled: 'Resume cancelled',
|
|
185
|
+
resumeDone: (title, count) => `Resumed: ${title} (${count} messages)`,
|
|
186
|
+
},
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function makeStrings(locale) {
|
|
190
|
+
return String(locale || '').startsWith('en') ? STRINGS.en : STRINGS.zh
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// 模型的展示名:优先 model 字段,其次 name,最后 baseURL
|
|
194
|
+
function modelLabel(m) {
|
|
195
|
+
return m?.model || m?.name || m?.baseURL || '(unknown)'
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ──────────────────────────────────────────────
|
|
199
|
+
// 系统 prompt — 明确告知环境、权限边界与工作方式
|
|
200
|
+
// ──────────────────────────────────────────────
|
|
201
|
+
// 顺序固定:基础人设 → 项目说明(AGENTS.md/CLAUDE.md)→ 扩展能力(skill 清单 + MCP 工具)。
|
|
202
|
+
// 把扩展放最后,是因为它是"增量能力",不该插在权限与环境说明中间打断阅读。
|
|
203
|
+
// state 里存着已加载的 AgentExtensions;没加载(如单测直接调)时 extra 为空,行为与改动前一致。
|
|
204
|
+
function extensionSuffix(state) {
|
|
205
|
+
try {
|
|
206
|
+
return state?.extensions?.promptSuffix?.(state.locale) || ''
|
|
207
|
+
} catch {
|
|
208
|
+
return ''
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function buildProjectPrompt(options) {
|
|
213
|
+
return buildSystemPrompt(options) + await loadProjectInstructions(options.cwd) + (options.extra || '')
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function buildSystemPrompt({ cwd, locale, shellDesc }) {
|
|
217
|
+
const zh = !String(locale || '').startsWith('en')
|
|
218
|
+
const now = new Date().toLocaleString()
|
|
219
|
+
const isWin = process.platform === 'win32'
|
|
220
|
+
// 工具数量动态取自 tools.js —— 之前这里写死过"6 个",加 ask_user 时没人想起来改,
|
|
221
|
+
// 就变成了对模型撒谎。数字跟着定义走,以后再加工具不会漏。
|
|
222
|
+
const builtinToolCount = TOOL_DEFINITIONS.length
|
|
223
|
+
if (zh) {
|
|
224
|
+
return `你是 "g ai" —— zen-gitsync CLI 内置的终端编码智能体,通过工具在用户真实电脑上完成编码任务。
|
|
225
|
+
|
|
226
|
+
# 运行环境
|
|
227
|
+
- 操作系统: ${process.platform}
|
|
228
|
+
- Shell: ${shellDesc}
|
|
229
|
+
- 当前工作目录: ${cwd}(用户在此启动 g ai,也是所有相对路径的基准)
|
|
230
|
+
- 当前时间: ${now}
|
|
231
|
+
|
|
232
|
+
# 平台兼容性(重要!)
|
|
233
|
+
- 必须使用与当前 Shell 兼容的命令,禁止盲套 Unix 写法
|
|
234
|
+
${isWin ? `- 当前是 Windows cmd.exe,以下 Unix 命令**不存在**,用了必定报"不是内部或外部命令":
|
|
235
|
+
tail / head / cat / grep / ls / sed / awk / wc / cut / uniq / xargs / which / touch
|
|
236
|
+
平台守卫会在执行前拦截这些命令,但请主动避免,不要浪费一轮调用
|
|
237
|
+
- 跨平台替代方案:
|
|
238
|
+
· 列目录 → list_files 工具 或 cmd 的 dir
|
|
239
|
+
· 搜内容 → search_text 工具 或 cmd 的 findstr
|
|
240
|
+
· 看文件 → read_file 工具 或 cmd 的 type
|
|
241
|
+
· 看图片 → read_image 工具(截图/设计稿/示意图)。read_file 读图片只会得到乱码,别浪费那一轮
|
|
242
|
+
· 看输出末尾 → PowerShell "命令 | Select-Object -Last N"
|
|
243
|
+
· 文本处理 → node -e "..." 或 PowerShell
|
|
244
|
+
· 查命令路径 → cmd 的 where(不是 which)
|
|
245
|
+
- 必须跑 shell 时优先跨平台写法(如 node -e "..."),别用 Unix 专属命令` : `- 当前是 POSIX 环境,Unix 命令可用`}
|
|
246
|
+
|
|
247
|
+
# 远程仓库(GitHub / Gitee)
|
|
248
|
+
- 问"这个项目关联哪个远端" → run_command 跑 \`git remote -v\`:本地信息,不联网、不依赖任何 CLI,任何环境都能答
|
|
249
|
+
- 问"我账号下有哪些仓库"或某仓库的 PR / Issue → 用官方 CLI。凭据由 CLI 自己保管:
|
|
250
|
+
· GitHub → \`gh\`。列仓库 \`gh repo list --limit 50 --json name,visibility,updatedAt,primaryLanguage\`;
|
|
251
|
+
看详情 \`gh repo view <owner/repo>\`;PR \`gh pr list\`;登录态 \`gh auth status\`
|
|
252
|
+
· Gitee → \`gitee\`。列仓库 \`gitee repo list\`;登录态 \`gitee auth status\`
|
|
253
|
+
(未登录时退出码**仍是 0**,必须看 \`--json\` 里的 status 字段,只看退出码会误判成已登录)
|
|
254
|
+
· 两者默认只覆盖**当前登录账号**;组织仓库、私有仓库可能需要更大的 token scope,拉不到就如实说明,
|
|
255
|
+
不要用其他途径绕过
|
|
256
|
+
· 绝不向用户索要 token —— 也不要让用户把 token 粘进对话
|
|
257
|
+
- CLI 可能没装、或没在当前进程的 PATH 里(装完没重启服务就是这个表现):
|
|
258
|
+
报"不是内部或外部命令" / command not found 时,**不要换写法反复重试**,更不要凭印象编仓库名或目录名。
|
|
259
|
+
直接告诉用户未检测到该 CLI,可在 ZenGitSync 的「远程仓库」页一键安装/登录;
|
|
260
|
+
若只是想回答本项目的问题,退回 \`git remote -v\`
|
|
261
|
+
- 克隆仓库 / 添加远端时**优先用 SSH**(用户偏好,本机已配好密钥,实测走 https 会弹凭据窗口把任务打断):
|
|
262
|
+
· GitHub 用 \`git@github.com:owner/repo.git\`,Gitee 用 \`git@gitee.com:owner/repo.git\`
|
|
263
|
+
· 用户给的是 \`https://...\` 或 \`gh repo clone owner/repo\` 时,先换算成 SSH 地址再执行 ——
|
|
264
|
+
走 https 会弹 Git Credential Manager 让用户输账号密码,任务就停在半路等输入
|
|
265
|
+
· 已有仓库换协议:\`git remote set-url origin <ssh 地址>\`(先 \`git remote -v\` 看当前是什么)
|
|
266
|
+
· 只有 SSH 真的不可用(报 \`Permission denied (publickey)\` / \`Host key verification failed\`)才退回 https,
|
|
267
|
+
并用一句话说明这次走的是 https、配好密钥后可改回;两边都失败就停下来问用户,不要反复重试
|
|
268
|
+
|
|
269
|
+
# 权限(用户已明确授权,无需反复征求同意)
|
|
270
|
+
- 工作目录内:读写文件、执行命令等所有操作直接执行
|
|
271
|
+
- 其他目录:同样可以读取和修改
|
|
272
|
+
- 唯一红线:不得破坏系统(格式化磁盘、删除根目录/系统目录、关机重启、写块设备等)。
|
|
273
|
+
命令守卫只能识别部分危险写法,不是沙箱;不得通过脚本或嵌套 shell 绕过限制。
|
|
274
|
+
|
|
275
|
+
# 工作方式
|
|
276
|
+
- 先动手、后提问:能用工具查清的不要问用户(list_files / read_file / search_text / run_command)
|
|
277
|
+
- 修改代码后主动验证:跑测试、构建或至少语法检查(用 run_command)
|
|
278
|
+
- 编辑文件优先 edit_file 精确替换;先 read_file 看原文,old_string 必须与文件内容完全一致(含缩进换行)
|
|
279
|
+
- 大文件用 offset/limit 分段读取,不要一次读爆上下文
|
|
280
|
+
- git 操作用 run_command 执行;提交代码可以用 git 命令,也可以用本 CLI 的 g -y(默认信息提交并推送)或 g --ai(AI 生成提交信息)
|
|
281
|
+
- run_command 默认就在工作目录执行,不要再加 cd / cd /d 前缀;默认超时 120 秒,长任务加大 timeout_seconds(最大 600)
|
|
282
|
+
- 命令在 ${shellDesc} 下执行,注意语法兼容
|
|
283
|
+
- 先读取当前项目的规范、依赖与测试配置,再选择对应的验证命令,不要假定所有项目都使用 npm
|
|
284
|
+
- 用户要求概述或简短回答时,只读取必要的结构与入口文件,控制探索范围
|
|
285
|
+
|
|
286
|
+
# 任务计划(多步任务必用)
|
|
287
|
+
- 任务需要多步时(改代码、排查、调研、多文件改动),**动手之前**先调一次 update_plan,把要做的事
|
|
288
|
+
拆成 3-8 个可核对的步骤,让用户在你改任何东西之前就知道范围和验收标准
|
|
289
|
+
- 单步小事(读个文件、答一个问题)不要列计划,那是噪音
|
|
290
|
+
- 每次都传**完整**的当前计划(不是增量),顺序即执行顺序;同一时刻最多一个 in_progress
|
|
291
|
+
- 完成一步就更新一次状态,别攒到最后一次性刷;计划与实际不符时直接改计划,不要硬着头皮往下走
|
|
292
|
+
- 全部做完后把 steps 传空表示收尾
|
|
293
|
+
- update_plan 只是给人看的进度板,不要为了"更新计划"去改文件或跑命令
|
|
294
|
+
|
|
295
|
+
# 与用户交互
|
|
296
|
+
- 需要向用户确认、提问或汇报重要决策时,直接用普通文本输出 —— 用户能实时看到你的文本;
|
|
297
|
+
需要暂停当前任务并等待用户决定或补充信息时,调用 ask_user,不要猜测或只在普通文本里提问
|
|
298
|
+
不要调用不存在的工具,内置工具就是上面列出的 ${builtinToolCount} 个(若用户装了 MCP,你的工具表里还会多出 mcp__ 开头的工具)
|
|
299
|
+
- 图片有两条来路:
|
|
300
|
+
· 用户通过 Alt+V 或 /image 附加 → 图片以 image_url 部件出现在 user 消息里,直接就能看到
|
|
301
|
+
· 用户只给了**路径**(或你自己在翻文件时遇到图)→ 用 read_image 工具读它,别用 read_file
|
|
302
|
+
(read_file 按 UTF-8 解码,图片只会是乱码),也别根据文件名猜内容
|
|
303
|
+
如果当前模型不支持视觉(带图请求报错),提醒用户换用支持视觉的模型
|
|
304
|
+
- 发现高风险或状态不一致的情况(例如版本号 / git tag / CHANGELOG 对不上、发布前环境异常、
|
|
305
|
+
仓库状态与预期不符)时:先用文本说明发现和影响,停下来等用户指示,不要擅自继续破坏性操作
|
|
306
|
+
|
|
307
|
+
# 输出
|
|
308
|
+
- 你的文本输出直接显示在用户终端,用简体中文交流
|
|
309
|
+
- 完成任务后用一两句话汇报结果,不要复述过程细节`
|
|
310
|
+
}
|
|
311
|
+
return `You are "g ai" — the terminal coding agent built into the zen-gitsync CLI. You use tools to perform coding tasks on the user's real machine.
|
|
312
|
+
|
|
313
|
+
# Environment
|
|
314
|
+
- OS: ${process.platform}
|
|
315
|
+
- Shell: ${shellDesc}
|
|
316
|
+
- Working directory: ${cwd} (where the user launched g ai; base for all relative paths)
|
|
317
|
+
- Current time: ${now}
|
|
318
|
+
|
|
319
|
+
# Platform compatibility (important!)
|
|
320
|
+
- You MUST use commands compatible with the current shell. Do NOT blindly copy Unix patterns.
|
|
321
|
+
${isWin ? `- This is Windows cmd.exe. The following Unix commands do NOT exist here and will fail with "not recognized":
|
|
322
|
+
tail / head / cat / grep / ls / sed / awk / wc / cut / uniq / xargs / which / touch
|
|
323
|
+
A platform guard will block these before execution, but avoid them proactively — don't waste a turn.
|
|
324
|
+
- Cross-platform alternatives:
|
|
325
|
+
· List dirs → list_files tool, or cmd's dir
|
|
326
|
+
· Search content → search_text tool, or cmd's findstr
|
|
327
|
+
· Read files → read_file tool, or cmd's type
|
|
328
|
+
· Look at an image → read_image tool (screenshots, mockups, diagrams). read_file only decodes text and returns garbage for images
|
|
329
|
+
· Tail output → PowerShell "command | Select-Object -Last N"
|
|
330
|
+
· Text processing → node -e "..." or PowerShell
|
|
331
|
+
· Find executable → cmd's where (not which)
|
|
332
|
+
- For shell one-offs, prefer cross-platform forms like node -e "..."` : `- POSIX environment: Unix commands are available`}
|
|
333
|
+
|
|
334
|
+
# Remote repositories (GitHub / Gitee)
|
|
335
|
+
- "Which remote does this project point at?" → run_command \`git remote -v\`: local info, no network, no CLI needed
|
|
336
|
+
- "Which repos do I have?" or a repo's PRs / issues → use the official CLI. The CLI owns the credentials:
|
|
337
|
+
· GitHub → \`gh\`. List repos \`gh repo list --limit 50 --json name,visibility,updatedAt,primaryLanguage\`;
|
|
338
|
+
details \`gh repo view <owner/repo>\`; PRs \`gh pr list\`; auth state \`gh auth status\`
|
|
339
|
+
· Gitee → \`gitee\`. List repos \`gitee repo list\`; auth state \`gitee auth status\`
|
|
340
|
+
(the exit code is still 0 when logged out — read the status field from \`--json\`, or you will
|
|
341
|
+
wrongly report "logged in")
|
|
342
|
+
· Both default to the **currently authenticated account only**; org and private repos may need
|
|
343
|
+
broader token scopes. If you cannot see them, say so plainly — do not route around it
|
|
344
|
+
· Never ask the user for a token, and never have them paste one into the conversation
|
|
345
|
+
- The CLI may not be installed, or missing from this process's PATH (that is what "installed but the
|
|
346
|
+
server cannot find it" looks like):
|
|
347
|
+
on "not recognized" / command not found, do NOT retry with a different spelling and do NOT invent
|
|
348
|
+
repo or directory names. Tell the user the CLI was not found and point them to ZenGitSync's
|
|
349
|
+
"Remote repositories" page (one-click install / login); if you only need to answer for this project,
|
|
350
|
+
fall back to \`git remote -v\`
|
|
351
|
+
- Cloning a repo or adding a remote: **prefer SSH** (the user's preference — keys are already set up,
|
|
352
|
+
and https has been observed to pop a credential window that stalls the task):
|
|
353
|
+
· GitHub → \`git@github.com:owner/repo.git\`; Gitee → \`git@gitee.com:owner/repo.git\`
|
|
354
|
+
· If the user hands you an \`https://...\` URL (or \`gh repo clone owner/repo\`), convert it to the SSH
|
|
355
|
+
form first — https pops up Git Credential Manager asking for a username/password and blocks the task
|
|
356
|
+
· Switching an existing repo: \`git remote set-url origin <ssh url>\` (check \`git remote -v\` first)
|
|
357
|
+
· Fall back to https only when SSH genuinely fails (\`Permission denied (publickey)\` /
|
|
358
|
+
\`Host key verification failed\`), and say in one line that this one used https and can go back to SSH
|
|
359
|
+
once the key is set up; if both fail, stop and ask the user — do not keep retrying
|
|
360
|
+
|
|
361
|
+
# Permissions (explicitly granted by the user — do not keep asking)
|
|
362
|
+
- Inside the working directory: read/write files and run commands directly
|
|
363
|
+
- Other directories: may also be read and modified
|
|
364
|
+
- 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.
|
|
365
|
+
|
|
366
|
+
# How to work
|
|
367
|
+
- Act first, ask later: use tools (list_files / read_file / search_text / run_command) instead of asking the user
|
|
368
|
+
- When a decision or missing detail must come from the user, call ask_user. Do not guess or merely describe a question in plain text.
|
|
369
|
+
- After editing code, verify: run tests, builds, or at least a syntax check via run_command
|
|
370
|
+
- Prefer edit_file for precise replacements; read_file first, old_string must match the file exactly
|
|
371
|
+
- Read large files in segments (offset/limit)
|
|
372
|
+
- Git operations go through run_command; to commit, use git commands or this CLI's g -y / g --ai
|
|
373
|
+
- run_command already executes in the working directory — do NOT prefix with cd; default timeout 120s, raise timeout_seconds (max 600) for long tasks
|
|
374
|
+
- Commands run under ${shellDesc}; keep syntax compatible
|
|
375
|
+
- Read this project's instructions, dependencies and test configuration before choosing verification commands; do not assume npm
|
|
376
|
+
- For a brief overview, inspect only the necessary structure and entry points; keep exploration proportional to the request
|
|
377
|
+
|
|
378
|
+
# Task plan (required for multi-step work)
|
|
379
|
+
- When a task takes several steps (code changes, debugging, research, multi-file edits), call update_plan
|
|
380
|
+
**before touching anything**: break it into 3-8 verifiable steps so the user knows the scope and the
|
|
381
|
+
acceptance criteria before you modify a single file
|
|
382
|
+
- Do not plan single trivial actions (read one file, answer one question) — that is noise
|
|
383
|
+
- Always send the **complete** current plan (not a delta), in execution order; at most one in_progress
|
|
384
|
+
- Update the status right after finishing a step instead of batching everything at the end; when the
|
|
385
|
+
plan no longer matches reality, rewrite the plan instead of pushing ahead
|
|
386
|
+
- Send an empty steps list once everything is done
|
|
387
|
+
- update_plan is a progress board for humans: never edit files or run commands just to "update the plan"
|
|
388
|
+
|
|
389
|
+
# Talking to the user
|
|
390
|
+
- 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. Use ask_user when the task must pause for an answer. Never call tools that do not exist; the built-in set is the ${builtinToolCount} tools listed above (if the user installed MCP servers, extra tools prefixed with mcp__ will also appear in your tool list)
|
|
391
|
+
- Images reach you two ways:
|
|
392
|
+
· The user attached one via Alt+V or /image → it arrives as an image_url part in the user message; you can see it directly
|
|
393
|
+
· The user only gave you a **path** (or you run into an image while exploring) → read it with the read_image tool, not read_file
|
|
394
|
+
(read_file decodes UTF-8 and returns garbage for images), and never guess an image's content from its filename
|
|
395
|
+
If the current model rejects images (no vision support), tell the user to switch to a vision-capable model
|
|
396
|
+
- When you spot high-risk or inconsistent state (version number / git tag / CHANGELOG mismatch, abnormal release environment, unexpected repo state), explain the finding and its impact in text, then STOP and wait for the user's decision instead of proceeding with destructive operations
|
|
397
|
+
|
|
398
|
+
# Output
|
|
399
|
+
- Your text output goes straight to the user's terminal; reply in English
|
|
400
|
+
- Report results in one or two sentences; do not narrate the process`
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// ──────────────────────────────────────────────
|
|
404
|
+
// 消息准备(消毒 / 旧图片降级)统一实现在 context.js —— CLI 与 Web 智能体面板
|
|
405
|
+
// 共用同一份,避免两侧各写一遍后行为分叉。这里 re-export 保持既有引用与测试不断。
|
|
406
|
+
// ──────────────────────────────────────────────
|
|
407
|
+
export { sanitizeMessages, stripStaleImages } from './context.js'
|
|
408
|
+
|
|
409
|
+
// ──────────────────────────────────────────────
|
|
410
|
+
// 单轮 agent 循环:用户一句话 → 流式输出 → 工具调用 → 再调用模型 … 直到模型给出最终文本
|
|
411
|
+
// images: [{path, bytes}] 待发送图片(可为空数组)
|
|
412
|
+
// ──────────────────────────────────────────────
|
|
413
|
+
function printSlashHelp(t, locale) {
|
|
414
|
+
const zh = !String(locale || '').startsWith('en')
|
|
415
|
+
const lines = zh ? [
|
|
416
|
+
' /help 显示本帮助',
|
|
417
|
+
' /model 列出可用模型;/model <序号> 切换模型',
|
|
418
|
+
' /addmodel 添加新的模型配置(交互式向导)',
|
|
419
|
+
' /cd <路径> 切换智能体工作目录',
|
|
420
|
+
' /image [路径] 查看待发送图片;/image <路径> 附加本地图片;/image clear 清除',
|
|
421
|
+
' /think full 完整显示后续思考(默认,模型返回的内容)',
|
|
422
|
+
' /think compact 预览前 12 行思考',
|
|
423
|
+
' /think off 隐藏思考;/think 切换完整与隐藏',
|
|
424
|
+
' /tools [模式] compact 精简 / full 完整工具输出',
|
|
425
|
+
' /skills 查看已安装的 Skill 与已接入的 MCP 服务',
|
|
426
|
+
' /stats 查看上一轮与会话累计用量、耗时',
|
|
427
|
+
' /new 开启新对话',
|
|
428
|
+
' /resume 选择并恢复之前的对话',
|
|
429
|
+
' /clear 清空对话历史',
|
|
430
|
+
' /exit, /quit 退出',
|
|
431
|
+
'',
|
|
432
|
+
' Alt+V 粘贴剪贴板图片,随下一条消息发送(需视觉模型)',
|
|
433
|
+
' 多行粘贴 直接粘贴多行文本:整段作为一条消息发送,输入行里显示 [粘贴 #N] 占位符',
|
|
434
|
+
'',
|
|
435
|
+
'权限说明: 工作目录内全部操作直接执行;其他目录可读写;',
|
|
436
|
+
'命令守卫识别部分系统级危险操作,不是隔离沙箱。',
|
|
437
|
+
] : [
|
|
438
|
+
' /help Show this help',
|
|
439
|
+
' /model List models; /model <n> to switch',
|
|
440
|
+
' /addmodel Add a new model (interactive wizard)',
|
|
441
|
+
' /cd <path> Change agent working directory',
|
|
442
|
+
' /image [path] List pending images; attach a file; /image clear to reset',
|
|
443
|
+
' /think full Show future thinking returned by the model in full (default)',
|
|
444
|
+
' /think compact Preview the first 12 thinking lines',
|
|
445
|
+
' /think off Hide thinking; /think toggles full / hidden',
|
|
446
|
+
' /tools [mode] compact / full tool output',
|
|
447
|
+
' /skills List installed skills and connected MCP servers',
|
|
448
|
+
' /stats Last turn timing and session token usage',
|
|
449
|
+
' /new Start a new conversation',
|
|
450
|
+
' /resume Choose and resume a previous conversation',
|
|
451
|
+
' /clear Clear conversation history',
|
|
452
|
+
' /exit, /quit Quit',
|
|
453
|
+
'',
|
|
454
|
+
' Alt+V Paste clipboard image (needs a vision-capable model)',
|
|
455
|
+
' Multi-line paste Paste a block as one message; the input line shows a [paste #N] placeholder',
|
|
456
|
+
'',
|
|
457
|
+
'Permissions: full access in the working directory; other dirs readable/writable;',
|
|
458
|
+
'the command guard detects some dangerous operations; it is not a sandbox.',
|
|
459
|
+
]
|
|
460
|
+
printHelpPanel(t.helpTitle, lines)
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
export async function handleSlashCommand(state, input, t) {
|
|
464
|
+
const [cmd, ...rest] = input.split(/\s+/)
|
|
465
|
+
const arg = rest.join(' ').trim()
|
|
466
|
+
const zh = !String(state.locale).startsWith('en')
|
|
467
|
+
if (state.busy && !['/help', '/stats', '/think', '/tools', '/skills', '/mcp', '/exit', '/quit'].includes(cmd)) {
|
|
468
|
+
printDim(t.busy)
|
|
469
|
+
return 'ok'
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
if (cmd === '/exit' || cmd === '/quit') return 'exit'
|
|
473
|
+
if (cmd === '/help') { printSlashHelp(t, state.locale); return 'ok' }
|
|
474
|
+
if (cmd === '/clear' || cmd === '/new') {
|
|
475
|
+
await state.persistSession?.()
|
|
476
|
+
state.messages = [state.messages[0]]
|
|
477
|
+
// 开新会话 ID,旧会话保留在磁盘上
|
|
478
|
+
state.sessionId = genSessionId()
|
|
479
|
+
state.sessionCreatedAt = new Date().toISOString()
|
|
480
|
+
state.lastTurnStats = null
|
|
481
|
+
state.sessionStats = null
|
|
482
|
+
printOk(cmd === '/new' ? t.newConversation : t.cleared)
|
|
483
|
+
return 'ok'
|
|
484
|
+
}
|
|
485
|
+
if (cmd === '/resume') {
|
|
486
|
+
if (state.busy) {
|
|
487
|
+
printDim(t.busy)
|
|
488
|
+
return 'ok'
|
|
489
|
+
}
|
|
490
|
+
const sessions = (await listSessions())
|
|
491
|
+
.filter(session => session.sessionId !== state.sessionId && session.messages.length > 1)
|
|
492
|
+
if (sessions.length === 0) {
|
|
493
|
+
printDim(t.resumeEmpty)
|
|
494
|
+
return 'ok'
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const zh = !String(state.locale || '').startsWith('en')
|
|
498
|
+
const cleanLabel = (value, fallback) => String(value || fallback).replace(/[\r\n\x00-\x1f\x7f]/g, ' ').trim()
|
|
499
|
+
const items = sessions.map((session) => {
|
|
500
|
+
const title = cleanLabel(session.title, zh ? '(未命名对话)' : '(untitled)')
|
|
501
|
+
const updated = new Date(session.updatedAt || session.createdAt || 0)
|
|
502
|
+
const date = Number.isNaN(updated.getTime()) ? '' : updated.toLocaleString(state.locale)
|
|
503
|
+
const count = session.messages.filter(message => message?.role !== 'system').length
|
|
504
|
+
return {
|
|
505
|
+
label: `${title} ${chalk.dim(`${date} · ${count} ${zh ? '条消息' : 'messages'}`)}`,
|
|
506
|
+
value: session,
|
|
507
|
+
}
|
|
508
|
+
})
|
|
509
|
+
const selectStrings = {
|
|
510
|
+
navHint: zh ? '↑↓ 切换,Enter 确认' : '↑↓ navigate, Enter confirm',
|
|
511
|
+
selectPrompt: (min, max) => zh ? `请选择(${min}-${max}):` : `Choose (${min}-${max}):`,
|
|
512
|
+
invalidChoice: (min, max) => zh ? `请输入 ${min}-${max} 之间的序号` : `Enter a number from ${min} to ${max}`,
|
|
513
|
+
}
|
|
514
|
+
const asker = {
|
|
515
|
+
ask(prompt) {
|
|
516
|
+
return new Promise(resolve => state.rl.question(`${prompt} `, answer => resolve(String(answer || '').trim())))
|
|
517
|
+
},
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
state.inWizard = true
|
|
521
|
+
try {
|
|
522
|
+
const selected = await selectFromList({
|
|
523
|
+
rl: state.rl,
|
|
524
|
+
asker,
|
|
525
|
+
title: t.resumeTitle,
|
|
526
|
+
items,
|
|
527
|
+
t: selectStrings,
|
|
528
|
+
})
|
|
529
|
+
if (!selected?.value) return 'ok'
|
|
530
|
+
|
|
531
|
+
const session = selected.value
|
|
532
|
+
const { promises: fsp } = await import('node:fs')
|
|
533
|
+
if (session.cwd) {
|
|
534
|
+
try {
|
|
535
|
+
const stat = await fsp.stat(session.cwd)
|
|
536
|
+
if (stat.isDirectory()) state.ctx.cwd = session.cwd
|
|
537
|
+
} catch { /* 已删除的旧目录不阻断恢复,沿用当前目录 */ }
|
|
538
|
+
}
|
|
539
|
+
state.sessionId = session.sessionId
|
|
540
|
+
state.sessionCreatedAt = session.createdAt || new Date().toISOString()
|
|
541
|
+
state.messages = repairToolHistory(session.messages)
|
|
542
|
+
state.lastTurnStats = session.lastTurnStats || null
|
|
543
|
+
state.sessionStats = session.sessionStats || null
|
|
544
|
+
const systemMessage = {
|
|
545
|
+
role: 'system',
|
|
546
|
+
content: await buildProjectPrompt({ cwd: state.ctx.cwd, locale: state.locale, shellDesc: state.shellDesc, extra: extensionSuffix(state) }),
|
|
547
|
+
}
|
|
548
|
+
if (state.messages[0]?.role === 'system') state.messages[0] = systemMessage
|
|
549
|
+
else state.messages.unshift(systemMessage)
|
|
550
|
+
state.pendingImages = []
|
|
551
|
+
const count = state.messages.filter(message => message?.role !== 'system').length
|
|
552
|
+
printOk(t.resumeDone(cleanLabel(session.title, session.sessionId), count))
|
|
553
|
+
} catch (err) {
|
|
554
|
+
if (err?.cancelled) printDim(t.resumeCancelled)
|
|
555
|
+
else printError(err.message)
|
|
556
|
+
} finally {
|
|
557
|
+
state.inWizard = false
|
|
558
|
+
}
|
|
559
|
+
return 'ok'
|
|
560
|
+
}
|
|
561
|
+
if (cmd === '/think') {
|
|
562
|
+
const next = arg || (state.showThinking ? 'off' : 'full')
|
|
563
|
+
if (!['off', 'compact', 'full'].includes(next)) {
|
|
564
|
+
printWarn('/think compact | full | off')
|
|
565
|
+
return 'ok'
|
|
566
|
+
}
|
|
567
|
+
state.showThinking = next !== 'off'
|
|
568
|
+
state.thinkingMode = next
|
|
569
|
+
printOk(zh ? `后续请求的思考显示:${({ off: '隐藏', compact: '预览前 12 行', full: '完整' })[next]}` : `Thinking for future requests: ${next}`)
|
|
570
|
+
return 'ok'
|
|
571
|
+
}
|
|
572
|
+
if (cmd === '/tools') {
|
|
573
|
+
if (arg && !['full', 'compact'].includes(arg)) { printWarn('/tools compact | full'); return 'ok' }
|
|
574
|
+
state.fullTools = arg ? arg === 'full' : !state.fullTools
|
|
575
|
+
printOk(zh ? `后续工具结果:${state.fullTools ? '完整' : '精简'}` : `Future tool output: ${state.fullTools ? 'full' : 'compact'}`)
|
|
576
|
+
return 'ok'
|
|
577
|
+
}
|
|
578
|
+
if (cmd === '/skills' || cmd === '/mcp') {
|
|
579
|
+
const info = state.extensions?.summary(state.locale) || { skills: [], servers: [] }
|
|
580
|
+
console.log(chalk.bold(`\n ${t.extSkillTitle}`))
|
|
581
|
+
if (info.skills.length === 0) printDim(t.extSkillEmpty)
|
|
582
|
+
for (const skill of info.skills) {
|
|
583
|
+
const scope = skill.scope === 'project' ? (zh ? '项目' : 'project') : (zh ? '全局' : 'global')
|
|
584
|
+
console.log(` · ${chalk.cyan(skill.name)} ${chalk.dim(`[${scope}]`)}${skill.description ? chalk.dim(` — ${skill.description}`) : ''}`)
|
|
585
|
+
}
|
|
586
|
+
console.log(chalk.bold(`\n ${t.extMcpTitle}`))
|
|
587
|
+
if (info.servers.length === 0) printDim(t.extMcpEmpty)
|
|
588
|
+
for (const server of info.servers) {
|
|
589
|
+
if (server.error) {
|
|
590
|
+
console.log(` · ${chalk.red(server.id)} ${chalk.dim(`— ${t.extMcpFailed}: ${server.error}`)}`)
|
|
591
|
+
} else {
|
|
592
|
+
console.log(` · ${chalk.cyan(server.id)} ${chalk.dim(`— ${server.tools} ${zh ? '个工具' : 'tools'} (${server.command})`)}`)
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
console.log('')
|
|
596
|
+
return 'ok'
|
|
597
|
+
}
|
|
598
|
+
if (cmd === '/stats') {
|
|
599
|
+
if (state.lastTurnStats) printTurnSummary(state.lastTurnStats, { locale: state.locale, session: state.sessionStats })
|
|
600
|
+
else printDim(zh ? '完成一轮对话后显示用量与耗时。' : 'Usage and timing appear after a turn completes.')
|
|
601
|
+
return 'ok'
|
|
602
|
+
}
|
|
603
|
+
if (cmd === '/image') {
|
|
604
|
+
if (arg === 'clear') {
|
|
605
|
+
state.pendingImages = []
|
|
606
|
+
printOk(t.imageCleared)
|
|
607
|
+
return 'ok'
|
|
608
|
+
}
|
|
609
|
+
if (!arg) {
|
|
610
|
+
if (state.pendingImages.length === 0) {
|
|
611
|
+
printDim(t.imageListEmpty)
|
|
612
|
+
} else {
|
|
613
|
+
console.log(chalk.bold(t.imageListTitle) + ':')
|
|
614
|
+
state.pendingImages.forEach((img, i) => {
|
|
615
|
+
console.log(` ${i + 1}. ${img.path} ${chalk.dim('(' + formatBytes(img.bytes) + ')')}`)
|
|
616
|
+
})
|
|
617
|
+
}
|
|
618
|
+
return 'ok'
|
|
619
|
+
}
|
|
620
|
+
const { resolve } = await import('node:path')
|
|
621
|
+
const target = resolve(state.ctx.cwd, arg)
|
|
622
|
+
const img = await checkImageFile(target)
|
|
623
|
+
if (!img) {
|
|
624
|
+
printError(t.imageBadPath(target))
|
|
625
|
+
return 'ok'
|
|
626
|
+
}
|
|
627
|
+
state.pendingImages.push(img)
|
|
628
|
+
printOk(t.imageAttached(state.pendingImages.length, formatBytes(img.bytes)))
|
|
629
|
+
return 'ok'
|
|
630
|
+
}
|
|
631
|
+
if (cmd === '/model') {
|
|
632
|
+
if (!arg) {
|
|
633
|
+
console.log(chalk.bold(t.availableModels) + ':')
|
|
634
|
+
state.models.forEach((m, i) => {
|
|
635
|
+
const current = m === state.model ? chalk.green(' ← ' + t.currentModel) : ''
|
|
636
|
+
const keyHint = m.apiKey ? '' : chalk.yellow(' (无 apiKey)')
|
|
637
|
+
console.log(` ${i + 1}. ${modelLabel(m)} ${chalk.dim(m.baseURL || '')}${keyHint}${current}`)
|
|
638
|
+
})
|
|
639
|
+
console.log(chalk.dim('/model <序号> 切换'))
|
|
640
|
+
return 'ok'
|
|
641
|
+
}
|
|
642
|
+
const idx = Number.parseInt(arg, 10)
|
|
643
|
+
if (!Number.isFinite(idx) || idx < 1 || idx > state.models.length) {
|
|
644
|
+
printError(t.invalidModelIndex)
|
|
645
|
+
return 'ok'
|
|
646
|
+
}
|
|
647
|
+
state.model = state.models[idx - 1]
|
|
648
|
+
printOk(t.modelSwitched(modelLabel(state.model)))
|
|
649
|
+
return 'ok'
|
|
650
|
+
}
|
|
651
|
+
if (cmd === '/addmodel') {
|
|
652
|
+
// 智能体执行中不允许启动向导:向导的 rl.question 会与流式输出交错,体验混乱
|
|
653
|
+
if (state.busy) {
|
|
654
|
+
printDim(t.busy)
|
|
655
|
+
return 'ok'
|
|
656
|
+
}
|
|
657
|
+
state.inWizard = true
|
|
658
|
+
try {
|
|
659
|
+
console.log(chalk.cyan.bold('\n' + t.addModelTitle))
|
|
660
|
+
// 复用 modelSetup 的交互式收集逻辑(服务商 → 接口 → 模型名 → Key → 显示名 → 测试)
|
|
661
|
+
const collected = await collectModelInput({
|
|
662
|
+
locale: state.locale,
|
|
663
|
+
rl: state.rl,
|
|
664
|
+
cancelMessage: t.addModelCancelled,
|
|
665
|
+
})
|
|
666
|
+
if (!collected) return 'ok'
|
|
667
|
+
const { baseURL, model, apiKey, displayName } = collected
|
|
668
|
+
// 构建模型配置(isDefault=false:/addmodel 只是追加,不改默认模型)
|
|
669
|
+
const newModel = buildModelConfig({ baseURL, model, apiKey, name: displayName, isDefault: false })
|
|
670
|
+
// 持久化到 ~/.zen-gitsync/config.json 顶层 models 数组
|
|
671
|
+
const cfg = await config.loadConfig()
|
|
672
|
+
const models = Array.isArray(cfg.models) ? cfg.models : []
|
|
673
|
+
models.push(newModel)
|
|
674
|
+
cfg.models = models
|
|
675
|
+
try {
|
|
676
|
+
await config.saveConfig(cfg)
|
|
677
|
+
} catch (err) {
|
|
678
|
+
printError(t.addModelSaveError(err.message))
|
|
679
|
+
return 'ok'
|
|
680
|
+
}
|
|
681
|
+
// 同步 REPL 状态:更新模型列表 + 自动切换到新模型(用户刚添加,大概率想立刻用)
|
|
682
|
+
state.models = models
|
|
683
|
+
state.model = newModel
|
|
684
|
+
printOk(t.addModelSaved(displayName || model))
|
|
685
|
+
} catch (err) {
|
|
686
|
+
printError(err.message)
|
|
687
|
+
} finally {
|
|
688
|
+
state.inWizard = false
|
|
689
|
+
}
|
|
690
|
+
return 'ok'
|
|
691
|
+
}
|
|
692
|
+
if (cmd === '/cd') {
|
|
693
|
+
if (!arg) { console.log(state.ctx.cwd); return 'ok' }
|
|
694
|
+
const { resolve } = await import('node:path')
|
|
695
|
+
const { promises: fsp } = await import('node:fs')
|
|
696
|
+
const target = resolve(state.ctx.cwd, arg)
|
|
697
|
+
try {
|
|
698
|
+
const st = await fsp.stat(target)
|
|
699
|
+
if (!st.isDirectory()) throw new Error('not a dir')
|
|
700
|
+
state.ctx.cwd = target
|
|
701
|
+
// 项目级 skill 与项目级 mcp.json 跟着工作目录走:先重载扩展(会关掉旧目录起的 MCP 子进程),
|
|
702
|
+
// 再重建首条 system 消息 —— 顺序反了会把旧目录的 skill 清单写进新目录的提示词。
|
|
703
|
+
await state.extensions?.reload({ cwd: target, locale: state.locale, onWarn: printWarn })
|
|
704
|
+
state.messages[0] = { role: 'system', content: await buildProjectPrompt({ cwd: target, locale: state.locale, shellDesc: state.shellDesc, extra: extensionSuffix(state) }) }
|
|
705
|
+
await state.persistSession?.()
|
|
706
|
+
printOk(t.cdOk(target))
|
|
707
|
+
} catch {
|
|
708
|
+
printError(t.cdFail(target))
|
|
709
|
+
}
|
|
710
|
+
return 'ok'
|
|
711
|
+
}
|
|
712
|
+
printWarn(`未知命令: ${cmd},输入 /help 查看可用命令`)
|
|
713
|
+
return 'ok'
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// ──────────────────────────────────────────────
|
|
717
|
+
// 主入口
|
|
718
|
+
// ──────────────────────────────────────────────
|
|
719
|
+
export async function runAiAgent(argv = []) {
|
|
720
|
+
const cfg = await config.loadConfig()
|
|
721
|
+
const locale = cfg.locale || 'zh-CN'
|
|
722
|
+
const t = makeStrings(locale)
|
|
723
|
+
|
|
724
|
+
// g ai --help:智能体专属帮助,不触发主 CLI 的 showHelp
|
|
725
|
+
if (argv.includes('--help') || argv.includes('-h')) {
|
|
726
|
+
printSlashHelp(t, locale)
|
|
727
|
+
return
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
let models = Array.isArray(cfg.models) ? cfg.models : []
|
|
731
|
+
if (models.length === 0) {
|
|
732
|
+
// 未配置模型:启动交互式配置向导,参照 g ui 设置里的"添加模型"
|
|
733
|
+
const setupResult = await runModelSetup({ locale })
|
|
734
|
+
if (!setupResult) {
|
|
735
|
+
process.exitCode = 1
|
|
736
|
+
return
|
|
737
|
+
}
|
|
738
|
+
models = setupResult.models
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// --model=<序号|名称> 选择模型;默认 isDefault,否则第一个
|
|
742
|
+
let model = models.find(m => m.isDefault) || models[0]
|
|
743
|
+
const modelArg = argv.find(a => a.startsWith('--model='))
|
|
744
|
+
if (modelArg) {
|
|
745
|
+
const q = modelArg.split('=')[1]
|
|
746
|
+
const idx = Number.parseInt(q, 10)
|
|
747
|
+
if (Number.isFinite(idx) && idx >= 1 && idx <= models.length) {
|
|
748
|
+
model = models[idx - 1]
|
|
749
|
+
} else {
|
|
750
|
+
const found = models.find(m => modelLabel(m).toLowerCase().includes(String(q).toLowerCase()))
|
|
751
|
+
if (found) model = found
|
|
752
|
+
else printWarn(t.unknownModel(q))
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
const cwd = getCwd()
|
|
757
|
+
const shellDesc = process.platform === 'win32'
|
|
758
|
+
? 'cmd.exe (Windows CMD)'
|
|
759
|
+
: (process.env.SHELL || '/bin/sh')
|
|
760
|
+
|
|
761
|
+
// 加载扩展(Skill 清单 + MCP 服务)。MCP 要起子进程,npx 首次还得下载包,
|
|
762
|
+
// 所以这一步可能明显停顿 —— 有 MCP 时先提示一句。任何失败都只降级并打印原因。
|
|
763
|
+
const extensions = await AgentExtensions.load({
|
|
764
|
+
cwd,
|
|
765
|
+
locale,
|
|
766
|
+
onWarn: printWarn,
|
|
767
|
+
onNotice: (count) => printDim(t.extLoadingMcp(count)),
|
|
768
|
+
})
|
|
769
|
+
|
|
770
|
+
const state = {
|
|
771
|
+
// 会话持久化:CLI 对话也保存到 ~/.zen-gitsync/agent-sessions/,
|
|
772
|
+
// 在 Web UI 智能体 tab 中可见(带 CLI 标记)
|
|
773
|
+
sessionId: genSessionId(),
|
|
774
|
+
sessionCreatedAt: new Date().toISOString(),
|
|
775
|
+
messages: [{ role: 'system', content: await buildProjectPrompt({ cwd, locale, shellDesc, extra: extensions.promptSuffix(locale) }) }],
|
|
776
|
+
ctx: {
|
|
777
|
+
cwd,
|
|
778
|
+
onChild: (child) => {
|
|
779
|
+
state.currentChild = child
|
|
780
|
+
// 子进程退出后清掉引用,避免 SIGINT 时 kill 一个已结束的进程
|
|
781
|
+
child.once?.('exit', () => { if (state.currentChild === child) state.currentChild = null })
|
|
782
|
+
},
|
|
783
|
+
},
|
|
784
|
+
models,
|
|
785
|
+
model,
|
|
786
|
+
locale,
|
|
787
|
+
shellDesc,
|
|
788
|
+
// 单轮工具调用上限:来自全局配置 aiMaxToolIterations(loadConfig 已规范化),
|
|
789
|
+
// 读不到时由 runAgentTurn 兜底为 200
|
|
790
|
+
maxToolIterations: cfg.aiMaxToolIterations,
|
|
791
|
+
// 单轮请求的上下文预算(token 上限/字符换算/条数上限/单条 user 截断线)。
|
|
792
|
+
// 解析公式与默认值都在 context.js 的 resolveRequestBudget —— CLI 与 Web 共用
|
|
793
|
+
// 同一份,别在这里另算一套。turn.js 每轮把它透传给 prepareRequestMessages。
|
|
794
|
+
requestBudget: resolveRequestBudget(cfg.aiMaxRequestTokens),
|
|
795
|
+
currentChild: null,
|
|
796
|
+
abortController: null,
|
|
797
|
+
cancelRequested: false,
|
|
798
|
+
busy: false,
|
|
799
|
+
showThinking: true, // /think 切换:是否回显模型的思考过程
|
|
800
|
+
thinkingMode: 'full',
|
|
801
|
+
fullTools: false,
|
|
802
|
+
lastTurnStats: null,
|
|
803
|
+
sessionStats: null,
|
|
804
|
+
pendingImages: [], // Alt+V / /image 附加的待发送图片 [{path, bytes}]
|
|
805
|
+
pasting: false, // 剪贴板读取进行中(防 Alt+V 连打并发)
|
|
806
|
+
inWizard: false, // /addmodel 交互式向导进行中:忽略 REPL 的 line 事件
|
|
807
|
+
rl: null, // REPL readline 引用(供 /addmodel 复用做 rl.question)
|
|
808
|
+
extensions, // 已加载的 Skill + MCP 扩展(见 extensions.js)
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// 持久化当前会话到磁盘(供 Web UI 读取)
|
|
812
|
+
let saving = Promise.resolve()
|
|
813
|
+
async function persistSession() {
|
|
814
|
+
if (state.messages.length <= 1) return
|
|
815
|
+
try {
|
|
816
|
+
const title = autoTitle(state.messages)
|
|
817
|
+
const sessionId = state.sessionId
|
|
818
|
+
const snapshot = JSON.parse(JSON.stringify({
|
|
819
|
+
version: 1,
|
|
820
|
+
sessionId: state.sessionId,
|
|
821
|
+
title,
|
|
822
|
+
source: 'cli',
|
|
823
|
+
cwd: state.ctx.cwd,
|
|
824
|
+
model: modelLabel(state.model),
|
|
825
|
+
createdAt: state.sessionCreatedAt,
|
|
826
|
+
updatedAt: new Date().toISOString(),
|
|
827
|
+
messages: repairToolHistory(state.messages),
|
|
828
|
+
lastTurnStats: state.lastTurnStats,
|
|
829
|
+
sessionStats: state.sessionStats,
|
|
830
|
+
}))
|
|
831
|
+
saving = saving.catch(() => {}).then(() => writeSession(sessionId, snapshot))
|
|
832
|
+
await saving
|
|
833
|
+
enforceRetention().catch(() => {})
|
|
834
|
+
} catch (err) {
|
|
835
|
+
// 持久化失败不影响 CLI 正常使用
|
|
836
|
+
printDim(`(会话保存失败: ${err.message})`)
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
state.persistSession = persistSession
|
|
840
|
+
|
|
841
|
+
// SIGINT 时中止进行中的 LLM 请求 + 正在跑的子命令
|
|
842
|
+
// (进程退出与全局子进程 drain 由 gitCommit.js 的统一 handler 负责,这里只做快速止血)
|
|
843
|
+
registerCleanup('aiAgent', async () => {
|
|
844
|
+
state.cancelRequested = true
|
|
845
|
+
try { state.abortController?.abort() } catch (_) {}
|
|
846
|
+
await terminateCommand(state.currentChild)
|
|
847
|
+
// Ctrl+C 硬退时终端仍停在 bracketed paste 模式 → 之后 shell 里粘贴会带出 200~/201~ 乱码。
|
|
848
|
+
// 这个开关是终端层的,进程死了没人替它关,必须在这里补一刀。
|
|
849
|
+
disableBracketedPaste()
|
|
850
|
+
// MCP 子进程必须显式收掉:它们挂在 stdio 上,不收会让进程无法自然退出
|
|
851
|
+
await extensions.close()
|
|
852
|
+
await persistSession()
|
|
853
|
+
})
|
|
854
|
+
|
|
855
|
+
// 扩展摘要:一条 dim 行,没有装扩展时完全静默(extNone 为空串)
|
|
856
|
+
const extSummary = extensions.skills.length || extensions.tools.length
|
|
857
|
+
? t.extLoaded(extensions.skills.length, extensions.tools.length)
|
|
858
|
+
: t.extNone
|
|
859
|
+
|
|
860
|
+
// 单发模式:g ai "帮我改个 bug"(位置参数拼成 prompt,跑一轮就退出)
|
|
861
|
+
const oneShot = argv.filter(a => !a.startsWith('-')).join(' ').trim()
|
|
862
|
+
if (oneShot) {
|
|
863
|
+
console.log(chalk.dim(`[${t.bannerModel}] ${modelLabel(model)} · [${t.bannerCwd}] ${cwd}`))
|
|
864
|
+
if (extSummary) printDim(extSummary)
|
|
865
|
+
state.abortController = new AbortController()
|
|
866
|
+
const stats = await runAgentTurn(state, oneShot, t)
|
|
867
|
+
if (stats.status === 'completed') printDim(t.oneShotDone)
|
|
868
|
+
else process.exitCode = stats.status === 'cancelled' ? 130 : 1
|
|
869
|
+
return
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// ── 交互模式(REPL)──
|
|
873
|
+
printBanner({
|
|
874
|
+
title: 'Zen GitSync — AI Agent',
|
|
875
|
+
modelLabel: modelLabel(model),
|
|
876
|
+
baseURL: model.baseURL || '',
|
|
877
|
+
cwd,
|
|
878
|
+
modelText: t.bannerModel,
|
|
879
|
+
cwdText: t.bannerCwd,
|
|
880
|
+
tip: t.bannerTip,
|
|
881
|
+
})
|
|
882
|
+
if (extSummary) printDim(extSummary)
|
|
883
|
+
|
|
884
|
+
const rl = readline.createInterface({
|
|
885
|
+
input: process.stdin,
|
|
886
|
+
output: process.stdout,
|
|
887
|
+
// 颜色闭合,避免提示符的样式泄漏到后续输出。
|
|
888
|
+
prompt: chalk.cyan.bold(t.prompt + ' '),
|
|
889
|
+
historySize: 200,
|
|
890
|
+
})
|
|
891
|
+
state.rl = rl // 供 /addmodel 等需要 rl.question 的斜杠命令复用
|
|
892
|
+
|
|
893
|
+
// 多行粘贴:终端里粘一整段文本时,readline 会把每个 \n 当 Enter —— 一次粘贴变成多次提交,
|
|
894
|
+
// 第 2 条起还会撞上下面的 busy 分支被丢掉。这里在数据进 readline 之前把整段捕获成占位符,
|
|
895
|
+
// 提交时再还原(详见 paste.js)。必须在处理 line 事件之前装好。
|
|
896
|
+
const pasteCapture = installPasteCapture(rl, {
|
|
897
|
+
formatToken: ({ index, lines }) => t.pasteToken(index, lines),
|
|
898
|
+
})
|
|
899
|
+
|
|
900
|
+
// ask_user pauses the tool loop inside the same readline session. The REPL
|
|
901
|
+
// line handler is bypassed while this small wizard owns the input line.
|
|
902
|
+
state.ctx.askUser = ({ question, options = [], allowFreeText = true, multiple = false }) => {
|
|
903
|
+
const zh = !String(state.locale || '').startsWith('en')
|
|
904
|
+
const safeOptions = Array.isArray(options) ? options.filter(Boolean).slice(0, 20) : []
|
|
905
|
+
// 多选只在真的给了选项时才有意义(与 tools.js 的归一化口径一致)
|
|
906
|
+
const multi = multiple === true && safeOptions.length > 0
|
|
907
|
+
state.inWizard = true
|
|
908
|
+
if (safeOptions.length > 0) {
|
|
909
|
+
console.log(chalk.cyan(`\n${question}`))
|
|
910
|
+
safeOptions.forEach((option, index) => console.log(` ${chalk.cyan(String(index + 1))}. ${option}`))
|
|
911
|
+
} else {
|
|
912
|
+
console.log(chalk.cyan(`\n${question}`))
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
return new Promise(resolve => {
|
|
916
|
+
let settled = false
|
|
917
|
+
const questionController = new AbortController()
|
|
918
|
+
const parentSignal = state.abortController?.signal
|
|
919
|
+
const cleanup = () => {
|
|
920
|
+
rl.removeListener?.('close', onClose)
|
|
921
|
+
rl.removeListener?.('operationCancel', onCancel)
|
|
922
|
+
rl.input?.removeListener?.('keypress', onKeypressCancel)
|
|
923
|
+
parentSignal?.removeEventListener?.('abort', onParentAbort)
|
|
924
|
+
}
|
|
925
|
+
const finish = answer => {
|
|
926
|
+
if (settled) return
|
|
927
|
+
settled = true
|
|
928
|
+
cleanup()
|
|
929
|
+
state.inWizard = false
|
|
930
|
+
resolve(answer)
|
|
931
|
+
}
|
|
932
|
+
const cancel = () => {
|
|
933
|
+
if (settled) return
|
|
934
|
+
questionController.abort()
|
|
935
|
+
finish('Cancelled by user; the question was not answered.')
|
|
936
|
+
}
|
|
937
|
+
const onClose = () => cancel()
|
|
938
|
+
const onCancel = () => {
|
|
939
|
+
state.cancelRequested = true
|
|
940
|
+
try { state.abortController?.abort() } catch (_) {}
|
|
941
|
+
cancel()
|
|
942
|
+
}
|
|
943
|
+
const onParentAbort = () => cancel()
|
|
944
|
+
const onKeypressCancel = (_str, key) => {
|
|
945
|
+
if (!key?.ctrl || key.name !== 'c') return
|
|
946
|
+
key.name = 'escape'
|
|
947
|
+
key.sequence = ''
|
|
948
|
+
key.ctrl = false
|
|
949
|
+
key.meta = false
|
|
950
|
+
key.shift = false
|
|
951
|
+
onCancel()
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
rl.once('close', onClose)
|
|
955
|
+
rl.once('operationCancel', onCancel)
|
|
956
|
+
rl.input?.prependListener?.('keypress', onKeypressCancel)
|
|
957
|
+
parentSignal?.addEventListener?.('abort', onParentAbort, { once: true })
|
|
958
|
+
|
|
959
|
+
const promptAnswer = () => {
|
|
960
|
+
const hint = safeOptions.length > 0
|
|
961
|
+
? (multi
|
|
962
|
+
? (zh ? '请输入序号(逗号分隔可多选)' : 'Choose numbers, comma-separated for multiple')
|
|
963
|
+
: allowFreeText ? (zh ? '请输入序号或直接输入回答' : 'Choose a number or type an answer') : (zh ? '请输入序号' : 'Choose a number'))
|
|
964
|
+
: (zh ? '请输入回答' : 'Type your answer')
|
|
965
|
+
rl.question(`${hint}: `, { signal: questionController.signal }, answer => {
|
|
966
|
+
if (settled) return
|
|
967
|
+
const trimmed = String(answer || '').trim()
|
|
968
|
+
if (multi) {
|
|
969
|
+
// 逗号分隔的序号 → 选项列表(JSON 数组字符串回给模型,与 Web 端口径一致);
|
|
970
|
+
// 整行不是合法序号串时,允许自由输入的话就把它当作唯一一项
|
|
971
|
+
const parts = trimmed.split(/[,,]/).map(part => part.trim()).filter(Boolean)
|
|
972
|
+
const indexes = parts.map(part => Number.parseInt(part, 10))
|
|
973
|
+
const allIndexes = parts.length > 0 && indexes.every((n, i) => Number.isInteger(n) && String(n) === parts[i] && n >= 1 && n <= safeOptions.length)
|
|
974
|
+
if (allIndexes) {
|
|
975
|
+
finish(JSON.stringify(indexes.map(n => safeOptions[n - 1])))
|
|
976
|
+
return
|
|
977
|
+
}
|
|
978
|
+
if (allowFreeText && trimmed) {
|
|
979
|
+
finish(JSON.stringify([trimmed]))
|
|
980
|
+
return
|
|
981
|
+
}
|
|
982
|
+
console.log(chalk.yellow(zh ? `请输入 1-${safeOptions.length} 之间的序号,多个用逗号分隔` : `Enter numbers from 1 to ${safeOptions.length}, comma-separated`))
|
|
983
|
+
promptAnswer()
|
|
984
|
+
return
|
|
985
|
+
}
|
|
986
|
+
if (safeOptions.length > 0) {
|
|
987
|
+
const index = Number.parseInt(trimmed, 10)
|
|
988
|
+
if (Number.isInteger(index) && index >= 1 && index <= safeOptions.length) {
|
|
989
|
+
finish(safeOptions[index - 1])
|
|
990
|
+
return
|
|
991
|
+
}
|
|
992
|
+
if (!allowFreeText || !trimmed) {
|
|
993
|
+
console.log(chalk.yellow(zh ? `请输入 1-${safeOptions.length} 之间的序号` : `Enter a number from 1 to ${safeOptions.length}`))
|
|
994
|
+
promptAnswer()
|
|
995
|
+
return
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
if (!trimmed) {
|
|
999
|
+
console.log(chalk.yellow(zh ? '回答不能为空' : 'Answer cannot be empty'))
|
|
1000
|
+
promptAnswer()
|
|
1001
|
+
return
|
|
1002
|
+
}
|
|
1003
|
+
finish(trimmed)
|
|
1004
|
+
})
|
|
1005
|
+
}
|
|
1006
|
+
promptAnswer()
|
|
1007
|
+
})
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
// 输入提示符管理。
|
|
1011
|
+
//
|
|
1012
|
+
// readline 的 _refreshLine() 用 prevRows 追踪“提示符占了几行”,
|
|
1013
|
+
// 下次刷新时 moveCursor(0, -prevRows) 上移光标。但模型输出直接写
|
|
1014
|
+
// stdout,readline 不知道这些行,导致 prevRows 过期 → 光标错位。
|
|
1015
|
+
//
|
|
1016
|
+
// 修复:每次画提示符前,先写 \r\n 确保光标在新行第 0 列,
|
|
1017
|
+
// 然后重置 prevRows=0 告诉 readline “当前行就是提示符行”。
|
|
1018
|
+
// 这样 _refreshLine() 的 clearScreenDown() 只清当前空行,不影响模型输出。
|
|
1019
|
+
|
|
1020
|
+
const showPrompt = () => {
|
|
1021
|
+
if (rl.closed) return
|
|
1022
|
+
// 双保险:确保 stdin 处于 flowing 模式(ora 的 stdin-discarder 可能在
|
|
1023
|
+
// stop() 时 pause 了 stdin,即使 discardStdin:false 已设,仍防止其他
|
|
1024
|
+
// 库意外 pause)
|
|
1025
|
+
if (process.stdin.isTTY && !process.stdin.readableFlowing) {
|
|
1026
|
+
process.stdin.resume()
|
|
1027
|
+
}
|
|
1028
|
+
// bracketed paste 是终端层的开关,跑过的外部命令(全屏 TUI 之类)退出时可能把它关掉,
|
|
1029
|
+
// 之后粘贴又会退回"每个 \n 一次提交"。它是幂等的,每次画提示符时重申一次。
|
|
1030
|
+
enableBracketedPaste()
|
|
1031
|
+
if (process.stdout.isTTY) {
|
|
1032
|
+
// 确保光标在新行第 0 列(\r 回行首,\n 换行)
|
|
1033
|
+
process.stdout.write('\r\n')
|
|
1034
|
+
// 重置 readline 内部光标追踪
|
|
1035
|
+
rl.prevRows = 0
|
|
1036
|
+
}
|
|
1037
|
+
rl.prompt()
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
// 安全版:防止 finally 块里的异常变成 unhandledRejection 杀掉会话
|
|
1041
|
+
const safeShowPrompt = () => {
|
|
1042
|
+
try {
|
|
1043
|
+
showPrompt()
|
|
1044
|
+
} catch (_) {
|
|
1045
|
+
try { rl.prompt() } catch { /* 给予宽容 */ }
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
// 仅重画提示符,不写 \r\n(用于空输入、busy 等不需要换行的场景)
|
|
1050
|
+
const safeRefreshPrompt = () => {
|
|
1051
|
+
try {
|
|
1052
|
+
if (rl.closed) return
|
|
1053
|
+
enableBracketedPaste()
|
|
1054
|
+
rl.prevRows = 0
|
|
1055
|
+
rl.prompt()
|
|
1056
|
+
} catch (_) {
|
|
1057
|
+
try { rl.prompt() } catch { /* noop */ }
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
// 在提示符上方插一行通知(粘贴进度等)。
|
|
1062
|
+
const notifyAbovePrompt = (text) => {
|
|
1063
|
+
console.log(text)
|
|
1064
|
+
if (!state.busy) safeShowPrompt()
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
// ── 斜杠命令即时提示(支持 ↑↓ 切换 / Tab、Enter 补全) ──
|
|
1068
|
+
// 在提示符下方浮现匹配到的命令,随输入过滤;不移动光标(用 DECSC/DECRC 保存-恢复)。
|
|
1069
|
+
// slashHintRows 记录当前提示占了几行,下次刷新/提交时据此擦除,避免残影。
|
|
1070
|
+
// slashHintBaseLine:本次 hint 显示时"输入行的基线值" — ↑↓ 时 readline 已经把
|
|
1071
|
+
// rl.line 改成历史上一行,我们用基线回滚 + _refreshLine() 重绘,让用户视觉上看
|
|
1072
|
+
// 不到"按 ↑ 输入行变了",只看到 hint 高亮跳了一格。
|
|
1073
|
+
let slashHintRows = 0
|
|
1074
|
+
let slashHintBaseLine = ''
|
|
1075
|
+
let slashHintMatches = []
|
|
1076
|
+
let slashHintIndex = 0
|
|
1077
|
+
const eraseSlashHint = () => {
|
|
1078
|
+
if (!process.stdout.isTTY || slashHintRows === 0) return
|
|
1079
|
+
// 保存光标 → 下移到提示区逐行清空 → 恢复光标
|
|
1080
|
+
let seq = '\x1b7'
|
|
1081
|
+
for (let i = 0; i < slashHintRows; i++) seq += '\x1b[1B\x1b[2K'
|
|
1082
|
+
seq += '\x1b8'
|
|
1083
|
+
process.stdout.write(seq)
|
|
1084
|
+
slashHintRows = 0
|
|
1085
|
+
}
|
|
1086
|
+
const drawSlashHint = () => {
|
|
1087
|
+
const body = renderSlashHintBody(slashHintMatches, slashHintIndex, state.locale)
|
|
1088
|
+
const hadVisibleHint = slashHintRows > 0
|
|
1089
|
+
eraseSlashHint()
|
|
1090
|
+
if (!body) return
|
|
1091
|
+
const rows = body.split('\n')
|
|
1092
|
+
if (!hadVisibleHint) {
|
|
1093
|
+
// CSI B(光标下移)到达终端底部后只会停在最后一行,不会自动滚屏,
|
|
1094
|
+
// 所以候选项会被裁掉。第一次显示提示时先用换行真实预留空间,
|
|
1095
|
+
// 再上移相同行数回到输入行;无论中途是否发生滚屏,都能回到原内容所在行。
|
|
1096
|
+
let reserve = ''
|
|
1097
|
+
for (let i = 0; i < rows.length; i++) reserve += '\n\x1b[2K'
|
|
1098
|
+
reserve += `\x1b[${rows.length}A`
|
|
1099
|
+
process.stdout.write(reserve)
|
|
1100
|
+
}
|
|
1101
|
+
// 保存光标,逐行下移打印(每行先清行防止与旧内容叠字),最后恢复光标回输入行
|
|
1102
|
+
let seq = '\x1b7'
|
|
1103
|
+
for (const r of rows) seq += '\x1b[1B\x1b[2K\r' + r
|
|
1104
|
+
seq += '\x1b8'
|
|
1105
|
+
process.stdout.write(seq)
|
|
1106
|
+
slashHintRows = rows.length
|
|
1107
|
+
}
|
|
1108
|
+
// 重置 hint 上下文:重新计算匹配 + 重画。输入字符变化时调用。
|
|
1109
|
+
const refreshSlashHint = () => {
|
|
1110
|
+
if (!process.stdout.isTTY || state.busy || state.inWizard) {
|
|
1111
|
+
eraseSlashHint()
|
|
1112
|
+
slashHintMatches = []
|
|
1113
|
+
slashHintIndex = 0
|
|
1114
|
+
slashHintBaseLine = ''
|
|
1115
|
+
return
|
|
1116
|
+
}
|
|
1117
|
+
slashHintBaseLine = rl.line
|
|
1118
|
+
slashHintMatches = filterSlashCommands(rl.line, state.locale)
|
|
1119
|
+
// 匹配列表缩短时夹紧 index(增删交替时不会越界)
|
|
1120
|
+
slashHintIndex = slashHintIndex < slashHintMatches.length ? slashHintIndex : 0
|
|
1121
|
+
drawSlashHint()
|
|
1122
|
+
}
|
|
1123
|
+
// ↑↓ / Shift+Tab:readline 已经把 rl.line 改成历史行或没动 → 强制恢复基线并重绘输入行
|
|
1124
|
+
const restoreInputLineToBaseline = () => {
|
|
1125
|
+
if (rl.line === slashHintBaseLine) return
|
|
1126
|
+
rl.line = slashHintBaseLine
|
|
1127
|
+
rl.cursor = slashHintBaseLine.length
|
|
1128
|
+
if (typeof rl._refreshLine === 'function') rl._refreshLine()
|
|
1129
|
+
}
|
|
1130
|
+
// Tab / Enter:把 rl.line 替换为补全文本 + 清掉 hint 状态。
|
|
1131
|
+
// Enter 会先被 readline 提交,因此由下方 line handler 在处理命令前调用本函数。
|
|
1132
|
+
const applyHintCompletion = (newLine) => {
|
|
1133
|
+
rl.line = newLine
|
|
1134
|
+
rl.cursor = newLine.length
|
|
1135
|
+
eraseSlashHint()
|
|
1136
|
+
slashHintMatches = []
|
|
1137
|
+
slashHintIndex = 0
|
|
1138
|
+
slashHintRows = 0
|
|
1139
|
+
slashHintBaseLine = newLine
|
|
1140
|
+
if (typeof rl._refreshLine === 'function') rl._refreshLine()
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
const neutralizeKeyForReadline = (key) => {
|
|
1144
|
+
key.name = 'escape'
|
|
1145
|
+
key.sequence = ''
|
|
1146
|
+
key.ctrl = false
|
|
1147
|
+
key.meta = false
|
|
1148
|
+
key.shift = false
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
// readline 自己会优先处理 Enter/Ctrl+C。用 prependListener 在它之前完成补全
|
|
1152
|
+
// 或取消当前操作,再把本次按键变成 readline 会忽略的 Esc。
|
|
1153
|
+
const interceptInteractiveControlKeys = (_ch, key) => {
|
|
1154
|
+
if (!key) return
|
|
1155
|
+
if (key.ctrl && key.name === 'c') {
|
|
1156
|
+
if (state.inWizard) {
|
|
1157
|
+
neutralizeKeyForReadline(key)
|
|
1158
|
+
rl.emit('operationCancel')
|
|
1159
|
+
return
|
|
1160
|
+
}
|
|
1161
|
+
if (state.busy) {
|
|
1162
|
+
neutralizeKeyForReadline(key)
|
|
1163
|
+
state.cancelRequested = true
|
|
1164
|
+
try { state.abortController?.abort() } catch (_) {}
|
|
1165
|
+
void terminateCommand(state.currentChild)
|
|
1166
|
+
return
|
|
1167
|
+
}
|
|
1168
|
+
if (slashHintRows > 0) {
|
|
1169
|
+
neutralizeKeyForReadline(key)
|
|
1170
|
+
applyHintCompletion('')
|
|
1171
|
+
return
|
|
1172
|
+
}
|
|
1173
|
+
return
|
|
1174
|
+
}
|
|
1175
|
+
if (key.name !== 'return' && key.name !== 'enter') return
|
|
1176
|
+
if (slashHintRows === 0 || slashHintMatches.length === 0 || state.busy || state.inWizard) return
|
|
1177
|
+
const selected = slashHintMatches[slashHintIndex]
|
|
1178
|
+
if (!selected) return
|
|
1179
|
+
applyHintCompletion(selected.cmd + ' ')
|
|
1180
|
+
neutralizeKeyForReadline(key)
|
|
1181
|
+
}
|
|
1182
|
+
rl.input.prependListener('keypress', interceptInteractiveControlKeys)
|
|
1183
|
+
|
|
1184
|
+
// Alt+V 粘贴剪贴板图片(node 把 ESC+v 解析为 meta+v;部分终端不发 Alt,可用 /image 兜底)
|
|
1185
|
+
const pasteFromClipboard = async () => {
|
|
1186
|
+
if (state.pasting) return
|
|
1187
|
+
state.pasting = true
|
|
1188
|
+
try {
|
|
1189
|
+
notifyAbovePrompt(chalk.dim(t.imagePasting))
|
|
1190
|
+
const img = await readClipboardImage()
|
|
1191
|
+
if (!img) {
|
|
1192
|
+
notifyAbovePrompt(chalk.yellow(t.imageEmpty))
|
|
1193
|
+
} else {
|
|
1194
|
+
state.pendingImages.push(img)
|
|
1195
|
+
notifyAbovePrompt(chalk.green(t.imageAttached(state.pendingImages.length, formatBytes(img.bytes))))
|
|
1196
|
+
}
|
|
1197
|
+
} catch (err) {
|
|
1198
|
+
// 不能让粘贴异常变成 unhandledRejection 杀掉会话
|
|
1199
|
+
printError(`粘贴图片失败: ${err.message}`)
|
|
1200
|
+
} finally {
|
|
1201
|
+
state.pasting = false
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
rl.input.on('keypress', (ch, key) => {
|
|
1205
|
+
if (!key) return
|
|
1206
|
+
|
|
1207
|
+
// ── 斜杠命令提示专用键盘:↑↓/Tab/Enter/Shift+Tab/Esc ──
|
|
1208
|
+
// 仅在 hint 已显示且有匹配项时拦截,readline 的 default 行为(历史浏览等)
|
|
1209
|
+
// 我们在 handler 里事后回滚 rl.line 来抵消
|
|
1210
|
+
const action = parseKeyForSlashHint(key)
|
|
1211
|
+
const hintActive = action === 'prev' || action === 'next' || action === 'complete' || action === 'cancel'
|
|
1212
|
+
? (slashHintRows > 0 && slashHintMatches.length > 0 && !state.busy && !state.inWizard)
|
|
1213
|
+
: false
|
|
1214
|
+
|
|
1215
|
+
if (hintActive) {
|
|
1216
|
+
if (action === 'prev' || action === 'next') {
|
|
1217
|
+
const n = slashHintMatches.length
|
|
1218
|
+
slashHintIndex = action === 'prev'
|
|
1219
|
+
? (slashHintIndex - 1 + n) % n
|
|
1220
|
+
: (slashHintIndex + 1) % n
|
|
1221
|
+
restoreInputLineToBaseline()
|
|
1222
|
+
drawSlashHint()
|
|
1223
|
+
return // 不再走 default update + Alt+V 分支
|
|
1224
|
+
}
|
|
1225
|
+
if (action === 'complete') {
|
|
1226
|
+
// Tab:把输入行替换为选中命令 + 末尾空格(便于继续打参数),
|
|
1227
|
+
// 光标跳到末尾;之后不再触发 hint(已经精确选了某个命令)
|
|
1228
|
+
const sel = slashHintMatches[slashHintIndex]
|
|
1229
|
+
const newLine = sel.cmd + ' '
|
|
1230
|
+
applyHintCompletion(newLine)
|
|
1231
|
+
return
|
|
1232
|
+
}
|
|
1233
|
+
if (action === 'cancel') {
|
|
1234
|
+
// Esc:只清 hint,不动输入行
|
|
1235
|
+
eraseSlashHint()
|
|
1236
|
+
slashHintMatches = []
|
|
1237
|
+
slashHintIndex = 0
|
|
1238
|
+
return
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
// Enter 走默认 readline 提交,line 事件里把选中项恢复到输入框;
|
|
1243
|
+
// 其他普通按键:重算匹配 + 重画 hint
|
|
1244
|
+
if (!(key.name === 'return' || key.name === 'enter')) {
|
|
1245
|
+
refreshSlashHint()
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
// Alt+V 粘贴剪贴板图片(node 把 ESC+v 解析为 meta+v;部分终端不发 Alt,可用 /image 兜底)
|
|
1249
|
+
const isAltV = (key.meta && key.name === 'v')
|
|
1250
|
+
|| (typeof key.sequence === 'string' && key.sequence.length === 2
|
|
1251
|
+
&& key.sequence.charCodeAt(0) === 27 && key.sequence[1] === 'v')
|
|
1252
|
+
if (isAltV) pasteFromClipboard()
|
|
1253
|
+
})
|
|
1254
|
+
|
|
1255
|
+
// 初始提示符:banner 后直接画(不需要 \r\n)
|
|
1256
|
+
rl.prevRows = 0
|
|
1257
|
+
rl.prompt()
|
|
1258
|
+
|
|
1259
|
+
rl.on('line', async (line) => {
|
|
1260
|
+
// 提交时光标已落到提示符下一行(即提示区起点),直接清到屏幕末尾擦掉整块提示,
|
|
1261
|
+
// 比 DECSC/DECRC 相对定位更稳(此时光标不在输入行)
|
|
1262
|
+
if (process.stdout.isTTY && slashHintRows > 0) {
|
|
1263
|
+
process.stdout.write('\x1b[0J')
|
|
1264
|
+
slashHintRows = 0
|
|
1265
|
+
}
|
|
1266
|
+
// /addmodel 等交互式向导进行中时,用户的回答由向导自身的 rl.question 处理,
|
|
1267
|
+
// 不应进入 REPL 的正常输入流程(否则会把向导的答案当成命令/消息发给模型)
|
|
1268
|
+
if (state.inWizard) return
|
|
1269
|
+
// 把输入行里的粘贴占位符还原成原文(带真实换行)—— 在判断是不是斜杠命令之前,
|
|
1270
|
+
// 这样 `请写进 .env [粘贴 #1]` 这种混排也能正确展开
|
|
1271
|
+
const pasted = pasteCapture.expand(line)
|
|
1272
|
+
if (pasted.stale.length > 0) printWarn(t.pasteExpired(pasted.stale.length))
|
|
1273
|
+
const input = pasted.text.trim()
|
|
1274
|
+
|
|
1275
|
+
if (input.startsWith('/')) {
|
|
1276
|
+
const r = await handleSlashCommand(state, input, t)
|
|
1277
|
+
if (r === 'exit') { rl.close(); return }
|
|
1278
|
+
safeRefreshPrompt()
|
|
1279
|
+
return
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
if (!input) { safeRefreshPrompt(); return }
|
|
1283
|
+
if (state.busy) {
|
|
1284
|
+
printDim(t.busy)
|
|
1285
|
+
safeRefreshPrompt()
|
|
1286
|
+
return
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
// 取出待发送图片(取出即清空队列,用户每条消息独立决定带不带图)
|
|
1290
|
+
const images = state.pendingImages.splice(0)
|
|
1291
|
+
if (images.length > 0) printDim(t.imageSending(images.length))
|
|
1292
|
+
|
|
1293
|
+
// 用了粘贴的这条消息:输入行里只有占位符,这里把真正发出去的内容亮一遍,
|
|
1294
|
+
// 否则回看终端记录只剩一个 [粘贴 #N],等于把内容藏起来了。
|
|
1295
|
+
// 只在 TTY 里回显 —— 管道/CI 下 readline 本来就不回显输入行(没有占位符这回事),
|
|
1296
|
+
// 再补一块只会粘在提示符后面变成半行。
|
|
1297
|
+
if (pasted.used > 0 && process.stdout.isTTY) process.stdout.write(renderPasteEcho(input, { locale: state.locale }))
|
|
1298
|
+
|
|
1299
|
+
state.busy = true
|
|
1300
|
+
state.cancelRequested = false
|
|
1301
|
+
state.abortController = new AbortController()
|
|
1302
|
+
try {
|
|
1303
|
+
await runAgentTurn(state, input, t, images)
|
|
1304
|
+
} catch (err) {
|
|
1305
|
+
if (state.cancelRequested) printDim(t.operationCancelled)
|
|
1306
|
+
else printError(err.message)
|
|
1307
|
+
} finally {
|
|
1308
|
+
state.busy = false
|
|
1309
|
+
state.cancelRequested = false
|
|
1310
|
+
state.abortController = null
|
|
1311
|
+
safeShowPrompt()
|
|
1312
|
+
}
|
|
1313
|
+
})
|
|
1314
|
+
|
|
1315
|
+
rl.on('close', () => {
|
|
1316
|
+
if (state.busy) {
|
|
1317
|
+
state.cancelRequested = true
|
|
1318
|
+
state.abortController?.abort()
|
|
1319
|
+
void terminateCommand(state.currentChild)
|
|
1320
|
+
}
|
|
1321
|
+
rl.input.removeListener('keypress', interceptInteractiveControlKeys)
|
|
1322
|
+
pasteCapture.dispose() // 卸载数据改写 + 把终端从 bracketed paste 模式里放出来
|
|
1323
|
+
printWarn('\n' + t.bye)
|
|
1324
|
+
})
|
|
1325
|
+
|
|
1326
|
+
// 全局安全网:任何未捕获的 Promise rejection / 异常都不应该静默杀掉
|
|
1327
|
+
// 整个 REPL 会话。这里记录到 stderr 但不退出,让 readline 继续运行。
|
|
1328
|
+
const rejectionHandler = (reason) => {
|
|
1329
|
+
const msg = reason instanceof Error ? reason.message : String(reason)
|
|
1330
|
+
printError(`内部错误(已捕获,会话继续): ${msg}`)
|
|
1331
|
+
safeShowPrompt()
|
|
1332
|
+
}
|
|
1333
|
+
process.on('unhandledRejection', rejectionHandler)
|
|
1334
|
+
|
|
1335
|
+
// 等 readline 关闭后返回(进程随后自然退出)
|
|
1336
|
+
await new Promise((resolve) => rl.on('close', resolve))
|
|
1337
|
+
|
|
1338
|
+
// 清理:移除本次会话注册的全局处理器
|
|
1339
|
+
process.removeListener('unhandledRejection', rejectionHandler)
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
export default { runAiAgent }
|