@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,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
|
+
}
|