@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,347 @@
1
+ import { useState, useEffect } from "react";
2
+ import { useConfigStore } from "@/store/config-store";
3
+ import { useTranslation } from "@/lib/i18n";
4
+ import { formatDateFull } from "@/lib/utils";
5
+ import { History, MessageSquare, Clock, ChevronDown, ChevronRight, Trash2, AlertTriangle, Shield } from "lucide-react";
6
+ import { Modal } from "@/components/ui/Modal";
7
+
8
+ function isRecent(isoDate: string): boolean {
9
+ if (!isoDate) return false;
10
+ const then = new Date(isoDate).getTime();
11
+ const now = Date.now();
12
+ const threeDaysMs = 3 * 24 * 60 * 60 * 1000;
13
+ return now - then < threeDaysMs;
14
+ }
15
+
16
+ interface SessionInfo {
17
+ id: string;
18
+ fileName: string;
19
+ filePath: string;
20
+ timestamp: string;
21
+ lastActive: string;
22
+ name?: string;
23
+ provider?: string;
24
+ model?: string;
25
+ messageCount: number;
26
+ duration?: number;
27
+ }
28
+
29
+ interface ProjectGroup {
30
+ projectPath: string;
31
+ projectName: string;
32
+ sessions: SessionInfo[];
33
+ totalSessions: number;
34
+ lastActive: string;
35
+ }
36
+
37
+ function formatDuration(ms?: number): string {
38
+ if (!ms) return "—";
39
+ const seconds = Math.floor(ms / 1000);
40
+ if (seconds < 60) return `${seconds}s`;
41
+ const minutes = Math.floor(seconds / 60);
42
+ if (minutes < 60) return `${minutes}m ${seconds % 60}s`;
43
+ const hours = Math.floor(minutes / 60);
44
+ return `${hours}h ${minutes % 60}m`;
45
+ }
46
+
47
+ function ProjectCard({
48
+ group,
49
+ defaultOpen,
50
+ onDelete,
51
+ }: {
52
+ group: ProjectGroup;
53
+ defaultOpen: boolean;
54
+ onDelete: (session: SessionInfo, groupPath: string) => void;
55
+ }) {
56
+ const { t } = useTranslation();
57
+ const [open, setOpen] = useState(defaultOpen);
58
+ const [deleting, setDeleting] = useState<string | null>(null);
59
+
60
+ return (
61
+ <div className="rounded-xl border" style={{ borderColor: "var(--card-border)" }}>
62
+ {/* Project Header */}
63
+ <button
64
+ onClick={() => setOpen(!open)}
65
+ className="flex w-full items-center justify-between px-6 py-4"
66
+ style={{ backgroundColor: "var(--card-bg)" }}
67
+ >
68
+ <div className="flex items-center gap-3">
69
+ <div className="flex h-9 w-9 items-center justify-center rounded-lg" style={{ backgroundColor: "var(--accent-bg)" }}>
70
+ <History className="h-4 w-4" style={{ color: "var(--sidebar-active-text)" }} />
71
+ </div>
72
+ <div className="text-left">
73
+ <h3 className="text-sm font-semibold" style={{ color: "var(--page-text)" }}>{group.projectName}</h3>
74
+ <p className="text-xs" style={{ color: "var(--muted-text)" }}>
75
+ {group.totalSessions} {t("sessions.sessions_count", String(group.totalSessions)).split(" ")[0]} · {t("sessions.last_active", new Date(group.lastActive).toLocaleDateString())}
76
+ </p>
77
+ </div>
78
+ </div>
79
+ <div className="flex items-center gap-3">
80
+ <span className="text-xs font-medium" style={{ color: "var(--sidebar-active-text)" }}>
81
+ {group.totalSessions}
82
+ </span>
83
+ {open ? <ChevronDown className="h-4 w-4" style={{ color: "var(--muted-text)" }} /> : <ChevronRight className="h-4 w-4" style={{ color: "var(--muted-text)" }} />}
84
+ </div>
85
+ </button>
86
+
87
+ {/* Session List */}
88
+ {open && (
89
+ <div style={{ borderTop: "1px solid var(--card-border)" }}>
90
+ {group.sessions.map((session) => (
91
+ <div
92
+ key={session.id || session.fileName}
93
+ className="flex items-center justify-between px-6 py-3"
94
+ style={{
95
+ borderBottom: "1px solid var(--card-border)",
96
+ backgroundColor: "var(--page-bg)",
97
+ opacity: deleting === session.fileName ? 0.5 : 1,
98
+ }}
99
+ >
100
+ <div className="flex items-center gap-3 min-w-0 flex-1">
101
+ <div className="flex flex-col items-center justify-center w-8 h-8 rounded-md" style={{ backgroundColor: "var(--accent-bg)" }}>
102
+ <MessageSquare className="h-3.5 w-3.5" style={{ color: "var(--sidebar-active-text)" }} />
103
+ </div>
104
+ <div className="min-w-0 flex-1">
105
+ <p className="text-sm font-medium truncate" style={{ color: "var(--page-text)" }}>
106
+ {session.name || session.fileName.replace(/\.jsonl$/, "").split("_").pop() || session.id.slice(0, 12)}
107
+ </p>
108
+ <div className="flex items-center gap-3 text-xs" style={{ color: "var(--muted-text)" }}>
109
+ <span>{formatDateFull(session.timestamp)}</span>
110
+ <span className="flex items-center gap-1">
111
+ <MessageSquare className="h-3 w-3" />
112
+ {session.messageCount}
113
+ </span>
114
+ <span className="flex items-center gap-1">
115
+ <Clock className="h-3 w-3" />
116
+ {formatDuration(session.duration)}
117
+ </span>
118
+ </div>
119
+ </div>
120
+ </div>
121
+ <div className="flex items-center gap-2 shrink-0 ml-4">
122
+ {session.provider && session.provider !== "unknown" && (
123
+ <span className="text-xs rounded-md px-2 py-0.5" style={{ backgroundColor: "var(--accent-bg)", color: "var(--sidebar-active-text)" }}>
124
+ {session.provider}/{session.model?.split("-").slice(0, 2).join("-") || session.model}
125
+ </span>
126
+ )}
127
+ {isRecent(session.lastActive) ? (
128
+ <span
129
+ className="rounded-lg p-1.5"
130
+ style={{ color: "var(--subtle-text)" }}
131
+ title={t("sessions.protected")}
132
+ >
133
+ <Shield className="h-3.5 w-3.5" />
134
+ </span>
135
+ ) : (
136
+ <button
137
+ onClick={(e) => {
138
+ e.stopPropagation();
139
+ onDelete(session, group.projectPath);
140
+ }}
141
+ className="rounded-lg p-1.5 transition-opacity"
142
+ style={{ color: "var(--subtle-text)" }}
143
+ title={t("sessions.delete")}
144
+ >
145
+ <Trash2 className="h-3.5 w-3.5" />
146
+ </button>
147
+ )}
148
+ </div>
149
+ </div>
150
+ ))}
151
+ </div>
152
+ )}
153
+ </div>
154
+ );
155
+ }
156
+
157
+ export function SessionsPage() {
158
+ const { t } = useTranslation();
159
+ const { initialized } = useConfigStore();
160
+ const [groups, setGroups] = useState<ProjectGroup[]>([]);
161
+ const [loading, setLoading] = useState(true);
162
+ const [error, setError] = useState<string | null>(null);
163
+ const [filter, setFilter] = useState("");
164
+ const [deleteTarget, setDeleteTarget] = useState<{ session: SessionInfo; groupPath: string } | null>(null);
165
+ const [deleting, setDeleting] = useState(false);
166
+
167
+ const loadSessions = () => {
168
+ if (!initialized) return;
169
+ setLoading(true);
170
+ fetch("/api/pi/sessions")
171
+ .then((r) => r.json())
172
+ .then((data) => {
173
+ setGroups(data);
174
+ setLoading(false);
175
+ })
176
+ .catch((e) => {
177
+ setError(e.message);
178
+ setLoading(false);
179
+ });
180
+ };
181
+
182
+ useEffect(() => {
183
+ loadSessions();
184
+ }, [initialized]);
185
+
186
+ const handleDelete = async () => {
187
+ if (!deleteTarget) return;
188
+ setDeleting(true);
189
+ try {
190
+ const res = await fetch(
191
+ `/api/pi/session?path=${encodeURIComponent(deleteTarget.session.filePath)}`,
192
+ { method: "DELETE" }
193
+ );
194
+ const result = await res.json();
195
+ if (result.success) {
196
+ const { groupPath, session } = deleteTarget;
197
+ setDeleteTarget(null);
198
+ setGroups((prev) => {
199
+ const updated = prev
200
+ .map((g) => {
201
+ if (g.projectPath !== groupPath) return g;
202
+ const remaining = g.sessions.filter((s) => s.filePath !== session.filePath);
203
+ return remaining.length === 0
204
+ ? null
205
+ : { ...g, sessions: remaining, totalSessions: remaining.length };
206
+ })
207
+ .filter(Boolean) as ProjectGroup[];
208
+ return updated;
209
+ });
210
+ } else {
211
+ alert(t("sessions.delete_failed"));
212
+ }
213
+ } catch {
214
+ alert(t("sessions.delete_error"));
215
+ } finally {
216
+ setDeleting(false);
217
+ }
218
+ };
219
+
220
+ const filteredGroups = filter
221
+ ? groups.filter((g) =>
222
+ g.projectName.toLowerCase().includes(filter.toLowerCase())
223
+ )
224
+ : groups;
225
+
226
+ if (loading) {
227
+ return (
228
+ <div className="flex items-center justify-center h-64">
229
+ <div className="h-8 w-8 animate-spin rounded-full border-2 border-gray-600 border-t-blue-500" />
230
+ </div>
231
+ );
232
+ }
233
+
234
+ if (error) {
235
+ return (
236
+ <div className="flex items-center justify-center h-64">
237
+ <p className="text-sm" style={{ color: "var(--error-text, #ef4444)" }}>{t("sessions.delete_failed")}: {error}</p>
238
+ </div>
239
+ );
240
+ }
241
+
242
+ const totalSessions = groups.reduce((s, g) => s + g.totalSessions, 0);
243
+
244
+ return (
245
+ <div className="space-y-6">
246
+ <div>
247
+ <h1 className="text-2xl font-bold" style={{ color: "var(--page-text)" }}>{t("sessions.title")}</h1>
248
+ <p className="mt-1 text-sm" style={{ color: "var(--muted-text)" }}>
249
+ {t("sessions.summary", String(totalSessions), String(groups.length))}
250
+ </p>
251
+ </div>
252
+
253
+ {/* Search */}
254
+ <div className="relative">
255
+ <input
256
+ type="text"
257
+ value={filter}
258
+ onChange={(e) => setFilter(e.target.value)}
259
+ placeholder={t("sessions.filter_placeholder")}
260
+ className="w-full rounded-lg border px-4 py-2.5 pl-10 text-sm"
261
+ style={{
262
+ backgroundColor: "var(--input-bg)",
263
+ borderColor: "var(--input-border)",
264
+ color: "var(--input-text)",
265
+ }}
266
+ />
267
+ <History className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4" style={{ color: "var(--muted-text)" }} />
268
+ </div>
269
+
270
+ {/* Project Groups */}
271
+ <div className="space-y-3">
272
+ {filteredGroups.length === 0 ? (
273
+ <div className="flex flex-col items-center justify-center py-12">
274
+ <History className="h-12 w-12" style={{ color: "var(--subtle-text)" }} />
275
+ <p className="mt-4 text-sm" style={{ color: "var(--muted-text)" }}>{t("sessions.no_sessions")}</p>
276
+ </div>
277
+ ) : (
278
+ filteredGroups.map((group, i) => (
279
+ <ProjectCard
280
+ key={group.projectPath}
281
+ group={group}
282
+ defaultOpen={i < 3}
283
+ onDelete={(session, groupPath) => setDeleteTarget({ session, groupPath })}
284
+ />
285
+ ))
286
+ )}
287
+ </div>
288
+
289
+ {/* Delete Confirmation Modal */}
290
+ <Modal
291
+ open={!!deleteTarget}
292
+ onClose={() => !deleting && setDeleteTarget(null)}
293
+ title={t("sessions.delete_title")}
294
+ >
295
+ <div className="space-y-4">
296
+ <div className="flex items-start gap-3">
297
+ <AlertTriangle className="h-5 w-5 shrink-0 mt-0.5" style={{ color: "#ef4444" }} />
298
+ <div>
299
+ <p className="text-sm" style={{ color: "var(--page-text)" }}>
300
+ Are you sure you want to delete this session?
301
+ </p>
302
+ <p className="text-xs mt-1" style={{ color: "var(--muted-text)" }}>
303
+ {deleteTarget?.session.name || deleteTarget?.session.fileName}
304
+ </p>
305
+ {deleteTarget?.session.messageCount && (
306
+ <p className="text-xs mt-1" style={{ color: "var(--subtle-text)" }}>
307
+ {deleteTarget.session.messageCount} messages · {formatDateFull(deleteTarget.session.timestamp)}
308
+ </p>
309
+ )}
310
+ <p className="text-xs mt-2" style={{ color: "var(--subtle-text)" }}>
311
+ This will permanently delete the session file from disk.
312
+ </p>
313
+ </div>
314
+ </div>
315
+ <div className="flex justify-end gap-3">
316
+ <button
317
+ onClick={() => setDeleteTarget(null)}
318
+ disabled={deleting}
319
+ className="rounded-lg px-4 py-2 text-sm"
320
+ style={{ color: "var(--muted-text)" }}
321
+ >
322
+ Cancel
323
+ </button>
324
+ <button
325
+ onClick={handleDelete}
326
+ disabled={deleting}
327
+ className="flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium text-white"
328
+ style={{ backgroundColor: "#dc2626" }}
329
+ >
330
+ {deleting ? (
331
+ <>
332
+ <div className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
333
+ Deleting...
334
+ </>
335
+ ) : (
336
+ <>
337
+ <Trash2 className="h-4 w-4" />
338
+ Delete
339
+ </>
340
+ )}
341
+ </button>
342
+ </div>
343
+ </div>
344
+ </Modal>
345
+ </div>
346
+ );
347
+ }