/** * pi-opencodego —— pi 薄封装入口。 * * 只做「事件翻译 + 配置读取 + 命令注册」,所有业务逻辑都在 src/core/(零 pi 依赖,可被 dsh 复用)。 * * 三个能力: * A. developer 兼容过滤(before_provider_request 改写 developer → system) * B. 多 key 轮询 + 配额感知 + 会话粘合(before_provider_headers 注入 key;after_provider_response 探测失败) * C. 用量/费用跟踪(message_end 记录 usage) */ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; import { loadConfig, saveConfig, getConfigPath, pickKeyForSession, markFailure, forceUse, clearSessionAffinity, classifyRateLimitError, earliestUnblock, keyStateAt, applyDeveloperCompat, DEFAULT_COMPAT_RULE, fetchUsage, formatUsageWindow, hasRateLimitedWindow, keyWindowBars, footerSummary, ANSI, WebUiServer, readPortFile, probePort, DEFAULT_WEB_PORT, type WebKeyView, type WebUiController, isPeakHour, estimateCost, UsageStore, formatStats, type Config, type RateLimitKind, } from "../src/core/index.ts"; /** 本插件关注的 provider id 集合(用户通过任一路径接入 OpenCode Go/Zen)。 */ const TARGET_PROVIDERS = new Set(["opencode-go", "oc-sdk-go", "oc-sdk-zen", "opencode"]); interface RuntimeState { store: UsageStore; lastSessionId: string | undefined; /** 上一次响应是否已因 429/配额 轮换过(用于去重) */ rateLimitRotated: boolean; /** key 名 → 最近一次 usage 查询结果与时间(懒探测缓存) */ quotaCache: Record; /** 懒探测节流间隔 ms(默认 30s) */ probeIntervalMs: number; } export default function piOpencodeHelper(pi: ExtensionAPI): void { const state: RuntimeState = { store: new UsageStore(), lastSessionId: undefined, rateLimitRotated: false, quotaCache: {}, probeIntervalMs: 30_000, }; // --------------------------------------------------------------------------- // 自动注册 opencode-go provider(仅当未手动配置时兜底) // --------------------------------------------------------------------------- function modelsJsonHasOpencodeGo(): boolean { try { const p = join(homedir(), ".pi", "agent", "models.json"); if (!existsSync(p)) return false; const parsed = JSON.parse(readFileSync(p, "utf-8")); return !!(parsed?.providers?.["opencode-go"] ?? parsed?.providers?.["oc-sdk-go"]); } catch { return false; } } function ensureOpencodeProvider(): void { // 已有手动配置则跳过(避免覆盖用户配置) if (modelsJsonHasOpencodeGo()) return; try { pi.registerProvider("opencode-go", { name: "OpenCode Go", baseUrl: "https://opencode.ai/zen/go/v1", // key 始终由插件在 before_provider_headers 用 key 池注入,这里仅占位让 auth 校验通过 apiKey: "placeholder-injected-by-ocgo", authHeader: true, api: "openai-completions", models: [ { id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", api: "openai-completions", reasoning: true, input: ["text"], contextWindow: 1024000, maxTokens: 384000, cost: { input: 0.22, output: 0.66, cacheRead: 0.007, cacheWrite: 0 } }, { id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", api: "openai-completions", reasoning: true, input: ["text"], contextWindow: 1024000, maxTokens: 384000, cost: { input: 0.66, output: 1.98, cacheRead: 0.022, cacheWrite: 0 } }, { id: "glm-5.1", name: "GLM-5.1", api: "openai-completions", reasoning: true, input: ["text"], contextWindow: 200000, maxTokens: 128000, cost: { input: 1.4, output: 4.4, cacheRead: 0.26, cacheWrite: 0 } }, { id: "kimi-k2.6", name: "Kimi K2.6", api: "openai-completions", reasoning: true, input: ["text", "image"], contextWindow: 200000, maxTokens: 128000, cost: { input: 0.95, output: 4.0, cacheRead: 0.16, cacheWrite: 0 } }, { id: "qwen3.6-plus", name: "Qwen3.6 Plus", api: "openai-completions", reasoning: true, input: ["text"], contextWindow: 200000, maxTokens: 128000, cost: { input: 0.5, output: 3.0, cacheRead: 0.05, cacheWrite: 0 } }, ], }); } catch { /* 注册失败不致命 */ } } ensureOpencodeProvider(); // --------------------------------------------------------------------------- // 工具:拿 session id // --------------------------------------------------------------------------- function currentSessionId(ctx: { sessionManager: { getSessionId(): string } }): string | undefined { try { return ctx.sessionManager.getSessionId() || undefined; } catch { return undefined; } } // --------------------------------------------------------------------------- // 配额懒探测 + 面板渲染(能力 C 的监控呈现) // --------------------------------------------------------------------------- /** 懒探测:30s 节流,命中才真正查 usage 并更新缓存。 */ // eslint-disable-next-line @typescript-eslint/no-explicit-any async function maybeProbeUsage(keyName: string, key: string, ctx: any): Promise { const cached = state.quotaCache[keyName]; const nowTs = Date.now(); if (cached && nowTs - cached.lastChecked < state.probeIntervalMs) return; // 节流 // 看门狗:懒探测前确保 Web 面板活着(单例短路,不会重复起) await ensureWebServer(); const res = await fetchUsage(key); if (!res.ok || !res.usage) return; state.quotaCache[keyName] = { lastChecked: nowTs, windows: res.usage.windows.map((w) => ({ name: w.name, status: w.status, ...(w.percent !== undefined ? { percent: w.percent } : {}), ...(w.resetsAt !== undefined ? { resetsAt: w.resetsAt } : {}), })), }; renderQuotaPanel(ctx); } /** 渲染 widget 面板(每个 key 三行进度条,彩色) + footer 摘要。 */ // eslint-disable-next-line @typescript-eslint/no-explicit-any function renderQuotaPanel(ctx: any): void { let hasUI = false; try { hasUI = !!ctx?.hasUI; } catch { hasUI = false; } if (!hasUI) return; const config = loadConfig(); if (config.keys.length === 0 || Object.keys(state.quotaCache).length === 0) return; const nowTs = Date.now(); const blocks: string[][] = []; for (let i = 0; i < config.keys.length; i++) { const entry = config.keys[i]; if (!entry) continue; const cached = state.quotaCache[entry.name]; const windows = (cached?.windows ?? []).map((w) => ({ name: w.name, status: w.status as "ok" | "rate-limited" | "unknown", ...(w.percent !== undefined ? { percent: w.percent } : {}), ...(w.resetsAt !== undefined ? { resetsAt: w.resetsAt } : {}), })); blocks.push(keyWindowBars(entry.name, windows, { active: i === config.activeKeyIndex, colored: true, now: nowTs })); } // 拼成 widget 字符串行(每 key 4 行,key 之间空一行) const lines: string[] = []; blocks.forEach((b, idx) => { if (idx > 0) lines.push(ANSI.reset); lines.push(...b); }); // footer 摘要:活跃 key(构造字符串) let footerText: string | undefined; const activeEntry = config.keys[config.activeKeyIndex]; if (activeEntry && state.quotaCache[activeEntry.name]) { const windows = state.quotaCache[activeEntry.name].windows.map((w) => ({ name: w.name, status: w.status as "ok" | "rate-limited" | "unknown", ...(w.percent !== undefined ? { percent: w.percent } : {}), ...(w.resetsAt !== undefined ? { resetsAt: w.resetsAt } : {}), })); footerText = footerSummary(activeEntry.name, windows, { colored: false, now: nowTs }); } // 安全调用 UI(headless/print 模式下 ui getter 可能抛) try { ctx.ui?.setWidget?.("ocgo-quota", lines, { placement: "aboveEditor" }); ctx.ui?.setStatus?.("ocgo-quota", footerText); } catch { /* UI 不可用则静默忽略 */ } } // --------------------------------------------------------------------------- // 能力 A:developer 兼容过滤 // --------------------------------------------------------------------------- pi.on("before_provider_request", (event, ctx) => { const model = ctx.model; if (model && TARGET_PROVIDERS.has(model.provider)) { const payload = event.payload as Record | undefined; if (payload) { const rewritten = applyDeveloperCompat(payload, DEFAULT_COMPAT_RULE); if (rewritten) return rewritten as unknown; } } return undefined; // 未改动,透传 }); // --------------------------------------------------------------------------- // 能力 B:key 注入(会话粘合 + 轮换) // --------------------------------------------------------------------------- pi.on("before_provider_headers", (event, ctx) => { const model = ctx.model; if (!model || !TARGET_PROVIDERS.has(model.provider)) return; const config = loadConfig(); if (config.keys.length === 0) return; const sessionId = currentSessionId(ctx); const now = Date.now(); const pick = pickKeyForSession(config, sessionId, now); if (pick.index < 0) return; const entry = config.keys[pick.index]; if (!entry) return; event.headers["Authorization"] = `Bearer ${entry.key}`; // 持久化(粘合 / 轮换可能改了 config) saveConfig(config); // 便于调试:记录当前生效 key 名(不含明文) // (不 print key 明文) }); // 能力 B:失败探测 → 触发轮换 pi.on("after_provider_response", (event, ctx) => { const model = ctx.model; if (!model || !TARGET_PROVIDERS.has(model.provider)) return; if (event.status === 429) { handleRateLimit(ctx, "transient"); } else if (event.status >= 500 && event.status < 600) { // 5xx 不轮换(可能是瞬时上游错误,交给 pi 重试) } }); function handleRateLimit(ctx: { model?: { provider?: string }; sessionManager: { getSessionId(): string } }, kind: RateLimitKind): void { const config = loadConfig(); if (config.keys.length === 0 || state.rateLimitRotated) return; const sessionId = currentSessionId(ctx); const bound = sessionId !== undefined ? config.sessionAffinity[sessionId] : undefined; if (bound === undefined) return; markFailure(config, bound, kind, Date.now()); // 轮换(下一个非封禁/冷却 key) const now = Date.now(); const next = pickKeyForSession(config, sessionId, now); if (next.changed && next.index >= 0) { state.rateLimitRotated = true; } saveConfig(config); } // 消息结束:清除 rate-limit 去重标记(下一次请求可再次轮换) pi.on("agent_settled", () => { state.rateLimitRotated = false; }); // --------------------------------------------------------------------------- // 能力 C:用量记录 // --------------------------------------------------------------------------- pi.on("message_end", (event, ctx) => { const m = event.message as { provider?: string; model?: string; usage?: unknown; timestamp?: number } | undefined; if (!m) return; const provider = m.provider; if (!provider || !TARGET_PROVIDERS.has(provider)) return; const usage = m.usage as | { input?: number; output?: number; cacheRead?: number; cacheWrite?: number; totalTokens?: number } | undefined; if (!usage) return; const sessionId = currentSessionId(ctx); const peak = isPeakHour(); const cost = estimateCost(m.model, { prompt: usage.input, completion: usage.output, cached: usage.cacheRead, cacheWrite: usage.cacheWrite, }, peak); state.store.append({ ts: m.timestamp ?? Date.now(), sessionId, model: m.model, prompt: usage.input ?? 0, completion: usage.output ?? 0, cached: usage.cacheRead ?? 0, cacheWrite: usage.cacheWrite ?? 0, peak, cost, }); // 懒探测:成功响应后查当前会话所粘合 key 的配额(30s 节流) const config = loadConfig(); if (config.keys.length > 0 && sessionId !== undefined) { const bound = config.sessionAffinity[sessionId]; const entry = bound !== undefined ? config.keys[bound] : undefined; if (entry) { void maybeProbeUsage(entry.name, entry.key, ctx); } } }); // --------------------------------------------------------------------------- // 会话生命周期:粘合清理 // --------------------------------------------------------------------------- pi.on("session_shutdown", (_event, ctx) => { const sid = currentSessionId(ctx); if (sid) { const config = loadConfig(); clearSessionAffinity(config, sid); saveConfig(config); } }); // --------------------------------------------------------------------------- // Web 配额面板(浏览器打开,独立端口) // --------------------------------------------------------------------------- function buildWebKeys(): WebKeyView[] { const config = loadConfig(); const now = Date.now(); const out: WebKeyView[] = []; for (let i = 0; i < config.keys.length; i++) { const entry = config.keys[i]; if (!entry) continue; const st = keyStateAt(config, i, now); const cached = state.quotaCache[entry.name]; const windows: { name: string; status: "ok" | "rate-limited" | "unknown"; percent?: number; resetsAt?: string }[] = (cached?.windows ?? []).map((w) => ({ name: w.name, status: w.status as "ok" | "rate-limited" | "unknown", ...(w.percent !== undefined ? { percent: w.percent } : {}), ...(w.resetsAt !== undefined ? { resetsAt: w.resetsAt } : {}), })); out.push({ name: entry.name, index: i, active: i === config.activeKeyIndex, coolingDown: st.coolingDown, quotaBlocked: st.quotaBlocked, windows, }); } return out; } async function refreshKeyForWeb(name: string): Promise<{ ok: boolean; message: string }> { const config = loadConfig(); const entry = config.keys.find((k) => k.name === name); if (!entry) return { ok: false, message: `key "${name}" 不存在` }; const res = await fetchUsage(entry.key); if (!res.ok || !res.usage) return { ok: false, message: res.message ?? "查询失败" }; state.quotaCache[name] = { lastChecked: Date.now(), windows: res.usage.windows.map((w) => ({ name: w.name, status: w.status, ...(w.percent !== undefined ? { percent: w.percent } : {}), ...(w.resetsAt !== undefined ? { resetsAt: w.resetsAt } : {}), })), }; return { ok: true, message: "已刷新" }; } const webController: WebUiController = { listKeys: () => buildWebKeys(), refreshKey: (name) => refreshKeyForWeb(name), addKey: (name, keyValue) => { const config = loadConfig(); if (!name || !keyValue) return { ok: false, message: "name/key 不能为空" }; if (config.keys.some((k) => k.name === name)) return { ok: false, message: `已存在 ${name}` }; config.keys.push({ name, key: keyValue }); if (config.keys.length === 1) config.activeKeyIndex = 0; saveConfig(config); return { ok: true, message: `已添加 ${name}` }; }, removeKey: (index) => { const config = loadConfig(); const n = index; if (!Number.isInteger(n) || n < 1 || n > config.keys.length) return { ok: false, message: "序号无效" }; const removed = config.keys.splice(n - 1, 1)[0]; delete config.cooldowns[n - 1]; delete config.quotaBlockedUntil[n - 1]; const shift = (rec: Record): Record => { const out: Record = {}; for (const [k, v] of Object.entries(rec)) { const ik = Number(k); out[ik > n - 1 ? ik - 1 : ik] = v; } return out; }; config.cooldowns = shift(config.cooldowns); config.quotaBlockedUntil = shift(config.quotaBlockedUntil); for (const [k, v] of Object.entries(config.sessionAffinity)) { if (v > n - 1) config.sessionAffinity[k] = v - 1; else if (v === n - 1) delete config.sessionAffinity[k]; } if (config.activeKeyIndex >= config.keys.length) config.activeKeyIndex = Math.max(0, config.keys.length - 1); saveConfig(config); return { ok: true, message: `已删除 ${removed?.name ?? n}` }; }, useKey: (index) => { const config = loadConfig(); if (forceUse(config, index - 1)) { saveConfig(config); return { ok: true, message: `已切换到 #${index}` }; } return { ok: false, message: "切换失败" }; }, nextKey: () => { const config = loadConfig(); if (config.keys.length === 0) return { ok: false, message: "无 key" }; const next = (config.activeKeyIndex + 1) % config.keys.length; if (forceUse(config, next)) { saveConfig(config); return { ok: true, message: `已切到 #${next + 1}` }; } return { ok: false, message: "切换失败" }; }, reset: () => { const config = loadConfig(); config.cooldowns = {}; config.quotaBlockedUntil = {}; saveConfig(config); return { ok: true, message: "已清除所有冷却/封禁" }; }, }; const webServer = new WebUiServer({ controller: webController, port: 8123, host: "127.0.0.1" }); let webServerStarted = false; /** 读注册文件 + 探测,找出「当前实际在跑的面板端口」;没有则返回 null。 */ async function findActiveWebPort(): Promise { const port = readPortFile(); if (await probePort(port)) return port; return null; } /** 单例 + 看门狗:探测真实端口(读注册文件,不写死 8123),有活面板则复用,否则本进程拉起。 */ async function ensureWebServer(): Promise { if (webServerStarted) return; // 已有别的进程在跑面板(无论端口漂移到哪,都能从注册文件找到)→ 复用 if ((await findActiveWebPort()) !== null) { webServerStarted = true; return; } try { await webServer.start(); // start() 成功会把实际端口写入注册文件 webServerStarted = true; console.log(`[ocgo] 配额 Web 面板: http://${webServer.address ?? "127.0.0.1:" + DEFAULT_WEB_PORT}`); // 启动时自动刷新一遍已有 key 的 usage for (const k of loadConfig().keys) { try { await refreshKeyForWeb(k.name); } catch { /* ignore */ } } } catch (err) { console.error("[ocgo] Web 面板启动失败:", err); } } // 进程启动时做一次单例检测(随 pi 会话静默尝试,不阻塞) // 测试 / 独立 serve.ts 场景可通过 OCGO_NO_WEB=1 禁用本进程自起面板,避免多实例 if (process.env.OCGO_NO_WEB !== "1") { void ensureWebServer(); } // --------------------------------------------------------------------------- // 命令:/ocgo ... // --------------------------------------------------------------------------- const fmtTime = (ms: number): string => new Date(ms).toLocaleString("zh-CN", { hour12: false }); pi.registerCommand("ocgo", { description: "OpenCode Go 管理:status/usage/use/next/add/rm/reset/cooldown/watchdog/cost", handler: async (args, ctx) => { const [cmd = "", ...rest] = args.trim().split(/\s+/); const ui = ctx.ui; switch (cmd) { case "": { showHelp(ui); break; } case "help": { showHelp(ui); break; } case "status": { const config = loadConfig(); const now = Date.now(); if (config.keys.length === 0) { ui.notify("没有配置任何 key,用 /ocgo add 添加。", "info"); break; } const lines = config.keys.map((k, i) => { const st = keyStateAt(config, i, now); const active = i === config.activeKeyIndex ? " [active]" : ""; const mark = st.quotaBlocked ? ` [quota-blocked until ${fmtTime(config.quotaBlockedUntil[i]!)}]` : st.coolingDown ? ` [cooldown]` : " [ok]"; return `${i + 1}. ${k.name}${active}${mark}`; }); ui.notify(`OpenCode Go keys (${config.keys.length}):\n${lines.join("\n")}`, "info"); break; } case "usage": case "quota": { const config = loadConfig(); if (config.keys.length === 0) { ui.notify("没有配置 key。", "warning"); break; } const idx = rest[0] !== undefined ? Number(rest[0]) - 1 : config.activeKeyIndex; const entry = config.keys[idx]; if (!entry) { ui.notify(`key #${idx + 1} 不存在。`, "warning"); break; } ui.notify(`查询 ${entry.name} 的用量...`, "info"); const res = await fetchUsage(entry.key); if (!res.ok || !res.usage) { ui.notify(`查询失败:${res.message ?? "未知错误"}`, "error"); break; } const windowsAt = res.usage.windows.map(formatUsageWindow).join("\n"); const limited = hasRateLimitedWindow(res.usage) ? "\n⚠️ 存在被限流的窗口(配额可能耗尽)" : ""; ui.notify(`OpenCode Go usage (${entry.name}):\n${windowsAt}${limited}`, "info"); // 更新懒探测缓存并刷新面板 state.quotaCache[entry.name] = { lastChecked: Date.now(), windows: res.usage.windows.map((w) => ({ name: w.name, status: w.status, ...(w.percent !== undefined ? { percent: w.percent } : {}), ...(w.resetsAt !== undefined ? { resetsAt: w.resetsAt } : {}), })), }; renderQuotaPanel(ctx); break; } case "use": { const config = loadConfig(); const n = Number(rest[0]); if (!Number.isInteger(n) || n < 1 || n > config.keys.length) { ui.notify("用法:/ocgo use ", "warning"); break; } if (forceUse(config, n - 1)) { saveConfig(config); ui.notify(`已切换到 key #${n}(清除了其冷却/封禁)。`, "info"); } else { ui.notify("切换失败。", "error"); } break; } case "next": { const config = loadConfig(); if (config.keys.length === 0) { ui.notify("没有配置 key。", "warning"); break; } const next = (config.activeKeyIndex + 1) % config.keys.length; if (forceUse(config, next)) { saveConfig(config); ui.notify(`已切换到下一个 key #${next + 1}。`, "info"); } break; } case "add": { const name = rest[0]; const key = rest[1]; if (!name || !key) { ui.notify("用法:/ocgo add ", "warning"); break; } const config = loadConfig(); if (config.keys.some((k) => k.name === name)) { ui.notify(`已存在名为 "${name}" 的 key。`, "warning"); break; } config.keys.push({ name, key }); // 若这是第一个 key,设为 active if (config.keys.length === 1) config.activeKeyIndex = 0; saveConfig(config); ui.notify(`已添加 key "${name}"(共 ${config.keys.length} 个)。`, "info"); break; } case "rm": { const config = loadConfig(); const n = Number(rest[0]); if (!Number.isInteger(n) || n < 1 || n > config.keys.length) { ui.notify("用法:/ocgo rm ", "warning"); break; } const removed = config.keys.splice(n - 1, 1)[0]; delete config.cooldowns[n - 1]; delete config.quotaBlockedUntil[n - 1]; // 收紧后面 key 的下标 const shift = (rec: Record): Record => { const out: Record = {}; for (const [k, v] of Object.entries(rec)) { const ik = Number(k); out[ik > n - 1 ? ik - 1 : ik] = v; } return out; }; config.cooldowns = shift(config.cooldowns); config.quotaBlockedUntil = shift(config.quotaBlockedUntil); for (const [k, v] of Object.entries(config.sessionAffinity)) { if (v > n - 1) config.sessionAffinity[k] = v - 1; else if (v === n - 1) delete config.sessionAffinity[k]; } if (config.activeKeyIndex >= config.keys.length) config.activeKeyIndex = Math.max(0, config.keys.length - 1); saveConfig(config); ui.notify(`已删除 key "${removed?.name ?? n}"(剩 ${config.keys.length} 个)。`, "info"); break; } case "reset": { const config = loadConfig(); config.cooldowns = {}; config.quotaBlockedUntil = {}; saveConfig(config); ui.notify("已清除所有冷却与配额封禁。", "info"); break; } case "cooldown": { const config = loadConfig(); if (rest[0] === undefined) { ui.notify(`当前冷却时长:${config.cooldownMinutes} 分钟。用法:/ocgo cooldown `); break; } const min = Number(rest[0]); if (!Number.isFinite(min) || min < 0) { ui.notify("冷却分钟数无效。", "warning"); break; } config.cooldownMinutes = min; saveConfig(config); ui.notify(`冷却时长已设为 ${min} 分钟。`, "info"); break; } case "watchdog": { const config = loadConfig(); const arg = rest[0]; if (arg === undefined || arg === "status") { ui.notify(`watchdog: ${config.watchdogEnabled ? "on" : "off"}, idle ${config.watchdogIdleMs}ms`); break; } if (arg === "on") { config.watchdogEnabled = true; saveConfig(config); ui.notify("watchdog 已开启。", "info"); } else if (arg === "off") { config.watchdogEnabled = false; saveConfig(config); ui.notify("watchdog 已关闭。", "info"); } else if (arg === "enable") { config.watchdogEnabled = true; saveConfig(config); ui.notify("watchdog 已开启。", "info"); } else { const ms = Number(arg); if (Number.isFinite(ms) && ms > 0) { config.watchdogIdleMs = ms; saveConfig(config); ui.notify(`watchdog idle 已设为 ${ms}ms。`, "info"); } else { ui.notify("用法:/ocgo watchdog [status|on|off|]", "warning"); } } break; } case "cost": case "stats": { const sinceArg = rest.find((r) => r.startsWith("--since=")); let since: number | undefined; if (sinceArg) { const val = sinceArg.split("=")[1]; const hours = Number(val); if (Number.isFinite(hours)) since = Date.now() - hours * 3600_000; } if (since === undefined) { // 默认今日 const now = new Date(); const start = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); since = start; } const stats = state.store.summarize({ since }); const s = formatStats(stats, "今日用量"); ui.notify(`${s}\n\n累计用量(全部):\n${formatStats(state.store.summarize({}), "")}`, "info"); break; } case "web": { const sub = rest[0] ?? "status"; const port = await findActiveWebPort(); const url = port ? `http://127.0.0.1:${port}` : null; if (sub === "start") { webServerStarted = false; await ensureWebServer(); const p2 = await findActiveWebPort(); ui.notify(p2 ? `Web 面板已启动: http://127.0.0.1:${p2}` : "Web 面板启动失败", p2 ? "info" : "error"); } else if (sub === "stop") { webServerStarted = false; try { await webServer.stop(); } catch { /* ignore */ } ui.notify("Web 面板已停止", "info"); } else if (sub === "restart") { webServerStarted = false; try { await webServer.stop(); } catch { /* ignore */ } await ensureWebServer(); const p3 = await findActiveWebPort(); ui.notify(p3 ? `Web 面板已重启: http://127.0.0.1:${p3}` : "Web 面板重启失败", p3 ? "info" : "error"); } else { ui.notify( url ? `Web 面板运行中: ${url}\n用法: /ocgo web start | stop | restart | status` : `Web 面板未运行(默认随 pi 启动自起)\n用法: /ocgo web start | stop | restart | status`, "info", ); } break; } default: { ui.notify(`未知子命令:${cmd}\n支持:status | usage | use | next | add | rm | reset | cooldown | watchdog | cost | web`, "warning"); } } }, }); } function showHelp(ui: { notify(msg: string, level?: string): void }): void { ui.notify( [ "/ocgo status 查看所有 key 及状态", "/ocgo usage [n] 查询 key #n(默认活跃)的限额用量", "/ocgo use 切换到 key #n 并清除其限制", "/ocgo next 切到下一个 key", "/ocgo add 添加 key", "/ocgo rm 删除 key #n", "/ocgo reset 清除所有冷却/封禁", "/ocgo cooldown 设置冷却分钟数", "/ocgo watchdog [on|off|ms] watchdog 设置", "/ocgo cost [--since=h] 今日(或近 h 小时)用量与费用统计", "/ocgo web [status|start|stop|restart] Web 配额面板控制", "/ocgo help 显示本帮助", ].join("\n"), "info", ); }