@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.
- package/README.ja.md +32 -8
- package/README.md +58 -10
- package/README.zh-CN.md +31 -7
- package/dist-electron/main/main.cjs +2453 -0
- package/package.json +22 -5
- package/pi-package/index.ts +228 -4
- package/public/apple-touch-icon.png +0 -0
- package/public/icon-192.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/pi.svg +6 -41
- package/public/sw.js +28 -5
- package/public/trayIconTemplate.png +0 -0
- package/server/pi-reader.ts +707 -27
- package/src/components/dashboard/DashboardPage.tsx +88 -16
- package/src/components/layout/AppShell.tsx +12 -5
- package/src/components/layout/Sidebar.tsx +15 -4
- package/src/components/providers/ProvidersModelsPage.tsx +84 -14
- package/src/components/sessions/SessionsPage.tsx +501 -149
- package/src/components/settings/SettingsPage.tsx +72 -0
- package/src/index.css +24 -0
- package/src/lib/translations/en.ts +31 -0
- package/src/lib/translations/ja.ts +31 -0
- package/src/lib/translations/zh-CN.ts +31 -0
- package/src/lib/translations/zh-TW.ts +31 -0
- package/src/main.tsx +31 -5
- package/src/store/config-store.ts +41 -0
- package/src/types/chat.ts +217 -0
- package/src/types/index.ts +2 -0
- package/vite.config.ts +240 -1
|
@@ -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
|
+
}
|
package/src/types/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
});
|