Some checks failed
ci/woodpecker/push/ci Pipeline failed
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
829 lines
28 KiB
TypeScript
829 lines
28 KiB
TypeScript
'use client';
|
|
|
|
import { useCallback, useEffect, useState } from 'react';
|
|
import { api } from '@/lib/api';
|
|
import { authClient, useSession } from '@/lib/auth-client';
|
|
import type { SsoProviderDiscovery } from '@/lib/sso';
|
|
import { SsoProviderSection } from '@/components/settings/sso-provider-section';
|
|
|
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
|
|
interface ModelInfo {
|
|
id: string;
|
|
provider: string;
|
|
name: string;
|
|
reasoning: boolean;
|
|
contextWindow: number;
|
|
maxTokens: number;
|
|
inputTypes: ('text' | 'image')[];
|
|
cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
|
|
}
|
|
|
|
interface ProviderInfo {
|
|
id: string;
|
|
name: string;
|
|
available: boolean;
|
|
models: ModelInfo[];
|
|
}
|
|
|
|
interface TestConnectionResult {
|
|
providerId: string;
|
|
reachable: boolean;
|
|
latencyMs?: number;
|
|
error?: string;
|
|
discoveredModels?: string[];
|
|
}
|
|
|
|
type TestState = 'idle' | 'testing' | 'success' | 'error';
|
|
|
|
interface ProviderTestStatus {
|
|
state: TestState;
|
|
result?: TestConnectionResult;
|
|
}
|
|
|
|
interface Preference {
|
|
key: string;
|
|
value: unknown;
|
|
category: string;
|
|
}
|
|
|
|
type Theme = 'light' | 'dark' | 'system';
|
|
type SaveState = 'idle' | 'saving' | 'saved' | 'error';
|
|
type Tab = 'profile' | 'appearance' | 'notifications' | 'providers';
|
|
|
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
function prefValue<T>(prefs: Preference[], key: string, fallback: T): T {
|
|
const p = prefs.find((x) => x.key === key);
|
|
if (p === undefined) return fallback;
|
|
return p.value as T;
|
|
}
|
|
|
|
// ─── Main Page ────────────────────────────────────────────────────────────────
|
|
|
|
export default function SettingsPage(): React.ReactElement {
|
|
const { data: session } = useSession();
|
|
const [activeTab, setActiveTab] = useState<Tab>('profile');
|
|
|
|
const tabs: { id: Tab; label: string }[] = [
|
|
{ id: 'profile', label: 'Profile' },
|
|
{ id: 'appearance', label: 'Appearance' },
|
|
{ id: 'notifications', label: 'Notifications' },
|
|
{ id: 'providers', label: 'Providers' },
|
|
];
|
|
|
|
return (
|
|
<div className="mx-auto max-w-3xl space-y-6">
|
|
<h1 className="text-2xl font-semibold">Settings</h1>
|
|
|
|
{/* Tab bar */}
|
|
<div className="flex gap-1 border-b border-surface-border">
|
|
{tabs.map((tab) => (
|
|
<button
|
|
key={tab.id}
|
|
type="button"
|
|
onClick={() => setActiveTab(tab.id)}
|
|
className={`px-4 py-2 text-sm font-medium transition-colors ${
|
|
activeTab === tab.id
|
|
? 'border-b-2 border-accent text-accent'
|
|
: 'text-text-secondary hover:text-text-primary'
|
|
}`}
|
|
>
|
|
{tab.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{activeTab === 'profile' && <ProfileTab session={session} />}
|
|
{activeTab === 'appearance' && <AppearanceTab />}
|
|
{activeTab === 'notifications' && <NotificationsTab />}
|
|
{activeTab === 'providers' && <ProvidersTab />}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Profile Tab ──────────────────────────────────────────────────────────────
|
|
|
|
function ProfileTab({
|
|
session,
|
|
}: {
|
|
session: { user: { id: string; name: string; email: string; image?: string | null } } | null;
|
|
}): React.ReactElement {
|
|
const [name, setName] = useState(session?.user.name ?? '');
|
|
const [image, setImage] = useState(session?.user.image ?? '');
|
|
const [saveState, setSaveState] = useState<SaveState>('idle');
|
|
const [errorMsg, setErrorMsg] = useState('');
|
|
|
|
// Sync from session when it loads
|
|
useEffect(() => {
|
|
if (session?.user) {
|
|
setName(session.user.name ?? '');
|
|
setImage(session.user.image ?? '');
|
|
}
|
|
}, [session]);
|
|
|
|
const handleSave = async (): Promise<void> => {
|
|
setSaveState('saving');
|
|
setErrorMsg('');
|
|
try {
|
|
const result = await authClient.updateUser({ name, image: image || null });
|
|
if (result.error) {
|
|
setErrorMsg(result.error.message ?? 'Failed to update profile');
|
|
setSaveState('error');
|
|
return;
|
|
}
|
|
setSaveState('saved');
|
|
setTimeout(() => setSaveState('idle'), 2000);
|
|
} catch (err: unknown) {
|
|
const message = err instanceof Error ? err.message : 'Failed to update profile';
|
|
setErrorMsg(message);
|
|
setSaveState('error');
|
|
}
|
|
};
|
|
|
|
return (
|
|
<section className="space-y-4">
|
|
<h2 className="text-lg font-medium text-text-secondary">Profile</h2>
|
|
<div className="rounded-lg border border-surface-border bg-surface-card p-6 space-y-4">
|
|
<FormField label="Display Name" id="profile-name">
|
|
<input
|
|
id="profile-name"
|
|
type="text"
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
placeholder="Your name"
|
|
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent"
|
|
/>
|
|
</FormField>
|
|
|
|
<FormField label="Email" id="profile-email">
|
|
<input
|
|
id="profile-email"
|
|
type="email"
|
|
value={session?.user.email ?? ''}
|
|
disabled
|
|
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-muted opacity-60 cursor-not-allowed"
|
|
/>
|
|
<p className="mt-1 text-xs text-text-muted">Email cannot be changed here.</p>
|
|
</FormField>
|
|
|
|
<FormField label="Avatar URL" id="profile-image">
|
|
<input
|
|
id="profile-image"
|
|
type="url"
|
|
value={image}
|
|
onChange={(e) => setImage(e.target.value)}
|
|
placeholder="https://example.com/avatar.png"
|
|
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent"
|
|
/>
|
|
</FormField>
|
|
|
|
<div className="flex items-center gap-3 pt-2">
|
|
<SaveButton state={saveState} onClick={handleSave} />
|
|
{saveState === 'error' && errorMsg && <p className="text-sm text-error">{errorMsg}</p>}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
// ─── Appearance Tab ───────────────────────────────────────────────────────────
|
|
|
|
function AppearanceTab(): React.ReactElement {
|
|
const [loading, setLoading] = useState(true);
|
|
const [theme, setTheme] = useState<Theme>('system');
|
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
|
const [defaultModel, setDefaultModel] = useState('');
|
|
const [saveState, setSaveState] = useState<SaveState>('idle');
|
|
const [errorMsg, setErrorMsg] = useState('');
|
|
|
|
useEffect(() => {
|
|
api<Preference[]>('/api/memory/preferences?category=appearance')
|
|
.catch(() => [] as Preference[])
|
|
.then((p) => {
|
|
setTheme(prefValue<Theme>(p, 'ui.theme', 'system'));
|
|
setSidebarCollapsed(prefValue<boolean>(p, 'ui.sidebar_collapsed', false));
|
|
setDefaultModel(prefValue<string>(p, 'ui.default_model', ''));
|
|
})
|
|
.finally(() => setLoading(false));
|
|
}, []);
|
|
|
|
const handleSave = async (): Promise<void> => {
|
|
setSaveState('saving');
|
|
setErrorMsg('');
|
|
try {
|
|
await Promise.all([
|
|
api('/api/memory/preferences', {
|
|
method: 'POST',
|
|
body: { key: 'ui.theme', value: theme, category: 'appearance', source: 'user' },
|
|
}),
|
|
api('/api/memory/preferences', {
|
|
method: 'POST',
|
|
body: {
|
|
key: 'ui.sidebar_collapsed',
|
|
value: sidebarCollapsed,
|
|
category: 'appearance',
|
|
source: 'user',
|
|
},
|
|
}),
|
|
...(defaultModel
|
|
? [
|
|
api('/api/memory/preferences', {
|
|
method: 'POST',
|
|
body: {
|
|
key: 'ui.default_model',
|
|
value: defaultModel,
|
|
category: 'appearance',
|
|
source: 'user',
|
|
},
|
|
}),
|
|
]
|
|
: []),
|
|
]);
|
|
setSaveState('saved');
|
|
setTimeout(() => setSaveState('idle'), 2000);
|
|
} catch (err: unknown) {
|
|
const message = err instanceof Error ? err.message : 'Failed to save preferences';
|
|
setErrorMsg(message);
|
|
setSaveState('error');
|
|
}
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<section>
|
|
<h2 className="mb-4 text-lg font-medium text-text-secondary">Appearance</h2>
|
|
<p className="text-sm text-text-muted">Loading preferences...</p>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<section className="space-y-4">
|
|
<h2 className="text-lg font-medium text-text-secondary">Appearance</h2>
|
|
<div className="rounded-lg border border-surface-border bg-surface-card p-6 space-y-6">
|
|
{/* Theme */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-text-primary mb-2">Theme</label>
|
|
<div className="flex gap-3">
|
|
{(['system', 'light', 'dark'] as Theme[]).map((t) => (
|
|
<button
|
|
key={t}
|
|
type="button"
|
|
onClick={() => setTheme(t)}
|
|
className={`rounded-lg border px-4 py-2 text-sm capitalize transition-colors ${
|
|
theme === t
|
|
? 'border-accent bg-accent/10 text-accent'
|
|
: 'border-surface-border bg-surface-elevated text-text-secondary hover:border-accent/50'
|
|
}`}
|
|
>
|
|
{t}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Sidebar collapsed default */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm font-medium text-text-primary">Collapse sidebar by default</p>
|
|
<p className="text-xs text-text-muted">Start with sidebar collapsed on page load</p>
|
|
</div>
|
|
<Toggle checked={sidebarCollapsed} onChange={setSidebarCollapsed} />
|
|
</div>
|
|
|
|
{/* Default model */}
|
|
<FormField label="Default Model" id="default-model">
|
|
<input
|
|
id="default-model"
|
|
type="text"
|
|
value={defaultModel}
|
|
onChange={(e) => setDefaultModel(e.target.value)}
|
|
placeholder="e.g. ollama/llama3.2"
|
|
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent"
|
|
/>
|
|
<p className="mt-1 text-xs text-text-muted">
|
|
Model ID to pre-select for new conversations.
|
|
</p>
|
|
</FormField>
|
|
|
|
<div className="flex items-center gap-3 pt-2">
|
|
<SaveButton state={saveState} onClick={handleSave} />
|
|
{saveState === 'error' && errorMsg && <p className="text-sm text-error">{errorMsg}</p>}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
// ─── Notifications Tab ────────────────────────────────────────────────────────
|
|
|
|
function NotificationsTab(): React.ReactElement {
|
|
const [loading, setLoading] = useState(true);
|
|
const [emailAgentComplete, setEmailAgentComplete] = useState(false);
|
|
const [emailMentions, setEmailMentions] = useState(true);
|
|
const [emailDigest, setEmailDigest] = useState(false);
|
|
const [saveState, setSaveState] = useState<SaveState>('idle');
|
|
const [errorMsg, setErrorMsg] = useState('');
|
|
|
|
useEffect(() => {
|
|
api<Preference[]>('/api/memory/preferences?category=communication')
|
|
.catch(() => [] as Preference[])
|
|
.then((p) => {
|
|
setEmailAgentComplete(prefValue<boolean>(p, 'notify.email_agent_complete', false));
|
|
setEmailMentions(prefValue<boolean>(p, 'notify.email_mentions', true));
|
|
setEmailDigest(prefValue<boolean>(p, 'notify.email_digest', false));
|
|
})
|
|
.finally(() => setLoading(false));
|
|
}, []);
|
|
|
|
const handleSave = async (): Promise<void> => {
|
|
setSaveState('saving');
|
|
setErrorMsg('');
|
|
try {
|
|
await Promise.all([
|
|
api('/api/memory/preferences', {
|
|
method: 'POST',
|
|
body: {
|
|
key: 'notify.email_agent_complete',
|
|
value: emailAgentComplete,
|
|
category: 'communication',
|
|
source: 'user',
|
|
},
|
|
}),
|
|
api('/api/memory/preferences', {
|
|
method: 'POST',
|
|
body: {
|
|
key: 'notify.email_mentions',
|
|
value: emailMentions,
|
|
category: 'communication',
|
|
source: 'user',
|
|
},
|
|
}),
|
|
api('/api/memory/preferences', {
|
|
method: 'POST',
|
|
body: {
|
|
key: 'notify.email_digest',
|
|
value: emailDigest,
|
|
category: 'communication',
|
|
source: 'user',
|
|
},
|
|
}),
|
|
]);
|
|
setSaveState('saved');
|
|
setTimeout(() => setSaveState('idle'), 2000);
|
|
} catch (err: unknown) {
|
|
const message = err instanceof Error ? err.message : 'Failed to save preferences';
|
|
setErrorMsg(message);
|
|
setSaveState('error');
|
|
}
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<section>
|
|
<h2 className="mb-4 text-lg font-medium text-text-secondary">Notifications</h2>
|
|
<p className="text-sm text-text-muted">Loading preferences...</p>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<section className="space-y-4">
|
|
<h2 className="text-lg font-medium text-text-secondary">Notifications</h2>
|
|
<div className="rounded-lg border border-surface-border bg-surface-card p-6 space-y-6">
|
|
<p className="text-xs text-text-muted">Configure when you receive email notifications.</p>
|
|
|
|
<NotifyRow
|
|
label="Agent task completed"
|
|
description="Email when an agent finishes a task"
|
|
checked={emailAgentComplete}
|
|
onChange={setEmailAgentComplete}
|
|
/>
|
|
<NotifyRow
|
|
label="Mentions"
|
|
description="Email when you are mentioned in a conversation"
|
|
checked={emailMentions}
|
|
onChange={setEmailMentions}
|
|
/>
|
|
<NotifyRow
|
|
label="Weekly digest"
|
|
description="Weekly summary of activity"
|
|
checked={emailDigest}
|
|
onChange={setEmailDigest}
|
|
/>
|
|
|
|
<div className="flex items-center gap-3 pt-2">
|
|
<SaveButton state={saveState} onClick={handleSave} />
|
|
{saveState === 'error' && errorMsg && <p className="text-sm text-error">{errorMsg}</p>}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
// ─── Providers Tab ────────────────────────────────────────────────────────────
|
|
|
|
function ProvidersTab(): React.ReactElement {
|
|
const [providers, setProviders] = useState<ProviderInfo[]>([]);
|
|
const [ssoProviders, setSsoProviders] = useState<SsoProviderDiscovery[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [ssoLoading, setSsoLoading] = useState(true);
|
|
const [testStatuses, setTestStatuses] = useState<Record<string, ProviderTestStatus>>({});
|
|
|
|
useEffect(() => {
|
|
api<ProviderInfo[]>('/api/providers')
|
|
.catch(() => [] as ProviderInfo[])
|
|
.then((p) => setProviders(p))
|
|
.finally(() => setLoading(false));
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
api<SsoProviderDiscovery[]>('/api/sso/providers')
|
|
.catch(() => [] as SsoProviderDiscovery[])
|
|
.then((providers) => setSsoProviders(providers))
|
|
.finally(() => setSsoLoading(false));
|
|
}, []);
|
|
|
|
const testConnection = useCallback(async (providerId: string): Promise<void> => {
|
|
setTestStatuses((prev) => ({
|
|
...prev,
|
|
[providerId]: { state: 'testing' },
|
|
}));
|
|
try {
|
|
const result = await api<TestConnectionResult>('/api/providers/test', {
|
|
method: 'POST',
|
|
body: { providerId },
|
|
});
|
|
setTestStatuses((prev) => ({
|
|
...prev,
|
|
[providerId]: { state: result.reachable ? 'success' : 'error', result },
|
|
}));
|
|
} catch {
|
|
setTestStatuses((prev) => ({
|
|
...prev,
|
|
[providerId]: {
|
|
state: 'error',
|
|
result: { providerId, reachable: false, error: 'Request failed' },
|
|
},
|
|
}));
|
|
}
|
|
}, []);
|
|
|
|
const defaultModel: ModelInfo | undefined = providers
|
|
.flatMap((p) => p.models)
|
|
.find((m) => providers.find((p) => p.id === m.provider)?.available);
|
|
|
|
return (
|
|
<section className="space-y-6">
|
|
<div className="space-y-4">
|
|
<h2 className="text-lg font-medium text-text-secondary">SSO Providers</h2>
|
|
<SsoProviderSection providers={ssoProviders} loading={ssoLoading} />
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
<h2 className="text-lg font-medium text-text-secondary">LLM Providers</h2>
|
|
{loading ? (
|
|
<p className="text-sm text-text-muted">Loading providers...</p>
|
|
) : providers.length === 0 ? (
|
|
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
|
|
<p className="text-sm text-text-muted">
|
|
No providers configured. Set{' '}
|
|
<code className="rounded bg-surface-elevated px-1 py-0.5 text-xs">
|
|
OLLAMA_BASE_URL
|
|
</code>{' '}
|
|
or{' '}
|
|
<code className="rounded bg-surface-elevated px-1 py-0.5 text-xs">
|
|
MOSAIC_CUSTOM_PROVIDERS
|
|
</code>{' '}
|
|
to add providers.
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-4">
|
|
{providers.map((provider) => (
|
|
<ProviderCard
|
|
key={provider.id}
|
|
provider={provider}
|
|
defaultModel={defaultModel}
|
|
testStatus={testStatuses[provider.id] ?? { state: 'idle' }}
|
|
onTest={() => void testConnection(provider.id)}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
// ─── Shared UI Components ─────────────────────────────────────────────────────
|
|
|
|
function FormField({
|
|
label,
|
|
id,
|
|
children,
|
|
}: {
|
|
label: string;
|
|
id: string;
|
|
children: React.ReactNode;
|
|
}): React.ReactElement {
|
|
return (
|
|
<div>
|
|
<label htmlFor={id} className="block text-sm font-medium text-text-primary">
|
|
{label}
|
|
</label>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Toggle({
|
|
checked,
|
|
onChange,
|
|
}: {
|
|
checked: boolean;
|
|
onChange: (v: boolean) => void;
|
|
}): React.ReactElement {
|
|
return (
|
|
<button
|
|
type="button"
|
|
role="switch"
|
|
aria-checked={checked}
|
|
onClick={() => onChange(!checked)}
|
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2 focus:ring-offset-surface-card ${
|
|
checked ? 'bg-accent' : 'bg-surface-border'
|
|
}`}
|
|
>
|
|
<span
|
|
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
|
checked ? 'translate-x-6' : 'translate-x-1'
|
|
}`}
|
|
/>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function NotifyRow({
|
|
label,
|
|
description,
|
|
checked,
|
|
onChange,
|
|
}: {
|
|
label: string;
|
|
description: string;
|
|
checked: boolean;
|
|
onChange: (v: boolean) => void;
|
|
}): React.ReactElement {
|
|
return (
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm font-medium text-text-primary">{label}</p>
|
|
<p className="text-xs text-text-muted">{description}</p>
|
|
</div>
|
|
<Toggle checked={checked} onChange={onChange} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SaveButton({
|
|
state,
|
|
onClick,
|
|
}: {
|
|
state: SaveState;
|
|
onClick: () => void;
|
|
}): React.ReactElement {
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={onClick}
|
|
disabled={state === 'saving'}
|
|
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent/90 disabled:cursor-not-allowed disabled:opacity-50"
|
|
>
|
|
{state === 'saving' ? 'Saving...' : state === 'saved' ? 'Saved!' : 'Save changes'}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
// ─── Provider Card (from original page) ──────────────────────────────────────
|
|
|
|
interface ProviderCardProps {
|
|
provider: ProviderInfo;
|
|
defaultModel: ModelInfo | undefined;
|
|
testStatus: ProviderTestStatus;
|
|
onTest: () => void;
|
|
}
|
|
|
|
function ProviderCard({
|
|
provider,
|
|
defaultModel,
|
|
testStatus,
|
|
onTest,
|
|
}: ProviderCardProps): React.ReactElement {
|
|
const [expanded, setExpanded] = useState(false);
|
|
|
|
return (
|
|
<div className="rounded-lg border border-surface-border bg-surface-card">
|
|
{/* Header row */}
|
|
<div className="flex items-center justify-between px-4 py-3">
|
|
<div className="flex items-center gap-3">
|
|
<ProviderAvatar id={provider.id} />
|
|
<div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm font-medium text-text-primary">{provider.name}</span>
|
|
<ProviderStatusBadge available={provider.available} />
|
|
</div>
|
|
<p className="text-xs text-text-muted">
|
|
{provider.models.length} model{provider.models.length !== 1 ? 's' : ''}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<TestConnectionButton status={testStatus} onTest={onTest} />
|
|
<button
|
|
type="button"
|
|
onClick={() => setExpanded((v) => !v)}
|
|
className="rounded px-2 py-1 text-xs text-text-muted transition-colors hover:bg-surface-elevated hover:text-text-primary"
|
|
aria-expanded={expanded}
|
|
aria-label={expanded ? 'Collapse models' : 'Expand models'}
|
|
>
|
|
{expanded ? '▲ Hide' : '▼ Models'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Test result banner */}
|
|
{testStatus.state !== 'idle' && testStatus.state !== 'testing' && testStatus.result && (
|
|
<TestResultBanner result={testStatus.result} />
|
|
)}
|
|
|
|
{/* Model list */}
|
|
{expanded && (
|
|
<div className="border-t border-surface-border">
|
|
<table className="w-full">
|
|
<thead>
|
|
<tr className="bg-surface-elevated text-left text-xs text-text-muted">
|
|
<th className="px-4 py-2 font-medium">Model</th>
|
|
<th className="hidden px-4 py-2 font-medium md:table-cell">Capabilities</th>
|
|
<th className="hidden px-4 py-2 font-medium md:table-cell">Context</th>
|
|
<th className="hidden px-4 py-2 font-medium md:table-cell">Cost (in/out)</th>
|
|
<th className="px-4 py-2 font-medium">Default</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{provider.models.map((model) => (
|
|
<ModelRow
|
|
key={model.id}
|
|
model={model}
|
|
isDefault={
|
|
defaultModel?.id === model.id && defaultModel?.provider === model.provider
|
|
}
|
|
/>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface ModelRowProps {
|
|
model: ModelInfo;
|
|
isDefault: boolean;
|
|
}
|
|
|
|
function ModelRow({ model, isDefault }: ModelRowProps): React.ReactElement {
|
|
return (
|
|
<tr className="border-t border-surface-border">
|
|
<td className="px-4 py-2">
|
|
<span className="text-sm text-text-primary">{model.name}</span>
|
|
</td>
|
|
<td className="hidden px-4 py-2 md:table-cell">
|
|
<div className="flex flex-wrap gap-1">
|
|
<CapabilityBadge label="chat" />
|
|
{model.reasoning && <CapabilityBadge label="reasoning" color="purple" />}
|
|
{model.inputTypes.includes('image') && <CapabilityBadge label="vision" color="blue" />}
|
|
</div>
|
|
</td>
|
|
<td className="hidden px-4 py-2 text-xs text-text-muted md:table-cell">
|
|
{formatContext(model.contextWindow)}
|
|
</td>
|
|
<td className="hidden px-4 py-2 text-xs text-text-muted md:table-cell">
|
|
{model.cost.input === 0 && model.cost.output === 0
|
|
? 'free'
|
|
: `$${model.cost.input} / $${model.cost.output}`}
|
|
</td>
|
|
<td className="px-4 py-2 text-center">
|
|
{isDefault && (
|
|
<span
|
|
className="inline-block rounded-full bg-accent/20 px-2 py-0.5 text-xs font-medium text-accent"
|
|
title="Default model used for new sessions"
|
|
>
|
|
default
|
|
</span>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
);
|
|
}
|
|
|
|
function ProviderAvatar({ id }: { id: string }): React.ReactElement {
|
|
const letter = id.charAt(0).toUpperCase();
|
|
return (
|
|
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-surface-elevated text-sm font-semibold text-text-secondary">
|
|
{letter}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ProviderStatusBadge({ available }: { available: boolean }): React.ReactElement {
|
|
return (
|
|
<span
|
|
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
|
|
available ? 'bg-success/20 text-success' : 'bg-surface-elevated text-text-muted'
|
|
}`}
|
|
>
|
|
{available ? 'Active' : 'Inactive'}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
interface TestConnectionButtonProps {
|
|
status: ProviderTestStatus;
|
|
onTest: () => void;
|
|
}
|
|
|
|
function TestConnectionButton({ status, onTest }: TestConnectionButtonProps): React.ReactElement {
|
|
const isTesting = status.state === 'testing';
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={onTest}
|
|
disabled={isTesting}
|
|
className="rounded px-2 py-1 text-xs transition-colors hover:bg-surface-elevated disabled:cursor-not-allowed disabled:opacity-50"
|
|
title="Test connection"
|
|
>
|
|
{isTesting ? (
|
|
<span className="text-text-muted">Testing…</span>
|
|
) : status.state === 'success' ? (
|
|
<span className="text-success">✓ Reachable</span>
|
|
) : status.state === 'error' ? (
|
|
<span className="text-error">✗ Unreachable</span>
|
|
) : (
|
|
<span className="text-text-muted">Test</span>
|
|
)}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function TestResultBanner({ result }: { result: TestConnectionResult }): React.ReactElement {
|
|
return (
|
|
<div
|
|
className={`px-4 py-2 text-xs ${
|
|
result.reachable ? 'bg-success/10 text-success' : 'bg-error/10 text-error'
|
|
}`}
|
|
>
|
|
{result.reachable ? (
|
|
<>
|
|
Connected
|
|
{result.latencyMs !== undefined && (
|
|
<span className="ml-1 opacity-70">({result.latencyMs}ms)</span>
|
|
)}
|
|
{result.discoveredModels && result.discoveredModels.length > 0 && (
|
|
<span className="ml-2 opacity-70">
|
|
— {result.discoveredModels.length} model
|
|
{result.discoveredModels.length !== 1 ? 's' : ''} discovered
|
|
</span>
|
|
)}
|
|
</>
|
|
) : (
|
|
<>Connection failed{result.error ? `: ${result.error}` : ''}</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CapabilityBadge({
|
|
label,
|
|
color = 'default',
|
|
}: {
|
|
label: string;
|
|
color?: 'default' | 'purple' | 'blue';
|
|
}): React.ReactElement {
|
|
const colorClass =
|
|
color === 'purple'
|
|
? 'bg-purple-500/20 text-purple-400'
|
|
: color === 'blue'
|
|
? 'bg-blue-500/20 text-blue-400'
|
|
: 'bg-surface-elevated text-text-muted';
|
|
return <span className={`rounded px-1.5 py-0.5 text-xs ${colorClass}`}>{label}</span>;
|
|
}
|
|
|
|
function formatContext(tokens: number): string {
|
|
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`;
|
|
if (tokens >= 1_000) return `${Math.round(tokens / 1_000)}k`;
|
|
return String(tokens);
|
|
}
|