zen-gitsync 2.16.9 → 2.16.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -0
- package/package.json +1 -1
- package/src/cli/ai/agent.js +123 -15
- package/src/cli/ai/modelSetup.js +265 -25
- package/src/cli/ai/modelSetup.test.js +36 -7
- package/src/cli/ai/sessionStore.js +97 -97
- package/src/cli/ai/termui.js +173 -6
- package/src/cli/ai/termui.test.js +261 -1
- package/src/ui/public/assets/AgentView-d83DuyQq.js +4 -0
- package/src/ui/public/assets/AppVersionBadge-UNabzjNF.js +2 -0
- package/src/ui/public/assets/BranchSelector-DUbFSWiQ.js +1 -0
- package/src/ui/public/assets/CommitForm-HiRHE42z.js +6 -0
- package/src/ui/public/assets/EditorView-BvLP-NL1.js +0 -0
- package/src/ui/public/assets/FlowExecutionViewer-6mr8M454.js +1 -0
- package/src/ui/public/assets/FlowOrchestrationWorkspace-Cw2ADY-p.js +21 -0
- package/src/ui/public/assets/LogList-DLg6RUCd.js +15 -0
- package/src/ui/public/assets/MindmapView-BKIsdNvz.js +2 -0
- package/src/ui/public/assets/MonitorView-BjjHr8a5.js +1 -0
- package/src/ui/public/assets/ProjectStartupButton-C-qI63k1.js +1 -0
- package/src/ui/public/assets/RemoteRepoCard-B0q3WipX.js +5 -0
- package/src/ui/public/assets/SourceMapView-BZgc-Ylv.js +3 -0
- package/src/ui/public/assets/UserInputNode-s3Dfg2Gm.js +1 -0
- package/src/ui/public/assets/WorkbenchView-BakrUWDH.js +28 -0
- package/src/ui/public/assets/element-plus-C0loJL0J.js +32 -0
- package/src/ui/public/assets/element-plus-CXfbWXaJ.css +1 -0
- package/src/ui/public/assets/{flow-mindmap-DnyDhwJv.js → flow-mindmap-CGn6wqsF.js} +1 -1
- package/src/ui/public/assets/index-Bumd4MSq.css +1 -0
- package/src/ui/public/assets/index-Cr5-U-r6.js +82 -0
- package/src/ui/public/assets/{monaco-Dje8Rg13.js → monaco-DPQ3OaiL.js} +2 -2
- package/src/ui/public/assets/socket-io-B9UuzwTN.js +1 -0
- package/src/ui/public/assets/{ts.worker-Dth06zuC.js → ts.worker-METxwbDZ.js} +1 -16
- package/src/ui/public/assets/{vendor-DVvq7qtS.css → vendor-C2ZpZPB4.css} +1 -1
- package/src/ui/public/assets/{vendor-exjCs1jx.js → vendor-pbWglt5U.js} +26 -67
- package/src/ui/public/assets/vue-flow-DRwHh4gM.js +39 -0
- package/src/ui/public/index.html +9 -15
- package/src/ui/server/index.js +4 -0
- package/src/ui/server/routes/workbench/agentChat.js +14 -3
- package/src/ui/server/routes/workbench/agentRoutes.js +218 -212
- package/src/ui/server/routes/workbench/agentSessionStore.js +173 -173
- package/src/ui/public/assets/AgentView-CioAzh-a.js +0 -4
- package/src/ui/public/assets/AppVersionBadge-Cwg6vOMz.js +0 -2
- package/src/ui/public/assets/BranchSelector-DgxIB9XY.js +0 -1
- package/src/ui/public/assets/CommitForm-CTa0pupJ.js +0 -6
- package/src/ui/public/assets/CommonDialog-ChSKsEz2.css +0 -1
- package/src/ui/public/assets/CommonDialog-Tlk0Fa7g.js +0 -1
- package/src/ui/public/assets/EditorView-BOZPwTxr.js +0 -0
- package/src/ui/public/assets/FlowExecutionViewer-CFpiqZ2C.js +0 -1
- package/src/ui/public/assets/FlowOrchestrationWorkspace-CVYPdN7a.js +0 -21
- package/src/ui/public/assets/LogList-B4rrapVn.js +0 -15
- package/src/ui/public/assets/MindmapView-C1pPUu8W.js +0 -2
- package/src/ui/public/assets/MonitorView-C2EyggDk.js +0 -1
- package/src/ui/public/assets/ProjectStartupButton-D7mgEsmf.js +0 -1
- package/src/ui/public/assets/RemoteRepoCard-B-5MDx8k.js +0 -5
- package/src/ui/public/assets/SourceMapView-C3gZ5s1r.js +0 -3
- package/src/ui/public/assets/SvgIcon-CDNsk4-Q.css +0 -1
- package/src/ui/public/assets/SvgIcon-Diox29ks.js +0 -1
- package/src/ui/public/assets/UserInputNode-CYxFuwuS.js +0 -1
- package/src/ui/public/assets/WorkbenchView-BE53JHac.js +0 -28
- package/src/ui/public/assets/_plugin-vue_export-helper-B9cwfrdH.js +0 -15
- package/src/ui/public/assets/configStore-B8X980y1.js +0 -1
- package/src/ui/public/assets/element-plus-BId3Dnij.js +0 -25
- package/src/ui/public/assets/element-plus-D3Dn7nX0.css +0 -1
- package/src/ui/public/assets/index--onqtBRQ.css +0 -1
- package/src/ui/public/assets/index-Ds_2iFkX.js +0 -68
- package/src/ui/public/assets/socket-io-CGezBd--.js +0 -1
- package/src/ui/public/assets/vue-flow-Dj_X_Vr1.js +0 -39
- /package/src/ui/public/assets/{dagre-BCXBo6I9.js → dagre-CePNlbK4.js} +0 -0
package/README.md
CHANGED
|
@@ -437,6 +437,11 @@ $ g ai "fix the failing test" # one-shot task, then exit
|
|
|
437
437
|
$ g ai --model=2 # pick the 2nd configured model (index or name)
|
|
438
438
|
```
|
|
439
439
|
|
|
440
|
+
The first-run setup wizard and `/addmodel` provider/model lists support **↑↓ keys to switch
|
|
441
|
+
selection + Enter to confirm** (typing a number also jumps directly; `0` selects the trailing
|
|
442
|
+
"custom / manual input" entry). Non-TTY environments (CI, piped input) automatically fall back
|
|
443
|
+
to numeric input. `Esc` or `Ctrl+C` cancels the wizard cleanly.
|
|
444
|
+
|
|
440
445
|
In-session commands: `/help`, `/model`, `/addmodel`, `/cd <path>`, `/image [path]`, `/think`, `/clear`, `/exit`.
|
|
441
446
|
|
|
442
447
|
Images: press `Alt+V` in the REPL to paste a clipboard image (screenshot), or attach a
|
|
@@ -966,6 +971,10 @@ $ g ai "修复失败的测试" # 单发模式:执行一轮后退出
|
|
|
966
971
|
$ g ai --model=2 # 使用第 2 个已配置的模型(序号或名称)
|
|
967
972
|
```
|
|
968
973
|
|
|
974
|
+
启动配置向导与 `/addmodel` 的"服务商 / 模型"列表支持 **↑↓ 键切换 + Enter 确认**(也可
|
|
975
|
+
直接输入数字跳转,`0` = 列表底部的"自定义 / 手动输入");非 TTY 环境下自动回退为数字输入。
|
|
976
|
+
`Esc` 或 `Ctrl+C` 一键取消整个向导。
|
|
977
|
+
|
|
969
978
|
会话内命令:`/help`、`/model`、`/addmodel`、`/cd <路径>`、`/image [路径]`、`/think`、`/clear`、`/exit`。
|
|
970
979
|
|
|
971
980
|
图片:在 REPL 中按 `Alt+V` 粘贴剪贴板图片(截图),或用 `/image <路径>` 附加本地图片;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zen-gitsync",
|
|
3
|
-
"version": "2.16.
|
|
3
|
+
"version": "2.16.11",
|
|
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. 保存到配置文件并返回模型对象
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
import readline from 'node:readline'
|
|
35
35
|
import chalk from 'chalk'
|
|
36
36
|
import config from '../../config.js'
|
|
37
|
-
import { startSpinner } from './termui.js'
|
|
37
|
+
import { startSpinner, renderSelectableListBody, parseKeyForSelectableList } from './termui.js'
|
|
38
38
|
|
|
39
39
|
// ──────────────────────────────────────────────
|
|
40
40
|
// 预设服务商列表
|
|
@@ -131,12 +131,15 @@ const STRINGS = {
|
|
|
131
131
|
selectProvider: '选择 AI 服务商:',
|
|
132
132
|
customOption: '自定义',
|
|
133
133
|
selectPrompt: (min, max) => `请选择 (${min}-${max}): `,
|
|
134
|
+
navHint: '↑↓ 切换,Enter 确认,数字直跳(0 = 上一行)',
|
|
134
135
|
invalidChoice: (min, max) => `无效选择,请输入 ${min}-${max} 之间的数字`,
|
|
135
136
|
endpointLabel: '接口地址',
|
|
136
137
|
endpointPrompt: (def) => `接口地址 [${def}]: `,
|
|
137
138
|
endpointInvalid: '接口地址格式不正确(需为 http/https URL)',
|
|
138
139
|
modelLabel: '模型名称',
|
|
139
140
|
builtinModels: '常用模型:',
|
|
141
|
+
fetchingModels: '正在获取模型列表…',
|
|
142
|
+
fetchModelsFailed: '获取模型列表失败,使用内置列表',
|
|
140
143
|
manualInput: '手动输入',
|
|
141
144
|
modelPrompt: '请输入模型名称: ',
|
|
142
145
|
modelRequired: '模型名称不能为空',
|
|
@@ -164,12 +167,15 @@ const STRINGS = {
|
|
|
164
167
|
selectProvider: 'Select an AI provider:',
|
|
165
168
|
customOption: 'Custom',
|
|
166
169
|
selectPrompt: (min, max) => `Choose (${min}-${max}): `,
|
|
170
|
+
navHint: '↑↓ to switch, Enter to confirm, number to jump (0 = extra)',
|
|
167
171
|
invalidChoice: (min, max) => `Invalid choice, enter a number between ${min} and ${max}`,
|
|
168
172
|
endpointLabel: 'Endpoint URL',
|
|
169
173
|
endpointPrompt: (def) => `Endpoint [${def}]: `,
|
|
170
174
|
endpointInvalid: 'Invalid endpoint URL (must be http/https)',
|
|
171
175
|
modelLabel: 'Model name',
|
|
172
176
|
builtinModels: 'Popular models:',
|
|
177
|
+
fetchingModels: 'Fetching model list…',
|
|
178
|
+
fetchModelsFailed: 'Failed to fetch model list, using built-in list',
|
|
173
179
|
manualInput: 'Manual input',
|
|
174
180
|
modelPrompt: 'Enter model name: ',
|
|
175
181
|
modelRequired: 'Model name cannot be empty',
|
|
@@ -259,6 +265,48 @@ export function buildModelConfig({ baseURL, model, apiKey, name, isDefault }) {
|
|
|
259
265
|
}
|
|
260
266
|
}
|
|
261
267
|
|
|
268
|
+
/**
|
|
269
|
+
* 从 API 获取可用模型列表(OpenAI 兼容 /models 接口)。
|
|
270
|
+
*
|
|
271
|
+
* @param {object} opts
|
|
272
|
+
* @param {string} opts.baseURL - API 基础地址
|
|
273
|
+
* @param {string} [opts.apiKey] - API Key(可选)
|
|
274
|
+
* @param {number} [opts.timeoutMs=5000] - 超时时间
|
|
275
|
+
* @param {typeof fetch} [opts.fetchFn] - 可注入 fetch(测试用)
|
|
276
|
+
* @returns {Promise<string[]>} 模型名数组,失败返回空数组
|
|
277
|
+
*/
|
|
278
|
+
export async function fetchModelsFromApi({ baseURL, apiKey, timeoutMs = 5000, fetchFn } = {}) {
|
|
279
|
+
const fetch = fetchFn || globalThis.fetch
|
|
280
|
+
if (!fetch) return []
|
|
281
|
+
|
|
282
|
+
const url = `${String(baseURL || '').replace(/\/$/, '')}/models`
|
|
283
|
+
const headers = {}
|
|
284
|
+
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`
|
|
285
|
+
|
|
286
|
+
const controller = new AbortController()
|
|
287
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
288
|
+
|
|
289
|
+
try {
|
|
290
|
+
const res = await fetch(url, {
|
|
291
|
+
method: 'GET',
|
|
292
|
+
headers,
|
|
293
|
+
signal: controller.signal,
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
if (!res.ok) return []
|
|
297
|
+
|
|
298
|
+
const data = await res.json()
|
|
299
|
+
// OpenAI 兼容接口返回 { data: [{ id: 'model-name', ... }] }
|
|
300
|
+
const models = data?.data || []
|
|
301
|
+
return models.map(m => m.id).filter(Boolean)
|
|
302
|
+
} catch (err) {
|
|
303
|
+
// 任何错误都静默返回空数组,由调用方决定是否回退
|
|
304
|
+
return []
|
|
305
|
+
} finally {
|
|
306
|
+
clearTimeout(timer)
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
262
310
|
/**
|
|
263
311
|
* 测试模型连接(OpenAI 兼容 /chat/completions)。
|
|
264
312
|
*
|
|
@@ -367,32 +415,201 @@ async function askYesNo(asker, prompt) {
|
|
|
367
415
|
}
|
|
368
416
|
|
|
369
417
|
/**
|
|
370
|
-
*
|
|
371
|
-
*
|
|
372
|
-
*
|
|
373
|
-
*
|
|
374
|
-
*
|
|
375
|
-
*
|
|
376
|
-
*
|
|
377
|
-
*
|
|
418
|
+
* 让用户从列表中选择一项。
|
|
419
|
+
*
|
|
420
|
+
* 两条路径:
|
|
421
|
+
* - TTY(终端)且传入了 rl → 上下键切换 + Enter 提交(类似 Codex CLI 的列表选择)
|
|
422
|
+
* - 数字键 1..9 / 0 也可直跳(0 = 额外项)
|
|
423
|
+
* - Esc / Ctrl+C 视作取消整个 wizard(调用方按 { cancelled: true } 走 catch 分支)
|
|
424
|
+
* - 其他情况 → 回退到原数字输入(由 asker.ask 提示,数字 + 回车确认;非法输入不退出,
|
|
425
|
+
* 提示 t.invalidChoice 让用户重新输)
|
|
426
|
+
*
|
|
427
|
+
* 设计要点:
|
|
428
|
+
* - 列表用 renderSelectableListBody 渲染,选中行整行反白(可视化锚点固定,跟斜杠命令
|
|
429
|
+
* 提示一样的"反白起点严格对齐"思路)
|
|
430
|
+
* - 方向键会触发 readline 默认历史浏览,我们用"清空 rl.line + 调 _refreshLine"撤销
|
|
431
|
+
* (参考 agent.js 对斜杠命令提示的处理)
|
|
432
|
+
* - 按 Enter 时 rl.question 回调自然拿到 line 字符串;空字符串 → 用当前 selectedIndex,
|
|
433
|
+
* 数字 → 直接选中,非数字 → 视为无效,保持当前列表继续等待输入(不重画列表)
|
|
434
|
+
*
|
|
435
|
+
* @param {object} opts
|
|
436
|
+
* @param {import('node:readline').Interface} [opts.rl] - 用于挂 keypress + rl.question;
|
|
437
|
+
* 不传或非 TTY 时退回数字输入
|
|
438
|
+
* @param {{ask:(p:string,d?:string)=>Promise<string>}} opts.asker - 数字输入回退路径的 asker;
|
|
439
|
+
* TTY 路径不需要,但参数签名上必须传(避免 collectModelInput 内部拆开两个调用)
|
|
440
|
+
* @param {string} opts.title
|
|
441
|
+
* @param {Array<{label: string, value: any}>} opts.items
|
|
442
|
+
* @param {object} opts.t - i18n strings
|
|
443
|
+
* @param {string} [opts.extraOptionLabel] - 额外项(编号 0)
|
|
444
|
+
* @returns {Promise<{index: number, value: any}|null>}
|
|
445
|
+
* - { index, value }:选中标准项
|
|
446
|
+
* - null:选中额外项
|
|
447
|
+
* - 抛出 { cancelled: true }:用户取消整个 wizard(Ctrl+C / Esc / readline close)
|
|
378
448
|
*/
|
|
379
|
-
async function selectFromList(asker, title, items, t, extraOptionLabel) {
|
|
449
|
+
async function selectFromList({ rl, asker, title, items, t, extraOptionLabel } = {}) {
|
|
450
|
+
const safeItems = Array.isArray(items) ? items : []
|
|
451
|
+
const extraActive = !!extraOptionLabel
|
|
452
|
+
const totalItems = safeItems.length + (extraActive ? 1 : 0)
|
|
453
|
+
if (totalItems === 0) return null
|
|
454
|
+
|
|
380
455
|
console.log()
|
|
381
456
|
console.log(chalk.bold(title))
|
|
457
|
+
|
|
458
|
+
// 判断走哪条路
|
|
459
|
+
const interactive = !!(rl && rl.input && process.stdout.isTTY && process.stdin.isTTY)
|
|
460
|
+
|
|
461
|
+
if (!interactive) {
|
|
462
|
+
// 非 TTY / 没 rl:走数字输入回退(原行为,保留 CI / 管道环境的可脚本化能力)
|
|
463
|
+
return selectByDigits({ asker, items: safeItems, t, extraOptionLabel })
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// ── TTY 路径:方向键列表选择 ──
|
|
467
|
+
let selectedIndex = 0
|
|
468
|
+
let finished = false
|
|
469
|
+
|
|
470
|
+
// 渲染初始列表
|
|
471
|
+
const initialBody = renderSelectableListBody(safeItems, 0, extraOptionLabel)
|
|
472
|
+
process.stdout.write(initialBody + '\n')
|
|
473
|
+
const listRows = initialBody.split('\n').length
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* 重画列表:用 ANSI 把光标逐行上移 + 清行,然后重新打印,光标回到列表首行行首,
|
|
477
|
+
* 不影响 readline 在 listRows+1 行的 prompt 行。
|
|
478
|
+
* 用 \x1b[2K(清整行)+ \x1b[1A(上移一行)循环 N 次,等价于
|
|
479
|
+
* "清掉光标以上 N 行"但不污染更下面的内容;最后 \r 回到行首方便后续输出起点对齐。
|
|
480
|
+
*/
|
|
481
|
+
const redraw = () => {
|
|
482
|
+
for (let i = 0; i < listRows; i++) {
|
|
483
|
+
process.stdout.write('\x1b[1A\x1b[2K')
|
|
484
|
+
}
|
|
485
|
+
process.stdout.write('\r')
|
|
486
|
+
process.stdout.write(renderSelectableListBody(safeItems, selectedIndex, extraOptionLabel) + '\n')
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* 撤销 readline 内置行为对 rl.line 的副作用:
|
|
491
|
+
* - 方向键:readline 读 history 一条回填到 rl.line + _refreshLine 重画 prompt
|
|
492
|
+
* 用户本来在 prompt 行没敲任何字符,历史回填会让用户突然看到历史的脏字符。
|
|
493
|
+
* 所以清空 line buffer 让 prompt 行保持空(类似 agent.js 对斜杠命令提示的做法)。
|
|
494
|
+
* - 数字键(jump:N):用户在 prompt 行确实敲了数字,该数字已经在 rl.line 里,
|
|
495
|
+
* 不能撤销(否则用户按 "8" 看不到 "8")— 但目前的 jump 路径用 rl.line.length === 0 守门,
|
|
496
|
+
* 等于已经避免和 readline 的字符输入重叠,所以 prev/next 路径才需要清,数字路径不用清。
|
|
497
|
+
*/
|
|
498
|
+
const clearLineBuffer = () => {
|
|
499
|
+
if (!rl) return
|
|
500
|
+
rl.line = ''
|
|
501
|
+
rl.cursor = 0
|
|
502
|
+
if (typeof rl._refreshLine === 'function') {
|
|
503
|
+
try { rl._refreshLine() } catch { /* 兜底 — 某些 readline 版本没有 _refreshLine */ }
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
return new Promise((resolve, reject) => {
|
|
508
|
+
const cleanup = () => {
|
|
509
|
+
if (finished) return
|
|
510
|
+
finished = true
|
|
511
|
+
if (rl && rl.input) rl.input.removeListener('keypress', keyHandler)
|
|
512
|
+
if (rl) rl.removeListener('close', onClose)
|
|
513
|
+
}
|
|
514
|
+
const onClose = () => {
|
|
515
|
+
cleanup()
|
|
516
|
+
reject({ cancelled: true })
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const keyHandler = (_str, key) => {
|
|
520
|
+
if (finished || !key) return
|
|
521
|
+
const action = parseKeyForSelectableList(key)
|
|
522
|
+
const max = totalItems - 1
|
|
523
|
+
|
|
524
|
+
if (action === 'prev' || action === 'next') {
|
|
525
|
+
if (action === 'prev') {
|
|
526
|
+
selectedIndex = selectedIndex > 0 ? selectedIndex - 1 : max
|
|
527
|
+
} else {
|
|
528
|
+
selectedIndex = selectedIndex < max ? selectedIndex + 1 : 0
|
|
529
|
+
}
|
|
530
|
+
clearLineBuffer() // 撤销 readline 历史回填
|
|
531
|
+
redraw()
|
|
532
|
+
return
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
if (typeof action === 'string' && action.startsWith('jump:')) {
|
|
536
|
+
// 只在用户当前没敲字符时接受 jump,避免和 readline 正常字符输入冲突
|
|
537
|
+
if (rl.line && rl.line.length > 0) return
|
|
538
|
+
const n = Number(action.slice(5))
|
|
539
|
+
let target = -1
|
|
540
|
+
if (n === 0 && extraActive) target = safeItems.length
|
|
541
|
+
else if (n >= 1 && n <= safeItems.length) target = n - 1
|
|
542
|
+
if (target >= 0) {
|
|
543
|
+
selectedIndex = target
|
|
544
|
+
redraw() // 数字已进 rl.line,不清;用户会看到 prompt 行同步显示这个数字
|
|
545
|
+
}
|
|
546
|
+
return
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
if (action === 'cancel') {
|
|
550
|
+
// Esc / Ctrl+C — 让用户取消整个 wizard
|
|
551
|
+
cleanup()
|
|
552
|
+
reject({ cancelled: true })
|
|
553
|
+
return
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// 'confirm' (Enter) 不处理,让 readline 自然触发 line 事件 → rl.question 回调拿 answer
|
|
557
|
+
// 其他字符(字母/数字但 line buffer 非空)让 readline 自然处理
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
rl.input.on('keypress', keyHandler)
|
|
561
|
+
rl.once('close', onClose)
|
|
562
|
+
|
|
563
|
+
// 提示词:列出可选范围 + 导航提示
|
|
564
|
+
const minIdx = extraActive ? 0 : 1
|
|
565
|
+
const maxIdx = safeItems.length
|
|
566
|
+
const navHint = t.navHint // "↑↓ 切换,Enter 确认,数字直跳"
|
|
567
|
+
const promptStr = `${chalk.cyan(t.selectPrompt(minIdx, maxIdx))}${chalk.dim(navHint ? ' ' + navHint : '')} `
|
|
568
|
+
|
|
569
|
+
rl.question(promptStr, (answer) => {
|
|
570
|
+
cleanup()
|
|
571
|
+
const trimmed = (answer || '').trim()
|
|
572
|
+
let idx = Number.parseInt(trimmed, 10)
|
|
573
|
+
if (Number.isFinite(idx) && idx >= minIdx && idx <= maxIdx) {
|
|
574
|
+
if (idx === 0) return resolve(null)
|
|
575
|
+
return resolve({ index: idx - 1, value: safeItems[idx - 1].value })
|
|
576
|
+
}
|
|
577
|
+
if (trimmed === '') {
|
|
578
|
+
// 空回答:用当前 selectedIndex
|
|
579
|
+
if (selectedIndex >= safeItems.length) return resolve(null)
|
|
580
|
+
return resolve({ index: selectedIndex, value: safeItems[selectedIndex].value })
|
|
581
|
+
}
|
|
582
|
+
// 非法输入:在当前列表下方打印提示,不重画列表(列表还在可见)
|
|
583
|
+
console.log(chalk.yellow(t.invalidChoice(minIdx, maxIdx)))
|
|
584
|
+
resolve('__retry__')
|
|
585
|
+
})
|
|
586
|
+
}).then(async (r) => {
|
|
587
|
+
if (r === '__retry__') {
|
|
588
|
+
// 递归重试(重新调一次)— 列表会再画一遍,实测可接受;保持 wizard 流程的简洁
|
|
589
|
+
return selectFromList({ rl, asker, title, items, t, extraOptionLabel })
|
|
590
|
+
}
|
|
591
|
+
return r
|
|
592
|
+
})
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* 数字输入回退路径(原 selectFromList 的实现)。
|
|
597
|
+
* 保留给非 TTY / 无 readline 的场景(管道、CI、测试)。
|
|
598
|
+
*/
|
|
599
|
+
async function selectByDigits({ asker, items, t, extraOptionLabel }) {
|
|
382
600
|
items.forEach((item, i) => {
|
|
383
601
|
console.log(` ${chalk.cyan(String(i + 1).padStart(2))}. ${item.label}`)
|
|
384
602
|
})
|
|
385
603
|
if (extraOptionLabel) {
|
|
386
604
|
console.log(` ${chalk.dim('0')}. ${chalk.dim(extraOptionLabel)}`)
|
|
387
605
|
}
|
|
388
|
-
|
|
389
606
|
const min = extraOptionLabel ? 0 : 1
|
|
390
607
|
const max = items.length
|
|
391
608
|
while (true) {
|
|
392
609
|
const answer = await asker.ask(t.selectPrompt(min, max))
|
|
393
610
|
const idx = Number.parseInt(answer, 10)
|
|
394
611
|
if (Number.isFinite(idx) && idx >= min && idx <= max) {
|
|
395
|
-
if (idx === 0) return null
|
|
612
|
+
if (idx === 0) return null
|
|
396
613
|
return { index: idx - 1, value: items[idx - 1].value }
|
|
397
614
|
}
|
|
398
615
|
console.log(chalk.yellow(t.invalidChoice(min, max)))
|
|
@@ -435,7 +652,13 @@ export async function collectModelInput({ locale = 'zh-CN', rl: injectedRl, fetc
|
|
|
435
652
|
label: `${p.label} ${chalk.dim(p.url)}`,
|
|
436
653
|
value: p,
|
|
437
654
|
}))
|
|
438
|
-
const providerChoice = await selectFromList(
|
|
655
|
+
const providerChoice = await selectFromList({
|
|
656
|
+
rl, asker,
|
|
657
|
+
title: t.selectProvider,
|
|
658
|
+
items: providerItems,
|
|
659
|
+
t,
|
|
660
|
+
extraOptionLabel: t.customOption,
|
|
661
|
+
})
|
|
439
662
|
|
|
440
663
|
let baseURL
|
|
441
664
|
if (providerChoice) {
|
|
@@ -463,12 +686,32 @@ export async function collectModelInput({ locale = 'zh-CN', rl: injectedRl, fetc
|
|
|
463
686
|
baseURL = confirmedBaseURL
|
|
464
687
|
}
|
|
465
688
|
|
|
466
|
-
// 3.
|
|
467
|
-
const
|
|
689
|
+
// 3. 输入 API Key(在获取模型列表之前,因为大部分服务商需要 Key 才能调 /models)
|
|
690
|
+
const apiKey = await asker.ask(t.apiKeyPrompt)
|
|
691
|
+
|
|
692
|
+
// 4. 选择/输入模型名称
|
|
693
|
+
// 先尝试从 API 获取模型列表(有了 apiKey 才能调通)
|
|
694
|
+
const fetchSpinner = startSpinner(t.fetchingModels)
|
|
695
|
+
const fetchedModels = await fetchModelsFromApi({ baseURL, apiKey, fetchFn })
|
|
696
|
+
fetchSpinner.stop()
|
|
697
|
+
|
|
698
|
+
// 使用 API 返回的模型列表,如果失败则使用内置列表
|
|
699
|
+
const modelList = fetchedModels.length > 0 ? fetchedModels : getBuiltinModels(baseURL)
|
|
700
|
+
|
|
701
|
+
if (fetchedModels.length === 0 && getBuiltinModels(baseURL).length > 0) {
|
|
702
|
+
console.log(chalk.dim(` ${t.fetchModelsFailed}`))
|
|
703
|
+
}
|
|
704
|
+
|
|
468
705
|
let model
|
|
469
|
-
if (
|
|
470
|
-
const modelItems =
|
|
471
|
-
const modelChoice = await selectFromList(
|
|
706
|
+
if (modelList.length > 0) {
|
|
707
|
+
const modelItems = modelList.map(m => ({ label: m, value: m }))
|
|
708
|
+
const modelChoice = await selectFromList({
|
|
709
|
+
rl, asker,
|
|
710
|
+
title: t.builtinModels,
|
|
711
|
+
items: modelItems,
|
|
712
|
+
t,
|
|
713
|
+
extraOptionLabel: t.manualInput,
|
|
714
|
+
})
|
|
472
715
|
if (modelChoice) {
|
|
473
716
|
model = modelChoice.value
|
|
474
717
|
} else {
|
|
@@ -480,7 +723,7 @@ export async function collectModelInput({ locale = 'zh-CN', rl: injectedRl, fetc
|
|
|
480
723
|
}
|
|
481
724
|
}
|
|
482
725
|
} else {
|
|
483
|
-
//
|
|
726
|
+
// 没有模型列表,直接手动输入
|
|
484
727
|
while (true) {
|
|
485
728
|
model = await asker.ask(t.modelPrompt)
|
|
486
729
|
if (model) break
|
|
@@ -488,9 +731,6 @@ export async function collectModelInput({ locale = 'zh-CN', rl: injectedRl, fetc
|
|
|
488
731
|
}
|
|
489
732
|
}
|
|
490
733
|
|
|
491
|
-
// 4. 输入 API Key
|
|
492
|
-
const apiKey = await asker.ask(t.apiKeyPrompt)
|
|
493
|
-
|
|
494
734
|
// 5. 输入显示名称(默认用 model 名)
|
|
495
735
|
const displayName = await asker.ask(t.displayNamePrompt(model), model)
|
|
496
736
|
|
|
@@ -591,4 +831,4 @@ export async function runModelSetup({ locale = 'zh-CN', rl: injectedRl, fetchFn
|
|
|
591
831
|
}
|
|
592
832
|
}
|
|
593
833
|
|
|
594
|
-
export default { runModelSetup, collectModelInput, testModelConnection, getBuiltinModels, findProviderByUrl, validateEndpoint, buildModelConfig, PROVIDERS, BUILTIN_MODELS }
|
|
834
|
+
export default { runModelSetup, collectModelInput, fetchModelsFromApi, testModelConnection, getBuiltinModels, findProviderByUrl, validateEndpoint, buildModelConfig, PROVIDERS, BUILTIN_MODELS }
|