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.
Files changed (67) hide show
  1. package/README.md +9 -0
  2. package/package.json +1 -1
  3. package/src/cli/ai/agent.js +123 -15
  4. package/src/cli/ai/modelSetup.js +265 -25
  5. package/src/cli/ai/modelSetup.test.js +36 -7
  6. package/src/cli/ai/sessionStore.js +97 -97
  7. package/src/cli/ai/termui.js +173 -6
  8. package/src/cli/ai/termui.test.js +261 -1
  9. package/src/ui/public/assets/AgentView-d83DuyQq.js +4 -0
  10. package/src/ui/public/assets/AppVersionBadge-UNabzjNF.js +2 -0
  11. package/src/ui/public/assets/BranchSelector-DUbFSWiQ.js +1 -0
  12. package/src/ui/public/assets/CommitForm-HiRHE42z.js +6 -0
  13. package/src/ui/public/assets/EditorView-BvLP-NL1.js +0 -0
  14. package/src/ui/public/assets/FlowExecutionViewer-6mr8M454.js +1 -0
  15. package/src/ui/public/assets/FlowOrchestrationWorkspace-Cw2ADY-p.js +21 -0
  16. package/src/ui/public/assets/LogList-DLg6RUCd.js +15 -0
  17. package/src/ui/public/assets/MindmapView-BKIsdNvz.js +2 -0
  18. package/src/ui/public/assets/MonitorView-BjjHr8a5.js +1 -0
  19. package/src/ui/public/assets/ProjectStartupButton-C-qI63k1.js +1 -0
  20. package/src/ui/public/assets/RemoteRepoCard-B0q3WipX.js +5 -0
  21. package/src/ui/public/assets/SourceMapView-BZgc-Ylv.js +3 -0
  22. package/src/ui/public/assets/UserInputNode-s3Dfg2Gm.js +1 -0
  23. package/src/ui/public/assets/WorkbenchView-BakrUWDH.js +28 -0
  24. package/src/ui/public/assets/element-plus-C0loJL0J.js +32 -0
  25. package/src/ui/public/assets/element-plus-CXfbWXaJ.css +1 -0
  26. package/src/ui/public/assets/{flow-mindmap-DnyDhwJv.js → flow-mindmap-CGn6wqsF.js} +1 -1
  27. package/src/ui/public/assets/index-Bumd4MSq.css +1 -0
  28. package/src/ui/public/assets/index-Cr5-U-r6.js +82 -0
  29. package/src/ui/public/assets/{monaco-Dje8Rg13.js → monaco-DPQ3OaiL.js} +2 -2
  30. package/src/ui/public/assets/socket-io-B9UuzwTN.js +1 -0
  31. package/src/ui/public/assets/{ts.worker-Dth06zuC.js → ts.worker-METxwbDZ.js} +1 -16
  32. package/src/ui/public/assets/{vendor-DVvq7qtS.css → vendor-C2ZpZPB4.css} +1 -1
  33. package/src/ui/public/assets/{vendor-exjCs1jx.js → vendor-pbWglt5U.js} +26 -67
  34. package/src/ui/public/assets/vue-flow-DRwHh4gM.js +39 -0
  35. package/src/ui/public/index.html +9 -15
  36. package/src/ui/server/index.js +4 -0
  37. package/src/ui/server/routes/workbench/agentChat.js +14 -3
  38. package/src/ui/server/routes/workbench/agentRoutes.js +218 -212
  39. package/src/ui/server/routes/workbench/agentSessionStore.js +173 -173
  40. package/src/ui/public/assets/AgentView-CioAzh-a.js +0 -4
  41. package/src/ui/public/assets/AppVersionBadge-Cwg6vOMz.js +0 -2
  42. package/src/ui/public/assets/BranchSelector-DgxIB9XY.js +0 -1
  43. package/src/ui/public/assets/CommitForm-CTa0pupJ.js +0 -6
  44. package/src/ui/public/assets/CommonDialog-ChSKsEz2.css +0 -1
  45. package/src/ui/public/assets/CommonDialog-Tlk0Fa7g.js +0 -1
  46. package/src/ui/public/assets/EditorView-BOZPwTxr.js +0 -0
  47. package/src/ui/public/assets/FlowExecutionViewer-CFpiqZ2C.js +0 -1
  48. package/src/ui/public/assets/FlowOrchestrationWorkspace-CVYPdN7a.js +0 -21
  49. package/src/ui/public/assets/LogList-B4rrapVn.js +0 -15
  50. package/src/ui/public/assets/MindmapView-C1pPUu8W.js +0 -2
  51. package/src/ui/public/assets/MonitorView-C2EyggDk.js +0 -1
  52. package/src/ui/public/assets/ProjectStartupButton-D7mgEsmf.js +0 -1
  53. package/src/ui/public/assets/RemoteRepoCard-B-5MDx8k.js +0 -5
  54. package/src/ui/public/assets/SourceMapView-C3gZ5s1r.js +0 -3
  55. package/src/ui/public/assets/SvgIcon-CDNsk4-Q.css +0 -1
  56. package/src/ui/public/assets/SvgIcon-Diox29ks.js +0 -1
  57. package/src/ui/public/assets/UserInputNode-CYxFuwuS.js +0 -1
  58. package/src/ui/public/assets/WorkbenchView-BE53JHac.js +0 -28
  59. package/src/ui/public/assets/_plugin-vue_export-helper-B9cwfrdH.js +0 -15
  60. package/src/ui/public/assets/configStore-B8X980y1.js +0 -1
  61. package/src/ui/public/assets/element-plus-BId3Dnij.js +0 -25
  62. package/src/ui/public/assets/element-plus-D3Dn7nX0.css +0 -1
  63. package/src/ui/public/assets/index--onqtBRQ.css +0 -1
  64. package/src/ui/public/assets/index-Ds_2iFkX.js +0 -68
  65. package/src/ui/public/assets/socket-io-CGezBd--.js +0 -1
  66. package/src/ui/public/assets/vue-flow-Dj_X_Vr1.js +0 -39
  67. /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.9",
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",
@@ -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 renderSlashHint = () => {
940
- if (!process.stdout.isTTY || state.busy || state.inWizard) { return }
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')
@@ -22,8 +22,8 @@
22
22
  // 1. 询问是否现在配置
23
23
  // 2. 选择服务商(预设列表 / 自定义)
24
24
  // 3. 确认接口地址(baseURL)
25
- // 4. 选择模型名称(常用模型列表 / 手动输入)
26
- // 5. 输入 API Key(本地模型可留空)
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
- * @param {object} asker
372
- * @param {string} title - 列表标题
373
- * @param {Array<{label: string, value: any}>} items
374
- * @param {object} t - i18n strings
375
- * @param {number} [extraOption] - 额外选项的序号(如"手动输入"用 0)
376
- * @param {string} [extraLabel]
377
- * @returns {Promise<{index: number, value: any}|null>} 选中的项,null = 用户选了额外选项
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(asker, t.selectProvider, providerItems, t, t.customOption)
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 builtin = getBuiltinModels(baseURL)
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 (builtin.length > 0) {
470
- const modelItems = builtin.map(m => ({ label: m, value: m }))
471
- const modelChoice = await selectFromList(asker, t.builtinModels, modelItems, t, t.manualInput)
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 }