@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.
- package/README.ja.md +137 -0
- package/README.md +277 -0
- package/README.zh-CN.md +176 -0
- package/index.html +13 -0
- package/package.json +44 -0
- package/pi-package/index.ts +100 -0
- package/pi-package/skills/pi-web-switch/SKILL.md +60 -0
- package/public/pi.svg +4 -0
- package/server/pi-reader.ts +678 -0
- package/src/App.tsx +25 -0
- package/src/components/dashboard/DashboardPage.tsx +607 -0
- package/src/components/layout/AppShell.tsx +18 -0
- package/src/components/layout/Sidebar.tsx +116 -0
- package/src/components/models/ModelsPage.tsx +570 -0
- package/src/components/providers/ProvidersPage.tsx +466 -0
- package/src/components/sessions/MemoryPage.tsx +177 -0
- package/src/components/sessions/SessionsPage.tsx +347 -0
- package/src/components/settings/SettingsPage.tsx +351 -0
- package/src/components/ui/Badge.tsx +29 -0
- package/src/components/ui/EmptyState.tsx +20 -0
- package/src/components/ui/Modal.tsx +41 -0
- package/src/components/ui/StatCard.tsx +37 -0
- package/src/data/builtin-providers.ts +148 -0
- package/src/data/mock-config.ts +261 -0
- package/src/data/mock-usage.ts +153 -0
- package/src/index.css +217 -0
- package/src/lib/config.ts +56 -0
- package/src/lib/currency.ts +48 -0
- package/src/lib/i18n.tsx +98 -0
- package/src/lib/translations/en.ts +168 -0
- package/src/lib/translations/index.ts +14 -0
- package/src/lib/translations/ja.ts +158 -0
- package/src/lib/translations/zh-CN.ts +158 -0
- package/src/lib/translations/zh-TW.ts +158 -0
- package/src/lib/utils.ts +51 -0
- package/src/main.tsx +106 -0
- package/src/store/config-store.ts +459 -0
- package/src/types/index.ts +187 -0
- package/src/vite-env.d.ts +1 -0
- package/tsconfig.json +24 -0
- 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
|
+
}
|