Initial commit — AI-powered coding tutor (Professor)
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>
This commit is contained in:
348
components/ProviderSettings.tsx
Normal file
348
components/ProviderSettings.tsx
Normal file
@@ -0,0 +1,348 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user