407 lines
15 KiB
TypeScript
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>
|
|
);
|
|
}
|