diff --git a/src/apps/officer-web/App.tsx b/src/apps/officer-web/App.tsx index ae86480a..5f998f52 100644 --- a/src/apps/officer-web/App.tsx +++ b/src/apps/officer-web/App.tsx @@ -43,10 +43,6 @@ export function App() { } /> } /> } /> - } /> - } /> - } /> - } /> } /> } /> diff --git a/src/apps/officer-web/Screens/Dashboard/CapabilityPage.tsx b/src/apps/officer-web/Screens/Dashboard/CapabilityPage.tsx index ce016528..4ee48c1e 100644 --- a/src/apps/officer-web/Screens/Dashboard/CapabilityPage.tsx +++ b/src/apps/officer-web/Screens/Dashboard/CapabilityPage.tsx @@ -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 - : `\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`; - 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 ( - - ); -}; - -const CapabilityChatOpenCode = ({ +export const CapabilityChat = ({ kind, filePath, resourceDir, isNew, description, onResponseEnd, - onProviderChange, -}: CapabilityChatInnerProps) => { - const openCodeModels = useVisibleOpenCodeModels(); - const seedFile = `${kind.toUpperCase()}.md`; - const promptFrontmatter = `\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`; - 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 ( - - ); -}; - -const CapabilityChatPiMono = ({ - kind, - filePath, - resourceDir, - isNew, - description, - onResponseEnd, - onProviderChange, -}: CapabilityChatInnerProps) => { +}: CapabilityChatProps) => { const piMonoModels = useVisiblePiMonoModels(); const seedFile = `${kind.toUpperCase()}.md`; const promptFrontmatter = `\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`; @@ -207,9 +87,7 @@ const CapabilityChatPiMono = ({ return ( { - const { settings } = useSettings(); - const [provider, setProvider] = useState<'claude' | 'opencode' | 'pi-mono'>(settings.chat.defaultProvider); - - if (provider === 'claude') { - return ; - } - if (provider === 'opencode') { - return ; - } - return ; -}; - export const FrontmatterBlock = ({ yaml }: { yaml: string }) => { const [open, setOpen] = useState(false); diff --git a/src/apps/officer-web/Screens/Dashboard/Chat/ChatPanel.tsx b/src/apps/officer-web/Screens/Dashboard/Chat/ChatPanel.tsx index 6b4baf85..470c1a76 100644 --- a/src/apps/officer-web/Screens/Dashboard/Chat/ChatPanel.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Chat/ChatPanel.tsx @@ -14,10 +14,9 @@ type ChatPanelProps = { chat: ReturnType; 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 ; - provider?: 'claude' | 'opencode' | 'pi-mono'; availableModels?: ModelOption[]; - onProviderChange?: (provider: 'claude' | 'opencode' | 'pi-mono') => void; onBeforeSend?: (text: string) => boolean | Promise; 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} diff --git a/src/apps/officer-web/Screens/Dashboard/Chat/InputArea.tsx b/src/apps/officer-web/Screens/Dashboard/Chat/InputArea.tsx index a772e8be..7b2ed0cc 100644 --- a/src/apps/officer-web/Screens/Dashboard/Chat/InputArea.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Chat/InputArea.tsx @@ -61,9 +61,7 @@ type InputAreaProps = { isConnected: boolean; commandFeedback: string | null; textareaRef: RefObject; - 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 = ({ )} = { + 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 (
{messages.length > 0 ? ( - {provider === 'claude' ? 'Claude' : provider === 'opencode' ? 'OpenCode' : 'Pi'} + {activeProvider ? displayName(activeProvider) : 'Pi'} ) : (
- {(['claude', 'opencode', 'pi-mono'] as const).map((value) => ( + {providers.map((provider) => ( ))}
)}
- {availableModels.length > 0 && provider === 'opencode' ? ( - - ) : availableModels.length > 0 ? ( + {providerModels.length > 0 ? ( ) : ( - {model ?? (provider === 'claude' ? 'Claude' : 'OpenCode')} + {model ?? 'Pi'} )}
diff --git a/src/apps/officer-web/Screens/Dashboard/Chat/usePiMono.ts b/src/apps/officer-web/Screens/Dashboard/Chat/usePiMono.ts index 5d8bb7c2..e179b75f 100644 --- a/src/apps/officer-web/Screens/Dashboard/Chat/usePiMono.ts +++ b/src/apps/officer-web/Screens/Dashboard/Chat/usePiMono.ts @@ -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; diff --git a/src/apps/officer-web/Screens/Dashboard/ChatHistory/ChatDetailPanel.tsx b/src/apps/officer-web/Screens/Dashboard/ChatHistory/ChatDetailPanel.tsx index b5c8764b..7281bf0e 100644 --- a/src/apps/officer-web/Screens/Dashboard/ChatHistory/ChatDetailPanel.tsx +++ b/src/apps/officer-web/Screens/Dashboard/ChatHistory/ChatDetailPanel.tsx @@ -108,7 +108,7 @@ const ClaudeInner = ({ sessionId, model }: InnerProps) => { window.history.replaceState(null, '', '/chat'); }} /> - + ); }; @@ -134,7 +134,7 @@ const OpenCodeInner = ({ sessionId, model }: InnerProps) => { window.history.replaceState(null, '', '/chat'); }} /> - + ); }; @@ -160,12 +160,12 @@ const PiMonoInner = ({ sessionId, model }: InnerProps) => { window.history.replaceState(null, '', '/chat'); }} /> - + ); }; -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' | /> 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' /> @@ -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' | /> @@ -320,33 +314,15 @@ type NewChatPanelProps = { initialProvider?: 'claude' | 'opencode' | 'pi-mono'; }; -const NewChatPanel = ({ initialProvider = 'claude' }: NewChatPanelProps) => { - const [selected, setSelected] = usePanelChannel(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(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 ; - } - if (selected.provider === 'opencode') { - return ; - } return ; } - if (provider === 'claude') { - return ; - } - if (provider === 'opencode') { - return ; - } - return ; + return ; }; export const ChatDetailPanel = () => { @@ -364,11 +340,5 @@ export const ChatDetailPanel = () => { return ; } - if (selected.provider === 'claude') { - return ; - } - if (selected.provider === 'opencode') { - return ; - } return ; }; diff --git a/src/apps/officer-web/Screens/Dashboard/ChatHistory/Screen.tsx b/src/apps/officer-web/Screens/Dashboard/ChatHistory/Screen.tsx index 3b0cfcae..c54c7e3f 100644 --- a/src/apps/officer-web/Screens/Dashboard/ChatHistory/Screen.tsx +++ b/src/apps/officer-web/Screens/Dashboard/ChatHistory/Screen.tsx @@ -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('all'); const { sessions, deleteSession } = useChatSessions(); const [selected, setSelected] = usePanelChannel('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,51 +40,33 @@ export const SessionList = () => { {/* Header */}

Sessions

-
- {/* Radio filter */} -
- {(['all', 'claude', 'opencode', 'pi-mono'] as const).map((value) => ( - - ))} -
- -
+
{/* Session list */}
- {filtered.length === 0 && ( + {sessions.length === 0 && (
No sessions yet. Start a new chat!
)} - {filtered.map((session) => { + {sessions.map((session) => { const isSelected = selected?.id === session.id && selected?.provider === session.provider; return (
{
{session.title} + + ({session.id.slice(0, 8)}) +
{new Date(session.createdAt).toLocaleDateString(undefined, { @@ -117,21 +91,12 @@ export const SessionList = () => { hour: '2-digit', minute: '2-digit', })} - - {session.provider === 'claude' ? 'Claude' : session.provider === 'opencode' ? 'OpenCode' : 'Pi'} - - - {session.id.slice(0, 8)} -
+ {session.model && ( +
+ {session.model} +
+ )}
{/* Chat */} - {provider === 'claude' ? ( - - ) : provider === 'opencode' ? ( - - ) : ( - - )} + diff --git a/src/apps/officer-web/Screens/Dashboard/Home/ChatLauncher.tsx b/src/apps/officer-web/Screens/Dashboard/Home/ChatLauncher.tsx index 430b74a3..652d332b 100644 --- a/src/apps/officer-web/Screens/Dashboard/Home/ChatLauncher.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Home/ChatLauncher.tsx @@ -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 = { + 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(settings.chat.defaultModel); const [input, setInput] = useState(''); const [attachments, setAttachments] = useState([]); @@ -44,11 +58,23 @@ export const ChatLauncher = () => { const imageInputRef = useRef(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 = () => {
- {(['claude', 'opencode', 'pi-mono'] as const).map((value) => ( + {providers.map((provider) => ( ))}
- {models.length > 0 && ( + {providerModels.length > 0 && ( - {models.map((m) => ( + {providerModels.map((m) => ( setModel(m.id)} className="cursor-pointer"> {m.name} - {m.provider && ({m.provider})} ))} diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/AIHarnessesSection.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/AIHarnessesSection.tsx index 68730c60..c099a7b7 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/AIHarnessesSection.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/AIHarnessesSection.tsx @@ -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(null); - - const { data: opencodeVersion, isLoading: opencodeLoading } = useQuery({ - queryKey: ['OPENCODE_VERSION'], - queryFn: () => client.get('/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('/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('/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('/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('/server-settings/pi-mono/api-keys'), + enabled: !!piMonoVersion?.version, }); - const { data: claudeAuth } = useQuery({ - queryKey: ['CLAUDE_CODE_AUTH'], - queryFn: () => client.get('/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('/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 } = useQuery({ + queryKey: ['PI_MONO_LOCAL_HEALTH'], + queryFn: () => client.get>('/server-settings/pi-mono/local-providers/health'), + enabled: localProviders.length > 0, + refetchInterval: 15_000, + }); + + const [keyInputs, setKeyInputs] = useState>({}); + const [savingKey, setSavingKey] = useState(null); + const [commandOpen, setCommandOpen] = useState(false); + const [editingProvider, setEditingProvider] = useState(null); + + // Local provider connection flow + const [addingLocal, setAddingLocal] = useState(false); + const [localName, setLocalName] = useState(''); + const [localUrl, setLocalUrl] = useState(''); + const [probeState, setProbeState] = useState({ 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('/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('/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('/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('/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(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('/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[] }) => ( -
- Not globally accessible. Run: -
- {command} - - -
-
- ); + 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 ( - <>
- - {aiHarnesses?.opencode && ( -
- {opencodeLoading ? ( - 'Checking version...' - ) : opencodeVersion?.version ? ( - <> -
{opencodeVersion.version}
-
{opencodeVersion.path}
- {opencodeAuth && ( -
- {opencodeAuth.authenticated ? ( - `Logged in (${opencodeAuth.providers.join(', ')})` - ) : ( -
- Not logged in - -
- )} -
- )} - {!opencodeVersion.globalPath && opencodeVersion.path && ( - - )} - - ) : ( - - )} -
- )} -
- -
- - {aiHarnesses?.claudeCode && ( -
- {claudeLoading ? ( - 'Checking version...' - ) : claudeVersion?.version ? ( - <> -
{claudeVersion.version}
-
{claudeVersion.path}
- {claudeAuth && ( -
- {claudeAuth.authenticated ? ( - `Logged in (${claudeAuth.subscriptionType ?? 'unknown plan'})` - ) : ( -
- Not logged in - -
- )} -
- )} - {!claudeVersion.globalPath && claudeVersion.path && ( - - )} - - ) : ( - - )} -
- )} -
- -
- - {aiHarnesses?.piMono && ( -
- {piMonoLoading ? ( - 'Checking version...' - ) : piMonoVersion?.version ? ( - <> -
{piMonoVersion.version}
-
{piMonoVersion.path}
- {!piMonoVersion.globalPath && piMonoVersion.path && ( - - )} - - ) : ( - - )} + + +
+ ) : ( + + )} +
+ {piMonoVersion?.version && ( + <> + {/* Local Providers */} +
+ Local Providers +
+ {localProviders.map((lp: LocalProviderEntry) => ( +
+ + {lp.name} + {lp.url} + +
+ ))} + {addingLocal ? ( +
+ {/* Step 1: Name + URL inputs stacked */} + setLocalName(ev.target.value)} + onKeyDown={(ev) => { + if (ev.key === 'Escape') resetLocalForm(); + }} + disabled={probeState.step !== 'url'} + autoFocus + /> + 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'} + /> +
+ {probeState.step === 'url' && ( + + )} + {(probeState.step === 'probing' || probeState.step === 'saving') && ( + + )} + +
+ + {/* Step 2: Auth form (if needed) */} + {probeState.step === 'auth' && ( +
+ + {probeState.probe.name} requires authentication + + {probeState.probe.authType === 'basic' ? ( + <> + setAuthUsername(ev.target.value)} + autoFocus + /> + setAuthPassword(ev.target.value)} + onKeyDown={(ev) => { + if (ev.key === 'Enter' && authUsername && authPassword) handleAuthSubmit(probeState.probe); + }} + /> + + + ) : ( + <> + setAuthApiKey(ev.target.value)} + onKeyDown={(ev) => { + if (ev.key === 'Enter' && authApiKey) handleAuthSubmit(probeState.probe); + }} + autoFocus + /> + + + )} +
+ )} +
+ ) : ( + + )} +
+ + {/* Remote Providers */} +
+ Remote Providers +
+ {connectedProviders.map((provider) => ( +
+
+ {provider.key} + +
+ {provider.env.map((env) => ( +
+ + setKeyInputs((prev) => ({ ...prev, [env]: ev.target.value }))} + /> + +
+ ))} +
+ ))} + {editingProvider && (() => { + const provider = PI_PROVIDERS.find((p) => p.key === editingProvider); + if (!provider || connectedProviders.includes(provider)) return null; + return ( +
+ {provider.key} + {provider.env.map((env) => ( +
+ + setKeyInputs((prev) => ({ ...prev, [env]: ev.target.value }))} + autoFocus={env === provider.env[0]} + /> + +
+ ))} +
+ ); + })()} + {unconnectedProviders.length > 0 && ( + + )} +
+ + + + No providers found. + + {unconnectedProviders.map((provider) => ( + { + setEditingProvider(provider.key); + setCommandOpen(false); + }} + > + {provider.key} + + ))} + + + +
+ )}
- - !open && setConfirmCommand(null)}> - - - Run with elevated privileges - - You are about to run a command with elevated privileges (sudo). Are you sure? - - - {confirmCommand?.command} - - Cancel - { - if (confirmCommand) setRunCommand(confirmCommand); - setConfirmCommand(null); - }} - > - Run - - - - - ); }; diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/SettingsPanel.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/SettingsPanel.tsx index 20878be6..aa9c9474 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/SettingsPanel.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/SettingsPanel.tsx @@ -131,7 +131,7 @@ export const SettingsContent = ({ globalKey, sections }: SettingsContentProps) =

{section.title}

{section.description}

-
{section.content}
+
{section.content}
); }; diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/SystemSettings.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/SystemSettings.tsx index 9dc63f8a..f61997fe 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/SystemSettings.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/SystemSettings.tsx @@ -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: }, - { key: 'plugins', icon: Puzzle, title: 'Plugins', description: 'Enable or disable plugins', content: }, - ], - }, { label: 'AI', icon: Bot, sections: [ + { key: 'ai-harnesses', icon: Terminal, title: 'Providers', description: 'Remote and local AI providers', content: }, + { key: 'model-visibility', icon: Eye, title: 'Models', description: 'Enable or disable models', content: }, { key: 'chat-defaults', icon: Terminal, title: 'Chat Defaults', description: 'Model, prompt, and temperature', content: }, - { key: 'model-visibility', icon: Eye, title: 'Model Visibility', description: 'Enable or disable models', content: }, ], }, ]; @@ -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() { - {allModels.map((m) => ( - + {piMonoModels.map((m) => ( + {m.name} - ({m.provider}) + {m.provider && ({m.provider})} ))} @@ -269,25 +237,81 @@ function ChatDefaultsSection() { ); } +type ModelPillProps = { + label: string; + modelKey: string; + enabled: boolean; + onAction: (key: string) => void; + onDragStart: (ev: DragEvent, key: string) => void; +}; + +const ModelPill = ({ label, modelKey, enabled, onAction, onDragStart }: ModelPillProps) => ( + 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} + + +); + +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 ( +
+ {label} +
+ {children} +
+
+ ); +}; + 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 [activeProvider, setActiveProvider] = useUserState('model-visibility-provider', ''); 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 } }); - }; - - const buildProviderGroups = (models: { id: string; name: string; provider?: string }[]) => { + const providerGroups = useMemo(() => { const groups: Record = {}; - 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(''); + // 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 { enabled, disabled } = useMemo(() => { + if (!currentGroup) return { enabled: [], disabled: [] }; + const en: { id: string; name: string; key: string }[] = []; + const dis: { id: string; name: string; key: string }[] = []; + for (const m of currentGroup.models) { + const key = `${currentGroup.provider}:${m.id}`; + if (enabledModels.includes(key)) { + en.push({ ...m, key }); + } else { + dis.push({ ...m, key }); + } + } + return { enabled: en, disabled: dis }; + }, [currentGroup, enabledModels]); - const handleRemoveProvider = async (provider: string) => { - await saveSettings({ - ...settings, - ai: { ...settings.ai, enabledProviders: enabledProviders.filter((p) => p !== provider) }, - }); - }; - - return ( - - - - Claude - - - OpenCode - - - Pi - - - - -
- {claudeModels.map((m) => ( - - ))} -
-
- - - - - - - !enabledProviders.includes(g.provider)) : disabledProviders} - addingProvider={addingProvider} - selectedNewProvider={selectedNewProvider} - emptyLabel="No Pi models available." - onSetAddingProvider={setAddingProvider} - onSetSelectedNewProvider={setSelectedNewProvider} - onEnableProvider={handleEnableProvider} - onToggleModel={toggleModel} - onRemoveProvider={handleRemoveProvider} - /> - -
+ const enableModel = useCallback( + async (key: string) => { + if (enabledModels.includes(key)) return; + await saveSettings({ ...settings, ai: { ...settings.ai, enabledModels: [...enabledModels, key] } }); + }, + [settings, enabledModels, saveSettings], ); -} -// --- Shared components --- + const disableModel = useCallback( + async (key: string) => { + await saveSettings({ ...settings, ai: { ...settings.ai, enabledModels: enabledModels.filter((id) => id !== key) } }); + }, + [settings, enabledModels, saveSettings], + ); -type ProviderGroup = { provider: string; models: { id: string; name: string }[] }; + const onDragStart = useCallback((ev: DragEvent, key: string) => { + ev.dataTransfer.setData('text/plain', key); + ev.dataTransfer.effectAllowed = 'move'; + }, []); -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) => ( - <> -
- {addingProvider ? ( - <> - - - - ) : ( - - )} -
- {groups.length === 0 ? ( -

{emptyLabel}

- ) : ( - - )} - -); - -type ProviderListProps = { - groups: ProviderGroup[]; - enabledProviders: string[]; - enabledModels: string[]; - onToggleModel: (key: string) => void; - onRemoveProvider: (provider: string) => void; -}; - -const ProviderList = ({ - groups, - enabledProviders, - enabledModels, - onToggleModel, - onRemoveProvider, -}: ProviderListProps) => { - const [openProvider, setOpenProvider] = useUserState('ai-settings-provider-accordion', ''); - const enabled = groups.filter((g) => enabledProviders.includes(g.provider)); - - if (enabled.length === 0) { - return

No providers enabled.

; + if (providerGroups.length === 0) { + return

No models available. Configure API keys in AI Settings.

; } return ( - setOpenProvider(v ?? '')}> - {enabled.map((g) => ( - - - {g.provider} - {g.models.length} - - - -
- {[...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 ( - - ); - })} -
-
-
- ))} -
+
+ {/* Provider tabs */} +
+ {providers.map((p) => ( + + ))} +
+ + {/* Enabled section */} + + {enabled.length === 0 && Drag models here to enable} + {enabled.map((m) => ( + + ))} + + + {/* Disabled section */} + + {disabled.length === 0 && All models enabled} + {disabled.map((m) => ( + + ))} + +
); -}; +} diff --git a/src/apps/officer-web/Screens/Dashboard/Workspaces/app-registry.tsx b/src/apps/officer-web/Screens/Dashboard/Workspaces/app-registry.tsx index 846e05a5..33391c4b 100644 --- a/src/apps/officer-web/Screens/Dashboard/Workspaces/app-registry.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Workspaces/app-registry.tsx @@ -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 ; - } - if (provider === 'opencode') { - return ; - } - return ; -}; - -const ClaudeChatWidget = ({ onProviderChange }: { onProviderChange: (p: 'claude' | 'opencode' | 'pi-mono') => void }) => { - const claude = useClaude(); - const models = useVisibleClaudeModels(); - return ; -}; - -const OpenCodeChatWidget = ({ onProviderChange }: { onProviderChange: (p: 'claude' | 'opencode' | 'pi-mono') => void }) => { - const opencode = useOpenCode(); - const models = useVisibleOpenCodeModels(); - return ; -}; - -const PiMonoChatWidget = ({ onProviderChange }: { onProviderChange: (p: 'claude' | 'opencode' | 'pi-mono') => void }) => { const piMono = usePiMono(); const models = useVisiblePiMonoModels(); - return ; + return ; }; const cwdToPath = (cwd: string) => (cwd === '~' ? '/' : cwd.slice(1)); diff --git a/src/apps/officer-web/state/useModels.ts b/src/apps/officer-web/state/useModels.ts index 7236bf64..92ebf946 100644 --- a/src/apps/officer-web/state/useModels.ts +++ b/src/apps/officer-web/state/useModels.ts @@ -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]); }; diff --git a/src/apps/officer-web/styles/globals.css b/src/apps/officer-web/styles/globals.css index 9118c5ba..25cc2ae3 100644 --- a/src/apps/officer-web/styles/globals.css +++ b/src/apps/officer-web/styles/globals.css @@ -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%; diff --git a/src/server.tsx b/src/server.tsx index 5f21259a..e2f7b760 100644 --- a/src/server.tsx +++ b/src/server.tsx @@ -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 = { - claude: claudeWebsocket, - opencode: opencodeWebsocket, +const handlers: Record = { + // 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); + } +})(); diff --git a/src/servers/api/chat-types.ts b/src/servers/api/chat-types.ts index e23ca420..37911ec4 100644 --- a/src/servers/api/chat-types.ts +++ b/src/servers/api/chat-types.ts @@ -20,6 +20,7 @@ export type ClientMessage = resourceChatDir?: string; taskInfo?: TaskInfo; } + | { type: 'resume'; sessionId: string } | { type: 'stop' }; // Server → Client diff --git a/src/servers/api/pi-mono/sessions.ts b/src/servers/api/pi-mono/sessions.ts index 8d261531..6181259a 100644 --- a/src/servers/api/pi-mono/sessions.ts +++ b/src/servers/api/pi-mono/sessions.ts @@ -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) { - models.push({ - id: data.id, - name: data.name ?? data.id, - provider: data.provider, - providerId: data.provider, - }); - } - } catch { - // skip non-JSON lines - } + // 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: `${provider}/${model}`, + name: model!, + provider: provider!, + providerId: provider!, + }); } - return ctx.json(models.length > 0 ? models : FALLBACK_MODELS); + return ctx.json(models); } catch { - return ctx.json(FALLBACK_MODELS); + return ctx.json([]); } }); diff --git a/src/servers/api/pi-mono/websocket.ts b/src/servers/api/pi-mono/websocket.ts index cb82f5af..1c4a68d9 100644 --- a/src/servers/api/pi-mono/websocket.ts +++ b/src/servers/api/pi-mono/websocket.ts @@ -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 | null; selectedModel: string | null; - pendingAttachmentIds: string[]; cwd: string | null; resourceChatDir: string | null; logId: string | null; fullText: string; - rpcReady: boolean; + killTimer: ReturnType | null; }; -const connections = new Map, ConnectionState>(); +// Session pool — pi processes persist across websocket reconnects +const sessions = new Map(); -function send(ws: ServerWebSocket, msg: ServerMessage) { - if (ws.readyState === 1) ws.send(JSON.stringify(msg)); +// Map ws → sessionId for quick lookup on close +const wsToSession = new Map, string>(); + +// Grace period before killing orphaned pi processes (ms) +const ORPHAN_GRACE_MS = 30_000; + +function send(ws: ServerWebSocket | 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 { } function writeRpcCommand(proc: Subprocess, command: Record) { - 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, state: ConnectionState, workingDir: string) { - const args = ['pi', '--mode', 'rpc', '--no-extensions', '--no-skills', '--no-prompt-templates', '--no-themes']; +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; +} - if (state.selectedModel) { - args.push('--model', state.selectedModel); +function attachWs(sessionId: string, ws: ServerWebSocket) { + 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) { + 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 = { '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 (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, 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, 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, state: ConnectionState, event: Record) { +function handlePiEvent(session: PiSession, event: Record) { 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, 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 | 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, state: ConnectionState, even const toolName = (event.toolName as string) ?? 'unknown'; const args = (event.args as Record) ?? {}; - // 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, 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, 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 = `${contextAppend}\n\n${prompt}`; - const rpcCommand: Record = { + writeRpcCommand(session.piProcess, { type: 'prompt', id: `req_${Date.now()}`, message: fullPrompt, - }; - - writeRpcCommand(state.piProcess, rpcCommand); + }); } function handleStop(ws: ServerWebSocket) { - 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) { - 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, 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) { - const state = connections.get(ws); - if (state) { - killPiProcess(state); - } - connections.delete(ws); + detachWs(ws); }, drain() {}, diff --git a/src/servers/api/server-settings/pi-mono.ts b/src/servers/api/server-settings/pi-mono.ts index 81bd1ad9..c4d7fdaa 100644 --- a/src/servers/api/server-settings/pi-mono.ts +++ b/src/servers/api/server-settings/pi-mono.ts @@ -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 { + 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 { + const base = url.replace(/\/+$/, ''); + const timeout = 5000; + + const headers: Record = {}; + 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> { + try { + const file = Bun.file(API_KEYS_FILE); + if (!(await file.exists())) return {}; + return (await file.json()) as Record; + } catch { + return {}; + } +} + +async function writeApiKeys(keys: Record) { + 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 = {}; + + await Promise.all(providers.map(async (p) => { + const base = p.url.replace(/\/+$/, ''); + const path = p.apiType === 'ollama' ? '/api/tags' : '/v1/models'; + const headers: Record = {}; + 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); +});