claude360 0.2.3 → 0.2.5
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 +3 -0
- package/package.json +5 -1
- package/src/diagnostics.js +2 -2
- package/src/index.js +90 -64
- package/src/init-config.js +51 -34
- package/src/mcp-skill.js +45 -10
- package/src/menu.js +21 -12
- package/src/prompts.js +318 -0
- package/src/topup.js +28 -5
- package/src/ui.js +70 -0
package/README.md
CHANGED
|
@@ -29,6 +29,8 @@
|
|
|
29
29
|
| 功能 | 说明 |
|
|
30
30
|
| --- | --- |
|
|
31
31
|
| 一行命令安装 | Windows PowerShell / macOS / Linux bash 一行命令拉起 bootstrap,自动检测 Node/npm 与全局 npm 权限 |
|
|
32
|
+
| 方向键交互 | 全部菜单 / 选择 / 确认使用方向键导航(↑↓ 移动、Enter 确认、Esc 返回、空格多选、←→ 切换 YES/NO),非 TTY / CI 环境自动降级为编号输入 |
|
|
33
|
+
| 真实模型选择 | 模型列表来自 `GET /api/cli/models`(用户可用分组 × 模型元数据),展示模型 ID / 名称 / 标签 / 说明,保存真实 model id |
|
|
32
34
|
| 浏览器设备授权 | 通过 `/api/cli/auth/*` 设备码流程换取受限 CLI 凭证,不接触账号密码 |
|
|
33
35
|
| API Key 选择/创建 | 已有 Key 列表展示并选择;无 Key 时按后端分组倍率向导创建 |
|
|
34
36
|
| 余额与分组展示 | 日常主菜单展示账户余额、当前 Key、当前分组与倍率(来自后端) |
|
|
@@ -173,6 +175,7 @@ CLI 仅依赖一组隔离的 `/api/cli` 接口,由 `newapi/controller/cli.go`
|
|
|
173
175
|
| POST | `/api/cli/tokens/:id/reveal` | 校验归属并返回明文 Key,被限频且禁用缓存 |
|
|
174
176
|
| POST | `/api/cli/tokens` | 按分组创建新 Key(受 `operation_setting.GetMaxUserTokens()` 约束) |
|
|
175
177
|
| GET | `/api/cli/groups` | 用户可用分组(含 `display_name`、`ratio`、`description`) |
|
|
178
|
+
| GET | `/api/cli/models` | 当前用户可用模型列表(含 `display_name`、`tags`、`description`、`recommended`;`tool` 参数预留) |
|
|
176
179
|
| GET | `/api/cli/topup/options` | 后端配置的充值金额选项与最低额,包含 WeChat 启用状态 |
|
|
177
180
|
| POST | `/api/cli/topup/wechat` | 创建微信 Native 订单,返回 `code_url`、`order_id`、`pay_money` |
|
|
178
181
|
| GET | `/api/cli/topup/:order_id` | 查询订单状态(仅订单所属用户) |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude360",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.5",
|
|
4
4
|
"description": "Interactive Claude360 CLI for browser auth, API key setup, balance checks, top-up, Claude Code and Codex launch.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -16,10 +16,14 @@
|
|
|
16
16
|
"node": ">=18"
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
|
+
"@inquirer/core": "^11.2.1",
|
|
19
20
|
"qrcode-terminal": "^0.12.0",
|
|
20
21
|
"smol-toml": "^1.6.1"
|
|
21
22
|
},
|
|
22
23
|
"scripts": {
|
|
23
24
|
"test": "node ./scripts/test.js"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@inquirer/testing": "^3.3.9"
|
|
24
28
|
}
|
|
25
29
|
}
|
package/src/diagnostics.js
CHANGED
|
@@ -5,7 +5,7 @@ import os from "node:os";
|
|
|
5
5
|
|
|
6
6
|
import { resolveCodexConfigPath } from "./tool-launcher.js";
|
|
7
7
|
import { sanitizeError, sanitizeText } from "./sanitize.js";
|
|
8
|
-
import { renderSectionTitle, renderTable } from "./ui.js";
|
|
8
|
+
import { renderHeader, renderSectionTitle, renderTable } from "./ui.js";
|
|
9
9
|
|
|
10
10
|
export async function runDiagnostics({
|
|
11
11
|
config = {},
|
|
@@ -179,7 +179,7 @@ export function formatDiagnosticsSummary(report, { width = 0 } = {}) {
|
|
|
179
179
|
},
|
|
180
180
|
];
|
|
181
181
|
|
|
182
|
-
const lines = ["诊断报告"];
|
|
182
|
+
const lines = [renderHeader("诊断报告")];
|
|
183
183
|
for (const group of groups) {
|
|
184
184
|
lines.push("", renderSectionTitle(group.title));
|
|
185
185
|
lines.push(renderTable({
|
package/src/index.js
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import readline from "node:readline/promises";
|
|
2
|
-
import { stdin as input, stdout as output } from "node:process";
|
|
3
1
|
import { execFile } from "node:child_process";
|
|
4
2
|
import { readFile as fsReadFile } from "node:fs/promises";
|
|
5
3
|
import { createRequire } from "node:module";
|
|
@@ -11,6 +9,8 @@ import { formatAccountStatus, loadAccountStatus } from "./account-status.js";
|
|
|
11
9
|
import { colorEnabled, formatCheckLine, playBanner, renderBanner } from "./banner.js";
|
|
12
10
|
import { runCcSwitchGenerator } from "./cc-switch.js";
|
|
13
11
|
import { createConfigStore } from "./config-store.js";
|
|
12
|
+
import { createPrompts, isInteractive } from "./prompts.js";
|
|
13
|
+
import { renderHeader } from "./ui.js";
|
|
14
14
|
import {
|
|
15
15
|
commandVersion,
|
|
16
16
|
defaultExecCommand,
|
|
@@ -72,9 +72,9 @@ export async function runCli({
|
|
|
72
72
|
authenticateWithBrowser: authWithBrowser = authenticateWithBrowser,
|
|
73
73
|
chooseOrCreateToken: chooseToken = chooseOrCreateToken,
|
|
74
74
|
createToken = createNewToken,
|
|
75
|
-
promptSelect
|
|
76
|
-
promptInput
|
|
77
|
-
confirm
|
|
75
|
+
promptSelect,
|
|
76
|
+
promptInput,
|
|
77
|
+
confirm,
|
|
78
78
|
openBrowser = defaultOpenBrowser,
|
|
79
79
|
installOrUpdateTools = installTools,
|
|
80
80
|
launchClaudeCode = startClaudeCode,
|
|
@@ -94,7 +94,7 @@ export async function runCli({
|
|
|
94
94
|
configureClaudeModel = configureClaudeDefaultModel,
|
|
95
95
|
configureCodexModel = configureCodexDefaultModel,
|
|
96
96
|
importEnvPermissions = importClaudeEnvPermissions,
|
|
97
|
-
multiSelectInput
|
|
97
|
+
multiSelectInput,
|
|
98
98
|
readFileImpl = fsReadFile,
|
|
99
99
|
claudeDir = resolveClaudeDir(),
|
|
100
100
|
codexDir = resolveCodexDir(),
|
|
@@ -114,6 +114,19 @@ export async function runCli({
|
|
|
114
114
|
let api = createApiClient({ baseUrl, cliToken: config.cliToken || "" });
|
|
115
115
|
// 仅在真实终端(writeLine 未被测试替换)启用彩色与动效
|
|
116
116
|
const fancyOutput = writeLine === console.log && colorEnabled();
|
|
117
|
+
// 方向键交互:真实 TTY 且交互未被测试替换时启用(优化需求第五节),
|
|
118
|
+
// 非交互环境降级为编号输入,由 prompts.js 统一处理
|
|
119
|
+
const interactiveUi = writeLine === console.log && isInteractive();
|
|
120
|
+
if (!promptSelect || !promptInput || !confirm) {
|
|
121
|
+
const defaults = createPrompts({ interactive: interactiveUi, color: fancyOutput });
|
|
122
|
+
promptSelect = promptSelect || defaults.promptSelect;
|
|
123
|
+
promptInput = promptInput || defaults.promptInput;
|
|
124
|
+
confirm = confirm || defaults.confirm;
|
|
125
|
+
if (!multiSelectInput && interactiveUi) {
|
|
126
|
+
multiSelectInput = ({ message, choices, preselected }) => defaults.multiSelect({ message, choices, preselected });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
multiSelectInput = multiSelectInput || promptMultiSelect;
|
|
117
130
|
|
|
118
131
|
if (showBanner) {
|
|
119
132
|
if (fancyOutput) {
|
|
@@ -121,7 +134,10 @@ export async function runCli({
|
|
|
121
134
|
} else {
|
|
122
135
|
writeLine(renderBanner({ version, baseUrl }));
|
|
123
136
|
}
|
|
124
|
-
|
|
137
|
+
// 首页保持低高度(优化需求 1):环境检查仅在未登录 / 强制配置时展示
|
|
138
|
+
if (!config.cliToken || forceSetup) {
|
|
139
|
+
await showEnvironmentChecks();
|
|
140
|
+
}
|
|
125
141
|
}
|
|
126
142
|
|
|
127
143
|
// ──────────────────────────────────────────────
|
|
@@ -373,7 +389,7 @@ export async function runCli({
|
|
|
373
389
|
// Claude360 Provider 菜单(PRD 8.5)
|
|
374
390
|
async function runCodexProviderMenu() {
|
|
375
391
|
while (true) {
|
|
376
|
-
writeLine("配置
|
|
392
|
+
writeLine(renderHeader("Codex Provider 配置", { color: fancyOutput }));
|
|
377
393
|
writeLine("当前 Provider:claude360");
|
|
378
394
|
writeLine("当前 Profile:claude360\n");
|
|
379
395
|
const action = await promptSelect("请选择:", [
|
|
@@ -452,12 +468,12 @@ export async function runCli({
|
|
|
452
468
|
configureLanguage: () => configureLanguage({ baseDir: claudeDir, filePath: claudeMemoryPath, promptSelect, writeLine }),
|
|
453
469
|
configureStyle: () => configureStyle({ baseDir: claudeDir, filePath: claudeMemoryPath, promptSelect, writeLine }),
|
|
454
470
|
installWorkflows: () => installClaudeWfs({ claudeDir, multiSelect, confirm, writeLine }),
|
|
455
|
-
installMcps: () => installMcps({ multiSelect, confirm, writeLine }),
|
|
471
|
+
installMcps: () => installMcps({ api, multiSelect, confirm, writeLine }),
|
|
456
472
|
configureModel: () => configureClaudeModel({ api, claudeDir, promptSelect, writeLine }),
|
|
457
473
|
importEnvPermissions: () => importEnvPermissions({ claudeDir, confirm, writeLine }),
|
|
458
474
|
testConnection: testConnectionStep,
|
|
459
475
|
askLaunch: async () => {
|
|
460
|
-
if (await confirm("初始化完成。是否立即启动 Claude Code?")) {
|
|
476
|
+
if (await confirm("初始化完成。是否立即启动 Claude Code?", { defaultYes: true })) {
|
|
461
477
|
await doLaunchClaude();
|
|
462
478
|
}
|
|
463
479
|
},
|
|
@@ -486,10 +502,10 @@ export async function runCli({
|
|
|
486
502
|
writeLine("✓ 已写入 Codex claude360 provider/profile(~/.codex/config.toml)");
|
|
487
503
|
},
|
|
488
504
|
checkCompat: checkCodexCompatStep,
|
|
489
|
-
installMcps: () => installCodexMcpServers({ codexDir, multiSelect, confirm, writeLine }),
|
|
505
|
+
installMcps: () => installCodexMcpServers({ api, codexDir, multiSelect, confirm, writeLine }),
|
|
490
506
|
testConnection: testConnectionStep,
|
|
491
507
|
askLaunch: async () => {
|
|
492
|
-
if (await confirm("初始化完成。是否立即启动 Codex?")) {
|
|
508
|
+
if (await confirm("初始化完成。是否立即启动 Codex?", { defaultYes: true })) {
|
|
493
509
|
await doLaunchCodex();
|
|
494
510
|
}
|
|
495
511
|
},
|
|
@@ -700,6 +716,7 @@ export async function runCli({
|
|
|
700
716
|
}
|
|
701
717
|
const approved = await confirm(
|
|
702
718
|
"将退出登录并清除本地保存的 CLI Token 与 API Key。\n不会删除 Claude360 网站上的账号或 API Key。\n是否继续?",
|
|
719
|
+
{ danger: true },
|
|
703
720
|
);
|
|
704
721
|
if (!approved) {
|
|
705
722
|
writeLine("已取消。");
|
|
@@ -713,7 +730,7 @@ export async function runCli({
|
|
|
713
730
|
}
|
|
714
731
|
|
|
715
732
|
async function doRelogin() {
|
|
716
|
-
const approved = await confirm("将重新进行浏览器授权并覆盖本地登录状态。是否继续?");
|
|
733
|
+
const approved = await confirm("将重新进行浏览器授权并覆盖本地登录状态。是否继续?", { danger: true });
|
|
717
734
|
if (!approved) {
|
|
718
735
|
return false;
|
|
719
736
|
}
|
|
@@ -725,6 +742,7 @@ export async function runCli({
|
|
|
725
742
|
async function doClearConfig() {
|
|
726
743
|
const approved = await confirm(
|
|
727
744
|
"该操作会清除本地 Claude360 CLI 登录状态和已保存 Key。\n不会删除 Claude360 网站上的账号或 API Key。\n是否继续?",
|
|
745
|
+
{ danger: true },
|
|
728
746
|
);
|
|
729
747
|
if (!approved) {
|
|
730
748
|
writeLine("已取消。");
|
|
@@ -743,7 +761,7 @@ export async function runCli({
|
|
|
743
761
|
|
|
744
762
|
async function runBalanceTopUpMenu() {
|
|
745
763
|
while (true) {
|
|
746
|
-
writeLine("
|
|
764
|
+
writeLine(renderHeader("余额与充值", { color: fancyOutput }));
|
|
747
765
|
const me = await showBalanceAndUsage();
|
|
748
766
|
if (me?.low_balance) {
|
|
749
767
|
writeLine("状态:余额偏低,建议充值");
|
|
@@ -767,7 +785,7 @@ export async function runCli({
|
|
|
767
785
|
|
|
768
786
|
async function doCreateKey() {
|
|
769
787
|
const token = await createToken({ api, promptSelect, promptInput });
|
|
770
|
-
const useNow = await confirm(`已创建 API Key:${token.tokenName}。是否将其设为当前使用的 Key
|
|
788
|
+
const useNow = await confirm(`已创建 API Key:${token.tokenName}。是否将其设为当前使用的 Key?`, { defaultYes: true });
|
|
771
789
|
if (useNow) {
|
|
772
790
|
await saveConfig({
|
|
773
791
|
apiKey: token.apiKey,
|
|
@@ -809,7 +827,7 @@ export async function runCli({
|
|
|
809
827
|
break;
|
|
810
828
|
case "mcp":
|
|
811
829
|
if (await confirmNotice()) {
|
|
812
|
-
await installMcps({ multiSelect, confirm, writeLine });
|
|
830
|
+
await installMcps({ api, multiSelect, confirm, writeLine });
|
|
813
831
|
}
|
|
814
832
|
break;
|
|
815
833
|
case "model":
|
|
@@ -863,7 +881,7 @@ export async function runCli({
|
|
|
863
881
|
break;
|
|
864
882
|
case "mcp":
|
|
865
883
|
if (await confirmNotice()) {
|
|
866
|
-
await installCodexMcpServers({ codexDir, multiSelect, confirm, writeLine });
|
|
884
|
+
await installCodexMcpServers({ api, codexDir, multiSelect, confirm, writeLine });
|
|
867
885
|
}
|
|
868
886
|
break;
|
|
869
887
|
case "model":
|
|
@@ -902,7 +920,7 @@ export async function runCli({
|
|
|
902
920
|
]);
|
|
903
921
|
switch (action) {
|
|
904
922
|
case "claude_mcp":
|
|
905
|
-
await installMcps({ multiSelect, confirm, writeLine });
|
|
923
|
+
await installMcps({ api, multiSelect, confirm, writeLine });
|
|
906
924
|
break;
|
|
907
925
|
case "claude_skill": {
|
|
908
926
|
const kind = await promptSelect("请选择要安装的内容:", [
|
|
@@ -918,7 +936,7 @@ export async function runCli({
|
|
|
918
936
|
break;
|
|
919
937
|
}
|
|
920
938
|
case "codex_mcp":
|
|
921
|
-
await installCodexMcpServers({ codexDir, multiSelect, confirm, writeLine });
|
|
939
|
+
await installCodexMcpServers({ api, codexDir, multiSelect, confirm, writeLine });
|
|
922
940
|
break;
|
|
923
941
|
case "codex_agents":
|
|
924
942
|
await installAgents({ confirm, writeLine });
|
|
@@ -1019,7 +1037,7 @@ export async function runCli({
|
|
|
1019
1037
|
{ label: "跳过", value: "skip" },
|
|
1020
1038
|
]);
|
|
1021
1039
|
if (wantMcp === "mcp") {
|
|
1022
|
-
await installMcps({
|
|
1040
|
+
await installMcps({ api, multiSelect, confirm, writeLine });
|
|
1023
1041
|
} else if (wantMcp === "skill") {
|
|
1024
1042
|
await installSkills({ promptSelect, confirm, writeLine });
|
|
1025
1043
|
}
|
|
@@ -1042,7 +1060,7 @@ export async function runCli({
|
|
|
1042
1060
|
if (targets.length === 1) {
|
|
1043
1061
|
const tool = targets[0];
|
|
1044
1062
|
const label = tool === "claude" ? "Claude Code" : "Codex";
|
|
1045
|
-
if (await confirm(`配置完成。是否立即启动 ${label}
|
|
1063
|
+
if (await confirm(`配置完成。是否立即启动 ${label}?`, { defaultYes: true })) {
|
|
1046
1064
|
return tool === "claude" ? doLaunchClaude() : doLaunchCodex();
|
|
1047
1065
|
}
|
|
1048
1066
|
return true;
|
|
@@ -1071,7 +1089,12 @@ export async function runCli({
|
|
|
1071
1089
|
}
|
|
1072
1090
|
}
|
|
1073
1091
|
while (true) {
|
|
1074
|
-
const action = await promptMenu({
|
|
1092
|
+
const action = await promptMenu({
|
|
1093
|
+
menu: buildFirstRunMenu(),
|
|
1094
|
+
promptInput,
|
|
1095
|
+
writeLine,
|
|
1096
|
+
select: interactiveUi ? promptSelect : undefined,
|
|
1097
|
+
});
|
|
1075
1098
|
switch (action) {
|
|
1076
1099
|
case "setup_claude":
|
|
1077
1100
|
if (await runFirstSetup(["claude"])) {
|
|
@@ -1121,12 +1144,53 @@ export async function runCli({
|
|
|
1121
1144
|
// 日常菜单循环(PRD 第 11 章)
|
|
1122
1145
|
// ──────────────────────────────────────────────
|
|
1123
1146
|
|
|
1147
|
+
// 切换 Key / 模型(优化需求 1:常用操作直达入口)
|
|
1148
|
+
async function runSwitchKeyModelMenu() {
|
|
1149
|
+
while (true) {
|
|
1150
|
+
const action = await promptSelect("切换 Key / 模型", [
|
|
1151
|
+
{ label: `切换 API Key(当前:${config.tokenName || "未选择"})`, value: "key" },
|
|
1152
|
+
{ label: "Claude Code 默认模型", value: "claude_model" },
|
|
1153
|
+
{ label: "Codex 默认模型", value: "codex_model" },
|
|
1154
|
+
{ label: "返回", value: "back" },
|
|
1155
|
+
]);
|
|
1156
|
+
switch (action) {
|
|
1157
|
+
case "key": {
|
|
1158
|
+
const token = await chooseToken({ api, promptSelect, promptInput, writeLine });
|
|
1159
|
+
await saveConfig({
|
|
1160
|
+
apiKey: token.apiKey,
|
|
1161
|
+
selectedTokenId: token.tokenId,
|
|
1162
|
+
tokenName: token.tokenName,
|
|
1163
|
+
group: token.group,
|
|
1164
|
+
});
|
|
1165
|
+
writeLine(`✓ 当前 Key 已切换为 ${token.tokenName}`);
|
|
1166
|
+
break;
|
|
1167
|
+
}
|
|
1168
|
+
case "claude_model":
|
|
1169
|
+
await configureClaudeModel({ api, claudeDir, promptSelect, writeLine });
|
|
1170
|
+
break;
|
|
1171
|
+
case "codex_model":
|
|
1172
|
+
await configureCodexModel({ api, codexDir, promptSelect, writeLine });
|
|
1173
|
+
break;
|
|
1174
|
+
default:
|
|
1175
|
+
return;
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1124
1180
|
async function runDailyFlow() {
|
|
1125
1181
|
while (true) {
|
|
1182
|
+
if (interactiveUi) {
|
|
1183
|
+
writeLine(renderHeader("主菜单", { color: fancyOutput }));
|
|
1184
|
+
}
|
|
1126
1185
|
const status = await loadAccountStatus({ api, config });
|
|
1127
1186
|
writeLine(formatAccountStatus({ ...status, width: outputWidth() }));
|
|
1128
1187
|
writeLine("");
|
|
1129
|
-
const action = await promptMenu({
|
|
1188
|
+
const action = await promptMenu({
|
|
1189
|
+
menu: buildDailyMenu(),
|
|
1190
|
+
promptInput,
|
|
1191
|
+
writeLine,
|
|
1192
|
+
select: interactiveUi ? promptSelect : undefined,
|
|
1193
|
+
});
|
|
1130
1194
|
switch (action) {
|
|
1131
1195
|
case "launch_claude":
|
|
1132
1196
|
if (await doLaunchClaude()) {
|
|
@@ -1141,6 +1205,9 @@ export async function runCli({
|
|
|
1141
1205
|
case "balance_topup":
|
|
1142
1206
|
await runBalanceTopUpMenu();
|
|
1143
1207
|
break;
|
|
1208
|
+
case "switch_key_model":
|
|
1209
|
+
await runSwitchKeyModelMenu();
|
|
1210
|
+
break;
|
|
1144
1211
|
case "create_key":
|
|
1145
1212
|
await doCreateKey();
|
|
1146
1213
|
break;
|
|
@@ -1251,47 +1318,6 @@ function formatCliUsage() {
|
|
|
1251
1318
|
].join("\n");
|
|
1252
1319
|
}
|
|
1253
1320
|
|
|
1254
|
-
async function defaultPromptSelect(message, choices) {
|
|
1255
|
-
const rl = readline.createInterface({ input, output });
|
|
1256
|
-
try {
|
|
1257
|
-
writeChoices(message, choices);
|
|
1258
|
-
const answer = await rl.question("> ");
|
|
1259
|
-
const index = Number(answer) - 1;
|
|
1260
|
-
if (Number.isInteger(index) && choices[index]) {
|
|
1261
|
-
return choices[index].value;
|
|
1262
|
-
}
|
|
1263
|
-
const matched = choices.find((choice) => choice.value === answer);
|
|
1264
|
-
if (matched) {
|
|
1265
|
-
return matched.value;
|
|
1266
|
-
}
|
|
1267
|
-
throw new Error("选择无效");
|
|
1268
|
-
} finally {
|
|
1269
|
-
rl.close();
|
|
1270
|
-
}
|
|
1271
|
-
}
|
|
1272
|
-
|
|
1273
|
-
async function defaultPromptInput(message, defaultValue = "") {
|
|
1274
|
-
const rl = readline.createInterface({ input, output });
|
|
1275
|
-
try {
|
|
1276
|
-
const suffix = defaultValue ? ` (${defaultValue})` : "";
|
|
1277
|
-
const answer = await rl.question(`${message}${suffix}: `);
|
|
1278
|
-
return answer.trim() || defaultValue;
|
|
1279
|
-
} finally {
|
|
1280
|
-
rl.close();
|
|
1281
|
-
}
|
|
1282
|
-
}
|
|
1283
|
-
|
|
1284
|
-
async function defaultConfirm(message) {
|
|
1285
|
-
const answer = await defaultPromptInput(`${message}\n输入 yes 确认`, "no");
|
|
1286
|
-
return ["yes", "y", "确认", "是", "继续"].includes(answer.trim().toLowerCase());
|
|
1287
|
-
}
|
|
1288
|
-
|
|
1289
|
-
function writeChoices(message, choices) {
|
|
1290
|
-
console.log(message);
|
|
1291
|
-
choices.forEach((choice, index) => {
|
|
1292
|
-
console.log(`${index + 1}. ${choice.label}`);
|
|
1293
|
-
});
|
|
1294
|
-
}
|
|
1295
1321
|
|
|
1296
1322
|
function defaultOpenBrowser(url) {
|
|
1297
1323
|
const commands = {
|
package/src/init-config.js
CHANGED
|
@@ -10,6 +10,7 @@ import path from "node:path";
|
|
|
10
10
|
import { createBackup } from "./backup.js";
|
|
11
11
|
import { upsertMarkedBlock } from "./mcp-skill.js";
|
|
12
12
|
import { upsertProfileKey, validateBasicToml } from "./tool-launcher.js";
|
|
13
|
+
import { renderModelTable } from "./ui.js";
|
|
13
14
|
|
|
14
15
|
const defaultFs = { copyFile, cp, mkdir, readFile, stat, writeFile };
|
|
15
16
|
|
|
@@ -190,32 +191,51 @@ export async function configurePromptStyle({
|
|
|
190
191
|
}
|
|
191
192
|
|
|
192
193
|
// ──────────────────────────────────────────────
|
|
193
|
-
// Claude Code 默认模型(PRD 6.7
|
|
194
|
-
//
|
|
195
|
-
// CLI
|
|
194
|
+
// Claude Code 默认模型(PRD 6.7 / 优化需求第 3 节):
|
|
195
|
+
// 模型列表从后端 GET /api/cli/models 拉取,展示真实模型 ID / 名称 /
|
|
196
|
+
// 标签 / 说明,用户选择后保存真实 model id;CLI 不硬编码模型与档位。
|
|
197
|
+
// 接口不可用时 Claude Code 回退官方别名(sonnet/opus/haiku)并明确提示,
|
|
198
|
+
// 「推荐 / 高性能 / 经济」只作为标签注记,不代替真实模型。
|
|
196
199
|
// ──────────────────────────────────────────────
|
|
197
200
|
|
|
198
|
-
export const
|
|
199
|
-
{ id: "
|
|
200
|
-
{ id: "
|
|
201
|
-
{ id: "
|
|
201
|
+
export const FALLBACK_CLAUDE_ALIASES = [
|
|
202
|
+
{ id: "sonnet", display_name: "Sonnet(官方别名)", tags: ["推荐"], description: "适合大多数代码任务" },
|
|
203
|
+
{ id: "opus", display_name: "Opus(官方别名)", tags: ["高性能"], description: "适合复杂架构和长上下文任务" },
|
|
204
|
+
{ id: "haiku", display_name: "Haiku(官方别名)", tags: ["经济"], description: "适合日常轻量任务" },
|
|
202
205
|
];
|
|
203
206
|
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
export async function
|
|
207
|
+
// 后端接口 GET /api/cli/models?tool=<claude_code|codex>:
|
|
208
|
+
// { models: [{ id, display_name, description, tags, recommended, context_length }] }
|
|
209
|
+
export async function loadAvailableModels(api, tool = "") {
|
|
207
210
|
if (api && typeof api.get === "function") {
|
|
208
211
|
try {
|
|
209
|
-
const
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
|
|
212
|
+
const query = tool ? `?tool=${encodeURIComponent(tool)}` : "";
|
|
213
|
+
const data = await api.get(`/api/cli/models${query}`);
|
|
214
|
+
const models = (Array.isArray(data?.models) ? data.models : [])
|
|
215
|
+
.filter((model) => model && typeof model.id === "string" && model.id !== "");
|
|
216
|
+
if (models.length > 0) {
|
|
217
|
+
return { models, source: "backend" };
|
|
213
218
|
}
|
|
214
219
|
} catch {
|
|
215
|
-
//
|
|
220
|
+
// 后端暂未提供模型列表接口或网络失败,由调用方决定回退策略
|
|
216
221
|
}
|
|
217
222
|
}
|
|
218
|
-
return {
|
|
223
|
+
return { models: [], source: "unavailable" };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// 选项标签:真实模型 id 优先,display_name 与标签只作补充说明
|
|
227
|
+
function formatModelChoiceLabel(model) {
|
|
228
|
+
const name = model.display_name && model.display_name !== model.id ? `(${model.display_name})` : "";
|
|
229
|
+
const tags = Array.isArray(model.tags) && model.tags.length > 0 ? ` [${model.tags.join("/")}]` : "";
|
|
230
|
+
return `${model.id}${name}${tags}`;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function selectModelFromList({ models, promptSelect, writeLine }) {
|
|
234
|
+
writeLine(renderModelTable(models, { width: process.stdout.columns || 0 }));
|
|
235
|
+
return promptSelect("请选择默认模型:", [
|
|
236
|
+
...models.map((model) => ({ label: formatModelChoiceLabel(model), value: model.id, hint: model.description || "" })),
|
|
237
|
+
{ label: "跳过", value: "skip" },
|
|
238
|
+
]);
|
|
219
239
|
}
|
|
220
240
|
|
|
221
241
|
async function readJsonIfExists(fs, filePath) {
|
|
@@ -250,22 +270,23 @@ export async function configureClaudeDefaultModel({
|
|
|
250
270
|
if (typeof promptSelect !== "function") {
|
|
251
271
|
throw new Error("缺少选择输入");
|
|
252
272
|
}
|
|
253
|
-
const {
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
273
|
+
const { models, source } = await loadAvailableModels(api, "claude_code");
|
|
274
|
+
let list = models;
|
|
275
|
+
if (source !== "backend") {
|
|
276
|
+
writeLine("Claude360 后端暂未提供模型列表,以下为 Claude Code 官方模型别名:");
|
|
277
|
+
list = FALLBACK_CLAUDE_ALIASES;
|
|
278
|
+
}
|
|
279
|
+
const selected = await selectModelFromList({ models: list, promptSelect, writeLine });
|
|
258
280
|
if (selected === "skip") {
|
|
259
281
|
writeLine("已跳过默认模型配置。");
|
|
260
282
|
return { skipped: true };
|
|
261
283
|
}
|
|
262
|
-
const tier = tiers.find((item) => item.id === selected);
|
|
263
284
|
const settingsPath = path.join(claudeDir, "settings.json");
|
|
264
285
|
const settings = await readJsonIfExists(fs, settingsPath);
|
|
265
|
-
settings.model =
|
|
286
|
+
settings.model = selected;
|
|
266
287
|
await writeJsonWithBackup({ baseDir: claudeDir, filePath: settingsPath, json: settings, writeLine, fs, now });
|
|
267
|
-
writeLine(`✓ 默认模型已设置为 ${
|
|
268
|
-
return { skipped: false, model:
|
|
288
|
+
writeLine(`✓ 默认模型已设置为 ${selected}(${settingsPath})`);
|
|
289
|
+
return { skipped: false, model: selected };
|
|
269
290
|
}
|
|
270
291
|
|
|
271
292
|
// ──────────────────────────────────────────────
|
|
@@ -349,24 +370,20 @@ export async function configureCodexDefaultModel({
|
|
|
349
370
|
if (typeof promptSelect !== "function") {
|
|
350
371
|
throw new Error("缺少选择输入");
|
|
351
372
|
}
|
|
352
|
-
const {
|
|
373
|
+
const { models, source } = await loadAvailableModels(api, "codex");
|
|
353
374
|
if (source !== "backend") {
|
|
354
375
|
writeLine("Claude360 后端暂未提供 Codex 模型列表,已跳过默认模型写入。");
|
|
355
376
|
writeLine("可在启动 Codex 后使用 /model 命令选择模型。");
|
|
356
377
|
return { skipped: true };
|
|
357
378
|
}
|
|
358
|
-
const selected = await
|
|
359
|
-
...tiers.map((tier) => ({ label: tier.label, value: tier.id })),
|
|
360
|
-
{ label: "跳过", value: "skip" },
|
|
361
|
-
]);
|
|
379
|
+
const selected = await selectModelFromList({ models, promptSelect, writeLine });
|
|
362
380
|
if (selected === "skip") {
|
|
363
381
|
writeLine("已跳过默认模型配置。");
|
|
364
382
|
return { skipped: true };
|
|
365
383
|
}
|
|
366
|
-
const tier = tiers.find((item) => item.id === selected);
|
|
367
384
|
const configPath = path.join(codexDir, "config.toml");
|
|
368
385
|
const current = await readFileIfExists(fs, configPath);
|
|
369
|
-
const next = `${upsertProfileKey(current, "claude360", "model",
|
|
386
|
+
const next = `${upsertProfileKey(current, "claude360", "model", selected).trimEnd()}\n`;
|
|
370
387
|
const tomlError = validateBasicToml(next);
|
|
371
388
|
if (tomlError) {
|
|
372
389
|
writeLine(`× 生成的 Codex 配置 TOML 校验失败:${tomlError}\n已放弃写入,原配置保持不变。`);
|
|
@@ -378,6 +395,6 @@ export async function configureCodexDefaultModel({
|
|
|
378
395
|
}
|
|
379
396
|
await fs.mkdir(codexDir, { recursive: true });
|
|
380
397
|
await fs.writeFile(configPath, next, "utf8");
|
|
381
|
-
writeLine(`✓ Codex 默认模型已设置为 ${
|
|
382
|
-
return { skipped: false, model:
|
|
398
|
+
writeLine(`✓ Codex 默认模型已设置为 ${selected}(${configPath})`);
|
|
399
|
+
return { skipped: false, model: selected };
|
|
383
400
|
}
|
package/src/mcp-skill.js
CHANGED
|
@@ -78,6 +78,36 @@ export function resolveClaudeJsonPath({ homedir = os.homedir } = {}) {
|
|
|
78
78
|
return path.join(homedir(), ".claude.json");
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
// 远程 MCP 项必须带完整安装信息才可用,避免脏数据进入安装流程
|
|
82
|
+
function isValidRemoteMcp(mcp) {
|
|
83
|
+
return Boolean(
|
|
84
|
+
mcp
|
|
85
|
+
&& typeof mcp.id === "string" && mcp.id !== ""
|
|
86
|
+
&& typeof mcp.label === "string" && mcp.label !== ""
|
|
87
|
+
&& Array.isArray(mcp.claudeArgs) && mcp.claudeArgs.length > 0
|
|
88
|
+
&& mcp.codex && typeof mcp.codex.command === "string"
|
|
89
|
+
&& Array.isArray(mcp.codex.args),
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// MCP 列表远程优先(优化需求第 4 节):GET /api/cli/mcps 返回
|
|
94
|
+
// { mcps: [...] };接口不可用或数据不合法时回退内置 RECOMMENDED_MCPS,
|
|
95
|
+
// 避免列表长期写死在 CLI。
|
|
96
|
+
export async function loadRecommendedMcps(api) {
|
|
97
|
+
if (api && typeof api.get === "function") {
|
|
98
|
+
try {
|
|
99
|
+
const data = await api.get("/api/cli/mcps");
|
|
100
|
+
const mcps = (Array.isArray(data?.mcps) ? data.mcps : []).filter(isValidRemoteMcp);
|
|
101
|
+
if (mcps.length > 0) {
|
|
102
|
+
return { mcps, source: "remote" };
|
|
103
|
+
}
|
|
104
|
+
} catch {
|
|
105
|
+
// 后端暂未提供 MCP 列表接口,回退内置推荐
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return { mcps: RECOMMENDED_MCPS, source: "builtin" };
|
|
109
|
+
}
|
|
110
|
+
|
|
81
111
|
// 已安装的 Claude Code 用户级 MCP(读取 ~/.claude.json 的 mcpServers 键)
|
|
82
112
|
export async function readInstalledClaudeMcps({
|
|
83
113
|
claudeJsonPath = resolveClaudeJsonPath(),
|
|
@@ -95,18 +125,19 @@ export async function readInstalledClaudeMcps({
|
|
|
95
125
|
}
|
|
96
126
|
}
|
|
97
127
|
|
|
98
|
-
// 多选推荐 MCP
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
128
|
+
// 多选推荐 MCP;展示推荐标识 ★、说明与平台兼容性,
|
|
129
|
+
// 已安装项展示为「已安装」且不可重复安装(PRD 6.6 / 优化需求第 4 节)
|
|
130
|
+
async function selectMcps({ mcps, multiSelect, installedIds, writeLine }) {
|
|
131
|
+
const choices = mcps.map((mcp) => ({
|
|
132
|
+
label: `${mcp.recommended ? "★ " : ""}${mcp.label}${installedIds.includes(mcp.id) ? "(已安装)" : ""}`,
|
|
102
133
|
value: mcp.id,
|
|
103
|
-
hint: mcp.desc
|
|
134
|
+
hint: `${mcp.desc || ""} · ${mcp.platforms || "全平台"}`,
|
|
104
135
|
}));
|
|
105
|
-
const preselected =
|
|
136
|
+
const preselected = mcps
|
|
106
137
|
.filter((mcp) => mcp.recommended && !installedIds.includes(mcp.id))
|
|
107
138
|
.map((mcp) => mcp.id);
|
|
108
139
|
const selected = await multiSelect({
|
|
109
|
-
message: "请选择要安装的 MCP
|
|
140
|
+
message: "请选择要安装的 MCP 服务(★ 为推荐项,已预选):",
|
|
110
141
|
choices,
|
|
111
142
|
preselected,
|
|
112
143
|
});
|
|
@@ -116,7 +147,7 @@ async function selectMcps({ multiSelect, installedIds, writeLine }) {
|
|
|
116
147
|
writeLine(`已安装,跳过:${id}`);
|
|
117
148
|
continue;
|
|
118
149
|
}
|
|
119
|
-
fresh.push(
|
|
150
|
+
fresh.push(mcps.find((mcp) => mcp.id === id));
|
|
120
151
|
}
|
|
121
152
|
return fresh;
|
|
122
153
|
}
|
|
@@ -140,6 +171,7 @@ async function confirmHeavyMcps(mcps, confirm, writeLine) {
|
|
|
140
171
|
}
|
|
141
172
|
|
|
142
173
|
export async function installRecommendedMcps({
|
|
174
|
+
api,
|
|
143
175
|
multiSelect,
|
|
144
176
|
confirm,
|
|
145
177
|
execCommand = defaultExec,
|
|
@@ -150,8 +182,9 @@ export async function installRecommendedMcps({
|
|
|
150
182
|
if (typeof multiSelect !== "function" || typeof confirm !== "function") {
|
|
151
183
|
throw new Error("缺少交互输入");
|
|
152
184
|
}
|
|
185
|
+
const { mcps } = await loadRecommendedMcps(api);
|
|
153
186
|
const installedIds = await readInstalledClaudeMcps({ claudeJsonPath, fs });
|
|
154
|
-
const fresh = await selectMcps({ multiSelect, installedIds, writeLine });
|
|
187
|
+
const fresh = await selectMcps({ mcps, multiSelect, installedIds, writeLine });
|
|
155
188
|
if (fresh.length === 0) {
|
|
156
189
|
writeLine("已跳过 MCP 安装。");
|
|
157
190
|
return [];
|
|
@@ -225,6 +258,7 @@ export function buildCodexMcpTable(mcp) {
|
|
|
225
258
|
}
|
|
226
259
|
|
|
227
260
|
export async function installCodexMcps({
|
|
261
|
+
api,
|
|
228
262
|
multiSelect,
|
|
229
263
|
confirm,
|
|
230
264
|
writeLine = console.log,
|
|
@@ -236,8 +270,9 @@ export async function installCodexMcps({
|
|
|
236
270
|
throw new Error("缺少交互输入");
|
|
237
271
|
}
|
|
238
272
|
const configPath = path.join(codexDir, "config.toml");
|
|
273
|
+
const { mcps } = await loadRecommendedMcps(api);
|
|
239
274
|
const installedIds = await readInstalledCodexMcps({ codexDir, fs });
|
|
240
|
-
const fresh = await selectMcps({ multiSelect, installedIds, writeLine });
|
|
275
|
+
const fresh = await selectMcps({ mcps, multiSelect, installedIds, writeLine });
|
|
241
276
|
if (fresh.length === 0) {
|
|
242
277
|
writeLine("已跳过 MCP 安装。");
|
|
243
278
|
return [];
|
package/src/menu.js
CHANGED
|
@@ -48,35 +48,36 @@ export function buildDailyMenu() {
|
|
|
48
48
|
title: "请选择功能:",
|
|
49
49
|
sections: [
|
|
50
50
|
{
|
|
51
|
-
title: "
|
|
51
|
+
title: "常用操作",
|
|
52
52
|
items: [
|
|
53
53
|
{ key: "1", label: "启动 Claude Code", value: "launch_claude", desc: "使用 Claude360 接入配置直接启动" },
|
|
54
54
|
{ key: "2", label: "启动 Codex", value: "launch_codex", desc: "使用 Claude360 接入配置直接启动" },
|
|
55
55
|
],
|
|
56
56
|
},
|
|
57
57
|
{
|
|
58
|
-
title: "
|
|
58
|
+
title: "账户与充值",
|
|
59
59
|
items: [
|
|
60
60
|
{ key: "3", label: "余额与充值", value: "balance_topup", desc: "查看余额用量,支持微信扫码充值" },
|
|
61
|
-
{ key: "4", label: "
|
|
62
|
-
{ key: "5", label: "
|
|
61
|
+
{ key: "4", label: "切换 Key / 模型", value: "switch_key_model", desc: "切换当前 API Key 或默认模型" },
|
|
62
|
+
{ key: "5", label: "创建新的 API Key", value: "create_key", desc: "在当前账号下新建一个 API Key" },
|
|
63
|
+
{ key: "6", label: "打开 Claude360 控制台", value: "open_console", desc: "在浏览器中打开网页控制台" },
|
|
63
64
|
],
|
|
64
65
|
},
|
|
65
66
|
{
|
|
66
|
-
title: "
|
|
67
|
+
title: "Key / 模型 / MCP 配置",
|
|
67
68
|
items: [
|
|
68
|
-
{ key: "
|
|
69
|
-
{ key: "
|
|
70
|
-
{ key: "
|
|
71
|
-
{ key: "
|
|
69
|
+
{ key: "7", label: "Claude Code 配置", value: "claude_code_menu", desc: "完整初始化、工作流、MCP、模型与记忆配置" },
|
|
70
|
+
{ key: "8", label: "Codex 配置", value: "codex_menu", desc: "完整初始化、Provider、工作流与 MCP 配置" },
|
|
71
|
+
{ key: "9", label: "一键完整初始化", value: "full_init", desc: "Claude Code / Codex 完整初始化向导" },
|
|
72
|
+
{ key: "M", label: "推荐 MCP / Skill", value: "mcp_skill", desc: "安装推荐的 MCP、工作流与 Skill 增强" },
|
|
72
73
|
{ key: "C", label: "生成 cc-switch 配置", value: "cc_switch", desc: "生成可导入 cc-switch 的供应商配置" },
|
|
73
74
|
],
|
|
74
75
|
},
|
|
75
76
|
{
|
|
76
|
-
title: "
|
|
77
|
+
title: "维护与设置",
|
|
77
78
|
items: [
|
|
78
|
-
{ key: "D", label: "诊断与修复", value: "diagnostics_menu", desc: "一键诊断环境与配置问题并尝试修复" },
|
|
79
79
|
{ key: "B", label: "安装或更新工具", value: "install_tools", desc: "安装或升级 Claude Code / Codex / 本 CLI" },
|
|
80
|
+
{ key: "D", label: "诊断与修复", value: "diagnostics_menu", desc: "一键诊断环境与配置问题并尝试修复" },
|
|
80
81
|
{ key: "0", label: "重新登录", value: "relogin", desc: "清除本地登录态后重新浏览器授权" },
|
|
81
82
|
{ key: "Q", label: "退出", value: "exit", desc: "退出 claude360 CLI" },
|
|
82
83
|
],
|
|
@@ -141,7 +142,15 @@ export function resolveMenuSelection(menu, input) {
|
|
|
141
142
|
return byValue ? byValue.value : null;
|
|
142
143
|
}
|
|
143
144
|
|
|
144
|
-
export async function promptMenu({ menu, promptInput, writeLine = console.log } = {}) {
|
|
145
|
+
export async function promptMenu({ menu, promptInput, select, writeLine = console.log } = {}) {
|
|
146
|
+
// 注入 select 时走方向键交互(优化需求一.2):分组转为分隔行
|
|
147
|
+
if (typeof select === "function") {
|
|
148
|
+
const choices = menu.sections.flatMap((section) => [
|
|
149
|
+
...(section.title ? [{ separator: renderSectionRule(section.title, false) }] : []),
|
|
150
|
+
...section.items.map((item) => ({ label: item.label, value: item.value, hint: item.desc || "" })),
|
|
151
|
+
]);
|
|
152
|
+
return select(menu.title || "请选择功能:", choices);
|
|
153
|
+
}
|
|
145
154
|
if (typeof promptInput !== "function") {
|
|
146
155
|
throw new Error("缺少菜单输入");
|
|
147
156
|
}
|
package/src/prompts.js
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
// 交互组件层(优化需求第 4 / 三 / 四 / 五节):
|
|
2
|
+
// TTY 下基于 @inquirer/core 提供方向键单选、空格多选、左右键 YES/NO 确认;
|
|
3
|
+
// 非 TTY / CI 环境降级为编号输入,保证管道输出与测试稳定。
|
|
4
|
+
// 所有页面通过 createPrompts() 拿到统一的 promptSelect / promptInput /
|
|
5
|
+
// confirm / multiSelect,不再各自拼交互逻辑。
|
|
6
|
+
|
|
7
|
+
import readline from "node:readline/promises";
|
|
8
|
+
import { stdin as input, stdout as output } from "node:process";
|
|
9
|
+
import {
|
|
10
|
+
createPrompt,
|
|
11
|
+
isDownKey,
|
|
12
|
+
isEnterKey,
|
|
13
|
+
isSpaceKey,
|
|
14
|
+
isTabKey,
|
|
15
|
+
isUpKey,
|
|
16
|
+
useKeypress,
|
|
17
|
+
usePagination,
|
|
18
|
+
useState,
|
|
19
|
+
} from "@inquirer/core";
|
|
20
|
+
|
|
21
|
+
import { promptMultiSelect } from "./menu.js";
|
|
22
|
+
|
|
23
|
+
const ESC = "[";
|
|
24
|
+
const RESET = `${ESC}0m`;
|
|
25
|
+
const BOLD = `${ESC}1m`;
|
|
26
|
+
const fg = (r, g, b) => `${ESC}38;2;${r};${g};${b}m`;
|
|
27
|
+
const CYAN = fg(34, 211, 238); // 品牌高亮:选中项
|
|
28
|
+
const RED = fg(248, 113, 113); // 危险操作:NO 高亮
|
|
29
|
+
const GRAY = fg(100, 116, 139); // 次要说明 / 未选中项
|
|
30
|
+
const GREEN = fg(74, 222, 128); // 完成态答案
|
|
31
|
+
|
|
32
|
+
const paint = (color, sgr, text) => (color ? `${sgr}${text}${RESET}` : text);
|
|
33
|
+
|
|
34
|
+
export function isInteractive(stream = process.stdout) {
|
|
35
|
+
if (process.env.CI) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
return Boolean(stream && stream.isTTY && process.stdin.isTTY);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Esc 约定:选项里带 back / skip / exit 时,Esc 等价于选择它(需求二.2)
|
|
42
|
+
export function resolveEscValue(choices = []) {
|
|
43
|
+
const hit = choices.find((choice) => ["back", "skip", "exit"].includes(choice?.value));
|
|
44
|
+
return hit ? hit.value : undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// 确认行:选中项大写、加粗、高亮并带箭头;未选中项小写灰色(需求第四节)。
|
|
48
|
+
// 终端无法局部放大字体,用大小写 + 颜色 + 箭头模拟视觉差异。
|
|
49
|
+
export function renderConfirmLine(yesActive, { color = true, danger = false } = {}) {
|
|
50
|
+
const yes = yesActive
|
|
51
|
+
? paint(color, `${BOLD}${CYAN}`, "▶ YES ◀")
|
|
52
|
+
: paint(color, GRAY, "yes");
|
|
53
|
+
const no = !yesActive
|
|
54
|
+
? paint(color, `${BOLD}${danger ? RED : CYAN}`, "▶ NO ◀")
|
|
55
|
+
: paint(color, GRAY, "no");
|
|
56
|
+
return ` ${yes} ${no}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ──────────────────────────────────────────────
|
|
60
|
+
// 交互组件(@inquirer/core 自定义 prompt)
|
|
61
|
+
// ──────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
const PAGE_SIZE = 14;
|
|
64
|
+
|
|
65
|
+
// 单选:方向键移动(跳过分隔行)、回车确认、Esc 返回 escValue
|
|
66
|
+
export const interactiveSelect = createPrompt((config, done) => {
|
|
67
|
+
const { message, choices, escValue, color = true } = config;
|
|
68
|
+
const [status, setStatus] = useState("idle");
|
|
69
|
+
const firstIndex = choices.findIndex((choice) => !choice.separator);
|
|
70
|
+
const [active, setActive] = useState(Math.max(firstIndex, 0));
|
|
71
|
+
|
|
72
|
+
useKeypress((key) => {
|
|
73
|
+
if (status === "done") {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (isEnterKey(key)) {
|
|
77
|
+
setStatus("done");
|
|
78
|
+
done(choices[active].value);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (isUpKey(key) || isDownKey(key)) {
|
|
82
|
+
const direction = isUpKey(key) ? -1 : 1;
|
|
83
|
+
let next = active;
|
|
84
|
+
do {
|
|
85
|
+
next = (next + direction + choices.length) % choices.length;
|
|
86
|
+
} while (choices[next].separator);
|
|
87
|
+
setActive(next);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (key.name === "escape" && escValue !== undefined) {
|
|
91
|
+
setStatus("done");
|
|
92
|
+
done(escValue);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
if (status === "done") {
|
|
97
|
+
return `${paint(color, BOLD, message)} ${paint(color, GREEN, choices[active]?.separator ? "" : String(choices[active]?.label ?? ""))}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const page = usePagination({
|
|
101
|
+
items: choices,
|
|
102
|
+
active,
|
|
103
|
+
pageSize: PAGE_SIZE,
|
|
104
|
+
loop: false,
|
|
105
|
+
renderItem({ item, isActive }) {
|
|
106
|
+
if (item.separator) {
|
|
107
|
+
return paint(color, GRAY, item.separator);
|
|
108
|
+
}
|
|
109
|
+
const hint = item.hint ? ` ${paint(color, GRAY, item.hint)}` : "";
|
|
110
|
+
if (isActive) {
|
|
111
|
+
return `${paint(color, `${BOLD}${CYAN}`, `❯ ${item.label}`)}${hint}`;
|
|
112
|
+
}
|
|
113
|
+
return ` ${item.label}${hint}`;
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
const help = paint(color, GRAY, `↑↓ 移动 · Enter 确认${escValue !== undefined ? " · Esc 返回" : ""}`);
|
|
117
|
+
return `${paint(color, BOLD, message)}\n${page}\n${help}`;
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// 多选:空格选择/取消、a 全选、i 反选、回车确认、Esc 取消(需求第 4 节)
|
|
121
|
+
export const interactiveMultiSelect = createPrompt((config, done) => {
|
|
122
|
+
const { message, choices, preselected = [], color = true } = config;
|
|
123
|
+
const [status, setStatus] = useState("idle");
|
|
124
|
+
const [active, setActive] = useState(0);
|
|
125
|
+
const [selected, setSelected] = useState(
|
|
126
|
+
() => new Set(preselected.filter((value) => choices.some((choice) => choice.value === value))),
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
const finish = (values) => {
|
|
130
|
+
setStatus("done");
|
|
131
|
+
done(values);
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
useKeypress((key) => {
|
|
135
|
+
if (status === "done") {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (isEnterKey(key)) {
|
|
139
|
+
finish(choices.filter((choice) => selected.has(choice.value)).map((choice) => choice.value));
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (isUpKey(key) || isDownKey(key)) {
|
|
143
|
+
const direction = isUpKey(key) ? -1 : 1;
|
|
144
|
+
setActive((active + direction + choices.length) % choices.length);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (isSpaceKey(key)) {
|
|
148
|
+
const next = new Set(selected);
|
|
149
|
+
const value = choices[active].value;
|
|
150
|
+
if (next.has(value)) {
|
|
151
|
+
next.delete(value);
|
|
152
|
+
} else {
|
|
153
|
+
next.add(value);
|
|
154
|
+
}
|
|
155
|
+
setSelected(next);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (key.name === "a") {
|
|
159
|
+
setSelected(new Set(choices.map((choice) => choice.value)));
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (key.name === "i") {
|
|
163
|
+
setSelected(new Set(choices.filter((choice) => !selected.has(choice.value)).map((choice) => choice.value)));
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (key.name === "escape") {
|
|
167
|
+
finish([]);
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
if (status === "done") {
|
|
172
|
+
return `${paint(color, BOLD, message)} ${paint(color, GREEN, `已选 ${selected.size} 项`)}`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const page = usePagination({
|
|
176
|
+
items: choices,
|
|
177
|
+
active,
|
|
178
|
+
pageSize: PAGE_SIZE,
|
|
179
|
+
loop: false,
|
|
180
|
+
renderItem({ item, isActive }) {
|
|
181
|
+
const mark = selected.has(item.value) ? paint(color, GREEN, "[x]") : "[ ]";
|
|
182
|
+
const hint = item.hint ? ` ${paint(color, GRAY, item.hint)}` : "";
|
|
183
|
+
const label = isActive ? paint(color, `${BOLD}${CYAN}`, `❯ ${mark} ${item.label}`) : ` ${mark} ${item.label}`;
|
|
184
|
+
return `${label}${hint}`;
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
const help = paint(color, GRAY, "↑↓ 移动 · 空格 选择 · a 全选 · i 反选 · Enter 确认 · Esc 取消");
|
|
188
|
+
return `${paint(color, BOLD, message)}\n${page}\n${help}`;
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// 确认:左右方向键 / Tab 切换 YES、NO,回车确认,y / n 快捷,Esc 取消。
|
|
192
|
+
// 默认值由调用方按风险设置:普通继续默认 YES,覆盖 / 删除 / 重置默认 NO。
|
|
193
|
+
export const interactiveConfirm = createPrompt((config, done) => {
|
|
194
|
+
const { message, defaultYes = false, danger = false, color = true } = config;
|
|
195
|
+
const [status, setStatus] = useState("idle");
|
|
196
|
+
const [yes, setYes] = useState(Boolean(defaultYes));
|
|
197
|
+
|
|
198
|
+
const finish = (value) => {
|
|
199
|
+
setStatus("done");
|
|
200
|
+
setYes(value);
|
|
201
|
+
done(value);
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
useKeypress((key) => {
|
|
205
|
+
if (status === "done") {
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (isEnterKey(key)) {
|
|
209
|
+
finish(yes);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (key.name === "left" || key.name === "right" || isTabKey(key)) {
|
|
213
|
+
setYes(!yes);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
if (key.name === "y") {
|
|
217
|
+
finish(true);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
if (key.name === "n" || key.name === "escape") {
|
|
221
|
+
finish(false);
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
if (status === "done") {
|
|
226
|
+
return `${paint(color, BOLD, message)} ${paint(color, GREEN, yes ? "YES" : "NO")}`;
|
|
227
|
+
}
|
|
228
|
+
const help = paint(color, GRAY, "←→ 切换 · Enter 确认 · Esc 取消");
|
|
229
|
+
return `${paint(color, BOLD, message)}\n\n${renderConfirmLine(yes, { color, danger })}\n\n${help}`;
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// ──────────────────────────────────────────────
|
|
233
|
+
// 降级实现:编号输入(非 TTY / CI 环境)
|
|
234
|
+
// ──────────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
async function defaultLineInput(message, defaultValue = "") {
|
|
237
|
+
const rl = readline.createInterface({ input, output });
|
|
238
|
+
try {
|
|
239
|
+
const suffix = defaultValue ? ` (${defaultValue})` : "";
|
|
240
|
+
const answer = await rl.question(`${message}${suffix}: `);
|
|
241
|
+
return answer.trim() || defaultValue;
|
|
242
|
+
} finally {
|
|
243
|
+
rl.close();
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function createFallbackPrompts({ promptInput, writeLine }) {
|
|
248
|
+
const ask = promptInput || defaultLineInput;
|
|
249
|
+
const print = writeLine || console.log;
|
|
250
|
+
return {
|
|
251
|
+
promptInput: ask,
|
|
252
|
+
async promptSelect(message, choices) {
|
|
253
|
+
while (true) {
|
|
254
|
+
print(message);
|
|
255
|
+
choices.forEach((choice, index) => {
|
|
256
|
+
print(`${index + 1}. ${choice.label}`);
|
|
257
|
+
});
|
|
258
|
+
const answer = String(await ask("请输入编号") ?? "").trim();
|
|
259
|
+
const index = Number(answer) - 1;
|
|
260
|
+
if (Number.isInteger(index) && choices[index]) {
|
|
261
|
+
return choices[index].value;
|
|
262
|
+
}
|
|
263
|
+
const matched = choices.find((choice) => choice.value === answer);
|
|
264
|
+
if (matched) {
|
|
265
|
+
return matched.value;
|
|
266
|
+
}
|
|
267
|
+
print("选择无效,请重新输入。");
|
|
268
|
+
}
|
|
269
|
+
},
|
|
270
|
+
async confirm(message, { defaultYes = false } = {}) {
|
|
271
|
+
const suffix = defaultYes ? "(Y/n)" : "(y/N)";
|
|
272
|
+
const answer = String(await ask(`${message} ${suffix}`) ?? "").trim().toLowerCase();
|
|
273
|
+
if (answer === "") {
|
|
274
|
+
return defaultYes;
|
|
275
|
+
}
|
|
276
|
+
return ["yes", "y", "确认", "是", "继续"].includes(answer);
|
|
277
|
+
},
|
|
278
|
+
multiSelect(options) {
|
|
279
|
+
return promptMultiSelect({ ...options, promptInput: ask, writeLine: print });
|
|
280
|
+
},
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ──────────────────────────────────────────────
|
|
285
|
+
// 工厂:根据环境返回统一交互接口
|
|
286
|
+
// ──────────────────────────────────────────────
|
|
287
|
+
|
|
288
|
+
// Ctrl+C 在 @inquirer 中抛 ExitPromptError,统一转为优雅退出
|
|
289
|
+
async function guardExit(run) {
|
|
290
|
+
try {
|
|
291
|
+
return await run();
|
|
292
|
+
} catch (error) {
|
|
293
|
+
if (error?.name === "ExitPromptError") {
|
|
294
|
+
process.exit(130);
|
|
295
|
+
}
|
|
296
|
+
throw error;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export function createPrompts({
|
|
301
|
+
interactive = isInteractive(),
|
|
302
|
+
color = true,
|
|
303
|
+
promptInput,
|
|
304
|
+
writeLine,
|
|
305
|
+
} = {}) {
|
|
306
|
+
if (!interactive) {
|
|
307
|
+
return createFallbackPrompts({ promptInput, writeLine });
|
|
308
|
+
}
|
|
309
|
+
return {
|
|
310
|
+
promptInput: promptInput || defaultLineInput,
|
|
311
|
+
promptSelect: (message, choices) =>
|
|
312
|
+
guardExit(() => interactiveSelect({ message, choices, escValue: resolveEscValue(choices), color })),
|
|
313
|
+
confirm: (message, { defaultYes = false, danger = false } = {}) =>
|
|
314
|
+
guardExit(() => interactiveConfirm({ message, defaultYes, danger, color })),
|
|
315
|
+
multiSelect: ({ message, choices, preselected }) =>
|
|
316
|
+
guardExit(() => interactiveMultiSelect({ message, choices, preselected, color })),
|
|
317
|
+
};
|
|
318
|
+
}
|
package/src/topup.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { renderKeyValueTable, renderSectionTitle } from "./ui.js";
|
|
2
|
+
|
|
1
3
|
export async function loadTopUpOptions(api) {
|
|
2
4
|
if (!api) {
|
|
3
5
|
throw new Error("缺少 API client");
|
|
@@ -5,12 +7,22 @@ export async function loadTopUpOptions(api) {
|
|
|
5
7
|
return api.get("/api/cli/topup/options");
|
|
6
8
|
}
|
|
7
9
|
|
|
10
|
+
// 轮询状态默认走单行刷新(TTY 用 \r 覆盖整行),避免重复刷屏(优化需求第 6 节);
|
|
11
|
+
// 空文本表示轮询结束,清行收尾
|
|
12
|
+
function defaultWriteStatus(text) {
|
|
13
|
+
if (!process.stdout.isTTY) {
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
process.stdout.write(`\r[2K${text || ""}`);
|
|
17
|
+
}
|
|
18
|
+
|
|
8
19
|
export async function runWechatTopUp({
|
|
9
20
|
api,
|
|
10
21
|
promptSelect,
|
|
11
22
|
promptInput,
|
|
12
23
|
renderQr = renderTerminalQr,
|
|
13
24
|
writeLine = console.log,
|
|
25
|
+
writeStatus = defaultWriteStatus,
|
|
14
26
|
sleep = defaultSleep,
|
|
15
27
|
pollIntervalMs = 3000,
|
|
16
28
|
maxPolls = 60,
|
|
@@ -30,16 +42,22 @@ export async function runWechatTopUp({
|
|
|
30
42
|
throw new Error("创建微信充值订单失败");
|
|
31
43
|
}
|
|
32
44
|
|
|
33
|
-
|
|
45
|
+
// 订单信息边框包裹,二维码区域带标题与支付状态
|
|
46
|
+
writeLine(renderSectionTitle("微信扫码支付"));
|
|
47
|
+
writeLine(renderKeyValueTable([
|
|
48
|
+
["订单号", String(order.order_id)],
|
|
49
|
+
["金额", String(order.money_display || order.money || amount)],
|
|
50
|
+
["支付方式", "微信扫码"],
|
|
51
|
+
]));
|
|
52
|
+
writeLine("请使用微信扫码支付:");
|
|
34
53
|
await printQrOrCodeUrl({ codeUrl: order.code_url, renderQr, writeLine });
|
|
35
|
-
writeLine(`订单金额:${order.money_display || order.money || amount}`);
|
|
36
|
-
writeLine("订单状态:等待支付...");
|
|
37
54
|
const status = await waitTopUpPaid({
|
|
38
55
|
api,
|
|
39
56
|
orderId: order.order_id,
|
|
40
57
|
sleep,
|
|
41
58
|
pollIntervalMs,
|
|
42
59
|
maxPolls,
|
|
60
|
+
writeStatus,
|
|
43
61
|
});
|
|
44
62
|
const balance = await api.get("/api/cli/me");
|
|
45
63
|
return { order, status, balance };
|
|
@@ -54,7 +72,7 @@ async function chooseTopUpAmount({ options, promptSelect, promptInput }) {
|
|
|
54
72
|
throw new Error("缺少充值金额选择输入");
|
|
55
73
|
}
|
|
56
74
|
const selected = await promptSelect("选择充值金额", amountOptions.map((amount) => ({
|
|
57
|
-
label:
|
|
75
|
+
label: `¥${amount}`,
|
|
58
76
|
value: amount,
|
|
59
77
|
})));
|
|
60
78
|
if (!amountOptions.includes(selected)) {
|
|
@@ -88,19 +106,24 @@ async function printQrOrCodeUrl({ codeUrl, renderQr, writeLine }) {
|
|
|
88
106
|
}
|
|
89
107
|
}
|
|
90
108
|
|
|
91
|
-
async function waitTopUpPaid({ api, orderId, sleep, pollIntervalMs, maxPolls }) {
|
|
109
|
+
async function waitTopUpPaid({ api, orderId, sleep, pollIntervalMs, maxPolls, writeStatus = () => {} }) {
|
|
92
110
|
for (let attempt = 0; attempt < maxPolls; attempt += 1) {
|
|
93
111
|
const status = await api.get(`/api/cli/topup/order?order_id=${encodeURIComponent(orderId)}`);
|
|
94
112
|
if (status?.status === "success") {
|
|
113
|
+
writeStatus("");
|
|
95
114
|
return status;
|
|
96
115
|
}
|
|
97
116
|
if (status?.status && status.status !== "pending") {
|
|
117
|
+
writeStatus("");
|
|
98
118
|
throw new Error(`充值订单状态异常:${status.status}`);
|
|
99
119
|
}
|
|
120
|
+
// 单行状态刷新,不向常规输出重复打印等待文本
|
|
121
|
+
writeStatus(`等待支付中… 已等待 ${Math.round(((attempt + 1) * pollIntervalMs) / 1000)}s`);
|
|
100
122
|
if (attempt < maxPolls - 1) {
|
|
101
123
|
await sleep(pollIntervalMs);
|
|
102
124
|
}
|
|
103
125
|
}
|
|
126
|
+
writeStatus("");
|
|
104
127
|
throw new Error("等待微信支付超时");
|
|
105
128
|
}
|
|
106
129
|
|
package/src/ui.js
CHANGED
|
@@ -135,3 +135,73 @@ export function renderSectionTitle(title, { color = false, width = 46 } = {}) {
|
|
|
135
135
|
}
|
|
136
136
|
return `${BORDER_COLOR}${lead}${RESET}${BOLD}${HEAD_COLOR}${label}${RESET}${BORDER_COLOR}${tail}${RESET}`;
|
|
137
137
|
}
|
|
138
|
+
|
|
139
|
+
// ──────────────────────────────────────────────
|
|
140
|
+
// 统一页面组件(优化需求第 5 / 一.1 节)
|
|
141
|
+
// ──────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
// 页面标题区:品牌名 + 当前页面名,统一边框样式
|
|
144
|
+
export function renderHeader(title, { subtitle = "", color = false } = {}) {
|
|
145
|
+
const label = `Claude360 CLI · ${title}`;
|
|
146
|
+
const inner = Math.max(cellWidth(label), cellWidth(subtitle)) + 2;
|
|
147
|
+
const line = (text) => {
|
|
148
|
+
const body = ` ${padCell(text, inner - 2)} `;
|
|
149
|
+
if (!color) {
|
|
150
|
+
return `│${body}│`;
|
|
151
|
+
}
|
|
152
|
+
return `${BORDER_COLOR}│${RESET}${BOLD}${HEAD_COLOR}${body}${RESET}${BORDER_COLOR}│${RESET}`;
|
|
153
|
+
};
|
|
154
|
+
const edge = (left, right) => {
|
|
155
|
+
const text = `${left}${"─".repeat(inner)}${right}`;
|
|
156
|
+
return color ? `${BORDER_COLOR}${text}${RESET}` : text;
|
|
157
|
+
};
|
|
158
|
+
const lines = [edge("┌", "┐"), line(label)];
|
|
159
|
+
if (subtitle) {
|
|
160
|
+
lines.push(line(subtitle));
|
|
161
|
+
}
|
|
162
|
+
lines.push(edge("└", "┘"));
|
|
163
|
+
return lines.join("\n");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// 信息盒子:info / warn / error / success 四种状态,
|
|
167
|
+
// 颜色只增强层级,状态始终有文字前缀(需求二「不依赖颜色表达唯一信息」)
|
|
168
|
+
const BOX_KINDS = {
|
|
169
|
+
info: { mark: "i", color: fg(125, 211, 252) },
|
|
170
|
+
warn: { mark: "!", color: fg(250, 204, 21) },
|
|
171
|
+
error: { mark: "×", color: fg(248, 113, 113) },
|
|
172
|
+
success: { mark: "✓", color: fg(74, 222, 128) },
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
export function renderBox(message, { kind = "info", color = false, width = 0 } = {}) {
|
|
176
|
+
const { mark, color: kindColor } = BOX_KINDS[kind] || BOX_KINDS.info;
|
|
177
|
+
const rawLines = String(message ?? "").split("\n");
|
|
178
|
+
const textLines = rawLines.map((line, index) => (index === 0 ? `${mark} ${line}` : ` ${line}`));
|
|
179
|
+
const maxAvailable = width > 4 ? width - 4 : 0;
|
|
180
|
+
const fitted = textLines.map((line) => (maxAvailable ? truncateDisplay(line, maxAvailable) : line));
|
|
181
|
+
const inner = Math.max(...fitted.map((line) => cellWidth(line))) + 2;
|
|
182
|
+
const edge = (left, right) => {
|
|
183
|
+
const text = `${left}${"─".repeat(inner)}${right}`;
|
|
184
|
+
return color ? `${kindColor}${text}${RESET}` : text;
|
|
185
|
+
};
|
|
186
|
+
const lines = [edge("┌", "┐")];
|
|
187
|
+
for (const line of fitted) {
|
|
188
|
+
const body = ` ${padCell(line, inner - 2)} `;
|
|
189
|
+
lines.push(color ? `${kindColor}│${RESET}${body}${kindColor}│${RESET}` : `│${body}│`);
|
|
190
|
+
}
|
|
191
|
+
lines.push(edge("└", "┘"));
|
|
192
|
+
return lines.join("\n");
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// 模型表(优化需求第 3 节):展示后端返回的真实模型信息
|
|
196
|
+
export function renderModelTable(models = [], { color = false, width = 0 } = {}) {
|
|
197
|
+
return renderTable({
|
|
198
|
+
head: ["模型 ID", "名称", "标签", "上下文", "说明"],
|
|
199
|
+
rows: models.map((model) => [
|
|
200
|
+
model.id ?? "-",
|
|
201
|
+
model.display_name || "-",
|
|
202
|
+
Array.isArray(model.tags) && model.tags.length > 0 ? model.tags.join(",") : "-",
|
|
203
|
+
model.context_length ? String(model.context_length) : "-",
|
|
204
|
+
model.description || "-",
|
|
205
|
+
]),
|
|
206
|
+
}, { color, width });
|
|
207
|
+
}
|