Files
platform/src/apps/officer-web/Screens/Dashboard/Settings/SystemSettings.tsx
T
2026-02-20 04:32:50 +00:00

407 lines
15 KiB
TypeScript

import { useState, useEffect, useMemo, useCallback, type DragEvent } from 'react';
import { toast } from 'sonner';
import { Terminal, Eye, Bot, Settings, X, Plus } 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 { useQueryClient } from '@tanstack/react-query';
import type { LayoutNode, PanelComponents } from '@/components/Workspace';
import { WorkspaceLayout } from '@/components/Workspace';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { TerminalView } from 'apps/Terminal';
import { appRegistry } from '../Workspaces/app-registry';
import { createSettingsPanelComponents, type SettingsSectionGroup } from './SettingsPanel';
import { useSettings } from '@/state/useSettings';
import { useUserState } from '@/state/useUserState';
import { usePiMonoModels, useVisiblePiMonoModels } from '@/state/useModels';
import type { UserSettings } from '@/state/types/user-settings';
import { AIHarnessesSection } from './ServerSettings/AIHarnessesSection';
import { RUN_COMMAND_CHANNEL, type RunCommandState } from './ServerSettings/run-command-channel';
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 /> },
],
},
];
const { Sidebar, Content } = createSettingsPanelComponents({
globalKey: 'SYSTEM_SETTINGS_SELECTED',
sidebarIcon: Settings,
sidebarLabel: 'System',
groups,
});
const baseLayout: LayoutNode = {
type: 'group',
id: 'system-root',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'system-left', appType: null }, size: 20 },
{ node: { type: 'panel', id: 'system-right', appType: null }, size: 80 },
],
};
const splitLayout: LayoutNode = {
type: 'group',
id: 'system-root',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'system-left', appType: null }, size: 20 },
{
node: {
type: 'group',
id: 'system-right-group',
direction: 'vertical',
children: [
{ node: { type: 'panel', id: 'system-right', appType: null }, size: 50 },
{ node: { type: 'panel', id: 'system-terminal', appType: null }, size: 50 },
],
},
size: 80,
},
],
};
const SystemTerminalPanel = () => {
const queryClient = useQueryClient();
const [state, setState] = usePanelChannel<RunCommandState>(RUN_COMMAND_CHANNEL, null);
const [session, setSession] = useState<{ id: string; command: string } | null>(null);
useEffect(() => {
if (state && (!session || session.command !== state.command)) {
setSession({ id: `run-cmd-${Date.now()}`, command: state.command });
} else if (!state) {
setSession(null);
}
}, [state]);
const close = () => setState(null);
const onCommandDone = (exitCode: number, output: string) => {
if (state) {
for (const key of state.refetchKeys) {
queryClient.invalidateQueries({ queryKey: [key] });
}
}
if (exitCode === 0) {
toast.success('Command completed successfully');
} else {
toast.error(output || `Command failed with exit code ${exitCode}`, { duration: 8000 });
}
setState(null);
};
if (!state || !session) return null;
return (
<div className="h-full flex flex-col">
<div className="shrink-0 px-4 py-1.5 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60 flex items-center gap-2">
<span className="text-xs font-medium text-duck-dark/50 dark:text-foreground/50 flex-1">Run Command</span>
<button onClick={close} className="p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors">
<X className="h-3.5 w-3.5 text-duck-dark/50 dark:text-foreground/50" />
</button>
</div>
<TerminalView
className="flex-1"
sandboxed={false}
command={session.command}
sessionId={session.id}
onCommandDone={onCommandDone}
/>
</div>
);
};
export const SystemSettings = () => {
const [runCommand] = usePanelChannel<RunCommandState>(RUN_COMMAND_CHANNEL, null);
const layout = useMemo(() => (runCommand ? splitLayout : baseLayout), [runCommand]);
const panelComponents: PanelComponents = useMemo(
() => ({
'system-left': Sidebar,
'system-right': Content,
'system-terminal': SystemTerminalPanel,
}),
[],
);
return (
<div className="h-full w-full pt-2">
<WorkspaceLayout layout={layout} onLayoutChange={() => {}} registry={appRegistry} components={panelComponents} />
</div>
);
};
// --- AI sections ---
function ChatDefaultsSection() {
const { settings, saveSettings } = useSettings();
const piMonoModels = useVisiblePiMonoModels();
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: { defaultProvider: 'pi-mono', 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]">
{piMonoModels.map((m) => (
<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 { settings, saveSettings } = useSettings();
const piMonoModels = usePiMonoModels();
const [activeProvider, setActiveProvider] = useUserState<string>('model-visibility-provider', '');
const enabledModels = settings.ai?.enabledModels ?? [];
const providerGroups = useMemo(() => {
const groups: Record<string, { id: string; name: string }[]> = {};
for (const m of piMonoModels) {
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)) }));
}, [piMonoModels]);
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 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 (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 saveSettings({ ...settings, ai: { ...settings.ai, enabledModels: [...enabledModels, key] } });
},
[settings, enabledModels, saveSettings],
);
const disableModel = useCallback(
async (key: string) => {
await saveSettings({ ...settings, ai: { ...settings.ai, enabledModels: enabledModels.filter((id) => id !== key) } });
},
[settings, enabledModels, saveSettings],
);
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 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'
}`}
>
{p}
</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>
);
}