PI-MONO
This commit is contained in:
@@ -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>
|
||||
|
||||
+468
-255
@@ -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() {
|
||||
);
|
||||
}
|
||||
|
||||
type ModelPillProps = {
|
||||
label: string;
|
||||
modelKey: string;
|
||||
enabled: boolean;
|
||||
onAction: (key: string) => void;
|
||||
onDragStart: (ev: DragEvent, key: string) => void;
|
||||
};
|
||||
|
||||
const ModelPill = ({ label, modelKey, enabled, onAction, onDragStart }: ModelPillProps) => (
|
||||
<span
|
||||
draggable
|
||||
onDragStart={(ev) => onDragStart(ev, modelKey)}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium cursor-grab active:cursor-grabbing select-none border border-duck-dark/15 dark:border-foreground/15 bg-background/60 text-duck-dark/80 dark:text-foreground/80 hover:bg-duck-dark/5 dark:hover:bg-foreground/5 transition-colors"
|
||||
>
|
||||
{label}
|
||||
<button
|
||||
onClick={() => onAction(modelKey)}
|
||||
className="ml-0.5 p-0.5 rounded-full hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors"
|
||||
>
|
||||
{enabled ? <X className="h-3 w-3" /> : <Plus className="h-3 w-3" />}
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
|
||||
type DropZoneProps = {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
onDrop: (key: string) => void;
|
||||
};
|
||||
|
||||
const DropZone = ({ label, children, onDrop }: DropZoneProps) => {
|
||||
const [over, setOver] = useState(false);
|
||||
|
||||
const handleDragOver = useCallback((ev: DragEvent) => {
|
||||
ev.preventDefault();
|
||||
setOver(true);
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback(() => setOver(false), []);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(ev: DragEvent) => {
|
||||
ev.preventDefault();
|
||||
setOver(false);
|
||||
const key = ev.dataTransfer.getData('text/plain');
|
||||
if (key) onDrop(key);
|
||||
},
|
||||
[onDrop],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid gap-1.5">
|
||||
<span className="text-xs font-medium text-duck-dark/50 dark:text-foreground/50 uppercase tracking-wide">{label}</span>
|
||||
<div
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
className={`min-h-[48px] p-2 rounded-lg border border-dashed transition-colors flex flex-wrap gap-1.5 ${over ? 'border-duck-teal bg-duck-teal/5' : 'border-duck-dark/15 dark:border-foreground/15'}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function ModelVisibilitySection() {
|
||||
const { settings, saveSettings } = useSettings();
|
||||
const claudeModels = useClaudeModels();
|
||||
const openCodeModels = useOpenCodeModels();
|
||||
const piMonoModels = usePiMonoModels();
|
||||
const [subTab, setSubTab] = useUserState('ai-settings-visibility-tab', 'claude');
|
||||
const [activeProvider, setActiveProvider] = useUserState<string>('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<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 { 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 (
|
||||
<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 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) => (
|
||||
<>
|
||||
<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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
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<string>('ai-settings-provider-accordion', '');
|
||||
const enabled = groups.filter((g) => enabledProviders.includes(g.provider));
|
||||
|
||||
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));
|
||||
|
||||
@@ -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]);
|
||||
};
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -20,6 +20,7 @@ export type ClientMessage =
|
||||
resourceChatDir?: string;
|
||||
taskInfo?: TaskInfo;
|
||||
}
|
||||
| { type: 'resume'; sessionId: string }
|
||||
| { type: 'stop' };
|
||||
|
||||
// Server → Client
|
||||
|
||||
@@ -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([]);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
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<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 (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() {},
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user