zen-gitsync 2.16.9 → 2.16.10
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 +123 -15
- package/src/cli/ai/modelSetup.js +68 -11
- package/src/cli/ai/modelSetup.test.js +36 -7
- package/src/cli/ai/termui.js +81 -6
- package/src/cli/ai/termui.test.js +130 -1
- package/src/ui/public/assets/AppVersionBadge-CZ3UbiAu.js +2 -0
- package/src/ui/public/assets/{BranchSelector-DgxIB9XY.js → BranchSelector-Kwcj7e0Z.js} +1 -1
- package/src/ui/public/assets/{CommitForm-CTa0pupJ.js → CommitForm-DueeSB7T.js} +1 -1
- package/src/ui/public/assets/{EditorView-BOZPwTxr.js → EditorView-B2gm2OW1.js} +0 -0
- package/src/ui/public/assets/{FlowOrchestrationWorkspace-CVYPdN7a.js → FlowOrchestrationWorkspace-BRUlRQwp.js} +1 -1
- package/src/ui/public/assets/{LogList-B4rrapVn.js → LogList-Do3C4BmA.js} +1 -1
- package/src/ui/public/assets/{RemoteRepoCard-B-5MDx8k.js → RemoteRepoCard-D582mbhK.js} +1 -1
- package/src/ui/public/assets/{SourceMapView-C3gZ5s1r.js → SourceMapView-gQRnN0Vj.js} +1 -1
- package/src/ui/public/assets/{WorkbenchView-BE53JHac.js → WorkbenchView-CbWSllXy.js} +1 -1
- package/src/ui/public/assets/{index-Ds_2iFkX.js → index-DvrM8frM.js} +4 -4
- package/src/ui/public/index.html +1 -1
- package/src/ui/public/assets/AppVersionBadge-Cwg6vOMz.js +0 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zen-gitsync",
|
|
3
|
-
"version": "2.16.
|
|
3
|
+
"version": "2.16.10",
|
|
4
4
|
"description": "Auto commit, scheduled sync, and visual GUI for Git. Run `g` in any repo for one-key commit & push, AI commit messages, scheduled background sync, and a drag-and-drop workflow builder.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"type": "module",
|
package/src/cli/ai/agent.js
CHANGED
|
@@ -49,9 +49,10 @@ import { TOOL_DEFINITIONS, executeTool } from './tools.js'
|
|
|
49
49
|
import { createThinkFilter } from './streamFilter.js'
|
|
50
50
|
import {
|
|
51
51
|
printBanner, printHelpPanel,
|
|
52
|
-
filterSlashCommands, renderSlashHintBody,
|
|
52
|
+
filterSlashCommands, renderSlashHintBody, parseKeyForSlashHint,
|
|
53
53
|
startSpinner, createAssistantWriter,
|
|
54
54
|
summarizeToolArgs, printToolHeader, printToolResult,
|
|
55
|
+
formatDuration,
|
|
55
56
|
printOk, printWarn, printError, printDim,
|
|
56
57
|
} from './termui.js'
|
|
57
58
|
import { readClipboardImage, checkImageFile, imageToDataUrl, formatBytes } from './images.js'
|
|
@@ -488,6 +489,7 @@ async function runAgentTurn(state, userText, t, images = []) {
|
|
|
488
489
|
// 发送前消毒:确保历史里没有空 content 的消息(部分 provider 报 2013)
|
|
489
490
|
sanitizeMessages(state.messages)
|
|
490
491
|
|
|
492
|
+
const llmStart = performance.now()
|
|
491
493
|
let result
|
|
492
494
|
try {
|
|
493
495
|
result = await streamChatOnce({
|
|
@@ -512,6 +514,7 @@ async function runAgentTurn(state, userText, t, images = []) {
|
|
|
512
514
|
if (last?.role === 'user') state.messages.pop()
|
|
513
515
|
return
|
|
514
516
|
}
|
|
517
|
+
const llmDuration = performance.now() - llmStart
|
|
515
518
|
stopSpinner()
|
|
516
519
|
writer.finish()
|
|
517
520
|
|
|
@@ -532,6 +535,11 @@ async function runAgentTurn(state, userText, t, images = []) {
|
|
|
532
535
|
// content 为 null 而非空字符串:部分 provider 拒绝空字符串内容(2013 错误)
|
|
533
536
|
state.messages.push({ role: 'assistant', content: content || null, tool_calls: toolCalls })
|
|
534
537
|
|
|
538
|
+
// LLM 耗时在进入工具执行前打印(仅本轮有工具调用时显示)
|
|
539
|
+
if (llmDuration > 0) {
|
|
540
|
+
printDim(` ⏱ LLM ${formatDuration(llmDuration)}`)
|
|
541
|
+
}
|
|
542
|
+
|
|
535
543
|
for (const tc of toolCalls) {
|
|
536
544
|
const name = tc.function?.name || ''
|
|
537
545
|
const rawArgs = tc.function?.arguments || ''
|
|
@@ -550,9 +558,11 @@ async function runAgentTurn(state, userText, t, images = []) {
|
|
|
550
558
|
printToolHeader(name, summarizeToolArgs(name, args, { chars: t.chars }))
|
|
551
559
|
// 长命令执行期间给个 spinner,让用户知道没有卡死
|
|
552
560
|
const toolSpinner = startSpinner(t.toolRunning(name))
|
|
561
|
+
const toolStart = performance.now()
|
|
553
562
|
const output = await executeTool(name, args, state.ctx)
|
|
563
|
+
const toolDuration = performance.now() - toolStart
|
|
554
564
|
toolSpinner.stop()
|
|
555
|
-
printToolResult(output)
|
|
565
|
+
printToolResult(output, undefined, toolDuration)
|
|
556
566
|
state.messages.push({ role: 'tool', tool_call_id: tc.id || name, name, content: output })
|
|
557
567
|
}
|
|
558
568
|
// 工具结果全部入历史后继续循环,让模型基于结果决定下一步
|
|
@@ -923,10 +933,16 @@ export async function runAiAgent(argv = []) {
|
|
|
923
933
|
if (!state.busy) safeShowPrompt()
|
|
924
934
|
}
|
|
925
935
|
|
|
926
|
-
// ── 斜杠命令即时提示 ──
|
|
936
|
+
// ── 斜杠命令即时提示(支持 ↑↓ 切换 / Tab 补全 / Enter 提交) ──
|
|
927
937
|
// 在提示符下方浮现匹配到的命令,随输入过滤;不移动光标(用 DECSC/DECRC 保存-恢复)。
|
|
928
938
|
// slashHintRows 记录当前提示占了几行,下次刷新/提交时据此擦除,避免残影。
|
|
939
|
+
// slashHintBaseLine:本次 hint 显示时"输入行的基线值" — ↑↓ 时 readline 已经把
|
|
940
|
+
// rl.line 改成历史上一行,我们用基线回滚 + _refreshLine() 重绘,让用户视觉上看
|
|
941
|
+
// 不到"按 ↑ 输入行变了",只看到 hint 高亮跳了一格。
|
|
929
942
|
let slashHintRows = 0
|
|
943
|
+
let slashHintBaseLine = ''
|
|
944
|
+
let slashHintMatches = []
|
|
945
|
+
let slashHintIndex = 0
|
|
930
946
|
const eraseSlashHint = () => {
|
|
931
947
|
if (!process.stdout.isTTY || slashHintRows === 0) return
|
|
932
948
|
// 保存光标 → 下移到提示区逐行清空 → 恢复光标
|
|
@@ -936,11 +952,8 @@ export async function runAiAgent(argv = []) {
|
|
|
936
952
|
process.stdout.write(seq)
|
|
937
953
|
slashHintRows = 0
|
|
938
954
|
}
|
|
939
|
-
const
|
|
940
|
-
|
|
941
|
-
const matches = filterSlashCommands(rl.line, state.locale)
|
|
942
|
-
const body = renderSlashHintBody(matches)
|
|
943
|
-
// 先擦掉旧提示(行数可能变化),再画新的
|
|
955
|
+
const drawSlashHint = () => {
|
|
956
|
+
const body = renderSlashHintBody(slashHintMatches, slashHintIndex)
|
|
944
957
|
eraseSlashHint()
|
|
945
958
|
if (!body) return
|
|
946
959
|
const rows = body.split('\n')
|
|
@@ -951,6 +964,42 @@ export async function runAiAgent(argv = []) {
|
|
|
951
964
|
process.stdout.write(seq)
|
|
952
965
|
slashHintRows = rows.length
|
|
953
966
|
}
|
|
967
|
+
// 重置 hint 上下文:重新计算匹配 + 重画。输入字符变化时调用。
|
|
968
|
+
const refreshSlashHint = () => {
|
|
969
|
+
if (!process.stdout.isTTY || state.busy || state.inWizard) {
|
|
970
|
+
eraseSlashHint()
|
|
971
|
+
slashHintMatches = []
|
|
972
|
+
slashHintIndex = 0
|
|
973
|
+
slashHintBaseLine = ''
|
|
974
|
+
return
|
|
975
|
+
}
|
|
976
|
+
slashHintBaseLine = rl.line
|
|
977
|
+
slashHintMatches = filterSlashCommands(rl.line, state.locale)
|
|
978
|
+
// 匹配列表缩短时夹紧 index(增删交替时不会越界)
|
|
979
|
+
slashHintIndex = slashHintIndex < slashHintMatches.length ? slashHintIndex : 0
|
|
980
|
+
drawSlashHint()
|
|
981
|
+
}
|
|
982
|
+
// ↑↓ / Shift+Tab:readline 已经把 rl.line 改成历史行或没动 → 强制恢复基线并重绘输入行
|
|
983
|
+
const restoreInputLineToBaseline = () => {
|
|
984
|
+
if (rl.line === slashHintBaseLine) return
|
|
985
|
+
rl.line = slashHintBaseLine
|
|
986
|
+
rl.cursor = slashHintBaseLine.length
|
|
987
|
+
if (typeof rl._refreshLine === 'function') rl._refreshLine()
|
|
988
|
+
}
|
|
989
|
+
// Tab / Enter:把 rl.line 替换为补全文本 + 清掉 hint 状态
|
|
990
|
+
// Tab:补全文本末尾加空格,便于用户继续打参数(不回车)
|
|
991
|
+
// Enter:不用此函数 — 用 state.slashHintSubmitLine 标志 + line handler 替换
|
|
992
|
+
// (readline 自己处理 Enter,我们不能事后改 rl.line 触发提交)
|
|
993
|
+
const applyHintCompletion = (newLine) => {
|
|
994
|
+
rl.line = newLine
|
|
995
|
+
rl.cursor = newLine.length
|
|
996
|
+
eraseSlashHint()
|
|
997
|
+
slashHintMatches = []
|
|
998
|
+
slashHintIndex = 0
|
|
999
|
+
slashHintRows = 0
|
|
1000
|
+
slashHintBaseLine = newLine
|
|
1001
|
+
if (typeof rl._refreshLine === 'function') rl._refreshLine()
|
|
1002
|
+
}
|
|
954
1003
|
|
|
955
1004
|
// Alt+V 粘贴剪贴板图片(node 把 ESC+v 解析为 meta+v;部分终端不发 Alt,可用 /image 兜底)
|
|
956
1005
|
const pasteFromClipboard = async () => {
|
|
@@ -973,14 +1022,73 @@ export async function runAiAgent(argv = []) {
|
|
|
973
1022
|
}
|
|
974
1023
|
}
|
|
975
1024
|
rl.input.on('keypress', (ch, key) => {
|
|
976
|
-
// Enter 提交由 line 处理器负责擦除提示,这里不重画(此刻 readline 已换行,
|
|
977
|
-
// 光标不在输入行,重画会错位)
|
|
978
|
-
if (!(key && (key.name === 'return' || key.name === 'enter'))) {
|
|
979
|
-
// 每次按键后即时刷新斜杠命令提示(readline 已先处理完本次按键,rl.line 为最新值)。
|
|
980
|
-
// 非 slash / 无匹配时 renderSlashHint 会把上一次的提示擦掉。
|
|
981
|
-
renderSlashHint()
|
|
982
|
-
}
|
|
983
1025
|
if (!key) return
|
|
1026
|
+
|
|
1027
|
+
// ── 斜杠命令提示专用键盘:↑↓/Tab/Enter/Shift+Tab/Esc ──
|
|
1028
|
+
// 仅在 hint 已显示且有匹配项时拦截,readline 的 default 行为(历史浏览等)
|
|
1029
|
+
// 我们在 handler 里事后回滚 rl.line 来抵消
|
|
1030
|
+
const action = parseKeyForSlashHint(key)
|
|
1031
|
+
const hintActive = action === 'prev' || action === 'next' || action === 'complete' || action === 'submit' || action === 'cancel'
|
|
1032
|
+
? (slashHintRows > 0 && slashHintMatches.length > 0 && !state.busy && !state.inWizard)
|
|
1033
|
+
: false
|
|
1034
|
+
|
|
1035
|
+
if (hintActive) {
|
|
1036
|
+
if (action === 'prev' || action === 'next') {
|
|
1037
|
+
const n = slashHintMatches.length
|
|
1038
|
+
slashHintIndex = action === 'prev'
|
|
1039
|
+
? (slashHintIndex - 1 + n) % n
|
|
1040
|
+
: (slashHintIndex + 1) % n
|
|
1041
|
+
restoreInputLineToBaseline()
|
|
1042
|
+
drawSlashHint()
|
|
1043
|
+
return // 不再走 default update + Alt+V 分支
|
|
1044
|
+
}
|
|
1045
|
+
if (action === 'complete') {
|
|
1046
|
+
// Tab:把输入行替换为选中命令 + 末尾空格(便于继续打参数),
|
|
1047
|
+
// 光标跳到末尾;之后不再触发 hint(已经精确选了某个命令)
|
|
1048
|
+
const sel = slashHintMatches[slashHintIndex]
|
|
1049
|
+
const newLine = sel.cmd + ' '
|
|
1050
|
+
applyHintCompletion(newLine)
|
|
1051
|
+
return
|
|
1052
|
+
}
|
|
1053
|
+
if (action === 'submit') {
|
|
1054
|
+
// Enter:把 rl.line 替换为补全命令,然后手动调用 rl._line() 触发 line 事件。
|
|
1055
|
+
// 这样提交的就是补全后的命令,而不是用户敲到一半的前缀。
|
|
1056
|
+
// 必须 return — 否则 readline 自己也会按 Enter 把当前 rl.line(用户原始输入) 提交一遍。
|
|
1057
|
+
const sel = slashHintMatches[slashHintIndex]
|
|
1058
|
+
const newLine = sel.cmd
|
|
1059
|
+
console.error('[DEBUG-ENTER] sel.cmd=' + JSON.stringify(sel.cmd) + ' rl.line before=' + JSON.stringify(rl.line))
|
|
1060
|
+
rl.line = newLine
|
|
1061
|
+
rl.cursor = newLine.length
|
|
1062
|
+
eraseSlashHint()
|
|
1063
|
+
slashHintMatches = []
|
|
1064
|
+
slashHintIndex = 0
|
|
1065
|
+
slashHintRows = 0
|
|
1066
|
+
slashHintBaseLine = newLine
|
|
1067
|
+
if (typeof rl._refreshLine === 'function') rl._refreshLine()
|
|
1068
|
+
// readline 的 _line() 内部会 this.emit('line', this.line) — 用补全后的 line
|
|
1069
|
+
if (typeof rl._line === 'function') {
|
|
1070
|
+
try { rl._line(); console.error('[DEBUG-ENTER] _line ok, rl.line after=' + JSON.stringify(rl.line)) } catch (e) { console.error('[DEBUG-ENTER] _line err: ' + e.message); rl.emit('line', newLine) }
|
|
1071
|
+
} else {
|
|
1072
|
+
rl.emit('line', newLine)
|
|
1073
|
+
}
|
|
1074
|
+
return
|
|
1075
|
+
}
|
|
1076
|
+
if (action === 'cancel') {
|
|
1077
|
+
// Esc:只清 hint,不动输入行
|
|
1078
|
+
eraseSlashHint()
|
|
1079
|
+
slashHintMatches = []
|
|
1080
|
+
slashHintIndex = 0
|
|
1081
|
+
return
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
// submit(Enter)走默认 readline 提交,line 事件里统一擦除 hint;
|
|
1086
|
+
// 其他普通按键:重算匹配 + 重画 hint
|
|
1087
|
+
if (!(key.name === 'return' || key.name === 'enter')) {
|
|
1088
|
+
refreshSlashHint()
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
// Alt+V 粘贴剪贴板图片(node 把 ESC+v 解析为 meta+v;部分终端不发 Alt,可用 /image 兜底)
|
|
984
1092
|
const isAltV = (key.meta && key.name === 'v')
|
|
985
1093
|
|| (typeof key.sequence === 'string' && key.sequence.length === 2
|
|
986
1094
|
&& key.sequence.charCodeAt(0) === 27 && key.sequence[1] === 'v')
|
package/src/cli/ai/modelSetup.js
CHANGED
|
@@ -22,8 +22,8 @@
|
|
|
22
22
|
// 1. 询问是否现在配置
|
|
23
23
|
// 2. 选择服务商(预设列表 / 自定义)
|
|
24
24
|
// 3. 确认接口地址(baseURL)
|
|
25
|
-
// 4.
|
|
26
|
-
// 5.
|
|
25
|
+
// 4. 输入 API Key(本地模型可留空)
|
|
26
|
+
// 5. 选择模型名称(先从 API 获取模型列表,失败则用内置列表 / 手动输入)
|
|
27
27
|
// 6. 输入显示名称(可选)
|
|
28
28
|
// 7. 测试连接
|
|
29
29
|
// 8. 保存到配置文件并返回模型对象
|
|
@@ -137,6 +137,8 @@ const STRINGS = {
|
|
|
137
137
|
endpointInvalid: '接口地址格式不正确(需为 http/https URL)',
|
|
138
138
|
modelLabel: '模型名称',
|
|
139
139
|
builtinModels: '常用模型:',
|
|
140
|
+
fetchingModels: '正在获取模型列表…',
|
|
141
|
+
fetchModelsFailed: '获取模型列表失败,使用内置列表',
|
|
140
142
|
manualInput: '手动输入',
|
|
141
143
|
modelPrompt: '请输入模型名称: ',
|
|
142
144
|
modelRequired: '模型名称不能为空',
|
|
@@ -170,6 +172,8 @@ const STRINGS = {
|
|
|
170
172
|
endpointInvalid: 'Invalid endpoint URL (must be http/https)',
|
|
171
173
|
modelLabel: 'Model name',
|
|
172
174
|
builtinModels: 'Popular models:',
|
|
175
|
+
fetchingModels: 'Fetching model list…',
|
|
176
|
+
fetchModelsFailed: 'Failed to fetch model list, using built-in list',
|
|
173
177
|
manualInput: 'Manual input',
|
|
174
178
|
modelPrompt: 'Enter model name: ',
|
|
175
179
|
modelRequired: 'Model name cannot be empty',
|
|
@@ -259,6 +263,48 @@ export function buildModelConfig({ baseURL, model, apiKey, name, isDefault }) {
|
|
|
259
263
|
}
|
|
260
264
|
}
|
|
261
265
|
|
|
266
|
+
/**
|
|
267
|
+
* 从 API 获取可用模型列表(OpenAI 兼容 /models 接口)。
|
|
268
|
+
*
|
|
269
|
+
* @param {object} opts
|
|
270
|
+
* @param {string} opts.baseURL - API 基础地址
|
|
271
|
+
* @param {string} [opts.apiKey] - API Key(可选)
|
|
272
|
+
* @param {number} [opts.timeoutMs=5000] - 超时时间
|
|
273
|
+
* @param {typeof fetch} [opts.fetchFn] - 可注入 fetch(测试用)
|
|
274
|
+
* @returns {Promise<string[]>} 模型名数组,失败返回空数组
|
|
275
|
+
*/
|
|
276
|
+
export async function fetchModelsFromApi({ baseURL, apiKey, timeoutMs = 5000, fetchFn } = {}) {
|
|
277
|
+
const fetch = fetchFn || globalThis.fetch
|
|
278
|
+
if (!fetch) return []
|
|
279
|
+
|
|
280
|
+
const url = `${String(baseURL || '').replace(/\/$/, '')}/models`
|
|
281
|
+
const headers = {}
|
|
282
|
+
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`
|
|
283
|
+
|
|
284
|
+
const controller = new AbortController()
|
|
285
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
286
|
+
|
|
287
|
+
try {
|
|
288
|
+
const res = await fetch(url, {
|
|
289
|
+
method: 'GET',
|
|
290
|
+
headers,
|
|
291
|
+
signal: controller.signal,
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
if (!res.ok) return []
|
|
295
|
+
|
|
296
|
+
const data = await res.json()
|
|
297
|
+
// OpenAI 兼容接口返回 { data: [{ id: 'model-name', ... }] }
|
|
298
|
+
const models = data?.data || []
|
|
299
|
+
return models.map(m => m.id).filter(Boolean)
|
|
300
|
+
} catch (err) {
|
|
301
|
+
// 任何错误都静默返回空数组,由调用方决定是否回退
|
|
302
|
+
return []
|
|
303
|
+
} finally {
|
|
304
|
+
clearTimeout(timer)
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
262
308
|
/**
|
|
263
309
|
* 测试模型连接(OpenAI 兼容 /chat/completions)。
|
|
264
310
|
*
|
|
@@ -463,11 +509,25 @@ export async function collectModelInput({ locale = 'zh-CN', rl: injectedRl, fetc
|
|
|
463
509
|
baseURL = confirmedBaseURL
|
|
464
510
|
}
|
|
465
511
|
|
|
466
|
-
// 3.
|
|
467
|
-
const
|
|
512
|
+
// 3. 输入 API Key(在获取模型列表之前,因为大部分服务商需要 Key 才能调 /models)
|
|
513
|
+
const apiKey = await asker.ask(t.apiKeyPrompt)
|
|
514
|
+
|
|
515
|
+
// 4. 选择/输入模型名称
|
|
516
|
+
// 先尝试从 API 获取模型列表(有了 apiKey 才能调通)
|
|
517
|
+
const fetchSpinner = startSpinner(t.fetchingModels)
|
|
518
|
+
const fetchedModels = await fetchModelsFromApi({ baseURL, apiKey, fetchFn })
|
|
519
|
+
fetchSpinner.stop()
|
|
520
|
+
|
|
521
|
+
// 使用 API 返回的模型列表,如果失败则使用内置列表
|
|
522
|
+
const modelList = fetchedModels.length > 0 ? fetchedModels : getBuiltinModels(baseURL)
|
|
523
|
+
|
|
524
|
+
if (fetchedModels.length === 0 && getBuiltinModels(baseURL).length > 0) {
|
|
525
|
+
console.log(chalk.dim(` ${t.fetchModelsFailed}`))
|
|
526
|
+
}
|
|
527
|
+
|
|
468
528
|
let model
|
|
469
|
-
if (
|
|
470
|
-
const modelItems =
|
|
529
|
+
if (modelList.length > 0) {
|
|
530
|
+
const modelItems = modelList.map(m => ({ label: m, value: m }))
|
|
471
531
|
const modelChoice = await selectFromList(asker, t.builtinModels, modelItems, t, t.manualInput)
|
|
472
532
|
if (modelChoice) {
|
|
473
533
|
model = modelChoice.value
|
|
@@ -480,7 +540,7 @@ export async function collectModelInput({ locale = 'zh-CN', rl: injectedRl, fetc
|
|
|
480
540
|
}
|
|
481
541
|
}
|
|
482
542
|
} else {
|
|
483
|
-
//
|
|
543
|
+
// 没有模型列表,直接手动输入
|
|
484
544
|
while (true) {
|
|
485
545
|
model = await asker.ask(t.modelPrompt)
|
|
486
546
|
if (model) break
|
|
@@ -488,9 +548,6 @@ export async function collectModelInput({ locale = 'zh-CN', rl: injectedRl, fetc
|
|
|
488
548
|
}
|
|
489
549
|
}
|
|
490
550
|
|
|
491
|
-
// 4. 输入 API Key
|
|
492
|
-
const apiKey = await asker.ask(t.apiKeyPrompt)
|
|
493
|
-
|
|
494
551
|
// 5. 输入显示名称(默认用 model 名)
|
|
495
552
|
const displayName = await asker.ask(t.displayNamePrompt(model), model)
|
|
496
553
|
|
|
@@ -591,4 +648,4 @@ export async function runModelSetup({ locale = 'zh-CN', rl: injectedRl, fetchFn
|
|
|
591
648
|
}
|
|
592
649
|
}
|
|
593
650
|
|
|
594
|
-
export default { runModelSetup, collectModelInput, testModelConnection, getBuiltinModels, findProviderByUrl, validateEndpoint, buildModelConfig, PROVIDERS, BUILTIN_MODELS }
|
|
651
|
+
export default { runModelSetup, collectModelInput, fetchModelsFromApi, testModelConnection, getBuiltinModels, findProviderByUrl, validateEndpoint, buildModelConfig, PROVIDERS, BUILTIN_MODELS }
|
|
@@ -409,8 +409,8 @@ function silenceConsole() {
|
|
|
409
409
|
test('collectModelInput: 预设服务商 + 内置模型 + 测试成功 → 返回收集的字段', async () => {
|
|
410
410
|
const restore = silenceConsole()
|
|
411
411
|
try {
|
|
412
|
-
// 答案:选 OpenAI(1) → 确认 baseURL(空=默认) → 选第一个模型(1) →
|
|
413
|
-
const rl = createMockRl(['1', '', '
|
|
412
|
+
// 答案:选 OpenAI(1) → 确认 baseURL(空=默认) → apiKey → 选第一个模型(1) → displayName(空=默认)
|
|
413
|
+
const rl = createMockRl(['1', '', 'sk-test', '1', ''])
|
|
414
414
|
const result = await collectModelInput({
|
|
415
415
|
locale: 'zh-CN',
|
|
416
416
|
rl,
|
|
@@ -429,8 +429,8 @@ test('collectModelInput: 预设服务商 + 内置模型 + 测试成功 → 返
|
|
|
429
429
|
test('collectModelInput: 自定义服务商 + 手动输入模型名 → 返回收集的字段', async () => {
|
|
430
430
|
const restore = silenceConsole()
|
|
431
431
|
try {
|
|
432
|
-
// 选自定义(0) → 输入 baseURL → 确认(空=默认) →
|
|
433
|
-
const rl = createMockRl(['0', 'https://my.custom.api/v1', '', '
|
|
432
|
+
// 选自定义(0) → 输入 baseURL → 确认(空=默认) → apiKey(空) → 手动输入模型名 → displayName(空=默认)
|
|
433
|
+
const rl = createMockRl(['0', 'https://my.custom.api/v1', '', '', 'my-model', ''])
|
|
434
434
|
const result = await collectModelInput({
|
|
435
435
|
locale: 'zh-CN',
|
|
436
436
|
rl,
|
|
@@ -449,8 +449,8 @@ test('collectModelInput: 自定义服务商 + 手动输入模型名 → 返回
|
|
|
449
449
|
test('collectModelInput: 测试失败 + 用户选择不保存 → 返回 null', async () => {
|
|
450
450
|
const restore = silenceConsole()
|
|
451
451
|
try {
|
|
452
|
-
// 选 DeepSeek(3) → 确认 → 选第一个模型(1) →
|
|
453
|
-
const rl = createMockRl(['3', '', '
|
|
452
|
+
// 选 DeepSeek(3) → 确认 → apiKey → 选第一个模型(1) → displayName → 测试401失败 → 不保存(n)
|
|
453
|
+
const rl = createMockRl(['3', '', 'sk-bad', '1', '', 'n'])
|
|
454
454
|
const result = await collectModelInput({
|
|
455
455
|
locale: 'zh-CN',
|
|
456
456
|
rl,
|
|
@@ -465,7 +465,7 @@ test('collectModelInput: 测试失败 + 用户选择不保存 → 返回 null',
|
|
|
465
465
|
test('collectModelInput: 测试失败 + 用户选择继续 → 返回收集的字段', async () => {
|
|
466
466
|
const restore = silenceConsole()
|
|
467
467
|
try {
|
|
468
|
-
const rl = createMockRl(['3', '', '
|
|
468
|
+
const rl = createMockRl(['3', '', 'sk-bad', '1', '', 'y'])
|
|
469
469
|
const result = await collectModelInput({
|
|
470
470
|
locale: 'zh-CN',
|
|
471
471
|
rl,
|
|
@@ -494,3 +494,32 @@ test('collectModelInput: 用户取消(触发 close)→ 返回 null', async () =>
|
|
|
494
494
|
restore()
|
|
495
495
|
}
|
|
496
496
|
})
|
|
497
|
+
|
|
498
|
+
test('collectModelInput: API 返回模型列表时优先使用 → 返回收集的字段', async () => {
|
|
499
|
+
const restore = silenceConsole()
|
|
500
|
+
try {
|
|
501
|
+
// mock fetch: /models 返回模型列表, /chat/completions 返回 200
|
|
502
|
+
const smartFetch = async (url, opts) => {
|
|
503
|
+
if (url.endsWith('/models')) {
|
|
504
|
+
return {
|
|
505
|
+
ok: true,
|
|
506
|
+
status: 200,
|
|
507
|
+
json: async () => ({ data: [{ id: 'api-model-1' }, { id: 'api-model-2' }] }),
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
return { ok: true, status: 200, json: async () => ({}) }
|
|
511
|
+
}
|
|
512
|
+
// 选 OpenAI(1) → 确认 baseURL → apiKey → 选第一个 API 返回的模型(1) → displayName
|
|
513
|
+
const rl = createMockRl(['1', '', 'sk-test', '1', ''])
|
|
514
|
+
const result = await collectModelInput({
|
|
515
|
+
locale: 'zh-CN',
|
|
516
|
+
rl,
|
|
517
|
+
fetchFn: smartFetch,
|
|
518
|
+
})
|
|
519
|
+
assert.ok(result)
|
|
520
|
+
assert.equal(result.model, 'api-model-1', '应使用 API 返回的模型而非内置列表')
|
|
521
|
+
assert.equal(result.apiKey, 'sk-test')
|
|
522
|
+
} finally {
|
|
523
|
+
restore()
|
|
524
|
+
}
|
|
525
|
+
})
|
package/src/cli/ai/termui.js
CHANGED
|
@@ -140,16 +140,59 @@ export function filterSlashCommands(input, locale) {
|
|
|
140
140
|
|
|
141
141
|
/**
|
|
142
142
|
* 生成即时提示面板的 ANSI 字符串(不含定位,由调用方负责保存/恢复光标)。
|
|
143
|
-
* 每行:两空格缩进 +
|
|
144
|
-
*
|
|
143
|
+
* 每行:两空格缩进 + 蓝色命令名(左对齐补齐 12 字符宽)+ 灰色说明。
|
|
144
|
+
* 所有行的"视觉起点"一致(均为 2 空格缩进);选中行用整行反白区分(从缩进处
|
|
145
|
+
* 开始),不另加 ❯ 之类的偏移标记 —— 避免反白块起点与其他行不齐。
|
|
146
|
+
* - 越界 selectedIndex(<0 / >=matches.length) 自动回退为 -1(即不高亮)
|
|
147
|
+
* - 空数组返回空串
|
|
145
148
|
*/
|
|
146
|
-
export function renderSlashHintBody(matches) {
|
|
149
|
+
export function renderSlashHintBody(matches, selectedIndex = -1) {
|
|
147
150
|
if (!Array.isArray(matches) || matches.length === 0) return ''
|
|
151
|
+
const sel = (Number.isInteger(selectedIndex) && selectedIndex >= 0 && selectedIndex < matches.length)
|
|
152
|
+
? selectedIndex
|
|
153
|
+
: -1
|
|
154
|
+
// 行宽固定 = 2 空格缩进 + 12 字符命令 + 说明;选中行反白整行(含缩进),
|
|
155
|
+
// 反白起点与未选中行严格对齐,视觉上选中行不会"偏左"。
|
|
156
|
+
const rowWidth = (m) => 2 + Math.max(12, String(m.cmd).length) + 1 + String(m.desc).length
|
|
148
157
|
return matches
|
|
149
|
-
.map((m) =>
|
|
158
|
+
.map((m, i) => {
|
|
159
|
+
// 用 padEnd 凑满 rowWidth,这样反白背景能延伸到行尾,左右不留缝
|
|
160
|
+
const raw = ' ' + String(m.cmd).padEnd(12) + ' ' + m.desc
|
|
161
|
+
// 所有行命令名统一用青色(不带额外前缀),选中行靠反白区分
|
|
162
|
+
const line = chalk.cyan(String(m.cmd).padEnd(12)) + ' ' + chalk.dim(m.desc)
|
|
163
|
+
if (i !== sel) return ' ' + line
|
|
164
|
+
// 选中行:把命令名+说明之外的两空格缩进也纳入反白,这样视觉起点严格对齐
|
|
165
|
+
return chalk.inverse(raw.padEnd(rowWidth(m)))
|
|
166
|
+
})
|
|
150
167
|
.join('\n')
|
|
151
168
|
}
|
|
152
169
|
|
|
170
|
+
/**
|
|
171
|
+
* 解析 readline keypress 事件,识别"斜杠命令提示"专用快捷键。
|
|
172
|
+
* 返回动作字符串(消费方据此改 selectedIndex 或补全输入);
|
|
173
|
+
* 不识别的键返回 null(由 readline 正常处理)。
|
|
174
|
+
*
|
|
175
|
+
* - ↑ / Shift+Tab → 'prev'
|
|
176
|
+
* - ↓ → 'next'
|
|
177
|
+
* - Tab → 'complete'(补全选中命令到输入行)
|
|
178
|
+
* - Enter → 'submit'(由 readline 默认提交,本函数仅标记是补全时机)
|
|
179
|
+
* - Esc → 'cancel'
|
|
180
|
+
*
|
|
181
|
+
* 注意:readline 默认会把方向键当历史浏览。消费方需要在我们这个 handler
|
|
182
|
+
* 里"事后回滚":把 rl.line 恢复成 hint 显示时的基线,再 _refreshLine()。
|
|
183
|
+
* (详见 agent.js 的 keypress 监听与说明)
|
|
184
|
+
*/
|
|
185
|
+
export function parseKeyForSlashHint(key) {
|
|
186
|
+
if (!key) return null
|
|
187
|
+
const name = key.name
|
|
188
|
+
if (name === 'tab') return key.shift ? 'prev' : 'complete'
|
|
189
|
+
if (name === 'up') return 'prev'
|
|
190
|
+
if (name === 'down') return 'next'
|
|
191
|
+
if (name === 'return' || name === 'enter') return 'submit'
|
|
192
|
+
if (name === 'escape') return 'cancel'
|
|
193
|
+
return null
|
|
194
|
+
}
|
|
195
|
+
|
|
153
196
|
/** 盒式帮助面板 */
|
|
154
197
|
export function printHelpPanel(title, lines) {
|
|
155
198
|
const body = [chalk.bold(title), ...lines].join('\n')
|
|
@@ -376,6 +419,28 @@ export function createAssistantWriter({
|
|
|
376
419
|
}
|
|
377
420
|
}
|
|
378
421
|
|
|
422
|
+
// ──────────────────────────────────────────────────────
|
|
423
|
+
// 耗时格式化
|
|
424
|
+
// ──────────────────────────────────────────────────────
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* 格式化耗时(毫秒 → 人类可读)。
|
|
428
|
+
* < 1s → "123ms"
|
|
429
|
+
* < 60s → "1.2s"
|
|
430
|
+
* ≥ 60s → "2m30s"
|
|
431
|
+
* @param {number} ms
|
|
432
|
+
* @returns {string}
|
|
433
|
+
*/
|
|
434
|
+
export function formatDuration(ms) {
|
|
435
|
+
if (!Number.isFinite(ms) || ms < 0) return ''
|
|
436
|
+
if (ms < 1000) return `${Math.round(ms)}ms`
|
|
437
|
+
const s = ms / 1000
|
|
438
|
+
if (s < 60) return `${s.toFixed(1)}s`
|
|
439
|
+
const m = Math.floor(s / 60)
|
|
440
|
+
const rem = Math.round(s % 60)
|
|
441
|
+
return `${m}m${rem}s`
|
|
442
|
+
}
|
|
443
|
+
|
|
379
444
|
// ──────────────────────────────────────────────
|
|
380
445
|
// 工具调用块(Claude Code 风格)
|
|
381
446
|
// ──────────────────────────────────────────────
|
|
@@ -426,8 +491,12 @@ export function printToolHeader(name, summary, write = (s) => process.stdout.wri
|
|
|
426
491
|
* run_command 的结果里首行是 `$ <command>` 回显——这条信息已经出现在上方的
|
|
427
492
|
* `▶ run_command $ <summary>` 工具头里,这里再印一次就是重复。所以这里把首
|
|
428
493
|
* 行 `$ ...` 剥掉,同时复用它来识别退出码(退出码仍驱动错误着色)。
|
|
494
|
+
*
|
|
495
|
+
* @param {string} result - 工具输出文本
|
|
496
|
+
* @param {(s: string) => void} [write] - 输出函数,默认写 stdout
|
|
497
|
+
* @param {number} [durationMs] - 执行耗时(毫秒),有值时在结果末尾追加 ⏱ 计时行
|
|
429
498
|
*/
|
|
430
|
-
export function printToolResult(result, write = (s) => process.stdout.write(s)) {
|
|
499
|
+
export function printToolResult(result, write = (s) => process.stdout.write(s), durationMs) {
|
|
431
500
|
const text = truncateDisplay(result)
|
|
432
501
|
// 先把 "$ <command>\n" 这一行回显从展示里剥掉(退出码仍在第二行里识别)
|
|
433
502
|
const visible = text.replace(/^\$[^\n]*\n/, '')
|
|
@@ -440,7 +509,11 @@ export function printToolResult(result, write = (s) => process.stdout.write(s))
|
|
|
440
509
|
const colorize = isError ? chalk.hex('#f0a020') : chalk.hex('#94a3b8')
|
|
441
510
|
const lines = visible.split('\n')
|
|
442
511
|
const rendered = lines.map((l) => chalk.dim(' │ ') + colorize(l)).join('\n')
|
|
443
|
-
|
|
512
|
+
// 有耗时时在结果末尾追加 ⏱ 计时行(与结果块同缩进)
|
|
513
|
+
const timingLine = (durationMs != null && Number.isFinite(durationMs))
|
|
514
|
+
? '\n' + chalk.dim(' │ ⏱ ') + chalk.hex('#7a87a0')(formatDuration(durationMs))
|
|
515
|
+
: ''
|
|
516
|
+
write(rendered + timingLine + '\n')
|
|
444
517
|
}
|
|
445
518
|
|
|
446
519
|
// ──────────────────────────────────────────────
|
|
@@ -456,11 +529,13 @@ export default {
|
|
|
456
529
|
termWidth,
|
|
457
530
|
stripAnsi,
|
|
458
531
|
truncateDisplay,
|
|
532
|
+
formatDuration,
|
|
459
533
|
printBanner,
|
|
460
534
|
printHelpPanel,
|
|
461
535
|
SLASH_COMMANDS,
|
|
462
536
|
filterSlashCommands,
|
|
463
537
|
renderSlashHintBody,
|
|
538
|
+
parseKeyForSlashHint,
|
|
464
539
|
drawInputTop,
|
|
465
540
|
drawInputBottom,
|
|
466
541
|
inputBottomBorder,
|