@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,116 @@
1
+ import { NavLink } from "react-router-dom";
2
+ import {
3
+ LayoutDashboard,
4
+ Box,
5
+ Plug,
6
+ Settings,
7
+ Pi,
8
+ History,
9
+ Brain,
10
+ Globe,
11
+ } from "lucide-react";
12
+ import { cn } from "@/lib/utils";
13
+ import { useTranslation, LANGUAGES } from "@/lib/i18n";
14
+ import { useState } from "react";
15
+
16
+ const navItems = [
17
+ { to: "/", icon: LayoutDashboard, key: "nav.dashboard" },
18
+ { to: "/models", icon: Box, key: "nav.models" },
19
+ { to: "/providers", icon: Plug, key: "nav.providers" },
20
+ { to: "/sessions", icon: History, key: "nav.sessions" },
21
+ { to: "/memory", icon: Brain, key: "nav.memory" },
22
+ { to: "/settings", icon: Settings, key: "nav.settings" },
23
+ ];
24
+
25
+ export function Sidebar() {
26
+ const { t, lang, setLang } = useTranslation();
27
+ const [langOpen, setLangOpen] = useState(false);
28
+
29
+ return (
30
+ <aside
31
+ className="flex h-screen w-64 flex-col border-r"
32
+ style={{
33
+ backgroundColor: "var(--sidebar-bg)",
34
+ borderColor: "var(--sidebar-border)",
35
+ }}
36
+ >
37
+ {/* Logo */}
38
+ <div
39
+ className="flex items-center gap-3 border-b px-6 py-5"
40
+ style={{ borderColor: "var(--sidebar-border)" }}
41
+ >
42
+ <div className="flex h-9 w-9 items-center justify-center rounded-lg"
43
+ style={{ backgroundColor: "#3b82f6" }}>
44
+ <Pi className="h-5 w-5" style={{ color: "#ffffff" }} />
45
+ </div>
46
+ <div>
47
+ <h1 className="text-base font-semibold" style={{ color: "var(--page-text)" }}>pi-switch</h1>
48
+ <p className="text-xs" style={{ color: "var(--subtle-text)" }}>{t("app.subtitle")}</p>
49
+ </div>
50
+ </div>
51
+
52
+ {/* Navigation */}
53
+ <nav className="flex-1 space-y-1 px-3 py-4">
54
+ {navItems.map(({ to, icon: Icon, key }) => (
55
+ <NavLink
56
+ key={to}
57
+ to={to}
58
+ end={to === "/"}
59
+ className={({ isActive }) =>
60
+ cn(
61
+ "flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
62
+ )
63
+ }
64
+ style={({ isActive }) => ({
65
+ backgroundColor: isActive ? "var(--sidebar-active-bg)" : "transparent",
66
+ color: isActive ? "var(--sidebar-active-text)" : "var(--sidebar-text)",
67
+ })}
68
+ >
69
+ <Icon className="h-4 w-4" />
70
+ {t(key)}
71
+ </NavLink>
72
+ ))}
73
+ </nav>
74
+
75
+ {/* Language Switcher */}
76
+ <div className="border-t px-3 py-3" style={{ borderColor: "var(--sidebar-border)" }}>
77
+ <button
78
+ onClick={() => setLangOpen(!langOpen)}
79
+ className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors"
80
+ style={{ color: "var(--sidebar-text)" }}
81
+ >
82
+ <Globe className="h-4 w-4" />
83
+ <span className="flex-1 text-left">{LANGUAGES.find((l) => l.code === lang)?.nativeLabel || "English"}</span>
84
+ </button>
85
+ {langOpen && (
86
+ <div className="mt-1 space-y-0.5 px-1">
87
+ {LANGUAGES.map((l) => (
88
+ <button
89
+ key={l.code}
90
+ onClick={() => { setLang(l.code); setLangOpen(false); }}
91
+ className={cn(
92
+ "flex w-full items-center gap-3 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors",
93
+ lang === l.code ? "bg-blue-600/10 text-blue-400" : ""
94
+ )}
95
+ style={{
96
+ color: lang === l.code ? "var(--sidebar-active-text)" : "var(--sidebar-text)",
97
+ backgroundColor: lang === l.code ? "var(--sidebar-active-bg)" : "transparent",
98
+ }}
99
+ >
100
+ {l.nativeLabel}
101
+ </button>
102
+ ))}
103
+ </div>
104
+ )}
105
+ </div>
106
+
107
+ {/* Version */}
108
+ <div
109
+ className="border-t px-6 py-3"
110
+ style={{ borderColor: "var(--sidebar-border)" }}
111
+ >
112
+ <p className="text-xs" style={{ color: "var(--subtle-text)" }}>{t("app.version")}</p>
113
+ </div>
114
+ </aside>
115
+ );
116
+ }
@@ -0,0 +1,570 @@
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 { formatTokens, formatCost, cn } from "@/lib/utils";
8
+ import type { Model } from "@/types";
9
+ import {
10
+ Search,
11
+ Plus,
12
+ Box,
13
+ Trash2,
14
+ Edit3,
15
+ CheckCircle2,
16
+ XCircle,
17
+ Brain,
18
+ Image,
19
+ Text,
20
+ Cpu,
21
+ } from "lucide-react";
22
+
23
+ function ModelIcon({ model }: { model: Model }) {
24
+ const inputs = model.input ?? ["text"];
25
+ return (
26
+ <div className="flex gap-1">
27
+ {inputs.includes("image") ? (
28
+ <Image className="h-3.5 w-3.5 text-purple-400" />
29
+ ) : (
30
+ <Text className="h-3.5 w-3.5 text-blue-400" />
31
+ )}
32
+ {model.reasoning && <Brain className="h-3.5 w-3.5 text-amber-400" />}
33
+ </div>
34
+ );
35
+ }
36
+
37
+ export function ModelsPage() {
38
+ const { t } = useTranslation();
39
+ const { allModels, allProviders, settings, updateModel, removeModel, toggleModel, addModel } =
40
+ useConfigStore();
41
+
42
+ const [search, setSearch] = useState("");
43
+ const [providerFilter, setProviderFilter] = useState("all");
44
+ const [editModel, setEditModel] = useState<(Model & { providerId: string; providerName: string }) | null>(null);
45
+ const [showAddForm, setShowAddForm] = useState(false);
46
+
47
+ const availableProviders = allProviders.filter(
48
+ (p) => p.models.length > 0
49
+ );
50
+
51
+ const filtered = allModels.filter((m) => {
52
+ if (search && !m.id.toLowerCase().includes(search.toLowerCase()) && !m.name?.toLowerCase().includes(search.toLowerCase()))
53
+ return false;
54
+ if (providerFilter !== "all" && m.providerId !== providerFilter) return false;
55
+ return true;
56
+ });
57
+
58
+ const isEnabled = (providerId: string, modelId: string) => {
59
+ return settings?.enabledModels?.includes(`${providerId}/${modelId}`) ?? false;
60
+ };
61
+
62
+ return (
63
+ <div className="space-y-6">
64
+ <div className="flex items-center justify-between">
65
+ <div>
66
+ <h1 className="text-2xl font-bold" style={{ color: "var(--page-text)" }}>{t("models.title")}</h1>
67
+ <p className="mt-1 text-sm" style={{ color: "var(--muted-text)" }}>
68
+ {t("models.title")} — {availableProviders.length} providers
69
+ </p>
70
+ </div>
71
+ <button
72
+ onClick={() => setShowAddForm(true)}
73
+ className="flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-colors"
74
+ style={{ backgroundColor: "#3b82f6", color: "#ffffff" }}
75
+ >
76
+ <Plus className="h-4 w-4" />
77
+ {t("models.add_model")}
78
+ </button>
79
+ </div>
80
+
81
+ {/* Filters */}
82
+ <div className="flex items-center gap-3">
83
+ <div className="relative flex-1 max-w-md">
84
+ <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-500" />
85
+ <input
86
+ type="text"
87
+ placeholder={t("models.search_placeholder")}
88
+ value={search}
89
+ onChange={(e) => setSearch(e.target.value)}
90
+ className="w-full rounded-lg border border-gray-700 bg-gray-900 py-2 pl-10 pr-4 text-sm text-white placeholder-gray-500 focus:border-blue-500 focus:outline-none"
91
+ />
92
+ </div>
93
+ <select
94
+ value={providerFilter}
95
+ onChange={(e) => setProviderFilter(e.target.value)}
96
+ className="rounded-lg border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-300 focus:border-blue-500 focus:outline-none"
97
+ >
98
+ <option value="all">{t("models.filter_all")}</option>
99
+ {availableProviders.map((p) => (
100
+ <option key={p.id} value={p.id}>
101
+ {p.name}
102
+ </option>
103
+ ))}
104
+ </select>
105
+ </div>
106
+
107
+ {/* Model List */}
108
+ {filtered.length === 0 ? (
109
+ <EmptyState
110
+ icon={<Box className="h-12 w-12" />}
111
+ title={t("models.no_models")}
112
+ description={search ? "Try a different search term" : "Add your first model to get started"}
113
+ action={
114
+ <button
115
+ onClick={() => setShowAddForm(true)}
116
+ className="rounded-lg px-4 py-2 text-sm font-medium transition-colors"
117
+ style={{ backgroundColor: "#3b82f6", color: "#ffffff" }}
118
+ >
119
+ {t("models.add_model")}
120
+ </button>
121
+ }
122
+ />
123
+ ) : (
124
+ <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
125
+ {filtered.map((m) => (
126
+ <div
127
+ key={`${m.providerId}/${m.id}`}
128
+ className={cn(
129
+ "rounded-xl border p-4 transition-all",
130
+ isEnabled(m.providerId, m.id)
131
+ ? "border-gray-700 bg-gray-900/70"
132
+ : "border-gray-800/50 bg-gray-900/30 opacity-60"
133
+ )}
134
+ >
135
+ <div className="flex items-start justify-between">
136
+ <div className="min-w-0 flex-1">
137
+ <div className="flex items-center gap-2">
138
+ <h3 className="truncate text-sm font-semibold text-white">{m.name || m.id}</h3>
139
+ <ModelIcon model={m} />
140
+ </div>
141
+ <p className="mt-0.5 truncate text-xs text-gray-500">
142
+ {m.providerName} · {m.id}
143
+ </p>
144
+ </div>
145
+ <button
146
+ onClick={() => toggleModel(m.providerId, m.id)}
147
+ className={cn(
148
+ "rounded-lg p-1.5 transition-colors",
149
+ isEnabled(m.providerId, m.id)
150
+ ? "text-emerald-400 hover:bg-emerald-500/10"
151
+ : "text-gray-600 hover:bg-gray-800"
152
+ )}
153
+ >
154
+ {isEnabled(m.providerId, m.id) ? (
155
+ <CheckCircle2 className="h-4 w-4" />
156
+ ) : (
157
+ <XCircle className="h-4 w-4" />
158
+ )}
159
+ </button>
160
+ </div>
161
+
162
+ {/* Stats */}
163
+ <div className="mt-3 grid grid-cols-2 gap-2 text-xs text-gray-400">
164
+ <div>
165
+ <span className="text-gray-600">Context:</span>{" "}
166
+ {formatTokens(m.contextWindow ?? 128000)}
167
+ </div>
168
+ <div>
169
+ <span className="text-gray-600">Max tokens:</span>{" "}
170
+ {formatTokens(m.maxTokens ?? 16384)}
171
+ </div>
172
+ <div>
173
+ <span className="text-gray-600">Input:</span>{" "}
174
+ {formatCost(m.cost?.input ?? 0)}/M
175
+ </div>
176
+ <div>
177
+ <span className="text-gray-600">Output:</span>{" "}
178
+ {formatCost(m.cost?.output ?? 0)}/M
179
+ </div>
180
+ </div>
181
+
182
+ {/* Actions */}
183
+ <div className="mt-3 flex items-center gap-2 border-t border-gray-800 pt-3">
184
+ <button
185
+ onClick={() => setEditModel(m)}
186
+ 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"
187
+ >
188
+ <Edit3 className="h-3 w-3" />
189
+ {t("models.edit_model")}
190
+ </button>
191
+ <button
192
+ onClick={() => removeModel(m.providerId, m.id)}
193
+ className="flex items-center gap-1 rounded-md px-2 py-1 text-xs text-gray-400 hover:bg-red-500/10 hover:text-red-400"
194
+ >
195
+ <Trash2 className="h-3 w-3" />
196
+ {t("models.delete_model")}
197
+ </button>
198
+ </div>
199
+ </div>
200
+ ))}
201
+ </div>
202
+ )}
203
+
204
+ {/* Edit Model Modal */}
205
+ <Modal
206
+ open={!!editModel}
207
+ onClose={() => setEditModel(null)}
208
+ title={`${t("models.edit_model")}: ${editModel?.name || editModel?.id}`}
209
+ size="lg"
210
+ >
211
+ {editModel && (
212
+ <ModelForm
213
+ initial={editModel}
214
+ onSubmit={(updates) => {
215
+ updateModel(editModel.providerId, editModel.id, updates);
216
+ setEditModel(null);
217
+ }}
218
+ onCancel={() => setEditModel(null)}
219
+ />
220
+ )}
221
+ </Modal>
222
+
223
+ {/* {t("models.add_model")} Modal */}
224
+ <Modal
225
+ open={showAddForm}
226
+ onClose={() => setShowAddForm(false)}
227
+ title={t("models.add_model")}
228
+ size="lg"
229
+ >
230
+ <AddModelForm
231
+ providers={allProviders}
232
+ onSubmit={(providerId, model) => {
233
+ addModel(providerId, model);
234
+ setShowAddForm(false);
235
+ }}
236
+ onCancel={() => setShowAddForm(false)}
237
+ />
238
+ </Modal>
239
+ </div>
240
+ );
241
+ }
242
+
243
+ // ─── Model Form ───────────────────────────────────────────
244
+
245
+ interface ModelFormProps {
246
+ initial: Model;
247
+ onSubmit: (updates: Partial<Model>) => void;
248
+ onCancel: () => void;
249
+ }
250
+
251
+ function ModelForm({ initial, onSubmit, onCancel }: ModelFormProps) {
252
+ const { t } = useTranslation();
253
+ const [form, setForm] = useState<Partial<Model>>({ ...initial });
254
+
255
+ return (
256
+ <div className="space-y-4">
257
+ <div className="grid grid-cols-2 gap-4">
258
+ <div>
259
+ <label className="block text-xs font-medium text-gray-400">Model ID</label>
260
+ <input
261
+ type="text"
262
+ value={form.id ?? ""}
263
+ onChange={(e) => setForm({ ...form, id: e.target.value })}
264
+ className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white"
265
+ />
266
+ </div>
267
+ <div>
268
+ <label className="block text-xs font-medium text-gray-400">Display Name</label>
269
+ <input
270
+ type="text"
271
+ value={form.name ?? ""}
272
+ onChange={(e) => setForm({ ...form, name: e.target.value })}
273
+ className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white"
274
+ />
275
+ </div>
276
+ <div>
277
+ <label className="block text-xs font-medium text-gray-400">Context Window</label>
278
+ <input
279
+ type="number"
280
+ value={form.contextWindow ?? 128000}
281
+ onChange={(e) => setForm({ ...form, contextWindow: parseInt(e.target.value) || 128000 })}
282
+ className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white"
283
+ />
284
+ </div>
285
+ <div>
286
+ <label className="block text-xs font-medium text-gray-400">Max Output Tokens</label>
287
+ <input
288
+ type="number"
289
+ value={form.maxTokens ?? 16384}
290
+ onChange={(e) => setForm({ ...form, maxTokens: parseInt(e.target.value) || 16384 })}
291
+ className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white"
292
+ />
293
+ </div>
294
+ </div>
295
+
296
+ {/* Capabilities */}
297
+ <div>
298
+ <label className="block text-xs font-medium text-gray-400 mb-2">Capabilities</label>
299
+ <div className="flex flex-wrap gap-3">
300
+ <label className="flex items-center gap-2 text-sm text-gray-300">
301
+ <input
302
+ type="checkbox"
303
+ checked={form.reasoning ?? false}
304
+ onChange={(e) => setForm({ ...form, reasoning: e.target.checked })}
305
+ className="rounded border-gray-600 bg-gray-800 text-blue-500"
306
+ />
307
+ Extended Thinking
308
+ </label>
309
+ <label className="flex items-center gap-2 text-sm text-gray-300">
310
+ <input
311
+ type="checkbox"
312
+ checked={form.input?.includes("image") ?? false}
313
+ onChange={(e) =>
314
+ setForm({
315
+ ...form,
316
+ input: e.target.checked ? ["text", "image"] : ["text"],
317
+ })
318
+ }
319
+ className="rounded border-gray-600 bg-gray-800 text-blue-500"
320
+ />
321
+ Image Input
322
+ </label>
323
+ </div>
324
+ </div>
325
+
326
+ {/* Cost */}
327
+ <div>
328
+ <label className="block text-xs font-medium text-gray-400 mb-2">Cost ($/M tokens)</label>
329
+ <div className="grid grid-cols-4 gap-3">
330
+ <div>
331
+ <label className="block text-xs text-gray-500">Input</label>
332
+ <input
333
+ type="number"
334
+ step="0.01"
335
+ value={form.cost?.input ?? 0}
336
+ onChange={(e) =>
337
+ setForm({
338
+ ...form,
339
+ cost: { ...form.cost!, input: parseFloat(e.target.value) || 0 },
340
+ })
341
+ }
342
+ className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white"
343
+ />
344
+ </div>
345
+ <div>
346
+ <label className="block text-xs text-gray-500">Output</label>
347
+ <input
348
+ type="number"
349
+ step="0.01"
350
+ value={form.cost?.output ?? 0}
351
+ onChange={(e) =>
352
+ setForm({
353
+ ...form,
354
+ cost: { ...form.cost!, output: parseFloat(e.target.value) || 0 },
355
+ })
356
+ }
357
+ className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white"
358
+ />
359
+ </div>
360
+ <div>
361
+ <label className="block text-xs text-gray-500">Cache Read</label>
362
+ <input
363
+ type="number"
364
+ step="0.01"
365
+ value={form.cost?.cacheRead ?? 0}
366
+ onChange={(e) =>
367
+ setForm({
368
+ ...form,
369
+ cost: { ...form.cost!, cacheRead: parseFloat(e.target.value) || 0 },
370
+ })
371
+ }
372
+ className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white"
373
+ />
374
+ </div>
375
+ <div>
376
+ <label className="block text-xs text-gray-500">Cache Write</label>
377
+ <input
378
+ type="number"
379
+ step="0.01"
380
+ value={form.cost?.cacheWrite ?? 0}
381
+ onChange={(e) =>
382
+ setForm({
383
+ ...form,
384
+ cost: { ...form.cost!, cacheWrite: parseFloat(e.target.value) || 0 },
385
+ })
386
+ }
387
+ className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white"
388
+ />
389
+ </div>
390
+ </div>
391
+ </div>
392
+
393
+ {/* Actions */}
394
+ <div className="flex justify-end gap-3 pt-2">
395
+ <button onClick={onCancel} className="rounded-lg px-4 py-2 text-sm text-gray-400 hover:bg-gray-800">
396
+ Cancel
397
+ </button>
398
+ <button
399
+ onClick={() => onSubmit(form)}
400
+ className="rounded-lg px-4 py-2 text-sm font-medium transition-colors"
401
+ style={{ backgroundColor: "#3b82f6", color: "#ffffff" }}
402
+ >
403
+ {t("models.save")}
404
+ </button>
405
+ </div>
406
+ </div>
407
+ );
408
+ }
409
+
410
+ // ─── Add Model Form ───────────────────────────────────────
411
+
412
+ interface AddModelFormProps {
413
+ providers: { id: string; name: string }[];
414
+ onSubmit: (providerId: string, model: Model) => void;
415
+ onCancel: () => void;
416
+ }
417
+
418
+ function AddModelForm({ providers, onSubmit, onCancel }: AddModelFormProps) {
419
+ const { t } = useTranslation();
420
+ const { allModels } = useConfigStore();
421
+ const [providerId, setProviderId] = useState(providers[0]?.id ?? "");
422
+ const [form, setForm] = useState<Partial<Model>>({
423
+ id: "",
424
+ name: "",
425
+ reasoning: false,
426
+ input: ["text"],
427
+ contextWindow: 128000,
428
+ maxTokens: 16384,
429
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
430
+ });
431
+
432
+ const handleSubmit = () => {
433
+ if (!form.id || !providerId) return;
434
+ onSubmit(providerId, form as Model);
435
+ };
436
+
437
+ // Get models for the selected provider
438
+ const providerModels = allModels.filter((m) => m.providerId === providerId);
439
+ const modelOptions = [...new Set(providerModels.map((m) => m.id))];
440
+
441
+ return (
442
+ <div className="space-y-4">
443
+ <div>
444
+ <label className="block text-xs font-medium text-gray-400">Provider</label>
445
+ <select
446
+ value={providerId}
447
+ onChange={(e) => { setProviderId(e.target.value); setForm({ ...form, id: "" }); }}
448
+ className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white"
449
+ >
450
+ {providers.map((p) => (
451
+ <option key={p.id} value={p.id}>
452
+ {p.name}
453
+ </option>
454
+ ))}
455
+ </select>
456
+ </div>
457
+ <div className="grid grid-cols-2 gap-4">
458
+ <div>
459
+ <label className="block text-xs font-medium text-gray-400">Model ID *</label>
460
+ <input
461
+ type="text"
462
+ list="model-list"
463
+ value={form.id ?? ""}
464
+ onChange={(e) => setForm({ ...form, id: e.target.value })}
465
+ placeholder={modelOptions.length > 0 ? "Select or type model ID..." : "Enter model ID..."}
466
+ className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white"
467
+ />
468
+ <datalist id="model-list">
469
+ {modelOptions.length > 0 ? (
470
+ modelOptions.map((mid) => (
471
+ <option key={mid} value={mid} />
472
+ ))
473
+ ) : (
474
+ <option value="" disabled>No models available for this provider</option>
475
+ )}
476
+ </datalist>
477
+ {modelOptions.length === 0 && (
478
+ <p className="text-xs mt-1 text-gray-500">No existing models for this provider. Type a new model ID manually.</p>
479
+ )}
480
+ </div>
481
+ <div>
482
+ <label className="block text-xs font-medium text-gray-400">Display Name</label>
483
+ <input
484
+ type="text"
485
+ value={form.name ?? ""}
486
+ onChange={(e) => setForm({ ...form, name: e.target.value })}
487
+ placeholder="My Custom Model"
488
+ className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white"
489
+ />
490
+ </div>
491
+ </div>
492
+
493
+ {/* Capabilities */}
494
+ <div className="flex flex-wrap gap-3">
495
+ <label className="flex items-center gap-2 text-sm text-gray-300">
496
+ <input
497
+ type="checkbox"
498
+ checked={form.reasoning ?? false}
499
+ onChange={(e) => setForm({ ...form, reasoning: e.target.checked })}
500
+ className="rounded border-gray-600 bg-gray-800 text-blue-500"
501
+ />
502
+ Extended Thinking
503
+ </label>
504
+ <label className="flex items-center gap-2 text-sm text-gray-300">
505
+ <input
506
+ type="checkbox"
507
+ checked={form.input?.includes("image") ?? false}
508
+ onChange={(e) =>
509
+ setForm({
510
+ ...form,
511
+ input: e.target.checked ? ["text", "image"] : ["text"],
512
+ })
513
+ }
514
+ className="rounded border-gray-600 bg-gray-800 text-blue-500"
515
+ />
516
+ Image Input
517
+ </label>
518
+ </div>
519
+
520
+ <div className="grid grid-cols-4 gap-3">
521
+ <div>
522
+ <label className="block text-xs text-gray-500">Input $/M</label>
523
+ <input
524
+ type="number"
525
+ step="0.01"
526
+ value={form.cost?.input ?? 0}
527
+ onChange={(e) =>
528
+ setForm({ ...form, cost: { ...form.cost!, input: parseFloat(e.target.value) || 0 } })
529
+ }
530
+ className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white"
531
+ />
532
+ </div>
533
+ <div>
534
+ <label className="block text-xs text-gray-500">Output $/M</label>
535
+ <input type="number" step="0.01" value={form.cost?.output ?? 0}
536
+ onChange={(e) =>
537
+ setForm({ ...form, cost: { ...form.cost!, output: parseFloat(e.target.value) || 0 } })
538
+ }
539
+ className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white" />
540
+ </div>
541
+ <div>
542
+ <label className="block text-xs text-gray-500">Context Window</label>
543
+ <input type="number" value={form.contextWindow ?? 128000}
544
+ onChange={(e) => setForm({ ...form, contextWindow: parseInt(e.target.value) || 128000 })}
545
+ className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white" />
546
+ </div>
547
+ <div>
548
+ <label className="block text-xs text-gray-500">Max Tokens</label>
549
+ <input type="number" value={form.maxTokens ?? 16384}
550
+ onChange={(e) => setForm({ ...form, maxTokens: parseInt(e.target.value) || 16384 })}
551
+ className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white" />
552
+ </div>
553
+ </div>
554
+
555
+ <div className="flex justify-end gap-3 pt-2">
556
+ <button onClick={onCancel} className="rounded-lg px-4 py-2 text-sm text-gray-400 hover:bg-gray-800">
557
+ Cancel
558
+ </button>
559
+ <button
560
+ onClick={handleSubmit}
561
+ disabled={!form.id}
562
+ className="rounded-lg px-4 py-2 text-sm font-medium transition-colors disabled:opacity-50"
563
+ style={{ backgroundColor: "#3b82f6", color: "#ffffff" }}
564
+ >
565
+ {t("models.add_model")}
566
+ </button>
567
+ </div>
568
+ </div>
569
+ );
570
+ }