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:
@@ -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 });
|
||||
|
||||
|
||||
+2
-2
@@ -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[]>([]);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -55,6 +55,7 @@ export type UserSettings = {
|
||||
systemPrompt: string;
|
||||
temperature: number;
|
||||
defaultPwd: string;
|
||||
hiddenModels?: string[];
|
||||
};
|
||||
ai: {
|
||||
enabledModels: string[];
|
||||
|
||||
Reference in New Issue
Block a user