Next.js 16, React 19, Monaco editor, Anthropic SDK, multi-provider AI, Wandbox Python execution, iframe HTML preview, SQLite auth + session persistence. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
349 lines
14 KiB
TypeScript
349 lines
14 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect, useRef } from 'react';
|
|
import type { ProviderConfig, ProviderId } from '@/types';
|
|
import { PROVIDERS, PROVIDER_MAP } from '@/lib/providers';
|
|
|
|
type ConnectStatus = 'idle' | 'loading' | 'success' | 'error';
|
|
|
|
interface Props {
|
|
config: ProviderConfig;
|
|
onSave: (config: ProviderConfig) => void;
|
|
onClose: () => void;
|
|
}
|
|
|
|
export default function ProviderSettings({ config, onSave, onClose }: Props) {
|
|
const [provider, setProvider] = useState<ProviderId>(config.provider);
|
|
const [model, setModel] = useState(config.model);
|
|
const [apiKey, setApiKey] = useState(config.apiKey ?? '');
|
|
const [baseUrl, setBaseUrl] = useState(config.baseUrl ?? '');
|
|
|
|
const [connectStatus, setConnectStatus] = useState<ConnectStatus>('idle');
|
|
const [connectError, setConnectError] = useState('');
|
|
const [fetchedModels, setFetchedModels] = useState<string[]>([]);
|
|
const [modelFilter, setModelFilter] = useState('');
|
|
const filterRef = useRef<HTMLInputElement>(null);
|
|
|
|
const def = PROVIDER_MAP[provider];
|
|
|
|
// Reset when provider changes
|
|
useEffect(() => {
|
|
const newDef = PROVIDER_MAP[provider];
|
|
setModel(newDef.defaultModel);
|
|
setBaseUrl(newDef.hasCustomBaseUrl ? newDef.defaultBaseUrl : '');
|
|
if (!newDef.requiresApiKey) setApiKey('');
|
|
setConnectStatus('idle');
|
|
setFetchedModels([]);
|
|
setModelFilter('');
|
|
}, [provider]);
|
|
|
|
// Focus filter when models load
|
|
useEffect(() => {
|
|
if (connectStatus === 'success') {
|
|
setTimeout(() => filterRef.current?.focus(), 50);
|
|
}
|
|
}, [connectStatus]);
|
|
|
|
async function handleConnect() {
|
|
setConnectStatus('loading');
|
|
setConnectError('');
|
|
setFetchedModels([]);
|
|
|
|
try {
|
|
const res = await fetch('/api/models', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
provider,
|
|
apiKey: apiKey.trim() || undefined,
|
|
baseUrl: baseUrl.trim() || undefined,
|
|
}),
|
|
});
|
|
|
|
const data = await res.json();
|
|
|
|
if (!res.ok || data.error) {
|
|
setConnectError(data.error ?? `Error ${res.status}`);
|
|
setConnectStatus('error');
|
|
return;
|
|
}
|
|
|
|
const models: string[] = data.models ?? [];
|
|
setFetchedModels(models);
|
|
setConnectStatus('success');
|
|
// Auto-select first model if current isn't in the fetched list
|
|
if (models.length && !models.includes(model)) {
|
|
setModel(models[0]);
|
|
}
|
|
} catch (err) {
|
|
setConnectError(err instanceof Error ? err.message : 'Connection failed');
|
|
setConnectStatus('error');
|
|
}
|
|
}
|
|
|
|
function handleSave() {
|
|
const saved: ProviderConfig = {
|
|
provider,
|
|
model: model.trim() || def.defaultModel,
|
|
...(apiKey.trim() ? { apiKey: apiKey.trim() } : {}),
|
|
...(baseUrl.trim() && def.hasCustomBaseUrl ? { baseUrl: baseUrl.trim() } : {}),
|
|
};
|
|
onSave(saved);
|
|
onClose();
|
|
}
|
|
|
|
const filteredModels = fetchedModels.filter((m) =>
|
|
m.toLowerCase().includes(modelFilter.toLowerCase())
|
|
);
|
|
|
|
return (
|
|
<div
|
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm"
|
|
onClick={(e) => e.target === e.currentTarget && onClose()}
|
|
>
|
|
<div className="w-full max-w-md rounded-2xl border border-zinc-700 bg-zinc-900 shadow-2xl">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between border-b border-zinc-700 px-6 py-4">
|
|
<h2 className="text-sm font-semibold text-zinc-100">AI Provider Settings</h2>
|
|
<button
|
|
onClick={onClose}
|
|
className="rounded-lg p-1 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200 transition-colors"
|
|
aria-label="Close"
|
|
>
|
|
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
|
<path d="M18 6 6 18M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Body */}
|
|
<div className="space-y-5 px-6 py-5">
|
|
|
|
{/* Provider selector */}
|
|
<div>
|
|
<label className="mb-2 block text-xs font-medium text-zinc-400">Provider</label>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
{PROVIDERS.map((p) => (
|
|
<button
|
|
key={p.id}
|
|
onClick={() => setProvider(p.id)}
|
|
className={`rounded-lg border px-3 py-2.5 text-left text-xs transition-all ${
|
|
provider === p.id
|
|
? 'border-blue-500 bg-blue-600/20 text-blue-300'
|
|
: 'border-zinc-700 bg-zinc-800 text-zinc-400 hover:border-zinc-600 hover:text-zinc-200'
|
|
}`}
|
|
>
|
|
<span className="block font-semibold">{p.label}</span>
|
|
<span className="block text-[10px] opacity-70 mt-0.5">
|
|
{p.requiresApiKey ? 'Requires API key' : 'Local — no key needed'}
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* API Key — shown before model so user fills it first */}
|
|
{def.requiresApiKey && (
|
|
<div>
|
|
<label className="mb-2 block text-xs font-medium text-zinc-400">
|
|
API Key
|
|
{provider === 'anthropic' && (
|
|
<span className="ml-1 text-zinc-600">(leave blank to use ANTHROPIC_API_KEY env var)</span>
|
|
)}
|
|
</label>
|
|
<input
|
|
type="password"
|
|
value={apiKey}
|
|
onChange={(e) => {
|
|
setApiKey(e.target.value);
|
|
setConnectStatus('idle');
|
|
setFetchedModels([]);
|
|
}}
|
|
placeholder={provider === 'anthropic' ? 'sk-ant-… (optional)' : 'sk-or-…'}
|
|
autoComplete="off"
|
|
className="w-full rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2 text-xs text-zinc-200 placeholder-zinc-600 focus:border-blue-500 focus:outline-none"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Base URL */}
|
|
{def.hasCustomBaseUrl && (
|
|
<div>
|
|
<label className="mb-2 block text-xs font-medium text-zinc-400">Base URL</label>
|
|
<input
|
|
type="text"
|
|
value={baseUrl}
|
|
onChange={(e) => {
|
|
setBaseUrl(e.target.value);
|
|
setConnectStatus('idle');
|
|
setFetchedModels([]);
|
|
}}
|
|
placeholder={def.defaultBaseUrl}
|
|
className="w-full rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2 text-xs text-zinc-200 placeholder-zinc-600 focus:border-blue-500 focus:outline-none"
|
|
/>
|
|
<p className="mt-1 text-[11px] text-zinc-600">
|
|
{provider === 'ollama'
|
|
? 'Ollama must be running with OLLAMA_ORIGINS=* for CORS.'
|
|
: 'LM Studio server must be running on this address.'}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Model */}
|
|
<div>
|
|
<div className="mb-2 flex items-center justify-between">
|
|
<label className="text-xs font-medium text-zinc-400">Model</label>
|
|
<button
|
|
onClick={handleConnect}
|
|
disabled={connectStatus === 'loading'}
|
|
className={`flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors disabled:cursor-not-allowed ${
|
|
connectStatus === 'success'
|
|
? 'bg-green-900/40 text-green-400 hover:bg-green-900/60'
|
|
: 'bg-zinc-700 text-zinc-300 hover:bg-zinc-600 disabled:opacity-60'
|
|
}`}
|
|
>
|
|
{connectStatus === 'loading' ? (
|
|
<>
|
|
<div className="h-3 w-3 animate-spin rounded-full border border-zinc-500 border-t-zinc-200" />
|
|
Connecting…
|
|
</>
|
|
) : connectStatus === 'success' ? (
|
|
<>
|
|
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
|
<path d="M20 6 9 17l-5-5" />
|
|
</svg>
|
|
{fetchedModels.length} model{fetchedModels.length !== 1 ? 's' : ''} · Reconnect
|
|
</>
|
|
) : (
|
|
<>
|
|
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
|
<path d="M5 12h14M12 5l7 7-7 7" />
|
|
</svg>
|
|
Connect
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Error */}
|
|
{connectStatus === 'error' && (
|
|
<div className="mb-2 rounded-lg border border-red-800 bg-red-900/20 px-3 py-2 text-xs text-red-400">
|
|
{connectError}
|
|
</div>
|
|
)}
|
|
|
|
{/* Live model list */}
|
|
{connectStatus === 'success' && fetchedModels.length > 0 ? (
|
|
<div className="space-y-1.5">
|
|
{fetchedModels.length > 8 && (
|
|
<input
|
|
ref={filterRef}
|
|
type="text"
|
|
value={modelFilter}
|
|
onChange={(e) => setModelFilter(e.target.value)}
|
|
placeholder={`Filter ${fetchedModels.length} models…`}
|
|
className="w-full rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2 text-xs text-zinc-200 placeholder-zinc-600 focus:border-blue-500 focus:outline-none"
|
|
/>
|
|
)}
|
|
<div className="max-h-52 overflow-y-auto rounded-lg border border-zinc-700 bg-zinc-800">
|
|
{filteredModels.length === 0 ? (
|
|
<p className="px-3 py-3 text-xs italic text-zinc-500">No models match “{modelFilter}”</p>
|
|
) : (
|
|
filteredModels.map((m) => (
|
|
<button
|
|
key={m}
|
|
onClick={() => setModel(m)}
|
|
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-xs transition-colors ${
|
|
m === model
|
|
? 'bg-blue-600/20 text-blue-300'
|
|
: 'text-zinc-300 hover:bg-zinc-700'
|
|
}`}
|
|
>
|
|
{m === model ? (
|
|
<svg className="h-3 w-3 flex-shrink-0 text-blue-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
|
<path d="M20 6 9 17l-5-5" />
|
|
</svg>
|
|
) : (
|
|
<span className="h-3 w-3 flex-shrink-0" />
|
|
)}
|
|
<span className="font-mono">{m}</span>
|
|
</button>
|
|
))
|
|
)}
|
|
</div>
|
|
{modelFilter && filteredModels.length < fetchedModels.length && (
|
|
<p className="text-right text-[11px] text-zinc-600">
|
|
{filteredModels.length} of {fetchedModels.length} shown
|
|
</p>
|
|
)}
|
|
</div>
|
|
) : (
|
|
/* Static fallback — shown before connecting or on error */
|
|
<div className="space-y-1.5">
|
|
{def.modelSuggestions.length > 0 ? (
|
|
<>
|
|
<select
|
|
value={def.modelSuggestions.includes(model) ? model : '__custom__'}
|
|
onChange={(e) => {
|
|
if (e.target.value !== '__custom__') setModel(e.target.value);
|
|
}}
|
|
className="w-full rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2 text-xs text-zinc-200 focus:border-blue-500 focus:outline-none"
|
|
>
|
|
{def.modelSuggestions.map((m) => (
|
|
<option key={m} value={m}>{m}</option>
|
|
))}
|
|
<option value="__custom__">Custom…</option>
|
|
</select>
|
|
{!def.modelSuggestions.includes(model) && (
|
|
<input
|
|
type="text"
|
|
value={model}
|
|
onChange={(e) => setModel(e.target.value)}
|
|
placeholder={def.defaultModel}
|
|
className="w-full rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2 text-xs text-zinc-200 placeholder-zinc-600 focus:border-blue-500 focus:outline-none"
|
|
/>
|
|
)}
|
|
</>
|
|
) : (
|
|
<input
|
|
type="text"
|
|
value={model}
|
|
onChange={(e) => setModel(e.target.value)}
|
|
placeholder={def.defaultModel}
|
|
className="w-full rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2 text-xs text-zinc-200 placeholder-zinc-600 focus:border-blue-500 focus:outline-none"
|
|
/>
|
|
)}
|
|
<p className="text-[11px] text-zinc-600">
|
|
Hit Connect to load available models from {def.label}.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{connectStatus === 'success' && (
|
|
<p className="mt-1.5 text-[11px] text-zinc-500">
|
|
Selected: <span className="font-mono text-zinc-400">{model}</span>
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="flex justify-end gap-2 border-t border-zinc-700 px-6 py-4">
|
|
<button
|
|
onClick={onClose}
|
|
className="rounded-lg px-4 py-2 text-xs text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200 transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={handleSave}
|
|
className="rounded-lg bg-blue-600 px-4 py-2 text-xs font-semibold text-white hover:bg-blue-500 transition-colors"
|
|
>
|
|
Save
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|