Files
platform/src/apps/officer-web/Screens/Dashboard/Settings/AISettings.tsx
T
pastilhas c3c62828e3 settings framework and section metadata keyed (487 -> 425)
SettingsSection now carries titleKey/descriptionKey instead of title/description, and
the sidebar and content pane resolve them. The section arrays are module-level consts
holding JSX content, so they cannot call a hook — the data holds keys and the renderers
translate. Sidebar search matches the RESOLVED words, so it works in the language on
screen.

Capital K in titleKey is load-bearing: docs-check-settings.ts greps key:\s*'…' for
route segments, and a lowercase title_key would have fed it twenty bogus paths.
Verified 0 bogus, 26 declared, 20 offered, 0 missing.

Section titles now share one key with the Spotlight rows that name the same section,
per 'one key per meaning'. Two casings had to converge, so Settings shows Spotlight's:
'Change Password' now reads 'Change password'.

Settings sidebar search placeholder was 'Search...'; it is now 'Search settings…',
which is what the skeleton catalog already said and what distinguishes it from the
global Spotlight.
2026-08-23 04:46:07 +00:00

504 lines
17 KiB
TypeScript

import { useState, useMemo, useCallback, type DragEvent } from 'react';
import { toast } from 'sonner';
import { Bot, Terminal, Eye, Sparkles, X, Plus, RefreshCw } from 'lucide-react';
import { useQueryClient } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
import type { LayoutNode, PanelComponents } from 'officerdev';
import { WorkspaceLayout } from 'officerdev';
import { useAuth } from 'hooks/useAuth';
import { SettingsRoute, createSettingsPanelComponents, type SettingsSectionGroup } from './SettingsPanel';
import { useSettings } from 'state/useSettings';
import type { UserSettings } from 'state/useSettings';
import { useModels, useVisibleModels, modelKey, getProviderDisplayName, type ModelOption } from 'state/useModels';
import { useTranslation } from '@/lib/i18n';
import { useAccessPolicy } from 'state/useAccessPolicy';
import { useUserState } from 'state/useUserState';
import { AIHarnessesSection } from './ServerSettings/AIHarnessesSection';
import { AIModels } from './ProfileSettings/AIModels';
const PROVIDER_DISPLAY: Record<string, string> = {
anthropic: 'Anthropic',
openai: 'OpenAI',
zai: 'ZAI',
google: 'Google',
groq: 'Groq',
mistral: 'Mistral',
xai: 'xAI',
openrouter: 'OpenRouter',
huggingface: 'Hugging Face',
'github-copilot': 'GitHub Copilot',
minimax: 'MiniMax',
bedrock: 'Amazon Bedrock',
'google-vertex': 'Google Vertex AI',
'azure-openai': 'Azure OpenAI',
};
function displayProviderName(provider: string): string {
return PROVIDER_DISPLAY[provider] ?? getProviderDisplayName(provider);
}
// --- Shared drag-and-drop components ---
type ModelPillProps = {
label: string;
modelKey: string;
enabled: boolean;
onAction: (key: string) => void;
onDragStart: (ev: DragEvent, key: string) => void;
};
const ModelPill = ({ label, modelKey, enabled, onAction, onDragStart }: ModelPillProps) => (
<span
draggable
onDragStart={(ev) => onDragStart(ev, modelKey)}
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium cursor-grab active:cursor-grabbing select-none border border-duck-dark/15 dark:border-foreground/15 bg-background/60 text-duck-dark/80 dark:text-foreground/80 hover:bg-duck-dark/5 dark:hover:bg-foreground/5 transition-colors"
>
{label}
<button
onClick={() => onAction(modelKey)}
className="ml-0.5 p-0.5 rounded-full hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors"
>
{enabled ? <X className="h-3 w-3" /> : <Plus className="h-3 w-3" />}
</button>
</span>
);
type DropZoneProps = {
label: string;
children: React.ReactNode;
onDrop: (key: string) => void;
};
const DropZone = ({ label, children, onDrop }: DropZoneProps) => {
const [over, setOver] = useState(false);
const handleDragOver = (ev: DragEvent) => {
ev.preventDefault();
setOver(true);
};
const handleDrop = (ev: DragEvent) => {
ev.preventDefault();
setOver(false);
const key = ev.dataTransfer.getData('text/plain');
if (key) onDrop(key);
};
return (
<div className="grid gap-1.5">
<span className="text-xs font-medium text-duck-dark/50 dark:text-foreground/50 uppercase tracking-wide">
{label}
</span>
<div
onDragOver={handleDragOver}
onDragLeave={() => setOver(false)}
onDrop={handleDrop}
className={`min-h-[48px] p-2 rounded-lg border border-dashed transition-colors flex flex-wrap gap-1.5 ${over ? 'border-duck-teal bg-duck-teal/5' : 'border-duck-dark/15 dark:border-foreground/15'}`}
>
{children}
</div>
</div>
);
};
// --- My Models Section (per-user hidden models) ---
function MyModelsSection() {
const { t } = useTranslation();
const { settings, saveSettings } = useSettings();
const visibleModels = useModels();
const [activeProvider, setActiveProvider] = useUserState<string>('my-models-provider', '');
const hiddenModels = settings.chat.hiddenModels ?? [];
const providerGroups = useMemo(() => {
const groups: Record<string, { id: string; name: string }[]> = {};
for (const m of visibleModels) {
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)) }));
}, [visibleModels]);
const providers = useMemo(() => providerGroups.map((g) => g.provider), [providerGroups]);
const selected = providers.includes(activeProvider) ? activeProvider : (providers[0] ?? '');
const currentGroup = providerGroups.find((g) => g.provider === selected);
const { shown, hidden } = useMemo(() => {
if (!currentGroup) return { shown: [], hidden: [] };
const hiddenSet = new Set(hiddenModels);
const s: { id: string; name: string; key: string }[] = [];
const h: { id: string; name: string; key: string }[] = [];
for (const m of currentGroup.models) {
const key = `${currentGroup.provider}:${m.id}`;
if (hiddenSet.has(key)) {
h.push({ ...m, key });
} else {
s.push({ ...m, key });
}
}
return { shown: s, hidden: h };
}, [currentGroup, hiddenModels]);
const saveHidden = useCallback(
async (newHidden: string[]) => {
const updated: UserSettings = {
...settings,
chat: { ...settings.chat, hiddenModels: newHidden },
};
await saveSettings(updated);
},
[settings, saveSettings],
);
const hideModel = useCallback(
async (key: string) => {
if (hiddenModels.includes(key)) return;
await saveHidden([...hiddenModels, key]);
},
[hiddenModels, saveHidden],
);
const showModel = useCallback(
async (key: string) => {
await saveHidden(hiddenModels.filter((k) => k !== key));
},
[hiddenModels, saveHidden],
);
const onDragStart = useCallback((ev: DragEvent, key: string) => {
ev.dataTransfer.setData('text/plain', key);
ev.dataTransfer.effectAllowed = 'move';
}, []);
if (providerGroups.length === 0) {
return <p className="text-sm text-duck-dark/40 dark:text-foreground/40">{t('settings.ai.noModels')}</p>;
}
return (
<div className="grid gap-4">
<div className="flex flex-wrap items-center gap-1 border-b border-duck-dark/10 dark:border-foreground/10 pb-1">
{providers.map((p) => (
<button
key={p}
onClick={() => setActiveProvider(p)}
className={`px-3 py-1.5 text-sm font-medium rounded-t-md transition-colors cursor-pointer ${
p === selected
? 'bg-duck-teal/10 text-duck-teal border-b-2 border-duck-teal'
: 'text-duck-dark/50 dark:text-foreground/50 hover:text-duck-dark dark:hover:text-foreground hover:bg-duck-dark/5 dark:hover:bg-foreground/5'
}`}
>
{displayProviderName(p)}
</button>
))}
</div>
<DropZone label={t('settings.ai.visible')} onDrop={showModel}>
{shown.length === 0 && (
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">{t('settings.ai.dragToShow')}</span>
)}
{shown.map((m) => (
<ModelPill
key={m.key}
label={m.name}
modelKey={m.key}
enabled
onAction={hideModel}
onDragStart={onDragStart}
/>
))}
</DropZone>
<DropZone label={t('settings.ai.hidden')} onDrop={hideModel}>
{hidden.length === 0 && (
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">{t('settings.ai.allVisible')}</span>
)}
{hidden.map((m) => (
<ModelPill
key={m.key}
label={m.name}
modelKey={m.key}
enabled={false}
onAction={showModel}
onDragStart={onDragStart}
/>
))}
</DropZone>
</div>
);
}
// --- Member Models Section (system-wide policy, admin only) ---
function MemberModelsSection() {
const { t } = useTranslation();
const client = useClient();
const queryClient = useQueryClient();
const { policy, savePolicy } = useAccessPolicy();
const piModels = useModels();
const [activeProvider, setActiveProvider] = useUserState<string>('member-models-provider', '');
const [refreshing, setRefreshing] = useState(false);
const refreshModels = async () => {
setRefreshing(true);
try {
await client.post('/server-settings/chat-providers/local-providers/refresh');
queryClient.invalidateQueries({ queryKey: ['CHAT_MODELS'] });
toast.success(t('settings.ai.refreshed'));
} catch {
toast.error(t('settings.ai.refreshFailed'));
} finally {
setRefreshing(false);
}
};
const enabledModels = policy.allowedModels;
const providerGroups = useMemo(() => {
const groups: Record<string, { id: string; name: string }[]> = {};
for (const m of piModels) {
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)) }));
}, [piModels]);
const providers = useMemo(() => providerGroups.map((g) => g.provider), [providerGroups]);
const selected = providers.includes(activeProvider) ? activeProvider : (providers[0] ?? '');
const currentGroup = providerGroups.find((g) => g.provider === selected);
const { enabled, disabled } = useMemo(() => {
if (!currentGroup) return { enabled: [], disabled: [] };
const allEnabled = enabledModels.length === 0;
const enabledProviderSet = new Set(enabledModels.map((key) => key.split(':')[0]));
const isNewProvider = !allEnabled && !enabledProviderSet.has(currentGroup.provider);
const en: { id: string; name: string; key: string }[] = [];
const dis: { id: string; name: string; key: string }[] = [];
for (const m of currentGroup.models) {
const key = `${currentGroup.provider}:${m.id}`;
if (allEnabled || isNewProvider || enabledModels.includes(key)) {
en.push({ ...m, key });
} else {
dis.push({ ...m, key });
}
}
return { enabled: en, disabled: dis };
}, [currentGroup, enabledModels]);
const enableModel = useCallback(
async (key: string) => {
if (enabledModels.includes(key)) return;
await savePolicy({ allowedModels: [...enabledModels, key] });
},
[enabledModels, savePolicy],
);
const disableModel = useCallback(
async (key: string) => {
let base = enabledModels;
if (base.length === 0) {
base = piModels.map((m) => modelKey(m));
} else {
const provider = key.split(':')[0]!;
const knownProviders = new Set(base.map((k) => k.split(':')[0]));
if (!knownProviders.has(provider)) {
const newProviderKeys = piModels.filter((m) => m.provider === provider).map((m) => modelKey(m));
base = [...base, ...newProviderKeys];
}
}
await savePolicy({ allowedModels: base.filter((id) => id !== key) });
},
[enabledModels, piModels, savePolicy],
);
const onDragStart = useCallback((ev: DragEvent, key: string) => {
ev.dataTransfer.setData('text/plain', key);
ev.dataTransfer.effectAllowed = 'move';
}, []);
if (providerGroups.length === 0) {
return <p className="text-sm text-duck-dark/40 dark:text-foreground/40">{t('settings.ai.noModels')}</p>;
}
return (
<div className="grid gap-4">
<div className="flex flex-wrap items-center gap-1 border-b border-duck-dark/10 dark:border-foreground/10 pb-1">
{providers.map((p) => (
<button
key={p}
onClick={() => setActiveProvider(p)}
className={`px-3 py-1.5 text-sm font-medium rounded-t-md transition-colors cursor-pointer ${
p === selected
? 'bg-duck-teal/10 text-duck-teal border-b-2 border-duck-teal'
: 'text-duck-dark/50 dark:text-foreground/50 hover:text-duck-dark dark:hover:text-foreground hover:bg-duck-dark/5 dark:hover:bg-foreground/5'
}`}
>
{displayProviderName(p)}
</button>
))}
<button
type="button"
onClick={refreshModels}
disabled={refreshing}
className="ml-auto p-1.5 rounded hover:bg-duck-teal/10 cursor-pointer transition-colors disabled:opacity-30"
title={t('settings.ai.refresh')}
>
<RefreshCw className={`h-3.5 w-3.5 text-duck-teal ${refreshing ? 'animate-spin' : ''}`} />
</button>
</div>
<DropZone label={t('settings.ai.enabled')} onDrop={enableModel}>
{enabled.length === 0 && (
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">
{t('settings.ai.dragToEnable')}
</span>
)}
{enabled.map((m) => (
<ModelPill
key={m.key}
label={m.name}
modelKey={m.key}
enabled
onAction={disableModel}
onDragStart={onDragStart}
/>
))}
</DropZone>
<DropZone label={t('settings.ai.disabled')} onDrop={disableModel}>
{disabled.length === 0 && (
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">{t('settings.ai.allEnabled')}</span>
)}
{disabled.map((m) => (
<ModelPill
key={m.key}
label={m.name}
modelKey={m.key}
enabled={false}
onAction={enableModel}
onDragStart={onDragStart}
/>
))}
</DropZone>
</div>
);
}
// --- Chat Defaults Section (commented out for future use) ---
// function ChatDefaultsSection() {
// const { settings, saveSettings } = useSettings();
// const piModels = useVisibleModels();
// 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);
// // ... full implementation for future use
// }
function useAISettingsGroups(): SettingsSectionGroup[] {
return useMemo(() => {
const modelsGroup: SettingsSectionGroup = {
labelKey: 'settings.groups.models',
icon: Eye,
sections: [
{
key: 'my-models',
icon: Eye,
titleKey: 'settings.sections.myModels.label',
descriptionKey: 'settings.sections.myModels.description',
content: <MyModelsSection />,
},
{
key: 'member-models',
icon: Eye,
titleKey: 'settings.sections.channelModels.label',
descriptionKey: 'settings.sections.channelModels.description',
content: <MemberModelsSection />,
},
],
};
const defaultsGroup: SettingsSectionGroup = {
labelKey: 'settings.groups.defaults',
icon: Sparkles,
sections: [
{
key: 'default-models',
icon: Sparkles,
titleKey: 'settings.sections.defaultModels.label',
descriptionKey: 'settings.sections.defaultModels.description',
content: <AIModels />,
},
],
};
const providersGroup: SettingsSectionGroup = {
labelKey: 'settings.groups.providers',
icon: Terminal,
sections: [
{
key: 'ai-harnesses',
icon: Terminal,
titleKey: 'settings.sections.providers.label',
descriptionKey: 'settings.sections.providers.description',
content: <AIHarnessesSection />,
},
],
};
return [providersGroup, modelsGroup, defaultsGroup];
}, []);
}
const BASE_PATH = '/settings/ai';
const layout: LayoutNode = {
type: 'group',
id: 'ai-root',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'ai-left', appType: null }, size: 20 },
{ node: { type: 'panel', id: 'ai-right', appType: null }, size: 80 },
],
};
export const AISettings = () => {
const groups = useAISettingsGroups();
const { Sidebar, Content, allSections } = useMemo(
() =>
createSettingsPanelComponents({
basePath: BASE_PATH,
sidebarIcon: Bot,
sidebarLabelKey: 'settings.nav.ai',
groups,
}),
[groups],
);
const panelComponents: PanelComponents = useMemo(
() => ({
'ai-left': Sidebar,
'ai-right': Content,
}),
[Sidebar, Content],
);
return (
<SettingsRoute basePath={BASE_PATH} sections={allSections}>
<div className="h-full w-full pt-2">
<WorkspaceLayout layout={layout} onLayoutChange={() => {}} components={panelComponents} />
</div>
</SettingsRoute>
);
};