add /settings/ai route with role-aware model management

Two-tier model policy: system policy (admin, allowedModels) controls
member access, per-user hiddenModels controls personal visibility.
All model selectors now use useUserVisibleModels. Moved providers
and model visibility out of system settings, AI models out of profile.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-04 21:36:58 +00:00
co-authored by Claude Opus 4.6
parent d5fd3f58b1
commit 188e91920a
13 changed files with 606 additions and 413 deletions
+17 -4
View File
@@ -39,9 +39,19 @@ export function App() {
<Routes>
<Route path="/" element={<Dashboard.HomeScreen />} />
<Route path="/settings/profile" element={<Dashboard.ProfileSettings />} />
<Route path="/settings/system" element={user?.role !== 'Member' ? <Dashboard.SystemSettings /> : <Navigate to="/" replace />} />
<Route path="/settings/resources" element={user?.role !== 'Member' ? <Dashboard.ResourceSettings /> : <Navigate to="/" replace />} />
<Route path="/settings/users" element={user?.role === 'Super Admin' ? <Dashboard.UserSettings /> : <Navigate to="/" replace />} />
<Route path="/settings/ai" element={<Dashboard.AISettings />} />
<Route
path="/settings/system"
element={user?.role !== 'Member' ? <Dashboard.SystemSettings /> : <Navigate to="/" replace />}
/>
<Route
path="/settings/resources"
element={user?.role !== 'Member' ? <Dashboard.ResourceSettings /> : <Navigate to="/" replace />}
/>
<Route
path="/settings/users"
element={user?.role === 'Super Admin' ? <Dashboard.UserSettings /> : <Navigate to="/" replace />}
/>
<Route path="/settings/integrations" element={<Dashboard.IntegrationsSettings />} />
<Route path="/settings/apps" element={<Dashboard.AppsSettings />} />
<Route path="/automation" element={<Dashboard.Automation />} />
@@ -66,7 +76,10 @@ export function App() {
<Route path="/email/:emailId" element={<Dashboard.EmailScreen />} />
<Route path="/browser" element={<Dashboard.BrowserScreen />} />
<Route path="/terminal" element={<Dashboard.TerminalScreen />} />
<Route path="/desktop" element={user?.role === 'Super Admin' ? <Dashboard.DesktopScreen /> : <Navigate to="/" replace />} />
<Route
path="/desktop"
element={user?.role === 'Super Admin' ? <Dashboard.DesktopScreen /> : <Navigate to="/" replace />}
/>
<Route path="/auth/signout" element={<Authentication.SignoutScreen />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
@@ -1,7 +1,7 @@
import { Link } from 'react-router';
import * as Dropdown from '@/components/ui/dropdown-menu';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { User, Users, LogOut, Settings, Package, Puzzle, Rocket, Sun, Moon } from 'lucide-react';
import { User, Users, LogOut, Settings, Package, Puzzle, Rocket, Sun, Moon, Bot } from 'lucide-react';
import { useAuth } from 'hooks/useAuth';
import { useTranslation } from '@/lib/i18n';
import { useColorMode } from '@/components/ui/ThemeProvider';
@@ -59,6 +59,12 @@ export function UserMenu() {
</DropdownMenuItem>
</>
)}
<DropdownMenuItem asChild className="cursor-pointer">
<Link to="/settings/ai">
<Bot className="mr-2 h-4 w-4" />
AI
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild className="cursor-pointer">
<Link to="/settings/integrations">
<Puzzle className="mr-2 h-4 w-4" />
@@ -81,11 +87,7 @@ export function UserMenu() {
)}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={toggleColorMode} className="cursor-pointer">
{colorMode === 'dark' ? (
<Sun className="mr-2 h-4 w-4" />
) : (
<Moon className="mr-2 h-4 w-4" />
)}
{colorMode === 'dark' ? <Sun className="mr-2 h-4 w-4" /> : <Moon className="mr-2 h-4 w-4" />}
{colorMode === 'dark' ? 'Light Mode' : 'Dark Mode'}
</DropdownMenuItem>
<DropdownMenuSeparator />
@@ -0,0 +1,519 @@
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 { createSettingsPanelComponents, type SettingsSectionGroup } from './SettingsPanel';
import { useSettings } from 'state/useSettings';
import type { UserSettings } from 'state/useSettings';
import { usePiModels, useVisiblePiModels, 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',
opencode: 'OpenCode Zen',
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 { user } = useAuth();
const { settings, saveSettings } = useSettings();
const allModels = usePiModels();
const policyModels = useVisiblePiModels();
const isAdmin = user?.role !== 'Member';
const visibleModels = isAdmin ? allModels : policyModels;
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 = usePiModels();
const [activeProvider, setActiveProvider] = useUserState<string>('member-models-provider', '');
const [refreshing, setRefreshing] = useState(false);
const refreshModels = async () => {
setRefreshing(true);
try {
await client.post('/server-settings/pi-mono/local-providers/refresh');
queryClient.invalidateQueries({ queryKey: ['PI_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 = useVisiblePiModels();
// 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
// }
// --- Build groups based on role ---
function useAISettingsGroups(): SettingsSectionGroup[] {
const { user } = useAuth();
const isAdmin = user?.role !== 'Member';
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 />,
},
...(isAdmin
? [
{
key: 'member-models',
icon: Eye,
title: 'Member Models',
description: 'Enable or disable models for members',
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 />,
},
],
};
if (isAdmin) {
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];
}
return [modelsGroup, defaultsGroup];
}, [isAdmin]);
}
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 } = useMemo(
() =>
createSettingsPanelComponents({
globalKey: 'AI_SETTINGS_SELECTED',
sidebarIcon: Bot,
sidebarLabel: 'AI',
groups,
}),
[groups],
);
const panelComponents: PanelComponents = useMemo(
() => ({
'ai-left': Sidebar,
'ai-right': Content,
}),
[Sidebar, Content],
);
return (
<div className="h-full w-full pt-2">
<WorkspaceLayout layout={layout} onLayoutChange={() => {}} components={panelComponents} />
</div>
);
};
@@ -12,7 +12,7 @@ import {
SelectValue,
} from '@/components/ui/select';
import { useSettings } from 'state/useSettings';
import { useEnabledPiModels, getProviderDisplayName, type ModelOption } from 'state/useModels';
import { useUserVisibleModels, getProviderDisplayName, type ModelOption } from 'state/useModels';
function buildGroups(models: ModelOption[]) {
const groups: Record<string, { id: string; name: string }[]> = {};
@@ -30,7 +30,7 @@ const NONE = '__none__';
export const AIModels = () => {
const { settings, saveSettings } = useSettings();
const piModels = useEnabledPiModels();
const piModels = useUserVisibleModels();
const [isSaving, setIsSaving] = useState(false);
const [chatModel, setChatModel] = useState<string | null>(settings.chat.defaultModel);
@@ -12,11 +12,11 @@ import {
SelectValue,
} from '@/components/ui/select';
import { useSettings } from 'state/useSettings';
import { useVisiblePiModels, type ModelOption } from 'state/useModels';
import { useUserVisibleModels, type ModelOption } from 'state/useModels';
export const TaskDefaults = () => {
const { settings, saveSettings } = useSettings();
const piModels = useVisiblePiModels();
const piModels = useUserVisibleModels();
const [isSaving, setIsSaving] = useState(false);
const [model, setModel] = useState<string | null>(settings.tasks.defaultModel);
@@ -1,5 +1,5 @@
import { useMemo } from 'react';
import { User, Lock, Globe, Bot, LayoutGrid, Volume2 } from 'lucide-react';
import { User, Lock, Globe, LayoutGrid, Volume2 } from 'lucide-react';
import type { LayoutNode, PanelComponents } from 'officerdev';
import { WorkspaceLayout } from 'officerdev';
@@ -7,7 +7,6 @@ import { createSettingsPanelComponents, type SettingsSection } from '../Settings
import { UserData } from './UserData';
import { ChangePassword } from './ChangePassword';
import { Languages } from './Languages';
import { AIModels } from './AIModels';
import { DockSettings } from './DockSettings';
import { VoicePreference } from './VoicePreference';
@@ -15,11 +14,34 @@ const GLOBAL_KEY = 'PROFILE_SETTINGS_SELECTED';
const sections: SettingsSection[] = [
{ key: 'profile', icon: User, title: 'Profile', description: 'Avatar, name, and email', content: <UserData /> },
{ key: 'change-password', icon: Lock, title: 'Change Password', description: 'Update your password', content: <ChangePassword /> },
{ key: 'ai-models', icon: Bot, title: 'AI Models', description: 'Default models for chat, projects, and tasks', content: <AIModels /> },
{ key: 'languages', icon: Globe, title: 'Languages', description: 'Spoken, default, and translation', content: <Languages /> },
{ key: 'voice', icon: Volume2, title: 'Voice', description: 'Text-to-speech voice preference', content: <VoicePreference /> },
{ key: 'dock', icon: LayoutGrid, title: 'Dock', description: 'Choose and reorder dock items', content: <DockSettings /> },
{
key: 'change-password',
icon: Lock,
title: 'Change Password',
description: 'Update your password',
content: <ChangePassword />,
},
{
key: 'languages',
icon: Globe,
title: 'Languages',
description: 'Spoken, default, and translation',
content: <Languages />,
},
{
key: 'voice',
icon: Volume2,
title: 'Voice',
description: 'Text-to-speech voice preference',
content: <VoicePreference />,
},
{
key: 'dock',
icon: LayoutGrid,
title: 'Dock',
description: 'Choose and reorder dock items',
content: <DockSettings />,
},
];
const { Sidebar, Content } = createSettingsPanelComponents({
@@ -1,79 +1,23 @@
import { useState, useEffect, useMemo, useCallback, type DragEvent } from 'react';
import { useState, useEffect, useMemo } from 'react';
import { toast } from 'sonner';
import { Terminal, Eye, Bot, Settings, X, Plus, Mail, Volume2, Mic, ScanText, RefreshCw } 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Bot, Settings, Mail, Volume2, Mic, ScanText, X } from 'lucide-react';
import { useQueryClient } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
import type { LayoutNode, PanelComponents } from 'officerdev';
import { WorkspaceLayout, TerminalView } from 'officerdev';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { createSettingsPanelComponents, type SettingsSectionGroup } from './SettingsPanel';
import { useSettings } from 'state/useSettings';
import { useUserState } from 'state/useUserState';
import { usePiModels, useVisiblePiModels, modelKey, getProviderDisplayName, type ModelOption } from 'state/useModels';
import { useAccessPolicy } from 'state/useAccessPolicy';
import type { UserSettings } from 'state/useSettings';
import { AIHarnessesSection } from './ServerSettings/AIHarnessesSection';
import { RUN_COMMAND_CHANNEL, type RunCommandState } from './ServerSettings/run-command-channel';
import { SMTPSection } from './ServerSettings/SMTPSection';
import { TTSSection } from './ServerSettings/TTSSection';
import { STTSection } from './ServerSettings/STTSection';
import { OCRSection } from './ServerSettings/OCRSection';
const PROVIDER_DISPLAY: Record<string, string> = {
anthropic: 'Anthropic',
openai: 'OpenAI',
opencode: 'OpenCode Zen',
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);
}
const groups: SettingsSectionGroup[] = [
{
label: 'AI',
icon: Bot,
sections: [
{
key: 'ai-harnesses',
icon: Terminal,
title: 'Providers',
description: 'Remote and local AI providers',
content: <AIHarnessesSection />,
},
{
key: 'model-visibility',
icon: Eye,
title: 'Models',
description: 'Enable or disable models',
content: <ModelVisibilitySection />,
},
{
key: 'chat-defaults',
icon: Terminal,
title: 'Chat Defaults',
description: 'Model, prompt, and temperature',
content: <ChatDefaultsSection />,
},
{
key: 'tts',
icon: Volume2,
@@ -211,329 +155,3 @@ export const SystemSettings = () => {
</div>
);
};
// --- AI sections ---
function ChatDefaultsSection() {
const { settings, saveSettings } = useSettings();
const piModels = useVisiblePiModels();
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 handleSave = async () => {
if (isSaving) return;
setIsSaving(true);
try {
const updated: UserSettings = {
...settings,
chat: { ...settings.chat, 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-background/60 border-duck-dark/20 text-duck-dark">
<SelectValue placeholder="Default" />
</SelectTrigger>
<SelectContent className="z-[600]">
{piModels.map((m: ModelOption) => (
<SelectItem key={m.id} value={m.id}>
<span className="font-bold">{m.name}</span>
{m.provider && <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-background/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-background/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>
);
}
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 = useCallback((ev: DragEvent) => {
ev.preventDefault();
setOver(true);
}, []);
const handleDragLeave = useCallback(() => setOver(false), []);
const handleDrop = useCallback(
(ev: DragEvent) => {
ev.preventDefault();
setOver(false);
const key = ev.dataTransfer.getData('text/plain');
if (key) onDrop(key);
},
[onDrop],
);
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={handleDragLeave}
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>
);
};
function ModelVisibilitySection() {
const client = useClient();
const queryClient = useQueryClient();
const { policy, savePolicy } = useAccessPolicy();
const piModels = usePiModels();
const [activeProvider, setActiveProvider] = useUserState<string>('model-visibility-provider', '');
const [refreshing, setRefreshing] = useState(false);
const refreshModels = async () => {
setRefreshing(true);
try {
await client.post('/server-settings/pi-mono/local-providers/refresh');
queryClient.invalidateQueries({ queryKey: ['PI_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]);
// Auto-select first provider if none selected or stale
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 AI Settings.
</p>
);
}
return (
<div className="grid gap-4">
{/* Provider tabs */}
<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>
{/* Enabled section */}
<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>
{/* Disabled section */}
<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>
);
}
@@ -1,5 +1,6 @@
export * from './ProfileSettings';
export * from './SystemSettings';
export * from './AISettings';
export * from './ResourceSettings';
export * from './UserSettings';
export * from './IntegrationsSettings';
@@ -1,6 +1,6 @@
import type { KeyboardEvent } from 'react';
import { useState, useRef, useEffect } from 'react';
import { useVisiblePiModels } from 'state/useModels';
import { useUserVisibleModels } from 'state/useModels';
import { usePiChat, type UsePiChatType } from '../../../hooks/usePiChat';
import { useAttachments } from '../useAttachments';
import { useSlashCommands } from '../useSlashCommands';
@@ -47,7 +47,7 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp
stopGeneration,
} = chat;
const availableModels = useVisiblePiModels();
const availableModels = useUserVisibleModels();
const attachmentManager = useAttachments({ sessionId });
const slashCommands = useSlashCommands({ sessionId });
@@ -6,7 +6,7 @@ import { cardStyle } from '@/components/Card';
import type { TaskInfo, ChatMessage } from '../../../Chat';
import { usePiChat, MessageBubble, StreamingBubble, ModelSelector } from '../../../Chat';
import { useSettings } from 'state/useSettings';
import { useVisiblePiModels } from 'state/useModels';
import { useUserVisibleModels } from 'state/useModels';
import type { TaskSummary } from '../../useTasks';
const playDing = () => {
@@ -46,7 +46,7 @@ type PiMonoInnerProps = {
const PiMonoInner = ({ defaultInput, cwd, initialModel, taskInfo, sandboxed }: PiMonoInnerProps) => {
const [phase, setPhase] = useState<Phase>('ready');
const chat = usePiChat(undefined, initialModel, { replaceUrl: false, taskInfo });
const availableModels = useVisiblePiModels();
const availableModels = useUserVisibleModels();
// --- Independent message accumulator (never loses messages) ---
const accRef = useRef<ChatMessage[]>([]);
+1 -1
View File
@@ -2,7 +2,7 @@ export { useSettings, DEFAULT_SETTINGS } from './useSettings';
export type { UseSettingsType, UserSettings, UserState } from './useSettings';
export { useUserState } from './useUserState';
export { useDashboardState } from './useDashboardState';
export { usePiModels, useVisiblePiModels, useEnabledPiModels, modelKey } from './useModels';
export { usePiModels, useVisiblePiModels, useUserVisibleModels, useEnabledPiModels, modelKey } from './useModels';
export type { ModelOption } from './useModels';
export { useAccessPolicy } from './useAccessPolicy';
export { useRecentModels } from './useRecentModels';
+20 -3
View File
@@ -2,6 +2,7 @@ import { useQuery } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
import { useAuth } from 'hooks/useAuth';
import { useAccessPolicy } from './useAccessPolicy';
import { useSettings } from './useSettings';
import type { ModelOption } from 'officerdev';
export type { ModelOption };
@@ -52,14 +53,13 @@ export function usePiModels() {
return models;
}
/** Filter models by system-wide access policy. Super Admin sees all. New providers pass through. */
/** Filter models by system-wide access policy. New providers pass through. */
export function useVisiblePiModels() {
const models = usePiModels();
const { user } = useAuth();
const { policy } = useAccessPolicy();
const allowed = policy.allowedModels;
if (user?.role === 'Super Admin' || allowed.length === 0) return models;
if (allowed.length === 0) return models;
const allowedSet = new Set(allowed);
const allowedProviderSet = new Set(allowed.map((key) => key.split(':')[0]));
@@ -72,6 +72,23 @@ export function useVisiblePiModels() {
});
}
/** Models visible to the current user: system policy (members) or all (admins), minus per-user hidden. */
export function useUserVisibleModels() {
const allModels = usePiModels();
const policyModels = useVisiblePiModels();
const { user } = useAuth();
const { settings } = useSettings();
const isAdmin = user?.role !== 'Member';
const base = isAdmin ? allModels : policyModels;
const hidden = settings.chat.hiddenModels;
if (!hidden || hidden.length === 0) return base;
const hiddenSet = new Set(hidden);
return base.filter((m) => !hiddenSet.has(modelKey(m)));
}
/** Strict filtering — only explicitly allowed models, no "new provider" passthrough. */
export function useEnabledPiModels() {
const models = usePiModels();
+1
View File
@@ -55,6 +55,7 @@ export type UserSettings = {
systemPrompt: string;
temperature: number;
defaultPwd: string;
hiddenModels?: string[];
};
ai: {
enabledModels: string[];