@raingor/pi-web-switch 0.3.0 → 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/promote/dashboard.png +0 -0
- package/src/components/models/ModelsPage.tsx +0 -570
- package/src/components/providers/ProvidersPage.tsx +0 -466
|
@@ -0,0 +1,1387 @@
|
|
|
1
|
+
import { useEffect, 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 { formatTokens, cn } from "@/lib/utils";
|
|
7
|
+
import type { ApiType, CustomProviderConfig, Model, Provider } from "@/types";
|
|
8
|
+
import {
|
|
9
|
+
Plus,
|
|
10
|
+
Trash2,
|
|
11
|
+
Edit3,
|
|
12
|
+
Eye,
|
|
13
|
+
EyeOff,
|
|
14
|
+
Server,
|
|
15
|
+
Shield,
|
|
16
|
+
Box,
|
|
17
|
+
Brain,
|
|
18
|
+
Image as ImageIcon,
|
|
19
|
+
Search,
|
|
20
|
+
Check,
|
|
21
|
+
X,
|
|
22
|
+
Loader2,
|
|
23
|
+
Zap,
|
|
24
|
+
ClipboardPaste,
|
|
25
|
+
} from "lucide-react";
|
|
26
|
+
|
|
27
|
+
const API_TYPES: { value: ApiType; label: string }[] = [
|
|
28
|
+
{ value: "openai-completions", label: "Chat Completions (/chat/completions)" },
|
|
29
|
+
{ value: "openai-responses", label: "OpenAI Responses" },
|
|
30
|
+
{ value: "anthropic-messages", label: "Anthropic Messages" },
|
|
31
|
+
{ value: "google-generative-ai", label: "Google Generative AI" },
|
|
32
|
+
{ value: "google-vertex", label: "Google Vertex AI" },
|
|
33
|
+
{ value: "bedrock-converse-stream", label: "AWS Bedrock" },
|
|
34
|
+
{ value: "mistral-conversations", label: "Mistral" },
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
function isValidHttpUrl(value: string): boolean {
|
|
38
|
+
try {
|
|
39
|
+
const u = new URL(value);
|
|
40
|
+
return u.protocol === "http:" || u.protocol === "https:";
|
|
41
|
+
} catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Sanitize to a config-safe id: lowercase letters, digits and hyphens only
|
|
47
|
+
function sanitizeProviderId(name: string): string {
|
|
48
|
+
return name
|
|
49
|
+
.trim()
|
|
50
|
+
.toLowerCase()
|
|
51
|
+
.replace(/\s+/g, "-")
|
|
52
|
+
.replace(/[^a-z0-9-]/g, "")
|
|
53
|
+
.replace(/-+/g, "-")
|
|
54
|
+
.replace(/^-|-$/g, "");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ─── Freeform Import Parser ───────────────────────────────
|
|
58
|
+
// Recognizes pasted text like:
|
|
59
|
+
// tokenrouter baseurl:https://api.example.com/v1 key:sk-xxxx
|
|
60
|
+
// modelid:vendor/model-a, vendor/model-b
|
|
61
|
+
// Labels accept half/full-width colons; unlabeled tokens fall back to
|
|
62
|
+
// heuristics (URL → baseUrl, sk-… → apiKey, foo/bar → model id).
|
|
63
|
+
|
|
64
|
+
interface ParsedImport {
|
|
65
|
+
name: string;
|
|
66
|
+
baseUrl: string;
|
|
67
|
+
apiKey: string;
|
|
68
|
+
modelIds: string[];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const IMPORT_LABEL_RE =
|
|
72
|
+
/(?<![\w/.\-])(apikey|api_key|api-key|key|token|secret|密钥|金鑰|baseurl|base_url|base-url|url|endpoint|地址|接口|provider|name|名称|名稱|供应商|供應商|model_ids?|modelids?|models?|模型)\s*[::](?!\/\/)/gi;
|
|
73
|
+
|
|
74
|
+
function importField(label: string): "name" | "baseUrl" | "apiKey" | "models" {
|
|
75
|
+
const l = label.toLowerCase();
|
|
76
|
+
if (/^(apikey|api_key|api-key|key|token|secret|密钥|金鑰)$/.test(l)) return "apiKey";
|
|
77
|
+
if (/^(baseurl|base_url|base-url|url|endpoint|地址|接口)$/.test(l)) return "baseUrl";
|
|
78
|
+
if (/^(provider|name|名称|名稱|供应商|供應商)$/.test(l)) return "name";
|
|
79
|
+
return "models";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function parseProviderImport(raw: string): ParsedImport {
|
|
83
|
+
const out: ParsedImport = { name: "", baseUrl: "", apiKey: "", modelIds: [] };
|
|
84
|
+
const pushModels = (value: string) => {
|
|
85
|
+
for (const part of value.split(/[\s,,;;]+/)) {
|
|
86
|
+
const v = part.trim();
|
|
87
|
+
if (v && !out.modelIds.includes(v)) out.modelIds.push(v);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
const assignFree = (text: string) => {
|
|
91
|
+
for (const token of text.split(/\s+/)) {
|
|
92
|
+
const v = token.replace(/[,,;;]+$/, "");
|
|
93
|
+
if (!v) continue;
|
|
94
|
+
if (/^https?:\/\//i.test(v)) {
|
|
95
|
+
if (!out.baseUrl) out.baseUrl = v;
|
|
96
|
+
} else if (/^sk-\S{8,}$/i.test(v) || /^[A-Za-z0-9_-]{32,}$/.test(v)) {
|
|
97
|
+
if (!out.apiKey) out.apiKey = v;
|
|
98
|
+
} else if (v.includes("/")) {
|
|
99
|
+
pushModels(v);
|
|
100
|
+
} else if (!out.name) {
|
|
101
|
+
out.name = v;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
106
|
+
if (!line.trim()) continue;
|
|
107
|
+
const matches = [...line.matchAll(IMPORT_LABEL_RE)];
|
|
108
|
+
const head = (matches.length ? line.slice(0, matches[0]!.index) : line).trim();
|
|
109
|
+
if (head) assignFree(head);
|
|
110
|
+
matches.forEach((m, i) => {
|
|
111
|
+
const start = m.index! + m[0].length;
|
|
112
|
+
const end = i + 1 < matches.length ? matches[i + 1]!.index! : line.length;
|
|
113
|
+
const value = line.slice(start, end).trim().replace(/[,,;;]+$/, "");
|
|
114
|
+
if (!value) return;
|
|
115
|
+
const field = importField(m[1] ?? "");
|
|
116
|
+
if (field === "models") pushModels(value);
|
|
117
|
+
else if (!out[field]) out[field] = value;
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function ProvidersModelsPage() {
|
|
124
|
+
const { t } = useTranslation();
|
|
125
|
+
const { allProviders, auth, removeCustomProvider } = useConfigStore();
|
|
126
|
+
|
|
127
|
+
const builtinProviders = allProviders.filter((p) => p.type === "builtin");
|
|
128
|
+
const customProviders = allProviders.filter((p) => p.type === "custom");
|
|
129
|
+
|
|
130
|
+
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
131
|
+
const [adding, setAdding] = useState(false);
|
|
132
|
+
const [importing, setImporting] = useState(false);
|
|
133
|
+
const [importBump, setImportBump] = useState(0);
|
|
134
|
+
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
|
135
|
+
const [deleteError, setDeleteError] = useState(false);
|
|
136
|
+
|
|
137
|
+
// Keep a valid selection (default: first custom, else first builtin)
|
|
138
|
+
const selected = allProviders.find((p) => p.id === selectedId) ?? null;
|
|
139
|
+
useEffect(() => {
|
|
140
|
+
if (!selected && !adding && allProviders.length > 0) {
|
|
141
|
+
setSelectedId(customProviders[0]?.id ?? allProviders[0]?.id ?? null);
|
|
142
|
+
}
|
|
143
|
+
}, [selected, adding, allProviders, customProviders]);
|
|
144
|
+
|
|
145
|
+
const hasKey = (p: Provider) => p.hasAuth || !!p.apiKey || !!auth?.[p.id]?.key;
|
|
146
|
+
|
|
147
|
+
const handleAddProvider = async (id: string, cfg: CustomProviderConfig): Promise<boolean> => {
|
|
148
|
+
const ok = await useConfigStore.getState().addCustomProvider(id, cfg);
|
|
149
|
+
if (ok) {
|
|
150
|
+
setAdding(false);
|
|
151
|
+
setSelectedId(id);
|
|
152
|
+
}
|
|
153
|
+
return ok;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const handleDeleteProvider = async () => {
|
|
157
|
+
if (!deleteConfirm) return;
|
|
158
|
+
setDeleteError(false);
|
|
159
|
+
const ok = await removeCustomProvider(deleteConfirm);
|
|
160
|
+
if (ok) {
|
|
161
|
+
if (selectedId === deleteConfirm) setSelectedId(null);
|
|
162
|
+
setDeleteConfirm(null);
|
|
163
|
+
} else {
|
|
164
|
+
setDeleteError(true);
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
return (
|
|
169
|
+
<div className="space-y-6">
|
|
170
|
+
<div>
|
|
171
|
+
<h1 className="text-2xl font-bold" style={{ color: "var(--page-text)" }}>
|
|
172
|
+
{t("nav.providers_models")}
|
|
173
|
+
</h1>
|
|
174
|
+
<p className="mt-1 text-sm" style={{ color: "var(--muted-text)" }}>
|
|
175
|
+
{t("providers_models.subtitle")}
|
|
176
|
+
</p>
|
|
177
|
+
</div>
|
|
178
|
+
|
|
179
|
+
<div className="flex overflow-hidden rounded-xl border border-gray-800 bg-gray-900/50">
|
|
180
|
+
{/* ─── Left: Provider List ─────────────────────── */}
|
|
181
|
+
<div className="w-60 shrink-0 border-r border-gray-800 p-3">
|
|
182
|
+
{builtinProviders.length > 0 && (
|
|
183
|
+
<>
|
|
184
|
+
<p className="px-2 pb-2 pt-1 text-xs font-medium uppercase tracking-wider text-gray-500">
|
|
185
|
+
{t("providers.builtin")}
|
|
186
|
+
</p>
|
|
187
|
+
<div className="space-y-0.5">
|
|
188
|
+
{builtinProviders.map((p) => (
|
|
189
|
+
<ProviderListItem
|
|
190
|
+
key={p.id}
|
|
191
|
+
provider={p}
|
|
192
|
+
active={!adding && selectedId === p.id}
|
|
193
|
+
hasKey={hasKey(p)}
|
|
194
|
+
onClick={() => {
|
|
195
|
+
setAdding(false);
|
|
196
|
+
setSelectedId(p.id);
|
|
197
|
+
}}
|
|
198
|
+
/>
|
|
199
|
+
))}
|
|
200
|
+
</div>
|
|
201
|
+
</>
|
|
202
|
+
)}
|
|
203
|
+
|
|
204
|
+
<p className="px-2 pb-2 pt-4 text-xs font-medium uppercase tracking-wider text-gray-500">
|
|
205
|
+
{t("providers_models.custom_providers")}
|
|
206
|
+
</p>
|
|
207
|
+
<div className="space-y-0.5">
|
|
208
|
+
{customProviders.map((p) => (
|
|
209
|
+
<ProviderListItem
|
|
210
|
+
key={p.id}
|
|
211
|
+
provider={p}
|
|
212
|
+
active={!adding && selectedId === p.id}
|
|
213
|
+
hasKey={hasKey(p)}
|
|
214
|
+
onClick={() => {
|
|
215
|
+
setAdding(false);
|
|
216
|
+
setSelectedId(p.id);
|
|
217
|
+
}}
|
|
218
|
+
/>
|
|
219
|
+
))}
|
|
220
|
+
</div>
|
|
221
|
+
|
|
222
|
+
<button
|
|
223
|
+
onClick={() => setAdding(true)}
|
|
224
|
+
className={cn(
|
|
225
|
+
"mt-2 flex w-full items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium transition-colors",
|
|
226
|
+
adding
|
|
227
|
+
? "border-gray-600 bg-gray-800 text-white"
|
|
228
|
+
: "border-gray-700 text-gray-300 hover:bg-gray-800 hover:text-white"
|
|
229
|
+
)}
|
|
230
|
+
>
|
|
231
|
+
<Plus className="h-4 w-4" />
|
|
232
|
+
{t("providers.add_provider")}
|
|
233
|
+
</button>
|
|
234
|
+
|
|
235
|
+
<button
|
|
236
|
+
onClick={() => setImporting(true)}
|
|
237
|
+
className="mt-2 flex w-full items-center gap-2 rounded-lg border border-gray-700 px-3 py-2 text-sm font-medium text-gray-300 transition-colors hover:bg-gray-800 hover:text-white"
|
|
238
|
+
>
|
|
239
|
+
<ClipboardPaste className="h-4 w-4" />
|
|
240
|
+
{t("providers_models.import")}
|
|
241
|
+
</button>
|
|
242
|
+
</div>
|
|
243
|
+
|
|
244
|
+
{/* ─── Right: Provider Detail / Add Form ───────── */}
|
|
245
|
+
<div className="min-w-0 flex-1 p-6">
|
|
246
|
+
{adding ? (
|
|
247
|
+
<AddProviderForm onSubmit={handleAddProvider} onCancel={() => setAdding(false)} />
|
|
248
|
+
) : selected ? (
|
|
249
|
+
<ProviderDetail
|
|
250
|
+
key={`${selected.id}:${importBump}`}
|
|
251
|
+
provider={selected}
|
|
252
|
+
onDelete={() => setDeleteConfirm(selected.id)}
|
|
253
|
+
/>
|
|
254
|
+
) : (
|
|
255
|
+
<div className="flex h-40 items-center justify-center text-sm text-gray-500">
|
|
256
|
+
{t("providers_models.select_hint")}
|
|
257
|
+
</div>
|
|
258
|
+
)}
|
|
259
|
+
</div>
|
|
260
|
+
</div>
|
|
261
|
+
|
|
262
|
+
{/* Import Provider Modal */}
|
|
263
|
+
<ImportProviderModal
|
|
264
|
+
open={importing}
|
|
265
|
+
onClose={() => setImporting(false)}
|
|
266
|
+
onImported={(id) => {
|
|
267
|
+
setImporting(false);
|
|
268
|
+
setAdding(false);
|
|
269
|
+
setSelectedId(id);
|
|
270
|
+
setImportBump((n) => n + 1);
|
|
271
|
+
}}
|
|
272
|
+
/>
|
|
273
|
+
|
|
274
|
+
{/* Delete Provider Confirmation Modal */}
|
|
275
|
+
<Modal
|
|
276
|
+
open={!!deleteConfirm}
|
|
277
|
+
onClose={() => { setDeleteConfirm(null); setDeleteError(false); }}
|
|
278
|
+
title={t("providers.delete_provider")}
|
|
279
|
+
>
|
|
280
|
+
<div className="space-y-4">
|
|
281
|
+
<div className="flex items-start gap-3">
|
|
282
|
+
<Trash2 className="h-5 w-5 shrink-0 mt-0.5 text-red-400" />
|
|
283
|
+
<div>
|
|
284
|
+
<p className="text-sm text-gray-200">
|
|
285
|
+
<strong>{allProviders.find((p) => p.id === deleteConfirm)?.name}</strong>
|
|
286
|
+
{" — "}
|
|
287
|
+
{t("providers.delete_confirm")}
|
|
288
|
+
</p>
|
|
289
|
+
<p className="text-xs mt-2 text-gray-500">
|
|
290
|
+
{t("providers_models.delete_provider_note")}
|
|
291
|
+
</p>
|
|
292
|
+
{deleteError && (
|
|
293
|
+
<p className="text-xs mt-2 text-red-400">{t("providers_models.save_failed")}</p>
|
|
294
|
+
)}
|
|
295
|
+
</div>
|
|
296
|
+
</div>
|
|
297
|
+
<div className="flex justify-end gap-3">
|
|
298
|
+
<button
|
|
299
|
+
onClick={() => { setDeleteConfirm(null); setDeleteError(false); }}
|
|
300
|
+
className="rounded-lg px-4 py-2 text-sm text-gray-400 hover:bg-gray-800"
|
|
301
|
+
>
|
|
302
|
+
{t("models.cancel")}
|
|
303
|
+
</button>
|
|
304
|
+
<button
|
|
305
|
+
onClick={handleDeleteProvider}
|
|
306
|
+
className="flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium text-white"
|
|
307
|
+
style={{ backgroundColor: "#dc2626" }}
|
|
308
|
+
>
|
|
309
|
+
<Trash2 className="h-4 w-4" />
|
|
310
|
+
{t("providers.delete_provider")}
|
|
311
|
+
</button>
|
|
312
|
+
</div>
|
|
313
|
+
</div>
|
|
314
|
+
</Modal>
|
|
315
|
+
</div>
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ─── Provider List Item ───────────────────────────────────
|
|
320
|
+
|
|
321
|
+
function ProviderListItem({
|
|
322
|
+
provider,
|
|
323
|
+
active,
|
|
324
|
+
hasKey,
|
|
325
|
+
onClick,
|
|
326
|
+
}: {
|
|
327
|
+
provider: Provider;
|
|
328
|
+
active: boolean;
|
|
329
|
+
hasKey: boolean;
|
|
330
|
+
onClick: () => void;
|
|
331
|
+
}) {
|
|
332
|
+
return (
|
|
333
|
+
<button
|
|
334
|
+
onClick={onClick}
|
|
335
|
+
className={cn(
|
|
336
|
+
"flex w-full items-center gap-2 rounded-lg border px-3 py-2 text-left text-sm transition-colors",
|
|
337
|
+
active
|
|
338
|
+
? "border-gray-600 bg-gray-800 text-white"
|
|
339
|
+
: "border-transparent text-gray-300 hover:bg-gray-800/60"
|
|
340
|
+
)}
|
|
341
|
+
>
|
|
342
|
+
{provider.type === "custom" ? (
|
|
343
|
+
<Server className="h-4 w-4 shrink-0 text-blue-400" />
|
|
344
|
+
) : (
|
|
345
|
+
<Shield className="h-4 w-4 shrink-0 text-emerald-400" />
|
|
346
|
+
)}
|
|
347
|
+
<span className="min-w-0 flex-1 truncate">{provider.name}</span>
|
|
348
|
+
<span
|
|
349
|
+
className={cn("h-2 w-2 shrink-0 rounded-full", hasKey ? "bg-emerald-400" : "bg-gray-600")}
|
|
350
|
+
/>
|
|
351
|
+
</button>
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// ─── Connection Test Button ───────────────────────────────
|
|
356
|
+
|
|
357
|
+
type TestState =
|
|
358
|
+
| { status: "idle" }
|
|
359
|
+
| { status: "testing" }
|
|
360
|
+
| { status: "ok"; latencyMs: number }
|
|
361
|
+
| { status: "fail"; message: string };
|
|
362
|
+
|
|
363
|
+
function TestConnectionButton({ baseUrl, apiKey }: { baseUrl: string; apiKey?: string }) {
|
|
364
|
+
const { t } = useTranslation();
|
|
365
|
+
const [test, setTest] = useState<TestState>({ status: "idle" });
|
|
366
|
+
|
|
367
|
+
const runTest = async () => {
|
|
368
|
+
setTest({ status: "testing" });
|
|
369
|
+
try {
|
|
370
|
+
const res = await fetch("/api/pi/provider-test", {
|
|
371
|
+
method: "POST",
|
|
372
|
+
headers: { "Content-Type": "application/json" },
|
|
373
|
+
body: JSON.stringify({ baseUrl: baseUrl.trim(), apiKey: apiKey || undefined }),
|
|
374
|
+
});
|
|
375
|
+
const data = await res.json();
|
|
376
|
+
if (data.success) {
|
|
377
|
+
setTest({ status: "ok", latencyMs: data.latencyMs ?? 0 });
|
|
378
|
+
} else {
|
|
379
|
+
setTest({ status: "fail", message: data.message ?? "unknown" });
|
|
380
|
+
}
|
|
381
|
+
} catch {
|
|
382
|
+
setTest({ status: "fail", message: "network error" });
|
|
383
|
+
}
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
const disabled = !baseUrl.trim() || !isValidHttpUrl(baseUrl.trim()) || test.status === "testing";
|
|
387
|
+
|
|
388
|
+
return (
|
|
389
|
+
<div className="flex items-center gap-3">
|
|
390
|
+
<button
|
|
391
|
+
onClick={runTest}
|
|
392
|
+
disabled={disabled}
|
|
393
|
+
className="flex items-center gap-2 rounded-lg border border-gray-700 px-3 py-1.5 text-sm text-gray-300 transition-colors hover:bg-gray-800 disabled:cursor-not-allowed disabled:opacity-50"
|
|
394
|
+
>
|
|
395
|
+
{test.status === "testing" ? (
|
|
396
|
+
<Loader2 className="h-4 w-4 animate-spin" />
|
|
397
|
+
) : (
|
|
398
|
+
<Zap className="h-4 w-4" />
|
|
399
|
+
)}
|
|
400
|
+
{test.status === "testing" ? t("providers_models.testing") : t("providers_models.test_connection")}
|
|
401
|
+
</button>
|
|
402
|
+
{test.status === "ok" && (
|
|
403
|
+
<span className="flex items-center gap-1 text-sm text-emerald-400">
|
|
404
|
+
<Check className="h-4 w-4" />
|
|
405
|
+
{t("providers_models.test_ok", String(test.latencyMs))}
|
|
406
|
+
</span>
|
|
407
|
+
)}
|
|
408
|
+
{test.status === "fail" && (
|
|
409
|
+
<span className="flex items-center gap-1 text-sm text-red-400">
|
|
410
|
+
<X className="h-4 w-4" />
|
|
411
|
+
{t("providers_models.test_fail", test.message)}
|
|
412
|
+
</span>
|
|
413
|
+
)}
|
|
414
|
+
</div>
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// ─── Provider Detail Panel ────────────────────────────────
|
|
419
|
+
|
|
420
|
+
function ProviderDetail({ provider, onDelete }: { provider: Provider; onDelete: () => void }) {
|
|
421
|
+
const { t } = useTranslation();
|
|
422
|
+
const {
|
|
423
|
+
auth,
|
|
424
|
+
settings,
|
|
425
|
+
updateSettings,
|
|
426
|
+
updateCustomProvider,
|
|
427
|
+
setProviderAuth,
|
|
428
|
+
removeProviderAuth,
|
|
429
|
+
addModel,
|
|
430
|
+
updateModel,
|
|
431
|
+
removeModel,
|
|
432
|
+
addEnabledModel,
|
|
433
|
+
removeEnabledModel,
|
|
434
|
+
} = useConfigStore();
|
|
435
|
+
|
|
436
|
+
const isCustom = provider.type === "custom";
|
|
437
|
+
const savedKey = provider.apiKey ?? auth?.[provider.id]?.key ?? "";
|
|
438
|
+
|
|
439
|
+
const [baseUrl, setBaseUrl] = useState(provider.baseUrl ?? "");
|
|
440
|
+
const [api, setApi] = useState<ApiType>(provider.api ?? "openai-completions");
|
|
441
|
+
const [apiKey, setApiKey] = useState(savedKey);
|
|
442
|
+
const [showKey, setShowKey] = useState(false);
|
|
443
|
+
const [editModel, setEditModel] = useState<Model | null>(null);
|
|
444
|
+
const [showAddModel, setShowAddModel] = useState(false);
|
|
445
|
+
const [deleteModel, setDeleteModel] = useState<Model | null>(null);
|
|
446
|
+
const [modelQuery, setModelQuery] = useState("");
|
|
447
|
+
const [saveState, setSaveState] = useState<"idle" | "saving" | "saved" | "error">("idle");
|
|
448
|
+
|
|
449
|
+
const urlInvalid = isCustom && baseUrl.trim() !== "" && !isValidHttpUrl(baseUrl.trim());
|
|
450
|
+
|
|
451
|
+
const dirty =
|
|
452
|
+
(isCustom && (baseUrl !== (provider.baseUrl ?? "") || api !== (provider.api ?? "openai-completions"))) ||
|
|
453
|
+
apiKey !== savedKey;
|
|
454
|
+
|
|
455
|
+
const handleSave = async () => {
|
|
456
|
+
setSaveState("saving");
|
|
457
|
+
let ok = true;
|
|
458
|
+
if (isCustom) {
|
|
459
|
+
ok = await updateCustomProvider(provider.id, {
|
|
460
|
+
baseUrl: baseUrl || undefined,
|
|
461
|
+
api,
|
|
462
|
+
apiKey: apiKey || undefined,
|
|
463
|
+
});
|
|
464
|
+
} else if (apiKey !== savedKey) {
|
|
465
|
+
ok = apiKey
|
|
466
|
+
? await setProviderAuth(provider.id, apiKey)
|
|
467
|
+
: await removeProviderAuth(provider.id);
|
|
468
|
+
}
|
|
469
|
+
setSaveState(ok ? "saved" : "error");
|
|
470
|
+
if (ok) setTimeout(() => setSaveState("idle"), 2500);
|
|
471
|
+
};
|
|
472
|
+
|
|
473
|
+
// Enabled state lives in settings.enabledModels ("provider/model" refs) —
|
|
474
|
+
// the same source pi reads, so toggling here matches the CLI behavior.
|
|
475
|
+
const isEnabled = (modelId: string) =>
|
|
476
|
+
settings?.enabledModels?.includes(`${provider.id}/${modelId}`) ?? false;
|
|
477
|
+
|
|
478
|
+
const handleToggle = (modelId: string) => {
|
|
479
|
+
const ref = `${provider.id}/${modelId}`;
|
|
480
|
+
if (isEnabled(modelId)) removeEnabledModel(ref);
|
|
481
|
+
else addEnabledModel(ref);
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
const setAllEnabled = (enable: boolean) => {
|
|
485
|
+
const list = settings?.enabledModels ?? [];
|
|
486
|
+
const refs = provider.models.map((m) => `${provider.id}/${m.id}`);
|
|
487
|
+
const next = enable
|
|
488
|
+
? Array.from(new Set([...list, ...refs]))
|
|
489
|
+
: list.filter((r) => !refs.includes(r));
|
|
490
|
+
updateSettings({ enabledModels: next });
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
const q = modelQuery.trim().toLowerCase();
|
|
494
|
+
const visibleModels = q
|
|
495
|
+
? provider.models.filter(
|
|
496
|
+
(m) => m.id.toLowerCase().includes(q) || (m.name ?? "").toLowerCase().includes(q)
|
|
497
|
+
)
|
|
498
|
+
: provider.models;
|
|
499
|
+
|
|
500
|
+
return (
|
|
501
|
+
<div className="space-y-5">
|
|
502
|
+
{/* Header */}
|
|
503
|
+
<div className="flex items-center gap-3">
|
|
504
|
+
<h2 className="text-lg font-semibold text-white">{provider.name}</h2>
|
|
505
|
+
<Badge variant={isCustom ? "default" : "info"}>
|
|
506
|
+
{isCustom ? t("providers.custom") : t("providers.builtin")}
|
|
507
|
+
</Badge>
|
|
508
|
+
{savedKey && <Badge variant="success">{t("providers.configured")}</Badge>}
|
|
509
|
+
{isCustom && (
|
|
510
|
+
<button
|
|
511
|
+
onClick={onDelete}
|
|
512
|
+
className="ml-auto rounded-lg p-2 text-gray-500 transition-colors hover:bg-red-500/10 hover:text-red-400"
|
|
513
|
+
title={t("providers.delete_provider")}
|
|
514
|
+
>
|
|
515
|
+
<Trash2 className="h-4 w-4" />
|
|
516
|
+
</button>
|
|
517
|
+
)}
|
|
518
|
+
</div>
|
|
519
|
+
|
|
520
|
+
{/* Base URL */}
|
|
521
|
+
<div>
|
|
522
|
+
<label className="block text-sm text-gray-400">{t("providers.base_url")}</label>
|
|
523
|
+
<input
|
|
524
|
+
type="text"
|
|
525
|
+
value={baseUrl}
|
|
526
|
+
disabled={!isCustom}
|
|
527
|
+
onChange={(e) => setBaseUrl(e.target.value)}
|
|
528
|
+
placeholder="https://api.example.com/v1"
|
|
529
|
+
className={cn(
|
|
530
|
+
"mt-1.5 w-full rounded-lg border bg-gray-800 px-3 py-2.5 text-sm text-white disabled:opacity-50",
|
|
531
|
+
urlInvalid ? "border-red-500" : "border-gray-700"
|
|
532
|
+
)}
|
|
533
|
+
/>
|
|
534
|
+
{urlInvalid && (
|
|
535
|
+
<p className="mt-1 text-xs text-red-400">{t("providers_models.invalid_url")}</p>
|
|
536
|
+
)}
|
|
537
|
+
</div>
|
|
538
|
+
|
|
539
|
+
{/* API Type */}
|
|
540
|
+
<div>
|
|
541
|
+
<label className="block text-sm text-gray-400">{t("providers.api_type")}</label>
|
|
542
|
+
<select
|
|
543
|
+
value={api}
|
|
544
|
+
disabled={!isCustom}
|
|
545
|
+
onChange={(e) => setApi(e.target.value as ApiType)}
|
|
546
|
+
className="mt-1.5 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2.5 text-sm text-white disabled:opacity-50"
|
|
547
|
+
>
|
|
548
|
+
{API_TYPES.map((a) => (
|
|
549
|
+
<option key={a.value} value={a.value}>{a.label}</option>
|
|
550
|
+
))}
|
|
551
|
+
</select>
|
|
552
|
+
</div>
|
|
553
|
+
|
|
554
|
+
{/* API Key */}
|
|
555
|
+
<div>
|
|
556
|
+
<label className="block text-sm text-gray-400">{t("providers.api_key")}</label>
|
|
557
|
+
<div className="relative mt-1.5">
|
|
558
|
+
<input
|
|
559
|
+
type={showKey ? "text" : "password"}
|
|
560
|
+
value={apiKey}
|
|
561
|
+
onChange={(e) => setApiKey(e.target.value)}
|
|
562
|
+
placeholder="sk-... or $MY_API_KEY"
|
|
563
|
+
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2.5 pr-10 text-sm text-white"
|
|
564
|
+
/>
|
|
565
|
+
<button
|
|
566
|
+
onClick={() => setShowKey(!showKey)}
|
|
567
|
+
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-md p-1.5 text-gray-500 hover:text-gray-300"
|
|
568
|
+
>
|
|
569
|
+
{showKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
|
570
|
+
</button>
|
|
571
|
+
</div>
|
|
572
|
+
</div>
|
|
573
|
+
|
|
574
|
+
{/* Save / Test / Feedback row */}
|
|
575
|
+
<div className="flex flex-wrap items-center gap-3">
|
|
576
|
+
{dirty && (
|
|
577
|
+
<button
|
|
578
|
+
onClick={handleSave}
|
|
579
|
+
disabled={saveState === "saving" || urlInvalid}
|
|
580
|
+
className="flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-colors disabled:opacity-50"
|
|
581
|
+
style={{ backgroundColor: "#3b82f6", color: "#ffffff" }}
|
|
582
|
+
>
|
|
583
|
+
{saveState === "saving" && <Loader2 className="h-4 w-4 animate-spin" />}
|
|
584
|
+
{t("models.save")}
|
|
585
|
+
</button>
|
|
586
|
+
)}
|
|
587
|
+
{saveState === "saved" && (
|
|
588
|
+
<span className="flex items-center gap-1 text-sm text-emerald-400">
|
|
589
|
+
<Check className="h-4 w-4" />
|
|
590
|
+
{t("providers_models.saved")}
|
|
591
|
+
</span>
|
|
592
|
+
)}
|
|
593
|
+
{saveState === "error" && (
|
|
594
|
+
<span className="flex items-center gap-1 text-sm text-red-400">
|
|
595
|
+
<X className="h-4 w-4" />
|
|
596
|
+
{t("providers_models.save_failed")}
|
|
597
|
+
</span>
|
|
598
|
+
)}
|
|
599
|
+
{isCustom && baseUrl.trim() !== "" && (
|
|
600
|
+
<TestConnectionButton baseUrl={baseUrl} apiKey={apiKey} />
|
|
601
|
+
)}
|
|
602
|
+
</div>
|
|
603
|
+
|
|
604
|
+
{/* Model List */}
|
|
605
|
+
<div>
|
|
606
|
+
<div className="flex items-center justify-between gap-3">
|
|
607
|
+
<label className="block text-sm text-gray-400">{t("providers_models.model_list")}</label>
|
|
608
|
+
{provider.models.length > 0 && (
|
|
609
|
+
<div className="flex items-center gap-2">
|
|
610
|
+
<button
|
|
611
|
+
onClick={() => setAllEnabled(true)}
|
|
612
|
+
className="rounded-md px-2 py-1 text-xs text-emerald-400 transition-colors hover:bg-emerald-500/10"
|
|
613
|
+
>
|
|
614
|
+
{t("providers_models.enable_all")}
|
|
615
|
+
</button>
|
|
616
|
+
<button
|
|
617
|
+
onClick={() => setAllEnabled(false)}
|
|
618
|
+
className="rounded-md px-2 py-1 text-xs text-gray-400 transition-colors hover:bg-gray-800"
|
|
619
|
+
>
|
|
620
|
+
{t("providers_models.disable_all")}
|
|
621
|
+
</button>
|
|
622
|
+
</div>
|
|
623
|
+
)}
|
|
624
|
+
</div>
|
|
625
|
+
|
|
626
|
+
{provider.models.length > 5 && (
|
|
627
|
+
<div className="relative mt-1.5">
|
|
628
|
+
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-500" />
|
|
629
|
+
<input
|
|
630
|
+
type="text"
|
|
631
|
+
value={modelQuery}
|
|
632
|
+
onChange={(e) => setModelQuery(e.target.value)}
|
|
633
|
+
placeholder={t("models.search_placeholder")}
|
|
634
|
+
className="w-full rounded-lg border border-gray-700 bg-gray-800 py-2 pl-9 pr-3 text-sm text-white"
|
|
635
|
+
/>
|
|
636
|
+
</div>
|
|
637
|
+
)}
|
|
638
|
+
|
|
639
|
+
<div className="mt-1.5 space-y-2 rounded-lg border border-gray-800 p-3">
|
|
640
|
+
{provider.models.length === 0 && (
|
|
641
|
+
<p className="px-1 py-2 text-sm text-gray-500">{t("models.no_models")}</p>
|
|
642
|
+
)}
|
|
643
|
+
{provider.models.length > 0 && visibleModels.length === 0 && (
|
|
644
|
+
<p className="px-1 py-2 text-sm text-gray-500">{t("models.no_models")}</p>
|
|
645
|
+
)}
|
|
646
|
+
{visibleModels.map((m) => (
|
|
647
|
+
<div
|
|
648
|
+
key={m.id}
|
|
649
|
+
className={cn(
|
|
650
|
+
"flex items-center gap-2 rounded-lg border border-gray-700 bg-gray-900/70 px-3 py-2.5",
|
|
651
|
+
!isEnabled(m.id) && "opacity-50"
|
|
652
|
+
)}
|
|
653
|
+
>
|
|
654
|
+
<Box className="h-4 w-4 shrink-0 text-gray-500" />
|
|
655
|
+
<span className="min-w-0 flex-1 truncate font-mono text-sm text-gray-200">
|
|
656
|
+
{m.id}
|
|
657
|
+
</span>
|
|
658
|
+
{m.reasoning && (
|
|
659
|
+
<span title={t("models.reasoning")} className="flex shrink-0">
|
|
660
|
+
<Brain className="h-3.5 w-3.5 text-purple-400" />
|
|
661
|
+
</span>
|
|
662
|
+
)}
|
|
663
|
+
{m.input?.includes("image") && (
|
|
664
|
+
<span title={t("models.image_input")} className="flex shrink-0">
|
|
665
|
+
<ImageIcon className="h-3.5 w-3.5 text-blue-400" />
|
|
666
|
+
</span>
|
|
667
|
+
)}
|
|
668
|
+
<span className="rounded-md border border-gray-600 bg-gray-800/50 px-2 py-0.5 text-xs text-gray-400">
|
|
669
|
+
{m.cost && (m.cost.input || m.cost.output)
|
|
670
|
+
? `$${m.cost.input}/${m.cost.output}`
|
|
671
|
+
: t("models.free")}
|
|
672
|
+
</span>
|
|
673
|
+
<span className="rounded-md border border-gray-600 bg-gray-800/50 px-2 py-0.5 text-xs text-gray-400">
|
|
674
|
+
{m.contextWindow ? formatTokens(m.contextWindow) : "—"}
|
|
675
|
+
</span>
|
|
676
|
+
<button
|
|
677
|
+
onClick={() => handleToggle(m.id)}
|
|
678
|
+
className={cn(
|
|
679
|
+
"rounded-md px-2 py-1 text-xs transition-colors",
|
|
680
|
+
isEnabled(m.id)
|
|
681
|
+
? "text-emerald-400 hover:bg-emerald-500/10"
|
|
682
|
+
: "text-gray-500 hover:bg-gray-700"
|
|
683
|
+
)}
|
|
684
|
+
>
|
|
685
|
+
{isEnabled(m.id) ? t("models.enabled") : t("models.disabled")}
|
|
686
|
+
</button>
|
|
687
|
+
<button
|
|
688
|
+
onClick={() => setEditModel(m)}
|
|
689
|
+
className="rounded-md p-1.5 text-gray-500 transition-colors hover:bg-gray-700 hover:text-gray-200"
|
|
690
|
+
title={t("models.edit_model")}
|
|
691
|
+
>
|
|
692
|
+
<Edit3 className="h-3.5 w-3.5" />
|
|
693
|
+
</button>
|
|
694
|
+
<button
|
|
695
|
+
onClick={() => setDeleteModel(m)}
|
|
696
|
+
className="rounded-md p-1.5 text-gray-500 transition-colors hover:bg-red-500/10 hover:text-red-400"
|
|
697
|
+
title={t("models.delete_model")}
|
|
698
|
+
>
|
|
699
|
+
<Trash2 className="h-3.5 w-3.5" />
|
|
700
|
+
</button>
|
|
701
|
+
</div>
|
|
702
|
+
))}
|
|
703
|
+
</div>
|
|
704
|
+
<button
|
|
705
|
+
onClick={() => setShowAddModel(true)}
|
|
706
|
+
className="mt-3 flex items-center gap-2 rounded-lg border border-gray-700 px-4 py-2 text-sm text-gray-300 transition-colors hover:bg-gray-800"
|
|
707
|
+
>
|
|
708
|
+
<Plus className="h-4 w-4" />
|
|
709
|
+
{t("models.add_model")}
|
|
710
|
+
</button>
|
|
711
|
+
</div>
|
|
712
|
+
|
|
713
|
+
{/* Edit Model Modal */}
|
|
714
|
+
<Modal
|
|
715
|
+
open={!!editModel}
|
|
716
|
+
onClose={() => setEditModel(null)}
|
|
717
|
+
title={`${t("models.edit_model")}: ${editModel?.name || editModel?.id}`}
|
|
718
|
+
size="lg"
|
|
719
|
+
>
|
|
720
|
+
{editModel && (
|
|
721
|
+
<ModelForm
|
|
722
|
+
initial={editModel}
|
|
723
|
+
onSubmit={(form) => {
|
|
724
|
+
updateModel(provider.id, editModel.id, form);
|
|
725
|
+
setEditModel(null);
|
|
726
|
+
}}
|
|
727
|
+
onCancel={() => setEditModel(null)}
|
|
728
|
+
/>
|
|
729
|
+
)}
|
|
730
|
+
</Modal>
|
|
731
|
+
|
|
732
|
+
{/* Add Model Modal */}
|
|
733
|
+
<Modal
|
|
734
|
+
open={showAddModel}
|
|
735
|
+
onClose={() => setShowAddModel(false)}
|
|
736
|
+
title={`${t("models.add_model")} — ${provider.name}`}
|
|
737
|
+
size="lg"
|
|
738
|
+
>
|
|
739
|
+
<ModelForm
|
|
740
|
+
onSubmit={(form) => {
|
|
741
|
+
if (!form.id) return;
|
|
742
|
+
addModel(provider.id, form as Model);
|
|
743
|
+
setShowAddModel(false);
|
|
744
|
+
}}
|
|
745
|
+
onCancel={() => setShowAddModel(false)}
|
|
746
|
+
/>
|
|
747
|
+
</Modal>
|
|
748
|
+
|
|
749
|
+
{/* Delete Model Confirmation Modal */}
|
|
750
|
+
<Modal
|
|
751
|
+
open={!!deleteModel}
|
|
752
|
+
onClose={() => setDeleteModel(null)}
|
|
753
|
+
title={t("models.delete_model")}
|
|
754
|
+
>
|
|
755
|
+
<div className="space-y-4">
|
|
756
|
+
<div className="flex items-start gap-3">
|
|
757
|
+
<Trash2 className="h-5 w-5 shrink-0 mt-0.5 text-red-400" />
|
|
758
|
+
<p className="text-sm text-gray-200">
|
|
759
|
+
<strong className="font-mono">{deleteModel?.id}</strong>
|
|
760
|
+
{" — "}
|
|
761
|
+
{t("models.delete_confirm")}
|
|
762
|
+
</p>
|
|
763
|
+
</div>
|
|
764
|
+
<div className="flex justify-end gap-3">
|
|
765
|
+
<button
|
|
766
|
+
onClick={() => setDeleteModel(null)}
|
|
767
|
+
className="rounded-lg px-4 py-2 text-sm text-gray-400 hover:bg-gray-800"
|
|
768
|
+
>
|
|
769
|
+
{t("models.cancel")}
|
|
770
|
+
</button>
|
|
771
|
+
<button
|
|
772
|
+
onClick={() => {
|
|
773
|
+
if (deleteModel) {
|
|
774
|
+
removeModel(provider.id, deleteModel.id);
|
|
775
|
+
setDeleteModel(null);
|
|
776
|
+
}
|
|
777
|
+
}}
|
|
778
|
+
className="flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium text-white"
|
|
779
|
+
style={{ backgroundColor: "#dc2626" }}
|
|
780
|
+
>
|
|
781
|
+
<Trash2 className="h-4 w-4" />
|
|
782
|
+
{t("models.delete_model")}
|
|
783
|
+
</button>
|
|
784
|
+
</div>
|
|
785
|
+
</div>
|
|
786
|
+
</Modal>
|
|
787
|
+
</div>
|
|
788
|
+
);
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// ─── Model Form (add & edit) ──────────────────────────────
|
|
792
|
+
|
|
793
|
+
function ModelForm({
|
|
794
|
+
initial,
|
|
795
|
+
onSubmit,
|
|
796
|
+
onCancel,
|
|
797
|
+
}: {
|
|
798
|
+
initial?: Model;
|
|
799
|
+
onSubmit: (form: Partial<Model>) => void;
|
|
800
|
+
onCancel: () => void;
|
|
801
|
+
}) {
|
|
802
|
+
const { t } = useTranslation();
|
|
803
|
+
const [form, setForm] = useState<Partial<Model>>(
|
|
804
|
+
initial
|
|
805
|
+
? { ...initial }
|
|
806
|
+
: {
|
|
807
|
+
id: "",
|
|
808
|
+
name: "",
|
|
809
|
+
reasoning: false,
|
|
810
|
+
input: ["text"],
|
|
811
|
+
contextWindow: 128000,
|
|
812
|
+
maxTokens: 16384,
|
|
813
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
814
|
+
}
|
|
815
|
+
);
|
|
816
|
+
const isEdit = !!initial;
|
|
817
|
+
|
|
818
|
+
return (
|
|
819
|
+
<div className="space-y-4">
|
|
820
|
+
<div className="grid grid-cols-2 gap-4">
|
|
821
|
+
<div>
|
|
822
|
+
<label className="block text-xs font-medium text-gray-400">{t("models.model_id")} *</label>
|
|
823
|
+
<input
|
|
824
|
+
type="text"
|
|
825
|
+
value={form.id ?? ""}
|
|
826
|
+
disabled={isEdit}
|
|
827
|
+
onChange={(e) => setForm({ ...form, id: e.target.value })}
|
|
828
|
+
placeholder="my-model-id"
|
|
829
|
+
className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white disabled:opacity-50"
|
|
830
|
+
/>
|
|
831
|
+
</div>
|
|
832
|
+
<div>
|
|
833
|
+
<label className="block text-xs font-medium text-gray-400">{t("models.display_name")}</label>
|
|
834
|
+
<input
|
|
835
|
+
type="text"
|
|
836
|
+
value={form.name ?? ""}
|
|
837
|
+
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
|
838
|
+
placeholder="My Custom Model"
|
|
839
|
+
className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white"
|
|
840
|
+
/>
|
|
841
|
+
</div>
|
|
842
|
+
<div>
|
|
843
|
+
<label className="block text-xs font-medium text-gray-400">{t("models.context_window")}</label>
|
|
844
|
+
<input
|
|
845
|
+
type="number"
|
|
846
|
+
value={form.contextWindow ?? 128000}
|
|
847
|
+
onChange={(e) => setForm({ ...form, contextWindow: parseInt(e.target.value) || 128000 })}
|
|
848
|
+
className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white"
|
|
849
|
+
/>
|
|
850
|
+
</div>
|
|
851
|
+
<div>
|
|
852
|
+
<label className="block text-xs font-medium text-gray-400">{t("models.max_tokens")}</label>
|
|
853
|
+
<input
|
|
854
|
+
type="number"
|
|
855
|
+
value={form.maxTokens ?? 16384}
|
|
856
|
+
onChange={(e) => setForm({ ...form, maxTokens: parseInt(e.target.value) || 16384 })}
|
|
857
|
+
className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white"
|
|
858
|
+
/>
|
|
859
|
+
</div>
|
|
860
|
+
</div>
|
|
861
|
+
|
|
862
|
+
{/* Capabilities */}
|
|
863
|
+
<div className="flex flex-wrap gap-3">
|
|
864
|
+
<label className="flex items-center gap-2 text-sm text-gray-300">
|
|
865
|
+
<input
|
|
866
|
+
type="checkbox"
|
|
867
|
+
checked={form.reasoning ?? false}
|
|
868
|
+
onChange={(e) => setForm({ ...form, reasoning: e.target.checked })}
|
|
869
|
+
className="rounded border-gray-600 bg-gray-800 text-blue-500"
|
|
870
|
+
/>
|
|
871
|
+
{t("models.reasoning")}
|
|
872
|
+
</label>
|
|
873
|
+
<label className="flex items-center gap-2 text-sm text-gray-300">
|
|
874
|
+
<input
|
|
875
|
+
type="checkbox"
|
|
876
|
+
checked={form.input?.includes("image") ?? false}
|
|
877
|
+
onChange={(e) =>
|
|
878
|
+
setForm({ ...form, input: e.target.checked ? ["text", "image"] : ["text"] })
|
|
879
|
+
}
|
|
880
|
+
className="rounded border-gray-600 bg-gray-800 text-blue-500"
|
|
881
|
+
/>
|
|
882
|
+
{t("models.image_input")}
|
|
883
|
+
</label>
|
|
884
|
+
</div>
|
|
885
|
+
|
|
886
|
+
{/* Cost */}
|
|
887
|
+
<div className="grid grid-cols-4 gap-3">
|
|
888
|
+
{(
|
|
889
|
+
[
|
|
890
|
+
["input", "models.cost_input"],
|
|
891
|
+
["output", "models.cost_output"],
|
|
892
|
+
["cacheRead", "models.cost_cache_read"],
|
|
893
|
+
["cacheWrite", "models.cost_cache_write"],
|
|
894
|
+
] as const
|
|
895
|
+
).map(([field, labelKey]) => (
|
|
896
|
+
<div key={field}>
|
|
897
|
+
<label className="block text-xs text-gray-500">{t(labelKey)} $/M</label>
|
|
898
|
+
<input
|
|
899
|
+
type="number"
|
|
900
|
+
step="0.01"
|
|
901
|
+
value={form.cost?.[field] ?? 0}
|
|
902
|
+
onChange={(e) =>
|
|
903
|
+
setForm({
|
|
904
|
+
...form,
|
|
905
|
+
cost: {
|
|
906
|
+
input: 0, output: 0, cacheRead: 0, cacheWrite: 0,
|
|
907
|
+
...form.cost,
|
|
908
|
+
[field]: parseFloat(e.target.value) || 0,
|
|
909
|
+
},
|
|
910
|
+
})
|
|
911
|
+
}
|
|
912
|
+
className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white"
|
|
913
|
+
/>
|
|
914
|
+
</div>
|
|
915
|
+
))}
|
|
916
|
+
</div>
|
|
917
|
+
|
|
918
|
+
<div className="flex justify-end gap-3 pt-2">
|
|
919
|
+
<button onClick={onCancel} className="rounded-lg px-4 py-2 text-sm text-gray-400 hover:bg-gray-800">
|
|
920
|
+
{t("models.cancel")}
|
|
921
|
+
</button>
|
|
922
|
+
<button
|
|
923
|
+
onClick={() => onSubmit(form)}
|
|
924
|
+
disabled={!form.id}
|
|
925
|
+
className="rounded-lg px-4 py-2 text-sm font-medium transition-colors disabled:opacity-50"
|
|
926
|
+
style={{ backgroundColor: "#3b82f6", color: "#ffffff" }}
|
|
927
|
+
>
|
|
928
|
+
{isEdit ? t("models.save") : t("models.add_model")}
|
|
929
|
+
</button>
|
|
930
|
+
</div>
|
|
931
|
+
</div>
|
|
932
|
+
);
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// ─── Add Provider Form ────────────────────────────────────
|
|
936
|
+
|
|
937
|
+
function AddProviderForm({
|
|
938
|
+
onSubmit,
|
|
939
|
+
onCancel,
|
|
940
|
+
}: {
|
|
941
|
+
onSubmit: (id: string, cfg: CustomProviderConfig) => Promise<boolean>;
|
|
942
|
+
onCancel: () => void;
|
|
943
|
+
}) {
|
|
944
|
+
const { t } = useTranslation();
|
|
945
|
+
const { allProviders } = useConfigStore();
|
|
946
|
+
const [name, setName] = useState("");
|
|
947
|
+
const [baseUrl, setBaseUrl] = useState("");
|
|
948
|
+
const [apiKey, setApiKey] = useState("");
|
|
949
|
+
const [api, setApi] = useState<ApiType>("openai-completions");
|
|
950
|
+
const [models, setModels] = useState<Model[]>([]);
|
|
951
|
+
const [showAddModel, setShowAddModel] = useState(false);
|
|
952
|
+
const [submitting, setSubmitting] = useState(false);
|
|
953
|
+
const [submitError, setSubmitError] = useState(false);
|
|
954
|
+
|
|
955
|
+
const id = sanitizeProviderId(name);
|
|
956
|
+
const idExists = !!id && allProviders.some((p) => p.id === id);
|
|
957
|
+
const urlInvalid = baseUrl.trim() !== "" && !isValidHttpUrl(baseUrl.trim());
|
|
958
|
+
|
|
959
|
+
const handleSubmit = async () => {
|
|
960
|
+
if (!id || !baseUrl || idExists || urlInvalid) return;
|
|
961
|
+
setSubmitting(true);
|
|
962
|
+
setSubmitError(false);
|
|
963
|
+
const ok = await onSubmit(id, {
|
|
964
|
+
baseUrl,
|
|
965
|
+
api,
|
|
966
|
+
apiKey: apiKey || undefined,
|
|
967
|
+
models,
|
|
968
|
+
});
|
|
969
|
+
setSubmitting(false);
|
|
970
|
+
if (!ok) setSubmitError(true);
|
|
971
|
+
};
|
|
972
|
+
|
|
973
|
+
return (
|
|
974
|
+
<div className="space-y-5">
|
|
975
|
+
<div>
|
|
976
|
+
<h2 className="text-lg font-semibold text-white">
|
|
977
|
+
{t("providers_models.add_provider_title")}
|
|
978
|
+
</h2>
|
|
979
|
+
<p className="mt-1 text-sm text-gray-500">{t("providers_models.add_provider_desc")}</p>
|
|
980
|
+
</div>
|
|
981
|
+
|
|
982
|
+
{/* Name */}
|
|
983
|
+
<div>
|
|
984
|
+
<label className="block text-sm text-gray-400">{t("providers_models.name")}</label>
|
|
985
|
+
<input
|
|
986
|
+
type="text"
|
|
987
|
+
value={name}
|
|
988
|
+
onChange={(e) => setName(e.target.value)}
|
|
989
|
+
placeholder={t("providers_models.name_placeholder")}
|
|
990
|
+
className={cn(
|
|
991
|
+
"mt-1.5 w-full rounded-lg border bg-gray-800 px-3 py-2.5 text-sm text-white",
|
|
992
|
+
idExists ? "border-red-500" : "border-gray-700"
|
|
993
|
+
)}
|
|
994
|
+
/>
|
|
995
|
+
{idExists ? (
|
|
996
|
+
<p className="mt-1 text-xs text-red-400">{t("providers_models.id_exists", id)}</p>
|
|
997
|
+
) : id ? (
|
|
998
|
+
<p className="mt-1 text-xs text-gray-500">{t("providers_models.id_preview", id)}</p>
|
|
999
|
+
) : null}
|
|
1000
|
+
</div>
|
|
1001
|
+
|
|
1002
|
+
{/* Base URL */}
|
|
1003
|
+
<div>
|
|
1004
|
+
<label className="block text-sm text-gray-400">{t("providers.base_url")}</label>
|
|
1005
|
+
<input
|
|
1006
|
+
type="text"
|
|
1007
|
+
value={baseUrl}
|
|
1008
|
+
onChange={(e) => setBaseUrl(e.target.value)}
|
|
1009
|
+
placeholder="https://api.example.com/v1"
|
|
1010
|
+
className={cn(
|
|
1011
|
+
"mt-1.5 w-full rounded-lg border bg-gray-800 px-3 py-2.5 text-sm text-white",
|
|
1012
|
+
urlInvalid ? "border-red-500" : "border-gray-700"
|
|
1013
|
+
)}
|
|
1014
|
+
/>
|
|
1015
|
+
{urlInvalid && (
|
|
1016
|
+
<p className="mt-1 text-xs text-red-400">{t("providers_models.invalid_url")}</p>
|
|
1017
|
+
)}
|
|
1018
|
+
</div>
|
|
1019
|
+
|
|
1020
|
+
{/* API Key */}
|
|
1021
|
+
<div>
|
|
1022
|
+
<label className="block text-sm text-gray-400">{t("providers.api_key")}</label>
|
|
1023
|
+
<input
|
|
1024
|
+
type="password"
|
|
1025
|
+
value={apiKey}
|
|
1026
|
+
onChange={(e) => setApiKey(e.target.value)}
|
|
1027
|
+
placeholder="sk-... or $MY_API_KEY"
|
|
1028
|
+
className="mt-1.5 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2.5 text-sm text-white"
|
|
1029
|
+
/>
|
|
1030
|
+
</div>
|
|
1031
|
+
|
|
1032
|
+
{/* API Type */}
|
|
1033
|
+
<div>
|
|
1034
|
+
<label className="block text-sm text-gray-400">{t("providers.api_type")}</label>
|
|
1035
|
+
<select
|
|
1036
|
+
value={api}
|
|
1037
|
+
onChange={(e) => setApi(e.target.value as ApiType)}
|
|
1038
|
+
className="mt-1.5 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2.5 text-sm text-white"
|
|
1039
|
+
>
|
|
1040
|
+
{API_TYPES.map((a) => (
|
|
1041
|
+
<option key={a.value} value={a.value}>{a.label}</option>
|
|
1042
|
+
))}
|
|
1043
|
+
</select>
|
|
1044
|
+
</div>
|
|
1045
|
+
|
|
1046
|
+
{/* Connection test with the values entered above */}
|
|
1047
|
+
{baseUrl.trim() !== "" && !urlInvalid && (
|
|
1048
|
+
<TestConnectionButton baseUrl={baseUrl} apiKey={apiKey} />
|
|
1049
|
+
)}
|
|
1050
|
+
|
|
1051
|
+
{/* Initial Model List */}
|
|
1052
|
+
<div>
|
|
1053
|
+
<label className="block text-sm text-gray-400">{t("providers_models.model_list")}</label>
|
|
1054
|
+
{models.length > 0 && (
|
|
1055
|
+
<div className="mt-1.5 space-y-2">
|
|
1056
|
+
{models.map((m) => (
|
|
1057
|
+
<div
|
|
1058
|
+
key={m.id}
|
|
1059
|
+
className="flex items-center gap-2 rounded-lg border border-gray-700 bg-gray-900/70 px-3 py-2.5"
|
|
1060
|
+
>
|
|
1061
|
+
<Box className="h-4 w-4 shrink-0 text-gray-500" />
|
|
1062
|
+
<span className="min-w-0 flex-1 truncate font-mono text-sm text-gray-200">
|
|
1063
|
+
{m.id}
|
|
1064
|
+
</span>
|
|
1065
|
+
<span className="rounded-md border border-gray-600 bg-gray-800/50 px-2 py-0.5 text-xs text-gray-400">
|
|
1066
|
+
{m.contextWindow ? formatTokens(m.contextWindow) : "—"}
|
|
1067
|
+
</span>
|
|
1068
|
+
<button
|
|
1069
|
+
onClick={() => setModels(models.filter((x) => x.id !== m.id))}
|
|
1070
|
+
className="rounded-md p-1.5 text-gray-500 transition-colors hover:bg-red-500/10 hover:text-red-400"
|
|
1071
|
+
title={t("models.delete_model")}
|
|
1072
|
+
>
|
|
1073
|
+
<Trash2 className="h-3.5 w-3.5" />
|
|
1074
|
+
</button>
|
|
1075
|
+
</div>
|
|
1076
|
+
))}
|
|
1077
|
+
</div>
|
|
1078
|
+
)}
|
|
1079
|
+
<button
|
|
1080
|
+
onClick={() => setShowAddModel(true)}
|
|
1081
|
+
className="mt-2 flex items-center gap-2 rounded-lg border border-gray-700 px-4 py-2 text-sm text-gray-300 transition-colors hover:bg-gray-800"
|
|
1082
|
+
>
|
|
1083
|
+
<Plus className="h-4 w-4" />
|
|
1084
|
+
{t("models.add_model")}
|
|
1085
|
+
</button>
|
|
1086
|
+
</div>
|
|
1087
|
+
|
|
1088
|
+
<div className="flex items-center gap-3">
|
|
1089
|
+
<button
|
|
1090
|
+
onClick={handleSubmit}
|
|
1091
|
+
disabled={!id || !baseUrl || idExists || urlInvalid || submitting}
|
|
1092
|
+
className="flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-colors disabled:opacity-50"
|
|
1093
|
+
style={{ backgroundColor: "#3b82f6", color: "#ffffff" }}
|
|
1094
|
+
>
|
|
1095
|
+
{submitting && <Loader2 className="h-4 w-4 animate-spin" />}
|
|
1096
|
+
{t("providers.add_provider")}
|
|
1097
|
+
</button>
|
|
1098
|
+
<button
|
|
1099
|
+
onClick={onCancel}
|
|
1100
|
+
className="rounded-lg px-4 py-2 text-sm text-gray-400 hover:bg-gray-800"
|
|
1101
|
+
>
|
|
1102
|
+
{t("models.cancel")}
|
|
1103
|
+
</button>
|
|
1104
|
+
{submitError && (
|
|
1105
|
+
<span className="flex items-center gap-1 text-sm text-red-400">
|
|
1106
|
+
<X className="h-4 w-4" />
|
|
1107
|
+
{t("providers_models.save_failed")}
|
|
1108
|
+
</span>
|
|
1109
|
+
)}
|
|
1110
|
+
</div>
|
|
1111
|
+
|
|
1112
|
+
{/* Add Initial Model Modal */}
|
|
1113
|
+
<Modal
|
|
1114
|
+
open={showAddModel}
|
|
1115
|
+
onClose={() => setShowAddModel(false)}
|
|
1116
|
+
title={t("models.add_model")}
|
|
1117
|
+
size="lg"
|
|
1118
|
+
>
|
|
1119
|
+
<ModelForm
|
|
1120
|
+
onSubmit={(form) => {
|
|
1121
|
+
if (!form.id) return;
|
|
1122
|
+
setModels([...models.filter((x) => x.id !== form.id), form as Model]);
|
|
1123
|
+
setShowAddModel(false);
|
|
1124
|
+
}}
|
|
1125
|
+
onCancel={() => setShowAddModel(false)}
|
|
1126
|
+
/>
|
|
1127
|
+
</Modal>
|
|
1128
|
+
</div>
|
|
1129
|
+
);
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
// ─── Import Provider Modal ─────────────────────────────────
|
|
1133
|
+
|
|
1134
|
+
function ImportProviderModal({
|
|
1135
|
+
open,
|
|
1136
|
+
onClose,
|
|
1137
|
+
onImported,
|
|
1138
|
+
}: {
|
|
1139
|
+
open: boolean;
|
|
1140
|
+
onClose: () => void;
|
|
1141
|
+
onImported: (id: string) => void;
|
|
1142
|
+
}) {
|
|
1143
|
+
const { t } = useTranslation();
|
|
1144
|
+
const { allProviders } = useConfigStore();
|
|
1145
|
+
|
|
1146
|
+
const [text, setText] = useState("");
|
|
1147
|
+
const [name, setName] = useState("");
|
|
1148
|
+
const [baseUrl, setBaseUrl] = useState("");
|
|
1149
|
+
const [apiKey, setApiKey] = useState("");
|
|
1150
|
+
const [api, setApi] = useState<ApiType>("openai-completions");
|
|
1151
|
+
const [modelIds, setModelIds] = useState<string[]>([]);
|
|
1152
|
+
const [submitting, setSubmitting] = useState(false);
|
|
1153
|
+
const [submitError, setSubmitError] = useState(false);
|
|
1154
|
+
|
|
1155
|
+
// Re-parse on every paste/edit of the raw text; fields below stay editable
|
|
1156
|
+
const handleText = (value: string) => {
|
|
1157
|
+
setText(value);
|
|
1158
|
+
const parsed = parseProviderImport(value);
|
|
1159
|
+
setName(parsed.name);
|
|
1160
|
+
setBaseUrl(parsed.baseUrl);
|
|
1161
|
+
setApiKey(parsed.apiKey);
|
|
1162
|
+
setModelIds(parsed.modelIds);
|
|
1163
|
+
};
|
|
1164
|
+
|
|
1165
|
+
const reset = () => {
|
|
1166
|
+
setText("");
|
|
1167
|
+
setName("");
|
|
1168
|
+
setBaseUrl("");
|
|
1169
|
+
setApiKey("");
|
|
1170
|
+
setApi("openai-completions");
|
|
1171
|
+
setModelIds([]);
|
|
1172
|
+
setSubmitting(false);
|
|
1173
|
+
setSubmitError(false);
|
|
1174
|
+
};
|
|
1175
|
+
|
|
1176
|
+
const id = sanitizeProviderId(name);
|
|
1177
|
+
const existing = allProviders.find((p) => p.id === id);
|
|
1178
|
+
const builtinConflict = existing?.type === "builtin";
|
|
1179
|
+
const mergeTarget = existing?.type === "custom" ? existing : null;
|
|
1180
|
+
const urlInvalid = baseUrl.trim() !== "" && !isValidHttpUrl(baseUrl.trim());
|
|
1181
|
+
const parsedEmpty =
|
|
1182
|
+
text.trim() !== "" && !name && !baseUrl && !apiKey && modelIds.length === 0;
|
|
1183
|
+
const canSubmit =
|
|
1184
|
+
!!id && (!!baseUrl.trim() || !!mergeTarget) && !urlInvalid && !builtinConflict && !submitting;
|
|
1185
|
+
|
|
1186
|
+
const handleImport = async () => {
|
|
1187
|
+
if (!canSubmit) return;
|
|
1188
|
+
setSubmitting(true);
|
|
1189
|
+
setSubmitError(false);
|
|
1190
|
+
const store = useConfigStore.getState();
|
|
1191
|
+
const newModels: Model[] = modelIds.map((mid) => ({
|
|
1192
|
+
id: mid,
|
|
1193
|
+
name: mid.split("/").pop() || mid,
|
|
1194
|
+
input: ["text"],
|
|
1195
|
+
contextWindow: 128000,
|
|
1196
|
+
maxTokens: 16384,
|
|
1197
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
1198
|
+
}));
|
|
1199
|
+
|
|
1200
|
+
let ok: boolean;
|
|
1201
|
+
if (mergeTarget) {
|
|
1202
|
+
// Merge into the existing custom provider (dedupe models by id)
|
|
1203
|
+
const existingCfg = store.modelsJson?.providers[id];
|
|
1204
|
+
const existingModels = existingCfg?.models ?? [];
|
|
1205
|
+
const merged = [
|
|
1206
|
+
...existingModels,
|
|
1207
|
+
...newModels.filter((m) => !existingModels.some((e) => e.id === m.id)),
|
|
1208
|
+
];
|
|
1209
|
+
ok = await store.updateCustomProvider(id, {
|
|
1210
|
+
baseUrl: baseUrl.trim() || existingCfg?.baseUrl,
|
|
1211
|
+
apiKey: apiKey || existingCfg?.apiKey,
|
|
1212
|
+
models: merged,
|
|
1213
|
+
});
|
|
1214
|
+
} else {
|
|
1215
|
+
ok = await store.addCustomProvider(id, {
|
|
1216
|
+
baseUrl: baseUrl.trim(),
|
|
1217
|
+
api,
|
|
1218
|
+
apiKey: apiKey || undefined,
|
|
1219
|
+
models: newModels,
|
|
1220
|
+
});
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
// Imported models are enabled by default (settings.enabledModels refs)
|
|
1224
|
+
if (ok && modelIds.length > 0) {
|
|
1225
|
+
const refs = modelIds.map((m) => `${id}/${m}`);
|
|
1226
|
+
const list = store.settings?.enabledModels ?? [];
|
|
1227
|
+
await store.updateSettings({ enabledModels: Array.from(new Set([...list, ...refs])) });
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
setSubmitting(false);
|
|
1231
|
+
if (ok) {
|
|
1232
|
+
reset();
|
|
1233
|
+
onImported(id);
|
|
1234
|
+
} else {
|
|
1235
|
+
setSubmitError(true);
|
|
1236
|
+
}
|
|
1237
|
+
};
|
|
1238
|
+
|
|
1239
|
+
return (
|
|
1240
|
+
<Modal
|
|
1241
|
+
open={open}
|
|
1242
|
+
onClose={() => { reset(); onClose(); }}
|
|
1243
|
+
title={t("providers_models.import_title")}
|
|
1244
|
+
size="lg"
|
|
1245
|
+
>
|
|
1246
|
+
<div className="space-y-4">
|
|
1247
|
+
<p className="text-sm text-gray-500">{t("providers_models.import_desc")}</p>
|
|
1248
|
+
|
|
1249
|
+
{/* Paste area */}
|
|
1250
|
+
<textarea
|
|
1251
|
+
value={text}
|
|
1252
|
+
onChange={(e) => handleText(e.target.value)}
|
|
1253
|
+
rows={4}
|
|
1254
|
+
placeholder={t("providers_models.import_placeholder")}
|
|
1255
|
+
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2.5 font-mono text-sm text-white placeholder:text-gray-600"
|
|
1256
|
+
/>
|
|
1257
|
+
{parsedEmpty && (
|
|
1258
|
+
<p className="text-xs text-amber-400">{t("providers_models.import_empty")}</p>
|
|
1259
|
+
)}
|
|
1260
|
+
|
|
1261
|
+
{/* Parsed preview (editable) */}
|
|
1262
|
+
<div className="grid grid-cols-2 gap-4">
|
|
1263
|
+
<div>
|
|
1264
|
+
<label className="block text-xs font-medium text-gray-400">
|
|
1265
|
+
{t("providers_models.name")}
|
|
1266
|
+
</label>
|
|
1267
|
+
<input
|
|
1268
|
+
type="text"
|
|
1269
|
+
value={name}
|
|
1270
|
+
onChange={(e) => setName(e.target.value)}
|
|
1271
|
+
placeholder={t("providers_models.name_placeholder")}
|
|
1272
|
+
className={cn(
|
|
1273
|
+
"mt-1 w-full rounded-lg border bg-gray-800 px-3 py-2 text-sm text-white",
|
|
1274
|
+
builtinConflict ? "border-red-500" : "border-gray-700"
|
|
1275
|
+
)}
|
|
1276
|
+
/>
|
|
1277
|
+
{builtinConflict ? (
|
|
1278
|
+
<p className="mt-1 text-xs text-red-400">
|
|
1279
|
+
{t("providers_models.import_builtin_conflict", id)}
|
|
1280
|
+
</p>
|
|
1281
|
+
) : mergeTarget ? (
|
|
1282
|
+
<p className="mt-1 text-xs text-amber-400">
|
|
1283
|
+
{t("providers_models.import_merge", id)}
|
|
1284
|
+
</p>
|
|
1285
|
+
) : id ? (
|
|
1286
|
+
<p className="mt-1 text-xs text-gray-500">{t("providers_models.id_preview", id)}</p>
|
|
1287
|
+
) : null}
|
|
1288
|
+
</div>
|
|
1289
|
+
<div>
|
|
1290
|
+
<label className="block text-xs font-medium text-gray-400">
|
|
1291
|
+
{t("providers.api_type")}
|
|
1292
|
+
</label>
|
|
1293
|
+
<select
|
|
1294
|
+
value={api}
|
|
1295
|
+
onChange={(e) => setApi(e.target.value as ApiType)}
|
|
1296
|
+
className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white"
|
|
1297
|
+
>
|
|
1298
|
+
{API_TYPES.map((a) => (
|
|
1299
|
+
<option key={a.value} value={a.value}>{a.label}</option>
|
|
1300
|
+
))}
|
|
1301
|
+
</select>
|
|
1302
|
+
</div>
|
|
1303
|
+
<div>
|
|
1304
|
+
<label className="block text-xs font-medium text-gray-400">
|
|
1305
|
+
{t("providers.base_url")}
|
|
1306
|
+
</label>
|
|
1307
|
+
<input
|
|
1308
|
+
type="text"
|
|
1309
|
+
value={baseUrl}
|
|
1310
|
+
onChange={(e) => setBaseUrl(e.target.value)}
|
|
1311
|
+
placeholder="https://api.example.com/v1"
|
|
1312
|
+
className={cn(
|
|
1313
|
+
"mt-1 w-full rounded-lg border bg-gray-800 px-3 py-2 text-sm text-white",
|
|
1314
|
+
urlInvalid ? "border-red-500" : "border-gray-700"
|
|
1315
|
+
)}
|
|
1316
|
+
/>
|
|
1317
|
+
{urlInvalid && (
|
|
1318
|
+
<p className="mt-1 text-xs text-red-400">{t("providers_models.invalid_url")}</p>
|
|
1319
|
+
)}
|
|
1320
|
+
</div>
|
|
1321
|
+
<div>
|
|
1322
|
+
<label className="block text-xs font-medium text-gray-400">
|
|
1323
|
+
{t("providers.api_key")}
|
|
1324
|
+
</label>
|
|
1325
|
+
<input
|
|
1326
|
+
type="password"
|
|
1327
|
+
value={apiKey}
|
|
1328
|
+
onChange={(e) => setApiKey(e.target.value)}
|
|
1329
|
+
placeholder="sk-... or $MY_API_KEY"
|
|
1330
|
+
className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white"
|
|
1331
|
+
/>
|
|
1332
|
+
</div>
|
|
1333
|
+
</div>
|
|
1334
|
+
|
|
1335
|
+
{/* Detected models */}
|
|
1336
|
+
{modelIds.length > 0 && (
|
|
1337
|
+
<div>
|
|
1338
|
+
<label className="block text-xs font-medium text-gray-400">
|
|
1339
|
+
{t("providers_models.model_list")}
|
|
1340
|
+
</label>
|
|
1341
|
+
<div className="mt-1.5 flex flex-wrap gap-2">
|
|
1342
|
+
{modelIds.map((mid) => (
|
|
1343
|
+
<span
|
|
1344
|
+
key={mid}
|
|
1345
|
+
className="flex items-center gap-1.5 rounded-md border border-gray-700 bg-gray-800 px-2 py-1 font-mono text-xs text-gray-200"
|
|
1346
|
+
>
|
|
1347
|
+
<Box className="h-3 w-3 text-gray-500" />
|
|
1348
|
+
{mid}
|
|
1349
|
+
<button
|
|
1350
|
+
onClick={() => setModelIds(modelIds.filter((x) => x !== mid))}
|
|
1351
|
+
className="text-gray-500 hover:text-red-400"
|
|
1352
|
+
>
|
|
1353
|
+
<X className="h-3 w-3" />
|
|
1354
|
+
</button>
|
|
1355
|
+
</span>
|
|
1356
|
+
))}
|
|
1357
|
+
</div>
|
|
1358
|
+
</div>
|
|
1359
|
+
)}
|
|
1360
|
+
|
|
1361
|
+
<div className="flex items-center justify-end gap-3 pt-1">
|
|
1362
|
+
{submitError && (
|
|
1363
|
+
<span className="flex items-center gap-1 text-sm text-red-400">
|
|
1364
|
+
<X className="h-4 w-4" />
|
|
1365
|
+
{t("providers_models.save_failed")}
|
|
1366
|
+
</span>
|
|
1367
|
+
)}
|
|
1368
|
+
<button
|
|
1369
|
+
onClick={() => { reset(); onClose(); }}
|
|
1370
|
+
className="rounded-lg px-4 py-2 text-sm text-gray-400 hover:bg-gray-800"
|
|
1371
|
+
>
|
|
1372
|
+
{t("models.cancel")}
|
|
1373
|
+
</button>
|
|
1374
|
+
<button
|
|
1375
|
+
onClick={handleImport}
|
|
1376
|
+
disabled={!canSubmit}
|
|
1377
|
+
className="flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-colors disabled:opacity-50"
|
|
1378
|
+
style={{ backgroundColor: "#3b82f6", color: "#ffffff" }}
|
|
1379
|
+
>
|
|
1380
|
+
{submitting && <Loader2 className="h-4 w-4 animate-spin" />}
|
|
1381
|
+
{t("providers_models.import")}
|
|
1382
|
+
</button>
|
|
1383
|
+
</div>
|
|
1384
|
+
</div>
|
|
1385
|
+
</Modal>
|
|
1386
|
+
);
|
|
1387
|
+
}
|