@laivc/laicode 0.0.1 → 0.1.0

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/bin/laicode.js CHANGED
@@ -1,41 +1,2063 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- const version = "0.0.1"
3
+ const fs = require("fs")
4
+ const fsp = require("fs/promises")
5
+ const os = require("os")
6
+ const path = require("path")
7
+ const readline = require("readline")
8
+ const { spawn } = require("child_process")
4
9
 
5
- const help = `LaiCode ${version}
10
+ const VERSION = "0.1.0"
11
+ const DEFAULT_SITE_BASE = "https://lai.vc"
12
+ const DEFAULT_API_BASE = "https://api.lai.vc/v1"
13
+ const STATE_DIR = process.env.LAICODE_HOME || path.join(os.homedir(), ".laicode")
14
+ const CONFIG_FILE = path.join(STATE_DIR, "config.json")
15
+ const DASHBOARD_CACHE_TTL_MS = 60_000
6
16
 
7
- Official CLI for configuring local developer tools to use Lai.vc.
17
+ const COMMAND_SPECS = [
18
+ { name: "menu", usage: "laicode menu", summary: "打开交互控制台", flags: ["--fancy"] },
19
+ { name: "brand", usage: "laicode brand", summary: "预览终端字标和品牌色板", flags: ["--color", "--plain"] },
20
+ { name: "status", usage: "laicode status [--refresh] [--json]", summary: "查看实时驾驶舱状态", flags: ["--refresh", "--json"] },
21
+ { name: "commands", usage: "laicode commands [--json]", summary: "查看命令目录和 flag 清单", flags: ["--json"] },
22
+ { name: "completion", usage: "laicode completion [bash|zsh|fish|powershell]", summary: "输出 shell 补全脚本", flags: ["bash", "zsh", "fish", "powershell"] },
23
+ { name: "login", usage: "laicode login [--tier std] [--no-open]", summary: "通过浏览器设备授权登录", flags: ["--tier", "--no-open", "--no-key"] },
24
+ { name: "doctor", usage: "laicode doctor [--model gpt-5.5] [--json]", summary: "运行本机、会话、网关诊断", flags: ["--model", "--json"] },
25
+ { name: "models", usage: "laicode models [--json] [--online]", summary: "查看模型雷达和价格档位", flags: ["--json", "--online", "--category", "--vendor"] },
26
+ { name: "chat", usage: 'laicode chat [--model gpt-5.5] [--message "hello"]', summary: "流式测试单个模型", flags: ["--model", "--message", "-m"] },
27
+ { name: "bench", usage: "laicode bench [--model gpt-5.5] [--count 3] [--json]", summary: "压测网关延迟和吞吐", flags: ["--model", "--count", "-n", "--prompt", "--json"] },
28
+ { name: "keys", usage: "laicode keys [list|create|delete] [--show] [--yes]", summary: "管理 Lai.vc 测试 API Key", flags: ["list", "create", "delete", "--show", "--yes", "--json", "--name", "--tier", "--id"] },
29
+ { name: "config", usage: "laicode config [list|get|set|unset]", summary: "查看或修改本地配置", flags: ["list", "get", "set", "unset", "--json"] },
30
+ { name: "logout", usage: "laicode logout", summary: "撤销会话并清理本地缓存", flags: [] },
31
+ { name: "help", usage: "laicode help", summary: "显示帮助", flags: [] },
32
+ ]
8
33
 
9
- Usage:
10
- laicode --help
11
- laicode --version
12
- laicode doctor
34
+ const GLOBAL_FLAGS = ["--help", "--version", "--plain", "--color", "--fancy"]
35
+ const COMMANDS = COMMAND_SPECS.map((cmd) => cmd.name)
36
+ const COMMAND_FLAGS = Object.fromEntries(COMMAND_SPECS.map((cmd) => [cmd.name, cmd.flags || []]))
37
+ const ENV_VARS = [
38
+ ["LAICODE_HOME", "覆盖本地状态目录,默认 ~/.laicode"],
39
+ ["LAICODE_SITE_URL", "覆盖主站地址,默认 https://lai.vc"],
40
+ ["LAICODE_PLAIN", "设为 1 时禁用终端样式"],
41
+ ["LAICODE_COLOR", "设为 1 时忽略 NO_COLOR,强制彩色输出"],
42
+ ]
13
43
 
14
- The full cross-platform configurator is coming soon.
15
- Website: https://lai.vc
16
- API base URL: https://api.lai.vc/v1
44
+ function buildHelp() {
45
+ const usageLines = ["laicode", "laicode --fancy", "laicode --color", "laicode --plain", ...COMMAND_SPECS.map((cmd) => cmd.usage)]
46
+ return `LaiCode ${VERSION}
47
+
48
+ Lai.vc 中转测试控制台。
49
+
50
+ 用法:
51
+ ${usageLines.map((line) => ` ${line}`).join("\n")}
52
+
53
+ 命令:
54
+ ${COMMAND_SPECS.map((cmd) => ` ${padDisplay(cmd.name, 11)} ${cmd.summary}`).join("\n")}
55
+
56
+ 环境变量:
57
+ ${ENV_VARS.map(([name, detail]) => ` ${padDisplay(name, 18)} ${detail}`).join("\n")}
17
58
  `
59
+ }
60
+
61
+ const ANSI = {
62
+ reset: "\x1b[0m",
63
+ bold: "\x1b[1m",
64
+ dim: "\x1b[2m",
65
+ red: "\x1b[31m",
66
+ green: "\x1b[32m",
67
+ yellow: "\x1b[33m",
68
+ blue: "\x1b[34m",
69
+ magenta: "\x1b[35m",
70
+ cyan: "\x1b[36m",
71
+ gray: "\x1b[90m",
72
+ }
73
+
74
+ const BRAND = {
75
+ plum: "#3a1e3f",
76
+ plumDeep: "#2a1430",
77
+ coral: "#ff6f7d",
78
+ persimmon: "#ff8a4c",
79
+ ivory: "#fff7ef",
80
+ gold: "#d9c6a1",
81
+ ink: "#1c1c1f",
82
+ }
83
+
84
+ function plainMode() {
85
+ if (process.env.LAICODE_PLAIN === "1") return true
86
+ if (process.env.LAICODE_COLOR === "1") return false
87
+ return process.env.NO_COLOR
88
+ }
89
+
90
+ function color(code, text) {
91
+ if (!process.stdout.isTTY || plainMode()) return String(text)
92
+ return `${code}${text}${ANSI.reset}`
93
+ }
94
+
95
+ function hexColor(hex, text) {
96
+ if (!process.stdout.isTTY || plainMode()) return String(text)
97
+ const clean = hex.replace("#", "")
98
+ const r = parseInt(clean.slice(0, 2), 16)
99
+ const g = parseInt(clean.slice(2, 4), 16)
100
+ const b = parseInt(clean.slice(4, 6), 16)
101
+ return `\x1b[38;2;${r};${g};${b}m${text}${ANSI.reset}`
102
+ }
103
+
104
+ const ui = {
105
+ bold: (s) => color(ANSI.bold, s),
106
+ dim: (s) => color(ANSI.dim, s),
107
+ ok: (s) => color(ANSI.green, s),
108
+ warn: (s) => color(ANSI.yellow, s),
109
+ err: (s) => color(ANSI.red, s),
110
+ accent: (s) => hexColor(BRAND.coral, s),
111
+ hot: (s) => hexColor(BRAND.persimmon, s),
112
+ gold: (s) => hexColor(BRAND.gold, s),
113
+ brand: (s) => hexColor(BRAND.coral, s),
114
+ plum: (s) => hexColor(BRAND.plum, s),
115
+ muted: (s) => color(ANSI.gray, s),
116
+ }
117
+
118
+ const VALUE_FLAGS = new Set([
119
+ "tier",
120
+ "model",
121
+ "message",
122
+ "m",
123
+ "count",
124
+ "n",
125
+ "prompt",
126
+ "category",
127
+ "vendor",
128
+ "name",
129
+ "id",
130
+ "shell",
131
+ ])
132
+
133
+ function parseArgs(argv) {
134
+ const out = { _: [] }
135
+ for (let i = 0; i < argv.length; i++) {
136
+ const a = argv[i]
137
+ if (!a.startsWith("-")) {
138
+ out._.push(a)
139
+ continue
140
+ }
141
+ if (a === "--") {
142
+ out._.push(...argv.slice(i + 1))
143
+ break
144
+ }
145
+ const eq = a.indexOf("=")
146
+ if (eq !== -1) {
147
+ out[a.slice(2, eq)] = a.slice(eq + 1)
148
+ continue
149
+ }
150
+ const key = a.replace(/^-+/, "")
151
+ const next = argv[i + 1]
152
+ if (VALUE_FLAGS.has(key) && next && !next.startsWith("-")) {
153
+ out[key] = next
154
+ i++
155
+ } else {
156
+ out[key] = true
157
+ }
158
+ }
159
+ return out
160
+ }
161
+
162
+ function nowIso() {
163
+ return new Date().toISOString()
164
+ }
165
+
166
+ function ageMs(iso) {
167
+ const ts = Date.parse(iso || "")
168
+ if (!Number.isFinite(ts)) return Infinity
169
+ return Date.now() - ts
170
+ }
171
+
172
+ function formatAge(iso) {
173
+ const age = ageMs(iso)
174
+ if (!Number.isFinite(age)) return "未知"
175
+ if (age < 10_000) return "刚刚"
176
+ if (age < 60_000) return `${Math.round(age / 1000)}s前`
177
+ if (age < 3_600_000) return `${Math.round(age / 60_000)}m前`
178
+ return `${Math.round(age / 3_600_000)}h前`
179
+ }
180
+
181
+ function formatMoney(value) {
182
+ if (value == null || Number.isNaN(Number(value))) return "-"
183
+ return `$${Number(value).toFixed(2)}`
184
+ }
185
+
186
+ function formatMs(value) {
187
+ if (value == null || Number.isNaN(Number(value))) return "-"
188
+ return `${Math.round(Number(value))}ms`
189
+ }
190
+
191
+ function latencyBar(value, maxValue, width = 14) {
192
+ if (!value || !maxValue) return "-".repeat(width)
193
+ const ratio = Math.max(0.05, Math.min(1, Number(value) / Number(maxValue)))
194
+ const filled = Math.max(1, Math.round(ratio * width))
195
+ return `${ui.accent("█".repeat(filled))}${ui.dim("░".repeat(Math.max(0, width - filled)))}`
196
+ }
197
+
198
+ function percentile(values, pct) {
199
+ const sorted = values.filter((v) => Number.isFinite(v)).sort((a, b) => a - b)
200
+ if (!sorted.length) return null
201
+ const index = Math.ceil((pct / 100) * sorted.length) - 1
202
+ return sorted[Math.max(0, Math.min(sorted.length - 1, index))]
203
+ }
204
+
205
+ function sleep(ms) {
206
+ return new Promise((resolve) => setTimeout(resolve, ms))
207
+ }
208
+
209
+ function maskSecret(value) {
210
+ if (!value || typeof value !== "string") return value
211
+ if (value.length <= 16) return "***"
212
+ return `${value.slice(0, 8)}...${value.slice(-4)}`
213
+ }
214
+
215
+ function compareVersions(a, b) {
216
+ const pa = String(a || "0").split(".").map((n) => Number(n) || 0)
217
+ const pb = String(b || "0").split(".").map((n) => Number(n) || 0)
218
+ const len = Math.max(pa.length, pb.length)
219
+ for (let i = 0; i < len; i++) {
220
+ const da = pa[i] || 0
221
+ const db = pb[i] || 0
222
+ if (da > db) return 1
223
+ if (da < db) return -1
224
+ }
225
+ return 0
226
+ }
227
+
228
+ function die(message, code = 1) {
229
+ console.error(message)
230
+ process.exit(code)
231
+ }
232
+
233
+ function terminalWidth() {
234
+ return Math.min(process.stdout.columns || 88, 110)
235
+ }
236
+
237
+ function stripAnsi(text) {
238
+ return String(text).replace(/\x1b\[[0-9;]*m/g, "")
239
+ }
240
+
241
+ function charWidth(ch) {
242
+ const code = ch.codePointAt(0)
243
+ if (code === 0) return 0
244
+ if (code < 32 || (code >= 0x7f && code < 0xa0)) return 0
245
+ if (
246
+ code >= 0x1100 &&
247
+ (code <= 0x115f ||
248
+ code === 0x2329 ||
249
+ code === 0x232a ||
250
+ (code >= 0x2e80 && code <= 0xa4cf && code !== 0x303f) ||
251
+ (code >= 0xac00 && code <= 0xd7a3) ||
252
+ (code >= 0xf900 && code <= 0xfaff) ||
253
+ (code >= 0xfe10 && code <= 0xfe19) ||
254
+ (code >= 0xfe30 && code <= 0xfe6f) ||
255
+ (code >= 0xff00 && code <= 0xff60) ||
256
+ (code >= 0xffe0 && code <= 0xffe6) ||
257
+ (code >= 0x1f300 && code <= 0x1f64f) ||
258
+ (code >= 0x1f900 && code <= 0x1f9ff))
259
+ ) {
260
+ return 2
261
+ }
262
+ return 1
263
+ }
264
+
265
+ function displayWidth(value) {
266
+ let width = 0
267
+ for (const ch of stripAnsi(value)) width += charWidth(ch)
268
+ return width
269
+ }
270
+
271
+ function padDisplay(value, width, align = "left") {
272
+ const text = String(value ?? "")
273
+ const missing = Math.max(0, width - displayWidth(text))
274
+ if (align === "right") return `${" ".repeat(missing)}${text}`
275
+ if (align === "center") {
276
+ const left = Math.floor(missing / 2)
277
+ return `${" ".repeat(left)}${text}${" ".repeat(missing - left)}`
278
+ }
279
+ return `${text}${" ".repeat(missing)}`
280
+ }
281
+
282
+ function line(char = "-") {
283
+ return char.repeat(terminalWidth())
284
+ }
285
+
286
+ function brandGlyph() {
287
+ if (plainMode()) return "LAI"
288
+ return `${ui.plum("◢")}${ui.accent("◣")}${ui.hot("◤")}${ui.gold("◥")}`
289
+ }
290
+
291
+ function printBrandMark() {
292
+ const width = Math.min(terminalWidth(), 96)
293
+ const title = `${brandGlyph()} ${ui.bold("LAICODE")} ${ui.dim("·")} ${ui.gold("来AI")}`
294
+ const tagline = "生产级 AI 聚合中转测试控制台"
295
+ console.log(`╭${"─".repeat(width - 2)}╮`)
296
+ console.log(`│ ${padDisplay(title, width - 4)} │`)
297
+ console.log(`│ ${padDisplay(ui.dim(tagline), width - 4)} │`)
298
+ console.log(`╰${"─".repeat(width - 2)}╯`)
299
+ }
300
+
301
+ function printBrandPalette() {
302
+ const rows = [
303
+ `${ui.plum("■")} Plum ${BRAND.plum} 主品牌 / 控制台底色`,
304
+ `${ui.accent("■")} Coral ${BRAND.coral} 操作 / 高亮 / 路由热区`,
305
+ `${ui.hot("■")} Persimmon ${BRAND.persimmon} 流量 / 压测 / 动态`,
306
+ `${ui.gold("■")} Gold ${BRAND.gold} 高级 / 完成 / 品牌光泽`,
307
+ `${ui.dim("■")} Ivory ${BRAND.ivory} 背景 / 文档 / 对比`,
308
+ ]
309
+ printPanel("品牌色板", rows)
310
+ }
311
+
312
+ function cmdBrand() {
313
+ printHero("品牌预览")
314
+ printPanel("Lai.vc 终端字标", [
315
+ `${brandGlyph()} LAICODE · 来AI`,
316
+ "生产级 AI 聚合中转测试控制台",
317
+ "一个接口,来连万象",
318
+ ])
319
+ printBrandPalette()
320
+ console.log("")
321
+ console.log(ui.dim("提示: 当前环境若设置 NO_COLOR=1,可使用 `laicode --color brand` 强制预览彩色。"))
322
+ }
323
+
324
+ function shellWords(words) {
325
+ return words.join(" ")
326
+ }
327
+
328
+ function completionShell(args = {}) {
329
+ const requested = args._[1] || args.shell
330
+ if (requested) return String(requested).toLowerCase()
331
+ const shell = path.basename(process.env.SHELL || "").toLowerCase()
332
+ if (shell.includes("zsh")) return "zsh"
333
+ if (shell.includes("fish")) return "fish"
334
+ if (process.platform === "win32") return "powershell"
335
+ return "bash"
336
+ }
337
+
338
+ function cmdCompletion(args = {}) {
339
+ const shell = completionShell(args)
340
+ const commands = shellWords(COMMANDS)
341
+ const globalFlags = shellWords(GLOBAL_FLAGS)
342
+ const allFlags = shellWords([...new Set(Object.values(COMMAND_FLAGS).flat())].filter((item) => item.startsWith("-")))
343
+ const commandFlagsCase = Object.entries(COMMAND_FLAGS)
344
+ .map(([cmd, flags]) => ` ${cmd}) opts="${shellWords(flags)}" ;;`)
345
+ .join("\n")
346
+
347
+ if (shell === "bash") {
348
+ console.log(`# LaiCode bash completion
349
+ _laicode_completion() {
350
+ local cur prev cmd opts
351
+ COMPREPLY=()
352
+ cur="\${COMP_WORDS[COMP_CWORD]}"
353
+ prev="\${COMP_WORDS[COMP_CWORD-1]}"
354
+ cmd="\${COMP_WORDS[1]}"
355
+ if [[ \${COMP_CWORD} -eq 1 ]]; then
356
+ COMPREPLY=( $(compgen -W "${commands} ${globalFlags}" -- "$cur") )
357
+ return 0
358
+ fi
359
+ case "$cmd" in
360
+ ${commandFlagsCase}
361
+ *) opts="${globalFlags} ${allFlags}" ;;
362
+ esac
363
+ COMPREPLY=( $(compgen -W "$opts" -- "$cur") )
364
+ }
365
+ complete -F _laicode_completion laicode`)
366
+ return
367
+ }
368
+
369
+ if (shell === "zsh") {
370
+ const zshCommands = COMMANDS.map((cmd) => ` '${cmd}:${cmd}'`).join(" \\\n")
371
+ console.log(`#compdef laicode
372
+ # LaiCode zsh completion
373
+ _laicode() {
374
+ local -a commands
375
+ commands=(
376
+ ${zshCommands}
377
+ )
378
+ if (( CURRENT == 2 )); then
379
+ _describe 'command' commands
380
+ return
381
+ fi
382
+ local cmd="$words[2]"
383
+ case "$cmd" in
384
+ ${Object.entries(COMMAND_FLAGS)
385
+ .map(([cmd, flags]) => ` ${cmd}) _values 'options' ${flags.map((flag) => `'${flag}'`).join(" ")} ;;`)
386
+ .join("\n")}
387
+ *) _values 'options' ${GLOBAL_FLAGS.map((flag) => `'${flag}'`).join(" ")} ;;
388
+ esac
389
+ }
390
+ _laicode "$@"`)
391
+ return
392
+ }
393
+
394
+ if (shell === "fish") {
395
+ const lines = [
396
+ "# LaiCode fish completion",
397
+ "complete -c laicode -f",
398
+ `complete -c laicode -n "__fish_use_subcommand" -a "${commands}"`,
399
+ ...GLOBAL_FLAGS.map((flag) => `complete -c laicode -l ${flag.replace(/^--/, "")}`),
400
+ ]
401
+ for (const [cmd, flags] of Object.entries(COMMAND_FLAGS)) {
402
+ for (const flag of flags) {
403
+ if (flag.startsWith("--")) lines.push(`complete -c laicode -n "__fish_seen_subcommand_from ${cmd}" -l ${flag.replace(/^--/, "")}`)
404
+ else if (flag.startsWith("-")) lines.push(`complete -c laicode -n "__fish_seen_subcommand_from ${cmd}" -s ${flag.replace(/^-/, "")}`)
405
+ else lines.push(`complete -c laicode -n "__fish_seen_subcommand_from ${cmd}" -a "${flag}"`)
406
+ }
407
+ }
408
+ console.log(lines.join("\n"))
409
+ return
410
+ }
411
+
412
+ if (shell === "powershell" || shell === "pwsh") {
413
+ console.log(`# LaiCode PowerShell completion
414
+ Register-ArgumentCompleter -Native -CommandName laicode -ScriptBlock {
415
+ param($wordToComplete, $commandAst, $cursorPosition)
416
+ $commands = '${commands}'.Split(' ')
417
+ $globalFlags = '${globalFlags}'.Split(' ')
418
+ $map = @{
419
+ ${Object.entries(COMMAND_FLAGS)
420
+ .map(([cmd, flags]) => ` '${cmd}' = '${shellWords(flags)}'.Split(' ')`)
421
+ .join("\n")}
422
+ }
423
+ $words = $commandAst.CommandElements | ForEach-Object { $_.Extent.Text }
424
+ if ($words.Count -le 2) { $candidates = $commands + $globalFlags }
425
+ else {
426
+ $cmd = $words[1]
427
+ $candidates = if ($map.ContainsKey($cmd)) { $map[$cmd] } else { $globalFlags }
428
+ }
429
+ $candidates | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
430
+ [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)
431
+ }
432
+ }`)
433
+ return
434
+ }
435
+
436
+ die("Usage: laicode completion [bash|zsh|fish|powershell]")
437
+ }
438
+
439
+ function commandCatalog() {
440
+ return COMMAND_SPECS.map((cmd) => ({
441
+ name: cmd.name,
442
+ usage: cmd.usage,
443
+ summary: cmd.summary,
444
+ flags: cmd.flags || [],
445
+ }))
446
+ }
447
+
448
+ function cmdCommands(args = {}) {
449
+ const commands = commandCatalog()
450
+ if (args.json) {
451
+ console.log(JSON.stringify({ version: VERSION, commands, globalFlags: GLOBAL_FLAGS }, null, 2))
452
+ return
453
+ }
454
+
455
+ printTable(
456
+ "命令目录",
457
+ [
458
+ { key: "name", label: "命令", width: 10, max: 16 },
459
+ { key: "summary", label: "说明", width: 28, max: 42 },
460
+ { key: "flags", label: "参数 / 子命令", width: 34, max: 52 },
461
+ ],
462
+ commands.map((cmd) => ({
463
+ name: cmd.name,
464
+ summary: cmd.summary,
465
+ flags: cmd.flags.length ? cmd.flags.join(" ") : "-",
466
+ }))
467
+ )
468
+ }
469
+
470
+ function printHero(subtitle = "Lai.vc 中转控制台") {
471
+ console.clear()
472
+ printBrandMark()
473
+ console.log(ui.bold(subtitle) + ui.dim(` v${VERSION}`))
474
+ console.log(ui.muted(line("─")))
475
+ }
476
+
477
+ function printPanel(title, rows) {
478
+ const width = Math.min(terminalWidth(), 96)
479
+ console.log(`╭${"─".repeat(width - 2)}╮`)
480
+ console.log(`│ ${padDisplay(title, width - 4)} │`)
481
+ console.log(`├${"─".repeat(width - 2)}┤`)
482
+ for (const row of rows) {
483
+ const text = String(row)
484
+ console.log(`│ ${padDisplay(text, width - 4)} │`)
485
+ }
486
+ console.log(`╰${"─".repeat(width - 2)}╯`)
487
+ }
488
+
489
+ function truncateDisplay(value, width) {
490
+ const text = String(value ?? "")
491
+ let out = ""
492
+ let used = 0
493
+ for (const ch of text) {
494
+ const w = charWidth(ch)
495
+ if (used + w > width - 1) return `${out}…`
496
+ out += ch
497
+ used += w
498
+ }
499
+ return out
500
+ }
501
+
502
+ function printTable(title, columns, rows) {
503
+ const widths = columns.map((col) => {
504
+ const min = displayWidth(col.label)
505
+ const content = rows.map((row) => displayWidth(row[col.key] ?? ""))
506
+ return Math.min(col.max || 32, Math.max(col.width || 0, min, ...content))
507
+ })
508
+ const total = widths.reduce((sum, w) => sum + w, 0) + columns.length * 3 + 1
509
+ const boxWidth = Math.min(Math.max(total, displayWidth(title) + 4), terminalWidth())
510
+
511
+ const border = (left, fill, sep, right) => {
512
+ let s = left
513
+ for (let i = 0; i < widths.length; i++) {
514
+ s += fill.repeat(widths[i] + 2)
515
+ s += i === widths.length - 1 ? right : sep
516
+ }
517
+ return s
518
+ }
519
+
520
+ console.log(`╭${"─".repeat(Math.max(2, boxWidth - 2))}╮`)
521
+ console.log(`│ ${padDisplay(title, Math.max(0, boxWidth - 4))} │`)
522
+ console.log(border("├", "─", "┬", "┤"))
523
+ console.log(
524
+ "│" +
525
+ columns
526
+ .map((col, i) => ` ${padDisplay(col.label, widths[i], col.align)} `)
527
+ .join("│") +
528
+ "│"
529
+ )
530
+ console.log(border("├", "─", "┼", "┤"))
531
+ for (const row of rows) {
532
+ console.log(
533
+ "│" +
534
+ columns
535
+ .map((col, i) => {
536
+ const raw = row[col.key] ?? ""
537
+ const text = displayWidth(raw) > widths[i] ? truncateDisplay(raw, widths[i]) : raw
538
+ return ` ${padDisplay(text, widths[i], col.align)} `
539
+ })
540
+ .join("│") +
541
+ "│"
542
+ )
543
+ }
544
+ console.log(border("╰", "─", "┴", "╯"))
545
+ }
546
+
547
+ function statusWord(ok, good = "就绪", bad = "缺失") {
548
+ return ok ? ui.ok(good) : ui.warn(bad)
549
+ }
550
+
551
+ function statusDot(ok) {
552
+ return ok ? ui.ok("●") : ui.warn("●")
553
+ }
554
+
555
+ function printDashboard(cfg, extra = {}) {
556
+ const rows = [
557
+ `${statusDot(Boolean(cfg.accessToken))} 账号 ${cfg.user?.email || cfg.user?.name || "未登录"}`,
558
+ `${statusDot(Boolean(cfg.accessToken))} 会话 ${cfg.accessToken ? "已授权" : "需要登录"}`,
559
+ `${statusDot(Boolean(cfg.apiKey))} 密钥 ${cfg.apiKey ? `${maskSecret(cfg.apiKey)} ${ui.dim(cfg.apiKeyName || "")}` : "未配置"}`,
560
+ `${statusDot(Boolean(cfg.defaultModel))} 模型 ${cfg.defaultModel || "未选择"}`,
561
+ `${ui.accent("●")} 网关 ${apiBase(cfg)}`,
562
+ ]
563
+ if (extra.onlineModels != null) rows.push(`${ui.gold("●")} 雷达 在线模型 ${extra.onlineModels} 个`)
564
+ if (extra.balance != null) rows.push(`${ui.gold("●")} 余额 $${Number(extra.balance || 0).toFixed(4)}`)
565
+ if (extra.requests != null) rows.push(`${ui.hot("●")} 请求 ${extra.requests}`)
566
+ if (extra.version) rows.push(`${ui.accent("●")} 版本 ${VERSION} · ${extra.version}`)
567
+ if (extra.checkedAt) {
568
+ const source = extra.stale ? "旧缓存" : extra.cached ? "缓存" : "实时"
569
+ rows.push(`${ui.muted("●")} 快照 ${source} · ${formatAge(extra.checkedAt)}`)
570
+ }
571
+ printPanel("驾驶舱状态", rows)
572
+ }
573
+
574
+ function nextActions(cfg) {
575
+ const actions = []
576
+ if (!cfg.accessToken) {
577
+ actions.push({ command: "laicode login", reason: "浏览器授权,自动准备测试 API Key" })
578
+ } else if (!cfg.apiKey) {
579
+ actions.push({ command: "laicode keys create", reason: "创建或缓存 Lai.vc 测试 API Key" })
580
+ }
581
+ if (!cfg.defaultModel) {
582
+ actions.push({ command: "laicode models --online", reason: "查看在线模型并选择默认模型" })
583
+ }
584
+ if (cfg.accessToken && cfg.apiKey && cfg.defaultModel) {
585
+ actions.push({ command: "laicode chat", reason: `使用 ${cfg.defaultModel} 做流式对话测试` })
586
+ actions.push({ command: "laicode bench", reason: `压测 ${cfg.defaultModel} 的首 token 和总耗时` })
587
+ actions.push({ command: "laicode doctor", reason: "验证会话、密钥和真实模型调用" })
588
+ }
589
+ return actions
590
+ }
591
+
592
+ function printNextActions(cfg) {
593
+ const actions = nextActions(cfg)
594
+ if (!actions.length) return
595
+ printPanel(
596
+ "下一步",
597
+ actions.map((action) => `${padDisplay(action.command, 26)} ${action.reason}`)
598
+ )
599
+ }
600
+
601
+ async function question(prompt, fallback = "") {
602
+ if (!process.stdin.isTTY) return fallback
603
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
604
+ const value = await new Promise((resolve) => rl.question(prompt, resolve))
605
+ rl.close()
606
+ return value || fallback
607
+ }
608
+
609
+ async function pause(message = "Press Enter to continue...") {
610
+ if (!process.stdin.isTTY) return
611
+ await question(ui.dim(message))
612
+ }
613
+
614
+ async function selectMenu(title, items, opts = {}) {
615
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
616
+ return items[0]?.value
617
+ }
618
+
619
+ if (!opts.fancy) {
620
+ if (!opts.noHero) printHero(title)
621
+ else console.log(ui.bold(title))
622
+ if (opts.subtitle) console.log(`${opts.subtitle}\n`)
623
+ for (let i = 0; i < items.length; i++) {
624
+ const item = items[i]
625
+ const n = String(i + 1).padStart(2, " ")
626
+ const label = item.value === opts.cancelValue ? ui.dim(item.label) : ui.bold(item.label)
627
+ const desc = item.description ? ui.dim(` ${item.description}`) : ""
628
+ console.log(`${ui.accent(n)} ${label}${desc}`)
629
+ }
630
+ console.log("")
631
+ const answer = await question(ui.dim("输入编号,或 q 退出: "), "")
632
+ if (!answer || answer.toLowerCase() === "q" || answer === "0") return opts.cancelValue
633
+ const picked = Number(answer)
634
+ if (!Number.isInteger(picked) || picked < 1 || picked > items.length) return opts.cancelValue
635
+ return items[picked - 1].value
636
+ }
637
+
638
+ let index = Math.max(0, items.findIndex((item) => item.value === opts.defaultValue))
639
+ if (index < 0) index = 0
640
+
641
+ const render = () => {
642
+ if (!opts.noHero) printHero(title)
643
+ else console.log(ui.bold(title))
644
+ if (opts.subtitle) console.log(`${opts.subtitle}\n`)
645
+ for (let i = 0; i < items.length; i++) {
646
+ const item = items[i]
647
+ const selected = i === index
648
+ const cursor = selected ? ui.accent(">") : " "
649
+ const label = selected ? ui.bold(item.label) : item.label
650
+ const desc = item.description ? ui.dim(` ${item.description}`) : ""
651
+ console.log(`${cursor} ${label}${desc}`)
652
+ }
653
+ console.log("")
654
+ console.log(ui.muted("Use Up/Down, Enter to select, q/0/Esc to quit."))
655
+ }
656
+
657
+ return new Promise((resolve) => {
658
+ const input = process.stdin
659
+ readline.emitKeypressEvents(input)
660
+ const wasRaw = input.isRaw
661
+ input.setRawMode(true)
662
+ input.resume()
663
+ render()
664
+
665
+ const cleanup = (value) => {
666
+ input.setRawMode(wasRaw || false)
667
+ input.off("keypress", onKey)
668
+ input.off("data", onData)
669
+ console.log("")
670
+ resolve(value)
671
+ }
672
+
673
+ const onData = (chunk) => {
674
+ const s = chunk.toString("utf8")
675
+ if (s === "q" || s === "0" || s === "x" || s === "\u0003") {
676
+ cleanup(opts.cancelValue)
677
+ }
678
+ }
679
+
680
+ const onKey = (_str, key) => {
681
+ if (!key) return
682
+ if (key.name === "up") {
683
+ index = (index - 1 + items.length) % items.length
684
+ render()
685
+ } else if (key.name === "down") {
686
+ index = (index + 1) % items.length
687
+ render()
688
+ } else if (key.name === "return") {
689
+ cleanup(items[index].value)
690
+ } else if (key.name === "q" || key.name === "0" || key.name === "x" || (key.ctrl && key.name === "c") || key.name === "escape") {
691
+ cleanup(opts.cancelValue)
692
+ }
693
+ }
694
+
695
+ input.on("data", onData)
696
+ input.on("keypress", onKey)
697
+ })
698
+ }
699
+
700
+ async function ensureStateDir() {
701
+ await fsp.mkdir(STATE_DIR, { recursive: true, mode: 0o700 })
702
+ try {
703
+ await fsp.chmod(STATE_DIR, 0o700)
704
+ } catch {
705
+ // Windows and some mounted filesystems may not support POSIX mode bits.
706
+ }
707
+ }
708
+
709
+ async function readConfig() {
710
+ try {
711
+ const raw = await fsp.readFile(CONFIG_FILE, "utf8")
712
+ return JSON.parse(raw)
713
+ } catch (err) {
714
+ if (err && err.code === "ENOENT") return {}
715
+ throw err
716
+ }
717
+ }
718
+
719
+ async function writeConfig(next) {
720
+ await ensureStateDir()
721
+ const tmp = `${CONFIG_FILE}.${process.pid}.tmp`
722
+ await fsp.writeFile(tmp, JSON.stringify(next, null, 2) + "\n", { mode: 0o600 })
723
+ try {
724
+ await fsp.chmod(tmp, 0o600)
725
+ } catch {
726
+ // Best effort on non-POSIX platforms.
727
+ }
728
+ await fsp.rename(tmp, CONFIG_FILE)
729
+ }
730
+
731
+ async function patchConfig(patch) {
732
+ const cfg = await readConfig()
733
+ const next = { ...cfg, ...patch, updatedAt: nowIso() }
734
+ await writeConfig(next)
735
+ return next
736
+ }
737
+
738
+ function redactedConfig(cfg) {
739
+ return {
740
+ ...cfg,
741
+ accessToken: cfg.accessToken ? maskSecret(cfg.accessToken) : undefined,
742
+ apiKey: cfg.apiKey ? maskSecret(cfg.apiKey) : undefined,
743
+ }
744
+ }
745
+
746
+ function safeConfig(cfg) {
747
+ const { accessToken, apiKey, ...rest } = cfg || {}
748
+ return {
749
+ ...rest,
750
+ signedIn: Boolean(accessToken),
751
+ hasApiKey: Boolean(apiKey),
752
+ accessToken: accessToken ? "[configured]" : undefined,
753
+ apiKey: apiKey ? "[configured]" : undefined,
754
+ }
755
+ }
756
+
757
+ function isSecretConfigKey(name) {
758
+ return name === "apiKey" || name === "accessToken"
759
+ }
760
+
761
+ function safeKeyRecord(key, reveal = false) {
762
+ if (reveal) return key
763
+ return {
764
+ id: key.id,
765
+ name: key.name || null,
766
+ tier: key.tier || null,
767
+ apiBaseUrl: key.apiBaseUrl || null,
768
+ createdTime: key.createdTime || null,
769
+ keyConfigured: Boolean(key.key),
770
+ }
771
+ }
772
+
773
+ function siteBase(cfg = {}) {
774
+ return (process.env.LAICODE_SITE_URL || cfg.siteBase || DEFAULT_SITE_BASE).replace(/\/$/, "")
775
+ }
18
776
 
19
- const arg = process.argv[2]
777
+ function apiBase(cfg = {}) {
778
+ return (cfg.apiBaseUrl || DEFAULT_API_BASE).replace(/\/$/, "")
779
+ }
780
+
781
+ async function readJsonResponse(res) {
782
+ const text = await res.text()
783
+ if (!text) return {}
784
+ try {
785
+ return JSON.parse(text)
786
+ } catch {
787
+ return { error: text }
788
+ }
789
+ }
790
+
791
+ async function requestJson(url, init = {}) {
792
+ const ctrl = new AbortController()
793
+ const timeout = setTimeout(() => ctrl.abort(), Number(init.timeoutMs || 30000))
794
+ try {
795
+ const res = await fetch(url, { ...init, signal: ctrl.signal })
796
+ const data = await readJsonResponse(res)
797
+ if (!res.ok) {
798
+ const msg = data.error || data.message || `${res.status} ${res.statusText}`
799
+ const err = new Error(msg)
800
+ err.status = res.status
801
+ err.data = data
802
+ throw err
803
+ }
804
+ return data
805
+ } finally {
806
+ clearTimeout(timeout)
807
+ }
808
+ }
809
+
810
+ async function siteJson(cfg, pathName, init = {}) {
811
+ const headers = { "Content-Type": "application/json", ...(init.headers || {}) }
812
+ return requestJson(`${siteBase(cfg)}${pathName}`, { ...init, headers })
813
+ }
814
+
815
+ async function authJson(cfg, pathName, init = {}) {
816
+ if (!cfg.accessToken) {
817
+ die("Not logged in. Run `laicode login` first.")
818
+ }
819
+ const headers = {
820
+ "Content-Type": "application/json",
821
+ Authorization: `Bearer ${cfg.accessToken}`,
822
+ ...(init.headers || {}),
823
+ }
824
+ try {
825
+ return await requestJson(`${siteBase(cfg)}${pathName}`, { ...init, headers })
826
+ } catch (err) {
827
+ if (err.status === 401) {
828
+ die("LaiCode session expired or revoked. Run `laicode login` again.")
829
+ }
830
+ throw err
831
+ }
832
+ }
833
+
834
+ function openBrowser(url) {
835
+ const platform = process.platform
836
+ let cmd
837
+ let args
838
+ if (platform === "darwin") {
839
+ cmd = "open"
840
+ args = [url]
841
+ } else if (platform === "win32") {
842
+ cmd = "cmd"
843
+ args = ["/c", "start", "", url]
844
+ } else {
845
+ cmd = "xdg-open"
846
+ args = [url]
847
+ }
848
+
849
+ if (!commandExists(cmd)) return false
850
+
851
+ try {
852
+ const child = spawn(cmd, args, { detached: true, stdio: "ignore" })
853
+ child.on("error", () => {})
854
+ child.unref()
855
+ return true
856
+ } catch {
857
+ return false
858
+ }
859
+ }
860
+
861
+ function commandExists(cmd) {
862
+ if (cmd.includes(path.sep)) return fs.existsSync(cmd)
863
+ const dirs = String(process.env.PATH || "").split(path.delimiter).filter(Boolean)
864
+ const exts =
865
+ process.platform === "win32"
866
+ ? String(process.env.PATHEXT || ".EXE;.CMD;.BAT;.COM").split(";")
867
+ : [""]
868
+ for (const dir of dirs) {
869
+ for (const ext of exts) {
870
+ if (fs.existsSync(path.join(dir, cmd + ext))) return true
871
+ }
872
+ }
873
+ return false
874
+ }
875
+
876
+ function platformName() {
877
+ const p = os.platform()
878
+ if (p === "darwin") return "macos"
879
+ if (p === "win32") return "windows"
880
+ return p
881
+ }
882
+
883
+ async function bootstrap(cfg, quiet = false) {
884
+ const data = await authJson(cfg, "/api/laicode/bootstrap", { method: "GET" })
885
+ await patchConfig({
886
+ siteBase: siteBase(cfg),
887
+ apiBaseUrl: data.apiBaseUrl || apiBase(cfg),
888
+ user: data.user || cfg.user,
889
+ lastBootstrapAt: nowIso(),
890
+ })
891
+ if (!quiet) {
892
+ const user = data.user?.email || data.user?.name || "unknown"
893
+ console.log(`Logged in as ${user}`)
894
+ if (data.account) {
895
+ console.log(`Balance: $${Number(data.account.balance || 0).toFixed(4)} · Requests: ${data.account.requests || 0}`)
896
+ }
897
+ }
898
+ return data
899
+ }
900
+
901
+ async function listKeys(cfg) {
902
+ const data = await authJson(cfg, "/api/laicode/keys", { method: "GET" })
903
+ return data.keys || []
904
+ }
905
+
906
+ function defaultKeyName() {
907
+ return `Laicode ${os.hostname()}`.slice(0, 40)
908
+ }
909
+
910
+ async function ensureApiKey(cfg, opts = {}) {
911
+ if (cfg.apiKey && /^sk-/.test(cfg.apiKey)) return cfg.apiKey
912
+ if (!cfg.accessToken) return null
913
+
914
+ const keys = await listKeys(cfg)
915
+ const wanted = opts.name || defaultKeyName()
916
+ const found =
917
+ keys.find((k) => k.name === wanted && k.key) ||
918
+ keys.find((k) => String(k.name || "").startsWith("Laicode ") && k.key) ||
919
+ keys.find((k) => k.key)
920
+
921
+ if (found) {
922
+ await patchConfig({
923
+ apiKey: found.key,
924
+ apiKeyId: found.id,
925
+ apiKeyName: found.name,
926
+ apiBaseUrl: found.apiBaseUrl || cfg.apiBaseUrl || DEFAULT_API_BASE,
927
+ defaultTier: found.tier || cfg.defaultTier,
928
+ })
929
+ return found.key
930
+ }
931
+
932
+ if (opts.create === false) return null
933
+
934
+ const body = { name: wanted }
935
+ if (opts.tier || cfg.defaultTier) body.tier = opts.tier || cfg.defaultTier
936
+ const data = await authJson(cfg, "/api/laicode/keys", {
937
+ method: "POST",
938
+ body: JSON.stringify(body),
939
+ })
940
+ const key = data.key
941
+ if (!key?.key) throw new Error("Key creation succeeded but no key was returned")
942
+ await patchConfig({
943
+ apiKey: key.key,
944
+ apiKeyId: key.id,
945
+ apiKeyName: key.name,
946
+ apiBaseUrl: key.apiBaseUrl || cfg.apiBaseUrl || DEFAULT_API_BASE,
947
+ defaultTier: key.tier || cfg.defaultTier,
948
+ })
949
+ return key.key
950
+ }
951
+
952
+ function pickDefaultModel(models = []) {
953
+ const preferred = ["gpt-5.5", "gpt-5.4", "gpt-5.4-mini"]
954
+ for (const id of preferred) {
955
+ const m = models.find((x) => x.modelId === id && modelOnline(x))
956
+ if (m) return m.modelId
957
+ }
958
+ const first = models.find(modelOnline) || models[0]
959
+ return first?.modelId
960
+ }
961
+
962
+ function modelOnline(model) {
963
+ return Boolean((model.tiers || []).some((t) => t.status === "online"))
964
+ }
965
+
966
+ function printModelRadarSummary(models, rows) {
967
+ const onlineModels = models.filter(modelOnline)
968
+ const vendors = new Set(models.map((m) => m.vendor).filter(Boolean))
969
+ const onlineRows = rows.filter((row) => row.online)
970
+ const cheapest = onlineRows
971
+ .filter((row) => row.rawInput != null)
972
+ .sort((a, b) => a.rawInput - b.rawInput)[0]
973
+ const fastest = onlineModels
974
+ .filter((m) => Number.isFinite(Number(m.performance?.latencyMs)))
975
+ .sort((a, b) => Number(a.performance.latencyMs) - Number(b.performance.latencyMs))[0]
976
+ const maxLatency = Math.max(1, ...onlineModels.map((m) => Number(m.performance?.latencyMs) || 0))
977
+ const recommended = pickDefaultModel(models) || "-"
978
+
979
+ printPanel("雷达摘要", [
980
+ `在线模型 ${onlineModels.length}/${models.length}`,
981
+ `厂商覆盖 ${vendors.size || 0} 个`,
982
+ `在线档位 ${onlineRows.length} 个`,
983
+ `推荐默认 ${recommended}`,
984
+ `最低输入价 ${cheapest ? `${cheapest.model} · ${cheapest.tier} · ${formatMoney(cheapest.rawInput)}/1M` : "-"}`,
985
+ `最快延迟 ${
986
+ fastest
987
+ ? `${fastest.modelId} · ${formatMs(fastest.performance.latencyMs)} ${latencyBar(fastest.performance.latencyMs, maxLatency, 12)}`
988
+ : "-"
989
+ }`,
990
+ ])
991
+ console.log("")
992
+ }
993
+
994
+ async function getModels(cfg) {
995
+ if (cfg.accessToken) {
996
+ const data = await bootstrap(cfg, true)
997
+ return { models: data.models || [], tiers: data.tiers || [], account: data.account, user: data.user }
998
+ }
999
+ const data = await siteJson(cfg, "/api/catalog", { method: "GET" })
1000
+ return { models: data.models || [], tiers: [] }
1001
+ }
1002
+
1003
+ async function getNpmLatestVersion() {
1004
+ const data = await requestJson("https://registry.npmjs.org/@laivc%2flaicode", {
1005
+ method: "GET",
1006
+ timeoutMs: 8000,
1007
+ })
1008
+ return data["dist-tags"]?.latest || data.version || null
1009
+ }
1010
+
1011
+ async function getVersionSummary() {
1012
+ try {
1013
+ const latest = await getNpmLatestVersion()
1014
+ if (!latest) return "latest 未知"
1015
+ const cmp = compareVersions(VERSION, latest)
1016
+ if (cmp < 0) return `npm ${latest},可更新`
1017
+ if (cmp > 0) return `npm ${latest},待发布`
1018
+ return `npm ${latest},已同步`
1019
+ } catch {
1020
+ return "版本未知"
1021
+ }
1022
+ }
1023
+
1024
+ function normalizeDashboardCache(cache, extra = {}) {
1025
+ if (!cache || typeof cache !== "object") return null
1026
+ return {
1027
+ onlineModels: cache.onlineModels ?? null,
1028
+ balance: cache.balance ?? null,
1029
+ requests: cache.requests ?? null,
1030
+ version: cache.version ?? null,
1031
+ checkedAt: cache.checkedAt || null,
1032
+ ...extra,
1033
+ }
1034
+ }
1035
+
1036
+ async function dashboardSnapshot(cfg, opts = {}) {
1037
+ const ttlMs = Number(opts.ttlMs ?? DASHBOARD_CACHE_TTL_MS)
1038
+ const cached = normalizeDashboardCache(cfg.dashboardCache, { cached: true })
1039
+ if (!opts.refresh && cached?.checkedAt && ageMs(cached.checkedAt) < ttlMs) return cached
1040
+
1041
+ const snap = { onlineModels: null, balance: null, requests: null, version: null, checkedAt: nowIso(), cached: false }
1042
+ const patch = {}
1043
+ try {
1044
+ if (cfg.accessToken) {
1045
+ const data = await requestJson(`${siteBase(cfg)}/api/laicode/bootstrap`, {
1046
+ method: "GET",
1047
+ timeoutMs: 8000,
1048
+ headers: {
1049
+ "Content-Type": "application/json",
1050
+ Authorization: `Bearer ${cfg.accessToken}`,
1051
+ },
1052
+ })
1053
+ patch.siteBase = siteBase(cfg)
1054
+ patch.apiBaseUrl = data.apiBaseUrl || apiBase(cfg)
1055
+ patch.user = data.user || cfg.user
1056
+ patch.lastBootstrapAt = snap.checkedAt
1057
+ snap.balance = data.account?.balance
1058
+ snap.requests = data.account?.requests
1059
+ snap.onlineModels = (data.models || []).filter(modelOnline).length
1060
+ } else {
1061
+ const data = await siteJson(cfg, "/api/catalog", { method: "GET", timeoutMs: 8000 })
1062
+ snap.onlineModels = (data.models || []).filter(modelOnline).length
1063
+ }
1064
+ } catch {
1065
+ // Dashboard snapshot is decorative; never block the main menu.
1066
+ if (cached) return { ...cached, stale: true }
1067
+ }
1068
+ snap.version = await getVersionSummary()
1069
+ await patchConfig({
1070
+ ...patch,
1071
+ dashboardCache: {
1072
+ onlineModels: snap.onlineModels,
1073
+ balance: snap.balance,
1074
+ requests: snap.requests,
1075
+ version: snap.version,
1076
+ checkedAt: snap.checkedAt,
1077
+ },
1078
+ })
1079
+ return snap
1080
+ }
1081
+
1082
+ function statusPayload(cfg, snapshot) {
1083
+ return {
1084
+ version: VERSION,
1085
+ stateDir: STATE_DIR,
1086
+ configFile: CONFIG_FILE,
1087
+ siteBase: siteBase(cfg),
1088
+ apiBaseUrl: apiBase(cfg),
1089
+ signedIn: Boolean(cfg.accessToken),
1090
+ hasApiKey: Boolean(cfg.apiKey),
1091
+ apiKeyConfigured: Boolean(cfg.apiKey),
1092
+ apiKeyId: cfg.apiKeyId || null,
1093
+ apiKeyName: cfg.apiKeyName || null,
1094
+ defaultModel: cfg.defaultModel || null,
1095
+ user: cfg.user || null,
1096
+ nextActions: nextActions(cfg),
1097
+ dashboard: snapshot,
1098
+ }
1099
+ }
1100
+
1101
+ async function cmdStatus(args = {}) {
1102
+ const cfg = await readConfig()
1103
+ const snapshot = await dashboardSnapshot(cfg, {
1104
+ refresh: args.refresh,
1105
+ ttlMs: args.refresh ? 0 : DASHBOARD_CACHE_TTL_MS,
1106
+ })
1107
+ const next = await readConfig()
1108
+
1109
+ if (args.json) {
1110
+ console.log(JSON.stringify(statusPayload(next, snapshot), null, 2))
1111
+ return
1112
+ }
1113
+
1114
+ printHero(args.refresh ? "状态总览 · 已刷新" : "状态总览")
1115
+ printDashboard(next, snapshot)
1116
+ console.log("")
1117
+ printPanel("本地状态", localStatusRows(next))
1118
+ console.log("")
1119
+ printNextActions(next)
1120
+ }
1121
+
1122
+ async function confirmRevealKey(args) {
1123
+ if (!args.show) return false
1124
+ if (args.yes || args.force) return true
1125
+ if (!process.stdin.isTTY) {
1126
+ die("拒绝在非交互环境显示完整 key。若确认需要,请显式添加 `--yes`。")
1127
+ }
1128
+ console.log(ui.warn("即将显示完整 API Key。请确认当前终端不会被录屏、共享或写入日志。"))
1129
+ const answer = await question("输入 SHOW 确认显示完整 key: ", "")
1130
+ if (answer !== "SHOW") die("已取消显示完整 key。")
1131
+ return true
1132
+ }
1133
+
1134
+ async function chatCompletion(cfg, messages, opts = {}) {
1135
+ const key = opts.apiKey || cfg.apiKey || (await ensureApiKey(cfg))
1136
+ if (!key) die("No API key available. Run `laicode login` or `laicode config set api-key sk-...`.")
1137
+
1138
+ const model = opts.model || cfg.defaultModel
1139
+ if (!model) die("未选择模型。请传入 `--model <model>` 或先运行 `laicode models`。")
1140
+
1141
+ const started = Date.now()
1142
+ let firstTokenAt = 0
1143
+ let content = ""
1144
+ const res = await fetch(`${apiBase(cfg)}/chat/completions`, {
1145
+ method: "POST",
1146
+ headers: {
1147
+ "Content-Type": "application/json",
1148
+ Authorization: `Bearer ${key}`,
1149
+ },
1150
+ body: JSON.stringify({
1151
+ model,
1152
+ messages,
1153
+ stream: opts.stream !== false,
1154
+ temperature: opts.temperature == null ? 0.2 : Number(opts.temperature),
1155
+ }),
1156
+ })
1157
+
1158
+ if (!res.ok) {
1159
+ const data = await readJsonResponse(res)
1160
+ const msg = data.error?.message || data.error || data.message || `${res.status} ${res.statusText}`
1161
+ const err = new Error(msg)
1162
+ err.status = res.status
1163
+ err.data = data
1164
+ throw err
1165
+ }
1166
+
1167
+ if (opts.stream === false) {
1168
+ const data = await readJsonResponse(res)
1169
+ content = data.choices?.[0]?.message?.content || ""
1170
+ return { content, firstTokenMs: Date.now() - started, totalMs: Date.now() - started, status: res.status }
1171
+ }
1172
+
1173
+ const decoder = new TextDecoder()
1174
+ let buffer = ""
1175
+ for await (const chunk of res.body) {
1176
+ buffer += decoder.decode(chunk, { stream: true })
1177
+ const lines = buffer.split(/\r?\n/)
1178
+ buffer = lines.pop() || ""
1179
+ for (const line of lines) {
1180
+ const trimmed = line.trim()
1181
+ if (!trimmed || !trimmed.startsWith("data:")) continue
1182
+ const payload = trimmed.slice(5).trim()
1183
+ if (payload === "[DONE]") continue
1184
+ let data
1185
+ try {
1186
+ data = JSON.parse(payload)
1187
+ } catch {
1188
+ continue
1189
+ }
1190
+ const delta = data.choices?.[0]?.delta?.content || data.choices?.[0]?.message?.content || ""
1191
+ if (delta) {
1192
+ if (!firstTokenAt) firstTokenAt = Date.now()
1193
+ content += delta
1194
+ if (opts.print !== false) process.stdout.write(delta)
1195
+ }
1196
+ }
1197
+ }
1198
+
1199
+ if (opts.print !== false) process.stdout.write("\n")
1200
+ return {
1201
+ content,
1202
+ firstTokenMs: firstTokenAt ? firstTokenAt - started : Date.now() - started,
1203
+ totalMs: Date.now() - started,
1204
+ status: res.status,
1205
+ }
1206
+ }
1207
+
1208
+ async function cmdLogin(args) {
1209
+ const cfg = await readConfig()
1210
+ const base = siteBase(cfg)
1211
+ const body = {
1212
+ client: "laicode-cli",
1213
+ version: VERSION,
1214
+ hostname: os.hostname(),
1215
+ platform: platformName(),
1216
+ scopes: ["account:read", "keys:read", "keys:create"],
1217
+ }
1218
+ const data = await siteJson({ siteBase: base }, "/api/laicode/device/start", {
1219
+ method: "POST",
1220
+ body: JSON.stringify(body),
1221
+ })
1222
+
1223
+ console.log("打开以下链接授权 LaiCode:")
1224
+ console.log(data.verificationUriComplete || data.verificationUri)
1225
+ if (data.userCode) console.log(`授权码: ${data.userCode}`)
1226
+ console.log(`有效期: ${data.expiresIn || 600}s`)
1227
+
1228
+ if (!args["no-open"]) {
1229
+ const opened = openBrowser(data.verificationUriComplete || data.verificationUri)
1230
+ if (opened) console.log("已尝试打开浏览器。若未打开,请手动复制链接。")
1231
+ else console.log("当前机器没有可用浏览器打开器,请手动复制链接。")
1232
+ }
20
1233
 
21
- if (!arg || arg === "--help" || arg === "-h") {
22
- console.log(help)
23
- process.exit(0)
1234
+ let interval = Number(data.interval || 2)
1235
+ const deadline = Date.now() + Number(data.expiresIn || 600) * 1000
1236
+ while (Date.now() < deadline) {
1237
+ await sleep(interval * 1000)
1238
+ const poll = await siteJson({ siteBase: base }, "/api/laicode/device/poll", {
1239
+ method: "POST",
1240
+ body: JSON.stringify({ deviceCode: data.deviceCode }),
1241
+ })
1242
+ if (poll.status === "pending") {
1243
+ process.stdout.write(".")
1244
+ interval = Number(poll.interval || interval)
1245
+ continue
1246
+ }
1247
+ if (poll.status === "slow_down") {
1248
+ interval = Math.max(interval + 1, Number(poll.interval || interval))
1249
+ process.stdout.write(" slow_down ")
1250
+ continue
1251
+ }
1252
+ process.stdout.write("\n")
1253
+ if (poll.status === "denied") die("授权已被拒绝。")
1254
+ if (poll.status === "expired") die("授权已过期,请重新运行 `laicode login`。")
1255
+ if (poll.status === "authorized") {
1256
+ const expiresIn = Number(poll.expiresIn || 2592000)
1257
+ let next = await patchConfig({
1258
+ siteBase: base,
1259
+ apiBaseUrl: poll.apiBaseUrl || DEFAULT_API_BASE,
1260
+ accessToken: poll.accessToken,
1261
+ accessTokenExpiresAt: new Date(Date.now() + expiresIn * 1000).toISOString(),
1262
+ user: poll.user,
1263
+ loggedInAt: nowIso(),
1264
+ })
1265
+ console.log(`已授权: ${poll.user?.email || poll.user?.name || "unknown"}`)
1266
+ if (!args["no-key"]) {
1267
+ const key = await ensureApiKey(next, { tier: args.tier })
1268
+ next = await readConfig()
1269
+ console.log(`API Key 就绪: ${maskSecret(key)} (${next.apiKeyName || "Laicode key"})`)
1270
+ }
1271
+ return
1272
+ }
1273
+ die(`Unexpected authorization status: ${poll.status}`)
1274
+ }
1275
+ die("授权等待超时,请重新运行 `laicode login`。")
24
1276
  }
25
1277
 
26
- if (arg === "--version" || arg === "-v") {
27
- console.log(version)
28
- process.exit(0)
1278
+ async function cmdModels(args) {
1279
+ const cfg = await readConfig()
1280
+ const data = await getModels(cfg)
1281
+ let models = data.models || []
1282
+ if (args.online) models = models.filter(modelOnline)
1283
+ if (args.category) models = models.filter((m) => m.category === args.category)
1284
+ if (args.vendor) models = models.filter((m) => m.vendor === args.vendor)
1285
+
1286
+ if (args.json) {
1287
+ console.log(JSON.stringify({ models }, null, 2))
1288
+ return
1289
+ }
1290
+
1291
+ if (!models.length) {
1292
+ console.log("未找到模型。")
1293
+ return
1294
+ }
1295
+
1296
+ const rows = []
1297
+ for (const m of models) {
1298
+ for (const t of m.tiers || []) {
1299
+ if (args.online && t.status !== "online") continue
1300
+ rows.push({
1301
+ model: m.modelId,
1302
+ vendor: m.vendor || "-",
1303
+ category: m.category || "-",
1304
+ tier: t.name || t.id,
1305
+ input: formatMoney(t.input),
1306
+ output: formatMoney(t.output),
1307
+ sources: String(t.sources ?? "-"),
1308
+ status: t.status === "online" ? ui.ok("在线") : ui.warn("离线"),
1309
+ latency: formatMs(m.performance?.latencyMs),
1310
+ rawInput: t.input == null ? null : Number(t.input),
1311
+ online: t.status === "online",
1312
+ })
1313
+ }
1314
+ }
1315
+ printModelRadarSummary(models, rows)
1316
+ printTable(
1317
+ "模型雷达 · Lai.vc",
1318
+ [
1319
+ { key: "model", label: "模型", width: 16, max: 24 },
1320
+ { key: "vendor", label: "厂商", width: 8, max: 14 },
1321
+ { key: "category", label: "类别", width: 6, max: 10 },
1322
+ { key: "tier", label: "档位", width: 6, max: 10 },
1323
+ { key: "input", label: "输入/1M", width: 8, align: "right" },
1324
+ { key: "output", label: "输出/1M", width: 8, align: "right" },
1325
+ { key: "sources", label: "源", width: 3, align: "right" },
1326
+ { key: "status", label: "状态", width: 4 },
1327
+ { key: "latency", label: "延迟", width: 8, align: "right" },
1328
+ ],
1329
+ rows
1330
+ )
1331
+ }
1332
+
1333
+ async function cmdKeys(args) {
1334
+ const action = args._[1] || "list"
1335
+ const cfg = await readConfig()
1336
+ if (action === "list") {
1337
+ const keys = await listKeys(cfg)
1338
+ const reveal = await confirmRevealKey(args)
1339
+ if (args.json) {
1340
+ console.log(JSON.stringify({ keys: keys.map((k) => safeKeyRecord(k, reveal)), reveal }, null, 2))
1341
+ return
1342
+ }
1343
+ if (!keys.length) {
1344
+ console.log("未找到 API Key。")
1345
+ return
1346
+ }
1347
+ printTable(
1348
+ "API 密钥",
1349
+ [
1350
+ { key: "id", label: "ID", width: 5, align: "right" },
1351
+ { key: "name", label: "名称", width: 24, max: 34 },
1352
+ { key: "key", label: "密钥", width: 18, max: 32 },
1353
+ { key: "created", label: "创建时间", width: 16, max: 22 },
1354
+ ],
1355
+ keys.map((k) => ({
1356
+ id: String(k.id),
1357
+ name: k.name || "-",
1358
+ key: reveal ? k.key : maskSecret(k.key),
1359
+ created: k.createdTime ? new Date(k.createdTime * 1000).toLocaleString("zh-CN") : "-",
1360
+ }))
1361
+ )
1362
+ return
1363
+ }
1364
+
1365
+ if (action === "create") {
1366
+ const name = args.name || defaultKeyName()
1367
+ const body = { name }
1368
+ if (args.tier) body.tier = args.tier
1369
+ const reveal = await confirmRevealKey(args)
1370
+ const data = await authJson(cfg, "/api/laicode/keys", {
1371
+ method: "POST",
1372
+ body: JSON.stringify(body),
1373
+ })
1374
+ const key = data.key
1375
+ await patchConfig({
1376
+ apiKey: key.key,
1377
+ apiKeyId: key.id,
1378
+ apiKeyName: key.name,
1379
+ apiBaseUrl: key.apiBaseUrl || cfg.apiBaseUrl || DEFAULT_API_BASE,
1380
+ defaultTier: key.tier || cfg.defaultTier,
1381
+ })
1382
+ if (args.json) {
1383
+ console.log(JSON.stringify({ ok: true, key: safeKeyRecord(key, reveal), reveal }, null, 2))
1384
+ return
1385
+ }
1386
+ console.log(`已创建 API Key ${key.id}: ${key.name}`)
1387
+ console.log(reveal ? key.key : maskSecret(key.key))
1388
+ return
1389
+ }
1390
+
1391
+ if (action === "delete") {
1392
+ const id = args._[2] || args.id
1393
+ if (!id) die("Usage: laicode keys delete <id>")
1394
+ await authJson(cfg, `/api/laicode/keys/${encodeURIComponent(id)}`, { method: "DELETE" })
1395
+ const patch = {}
1396
+ if (String(cfg.apiKeyId) === String(id)) {
1397
+ patch.apiKey = undefined
1398
+ patch.apiKeyId = undefined
1399
+ patch.apiKeyName = undefined
1400
+ }
1401
+ await patchConfig({ ...cfg, ...patch })
1402
+ if (args.json) {
1403
+ console.log(JSON.stringify({ ok: true, deletedId: id }, null, 2))
1404
+ return
1405
+ }
1406
+ console.log(`已删除 API Key ${id}`)
1407
+ return
1408
+ }
1409
+
1410
+ die("Usage: laicode keys [list|create|delete]")
1411
+ }
1412
+
1413
+ async function cmdConfig(args) {
1414
+ const action = args._[1] || "list"
1415
+ const cfg = await readConfig()
1416
+ const keyMap = {
1417
+ "api-key": "apiKey",
1418
+ "base-url": "apiBaseUrl",
1419
+ "site-url": "siteBase",
1420
+ model: "defaultModel",
1421
+ tier: "defaultTier",
1422
+ }
1423
+
1424
+ if (action === "list" || action === undefined) {
1425
+ const out = args.json ? safeConfig(cfg) : redactedConfig(cfg)
1426
+ if (args.json) console.log(JSON.stringify(out, null, 2))
1427
+ else {
1428
+ console.log(`State dir: ${STATE_DIR}`)
1429
+ console.log(JSON.stringify(out, null, 2))
1430
+ }
1431
+ return
1432
+ }
1433
+
1434
+ if (action === "get") {
1435
+ const raw = args._[2]
1436
+ if (!raw) die("Usage: laicode config get <key>")
1437
+ const name = keyMap[raw] || raw
1438
+ const value = cfg[name]
1439
+ if (args.json) {
1440
+ console.log(
1441
+ JSON.stringify(
1442
+ {
1443
+ key: raw,
1444
+ mappedKey: name,
1445
+ secret: isSecretConfigKey(name),
1446
+ configured: isSecretConfigKey(name) ? Boolean(value) : undefined,
1447
+ value: isSecretConfigKey(name) ? undefined : value ?? null,
1448
+ },
1449
+ null,
1450
+ 2
1451
+ )
1452
+ )
1453
+ return
1454
+ }
1455
+ console.log(isSecretConfigKey(name) ? maskSecret(value) : value ?? "")
1456
+ return
1457
+ }
1458
+
1459
+ if (action === "set") {
1460
+ const raw = args._[2]
1461
+ const value = args._[3]
1462
+ if (!raw || value == null) die("Usage: laicode config set <key> <value>")
1463
+ const name = keyMap[raw] || raw
1464
+ const next = await patchConfig({ [name]: value })
1465
+ const warnings = []
1466
+ if (name === "apiKey" && !String(value).startsWith("sk-")) warnings.push("API key does not start with sk-")
1467
+ if (args.json) {
1468
+ console.log(
1469
+ JSON.stringify(
1470
+ {
1471
+ ok: true,
1472
+ action: "set",
1473
+ key: raw,
1474
+ mappedKey: name,
1475
+ secret: isSecretConfigKey(name),
1476
+ warnings,
1477
+ config: safeConfig(next),
1478
+ },
1479
+ null,
1480
+ 2
1481
+ )
1482
+ )
1483
+ return
1484
+ }
1485
+ console.log(`Set ${raw}`)
1486
+ for (const warning of warnings) console.log(`Warning: ${warning}`)
1487
+ return
1488
+ }
1489
+
1490
+ if (action === "unset") {
1491
+ const raw = args._[2]
1492
+ if (!raw) die("Usage: laicode config unset <key>")
1493
+ const name = keyMap[raw] || raw
1494
+ const next = { ...cfg }
1495
+ delete next[name]
1496
+ next.updatedAt = nowIso()
1497
+ await writeConfig(next)
1498
+ if (args.json) {
1499
+ console.log(
1500
+ JSON.stringify(
1501
+ {
1502
+ ok: true,
1503
+ action: "unset",
1504
+ key: raw,
1505
+ mappedKey: name,
1506
+ secret: isSecretConfigKey(name),
1507
+ config: safeConfig(next),
1508
+ },
1509
+ null,
1510
+ 2
1511
+ )
1512
+ )
1513
+ return
1514
+ }
1515
+ console.log(`Unset ${raw}`)
1516
+ return
1517
+ }
1518
+
1519
+ die("Usage: laicode config [list|get|set|unset]")
1520
+ }
1521
+
1522
+ async function cmdChat(args) {
1523
+ let cfg = await readConfig()
1524
+ if (!cfg.apiKey && cfg.accessToken) {
1525
+ await ensureApiKey(cfg)
1526
+ cfg = await readConfig()
1527
+ }
1528
+
1529
+ let model = args.model || cfg.defaultModel
1530
+ if (!model && cfg.accessToken) {
1531
+ const data = await getModels(cfg)
1532
+ model = pickDefaultModel(data.models)
1533
+ }
1534
+ if (!model) die("未选择模型。请使用 `laicode chat --model <model>`。")
1535
+
1536
+ const message = args.message || args.m || args._.slice(1).join(" ")
1537
+ if (message) {
1538
+ console.log(`model: ${model}`)
1539
+ await chatCompletion(cfg, [{ role: "user", content: message }], { model, stream: true })
1540
+ await patchConfig({ defaultModel: model })
1541
+ return
1542
+ }
1543
+
1544
+ console.log(`进入交互对话。model=${model}。输入 /exit 退出。`)
1545
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
1546
+ const messages = []
1547
+ for (;;) {
1548
+ const input = await new Promise((resolve) => rl.question("> ", resolve))
1549
+ if (!input || input.trim() === "/exit" || input.trim() === "/quit") break
1550
+ messages.push({ role: "user", content: input })
1551
+ process.stdout.write("assistant: ")
1552
+ const res = await chatCompletion(cfg, messages, { model, stream: true })
1553
+ messages.push({ role: "assistant", content: res.content })
1554
+ }
1555
+ rl.close()
1556
+ await patchConfig({ defaultModel: model })
1557
+ }
1558
+
1559
+ async function cmdBench(args) {
1560
+ let cfg = await readConfig()
1561
+ if (!cfg.apiKey && cfg.accessToken) {
1562
+ await ensureApiKey(cfg)
1563
+ cfg = await readConfig()
1564
+ }
1565
+ let model = args.model || cfg.defaultModel
1566
+ if (!model && cfg.accessToken) {
1567
+ const data = await getModels(cfg)
1568
+ model = pickDefaultModel(data.models)
1569
+ }
1570
+ if (!model) die("未选择模型。请使用 `laicode bench --model <model>`。")
1571
+
1572
+ const count = Math.max(1, Number(args.count || args.n || 3))
1573
+ const prompt = args.prompt || "Say OK and one short sentence about API gateway health."
1574
+ const rows = []
1575
+ const startedAt = nowIso()
1576
+ if (!args.json) console.log(ui.bold(`正在压测 ${model}`) + ui.dim(` · ${count} 次流式请求`))
1577
+ for (let i = 0; i < count; i++) {
1578
+ const label = `${i + 1}/${count}`
1579
+ try {
1580
+ const res = await chatCompletion(
1581
+ cfg,
1582
+ [{ role: "user", content: prompt }],
1583
+ { model, stream: true, print: false, temperature: 0 }
1584
+ )
1585
+ rows.push({ index: i + 1, ok: true, firstTokenMs: res.firstTokenMs, totalMs: res.totalMs })
1586
+ if (!args.json) console.log(`${ui.ok("●")} ${label} 成功 首 token=${res.firstTokenMs}ms 总耗时=${res.totalMs}ms`)
1587
+ } catch (err) {
1588
+ rows.push({ index: i + 1, ok: false, error: err.message })
1589
+ if (!args.json) console.log(`${ui.err("●")} ${label} 失败 ${err.message}`)
1590
+ }
1591
+ }
1592
+ const ok = rows.filter((r) => r.ok)
1593
+ const avg = (field) => Math.round(ok.reduce((sum, r) => sum + r[field], 0) / Math.max(1, ok.length))
1594
+ const totals = ok.map((r) => r.totalMs)
1595
+ const firstTokens = ok.map((r) => r.firstTokenMs)
1596
+ const maxTotal = Math.max(1, ...totals)
1597
+ const avgTotal = ok.length ? avg("totalMs") : null
1598
+ const avgFirst = ok.length ? avg("firstTokenMs") : null
1599
+ const fastest = totals.length ? Math.min(...totals) : null
1600
+ const slowest = totals.length ? Math.max(...totals) : null
1601
+ const summary = {
1602
+ total: rows.length,
1603
+ success: ok.length,
1604
+ failed: rows.length - ok.length,
1605
+ successRate: rows.length ? ok.length / rows.length : 0,
1606
+ avgFirstTokenMs: avgFirst,
1607
+ avgTotalMs: avgTotal,
1608
+ p50TotalMs: percentile(totals, 50),
1609
+ p95TotalMs: percentile(totals, 95),
1610
+ fastestTotalMs: fastest,
1611
+ slowestTotalMs: slowest,
1612
+ totalJitterMs: totals.length ? slowest - fastest : null,
1613
+ firstTokenJitterMs: firstTokens.length ? Math.max(...firstTokens) - Math.min(...firstTokens) : null,
1614
+ throughputRps: avgTotal ? Number((1000 / avgTotal).toFixed(2)) : null,
1615
+ }
1616
+
1617
+ await patchConfig({ defaultModel: model })
1618
+
1619
+ if (args.json) {
1620
+ console.log(
1621
+ JSON.stringify(
1622
+ {
1623
+ model,
1624
+ count,
1625
+ startedAt,
1626
+ endedAt: nowIso(),
1627
+ promptLength: prompt.length,
1628
+ summary,
1629
+ results: rows,
1630
+ },
1631
+ null,
1632
+ 2
1633
+ )
1634
+ )
1635
+ if (summary.failed) process.exitCode = 1
1636
+ return
1637
+ }
1638
+
1639
+ printTable(
1640
+ `${model} 单次结果`,
1641
+ [
1642
+ { key: "index", label: "#", width: 3, align: "right" },
1643
+ { key: "status", label: "状态", width: 6 },
1644
+ { key: "first", label: "首 token", width: 10, align: "right" },
1645
+ { key: "total", label: "总耗时", width: 10, align: "right" },
1646
+ { key: "note", label: "耗时条 / 备注", width: 28, max: 48 },
1647
+ ],
1648
+ rows.map((r) => ({
1649
+ index: String(r.index),
1650
+ status: r.ok ? ui.ok("成功") : ui.err("失败"),
1651
+ first: r.ok ? `${r.firstTokenMs}ms` : "-",
1652
+ total: r.ok ? `${r.totalMs}ms` : "-",
1653
+ note: r.ok ? latencyBar(r.totalMs, maxTotal, 18) : r.error || "请求失败",
1654
+ }))
1655
+ )
1656
+ printPanel(`${model} 压测结果`, [
1657
+ `成功率 ${summary.success}/${summary.total} (${Math.round(summary.successRate * 100)}%)`,
1658
+ `平均首 token ${formatMs(avgFirst)}`,
1659
+ `平均总耗时 ${formatMs(avgTotal)}`,
1660
+ `P50 / P95 ${formatMs(summary.p50TotalMs)} / ${formatMs(summary.p95TotalMs)}`,
1661
+ `最快 / 最慢 ${formatMs(fastest)} / ${formatMs(slowest)}`,
1662
+ `抖动范围 ${formatMs(summary.totalJitterMs)}`,
1663
+ `首 token 抖动 ${formatMs(summary.firstTokenJitterMs)}`,
1664
+ `吞吐估算 ${summary.throughputRps ? `${summary.throughputRps.toFixed(2)} req/s` : "-"}`,
1665
+ ])
1666
+ if (summary.failed) process.exitCode = 1
1667
+ }
1668
+
1669
+ async function cmdDoctor(args) {
1670
+ const checks = []
1671
+ const startedAt = nowIso()
1672
+ const record = (id, name, ok, detail = "", extra = {}) => {
1673
+ checks.push({ id, name, ok: Boolean(ok), status: ok ? "ok" : "failed", detail, ...extra })
1674
+ }
1675
+
1676
+ await ensureStateDir()
1677
+ record("state-dir", "本地状态目录", true, STATE_DIR)
1678
+
1679
+ let cfg = await readConfig()
1680
+ record("config-file", "配置文件", true, fs.existsSync(CONFIG_FILE) ? CONFIG_FILE : "尚未创建")
1681
+
1682
+ try {
1683
+ const publicCatalog = await siteJson(cfg, "/api/catalog", { method: "GET", timeoutMs: 10000 })
1684
+ const count = publicCatalog.models?.length || 0
1685
+ record("catalog", "公开模型目录", Boolean(count), `${count} 个模型`, { count })
1686
+ } catch (err) {
1687
+ record("catalog", "公开模型目录", false, err.message)
1688
+ }
1689
+
1690
+ try {
1691
+ const latest = await getNpmLatestVersion()
1692
+ if (!latest) {
1693
+ record("version", "版本检查", true, `当前 ${VERSION},未取到 latest`, { current: VERSION, latest: null })
1694
+ } else {
1695
+ const cmp = compareVersions(VERSION, latest)
1696
+ if (cmp < 0) {
1697
+ record("version", "版本检查", true, `当前 ${VERSION},npm latest ${latest},可更新`, {
1698
+ current: VERSION,
1699
+ latest,
1700
+ state: "upgrade-available",
1701
+ })
1702
+ } else if (cmp > 0) {
1703
+ record("version", "版本检查", true, `当前 ${VERSION},npm latest ${latest},待发布`, {
1704
+ current: VERSION,
1705
+ latest,
1706
+ state: "local-ahead",
1707
+ })
1708
+ } else {
1709
+ record("version", "版本检查", true, `当前 ${VERSION},已是最新`, {
1710
+ current: VERSION,
1711
+ latest,
1712
+ state: "synced",
1713
+ })
1714
+ }
1715
+ }
1716
+ } catch (err) {
1717
+ record("version", "版本检查", true, `跳过: ${err.message}`, { skipped: true })
1718
+ }
1719
+
1720
+ if (cfg.accessToken) {
1721
+ try {
1722
+ const data = await bootstrap(cfg, true)
1723
+ record("session", "CLI 会话", true, data.user?.email || data.user?.name || "已授权", {
1724
+ user: data.user || cfg.user || null,
1725
+ })
1726
+ } catch (err) {
1727
+ record("session", "CLI 会话", false, err.message)
1728
+ }
1729
+ } else {
1730
+ record("session", "CLI 会话", false, "未登录,请运行 `laicode login`")
1731
+ }
1732
+
1733
+ if (!cfg.apiKey && cfg.accessToken) {
1734
+ try {
1735
+ await ensureApiKey(cfg)
1736
+ cfg = await readConfig()
1737
+ record("api-key", "API Key", true, cfg.apiKeyName || "已配置", {
1738
+ configured: Boolean(cfg.apiKey),
1739
+ apiKeyName: cfg.apiKeyName || null,
1740
+ })
1741
+ } catch (err) {
1742
+ record("api-key", "API Key", false, err.message)
1743
+ }
1744
+ } else if (cfg.apiKey) {
1745
+ record("api-key", "API Key", true, cfg.apiKeyName || "已配置", {
1746
+ configured: true,
1747
+ apiKeyName: cfg.apiKeyName || null,
1748
+ })
1749
+ } else {
1750
+ record("api-key", "API Key", false, "未配置")
1751
+ }
1752
+
1753
+ if (cfg.apiKey) {
1754
+ try {
1755
+ let model = args.model || cfg.defaultModel
1756
+ if (!model && cfg.accessToken) {
1757
+ const data = await getModels(cfg)
1758
+ model = pickDefaultModel(data.models)
1759
+ }
1760
+ if (!model) throw new Error("no model selected")
1761
+ const res = await chatCompletion(
1762
+ cfg,
1763
+ [{ role: "user", content: "Reply with OK." }],
1764
+ { model, stream: true, print: false, temperature: 0 }
1765
+ )
1766
+ record("chat-completion", "真实模型调用", true, `${model} 首 token=${res.firstTokenMs}ms 总耗时=${res.totalMs}ms`, {
1767
+ model,
1768
+ firstTokenMs: res.firstTokenMs,
1769
+ totalMs: res.totalMs,
1770
+ })
1771
+ } catch (err) {
1772
+ record("chat-completion", "真实模型调用", false, err.message)
1773
+ }
1774
+ }
1775
+
1776
+ const failed = checks.filter((c) => !c.ok).length
1777
+ const payload = {
1778
+ ok: failed === 0,
1779
+ version: VERSION,
1780
+ startedAt,
1781
+ endedAt: nowIso(),
1782
+ stateDir: STATE_DIR,
1783
+ configFile: CONFIG_FILE,
1784
+ siteBase: siteBase(cfg),
1785
+ apiBaseUrl: apiBase(cfg),
1786
+ defaultModel: cfg.defaultModel || null,
1787
+ signedIn: Boolean(cfg.accessToken),
1788
+ hasApiKey: Boolean(cfg.apiKey),
1789
+ checks,
1790
+ summary: {
1791
+ total: checks.length,
1792
+ passed: checks.length - failed,
1793
+ failed,
1794
+ },
1795
+ }
1796
+
1797
+ if (args.json) {
1798
+ console.log(JSON.stringify(payload, null, 2))
1799
+ if (failed) process.exitCode = 1
1800
+ return
1801
+ }
1802
+
1803
+ printTable(
1804
+ "系统诊断",
1805
+ [
1806
+ { key: "status", label: "状态", width: 6 },
1807
+ { key: "name", label: "检查项", width: 16, max: 24 },
1808
+ { key: "detail", label: "详情", width: 42, max: 58 },
1809
+ ],
1810
+ checks.map((c) => ({
1811
+ status: c.ok ? ui.ok("通过") : ui.err("失败"),
1812
+ name: c.name,
1813
+ detail: c.detail || "-",
1814
+ }))
1815
+ )
1816
+ if (failed) process.exitCode = 1
29
1817
  }
30
1818
 
31
- if (arg === "doctor") {
32
- console.log("LaiCode doctor")
33
- console.log("API base URL: https://api.lai.vc/v1")
34
- console.log("Status: CLI package installed. Full configurator coming soon.")
35
- process.exit(0)
1819
+ async function cmdLogout() {
1820
+ const cfg = await readConfig()
1821
+ if (cfg.accessToken) {
1822
+ try {
1823
+ await authJson(cfg, "/api/laicode/session/revoke", { method: "POST" })
1824
+ console.log("Remote session revoked.")
1825
+ } catch (err) {
1826
+ if (err.status !== 401) console.log(`Remote revoke failed: ${err.message}`)
1827
+ }
1828
+ }
1829
+ const next = { ...cfg }
1830
+ delete next.accessToken
1831
+ delete next.accessTokenExpiresAt
1832
+ delete next.user
1833
+ delete next.apiKey
1834
+ delete next.apiKeyId
1835
+ delete next.apiKeyName
1836
+ next.updatedAt = nowIso()
1837
+ await writeConfig(next)
1838
+ console.log("Local LaiCode session cleared.")
36
1839
  }
37
1840
 
38
- console.error(`Unknown command: ${arg}`)
39
- console.error("Run `laicode --help` for usage.")
40
- process.exit(1)
1841
+ function localStatusRows(cfg) {
1842
+ return [
1843
+ `账号 ${cfg.user?.email || cfg.user?.name || "未登录"}`,
1844
+ `会话 ${cfg.accessToken ? statusWord(true, "已授权") : statusWord(false, "需要登录")}`,
1845
+ `密钥 ${cfg.apiKey ? maskSecret(cfg.apiKey) : "未配置"}`,
1846
+ `模型 ${cfg.defaultModel || "未选择"}`,
1847
+ `网关 ${apiBase(cfg)}`,
1848
+ `状态 ${STATE_DIR}`,
1849
+ ]
1850
+ }
1851
+
1852
+ async function chooseModel(cfg, title = "Choose Model") {
1853
+ const data = await getModels(cfg)
1854
+ const online = (data.models || []).filter(modelOnline)
1855
+ if (!online.length) {
1856
+ console.log(ui.warn("没有找到在线模型。"))
1857
+ return null
1858
+ }
1859
+
1860
+ const items = online.map((m) => {
1861
+ const prices = (m.tiers || [])
1862
+ .filter((t) => t.status === "online")
1863
+ .map((t) => `${t.id}:$${t.input}/1M`)
1864
+ .join(" ")
1865
+ return {
1866
+ label: `${m.modelId}`,
1867
+ description: `${m.vendor || ""} ${m.category || ""} ${prices}`.trim(),
1868
+ value: m.modelId,
1869
+ }
1870
+ })
1871
+ items.push({ label: "Cancel", description: "", value: null })
1872
+ return selectMenu(title, items, { defaultValue: cfg.defaultModel || pickDefaultModel(online), cancelValue: null })
1873
+ }
1874
+
1875
+ async function menuLogin() {
1876
+ await cmdLogin({ _: ["login"] })
1877
+ await pause()
1878
+ }
1879
+
1880
+ async function menuModels() {
1881
+ await cmdModels({ _: ["models"], online: true })
1882
+ await pause()
1883
+ }
1884
+
1885
+ async function menuChat() {
1886
+ let cfg = await readConfig()
1887
+ if (!cfg.accessToken && !cfg.apiKey) {
1888
+ console.log(ui.warn("请先登录或设置 API Key 后再开始对话。"))
1889
+ await pause()
1890
+ return
1891
+ }
1892
+ if (!cfg.apiKey && cfg.accessToken) {
1893
+ await ensureApiKey(cfg)
1894
+ cfg = await readConfig()
1895
+ }
1896
+ const model = await chooseModel(cfg, "Chat Test")
1897
+ if (!model) return
1898
+ const prompt = await question("Prompt: ", "Say hello in one sentence.")
1899
+ printHero("Streaming Response")
1900
+ console.log(`${ui.dim("model")} ${model}\n`)
1901
+ await chatCompletion(cfg, [{ role: "user", content: prompt }], { model, stream: true })
1902
+ await patchConfig({ defaultModel: model })
1903
+ await pause()
1904
+ }
1905
+
1906
+ async function menuBench() {
1907
+ let cfg = await readConfig()
1908
+ if (!cfg.accessToken && !cfg.apiKey) {
1909
+ console.log(ui.warn("请先登录或设置 API Key 后再进行压测。"))
1910
+ await pause()
1911
+ return
1912
+ }
1913
+ if (!cfg.apiKey && cfg.accessToken) {
1914
+ await ensureApiKey(cfg)
1915
+ cfg = await readConfig()
1916
+ }
1917
+ const model = await chooseModel(cfg, "Benchmark")
1918
+ if (!model) return
1919
+ const count = await question("Requests [3]: ", "3")
1920
+ printHero("Benchmark")
1921
+ await cmdBench({ _: ["bench"], model, count })
1922
+ await pause()
1923
+ }
1924
+
1925
+ async function menuKeys() {
1926
+ const action = await selectMenu(
1927
+ "API Keys",
1928
+ [
1929
+ { label: "列出密钥", description: "默认掩码展示", value: "list" },
1930
+ { label: "创建密钥", description: "创建 Laicode 测试 key", value: "create" },
1931
+ { label: "删除密钥", description: "按 ID 删除", value: "delete" },
1932
+ { label: "返回", description: "", value: null },
1933
+ ],
1934
+ { cancelValue: null }
1935
+ )
1936
+ if (!action) return
1937
+ if (action === "list") {
1938
+ await cmdKeys({ _: ["keys", "list"] })
1939
+ await pause()
1940
+ return
1941
+ }
1942
+ if (action === "create") {
1943
+ const name = await question(`名称 [${defaultKeyName()}]: `, defaultKeyName())
1944
+ const tier = await question("档位 [默认]: ", "")
1945
+ await cmdKeys({ _: ["keys", "create"], name, tier: tier || undefined })
1946
+ await pause()
1947
+ return
1948
+ }
1949
+ if (action === "delete") {
1950
+ await cmdKeys({ _: ["keys", "list"] })
1951
+ const id = await question("要删除的 Key ID: ", "")
1952
+ if (id) await cmdKeys({ _: ["keys", "delete", id] })
1953
+ await pause()
1954
+ }
1955
+ }
1956
+
1957
+ async function menuConfig() {
1958
+ const cfg = await readConfig()
1959
+ printHero("本地配置")
1960
+ printPanel("本地状态", localStatusRows(cfg).map((row) => stripAnsi(row)))
1961
+ console.log("")
1962
+ console.log(JSON.stringify(redactedConfig(cfg), null, 2))
1963
+ await pause()
1964
+ }
1965
+
1966
+ async function menuDoctor() {
1967
+ printHero("系统诊断")
1968
+ await cmdDoctor({ _: ["doctor"] })
1969
+ await pause()
1970
+ }
1971
+
1972
+ async function menuLogout() {
1973
+ const answer = await question("退出登录并清理本地 key 缓存?[y/N]: ", "n")
1974
+ if (answer.toLowerCase() === "y" || answer.toLowerCase() === "yes") {
1975
+ await cmdLogout()
1976
+ }
1977
+ await pause()
1978
+ }
1979
+
1980
+ async function cmdMenu(args = {}) {
1981
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
1982
+ console.log(buildHelp())
1983
+ return
1984
+ }
1985
+
1986
+ for (;;) {
1987
+ const cfg = await readConfig()
1988
+ const snapshot = await dashboardSnapshot(cfg)
1989
+ printHero("Lai.vc 中转控制台")
1990
+ printDashboard(cfg, snapshot)
1991
+ console.log("")
1992
+ const action = await selectMenu(
1993
+ "选择任务",
1994
+ [
1995
+ { label: "开始对话", description: "流式测试单个模型", value: "chat" },
1996
+ { label: "模型雷达", description: "在线模型、价格、多源、延迟", value: "models" },
1997
+ { label: "网关压测", description: "首 token、总耗时、成功率", value: "bench" },
1998
+ { label: "系统诊断", description: "会话、密钥、目录、真实调用", value: "doctor" },
1999
+ { label: "密钥管理", description: "列出、创建、删除测试 key", value: "keys" },
2000
+ { label: "本地配置", description: "查看 ~/.laicode 状态", value: "config" },
2001
+ { label: "品牌预览", description: "终端字标和品牌色板", value: "brand" },
2002
+ { label: "重新登录", description: "浏览器设备授权", value: "login" },
2003
+ { label: "退出登录", description: "撤销会话并清理缓存", value: "logout" },
2004
+ { label: "退出", description: "", value: "exit" },
2005
+ ],
2006
+ { cancelValue: "exit", fancy: args.fancy, noHero: !args.fancy }
2007
+ )
2008
+
2009
+ if (!action || action === "exit") return
2010
+ if (action === "login") await menuLogin()
2011
+ else if (action === "models") await menuModels()
2012
+ else if (action === "chat") await menuChat()
2013
+ else if (action === "bench") await menuBench()
2014
+ else if (action === "doctor") await menuDoctor()
2015
+ else if (action === "keys") await menuKeys()
2016
+ else if (action === "config") await menuConfig()
2017
+ else if (action === "brand") {
2018
+ cmdBrand()
2019
+ await pause()
2020
+ }
2021
+ else if (action === "logout") await menuLogout()
2022
+ }
2023
+ }
2024
+
2025
+ async function main() {
2026
+ const args = parseArgs(process.argv.slice(2))
2027
+ if (args.color) process.env.LAICODE_COLOR = "1"
2028
+ if (args.plain) process.env.LAICODE_PLAIN = "1"
2029
+ const cmd = args._[0]
2030
+
2031
+ if (cmd === "--version" || cmd === "-v" || args.version || args.v) {
2032
+ console.log(VERSION)
2033
+ return
2034
+ }
2035
+ if (!cmd) {
2036
+ return await cmdMenu(args)
2037
+ }
2038
+ if (!cmd || cmd === "help" || args.help || args.h) {
2039
+ console.log(buildHelp())
2040
+ return
2041
+ }
2042
+
2043
+ try {
2044
+ if (cmd === "login") return await cmdLogin(args)
2045
+ if (cmd === "models") return await cmdModels(args)
2046
+ if (cmd === "keys") return await cmdKeys(args)
2047
+ if (cmd === "config") return await cmdConfig(args)
2048
+ if (cmd === "chat") return await cmdChat(args)
2049
+ if (cmd === "bench") return await cmdBench(args)
2050
+ if (cmd === "doctor") return await cmdDoctor(args)
2051
+ if (cmd === "logout") return await cmdLogout(args)
2052
+ if (cmd === "menu") return await cmdMenu(args)
2053
+ if (cmd === "brand") return cmdBrand(args)
2054
+ if (cmd === "status") return await cmdStatus(args)
2055
+ if (cmd === "completion") return cmdCompletion(args)
2056
+ if (cmd === "commands") return cmdCommands(args)
2057
+ die(`Unknown command: ${cmd}\nRun \`laicode --help\` for usage.`)
2058
+ } catch (err) {
2059
+ die(err.message || String(err))
2060
+ }
2061
+ }
41
2062
 
2063
+ main()