@raingor/pi-web-switch 0.4.0 → 0.4.2

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.
@@ -0,0 +1,217 @@
1
+ // Chat-related type definitions — ported and simplified from pi-web's lib/types.ts
2
+
3
+ export interface TextContent {
4
+ type: "text";
5
+ text: string;
6
+ }
7
+
8
+ export interface ImageContent {
9
+ type: "image";
10
+ source: {
11
+ type: "base64" | "url";
12
+ media_type?: string;
13
+ data?: string;
14
+ url?: string;
15
+ };
16
+ }
17
+
18
+ export interface ThinkingContent {
19
+ type: "thinking";
20
+ thinking: string;
21
+ deferred?: boolean;
22
+ }
23
+
24
+ export interface ToolCallContent {
25
+ type: "toolCall";
26
+ toolCallId: string;
27
+ toolName: string;
28
+ input: Record<string, unknown>;
29
+ }
30
+
31
+ export type AssistantContentBlock = TextContent | ImageContent | ThinkingContent | ToolCallContent;
32
+
33
+ export interface UserMessage {
34
+ role: "user";
35
+ content: string | (TextContent | ImageContent)[];
36
+ timestamp?: number;
37
+ }
38
+
39
+ export interface AssistantMessage {
40
+ role: "assistant";
41
+ content: AssistantContentBlock[];
42
+ model: string;
43
+ provider: string;
44
+ stopReason?: string;
45
+ errorMessage?: string;
46
+ timestamp?: number;
47
+ usage?: {
48
+ input: number;
49
+ output: number;
50
+ cacheRead: number;
51
+ cacheWrite: number;
52
+ cost: {
53
+ input: number;
54
+ output: number;
55
+ cacheRead: number;
56
+ cacheWrite: number;
57
+ total: number;
58
+ };
59
+ };
60
+ }
61
+
62
+ export interface ToolResultMessage {
63
+ role: "toolResult";
64
+ toolCallId: string;
65
+ toolName?: string;
66
+ content: (TextContent | ImageContent)[];
67
+ isError?: boolean;
68
+ details?: unknown;
69
+ timestamp?: number;
70
+ }
71
+
72
+ export interface CustomMessage {
73
+ role: "custom";
74
+ customType: string;
75
+ content: string | (TextContent | ImageContent)[];
76
+ display: boolean;
77
+ details?: unknown;
78
+ timestamp?: number;
79
+ }
80
+
81
+ export interface BashExecutionMessage {
82
+ role: "bashExecution";
83
+ command: string;
84
+ output: string;
85
+ exitCode?: number;
86
+ cancelled?: boolean;
87
+ truncated?: boolean;
88
+ fullOutputPath?: string;
89
+ excludeFromContext?: boolean;
90
+ timestamp?: number;
91
+ }
92
+
93
+ export type AgentMessage = UserMessage | AssistantMessage | ToolResultMessage | CustomMessage | BashExecutionMessage;
94
+
95
+ export interface SessionInfo {
96
+ path: string;
97
+ id: string;
98
+ cwd: string;
99
+ name?: string;
100
+ created: string;
101
+ modified: string;
102
+ messageCount: number;
103
+ firstMessage: string;
104
+ parentSessionId?: string;
105
+ projectRoot?: string;
106
+ worktreeBranch?: string;
107
+ }
108
+
109
+ export interface SessionContext {
110
+ messages: AgentMessage[];
111
+ entryIds: string[];
112
+ thinkingLevel: string;
113
+ model: { provider: string; modelId: string } | null;
114
+ }
115
+
116
+ export interface SessionTreeNode {
117
+ entry: { id: string; type: string };
118
+ children: SessionTreeNode[];
119
+ label?: string;
120
+ compressedEntryIds?: string[];
121
+ }
122
+
123
+ export interface SessionData {
124
+ sessionId: string;
125
+ filePath: string;
126
+ tree: SessionTreeNode[];
127
+ leafId: string | null;
128
+ context: SessionContext;
129
+ }
130
+
131
+ export interface ContextUsage {
132
+ percent: number | null;
133
+ contextWindow: number;
134
+ tokens: number | null;
135
+ }
136
+
137
+ export interface SessionStatsInfo {
138
+ sessionFile?: string;
139
+ sessionId: string;
140
+ sessionName?: string;
141
+ userMessages: number;
142
+ assistantMessages: number;
143
+ toolCalls: number;
144
+ toolResults: number;
145
+ totalMessages: number;
146
+ tokens: {
147
+ input: number;
148
+ output: number;
149
+ cacheRead: number;
150
+ cacheWrite: number;
151
+ total: number;
152
+ };
153
+ cost: number;
154
+ contextUsage?: ContextUsage;
155
+ }
156
+
157
+ export interface AttachedImage {
158
+ data: string;
159
+ mimeType: string;
160
+ previewUrl: string;
161
+ }
162
+
163
+ export interface ChatInputHandle {
164
+ insertText: (text: string) => void;
165
+ insertIfEmpty: (content: string) => void;
166
+ prependText: (text: string) => void;
167
+ addImages: (files: File[]) => void;
168
+ }
169
+
170
+ export interface ModelEntry {
171
+ id: string;
172
+ name: string;
173
+ provider: string;
174
+ }
175
+
176
+ export interface ModelsResponse {
177
+ models: Record<string, string>;
178
+ modelList?: ModelEntry[];
179
+ defaultModel?: { provider: string; modelId: string } | null;
180
+ thinkingLevels?: Record<string, string[]>;
181
+ modelError?: string;
182
+ }
183
+
184
+ export interface AgentStateResponse {
185
+ isStreaming?: boolean;
186
+ isPromptRunning?: boolean;
187
+ isBashRunning?: boolean;
188
+ isCompacting?: boolean;
189
+ contextUsage?: ContextUsage | null;
190
+ systemPrompt?: string;
191
+ thinkingLevel?: string;
192
+ model?: { id: string; provider: string };
193
+ }
194
+
195
+ export interface NoticeItem {
196
+ id: string;
197
+ message: string;
198
+ type: "info" | "success" | "warning" | "error";
199
+ exiting?: boolean;
200
+ }
201
+
202
+ export type AgentPhase =
203
+ | { kind: "waiting_model" }
204
+ | { kind: "running_command" }
205
+ | { kind: "running_tools"; tools: { id: string; name: string }[] }
206
+ | null;
207
+
208
+ export interface QueuedMessages {
209
+ steering: string[];
210
+ followUp: string[];
211
+ }
212
+
213
+ export interface SlashCommandInfo {
214
+ name: string;
215
+ description?: string;
216
+ source: "extension" | "prompt" | "skill";
217
+ }
@@ -19,6 +19,7 @@ export interface ThinkingLevelMap {
19
19
  export interface ModelCompat {
20
20
  supportsStore?: boolean;
21
21
  supportsDeveloperRole?: boolean;
22
+ supportsFinishReason?: boolean;
22
23
  supportsReasoningEffort?: boolean;
23
24
  supportsUsageInStreaming?: boolean;
24
25
  maxTokensField?: "max_completion_tokens" | "max_tokens";
@@ -109,6 +110,7 @@ export interface PiSettings {
109
110
  treeFilterMode?: string;
110
111
  doubleEscapeAction?: string;
111
112
  enabledModels?: string[];
113
+ sessionExpiryDays?: number;
112
114
  }
113
115
 
114
116
  export interface PiAuth {
package/vite.config.ts CHANGED
@@ -18,6 +18,24 @@ function piApiPlugin(): Plugin {
18
18
  pi = require("./server/pi-reader");
19
19
  builtins = require("./src/data/builtin-providers");
20
20
 
21
+ // Warm the usage cache in the background so the dashboard's first
22
+ // request doesn't block on scanning ~150MB of session JSONL.
23
+ setTimeout(() => {
24
+ try {
25
+ pi.readAllUsage();
26
+ } catch {
27
+ /* ignore warm-up failure */
28
+ }
29
+ try {
30
+ pi.readCopilotUsage();
31
+ } catch {
32
+ /* ignore warm-up failure */
33
+ }
34
+ }, 0);
35
+
36
+ // Start the auto-expiry timer: scan once at startup, then every 24h.
37
+ try { pi.startAutoExpiryTimer(); } catch { /* ignore */ }
38
+
21
39
  const routes: Record<string, (req: Connect.IncomingMessage, res: any) => void> = {
22
40
  "GET /api/pi/settings"(_, res) {
23
41
  const data = pi.readSettings();
@@ -115,6 +133,28 @@ function piApiPlugin(): Plugin {
115
133
  res.setHeader("Content-Type", "application/json");
116
134
  res.end(JSON.stringify(pi.listTrash()));
117
135
  },
136
+ "GET /api/pi/copilot-config"(_, res) {
137
+ res.setHeader("Content-Type", "application/json");
138
+ res.end(JSON.stringify(pi.readCopilotConfig() ?? {}));
139
+ },
140
+ "POST /api/pi/copilot-config"(req, res) {
141
+ let body = "";
142
+ req.on("data", (chunk: string) => (body += chunk));
143
+ req.on("end", () => {
144
+ try {
145
+ const cfg = JSON.parse(body) as { username?: string; token?: string };
146
+ const ok = pi.writeCopilotConfig(cfg);
147
+ // Config changed → drop cached usage so the next view refetches.
148
+ pi.clearCopilotCaches();
149
+ res.setHeader("Content-Type", "application/json");
150
+ res.end(JSON.stringify({ success: ok }));
151
+ } catch {
152
+ res.statusCode = 400;
153
+ res.setHeader("Content-Type", "application/json");
154
+ res.end(JSON.stringify({ success: false, error: "Invalid request body" }));
155
+ }
156
+ });
157
+ },
118
158
  "POST /api/pi/session/trash"(req, res) {
119
159
  let body = "";
120
160
  req.on("data", (chunk: string) => (body += chunk));
@@ -147,6 +187,21 @@ function piApiPlugin(): Plugin {
147
187
  }
148
188
  });
149
189
  },
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
+ },
150
205
  "GET /api/pi/session-preview"(req, res) {
151
206
  const parsedUrl = new URL(req.url!, "http://localhost");
152
207
  const p = parsedUrl.searchParams.get("path") || "";
@@ -282,8 +337,14 @@ function piApiPlugin(): Plugin {
282
337
  const toParam = parsedUrl.searchParams.get("to") || "";
283
338
 
284
339
  const now = new Date();
340
+ // Date buckets follow China time (UTC+8) regardless of system timezone
285
341
  const localDateStr = (dt: Date) =>
286
- `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, "0")}-${String(dt.getDate()).padStart(2, "0")}`;
342
+ new Intl.DateTimeFormat("en-CA", {
343
+ timeZone: "Asia/Shanghai",
344
+ year: "numeric",
345
+ month: "2-digit",
346
+ day: "2-digit",
347
+ }).format(dt);
287
348
  let fromDate: string;
288
349
  let toDate = localDateStr(now);
289
350
 
@@ -306,6 +367,172 @@ function piApiPlugin(): Plugin {
306
367
  return res.end(JSON.stringify(usage));
307
368
  }
308
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
+ const now = new Date();
378
+ // Date buckets follow China time (UTC+8) regardless of system timezone
379
+ const localDateStr = (dt: Date) =>
380
+ new Intl.DateTimeFormat("en-CA", {
381
+ timeZone: "Asia/Shanghai",
382
+ year: "numeric",
383
+ month: "2-digit",
384
+ day: "2-digit",
385
+ }).format(dt);
386
+ let fromDate: string;
387
+ let toDate = localDateStr(now);
388
+
389
+ if (range === "today") {
390
+ fromDate = toDate;
391
+ } else if (range === "7d") {
392
+ const d = new Date(now); d.setDate(d.getDate() - 6); fromDate = localDateStr(d);
393
+ } else if (range === "30d") {
394
+ const d = new Date(now); d.setDate(d.getDate() - 29); fromDate = localDateStr(d);
395
+ } else if (range === "custom" && fromParam) {
396
+ fromDate = fromParam;
397
+ if (toParam) toDate = toParam;
398
+ } else {
399
+ fromDate = toDate;
400
+ }
401
+
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();
479
+ const usage = pi.getUsageByRange(allRecords, fromDate, toDate);
480
+ res.setHeader("Content-Type", "application/json");
481
+ return res.end(JSON.stringify(usage));
482
+ }
483
+
484
+ // Helper: resolve date range params
485
+ const resolveDateRange = (range: string, fromParam: string, toParam: string) => {
486
+ const now = new Date();
487
+ // Date buckets follow China time (UTC+8) regardless of system timezone
488
+ const localDateStr = (dt: Date) =>
489
+ new Intl.DateTimeFormat("en-CA", {
490
+ timeZone: "Asia/Shanghai",
491
+ year: "numeric",
492
+ month: "2-digit",
493
+ day: "2-digit",
494
+ }).format(dt);
495
+ let fromDate: string;
496
+ let toDate = localDateStr(now);
497
+ if (range === "today") fromDate = toDate;
498
+ else if (range === "7d") { const d = new Date(now); d.setDate(d.getDate() - 6); fromDate = localDateStr(d); }
499
+ else if (range === "30d") { const d = new Date(now); d.setDate(d.getDate() - 29); fromDate = localDateStr(d); }
500
+ else if (range === "custom" && fromParam) { fromDate = fromParam; if (toParam) toDate = toParam; }
501
+ else fromDate = toDate;
502
+ return { fromDate, toDate };
503
+ };
504
+
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]!;
525
+ const parsedUrl = new URL(url, "http://localhost");
526
+ const range = parsedUrl.searchParams.get("range") || "today";
527
+ const fromParam = parsedUrl.searchParams.get("from") || "";
528
+ const toParam = parsedUrl.searchParams.get("to") || "";
529
+ const { fromDate, toDate } = resolveDateRange(range, fromParam, toParam);
530
+ const allRecords = pi.filterByProvider(pi.readAllCombinedUsage(), providerId);
531
+ const usage = pi.getUsageByRange(allRecords, fromDate, toDate);
532
+ res.setHeader("Content-Type", "application/json");
533
+ return res.end(JSON.stringify(usage));
534
+ }
535
+
309
536
  const key = `${method} ${pathOnly}`;
310
537
  const handler = routes[key];
311
538
  if (handler) {
@@ -323,6 +550,9 @@ function piApiPlugin(): Plugin {
323
550
  // ─── Vite Config ────────────────────────────────────────
324
551
 
325
552
  export default defineConfig({
553
+ // Relative base so Electron can loadFile() the built HTML from disk —
554
+ // absolute "/assets/..." URLs would resolve to the filesystem root.
555
+ base: './',
326
556
  plugins: [
327
557
  react(),
328
558
  tailwindcss(),
@@ -337,4 +567,13 @@ export default defineConfig({
337
567
  port: 5176,
338
568
  strictPort: true,
339
569
  },
570
+ build: {
571
+ rollupOptions: {
572
+ input: {
573
+ 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
+ },
577
+ },
578
+ },
340
579
  });