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>
This commit is contained in:
admin
2026-01-28 00:27:56 +04:00
Unverified
parent 3b128ba3bd
commit b52318eeae
1724 changed files with 351216 additions and 0 deletions

View File

@@ -0,0 +1,102 @@
import React from 'react';
import { Lock, Eye, FileText, Mic, Brain } from 'lucide-react';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import { cn } from '../../lib/utils';
import type { ModelInfo } from './types';
interface CapabilityIconsProps {
supportedFileTypes: ModelInfo['supportedFileTypes'];
hasApiKey: boolean;
showReasoning?: boolean;
showLockIcon?: boolean;
className?: string;
size?: 'sm' | 'md';
}
interface CapabilityBadgeProps {
icon: React.ReactNode;
label: string;
variant?: 'default' | 'warning' | 'success' | 'info';
}
function CapabilityBadge({ icon, label, variant = 'default' }: CapabilityBadgeProps) {
const variantStyles = {
default: 'bg-muted/80 text-muted-foreground hover:bg-muted hover:text-foreground',
warning: 'bg-amber-500/10 text-amber-500 hover:bg-amber-500/20',
success: 'bg-emerald-500/10 text-emerald-500 hover:bg-emerald-500/20',
info: 'bg-blue-500/10 text-blue-500 hover:bg-blue-500/20',
};
return (
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
'flex items-center justify-center w-7 h-7 rounded-lg transition-all duration-200 cursor-default',
variantStyles[variant]
)}
>
{icon}
</div>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
{label}
</TooltipContent>
</Tooltip>
);
}
export function CapabilityIcons({
supportedFileTypes,
hasApiKey,
showReasoning,
showLockIcon = true,
className,
size = 'sm',
}: CapabilityIconsProps) {
const iconSize = size === 'sm' ? 'h-3.5 w-3.5' : 'h-4 w-4';
return (
<div className={cn('flex items-center gap-1', className)}>
{supportedFileTypes?.includes('image') && (
<CapabilityBadge
icon={<Eye className={iconSize} />}
label="Vision / Image support"
variant="success"
/>
)}
{supportedFileTypes?.includes('pdf') && (
<CapabilityBadge
icon={<FileText className={iconSize} />}
label="PDF support"
variant="info"
/>
)}
{supportedFileTypes?.includes('audio') && (
<CapabilityBadge
icon={<Mic className={iconSize} />}
label="Audio support"
variant="info"
/>
)}
{showReasoning && (
<CapabilityBadge
icon={<Brain className={iconSize} />}
label="Extended thinking"
variant="default"
/>
)}
{showLockIcon && !hasApiKey && (
<CapabilityBadge
icon={<Lock className={iconSize} />}
label="Click to add API key"
variant="warning"
/>
)}
</div>
);
}

View File

@@ -0,0 +1,175 @@
import React from 'react';
import { Star, HelpCircle } from 'lucide-react';
import { cn } from '../../lib/utils';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip';
import type { LLMProvider } from '@dexto/core';
import { PROVIDER_LOGOS, needsDarkModeInversion, formatPricingLines } from './constants';
import { CapabilityIcons } from './CapabilityIcons';
import type { ModelInfo, ProviderCatalog } from './types';
interface CompactModelCardProps {
provider: LLMProvider;
model: ModelInfo;
providerInfo: ProviderCatalog;
isFavorite: boolean;
isActive: boolean;
onClick: () => void;
onToggleFavorite: () => void;
}
// Provider display name mapping
const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
anthropic: 'Claude',
google: 'Gemini',
openai: 'GPT',
groq: 'Groq',
xai: 'Grok',
cohere: 'Cohere',
'openai-compatible': 'Custom',
dexto: 'Dexto',
};
// Providers that have multi-vendor models (don't strip provider prefixes from display name)
const MULTI_VENDOR_PROVIDERS = new Set([
'openrouter',
'dexto',
'openai-compatible',
'litellm',
'glama',
'bedrock',
'vertex',
]);
export function CompactModelCard({
provider,
model,
providerInfo,
isFavorite,
isActive,
onClick,
onToggleFavorite,
}: CompactModelCardProps) {
const displayName = model.displayName || model.name;
const hasApiKey = providerInfo.hasApiKey;
const providerName = PROVIDER_DISPLAY_NAMES[provider] || provider;
// Build description for tooltip
const priceLines = formatPricingLines(model.pricing || undefined);
const descriptionLines = [
`Provider: ${providerInfo.name}`,
model.maxInputTokens && `Max tokens: ${model.maxInputTokens.toLocaleString()}`,
Array.isArray(model.supportedFileTypes) &&
model.supportedFileTypes.length > 0 &&
`Supports: ${model.supportedFileTypes.join(', ')}`,
!hasApiKey && 'API key required',
...priceLines,
].filter(Boolean) as string[];
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div
onClick={onClick}
onKeyDown={(event) => {
const target = event.target as HTMLElement | null;
if (target && target.closest('button')) return;
const isEnter = event.key === 'Enter';
const isSpace =
event.key === ' ' ||
event.key === 'Spacebar' ||
event.code === 'Space';
if (!isEnter && !isSpace) return;
if (isSpace) event.preventDefault();
onClick();
}}
className={cn(
'relative flex items-center gap-2.5 px-3 py-2 rounded-xl border transition-all duration-150 cursor-pointer group whitespace-nowrap',
'hover:bg-accent/50 hover:border-primary/30',
isActive
? 'bg-primary/10 border-primary/40 shadow-sm'
: 'border-border/40 bg-card/60',
!hasApiKey && 'opacity-70'
)}
role="button"
tabIndex={0}
>
{/* Provider Logo */}
<div className="w-6 h-6 flex items-center justify-center flex-shrink-0">
{PROVIDER_LOGOS[provider] ? (
<img
src={PROVIDER_LOGOS[provider]}
alt={`${provider} logo`}
width={20}
height={20}
className={cn(
'object-contain',
needsDarkModeInversion(provider) &&
'dark:invert dark:brightness-0 dark:contrast-200'
)}
/>
) : (
<HelpCircle className="h-4 w-4 text-muted-foreground" />
)}
</div>
{/* Model Name */}
<div className="flex flex-col min-w-0">
<span className="text-xs font-semibold text-foreground leading-tight truncate">
{providerName}
</span>
<span className="text-[10px] text-muted-foreground leading-tight truncate">
{MULTI_VENDOR_PROVIDERS.has(provider)
? displayName
: displayName.replace(
new RegExp(`^${providerName}\\s*`, 'i'),
''
)}
</span>
</div>
{/* Capability Icons */}
<CapabilityIcons
supportedFileTypes={model.supportedFileTypes}
hasApiKey={hasApiKey}
size="sm"
className="flex-shrink-0"
/>
{/* Favorite Star */}
<button
onClick={(e) => {
e.stopPropagation();
onToggleFavorite();
}}
className={cn(
'flex-shrink-0 p-0.5 rounded-full transition-all duration-200',
'hover:scale-110 active:scale-95',
'opacity-0 group-hover:opacity-100',
isFavorite && 'opacity-100'
)}
aria-label={isFavorite ? 'Remove from favorites' : 'Add to favorites'}
>
<Star
className={cn(
'h-3.5 w-3.5 transition-all',
isFavorite
? 'fill-yellow-400 text-yellow-400'
: 'text-muted-foreground/50 hover:text-yellow-400'
)}
/>
</button>
</div>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
<div className="text-xs space-y-0.5">
{descriptionLines.map((line, idx) => (
<div key={idx}>{line}</div>
))}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,318 @@
import React from 'react';
import { Star, HelpCircle, Lock, X, Pencil } from 'lucide-react';
import { cn } from '../../lib/utils';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip';
import type { LLMProvider } from '@dexto/core';
import { PROVIDER_LOGOS, needsDarkModeInversion, formatPricingLines, hasLogo } from './constants';
import { CapabilityIcons } from './CapabilityIcons';
import type { ModelInfo, ProviderCatalog } from './types';
interface ModelCardProps {
provider: LLMProvider;
model: ModelInfo;
providerInfo?: ProviderCatalog;
isFavorite: boolean;
isActive: boolean;
onClick: () => void;
onToggleFavorite: () => void;
onDelete?: () => void;
onEdit?: () => void;
size?: 'sm' | 'md' | 'lg';
isCustom?: boolean;
/** Installed local model (downloaded via CLI) */
isInstalled?: boolean;
}
// Provider display name mapping
const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
anthropic: 'Claude',
google: 'Gemini',
openai: 'GPT',
groq: 'Groq',
xai: 'Grok',
cohere: 'Cohere',
openrouter: 'OpenRouter',
'openai-compatible': 'Custom',
litellm: 'LiteLLM',
glama: 'Glama',
local: 'Local',
ollama: 'Ollama',
dexto: 'Dexto',
};
// Parse display name into provider and model parts
function parseModelName(
displayName: string,
provider: string
): { providerName: string; modelName: string; suffix?: string } {
const providerName = PROVIDER_DISPLAY_NAMES[provider] || provider;
// For multi-vendor or custom model providers, show the full display name without parsing
if (
provider === 'openrouter' ||
provider === 'dexto' ||
provider === 'openai-compatible' ||
provider === 'litellm' ||
provider === 'glama' ||
provider === 'bedrock' ||
provider === 'vertex'
) {
return { providerName, modelName: displayName };
}
// Extract suffix like (Reasoning) if present
const suffixMatch = displayName.match(/\(([^)]+)\)$/);
const suffix = suffixMatch ? suffixMatch[1] : undefined;
const nameWithoutSuffix = suffix ? displayName.replace(/\s*\([^)]+\)$/, '') : displayName;
// Try to extract model variant (remove provider prefix if present)
let modelName = nameWithoutSuffix;
const lowerName = nameWithoutSuffix.toLowerCase();
const lowerProvider = providerName.toLowerCase();
if (lowerName.startsWith(lowerProvider)) {
modelName = nameWithoutSuffix.slice(providerName.length).trim();
}
// Clean up common patterns
modelName = modelName.replace(/^[-\s]+/, '').replace(/^(claude|gemini|gpt|grok)\s*/i, '');
return { providerName, modelName: modelName || nameWithoutSuffix, suffix };
}
export function ModelCard({
provider,
model,
providerInfo,
isFavorite,
isActive,
onClick,
onToggleFavorite,
onDelete,
onEdit,
size = 'md',
isCustom = false,
isInstalled = false,
}: ModelCardProps) {
const displayName = model.displayName || model.name;
// Local/ollama/installed models don't need API keys
// Custom models are user-configured, so don't show lock (they handle their own auth)
const noApiKeyNeeded = isInstalled || isCustom || provider === 'local' || provider === 'ollama';
const hasApiKey = noApiKeyNeeded || (providerInfo?.hasApiKey ?? false);
const { providerName, modelName, suffix } = parseModelName(displayName, provider);
// Build description lines for tooltip
const priceLines = formatPricingLines(model.pricing || undefined);
const descriptionLines = [
`Model: ${displayName}`,
isInstalled
? 'Installed via CLI'
: provider === 'local'
? 'Local Model'
: provider === 'openai-compatible'
? 'Custom Model'
: `Provider: ${providerInfo?.name}`,
model.maxInputTokens && `Max tokens: ${model.maxInputTokens.toLocaleString()}`,
Array.isArray(model.supportedFileTypes) &&
model.supportedFileTypes.length > 0 &&
`Supports: ${model.supportedFileTypes.join(', ')}`,
!hasApiKey && 'API key required (click to add)',
...priceLines,
].filter(Boolean) as string[];
const sizeClasses = {
sm: 'px-2 py-4 h-[200px] w-full',
md: 'px-3 py-5 h-[230px] w-full',
lg: 'px-4 py-6 h-[275px] w-full',
};
const logoSizes = {
sm: { width: 36, height: 36, container: 'w-10 h-10' },
md: { width: 48, height: 48, container: 'w-14 h-14' },
lg: { width: 60, height: 60, container: 'w-16 h-16' },
};
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div
onClick={onClick}
onKeyDown={(event) => {
const target = event.target as HTMLElement | null;
if (target && target.closest('button')) return;
const isEnter = event.key === 'Enter';
const isSpace =
event.key === ' ' ||
event.key === 'Spacebar' ||
event.code === 'Space';
if (!isEnter && !isSpace) return;
if (isSpace) event.preventDefault();
onClick();
}}
className={cn(
'relative flex flex-col items-center rounded-2xl border-2 transition-all duration-200 cursor-pointer group overflow-hidden',
sizeClasses[size],
'hover:bg-accent/40 hover:border-primary/40 hover:shadow-lg hover:shadow-primary/5 hover:-translate-y-0.5',
isActive
? 'bg-primary/10 border-primary shadow-lg shadow-primary/10'
: 'border-border/50 bg-card/60 backdrop-blur-sm',
!hasApiKey && 'opacity-70'
)}
role="button"
tabIndex={0}
>
{/* Lock Icon - Top Left (when no API key) */}
{!hasApiKey && (
<Tooltip>
<TooltipTrigger asChild>
<div className="absolute top-2 left-2 p-1.5 rounded-full bg-amber-500/20 z-10">
<Lock className="h-3.5 w-3.5 text-amber-500" />
</div>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
Click to add API key
</TooltipContent>
</Tooltip>
)}
{/* Action Buttons - Top Left for custom/installed models */}
{(isCustom || isInstalled) && (onEdit || onDelete) && (
<div className="absolute top-2 left-2 flex gap-1 z-10 opacity-0 group-hover:opacity-100 transition-all duration-200">
{onEdit && (
<button
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
className={cn(
'p-1.5 rounded-full transition-all duration-200',
'hover:bg-primary/20 hover:scale-110 active:scale-95'
)}
aria-label={
isInstalled
? 'Edit installed model'
: 'Edit custom model'
}
>
<Pencil className="h-4 w-4 text-muted-foreground/60 hover:text-primary" />
</button>
)}
{onDelete && (
<button
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
className={cn(
'p-1.5 rounded-full transition-all duration-200',
'hover:bg-destructive/20 hover:scale-110 active:scale-95'
)}
aria-label={
isInstalled
? 'Delete installed model'
: 'Delete custom model'
}
>
<X className="h-4 w-4 text-muted-foreground/60 hover:text-destructive" />
</button>
)}
</div>
)}
{/* Favorite Star - Top Right */}
<button
onClick={(e) => {
e.stopPropagation();
onToggleFavorite();
}}
className={cn(
'absolute top-2 right-2 p-1.5 rounded-full transition-all duration-200 z-10',
'hover:bg-yellow-500/20 hover:scale-110 active:scale-95',
'opacity-0 group-hover:opacity-100',
isFavorite && 'opacity-100'
)}
aria-label={isFavorite ? 'Remove from favorites' : 'Add to favorites'}
>
<Star
className={cn(
'h-4 w-4 transition-all duration-200',
isFavorite
? 'fill-yellow-400 text-yellow-400 drop-shadow-[0_0_3px_rgba(250,204,21,0.5)]'
: 'text-muted-foreground/60 hover:text-yellow-400'
)}
/>
</button>
{/* Provider Logo */}
<div
className={cn(
'flex items-center justify-center rounded-xl bg-muted/60 mb-1.5',
logoSizes[size].container
)}
>
{hasLogo(provider) ? (
<img
src={PROVIDER_LOGOS[provider]}
alt={`${provider} logo`}
width={logoSizes[size].width}
height={logoSizes[size].height}
className={cn(
'object-contain',
needsDarkModeInversion(provider) &&
'dark:invert dark:brightness-0 dark:contrast-200'
)}
/>
) : (
<HelpCircle className="h-6 w-6 text-muted-foreground" />
)}
</div>
{/* Model Name */}
<div className="text-center flex-1 flex flex-col min-w-0 w-full">
<div
className={cn(
'font-bold text-foreground leading-tight',
size === 'sm' ? 'text-base' : 'text-lg'
)}
>
{providerName}
</div>
<div
className={cn(
'text-muted-foreground leading-tight mt-0.5 line-clamp-3',
size === 'sm' ? 'text-sm' : 'text-base'
)}
>
{modelName}
</div>
{suffix && (
<div className="text-xs text-primary/90 font-medium mt-1">
({suffix})
</div>
)}
</div>
{/* Capability Icons - fixed height to ensure consistent card layout */}
<div className="mt-auto pt-2 h-8 flex items-center justify-center">
<CapabilityIcons
supportedFileTypes={model.supportedFileTypes}
hasApiKey={hasApiKey}
showLockIcon={false}
size={size === 'sm' ? 'sm' : 'md'}
/>
</div>
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-xs">
<div className="text-xs space-y-0.5">
{descriptionLines.map((line, idx) => (
<div key={idx}>{line}</div>
))}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,237 @@
import React, { useState } from 'react';
import { Star, HelpCircle, ChevronDown, ChevronRight, ExternalLink } from 'lucide-react';
import type { ProviderCatalog, ModelInfo } from './types';
import { cn } from '../../lib/utils';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip';
import type { LLMProvider } from '@dexto/core';
import {
PROVIDER_LOGOS,
needsDarkModeInversion,
PROVIDER_PRICING_URLS,
formatPricingLines,
} from './constants';
import { CapabilityIcons } from './CapabilityIcons';
type Props = {
providerId: LLMProvider;
provider: ProviderCatalog;
models: ModelInfo[];
favorites: string[];
currentModel?: { provider: string; model: string; displayName?: string };
onToggleFavorite: (providerId: LLMProvider, modelName: string) => void;
onUse: (providerId: LLMProvider, model: ModelInfo) => void;
defaultExpanded?: boolean;
};
export function ProviderSection({
providerId,
provider,
models,
favorites,
currentModel,
onToggleFavorite,
onUse,
defaultExpanded = true,
}: Props) {
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
if (models.length === 0) return null;
const isCurrentModel = (modelName: string) =>
currentModel?.provider === providerId && currentModel?.model === modelName;
const isFavorite = (modelName: string) => favorites.includes(`${providerId}|${modelName}`);
const hasActiveModel = models.some((m) => isCurrentModel(m.name));
return (
<TooltipProvider>
<div className="space-y-2">
{/* Provider Header */}
<button
onClick={() => setIsExpanded(!isExpanded)}
className={cn(
'w-full flex items-center justify-between p-3 rounded-xl transition-all duration-200',
'hover:bg-accent/50 group',
hasActiveModel && 'bg-primary/5'
)}
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 flex items-center justify-center rounded-lg bg-muted/50">
{PROVIDER_LOGOS[providerId] ? (
<img
src={PROVIDER_LOGOS[providerId]}
alt={`${providerId} logo`}
width={20}
height={20}
className={cn(
'object-contain',
needsDarkModeInversion(providerId) &&
'dark:invert dark:brightness-0 dark:contrast-200'
)}
/>
) : (
<HelpCircle className="h-5 w-5 text-muted-foreground" />
)}
</div>
<div className="text-left">
<span className="text-sm font-semibold">{provider.name}</span>
<span className="text-xs text-muted-foreground ml-2">
{models.length} model{models.length !== 1 ? 's' : ''}
</span>
</div>
</div>
<div className="flex items-center gap-3">
{!provider.hasApiKey && (
<span className="text-xs px-2 py-1 rounded-md bg-amber-500/10 text-amber-500">
API Key Required
</span>
)}
{PROVIDER_PRICING_URLS[providerId] && (
<a
href={PROVIDER_PRICING_URLS[providerId]}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity"
>
Pricing
<ExternalLink className="h-3 w-3" />
</a>
)}
{isExpanded ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
</div>
</button>
{/* Models List */}
{isExpanded && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 pl-2">
{models.map((model) => {
const displayName = model.displayName || model.name;
const isActive = isCurrentModel(model.name);
const favorite = isFavorite(model.name);
const hasApiKey = provider.hasApiKey;
// Build description lines for tooltip
const priceLines = formatPricingLines(model.pricing || undefined);
const descriptionLines = [
model.maxInputTokens &&
`Max tokens: ${model.maxInputTokens.toLocaleString()}`,
Array.isArray(model.supportedFileTypes) &&
model.supportedFileTypes.length > 0 &&
`Supports: ${model.supportedFileTypes.join(', ')}`,
model.default && 'Default model',
!hasApiKey && 'API key required',
...priceLines,
].filter(Boolean) as string[];
return (
<Tooltip key={model.name}>
<TooltipTrigger asChild>
<div
onClick={() => onUse(providerId, model)}
onKeyDown={(e) => {
const target = e.target as HTMLElement | null;
if (target && target.closest('button')) return;
const isEnter = e.key === 'Enter';
const isSpace =
e.key === ' ' ||
e.key === 'Spacebar' ||
e.code === 'Space';
if (isSpace) e.preventDefault();
if (isEnter || isSpace) {
onUse(providerId, model);
}
}}
className={cn(
'group/card relative flex items-center gap-3 px-4 py-3 rounded-xl border transition-all duration-150 cursor-pointer outline-none',
'hover:bg-accent/50 hover:border-accent-foreground/20 hover:shadow-sm',
'focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/50',
isActive
? 'bg-primary/5 border-primary/30 shadow-sm ring-1 ring-primary/20'
: 'border-border/40 bg-card/30',
!hasApiKey && 'opacity-60'
)}
role="button"
tabIndex={0}
>
{/* Model Name */}
<div className="flex-1 text-left min-w-0">
<div className="text-sm font-medium truncate">
{displayName}
</div>
</div>
{/* Capability Icons */}
<CapabilityIcons
supportedFileTypes={model.supportedFileTypes}
hasApiKey={hasApiKey}
/>
{/* Favorite Star */}
<button
onKeyDown={(e) => {
const isSpace =
e.key === ' ' ||
e.key === 'Spacebar' ||
e.code === 'Space';
const isEnter = e.key === 'Enter';
if (isEnter || isSpace) {
e.stopPropagation();
if (isSpace) e.preventDefault();
}
}}
onClick={(e) => {
e.stopPropagation();
onToggleFavorite(providerId, model.name);
}}
className={cn(
'flex-shrink-0 p-1.5 rounded-lg transition-all duration-200',
'hover:bg-accent hover:scale-110 active:scale-95',
'opacity-0 group-hover/card:opacity-100',
favorite && 'opacity-100'
)}
aria-label={
favorite
? 'Remove from favorites'
: 'Add to favorites'
}
>
<Star
className={cn(
'h-4 w-4 transition-colors',
favorite
? 'fill-yellow-500 text-yellow-500'
: 'text-muted-foreground hover:text-yellow-500'
)}
/>
</button>
{/* Active Indicator */}
{isActive && (
<div className="absolute inset-y-2 left-0 w-1 bg-primary rounded-full" />
)}
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-xs">
<div className="text-xs space-y-0.5">
{descriptionLines.map((line, idx) => (
<div key={idx}>{line}</div>
))}
</div>
</TooltipContent>
</Tooltip>
);
})}
</div>
)}
</div>
</TooltipProvider>
);
}

View File

@@ -0,0 +1,57 @@
import React, { useEffect, useRef } from 'react';
import { Search, X } from 'lucide-react';
import { cn } from '../../lib/utils';
type Props = {
value: string;
onChange: (v: string) => void;
placeholder?: string;
autoFocus?: boolean;
};
export function SearchBar({
value,
onChange,
placeholder = 'Search models...',
autoFocus = true,
}: Props) {
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (autoFocus && inputRef.current) {
setTimeout(() => {
inputRef.current?.focus();
}, 100);
}
}, [autoFocus]);
return (
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
<input
ref={inputRef}
type="text"
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
className={cn(
'w-full h-11 pl-10 pr-10 rounded-xl',
'bg-muted/50 border border-border/50',
'text-sm placeholder:text-muted-foreground/70',
'focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary/50',
'transition-all duration-200'
)}
/>
{value && (
<button
type="button"
onClick={() => onChange('')}
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 rounded-full hover:bg-accent transition-colors"
aria-label="Clear search"
>
<X className="h-3.5 w-3.5 text-muted-foreground" />
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,107 @@
import React from 'react';
import { Sparkles, FlaskConical, Zap } from 'lucide-react';
import type { LLMProvider } from '@dexto/core';
// Provider logo file mapping - single source of truth
// Empty string means "use Bot icon fallback" in components
export const PROVIDER_LOGOS: Record<LLMProvider, string> = {
openai: '/logos/openai.svg',
anthropic: '/logos/claude-color.svg',
google: '/logos/gemini-color.svg',
groq: '/logos/groq.svg',
xai: '/logos/grok.svg',
'openai-compatible': '/logos/openai.svg',
cohere: '/logos/cohere-color.svg',
openrouter: '/logos/openrouter.svg',
litellm: '/logos/litellm.svg',
glama: '/logos/glama.svg',
vertex: '/logos/gemini-color.svg', // Vertex AI uses Gemini logo (primary model family)
bedrock: '/logos/aws-color.svg',
local: '', // Uses Bot icon fallback - local GGUF models via node-llama-cpp
ollama: '/logos/ollama.svg', // Ollama server
dexto: '/logos/dexto/dexto_logo_icon.svg', // Dexto gateway - use Dexto logo
};
// Provider pricing URLs (for quick access from Model Picker)
export const PROVIDER_PRICING_URLS: Partial<Record<LLMProvider, string>> = {
openai: 'https://platform.openai.com/docs/pricing',
anthropic: 'https://www.anthropic.com/pricing#api',
google: 'https://ai.google.dev/gemini-api/docs/pricing',
groq: 'https://groq.com/pricing/',
xai: 'https://docs.x.ai/docs/models',
cohere: 'https://cohere.com/pricing',
openrouter: 'https://openrouter.ai/models',
litellm: 'https://docs.litellm.ai/',
glama: 'https://glama.ai/',
vertex: 'https://cloud.google.com/vertex-ai/generative-ai/pricing',
bedrock: 'https://aws.amazon.com/bedrock/pricing/',
// TODO: make this a valid URL
dexto: 'https://dexto.ai/pricing',
// 'openai-compatible' intentionally omitted (varies by vendor)
};
// Helper: Format pricing from permillion to perthousand tokens
export function formatPricingLines(pricing?: {
inputPerM?: number;
outputPerM?: number;
cacheReadPerM?: number;
cacheWritePerM?: number;
currency?: 'USD';
unit?: 'per_million_tokens';
}): string[] {
if (!pricing) return [];
// Bail early if required pricing fields are missing
if (pricing.inputPerM == null || pricing.outputPerM == null) return [];
const currency = pricing.currency || 'USD';
const cur = currency === 'USD' ? '$' : '';
const lines: string[] = [];
lines.push(
`Cost: ${cur}${pricing.inputPerM.toFixed(2)} in / ${cur}${pricing.outputPerM.toFixed(2)} out per 1M tokens`
);
if (pricing.cacheReadPerM != null) {
lines.push(`Cache read: ${cur}${pricing.cacheReadPerM.toFixed(2)} per 1M tokens`);
}
if (pricing.cacheWritePerM != null) {
lines.push(`Cache write: ${cur}${pricing.cacheWritePerM.toFixed(2)} per 1M tokens`);
}
return lines;
}
// Logos that have hardcoded colors and don't need dark mode inversion
export const COLORED_LOGOS: readonly LLMProvider[] = [
'google',
'cohere',
'anthropic',
'vertex',
'dexto',
] as const;
// Helper to check if a logo needs dark mode inversion
export const needsDarkModeInversion = (provider: LLMProvider): boolean => {
return !COLORED_LOGOS.includes(provider);
};
// Helper to check if a provider has a logo
export const hasLogo = (provider: LLMProvider): boolean => {
return !!PROVIDER_LOGOS[provider];
};
// Model capability icons - sleek emojis for current capabilities
export const CAPABILITY_ICONS = {
// File type capabilities (what we currently use)
image: <span className="text-sm">🖼</span>,
audio: <span className="text-sm">🎵</span>,
pdf: <span className="text-sm">📄</span>,
// Other capabilities we currently have
reasoning: <span className="text-sm">🧠</span>,
experimental: (
<FlaskConical className="h-3.5 w-3.5 text-muted-foreground hover:text-amber-500 transition-colors cursor-help" />
),
new: (
<Sparkles className="h-3.5 w-3.5 text-muted-foreground hover:text-yellow-500 transition-colors cursor-help" />
),
realtime: (
<Zap className="h-3.5 w-3.5 text-muted-foreground hover:text-blue-500 transition-colors cursor-help" />
),
};

View File

@@ -0,0 +1 @@
export { default } from './ModelPickerModal';

View File

@@ -0,0 +1,80 @@
import type { SupportedFileType, LLMProvider } from '@dexto/core';
export type ModelInfo = {
name: string;
displayName?: string;
default?: boolean;
maxInputTokens: number;
supportedFileTypes: SupportedFileType[];
pricing?: {
inputPerM: number;
outputPerM: number;
cacheReadPerM?: number;
cacheWritePerM?: number;
currency?: 'USD';
unit?: 'per_million_tokens';
};
};
export type ProviderCatalog = {
name: string;
hasApiKey: boolean;
primaryEnvVar: string;
supportsBaseURL: boolean;
models: ModelInfo[];
};
export type CatalogResponse = { providers: Record<LLMProvider, ProviderCatalog> };
export type CurrentLLMConfigResponse = {
config: {
provider: string;
model: string;
displayName?: string;
baseURL?: string;
apiKey?: string;
maxInputTokens?: number;
};
};
export function favKey(provider: string, model: string) {
return `${provider}|${model}`;
}
export function validateBaseURL(url: string): { isValid: boolean; error?: string } {
const str = url.trim();
if (!str.length) return { isValid: true };
try {
const u = new URL(str);
if (!['http:', 'https:'].includes(u.protocol)) {
return { isValid: false, error: 'URL must use http:// or https://' };
}
if (!u.pathname.includes('/v1')) {
return { isValid: false, error: 'URL must include /v1 for compatibility' };
}
return { isValid: true };
} catch {
return { isValid: false, error: 'Invalid URL format' };
}
}
export const FAVORITES_STORAGE_KEY = 'dexto:modelFavorites';
export const CUSTOM_MODELS_STORAGE_KEY = 'dexto:customModels';
// Default favorites for new users (newest/best models)
export const DEFAULT_FAVORITES = [
'anthropic|claude-sonnet-4-5-20250929',
'anthropic|claude-opus-4-5-20251101',
'openai|gpt-5.1-chat-latest',
'openai|gpt-5.1',
'google|gemini-3-pro-preview',
'google|gemini-3-pro-image-preview',
];
// Minimal storage for custom models - other fields are inferred
export interface CustomModelStorage {
name: string; // Model identifier
baseURL: string; // OpenAI-compatible endpoint
maxInputTokens?: number; // Optional, defaults to 128k
maxOutputTokens?: number; // Optional, provider decides
}