@raingor/pi-web-switch 0.4.2 → 0.4.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 +18 -0
- package/package.json +5 -38
- 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 +41 -6
- package/server/pi-reader.ts +28 -219
- package/src/components/dashboard/DashboardPage.tsx +0 -29
- package/src/components/layout/Sidebar.tsx +3 -14
- package/src/components/sessions/MemoryPage.tsx +2 -1
- package/src/components/sessions/SessionsPage.tsx +2 -36
- package/src/lib/translations/en.ts +0 -5
- package/src/lib/translations/ja.ts +0 -5
- package/src/lib/translations/zh-CN.ts +0 -5
- package/src/lib/translations/zh-TW.ts +0 -5
- package/src/main.tsx +5 -29
- package/src/types/index.ts +0 -1
- package/vite.config.ts +3 -27
- package/dist-electron/main/main.cjs +0 -2453
package/dist/index.html
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
|
6
|
+
<title>pi-switch — pi Configuration Manager</title>
|
|
7
|
+
<link rel="icon" type="image/svg+xml" href="./pi.svg" />
|
|
8
|
+
<link rel="apple-touch-icon" href="./apple-touch-icon.png" />
|
|
9
|
+
<link rel="manifest" href="./manifest.webmanifest" />
|
|
10
|
+
<meta name="theme-color" content="#2563eb" />
|
|
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-eom_4fyA.js"></script>
|
|
13
|
+
<link rel="stylesheet" crossorigin href="./assets/main-M_eVNxKU.css">
|
|
14
|
+
</head>
|
|
15
|
+
<body class="bg-gray-950 text-gray-100 antialiased">
|
|
16
|
+
<div id="root"></div>
|
|
17
|
+
</body>
|
|
18
|
+
</html>
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@raingor/pi-web-switch",
|
|
3
3
|
"private": false,
|
|
4
|
-
"version": "0.4.
|
|
4
|
+
"version": "0.4.3",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"main": "dist
|
|
6
|
+
"main": "dist/index.html",
|
|
7
7
|
"description": "Web UI for pi coding agent — live configuration management, session browser, and memory viewer",
|
|
8
8
|
"keywords": [
|
|
9
9
|
"pi-package",
|
|
@@ -26,11 +26,7 @@
|
|
|
26
26
|
"scripts": {
|
|
27
27
|
"dev": "vite",
|
|
28
28
|
"build": "tsc -b && vite build",
|
|
29
|
-
"preview": "vite preview"
|
|
30
|
-
"electron:dev": "node scripts/electron-dev.mjs",
|
|
31
|
-
"electron:build": "tsc -b && vite build && vite build --config vite.electron.config.ts && vite build --config vite.preload.config.ts && electron-builder",
|
|
32
|
-
"electron:preview": "vite build && vite build --config vite.electron.config.ts && vite build --config vite.preload.config.ts && env -u ELECTRON_RUN_AS_NODE npx electron .",
|
|
33
|
-
"tray:icon": "node scripts/generate-tray-icon.mjs"
|
|
29
|
+
"preview": "vite preview"
|
|
34
30
|
},
|
|
35
31
|
"files": [
|
|
36
32
|
"pi-package",
|
|
@@ -69,14 +65,10 @@
|
|
|
69
65
|
"@types/react": "^19.1.2",
|
|
70
66
|
"@types/react-dom": "^19.1.2",
|
|
71
67
|
"@vitejs/plugin-react": "^4.4.1",
|
|
72
|
-
"electron": "^43.2.0",
|
|
73
|
-
"electron-builder": "^26.15.3",
|
|
74
|
-
"electron-vite": "^5.0.0",
|
|
75
68
|
"tailwindcss": "^4.1.4",
|
|
76
69
|
"typescript": "~5.8.3",
|
|
77
70
|
"undici": "^8.9.0",
|
|
78
71
|
"vite": "^6.3.2",
|
|
79
|
-
"vite-plugin-electron": "^1.1.0",
|
|
80
72
|
"vite-plugin-pwa": "^1.3.0"
|
|
81
73
|
},
|
|
82
74
|
"build": {
|
|
@@ -86,32 +78,7 @@
|
|
|
86
78
|
"output": "release"
|
|
87
79
|
},
|
|
88
80
|
"files": [
|
|
89
|
-
"dist/**/*"
|
|
90
|
-
|
|
91
|
-
],
|
|
92
|
-
"mac": {
|
|
93
|
-
"target": [
|
|
94
|
-
{
|
|
95
|
-
"target": "dmg"
|
|
96
|
-
},
|
|
97
|
-
{
|
|
98
|
-
"target": "zip"
|
|
99
|
-
}
|
|
100
|
-
],
|
|
101
|
-
"icon": "build/icon.icns",
|
|
102
|
-
"extendInfo": {
|
|
103
|
-
"LSMinimumSystemVersion": "11.0.0",
|
|
104
|
-
"CFBundleName": "pi-web-switch"
|
|
105
|
-
},
|
|
106
|
-
"category": "public.app-category.developer-tools"
|
|
107
|
-
},
|
|
108
|
-
"win": {
|
|
109
|
-
"target": "nsis",
|
|
110
|
-
"icon": "build/icon.ico"
|
|
111
|
-
},
|
|
112
|
-
"linux": {
|
|
113
|
-
"target": "AppImage",
|
|
114
|
-
"icon": "build/icon.png"
|
|
115
|
-
}
|
|
81
|
+
"dist/**/*"
|
|
82
|
+
]
|
|
116
83
|
}
|
|
117
84
|
}
|
|
Binary file
|
package/public/icon-192.png
CHANGED
|
Binary file
|
package/public/icon-512.png
CHANGED
|
Binary file
|
package/public/pi.svg
CHANGED
|
@@ -1,12 +1,47 @@
|
|
|
1
1
|
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512" fill="none">
|
|
2
2
|
<defs>
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
<stop offset="
|
|
3
|
+
<!-- Brand blue diagonal gradient -->
|
|
4
|
+
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
|
5
|
+
<stop offset="0" stop-color="#6ba6fb"/>
|
|
6
|
+
<stop offset="0.5" stop-color="#2563eb"/>
|
|
7
|
+
<stop offset="1" stop-color="#1e40af"/>
|
|
6
8
|
</linearGradient>
|
|
9
|
+
<!-- Soft top gloss for a tactile, premium feel -->
|
|
10
|
+
<linearGradient id="gloss" x1="0" y1="0" x2="0" y2="1">
|
|
11
|
+
<stop offset="0" stop-color="#ffffff" stop-opacity="0.28"/>
|
|
12
|
+
<stop offset="1" stop-color="#ffffff" stop-opacity="0"/>
|
|
13
|
+
</linearGradient>
|
|
14
|
+
<!-- Subtle vertical shading on the pi glyph -->
|
|
15
|
+
<linearGradient id="piGrad" x1="0" y1="0" x2="0" y2="1">
|
|
16
|
+
<stop offset="0" stop-color="#ffffff"/>
|
|
17
|
+
<stop offset="1" stop-color="#d9e6ff"/>
|
|
18
|
+
</linearGradient>
|
|
19
|
+
<!-- Toggle knob highlight -->
|
|
20
|
+
<radialGradient id="knob" cx="0.35" cy="0.3" r="0.85">
|
|
21
|
+
<stop offset="0" stop-color="#ffffff"/>
|
|
22
|
+
<stop offset="1" stop-color="#cdd9f2"/>
|
|
23
|
+
</radialGradient>
|
|
24
|
+
<filter id="softShadow" x="-30%" y="-30%" width="160%" height="160%">
|
|
25
|
+
<feDropShadow dx="0" dy="7" stdDeviation="11" flood-color="#0a2a6b" flood-opacity="0.38"/>
|
|
26
|
+
</filter>
|
|
27
|
+
<filter id="knobShadow" x="-60%" y="-60%" width="220%" height="220%">
|
|
28
|
+
<feDropShadow dx="0" dy="3" stdDeviation="5" flood-color="#0a2a6b" flood-opacity="0.35"/>
|
|
29
|
+
</filter>
|
|
7
30
|
</defs>
|
|
8
|
-
|
|
9
|
-
|
|
31
|
+
|
|
32
|
+
<!-- Squircle background -->
|
|
33
|
+
<rect x="0" y="0" width="512" height="512" rx="115" fill="url(#bg)"/>
|
|
34
|
+
<!-- Top gloss highlight -->
|
|
35
|
+
<rect x="0" y="0" width="512" height="250" rx="115" fill="url(#gloss)"/>
|
|
36
|
+
|
|
37
|
+
<!-- Toggle switch track (behind the pi) -->
|
|
38
|
+
<rect x="92" y="217" width="328" height="78" rx="39" fill="#ffffff" opacity="0.20"/>
|
|
39
|
+
<rect x="92" y="217" width="328" height="78" rx="39" fill="none" stroke="#ffffff" stroke-opacity="0.25" stroke-width="2"/>
|
|
40
|
+
<!-- Toggle knob in the "on" position -->
|
|
41
|
+
<circle cx="378" cy="256" r="30" fill="url(#knob)" filter="url(#knobShadow)"/>
|
|
42
|
+
|
|
43
|
+
<!-- Pi glyph -->
|
|
44
|
+
<text x="256" y="256" text-anchor="middle" dominant-baseline="central"
|
|
10
45
|
font-family="-apple-system, 'SF Pro Display', 'Segoe UI', Roboto, Helvetica, Arial, sans-serif"
|
|
11
|
-
font-size="
|
|
46
|
+
font-size="258" font-weight="700" fill="url(#piGrad)" filter="url(#softShadow)">π</text>
|
|
12
47
|
</svg>
|
package/server/pi-reader.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFileSync, readdirSync, existsSync, statSync, unlinkSync, writeFileSync, mkdirSync, renameSync
|
|
1
|
+
import { readFileSync, readdirSync, existsSync, statSync, unlinkSync, writeFileSync, mkdirSync, renameSync } from "fs";
|
|
2
2
|
import { homedir, platform } from "os";
|
|
3
3
|
import { join, resolve, dirname, relative, sep } from "path";
|
|
4
4
|
import { spawnSync } from "child_process";
|
|
@@ -125,47 +125,6 @@ function getSessionDirs(): string[] {
|
|
|
125
125
|
.filter((dir) => statSync(dir).isDirectory());
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
-
/**
|
|
129
|
-
* Recursively walk a project directory to find all session JSONL files.
|
|
130
|
-
*
|
|
131
|
-
* Supports both the legacy flat layout and the current nested layout:
|
|
132
|
-
* Legacy: --project--/session.jsonl (any .jsonl file at the project root)
|
|
133
|
-
* Current: --project--/{sessionId}/{hash}/run-0/session.jsonl
|
|
134
|
-
*/
|
|
135
|
-
function walkSessionJsonl(dir: string, out: string[]): void {
|
|
136
|
-
let entries: string[];
|
|
137
|
-
try {
|
|
138
|
-
entries = readdirSync(dir);
|
|
139
|
-
} catch {
|
|
140
|
-
return;
|
|
141
|
-
}
|
|
142
|
-
for (const name of entries) {
|
|
143
|
-
const p = join(dir, name);
|
|
144
|
-
try {
|
|
145
|
-
const stat = statSync(p);
|
|
146
|
-
if (stat.isDirectory()) {
|
|
147
|
-
walkSessionJsonl(p, out);
|
|
148
|
-
} else if (name.endsWith(".jsonl")) {
|
|
149
|
-
out.push(p);
|
|
150
|
-
}
|
|
151
|
-
} catch {
|
|
152
|
-
// skip unreadable entries
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/**
|
|
158
|
-
* Return every session JSONL file across all project directories, handling
|
|
159
|
-
* both the legacy flat layout and the current nested run-0 layout.
|
|
160
|
-
*/
|
|
161
|
-
function getAllSessionFiles(): string[] {
|
|
162
|
-
const files: string[] = [];
|
|
163
|
-
for (const dir of getSessionDirs()) {
|
|
164
|
-
walkSessionJsonl(dir, files);
|
|
165
|
-
}
|
|
166
|
-
return files;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
128
|
// Usage stats are bucketed in China time (UTC+8) regardless of the machine's
|
|
170
129
|
// system timezone, so daily totals stay consistent for a Beijing-based user.
|
|
171
130
|
const CN_TZ = "Asia/Shanghai";
|
|
@@ -254,13 +213,22 @@ export function readAllUsage(): UsageRecord[] {
|
|
|
254
213
|
return usageCache.records;
|
|
255
214
|
}
|
|
256
215
|
const allRecords: UsageRecord[] = [];
|
|
257
|
-
const
|
|
216
|
+
const dirs = getSessionDirs();
|
|
258
217
|
|
|
259
|
-
for (const
|
|
260
|
-
|
|
261
|
-
|
|
218
|
+
for (const dir of dirs) {
|
|
219
|
+
try {
|
|
220
|
+
const files = readdirSync(dir).filter((f) => f.endsWith(".jsonl"));
|
|
221
|
+
for (const file of files) {
|
|
222
|
+
const filePath = join(dir, file);
|
|
223
|
+
const records = parseSessionFile(filePath);
|
|
224
|
+
allRecords.push(...records);
|
|
225
|
+
}
|
|
226
|
+
} catch {
|
|
227
|
+
// skip unreadable directories
|
|
228
|
+
}
|
|
262
229
|
}
|
|
263
230
|
|
|
231
|
+
// Sort by date ascending
|
|
264
232
|
allRecords.sort((a, b) => a.date.localeCompare(b.date));
|
|
265
233
|
usageCache = { records: allRecords, at: Date.now() };
|
|
266
234
|
return allRecords;
|
|
@@ -1094,12 +1062,13 @@ export function listSessions(): ProjectGroup[] {
|
|
|
1094
1062
|
}
|
|
1095
1063
|
|
|
1096
1064
|
const group = groups.get(projectPath)!;
|
|
1097
|
-
const files
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1065
|
+
const files = readdirSync(dir)
|
|
1066
|
+
.filter((f) => f.endsWith(".jsonl"))
|
|
1067
|
+
.sort()
|
|
1068
|
+
.reverse(); // newest first
|
|
1101
1069
|
|
|
1102
|
-
for (const
|
|
1070
|
+
for (const file of files) {
|
|
1071
|
+
const filePath = join(dir, file);
|
|
1103
1072
|
const session = parseSessionFileInfo(filePath);
|
|
1104
1073
|
if (session) {
|
|
1105
1074
|
group.sessions.push(session);
|
|
@@ -1108,10 +1077,11 @@ export function listSessions(): ProjectGroup[] {
|
|
|
1108
1077
|
|
|
1109
1078
|
group.totalSessions = group.sessions.length;
|
|
1110
1079
|
if (group.sessions.length > 0) {
|
|
1111
|
-
group.lastActive = group.sessions[0]?.timestamp ?? "";
|
|
1080
|
+
group.lastActive = group.sessions[0]?.timestamp ?? ""; // already sorted newest-first
|
|
1112
1081
|
}
|
|
1113
1082
|
}
|
|
1114
1083
|
|
|
1084
|
+
// Sort groups by lastActive descending
|
|
1115
1085
|
return Array.from(groups.values())
|
|
1116
1086
|
.filter((g) => g.sessions.length > 0)
|
|
1117
1087
|
.sort((a, b) => b.lastActive.localeCompare(a.lastActive));
|
|
@@ -1298,74 +1268,6 @@ export function permanentlyDeleteTrash(trashPath: string): boolean {
|
|
|
1298
1268
|
}
|
|
1299
1269
|
}
|
|
1300
1270
|
|
|
1301
|
-
// ─── Session Auto-Expiry ──────────────────────────────────
|
|
1302
|
-
|
|
1303
|
-
const AUTO_EXPIRE_INTERVAL_MS = 24 * 60 * 60 * 1000; // every 24h
|
|
1304
|
-
let autoExpireTimer: ReturnType<typeof setInterval> | null = null;
|
|
1305
|
-
|
|
1306
|
-
function getSessionExpiryDays(): number {
|
|
1307
|
-
const settings = readSettings();
|
|
1308
|
-
const val = settings?.sessionExpiryDays;
|
|
1309
|
-
const n = typeof val === "number" ? val : Number(val);
|
|
1310
|
-
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 7;
|
|
1311
|
-
}
|
|
1312
|
-
|
|
1313
|
-
interface ExpireResult {
|
|
1314
|
-
expired: string[];
|
|
1315
|
-
skipped: string[];
|
|
1316
|
-
errors: string[];
|
|
1317
|
-
}
|
|
1318
|
-
|
|
1319
|
-
export function autoExpireSessions(): ExpireResult {
|
|
1320
|
-
const result: ExpireResult = { expired: [], skipped: [], errors: [] };
|
|
1321
|
-
const expiryDays = getSessionExpiryDays();
|
|
1322
|
-
const cutoffMs = Date.now() - expiryDays * 24 * 60 * 60 * 1000;
|
|
1323
|
-
|
|
1324
|
-
const dirs = getSessionDirs();
|
|
1325
|
-
for (const dir of dirs) {
|
|
1326
|
-
let files: string[];
|
|
1327
|
-
try {
|
|
1328
|
-
files = readdirSync(dir)
|
|
1329
|
-
.filter((f) => f.endsWith(".jsonl"))
|
|
1330
|
-
.map((f) => join(dir, f));
|
|
1331
|
-
} catch {
|
|
1332
|
-
continue;
|
|
1333
|
-
}
|
|
1334
|
-
|
|
1335
|
-
for (const filePath of files) {
|
|
1336
|
-
try {
|
|
1337
|
-
const info = parseSessionFileInfo(filePath);
|
|
1338
|
-
if (!info) { result.skipped.push(filePath); continue; }
|
|
1339
|
-
|
|
1340
|
-
const lastActiveTs = new Date(info.lastActive || info.timestamp).getTime();
|
|
1341
|
-
if (isNaN(lastActiveTs)) { result.skipped.push(filePath); continue; }
|
|
1342
|
-
|
|
1343
|
-
if (lastActiveTs < cutoffMs) {
|
|
1344
|
-
const ok = trashSessionFile(filePath);
|
|
1345
|
-
if (ok) {
|
|
1346
|
-
result.expired.push(filePath);
|
|
1347
|
-
} else {
|
|
1348
|
-
result.errors.push(`trash failed: ${filePath}`);
|
|
1349
|
-
}
|
|
1350
|
-
}
|
|
1351
|
-
} catch {
|
|
1352
|
-
result.errors.push(`scan failed: ${filePath}`);
|
|
1353
|
-
}
|
|
1354
|
-
}
|
|
1355
|
-
}
|
|
1356
|
-
|
|
1357
|
-
return result;
|
|
1358
|
-
}
|
|
1359
|
-
|
|
1360
|
-
export function startAutoExpiryTimer(): void {
|
|
1361
|
-
if (autoExpireTimer) return;
|
|
1362
|
-
// Run once at startup (fire-and-forget, swallow errors)
|
|
1363
|
-
try { autoExpireSessions(); } catch { /* ignore */ }
|
|
1364
|
-
autoExpireTimer = setInterval(() => {
|
|
1365
|
-
try { autoExpireSessions(); } catch { /* ignore */ }
|
|
1366
|
-
}, AUTO_EXPIRE_INTERVAL_MS);
|
|
1367
|
-
}
|
|
1368
|
-
|
|
1369
1271
|
// ─── Session Preview ────────────────────────────────
|
|
1370
1272
|
|
|
1371
1273
|
export interface SessionPreviewMessage {
|
|
@@ -1647,93 +1549,6 @@ export interface ApplyUpdateResult {
|
|
|
1647
1549
|
message?: string;
|
|
1648
1550
|
}
|
|
1649
1551
|
|
|
1650
|
-
// ─── npm resolution ──────────────────────────────────────
|
|
1651
|
-
// GUI-launched apps (Finder / Launchpad) inherit a minimal PATH
|
|
1652
|
-
// (/usr/bin:/bin:/usr/sbin:/sbin) that does NOT contain the user's npm or
|
|
1653
|
-
// node, so `spawnSync("npm", ...)` fails with ENOENT in the packaged app.
|
|
1654
|
-
// Instead of relying on PATH we locate the real npm-cli.js + node binary
|
|
1655
|
-
// from common install locations and run npm via that explicit node.
|
|
1656
|
-
|
|
1657
|
-
function realpathOr(p: string): string | null {
|
|
1658
|
-
try {
|
|
1659
|
-
return realpathSync(p);
|
|
1660
|
-
} catch {
|
|
1661
|
-
return null;
|
|
1662
|
-
}
|
|
1663
|
-
}
|
|
1664
|
-
|
|
1665
|
-
/** Candidate npm binaries across common layouts (PATH + known locations). */
|
|
1666
|
-
function npmBinCandidates(): string[] {
|
|
1667
|
-
const home = homedir();
|
|
1668
|
-
const fromPath = (process.env.PATH || "")
|
|
1669
|
-
.split(":")
|
|
1670
|
-
.filter(Boolean)
|
|
1671
|
-
.map((d) => join(d, "npm"));
|
|
1672
|
-
return [
|
|
1673
|
-
...fromPath,
|
|
1674
|
-
`${home}/.npm-global/bin/npm`,
|
|
1675
|
-
`${home}/.npm-packages/bin/npm`,
|
|
1676
|
-
`${home}/.local/share/pnpm/npm`,
|
|
1677
|
-
"/usr/local/bin/npm",
|
|
1678
|
-
"/opt/homebrew/bin/npm",
|
|
1679
|
-
// pi-node bundles its own node/npm under ~/.local/share/pi-node/node-*/
|
|
1680
|
-
...(() => {
|
|
1681
|
-
const base = join(home, ".local", "share", "pi-node");
|
|
1682
|
-
try {
|
|
1683
|
-
return readdirSync(base)
|
|
1684
|
-
.filter((n) => n.startsWith("node-"))
|
|
1685
|
-
.map((n) => join(base, n, "bin", "npm"));
|
|
1686
|
-
} catch {
|
|
1687
|
-
return [];
|
|
1688
|
-
}
|
|
1689
|
-
})(),
|
|
1690
|
-
];
|
|
1691
|
-
}
|
|
1692
|
-
|
|
1693
|
-
/** Resolve the npm CLI entry (npm-cli.js) — npm bins are symlinks to it. */
|
|
1694
|
-
function resolveNpmCliJs(): string | null {
|
|
1695
|
-
for (const bin of npmBinCandidates()) {
|
|
1696
|
-
const real = realpathOr(bin);
|
|
1697
|
-
if (real && existsSync(real) && /npm-cli\.js$/.test(real)) return real;
|
|
1698
|
-
}
|
|
1699
|
-
return null;
|
|
1700
|
-
}
|
|
1701
|
-
|
|
1702
|
-
/** Candidate node binaries (PATH + common locations + pi-node bundle). */
|
|
1703
|
-
function resolveNodeBin(): string | null {
|
|
1704
|
-
const home = homedir();
|
|
1705
|
-
const fromPath = (process.env.PATH || "")
|
|
1706
|
-
.split(":")
|
|
1707
|
-
.filter(Boolean)
|
|
1708
|
-
.map((d) => join(d, "node"));
|
|
1709
|
-
const candidates = [
|
|
1710
|
-
...fromPath,
|
|
1711
|
-
`${home}/.npm-global/bin/node`,
|
|
1712
|
-
`${home}/.npm-packages/bin/node`,
|
|
1713
|
-
"/usr/local/bin/node",
|
|
1714
|
-
"/opt/homebrew/bin/node",
|
|
1715
|
-
...(() => {
|
|
1716
|
-
const base = join(home, ".local", "share", "pi-node");
|
|
1717
|
-
try {
|
|
1718
|
-
return readdirSync(base)
|
|
1719
|
-
.filter((n) => n.startsWith("node-"))
|
|
1720
|
-
.map((n) => join(base, n, "bin", "node"));
|
|
1721
|
-
} catch {
|
|
1722
|
-
return [];
|
|
1723
|
-
}
|
|
1724
|
-
})(),
|
|
1725
|
-
];
|
|
1726
|
-
for (const bin of candidates) {
|
|
1727
|
-
try {
|
|
1728
|
-
const out = spawnSync(bin, ["--version"], { encoding: "utf8", timeout: 5000 });
|
|
1729
|
-
if (out.status === 0 && out.stdout) return bin;
|
|
1730
|
-
} catch {
|
|
1731
|
-
// try next candidate
|
|
1732
|
-
}
|
|
1733
|
-
}
|
|
1734
|
-
return null;
|
|
1735
|
-
}
|
|
1736
|
-
|
|
1737
1552
|
/**
|
|
1738
1553
|
* One-click update: npm install <name>@latest inside ~/.pi/agent/npm.
|
|
1739
1554
|
* Only packages already installed there are accepted (pi core is excluded —
|
|
@@ -1742,8 +1557,6 @@ function resolveNodeBin(): string | null {
|
|
|
1742
1557
|
export function applyExtensionUpdates(names: string[]): ApplyUpdateResult[] {
|
|
1743
1558
|
const dir = join(PI_DIR, "npm");
|
|
1744
1559
|
const installed = new Set(listInstalledExtensions().map((e) => e.name));
|
|
1745
|
-
const npmCliJs = resolveNpmCliJs();
|
|
1746
|
-
const nodeBin = resolveNodeBin();
|
|
1747
1560
|
|
|
1748
1561
|
return names.map((name) => {
|
|
1749
1562
|
if (!installed.has(name)) {
|
|
@@ -1752,19 +1565,15 @@ export function applyExtensionUpdates(names: string[]): ApplyUpdateResult[] {
|
|
|
1752
1565
|
try {
|
|
1753
1566
|
// --legacy-peer-deps: peer deps (e.g. pi core) are provided by the pi host,
|
|
1754
1567
|
// not installed here — strict resolution would fail with ERESOLVE.
|
|
1755
|
-
const
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
out = spawnSync(nodeBin, [npmCliJs, ...args], {
|
|
1568
|
+
const out = spawnSync(
|
|
1569
|
+
"npm",
|
|
1570
|
+
["install", `${name}@latest`, "--no-audit", "--no-fund", "--legacy-peer-deps"],
|
|
1571
|
+
{
|
|
1760
1572
|
cwd: dir,
|
|
1761
1573
|
encoding: "utf8",
|
|
1762
1574
|
timeout: 120000,
|
|
1763
|
-
}
|
|
1764
|
-
|
|
1765
|
-
// Fall back to PATH resolution (dev / terminal environments)
|
|
1766
|
-
out = spawnSync("npm", args, { cwd: dir, encoding: "utf8", timeout: 120000 });
|
|
1767
|
-
}
|
|
1575
|
+
}
|
|
1576
|
+
);
|
|
1768
1577
|
if (out.status === 0) return { name, success: true };
|
|
1769
1578
|
const stderr = (out.stderr || "").trim().split("\n").slice(-3).join(" ");
|
|
1770
1579
|
return { name, success: false, message: stderr || `npm exited with ${out.status}` };
|
|
@@ -415,35 +415,6 @@ export function DashboardPage() {
|
|
|
415
415
|
|
|
416
416
|
return (
|
|
417
417
|
<div className="space-y-5">
|
|
418
|
-
{/* Source Selector: Pi / Cindy-Pi */}
|
|
419
|
-
<div className="flex items-center gap-1 rounded-lg border p-0.5" style={{ borderColor: "var(--card-border)", backgroundColor: "var(--page-bg)" }}>
|
|
420
|
-
{([
|
|
421
|
-
{ key: "all" as SourceKey, label: "dashboard.source_all", icon: "📊" },
|
|
422
|
-
{ key: "pi" as SourceKey, label: "dashboard.source_pi", icon: "🖥" },
|
|
423
|
-
{ key: "cindy-pi" as SourceKey, label: "dashboard.source_cindy_pi", icon: "🤖" },
|
|
424
|
-
{ key: "claude" as SourceKey, label: "dashboard.source_claude", icon: "🧠" },
|
|
425
|
-
{ key: "codex" as SourceKey, label: "dashboard.source_codex", icon: "⚡" },
|
|
426
|
-
{ key: "opencode" as SourceKey, label: "dashboard.source_opencode", icon: "🔷" },
|
|
427
|
-
{ key: "gemini" as SourceKey, label: "dashboard.source_gemini", icon: "✨" },
|
|
428
|
-
{ key: "grok" as SourceKey, label: "dashboard.source_grok", icon: "🌀" },
|
|
429
|
-
{ key: "atomcode" as SourceKey, label: "dashboard.source_atomcode", icon: "⚛️" },
|
|
430
|
-
{ key: "copilot" as SourceKey, label: "dashboard.source_copilot", icon: "🐙" },
|
|
431
|
-
]).map((s) => (
|
|
432
|
-
<button
|
|
433
|
-
key={s.key}
|
|
434
|
-
onClick={() => setSource(s.key)}
|
|
435
|
-
className={cn(
|
|
436
|
-
"rounded-md px-3 py-1.5 text-xs font-medium transition-colors flex items-center gap-1.5",
|
|
437
|
-
source === s.key ? "text-white" : "hover:bg-gray-800/30"
|
|
438
|
-
)}
|
|
439
|
-
style={source === s.key ? { backgroundColor: "#3b82f6", color: "#fff" } : { color: "var(--muted-text)" }}
|
|
440
|
-
>
|
|
441
|
-
<span>{s.icon}</span>
|
|
442
|
-
<span>{t(s.label)}</span>
|
|
443
|
-
</button>
|
|
444
|
-
))}
|
|
445
|
-
</div>
|
|
446
|
-
|
|
447
418
|
{/* Title + Time Range Selector + Currency Toggle */}
|
|
448
419
|
<div className="flex items-center justify-between flex-wrap gap-3">
|
|
449
420
|
<div>
|
|
@@ -33,21 +33,10 @@ export function Sidebar() {
|
|
|
33
33
|
borderColor: "var(--sidebar-border)",
|
|
34
34
|
}}
|
|
35
35
|
>
|
|
36
|
-
{/* Logo
|
|
37
|
-
immersive (hiddenInset) title bar so they don't overlap the logo.
|
|
38
|
-
WebkitAppRegion: drag restores window dragging since the native
|
|
39
|
-
title bar is hidden. */}
|
|
36
|
+
{/* Logo */}
|
|
40
37
|
<div
|
|
41
|
-
className="flex items-center gap-3 border-b px-6
|
|
42
|
-
style={{
|
|
43
|
-
borderColor: "var(--sidebar-border)",
|
|
44
|
-
userSelect: "none",
|
|
45
|
-
WebkitUserSelect: "none",
|
|
46
|
-
// Electron-only: lets the user drag the frameless (hiddenInset) window
|
|
47
|
-
// from the sidebar header. TypeScript doesn't know the non-standard
|
|
48
|
-
// -webkit-app-region property, so cast it explicitly.
|
|
49
|
-
...({ WebkitAppRegion: "drag" } as React.CSSProperties),
|
|
50
|
-
}}
|
|
38
|
+
className="flex items-center gap-3 border-b px-6 py-5"
|
|
39
|
+
style={{ borderColor: "var(--sidebar-border)" }}
|
|
51
40
|
>
|
|
52
41
|
<img src="/pi.svg" alt="pi-switch" className="h-9 w-9 rounded-lg" />
|
|
53
42
|
<div>
|
|
@@ -60,8 +60,9 @@ function parseMemoryEntries(content: string): MemoryEntry[] {
|
|
|
60
60
|
for (const section of sections) {
|
|
61
61
|
const trimmed = section.trim();
|
|
62
62
|
// Match `<!-- created=DATE, last=DATE -->` at the end
|
|
63
|
+
// Tolerate extra fields after `last=` (e.g. `, project64=...`) before `-->`
|
|
63
64
|
const markerMatch = trimmed.match(
|
|
64
|
-
/<!--\s*created\s*=\s*([^,\s]+)\s*,\s*last\s*=\s*([
|
|
65
|
+
/<!--\s*created\s*=\s*([^,\s>]+)\s*,\s*last\s*=\s*([^,\s>]+)[^>]*-->\s*$/
|
|
65
66
|
);
|
|
66
67
|
if (markerMatch) {
|
|
67
68
|
entries.push({
|
|
@@ -3,7 +3,7 @@ import { useConfigStore } from "@/store/config-store";
|
|
|
3
3
|
import { useTranslation } from "@/lib/i18n";
|
|
4
4
|
import {
|
|
5
5
|
History, MessageSquare, Clock, ChevronDown, ChevronRight, Trash2, AlertTriangle,
|
|
6
|
-
Shield, RefreshCw, Undo2, Eye, Folder, FolderOpen, FileText, Search,
|
|
6
|
+
Shield, RefreshCw, Undo2, Eye, Folder, FolderOpen, FileText, Search,
|
|
7
7
|
} from "lucide-react";
|
|
8
8
|
import { Modal } from "@/components/ui/Modal";
|
|
9
9
|
|
|
@@ -466,7 +466,7 @@ function findParentProjectPath(node: TreeNode): string {
|
|
|
466
466
|
|
|
467
467
|
export function SessionsPage() {
|
|
468
468
|
const { t } = useTranslation();
|
|
469
|
-
const { initialized
|
|
469
|
+
const { initialized } = useConfigStore();
|
|
470
470
|
const [tab, setTab] = useState<"sessions" | "trash">("sessions");
|
|
471
471
|
const [groups, setGroups] = useState<ProjectGroup[]>([]);
|
|
472
472
|
const [trash, setTrash] = useState<TrashEntry[]>([]);
|
|
@@ -480,7 +480,6 @@ export function SessionsPage() {
|
|
|
480
480
|
const [selectedTrash, setSelectedTrash] = useState<Set<string>>(new Set());
|
|
481
481
|
const [purgeTarget, setPurgeTarget] = useState<"batch" | TrashEntry | null>(null);
|
|
482
482
|
const [purging, setPurging] = useState(false);
|
|
483
|
-
const [expiring, setExpiring] = useState(false);
|
|
484
483
|
// Preview modal state
|
|
485
484
|
const [previewTarget, setPreviewTarget] = useState<SessionInfo | null>(null);
|
|
486
485
|
const [preview, setPreview] = useState<{ messages: PreviewMessage[]; total: number } | null>(null);
|
|
@@ -576,27 +575,6 @@ export function SessionsPage() {
|
|
|
576
575
|
loadAll();
|
|
577
576
|
};
|
|
578
577
|
|
|
579
|
-
const handleAutoExpire = async () => {
|
|
580
|
-
setExpiring(true);
|
|
581
|
-
try {
|
|
582
|
-
const res = await fetch("/api/pi/session/auto-expire", { method: "POST" });
|
|
583
|
-
const result = await res.json();
|
|
584
|
-
if (result.success) {
|
|
585
|
-
const count = result.expired?.length ?? 0;
|
|
586
|
-
alert(count > 0
|
|
587
|
-
? t("sessions.auto_expire_success", String(count))
|
|
588
|
-
: t("sessions.auto_expire_none"));
|
|
589
|
-
loadAll();
|
|
590
|
-
} else {
|
|
591
|
-
alert(t("sessions.auto_expire_error", result.error || "unknown"));
|
|
592
|
-
}
|
|
593
|
-
} catch {
|
|
594
|
-
alert(t("sessions.auto_expire_error", "network"));
|
|
595
|
-
} finally {
|
|
596
|
-
setExpiring(false);
|
|
597
|
-
}
|
|
598
|
-
};
|
|
599
|
-
|
|
600
578
|
const openPreview = (session: SessionInfo) => {
|
|
601
579
|
setPreviewTarget(session);
|
|
602
580
|
setPreview(null);
|
|
@@ -723,18 +701,6 @@ function countExpandableNodes(nodes: TreeNode[]): number {
|
|
|
723
701
|
<RefreshCw className={refreshing ? "h-3.5 w-3.5 animate-spin" : "h-3.5 w-3.5"} />
|
|
724
702
|
{t("sessions.refresh")}
|
|
725
703
|
</button>
|
|
726
|
-
{tab === "sessions" && groups.length > 0 && (
|
|
727
|
-
<button
|
|
728
|
-
onClick={handleAutoExpire}
|
|
729
|
-
disabled={expiring}
|
|
730
|
-
className="flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors"
|
|
731
|
-
style={{ borderColor: "var(--card-border)", color: "var(--muted-text)", backgroundColor: "var(--card-bg)", opacity: expiring ? 0.6 : 1 }}
|
|
732
|
-
title={t("sessions.auto_expire_tooltip", String(settings?.sessionExpiryDays ?? 7))}
|
|
733
|
-
>
|
|
734
|
-
<ArchiveX className={expiring ? "h-3.5 w-3.5 animate-spin" : "h-3.5 w-3.5"} />
|
|
735
|
-
{t("sessions.auto_expire")}
|
|
736
|
-
</button>
|
|
737
|
-
)}
|
|
738
704
|
</div>
|
|
739
705
|
</div>
|
|
740
706
|
|
|
@@ -242,11 +242,6 @@ const en: Record<string, string> = {
|
|
|
242
242
|
"sessions.delete_error": "Error deleting session",
|
|
243
243
|
"sessions.load_failed": "Failed to load sessions",
|
|
244
244
|
"sessions.refresh": "Refresh",
|
|
245
|
-
"sessions.auto_expire": "Auto-Expire",
|
|
246
|
-
"sessions.auto_expire_tooltip": "Move sessions inactive for {0} days to trash",
|
|
247
|
-
"sessions.auto_expire_success": "Moved {0} expired sessions to trash",
|
|
248
|
-
"sessions.auto_expire_none": "No expired sessions found",
|
|
249
|
-
"sessions.auto_expire_error": "Auto-expire failed: {0}",
|
|
250
245
|
"sessions.expand_all": "Expand All",
|
|
251
246
|
"sessions.collapse_all": "Collapse All",
|
|
252
247
|
"sessions.more_count": "{0} more",
|
|
@@ -236,11 +236,6 @@ const ja: Record<string, string> = {
|
|
|
236
236
|
"sessions.delete_error": "削除中にエラーが発生しました",
|
|
237
237
|
"sessions.load_failed": "セッションの読み込みに失敗しました",
|
|
238
238
|
"sessions.refresh": "更新",
|
|
239
|
-
"sessions.auto_expire": "自動期限切れ",
|
|
240
|
-
"sessions.auto_expire_tooltip": "{0}日間非活跃のセッションをゴミ箱に移動",
|
|
241
|
-
"sessions.auto_expire_success": "{0}件の期限切れセッションをゴミ箱に移動しました",
|
|
242
|
-
"sessions.auto_expire_none": "期限切れのセッションはありません",
|
|
243
|
-
"sessions.auto_expire_error": "自動期限切れに失敗:{0}",
|
|
244
239
|
"sessions.expand_all": "すべて展開",
|
|
245
240
|
"sessions.collapse_all": "すべて折りたたむ",
|
|
246
241
|
"sessions.more_count": "あと {0} 件",
|
|
@@ -236,11 +236,6 @@ const zhCN: Record<string, string> = {
|
|
|
236
236
|
"sessions.delete_error": "删除时发生错误",
|
|
237
237
|
"sessions.load_failed": "加载会话失败",
|
|
238
238
|
"sessions.refresh": "刷新",
|
|
239
|
-
"sessions.auto_expire": "自动清理",
|
|
240
|
-
"sessions.auto_expire_tooltip": "将 {0} 天未活跃的会话移入回收站",
|
|
241
|
-
"sessions.auto_expire_success": "已将 {0} 个过期会话移入回收站",
|
|
242
|
-
"sessions.auto_expire_none": "没有找到过期会话",
|
|
243
|
-
"sessions.auto_expire_error": "自动清理失败:{0}",
|
|
244
239
|
"sessions.expand_all": "全部展开",
|
|
245
240
|
"sessions.collapse_all": "全部折叠",
|
|
246
241
|
"sessions.more_count": "还有 {0} 条",
|