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