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 { useAccessPolicy } from 'state/useAccessPolicy'; import { useUserState } from 'state/useUserState'; import { AIHarnessesSection } from './ServerSettings/AIHarnessesSection'; import { AIModels } from './ProfileSettings/AIModels'; const PROVIDER_DISPLAY: Record = { 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) => ( 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} ); 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 (
{label}
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}
); }; // --- My Models Section (per-user hidden models) --- function MyModelsSection() { const { settings, saveSettings } = useSettings(); const visibleModels = useModels(); const [activeProvider, setActiveProvider] = useUserState('my-models-provider', ''); const hiddenModels = settings.chat.hiddenModels ?? []; const providerGroups = useMemo(() => { const groups: Record = {}; 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 (

No models available. Configure API keys in Providers.

); } return (
{providers.map((p) => ( ))}
{shown.length === 0 && ( Drag models here to show )} {shown.map((m) => ( ))} {hidden.length === 0 && ( All models visible )} {hidden.map((m) => ( ))}
); } // --- Member Models Section (system-wide policy, admin only) --- function MemberModelsSection() { const client = useClient(); const queryClient = useQueryClient(); const { policy, savePolicy } = useAccessPolicy(); const piModels = useModels(); const [activeProvider, setActiveProvider] = useUserState('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('Models refreshed'); } catch { toast.error('Failed to refresh models'); } finally { setRefreshing(false); } }; const enabledModels = policy.allowedModels; const providerGroups = useMemo(() => { const groups: Record = {}; 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 (

No models available. Configure API keys in Providers.

); } return (
{providers.map((p) => ( ))}
{enabled.length === 0 && ( Drag models here to enable )} {enabled.map((m) => ( ))} {disabled.length === 0 && ( All models enabled )} {disabled.map((m) => ( ))}
); } // --- 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(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 = { label: 'Models', icon: Eye, sections: [ { key: 'my-models', icon: Eye, title: 'My Models', description: 'Show or hide models for yourself', content: , }, { key: 'member-models', icon: Eye, title: 'Channel Models', description: 'Models reachable by non-owner accounts', content: , }, ], }; const defaultsGroup: SettingsSectionGroup = { label: 'Defaults', icon: Sparkles, sections: [ { key: 'default-models', icon: Sparkles, title: 'Default Models', description: 'Default models for chat, projects, and tasks', content: , }, ], }; const providersGroup: SettingsSectionGroup = { label: 'Providers', icon: Terminal, sections: [ { key: 'ai-harnesses', icon: Terminal, title: 'Providers', description: 'Remote and local AI providers', content: , }, ], }; 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, sidebarLabel: 'AI', groups, }), [groups], ); const panelComponents: PanelComponents = useMemo( () => ({ 'ai-left': Sidebar, 'ai-right': Content, }), [Sidebar, Content], ); return (
{}} components={panelComponents} />
); };