/** * 独立常驻入口:启动 OpenCode Go 配额 Web 面板。 * * 用法:node --experimental-strip-types src/serve.ts (或 npm run serve) * 启动后浏览器打开 http://127.0.0.1:8123 即可查看各 key 的滚动/周/月限额。 * * 复用 src/core/(零 pi 依赖),不依赖任何 pi 会话进程。 */ import { loadConfig, saveConfig, fetchUsage, forceUse, keyStateAt, WebUiServer, type WebUiController, type WebKeyView, } from "./core/index.ts"; async function main(): Promise { const port = Number(process.env.OCGO_WEB_PORT ?? 8123); const host = process.env.OCGO_WEB_HOST ?? "127.0.0.1"; // 组装 controller:读写同一份 key 池 + usage 缓存 const quotaCache: Record = {}; let activeIndex = 0; // 初始同步 active 下标 activeIndex = loadConfig().activeKeyIndex; 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 = 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 } : {}), })); out.push({ name: entry.name, index: i, keyPrefix: entry.key?.slice(0, 10), active: i === config.activeKeyIndex, coolingDown: st.coolingDown, quotaBlocked: st.quotaBlocked, windows, }); } return out; } async function refresh(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 ?? "查询失败" }; 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 controller: WebUiController = { listKeys: () => buildWebKeys(), refreshKey: (name) => refresh(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: "已清除所有冷却/封禁" }; }, getIntervalMs: () => currentRefreshMs, setIntervalMs: (ms) => { const n = Number(ms); if (!Number.isFinite(n) || n < 1000) return { ok: false, message: "间隔必须 ≥ 1000ms" }; scheduleAutoRefresh(n); return { ok: true, message: `刷新间隔已设为 ${Math.round(n / 1000)}s` }; }, }; const server = new WebUiServer({ controller, port, host, maxPortAttempts: 20 }); await server.start(); console.log(`[ocgo] 配额 Web 面板已常驻: http://${server.address}`); console.log(` key 池: ${loadConfig().keys.length} 个`); // 启动时自动刷新一遍所有 key,面板一打开就有数据 for (const k of loadConfig().keys) { try { await refresh(k.name); } catch { /* ignore */ } } console.log("[ocgo] 启动完成,保持运行即可。Ctrl+C 退出。"); // 定时自动刷新所有 key(默认每 30s),前端轮询 /api/status 才能拿到实时数据。 let refreshTimer: ReturnType | undefined; let currentRefreshMs = Number(process.env.OCGO_AUTO_REFRESH_MS ?? 30000); function scheduleAutoRefresh(ms: number): void { if (refreshTimer) clearInterval(refreshTimer); currentRefreshMs = ms; refreshTimer = setInterval(async () => { for (const k of loadConfig().keys) { try { await refresh(k.name); } catch { /* ignore 单 key 失败不影响整体 */ } } }, currentRefreshMs).unref?.(); } scheduleAutoRefresh(currentRefreshMs); // 保持进程存活 await new Promise(() => {}); } main().catch((err) => { console.error("[ocgo] 启动失败:", err); process.exit(1); });