Files
SuperCharged-Claude-Code-Up…/dexto/packages/webui/components/ModelPicker/ModelPickerModal.tsx
admin b52318eeae feat: Add intelligent auto-router and enhanced integrations
- Add intelligent-router.sh hook for automatic agent routing
- Add AUTO-TRIGGER-SUMMARY.md documentation
- Add FINAL-INTEGRATION-SUMMARY.md documentation
- Complete Prometheus integration (6 commands + 4 tools)
- Complete Dexto integration (12 commands + 5 tools)
- Enhanced Ralph with access to all agents
- Fix /clawd command (removed disable-model-invocation)
- Update hooks.json to v5 with intelligent routing
- 291 total skills now available
- All 21 commands with automatic routing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-28 00:27:56 +04:00

1263 lines
66 KiB
TypeScript

/**
* Model Picker Modal
*
* Allows users to browse and switch between LLM models across providers.
*
* TODO: Implement "Run via" toggle for featured models
* - Show a single model card with toggle buttons: "Dexto / Direct / OpenRouter"
* - Toggle changes both provider AND model ID (e.g., dexto uses OpenRouter IDs,
* direct uses native IDs like claude-sonnet-4-5 vs anthropic/claude-sonnet-4.5)
* - Disable toggles when credentials are missing (e.g., no ANTHROPIC_API_KEY)
* - Requires a curated mapping table for featured models (provider/model pairs per backend)
* - See feature-plans/holistic-dexto-auth-analysis/13-model-id-namespaces-and-mapping.md
* - See feature-plans/holistic-dexto-auth-analysis/14-webui-effective-credentials-and-routing-awareness.md
*/
import { useEffect, useMemo, useState, useCallback, useRef } from 'react';
import {
useLLMCatalog,
useSwitchLLM,
useCustomModels,
useCreateCustomModel,
useDeleteCustomModel,
useProviderApiKey,
useSaveApiKey,
type SwitchLLMPayload,
type CustomModel,
} from '../hooks/useLLM';
import { useLocalModels, useDeleteInstalledModel, type LocalModel } from '../hooks/useModels';
import { useDextoAuth } from '../hooks/useDextoAuth';
import {
CustomModelForm,
type CustomModelFormData,
type CustomModelProvider,
} from './CustomModelForms';
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import { Button } from '../ui/button';
import { Alert, AlertDescription } from '../ui/alert';
import { ApiKeyModal } from '../ApiKeyModal';
import { useSessionStore } from '@/lib/stores/sessionStore';
import { useCurrentLLM } from '../hooks/useCurrentLLM';
import {
Bot,
ChevronDown,
ChevronLeft,
ChevronUp,
Loader2,
Star,
Plus,
Filter,
} from 'lucide-react';
import { SearchBar } from './SearchBar';
import { ModelCard } from './ModelCard';
import {
FAVORITES_STORAGE_KEY,
CUSTOM_MODELS_STORAGE_KEY,
DEFAULT_FAVORITES,
ProviderCatalog,
ModelInfo,
favKey,
validateBaseURL,
} from './types';
import { cn } from '../../lib/utils';
import type { LLMProvider } from '@dexto/core';
import { LLM_PROVIDERS } from '@dexto/core';
import { PROVIDER_LOGOS, needsDarkModeInversion, hasLogo } from './constants';
import { useAnalytics } from '@/lib/analytics/index.js';
export default function ModelPickerModal() {
const [open, setOpen] = useState(false);
const [providers, setProviders] = useState<Partial<Record<LLMProvider, ProviderCatalog>>>({});
const [search, setSearch] = useState('');
const [baseURL, setBaseURL] = useState('');
const [error, setError] = useState<string | null>(null);
// Provider filter - empty array means 'all', can include 'custom' or any LLMProvider
const [providerFilter, setProviderFilter] = useState<Array<LLMProvider | 'custom'>>([]);
const [activeView, setActiveView] = useState<'favorites' | 'all'>('all');
const [showCustomForm, setShowCustomForm] = useState(false);
// Custom models form state (data comes from API via useCustomModels)
const [customModelForm, setCustomModelForm] = useState<CustomModelFormData>({
provider: 'openai-compatible',
name: '',
baseURL: '',
displayName: '',
maxInputTokens: '',
maxOutputTokens: '',
apiKey: '',
filePath: '',
});
// Track original name when editing (to handle renames)
const [editingModelName, setEditingModelName] = useState<string | null>(null);
// API key modal
const [keyModalOpen, setKeyModalOpen] = useState(false);
const [pendingKeyProvider, setPendingKeyProvider] = useState<LLMProvider | null>(null);
const [pendingSelection, setPendingSelection] = useState<{
provider: LLMProvider;
model: ModelInfo;
} | null>(null);
const currentSessionId = useSessionStore((s) => s.currentSessionId);
const { data: currentLLM, refetch: refreshCurrentLLM } = useCurrentLLM(currentSessionId);
// Analytics tracking
const analytics = useAnalytics();
const analyticsRef = useRef(analytics);
useEffect(() => {
analyticsRef.current = analytics;
}, [analytics]);
// Load catalog when opening
const {
data: catalogData,
isLoading: loading,
error: catalogError,
} = useLLMCatalog({ enabled: open });
// Load dexto auth status (for checking if user can use dexto provider)
const { data: dextoAuthStatus } = useDextoAuth(open);
// Load custom models from API (always enabled so trigger shows correct icon)
const { data: customModels = [] } = useCustomModels();
// Load installed local GGUF models from state.json (downloaded via CLI/Interactive CLI)
const { data: localModelsData } = useLocalModels({ enabled: open });
const installedLocalModels = useMemo(
() => localModelsData?.models ?? [],
[localModelsData?.models]
);
const { mutateAsync: createCustomModelAsync } = useCreateCustomModel();
const { mutate: deleteCustomModelMutation } = useDeleteCustomModel();
const { mutate: deleteInstalledModelMutation } = useDeleteInstalledModel();
const { mutateAsync: saveApiKey } = useSaveApiKey();
// Fetch provider API key status for the current form provider (for smart storage logic)
const { data: providerKeyData } = useProviderApiKey(customModelForm.provider as LLMProvider, {
enabled: open && showCustomForm,
});
useEffect(() => {
if (catalogData && 'providers' in catalogData) {
setProviders(catalogData.providers);
}
}, [catalogData]);
// When opening, initialize from current session LLM
useEffect(() => {
if (!open) return;
if (currentLLM) {
setBaseURL(currentLLM.baseURL || '');
}
}, [open, currentLLM]);
const [favorites, setFavorites] = useState<string[]>([]);
// Load favorites from localStorage (custom models come from API)
useEffect(() => {
if (open) {
try {
const favRaw = localStorage.getItem(FAVORITES_STORAGE_KEY);
// Use default favorites for new users (when localStorage key doesn't exist)
const loadedFavorites =
favRaw !== null ? (JSON.parse(favRaw) as string[]) : DEFAULT_FAVORITES;
setFavorites(loadedFavorites);
} catch (err) {
console.warn('Failed to load favorites from localStorage:', err);
setFavorites([]);
}
}
}, [open]);
// Migrate localStorage custom models to API (one-time migration)
const [migrationDone, setMigrationDone] = useState(false);
useEffect(() => {
if (!open || migrationDone) return;
const migrateModels = async () => {
try {
const localStorageRaw = localStorage.getItem(CUSTOM_MODELS_STORAGE_KEY);
if (!localStorageRaw) {
setMigrationDone(true);
return;
}
const localModels = JSON.parse(localStorageRaw) as Array<{
name: string;
baseURL: string;
maxInputTokens?: number;
maxOutputTokens?: number;
}>;
if (localModels.length === 0) {
localStorage.removeItem(CUSTOM_MODELS_STORAGE_KEY);
setMigrationDone(true);
return;
}
// Check which models don't exist in API yet
const existingNames = new Set(customModels.map((m) => m.name));
const toMigrate = localModels.filter((m) => !existingNames.has(m.name));
if (toMigrate.length === 0) {
// All models already migrated, clean up localStorage
localStorage.removeItem(CUSTOM_MODELS_STORAGE_KEY);
setMigrationDone(true);
return;
}
// Migrate each model - await all to complete before clearing localStorage
const migrationPromises = toMigrate.map((model) =>
createCustomModelAsync({
name: model.name,
baseURL: model.baseURL,
maxInputTokens: model.maxInputTokens,
maxOutputTokens: model.maxOutputTokens,
})
);
// Wait for all migrations to succeed before clearing localStorage
await Promise.all(migrationPromises);
// Only clear localStorage after successful migration
localStorage.removeItem(CUSTOM_MODELS_STORAGE_KEY);
console.info(`Migrated ${toMigrate.length} custom models from localStorage to API`);
setMigrationDone(true);
} catch (err) {
// Don't clear localStorage on failure - keep models for retry
console.warn('Failed to migrate custom models from localStorage:', err);
setMigrationDone(true);
}
};
migrateModels();
}, [open, migrationDone, customModels, createCustomModelAsync]);
const toggleFavorite = useCallback((providerId: LLMProvider, modelName: string) => {
const key = favKey(providerId, modelName);
setFavorites((prev) => {
const newFavs = prev.includes(key) ? prev.filter((f) => f !== key) : [...prev, key];
localStorage.setItem(FAVORITES_STORAGE_KEY, JSON.stringify(newFavs));
return newFavs;
});
}, []);
const [isAddingModel, setIsAddingModel] = useState(false);
const switchLLMMutation = useSwitchLLM();
const addCustomModel = useCallback(async () => {
const { provider, name, baseURL, maxInputTokens, maxOutputTokens, displayName, apiKey } =
customModelForm;
if (!name.trim()) {
setError('Model name is required');
return;
}
setIsAddingModel(true);
try {
// Determine API key storage strategy
// TODO: Deduplicate - canonical version is determineApiKeyStorage() in @dexto/agent-management
// Can't import directly as WebUI runs in browser. Move to @dexto/core if this changes often.
const SHARED_API_KEY_PROVIDERS = ['glama', 'openrouter', 'litellm'];
const userEnteredKey = apiKey?.trim();
const providerHasKey = providerKeyData?.hasKey ?? false;
const hasSharedEnvVarKey = SHARED_API_KEY_PROVIDERS.includes(provider);
let saveToProviderEnvVar = false;
let saveAsPerModel = false;
// Only process if user actually entered a new key
if (userEnteredKey) {
if (hasSharedEnvVarKey) {
if (!providerHasKey) {
// No existing key - save to provider env var
saveToProviderEnvVar = true;
} else {
// Provider already has a key - save as per-model override
saveAsPerModel = true;
}
} else {
// Non-shared providers always save per-model
saveAsPerModel = true;
}
}
// If user didn't enter a key, we don't modify anything - existing key (if any) is used
if (saveToProviderEnvVar && userEnteredKey) {
await saveApiKey({ provider: provider as LLMProvider, apiKey: userEnteredKey });
}
// If editing and name changed, delete the old model first
if (editingModelName && editingModelName !== name.trim()) {
try {
await new Promise<void>((resolve, reject) => {
deleteCustomModelMutation(editingModelName, {
onSuccess: () => resolve(),
onError: (err: Error) => reject(err),
});
});
} catch (err) {
// Log but continue - old model might already be deleted
console.warn(`Failed to delete old model "${editingModelName}":`, err);
}
}
// Create/update the custom model
await createCustomModelAsync({
provider,
name: name.trim(),
...(provider === 'openai-compatible' &&
baseURL.trim() && { baseURL: baseURL.trim() }),
...(provider === 'litellm' && baseURL.trim() && { baseURL: baseURL.trim() }),
...(displayName?.trim() && { displayName: displayName.trim() }),
...(maxInputTokens && { maxInputTokens: parseInt(maxInputTokens, 10) }),
...(maxOutputTokens && { maxOutputTokens: parseInt(maxOutputTokens, 10) }),
...(saveAsPerModel && userEnteredKey && { apiKey: userEnteredKey }),
});
// Only switch to the model for new models, not edits
// (user is already using edited model or chose not to switch)
if (!editingModelName) {
const baseSwitchPayload: SwitchLLMPayload = {
provider: provider as LLMProvider,
model: name.trim(),
...(provider === 'openai-compatible' &&
baseURL.trim() && { baseURL: baseURL.trim() }),
...(provider === 'litellm' && baseURL.trim() && { baseURL: baseURL.trim() }),
...(saveAsPerModel && userEnteredKey && { apiKey: userEnteredKey }),
};
// Always update global default first (no sessionId)
await switchLLMMutation.mutateAsync(baseSwitchPayload);
// Then switch current session if active
if (currentSessionId) {
try {
await switchLLMMutation.mutateAsync({
...baseSwitchPayload,
sessionId: currentSessionId,
});
} catch (sessionErr) {
setError(
sessionErr instanceof Error
? `Model added and set as global default, but failed to switch current session: ${sessionErr.message}`
: 'Model added and set as global default, but failed to switch current session'
);
await refreshCurrentLLM();
setIsAddingModel(false);
return;
}
}
await refreshCurrentLLM();
// Track the switch
if (currentLLM) {
analyticsRef.current.trackLLMSwitched({
fromProvider: currentLLM.provider,
fromModel: currentLLM.model,
toProvider: provider,
toModel: name.trim(),
sessionId: currentSessionId || undefined,
trigger: 'user_action',
});
}
}
// Reset form and close
setCustomModelForm({
provider: 'openai-compatible',
name: '',
baseURL: '',
displayName: '',
maxInputTokens: '',
maxOutputTokens: '',
apiKey: '',
filePath: '',
});
setEditingModelName(null);
setShowCustomForm(false);
setError(null);
setOpen(false);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to add model');
} finally {
setIsAddingModel(false);
}
}, [
customModelForm,
createCustomModelAsync,
switchLLMMutation,
currentSessionId,
currentLLM,
refreshCurrentLLM,
providerKeyData,
saveApiKey,
editingModelName,
deleteCustomModelMutation,
]);
const deleteCustomModel = useCallback(
(name: string) => {
deleteCustomModelMutation(name, {
onError: (err: Error) => {
setError(err.message);
},
});
},
[deleteCustomModelMutation]
);
const deleteInstalledModel = useCallback(
(modelId: string) => {
// Delete installed model and its GGUF file from disk
deleteInstalledModelMutation(
{ modelId, deleteFile: true },
{
onError: (err: Error) => {
setError(err.message);
},
}
);
},
[deleteInstalledModelMutation]
);
const editCustomModel = useCallback((model: CustomModel) => {
// Map provider to form-supported provider (vertex uses openai-compatible form)
const formSupportedProviders: CustomModelProvider[] = [
'openai-compatible',
'openrouter',
'litellm',
'glama',
'bedrock',
'ollama',
'local',
];
const provider = model.provider ?? 'openai-compatible';
const formProvider: CustomModelProvider = formSupportedProviders.includes(
provider as CustomModelProvider
)
? (provider as CustomModelProvider)
: 'openai-compatible';
setCustomModelForm({
provider: formProvider,
name: model.name,
baseURL: model.baseURL ?? '',
displayName: model.displayName ?? '',
maxInputTokens: model.maxInputTokens?.toString() ?? '',
maxOutputTokens: model.maxOutputTokens?.toString() ?? '',
apiKey: model.apiKey ?? '',
filePath: model.filePath ?? '',
});
setEditingModelName(model.name);
setShowCustomForm(true);
setError(null);
}, []);
const modelMatchesSearch = useCallback(
(providerId: LLMProvider, model: ModelInfo): boolean => {
const q = search.trim().toLowerCase();
if (!q) return true;
return (
model.name.toLowerCase().includes(q) ||
(model.displayName?.toLowerCase().includes(q) ?? false) ||
providerId.toLowerCase().includes(q) ||
(providers[providerId]?.name.toLowerCase().includes(q) ?? false)
);
},
[search, providers]
);
function onPickModel(
providerId: LLMProvider,
model: ModelInfo,
customBaseURL?: string,
skipApiKeyCheck = false,
customApiKey?: string
) {
const provider = providers[providerId];
const effectiveBaseURL = customBaseURL || baseURL;
const supportsBaseURL = provider?.supportsBaseURL ?? Boolean(effectiveBaseURL);
if (supportsBaseURL && effectiveBaseURL) {
const v = validateBaseURL(effectiveBaseURL);
if (!v.isValid) {
setError(v.error || 'Invalid base URL');
return;
}
}
// Dexto provider requires OAuth login via CLI, not manual API key entry
// Check canUse from auth status API (requires both authentication AND API key)
if (!skipApiKeyCheck && providerId === 'dexto') {
if (!dextoAuthStatus?.canUse) {
setError('Run `dexto login` or `/login` from the CLI to authenticate with Dexto');
return;
}
} else if (!skipApiKeyCheck && provider && !provider.hasApiKey && !customApiKey) {
// Other providers - show API key modal if no key configured
setPendingSelection({ provider: providerId, model });
setPendingKeyProvider(providerId);
setKeyModalOpen(true);
return;
}
const basePayload: SwitchLLMPayload = {
provider: providerId,
model: model.name,
...(supportsBaseURL && effectiveBaseURL && { baseURL: effectiveBaseURL }),
...(customApiKey && { apiKey: customApiKey }),
};
// Always update global default first (no sessionId), then switch current session if active
switchLLMMutation.mutate(basePayload, {
onSuccess: async () => {
// If there's an active session, also switch it to the new model
if (currentSessionId) {
try {
await switchLLMMutation.mutateAsync({
...basePayload,
sessionId: currentSessionId,
});
} catch (err) {
setError(
err instanceof Error
? err.message
: 'Failed to switch model for current session'
);
return;
}
}
await refreshCurrentLLM();
if (currentLLM) {
analyticsRef.current.trackLLMSwitched({
fromProvider: currentLLM.provider,
fromModel: currentLLM.model,
toProvider: providerId,
toModel: model.name,
sessionId: currentSessionId || undefined,
trigger: 'user_action',
});
}
setOpen(false);
setError(null);
},
onError: (error: Error) => {
setError(error.message);
},
});
}
function onPickCustomModel(customModel: CustomModel) {
const provider = (customModel.provider ?? 'openai-compatible') as LLMProvider;
const modelInfo: ModelInfo = {
name: customModel.name,
displayName: customModel.displayName || customModel.name,
maxInputTokens: customModel.maxInputTokens || 128000,
supportedFileTypes: ['pdf', 'image', 'audio'],
};
// Skip API key check for custom models - user already configured them.
// If they didn't add an API key, it's intentional (self-hosted, local, or env var).
// Pass the custom model's apiKey for per-model override if present.
onPickModel(provider, modelInfo, customModel.baseURL, true, customModel.apiKey);
}
function onPickInstalledModel(model: LocalModel) {
// Installed local models use the model ID as the name
// Context length is auto-detected by node-llama-cpp at runtime
const modelInfo: ModelInfo = {
name: model.id,
displayName: model.displayName,
maxInputTokens: model.contextLength || 8192,
supportedFileTypes: [], // Local models typically don't support file attachments
};
// Skip API key check - local models don't need API keys
onPickModel('local', modelInfo, undefined, true);
}
function onApiKeySaved(meta: { provider: string; envVar: string }) {
const providerKey = meta.provider as LLMProvider;
setProviders((prev) => ({
...prev,
[providerKey]: prev[providerKey]
? { ...prev[providerKey]!, hasApiKey: true }
: prev[providerKey],
}));
setKeyModalOpen(false);
if (pendingSelection) {
const { provider: providerId, model } = pendingSelection;
// Skip API key check since we just saved it
onPickModel(providerId, model, undefined, true);
setPendingSelection(null);
}
}
const triggerLabel = currentLLM?.displayName || currentLLM?.model || 'Choose Model';
const isWelcomeScreen = !currentSessionId;
// Toggle a filter (add if not present, remove if present)
const toggleFilter = useCallback((filter: LLMProvider | 'custom') => {
setProviderFilter((prev) =>
prev.includes(filter) ? prev.filter((f) => f !== filter) : [...prev, filter]
);
}, []);
// Build favorites list (includes both catalog models and custom models)
const favoriteModels = useMemo(() => {
return favorites
.map((key) => {
const [providerIdRaw, modelName] = key.split('|');
const providerId = providerIdRaw as LLMProvider;
if (!LLM_PROVIDERS.includes(providerId)) return null;
// Check if it's a custom model (check by model name match and provider type)
const customModel = customModels.find(
(cm) =>
cm.name === modelName && (cm.provider ?? 'openai-compatible') === providerId
);
if (customModel) {
return {
providerId,
provider: undefined,
model: {
name: customModel.name,
displayName: customModel.displayName || customModel.name,
maxInputTokens: customModel.maxInputTokens || 128000,
supportedFileTypes: ['pdf', 'image', 'audio'] as string[],
},
isCustom: true,
customModel,
};
}
const provider = providers[providerId];
const model = provider?.models.find((m) => m.name === modelName);
if (!provider || !model) return null;
return { providerId, provider, model, isCustom: false };
})
.filter(Boolean) as Array<{
providerId: LLMProvider;
provider?: ProviderCatalog;
model: ModelInfo;
isCustom: boolean;
customModel?: CustomModel;
}>;
}, [favorites, providers, customModels]);
// All models flat list (filtered by search and provider)
const allModels = useMemo(() => {
// Get non-custom provider filters
const providerFilters = providerFilter.filter((f): f is LLMProvider => f !== 'custom');
// If only 'custom' is selected, don't show catalog models
if (providerFilter.length > 0 && providerFilters.length === 0) return [];
const result: Array<{
providerId: LLMProvider;
provider: ProviderCatalog;
model: ModelInfo;
}> = [];
for (const providerId of LLM_PROVIDERS) {
// Empty filter = show all, otherwise check if provider is in filter
if (providerFilter.length > 0 && !providerFilters.includes(providerId)) continue;
const provider = providers[providerId];
if (!provider) continue;
for (const model of provider.models) {
if (modelMatchesSearch(providerId, model)) {
result.push({ providerId, provider, model });
}
}
}
return result;
}, [providers, providerFilter, modelMatchesSearch]);
// Filtered custom models (shown when no filter, 'custom' filter, or provider-specific filter)
const filteredCustomModels = useMemo(() => {
const hasCustomFilter = providerFilter.includes('custom');
const hasOpenRouterFilter = providerFilter.includes('openrouter');
const noFilter = providerFilter.length === 0;
// If filter is set but neither 'custom' nor 'openrouter', hide custom models
if (!noFilter && !hasCustomFilter && !hasOpenRouterFilter) return [];
let filtered = customModels;
// If openrouter filter is active (without custom), only show openrouter custom models
if (hasOpenRouterFilter && !hasCustomFilter && !noFilter) {
filtered = customModels.filter((cm) => cm.provider === 'openrouter');
}
const q = search.trim().toLowerCase();
if (!q) return filtered;
return filtered.filter(
(cm) =>
cm.name.toLowerCase().includes(q) ||
(cm.displayName?.toLowerCase().includes(q) ?? false) ||
(cm.provider?.toLowerCase().includes(q) ?? false) ||
(cm.baseURL?.toLowerCase().includes(q) ?? false)
);
}, [providerFilter, search, customModels]);
// Filtered installed local models (downloaded via CLI/Interactive CLI)
// Shown when no filter or 'local' filter is active
const filteredInstalledModels = useMemo(() => {
const hasLocalFilter = providerFilter.includes('local');
const noFilter = providerFilter.length === 0;
// If filter is set but not 'local', hide installed models
if (!noFilter && !hasLocalFilter) return [];
const q = search.trim().toLowerCase();
if (!q) return installedLocalModels;
return installedLocalModels.filter(
(model) =>
model.id.toLowerCase().includes(q) ||
model.displayName.toLowerCase().includes(q) ||
'local'.includes(q)
);
}, [providerFilter, search, installedLocalModels]);
// Available providers for filter
// OpenRouter always shown (users add their own models via custom models)
// Local shown when there are installed models from CLI
const availableProviders = useMemo(() => {
const base = LLM_PROVIDERS.filter((p) => p === 'openrouter' || providers[p]?.models.length);
// Add 'local' if there are installed local models
if (installedLocalModels.length > 0 && !base.includes('local')) {
return [...base, 'local' as LLMProvider];
}
return base;
}, [providers, installedLocalModels]);
const isCurrentModel = (providerId: string, modelName: string) =>
currentLLM?.provider === providerId && currentLLM?.model === modelName;
return (
<>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
className="flex items-center gap-2 cursor-pointer"
title="Choose model"
>
{currentLLM?.provider && hasLogo(currentLLM.provider as LLMProvider) ? (
<img
src={PROVIDER_LOGOS[currentLLM.provider as LLMProvider]}
alt={`${currentLLM.provider} logo`}
width={16}
height={16}
className={cn(
'object-contain',
needsDarkModeInversion(currentLLM.provider as LLMProvider) &&
'dark:invert dark:brightness-0 dark:contrast-200'
)}
/>
) : (
<Bot className="h-4 w-4" />
)}
<span className="text-sm">{triggerLabel}</span>
{currentLLM?.viaDexto && (
<span className="text-xs text-muted-foreground">via Dexto</span>
)}
<ChevronDown
className={cn('h-3 w-3 transition-transform', open && 'rotate-180')}
/>
</Button>
</PopoverTrigger>
<PopoverContent
side="top"
align="end"
sideOffset={8}
avoidCollisions={true}
collisionPadding={16}
className={cn(
'w-[calc(100vw-32px)] max-w-[700px]',
isWelcomeScreen ? 'max-h-[min(400px,50vh)]' : 'max-h-[min(580px,75vh)]',
'flex flex-col p-0 overflow-hidden',
'rounded-xl border border-border/60 bg-popover/98 backdrop-blur-xl shadow-xl'
)}
>
{/* Full-screen Add Custom Model Form - replaces all content when active */}
{showCustomForm ? (
<CustomModelForm
formData={customModelForm}
onChange={(updates) =>
setCustomModelForm((prev) => ({ ...prev, ...updates }))
}
onSubmit={addCustomModel}
onCancel={() => {
setShowCustomForm(false);
setEditingModelName(null);
setError(null);
}}
isSubmitting={isAddingModel}
error={error}
isEditing={editingModelName !== null}
/>
) : (
<>
{/* Header - Search + Add Custom Button + Filters */}
<div className="flex-shrink-0 px-3 pt-3 pb-2 border-b border-border/30 space-y-2">
{(error || catalogError) && (
<Alert variant="destructive" className="py-2">
<AlertDescription className="text-xs">
{error || catalogError?.message}
</AlertDescription>
</Alert>
)}
<div className="flex items-center gap-2">
<div className="flex-1">
<SearchBar
value={search}
onChange={setSearch}
placeholder="Search models..."
/>
</div>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => setShowCustomForm(true)}
className="p-2 rounded-lg transition-colors flex-shrink-0 bg-muted/50 text-muted-foreground hover:text-foreground hover:bg-muted"
>
<Plus className="h-4 w-4" />
<span className="sr-only">Add custom model</span>
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
Add custom model
</TooltipContent>
</Tooltip>
</div>
{/* Provider Filter Pills - only in All view */}
{activeView === 'all' && availableProviders.length > 1 && (
<div className="flex items-center gap-1.5 flex-wrap pt-1">
<Filter className="h-3 w-3 text-muted-foreground flex-shrink-0" />
<button
onClick={() => setProviderFilter([])}
className={cn(
'px-2 py-1 text-[11px] font-medium rounded-md transition-colors',
providerFilter.length === 0
? 'bg-emerald-500/20 text-emerald-400 border border-emerald-500/30'
: 'bg-muted/50 text-muted-foreground hover:bg-muted hover:text-foreground'
)}
>
All
</button>
{availableProviders.map((providerId) => (
<button
key={providerId}
onClick={() => toggleFilter(providerId)}
className={cn(
'flex items-center gap-1 px-2 py-1 text-[11px] font-medium rounded-md transition-colors',
providerFilter.includes(providerId)
? 'bg-emerald-500/20 text-emerald-400 border border-emerald-500/30'
: 'bg-muted/50 text-muted-foreground hover:bg-muted hover:text-foreground'
)}
>
{PROVIDER_LOGOS[providerId] && (
<img
src={PROVIDER_LOGOS[providerId]}
alt=""
width={10}
height={10}
className={cn(
'object-contain',
needsDarkModeInversion(providerId) &&
!providerFilter.includes(
providerId
) &&
'dark:invert dark:brightness-0 dark:contrast-200'
)}
/>
)}
<span className="hidden sm:inline">
{providers[providerId]?.name || providerId}
</span>
</button>
))}
<button
onClick={() => toggleFilter('custom')}
className={cn(
'flex items-center gap-1 px-2 py-1 text-[11px] font-medium rounded-md transition-colors',
providerFilter.includes('custom')
? 'bg-emerald-500/20 text-emerald-400 border border-emerald-500/30'
: 'bg-muted/50 text-muted-foreground hover:bg-muted hover:text-foreground'
)}
>
<Bot className="h-2.5 w-2.5" />
<span className="hidden sm:inline">Custom</span>
</button>
</div>
)}
</div>
{/* Main Content */}
<div className="flex-1 min-h-0 overflow-y-auto p-3">
{loading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : activeView === 'favorites' ? (
/* Favorites List View */
favoriteModels.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<Star className="h-8 w-8 text-muted-foreground/30 mb-2" />
<p className="text-sm font-medium text-muted-foreground">
No favorites yet
</p>
<p className="text-xs text-muted-foreground/70 mt-1">
Click &quot;Show all&quot; to browse and add
favorites
</p>
</div>
) : (
<div className="space-y-1">
{favoriteModels
.filter(({ providerId, model }) => {
if (!search.trim()) return true;
const q = search.trim().toLowerCase();
return (
model.name.toLowerCase().includes(q) ||
(model.displayName
?.toLowerCase()
.includes(q) ??
false) ||
providerId.toLowerCase().includes(q)
);
})
.map(
({
providerId,
model,
isCustom,
customModel,
}) => (
<div
key={favKey(providerId, model.name)}
onClick={() =>
isCustom && customModel
? onPickCustomModel(customModel)
: onPickModel(providerId, model)
}
onKeyDown={(e) => {
if (e.target !== e.currentTarget)
return;
if (
e.key === 'Enter' ||
e.key === ' '
) {
e.preventDefault();
isCustom && customModel
? onPickCustomModel(
customModel
)
: onPickModel(
providerId,
model
);
}
}}
role="button"
tabIndex={0}
className={cn(
'w-full flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors cursor-pointer',
'hover:bg-accent/50',
isCurrentModel(
providerId,
model.name
)
? 'bg-primary/10 border border-primary/30'
: 'border border-transparent'
)}
>
<div className="w-8 h-8 flex items-center justify-center rounded-lg bg-muted/60 flex-shrink-0">
{hasLogo(providerId) ? (
<img
src={
PROVIDER_LOGOS[
providerId
]
}
alt=""
width={20}
height={20}
className={cn(
'object-contain',
needsDarkModeInversion(
providerId
) &&
'dark:invert dark:brightness-0 dark:contrast-200'
)}
/>
) : (
<Bot className="h-4 w-4 text-muted-foreground" />
)}
</div>
<div className="flex-1 text-left min-w-0">
<div className="text-sm font-medium text-foreground truncate">
{model.displayName ||
model.name}
</div>
{providerId ===
'openai-compatible' && (
<div className="text-xs text-muted-foreground truncate">
Custom
</div>
)}
</div>
<div className="flex items-center gap-1 flex-shrink-0">
{model.supportedFileTypes?.includes(
'image'
) && (
<span
className="w-5 h-5 rounded bg-emerald-500/20 flex items-center justify-center"
title="Vision"
>
<svg
className="w-3 h-3 text-emerald-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
</svg>
</span>
)}
{model.supportedFileTypes?.includes(
'pdf'
) && (
<span
className="w-5 h-5 rounded bg-blue-500/20 flex items-center justify-center"
title="PDF"
>
<svg
className="w-3 h-3 text-blue-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
</span>
)}
</div>
<button
onClick={(e) => {
e.stopPropagation();
toggleFavorite(
providerId,
model.name
);
}}
className="p-1 rounded hover:bg-yellow-500/20 transition-colors flex-shrink-0"
>
<Star className="h-4 w-4 fill-yellow-400 text-yellow-400" />
</button>
</div>
)
)}
</div>
)
) : (
/* All Models Card Grid View */
<div>
{allModels.length === 0 &&
filteredCustomModels.length === 0 &&
filteredInstalledModels.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<p className="text-sm font-medium text-muted-foreground">
{providerFilter.includes('openrouter')
? 'No OpenRouter models yet'
: providerFilter.includes('local')
? 'No local models installed'
: 'No models found'}
</p>
<p className="text-xs text-muted-foreground/70 mt-1">
{providerFilter.includes('openrouter')
? 'Click the + button to add an OpenRouter model'
: providerFilter.includes('local')
? 'Use the CLI to download models: dexto setup'
: 'Try adjusting your search or filters'}
</p>
</div>
) : (
<div
className="grid gap-2 justify-center"
style={{
gridTemplateColumns: 'repeat(auto-fill, 140px)',
}}
>
{allModels.map(
({ providerId, provider, model }) => (
<ModelCard
key={`${providerId}|${model.name}`}
provider={providerId}
model={model}
providerInfo={provider}
isFavorite={favorites.includes(
favKey(providerId, model.name)
)}
isActive={isCurrentModel(
providerId,
model.name
)}
onClick={() =>
onPickModel(providerId, model)
}
onToggleFavorite={() =>
toggleFavorite(
providerId,
model.name
)
}
size="sm"
/>
)
)}
{/* Installed local models (downloaded via CLI) - shown before custom models */}
{filteredInstalledModels.map((model) => (
<ModelCard
key={`local|${model.id}`}
provider="local"
model={{
name: model.id,
displayName: model.displayName,
maxInputTokens:
model.contextLength || 8192,
supportedFileTypes: [],
}}
isFavorite={favorites.includes(
favKey('local', model.id)
)}
isActive={isCurrentModel('local', model.id)}
onClick={() => onPickInstalledModel(model)}
onToggleFavorite={() =>
toggleFavorite('local', model.id)
}
onDelete={() =>
deleteInstalledModel(model.id)
}
size="sm"
isInstalled
/>
))}
{/* Custom models (user-configured) */}
{filteredCustomModels.map((cm) => {
const cmProvider = (cm.provider ??
'openai-compatible') as LLMProvider;
return (
<ModelCard
key={`custom|${cm.name}`}
provider={cmProvider}
providerInfo={providers[cmProvider]}
model={{
name: cm.name,
displayName:
cm.displayName || cm.name,
maxInputTokens:
cm.maxInputTokens || 128000,
supportedFileTypes: [
'pdf',
'image',
'audio',
],
}}
isFavorite={favorites.includes(
favKey(cmProvider, cm.name)
)}
isActive={isCurrentModel(
cmProvider,
cm.name
)}
onClick={() => onPickCustomModel(cm)}
onToggleFavorite={() =>
toggleFavorite(cmProvider, cm.name)
}
onEdit={() => editCustomModel(cm)}
onDelete={() =>
deleteCustomModel(cm.name)
}
size="sm"
isCustom
/>
);
})}
</div>
)}
</div>
)}
</div>
{/* Bottom Navigation Bar */}
<div className="flex-shrink-0 border-t border-border/30 px-3 py-2 flex items-center justify-end">
<button
onClick={() =>
setActiveView(
activeView === 'favorites' ? 'all' : 'favorites'
)
}
className="flex items-center gap-1.5 text-sm font-medium text-primary hover:text-primary/80 transition-colors"
>
{activeView === 'favorites' ? (
<>
Show all
<ChevronUp className="h-4 w-4" />
</>
) : (
<>
Favorites
<ChevronLeft className="h-4 w-4 rotate-180" />
</>
)}
{activeView === 'favorites' && favoriteModels.length > 0 && (
<span className="ml-1 w-2 h-2 rounded-full bg-primary" />
)}
</button>
</div>
</>
)}
</PopoverContent>
</Popover>
{pendingKeyProvider && (
<ApiKeyModal
open={keyModalOpen}
onOpenChange={setKeyModalOpen}
provider={pendingKeyProvider}
primaryEnvVar={providers[pendingKeyProvider]?.primaryEnvVar || ''}
onSaved={onApiKeySaved}
/>
)}
</>
);
}