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:
Aodhan Collins
2026-03-04 21:48:34 +00:00
commit f644937604
56 changed files with 14012 additions and 0 deletions

View 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 &ldquo;{modelFilter}&rdquo;</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>
);
}