@raingor/pi-web-switch 0.3.1 → 0.3.2
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.md +0 -1
- package/README.zh-CN.md +0 -1
- package/package.json +1 -1
- package/server/pi-reader.ts +403 -4
- package/src/App.tsx +3 -0
- package/src/components/dashboard/DashboardPage.tsx +275 -117
- package/src/components/layout/Sidebar.tsx +2 -0
- package/src/components/providers/ProvidersModelsPage.tsx +1387 -0
- package/src/components/sessions/MemoryPage.tsx +480 -85
- package/src/components/sessions/SessionsPage.tsx +473 -89
- package/src/components/settings/SettingsPage.tsx +555 -243
- package/src/data/builtin-providers.ts +0 -12
- package/src/data/mock-config.ts +1 -15
- package/src/data/mock-usage.ts +0 -2
- package/src/lib/translations/en.ts +114 -23
- package/src/lib/translations/ja.ts +114 -23
- package/src/lib/translations/zh-CN.ts +114 -23
- package/src/lib/translations/zh-TW.ts +114 -23
- package/src/main.tsx +9 -0
- package/src/store/config-store.ts +18 -12
- package/src/types/index.ts +14 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/vite.config.ts +115 -3
- package/src/components/models/ModelsPage.tsx +0 -570
- package/src/components/providers/ProvidersPage.tsx +0 -466
|
@@ -1,7 +1,22 @@
|
|
|
1
|
-
import { useState, useEffect } from "react";
|
|
1
|
+
import { useState, useEffect, useMemo, useCallback } from "react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
2
3
|
import { useConfigStore } from "@/store/config-store";
|
|
3
4
|
import { useTranslation } from "@/lib/i18n";
|
|
4
|
-
import {
|
|
5
|
+
import { Modal } from "@/components/ui/Modal";
|
|
6
|
+
import {
|
|
7
|
+
Brain,
|
|
8
|
+
User,
|
|
9
|
+
AlertTriangle,
|
|
10
|
+
FileText,
|
|
11
|
+
Calendar,
|
|
12
|
+
RefreshCw,
|
|
13
|
+
Search,
|
|
14
|
+
Copy,
|
|
15
|
+
Check,
|
|
16
|
+
Trash2,
|
|
17
|
+
ChevronDown,
|
|
18
|
+
ChevronRight,
|
|
19
|
+
} from "lucide-react";
|
|
5
20
|
|
|
6
21
|
interface MemoryFile {
|
|
7
22
|
name: string;
|
|
@@ -10,6 +25,12 @@ interface MemoryFile {
|
|
|
10
25
|
updatedAt: string;
|
|
11
26
|
}
|
|
12
27
|
|
|
28
|
+
interface MemoryEntry {
|
|
29
|
+
text: string;
|
|
30
|
+
created: string;
|
|
31
|
+
last: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
13
34
|
const FILE_ICONS: Record<string, typeof Brain> = {
|
|
14
35
|
"MEMORY.md": Brain,
|
|
15
36
|
"USER.md": User,
|
|
@@ -22,88 +43,324 @@ const FILE_COLORS: Record<string, string> = {
|
|
|
22
43
|
"failures.md": "#ef4444",
|
|
23
44
|
};
|
|
24
45
|
|
|
46
|
+
const FILE_LABEL_KEYS: Record<string, string> = {
|
|
47
|
+
"MEMORY.md": "memory.project_memories",
|
|
48
|
+
"USER.md": "memory.user_profile",
|
|
49
|
+
"failures.md": "memory.failure_records",
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** Date groups expanded by default per file */
|
|
53
|
+
const DEFAULT_OPEN_GROUPS = 3;
|
|
54
|
+
|
|
55
|
+
/** Parse §-separated entries with `<!-- created=DATE, last=DATE -->` markers */
|
|
56
|
+
function parseMemoryEntries(content: string): MemoryEntry[] {
|
|
57
|
+
const entries: MemoryEntry[] = [];
|
|
58
|
+
const sections = content.split("§").filter((s) => s.trim().length > 0);
|
|
59
|
+
|
|
60
|
+
for (const section of sections) {
|
|
61
|
+
const trimmed = section.trim();
|
|
62
|
+
// Match `<!-- created=DATE, last=DATE -->` at the end
|
|
63
|
+
const markerMatch = trimmed.match(
|
|
64
|
+
/<!--\s*created\s*=\s*([^,\s]+)\s*,\s*last\s*=\s*([^>\s]+)\s*-->\s*$/
|
|
65
|
+
);
|
|
66
|
+
if (markerMatch) {
|
|
67
|
+
entries.push({
|
|
68
|
+
text: trimmed.slice(0, markerMatch.index).trim(),
|
|
69
|
+
created: markerMatch[1]?.trim() ?? "",
|
|
70
|
+
last: markerMatch[2]?.trim() ?? "",
|
|
71
|
+
});
|
|
72
|
+
} else {
|
|
73
|
+
// No date marker — treat as a standalone entry
|
|
74
|
+
entries.push({ text: trimmed, created: "", last: "" });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return entries;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Group entries by date (descending) */
|
|
82
|
+
function groupByDate(entries: MemoryEntry[]): Map<string, MemoryEntry[]> {
|
|
83
|
+
const groups = new Map<string, MemoryEntry[]>();
|
|
84
|
+
for (const entry of entries) {
|
|
85
|
+
const dateKey = entry.created || entry.last || "other";
|
|
86
|
+
if (!groups.has(dateKey)) groups.set(dateKey, []);
|
|
87
|
+
groups.get(dateKey)!.push(entry);
|
|
88
|
+
}
|
|
89
|
+
return new Map(
|
|
90
|
+
[...groups.entries()].sort((a, b) => {
|
|
91
|
+
if (a[0] === "other") return 1;
|
|
92
|
+
if (b[0] === "other") return -1;
|
|
93
|
+
return b[0].localeCompare(a[0]);
|
|
94
|
+
})
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
25
98
|
function formatDate(iso: string): string {
|
|
26
99
|
if (!iso) return "—";
|
|
27
|
-
|
|
28
|
-
|
|
100
|
+
return new Date(iso).toLocaleDateString(undefined, {
|
|
101
|
+
year: "numeric",
|
|
29
102
|
month: "short",
|
|
30
103
|
day: "numeric",
|
|
31
|
-
hour: "2-digit",
|
|
32
|
-
minute: "2-digit",
|
|
33
104
|
});
|
|
34
105
|
}
|
|
35
106
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
.
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
.
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
107
|
+
/** Highlight search matches inside a plain-text fragment */
|
|
108
|
+
function highlight(text: string, q: string): ReactNode {
|
|
109
|
+
if (!q || !text) return text;
|
|
110
|
+
const lower = text.toLowerCase();
|
|
111
|
+
const parts: ReactNode[] = [];
|
|
112
|
+
let pos = 0;
|
|
113
|
+
let idx = lower.indexOf(q);
|
|
114
|
+
while (idx !== -1) {
|
|
115
|
+
if (idx > pos) parts.push(text.slice(pos, idx));
|
|
116
|
+
parts.push(
|
|
117
|
+
<mark
|
|
118
|
+
key={`${idx}-${pos}`}
|
|
119
|
+
className="rounded-sm px-0.5"
|
|
120
|
+
style={{ backgroundColor: "rgba(250, 204, 21, 0.35)", color: "inherit" }}
|
|
121
|
+
>
|
|
122
|
+
{text.slice(idx, idx + q.length)}
|
|
123
|
+
</mark>
|
|
124
|
+
);
|
|
125
|
+
pos = idx + q.length;
|
|
126
|
+
idx = lower.indexOf(q, pos);
|
|
127
|
+
}
|
|
128
|
+
if (pos < text.length) parts.push(text.slice(pos));
|
|
129
|
+
return parts;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Lightweight inline markdown: **bold**, `code`, [text](url). No external deps. */
|
|
133
|
+
function renderInline(text: string, q: string): ReactNode {
|
|
134
|
+
const re = /(\*\*[^*]+\*\*|`[^`]+`|\[[^\]]+\]\((?:https?:\/\/)[^)\s]+\))/g;
|
|
135
|
+
const parts = text.split(re);
|
|
136
|
+
return parts.map((part, i) => {
|
|
137
|
+
if (/^\*\*[^*]+\*\*$/.test(part)) {
|
|
138
|
+
return (
|
|
139
|
+
<strong key={i} className="font-semibold">
|
|
140
|
+
{highlight(part.slice(2, -2), q)}
|
|
141
|
+
</strong>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
if (/^`[^`]+`$/.test(part)) {
|
|
145
|
+
return (
|
|
146
|
+
<code
|
|
147
|
+
key={i}
|
|
148
|
+
className="rounded border px-1 py-px text-[12px]"
|
|
149
|
+
style={{
|
|
150
|
+
borderColor: "var(--card-border)",
|
|
151
|
+
backgroundColor: "var(--page-bg)",
|
|
152
|
+
color: "var(--page-text)",
|
|
153
|
+
}}
|
|
154
|
+
>
|
|
155
|
+
{highlight(part.slice(1, -1), q)}
|
|
156
|
+
</code>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
const link = part.match(/^\[([^\]]+)\]\(((?:https?:\/\/)[^)\s]+)\)$/);
|
|
160
|
+
if (link) {
|
|
161
|
+
return (
|
|
162
|
+
<a
|
|
163
|
+
key={i}
|
|
164
|
+
href={link[2]}
|
|
165
|
+
target="_blank"
|
|
166
|
+
rel="noreferrer"
|
|
167
|
+
className="underline underline-offset-2"
|
|
168
|
+
style={{ color: "#3b82f6" }}
|
|
169
|
+
>
|
|
170
|
+
{highlight(link[1] ?? "", q)}
|
|
171
|
+
</a>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
return <span key={i}>{highlight(part, q)}</span>;
|
|
175
|
+
});
|
|
59
176
|
}
|
|
60
177
|
|
|
61
|
-
function
|
|
178
|
+
function EntryCard({
|
|
179
|
+
entry,
|
|
180
|
+
q,
|
|
181
|
+
onDelete,
|
|
182
|
+
}: {
|
|
183
|
+
entry: MemoryEntry;
|
|
184
|
+
q: string;
|
|
185
|
+
onDelete: () => void;
|
|
186
|
+
}) {
|
|
187
|
+
const { t } = useTranslation();
|
|
188
|
+
const [copied, setCopied] = useState(false);
|
|
189
|
+
|
|
190
|
+
const handleCopy = () => {
|
|
191
|
+
const markCopied = () => {
|
|
192
|
+
setCopied(true);
|
|
193
|
+
setTimeout(() => setCopied(false), 1500);
|
|
194
|
+
};
|
|
195
|
+
navigator.clipboard.writeText(entry.text).then(markCopied).catch(() => {
|
|
196
|
+
// Fallback for non-focused/insecure contexts
|
|
197
|
+
const ta = document.createElement("textarea");
|
|
198
|
+
ta.value = entry.text;
|
|
199
|
+
ta.style.position = "fixed";
|
|
200
|
+
ta.style.opacity = "0";
|
|
201
|
+
document.body.appendChild(ta);
|
|
202
|
+
ta.select();
|
|
203
|
+
try {
|
|
204
|
+
document.execCommand("copy");
|
|
205
|
+
markCopied();
|
|
206
|
+
} finally {
|
|
207
|
+
document.body.removeChild(ta);
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
return (
|
|
213
|
+
<div
|
|
214
|
+
className="group relative rounded-lg border px-3.5 py-2.5"
|
|
215
|
+
style={{ backgroundColor: "var(--card-bg)", borderColor: "var(--card-border)" }}
|
|
216
|
+
>
|
|
217
|
+
<p
|
|
218
|
+
className="whitespace-pre-wrap pr-14 text-[13px] leading-relaxed"
|
|
219
|
+
style={{ color: "var(--page-text)" }}
|
|
220
|
+
>
|
|
221
|
+
{renderInline(entry.text, q)}
|
|
222
|
+
</p>
|
|
223
|
+
{/* Hover actions: copy / delete */}
|
|
224
|
+
<div className="absolute right-2 top-2 flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
|
225
|
+
<button
|
|
226
|
+
onClick={handleCopy}
|
|
227
|
+
className="rounded p-1 transition-colors hover:bg-black/10"
|
|
228
|
+
style={{ color: copied ? "#10b981" : "var(--muted-text)" }}
|
|
229
|
+
title={copied ? t("memory.copied") : t("memory.copy")}
|
|
230
|
+
>
|
|
231
|
+
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
|
232
|
+
</button>
|
|
233
|
+
<button
|
|
234
|
+
onClick={onDelete}
|
|
235
|
+
className="rounded p-1 transition-colors hover:bg-black/10"
|
|
236
|
+
style={{ color: "#ef4444" }}
|
|
237
|
+
title={t("memory.delete")}
|
|
238
|
+
>
|
|
239
|
+
<Trash2 className="h-3.5 w-3.5" />
|
|
240
|
+
</button>
|
|
241
|
+
</div>
|
|
242
|
+
</div>
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function MemoryFileSection({
|
|
247
|
+
file,
|
|
248
|
+
entries,
|
|
249
|
+
q,
|
|
250
|
+
onDeleteEntry,
|
|
251
|
+
}: {
|
|
252
|
+
file: MemoryFile;
|
|
253
|
+
entries: MemoryEntry[];
|
|
254
|
+
q: string;
|
|
255
|
+
onDeleteEntry: (entry: MemoryEntry) => void;
|
|
256
|
+
}) {
|
|
62
257
|
const { t } = useTranslation();
|
|
63
|
-
const [open, setOpen] = useState(true);
|
|
64
258
|
const Icon = FILE_ICONS[file.filename] || FileText;
|
|
65
259
|
const color = FILE_COLORS[file.filename] || "#6b7280";
|
|
66
|
-
|
|
260
|
+
// Collapse overrides: user toggles win over the default (first N groups open)
|
|
261
|
+
const [openOverrides, setOpenOverrides] = useState<Record<string, boolean>>({});
|
|
67
262
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
263
|
+
const grouped = useMemo(() => groupByDate(entries), [entries]);
|
|
264
|
+
const labelKey = FILE_LABEL_KEYS[file.filename];
|
|
265
|
+
|
|
266
|
+
const toggleGroup = (key: string, currentOpen: boolean) =>
|
|
267
|
+
setOpenOverrides((prev) => ({ ...prev, [key]: !currentOpen }));
|
|
71
268
|
|
|
72
269
|
return (
|
|
73
|
-
<div
|
|
74
|
-
{/*
|
|
75
|
-
<
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
style={{ backgroundColor: "var(--card-bg)" }}
|
|
270
|
+
<div>
|
|
271
|
+
{/* File header */}
|
|
272
|
+
<div
|
|
273
|
+
className="flex items-center gap-2 pb-3"
|
|
274
|
+
style={{ borderBottom: "1px solid var(--card-border)" }}
|
|
79
275
|
>
|
|
80
|
-
<
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
276
|
+
<Icon className="h-4 w-4" style={{ color }} />
|
|
277
|
+
<h3 className="text-sm font-semibold" style={{ color: "var(--page-text)" }}>
|
|
278
|
+
{labelKey ? t(labelKey) : file.name}
|
|
279
|
+
</h3>
|
|
280
|
+
<span
|
|
281
|
+
className="rounded border px-1.5 py-0.5 text-[10px]"
|
|
282
|
+
style={{
|
|
283
|
+
borderColor: "var(--card-border)",
|
|
284
|
+
color: "var(--muted-text)",
|
|
285
|
+
backgroundColor: "var(--card-bg)",
|
|
286
|
+
}}
|
|
287
|
+
>
|
|
288
|
+
{file.filename}
|
|
289
|
+
</span>
|
|
290
|
+
<span className="text-[11px]" style={{ color: "var(--muted-text)" }}>
|
|
291
|
+
{t("memory.entries_count", String(entries.length))}
|
|
292
|
+
</span>
|
|
293
|
+
{file.updatedAt && (
|
|
294
|
+
<span className="ml-auto text-[11px]" style={{ color: "var(--muted-text)" }}>
|
|
295
|
+
{formatDate(file.updatedAt)}
|
|
94
296
|
</span>
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
{
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
297
|
+
)}
|
|
298
|
+
</div>
|
|
299
|
+
|
|
300
|
+
{/* Entries grouped by date */}
|
|
301
|
+
{entries.length === 0 ? (
|
|
302
|
+
<p className="mt-4 text-xs italic" style={{ color: "var(--muted-text)" }}>
|
|
303
|
+
{t("memory.empty_file")}
|
|
304
|
+
</p>
|
|
305
|
+
) : (
|
|
306
|
+
<div className="mt-4 space-y-4">
|
|
307
|
+
{[...grouped.entries()].map(([dateKey, dateEntries], groupIdx) => {
|
|
308
|
+
// Searching forces all matched groups open; otherwise first N open by default
|
|
309
|
+
const open = q
|
|
310
|
+
? true
|
|
311
|
+
: openOverrides[dateKey] ?? groupIdx < DEFAULT_OPEN_GROUPS;
|
|
312
|
+
return (
|
|
313
|
+
<div key={dateKey}>
|
|
314
|
+
{/* Date header (click to collapse/expand) */}
|
|
315
|
+
<button
|
|
316
|
+
onClick={() => toggleGroup(dateKey, open)}
|
|
317
|
+
className="mb-2.5 flex w-full items-center gap-2"
|
|
318
|
+
disabled={!!q}
|
|
319
|
+
>
|
|
320
|
+
{open ? (
|
|
321
|
+
<ChevronDown className="h-3.5 w-3.5" style={{ color }} />
|
|
322
|
+
) : (
|
|
323
|
+
<ChevronRight className="h-3.5 w-3.5" style={{ color }} />
|
|
324
|
+
)}
|
|
325
|
+
<Calendar className="h-3.5 w-3.5" style={{ color }} />
|
|
326
|
+
<span
|
|
327
|
+
className="text-xs font-semibold uppercase tracking-wide"
|
|
328
|
+
style={{ color }}
|
|
329
|
+
>
|
|
330
|
+
{dateKey === "other" ? t("memory.other") : dateKey}
|
|
331
|
+
</span>
|
|
332
|
+
<div
|
|
333
|
+
className="h-px flex-1 opacity-50"
|
|
334
|
+
style={{ backgroundColor: "var(--card-border)" }}
|
|
335
|
+
/>
|
|
336
|
+
<span
|
|
337
|
+
className="rounded border px-1.5 py-0.5 text-[10px]"
|
|
338
|
+
style={{
|
|
339
|
+
borderColor: "var(--card-border)",
|
|
340
|
+
color: "var(--muted-text)",
|
|
341
|
+
backgroundColor: "var(--card-bg)",
|
|
342
|
+
}}
|
|
343
|
+
>
|
|
344
|
+
{t("memory.entries_count", String(dateEntries.length))}
|
|
345
|
+
</span>
|
|
346
|
+
</button>
|
|
347
|
+
|
|
348
|
+
{/* Timeline entries */}
|
|
349
|
+
{open && (
|
|
350
|
+
<div className="space-y-1.5 pl-2">
|
|
351
|
+
{dateEntries.map((entry, idx) => (
|
|
352
|
+
<EntryCard
|
|
353
|
+
key={`${dateKey}-${idx}`}
|
|
354
|
+
entry={entry}
|
|
355
|
+
q={q}
|
|
356
|
+
onDelete={() => onDeleteEntry(entry)}
|
|
357
|
+
/>
|
|
358
|
+
))}
|
|
359
|
+
</div>
|
|
360
|
+
)}
|
|
361
|
+
</div>
|
|
362
|
+
);
|
|
363
|
+
})}
|
|
107
364
|
</div>
|
|
108
365
|
)}
|
|
109
366
|
</div>
|
|
@@ -115,23 +372,73 @@ export function MemoryPage() {
|
|
|
115
372
|
const { initialized } = useConfigStore();
|
|
116
373
|
const [files, setFiles] = useState<MemoryFile[]>([]);
|
|
117
374
|
const [loading, setLoading] = useState(true);
|
|
375
|
+
const [refreshing, setRefreshing] = useState(false);
|
|
118
376
|
const [error, setError] = useState<string | null>(null);
|
|
377
|
+
const [filter, setFilter] = useState("");
|
|
378
|
+
const [deleteTarget, setDeleteTarget] = useState<{ filename: string; entry: MemoryEntry } | null>(null);
|
|
379
|
+
const [deleting, setDeleting] = useState(false);
|
|
119
380
|
|
|
120
|
-
|
|
381
|
+
const loadAll = useCallback(() => {
|
|
121
382
|
if (!initialized) return;
|
|
122
|
-
|
|
383
|
+
setRefreshing(true);
|
|
123
384
|
fetch("/api/pi/memory")
|
|
124
385
|
.then((r) => r.json())
|
|
125
386
|
.then((data) => {
|
|
126
387
|
setFiles(data);
|
|
127
|
-
|
|
388
|
+
setError(null);
|
|
128
389
|
})
|
|
129
|
-
.catch((e) =>
|
|
130
|
-
|
|
390
|
+
.catch((e) => setError(e.message))
|
|
391
|
+
.finally(() => {
|
|
131
392
|
setLoading(false);
|
|
393
|
+
setRefreshing(false);
|
|
132
394
|
});
|
|
133
395
|
}, [initialized]);
|
|
134
396
|
|
|
397
|
+
useEffect(() => { loadAll(); }, [loadAll]);
|
|
398
|
+
|
|
399
|
+
const handleDelete = async () => {
|
|
400
|
+
if (!deleteTarget) return;
|
|
401
|
+
setDeleting(true);
|
|
402
|
+
try {
|
|
403
|
+
const res = await fetch("/api/pi/memory/delete-entry", {
|
|
404
|
+
method: "POST",
|
|
405
|
+
headers: { "Content-Type": "application/json" },
|
|
406
|
+
body: JSON.stringify({ filename: deleteTarget.filename, text: deleteTarget.entry.text }),
|
|
407
|
+
});
|
|
408
|
+
const result = await res.json();
|
|
409
|
+
if (result.success) {
|
|
410
|
+
setDeleteTarget(null);
|
|
411
|
+
loadAll();
|
|
412
|
+
} else {
|
|
413
|
+
alert(t("memory.delete_failed"));
|
|
414
|
+
}
|
|
415
|
+
} catch {
|
|
416
|
+
alert(t("memory.delete_failed"));
|
|
417
|
+
} finally {
|
|
418
|
+
setDeleting(false);
|
|
419
|
+
}
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
// Parse all files once; stats + search operate on parsed entries
|
|
423
|
+
const parsed = useMemo(
|
|
424
|
+
() =>
|
|
425
|
+
files
|
|
426
|
+
.filter((f) => f.content)
|
|
427
|
+
.map((f) => ({ file: f, entries: parseMemoryEntries(f.content) })),
|
|
428
|
+
[files]
|
|
429
|
+
);
|
|
430
|
+
|
|
431
|
+
const q = filter.trim().toLowerCase();
|
|
432
|
+
const visible = useMemo(() => {
|
|
433
|
+
if (!q) return parsed;
|
|
434
|
+
return parsed
|
|
435
|
+
.map(({ file, entries }) => ({
|
|
436
|
+
file,
|
|
437
|
+
entries: entries.filter((e) => e.text.toLowerCase().includes(q)),
|
|
438
|
+
}))
|
|
439
|
+
.filter(({ entries }) => entries.length > 0);
|
|
440
|
+
}, [parsed, q]);
|
|
441
|
+
|
|
135
442
|
if (loading) {
|
|
136
443
|
return (
|
|
137
444
|
<div className="flex items-center justify-center h-64">
|
|
@@ -143,35 +450,123 @@ export function MemoryPage() {
|
|
|
143
450
|
if (error) {
|
|
144
451
|
return (
|
|
145
452
|
<div className="flex items-center justify-center h-64">
|
|
146
|
-
<p className="text-sm
|
|
453
|
+
<p className="text-sm" style={{ color: "var(--error-text, #ef4444)" }}>
|
|
454
|
+
{t("memory.load_failed")}: {error}
|
|
455
|
+
</p>
|
|
147
456
|
</div>
|
|
148
457
|
);
|
|
149
458
|
}
|
|
150
459
|
|
|
151
|
-
const
|
|
460
|
+
const totalEntries = parsed.reduce((s, p) => s + p.entries.length, 0);
|
|
152
461
|
|
|
153
462
|
return (
|
|
154
463
|
<div className="space-y-6">
|
|
155
|
-
<div>
|
|
156
|
-
<
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
464
|
+
<div className="flex items-start justify-between">
|
|
465
|
+
<div>
|
|
466
|
+
<h1 className="text-2xl font-bold" style={{ color: "var(--page-text)" }}>{t("memory.title")}</h1>
|
|
467
|
+
<p className="mt-1 text-sm" style={{ color: "var(--muted-text)" }}>
|
|
468
|
+
{t("memory.summary", String(totalEntries), String(parsed.length))}
|
|
469
|
+
</p>
|
|
470
|
+
</div>
|
|
471
|
+
<button
|
|
472
|
+
onClick={loadAll}
|
|
473
|
+
className="flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors"
|
|
474
|
+
style={{ borderColor: "var(--card-border)", color: "var(--muted-text)", backgroundColor: "var(--card-bg)" }}
|
|
475
|
+
title={t("memory.refresh")}
|
|
476
|
+
>
|
|
477
|
+
<RefreshCw className={refreshing ? "h-3.5 w-3.5 animate-spin" : "h-3.5 w-3.5"} />
|
|
478
|
+
{t("memory.refresh")}
|
|
479
|
+
</button>
|
|
160
480
|
</div>
|
|
161
481
|
|
|
162
|
-
{
|
|
482
|
+
{parsed.length === 0 ? (
|
|
163
483
|
<div className="flex flex-col items-center justify-center py-12">
|
|
164
484
|
<Brain className="h-12 w-12" style={{ color: "var(--subtle-text)" }} />
|
|
165
485
|
<p className="mt-4 text-sm" style={{ color: "var(--muted-text)" }}>{t("memory.no_memory")}</p>
|
|
166
486
|
<p className="text-xs mt-1" style={{ color: "var(--subtle-text)" }}>{t("memory.no_memory_desc")}</p>
|
|
167
487
|
</div>
|
|
168
488
|
) : (
|
|
169
|
-
|
|
170
|
-
{
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
489
|
+
<>
|
|
490
|
+
{/* Search */}
|
|
491
|
+
<div className="relative max-w-md">
|
|
492
|
+
<Search
|
|
493
|
+
className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2"
|
|
494
|
+
style={{ color: "var(--subtle-text)" }}
|
|
495
|
+
/>
|
|
496
|
+
<input
|
|
497
|
+
type="text"
|
|
498
|
+
value={filter}
|
|
499
|
+
onChange={(e) => setFilter(e.target.value)}
|
|
500
|
+
placeholder={t("memory.search_placeholder")}
|
|
501
|
+
className="w-full rounded-lg border py-2 pl-9 pr-3 text-sm outline-none focus:ring-1 focus:ring-blue-500"
|
|
502
|
+
style={{
|
|
503
|
+
backgroundColor: "var(--card-bg)",
|
|
504
|
+
borderColor: "var(--card-border)",
|
|
505
|
+
color: "var(--page-text)",
|
|
506
|
+
}}
|
|
507
|
+
/>
|
|
508
|
+
</div>
|
|
509
|
+
|
|
510
|
+
{visible.length === 0 ? (
|
|
511
|
+
<p className="py-8 text-center text-sm" style={{ color: "var(--muted-text)" }}>
|
|
512
|
+
{t("memory.no_results")}
|
|
513
|
+
</p>
|
|
514
|
+
) : (
|
|
515
|
+
<div className="space-y-8">
|
|
516
|
+
{visible.map(({ file, entries }) => (
|
|
517
|
+
<MemoryFileSection
|
|
518
|
+
key={file.filename}
|
|
519
|
+
file={file}
|
|
520
|
+
entries={entries}
|
|
521
|
+
q={q}
|
|
522
|
+
onDeleteEntry={(entry) => setDeleteTarget({ filename: file.filename, entry })}
|
|
523
|
+
/>
|
|
524
|
+
))}
|
|
525
|
+
</div>
|
|
526
|
+
)}
|
|
527
|
+
</>
|
|
174
528
|
)}
|
|
529
|
+
|
|
530
|
+
{/* Delete entry confirm */}
|
|
531
|
+
<Modal
|
|
532
|
+
open={deleteTarget !== null}
|
|
533
|
+
onClose={() => !deleting && setDeleteTarget(null)}
|
|
534
|
+
title={t("memory.delete_title")}
|
|
535
|
+
size="md"
|
|
536
|
+
>
|
|
537
|
+
{deleteTarget && (
|
|
538
|
+
<div className="space-y-4">
|
|
539
|
+
<div className="flex items-start gap-3">
|
|
540
|
+
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-red-400" />
|
|
541
|
+
<div className="min-w-0">
|
|
542
|
+
<p className="text-sm text-gray-300">{t("memory.delete_confirm")}</p>
|
|
543
|
+
<p className="mt-2 max-h-32 overflow-y-auto whitespace-pre-wrap rounded border border-gray-700 bg-gray-800/60 px-3 py-2 text-xs text-gray-400">
|
|
544
|
+
{deleteTarget.entry.text.length > 300
|
|
545
|
+
? deleteTarget.entry.text.slice(0, 300) + "…"
|
|
546
|
+
: deleteTarget.entry.text}
|
|
547
|
+
</p>
|
|
548
|
+
<p className="mt-2 text-xs text-red-400">{t("memory.delete_note")}</p>
|
|
549
|
+
</div>
|
|
550
|
+
</div>
|
|
551
|
+
<div className="flex justify-end gap-2">
|
|
552
|
+
<button
|
|
553
|
+
onClick={() => setDeleteTarget(null)}
|
|
554
|
+
disabled={deleting}
|
|
555
|
+
className="rounded-lg border border-gray-700 px-4 py-2 text-sm text-gray-300 hover:bg-gray-800"
|
|
556
|
+
>
|
|
557
|
+
{t("memory.cancel")}
|
|
558
|
+
</button>
|
|
559
|
+
<button
|
|
560
|
+
onClick={handleDelete}
|
|
561
|
+
disabled={deleting}
|
|
562
|
+
className="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-500 disabled:opacity-50"
|
|
563
|
+
>
|
|
564
|
+
{deleting ? t("memory.deleting") : t("memory.delete")}
|
|
565
|
+
</button>
|
|
566
|
+
</div>
|
|
567
|
+
</div>
|
|
568
|
+
)}
|
|
569
|
+
</Modal>
|
|
175
570
|
</div>
|
|
176
571
|
);
|
|
177
572
|
}
|