This commit is contained in:
2026-02-16 19:34:35 +00:00
commit 9ab0940ca4
784 changed files with 41710 additions and 0 deletions
@@ -0,0 +1,359 @@
import { useState, useEffect, useMemo } from 'react';
import { toast } from 'sonner';
import { Terminal, Eye, Trash2 } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Slider } from '@/components/ui/slider';
import { Switch } from '@/components/ui/switch';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '@/components/ui/accordion';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Card } from '@/components/Card';
import { DashboardLayout } from '../Layout';
import { useSettings } from '@/state/useSettings';
import { useUserState } from '@/state/useUserState';
import {
useClaudeModels,
useOpenCodeModels,
useVisibleClaudeModels,
useVisibleOpenCodeModels,
} from '@/state/useModels';
import type { UserSettings } from '@/state/types/user-settings';
export const AISettings = () => {
const [mainTab, setMainTab] = useUserState('ai-settings-tab', 'chat-defaults');
return (
<DashboardLayout>
<div className="flex justify-center h-full px-4 py-8 overflow-y-auto">
<Card className="w-full max-w-2xl h-fit p-6">
<Tabs value={mainTab} onValueChange={setMainTab}>
<TabsList className="w-full mb-6">
<TabsTrigger value="chat-defaults" className="flex-1 gap-2 cursor-pointer">
<Terminal className="h-4 w-4" />
Chat Defaults
</TabsTrigger>
<TabsTrigger value="model-visibility" className="flex-1 gap-2 cursor-pointer">
<Eye className="h-4 w-4" />
Model Visibility
</TabsTrigger>
</TabsList>
<TabsContent value="chat-defaults">
<ChatDefaultsSection />
</TabsContent>
<TabsContent value="model-visibility">
<ModelVisibilitySection />
</TabsContent>
</Tabs>
</Card>
</div>
</DashboardLayout>
);
};
const ChatDefaultsSection = () => {
const { settings, saveSettings } = useSettings();
const claudeModels = useVisibleClaudeModels();
const openCodeModels = useVisibleOpenCodeModels();
const [isSaving, setIsSaving] = useState(false);
const [model, setModel] = useState<string | null>(settings.chat.defaultModel);
const [systemPrompt, setSystemPrompt] = useState(settings.chat.systemPrompt);
const [temperature, setTemperature] = useState(settings.chat.temperature);
const [defaultPwd, setDefaultPwd] = useState(settings.chat.defaultPwd);
useEffect(() => {
setModel(settings.chat.defaultModel);
setSystemPrompt(settings.chat.systemPrompt);
setTemperature(settings.chat.temperature);
setDefaultPwd(settings.chat.defaultPwd);
}, [settings]);
const allModels = useMemo(
() => [
...claudeModels.map((m) => ({ ...m, provider: 'Claude' })),
...openCodeModels.map((m) => ({ ...m, provider: m.provider ?? 'OpenCode' })),
],
[claudeModels, openCodeModels],
);
const handleSave = async () => {
if (isSaving) return;
setIsSaving(true);
try {
const isOpenCode = openCodeModels.some((m) => m.id === model);
const defaultProvider = isOpenCode ? ('opencode' as const) : ('claude' as const);
const updated: UserSettings = {
...settings,
chat: { defaultProvider, defaultModel: model, systemPrompt, temperature, defaultPwd },
};
await saveSettings(updated);
toast.success('Chat defaults saved');
} catch {
toast.error('Failed to save settings');
} finally {
setIsSaving(false);
}
};
return (
<div className="grid gap-4">
<Label className="grid gap-2">
<span className="text-duck-dark/70">Model</span>
<Select value={model ?? ''} onValueChange={(v) => setModel(v || null)}>
<SelectTrigger className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark">
<SelectValue placeholder="Default" />
</SelectTrigger>
<SelectContent className="z-[600]">
{allModels.map((m) => (
<SelectItem key={`${m.provider}:${m.id}`} value={m.id}>
<span className="font-bold">{m.name}</span>
<span className="text-duck-dark/50 ml-1">({m.provider})</span>
</SelectItem>
))}
</SelectContent>
</Select>
</Label>
<Label className="grid gap-2">
<span className="text-duck-dark/70">System Prompt</span>
<Textarea
className="bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40 min-h-[100px]"
placeholder="Custom instructions for the AI..."
value={systemPrompt}
onChange={(ev) => setSystemPrompt(ev.target.value)}
/>
</Label>
<Label className="grid gap-2">
<div className="flex items-center justify-between">
<span className="text-duck-dark/70">Temperature</span>
<span className="text-sm text-duck-dark/50">{temperature.toFixed(1)}</span>
</div>
<Slider min={0} max={2} step={0.1} value={[temperature]} onValueChange={([v]) => setTemperature(v ?? 1)} />
</Label>
<Label className="grid gap-2">
<span className="text-duck-dark/70">Default Working Directory</span>
<Input
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
value={defaultPwd}
onChange={(ev) => setDefaultPwd(ev.target.value)}
placeholder="~"
/>
</Label>
<Button
type="button"
onClick={handleSave}
disabled={isSaving}
className="w-full h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold transition-all duration-200 hover:scale-105 cursor-pointer disabled:opacity-50 disabled:hover:scale-100"
>
{isSaving ? 'Saving...' : 'Save'}
</Button>
</div>
);
};
const ModelVisibilitySection = () => {
const { settings, saveSettings } = useSettings();
const claudeModels = useClaudeModels();
const openCodeModels = useOpenCodeModels();
const [subTab, setSubTab] = useUserState('ai-settings-visibility-tab', 'opencode');
const enabledModels = settings.ai?.enabledModels ?? [];
const enabledProviders = settings.ai?.enabledProviders ?? [];
const toggleModel = async (key: string) => {
const isEnabled = enabledModels.includes(key);
const newEnabled = isEnabled ? enabledModels.filter((id) => id !== key) : [...enabledModels, key];
await saveSettings({ ...settings, ai: { ...settings.ai, enabledModels: newEnabled } });
};
const ocGroups = useMemo(() => {
const groups: Record<string, { id: string; name: string }[]> = {};
for (const m of openCodeModels) {
const provider = m.provider ?? 'Other';
if (!groups[provider]) groups[provider] = [];
groups[provider].push({ id: m.id, name: m.name });
}
return Object.entries(groups)
.sort(([a], [b]) => a.localeCompare(b))
.map(([provider, models]) => ({ provider, models: models.sort((a, b) => a.name.localeCompare(b.name)) }));
}, [openCodeModels]);
const [addingProvider, setAddingProvider] = useState(false);
const [selectedNewProvider, setSelectedNewProvider] = useState<string>('');
const disabledProviders = ocGroups.filter((g) => !enabledProviders.includes(g.provider));
const handleEnableProvider = async () => {
if (!selectedNewProvider) return;
await saveSettings({
...settings,
ai: { ...settings.ai, enabledProviders: [...enabledProviders, selectedNewProvider] },
});
setAddingProvider(false);
setSelectedNewProvider('');
};
const handleRemoveProvider = async (provider: string) => {
await saveSettings({
...settings,
ai: { ...settings.ai, enabledProviders: enabledProviders.filter((p) => p !== provider) },
});
};
return (
<Tabs value={subTab} onValueChange={setSubTab}>
<TabsList className="w-full mb-4">
<TabsTrigger value="opencode" className="flex-1 cursor-pointer">
OpenCode
</TabsTrigger>
<TabsTrigger value="claude" className="flex-1 cursor-pointer">
Claude
</TabsTrigger>
</TabsList>
<TabsContent value="claude">
<div className="grid gap-1">
{claudeModels.map((m) => (
<label
key={m.id}
className="flex items-center justify-between py-2 px-3 rounded-md hover:bg-duck-dark/5 cursor-pointer"
>
<span className="text-sm text-duck-dark">{m.name}</span>
<Switch checked={enabledModels.includes(m.id)} onCheckedChange={() => toggleModel(m.id)} />
</label>
))}
</div>
</TabsContent>
<TabsContent value="opencode">
<div className="flex justify-end items-center gap-2 mb-3">
{addingProvider ? (
<>
<Select value={selectedNewProvider} onValueChange={setSelectedNewProvider}>
<SelectTrigger className="h-9 flex-1 bg-white/60 border-duck-dark/20 text-duck-dark text-sm">
<SelectValue placeholder="Select provider..." />
</SelectTrigger>
<SelectContent className="z-[600]">
{disabledProviders.map((g) => (
<SelectItem key={g.provider} value={g.provider}>
{g.provider}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
size="sm"
disabled={!selectedNewProvider}
onClick={handleEnableProvider}
className="cursor-pointer bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold disabled:opacity-50"
>
Enable
</Button>
</>
) : (
<Button
type="button"
variant="outline"
size="sm"
className="cursor-pointer"
onClick={() => setAddingProvider(true)}
disabled={disabledProviders.length === 0}
>
Add Provider
</Button>
)}
</div>
{ocGroups.length === 0 ? (
<p className="text-sm text-duck-dark/40">No OpenCode models available.</p>
) : (
<OpenCodeProviderList
groups={ocGroups}
enabledProviders={enabledProviders}
enabledModels={enabledModels}
onToggleModel={toggleModel}
onRemoveProvider={handleRemoveProvider}
/>
)}
</TabsContent>
</Tabs>
);
};
type ProviderGroup = { provider: string; models: { id: string; name: string }[] };
type OpenCodeProviderListProps = {
groups: ProviderGroup[];
enabledProviders: string[];
enabledModels: string[];
onToggleModel: (key: string) => void;
onRemoveProvider: (provider: string) => void;
};
const OpenCodeProviderList = ({
groups,
enabledProviders,
enabledModels,
onToggleModel,
onRemoveProvider,
}: OpenCodeProviderListProps) => {
const [openProvider, setOpenProvider] = useUserState<string>('ai-settings-oc-accordion', '');
const enabled = groups.filter((g) => enabledProviders.includes(g.provider));
if (enabled.length === 0) {
return <p className="text-sm text-duck-dark/40">No providers enabled.</p>;
}
return (
<Accordion type="single" collapsible value={openProvider} onValueChange={(v) => setOpenProvider(v ?? '')}>
{enabled.map((g) => (
<AccordionItem key={g.provider} value={g.provider}>
<AccordionTrigger className="py-2 px-3 text-sm font-medium text-duck-dark hover:no-underline [&>svg]:ml-1">
<span className="flex-1 text-left">{g.provider}</span>
<span className="text-xs opacity-60 mr-5">{g.models.length}</span>
<button
type="button"
onClick={(ev) => {
ev.stopPropagation();
onRemoveProvider(g.provider);
}}
className="p-1 text-duck-dark/30 hover:text-red-500 cursor-pointer transition-colors mr-3"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</AccordionTrigger>
<AccordionContent className="px-3 pb-2">
<div className="grid gap-1 max-h-52 overflow-y-auto">
{[...g.models]
.sort((a, b) => {
const aEnabled = enabledModels.includes(`${g.provider}:${a.id}`);
const bEnabled = enabledModels.includes(`${g.provider}:${b.id}`);
if (aEnabled !== bEnabled) return aEnabled ? -1 : 1;
return a.name.localeCompare(b.name);
})
.map((m) => {
const key = `${g.provider}:${m.id}`;
return (
<label
key={m.id}
className="flex items-center justify-between py-1.5 px-2 rounded-md hover:bg-duck-dark/5 cursor-pointer"
>
<span className="text-sm text-duck-dark/70">{m.name}</span>
<Switch checked={enabledModels.includes(key)} onCheckedChange={() => onToggleModel(key)} />
</label>
);
})}
</div>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
);
};