feat(web): settings persistence — profile, preferences save to DB (#124) (#145)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #145.
This commit is contained in:
@@ -2,7 +2,9 @@
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import { useSession } from '@/lib/auth-client';
|
||||
import { authClient, useSession } from '@/lib/auth-client';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ModelInfo {
|
||||
id: string;
|
||||
@@ -37,8 +39,390 @@ interface ProviderTestStatus {
|
||||
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 [loading, setLoading] = useState(true);
|
||||
const [testStatuses, setTestStatuses] = useState<Record<string, ProviderTestStatus>>({});
|
||||
@@ -75,68 +459,134 @@ export default function SettingsPage(): React.ReactElement {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Derive default model: first available model across all providers
|
||||
const defaultModel: ModelInfo | undefined = providers
|
||||
.flatMap((p) => p.models)
|
||||
.find((m) => providers.find((p) => p.id === m.provider)?.available);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl space-y-8">
|
||||
<h1 className="text-2xl font-semibold">Settings</h1>
|
||||
|
||||
{/* Profile */}
|
||||
<section>
|
||||
<h2 className="mb-4 text-lg font-medium text-text-secondary">Profile</h2>
|
||||
<section 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">
|
||||
{session?.user ? (
|
||||
<div className="space-y-3">
|
||||
<Field label="Name" value={session.user.name ?? '—'} />
|
||||
<Field label="Email" value={session.user.email} />
|
||||
<Field label="User ID" value={session.user.id} />
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-text-muted">Not signed in</p>
|
||||
)}
|
||||
<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>
|
||||
</section>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
{/* Providers */}
|
||||
<section>
|
||||
<h2 className="mb-4 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>
|
||||
)}
|
||||
</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;
|
||||
@@ -351,15 +801,6 @@ function CapabilityBadge({
|
||||
return <span className={`rounded px-1.5 py-0.5 text-xs ${colorClass}`}>{label}</span>;
|
||||
}
|
||||
|
||||
function Field({ label, value }: { label: string; value: string }): React.ReactElement {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-text-muted">{label}</span>
|
||||
<span className="text-sm text-text-primary">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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`;
|
||||
|
||||
Reference in New Issue
Block a user