/** * OpenCode Go 配额 Web 面板 —— 零 pi 依赖的独立 HTTP server。 * * 提供浏览器可访问的管理界面:查看每个 key 的三档限额(rolling/weekly/monthly)、 * 冷却/封禁状态、以及 key 的增删切换。通过注入的 `Controller` 读取数据与执行操作, * 不直接依赖 pi / dsh 的任何类型(宿主薄封装注入即可复用)。 */ import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; import type { AddressInfo } from "node:net"; import { writePortFile, DEFAULT_WEB_PORT } from "./portfile.ts"; import { isPeakHour } from "./pricing.ts"; /** 宿主导入的控制器:提供数据读取与操作执行的纯函数回调。 */ export interface WebUiController { /** 列出所有 key 及其状态(含配额窗口、active、cooldown/quota 状态)。 */ listKeys(): WebKeyView[]; /** 强制刷新某个 key 的 usage。返回是否成功。 */ refreshKey(name: string): Promise<{ ok: boolean; message: string }>; /** 添加 key。 */ addKey(name: string, keyValue: string): { ok: boolean; message: string }; /** 删除 key。 */ removeKey(index: number): { ok: boolean; message: string }; /** 切到指定 key(按 1-based 序号)。 */ useKey(index: number): { ok: boolean; message: string }; /** 切到下一个 key。 */ nextKey(): { ok: boolean; message: string }; /** 读取当前自动刷新间隔(毫秒)。 */ getIntervalMs?(): number; /** 动态调整自动刷新间隔(毫秒)。 */ setIntervalMs?(ms: number): { ok: boolean; message: string }; /** 清除所有冷却/封禁。 */ reset(): { ok: boolean; message: string }; } export interface WebWindow { name: string; status: "ok" | "rate-limited" | "unknown"; percent?: number; resetsAt?: string; } export interface WebKeyView { name: string; index: number; /** key 前缀(如 sk-xxxx,仅前若干位,不暴露完整 key)。 */ keyPrefix?: string; active: boolean; coolingDown: boolean; quotaBlocked: boolean; windows: WebWindow[]; } export interface WebUiOptions { host?: string; /** 起始端口;若被占用会自动递增重试(最多尝试 maxPortAttempts 次)。 */ port?: number; controller: WebUiController; /** 端口递增重试次数上限(默认 20)。 */ maxPortAttempts?: number; } export class WebUiServer { private server: ReturnType | undefined; private controller: WebUiController; private basePort: number; private maxPortAttempts: number; private host: string; private actualPort: number | undefined; constructor(opts: WebUiOptions) { this.controller = opts.controller; this.basePort = opts.port ?? 8123; this.maxPortAttempts = opts.maxPortAttempts ?? 20; this.host = opts.host ?? "127.0.0.1"; } get address(): string | undefined { if (this.actualPort === undefined) return undefined; return `${this.host}:${this.actualPort}`; } async start(): Promise { if (this.server) return; // 从 basePort 开始尝试,端口被占用则 +1 重试 for (let attempt = 0; attempt < this.maxPortAttempts; attempt++) { const candidate = this.basePort + attempt; const created = createServer((req, res) => void this.handle(req, res)); const bound = await new Promise((resolve) => { created.once("error", () => resolve(false)); created.listen(candidate, this.host, () => { created.removeListener("error", () => {}); resolve(true); }); }); if (bound) { this.server = created; this.actualPort = candidate; // 端口漂移时把实际端口写入注册文件,供看门狗/脚本定位 writePortFile(candidate); return; } // 端口占用,close 后试下一个 created.close(); } throw new Error(`Web UI 无法绑定端口(${this.basePort}~${this.basePort + this.maxPortAttempts - 1} 都被占用)`); } stop(): Promise { if (!this.server) return Promise.resolve(); const server = this.server; this.server = undefined; return new Promise((resolve) => { // closeAllConnections 强制关闭 keep-alive 连接,否则 close 回调不触发(测试/重启会挂起) (server as unknown as { closeAllConnections?: () => void }).closeAllConnections?.(); server.close(() => resolve()); }); } private async handle(req: IncomingMessage, res: ServerResponse): Promise { const url = (req.url ?? "/").split("?")[0]; try { if (req.method === "GET" && (url === "/" || url === "/index.html")) { return this.sendHtml(res, pageHtml()); } if (req.method === "GET" && url === "/api/status") { return this.sendJson(res, { ok: true, keys: this.controller.listKeys(), peak: peakInfo(), refreshIntervalMs: this.controller.getIntervalMs?.(), }); } if (req.method === "POST" && url === "/api/refresh") { const body = await readBody(req); const name = body?.name ?? ""; const r = await this.controller.refreshKey(String(name)); return this.sendJson(res, r); } if (req.method === "POST" && url === "/api/add") { const body = await readBody(req); const r = this.controller.addKey(String(body?.name ?? ""), String(body?.key ?? "")); return this.sendJson(res, r); } if (req.method === "POST" && url === "/api/remove") { const body = await readBody(req); const r = this.controller.removeKey(Number(body?.index)); return this.sendJson(res, r); } if (req.method === "POST" && url === "/api/use") { const body = await readBody(req); const r = this.controller.useKey(Number(body?.index)); return this.sendJson(res, r); } if (req.method === "POST" && url === "/api/next") { return this.sendJson(res, this.controller.nextKey()); } if (req.method === "POST" && url === "/api/reset") { return this.sendJson(res, this.controller.reset()); } if (req.method === "POST" && url === "/api/interval") { const body = await readBody(req); const ms = Number(body?.ms); const r = this.controller.setIntervalMs?.(ms); return this.sendJson(res, r ?? { ok: false, message: "不支持" }); } return this.sendJson(res, { ok: false, message: "Not found" }, 404); } catch (err) { return this.sendJson(res, { ok: false, message: String(err) }, 500); } } private sendHtml(res: ServerResponse, html: string): void { res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); res.end(html); } private sendJson(res: ServerResponse, data: unknown, code = 200): void { res.writeHead(code, { "Content-Type": "application/json; charset=utf-8" }); res.end(JSON.stringify(data)); } } function readBody(req: IncomingMessage): Promise | undefined> { return new Promise((resolve) => { let data = ""; req.on("data", (c) => { data += c; if (data.length > 1e6) req.destroy(); }); req.on("end", () => { try { resolve(data ? JSON.parse(data) : undefined); } catch { resolve(undefined); } }); req.on("error", () => resolve(undefined)); }); } /** DeepSeek 峰谷时段信息。返回当前是否忙时 + 北京时段说明(北京=UTC+8)。 */ export function peakInfo(now = new Date()): { active: boolean; label: string; peekWindows: string[]; } { const active = isPeakHour(now); // DeepSeek V4 Peak(UTC): 01-04 与 06-10 → 北京 +8: 09-12 与 14-18 const peekWindows = ["09:00-12:00", "14:00-18:00"]; return { active, label: active ? "忙时 (Peak)" : "闲时 (Off-Peak)", peekWindows, }; } /** 生成面板 HTML(含内联 CSS/JS,浏览器端渲染进度条)。 */ function pageHtml(): string { const __dir = dirname(fileURLToPath(import.meta.url)); const htmlPath = join(__dir, "assets", "panel.html"); try { return readFileSync(htmlPath, "utf-8"); } catch { return "

panel.html 缺失

路径: " + htmlPath + "

"; } }