@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,466 @@
1
+ import { useState } from "react";
2
+ import { useConfigStore } from "@/store/config-store";
3
+ import { useTranslation } from "@/lib/i18n";
4
+ import { Badge } from "@/components/ui/Badge";
5
+ import { Modal } from "@/components/ui/Modal";
6
+ import { EmptyState } from "@/components/ui/EmptyState";
7
+ import { cn } from "@/lib/utils";
8
+ import type { ApiType, CustomProviderConfig } from "@/types";
9
+ import {
10
+ Plug,
11
+ Plus,
12
+ Trash2,
13
+ Edit3,
14
+ Globe,
15
+ Key,
16
+ ChevronDown,
17
+ ChevronRight,
18
+ Shield,
19
+ Server,
20
+ } from "lucide-react";
21
+
22
+ const API_TYPES: { value: ApiType; label: string }[] = [
23
+ { value: "openai-completions", label: "OpenAI Chat Completions" },
24
+ { value: "openai-responses", label: "OpenAI Responses" },
25
+ { value: "anthropic-messages", label: "Anthropic Messages" },
26
+ { value: "google-generative-ai", label: "Google Generative AI" },
27
+ { value: "google-vertex", label: "Google Vertex AI" },
28
+ { value: "bedrock-converse-stream", label: "AWS Bedrock" },
29
+ { value: "mistral-conversations", label: "Mistral" },
30
+ ];
31
+
32
+ export function ProvidersPage() {
33
+ const { t } = useTranslation();
34
+ const { allProviders, auth, updateCustomProvider, removeCustomProvider, setProviderAuth, removeProviderAuth } =
35
+ useConfigStore();
36
+ const [expandedId, setExpandedId] = useState<string | null>(null);
37
+ const [editProvider, setEditProvider] = useState<string | null>(null);
38
+ const [showAdd, setShowAdd] = useState(false);
39
+ const [showAuth, setShowAuth] = useState<string | null>(null);
40
+ const [authKey, setAuthKey] = useState("");
41
+ const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
42
+
43
+ const customProviders = allProviders.filter((p) => p.type === "custom");
44
+
45
+ const handleAddProvider = (id: string, cfg: CustomProviderConfig) => {
46
+ useConfigStore.getState().addCustomProvider(id, cfg);
47
+ setShowAdd(false);
48
+ };
49
+
50
+ const handleEditProvider = (id: string, cfg: Partial<CustomProviderConfig>) => {
51
+ updateCustomProvider(id, cfg);
52
+ setEditProvider(null);
53
+ };
54
+
55
+ const handleSetAuth = (providerId: string) => {
56
+ if (authKey) {
57
+ setProviderAuth(providerId, authKey);
58
+ setShowAuth(null);
59
+ setAuthKey("");
60
+ }
61
+ };
62
+
63
+ return (
64
+ <div className="space-y-6">
65
+ <div className="flex items-center justify-between">
66
+ <div>
67
+ <h1 className="text-2xl font-bold" style={{ color: "var(--page-text)" }}>{t("providers.title")}</h1>
68
+ <p className="mt-1 text-sm" style={{ color: "var(--muted-text)" }}>
69
+ {allProviders.length} providers ({customProviders.length} custom)
70
+ </p>
71
+ </div>
72
+ <button
73
+ onClick={() => setShowAdd(true)}
74
+ className="flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-colors"
75
+ style={{ backgroundColor: "#3b82f6", color: "#ffffff" }}
76
+ >
77
+ <Plus className="h-4 w-4" />
78
+ {t("providers.add_provider")}
79
+ </button>
80
+ </div>
81
+
82
+ {allProviders.length === 0 ? (
83
+ <EmptyState
84
+ icon={<Plug className="h-12 w-12" />}
85
+ title={t("providers.title")}
86
+ description="Add your first provider to get started"
87
+ />
88
+ ) : (
89
+ <div className="space-y-2">
90
+ {allProviders.map((p) => (
91
+ <div key={p.id} className="rounded-xl border border-gray-800 bg-gray-900/50">
92
+ {/* Header */}
93
+ <button
94
+ onClick={() => setExpandedId(expandedId === p.id ? null : p.id)}
95
+ className="flex w-full items-center gap-3 px-5 py-4 text-left"
96
+ >
97
+ <div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gray-800">
98
+ {p.type === "custom" ? (
99
+ <Server className="h-5 w-5 text-blue-400" />
100
+ ) : (
101
+ <Shield className="h-5 w-5 text-emerald-400" />
102
+ )}
103
+ </div>
104
+ <div className="min-w-0 flex-1">
105
+ <div className="flex items-center gap-2">
106
+ <span className="font-medium text-white">{p.name}</span>
107
+ <Badge variant={p.type === "builtin" ? "info" : "default"}>
108
+ {p.type === "builtin" ? "Built-in" : "Custom"}
109
+ </Badge>
110
+ {p.hasAuth && (
111
+ <Badge variant="success">Auth Configured</Badge>
112
+ )}
113
+ </div>
114
+ <p className="mt-0.5 text-xs text-gray-500">
115
+ {p.models.length} models · {p.api ?? "No API type"}
116
+ </p>
117
+ </div>
118
+ <div className="flex items-center gap-2">
119
+ <span
120
+ onClick={(e) => {
121
+ e.stopPropagation();
122
+ setShowAuth(p.id);
123
+ setAuthKey(auth?.[p.id]?.key ?? "");
124
+ }}
125
+ className="flex items-center gap-1 rounded-md px-2 py-1 text-xs text-gray-400 hover:bg-gray-800 hover:text-gray-200"
126
+ >
127
+ <Key className="h-3 w-3" />
128
+ Auth
129
+ </span>
130
+ {p.type === "custom" && (
131
+ <>
132
+ <span
133
+ onClick={(e) => {
134
+ e.stopPropagation();
135
+ setEditProvider(p.id);
136
+ }}
137
+ className="flex items-center gap-1 rounded-md px-2 py-1 text-xs text-gray-400 hover:bg-gray-800 hover:text-gray-200"
138
+ >
139
+ <Edit3 className="h-3 w-3" />
140
+ {t("providers.edit_provider")}
141
+ </span>
142
+ <span
143
+ onClick={(e) => {
144
+ e.stopPropagation();
145
+ setDeleteConfirm(p.id);
146
+ }}
147
+ className="flex items-center gap-1 rounded-md px-2 py-1 text-xs text-red-400 hover:bg-red-500/10"
148
+ >
149
+ <Trash2 className="h-3 w-3" />
150
+ {t("providers.delete_provider")}
151
+ </span>
152
+ </>
153
+ )}
154
+ {expandedId === p.id ? (
155
+ <ChevronDown className="h-4 w-4 text-gray-500" />
156
+ ) : (
157
+ <ChevronRight className="h-4 w-4 text-gray-500" />
158
+ )}
159
+ </div>
160
+ </button>
161
+
162
+ {/* Expanded Details */}
163
+ {expandedId === p.id && (
164
+ <div className="border-t border-gray-800 px-5 py-4">
165
+ {/* Provider Info */}
166
+ <div className="mb-4 grid grid-cols-2 gap-4 text-sm">
167
+ <div>
168
+ <span className="text-gray-500">Provider ID:</span>
169
+ <span className="ml-2 text-gray-300">{p.id}</span>
170
+ </div>
171
+ <div>
172
+ <span className="text-gray-500">API Type:</span>
173
+ <span className="ml-2 text-gray-300">{p.api ?? "—"}</span>
174
+ </div>
175
+ {p.baseUrl && (
176
+ <div className="col-span-2">
177
+ <span className="text-gray-500">Base URL:</span>
178
+ <code className="ml-2 rounded bg-gray-800 px-2 py-0.5 text-xs text-blue-400">
179
+ {p.baseUrl}
180
+ </code>
181
+ </div>
182
+ )}
183
+ {p.authMethod && (
184
+ <div>
185
+ <span className="text-gray-500">Auth Method:</span>
186
+ <span className="ml-2 text-gray-300">{p.authMethod}</span>
187
+ </div>
188
+ )}
189
+ </div>
190
+
191
+ {/* Models */}
192
+ <h4 className="mb-2 text-xs font-medium uppercase tracking-wider text-gray-500">
193
+ Models ({p.models.length})
194
+ </h4>
195
+ <div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
196
+ {p.models.map((m) => (
197
+ <div
198
+ key={m.id}
199
+ className={cn(
200
+ "rounded-lg border px-3 py-2",
201
+ m.enabled ? "border-gray-700 bg-gray-800/50" : "border-gray-800 bg-gray-800/20 opacity-50"
202
+ )}
203
+ >
204
+ <div className="flex items-center justify-between">
205
+ <span className="text-sm font-medium text-gray-200">{m.name || m.id}</span>
206
+ <Badge variant={m.enabled ? "success" : "default"}>{m.enabled ? "On" : "Off"}</Badge>
207
+ </div>
208
+ <p className="mt-0.5 text-xs text-gray-500">
209
+ {m.cost ? `$${m.cost.input}/${m.cost.output} per M` : "Free"}
210
+ </p>
211
+ </div>
212
+ ))}
213
+ </div>
214
+
215
+ {/* Delete — only for custom providers */}
216
+ {p.type === "custom" && (
217
+ <div className="mt-6 border-t border-gray-800 pt-4">
218
+ <button
219
+ onClick={(e) => {
220
+ e.stopPropagation();
221
+ setDeleteConfirm(p.id);
222
+ }}
223
+ className="flex items-center gap-2 rounded-lg px-4 py-2 text-sm text-red-400 transition-colors hover:bg-red-500/10"
224
+ >
225
+ <Trash2 className="h-4 w-4" />
226
+ Delete {p.name}
227
+ </button>
228
+ </div>
229
+ )}
230
+ </div>
231
+ )}
232
+ </div>
233
+ ))}
234
+ </div>
235
+ )}
236
+
237
+ {/* Add Provider Modal */}
238
+ <Modal open={showAdd} onClose={() => setShowAdd(false)} title={t("providers.add_provider")} size="lg">
239
+ <ProviderForm
240
+ onSubmit={handleAddProvider}
241
+ onCancel={() => setShowAdd(false)}
242
+ />
243
+ </Modal>
244
+
245
+ {/* Edit Provider Modal */}
246
+ <Modal open={!!editProvider} onClose={() => setEditProvider(null)} title={t("providers.edit_provider")} size="lg">
247
+ {editProvider && (
248
+ <ProviderForm
249
+ initial={customProviders.find((p) => p.id === editProvider)}
250
+ onSubmit={(_, cfg) => handleEditProvider(editProvider, cfg)}
251
+ onCancel={() => setEditProvider(null)}
252
+ isEdit
253
+ />
254
+ )}
255
+ </Modal>
256
+
257
+ {/* Auth Modal */}
258
+ <Modal
259
+ open={!!showAuth}
260
+ onClose={() => { setShowAuth(null); setAuthKey(""); }}
261
+ title={`API Key — ${allProviders.find((p) => p.id === showAuth)?.name}`}
262
+ >
263
+ <div className="space-y-4">
264
+ <div>
265
+ <label className="block text-xs font-medium text-gray-400">API Key</label>
266
+ <input
267
+ type="password"
268
+ value={authKey}
269
+ onChange={(e) => setAuthKey(e.target.value)}
270
+ placeholder="sk-..."
271
+ className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white"
272
+ />
273
+ </div>
274
+ <div className="flex justify-between">
275
+ {showAuth && auth?.[showAuth] && (
276
+ <button
277
+ onClick={() => {
278
+ removeProviderAuth(showAuth!);
279
+ setShowAuth(null);
280
+ setAuthKey("");
281
+ }}
282
+ className="text-sm text-red-400 hover:text-red-300"
283
+ >
284
+ Remove Key
285
+ </button>
286
+ )}
287
+ <div className="flex gap-3 ml-auto">
288
+ <button
289
+ onClick={() => { setShowAuth(null); setAuthKey(""); }}
290
+ className="rounded-lg px-4 py-2 text-sm text-gray-400 hover:bg-gray-800"
291
+ >
292
+ Cancel
293
+ </button>
294
+ <button
295
+ onClick={() => handleSetAuth(showAuth!)}
296
+ disabled={!authKey}
297
+ className="rounded-lg px-4 py-2 text-sm font-medium transition-colors disabled:opacity-50"
298
+ style={{ backgroundColor: "#3b82f6", color: "#ffffff" }}
299
+ >
300
+ Save Key
301
+ </button>
302
+ </div>
303
+ </div>
304
+ </div>
305
+ </Modal>
306
+
307
+ {/* Delete Confirmation Modal */}
308
+ <Modal
309
+ open={!!deleteConfirm}
310
+ onClose={() => setDeleteConfirm(null)}
311
+ title="Delete Provider"
312
+ >
313
+ <div className="space-y-4">
314
+ <div className="flex items-start gap-3">
315
+ <Trash2 className="h-5 w-5 shrink-0 mt-0.5 text-red-400" />
316
+ <div>
317
+ <p className="text-sm text-gray-200">
318
+ Are you sure you want to delete <strong>{allProviders.find((p) => p.id === deleteConfirm)?.name}</strong>?
319
+ </p>
320
+ <p className="text-xs mt-2 text-gray-500">
321
+ This will remove the provider and all its models from your configuration.
322
+ </p>
323
+ </div>
324
+ </div>
325
+ <div className="flex justify-end gap-3">
326
+ <button
327
+ onClick={() => setDeleteConfirm(null)}
328
+ className="rounded-lg px-4 py-2 text-sm text-gray-400 hover:bg-gray-800"
329
+ >
330
+ Cancel
331
+ </button>
332
+ <button
333
+ onClick={() => {
334
+ if (deleteConfirm) {
335
+ removeCustomProvider(deleteConfirm);
336
+ setDeleteConfirm(null);
337
+ }
338
+ }}
339
+ className="flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium text-white"
340
+ style={{ backgroundColor: "#dc2626" }}
341
+ >
342
+ <Trash2 className="h-4 w-4" />
343
+ Delete
344
+ </button>
345
+ </div>
346
+ </div>
347
+ </Modal>
348
+ </div>
349
+ );
350
+ }
351
+
352
+ // ─── Provider Form ────────────────────────────────────────
353
+
354
+ interface ProviderFormProps {
355
+ initial?: { id: string; baseUrl?: string; api?: ApiType; apiKey?: string; headers?: Record<string, string> };
356
+ onSubmit: (id: string, cfg: CustomProviderConfig) => void;
357
+ onCancel: () => void;
358
+ isEdit?: boolean;
359
+ }
360
+
361
+ function ProviderForm({ initial, onSubmit, onCancel, isEdit }: ProviderFormProps) {
362
+ const [id, setId] = useState(initial?.id ?? "");
363
+ const [baseUrl, setBaseUrl] = useState(initial?.baseUrl ?? "");
364
+ const [api, setApi] = useState<ApiType>(initial?.api ?? "openai-completions");
365
+ const [apiKey, setApiKey] = useState(initial?.apiKey ?? "");
366
+ const [headersStr, setHeadersStr] = useState(
367
+ initial?.headers ? JSON.stringify(initial.headers, null, 2) : ""
368
+ );
369
+
370
+ const handleSubmit = () => {
371
+ if (!id) return;
372
+ let headers: Record<string, string> | undefined;
373
+ try {
374
+ headers = headersStr ? JSON.parse(headersStr) : undefined;
375
+ } catch {
376
+ // invalid JSON
377
+ }
378
+ const cfg: CustomProviderConfig = {
379
+ baseUrl: baseUrl || undefined,
380
+ api,
381
+ apiKey: apiKey || undefined,
382
+ headers,
383
+ models: [],
384
+ };
385
+ onSubmit(id, cfg);
386
+ };
387
+
388
+ return (
389
+ <div className="space-y-4">
390
+ {!isEdit && (
391
+ <div>
392
+ <label className="block text-xs font-medium text-gray-400">Provider ID *</label>
393
+ <input
394
+ type="text"
395
+ value={id}
396
+ onChange={(e) => setId(e.target.value.replace(/[^a-z0-9-]/g, ""))}
397
+ placeholder="my-ollama"
398
+ className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white"
399
+ />
400
+ <p className="mt-1 text-xs text-gray-500">Lowercase letters, numbers, and hyphens only</p>
401
+ </div>
402
+ )}
403
+
404
+ <div>
405
+ <label className="block text-xs font-medium text-gray-400">API Type</label>
406
+ <select
407
+ value={api}
408
+ onChange={(e) => setApi(e.target.value as ApiType)}
409
+ className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white"
410
+ >
411
+ {API_TYPES.map((t) => (
412
+ <option key={t.value} value={t.value}>{t.label}</option>
413
+ ))}
414
+ </select>
415
+ </div>
416
+
417
+ <div>
418
+ <label className="block text-xs font-medium text-gray-400">Base URL</label>
419
+ <input
420
+ type="text"
421
+ value={baseUrl}
422
+ onChange={(e) => setBaseUrl(e.target.value)}
423
+ placeholder="http://localhost:11434/v1"
424
+ className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white"
425
+ />
426
+ </div>
427
+
428
+ <div>
429
+ <label className="block text-xs font-medium text-gray-400">API Key (optional)</label>
430
+ <input
431
+ type="password"
432
+ value={apiKey}
433
+ onChange={(e) => setApiKey(e.target.value)}
434
+ placeholder="$MY_API_KEY or sk-..."
435
+ className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white"
436
+ />
437
+ <p className="mt-1 text-xs text-gray-500">Use $ENV_VAR for env vars, or paste the key directly</p>
438
+ </div>
439
+
440
+ <div>
441
+ <label className="block text-xs font-medium text-gray-400">Custom Headers (JSON)</label>
442
+ <textarea
443
+ value={headersStr}
444
+ onChange={(e) => setHeadersStr(e.target.value)}
445
+ placeholder='{"X-Custom-Header": "value"}'
446
+ rows={3}
447
+ className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white font-mono"
448
+ />
449
+ </div>
450
+
451
+ <div className="flex justify-end gap-3 pt-2">
452
+ <button onClick={onCancel} className="rounded-lg px-4 py-2 text-sm text-gray-400 hover:bg-gray-800">
453
+ Cancel
454
+ </button>
455
+ <button
456
+ onClick={handleSubmit}
457
+ disabled={!id || !baseUrl}
458
+ className="rounded-lg px-4 py-2 text-sm font-medium transition-colors disabled:opacity-50"
459
+ style={{ backgroundColor: "#3b82f6", color: "#ffffff" }}
460
+ >
461
+ {isEdit ? "Save Changes" : "Add Provider"}
462
+ </button>
463
+ </div>
464
+ </div>
465
+ );
466
+ }
@@ -0,0 +1,177 @@
1
+ import { useState, useEffect } from "react";
2
+ import { useConfigStore } from "@/store/config-store";
3
+ import { useTranslation } from "@/lib/i18n";
4
+ import { Brain, User, AlertTriangle, Clock, FileText } from "lucide-react";
5
+
6
+ interface MemoryFile {
7
+ name: string;
8
+ filename: string;
9
+ content: string;
10
+ updatedAt: string;
11
+ }
12
+
13
+ const FILE_ICONS: Record<string, typeof Brain> = {
14
+ "MEMORY.md": Brain,
15
+ "USER.md": User,
16
+ "failures.md": AlertTriangle,
17
+ };
18
+
19
+ const FILE_COLORS: Record<string, string> = {
20
+ "MEMORY.md": "#3b82f6",
21
+ "USER.md": "#10b981",
22
+ "failures.md": "#ef4444",
23
+ };
24
+
25
+ function formatDate(iso: string): string {
26
+ if (!iso) return "—";
27
+ const d = new Date(iso);
28
+ return d.toLocaleDateString("en-US", {
29
+ month: "short",
30
+ day: "numeric",
31
+ hour: "2-digit",
32
+ minute: "2-digit",
33
+ });
34
+ }
35
+
36
+ function mdToHtml(text: string): string {
37
+ return text
38
+ // Headings
39
+ .replace(/^### (.+)$/gm, '<h3 class="text-sm font-semibold mt-4 mb-2" style="color:var(--page-text)">$1</h3>')
40
+ .replace(/^## (.+)$/gm, '<h2 class="text-base font-bold mt-5 mb-2" style="color:var(--page-text)">$1</h2>')
41
+ .replace(/^# (.+)$/gm, '<h1 class="text-lg font-bold mt-5 mb-3" style="color:var(--page-text)">$1</h1>')
42
+ // Bold
43
+ .replace(/\*\*(.+?)\*\*/g, '<strong style="color:var(--page-text)">$1</strong>')
44
+ // Inline code
45
+ .replace(/`([^`]+)`/g, '<code class="text-xs px-1 py-0.5 rounded" style="background:var(--accent-bg);color:var(--sidebar-active-text)">$1</code>')
46
+ // Section separator § → horizontal rule
47
+ .replace(/^§\s*$/gm, '<hr style="border-color:var(--card-border);margin:12px 0" />')
48
+ // List items (must be after other inline patterns)
49
+ .replace(/^- (.+)$/gm, '<li class="text-sm ml-4" style="color:var(--page-text);list-style:disc">$1</li>')
50
+ // Comments: <!-- ... -->
51
+ .replace(/<!-- (.+?) -->/g, '<span class="text-xs ml-2" style="color:var(--subtle-text)">// $1</span>')
52
+ // Double line breaks to paragraphs
53
+ .replace(/\n\n/g, '</p><p class="text-sm leading-relaxed" style="color:var(--page-text)">')
54
+ // Single line breaks within paragraphs
55
+ .replace(/\n/g, '<br />')
56
+ // Wrap everything in paragraph if not already wrapped
57
+ .replace(/^(?!<[hp])/m, '<p class="text-sm leading-relaxed" style="color:var(--page-text)">')
58
+ .replace(/([^>])$/m, '$1</p>');
59
+ }
60
+
61
+ function MemoryCard({ file }: { file: MemoryFile }) {
62
+ const { t } = useTranslation();
63
+ const [open, setOpen] = useState(true);
64
+ const Icon = FILE_ICONS[file.filename] || FileText;
65
+ const color = FILE_COLORS[file.filename] || "#6b7280";
66
+ const lineCount = file.content.split("\n").length;
67
+
68
+ if (!file.content) {
69
+ return null;
70
+ }
71
+
72
+ return (
73
+ <div className="rounded-xl border overflow-hidden" style={{ borderColor: "var(--card-border)" }}>
74
+ {/* Header */}
75
+ <button
76
+ onClick={() => setOpen(!open)}
77
+ className="flex w-full items-center justify-between px-6 py-4"
78
+ style={{ backgroundColor: "var(--card-bg)" }}
79
+ >
80
+ <div className="flex items-center gap-3">
81
+ <div className="flex h-9 w-9 items-center justify-center rounded-lg" style={{ backgroundColor: `${color}15` }}>
82
+ <Icon className="h-4 w-4" style={{ color }} />
83
+ </div>
84
+ <div className="text-left">
85
+ <h3 className="text-sm font-semibold" style={{ color: "var(--page-text)" }}>{t("memory." + file.filename.replace("MEMORY.md", "project_memories").replace("USER.md", "user_profile").replace("failures.md", "failure_records"))}</h3>
86
+ <p className="text-xs" style={{ color: "var(--muted-text)" }}>
87
+ {t("memory.lines", String(lineCount))} · {t("memory.updated", formatDate(file.updatedAt))}
88
+ </p>
89
+ </div>
90
+ </div>
91
+ <div className="flex items-center gap-2">
92
+ <span className="text-xs font-medium" style={{ color }}>
93
+ {file.filename}
94
+ </span>
95
+ <Clock className="h-3.5 w-3.5" style={{ color: "var(--muted-text)" }} />
96
+ </div>
97
+ </button>
98
+
99
+ {/* Content */}
100
+ {open && (
101
+ <div className="px-6 py-4" style={{ borderTop: "1px solid var(--card-border)", backgroundColor: "var(--page-bg)" }}>
102
+ <div
103
+ className="prose prose-sm max-w-none"
104
+ dangerouslySetInnerHTML={{ __html: mdToHtml(file.content) }}
105
+ style={{ color: "var(--page-text)" }}
106
+ />
107
+ </div>
108
+ )}
109
+ </div>
110
+ );
111
+ }
112
+
113
+ export function MemoryPage() {
114
+ const { t } = useTranslation();
115
+ const { initialized } = useConfigStore();
116
+ const [files, setFiles] = useState<MemoryFile[]>([]);
117
+ const [loading, setLoading] = useState(true);
118
+ const [error, setError] = useState<string | null>(null);
119
+
120
+ useEffect(() => {
121
+ if (!initialized) return;
122
+ setLoading(true);
123
+ fetch("/api/pi/memory")
124
+ .then((r) => r.json())
125
+ .then((data) => {
126
+ setFiles(data);
127
+ setLoading(false);
128
+ })
129
+ .catch((e) => {
130
+ setError(e.message);
131
+ setLoading(false);
132
+ });
133
+ }, [initialized]);
134
+
135
+ if (loading) {
136
+ return (
137
+ <div className="flex items-center justify-center h-64">
138
+ <div className="h-8 w-8 animate-spin rounded-full border-2 border-gray-600 border-t-blue-500" />
139
+ </div>
140
+ );
141
+ }
142
+
143
+ if (error) {
144
+ return (
145
+ <div className="flex items-center justify-center h-64">
146
+ <p className="text-sm text-red-400">Failed to load memory: {error}</p>
147
+ </div>
148
+ );
149
+ }
150
+
151
+ const totalLines = files.reduce((s, f) => s + f.content.split("\n").length, 0);
152
+
153
+ return (
154
+ <div className="space-y-6">
155
+ <div>
156
+ <h1 className="text-2xl font-bold" style={{ color: "var(--page-text)" }}>{t("memory.title")}</h1>
157
+ <p className="mt-1 text-sm" style={{ color: "var(--muted-text)" }}>
158
+ {t("memory.summary", String(totalLines), String(files.length))}
159
+ </p>
160
+ </div>
161
+
162
+ {files.length === 0 ? (
163
+ <div className="flex flex-col items-center justify-center py-12">
164
+ <Brain className="h-12 w-12" style={{ color: "var(--subtle-text)" }} />
165
+ <p className="mt-4 text-sm" style={{ color: "var(--muted-text)" }}>{t("memory.no_memory")}</p>
166
+ <p className="text-xs mt-1" style={{ color: "var(--subtle-text)" }}>{t("memory.no_memory_desc")}</p>
167
+ </div>
168
+ ) : (
169
+ <div className="space-y-4">
170
+ {files.map((file) => (
171
+ <MemoryCard key={file.filename} file={file} />
172
+ ))}
173
+ </div>
174
+ )}
175
+ </div>
176
+ );
177
+ }