fix(pi): add snap node compatibility diagnostics and documentation

- Added detailed error logging to detect snap node compatibility issues
- When Pi process exits with code 1, log helpful diagnostic info including node path
- Add hint to check for snap node and reinstall via apt/nvm
- Create SNAP_NODE_COMPATIBILITY.md with full troubleshooting guide
- Document root cause: snap node has file descriptor incompatibility with Bun.spawn stdin pipes
- Provide clear installation instructions for NodeSource and nvm alternatives
This commit is contained in:
2026-03-04 01:45:36 +00:00
parent 72d1341cbc
commit ef13f96d36
34 changed files with 2394 additions and 1466 deletions
@@ -44,15 +44,12 @@ const PI_PROVIDERS: { key: string; piId: string }[] = [
{ key: 'MiniMax', piId: 'minimax' },
{ key: 'Hugging Face', piId: 'huggingface' },
{ key: 'Azure OpenAI', piId: 'azure-openai-responses' },
{ key: 'OpenCode Zen', piId: 'zai' },
{ key: 'OpenCode Zen', piId: 'opencode' },
{ key: 'ZAI', piId: 'zai' },
{ key: 'Cerebras', piId: 'cerebras' },
];
type ProbeState =
| { step: 'url' }
| { step: 'probing' }
| { step: 'auth'; probe: ProbeResult }
| { step: 'saving' };
type ProbeState = { step: 'url' } | { step: 'probing' } | { step: 'auth'; probe: ProbeResult } | { step: 'saving' };
export const AIHarnessesSection = () => {
const client = useClient();
@@ -119,8 +116,7 @@ export const AIHarnessesSection = () => {
refetchInterval: 30_000,
});
const getStoredMasked = (piId: string) =>
storedKeys.find((k) => k.provider === piId)?.value ?? '';
const getStoredMasked = (piId: string) => storedKeys.find((k) => k.provider === piId)?.value ?? '';
const saveApiKey = async (piId: string) => {
const value = keyInputs[piId];
@@ -142,7 +138,7 @@ export const AIHarnessesSection = () => {
}
};
const disconnectProvider = async (provider: typeof PI_PROVIDERS[number]) => {
const disconnectProvider = async (provider: (typeof PI_PROVIDERS)[number]) => {
await client.put('/server-settings/pi-mono/api-keys', { provider: provider.piId, value: '' });
queryClient.invalidateQueries({ queryKey: ['PI_MONO_API_KEYS'] });
queryClient.invalidateQueries({ queryKey: ['PI_MONO_REMOTE_HEALTH'] });
@@ -160,7 +156,9 @@ export const AIHarnessesSection = () => {
}
};
const handleProbe = async (auth?: { type: 'api-key'; apiKey: string } | { type: 'basic'; username: string; password: string }) => {
const handleProbe = async (
auth?: { type: 'api-key'; apiKey: string } | { type: 'basic'; username: string; password: string },
) => {
setProbeState({ step: 'probing' });
try {
const result = await client.post<ProbeResult>('/server-settings/pi-mono/local-providers/probe', {
@@ -185,9 +183,10 @@ export const AIHarnessesSection = () => {
};
const handleAuthSubmit = async (probe: ProbeResult) => {
const auth = probe.authType === 'basic'
? { type: 'basic' as const, username: authUsername, password: authPassword }
: { type: 'api-key' as const, apiKey: authApiKey };
const auth =
probe.authType === 'basic'
? { type: 'basic' as const, username: authUsername, password: authPassword }
: { type: 'api-key' as const, apiKey: authApiKey };
// Re-probe with credentials to verify they work
setProbeState({ step: 'probing' });
@@ -276,302 +275,306 @@ export const AIHarnessesSection = () => {
</div>
{piMonoVersion?.version && (
<>
{/* Local Providers */}
<div className="mt-6 text-xs text-duck-dark/50">
<span className="text-sm font-medium text-duck-dark dark:text-foreground">Local Providers</span>
<div className="mt-3 flex flex-col gap-2">
{localProviders.map((lp: LocalProviderEntry) => (
<div key={lp.id} className="flex items-center gap-2">
<span
className={`inline-block h-2 w-2 shrink-0 rounded-full ${lp.id in localHealth ? (localHealth[lp.id] ? 'bg-green-500' : 'bg-red-500') : 'bg-duck-dark/20'}`}
title={lp.id in localHealth ? (localHealth[lp.id] ? 'Online' : 'Offline') : 'Checking...'}
/>
<span className="text-duck-dark/70 dark:text-foreground/70 font-medium">{lp.name}</span>
<span className="text-duck-dark/40 dark:text-foreground/40 truncate">{lp.url}</span>
<button
type="button"
onClick={() => removeLocalProvider(lp.id)}
className="shrink-0 p-0.5 rounded hover:bg-red-500/10 cursor-pointer transition-colors"
title="Remove provider"
>
<Trash2 className="h-3 w-3 text-duck-dark/30 hover:text-red-500" />
</button>
</div>
))}
{addingLocal ? (
<div className="flex flex-col gap-2 max-w-sm">
{/* Step 1: Name + URL inputs stacked */}
<Input
type="text"
className="h-8 text-xs"
placeholder="Name (e.g. My Ollama)"
value={localName}
onChange={(ev) => setLocalName(ev.target.value)}
onKeyDown={(ev) => {
if (ev.key === 'Escape') resetLocalForm();
}}
disabled={probeState.step !== 'url'}
autoFocus
/>
<Input
type="url"
className="h-8 text-xs"
placeholder="http://localhost:11434"
value={localUrl}
onChange={(ev) => setLocalUrl(ev.target.value)}
onKeyDown={(ev) => {
if (ev.key === 'Escape') resetLocalForm();
if (ev.key === 'Enter' && localUrl.trim() && probeState.step === 'url') handleProbe();
}}
disabled={probeState.step !== 'url'}
/>
<div className="flex items-center gap-2">
{probeState.step === 'url' && (
<Button
type="button"
size="sm"
className="h-7 text-xs bg-duck-dark text-white hover:bg-duck-dark/90 dark:bg-foreground dark:text-background dark:hover:bg-foreground/90 cursor-pointer disabled:opacity-40"
disabled={!localUrl.trim()}
onClick={() => handleProbe()}
>
Connect
</Button>
)}
{(probeState.step === 'probing' || probeState.step === 'saving') && (
<Loader2 className="h-4 w-4 animate-spin text-duck-teal shrink-0" />
)}
<button
type="button"
onClick={resetLocalForm}
className="text-duck-dark/40 hover:text-duck-dark/70 dark:text-foreground/40 dark:hover:text-foreground/70 cursor-pointer transition-colors text-xs"
>
Cancel
</button>
</div>
{/* Step 2: Auth form (if needed) */}
{probeState.step === 'auth' && (
<div className="flex flex-col gap-2 rounded-md border border-duck-dark/10 dark:border-foreground/10 p-3">
<span className="text-duck-dark/70 dark:text-foreground/70 font-medium">
{probeState.probe.name} requires authentication
</span>
{probeState.probe.authType === 'basic' ? (
<>
<Input
type="text"
className="h-8 text-xs"
placeholder="Username"
value={authUsername}
onChange={(ev) => setAuthUsername(ev.target.value)}
autoFocus
/>
<Input
type="password"
className="h-8 text-xs"
placeholder="Password"
value={authPassword}
onChange={(ev) => setAuthPassword(ev.target.value)}
onKeyDown={(ev) => {
if (ev.key === 'Enter' && authUsername && authPassword) handleAuthSubmit(probeState.probe);
}}
/>
<Button
type="button"
size="sm"
className="h-7 text-xs w-fit bg-duck-dark text-white hover:bg-duck-dark/90 dark:bg-foreground dark:text-background dark:hover:bg-foreground/90 cursor-pointer disabled:opacity-40"
disabled={!authUsername || !authPassword}
onClick={() => handleAuthSubmit(probeState.probe)}
>
Authenticate
</Button>
</>
) : (
<>
<Input
type="password"
className="h-8 text-xs"
placeholder="API Key"
value={authApiKey}
onChange={(ev) => setAuthApiKey(ev.target.value)}
onKeyDown={(ev) => {
if (ev.key === 'Enter' && authApiKey) handleAuthSubmit(probeState.probe);
}}
autoFocus
/>
<Button
type="button"
size="sm"
className="h-7 text-xs w-fit bg-duck-dark text-white hover:bg-duck-dark/90 dark:bg-foreground dark:text-background dark:hover:bg-foreground/90 cursor-pointer disabled:opacity-40"
disabled={!authApiKey}
onClick={() => handleAuthSubmit(probeState.probe)}
>
Authenticate
</Button>
</>
)}
</div>
)}
</div>
) : (
<Button
type="button"
variant="outline"
size="sm"
className="w-fit cursor-pointer"
onClick={() => setAddingLocal(true)}
>
<Plus className="h-3.5 w-3.5 mr-1.5" />
Connect Local Provider
</Button>
)}
</div>
</div>
{/* Remote Providers */}
<div className="mt-6 text-xs text-duck-dark/50">
<span className="text-sm font-medium text-duck-dark dark:text-foreground">Remote Providers</span>
<div className="mt-3 flex flex-col gap-2">
{connectedProviders.map((provider) => (
<div key={provider.piId} className="flex flex-col gap-2">
<div className="flex items-center gap-2">
{/* Local Providers */}
<div className="mt-6 text-xs text-duck-dark/50">
<span className="text-sm font-medium text-duck-dark dark:text-foreground">Local Providers</span>
<div className="mt-3 flex flex-col gap-2">
{localProviders.map((lp: LocalProviderEntry) => (
<div key={lp.id} className="flex items-center gap-2">
<span
className={`inline-block h-2 w-2 shrink-0 rounded-full ${
remoteHealth[provider.piId] === true
? 'bg-green-500'
: remoteHealth[provider.piId] === false
? 'bg-red-500'
: 'bg-duck-dark/20'
}`}
title={
remoteHealth[provider.piId] === true
? 'Valid'
: remoteHealth[provider.piId] === false
? 'Invalid'
: 'Unknown'
}
className={`inline-block h-2 w-2 shrink-0 rounded-full ${lp.id in localHealth ? (localHealth[lp.id] ? 'bg-green-500' : 'bg-red-500') : 'bg-duck-dark/20'}`}
title={lp.id in localHealth ? (localHealth[lp.id] ? 'Online' : 'Offline') : 'Checking...'}
/>
<span className="text-duck-dark/70 dark:text-foreground/70 font-medium">{provider.key}</span>
<span className="text-duck-dark/70 dark:text-foreground/70 font-medium">{lp.name}</span>
<span className="text-duck-dark/40 dark:text-foreground/40 truncate">{lp.url}</span>
<button
type="button"
onClick={() => setEditingProvider(editingProvider === provider.key ? null : provider.key)}
className="shrink-0 p-0.5 rounded hover:bg-duck-teal/10 cursor-pointer transition-colors"
title="Edit key"
>
<Pencil className="h-3 w-3 text-duck-dark/30 hover:text-duck-teal" />
</button>
<button
type="button"
onClick={() => disconnectProvider(provider)}
onClick={() => removeLocalProvider(lp.id)}
className="shrink-0 p-0.5 rounded hover:bg-red-500/10 cursor-pointer transition-colors"
title="Disconnect provider"
title="Remove provider"
>
<Trash2 className="h-3 w-3 text-duck-dark/30 hover:text-red-500" />
</button>
</div>
{editingProvider === provider.key && (
<div className="flex items-center gap-2 pl-4">
<Input
type="password"
className="h-7 text-xs flex-1"
placeholder={getStoredMasked(provider.piId) || 'Enter API key'}
value={keyInputs[provider.piId] ?? ''}
onChange={(ev) => setKeyInputs((prev) => ({ ...prev, [provider.piId]: ev.target.value }))}
onKeyDown={(ev) => {
if (ev.key === 'Enter' && keyInputs[provider.piId]) saveApiKey(provider.piId);
if (ev.key === 'Escape') setEditingProvider(null);
}}
autoFocus
/>
))}
{addingLocal ? (
<div className="flex flex-col gap-2 max-w-sm">
{/* Step 1: Name + URL inputs stacked */}
<Input
type="text"
className="h-8 text-xs"
placeholder="Name (e.g. My Ollama)"
value={localName}
onChange={(ev) => setLocalName(ev.target.value)}
onKeyDown={(ev) => {
if (ev.key === 'Escape') resetLocalForm();
}}
disabled={probeState.step !== 'url'}
autoFocus
/>
<Input
type="url"
className="h-8 text-xs"
placeholder="http://localhost:11434"
value={localUrl}
onChange={(ev) => setLocalUrl(ev.target.value)}
onKeyDown={(ev) => {
if (ev.key === 'Escape') resetLocalForm();
if (ev.key === 'Enter' && localUrl.trim() && probeState.step === 'url') handleProbe();
}}
disabled={probeState.step !== 'url'}
/>
<div className="flex items-center gap-2">
{probeState.step === 'url' && (
<Button
type="button"
size="sm"
className="h-7 text-xs bg-duck-dark text-white hover:bg-duck-dark/90 dark:bg-foreground dark:text-background dark:hover:bg-foreground/90 cursor-pointer disabled:opacity-40"
disabled={!localUrl.trim()}
onClick={() => handleProbe()}
>
Connect
</Button>
)}
{(probeState.step === 'probing' || probeState.step === 'saving') && (
<Loader2 className="h-4 w-4 animate-spin text-duck-teal shrink-0" />
)}
<button
type="button"
disabled={keyInputs[provider.piId] === undefined || savingKey === provider.piId}
onClick={() => saveApiKey(provider.piId)}
className="shrink-0 p-1 rounded hover:bg-duck-teal/10 cursor-pointer transition-colors disabled:opacity-30 disabled:cursor-default"
title="Save key"
>
<Save className="h-3.5 w-3.5 text-duck-teal" />
</button>
<button
type="button"
onClick={() => setEditingProvider(null)}
onClick={resetLocalForm}
className="text-duck-dark/40 hover:text-duck-dark/70 dark:text-foreground/40 dark:hover:text-foreground/70 cursor-pointer transition-colors text-xs"
>
Cancel
</button>
</div>
)}
</div>
))}
{editingProvider && (() => {
const provider = PI_PROVIDERS.find((p) => p.key === editingProvider);
if (!provider || connectedProviders.some((cp) => cp.piId === provider.piId)) return null;
return (
<div key={provider.piId} className="flex items-center gap-2">
<label className="w-36 text-duck-dark/70 dark:text-foreground/70 shrink-0 truncate font-medium text-[11px]">{provider.key}</label>
<Input
type="password"
className="h-7 text-xs flex-1"
placeholder="Enter API key"
value={keyInputs[provider.piId] ?? ''}
onChange={(ev) => setKeyInputs((prev) => ({ ...prev, [provider.piId]: ev.target.value }))}
onKeyDown={(ev) => {
if (ev.key === 'Enter' && keyInputs[provider.piId]) saveApiKey(provider.piId);
if (ev.key === 'Escape') setEditingProvider(null);
}}
autoFocus
/>
<button
type="button"
disabled={keyInputs[provider.piId] === undefined || savingKey === provider.piId}
onClick={() => saveApiKey(provider.piId)}
className="shrink-0 p-1 rounded hover:bg-duck-teal/10 cursor-pointer transition-colors disabled:opacity-30 disabled:cursor-default"
title="Save key"
>
<Save className="h-3.5 w-3.5 text-duck-teal" />
</button>
<button
type="button"
onClick={() => setEditingProvider(null)}
className="text-duck-dark/40 hover:text-duck-dark/70 dark:text-foreground/40 dark:hover:text-foreground/70 cursor-pointer transition-colors text-xs"
>
Cancel
</button>
{/* Step 2: Auth form (if needed) */}
{probeState.step === 'auth' && (
<div className="flex flex-col gap-2 rounded-md border border-duck-dark/10 dark:border-foreground/10 p-3">
<span className="text-duck-dark/70 dark:text-foreground/70 font-medium">
{probeState.probe.name} requires authentication
</span>
{probeState.probe.authType === 'basic' ? (
<>
<Input
type="text"
className="h-8 text-xs"
placeholder="Username"
value={authUsername}
onChange={(ev) => setAuthUsername(ev.target.value)}
autoFocus
/>
<Input
type="password"
className="h-8 text-xs"
placeholder="Password"
value={authPassword}
onChange={(ev) => setAuthPassword(ev.target.value)}
onKeyDown={(ev) => {
if (ev.key === 'Enter' && authUsername && authPassword)
handleAuthSubmit(probeState.probe);
}}
/>
<Button
type="button"
size="sm"
className="h-7 text-xs w-fit bg-duck-dark text-white hover:bg-duck-dark/90 dark:bg-foreground dark:text-background dark:hover:bg-foreground/90 cursor-pointer disabled:opacity-40"
disabled={!authUsername || !authPassword}
onClick={() => handleAuthSubmit(probeState.probe)}
>
Authenticate
</Button>
</>
) : (
<>
<Input
type="password"
className="h-8 text-xs"
placeholder="API Key"
value={authApiKey}
onChange={(ev) => setAuthApiKey(ev.target.value)}
onKeyDown={(ev) => {
if (ev.key === 'Enter' && authApiKey) handleAuthSubmit(probeState.probe);
}}
autoFocus
/>
<Button
type="button"
size="sm"
className="h-7 text-xs w-fit bg-duck-dark text-white hover:bg-duck-dark/90 dark:bg-foreground dark:text-background dark:hover:bg-foreground/90 cursor-pointer disabled:opacity-40"
disabled={!authApiKey}
onClick={() => handleAuthSubmit(probeState.probe)}
>
Authenticate
</Button>
</>
)}
</div>
)}
</div>
);
})()}
{unconnectedProviders.length > 0 && (
<Button
type="button"
variant="outline"
size="sm"
className="mt-1 w-fit cursor-pointer"
onClick={() => setCommandOpen(true)}
>
<Plus className="h-3.5 w-3.5 mr-1.5" />
Connect Provider
</Button>
)}
) : (
<Button
type="button"
variant="outline"
size="sm"
className="w-fit cursor-pointer"
onClick={() => setAddingLocal(true)}
>
<Plus className="h-3.5 w-3.5 mr-1.5" />
Connect Local Provider
</Button>
)}
</div>
</div>
{/* Remote Providers */}
<div className="mt-6 text-xs text-duck-dark/50">
<span className="text-sm font-medium text-duck-dark dark:text-foreground">Remote Providers</span>
<div className="mt-3 flex flex-col gap-2">
{connectedProviders.map((provider) => (
<div key={provider.piId} className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<span
className={`inline-block h-2 w-2 shrink-0 rounded-full ${
remoteHealth[provider.piId] === true
? 'bg-green-500'
: remoteHealth[provider.piId] === false
? 'bg-red-500'
: 'bg-duck-dark/20'
}`}
title={
remoteHealth[provider.piId] === true
? 'Valid'
: remoteHealth[provider.piId] === false
? 'Invalid'
: 'Unknown'
}
/>
<span className="text-duck-dark/70 dark:text-foreground/70 font-medium">{provider.key}</span>
<button
type="button"
onClick={() => setEditingProvider(editingProvider === provider.key ? null : provider.key)}
className="shrink-0 p-0.5 rounded hover:bg-duck-teal/10 cursor-pointer transition-colors"
title="Edit key"
>
<Pencil className="h-3 w-3 text-duck-dark/30 hover:text-duck-teal" />
</button>
<button
type="button"
onClick={() => disconnectProvider(provider)}
className="shrink-0 p-0.5 rounded hover:bg-red-500/10 cursor-pointer transition-colors"
title="Disconnect provider"
>
<Trash2 className="h-3 w-3 text-duck-dark/30 hover:text-red-500" />
</button>
</div>
{editingProvider === provider.key && (
<div className="flex items-center gap-2 pl-4">
<Input
type="password"
className="h-7 text-xs flex-1"
placeholder={getStoredMasked(provider.piId) || 'Enter API key'}
value={keyInputs[provider.piId] ?? ''}
onChange={(ev) => setKeyInputs((prev) => ({ ...prev, [provider.piId]: ev.target.value }))}
onKeyDown={(ev) => {
if (ev.key === 'Enter' && keyInputs[provider.piId]) saveApiKey(provider.piId);
if (ev.key === 'Escape') setEditingProvider(null);
}}
autoFocus
/>
<button
type="button"
disabled={keyInputs[provider.piId] === undefined || savingKey === provider.piId}
onClick={() => saveApiKey(provider.piId)}
className="shrink-0 p-1 rounded hover:bg-duck-teal/10 cursor-pointer transition-colors disabled:opacity-30 disabled:cursor-default"
title="Save key"
>
<Save className="h-3.5 w-3.5 text-duck-teal" />
</button>
<button
type="button"
onClick={() => setEditingProvider(null)}
className="text-duck-dark/40 hover:text-duck-dark/70 dark:text-foreground/40 dark:hover:text-foreground/70 cursor-pointer transition-colors text-xs"
>
Cancel
</button>
</div>
)}
</div>
))}
{editingProvider &&
(() => {
const provider = PI_PROVIDERS.find((p) => p.key === editingProvider);
if (!provider || connectedProviders.some((cp) => cp.piId === provider.piId)) return null;
return (
<div key={provider.piId} className="flex items-center gap-2">
<label className="w-36 text-duck-dark/70 dark:text-foreground/70 shrink-0 truncate font-medium text-[11px]">
{provider.key}
</label>
<Input
type="password"
className="h-7 text-xs flex-1"
placeholder="Enter API key"
value={keyInputs[provider.piId] ?? ''}
onChange={(ev) => setKeyInputs((prev) => ({ ...prev, [provider.piId]: ev.target.value }))}
onKeyDown={(ev) => {
if (ev.key === 'Enter' && keyInputs[provider.piId]) saveApiKey(provider.piId);
if (ev.key === 'Escape') setEditingProvider(null);
}}
autoFocus
/>
<button
type="button"
disabled={keyInputs[provider.piId] === undefined || savingKey === provider.piId}
onClick={() => saveApiKey(provider.piId)}
className="shrink-0 p-1 rounded hover:bg-duck-teal/10 cursor-pointer transition-colors disabled:opacity-30 disabled:cursor-default"
title="Save key"
>
<Save className="h-3.5 w-3.5 text-duck-teal" />
</button>
<button
type="button"
onClick={() => setEditingProvider(null)}
className="text-duck-dark/40 hover:text-duck-dark/70 dark:text-foreground/40 dark:hover:text-foreground/70 cursor-pointer transition-colors text-xs"
>
Cancel
</button>
</div>
);
})()}
{unconnectedProviders.length > 0 && (
<Button
type="button"
variant="outline"
size="sm"
className="mt-1 w-fit cursor-pointer"
onClick={() => setCommandOpen(true)}
>
<Plus className="h-3.5 w-3.5 mr-1.5" />
Connect Provider
</Button>
)}
</div>
<CommandDialog open={commandOpen} onOpenChange={setCommandOpen}>
<CommandInput placeholder="Search providers..." />
<CommandList>
<CommandEmpty>No providers found.</CommandEmpty>
<CommandGroup heading="Available Providers">
{unconnectedProviders.map((provider) => (
<CommandItem
key={provider.key}
onSelect={() => {
setEditingProvider(provider.key);
setCommandOpen(false);
}}
>
<span>{provider.key}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</CommandDialog>
</div>
<CommandDialog open={commandOpen} onOpenChange={setCommandOpen}>
<CommandInput placeholder="Search providers..." />
<CommandList>
<CommandEmpty>No providers found.</CommandEmpty>
<CommandGroup heading="Available Providers">
{unconnectedProviders.map((provider) => (
<CommandItem
key={provider.key}
onSelect={() => {
setEditingProvider(provider.key);
setCommandOpen(false);
}}
>
<span>{provider.key}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</CommandDialog>
</div>
</>
)}
</div>
@@ -30,6 +30,7 @@ const PROVIDER_DISPLAY: Record<string, string> = {
anthropic: 'Anthropic',
openai: 'OpenAI',
opencode: 'OpenCode Zen',
zai: 'ZAI',
google: 'Google',
groq: 'Groq',
mistral: 'Mistral',
@@ -52,12 +53,42 @@ 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, title: 'Text to Speech', description: 'TTS provider and voice', content: <TTSSection /> },
{
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,
title: 'Text to Speech',
description: 'TTS provider and voice',
content: <TTSSection />,
},
{ key: 'stt', icon: Mic, title: 'Speech to Text', description: 'Whisper server URL', content: <STTSection /> },
{ key: 'ocr', icon: ScanText, title: 'OCR', description: 'Vision model for text extraction', content: <OCRSection /> },
{
key: 'ocr',
icon: ScanText,
title: 'OCR',
description: 'Vision model for text extraction',
content: <OCRSection />,
},
],
},
{
@@ -142,7 +173,10 @@ const SystemTerminalPanel = () => {
<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">
<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>
@@ -325,7 +359,9 @@ const DropZone = ({ label, children, onDrop }: DropZoneProps) => {
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>
<span className="text-xs font-medium text-duck-dark/50 dark:text-foreground/50 uppercase tracking-wide">
{label}
</span>
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
@@ -376,7 +412,7 @@ function ModelVisibilitySection() {
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 selected = providers.includes(activeProvider) ? activeProvider : (providers[0] ?? '');
const currentGroup = providerGroups.find((g) => g.provider === selected);
@@ -430,7 +466,11 @@ function ModelVisibilitySection() {
}, []);
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 (
<p className="text-sm text-duck-dark/40 dark:text-foreground/40">
No models available. Configure API keys in AI Settings.
</p>
);
}
return (
@@ -463,17 +503,35 @@ function ModelVisibilitySection() {
{/* 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.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} />
<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.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} />
<ModelPill
key={m.key}
label={m.name}
modelKey={m.key}
enabled={false}
onAction={enableModel}
onDragStart={onDragStart}
/>
))}
</DropZone>
</div>