Five settings pages moved from a `*_SELECTED` global to `/settings/:page/:section`. The sidebar entry is a react-router `<NavLink>` rather than a button holding the key in its onClick closure, so a section is linkable, cmd-clickable and gets its active state from the router; each page renders one `SettingsRoute` guard that canonicalises both the bare route and a section that does not exist. Integrations needed more than the shared factory. It builds its own sidebar, and it kept the Enterprise/Personal tab in a second global — which is why a deep link to a Personal section could never have worked: the link set the section, the tab stayed on Enterprise, and the content pane said "Select a section" about a section that existed. The tab is derived from the section key now. Also removes the `/settings/resources` menu item (audit M8) and its two locale keys: there has never been such a route, so it bounced to the catch-all and out to `/`.
507 lines
16 KiB
TypeScript
507 lines
16 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 { 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 { 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">
|
|
No models available. Configure API keys in Providers.
|
|
</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="Visible" onDrop={showModel}>
|
|
{shown.length === 0 && (
|
|
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">Drag models here to show</span>
|
|
)}
|
|
{shown.map((m) => (
|
|
<ModelPill
|
|
key={m.key}
|
|
label={m.name}
|
|
modelKey={m.key}
|
|
enabled
|
|
onAction={hideModel}
|
|
onDragStart={onDragStart}
|
|
/>
|
|
))}
|
|
</DropZone>
|
|
|
|
<DropZone label="Hidden" onDrop={hideModel}>
|
|
{hidden.length === 0 && (
|
|
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">All models visible</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 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('Models refreshed');
|
|
} catch {
|
|
toast.error('Failed to refresh models');
|
|
} 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">
|
|
No models available. Configure API keys in Providers.
|
|
</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="Refresh models from providers"
|
|
>
|
|
<RefreshCw className={`h-3.5 w-3.5 text-duck-teal ${refreshing ? 'animate-spin' : ''}`} />
|
|
</button>
|
|
</div>
|
|
|
|
<DropZone label="Enabled" onDrop={enableModel}>
|
|
{enabled.length === 0 && (
|
|
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">Drag models here to enable</span>
|
|
)}
|
|
{enabled.map((m) => (
|
|
<ModelPill
|
|
key={m.key}
|
|
label={m.name}
|
|
modelKey={m.key}
|
|
enabled
|
|
onAction={disableModel}
|
|
onDragStart={onDragStart}
|
|
/>
|
|
))}
|
|
</DropZone>
|
|
|
|
<DropZone label="Disabled" onDrop={disableModel}>
|
|
{disabled.length === 0 && (
|
|
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">All models enabled</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 = {
|
|
label: 'Models',
|
|
icon: Eye,
|
|
sections: [
|
|
{
|
|
key: 'my-models',
|
|
icon: Eye,
|
|
title: 'My Models',
|
|
description: 'Show or hide models for yourself',
|
|
content: <MyModelsSection />,
|
|
},
|
|
{
|
|
key: 'member-models',
|
|
icon: Eye,
|
|
title: 'Channel Models',
|
|
description: 'Models reachable by non-owner accounts',
|
|
content: <MemberModelsSection />,
|
|
},
|
|
],
|
|
};
|
|
|
|
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: <AIModels />,
|
|
},
|
|
],
|
|
};
|
|
|
|
const providersGroup: SettingsSectionGroup = {
|
|
label: 'Providers',
|
|
icon: Terminal,
|
|
sections: [
|
|
{
|
|
key: 'ai-harnesses',
|
|
icon: Terminal,
|
|
title: 'Providers',
|
|
description: 'Remote and local AI providers',
|
|
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,
|
|
sidebarLabel: '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>
|
|
);
|
|
};
|