This commit is contained in:
2026-02-20 04:32:50 +00:00
parent d7503ca56b
commit d9f6eb14ed
24 changed files with 1426 additions and 1183 deletions
-4
View File
@@ -43,10 +43,6 @@ export function App() {
<Route path="/chat" element={<Dashboard.SessionListPage />} />
<Route path="/chat/new" element={<Dashboard.SessionListPage isNew />} />
<Route path="/chat/:sessionId" element={<Dashboard.SessionListPage />} />
<Route path="/chat/opencode/new" element={<Dashboard.SessionListPage provider="opencode" isNew />} />
<Route path="/chat/opencode/:sessionId" element={<Dashboard.SessionListPage provider="opencode" />} />
<Route path="/chat/pi-mono/new" element={<Dashboard.SessionListPage provider="pi-mono" isNew />} />
<Route path="/chat/pi-mono/:sessionId" element={<Dashboard.SessionListPage provider="pi-mono" />} />
<Route path="/plans" element={<Dashboard.Plans />} />
<Route path="/files" element={<Dashboard.FilesPage />} />
@@ -1,4 +1,4 @@
import { useState, useEffect, useRef, useMemo } from 'react';
import { useState, useEffect, useRef } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
@@ -8,12 +8,8 @@ import { ArrowLeft, Pencil, Plus, Check, X, Trash2, Search, ChevronRight } from
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { useClient } from 'hooks/useClient';
import { useVisibleClaudeModels, useVisibleOpenCodeModels, useVisiblePiMonoModels } from '@/state/useModels';
import { useSettings } from '@/state/useSettings';
import { useVisiblePiMonoModels } from '@/state/useModels';
import { Card } from '@/components/Card';
import type { ChatMessage } from 'apps/Chat';
import { useClaude } from '@/Screens/Dashboard/Chat/useClaude';
import { useOpenCode } from '@/Screens/Dashboard/Chat/useOpenCode';
import { usePiMono } from '@/Screens/Dashboard/Chat/usePiMono';
import { EmbeddableChat } from '@/Screens/Dashboard/Chat/EmbeddableChat';
type CapabilitySummary = {
@@ -60,130 +56,14 @@ type CapabilityChatProps = {
onResponseEnd?: () => void;
};
type CapabilityChatInnerProps = CapabilityChatProps & {
onProviderChange: (p: 'claude' | 'opencode' | 'pi-mono') => void;
};
const CapabilityChatClaude = ({
kind,
endpoint,
dirName,
filePath,
resourceDir,
chatSessionId,
isNew,
description,
onResponseEnd,
onProviderChange,
}: CapabilityChatInnerProps) => {
const client = useClient();
const claudeModels = useVisibleClaudeModels();
const seedFile = `${kind.toUpperCase()}.md`;
const promptFrontmatter = chatSessionId
? undefined
: `<frontmatter>\ninput file: ${filePath}\n${seedFile}: ${filePath}\ndir: ${resourceDir}\n\nBe aware of any extra files alongside the same dir as the ${kind} file we're handling, for possible extra context. You can also, if pertinent, create scripts or other files that will help you in the future.\n</frontmatter>`;
const defaultInput = chatSessionId
? undefined
: isNew
? description ?? `Help me create the content for this new ${kind} file`
: `Help me understand and improve this ${kind} file`;
const storage = useMemo(
() => ({
load: async () => {
const data = await client.get<{ sessionId: string | null; messages: ChatMessage[] }>(
`${endpoint}/${dirName}/chat`,
);
return { sessionId: data.sessionId, messages: data.messages ?? [] };
},
save: async (sessionId: string, messages: ChatMessage[]) => {
await client.put(`${endpoint}/${dirName}/chat`, { sessionId, messages });
},
}),
[endpoint, dirName],
);
const claude = useClaude(chatSessionId ?? undefined, undefined, {
replaceUrl: false,
storage,
resourceChatDir: resourceDir,
});
const onResponseEndRef = useRef(onResponseEnd);
onResponseEndRef.current = onResponseEnd;
const wasGenerating = useRef(false);
useEffect(() => {
if (wasGenerating.current && !claude.isGenerating) {
onResponseEndRef.current?.();
}
wasGenerating.current = claude.isGenerating;
}, [claude.isGenerating]);
return (
<EmbeddableChat
chat={claude}
provider="claude"
availableModels={claudeModels}
onProviderChange={onProviderChange}
defaultInput={defaultInput}
promptPrefix={promptFrontmatter}
className="h-full"
/>
);
};
const CapabilityChatOpenCode = ({
export const CapabilityChat = ({
kind,
filePath,
resourceDir,
isNew,
description,
onResponseEnd,
onProviderChange,
}: CapabilityChatInnerProps) => {
const openCodeModels = useVisibleOpenCodeModels();
const seedFile = `${kind.toUpperCase()}.md`;
const promptFrontmatter = `<frontmatter>\ninput file: ${filePath}\n${seedFile}: ${filePath}\ndir: ${resourceDir}\n\nBe aware of any extra files alongside the same dir as the ${kind} file we're handling, for possible extra context. You can also, if pertinent, create scripts or other files that will help you in the future.\n</frontmatter>`;
const defaultInput = isNew
? description ?? `Help me create the content for this new ${kind} file`
: `Help me understand and improve this ${kind} file`;
const opencode = useOpenCode(undefined, undefined, { replaceUrl: false });
const onResponseEndRef = useRef(onResponseEnd);
onResponseEndRef.current = onResponseEnd;
const wasGenerating = useRef(false);
useEffect(() => {
if (wasGenerating.current && !opencode.isGenerating) {
onResponseEndRef.current?.();
}
wasGenerating.current = opencode.isGenerating;
}, [opencode.isGenerating]);
return (
<EmbeddableChat
chat={opencode}
provider="opencode"
availableModels={openCodeModels}
onProviderChange={onProviderChange}
defaultInput={defaultInput}
promptPrefix={promptFrontmatter}
className="h-full"
/>
);
};
const CapabilityChatPiMono = ({
kind,
filePath,
resourceDir,
isNew,
description,
onResponseEnd,
onProviderChange,
}: CapabilityChatInnerProps) => {
}: CapabilityChatProps) => {
const piMonoModels = useVisiblePiMonoModels();
const seedFile = `${kind.toUpperCase()}.md`;
const promptFrontmatter = `<frontmatter>\ninput file: ${filePath}\n${seedFile}: ${filePath}\ndir: ${resourceDir}\n\nBe aware of any extra files alongside the same dir as the ${kind} file we're handling, for possible extra context. You can also, if pertinent, create scripts or other files that will help you in the future.\n</frontmatter>`;
@@ -207,9 +87,7 @@ const CapabilityChatPiMono = ({
return (
<EmbeddableChat
chat={piMono}
provider="pi-mono"
availableModels={piMonoModels}
onProviderChange={onProviderChange}
defaultInput={defaultInput}
promptPrefix={promptFrontmatter}
className="h-full"
@@ -217,19 +95,6 @@ const CapabilityChatPiMono = ({
);
};
export const CapabilityChat = (props: CapabilityChatProps) => {
const { settings } = useSettings();
const [provider, setProvider] = useState<'claude' | 'opencode' | 'pi-mono'>(settings.chat.defaultProvider);
if (provider === 'claude') {
return <CapabilityChatClaude key="claude" {...props} onProviderChange={setProvider} />;
}
if (provider === 'opencode') {
return <CapabilityChatOpenCode key="opencode" {...props} onProviderChange={setProvider} />;
}
return <CapabilityChatPiMono key="pi-mono" {...props} onProviderChange={setProvider} />;
};
export const FrontmatterBlock = ({ yaml }: { yaml: string }) => {
const [open, setOpen] = useState(false);
@@ -14,10 +14,9 @@ type ChatPanelProps = {
chat: ReturnType<typeof useClaude>;
provider?: 'claude' | 'opencode' | 'pi-mono';
availableModels?: ModelOption[];
onProviderChange?: (provider: 'claude' | 'opencode' | 'pi-mono') => void;
};
export const ChatPanel = ({ chat, provider = 'claude', availableModels = [], onProviderChange }: ChatPanelProps) => {
export const ChatPanel = ({ chat, provider = 'claude', availableModels = [] }: ChatPanelProps) => {
const { isConnected, isGenerating, sessionId, setSelectedModel, sendPrompt } = chat;
const location = useLocation();
@@ -102,9 +101,7 @@ export const ChatPanel = ({ chat, provider = 'claude', availableModels = [], onP
<EmbeddableChat
chat={chat}
provider={provider}
availableModels={availableModels}
onProviderChange={onProviderChange}
onBeforeSend={handleBeforeSend}
commandFeedback={commandFeedback}
defaultInput={initialPrefill.current}
@@ -13,9 +13,7 @@ export type Attachment =
type EmbeddableChatProps = {
chat: ReturnType<typeof useClaude>;
provider?: 'claude' | 'opencode' | 'pi-mono';
availableModels?: ModelOption[];
onProviderChange?: (provider: 'claude' | 'opencode' | 'pi-mono') => void;
onBeforeSend?: (text: string) => boolean | Promise<boolean>;
commandFeedback?: string | null;
defaultInput?: string;
@@ -27,9 +25,7 @@ type EmbeddableChatProps = {
export const EmbeddableChat = ({
chat,
provider = 'claude',
availableModels = [],
onProviderChange,
onBeforeSend,
commandFeedback = null,
defaultInput = '',
@@ -71,7 +67,7 @@ export const EmbeddableChat = ({
const res = await client.post<{ url: string; title: string; content: string; attachmentId: string }>('/scrape', {
url,
sessionId: sessionId ?? undefined,
provider,
provider: 'pi-mono',
});
setAttachments((prev) =>
prev.map((a, i) =>
@@ -97,7 +93,7 @@ export const EmbeddableChat = ({
const formData = new FormData();
formData.append('file', file);
if (sessionId) formData.append('sessionId', sessionId);
formData.append('provider', provider);
formData.append('provider', 'pi-mono');
const res = await client.post<{ filename: string; dataUrl: string; attachmentId: string }>('/upload', formData);
setAttachments((prev) =>
@@ -239,9 +235,7 @@ export const EmbeddableChat = ({
isConnected={isConnected}
commandFeedback={commandFeedback}
textareaRef={textareaRef}
provider={provider}
messages={messages}
onProviderChange={onProviderChange}
availableModels={availableModels}
selectedModel={selectedModel}
onModelChange={setSelectedModel}
@@ -61,9 +61,7 @@ type InputAreaProps = {
isConnected: boolean;
commandFeedback: string | null;
textareaRef: RefObject<HTMLTextAreaElement | null>;
provider: 'claude' | 'opencode' | 'pi-mono';
messages: ChatMessage[];
onProviderChange?: (provider: 'claude' | 'opencode' | 'pi-mono') => void;
availableModels: ModelOption[];
selectedModel: string | null;
onModelChange: (modelId: string) => void;
@@ -84,9 +82,7 @@ export const InputArea = ({
isConnected,
commandFeedback,
textareaRef,
provider,
messages,
onProviderChange,
availableModels,
selectedModel,
onModelChange,
@@ -314,9 +310,7 @@ export const InputArea = ({
)}
</div>
<Settings
provider={provider}
messages={messages}
onProviderChange={onProviderChange}
availableModels={availableModels}
selectedModel={selectedModel}
onModelChange={onModelChange}
@@ -1,13 +1,27 @@
import { useMemo } from 'react';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useAuth } from 'hooks/useAuth';
import type { ModelOption } from '@/state/useModels';
import type { ChatMessage } from 'apps/Chat';
import { OpenCodeModelPicker } from './OpenCodeModelPicker';
const PROVIDER_DISPLAY: Record<string, string> = {
anthropic: 'Anthropic',
openai: 'OpenAI',
opencode: 'OpenCode Zen',
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',
};
type SettingsProps = {
provider: 'claude' | 'opencode' | 'pi-mono';
messages: ChatMessage[];
onProviderChange?: (provider: 'claude' | 'opencode' | 'pi-mono') => void;
availableModels: ModelOption[];
selectedModel: string | null;
onModelChange: (modelId: string) => void;
@@ -17,9 +31,7 @@ type SettingsProps = {
};
export const Settings = ({
provider,
messages,
onProviderChange,
availableModels,
selectedModel,
onModelChange,
@@ -27,41 +39,47 @@ export const Settings = ({
isConnected,
isGenerating,
}: SettingsProps) => {
const { user } = useAuth();
const providers = useMemo(
() => [...new Set(availableModels.map((m) => m.provider).filter(Boolean))] as string[],
[availableModels],
);
const fallbackModelId = availableModels[0]?.id ?? null;
const activeProvider = availableModels.find((m) => m.id === selectedModel)?.provider ?? providers[0];
const providerModels = availableModels.filter((m) => m.provider === activeProvider);
const fallbackModelId = providerModels[0]?.id ?? null;
const handleProviderClick = (provider: string) => {
const firstModel = availableModels.find((m) => m.provider === provider);
if (firstModel) onModelChange(firstModel.id);
};
const displayName = (provider: string) => PROVIDER_DISPLAY[provider] ?? provider;
return (
<div className="flex items-center justify-between mt-2">
{messages.length > 0 ? (
<span className="rounded-md bg-duck-dark/80 px-3 py-1 text-xs font-medium text-white">
{provider === 'claude' ? 'Claude' : provider === 'opencode' ? 'OpenCode' : 'Pi'}
{activeProvider ? displayName(activeProvider) : 'Pi'}
</span>
) : (
<div className="flex items-center gap-1 rounded-lg bg-background/60 p-1">
{(['claude', 'opencode', 'pi-mono'] as const).map((value) => (
{providers.map((provider) => (
<button
key={value}
onClick={() => onProviderChange?.(value)}
className={`rounded-md px-3 py-1 text-xs font-medium transition-colors ${
provider === value ? 'bg-background text-duck-dark shadow-sm' : 'text-duck-dark/70 hover:text-duck-dark/90'
} ${!onProviderChange ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}`}
key={provider}
onClick={() => handleProviderClick(provider)}
className={`rounded-md px-3 py-1 text-xs font-medium transition-colors cursor-pointer ${
activeProvider === provider
? 'bg-background text-duck-dark shadow-sm'
: 'text-duck-dark/70 hover:text-duck-dark/90'
}`}
>
{value === 'claude' ? 'Claude' : value === 'opencode' ? 'OpenCode' : 'Pi'}
{displayName(provider)}
</button>
))}
</div>
)}
<div className="text-xs text-duck-dark/50">
{availableModels.length > 0 && provider === 'opencode' ? (
<OpenCodeModelPicker
models={availableModels}
selectedModel={selectedModel ?? fallbackModelId}
onSelect={onModelChange}
isConnected={isConnected}
isGenerating={isGenerating}
/>
) : availableModels.length > 0 ? (
{providerModels.length > 0 ? (
<Select
value={selectedModel ?? fallbackModelId ?? undefined}
onValueChange={(v) => onModelChange(v)}
@@ -71,7 +89,7 @@ export const Settings = ({
<SelectValue />
</SelectTrigger>
<SelectContent className="z-[800]" side="top">
{availableModels.map((m) => (
{providerModels.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.name}
</SelectItem>
@@ -79,7 +97,7 @@ export const Settings = ({
</SelectContent>
</Select>
) : (
<span>{model ?? (provider === 'claude' ? 'Claude' : 'OpenCode')}</span>
<span>{model ?? 'Pi'}</span>
)}
</div>
</div>
@@ -53,7 +53,7 @@ export const usePiMono = (initialSessionId?: string, initialModel?: string | nul
sessionIdRef.current = msg.sessionId;
setSessionId(msg.sessionId);
setModel(msg.model);
if (replaceUrl) window.history.replaceState(null, '', `/chat/pi-mono/${msg.sessionId}`);
if (replaceUrl) window.history.replaceState(null, '', `/chat/${msg.sessionId}`);
break;
case 'system:prompt':
@@ -118,6 +118,13 @@ export const usePiMono = (initialSessionId?: string, initialModel?: string | nul
const { isConnected, send } = useChatWebSocket({ url: wsUrl, onMessage: handleMessage });
// Attach to an existing server-side session on reconnect
useEffect(() => {
if (isConnected && initialSessionId) {
send({ type: 'resume', sessionId: initialSessionId });
}
}, [isConnected, initialSessionId]);
// Load messages from server on mount when resuming a session
useEffect(() => {
if (!initialSessionId) return;
@@ -108,7 +108,7 @@ const ClaudeInner = ({ sessionId, model }: InnerProps) => {
window.history.replaceState(null, '', '/chat');
}}
/>
<EmbeddableChat chat={chat} provider="claude" availableModels={models} className="flex-1 min-h-0" />
<EmbeddableChat chat={chat} availableModels={models} className="flex-1 min-h-0" />
</div>
);
};
@@ -134,7 +134,7 @@ const OpenCodeInner = ({ sessionId, model }: InnerProps) => {
window.history.replaceState(null, '', '/chat');
}}
/>
<EmbeddableChat chat={chat} provider="opencode" availableModels={models} className="flex-1 min-h-0" />
<EmbeddableChat chat={chat} availableModels={models} className="flex-1 min-h-0" />
</div>
);
};
@@ -160,12 +160,12 @@ const PiMonoInner = ({ sessionId, model }: InnerProps) => {
window.history.replaceState(null, '', '/chat');
}}
/>
<EmbeddableChat chat={chat} provider="pi-mono" availableModels={models} className="flex-1 min-h-0" />
<EmbeddableChat chat={chat} availableModels={models} className="flex-1 min-h-0" />
</div>
);
};
const NewClaudeInner = ({ onProviderChange }: { onProviderChange: (p: 'claude' | 'opencode' | 'pi-mono') => void }) => {
const NewClaudeInner = () => {
const location = useLocation();
const locationState = location.state as ChatLocationState;
const initialSentRef = useRef(false);
@@ -205,9 +205,7 @@ const NewClaudeInner = ({ onProviderChange }: { onProviderChange: (p: 'claude' |
/>
<EmbeddableChat
chat={chat}
provider="claude"
availableModels={models}
onProviderChange={onProviderChange}
defaultInput={locationState?.prefillInput ?? ''}
cwd={locationState?.cwd}
className="flex-1 min-h-0"
@@ -216,7 +214,7 @@ const NewClaudeInner = ({ onProviderChange }: { onProviderChange: (p: 'claude' |
);
};
const NewOpenCodeInner = ({ onProviderChange }: { onProviderChange: (p: 'claude' | 'opencode' | 'pi-mono') => void }) => {
const NewOpenCodeInner = () => {
const location = useLocation();
const locationState = location.state as ChatLocationState;
const initialSentRef = useRef(false);
@@ -256,9 +254,7 @@ const NewOpenCodeInner = ({ onProviderChange }: { onProviderChange: (p: 'claude'
/>
<EmbeddableChat
chat={chat}
provider="opencode"
availableModels={models}
onProviderChange={onProviderChange}
defaultInput={locationState?.prefillInput ?? ''}
className="flex-1 min-h-0"
/>
@@ -266,7 +262,7 @@ const NewOpenCodeInner = ({ onProviderChange }: { onProviderChange: (p: 'claude'
);
};
const NewPiMonoInner = ({ onProviderChange }: { onProviderChange: (p: 'claude' | 'opencode' | 'pi-mono') => void }) => {
const NewPiMonoInner = () => {
const location = useLocation();
const locationState = location.state as ChatLocationState;
const initialSentRef = useRef(false);
@@ -306,9 +302,7 @@ const NewPiMonoInner = ({ onProviderChange }: { onProviderChange: (p: 'claude' |
/>
<EmbeddableChat
chat={chat}
provider="pi-mono"
availableModels={models}
onProviderChange={onProviderChange}
defaultInput={locationState?.prefillInput ?? ''}
className="flex-1 min-h-0"
/>
@@ -320,33 +314,15 @@ type NewChatPanelProps = {
initialProvider?: 'claude' | 'opencode' | 'pi-mono';
};
const NewChatPanel = ({ initialProvider = 'claude' }: NewChatPanelProps) => {
const [selected, setSelected] = usePanelChannel<SelectedSession>(CHANNEL, null);
const provider = selected?.provider ?? initialProvider;
const handleProviderChange = (p: 'claude' | 'opencode' | 'pi-mono') => {
setSelected({ id: 'new', provider: p });
};
const NewChatPanel = ({ initialProvider = 'pi-mono' }: NewChatPanelProps) => {
const [selected] = usePanelChannel<SelectedSession>(CHANNEL, null);
// Once a session is created, the inner component updates selected via the channel
if (selected && selected.id !== 'new') {
if (selected.provider === 'claude') {
return <ClaudeInner key={selected.id} sessionId={selected.id} model={selected.model} />;
}
if (selected.provider === 'opencode') {
return <OpenCodeInner key={selected.id} sessionId={selected.id} model={selected.model} />;
}
return <PiMonoInner key={selected.id} sessionId={selected.id} model={selected.model} />;
}
if (provider === 'claude') {
return <NewClaudeInner key="new-claude" onProviderChange={handleProviderChange} />;
}
if (provider === 'opencode') {
return <NewOpenCodeInner key="new-opencode" onProviderChange={handleProviderChange} />;
}
return <NewPiMonoInner key="new-pi-mono" onProviderChange={handleProviderChange} />;
return <NewPiMonoInner key="new-pi-mono" />;
};
export const ChatDetailPanel = () => {
@@ -364,11 +340,5 @@ export const ChatDetailPanel = () => {
return <NewChatPanel key="new" initialProvider={selected.provider} />;
}
if (selected.provider === 'claude') {
return <ClaudeInner key={selected.id} sessionId={selected.id} model={selected.model} />;
}
if (selected.provider === 'opencode') {
return <OpenCodeInner key={selected.id} sessionId={selected.id} model={selected.model} />;
}
return <PiMonoInner key={selected.id} sessionId={selected.id} model={selected.model} />;
};
@@ -1,13 +1,10 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useEffect, useRef, useCallback } from 'react';
import { Plus, MessageSquare, Trash2 } from 'lucide-react';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { useChatSessions } from '@/state/useChatSessions';
import type { SelectedSession } from './ChatDetailPanel';
type Filter = 'all' | 'claude' | 'opencode' | 'pi-mono';
export const SessionList = () => {
const [filter, setFilter] = useState<Filter>('all');
const { sessions, deleteSession } = useChatSessions();
const [selected, setSelected] = usePanelChannel<SelectedSession>('chat:selected-session', null);
const scrolledRef = useRef(false);
@@ -25,17 +22,9 @@ export const SessionList = () => {
scrolledRef.current = false;
}, [selected?.id, selected?.provider]);
const filtered = filter === 'all' ? sessions : sessions.filter((s) => s.provider === filter);
const handleSelect = (session: (typeof sessions)[number]) => {
setSelected({ id: session.id, provider: session.provider, model: session.model ?? null });
const path =
session.provider === 'claude'
? `/chat/${session.id}`
: session.provider === 'opencode'
? `/chat/opencode/${session.id}`
: `/chat/pi-mono/${session.id}`;
window.history.replaceState(null, '', path);
window.history.replaceState(null, '', `/chat/${session.id}`);
};
const handleDelete = async (provider: 'claude' | 'opencode' | 'pi-mono', id: string) => {
@@ -51,26 +40,9 @@ export const SessionList = () => {
{/* Header */}
<div className="shrink-0 flex items-center justify-between px-4 py-2 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60">
<h2 className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70">Sessions</h2>
<div className="flex items-center gap-2">
{/* Radio filter */}
<div className="flex items-center gap-0.5 rounded-lg bg-duck-dark/5 dark:bg-foreground/5 p-0.5">
{(['all', 'claude', 'opencode', 'pi-mono'] as const).map((value) => (
<button
key={value}
onClick={() => setFilter(value)}
className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors cursor-pointer ${
filter === value
? 'bg-background dark:bg-foreground/10 text-duck-dark dark:text-foreground shadow-sm'
: 'text-duck-dark/50 dark:text-foreground/50 hover:text-duck-dark/80 dark:hover:text-foreground/80'
}`}
>
{value === 'all' ? 'All' : value === 'claude' ? 'Claude' : value === 'opencode' ? 'OpenCode' : 'Pi'}
</button>
))}
</div>
<button
onClick={() => {
setSelected({ id: 'new', provider: 'claude' });
setSelected({ id: 'new', provider: 'pi-mono' });
window.history.replaceState(null, '', '/chat/new');
}}
className="flex items-center gap-1.5 rounded-md bg-duck-teal hover:bg-duck-teal/90 text-duck-yellow cursor-pointer h-7 px-3 text-xs font-medium"
@@ -79,23 +51,22 @@ export const SessionList = () => {
New Chat
</button>
</div>
</div>
{/* Session list */}
<div className="flex-1 min-h-0 overflow-y-auto p-3 space-y-1.5">
{filtered.length === 0 && (
{sessions.length === 0 && (
<div className="text-center py-16 text-duck-dark/30 dark:text-foreground/30 text-sm">
No sessions yet. Start a new chat!
</div>
)}
{filtered.map((session) => {
{sessions.map((session) => {
const isSelected = selected?.id === session.id && selected?.provider === session.provider;
return (
<div
key={`${session.provider}-${session.id}`}
ref={isSelected ? selectedRef : undefined}
className={`group flex items-center gap-3 rounded-lg border transition-colors cursor-pointer ${
className={`group flex items-center rounded-lg border transition-colors cursor-pointer ${
isSelected
? 'border-duck-teal/30 bg-duck-teal/5 dark:bg-duck-teal/10'
: 'border-duck-dark/10 dark:border-foreground/10 bg-background/80 hover:bg-background/90'
@@ -109,6 +80,9 @@ export const SessionList = () => {
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-duck-dark/80 dark:text-foreground/80 truncate">
{session.title}
<span className="ml-1.5 font-mono text-xs font-normal text-duck-dark/25 dark:text-foreground/25">
({session.id.slice(0, 8)})
</span>
</div>
<div className="text-xs text-duck-dark/40 dark:text-foreground/40">
{new Date(session.createdAt).toLocaleDateString(undefined, {
@@ -117,21 +91,12 @@ export const SessionList = () => {
hour: '2-digit',
minute: '2-digit',
})}
<span
className={`ml-2 text-xs font-medium ${
session.provider === 'claude'
? 'text-duck-teal'
: session.provider === 'opencode'
? 'text-duck-orange'
: 'text-purple-500'
}`}
>
{session.provider === 'claude' ? 'Claude' : session.provider === 'opencode' ? 'OpenCode' : 'Pi'}
</span>
<span className="ml-2 font-mono text-duck-dark/25 dark:text-foreground/25">
{session.id.slice(0, 8)}
</span>
</div>
{session.model && (
<div className="text-xs text-duck-teal/70 dark:text-duck-teal/60 truncate">
{session.model}
</div>
)}
</div>
</button>
<button
@@ -19,27 +19,30 @@ export const ChatHistory = () => {
className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-duck-dark/5 group"
>
<Link
to={session.provider === 'claude' ? `/chat/${session.id}` : `/chat/opencode/${session.id}`}
to={`/chat/${session.id}`}
className="flex items-center gap-2 flex-1 min-w-0"
>
<MessageSquare className="h-4 w-4 shrink-0 text-duck-teal/60" />
<div className="min-w-0 flex-1">
<span className="text-sm text-duck-dark truncate block">{session.title}</span>
<span className="text-xs text-duck-dark/40 truncate block">
<span className="text-sm text-duck-dark dark:text-foreground truncate block">
{session.title}
<span className="ml-1.5 font-mono text-xs text-duck-dark/25 dark:text-foreground/25">
({session.id.slice(0, 8)})
</span>
</span>
<span className="text-xs text-duck-dark/40 dark:text-foreground/40 truncate block">
{new Date(session.createdAt).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
<span
className={`ml-1.5 font-medium ${
session.provider === 'claude' ? 'text-duck-teal' : 'text-duck-orange'
}`}
>
{session.provider === 'claude' ? 'Claude' : 'OpenCode'}
</span>
{session.model && (
<span className="text-xs text-duck-teal/70 dark:text-duck-teal/60 truncate block">
{session.model}
</span>
)}
</div>
</Link>
<button
@@ -22,24 +22,23 @@ const layout: LayoutNode = {
};
type SessionListPageProps = {
provider?: 'claude' | 'opencode' | 'pi-mono';
isNew?: boolean;
};
export const SessionListPage = ({ provider = 'claude', isNew }: SessionListPageProps) => {
export const SessionListPage = ({ isNew }: SessionListPageProps) => {
const { sessionId } = useParams<{ sessionId: string }>();
const { sessions } = useChatSessions();
const [, setSelected] = usePanelChannel<SelectedSession>('chat:selected-session', null);
useEffect(() => {
if (isNew) {
setSelected({ id: 'new', provider });
setSelected({ id: 'new', provider: 'pi-mono' });
return;
}
if (!sessionId) return;
const session = sessions.find((s) => s.id === sessionId && s.provider === provider);
setSelected({ id: sessionId, provider, model: session?.model ?? null });
}, [sessionId, provider, isNew]);
const session = sessions.find((s) => s.id === sessionId);
setSelected({ id: sessionId, provider: session?.provider ?? 'pi-mono', model: session?.model ?? null });
}, [sessionId, isNew]);
const panelComponents: PanelComponents = useMemo(
() => ({
@@ -1,14 +1,12 @@
import { useState, useEffect, useRef } from 'react';
import { useEffect, useRef } from 'react';
import { X } from 'lucide-react';
import { Dialog, DialogOverlay, DialogPortal } from '@/components/ui/dialog';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { cardStyle } from '@/components/Card';
import type { TaskInfo } from 'apps/Chat';
import { useClaude } from '@/Screens/Dashboard/Chat/useClaude';
import { useOpenCode } from '@/Screens/Dashboard/Chat/useOpenCode';
import { usePiMono } from '@/Screens/Dashboard/Chat/usePiMono';
import { EmbeddableChat } from '@/Screens/Dashboard/Chat/EmbeddableChat';
import { useVisibleClaudeModels, useVisibleOpenCodeModels, useVisiblePiMonoModels } from '@/state/useModels';
import { useVisiblePiMonoModels } from '@/state/useModels';
import { useSettings } from '@/state/useSettings';
import type { TaskSummary } from 'apps/FileBrowser';
@@ -36,78 +34,19 @@ const playDing = () => {
setTimeout(() => ctx.close(), 1500);
};
type InnerProps = {
type PiMonoInnerProps = {
defaultInput: string;
cwd: { root?: string; path: string };
initialModel: string | null;
onProviderChange: (p: 'claude' | 'opencode' | 'pi-mono') => void;
};
const ClaudeInner = ({
defaultInput,
cwd,
initialModel,
onProviderChange,
taskInfo,
}: InnerProps & { taskInfo: TaskInfo }) => {
const chat = useClaude(undefined, initialModel, { replaceUrl: false, taskInfo });
const models = useVisibleClaudeModels();
const wasGenerating = useRef(false);
useEffect(() => {
if (wasGenerating.current && !chat.isGenerating) playDing();
wasGenerating.current = chat.isGenerating;
}, [chat.isGenerating]);
return (
<EmbeddableChat
chat={chat}
provider="claude"
availableModels={models}
onProviderChange={onProviderChange}
defaultInput={defaultInput}
cwd={cwd}
className="flex-1 min-h-0"
/>
);
};
const OpenCodeInner = ({
defaultInput,
cwd,
initialModel,
onProviderChange,
taskInfo,
}: InnerProps & { taskInfo: TaskInfo }) => {
const chat = useOpenCode(undefined, initialModel, { replaceUrl: false, taskInfo });
const models = useVisibleOpenCodeModels();
const wasGenerating = useRef(false);
useEffect(() => {
if (wasGenerating.current && !chat.isGenerating) playDing();
wasGenerating.current = chat.isGenerating;
}, [chat.isGenerating]);
return (
<EmbeddableChat
chat={chat}
provider="opencode"
availableModels={models}
onProviderChange={onProviderChange}
defaultInput={defaultInput}
cwd={cwd}
className="flex-1 min-h-0"
/>
);
taskInfo: TaskInfo;
};
const PiMonoInner = ({
defaultInput,
cwd,
initialModel,
onProviderChange,
taskInfo,
}: InnerProps & { taskInfo: TaskInfo }) => {
}: PiMonoInnerProps) => {
const chat = usePiMono(undefined, initialModel, { replaceUrl: false, taskInfo });
const models = useVisiblePiMonoModels();
@@ -120,9 +59,7 @@ const PiMonoInner = ({
return (
<EmbeddableChat
chat={chat}
provider="pi-mono"
availableModels={models}
onProviderChange={onProviderChange}
defaultInput={defaultInput}
cwd={cwd}
className="flex-1 min-h-0"
@@ -143,7 +80,6 @@ type TaskRunnerModalProps = {
export const TaskRunnerModal = ({ open, onOpenChange, task, entryName, entryType, cwd = { path: '' }, promptOverride }: TaskRunnerModalProps) => {
const { settings } = useSettings();
const taskSettings = settings.tasks;
const [provider, setProvider] = useState<'claude' | 'opencode' | 'pi-mono'>(taskSettings.defaultProvider);
const defaultInput = promptOverride
?? (entryName && entryType
? `Read the task instructions at ${task.filePath} and execute them on the ${entryType}: ${entryName}`
@@ -171,34 +107,13 @@ export const TaskRunnerModal = ({ open, onOpenChange, task, entryName, entryType
</div>
{/* Chat */}
{provider === 'claude' ? (
<ClaudeInner
key="claude"
defaultInput={defaultInput}
cwd={cwd}
initialModel={taskSettings.defaultProvider === 'claude' ? taskSettings.defaultModel : null}
onProviderChange={setProvider}
taskInfo={taskInfo}
/>
) : provider === 'opencode' ? (
<OpenCodeInner
key="opencode"
defaultInput={defaultInput}
cwd={cwd}
initialModel={taskSettings.defaultProvider === 'opencode' ? taskSettings.defaultModel : null}
onProviderChange={setProvider}
taskInfo={taskInfo}
/>
) : (
<PiMonoInner
key="pi-mono"
defaultInput={defaultInput}
cwd={cwd}
initialModel={taskSettings.defaultProvider === 'pi-mono' ? taskSettings.defaultModel : null}
onProviderChange={setProvider}
taskInfo={taskInfo}
/>
)}
</DialogPrimitive.Content>
</DialogPortal>
</Dialog>
@@ -1,4 +1,4 @@
import { useState, useRef, useEffect, type KeyboardEvent } from 'react';
import { useState, useMemo, useRef, useEffect, type KeyboardEvent } from 'react';
import { useNavigate } from 'react-router';
import {
Send,
@@ -23,18 +23,32 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useSettings } from '@/state/useSettings';
import { useVisibleClaudeModels, useVisibleOpenCodeModels, useVisiblePiMonoModels } from '@/state/useModels';
import { useVisiblePiMonoModels } from '@/state/useModels';
import type { Attachment } from '@/Screens/Dashboard/Chat/EmbeddableChat';
const PROVIDER_DISPLAY: Record<string, string> = {
anthropic: 'Anthropic',
openai: 'OpenAI',
opencode: 'OpenCode Zen',
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',
};
export const ChatLauncher = () => {
const navigate = useNavigate();
const { settings } = useSettings();
const claudeModels = useVisibleClaudeModels();
const openCodeModels = useVisibleOpenCodeModels();
const piMonoModels = useVisiblePiMonoModels();
const client = useClient();
const [provider, setProvider] = useState<'claude' | 'opencode' | 'pi-mono'>(settings.chat.defaultProvider);
const [model, setModel] = useState<string | null>(settings.chat.defaultModel);
const [input, setInput] = useState('');
const [attachments, setAttachments] = useState<Attachment[]>([]);
@@ -44,11 +58,23 @@ export const ChatLauncher = () => {
const imageInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
setProvider(settings.chat.defaultProvider);
setModel(settings.chat.defaultModel);
}, [settings.chat.defaultProvider, settings.chat.defaultModel]);
}, [settings.chat.defaultModel]);
const models = provider === 'claude' ? claudeModels : provider === 'opencode' ? openCodeModels : piMonoModels;
const providers = useMemo(
() => [...new Set(piMonoModels.map((m) => m.provider).filter(Boolean))] as string[],
[piMonoModels],
);
const activeProvider = piMonoModels.find((m) => m.id === model)?.provider ?? providers[0];
const providerModels = piMonoModels.filter((m) => m.provider === activeProvider);
const displayName = (provider: string) => PROVIDER_DISPLAY[provider] ?? provider;
const handleProviderClick = (provider: string) => {
const firstModel = piMonoModels.find((m) => m.provider === provider);
if (firstModel) setModel(firstModel.id);
};
const handleAttachWebpage = async (url: string) => {
const idx = attachments.length;
@@ -60,7 +86,7 @@ export const ChatLauncher = () => {
try {
const res = await client.post<{ url: string; title: string; content: string; attachmentId: string }>('/scrape', {
url,
provider,
provider: 'pi-mono',
});
setAttachments((prev) =>
prev.map((a, i) =>
@@ -85,7 +111,7 @@ export const ChatLauncher = () => {
try {
const formData = new FormData();
formData.append('file', file);
formData.append('provider', provider);
formData.append('provider', 'pi-mono');
const res = await client.post<{ filename: string; dataUrl: string; attachmentId: string }>('/upload', formData);
setAttachments((prev) =>
@@ -125,9 +151,7 @@ export const ChatLauncher = () => {
attachmentIds.push(a.attachmentId);
}
const route =
provider === 'claude' ? '/chat/new' : provider === 'opencode' ? '/chat/opencode/new' : '/chat/pi-mono/new';
navigate(route, {
navigate('/chat/new', {
state: {
initialMessage: prompt,
model,
@@ -259,40 +283,36 @@ export const ChatLauncher = () => {
<div className="flex items-center justify-between px-4 pb-3">
<div className="flex items-center gap-1 rounded-lg bg-duck-dark/5 p-1">
{(['claude', 'opencode', 'pi-mono'] as const).map((value) => (
{providers.map((provider) => (
<button
key={value}
onClick={() => {
setProvider(value);
setModel(null);
}}
key={provider}
onClick={() => handleProviderClick(provider)}
className={`rounded-md px-3 py-1 text-xs font-medium transition-colors cursor-pointer ${
provider === value
activeProvider === provider
? 'bg-background text-duck-dark shadow-sm'
: 'text-duck-dark/50 hover:text-duck-dark/70'
}`}
>
{value === 'claude' ? 'Claude' : value === 'opencode' ? 'OpenCode' : 'Pi'}
{displayName(provider)}
</button>
))}
</div>
{models.length > 0 && (
{providerModels.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="flex items-center gap-1 text-xs text-duck-dark/50 hover:text-duck-dark/70 cursor-pointer transition-colors">
{models.find((m) => m.id === (model ?? models[0]?.id))?.name ?? models[0]?.name}
{providerModels.find((m) => m.id === (model ?? providerModels[0]?.id))?.name ?? providerModels[0]?.name}
<ChevronDown className="h-3 w-3" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="z-[600] max-h-64 overflow-y-auto">
{models.map((m) => (
{providerModels.map((m) => (
<DropdownMenuItem key={m.id} onClick={() => setModel(m.id)} className="cursor-pointer">
<Check
className={`mr-2 h-3 w-3 ${(model ?? models[0]?.id) === m.id ? 'opacity-100' : 'opacity-0'}`}
className={`mr-2 h-3 w-3 ${(model ?? providerModels[0]?.id) === m.id ? 'opacity-100' : 'opacity-0'}`}
/>
<span className="font-bold">{m.name}</span>
{m.provider && <span className="text-duck-dark/50 ml-1">({m.provider})</span>}
</DropdownMenuItem>
))}
</DropdownMenuContent>
@@ -1,321 +1,534 @@
import { useState } from 'react';
import { Copy, Check, Play } from 'lucide-react';
import { Plus, Save, Trash2, RefreshCw, Loader2, X } from 'lucide-react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import { useClient } from 'hooks/useClient';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { useServerSettings } from '@/state/useServerSettings';
import { RUN_COMMAND_CHANNEL, type RunCommandState } from './run-command-channel';
type VersionInfo = { version: string | null; path: string | null; globalPath: string | null };
type ClaudeAuthInfo = { authenticated: boolean; loggedIn?: boolean; subscriptionType?: string };
type OpencodeAuthInfo = { authenticated: boolean; providers: string[] };
type StoredApiKeys = { keys: { env: string; value: string }[] };
type LocalProviderEntry = {
id: string;
name: string;
url: string;
apiType: 'ollama' | 'openai-compatible' | 'lmstudio';
auth?: { type: 'api-key' | 'basic' };
};
type ProbeResult = {
success: boolean;
apiType?: LocalProviderEntry['apiType'];
name?: string;
needsAuth?: boolean;
authType?: 'api-key' | 'basic' | 'unknown';
models?: string[];
error?: string;
};
const PI_PROVIDERS: { key: string; env: string[] }[] = [
{ key: 'OpenAI', env: ['OPENAI_API_KEY'] },
{ key: 'Google', env: ['GOOGLE_API_KEY', 'GEMINI_API_KEY'] },
{ key: 'OpenCode Zen', env: ['OPENCODE_API_KEY'] },
{ key: 'MiniMax', env: ['MINIMAX_API_KEY'] },
{ key: 'Groq', env: ['GROQ_API_KEY'] },
{ key: 'Mistral', env: ['MISTRAL_API_KEY'] },
{ key: 'xAI', env: ['XAI_API_KEY'] },
{ key: 'OpenRouter', env: ['OPENROUTER_API_KEY'] },
{ key: 'Hugging Face', env: ['HF_TOKEN'] },
{ key: 'GitHub Copilot', env: ['COPILOT_GITHUB_TOKEN'] },
{ key: 'Amazon Bedrock', env: ['AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', 'AWS_REGION'] },
{ key: 'Google Vertex AI', env: ['GOOGLE_APPLICATION_CREDENTIALS', 'GOOGLE_CLOUD_PROJECT', 'GOOGLE_CLOUD_LOCATION'] },
{ key: 'Azure OpenAI', env: ['AZURE_OPENAI_API_KEY', 'AZURE_OPENAI_BASE_URL'] },
{ key: 'Anthropic', env: ['ANTHROPIC_API_KEY'] },
];
const TEXT_FIELDS = new Set([
'AWS_REGION',
'GOOGLE_APPLICATION_CREDENTIALS',
'GOOGLE_CLOUD_PROJECT',
'GOOGLE_CLOUD_LOCATION',
'AZURE_OPENAI_BASE_URL',
]);
type ProbeState =
| { step: 'url' }
| { step: 'probing' }
| { step: 'auth'; probe: ProbeResult }
| { step: 'saving' };
export const AIHarnessesSection = () => {
const client = useClient();
const queryClient = useQueryClient();
const { aiHarnesses, saveSettings } = useServerSettings();
const [installing, setInstalling] = useState<{ claudeCode: boolean; opencode: boolean; piMono: boolean }>({
claudeCode: false,
opencode: false,
piMono: false,
});
const [copied, setCopied] = useState<string | null>(null);
const { data: opencodeVersion, isLoading: opencodeLoading } = useQuery({
queryKey: ['OPENCODE_VERSION'],
queryFn: () => client.get<VersionInfo>('/server-settings/opencode/version'),
enabled: !!aiHarnesses?.opencode,
refetchInterval: (query) => {
const data = query.state.data;
return data?.version && !data?.globalPath ? 1000 : false;
},
});
const { data: claudeVersion, isLoading: claudeLoading } = useQuery({
queryKey: ['CLAUDE_CODE_VERSION'],
queryFn: () => client.get<VersionInfo>('/server-settings/claude-code/version'),
enabled: !!aiHarnesses?.claudeCode,
refetchInterval: (query) => {
const data = query.state.data;
return data?.version && !data?.globalPath ? 1000 : false;
},
});
const [installing, setInstalling] = useState(false);
const { data: piMonoVersion, isLoading: piMonoLoading } = useQuery({
queryKey: ['PI_MONO_VERSION'],
queryFn: () => client.get<VersionInfo>('/server-settings/pi-mono/version'),
enabled: !!aiHarnesses?.piMono,
refetchInterval: (query) => {
const data = query.state.data;
return data?.version && !data?.globalPath ? 1000 : false;
},
});
const { data: opencodeAuth } = useQuery({
queryKey: ['OPENCODE_AUTH'],
queryFn: () => client.get<OpencodeAuthInfo>('/server-settings/opencode/auth'),
enabled: !!opencodeVersion?.version,
refetchInterval: (query) => (!query.state.data?.authenticated ? 2000 : false),
const { data: piMonoKeys } = useQuery({
queryKey: ['PI_MONO_API_KEYS'],
queryFn: () => client.get<StoredApiKeys>('/server-settings/pi-mono/api-keys'),
enabled: !!piMonoVersion?.version,
});
const { data: claudeAuth } = useQuery({
queryKey: ['CLAUDE_CODE_AUTH'],
queryFn: () => client.get<ClaudeAuthInfo>('/server-settings/claude-code/auth'),
enabled: !!claudeVersion?.version,
refetchInterval: (query) => (!query.state.data?.authenticated ? 2000 : false),
const { data: localProviders = [] as LocalProviderEntry[] } = useQuery({
queryKey: ['PI_MONO_LOCAL_PROVIDERS'],
queryFn: () => client.get<LocalProviderEntry[]>('/server-settings/pi-mono/local-providers'),
enabled: !!piMonoVersion?.version,
});
const toggleHarness = (key: 'claudeCode' | 'opencode' | 'piMono', checked: boolean) => {
const updated = { ...aiHarnesses, [key]: checked };
saveSettings({ aiHarnesses: updated });
const { data: localHealth = {} as Record<string, boolean> } = useQuery({
queryKey: ['PI_MONO_LOCAL_HEALTH'],
queryFn: () => client.get<Record<string, boolean>>('/server-settings/pi-mono/local-providers/health'),
enabled: localProviders.length > 0,
refetchInterval: 15_000,
});
const [keyInputs, setKeyInputs] = useState<Record<string, string>>({});
const [savingKey, setSavingKey] = useState<string | null>(null);
const [commandOpen, setCommandOpen] = useState(false);
const [editingProvider, setEditingProvider] = useState<string | null>(null);
// Local provider connection flow
const [addingLocal, setAddingLocal] = useState(false);
const [localName, setLocalName] = useState('');
const [localUrl, setLocalUrl] = useState('');
const [probeState, setProbeState] = useState<ProbeState>({ step: 'url' });
const [authApiKey, setAuthApiKey] = useState('');
const [authUsername, setAuthUsername] = useState('');
const [authPassword, setAuthPassword] = useState('');
const resetLocalForm = () => {
setAddingLocal(false);
setLocalName('');
setLocalUrl('');
setProbeState({ step: 'url' });
setAuthApiKey('');
setAuthUsername('');
setAuthPassword('');
};
const installClaude = async () => {
setInstalling((prev) => ({ ...prev, claudeCode: true }));
const storedEnvs = new Set(piMonoKeys?.keys.map((k: { env: string }) => k.env) ?? []);
const connectedProviders = PI_PROVIDERS.filter((p) => p.env.some((e) => storedEnvs.has(e)));
const unconnectedProviders = PI_PROVIDERS.filter((p) => !p.env.some((e) => storedEnvs.has(e)));
const getStoredMasked = (env: string) =>
piMonoKeys?.keys.find((k: { env: string; value: string }) => k.env === env)?.value ?? '';
const saveApiKey = async (env: string) => {
const value = keyInputs[env];
if (value === undefined) return;
setSavingKey(env);
try {
const result = await client.post<VersionInfo>('/server-settings/claude-code/install');
queryClient.setQueryData(['CLAUDE_CODE_VERSION'], result);
await client.put('/server-settings/pi-mono/api-keys', { key: env, value });
queryClient.invalidateQueries({ queryKey: ['PI_MONO_API_KEYS'] });
setKeyInputs((prev) => {
const next = { ...prev };
delete next[env];
return next;
});
} finally {
setInstalling((prev) => ({ ...prev, claudeCode: false }));
setSavingKey(null);
}
};
const installOpencode = async () => {
setInstalling((prev) => ({ ...prev, opencode: true }));
try {
const result = await client.post<VersionInfo>('/server-settings/opencode/install');
queryClient.setQueryData(['OPENCODE_VERSION'], result);
} finally {
setInstalling((prev) => ({ ...prev, opencode: false }));
const disconnectProvider = async (provider: typeof PI_PROVIDERS[number]) => {
for (const env of provider.env) {
await client.put('/server-settings/pi-mono/api-keys', { key: env, value: '' });
}
queryClient.invalidateQueries({ queryKey: ['PI_MONO_API_KEYS'] });
if (editingProvider === provider.key) setEditingProvider(null);
};
const installPiMono = async () => {
setInstalling((prev) => ({ ...prev, piMono: true }));
setInstalling(true);
try {
const result = await client.post<VersionInfo>('/server-settings/pi-mono/install');
queryClient.setQueryData(['PI_MONO_VERSION'], result);
} finally {
setInstalling((prev) => ({ ...prev, piMono: false }));
setInstalling(false);
}
};
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
setCopied(text);
setTimeout(() => setCopied(null), 1500);
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', {
url: localUrl.trim(),
auth,
});
if (!result.success) {
toast.error(result.error ?? 'Could not detect API type');
setProbeState({ step: 'url' });
return;
}
if (result.needsAuth) {
setProbeState({ step: 'auth', probe: result });
return;
}
// No auth needed — save directly
await saveLocalProvider(result, auth);
} catch {
toast.error('Failed to connect');
setProbeState({ step: 'url' });
}
};
const [, setRunCommand] = usePanelChannel<RunCommandState>(RUN_COMMAND_CHANNEL, null);
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 [confirmCommand, setConfirmCommand] = useState<{ command: string; refetchKeys: string[] } | null>(null);
// Re-probe with credentials to verify they work
setProbeState({ step: 'probing' });
try {
const result = await client.post<ProbeResult>('/server-settings/pi-mono/local-providers/probe', {
url: localUrl.trim(),
auth,
});
if (!result.success) {
toast.error(result.error ?? 'Could not connect with provided credentials');
setProbeState({ step: 'auth', probe });
return;
}
if (result.needsAuth) {
toast.error('Authentication failed');
setProbeState({ step: 'auth', probe });
return;
}
await saveLocalProvider(result, auth);
} catch {
toast.error('Failed to connect');
setProbeState({ step: 'auth', probe });
}
};
const CopyCommand = ({ command, refetchKeys }: { command: string; refetchKeys: string[] }) => (
<div className="mt-2 text-xs text-amber-600">
Not globally accessible. Run:
<div className="flex items-center gap-1 mt-1">
<code className="flex-1 bg-duck-dark/5 rounded px-2 py-1 text-duck-dark/70">{command}</code>
<button
type="button"
onClick={() => setConfirmCommand({ command, refetchKeys })}
className="shrink-0 p-1 rounded hover:bg-duck-teal/10 cursor-pointer transition-colors"
title="Run in terminal"
>
<Play className="h-3.5 w-3.5 text-duck-teal" />
</button>
<button
type="button"
onClick={() => copyToClipboard(command)}
className="shrink-0 p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors"
title="Copy command"
>
{copied === command ? (
<Check className="h-3.5 w-3.5 text-green-600" />
) : (
<Copy className="h-3.5 w-3.5 text-duck-dark/50" />
)}
</button>
</div>
</div>
);
const saveLocalProvider = async (
probe: ProbeResult,
auth?: { type: 'api-key'; apiKey: string } | { type: 'basic'; username: string; password: string },
) => {
setProbeState({ step: 'saving' });
try {
await client.post('/server-settings/pi-mono/local-providers', {
url: localUrl.trim(),
name: localName.trim() || probe.name,
apiType: probe.apiType,
auth,
});
queryClient.invalidateQueries({ queryKey: ['PI_MONO_LOCAL_PROVIDERS'] });
toast.success(`Connected to ${probe.name}`);
resetLocalForm();
} catch {
toast.error('Failed to save provider');
setProbeState({ step: 'url' });
}
};
const removeLocalProvider = async (id: string) => {
await client.delete(`/server-settings/pi-mono/local-providers/${id}`);
queryClient.invalidateQueries({ queryKey: ['PI_MONO_LOCAL_PROVIDERS'] });
};
return (
<>
<div className="flex flex-col gap-4">
<div>
<label className="flex items-center gap-3 cursor-pointer">
<Checkbox
checked={!!aiHarnesses?.opencode}
onCheckedChange={(checked) => toggleHarness('opencode', !!checked)}
/>
<span className="text-sm font-medium text-duck-dark">Opencode</span>
</label>
{aiHarnesses?.opencode && (
<div className="ml-7 mt-2 text-xs text-duck-dark/50">
{opencodeLoading ? (
'Checking version...'
) : opencodeVersion?.version ? (
<>
<div>{opencodeVersion.version}</div>
<div>{opencodeVersion.path}</div>
{opencodeAuth && (
<div className={`mt-1 ${opencodeAuth.authenticated ? 'text-green-600' : 'text-amber-600'}`}>
{opencodeAuth.authenticated ? (
`Logged in (${opencodeAuth.providers.join(', ')})`
) : (
<div className="flex items-center gap-2">
<span>Not logged in</span>
<Button
size="sm"
className="bg-duck-teal text-duck-yellow hover:bg-duck-teal/90"
onClick={() => client.post('/server-settings/opencode/auth/login')}
>
Login
</Button>
</div>
)}
</div>
)}
{!opencodeVersion.globalPath && opencodeVersion.path && (
<CopyCommand command={`sudo ln -s ${opencodeVersion.path} /usr/local/bin/opencode`} refetchKeys={['OPENCODE_VERSION']} />
)}
</>
) : (
<Button
size="sm"
className="bg-duck-teal text-duck-yellow hover:bg-duck-teal/90"
onClick={installOpencode}
disabled={installing.opencode}
>
{installing.opencode ? 'Installing...' : 'Install'}
</Button>
)}
</div>
)}
</div>
<div>
<label className="flex items-center gap-3 cursor-pointer">
<Checkbox
checked={!!aiHarnesses?.claudeCode}
onCheckedChange={(checked) => toggleHarness('claudeCode', !!checked)}
/>
<span className="text-sm font-medium text-duck-dark">Claude Code</span>
</label>
{aiHarnesses?.claudeCode && (
<div className="ml-7 mt-2 text-xs text-duck-dark/50">
{claudeLoading ? (
'Checking version...'
) : claudeVersion?.version ? (
<>
<div>{claudeVersion.version}</div>
<div>{claudeVersion.path}</div>
{claudeAuth && (
<div className={`mt-1 ${claudeAuth.authenticated ? 'text-green-600' : 'text-amber-600'}`}>
{claudeAuth.authenticated ? (
`Logged in (${claudeAuth.subscriptionType ?? 'unknown plan'})`
) : (
<div className="flex items-center gap-2">
<span>Not logged in</span>
<Button
size="sm"
className="bg-duck-teal text-duck-yellow hover:bg-duck-teal/90"
onClick={() => client.post('/server-settings/claude-code/auth/login')}
>
Login
</Button>
</div>
)}
</div>
)}
{!claudeVersion.globalPath && claudeVersion.path && (
<CopyCommand command={`sudo ln -s ${claudeVersion.path} /usr/local/bin/claude`} refetchKeys={['CLAUDE_CODE_VERSION']} />
)}
</>
) : (
<Button
size="sm"
className="bg-duck-teal text-duck-yellow hover:bg-duck-teal/90"
onClick={installClaude}
disabled={installing.claudeCode}
>
{installing.claudeCode ? 'Installing...' : 'Install'}
</Button>
)}
</div>
)}
</div>
<div>
<label className="flex items-center gap-3 cursor-pointer">
<Checkbox
checked={!!aiHarnesses?.piMono}
onCheckedChange={(checked) => toggleHarness('piMono', !!checked)}
/>
<span className="text-sm font-medium text-duck-dark">Pi</span>
</label>
{aiHarnesses?.piMono && (
<div className="ml-7 mt-2 text-xs text-duck-dark/50">
<div className="flex items-center gap-3">
<span className="text-sm font-medium text-duck-dark">Pi Mono</span>
{piMonoLoading ? (
'Checking version...'
<span className="text-xs text-duck-dark/50">Checking...</span>
) : piMonoVersion?.version ? (
<>
<div>{piMonoVersion.version}</div>
<div>{piMonoVersion.path}</div>
{!piMonoVersion.globalPath && piMonoVersion.path && (
<CopyCommand command={`sudo ln -s ${piMonoVersion.path} /usr/local/bin/pi`} refetchKeys={['PI_MONO_VERSION']} />
)}
</>
<div className="flex items-center gap-2">
<span className="text-xs text-duck-dark/50">{piMonoVersion.version}</span>
<button
type="button"
onClick={installPiMono}
disabled={installing}
className="p-0.5 rounded hover:bg-duck-teal/10 cursor-pointer transition-colors disabled:opacity-30"
title="Update Pi Mono"
>
<RefreshCw className={`h-3 w-3 text-duck-teal ${installing ? 'animate-spin' : ''}`} />
</button>
</div>
) : (
<Button
size="sm"
className="bg-duck-teal text-duck-yellow hover:bg-duck-teal/90"
className="h-6 text-xs bg-duck-teal text-duck-yellow hover:bg-duck-teal/90"
onClick={installPiMono}
disabled={installing.piMono}
disabled={installing}
>
{installing.piMono ? 'Installing...' : 'Install'}
{installing ? 'Installing...' : 'Install'}
</Button>
)}
</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>
<AlertDialog open={!!confirmCommand} onOpenChange={(open) => !open && setConfirmCommand(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Run with elevated privileges</AlertDialogTitle>
<AlertDialogDescription>
You are about to run a command with elevated privileges (sudo). Are you sure?
</AlertDialogDescription>
</AlertDialogHeader>
<code className="text-xs bg-duck-dark/5 rounded px-3 py-2 text-duck-dark/70 break-all">{confirmCommand?.command}</code>
<AlertDialogFooter>
<AlertDialogCancel className="cursor-pointer">Cancel</AlertDialogCancel>
<AlertDialogAction
className="cursor-pointer bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold"
onClick={() => {
if (confirmCommand) setRunCommand(confirmCommand);
setConfirmCommand(null);
{/* 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.key} className="flex flex-col gap-1">
<div className="flex items-center gap-2 mt-1">
<span className="text-duck-dark/60 font-medium text-[11px]">{provider.key}</span>
<button
type="button"
onClick={() => disconnectProvider(provider)}
className="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>
{provider.env.map((env) => (
<div key={env} className="flex items-center gap-2">
<label className="w-52 text-duck-dark/70 shrink-0 truncate" title={env}>{env}</label>
<Input
type={TEXT_FIELDS.has(env) ? 'text' : 'password'}
className="h-7 text-xs flex-1"
placeholder={getStoredMasked(env) || 'Not set'}
value={keyInputs[env] ?? ''}
onChange={(ev) => setKeyInputs((prev) => ({ ...prev, [env]: ev.target.value }))}
/>
<button
type="button"
disabled={keyInputs[env] === undefined || savingKey === env}
onClick={() => saveApiKey(env)}
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>
</div>
))}
</div>
))}
{editingProvider && (() => {
const provider = PI_PROVIDERS.find((p) => p.key === editingProvider);
if (!provider || connectedProviders.includes(provider)) return null;
return (
<div key={provider.key} className="flex flex-col gap-1">
<span className="text-duck-dark/60 font-medium text-[11px] mt-1">{provider.key}</span>
{provider.env.map((env) => (
<div key={env} className="flex items-center gap-2">
<label className="w-52 text-duck-dark/70 shrink-0 truncate" title={env}>{env}</label>
<Input
type={TEXT_FIELDS.has(env) ? 'text' : 'password'}
className="h-7 text-xs flex-1"
placeholder="Not set"
value={keyInputs[env] ?? ''}
onChange={(ev) => setKeyInputs((prev) => ({ ...prev, [env]: ev.target.value }))}
autoFocus={env === provider.env[0]}
/>
<button
type="button"
disabled={keyInputs[env] === undefined || savingKey === env}
onClick={() => saveApiKey(env)}
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>
</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>
)}
</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);
}}
>
Run
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<span>{provider.key}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</CommandDialog>
</div>
</>
)}
</div>
</div>
);
};
@@ -131,7 +131,7 @@ export const SettingsContent = ({ globalKey, sections }: SettingsContentProps) =
<h2 className="text-lg font-bold text-duck-dark dark:text-foreground">{section.title}</h2>
</div>
<p className="text-sm text-duck-dark/50 dark:text-foreground/50 mb-6">{section.description}</p>
<div className="max-w-xl">{section.content}</div>
<div>{section.content}</div>
</div>
);
};
@@ -1,14 +1,11 @@
import { useState, useEffect, useMemo } from 'react';
import { useState, useEffect, useMemo, useCallback, type DragEvent } from 'react';
import { toast } from 'sonner';
import { Terminal, Eye, Trash2, Bot, Server, Puzzle, Settings, X } from 'lucide-react';
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 { Switch } from '@/components/ui/switch';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '@/components/ui/accordion';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useQueryClient } from '@tanstack/react-query';
import type { LayoutNode, PanelComponents } from '@/components/Workspace';
@@ -19,34 +16,19 @@ import { appRegistry } from '../Workspaces/app-registry';
import { createSettingsPanelComponents, type SettingsSectionGroup } from './SettingsPanel';
import { useSettings } from '@/state/useSettings';
import { useUserState } from '@/state/useUserState';
import {
useClaudeModels,
useOpenCodeModels,
usePiMonoModels,
useVisibleClaudeModels,
useVisibleOpenCodeModels,
useVisiblePiMonoModels,
} from '@/state/useModels';
import { usePiMonoModels, useVisiblePiMonoModels } from '@/state/useModels';
import type { UserSettings } from '@/state/types/user-settings';
import { AIHarnessesSection } from './ServerSettings/AIHarnessesSection';
import { PluginsSection } from './ServerSettings/PluginsSection';
import { RUN_COMMAND_CHANNEL, type RunCommandState } from './ServerSettings/run-command-channel';
const groups: SettingsSectionGroup[] = [
{
label: 'Server',
icon: Server,
sections: [
{ key: 'ai-harnesses', icon: Terminal, title: 'AI Harnesses', description: 'AI coding tools setup', content: <AIHarnessesSection /> },
{ key: 'plugins', icon: Puzzle, title: 'Plugins', description: 'Enable or disable plugins', content: <PluginsSection /> },
],
},
{
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: 'model-visibility', icon: Eye, title: 'Model Visibility', description: 'Enable or disable models', content: <ModelVisibilitySection /> },
],
},
];
@@ -164,8 +146,6 @@ export const SystemSettings = () => {
function ChatDefaultsSection() {
const { settings, saveSettings } = useSettings();
const claudeModels = useVisibleClaudeModels();
const openCodeModels = useVisibleOpenCodeModels();
const piMonoModels = useVisiblePiMonoModels();
const [isSaving, setIsSaving] = useState(false);
@@ -181,25 +161,13 @@ function ChatDefaultsSection() {
setDefaultPwd(settings.chat.defaultPwd);
}, [settings]);
const allModels = useMemo(
() => [
...claudeModels.map((m) => ({ ...m, provider: 'Claude' })),
...openCodeModels.map((m) => ({ ...m, provider: m.provider ?? 'OpenCode' })),
...piMonoModels.map((m) => ({ ...m, provider: m.provider ?? 'Pi' })),
],
[claudeModels, openCodeModels, piMonoModels],
);
const handleSave = async () => {
if (isSaving) return;
setIsSaving(true);
try {
const isPiMono = piMonoModels.some((m) => m.id === model);
const isOpenCode = openCodeModels.some((m) => m.id === model);
const defaultProvider = isPiMono ? ('pi-mono' as const) : isOpenCode ? ('opencode' as const) : ('claude' as const);
const updated: UserSettings = {
...settings,
chat: { defaultProvider, defaultModel: model, systemPrompt, temperature, defaultPwd },
chat: { defaultProvider: 'pi-mono', defaultModel: model, systemPrompt, temperature, defaultPwd },
};
await saveSettings(updated);
toast.success('Chat defaults saved');
@@ -219,10 +187,10 @@ function ChatDefaultsSection() {
<SelectValue placeholder="Default" />
</SelectTrigger>
<SelectContent className="z-[600]">
{allModels.map((m) => (
<SelectItem key={`${m.provider}:${m.id}`} value={m.id}>
{piMonoModels.map((m) => (
<SelectItem key={m.id} value={m.id}>
<span className="font-bold">{m.name}</span>
<span className="text-duck-dark/50 ml-1">({m.provider})</span>
{m.provider && <span className="text-duck-dark/50 ml-1">({m.provider})</span>}
</SelectItem>
))}
</SelectContent>
@@ -269,25 +237,81 @@ function ChatDefaultsSection() {
);
}
function ModelVisibilitySection() {
const { settings, saveSettings } = useSettings();
const claudeModels = useClaudeModels();
const openCodeModels = useOpenCodeModels();
const piMonoModels = usePiMonoModels();
const [subTab, setSubTab] = useUserState('ai-settings-visibility-tab', 'claude');
const enabledModels = settings.ai?.enabledModels ?? [];
const enabledProviders = settings.ai?.enabledProviders ?? [];
const toggleModel = async (key: string) => {
const isEnabled = enabledModels.includes(key);
const newEnabled = isEnabled ? enabledModels.filter((id) => id !== key) : [...enabledModels, key];
await saveSettings({ ...settings, ai: { ...settings.ai, enabledModels: newEnabled } });
type ModelPillProps = {
label: string;
modelKey: string;
enabled: boolean;
onAction: (key: string) => void;
onDragStart: (ev: DragEvent, key: string) => void;
};
const buildProviderGroups = (models: { id: string; name: string; provider?: string }[]) => {
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 models) {
for (const m of piMonoModels) {
const provider = m.provider ?? 'Other';
if (!groups[provider]) groups[provider] = [];
groups[provider].push({ id: m.id, name: m.name });
@@ -295,250 +319,88 @@ function ModelVisibilitySection() {
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 ocGroups = useMemo(() => buildProviderGroups(openCodeModels), [openCodeModels]);
const piGroups = useMemo(() => buildProviderGroups(piMonoModels), [piMonoModels]);
const providers = useMemo(() => providerGroups.map((g) => g.provider), [providerGroups]);
const [addingProvider, setAddingProvider] = useState(false);
const [selectedNewProvider, setSelectedNewProvider] = useState<string>('');
// Auto-select first provider if none selected or stale
const selected = providers.includes(activeProvider) ? activeProvider : providers[0] ?? '';
const currentGroups = subTab === 'opencode' ? ocGroups : piGroups;
const disabledProviders = currentGroups.filter((g) => !enabledProviders.includes(g.provider));
const currentGroup = providerGroups.find((g) => g.provider === selected);
const handleEnableProvider = async () => {
if (!selectedNewProvider) return;
await saveSettings({
...settings,
ai: { ...settings.ai, enabledProviders: [...enabledProviders, selectedNewProvider] },
});
setAddingProvider(false);
setSelectedNewProvider('');
};
const handleRemoveProvider = async (provider: string) => {
await saveSettings({
...settings,
ai: { ...settings.ai, enabledProviders: enabledProviders.filter((p) => p !== provider) },
});
};
return (
<Tabs value={subTab} onValueChange={setSubTab}>
<TabsList className="w-full mb-4">
<TabsTrigger value="claude" className="flex-1 cursor-pointer">
Claude
</TabsTrigger>
<TabsTrigger value="opencode" className="flex-1 cursor-pointer">
OpenCode
</TabsTrigger>
<TabsTrigger value="pi-mono" className="flex-1 cursor-pointer">
Pi
</TabsTrigger>
</TabsList>
<TabsContent value="claude">
<div className="grid gap-1">
{claudeModels.map((m) => (
<label
key={m.id}
className="flex items-center justify-between py-2 px-3 rounded-md hover:bg-duck-dark/5 cursor-pointer"
>
<span className="text-sm text-duck-dark">{m.name}</span>
<Switch checked={enabledModels.includes(m.id)} onCheckedChange={() => toggleModel(m.id)} />
</label>
))}
</div>
</TabsContent>
<TabsContent value="opencode">
<ProviderGroupTab
groups={ocGroups}
enabledProviders={enabledProviders}
enabledModels={enabledModels}
disabledProviders={disabledProviders}
addingProvider={addingProvider}
selectedNewProvider={selectedNewProvider}
emptyLabel="No OpenCode models available."
onSetAddingProvider={setAddingProvider}
onSetSelectedNewProvider={setSelectedNewProvider}
onEnableProvider={handleEnableProvider}
onToggleModel={toggleModel}
onRemoveProvider={handleRemoveProvider}
/>
</TabsContent>
<TabsContent value="pi-mono">
<ProviderGroupTab
groups={piGroups}
enabledProviders={enabledProviders}
enabledModels={enabledModels}
disabledProviders={subTab === 'pi-mono' ? piGroups.filter((g) => !enabledProviders.includes(g.provider)) : disabledProviders}
addingProvider={addingProvider}
selectedNewProvider={selectedNewProvider}
emptyLabel="No Pi models available."
onSetAddingProvider={setAddingProvider}
onSetSelectedNewProvider={setSelectedNewProvider}
onEnableProvider={handleEnableProvider}
onToggleModel={toggleModel}
onRemoveProvider={handleRemoveProvider}
/>
</TabsContent>
</Tabs>
);
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]);
// --- Shared components ---
type ProviderGroup = { provider: string; models: { id: string; name: string }[] };
type ProviderGroupTabProps = {
groups: ProviderGroup[];
enabledProviders: string[];
enabledModels: string[];
disabledProviders: ProviderGroup[];
addingProvider: boolean;
selectedNewProvider: string;
emptyLabel: string;
onSetAddingProvider: (v: boolean) => void;
onSetSelectedNewProvider: (v: string) => void;
onEnableProvider: () => void;
onToggleModel: (key: string) => void;
onRemoveProvider: (provider: string) => void;
};
const ProviderGroupTab = ({
groups,
enabledProviders,
enabledModels,
disabledProviders,
addingProvider,
selectedNewProvider,
emptyLabel,
onSetAddingProvider,
onSetSelectedNewProvider,
onEnableProvider,
onToggleModel,
onRemoveProvider,
}: ProviderGroupTabProps) => (
<>
<div className="flex justify-end items-center gap-2 mb-3">
{addingProvider ? (
<>
<Select value={selectedNewProvider} onValueChange={onSetSelectedNewProvider}>
<SelectTrigger className="h-9 flex-1 bg-background/60 border-duck-dark/20 text-duck-dark text-sm">
<SelectValue placeholder="Select provider..." />
</SelectTrigger>
<SelectContent className="z-[600]">
{disabledProviders.map((g) => (
<SelectItem key={g.provider} value={g.provider}>
{g.provider}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
size="sm"
disabled={!selectedNewProvider}
onClick={onEnableProvider}
className="cursor-pointer bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold disabled:opacity-50"
>
Enable
</Button>
</>
) : (
<Button
type="button"
variant="outline"
size="sm"
className="cursor-pointer"
onClick={() => onSetAddingProvider(true)}
disabled={disabledProviders.length === 0}
>
Add Provider
</Button>
)}
</div>
{groups.length === 0 ? (
<p className="text-sm text-duck-dark/40">{emptyLabel}</p>
) : (
<ProviderList
groups={groups}
enabledProviders={enabledProviders}
enabledModels={enabledModels}
onToggleModel={onToggleModel}
onRemoveProvider={onRemoveProvider}
/>
)}
</>
const enableModel = useCallback(
async (key: string) => {
if (enabledModels.includes(key)) return;
await saveSettings({ ...settings, ai: { ...settings.ai, enabledModels: [...enabledModels, key] } });
},
[settings, enabledModels, saveSettings],
);
type ProviderListProps = {
groups: ProviderGroup[];
enabledProviders: string[];
enabledModels: string[];
onToggleModel: (key: string) => void;
onRemoveProvider: (provider: string) => void;
};
const disableModel = useCallback(
async (key: string) => {
await saveSettings({ ...settings, ai: { ...settings.ai, enabledModels: enabledModels.filter((id) => id !== key) } });
},
[settings, enabledModels, saveSettings],
);
const ProviderList = ({
groups,
enabledProviders,
enabledModels,
onToggleModel,
onRemoveProvider,
}: ProviderListProps) => {
const [openProvider, setOpenProvider] = useUserState<string>('ai-settings-provider-accordion', '');
const enabled = groups.filter((g) => enabledProviders.includes(g.provider));
const onDragStart = useCallback((ev: DragEvent, key: string) => {
ev.dataTransfer.setData('text/plain', key);
ev.dataTransfer.effectAllowed = 'move';
}, []);
if (enabled.length === 0) {
return <p className="text-sm text-duck-dark/40">No providers enabled.</p>;
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 (
<Accordion type="single" collapsible value={openProvider} onValueChange={(v) => setOpenProvider(v ?? '')}>
{enabled.map((g) => (
<AccordionItem key={g.provider} value={g.provider}>
<AccordionTrigger className="py-2 px-3 text-sm font-medium text-duck-dark hover:no-underline [&>svg]:ml-1">
<span className="flex-1 text-left">{g.provider}</span>
<span className="text-xs opacity-60 mr-5">{g.models.length}</span>
<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
type="button"
onClick={(ev) => {
ev.stopPropagation();
onRemoveProvider(g.provider);
}}
className="p-1 text-duck-dark/30 hover:text-red-500 cursor-pointer transition-colors mr-3"
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'
}`}
>
<Trash2 className="h-3.5 w-3.5" />
{p}
</button>
</AccordionTrigger>
<AccordionContent className="px-3 pb-2">
<div className="grid gap-1 max-h-52 overflow-y-auto">
{[...g.models]
.sort((a, b) => {
const aEnabled = enabledModels.includes(`${g.provider}:${a.id}`);
const bEnabled = enabledModels.includes(`${g.provider}:${b.id}`);
if (aEnabled !== bEnabled) return aEnabled ? -1 : 1;
return a.name.localeCompare(b.name);
})
.map((m) => {
const key = `${g.provider}:${m.id}`;
return (
<label
key={m.id}
className="flex items-center justify-between py-1.5 px-2 rounded-md hover:bg-duck-dark/5 cursor-pointer"
>
<span className="text-sm text-duck-dark/70">{m.name}</span>
<Switch checked={enabledModels.includes(key)} onCheckedChange={() => onToggleModel(key)} />
</label>
);
})}
</div>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</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>
);
};
}
@@ -9,11 +9,9 @@ import { TerminalView } from 'apps/Terminal';
import { useWorkspacesState } from '@/state/useWorkspacesState';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { FileViewerProvider, FileViewerHeader, FileViewerBody } from 'apps/FileViewer';
import { useClaude } from '../Chat/useClaude';
import { useOpenCode } from '../Chat/useOpenCode';
import { usePiMono } from '../Chat/usePiMono';
import { ChatPanel } from '../Chat/ChatPanel';
import { useVisibleClaudeModels, useVisibleOpenCodeModels, useVisiblePiMonoModels } from '@/state/useModels';
import { useVisiblePiMonoModels } from '@/state/useModels';
import { ChatHistoryApp as ChatHistory } from '../ChatHistory';
import { Files } from '../Files';
import { Catalog } from 'sounds';
@@ -23,32 +21,9 @@ import { widgetRegistry } from 'widgets/widget-registry';
import { WidgetPanel } from 'widgets/WidgetPanel';
const ChatWidget = () => {
const [provider, setProvider] = useState<'claude' | 'opencode' | 'pi-mono'>('claude');
if (provider === 'claude') {
return <ClaudeChatWidget key="claude" onProviderChange={setProvider} />;
}
if (provider === 'opencode') {
return <OpenCodeChatWidget key="opencode" onProviderChange={setProvider} />;
}
return <PiMonoChatWidget key="pi-mono" onProviderChange={setProvider} />;
};
const ClaudeChatWidget = ({ onProviderChange }: { onProviderChange: (p: 'claude' | 'opencode' | 'pi-mono') => void }) => {
const claude = useClaude();
const models = useVisibleClaudeModels();
return <ChatPanel chat={claude} provider="claude" availableModels={models} onProviderChange={onProviderChange} />;
};
const OpenCodeChatWidget = ({ onProviderChange }: { onProviderChange: (p: 'claude' | 'opencode' | 'pi-mono') => void }) => {
const opencode = useOpenCode();
const models = useVisibleOpenCodeModels();
return <ChatPanel chat={opencode} provider="opencode" availableModels={models} onProviderChange={onProviderChange} />;
};
const PiMonoChatWidget = ({ onProviderChange }: { onProviderChange: (p: 'claude' | 'opencode' | 'pi-mono') => void }) => {
const piMono = usePiMono();
const models = useVisiblePiMonoModels();
return <ChatPanel chat={piMono} provider="pi-mono" availableModels={models} onProviderChange={onProviderChange} />;
return <ChatPanel chat={piMono} provider="pi-mono" availableModels={models} />;
};
const cwdToPath = (cwd: string) => (cwd === '~' ? '/' : cwd.slice(1));
+6 -1
View File
@@ -85,5 +85,10 @@ export const useVisiblePiMonoModels = () => {
const models = usePiMonoModels();
const { settings } = useSettings();
const enabled = settings.ai?.enabledModels ?? [];
return useMemo(() => models.filter((m) => enabled.includes(modelKey(m))), [models, enabled]);
return useMemo(() => {
const filtered = models.filter((m) => enabled.includes(modelKey(m)));
// If no models match the visibility filter, show all — the provider list
// changes dynamically based on API keys so the filter may be stale
return filtered.length > 0 ? filtered : models;
}, [models, enabled]);
};
+2 -2
View File
@@ -169,7 +169,7 @@
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 211 74.77% 45.58%;
--accent-foreground: 0 0% 0%;
--accent-foreground: 0 0% 100%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
@@ -252,7 +252,7 @@
--muted-foreground: 215 20.2% 65.1%;
--accent: 211 82% 64%;
--accent-foreground: 0 0% 0%;
--accent-foreground: 0 0% 100%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
+44 -9
View File
@@ -5,8 +5,8 @@ import { eq } from 'drizzle-orm';
import { honoServer } from './servers/hono';
import { verify } from './servers/jwt';
import { officerdb, TokenBlacklist } from 'officerdb';
import { claudeWebsocket } from './servers/api/claude/websocket';
import { opencodeWebsocket } from './servers/api/opencode/websocket';
// import { claudeWebsocket } from './servers/api/claude/websocket';
// import { opencodeWebsocket } from './servers/api/opencode/websocket';
import { piMonoWebsocket } from './servers/api/pi-mono/websocket';
import { terminalWebsocket, initTerminalSidecars } from './servers/api/terminal/websocket';
import officerWeb from './apps/officer-web/index.html';
@@ -17,21 +17,21 @@ type WSData = {
userId: number;
email: string;
role: string;
provider: 'claude' | 'opencode' | 'pi-mono' | 'terminal';
provider: /* 'claude' | 'opencode' | */ 'pi-mono' | 'terminal';
sandboxed: boolean;
sessionId?: string;
cwd?: string;
command?: string;
};
const handlers: Record<string, typeof claudeWebsocket> = {
claude: claudeWebsocket,
opencode: opencodeWebsocket,
const handlers: Record<string, typeof piMonoWebsocket> = {
// claude: claudeWebsocket,
// opencode: opencodeWebsocket,
'pi-mono': piMonoWebsocket,
terminal: terminalWebsocket,
};
async function upgradeWs(req: Request, server: any, provider: 'claude' | 'opencode' | 'pi-mono' | 'terminal') {
async function upgradeWs(req: Request, server: any, provider: /* 'claude' | 'opencode' | */ 'pi-mono' | 'terminal') {
const token = new URL(req.url).searchParams.get('token');
if (!token) return new Response('Unauthorized', { status: 401 });
@@ -71,8 +71,8 @@ const server = serve({
if (await file.exists()) return new Response(file);
return new Response(null, { status: 404 });
},
'/api/harness/claudecode/ws': (req, server) => upgradeWs(req, server, 'claude'),
'/api/harness/opencode/ws': (req, server) => upgradeWs(req, server, 'opencode'),
// '/api/harness/claudecode/ws': (req, server) => upgradeWs(req, server, 'claude'),
// '/api/harness/opencode/ws': (req, server) => upgradeWs(req, server, 'opencode'),
'/api/harness/pi-mono/ws': (req, server) => upgradeWs(req, server, 'pi-mono'),
'/api/terminal/ws': (req, server) => upgradeWs(req, server, 'terminal'),
'/': officerWeb,
@@ -105,3 +105,38 @@ const server = serve({
console.log(`🚀 Server running at ${server.url}`);
void initTerminalSidecars();
// Ensure pi-mono is installed
(async () => {
try {
const check = Bun.spawn(['pi', '--version'], { stdout: 'pipe', stderr: 'pipe' });
const output = await new Response(check.stdout).text();
await check.exited;
if (check.exitCode === 0) {
console.log(`[pi-mono] found: ${output.trim()}`);
return;
}
} catch {
// not found
}
console.log('[pi-mono] not found, installing...');
try {
const install = Bun.spawn(['npm', 'install', '-g', '@mariozechner/pi-coding-agent'], {
stdout: 'pipe',
stderr: 'pipe',
});
const stderr = await new Response(install.stderr).text();
await install.exited;
if (install.exitCode !== 0) {
console.error('[pi-mono] install failed:', stderr.trim());
return;
}
const ver = Bun.spawn(['pi', '--version'], { stdout: 'pipe', stderr: 'pipe' });
const version = await new Response(ver.stdout).text();
await ver.exited;
console.log(`[pi-mono] installed: ${version.trim()}`);
} catch (err) {
console.error('[pi-mono] install failed:', err);
}
})();
+1
View File
@@ -20,6 +20,7 @@ export type ClientMessage =
resourceChatDir?: string;
taskInfo?: TaskInfo;
}
| { type: 'resume'; sessionId: string }
| { type: 'stop' };
// Server → Client
+19 -29
View File
@@ -1,54 +1,44 @@
import { Hono } from 'hono';
import type { HonoVariables } from '@@/create-router';
import { readApiKeys } from '@@/api/server-settings/pi-mono';
export const piMonoModelsRouter = new Hono<{ Variables: HonoVariables }>();
// Hardcoded fallback models — pi supports many providers but these are the most common
const FALLBACK_MODELS = [
{ id: 'claude-sonnet-4-5-20250514', name: 'Claude Sonnet 4.5', provider: 'anthropic', providerId: 'anthropic' },
{ id: 'claude-opus-4-20250918', name: 'Claude Opus 4', provider: 'anthropic', providerId: 'anthropic' },
{ id: 'gpt-4.1', name: 'GPT-4.1', provider: 'openai', providerId: 'openai' },
{ id: 'o3', name: 'o3', provider: 'openai', providerId: 'openai' },
{ id: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro', provider: 'google', providerId: 'google' },
{ id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash', provider: 'google', providerId: 'google' },
];
piMonoModelsRouter.get('/pi-mono/models', async (ctx) => {
// Spawn a short-lived pi process to query available models
try {
const proc = Bun.spawn(['pi', '--list-models', '--mode', 'json'], {
const storedKeys = await readApiKeys();
const proc = Bun.spawn(['pi', '--list-models'], {
stdout: 'pipe',
stderr: 'pipe',
env: { ...process.env },
env: { ...process.env, ...storedKeys },
});
const output = await new Response(proc.stdout).text();
await proc.exited;
if (proc.exitCode !== 0) return ctx.json(FALLBACK_MODELS);
if (proc.exitCode !== 0) return ctx.json([]);
// Parse the output — pi --list-models outputs model info
// Parse the whitespace-separated table output:
// provider model context max-out thinking images
// anthropic claude-sonnet-4-6 200K 128K yes yes
const lines = output.trim().split('\n').filter(Boolean);
const models: { id: string; name: string; provider: string; providerId: string }[] = [];
for (const line of lines) {
try {
const data = JSON.parse(line);
if (data.id && data.provider) {
// Skip header line (first line)
for (let i = 1; i < lines.length; i++) {
const cols = lines[i]!.trim().split(/\s+/);
if (cols.length < 2) continue;
const [provider, model] = cols;
models.push({
id: data.id,
name: data.name ?? data.id,
provider: data.provider,
providerId: data.provider,
id: `${provider}/${model}`,
name: model!,
provider: provider!,
providerId: provider!,
});
}
} catch {
// skip non-JSON lines
}
}
return ctx.json(models.length > 0 ? models : FALLBACK_MODELS);
return ctx.json(models);
} catch {
return ctx.json(FALLBACK_MODELS);
return ctx.json([]);
}
});
+269 -133
View File
@@ -15,26 +15,33 @@ import {
import { readSkillDirs, parseFrontmatter } from '@@/api/skills/skills';
import type { ClientMessage, ServerMessage, ImageData, TaskInfo } from '@@/api/chat-types';
import { createTaskLog, appendToLog, finalizeLog } from '@@/api/task-logger';
import { readApiKeys, readLocalProviders } from '@@/api/server-settings/pi-mono';
type WSData = { userId: number; email: string };
type ConnectionState = {
// Pi process state, keyed by sessionId — survives websocket reconnects
type PiSession = {
piProcess: Subprocess | null;
sessionId: string | null;
pendingTitle: string | null;
ws: ServerWebSocket<WSData> | null;
selectedModel: string | null;
pendingAttachmentIds: string[];
cwd: string | null;
resourceChatDir: string | null;
logId: string | null;
fullText: string;
rpcReady: boolean;
killTimer: ReturnType<typeof setTimeout> | null;
};
const connections = new Map<ServerWebSocket<WSData>, ConnectionState>();
// Session pool — pi processes persist across websocket reconnects
const sessions = new Map<string, PiSession>();
function send(ws: ServerWebSocket<WSData>, msg: ServerMessage) {
if (ws.readyState === 1) ws.send(JSON.stringify(msg));
// Map ws → sessionId for quick lookup on close
const wsToSession = new Map<ServerWebSocket<WSData>, string>();
// Grace period before killing orphaned pi processes (ms)
const ORPHAN_GRACE_MS = 30_000;
function send(ws: ServerWebSocket<WSData> | null, msg: ServerMessage) {
if (ws && ws.readyState === 1) ws.send(JSON.stringify(msg));
}
function resolveRootDir(email: string, root?: string): string {
@@ -68,29 +75,186 @@ async function buildSkillsPrompt(email: string): Promise<string> {
}
function writeRpcCommand(proc: Subprocess, command: Record<string, unknown>) {
const writer = proc.stdin as WritableStream;
const textEncoder = new TextEncoder();
const w = writer.getWriter();
w.write(textEncoder.encode(JSON.stringify(command) + '\n'));
w.releaseLock();
const stdin = proc.stdin;
if (!stdin || typeof stdin === 'number') return;
try {
(stdin as { write: (data: string) => void; flush: () => void }).write(JSON.stringify(command) + '\n');
(stdin as { flush: () => void }).flush();
} catch (err) {
console.error('[pi-mono-ws] writeRpcCommand error:', err);
}
}
function spawnPiProcess(ws: ServerWebSocket<WSData>, state: ConnectionState, workingDir: string) {
function getOrCreateSession(sessionId: string): PiSession {
let session = sessions.get(sessionId);
if (!session) {
session = {
piProcess: null,
ws: null,
selectedModel: null,
cwd: null,
resourceChatDir: null,
logId: null,
fullText: '',
killTimer: null,
};
sessions.set(sessionId, session);
}
return session;
}
function attachWs(sessionId: string, ws: ServerWebSocket<WSData>) {
const session = getOrCreateSession(sessionId);
// Cancel any pending kill timer — the session is alive again
if (session.killTimer) {
clearTimeout(session.killTimer);
session.killTimer = null;
}
session.ws = ws;
wsToSession.set(ws, sessionId);
}
function detachWs(ws: ServerWebSocket<WSData>) {
const sessionId = wsToSession.get(ws);
wsToSession.delete(ws);
if (!sessionId) return;
const session = sessions.get(sessionId);
if (!session || session.ws !== ws) return;
// Detach ws but keep pi process alive for grace period
session.ws = null;
if (session.piProcess) {
session.killTimer = setTimeout(() => {
// If no new ws has attached, kill the process
if (!session.ws && session.piProcess) {
try {
session.piProcess.kill();
} catch {
// already dead
}
session.piProcess = null;
sessions.delete(sessionId);
}
}, ORPHAN_GRACE_MS);
} else {
sessions.delete(sessionId);
}
}
function resolveLocalModel(modelId: string): { providerId: string; modelName: string } | null {
if (!modelId.startsWith('local:')) return null;
const parts = modelId.split(':');
if (parts.length < 3) return null;
return { providerId: parts[1]!, modelName: parts.slice(2).join(':') };
}
async function handleLocalChat(session: PiSession, prompt: string) {
const parsed = session.selectedModel ? resolveLocalModel(session.selectedModel) : null;
if (!parsed) {
send(session.ws, { type: 'error', message: 'Invalid local model' });
return;
}
const providers = await readLocalProviders();
const provider = providers.find((p) => p.id === parsed.providerId);
if (!provider) {
send(session.ws, { type: 'error', message: 'Local provider not found' });
return;
}
const base = provider.url.replace(/\/+$/, '');
const url = `${base}/v1/chat/completions`;
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (provider.auth?.type === 'api-key') {
headers['Authorization'] = `Bearer ${provider.auth.apiKey}`;
} else if (provider.auth?.type === 'basic') {
headers['Authorization'] = `Basic ${btoa(`${provider.auth.username}:${provider.auth.password}`)}`;
}
const body = JSON.stringify({
model: parsed.modelName,
messages: [{ role: 'user', content: prompt }],
stream: true,
});
try {
const res = await fetch(url, { method: 'POST', headers, body });
if (!res.ok) {
const text = await res.text();
send(session.ws, { type: 'error', message: `Local provider error: ${res.status} ${text}` });
return;
}
session.fullText = '';
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const data = line.slice(6).trim();
if (data === '[DONE]') continue;
try {
const chunk = JSON.parse(data) as { choices?: { delta?: { content?: string } }[] };
const delta = chunk.choices?.[0]?.delta?.content;
if (delta) {
session.fullText += delta;
send(session.ws, { type: 'assistant:partial', text: delta });
}
} catch {
// skip unparseable chunks
}
}
}
if (session.fullText) {
send(session.ws, { type: 'assistant:text', text: session.fullText });
if (session.logId) appendToLog(session.logId, { role: 'assistant', text: session.fullText });
session.fullText = '';
}
send(session.ws, { type: 'result', costUsd: 0, durationMs: 0, numTurns: 0, isError: false });
if (session.logId) {
appendToLog(session.logId, { role: 'result', costUsd: 0, durationMs: 0, numTurns: 0, isError: false });
finalizeLog(session.logId);
session.logId = null;
}
} catch (err) {
send(session.ws, { type: 'error', message: `Local provider error: ${err}` });
}
}
async function spawnPiProcess(session: PiSession, workingDir: string) {
const args = ['pi', '--mode', 'rpc', '--no-extensions', '--no-skills', '--no-prompt-templates', '--no-themes'];
if (state.selectedModel) {
args.push('--model', state.selectedModel);
if (session.selectedModel) {
args.push('--model', session.selectedModel);
}
const storedKeys = await readApiKeys();
const proc = Bun.spawn(args, {
cwd: workingDir,
stdin: 'pipe',
stdout: 'pipe',
stderr: 'pipe',
env: { ...process.env },
env: { ...process.env, ...storedKeys },
});
state.piProcess = proc;
session.piProcess = proc;
// Read stdout line-by-line for JSON events
const reader = proc.stdout.getReader();
@@ -111,7 +275,7 @@ function spawnPiProcess(ws: ServerWebSocket<WSData>, state: ConnectionState, wor
if (!line.trim()) continue;
try {
const event = JSON.parse(line);
handlePiEvent(ws, state, event);
handlePiEvent(session, event);
} catch {
// skip unparseable lines
}
@@ -144,21 +308,19 @@ function spawnPiProcess(ws: ServerWebSocket<WSData>, state: ConnectionState, wor
// Handle process exit
proc.exited.then((code) => {
console.log(`[pi-mono-ws] pi process exited with code ${code}`);
if (state.piProcess === proc) {
state.piProcess = null;
if (session.piProcess === proc) {
session.piProcess = null;
}
});
}
function handlePiEvent(ws: ServerWebSocket<WSData>, state: ConnectionState, event: Record<string, unknown>) {
function handlePiEvent(session: PiSession, event: Record<string, unknown>) {
const type = event.type as string;
const ws = session.ws;
// RPC responses (type === 'response')
if (type === 'response') {
const command = event.command as string;
if (command === 'get_available_models' && event.success) {
// Models are handled by the REST endpoint, not here
}
if (command === 'prompt' && !event.success) {
send(ws, { type: 'error', message: (event.error as string) ?? 'Prompt failed' });
}
@@ -167,29 +329,27 @@ function handlePiEvent(ws: ServerWebSocket<WSData>, state: ConnectionState, even
switch (type) {
case 'agent_start':
state.fullText = '';
session.fullText = '';
break;
case 'message_update': {
// message_update contains assistantMessageEvent with content deltas
const ame = event.assistantMessageEvent as Record<string, unknown> | undefined;
if (!ame) break;
const ameType = ame.type as string;
if (ameType === 'text_delta') {
const delta = ame.delta as string;
state.fullText += delta;
session.fullText += delta;
send(ws, { type: 'assistant:partial', text: delta });
}
break;
}
case 'message_end': {
// Full assistant message complete
if (state.fullText) {
send(ws, { type: 'assistant:text', text: state.fullText });
if (state.logId) appendToLog(state.logId, { role: 'assistant', text: state.fullText });
state.fullText = '';
if (session.fullText) {
send(ws, { type: 'assistant:text', text: session.fullText });
if (session.logId) appendToLog(session.logId, { role: 'assistant', text: session.fullText });
session.fullText = '';
}
break;
}
@@ -199,15 +359,14 @@ function handlePiEvent(ws: ServerWebSocket<WSData>, state: ConnectionState, even
const toolName = (event.toolName as string) ?? 'unknown';
const args = (event.args as Record<string, unknown>) ?? {};
// Commit any streaming text before tool use
if (state.fullText) {
send(ws, { type: 'assistant:text', text: state.fullText });
if (state.logId) appendToLog(state.logId, { role: 'assistant', text: state.fullText });
state.fullText = '';
if (session.fullText) {
send(ws, { type: 'assistant:text', text: session.fullText });
if (session.logId) appendToLog(session.logId, { role: 'assistant', text: session.fullText });
session.fullText = '';
}
send(ws, { type: 'tool:use', toolName, toolInput: args, toolUseId: toolCallId });
if (state.logId) appendToLog(state.logId, { role: 'tool', toolName, toolInput: args, toolUseId: toolCallId });
if (session.logId) appendToLog(session.logId, { role: 'tool', toolName, toolInput: args, toolUseId: toolCallId });
break;
}
@@ -218,8 +377,8 @@ function handlePiEvent(ws: ServerWebSocket<WSData>, state: ConnectionState, even
const output = result != null ? (typeof result === 'string' ? result : JSON.stringify(result)) : '';
send(ws, { type: 'tool:result', toolUseId: toolCallId, output, isError });
if (state.logId)
appendToLog(state.logId, {
if (session.logId)
appendToLog(session.logId, {
role: 'tool',
toolName: '',
toolInput: {},
@@ -231,26 +390,24 @@ function handlePiEvent(ws: ServerWebSocket<WSData>, state: ConnectionState, even
}
case 'agent_end': {
// Commit any remaining streaming text
if (state.fullText) {
send(ws, { type: 'assistant:text', text: state.fullText });
if (state.logId) appendToLog(state.logId, { role: 'assistant', text: state.fullText });
state.fullText = '';
if (session.fullText) {
send(ws, { type: 'assistant:text', text: session.fullText });
if (session.logId) appendToLog(session.logId, { role: 'assistant', text: session.fullText });
session.fullText = '';
}
send(ws, { type: 'result', costUsd: 0, durationMs: 0, numTurns: 0, isError: false });
if (state.logId) {
appendToLog(state.logId, { role: 'result', costUsd: 0, durationMs: 0, numTurns: 0, isError: false });
finalizeLog(state.logId);
state.logId = null;
if (session.logId) {
appendToLog(session.logId, { role: 'result', costUsd: 0, durationMs: 0, numTurns: 0, isError: false });
finalizeLog(session.logId);
session.logId = null;
}
break;
}
case 'extension_ui_request': {
// Auto-cancel extension UI requests since we don't support them
if (state.piProcess && event.id) {
writeRpcCommand(state.piProcess, { type: 'extension_ui_response', id: event.id, cancelled: true });
if (session.piProcess && event.id) {
writeRpcCommand(session.piProcess, { type: 'extension_ui_response', id: event.id, cancelled: true });
}
break;
}
@@ -280,37 +437,46 @@ async function handleChat({
resourceChatDir,
taskInfo,
}: HandleChatParams) {
const state = connections.get(ws);
if (!state) return;
const email = ws.data.email;
if (taskInfo && !state.logId) {
state.logId = createTaskLog(ws.data.email, taskInfo, 'pi-mono', model ?? 'unknown');
appendToLog(state.logId, { role: 'user', text: prompt });
// Determine or create session ID
let sid = sessionId ?? wsToSession.get(ws) ?? null;
let isNewSession = false;
if (!sid) {
sid = crypto.randomUUID();
isNewSession = true;
}
if (resourceChatDir) state.resourceChatDir = resourceChatDir;
if (model) state.selectedModel = model;
// Attach this ws to the session (adopts existing pi process if any)
attachWs(sid, ws);
const session = getOrCreateSession(sid);
if (!sessionId && !state.sessionId) {
// New session — generate our own sessionId for officer tracking
const newSessionId = crypto.randomUUID();
state.sessionId = newSessionId;
state.pendingTitle = prompt.slice(0, 100);
if (attachmentIds?.length) state.pendingAttachmentIds = attachmentIds;
if (taskInfo && !session.logId) {
session.logId = createTaskLog(email, taskInfo, 'pi-mono', model ?? 'unknown');
appendToLog(session.logId, { role: 'user', text: prompt });
}
send(ws, { type: 'session:init', sessionId: newSessionId, model: model ?? 'pi-mono' });
if (resourceChatDir) session.resourceChatDir = resourceChatDir;
if (model) session.selectedModel = model;
if (state.resourceChatDir) {
const chatDir = join(state.resourceChatDir, 'chat');
const meta = { id: newSessionId, model: model ?? 'pi-mono' };
if (isNewSession) {
const pendingTitle = prompt.slice(0, 100);
// Send session:init AFTER attaching ws so the pi process survives the reconnect
send(ws, { type: 'session:init', sessionId: sid, model: model ?? 'pi-mono' });
if (session.resourceChatDir) {
const chatDir = join(session.resourceChatDir, 'chat');
const meta = { id: sid, model: model ?? 'pi-mono' };
mkdir(chatDir, { recursive: true })
.then(() => Bun.write(join(chatDir, 'meta.json'), JSON.stringify(meta)))
.catch(() => {});
} else {
const dir = getPiMonoSessionDir(ws.data.email, newSessionId);
const dir = getPiMonoSessionDir(email, sid);
const meta = {
id: newSessionId,
title: state.pendingTitle ?? 'New chat',
id: sid,
title: pendingTitle,
createdAt: Date.now(),
model: model ?? 'pi-mono',
};
@@ -319,95 +485,67 @@ async function handleChat({
.catch(() => {});
// Move tmp attachments to session dir
if (state.pendingAttachmentIds.length > 0) {
const tmpDir = getTmpAttachmentsDir(ws.data.email);
const destDir = getAttachmentsDir(ws.data.email, 'pi-mono', newSessionId);
if (attachmentIds?.length) {
const tmpDir = getTmpAttachmentsDir(email);
const destDir = getAttachmentsDir(email, 'pi-mono', sid);
mkdir(destDir, { recursive: true })
.then(() =>
Promise.all(
state.pendingAttachmentIds.map((id) => rename(join(tmpDir, id), join(destDir, id)).catch(() => {})),
),
Promise.all(attachmentIds.map((id) => rename(join(tmpDir, id), join(destDir, id)).catch(() => {}))),
)
.catch(() => {});
state.pendingAttachmentIds = [];
}
}
state.pendingTitle = null;
} else if (sessionId && !state.sessionId) {
state.sessionId = sessionId;
}
// Local provider models — bypass pi, call API directly
if (session.selectedModel?.startsWith('local:')) {
handleLocalChat(session, prompt);
return;
}
// Ensure pi process is running
const homeDir = getHomeDir(ws.data.email);
if (cwd) state.cwd = join(resolveRootDir(ws.data.email, cwd.root), cwd.path);
const workingDir = state.cwd ?? homeDir;
const homeDir = getHomeDir(email);
if (cwd) session.cwd = join(resolveRootDir(email, cwd.root), cwd.path);
const workingDir = session.cwd ?? homeDir;
if (!state.piProcess) {
spawnPiProcess(ws, state, workingDir);
if (!session.piProcess) {
await spawnPiProcess(session, workingDir);
// Give pi a moment to initialize
await new Promise((r) => setTimeout(r, 500));
}
if (!state.piProcess) {
send(ws, { type: 'error', message: 'Failed to start pi process' });
if (!session.piProcess) {
send(session.ws, { type: 'error', message: 'Failed to start pi process' });
return;
}
// Set model if specified
if (model && model !== state.selectedModel) {
state.selectedModel = model;
// Model is set via CLI args on spawn, would need a new process to change
}
// Build context and send prompt
const skillsAppend = await buildSkillsPrompt(ws.data.email);
const skillsAppend = await buildSkillsPrompt(email);
const contextAppend = `\n\nThe user's home directory is: ${homeDir}` + skillsAppend;
send(ws, { type: 'system:prompt', text: contextAppend });
send(session.ws, { type: 'system:prompt', text: contextAppend });
const fullPrompt = `<system>${contextAppend}</system>\n\n${prompt}`;
const rpcCommand: Record<string, unknown> = {
writeRpcCommand(session.piProcess, {
type: 'prompt',
id: `req_${Date.now()}`,
message: fullPrompt,
};
writeRpcCommand(state.piProcess, rpcCommand);
});
}
function handleStop(ws: ServerWebSocket<WSData>) {
const state = connections.get(ws);
if (!state?.piProcess) return;
const sessionId = wsToSession.get(ws);
if (!sessionId) return;
const session = sessions.get(sessionId);
if (!session?.piProcess) return;
writeRpcCommand(state.piProcess, { type: 'abort', id: `abort_${Date.now()}` });
writeRpcCommand(session.piProcess, { type: 'abort', id: `abort_${Date.now()}` });
send(ws, { type: 'stopped' });
}
function killPiProcess(state: ConnectionState) {
if (state.piProcess) {
try {
state.piProcess.kill();
} catch {
// already dead
}
state.piProcess = null;
}
}
export const piMonoWebsocket = {
open(ws: ServerWebSocket<WSData>) {
connections.set(ws, {
piProcess: null,
sessionId: null,
pendingTitle: null,
selectedModel: null,
pendingAttachmentIds: [],
cwd: null,
resourceChatDir: null,
logId: null,
fullText: '',
rpcReady: false,
});
// Nothing to do — session is attached when a chat message arrives
},
message(ws: ServerWebSocket<WSData>, raw: string | Buffer) {
@@ -431,17 +569,15 @@ export const piMonoWebsocket = {
resourceChatDir: msg.resourceChatDir,
taskInfo: msg.taskInfo,
});
} else if (msg.type === 'resume') {
attachWs(msg.sessionId, ws);
} else if (msg.type === 'stop') {
handleStop(ws);
}
},
close(ws: ServerWebSocket<WSData>) {
const state = connections.get(ws);
if (state) {
killPiProcess(state);
}
connections.delete(ws);
detachWs(ws);
},
drain() {},
+283
View File
@@ -1,7 +1,221 @@
import { join } from 'node:path';
import { createRouter } from '../../create-router';
import { DATA_PATH } from '../../data-path';
export const piMonoRouter = createRouter();
const API_KEYS_FILE = join(DATA_PATH, 'pi_mono_api_keys.json');
const LOCAL_PROVIDERS_FILE = join(DATA_PATH, 'pi_mono_local_providers.json');
// --- Local provider types ---
export type LocalProvider = {
id: string;
name: string;
url: string;
apiType: 'ollama' | 'openai-compatible' | 'lmstudio';
auth?: { type: 'api-key'; apiKey: string } | { type: 'basic'; username: string; password: string };
};
type ProbeResult = {
success: boolean;
apiType?: LocalProvider['apiType'];
name?: string;
needsAuth?: boolean;
authType?: 'api-key' | 'basic' | 'unknown';
models?: string[];
error?: string;
};
export async function readLocalProviders(): Promise<LocalProvider[]> {
try {
const file = Bun.file(LOCAL_PROVIDERS_FILE);
if (!(await file.exists())) return [];
return (await file.json()) as LocalProvider[];
} catch {
return [];
}
}
async function writeLocalProviders(providers: LocalProvider[]) {
await Bun.write(LOCAL_PROVIDERS_FILE, JSON.stringify(providers, null, 2));
}
async function probeUrl(url: string, auth?: LocalProvider['auth']): Promise<ProbeResult> {
const base = url.replace(/\/+$/, '');
const timeout = 5000;
const headers: Record<string, string> = {};
if (auth?.type === 'api-key') {
headers['Authorization'] = `Bearer ${auth.apiKey}`;
} else if (auth?.type === 'basic') {
headers['Authorization'] = `Basic ${btoa(`${auth.username}:${auth.password}`)}`;
}
const tryFetch = async (path: string) => {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
try {
const res = await fetch(`${base}${path}`, { headers, signal: controller.signal });
return res;
} catch {
return null;
} finally {
clearTimeout(timer);
}
};
// 1. Try Ollama: GET /api/tags
const ollamaRes = await tryFetch('/api/tags');
if (ollamaRes) {
if (ollamaRes.status === 401 || ollamaRes.status === 403) {
return { success: true, apiType: 'ollama', name: 'Ollama', needsAuth: true, authType: 'unknown' };
}
if (ollamaRes.ok) {
try {
const data = (await ollamaRes.json()) as { models?: { name: string }[] };
if (data.models) {
return {
success: true,
apiType: 'ollama',
name: 'Ollama',
needsAuth: false,
models: data.models.map((m) => m.name),
};
}
} catch {
// not ollama, continue
}
}
}
// 2. Try LM Studio: GET /v1/models (LM Studio returns specific format)
// 3. Try OpenAI-compatible: GET /v1/models
const oaiRes = await tryFetch('/v1/models');
if (oaiRes) {
if (oaiRes.status === 401 || oaiRes.status === 403) {
const wwwAuth = oaiRes.headers.get('www-authenticate') ?? '';
const authType = wwwAuth.toLowerCase().includes('basic') ? 'basic' as const : 'api-key' as const;
return { success: true, apiType: 'openai-compatible', name: 'OpenAI-compatible', needsAuth: true, authType };
}
if (oaiRes.ok) {
try {
const data = (await oaiRes.json()) as { data?: { id: string }[]; object?: string };
if (data.data) {
// LM Studio includes "lm-studio" in model IDs
const isLmStudio = data.data.some((m) => m.id.includes('lm-studio'));
const apiType = isLmStudio ? 'lmstudio' as const : 'openai-compatible' as const;
const name = isLmStudio ? 'LM Studio' : 'OpenAI-compatible';
return {
success: true,
apiType,
name,
needsAuth: false,
models: data.data.map((m) => m.id),
};
}
} catch {
// not valid JSON
}
}
}
// 4. Try bare /models (some servers)
const bareRes = await tryFetch('/models');
if (bareRes) {
if (bareRes.status === 401 || bareRes.status === 403) {
return { success: true, apiType: 'openai-compatible', name: 'OpenAI-compatible', needsAuth: true, authType: 'api-key' };
}
if (bareRes.ok) {
try {
const data = (await bareRes.json()) as { data?: { id: string }[] };
if (data.data) {
return {
success: true,
apiType: 'openai-compatible',
name: 'OpenAI-compatible',
needsAuth: false,
models: data.data.map((m) => m.id),
};
}
} catch {
// continue
}
}
}
return { success: false, error: 'Could not detect API type at this URL' };
}
export async function readApiKeys(): Promise<Record<string, string>> {
try {
const file = Bun.file(API_KEYS_FILE);
if (!(await file.exists())) return {};
return (await file.json()) as Record<string, string>;
} catch {
return {};
}
}
async function writeApiKeys(keys: Record<string, string>) {
await Bun.write(API_KEYS_FILE, JSON.stringify(keys, null, 2));
}
const PROVIDERS: { key: string; env: string[] }[] = [
{ key: 'OpenAI', env: ['OPENAI_API_KEY'] },
{ key: 'Google', env: ['GOOGLE_API_KEY', 'GEMINI_API_KEY'] },
{ key: 'OpenCode Zen', env: ['OPENCODE_API_KEY'] },
{ key: 'MiniMax', env: ['MINIMAX_API_KEY'] },
{ key: 'Groq', env: ['GROQ_API_KEY'] },
{ key: 'Mistral', env: ['MISTRAL_API_KEY'] },
{ key: 'xAI', env: ['XAI_API_KEY'] },
{ key: 'OpenRouter', env: ['OPENROUTER_API_KEY'] },
{ key: 'Hugging Face', env: ['HF_TOKEN'] },
{ key: 'GitHub Copilot', env: ['COPILOT_GITHUB_TOKEN'] },
{ key: 'Amazon Bedrock', env: ['AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', 'AWS_REGION'] },
{ key: 'Google Vertex AI', env: ['GOOGLE_APPLICATION_CREDENTIALS', 'GOOGLE_CLOUD_PROJECT', 'GOOGLE_CLOUD_LOCATION'] },
{ key: 'Azure OpenAI', env: ['AZURE_OPENAI_API_KEY', 'AZURE_OPENAI_BASE_URL'] },
{ key: 'Anthropic', env: ['ANTHROPIC_API_KEY'] },
];
piMonoRouter.get('/auth', async (ctx) => {
const storedKeys = await readApiKeys();
const providers = PROVIDERS.filter((p) =>
p.env.some((e) => storedKeys[e]?.trim() || process.env[e]?.trim()),
).map((p) => p.key);
return ctx.json({ authenticated: providers.length > 0, providers });
});
const maskValue = (value: string) => {
if (value.length <= 8) return '***';
return value.slice(0, 3) + '...' + value.slice(-3);
};
piMonoRouter.get('/api-keys', async (ctx) => {
const storedKeys = await readApiKeys();
const keys = PROVIDERS.flatMap((p) =>
p.env
.filter((e) => storedKeys[e]?.trim())
.map((e) => ({ env: e, value: maskValue(storedKeys[e]!) })),
);
return ctx.json({ keys });
});
piMonoRouter.put('/api-keys', async (ctx) => {
const { key, value } = await ctx.req.json<{ key: string; value: string }>();
const allEnvs = PROVIDERS.flatMap((p) => p.env);
if (!allEnvs.includes(key)) return ctx.json({ error: 'Invalid key' }, 400);
const keys = await readApiKeys();
if (value.trim()) {
keys[key] = value.trim();
} else {
delete keys[key];
}
await writeApiKeys(keys);
return ctx.json({ ok: true });
});
const GLOBAL_DIRS = ['/usr/local/bin', '/usr/bin'];
const getPaths = async () => {
@@ -52,3 +266,72 @@ piMonoRouter.post('/install', async (ctx) => {
return ctx.json({ version: null, path: null, globalPath: null, error: 'Installation failed' }, 500);
}
});
// --- Local providers ---
piMonoRouter.get('/local-providers', async (ctx) => {
const providers = await readLocalProviders();
return ctx.json(providers.map((p) => ({
...p,
auth: p.auth ? { type: p.auth.type } : undefined,
})));
});
piMonoRouter.post('/local-providers/probe', async (ctx) => {
const { url, auth } = await ctx.req.json<{ url: string; auth?: LocalProvider['auth'] }>();
if (!url?.trim()) return ctx.json({ success: false, error: 'URL is required' }, 400);
const result = await probeUrl(url.trim(), auth);
return ctx.json(result);
});
piMonoRouter.post('/local-providers', async (ctx) => {
const body = await ctx.req.json<{ url: string; name?: string; apiType: LocalProvider['apiType']; auth?: LocalProvider['auth'] }>();
const providers = await readLocalProviders();
const provider: LocalProvider = {
id: crypto.randomUUID(),
name: body.name ?? body.apiType,
url: body.url.replace(/\/+$/, ''),
apiType: body.apiType,
auth: body.auth,
};
providers.push(provider);
await writeLocalProviders(providers);
return ctx.json({ ...provider, auth: provider.auth ? { type: provider.auth.type } : undefined });
});
piMonoRouter.delete('/local-providers/:id', async (ctx) => {
const { id } = ctx.req.param();
const providers = await readLocalProviders();
const filtered = providers.filter((p) => p.id !== id);
if (filtered.length === providers.length) return ctx.json({ error: 'Not found' }, 404);
await writeLocalProviders(filtered);
return ctx.json({ ok: true });
});
piMonoRouter.get('/local-providers/health', async (ctx) => {
const providers = await readLocalProviders();
const results: Record<string, boolean> = {};
await Promise.all(providers.map(async (p) => {
const base = p.url.replace(/\/+$/, '');
const path = p.apiType === 'ollama' ? '/api/tags' : '/v1/models';
const headers: Record<string, string> = {};
if (p.auth?.type === 'api-key') headers['Authorization'] = `Bearer ${p.auth.apiKey}`;
else if (p.auth?.type === 'basic') headers['Authorization'] = `Basic ${btoa(`${p.auth.username}:${p.auth.password}`)}`;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 3000);
try {
const res = await fetch(`${base}${path}`, { headers, signal: controller.signal });
results[p.id] = res.ok;
} catch {
results[p.id] = false;
} finally {
clearTimeout(timer);
}
}));
return ctx.json(results);
});