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