@raingor/pi-web-switch 0.4.2 → 0.4.4

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.
Files changed (37) hide show
  1. package/dist/index.html +18 -0
  2. package/index.html +2 -2
  3. package/package.json +7 -38
  4. package/public/apple-touch-icon.png +0 -0
  5. package/public/icon-192.png +0 -0
  6. package/public/icon-512.png +0 -0
  7. package/public/manifest.webmanifest +2 -2
  8. package/public/pi.svg +41 -6
  9. package/public/sw.js +51 -51
  10. package/server/pi-reader.ts +353 -237
  11. package/src/App.tsx +2 -0
  12. package/src/components/dashboard/DashboardPage.tsx +341 -56
  13. package/src/components/layout/AppShell.tsx +45 -13
  14. package/src/components/layout/Sidebar.tsx +78 -85
  15. package/src/components/providers/ProvidersModelsPage.tsx +125 -131
  16. package/src/components/sessions/MemoryPage.tsx +34 -13
  17. package/src/components/sessions/SessionsPage.tsx +20 -40
  18. package/src/components/settings/SettingsPage.tsx +6 -1
  19. package/src/components/speedtest/ModelSpeedTestPage.tsx +429 -0
  20. package/src/components/ui/EmptyState.tsx +7 -6
  21. package/src/components/ui/Modal.tsx +33 -25
  22. package/src/components/ui/StatCard.tsx +10 -13
  23. package/src/data/builtin-providers.test.ts +109 -0
  24. package/src/data/builtin-providers.ts +67 -44
  25. package/src/data/model-catalog.test.ts +122 -0
  26. package/src/data/model-catalog.ts +697 -478
  27. package/src/index.css +624 -210
  28. package/src/lib/translations/en.ts +88 -14
  29. package/src/lib/translations/ja.ts +88 -14
  30. package/src/lib/translations/zh-CN.ts +88 -14
  31. package/src/lib/translations/zh-TW.ts +87 -14
  32. package/src/main.tsx +97 -44
  33. package/src/types/index.ts +0 -1
  34. package/vite.config.ts +65 -164
  35. package/dist-electron/main/main.cjs +0 -2453
  36. package/src/data/mock-config.ts +0 -247
  37. package/src/data/mock-usage.ts +0 -151
package/src/main.tsx CHANGED
@@ -39,10 +39,10 @@ function useThemeSync(initialized: boolean) {
39
39
  function LoadingScreen() {
40
40
  const { t } = useTranslation();
41
41
  return (
42
- <div className="flex h-screen items-center justify-center bg-gray-950">
43
- <div className="flex flex-col items-center gap-4">
44
- <div className="h-8 w-8 animate-spin rounded-full border-2 border-gray-600 border-t-blue-500" />
45
- <p className="text-sm text-gray-500">{t("loading.config")}</p>
42
+ <div className="loading-console">
43
+ <div className="loading-core">
44
+ <div className="loading-ring" />
45
+ <p>{t("loading.config")}</p>
46
46
  </div>
47
47
  </div>
48
48
  );
@@ -51,8 +51,8 @@ function LoadingScreen() {
51
51
  function ErrorScreen({ error, onRetry }: { error: string; onRetry: () => void }) {
52
52
  const { t } = useTranslation();
53
53
  return (
54
- <div className="flex h-screen items-center justify-center bg-gray-950">
55
- <div className="flex flex-col items-center gap-4 max-w-md text-center">
54
+ <div className="loading-console">
55
+ <div className="tech-panel relative z-10 flex max-w-md flex-col items-center gap-4 rounded-xl border p-8 text-center">
56
56
  <p className="text-lg font-semibold text-red-400">{t("loading.error_title")}</p>
57
57
  <p className="text-sm text-gray-400">{error}</p>
58
58
  <button
@@ -112,48 +112,101 @@ function applySavedFontSize() {
112
112
  applySavedZoom();
113
113
  function applySavedZoom() {
114
114
  const saved = Number(localStorage.getItem("pi-ui-zoom"));
115
+ document.documentElement.style.zoom = "";
115
116
  if (saved >= 50 && saved <= 200) {
116
- document.documentElement.style.zoom = `${saved}%`;
117
+ document.documentElement.style.setProperty("--ui-zoom", String(saved / 100));
117
118
  }
118
119
  }
119
120
 
120
- createRoot(document.getElementById("root")!).render(
121
- <StrictMode>
122
- <Root />
123
- </StrictMode>
124
- );
121
+ // ─── Service worker lifecycle ───────────────────────────
122
+ // A production SW can remain registered when the same localhost origin is
123
+ // later opened with Vite dev. Actively remove it in development; otherwise a
124
+ // normal Cmd+R may be served an old HTML shell while Cmd+Shift+R bypasses it.
125
+ const APP_CACHE_PREFIX = "pi-web-switch-";
126
+ const DEV_SW_CLEANUP_KEY = "pi-web-switch-dev-sw-cleaned";
127
+
128
+ async function clearAppCaches() {
129
+ if (!("caches" in window)) return;
130
+ const keys = await caches.keys();
131
+ await Promise.all(
132
+ keys.filter((key) => key.startsWith(APP_CACHE_PREFIX)).map((key) => caches.delete(key))
133
+ );
134
+ }
125
135
 
126
- // ─── PWA: register service worker (production only — dev must not
127
- // cache HTML pages, or SPA routes like /providers can return stale HTML
128
- // to API fetches and break JSON parsing) ─────────────────────
129
- if ("serviceWorker" in navigator) {
130
- if (import.meta.env.PROD) {
131
- window.addEventListener("load", () => {
132
- navigator.serviceWorker.register("/sw.js").catch((err) => {
133
- console.warn("SW registration failed:", err);
134
- });
135
- });
136
- } else {
137
- // Dev: proactively unregister any leftover service worker (e.g. from a
138
- // previous `vite preview` or production build on the same origin) and
139
- // clear its caches. Dev module URLs are not hashed, so a stale SW would
140
- // serve old code on every plain reload until a hard refresh.
141
- window.addEventListener("load", () => {
142
- navigator.serviceWorker
143
- .getRegistrations()
144
- .then((registrations) =>
145
- Promise.all(registrations.map((reg) => reg.unregister()))
146
- )
147
- .then(() =>
148
- window.caches
149
- ? window.caches.keys().then((keys) =>
150
- Promise.all(keys.map((k) => window.caches.delete(k)))
151
- )
152
- : undefined
153
- )
154
- .catch((err) => {
155
- console.warn("SW cleanup failed:", err);
156
- });
136
+ function getAppBaseUrl() {
137
+ const moduleScript = Array.from(document.scripts).find(
138
+ (script) => script.type === "module" && script.src
139
+ );
140
+ return new URL("../", moduleScript?.src ?? document.baseURI);
141
+ }
142
+
143
+ async function configureServiceWorker() {
144
+ if (!("serviceWorker" in navigator)) return;
145
+
146
+ const appBaseUrl = getAppBaseUrl();
147
+
148
+ if (import.meta.env.DEV) {
149
+ try {
150
+ const registrations = await navigator.serviceWorker.getRegistrations();
151
+ const appRegistrations = registrations.filter(
152
+ (registration) => registration.scope === appBaseUrl.href
153
+ );
154
+ const hadController = Boolean(navigator.serviceWorker.controller);
155
+ const removed = await Promise.all(
156
+ appRegistrations.map((registration) => registration.unregister())
157
+ );
158
+ await clearAppCaches();
159
+
160
+ // If this document was already controlled by the old production worker,
161
+ // reload once after unregistering it so the first API/static requests are
162
+ // also guaranteed to bypass the old worker. sessionStorage prevents a
163
+ // reload loop if the browser reports the controller for one extra tick.
164
+ if (
165
+ hadController &&
166
+ removed.some(Boolean) &&
167
+ sessionStorage.getItem(DEV_SW_CLEANUP_KEY) !== "1"
168
+ ) {
169
+ sessionStorage.setItem(DEV_SW_CLEANUP_KEY, "1");
170
+ window.location.reload();
171
+ return;
172
+ }
173
+ sessionStorage.removeItem(DEV_SW_CLEANUP_KEY);
174
+ } catch (err) {
175
+ // Cache cleanup must never prevent React from mounting.
176
+ console.warn("SW cleanup failed:", err);
177
+ }
178
+ return;
179
+ }
180
+
181
+ try {
182
+ // Resolve from the built JS asset directory so this also works when the
183
+ // app is hosted under a subdirectory instead of the domain root.
184
+ const swUrl = new URL("sw.js", appBaseUrl);
185
+ const registration = await navigator.serviceWorker.register(swUrl, {
186
+ updateViaCache: "none",
157
187
  });
188
+ // Check on every application start instead of waiting for the browser's
189
+ // periodic service-worker update window.
190
+ await registration.update();
191
+ } catch (err) {
192
+ console.warn("SW registration failed:", err);
158
193
  }
159
- }
194
+ }
195
+
196
+ async function bootstrap() {
197
+ if (import.meta.env.DEV) {
198
+ await configureServiceWorker();
199
+ }
200
+
201
+ createRoot(document.getElementById("root")!).render(
202
+ <StrictMode>
203
+ <Root />
204
+ </StrictMode>
205
+ );
206
+
207
+ if (import.meta.env.PROD) {
208
+ void configureServiceWorker();
209
+ }
210
+ }
211
+
212
+ void bootstrap();
@@ -110,7 +110,6 @@ export interface PiSettings {
110
110
  treeFilterMode?: string;
111
111
  doubleEscapeAction?: string;
112
112
  enabledModels?: string[];
113
- sessionExpiryDays?: number;
114
113
  }
115
114
 
116
115
  export interface PiAuth {
package/vite.config.ts CHANGED
@@ -7,16 +7,12 @@ import type { Connect } from "vite";
7
7
  // ─── Pi Config API Plugin ───────────────────────────────
8
8
 
9
9
  function piApiPlugin(): Plugin {
10
- // Lazy-load the server-side module (Node.js only)
11
- let pi: typeof import("./server/pi-reader");
12
- let builtins: typeof import("./src/data/builtin-providers");
13
-
14
10
  return {
15
11
  name: "pi-api",
16
12
  configureServer(server) {
17
- // Load server-side modules
18
- pi = require("./server/pi-reader");
19
- builtins = require("./src/data/builtin-providers");
13
+ // Lazy-load the server-side module (Node.js only)
14
+ const pi = require("./server/pi-reader");
15
+ const builtins = require("./src/data/builtin-providers");
20
16
 
21
17
  // Warm the usage cache in the background so the dashboard's first
22
18
  // request doesn't block on scanning ~150MB of session JSONL.
@@ -27,15 +23,12 @@ function piApiPlugin(): Plugin {
27
23
  /* ignore warm-up failure */
28
24
  }
29
25
  try {
30
- pi.readCopilotUsage();
26
+ pi.readChatgptUsage();
31
27
  } catch {
32
28
  /* ignore warm-up failure */
33
29
  }
34
30
  }, 0);
35
31
 
36
- // Start the auto-expiry timer: scan once at startup, then every 24h.
37
- try { pi.startAutoExpiryTimer(); } catch { /* ignore */ }
38
-
39
32
  const routes: Record<string, (req: Connect.IncomingMessage, res: any) => void> = {
40
33
  "GET /api/pi/settings"(_, res) {
41
34
  const data = pi.readSettings();
@@ -56,6 +49,48 @@ function piApiPlugin(): Plugin {
56
49
  res.setHeader("Content-Type", "application/json");
57
50
  res.end(JSON.stringify(data ?? {}));
58
51
  },
52
+ "GET /api/pi/official-usage-config"(_, res) {
53
+ const config = pi.readOfficialUsageConfig();
54
+ res.setHeader("Content-Type", "application/json");
55
+ res.end(JSON.stringify({
56
+ endpoint: config.endpoint,
57
+ authMode: config.authMode,
58
+ keyCount: config.apiKeys.length,
59
+ maskedKeys: config.apiKeys.map((key: string) => key.length > 8 ? `${key.slice(0, 4)}••••${key.slice(-4)}` : "••••••••"),
60
+ }));
61
+ },
62
+ "POST /api/pi/official-usage-refresh"(_, res) {
63
+ pi.queryOfficialUsage(pi.readOfficialUsageConfig()).then((usage) => {
64
+ res.setHeader("Content-Type", "application/json");
65
+ res.end(JSON.stringify({ success: true, usage }));
66
+ }).catch((error) => {
67
+ res.statusCode = 400;
68
+ res.setHeader("Content-Type", "application/json");
69
+ res.end(JSON.stringify({ success: false, error: error instanceof Error ? error.message : "Official usage query failed" }));
70
+ });
71
+ },
72
+ "POST /api/pi/official-usage-query"(req, res) {
73
+ let body = "";
74
+ req.on("data", (chunk: string) => (body += chunk));
75
+ req.on("end", async () => {
76
+ try {
77
+ const input = JSON.parse(body);
78
+ const config = {
79
+ endpoint: typeof input.endpoint === "string" ? input.endpoint : "",
80
+ apiKeys: Array.isArray(input.apiKeys) ? input.apiKeys : [],
81
+ authMode: input.authMode,
82
+ };
83
+ const usage = await pi.queryOfficialUsage(config);
84
+ const saved = pi.writeOfficialUsageConfig(config);
85
+ res.setHeader("Content-Type", "application/json");
86
+ res.end(JSON.stringify({ success: saved, usage, error: saved ? undefined : "Failed to save configuration" }));
87
+ } catch (error) {
88
+ res.statusCode = 400;
89
+ res.setHeader("Content-Type", "application/json");
90
+ res.end(JSON.stringify({ success: false, error: error instanceof Error ? error.message : "Official usage query failed" }));
91
+ }
92
+ });
93
+ },
59
94
  "POST /api/pi/auth"(req, res) {
60
95
  let body = "";
61
96
  req.on("data", (chunk: string) => (body += chunk));
@@ -187,21 +222,6 @@ function piApiPlugin(): Plugin {
187
222
  }
188
223
  });
189
224
  },
190
- "POST /api/pi/session/auto-expire"(req, res) {
191
- let body = "";
192
- req.on("data", (chunk: string) => (body += chunk));
193
- req.on("end", () => {
194
- try {
195
- const result = pi.autoExpireSessions();
196
- res.setHeader("Content-Type", "application/json");
197
- res.end(JSON.stringify({ success: true, ...result }));
198
- } catch {
199
- res.statusCode = 500;
200
- res.setHeader("Content-Type", "application/json");
201
- res.end(JSON.stringify({ success: false, error: "Auto-expire failed" }));
202
- }
203
- });
204
- },
205
225
  "GET /api/pi/session-preview"(req, res) {
206
226
  const parsedUrl = new URL(req.url!, "http://localhost");
207
227
  const p = parsedUrl.searchParams.get("path") || "";
@@ -312,6 +332,14 @@ function piApiPlugin(): Plugin {
312
332
  // Strip query string
313
333
  const pathOnly = url.split("?")[0];
314
334
 
335
+ // Automatically archive sessions that have been inactive for more
336
+ // than two weeks. This is recoverable through the existing trash tab.
337
+ if (method === "POST" && pathOnly === "/api/pi/sessions/auto-trash") {
338
+ const result = pi.autoTrashStaleSessions(14);
339
+ res.setHeader("Content-Type", "application/json");
340
+ return res.end(JSON.stringify(result));
341
+ }
342
+
315
343
  // Handle DELETE /api/pi/session?path=... (move to trash) and /api/pi/trash?path=... (permanent)
316
344
  if (method === "DELETE" && (pathOnly === "/api/pi/session" || pathOnly === "/api/pi/trash")) {
317
345
  const parsedUrl = new URL(url, "http://localhost");
@@ -335,45 +363,11 @@ function piApiPlugin(): Plugin {
335
363
  const range = parsedUrl.searchParams.get("range") || "today";
336
364
  const fromParam = parsedUrl.searchParams.get("from") || "";
337
365
  const toParam = parsedUrl.searchParams.get("to") || "";
338
-
339
- const now = new Date();
340
- // Date buckets follow China time (UTC+8) regardless of system timezone
341
- const localDateStr = (dt: Date) =>
342
- new Intl.DateTimeFormat("en-CA", {
343
- timeZone: "Asia/Shanghai",
344
- year: "numeric",
345
- month: "2-digit",
346
- day: "2-digit",
347
- }).format(dt);
348
- let fromDate: string;
349
- let toDate = localDateStr(now);
350
-
351
- if (range === "today") {
352
- fromDate = toDate;
353
- } else if (range === "7d") {
354
- const d = new Date(now); d.setDate(d.getDate() - 6); fromDate = localDateStr(d);
355
- } else if (range === "30d") {
356
- const d = new Date(now); d.setDate(d.getDate() - 29); fromDate = localDateStr(d);
357
- } else if (range === "custom" && fromParam) {
358
- fromDate = fromParam;
359
- if (toParam) toDate = toParam;
360
- } else {
361
- fromDate = toDate;
366
+ if (parsedUrl.searchParams.get("refresh") === "1") {
367
+ pi.clearUsageCache();
368
+ pi.clearChatgptUsageCache();
362
369
  }
363
370
 
364
- const allRecords = pi.readAllUsage();
365
- const usage = pi.getUsageByRange(allRecords, fromDate, toDate);
366
- res.setHeader("Content-Type", "application/json");
367
- return res.end(JSON.stringify(usage));
368
- }
369
-
370
- // Handle GET /api/pi/cindy-usage-range?range=today|7d|30d|custom&from=...&to=...
371
- if (method === "GET" && pathOnly === "/api/pi/cindy-usage-range") {
372
- const parsedUrl = new URL(url, "http://localhost");
373
- const range = parsedUrl.searchParams.get("range") || "today";
374
- const fromParam = parsedUrl.searchParams.get("from") || "";
375
- const toParam = parsedUrl.searchParams.get("to") || "";
376
-
377
371
  const now = new Date();
378
372
  // Date buckets follow China time (UTC+8) regardless of system timezone
379
373
  const localDateStr = (dt: Date) =>
@@ -399,83 +393,7 @@ function piApiPlugin(): Plugin {
399
393
  fromDate = toDate;
400
394
  }
401
395
 
402
- const allRecords = pi.readCindyUsage();
403
- const usage = pi.getUsageByRange(allRecords, fromDate, toDate);
404
- res.setHeader("Content-Type", "application/json");
405
- return res.end(JSON.stringify(usage));
406
- }
407
-
408
- // Handle GET /api/pi/claude-usage-range?range=today|7d|30d|custom&from=...&to=...
409
- if (method === "GET" && pathOnly === "/api/pi/claude-usage-range") {
410
- const parsedUrl = new URL(url, "http://localhost");
411
- const range = parsedUrl.searchParams.get("range") || "today";
412
- const fromParam = parsedUrl.searchParams.get("from") || "";
413
- const toParam = parsedUrl.searchParams.get("to") || "";
414
-
415
- const now = new Date();
416
- // Date buckets follow China time (UTC+8) regardless of system timezone
417
- const localDateStr = (dt: Date) =>
418
- new Intl.DateTimeFormat("en-CA", {
419
- timeZone: "Asia/Shanghai",
420
- year: "numeric",
421
- month: "2-digit",
422
- day: "2-digit",
423
- }).format(dt);
424
- let fromDate: string;
425
- let toDate = localDateStr(now);
426
-
427
- if (range === "today") {
428
- fromDate = toDate;
429
- } else if (range === "7d") {
430
- const d = new Date(now); d.setDate(d.getDate() - 6); fromDate = localDateStr(d);
431
- } else if (range === "30d") {
432
- const d = new Date(now); d.setDate(d.getDate() - 29); fromDate = localDateStr(d);
433
- } else if (range === "custom" && fromParam) {
434
- fromDate = fromParam;
435
- if (toParam) toDate = toParam;
436
- } else {
437
- fromDate = toDate;
438
- }
439
-
440
- const allRecords = pi.readClaudeUsage();
441
- const usage = pi.getUsageByRange(allRecords, fromDate, toDate);
442
- res.setHeader("Content-Type", "application/json");
443
- return res.end(JSON.stringify(usage));
444
- }
445
-
446
- // Handle GET /api/pi/codex-usage-range?range=today|7d|30d|custom&from=...&to=...
447
- if (method === "GET" && pathOnly === "/api/pi/codex-usage-range") {
448
- const parsedUrl = new URL(url, "http://localhost");
449
- const range = parsedUrl.searchParams.get("range") || "today";
450
- const fromParam = parsedUrl.searchParams.get("from") || "";
451
- const toParam = parsedUrl.searchParams.get("to") || "";
452
-
453
- const now = new Date();
454
- // Date buckets follow China time (UTC+8) regardless of system timezone
455
- const localDateStr = (dt: Date) =>
456
- new Intl.DateTimeFormat("en-CA", {
457
- timeZone: "Asia/Shanghai",
458
- year: "numeric",
459
- month: "2-digit",
460
- day: "2-digit",
461
- }).format(dt);
462
- let fromDate: string;
463
- let toDate = localDateStr(now);
464
-
465
- if (range === "today") {
466
- fromDate = toDate;
467
- } else if (range === "7d") {
468
- const d = new Date(now); d.setDate(d.getDate() - 6); fromDate = localDateStr(d);
469
- } else if (range === "30d") {
470
- const d = new Date(now); d.setDate(d.getDate() - 29); fromDate = localDateStr(d);
471
- } else if (range === "custom" && fromParam) {
472
- fromDate = fromParam;
473
- if (toParam) toDate = toParam;
474
- } else {
475
- fromDate = toDate;
476
- }
477
-
478
- const allRecords = pi.readCodexUsage();
396
+ const allRecords = pi.readAllUsage();
479
397
  const usage = pi.getUsageByRange(allRecords, fromDate, toDate);
480
398
  res.setHeader("Content-Type", "application/json");
481
399
  return res.end(JSON.stringify(usage));
@@ -502,33 +420,18 @@ function piApiPlugin(): Plugin {
502
420
  return { fromDate, toDate };
503
421
  };
504
422
 
505
- // Handle GET /api/pi/all-usage-range
506
- if (method === "GET" && pathOnly === "/api/pi/all-usage-range") {
507
- const parsedUrl = new URL(url, "http://localhost");
508
- const range = parsedUrl.searchParams.get("range") || "today";
509
- const fromParam = parsedUrl.searchParams.get("from") || "";
510
- const toParam = parsedUrl.searchParams.get("to") || "";
511
- const { fromDate, toDate } = resolveDateRange(range, fromParam, toParam);
512
- const allRecords = pi.readAllCombinedUsage();
513
- const usage = pi.getUsageByRange(allRecords, fromDate, toDate);
514
- res.setHeader("Content-Type", "application/json");
515
- return res.end(JSON.stringify(usage));
516
- }
517
-
518
- // Handle provider-filtered endpoints: /api/pi/{provider}-usage-range
519
- // Copilot is read from the local ~/.copilot/session-store.db (no
520
- // GitHub REST API, no PAT), so it shares the same sync pipeline as
521
- // the other local sources.
522
- const providerMatch = pathOnly.match(/^\/api\/pi\/(atomcode|copilot|opencode|gemini|grok)-usage-range$/);
523
- if (method === "GET" && providerMatch) {
524
- const providerId = providerMatch[1]!;
423
+ // Handle GET /api/pi/chatgpt-usage-range using local Codex Desktop
424
+ // rollout JSONL files under ~/.codex/sessions and archived_sessions.
425
+ if (method === "GET" && pathOnly === "/api/pi/chatgpt-usage-range") {
525
426
  const parsedUrl = new URL(url, "http://localhost");
526
427
  const range = parsedUrl.searchParams.get("range") || "today";
527
428
  const fromParam = parsedUrl.searchParams.get("from") || "";
528
429
  const toParam = parsedUrl.searchParams.get("to") || "";
430
+ if (parsedUrl.searchParams.get("refresh") === "1") {
431
+ pi.clearChatgptUsageCache();
432
+ }
529
433
  const { fromDate, toDate } = resolveDateRange(range, fromParam, toParam);
530
- const allRecords = pi.filterByProvider(pi.readAllCombinedUsage(), providerId);
531
- const usage = pi.getUsageByRange(allRecords, fromDate, toDate);
434
+ const usage = pi.getUsageByRange(pi.readChatgptUsage(), fromDate, toDate);
532
435
  res.setHeader("Content-Type", "application/json");
533
436
  return res.end(JSON.stringify(usage));
534
437
  }
@@ -571,8 +474,6 @@ export default defineConfig({
571
474
  rollupOptions: {
572
475
  input: {
573
476
  main: path.resolve(__dirname, "index.html"),
574
- // Menu-bar popup (used by the Electron tray app)
575
- popup: path.resolve(__dirname, "electron/popup.html"),
576
477
  },
577
478
  },
578
479
  },