ProfileSettings reaches zero: api keys, profile, voice, dock, languages and the model defaults. Language names now come from Intl.DisplayNames rather than the catalog. The runtime already knows "German" in English and "alemao" in Portuguese, so 28 names would have been 56 hand-written entries that Intl gets right for free -- including for locales not shipped yet. Same reasoning D7 gives for dates and numbers. The tag LIST stays hardcoded: which languages you may claim to speak is a product choice. ApiKeys had toLocaleDateString(undefined, ...), which followed the BROWSER locale -- the exact contradiction D7 describes. It uses useFormat().date now. Its revoked-key toggle hand-rolled a plural with a ternary on n === 1; that is a real plural entry now. Scanner: an Authorization header value like "Bearer <token>" is not a sentence. Excluded by auth-scheme prefix; verified the delta is exactly the four such credentials across both remaining areas.
125 lines
4.7 KiB
TypeScript
125 lines
4.7 KiB
TypeScript
import { useState, useEffect, useMemo } from 'react';
|
|
import { toast } from 'sonner';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Button } from '@/components/ui/button';
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectGroup,
|
|
SelectItem,
|
|
SelectLabel,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select';
|
|
import { useSettings } from 'state/useSettings';
|
|
import { useUserVisibleModels, getProviderDisplayName, type ModelOption } from 'state/useModels';
|
|
import { useTranslation } from '@/lib/i18n';
|
|
|
|
function buildGroups(models: ModelOption[]) {
|
|
const groups: Record<string, { id: string; name: string }[]> = {};
|
|
for (const m of models) {
|
|
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)) }));
|
|
}
|
|
|
|
const NONE = '__none__';
|
|
|
|
export const AIModels = () => {
|
|
const { t } = useTranslation();
|
|
const { settings, saveSettings } = useSettings();
|
|
const piModels = useUserVisibleModels();
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
|
|
const [chatModel, setChatModel] = useState<string | null>(settings.chat.defaultModel);
|
|
const [projectModel, setProjectModel] = useState<string | null>(settings.chat.defaultProjectModel);
|
|
const [taskModel, setTaskModel] = useState<string | null>(settings.tasks.defaultModel);
|
|
|
|
useEffect(() => {
|
|
setChatModel(settings.chat.defaultModel);
|
|
setProjectModel(settings.chat.defaultProjectModel);
|
|
setTaskModel(settings.tasks.defaultModel);
|
|
}, [settings]);
|
|
|
|
const groups = useMemo(() => buildGroups(piModels), [piModels]);
|
|
|
|
const handleSave = async () => {
|
|
if (isSaving) return;
|
|
setIsSaving(true);
|
|
try {
|
|
await saveSettings({
|
|
...settings,
|
|
chat: { ...settings.chat, defaultModel: chatModel, defaultProjectModel: projectModel },
|
|
tasks: { ...settings.tasks, defaultModel: taskModel },
|
|
});
|
|
toast.success(t('settings.profile.models.aiDefaultsSaved'));
|
|
} catch {
|
|
toast.error(t('settings.profile.models.saveFailed'));
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
};
|
|
|
|
const renderModelSelect = (value: string | null, onChange: (v: string | null) => void, placeholder: string) => (
|
|
<Select value={value ?? NONE} onValueChange={(v) => onChange(v === NONE ? null : v)}>
|
|
<SelectTrigger className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark">
|
|
<SelectValue placeholder={placeholder} />
|
|
</SelectTrigger>
|
|
<SelectContent className="z-[600] max-h-[300px]">
|
|
<SelectItem value={NONE}>{placeholder}</SelectItem>
|
|
{groups.map(({ provider, models }) => (
|
|
<SelectGroup key={provider}>
|
|
<SelectLabel>{getProviderDisplayName(provider)}</SelectLabel>
|
|
{models.map((m) => (
|
|
<SelectItem key={`${provider}:${m.id}`} value={m.id}>
|
|
{m.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectGroup>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
);
|
|
|
|
return (
|
|
<div className="grid gap-5">
|
|
<Label className="grid gap-2">
|
|
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.profile.models.defaultChat')}</span>
|
|
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">
|
|
{t('settings.profile.models.defaultChatHint')}
|
|
</p>
|
|
{renderModelSelect(chatModel, setChatModel, 'System default')}
|
|
</Label>
|
|
|
|
<Label className="grid gap-2">
|
|
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.profile.models.defaultProject')}</span>
|
|
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">
|
|
{t('settings.profile.models.defaultProjectHint')}
|
|
</p>
|
|
{renderModelSelect(projectModel, setProjectModel, 'Same as chat default')}
|
|
</Label>
|
|
|
|
<Label className="grid gap-2">
|
|
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.profile.models.defaultTask')}</span>
|
|
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">
|
|
{t('settings.profile.models.defaultTaskHint')}
|
|
</p>
|
|
{renderModelSelect(taskModel, setTaskModel, 'Same as chat default')}
|
|
</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>
|
|
);
|
|
};
|