PI-MONO
This commit is contained in:
+479
-266
@@ -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">
|
||||
{piMonoLoading ? (
|
||||
'Checking version...'
|
||||
) : 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']} />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-duck-teal text-duck-yellow hover:bg-duck-teal/90"
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-medium text-duck-dark">Pi Mono</span>
|
||||
{piMonoLoading ? (
|
||||
<span className="text-xs text-duck-dark/50">Checking...</span>
|
||||
) : piMonoVersion?.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.piMono}
|
||||
disabled={installing}
|
||||
className="p-0.5 rounded hover:bg-duck-teal/10 cursor-pointer transition-colors disabled:opacity-30"
|
||||
title="Update Pi Mono"
|
||||
>
|
||||
{installing.piMono ? 'Installing...' : 'Install'}
|
||||
</Button>
|
||||
)}
|
||||
<RefreshCw className={`h-3 w-3 text-duck-teal ${installing ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-6 text-xs bg-duck-teal text-duck-yellow hover:bg-duck-teal/90"
|
||||
onClick={installPiMono}
|
||||
disabled={installing}
|
||||
>
|
||||
{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>
|
||||
|
||||
{/* 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);
|
||||
}}
|
||||
>
|
||||
<span>{provider.key}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</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);
|
||||
}}
|
||||
>
|
||||
Run
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user