/** * comate-quota — 分钟级显示 Comate 月度额度 * * 配置目录:/.comate-quota/ * - URL 服务地址,首次使用时提示输入 * - COOKIE 浏览器登录后的完整 Cookie(权限 600),首次或过期时提示输入 */ import { getAgentDir, type ExtensionAPI, type ExtensionContext, type ThemeColor, } from "@earendil-works/pi-coding-agent"; import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; import { join } from "node:path"; // 包自身数据目录约定为 /.comate-quota/(隐藏目录,与 npm 安装的 // 包代码目录 /npm/node_modules/@muzi3/comate-quota/ 解耦)。 // 这里用 getAgentDir() 而非硬编码 ~/.pi/agent,以兼容自定义 // PI_CODING_AGENT_DIR 与改名的发行版。 const CONFIG_DIR = join(getAgentDir(), ".comate-quota"); const COOKIE_FILE = join(CONFIG_DIR, "COOKIE"); const URL_FILE = join(CONFIG_DIR, "URL"); const QUOTA_PATH = "/api/mine/quota_info"; const INTERVAL_MS = 5 * 60 * 1000; const STATUS_KEY = "comate-quota"; /** 进度条总格数,与 pi-ui footer 的上下文窗口进度条对齐,两行等长。 */ const BAR_CELLS = 14; const BAR_FILLED = "█"; const BAR_EMPTY = "░"; /** * 标签列占 8 列(含与进度条的分隔空格),与 `@muzi3/pi-ui` footer 第 2 行的 `Context ` 对齐, * 页脚两条进度条左对齐;标签色也统一为 `muted`。改这里要同步改 pi-ui 的 LABEL_COLUMN。 */ const LABEL = "Comate "; type Snapshot = { used?: number; quota?: number; remain?: number; username?: string; error?: string; }; function loadBaseUrl(): string | null { try { const v = readFileSync(URL_FILE, "utf8").trim().replace(/\/+$/, ""); return v || null; } catch { return null; } } function saveBaseUrl(url: string): void { mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 }); writeFileSync(URL_FILE, url, { mode: 0o600 }); } function saveCookie(cookie: string): void { mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 }); writeFileSync(COOKIE_FILE, cookie, { mode: 0o600 }); } function isValidHttpUrl(s: string): boolean { try { const u = new URL(s); return u.protocol === "http:" || u.protocol === "https:"; } catch { return false; } } async function fetchQuota(baseUrl: string): Promise { let cookie = ""; try { cookie = readFileSync(COOKIE_FILE, "utf8").trim(); } catch {} if (!cookie) return { error: `cookie 不存在,请登录 ${baseUrl} 获取` }; const ctl = new AbortController(); const t = setTimeout(() => ctl.abort(), 10_000); try { const res = await fetch(baseUrl + QUOTA_PATH, { headers: { Cookie: cookie, Accept: "application/json" }, signal: ctl.signal, }); if (!res.ok) return { error: res.status === 401 || res.status === 403 ? `cookie 已过期,重新登录 ${baseUrl}` : `HTTP ${res.status}` }; // cookie 过期时 SSO 网关会返回 200 + HTML 登录页,此时 res.json() 会抛 // "Unexpected token '<'"。解析前先按 Content-Type 拦截,给出明确提示。 const contentType = res.headers.get("content-type") ?? ""; if (!contentType.includes("application/json")) return { error: `cookie 已过期,重新登录 ${baseUrl}` }; const { data = {} } = (await res.json()) as { data?: Record }; const num = (v: unknown) => (typeof v === "number" ? v : undefined); return { used: num(data.monthly_used_quota), quota: num(data.monthly_quota_limit), remain: num(data.monthly_remaining_quota), username: typeof data.username === "string" ? data.username : undefined, }; } catch (e) { return { error: e instanceof Error ? e.message : String(e) }; } finally { clearTimeout(t); } } /** * 额度占用率的语义色。阈值与 pi-ui 的上下文窗口进度条一致(>70% 警告,>90% 错误), * 页脚两行进度条读法相同,不用记两套。 */ function quotaColor(pct: number): ThemeColor { if (pct > 90) return "error"; if (pct > 70) return "warning"; return "success"; } /** * 固定宽度进度条。非零占用至少填一格,否则 0.3% 这类低占用渲染成全空条, * 看起来像数据缺失而不是“还没用”。 */ function renderBar(pct: number): string { const ratio = Math.min(1, Math.max(0, pct / 100)); const filled = ratio > 0 ? Math.max(1, Math.round(ratio * BAR_CELLS)) : 0; return BAR_FILLED.repeat(filled) + BAR_EMPTY.repeat(BAR_CELLS - filled); } function renderStatus(ctx: ExtensionContext, s: Snapshot): string { const { theme } = ctx.ui; if (s.error) return theme.fg("error", `⚠ Comate: ${s.error}`); // 未拿到数据时也占进度条的位,页脚不会因刷新而抽动;标签色与非占位态一致,只把内容压暗。 if (s.used === undefined || s.quota === undefined) return theme.fg("muted", LABEL) + theme.fg("dim", `${renderBar(0)} -`); const pct = s.quota > 0 ? (s.used / s.quota) * 100 : 0; const color = quotaColor(pct); // 占比跟条同色(这一行的重点),额度总量降到 dim,与 pi-ui 的 `/ 窗口` 同一套读法。 return ( theme.fg("muted", LABEL) + theme.fg(color, renderBar(pct)) + theme.fg(color, ` ${pct.toFixed(0)}%`) + theme.fg("dim", ` / ¥${s.quota.toFixed(2)}`) ); } export default function comateQuota(pi: ExtensionAPI) { let timer: ReturnType | null = null; let latest: Snapshot = {}; async function ensureBaseUrl(ctx: ExtensionContext): Promise { const cached = loadBaseUrl(); if (cached) return cached; if (!ctx.hasUI) return null; const input = await ctx.ui.input( "请输入 Comate 服务地址:", "", ); const url = input?.trim().replace(/\/+$/, ""); if (!url) return null; if (!isValidHttpUrl(url)) { ctx.ui.notify(`无效的 URL:${url}`, "error"); return null; } saveBaseUrl(url); ctx.ui.notify("服务地址已保存", "info"); return url; } async function promptAndSaveCookie(ctx: ExtensionContext, baseUrl: string, reason: string): Promise { if (!ctx.hasUI) return false; const newCookie = await ctx.ui.input(`${reason},请登录 ${baseUrl} 获取 Cookie 并粘贴:`, ""); if (!newCookie?.trim()) return false; saveCookie(newCookie.trim()); ctx.ui.notify("Cookie 已更新", "info"); return true; } async function refresh(ctx: ExtensionContext) { const baseUrl = await ensureBaseUrl(ctx); if (!baseUrl) { latest = { error: `未配置服务地址,请写入 ${URL_FILE}` }; ctx.ui.setStatus(STATUS_KEY, renderStatus(ctx, latest)); return; } latest = await fetchQuota(baseUrl); // cookie 不存在或过期时自动弹出输入框 if (latest.error?.includes("不存在") || latest.error?.includes("过期")) { const updated = await promptAndSaveCookie(ctx, baseUrl, latest.error); if (updated) { latest = await fetchQuota(baseUrl); } } ctx.ui.setStatus(STATUS_KEY, renderStatus(ctx, latest)); } pi.on("session_start", async (_e, ctx) => { if (!ctx.hasUI) return; await refresh(ctx); timer = setInterval(() => refresh(ctx).catch(() => {}), INTERVAL_MS); }); pi.on("session_shutdown", (_e, ctx) => { if (timer) clearInterval(timer); timer = null; ctx.ui.setStatus(STATUS_KEY, undefined); }); pi.registerCommand("comate-quota", { description: "立即刷新并显示 Comate 月度额度", handler: async (_args, ctx) => { await refresh(ctx); const s = latest; if (s.error) return ctx.ui.notify(`Comate: ${s.error}`, "error"); const who = s.username ? `@${s.username} ` : ""; ctx.ui.notify( `${who}月度额度 ¥${s.quota?.toFixed(2)} | 已用 ¥${s.used?.toFixed(2)} | 剩余 ¥${s.remain?.toFixed(2)}`, "info", ); }, }); }