@tonyclaw/llm-inspector 1.14.2 → 1.14.4
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/.output/nitro.json +1 -1
- package/.output/public/assets/index-5yrjXc3u.js +105 -0
- package/.output/public/assets/index-o0Ui96SM.css +1 -0
- package/.output/public/assets/{main-BElVT2p3.js → main-CC0TDCAo.js} +1 -1
- package/.output/server/_libs/lucide-react.mjs +113 -105
- package/.output/server/_ssr/{index-DmLit8Ad.mjs → index-Cz6oxzsy.mjs} +350 -55
- package/.output/server/_ssr/index.mjs +2 -2
- package/.output/server/_ssr/{router-BoeSXWHG.mjs → router-Bl3OCdGC.mjs} +229 -33
- package/.output/server/_tanstack-start-manifest_v-Oekf1osO.mjs +4 -0
- package/.output/server/index.mjs +22 -22
- package/package.json +1 -1
- package/src/components/providers/ImportWizardDialog.tsx +281 -0
- package/src/components/providers/ProviderCard.tsx +2 -0
- package/src/components/providers/ProviderForm.tsx +76 -26
- package/src/components/providers/ProvidersPanel.tsx +35 -5
- package/src/components/providers/SettingsDialog.tsx +1 -1
- package/src/proxy/providerImporters.ts +235 -0
- package/src/routes/api/providers.scan.ts +23 -0
- package/styles/globals.css +121 -121
- package/.output/public/assets/index-DDrUlr6L.js +0 -105
- package/.output/public/assets/index-DOG5AdQ9.css +0 -1
- package/.output/server/_tanstack-start-manifest_v-B6idtbmL.mjs +0 -4
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { type JSX, useState, useCallback, useEffect } from "react";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "../ui/dialog";
|
|
4
|
+
import { Button } from "../ui/button";
|
|
5
|
+
import { Badge } from "../ui/badge";
|
|
6
|
+
import { Loader2, Download, AlertCircle } from "lucide-react";
|
|
7
|
+
|
|
8
|
+
const ExternalProviderSchema = z.object({
|
|
9
|
+
name: z.string(),
|
|
10
|
+
apiKey: z.string(),
|
|
11
|
+
format: z.enum(["anthropic", "openai"]),
|
|
12
|
+
anthropicBaseUrl: z.string(),
|
|
13
|
+
openaiBaseUrl: z.string(),
|
|
14
|
+
models: z.array(z.string()),
|
|
15
|
+
sourceTool: z.enum(["claude-code", "opencode"]),
|
|
16
|
+
alreadyExists: z.boolean(),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const ScanResponseSchema = z.object({
|
|
20
|
+
providers: z.array(ExternalProviderSchema),
|
|
21
|
+
warnings: z.array(z.string()).optional(),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const ImportResponseSchema = z.object({
|
|
25
|
+
success: z.boolean().optional(),
|
|
26
|
+
imported: z.number().optional(),
|
|
27
|
+
message: z.string().optional(),
|
|
28
|
+
errors: z.array(z.string()).optional(),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
type ImportWizardDialogProps = {
|
|
32
|
+
open: boolean;
|
|
33
|
+
onOpenChange: (open: boolean) => void;
|
|
34
|
+
onImportComplete: () => void;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export function ImportWizardDialog({
|
|
38
|
+
open,
|
|
39
|
+
onOpenChange,
|
|
40
|
+
onImportComplete,
|
|
41
|
+
}: ImportWizardDialogProps): JSX.Element {
|
|
42
|
+
const [scanning, setScanning] = useState(false);
|
|
43
|
+
const [scanError, setScanError] = useState<string | null>(null);
|
|
44
|
+
const [providers, setProviders] = useState<z.infer<typeof ExternalProviderSchema>[]>([]);
|
|
45
|
+
const [warnings, setWarnings] = useState<string[]>([]);
|
|
46
|
+
const [selected, setSelected] = useState<Set<number>>(new Set());
|
|
47
|
+
const [importing, setImporting] = useState(false);
|
|
48
|
+
const [importResult, setImportResult] = useState<string | null>(null);
|
|
49
|
+
const [importError, setImportError] = useState<string | null>(null);
|
|
50
|
+
|
|
51
|
+
const scan = useCallback(() => {
|
|
52
|
+
setScanning(true);
|
|
53
|
+
setScanError(null);
|
|
54
|
+
setWarnings([]);
|
|
55
|
+
setProviders([]);
|
|
56
|
+
setSelected(new Set());
|
|
57
|
+
setImportResult(null);
|
|
58
|
+
setImportError(null);
|
|
59
|
+
|
|
60
|
+
fetch("/api/providers/scan")
|
|
61
|
+
.then((res) => {
|
|
62
|
+
if (!res.ok) {
|
|
63
|
+
return res.text().then((text) => {
|
|
64
|
+
setScanError(`Scan failed (${res.status}): ${text}`);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
return res.json().then((data: unknown) => {
|
|
68
|
+
const parsed = ScanResponseSchema.safeParse(data);
|
|
69
|
+
if (!parsed.success) {
|
|
70
|
+
setScanError(`Invalid response: ${parsed.error.message}`);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
setProviders(parsed.data.providers);
|
|
74
|
+
setWarnings(parsed.data.warnings ?? []);
|
|
75
|
+
// Pre-select non-existing providers
|
|
76
|
+
const indices = new Set<number>();
|
|
77
|
+
parsed.data.providers.forEach((p, i) => {
|
|
78
|
+
if (!p.alreadyExists) indices.add(i);
|
|
79
|
+
});
|
|
80
|
+
setSelected(indices);
|
|
81
|
+
});
|
|
82
|
+
})
|
|
83
|
+
.catch((err: unknown) => {
|
|
84
|
+
setScanError(err instanceof Error ? err.message : String(err));
|
|
85
|
+
})
|
|
86
|
+
.finally(() => {
|
|
87
|
+
setScanning(false);
|
|
88
|
+
});
|
|
89
|
+
}, []);
|
|
90
|
+
|
|
91
|
+
// Auto-scan when dialog opens
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
if (open) {
|
|
94
|
+
scan();
|
|
95
|
+
}
|
|
96
|
+
}, [open, scan]);
|
|
97
|
+
|
|
98
|
+
const toggleProvider = useCallback((index: number) => {
|
|
99
|
+
setSelected((prev) => {
|
|
100
|
+
const next = new Set(prev);
|
|
101
|
+
if (next.has(index)) {
|
|
102
|
+
next.delete(index);
|
|
103
|
+
} else {
|
|
104
|
+
next.add(index);
|
|
105
|
+
}
|
|
106
|
+
return next;
|
|
107
|
+
});
|
|
108
|
+
}, []);
|
|
109
|
+
|
|
110
|
+
const importSelected = useCallback(() => {
|
|
111
|
+
const toImport = providers.filter((_, i) => selected.has(i));
|
|
112
|
+
if (toImport.length === 0) return;
|
|
113
|
+
|
|
114
|
+
setImporting(true);
|
|
115
|
+
setImportError(null);
|
|
116
|
+
setImportResult(null);
|
|
117
|
+
|
|
118
|
+
// Convert ExternalProvider[] to ProviderConfig shape for import
|
|
119
|
+
const now = new Date().toISOString();
|
|
120
|
+
const providersPayload = toImport.map((p) => ({
|
|
121
|
+
id: window.crypto.randomUUID(),
|
|
122
|
+
name: p.name,
|
|
123
|
+
apiKey: p.apiKey,
|
|
124
|
+
format: p.format,
|
|
125
|
+
anthropicBaseUrl: p.anthropicBaseUrl,
|
|
126
|
+
openaiBaseUrl: p.openaiBaseUrl,
|
|
127
|
+
models: p.models,
|
|
128
|
+
createdAt: now,
|
|
129
|
+
updatedAt: now,
|
|
130
|
+
}));
|
|
131
|
+
|
|
132
|
+
fetch("/api/providers/import", {
|
|
133
|
+
method: "POST",
|
|
134
|
+
headers: { "Content-Type": "application/json" },
|
|
135
|
+
body: JSON.stringify({ providers: providersPayload }),
|
|
136
|
+
})
|
|
137
|
+
.then((res) => res.json())
|
|
138
|
+
.then((data: unknown) => {
|
|
139
|
+
const parsed = ImportResponseSchema.safeParse(data);
|
|
140
|
+
if (!parsed.success) {
|
|
141
|
+
setImportError(`Invalid response: ${parsed.error.message}`);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const result = parsed.data;
|
|
145
|
+
if (
|
|
146
|
+
result.errors !== undefined &&
|
|
147
|
+
result.errors.length > 0 &&
|
|
148
|
+
result.message !== undefined
|
|
149
|
+
) {
|
|
150
|
+
setImportResult(result.message);
|
|
151
|
+
} else {
|
|
152
|
+
setImportResult(`Imported ${result.imported ?? toImport.length} provider(s)`);
|
|
153
|
+
}
|
|
154
|
+
onImportComplete();
|
|
155
|
+
})
|
|
156
|
+
.catch((err: unknown) => {
|
|
157
|
+
setImportError(err instanceof Error ? err.message : String(err));
|
|
158
|
+
})
|
|
159
|
+
.finally(() => {
|
|
160
|
+
setImporting(false);
|
|
161
|
+
});
|
|
162
|
+
}, [providers, selected, onImportComplete]);
|
|
163
|
+
|
|
164
|
+
const hasSelectable = providers.some((p) => !p.alreadyExists);
|
|
165
|
+
|
|
166
|
+
return (
|
|
167
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
168
|
+
<DialogContent className="max-w-xl max-h-[80vh] overflow-hidden flex flex-col">
|
|
169
|
+
<DialogHeader>
|
|
170
|
+
<DialogTitle>Import from External Tools</DialogTitle>
|
|
171
|
+
<p className="text-xs text-muted-foreground">
|
|
172
|
+
Detect provider configurations from Claude Code and OpenCode.
|
|
173
|
+
</p>
|
|
174
|
+
</DialogHeader>
|
|
175
|
+
|
|
176
|
+
<div className="flex-1 overflow-y-auto space-y-3">
|
|
177
|
+
{scanning && (
|
|
178
|
+
<div className="flex items-center justify-center py-8 gap-2 text-muted-foreground">
|
|
179
|
+
<Loader2 className="size-4 animate-spin" />
|
|
180
|
+
<span className="text-sm">Scanning for external providers...</span>
|
|
181
|
+
</div>
|
|
182
|
+
)}
|
|
183
|
+
|
|
184
|
+
{scanError !== null && (
|
|
185
|
+
<div className="flex items-center gap-2 text-destructive text-sm py-4">
|
|
186
|
+
<AlertCircle className="size-4" />
|
|
187
|
+
{scanError}
|
|
188
|
+
</div>
|
|
189
|
+
)}
|
|
190
|
+
|
|
191
|
+
{!scanning && scanError === null && providers.length === 0 && (
|
|
192
|
+
<p className="text-sm text-muted-foreground py-8 text-center">
|
|
193
|
+
No external provider configurations found.
|
|
194
|
+
<br />
|
|
195
|
+
<span className="text-xs">
|
|
196
|
+
Supported tools: Claude Code (~/.claude/settings.json), OpenCode
|
|
197
|
+
(~/.config/opencode/opencode.json)
|
|
198
|
+
</span>
|
|
199
|
+
</p>
|
|
200
|
+
)}
|
|
201
|
+
|
|
202
|
+
{!scanning &&
|
|
203
|
+
providers.map((p, i) => (
|
|
204
|
+
<label
|
|
205
|
+
key={`${p.sourceTool}-${p.name}`}
|
|
206
|
+
className="flex items-start gap-3 p-3 border rounded-md cursor-pointer hover:bg-muted/50 has-[:disabled]:opacity-50 has-[:disabled]:cursor-not-allowed"
|
|
207
|
+
>
|
|
208
|
+
<input
|
|
209
|
+
type="checkbox"
|
|
210
|
+
checked={selected.has(i)}
|
|
211
|
+
disabled={p.alreadyExists || importing}
|
|
212
|
+
onChange={() => toggleProvider(i)}
|
|
213
|
+
className="mt-0.5 size-4"
|
|
214
|
+
/>
|
|
215
|
+
<div className="flex-1 min-w-0">
|
|
216
|
+
<div className="flex items-center gap-2">
|
|
217
|
+
<span className="text-sm font-medium truncate">{p.name}</span>
|
|
218
|
+
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
|
|
219
|
+
{p.format}
|
|
220
|
+
</Badge>
|
|
221
|
+
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
|
|
222
|
+
{p.sourceTool}
|
|
223
|
+
</Badge>
|
|
224
|
+
{p.alreadyExists && (
|
|
225
|
+
<span className="text-[10px] text-muted-foreground">Already added</span>
|
|
226
|
+
)}
|
|
227
|
+
</div>
|
|
228
|
+
<div className="text-xs text-muted-foreground mt-1 truncate">
|
|
229
|
+
{p.models.slice(0, 4).join(", ")}
|
|
230
|
+
{p.models.length > 4 ? ` +${p.models.length - 4} more` : ""}
|
|
231
|
+
</div>
|
|
232
|
+
<div className="text-xs text-muted-foreground mt-0.5 truncate">
|
|
233
|
+
{p.format === "anthropic" ? p.anthropicBaseUrl : p.openaiBaseUrl}
|
|
234
|
+
</div>
|
|
235
|
+
<div className="text-xs text-muted-foreground mt-0.5 font-mono">
|
|
236
|
+
{p.apiKey.length > 8
|
|
237
|
+
? `${p.apiKey.slice(0, 4)}••••${p.apiKey.slice(-4)}`
|
|
238
|
+
: "••••"}
|
|
239
|
+
</div>
|
|
240
|
+
</div>
|
|
241
|
+
</label>
|
|
242
|
+
))}
|
|
243
|
+
|
|
244
|
+
{warnings.length > 0 && (
|
|
245
|
+
<div className="text-xs text-muted-foreground space-y-1 border-t pt-2">
|
|
246
|
+
{warnings.map((w, i) => (
|
|
247
|
+
<div key={i} className="flex items-center gap-1">
|
|
248
|
+
<AlertCircle className="size-3" />
|
|
249
|
+
{w}
|
|
250
|
+
</div>
|
|
251
|
+
))}
|
|
252
|
+
</div>
|
|
253
|
+
)}
|
|
254
|
+
|
|
255
|
+
{importResult !== null && (
|
|
256
|
+
<div className="text-sm text-green-500 border-t pt-2">{importResult}</div>
|
|
257
|
+
)}
|
|
258
|
+
|
|
259
|
+
{importError !== null && (
|
|
260
|
+
<div className="text-sm text-destructive border-t pt-2">{importError}</div>
|
|
261
|
+
)}
|
|
262
|
+
</div>
|
|
263
|
+
|
|
264
|
+
<div className="flex items-center justify-between pt-3 border-t">
|
|
265
|
+
<Button variant="outline" size="sm" onClick={scan} disabled={scanning}>
|
|
266
|
+
<Loader2 className={`size-3 mr-1 ${scanning ? "animate-spin" : ""}`} />
|
|
267
|
+
Rescan
|
|
268
|
+
</Button>
|
|
269
|
+
<Button
|
|
270
|
+
size="sm"
|
|
271
|
+
onClick={importSelected}
|
|
272
|
+
disabled={!hasSelectable || selected.size === 0 || importing}
|
|
273
|
+
>
|
|
274
|
+
<Download className="size-3 mr-1" />
|
|
275
|
+
{importing ? "Importing..." : `Import Selected (${selected.size})`}
|
|
276
|
+
</Button>
|
|
277
|
+
</div>
|
|
278
|
+
</DialogContent>
|
|
279
|
+
</Dialog>
|
|
280
|
+
);
|
|
281
|
+
}
|
|
@@ -400,6 +400,7 @@ export function ProviderCard({
|
|
|
400
400
|
size="sm"
|
|
401
401
|
onClick={() => onEdit(provider)}
|
|
402
402
|
className="text-xs h-7 gap-1"
|
|
403
|
+
disabled={isTesting ?? false}
|
|
403
404
|
>
|
|
404
405
|
<Pencil className="size-3" />
|
|
405
406
|
Edit
|
|
@@ -409,6 +410,7 @@ export function ProviderCard({
|
|
|
409
410
|
size="sm"
|
|
410
411
|
onClick={() => onDelete(provider.id)}
|
|
411
412
|
className="text-xs h-7 gap-1 text-destructive hover:text-destructive"
|
|
413
|
+
disabled={isTesting ?? false}
|
|
412
414
|
>
|
|
413
415
|
<Trash2 className="size-3" />
|
|
414
416
|
Delete
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { type JSX, useState, useEffect } from "react";
|
|
1
|
+
import { type JSX, useState, useEffect, useRef } from "react";
|
|
2
2
|
import { Button } from "../ui/button";
|
|
3
|
-
import { Eye, EyeOff, Copy, Check } from "lucide-react";
|
|
3
|
+
import { Eye, EyeOff, Copy, Check, ChevronDown } from "lucide-react";
|
|
4
4
|
import type { ProviderConfig } from "../../proxy/providers";
|
|
5
5
|
import { maskApiKey } from "../../lib/mask";
|
|
6
6
|
|
|
@@ -54,7 +54,7 @@ type ProviderFormProps = {
|
|
|
54
54
|
};
|
|
55
55
|
|
|
56
56
|
export function ProviderForm({ provider, onSubmit, onCancel }: ProviderFormProps): JSX.Element {
|
|
57
|
-
const [name, setName] = useState(provider?.name ?? "
|
|
57
|
+
const [name, setName] = useState(provider?.name ?? "");
|
|
58
58
|
const [apiKey, setApiKey] = useState(provider?.apiKey ?? "");
|
|
59
59
|
const [showApiKey, setShowApiKey] = useState(false);
|
|
60
60
|
const [copied, setCopied] = useState(false);
|
|
@@ -69,6 +69,23 @@ export function ProviderForm({ provider, onSubmit, onCancel }: ProviderFormProps
|
|
|
69
69
|
const [source, setSource] = useState<"company" | "personal" | undefined>(provider?.source);
|
|
70
70
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
71
71
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
72
|
+
const [openModelDropdown, setOpenModelDropdown] = useState<number | null>(null);
|
|
73
|
+
const modelRowRefs = useRef<(HTMLDivElement | null)[]>([]);
|
|
74
|
+
|
|
75
|
+
// Close model dropdown when clicking outside
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (openModelDropdown === null) return;
|
|
78
|
+
const index = openModelDropdown;
|
|
79
|
+
function handleClick(e: MouseEvent) {
|
|
80
|
+
if (!(e.target instanceof Node)) return;
|
|
81
|
+
const ref = modelRowRefs.current[index];
|
|
82
|
+
if (ref !== null && ref !== undefined && !ref.contains(e.target)) {
|
|
83
|
+
setOpenModelDropdown(null);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
document.addEventListener("mousedown", handleClick);
|
|
87
|
+
return () => document.removeEventListener("mousedown", handleClick);
|
|
88
|
+
}, [openModelDropdown]);
|
|
72
89
|
|
|
73
90
|
// Track if URL fields have been manually edited (to avoid overriding user edits)
|
|
74
91
|
const [manualAnthropicUrlOverride, setManualAnthropicUrlOverride] = useState(false);
|
|
@@ -188,7 +205,7 @@ export function ProviderForm({ provider, onSubmit, onCancel }: ProviderFormProps
|
|
|
188
205
|
type="text"
|
|
189
206
|
value={name}
|
|
190
207
|
onChange={(e) => setName(e.target.value)}
|
|
191
|
-
placeholder="
|
|
208
|
+
placeholder="Provider Name"
|
|
192
209
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:border-ring focus-visible:outline-ring focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50"
|
|
193
210
|
/>
|
|
194
211
|
{errors.name !== undefined && <p className="text-xs text-destructive">{errors.name}</p>}
|
|
@@ -260,29 +277,62 @@ export function ProviderForm({ provider, onSubmit, onCancel }: ProviderFormProps
|
|
|
260
277
|
<label className="text-sm font-medium">
|
|
261
278
|
Models <span className="text-destructive">*</span>
|
|
262
279
|
</label>
|
|
263
|
-
{(isMiniMax || isAlibaba) && (
|
|
264
|
-
<datalist id="model-suggestions">
|
|
265
|
-
{(isMiniMax ? MINIMAX_MODELS : ALIBABA_MODELS).map((opt) => (
|
|
266
|
-
<option key={opt} value={opt} />
|
|
267
|
-
))}
|
|
268
|
-
</datalist>
|
|
269
|
-
)}
|
|
270
280
|
{models.map((m, i) => (
|
|
271
|
-
<div
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
281
|
+
<div
|
|
282
|
+
key={i}
|
|
283
|
+
ref={(el) => {
|
|
284
|
+
modelRowRefs.current[i] = el;
|
|
285
|
+
}}
|
|
286
|
+
className="flex items-center gap-2"
|
|
287
|
+
>
|
|
288
|
+
<div className="relative flex-1">
|
|
289
|
+
<input
|
|
290
|
+
type="text"
|
|
291
|
+
value={m}
|
|
292
|
+
onChange={(e) => {
|
|
293
|
+
setModels((prev) => {
|
|
294
|
+
const next = [...prev];
|
|
295
|
+
next[i] = e.target.value;
|
|
296
|
+
return next;
|
|
297
|
+
});
|
|
298
|
+
}}
|
|
299
|
+
placeholder={isMiniMax || isAlibaba ? "Type or select a model..." : "Model name"}
|
|
300
|
+
className="w-full rounded-md border border-input bg-background px-4 py-3 pr-8 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:border-ring focus-visible:outline-ring focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50"
|
|
301
|
+
/>
|
|
302
|
+
{(isMiniMax || isAlibaba) && (
|
|
303
|
+
<>
|
|
304
|
+
<button
|
|
305
|
+
type="button"
|
|
306
|
+
onClick={() => setOpenModelDropdown(openModelDropdown === i ? null : i)}
|
|
307
|
+
className="absolute right-1 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors p-1 z-10"
|
|
308
|
+
aria-label="Show model suggestions"
|
|
309
|
+
>
|
|
310
|
+
<ChevronDown className="size-4" />
|
|
311
|
+
</button>
|
|
312
|
+
{openModelDropdown === i && (
|
|
313
|
+
<div className="absolute left-0 right-0 top-full mt-1 z-50 bg-popover border border-border rounded-md shadow-md max-h-48 overflow-y-auto">
|
|
314
|
+
{(isMiniMax ? MINIMAX_MODELS : ALIBABA_MODELS).map((opt) => (
|
|
315
|
+
<button
|
|
316
|
+
key={opt}
|
|
317
|
+
type="button"
|
|
318
|
+
onClick={() => {
|
|
319
|
+
setModels((prev) => {
|
|
320
|
+
const next = [...prev];
|
|
321
|
+
next[i] = opt;
|
|
322
|
+
return next;
|
|
323
|
+
});
|
|
324
|
+
setOpenModelDropdown(null);
|
|
325
|
+
}}
|
|
326
|
+
className="w-full text-left px-3 py-2 text-sm hover:bg-muted transition-colors"
|
|
327
|
+
>
|
|
328
|
+
{opt}
|
|
329
|
+
</button>
|
|
330
|
+
))}
|
|
331
|
+
</div>
|
|
332
|
+
)}
|
|
333
|
+
</>
|
|
334
|
+
)}
|
|
335
|
+
</div>
|
|
286
336
|
{models.length > 1 && (
|
|
287
337
|
<button
|
|
288
338
|
type="button"
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { type JSX, useState, useEffect, useCallback, useRef } from "react";
|
|
1
|
+
import { type JSX, useState, useEffect, useCallback, useMemo, useRef } from "react";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { Button } from "../ui/button";
|
|
4
|
-
import { Plus, AlertCircle, Copy, Check, Download, Upload } from "lucide-react";
|
|
4
|
+
import { Plus, AlertCircle, Copy, Check, Download, Upload, Scan } from "lucide-react";
|
|
5
|
+
import { ImportWizardDialog } from "./ImportWizardDialog";
|
|
5
6
|
import { ProviderCard } from "./ProviderCard";
|
|
6
7
|
import { ProviderForm } from "./ProviderForm";
|
|
7
8
|
import { parseJsonResponse, readApiError } from "../../lib/apiClient";
|
|
@@ -85,14 +86,24 @@ export function ProvidersPanel({
|
|
|
85
86
|
const [configPath, setConfigPath] = useState<string | null>(null);
|
|
86
87
|
const [configPathCopied, setConfigPathCopied] = useState(false);
|
|
87
88
|
const [highlightedProviderId, setHighlightedProviderId] = useState<string | null>(null);
|
|
89
|
+
const [showImportWizard, setShowImportWizard] = useState(false);
|
|
88
90
|
const [sourceFilter, setSourceFilter] = useState<"all" | "personal" | "company">("all");
|
|
89
91
|
const listScrollRef = useRef<HTMLDivElement>(null);
|
|
90
92
|
const highlightTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
91
93
|
|
|
92
94
|
// Use external state if provided (from SWR), otherwise use internal state
|
|
93
95
|
const providers = externalProviders ?? [];
|
|
94
|
-
const filteredProviders =
|
|
95
|
-
|
|
96
|
+
const filteredProviders = useMemo(() => {
|
|
97
|
+
const filtered =
|
|
98
|
+
sourceFilter === "all" ? providers : providers.filter((p) => p.source === sourceFilter);
|
|
99
|
+
if (sourceFilter === "all") {
|
|
100
|
+
return [...filtered].sort((a, b) => {
|
|
101
|
+
const order: Record<string, number> = { personal: 0, company: 1 };
|
|
102
|
+
return (order[a.source ?? ""] ?? 2) - (order[b.source ?? ""] ?? 2);
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
return filtered;
|
|
106
|
+
}, [providers, sourceFilter]);
|
|
96
107
|
const testResults = externalTestResults ?? internalTestResults;
|
|
97
108
|
const testingProviders = externalTestingProviders ?? internalTestingProviders;
|
|
98
109
|
const testingTimeLeft = externalTestingTimeLeft ?? internalTestingTimeLeft;
|
|
@@ -392,7 +403,7 @@ export function ProvidersPanel({
|
|
|
392
403
|
if (showForm || editingProvider) {
|
|
393
404
|
return (
|
|
394
405
|
<div className="space-y-4">
|
|
395
|
-
<div className="flex items-center justify-between">
|
|
406
|
+
<div className="flex items-center justify-between sticky top-0 bg-background z-10 pb-2">
|
|
396
407
|
<h3 className="text-lg font-medium">
|
|
397
408
|
{editingProvider ? "Edit Provider" : "Add New Provider"}
|
|
398
409
|
</h3>
|
|
@@ -439,6 +450,15 @@ export function ProvidersPanel({
|
|
|
439
450
|
onChange={handleFileChange}
|
|
440
451
|
style={{ display: "none" }}
|
|
441
452
|
/>
|
|
453
|
+
<Button
|
|
454
|
+
variant="outline"
|
|
455
|
+
size="sm"
|
|
456
|
+
onClick={() => setShowImportWizard(true)}
|
|
457
|
+
className="gap-1"
|
|
458
|
+
>
|
|
459
|
+
<Scan className="size-3" />
|
|
460
|
+
Scan
|
|
461
|
+
</Button>
|
|
442
462
|
<Button onClick={() => setShowForm(true)} size="sm" className="gap-1">
|
|
443
463
|
<Plus className="size-4" />
|
|
444
464
|
Add Provider
|
|
@@ -524,6 +544,16 @@ export function ProvidersPanel({
|
|
|
524
544
|
</div>
|
|
525
545
|
</div>
|
|
526
546
|
)}
|
|
547
|
+
|
|
548
|
+
<ImportWizardDialog
|
|
549
|
+
open={showImportWizard}
|
|
550
|
+
onOpenChange={setShowImportWizard}
|
|
551
|
+
onImportComplete={() => {
|
|
552
|
+
if (onProvidersMutate !== undefined) {
|
|
553
|
+
void onProvidersMutate();
|
|
554
|
+
}
|
|
555
|
+
}}
|
|
556
|
+
/>
|
|
527
557
|
</div>
|
|
528
558
|
);
|
|
529
559
|
}
|
|
@@ -69,7 +69,7 @@ export function SettingsDialog(): JSX.Element {
|
|
|
69
69
|
<TabsTrigger value="proxy">Proxy</TabsTrigger>
|
|
70
70
|
</TabsList>
|
|
71
71
|
|
|
72
|
-
<div className="mt-4 overflow-y-auto flex-1">
|
|
72
|
+
<div className="mt-4 overflow-y-auto flex-1 pr-3">
|
|
73
73
|
<TabsContent value="providers">
|
|
74
74
|
<ProvidersPanel
|
|
75
75
|
externalProviders={providers}
|