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