@raingor/pi-web-switch 0.2.0

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 (41) hide show
  1. package/README.ja.md +137 -0
  2. package/README.md +277 -0
  3. package/README.zh-CN.md +176 -0
  4. package/index.html +13 -0
  5. package/package.json +44 -0
  6. package/pi-package/index.ts +100 -0
  7. package/pi-package/skills/pi-web-switch/SKILL.md +60 -0
  8. package/public/pi.svg +4 -0
  9. package/server/pi-reader.ts +678 -0
  10. package/src/App.tsx +25 -0
  11. package/src/components/dashboard/DashboardPage.tsx +607 -0
  12. package/src/components/layout/AppShell.tsx +18 -0
  13. package/src/components/layout/Sidebar.tsx +116 -0
  14. package/src/components/models/ModelsPage.tsx +570 -0
  15. package/src/components/providers/ProvidersPage.tsx +466 -0
  16. package/src/components/sessions/MemoryPage.tsx +177 -0
  17. package/src/components/sessions/SessionsPage.tsx +347 -0
  18. package/src/components/settings/SettingsPage.tsx +351 -0
  19. package/src/components/ui/Badge.tsx +29 -0
  20. package/src/components/ui/EmptyState.tsx +20 -0
  21. package/src/components/ui/Modal.tsx +41 -0
  22. package/src/components/ui/StatCard.tsx +37 -0
  23. package/src/data/builtin-providers.ts +148 -0
  24. package/src/data/mock-config.ts +261 -0
  25. package/src/data/mock-usage.ts +153 -0
  26. package/src/index.css +217 -0
  27. package/src/lib/config.ts +56 -0
  28. package/src/lib/currency.ts +48 -0
  29. package/src/lib/i18n.tsx +98 -0
  30. package/src/lib/translations/en.ts +168 -0
  31. package/src/lib/translations/index.ts +14 -0
  32. package/src/lib/translations/ja.ts +158 -0
  33. package/src/lib/translations/zh-CN.ts +158 -0
  34. package/src/lib/translations/zh-TW.ts +158 -0
  35. package/src/lib/utils.ts +51 -0
  36. package/src/main.tsx +106 -0
  37. package/src/store/config-store.ts +459 -0
  38. package/src/types/index.ts +187 -0
  39. package/src/vite-env.d.ts +1 -0
  40. package/tsconfig.json +24 -0
  41. package/vite.config.ts +172 -0
@@ -0,0 +1,678 @@
1
+ import { readFileSync, readdirSync, existsSync, statSync, unlinkSync, writeFileSync } from "fs";
2
+ import { homedir } from "os";
3
+ import { join, resolve } from "path";
4
+
5
+ const PI_DIR = join(homedir(), ".pi", "agent");
6
+
7
+ // ─── Config File Paths ───────────────────────────────────
8
+
9
+ function piPath(filename: string): string {
10
+ return join(PI_DIR, filename);
11
+ }
12
+
13
+ function readJson<T>(filename: string): T | null {
14
+ const path = piPath(filename);
15
+ try {
16
+ if (!existsSync(path)) return null;
17
+ const raw = readFileSync(path, "utf-8");
18
+ return JSON.parse(raw) as T;
19
+ } catch {
20
+ return null;
21
+ }
22
+ }
23
+
24
+ // ─── Settings ───────────────────────────────────────────
25
+
26
+ export function readSettings() {
27
+ return readJson<any>("settings.json");
28
+ }
29
+
30
+ export function writeSettings(settings: any): boolean {
31
+ try {
32
+ const path = piPath("settings.json");
33
+ const backup = existsSync(path) ? readFileSync(path, "utf-8") : null;
34
+ const raw = JSON.stringify(settings, null, 2);
35
+ writeFileSync(path, raw, "utf-8");
36
+ return true;
37
+ } catch {
38
+ return false;
39
+ }
40
+ }
41
+
42
+ import { writeFileSync } from "fs";
43
+
44
+ // ─── Auth ───────────────────────────────────────────────
45
+
46
+ export function readAuth() {
47
+ return readJson<any>("auth.json");
48
+ }
49
+
50
+ export function writeAuth(auth: any): boolean {
51
+ try {
52
+ const path = piPath("auth.json");
53
+ writeFileSync(path, JSON.stringify(auth, null, 2), "utf-8");
54
+ return true;
55
+ } catch {
56
+ return false;
57
+ }
58
+ }
59
+
60
+ // ─── Models (custom providers) ──────────────────────────
61
+
62
+ export function readModels() {
63
+ return readJson<{ providers: Record<string, any> }>("models.json");
64
+ }
65
+
66
+ export function writeModels(models: any): boolean {
67
+ try {
68
+ const path = piPath("models.json");
69
+ writeFileSync(path, JSON.stringify(models, null, 2), "utf-8");
70
+ return true;
71
+ } catch {
72
+ return false;
73
+ }
74
+ }
75
+
76
+ // ─── Session Usage Parser ───────────────────────────────
77
+
78
+ interface UsageRecord {
79
+ date: string;
80
+ hour?: number;
81
+ providerId: string;
82
+ modelId: string;
83
+ inputTokens: number;
84
+ outputTokens: number;
85
+ cacheReadTokens: number;
86
+ cacheWriteTokens: number;
87
+ requests: number;
88
+ cost: number;
89
+ }
90
+
91
+ interface UsageEvent {
92
+ input?: number;
93
+ output?: number;
94
+ cacheRead?: number;
95
+ cacheWrite?: number;
96
+ totalTokens?: number;
97
+ cost?: {
98
+ input?: number;
99
+ output?: number;
100
+ cacheRead?: number;
101
+ cacheWrite?: number;
102
+ total?: number;
103
+ };
104
+ }
105
+
106
+ function getSessionDirs(): string[] {
107
+ const sessionsPath = join(PI_DIR, "sessions");
108
+ if (!existsSync(sessionsPath)) return [];
109
+ return readdirSync(sessionsPath)
110
+ .filter((name) => name.startsWith("--"))
111
+ .map((name) => join(sessionsPath, name))
112
+ .filter((dir) => statSync(dir).isDirectory());
113
+ }
114
+
115
+ function parseSessionFile(filePath: string): UsageRecord[] {
116
+ const records: UsageRecord[] = [];
117
+ try {
118
+ const raw = readFileSync(filePath, "utf-8");
119
+ const lines = raw.split("\n").filter((l) => l.trim());
120
+
121
+ let currentProvider = "unknown";
122
+ let currentModel = "unknown";
123
+
124
+ for (const line of lines) {
125
+ try {
126
+ const obj = JSON.parse(line);
127
+ const type = obj.type;
128
+
129
+ if (type === "model_change") {
130
+ currentProvider = obj.provider || currentProvider;
131
+ currentModel = obj.modelId || currentModel;
132
+ continue;
133
+ }
134
+
135
+ if (type === "message" && obj.message?.role === "assistant") {
136
+ const usage: UsageEvent | undefined = obj.message.usage;
137
+ if (!usage || !usage.input) continue;
138
+
139
+ const timestamp = obj.timestamp || obj.message.timestamp;
140
+ const d = new Date(timestamp);
141
+ const date = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
142
+ const hour = d.getHours();
143
+
144
+ records.push({
145
+ date,
146
+ hour,
147
+ providerId: obj.message.provider || currentProvider,
148
+ modelId: obj.message.model || currentModel,
149
+ inputTokens: usage.input ?? 0,
150
+ outputTokens: usage.output ?? 0,
151
+ cacheReadTokens: usage.cacheRead ?? 0,
152
+ cacheWriteTokens: usage.cacheWrite ?? 0,
153
+ requests: 1,
154
+ cost: usage.cost?.total ?? 0,
155
+ });
156
+ }
157
+ } catch {
158
+ // skip malformed lines
159
+ }
160
+ }
161
+ } catch {
162
+ // skip unreadable files
163
+ }
164
+ return records;
165
+ }
166
+
167
+ export function readAllUsage(): UsageRecord[] {
168
+ const allRecords: UsageRecord[] = [];
169
+ const dirs = getSessionDirs();
170
+
171
+ for (const dir of dirs) {
172
+ try {
173
+ const files = readdirSync(dir).filter((f) => f.endsWith(".jsonl"));
174
+ for (const file of files) {
175
+ const filePath = join(dir, file);
176
+ const records = parseSessionFile(filePath);
177
+ allRecords.push(...records);
178
+ }
179
+ } catch {
180
+ // skip unreadable directories
181
+ }
182
+ }
183
+
184
+ // Sort by date ascending
185
+ allRecords.sort((a, b) => a.date.localeCompare(b.date));
186
+ return allRecords;
187
+ }
188
+
189
+ // ─── Aggregation Helpers ────────────────────────────────
190
+
191
+ export function getDailyAggregates(records: UsageRecord[]) {
192
+ const daily = new Map<
193
+ string,
194
+ {
195
+ totalTokens: number;
196
+ totalCost: number;
197
+ totalRequests: number;
198
+ inputTokens: number;
199
+ outputTokens: number;
200
+ }
201
+ >();
202
+
203
+ for (const r of records) {
204
+ const d = daily.get(r.date) ?? {
205
+ totalTokens: 0,
206
+ totalCost: 0,
207
+ totalRequests: 0,
208
+ inputTokens: 0,
209
+ outputTokens: 0,
210
+ };
211
+ d.totalTokens += r.inputTokens + r.outputTokens + r.cacheReadTokens + r.cacheWriteTokens;
212
+ d.totalCost += r.cost;
213
+ d.totalRequests += r.requests;
214
+ d.inputTokens += r.inputTokens;
215
+ d.outputTokens += r.outputTokens;
216
+ daily.set(r.date, d);
217
+ }
218
+
219
+ return Array.from(daily.entries())
220
+ .map(([date, agg]) => ({ date, ...agg }))
221
+ .sort((a, b) => a.date.localeCompare(b.date));
222
+ }
223
+
224
+ export function getProviderSummaries(records: UsageRecord[]) {
225
+ const sums = new Map<
226
+ string,
227
+ { totalTokens: number; totalCost: number; totalRequests: number }
228
+ >();
229
+
230
+ for (const r of records) {
231
+ const s = sums.get(r.providerId) ?? {
232
+ totalTokens: 0,
233
+ totalCost: 0,
234
+ totalRequests: 0,
235
+ };
236
+ s.totalTokens += r.inputTokens + r.outputTokens + r.cacheReadTokens + r.cacheWriteTokens;
237
+ s.totalCost += r.cost;
238
+ s.totalRequests += r.requests;
239
+ sums.set(r.providerId, s);
240
+ }
241
+
242
+ return Array.from(sums.entries()).map(([providerId, s]) => ({
243
+ providerId,
244
+ ...s,
245
+ }));
246
+ }
247
+
248
+ export function getModelSummaries(records: UsageRecord[]) {
249
+ const sums = new Map<
250
+ string,
251
+ {
252
+ providerId: string;
253
+ totalTokens: number;
254
+ totalCost: number;
255
+ totalRequests: number;
256
+ count: number;
257
+ }
258
+ >();
259
+
260
+ for (const r of records) {
261
+ const key = `${r.providerId}/${r.modelId}`;
262
+ const s = sums.get(key) ?? {
263
+ providerId: r.providerId,
264
+ totalTokens: 0,
265
+ totalCost: 0,
266
+ totalRequests: 0,
267
+ count: 0,
268
+ };
269
+ s.totalTokens += r.inputTokens + r.outputTokens + r.cacheReadTokens + r.cacheWriteTokens;
270
+ s.totalCost += r.cost;
271
+ s.totalRequests += r.requests;
272
+ s.count++;
273
+ sums.set(key, s);
274
+ }
275
+
276
+ return Array.from(sums.entries()).map(([key, s]) => {
277
+ const [providerId, modelId] = key.split("/");
278
+ return {
279
+ modelId: modelId!,
280
+ providerId: s.providerId,
281
+ totalTokens: s.totalTokens,
282
+ totalCost: s.totalCost,
283
+ totalRequests: s.totalRequests,
284
+ avgTokensPerRequest: s.totalRequests > 0 ? Math.round(s.totalTokens / s.totalRequests) : 0,
285
+ };
286
+ });
287
+ }
288
+
289
+ export function getTotals(records: UsageRecord[]) {
290
+ let totalTokens = 0;
291
+ let totalCost = 0;
292
+ let totalRequests = 0;
293
+
294
+ for (const r of records) {
295
+ totalTokens += r.inputTokens + r.outputTokens + r.cacheReadTokens + r.cacheWriteTokens;
296
+ totalCost += r.cost;
297
+ totalRequests += r.requests;
298
+ }
299
+
300
+ return { totalTokens, totalCost, totalRequests };
301
+ }
302
+
303
+ // ─── Date-Range Usage ───────────────────────────────────
304
+
305
+ export function getUsageByRange(records: UsageRecord[], fromDate: string, toDate: string) {
306
+ const filtered = records.filter((r) => r.date >= fromDate && r.date <= toDate);
307
+
308
+ let totalInput = 0;
309
+ let totalOutput = 0;
310
+ let totalCacheRead = 0;
311
+ let totalCacheWrite = 0;
312
+ let totalCost = 0;
313
+ let totalRequests = 0;
314
+
315
+ for (const r of filtered) {
316
+ totalInput += r.inputTokens;
317
+ totalOutput += r.outputTokens;
318
+ totalCacheRead += r.cacheReadTokens;
319
+ totalCacheWrite += r.cacheWriteTokens;
320
+ totalCost += r.cost;
321
+ totalRequests += r.requests;
322
+ }
323
+
324
+ const totalTokens = totalInput + totalOutput + totalCacheRead + totalCacheWrite;
325
+ const cacheHitRate = totalTokens > 0 ? ((totalCacheRead + totalCacheWrite) / totalTokens) * 100 : 0;
326
+
327
+ // Per-day breakdown for the trend chart
328
+ const daily = new Map<string, {
329
+ input: number; output: number; cacheRead: number; cacheWrite: number;
330
+ cost: number; requests: number;
331
+ }>();
332
+
333
+ for (const r of filtered) {
334
+ const d = daily.get(r.date) ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, requests: 0 };
335
+ d.input += r.inputTokens;
336
+ d.output += r.outputTokens;
337
+ d.cacheRead += r.cacheReadTokens;
338
+ d.cacheWrite += r.cacheWriteTokens;
339
+ d.cost += r.cost;
340
+ d.requests += r.requests;
341
+ daily.set(r.date, d);
342
+ }
343
+
344
+ // Hourly breakdown for "today" view
345
+ const hourly = new Map<string, {
346
+ hour: string;
347
+ input: number; output: number; cacheRead: number; cacheWrite: number;
348
+ cost: number; requests: number;
349
+ }>();
350
+
351
+ for (const r of filtered) {
352
+ if (r.hour !== undefined) {
353
+ const hKey = `${r.date} ${String(r.hour).padStart(2, "0")}:00`;
354
+ const h = hourly.get(hKey) ?? {
355
+ hour: hKey, input: 0, output: 0, cacheRead: 0, cacheWrite: 0,
356
+ cost: 0, requests: 0,
357
+ };
358
+ h.input += r.inputTokens;
359
+ h.output += r.outputTokens;
360
+ h.cacheRead += r.cacheReadTokens;
361
+ h.cacheWrite += r.cacheWriteTokens;
362
+ h.cost += r.cost;
363
+ h.requests += r.requests;
364
+ hourly.set(hKey, h);
365
+ }
366
+ }
367
+
368
+ // Build request log entries from filtered records
369
+ // Each record represents one assistant message with usage data
370
+ // Group by (date, providerId, modelId) to form log entries
371
+ const requestLog = new Map<string, {
372
+ timestamp: string;
373
+ providerId: string;
374
+ modelId: string;
375
+ input: number;
376
+ output: number;
377
+ cost: number;
378
+ requests: number;
379
+ }>();
380
+
381
+ for (const r of filtered) {
382
+ const key = `${r.date}|${r.providerId}|${r.modelId}`;
383
+ const existing = requestLog.get(key) ?? {
384
+ timestamp: r.date,
385
+ providerId: r.providerId,
386
+ modelId: r.modelId,
387
+ input: 0,
388
+ output: 0,
389
+ cost: 0,
390
+ requests: 0,
391
+ };
392
+ existing.input += r.inputTokens;
393
+ existing.output += r.outputTokens;
394
+ existing.cost += r.cost;
395
+ existing.requests += r.requests;
396
+ requestLog.set(key, existing);
397
+ }
398
+
399
+ // Build provider stats
400
+ const providerStats = new Map<string, {
401
+ providerId: string;
402
+ totalTokens: number;
403
+ totalInput: number;
404
+ totalOutput: number;
405
+ totalCost: number;
406
+ totalRequests: number;
407
+ modelCount: Set<string>;
408
+ }>();
409
+
410
+ // Build model stats
411
+ const modelStats = new Map<string, {
412
+ modelId: string;
413
+ providerId: string;
414
+ totalTokens: number;
415
+ totalInput: number;
416
+ totalOutput: number;
417
+ totalCost: number;
418
+ totalRequests: number;
419
+ }>();
420
+
421
+ for (const r of filtered) {
422
+ // Provider stats
423
+ const ps = providerStats.get(r.providerId) ?? {
424
+ providerId: r.providerId,
425
+ totalTokens: 0,
426
+ totalInput: 0,
427
+ totalOutput: 0,
428
+ totalCost: 0,
429
+ totalRequests: 0,
430
+ modelCount: new Set<string>(),
431
+ };
432
+ ps.totalTokens += r.inputTokens + r.outputTokens + r.cacheReadTokens + r.cacheWriteTokens;
433
+ ps.totalInput += r.inputTokens;
434
+ ps.totalOutput += r.outputTokens;
435
+ ps.totalCost += r.cost;
436
+ ps.totalRequests += r.requests;
437
+ ps.modelCount.add(r.modelId);
438
+ providerStats.set(r.providerId, ps);
439
+
440
+ // Model stats
441
+ const mk = `${r.providerId}/${r.modelId}`;
442
+ const ms = modelStats.get(mk) ?? {
443
+ modelId: r.modelId,
444
+ providerId: r.providerId,
445
+ totalTokens: 0,
446
+ totalInput: 0,
447
+ totalOutput: 0,
448
+ totalCost: 0,
449
+ totalRequests: 0,
450
+ };
451
+ ms.totalTokens += r.inputTokens + r.outputTokens + r.cacheReadTokens + r.cacheWriteTokens;
452
+ ms.totalInput += r.inputTokens;
453
+ ms.totalOutput += r.outputTokens;
454
+ ms.totalCost += r.cost;
455
+ ms.totalRequests += r.requests;
456
+ modelStats.set(mk, ms);
457
+ }
458
+
459
+ return {
460
+ totalTokens,
461
+ totalInput,
462
+ totalOutput,
463
+ totalCacheRead,
464
+ totalCacheWrite,
465
+ totalCost,
466
+ totalRequests,
467
+ cacheHitRate: Math.round(cacheHitRate * 10) / 10,
468
+ dailyBreakdown: Array.from(daily.entries())
469
+ .map(([date, d]) => ({ date, ...d }))
470
+ .sort((a, b) => a.date.localeCompare(b.date)),
471
+ hourlyBreakdown: Array.from(hourly.entries())
472
+ .map(([, h]) => ({ hour: h.hour, input: h.input, output: h.output, cacheRead: h.cacheRead, cacheWrite: h.cacheWrite, cost: h.cost, requests: h.requests }))
473
+ .sort((a, b) => a.hour.localeCompare(b.hour)),
474
+ requestLog: Array.from(requestLog.values())
475
+ .sort((a, b) => b.timestamp.localeCompare(a.timestamp)),
476
+ providerStats: Array.from(providerStats.values())
477
+ .map((ps) => ({ ...ps, modelCount: ps.modelCount.size }))
478
+ .sort((a, b) => b.totalCost - a.totalCost),
479
+ modelStats: Array.from(modelStats.values())
480
+ .sort((a, b) => b.totalCost - a.totalCost),
481
+ };
482
+ }
483
+
484
+ // ─── Hermes Memory Reader ───────────────────────────────
485
+
486
+ const HERMES_DIR = join(PI_DIR, "pi-hermes-memory");
487
+
488
+ interface MemoryFile {
489
+ name: string;
490
+ filename: string;
491
+ content: string;
492
+ updatedAt: string;
493
+ }
494
+
495
+ export function readMemoryFiles(): MemoryFile[] {
496
+ const files = [
497
+ { name: "Project Memories", filename: "MEMORY.md" },
498
+ { name: "User Profile", filename: "USER.md" },
499
+ { name: "Failure Records", filename: "failures.md" },
500
+ ];
501
+
502
+ return files.map(({ name, filename }) => {
503
+ const filePath = join(HERMES_DIR, filename);
504
+ let content = "";
505
+ let updatedAt = "";
506
+ try {
507
+ if (existsSync(filePath)) {
508
+ content = readFileSync(filePath, "utf-8");
509
+ const stat = statSync(filePath);
510
+ updatedAt = stat.mtime.toISOString();
511
+ }
512
+ } catch {
513
+ content = "// Error reading file";
514
+ }
515
+ return { name, filename, content, updatedAt };
516
+ });
517
+ }
518
+
519
+ // ─── Session Listing ────────────────────────────────────
520
+
521
+ interface SessionFileInfo {
522
+ id: string;
523
+ fileName: string;
524
+ filePath: string;
525
+ timestamp: string;
526
+ lastActive: string;
527
+ name?: string;
528
+ provider?: string;
529
+ model?: string;
530
+ messageCount: number;
531
+ duration?: number;
532
+ }
533
+
534
+ interface ProjectGroup {
535
+ projectPath: string;
536
+ projectName: string;
537
+ sessions: SessionFileInfo[];
538
+ totalSessions: number;
539
+ lastActive: string;
540
+ }
541
+
542
+ function decodeProjectName(dirName: string): { projectPath: string; projectName: string } {
543
+ // dirName: "--Users-a123--workspace-wwwroot-X-xenicalofficial-official-v1--"
544
+ // Replace "--" with "/", trim leading/trailing "/" and "-"
545
+ let decoded = dirName.replace(/^--|--$/g, "").replace(/--/g, "/");
546
+ // Remove leading "/Users/a123" or similar home path prefix for display
547
+ const home = homedir();
548
+ let displayName = decoded;
549
+ if (displayName.startsWith(home)) {
550
+ displayName = "~" + displayName.slice(home.length);
551
+ }
552
+ // Use the last 1-2 path segments as the project name
553
+ const segments = displayName.split("/").filter(Boolean);
554
+ const projectName = segments.length > 0 ? segments[segments.length - 1] : dirName;
555
+ return { projectPath: decoded, projectName };
556
+ }
557
+
558
+ export function listSessions(): ProjectGroup[] {
559
+ const dirs = getSessionDirs();
560
+ const groups = new Map<string, ProjectGroup>();
561
+
562
+ for (const dir of dirs) {
563
+ const dirName = dir.split("/").pop() || dir;
564
+ const { projectPath, projectName } = decodeProjectName(dirName);
565
+
566
+ if (!groups.has(projectPath)) {
567
+ groups.set(projectPath, {
568
+ projectPath,
569
+ projectName,
570
+ sessions: [],
571
+ totalSessions: 0,
572
+ lastActive: "",
573
+ });
574
+ }
575
+
576
+ const group = groups.get(projectPath)!;
577
+ const files = readdirSync(dir)
578
+ .filter((f) => f.endsWith(".jsonl"))
579
+ .sort()
580
+ .reverse(); // newest first
581
+
582
+ for (const file of files) {
583
+ const filePath = join(dir, file);
584
+ const session = parseSessionFileInfo(filePath);
585
+ if (session) {
586
+ group.sessions.push(session);
587
+ }
588
+ }
589
+
590
+ group.totalSessions = group.sessions.length;
591
+ if (group.sessions.length > 0) {
592
+ group.lastActive = group.sessions[0].timestamp; // already sorted newest-first
593
+ }
594
+ }
595
+
596
+ // Sort groups by lastActive descending
597
+ return Array.from(groups.values())
598
+ .filter((g) => g.sessions.length > 0)
599
+ .sort((a, b) => b.lastActive.localeCompare(a.lastActive));
600
+ }
601
+
602
+ function parseSessionFileInfo(filePath: string): SessionFileInfo | null {
603
+ try {
604
+ const raw = readFileSync(filePath, "utf-8");
605
+ const lines = raw.split("\n").filter((l) => l.trim());
606
+
607
+ let id = "";
608
+ let timestamp = "";
609
+ let name: string | undefined;
610
+ let provider = "unknown";
611
+ let model = "unknown";
612
+ let messageCount = 0;
613
+ let firstTs = 0;
614
+ let lastTs = 0;
615
+
616
+ for (const line of lines) {
617
+ try {
618
+ const obj = JSON.parse(line);
619
+ const type = obj.type;
620
+
621
+ if (type === "session") {
622
+ id = obj.id || "";
623
+ timestamp = obj.timestamp || "";
624
+ const ts = new Date(timestamp).getTime();
625
+ firstTs = ts;
626
+ lastTs = ts;
627
+ } else if (type === "session_info") {
628
+ name = obj.name || name;
629
+ } else if (type === "model_change") {
630
+ provider = obj.provider || provider;
631
+ model = obj.modelId || model;
632
+ } else if (type === "message") {
633
+ messageCount++;
634
+ const ts = new Date(obj.timestamp).getTime();
635
+ if (ts > lastTs) lastTs = ts;
636
+ if (firstTs === 0) firstTs = ts;
637
+ }
638
+ } catch {
639
+ // skip
640
+ }
641
+ }
642
+
643
+ const duration = lastTs > firstTs ? lastTs - firstTs : undefined;
644
+ const fileName = filePath.split("/").pop() || filePath;
645
+
646
+ return {
647
+ id,
648
+ fileName,
649
+ filePath,
650
+ timestamp,
651
+ lastActive: lastTs > 0 ? new Date(lastTs).toISOString() : timestamp,
652
+ name,
653
+ provider,
654
+ model,
655
+ messageCount,
656
+ duration,
657
+ };
658
+ } catch {
659
+ return null;
660
+ }
661
+ }
662
+
663
+ // ─── Delete Session ─────────────────────────────────────
664
+
665
+ export function deleteSessionFile(filePath: string): boolean {
666
+ try {
667
+ // Security: only allow deleting files within the sessions directory
668
+ const sessionsPath = join(PI_DIR, "sessions");
669
+ if (!filePath.startsWith(sessionsPath)) return false;
670
+ if (!filePath.endsWith(".jsonl")) return false;
671
+ if (!existsSync(filePath)) return false;
672
+
673
+ unlinkSync(filePath);
674
+ return true;
675
+ } catch {
676
+ return false;
677
+ }
678
+ }
package/src/App.tsx ADDED
@@ -0,0 +1,25 @@
1
+ import { BrowserRouter, Routes, Route } from "react-router-dom";
2
+ import { AppShell } from "@/components/layout/AppShell";
3
+ import { DashboardPage } from "@/components/dashboard/DashboardPage";
4
+ import { ModelsPage } from "@/components/models/ModelsPage";
5
+ import { ProvidersPage } from "@/components/providers/ProvidersPage";
6
+ import { SessionsPage } from "@/components/sessions/SessionsPage";
7
+ import { MemoryPage } from "@/components/sessions/MemoryPage";
8
+ import { SettingsPage } from "@/components/settings/SettingsPage";
9
+
10
+ export default function App() {
11
+ return (
12
+ <BrowserRouter>
13
+ <Routes>
14
+ <Route element={<AppShell />}>
15
+ <Route path="/" element={<DashboardPage />} />
16
+ <Route path="/models" element={<ModelsPage />} />
17
+ <Route path="/providers" element={<ProvidersPage />} />
18
+ <Route path="/sessions" element={<SessionsPage />} />
19
+ <Route path="/memory" element={<MemoryPage />} />
20
+ <Route path="/settings" element={<SettingsPage />} />
21
+ </Route>
22
+ </Routes>
23
+ </BrowserRouter>
24
+ );
25
+ }