@raingor/pi-web-switch 0.8.0 → 0.8.3
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/dist/index.html +2 -2
- package/package.json +1 -1
- package/server/pi-reader.ts +114 -6
- package/src/components/dashboard/DashboardPage.tsx +69 -0
- package/src/components/providers/ProvidersModelsPage.tsx +266 -108
- package/src/components/settings/SettingsPage.tsx +23 -33
- package/src/components/speedtest/ModelSpeedTestPage.tsx +138 -4
- package/src/components/subagents/SubagentsPage.tsx +30 -21
- package/src/data/changelog.ts +15 -0
- package/src/index.css +1 -2
- package/src/lib/translations/en.ts +34 -8
- package/src/lib/translations/ja.ts +34 -8
- package/src/lib/translations/zh-CN.ts +34 -8
- package/src/lib/translations/zh-TW.ts +34 -8
- package/src/store/config-store.ts +3 -1
- package/src/types/index.ts +14 -0
- package/vite.config.ts +7 -0
package/dist/index.html
CHANGED
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
<link rel="manifest" href="./manifest.webmanifest" />
|
|
10
10
|
<meta name="theme-color" content="#05090d" />
|
|
11
11
|
<meta name="description" content="Web UI for pi coding agent — configuration management, session browser, and usage dashboard" />
|
|
12
|
-
<script type="module" crossorigin src="./assets/main-
|
|
13
|
-
<link rel="stylesheet" crossorigin href="./assets/main-
|
|
12
|
+
<script type="module" crossorigin src="./assets/main-4u-qsOhX.js"></script>
|
|
13
|
+
<link rel="stylesheet" crossorigin href="./assets/main-Cz1qHtDa.css">
|
|
14
14
|
</head>
|
|
15
15
|
<body>
|
|
16
16
|
<div id="root"></div>
|
package/package.json
CHANGED
package/server/pi-reader.ts
CHANGED
|
@@ -54,6 +54,87 @@ export function writeSettings(settings: any): boolean {
|
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
export interface CodexUsageWindow {
|
|
58
|
+
windowSeconds: number;
|
|
59
|
+
usedPercent: number;
|
|
60
|
+
remainingPercent: number;
|
|
61
|
+
resetAfterSeconds: number | null;
|
|
62
|
+
resetAt: number | null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface CodexUsageStatus {
|
|
66
|
+
loggedIn: boolean;
|
|
67
|
+
provider: "openai-codex";
|
|
68
|
+
planType?: string;
|
|
69
|
+
primary?: CodexUsageWindow;
|
|
70
|
+
secondary?: CodexUsageWindow;
|
|
71
|
+
checkedAt: string;
|
|
72
|
+
error?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let codexUsageStatusCache: { value: CodexUsageStatus; at: number } | null = null;
|
|
76
|
+
const CODEX_USAGE_STATUS_TTL_MS = 30_000;
|
|
77
|
+
|
|
78
|
+
function codexUsageWindow(value: any): CodexUsageWindow | undefined {
|
|
79
|
+
if (!value || typeof value !== "object" || typeof value.limit_window_seconds !== "number") return undefined;
|
|
80
|
+
const usedPercent = Math.min(100, Math.max(0, Number(value.used_percent) || 0));
|
|
81
|
+
return {
|
|
82
|
+
windowSeconds: value.limit_window_seconds,
|
|
83
|
+
usedPercent,
|
|
84
|
+
remainingPercent: Math.max(0, 100 - usedPercent),
|
|
85
|
+
resetAfterSeconds: typeof value.reset_after_seconds === "number" ? value.reset_after_seconds : null,
|
|
86
|
+
resetAt: typeof value.reset_at === "number" ? value.reset_at : null,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Read the locally saved openai-codex OAuth session and query OpenAI's own
|
|
92
|
+
* Codex usage endpoint. Only a sanitized quota summary leaves the server;
|
|
93
|
+
* OAuth access/refresh tokens are never returned or written anywhere.
|
|
94
|
+
*/
|
|
95
|
+
export async function getCodexUsageStatus(force = false): Promise<CodexUsageStatus> {
|
|
96
|
+
if (!force && codexUsageStatusCache && Date.now() - codexUsageStatusCache.at < CODEX_USAGE_STATUS_TTL_MS) {
|
|
97
|
+
return codexUsageStatusCache.value;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const checkedAt = new Date().toISOString();
|
|
101
|
+
const codex = readAuth()?.["openai-codex"];
|
|
102
|
+
if (codex?.type !== "oauth" || typeof codex.access !== "string" || !codex.access || typeof codex.accountId !== "string" || !codex.accountId) {
|
|
103
|
+
return { loggedIn: false, provider: "openai-codex", checkedAt };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
const response = await fetch("https://chatgpt.com/backend-api/wham/usage", {
|
|
108
|
+
headers: {
|
|
109
|
+
Authorization: `Bearer ${codex.access}`,
|
|
110
|
+
"chatgpt-account-id": codex.accountId,
|
|
111
|
+
},
|
|
112
|
+
signal: AbortSignal.timeout(15_000),
|
|
113
|
+
});
|
|
114
|
+
if (!response.ok) {
|
|
115
|
+
const value: CodexUsageStatus = { loggedIn: true, provider: "openai-codex", checkedAt, error: `OpenAI returned ${response.status}` };
|
|
116
|
+
codexUsageStatusCache = { value, at: Date.now() };
|
|
117
|
+
return value;
|
|
118
|
+
}
|
|
119
|
+
const payload = await response.json() as any;
|
|
120
|
+
const rateLimit = payload?.rate_limit;
|
|
121
|
+
const value: CodexUsageStatus = {
|
|
122
|
+
loggedIn: true,
|
|
123
|
+
provider: "openai-codex",
|
|
124
|
+
planType: typeof payload?.plan_type === "string" ? payload.plan_type : undefined,
|
|
125
|
+
primary: codexUsageWindow(rateLimit?.primary_window),
|
|
126
|
+
secondary: codexUsageWindow(rateLimit?.secondary_window),
|
|
127
|
+
checkedAt,
|
|
128
|
+
};
|
|
129
|
+
codexUsageStatusCache = { value, at: Date.now() };
|
|
130
|
+
return value;
|
|
131
|
+
} catch {
|
|
132
|
+
const value: CodexUsageStatus = { loggedIn: true, provider: "openai-codex", checkedAt, error: "Unable to query OpenAI usage" };
|
|
133
|
+
codexUsageStatusCache = { value, at: Date.now() };
|
|
134
|
+
return value;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
57
138
|
// ─── Official Usage Query ──────────────────────────────
|
|
58
139
|
|
|
59
140
|
export type OfficialUsageAuthMode = "auto" | "bearer" | "x-api-key" | "api-key";
|
|
@@ -1954,8 +2035,8 @@ export interface UpdateCheckResult {
|
|
|
1954
2035
|
checkedAt: number;
|
|
1955
2036
|
}
|
|
1956
2037
|
|
|
1957
|
-
/** Discover the
|
|
1958
|
-
function
|
|
2038
|
+
/** Discover the pi executable: PI_BINARY env → PATH → known global-install locations. */
|
|
2039
|
+
function resolvePiBinary(): { bin: string; version: string } | null {
|
|
1959
2040
|
const home = homedir();
|
|
1960
2041
|
const candidates = [
|
|
1961
2042
|
process.env.PI_BINARY,
|
|
@@ -1971,7 +2052,7 @@ function getPiVersion(): string | null {
|
|
|
1971
2052
|
const out = spawnSync(bin, ["--version"], { encoding: "utf8", timeout: 15000 });
|
|
1972
2053
|
if (out.status === 0) {
|
|
1973
2054
|
const v = out.stdout.trim();
|
|
1974
|
-
if (v) return v;
|
|
2055
|
+
if (v) return { bin, version: v };
|
|
1975
2056
|
}
|
|
1976
2057
|
} catch {
|
|
1977
2058
|
// try next candidate
|
|
@@ -1980,6 +2061,11 @@ function getPiVersion(): string | null {
|
|
|
1980
2061
|
return null;
|
|
1981
2062
|
}
|
|
1982
2063
|
|
|
2064
|
+
/** Installed pi version, or null when no pi executable could be found. */
|
|
2065
|
+
function getPiVersion(): string | null {
|
|
2066
|
+
return resolvePiBinary()?.version ?? null;
|
|
2067
|
+
}
|
|
2068
|
+
|
|
1983
2069
|
function readJsonFile<T>(filePath: string): T | null {
|
|
1984
2070
|
try {
|
|
1985
2071
|
return JSON.parse(readFileSync(filePath, "utf-8")) as T;
|
|
@@ -2131,15 +2217,37 @@ export interface ApplyUpdateResult {
|
|
|
2131
2217
|
}
|
|
2132
2218
|
|
|
2133
2219
|
/**
|
|
2134
|
-
*
|
|
2135
|
-
*
|
|
2136
|
-
*
|
|
2220
|
+
* Update pi core itself via `pi update`.
|
|
2221
|
+
*
|
|
2222
|
+
* pi core is not installed under ~/.pi/agent/npm, so `npm install` there would
|
|
2223
|
+
* be wrong. `pi update` with no target updates pi only — deliberately without
|
|
2224
|
+
* `--extensions`, which would instead update the packages and leave pi alone.
|
|
2225
|
+
*/
|
|
2226
|
+
function applyPiCoreUpdate(): ApplyUpdateResult {
|
|
2227
|
+
const name = PI_CORE_PACKAGE;
|
|
2228
|
+
const pi = resolvePiBinary();
|
|
2229
|
+
if (!pi) return { name, success: false, message: "pi executable not found" };
|
|
2230
|
+
try {
|
|
2231
|
+
const out = spawnSync(pi.bin, ["update"], { encoding: "utf8", timeout: 300000 });
|
|
2232
|
+
if (out.status === 0) return { name, success: true };
|
|
2233
|
+
const stderr = (out.stderr || out.stdout || "").trim().split("\n").slice(-3).join(" ");
|
|
2234
|
+
return { name, success: false, message: stderr || `pi update exited with ${out.status}` };
|
|
2235
|
+
} catch (e) {
|
|
2236
|
+
return { name, success: false, message: String(e) };
|
|
2237
|
+
}
|
|
2238
|
+
}
|
|
2239
|
+
|
|
2240
|
+
/**
|
|
2241
|
+
* One-click update. Extensions use `npm install <name>@latest` inside
|
|
2242
|
+
* ~/.pi/agent/npm; pi core is routed to `pi update` instead, since it lives
|
|
2243
|
+
* outside that directory and has its own updater.
|
|
2137
2244
|
*/
|
|
2138
2245
|
export function applyExtensionUpdates(names: string[]): ApplyUpdateResult[] {
|
|
2139
2246
|
const dir = join(PI_DIR, "npm");
|
|
2140
2247
|
const installed = new Set(listInstalledExtensions().map((e) => e.name));
|
|
2141
2248
|
|
|
2142
2249
|
return names.map((name) => {
|
|
2250
|
+
if (name === PI_CORE_PACKAGE) return applyPiCoreUpdate();
|
|
2143
2251
|
if (!installed.has(name)) {
|
|
2144
2252
|
return { name, success: false, message: "not an installed extension" };
|
|
2145
2253
|
}
|
|
@@ -14,6 +14,35 @@ import {
|
|
|
14
14
|
|
|
15
15
|
// ─── Types ──────────────────────────────────────────────
|
|
16
16
|
|
|
17
|
+
interface CodexUsageStatus {
|
|
18
|
+
loggedIn: boolean;
|
|
19
|
+
provider: "openai-codex";
|
|
20
|
+
planType?: string;
|
|
21
|
+
primary?: { windowSeconds: number; usedPercent: number; remainingPercent: number; resetAfterSeconds: number | null; resetAt: number | null };
|
|
22
|
+
secondary?: { windowSeconds: number; usedPercent: number; remainingPercent: number; resetAfterSeconds: number | null; resetAt: number | null };
|
|
23
|
+
checkedAt: string;
|
|
24
|
+
error?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function formatRemainingTime(seconds: number | null | undefined): string | null {
|
|
28
|
+
if (typeof seconds !== "number" || seconds < 0) return null;
|
|
29
|
+
const hours = Math.floor(seconds / 3600);
|
|
30
|
+
const minutes = Math.floor((seconds % 3600) / 60);
|
|
31
|
+
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function formatResetAt(unixSeconds: number | null | undefined, lang: string): string | null {
|
|
35
|
+
if (typeof unixSeconds !== "number" || unixSeconds <= 0) return null;
|
|
36
|
+
return new Intl.DateTimeFormat(lang, {
|
|
37
|
+
year: "numeric",
|
|
38
|
+
month: "2-digit",
|
|
39
|
+
day: "2-digit",
|
|
40
|
+
hour: "2-digit",
|
|
41
|
+
minute: "2-digit",
|
|
42
|
+
hour12: false,
|
|
43
|
+
}).format(new Date(unixSeconds * 1000));
|
|
44
|
+
}
|
|
45
|
+
|
|
17
46
|
interface UsageRangeData {
|
|
18
47
|
totalTokens: number;
|
|
19
48
|
totalInput: number;
|
|
@@ -364,6 +393,7 @@ export function DashboardPage() {
|
|
|
364
393
|
const [providerSort, setProviderSort] = useState<{ key: string; dir: SortDir }>({ key: "totalCost", dir: "desc" });
|
|
365
394
|
const [modelSort, setModelSort] = useState<{ key: string; dir: SortDir }>({ key: "totalCost", dir: "desc" });
|
|
366
395
|
const [prevTotals, setPrevTotals] = useState<{ tokens: number; cost: number } | null>(null);
|
|
396
|
+
const [codexUsage, setCodexUsage] = useState<CodexUsageStatus | null>(null);
|
|
367
397
|
|
|
368
398
|
const customInvalid = range === "custom" && !!customFrom && !!customTo && customFrom > customTo;
|
|
369
399
|
|
|
@@ -402,6 +432,22 @@ export function DashboardPage() {
|
|
|
402
432
|
|
|
403
433
|
useEffect(() => { fetchData(); }, [fetchData]);
|
|
404
434
|
|
|
435
|
+
// Official Codex quotas use the locally logged-in OAuth session. The API
|
|
436
|
+
// returns only a sanitized summary; OAuth credentials never reach the UI.
|
|
437
|
+
useEffect(() => {
|
|
438
|
+
if (source !== "pi") return;
|
|
439
|
+
let cancelled = false;
|
|
440
|
+
const load = () => {
|
|
441
|
+
fetch("/api/pi/codex-usage-status")
|
|
442
|
+
.then((r) => r.json())
|
|
443
|
+
.then((status: CodexUsageStatus) => { if (!cancelled) setCodexUsage(status); })
|
|
444
|
+
.catch(() => { if (!cancelled) setCodexUsage(null); });
|
|
445
|
+
};
|
|
446
|
+
load();
|
|
447
|
+
const id = window.setInterval(load, 30_000);
|
|
448
|
+
return () => { cancelled = true; window.clearInterval(id); };
|
|
449
|
+
}, [source]);
|
|
450
|
+
|
|
405
451
|
// Reset request-log pagination when the queried range or source changes
|
|
406
452
|
useEffect(() => { setLogPage(1); }, [range, customFrom, customTo, source]);
|
|
407
453
|
|
|
@@ -659,6 +705,29 @@ export function DashboardPage() {
|
|
|
659
705
|
))}
|
|
660
706
|
</div>
|
|
661
707
|
{source === "chatgpt" && <span className="dashboard-source-note">{t("dashboard.source_chatgpt_note")}</span>}
|
|
708
|
+
{source === "pi" && codexUsage && (
|
|
709
|
+
<div className="ml-auto flex flex-wrap items-center gap-2 text-xs" style={{ color: "var(--muted-text)" }}>
|
|
710
|
+
<span className={cn("inline-flex items-center gap-1 font-medium", codexUsage.loggedIn ? "text-emerald-400" : "text-gray-500")}>
|
|
711
|
+
<span className="h-1.5 w-1.5 rounded-full bg-current" />
|
|
712
|
+
{codexUsage.loggedIn ? t("dashboard.codex_logged_in") : t("dashboard.codex_not_logged_in")}
|
|
713
|
+
</span>
|
|
714
|
+
{codexUsage.loggedIn && codexUsage.primary && codexUsage.secondary && (
|
|
715
|
+
<>
|
|
716
|
+
<span className="rounded border border-gray-700 px-2 py-1 font-mono">
|
|
717
|
+
{t("dashboard.codex_5h")}: {t("dashboard.codex_remaining", `${codexUsage.primary.remainingPercent}%`)}
|
|
718
|
+
{formatRemainingTime(codexUsage.primary.resetAfterSeconds) && ` · ${formatRemainingTime(codexUsage.primary.resetAfterSeconds)}`}
|
|
719
|
+
{formatResetAt(codexUsage.primary.resetAt, lang) && ` · ${t("dashboard.codex_resets", formatResetAt(codexUsage.primary.resetAt, lang)!)}`}
|
|
720
|
+
</span>
|
|
721
|
+
<span className="rounded border border-gray-700 px-2 py-1 font-mono">
|
|
722
|
+
{t("dashboard.codex_7d")}: {t("dashboard.codex_remaining", `${codexUsage.secondary.remainingPercent}%`)}
|
|
723
|
+
{formatRemainingTime(codexUsage.secondary.resetAfterSeconds) && ` · ${formatRemainingTime(codexUsage.secondary.resetAfterSeconds)}`}
|
|
724
|
+
{formatResetAt(codexUsage.secondary.resetAt, lang) && ` · ${t("dashboard.codex_resets", formatResetAt(codexUsage.secondary.resetAt, lang)!)}`}
|
|
725
|
+
</span>
|
|
726
|
+
</>
|
|
727
|
+
)}
|
|
728
|
+
{codexUsage.error && <span className="text-amber-400">{t("dashboard.codex_quota_unavailable")}</span>}
|
|
729
|
+
</div>
|
|
730
|
+
)}
|
|
662
731
|
</div>
|
|
663
732
|
|
|
664
733
|
{/* Custom Date Picker */}
|