feat(ui): integrate OpenRouter model picker into profile editor

- add isOpenRouterProfile() detection in utils.ts

- show OpenRouterBadge in header when detected

- replace model input with picker for OpenRouter profiles

- add tier mapping section in friendly-ui-section.tsx

- show OpenRouter icon in profile-card.tsx

- prefetch models on api.tsx page load
This commit is contained in:
kaitranntt
2025-12-20 18:31:00 -05:00
parent 3cd21bb67b
commit 677f9d1e72
6 changed files with 150 additions and 2 deletions
@@ -1,11 +1,16 @@
/**
* Friendly UI Section
* Left column with environment variables and info tabs
* Enhanced with OpenRouter model picker when applicable
*/
import { useMemo } from 'react';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { EnvEditorSection } from './env-editor-section';
import { InfoSection } from './info-section';
import { OpenRouterModelPicker } from '@/components/profiles/openrouter-model-picker';
import { ModelTierMapping, type TierMapping } from '@/components/profiles/model-tier-mapping';
import { isOpenRouterProfile, extractTierMapping, applyTierMapping } from './utils';
import type { Settings, SettingsResponse } from './types';
interface FriendlyUISectionProps {
@@ -16,6 +21,7 @@ interface FriendlyUISectionProps {
onNewEnvKeyChange: (key: string) => void;
onEnvValueChange: (key: string, value: string) => void;
onAddEnvVar: () => void;
onEnvBulkChange?: (env: Record<string, string>) => void;
}
export function FriendlyUISection({
@@ -26,7 +32,45 @@ export function FriendlyUISection({
onNewEnvKeyChange,
onEnvValueChange,
onAddEnvVar,
onEnvBulkChange,
}: FriendlyUISectionProps) {
const isOpenRouter = isOpenRouterProfile(currentSettings);
const settingsEnv = currentSettings?.env;
// Derive tier mapping from env vars (no local state to sync)
const tierMapping = useMemo<TierMapping>(
() => extractTierMapping(settingsEnv ?? {}),
[settingsEnv]
);
// Memoize currentEnv for consistent reference
const currentEnv = settingsEnv ?? {};
// Handle model selection from OpenRouter picker
const handleModelChange = (modelId: string) => {
onEnvValueChange('ANTHROPIC_MODEL', modelId);
};
// Handle tier mapping change
const handleTierMappingChange = (mapping: TierMapping) => {
// Apply tier mapping to env vars
if (onEnvBulkChange) {
const newEnv = applyTierMapping(currentEnv, mapping);
onEnvBulkChange(newEnv);
} else {
// Fallback: update one by one
if (mapping.opus !== undefined) {
onEnvValueChange('ANTHROPIC_DEFAULT_OPUS_MODEL', mapping.opus || '');
}
if (mapping.sonnet !== undefined) {
onEnvValueChange('ANTHROPIC_DEFAULT_SONNET_MODEL', mapping.sonnet || '');
}
if (mapping.haiku !== undefined) {
onEnvValueChange('ANTHROPIC_DEFAULT_HAIKU_MODEL', mapping.haiku || '');
}
}
};
return (
<div className="h-full flex flex-col">
<Tabs defaultValue="env" className="h-full flex flex-col">
@@ -46,6 +90,27 @@ export function FriendlyUISection({
value="env"
className="flex-1 mt-0 border-0 p-0 data-[state=inactive]:hidden flex flex-col overflow-hidden"
>
{/* OpenRouter Model Picker Section */}
{isOpenRouter && (
<div className="px-4 pt-4 pb-2 border-b space-y-3 shrink-0">
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">
Model (OpenRouter)
</label>
<OpenRouterModelPicker
value={currentEnv.ANTHROPIC_MODEL}
onChange={handleModelChange}
placeholder="Search OpenRouter models..."
/>
</div>
<ModelTierMapping
selectedModel={currentEnv.ANTHROPIC_MODEL}
value={tierMapping}
onChange={handleTierMappingChange}
/>
</div>
)}
<EnvEditorSection
currentSettings={currentSettings}
newEnvKey={newEnvKey}
@@ -6,10 +6,14 @@
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Save, Loader2, Trash2, RefreshCw } from 'lucide-react';
import { OpenRouterBadge } from '@/components/profiles/openrouter-badge';
import { isOpenRouterProfile } from './utils';
import type { Settings } from './types';
interface HeaderSectionProps {
profileName: string;
data: { path?: string; mtime: number } | undefined;
settings?: Settings;
isLoading: boolean;
isSaving: boolean;
hasChanges: boolean;
@@ -22,6 +26,7 @@ interface HeaderSectionProps {
export function HeaderSection({
profileName,
data,
settings,
isLoading,
isSaving,
hasChanges,
@@ -40,6 +45,7 @@ export function HeaderSection({
{data.path.replace(/^.*\//, '')}
</Badge>
)}
{isOpenRouterProfile(settings) && <OpenRouterBadge className="ml-1" />}
</div>
{data && (
<p className="text-xs text-muted-foreground mt-0.5">
+2 -1
View File
@@ -139,6 +139,7 @@ export function ProfileEditor({ profileName, onDelete }: ProfileEditorProps) {
<HeaderSection
profileName={profileName}
data={data}
settings={currentSettings}
isLoading={isLoading}
isSaving={saveMutation.isPending}
hasChanges={computedHasChanges}
@@ -214,5 +215,5 @@ export { RawEditorSection } from './raw-editor-section';
export { HeaderSection } from './header-section';
export { FriendlyUISection } from './friendly-ui-section';
export { useProfileEditor } from './use-profile-editor';
export { isSensitiveKey } from './utils';
export { isSensitiveKey, isOpenRouterProfile, extractTierMapping, applyTierMapping } from './utils';
export type { Settings, SettingsResponse, ProfileEditorProps } from './types';
@@ -2,6 +2,8 @@
* Utility functions for Profile Editor
*/
import type { Settings } from './types';
/** Check if a key is considered sensitive (API keys, tokens, etc.) */
export function isSensitiveKey(key: string): boolean {
const sensitivePatterns = [
@@ -15,3 +17,58 @@ export function isSensitiveKey(key: string): boolean {
];
return sensitivePatterns.some((pattern) => pattern.test(key));
}
/**
* Check if settings indicate an OpenRouter profile
*/
export function isOpenRouterProfile(settings: Settings | undefined): boolean {
if (!settings?.env) return false;
const baseUrl = settings.env.ANTHROPIC_BASE_URL || '';
return baseUrl.toLowerCase().includes('openrouter.ai');
}
/**
* Extract tier mapping from settings env vars
*/
export function extractTierMapping(env: Record<string, string>): {
opus?: string;
sonnet?: string;
haiku?: string;
} {
return {
opus: env.ANTHROPIC_DEFAULT_OPUS_MODEL || undefined,
sonnet: env.ANTHROPIC_DEFAULT_SONNET_MODEL || undefined,
haiku: env.ANTHROPIC_DEFAULT_HAIKU_MODEL || undefined,
};
}
/**
* Merge tier mapping into env vars
*/
export function applyTierMapping(
env: Record<string, string>,
mapping: { opus?: string; sonnet?: string; haiku?: string }
): Record<string, string> {
const result = { ...env };
// Set or remove tier overrides
if (mapping.opus) {
result.ANTHROPIC_DEFAULT_OPUS_MODEL = mapping.opus;
} else {
delete result.ANTHROPIC_DEFAULT_OPUS_MODEL;
}
if (mapping.sonnet) {
result.ANTHROPIC_DEFAULT_SONNET_MODEL = mapping.sonnet;
} else {
delete result.ANTHROPIC_DEFAULT_SONNET_MODEL;
}
if (mapping.haiku) {
result.ANTHROPIC_DEFAULT_HAIKU_MODEL = mapping.haiku;
} else {
delete result.ANTHROPIC_DEFAULT_HAIKU_MODEL;
}
return result;
}
+16 -1
View File
@@ -1,7 +1,10 @@
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { SettingsIcon, PlayIcon } from 'lucide-react';
import { isOpenRouterProfile } from './editor/utils';
import type { Settings } from './editor/types';
interface ProfileCardProps {
profile: {
@@ -12,18 +15,30 @@ interface ProfileCardProps {
lastUsed?: string;
model?: string;
};
/** Optional settings for OpenRouter detection */
settings?: Settings;
onSwitch?: () => void;
onConfig?: () => void;
onTest?: () => void;
}
export function ProfileCard({ profile, onSwitch, onConfig, onTest }: ProfileCardProps) {
export function ProfileCard({ profile, settings, onSwitch, onConfig, onTest }: ProfileCardProps) {
const showOpenRouterIcon = isOpenRouterProfile(settings);
return (
<Card className={profile.isActive ? 'border-primary' : ''}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h3 className="font-semibold">{profile.name}</h3>
{showOpenRouterIcon && (
<Tooltip>
<TooltipTrigger asChild>
<img src="/icons/openrouter.svg" alt="OpenRouter" className="w-4 h-4" />
</TooltipTrigger>
<TooltipContent>OpenRouter profile</TooltipContent>
</Tooltip>
)}
{profile.isActive && (
<Badge variant="default" className="text-xs">
Active
+4
View File
@@ -24,6 +24,7 @@ import {
import { ProfileEditor } from '@/components/profile-editor';
import { ProfileCreateDialog } from '@/components/profiles/profile-create-dialog';
import { useProfiles, useDeleteProfile } from '@/hooks/use-profiles';
import { useOpenRouterModels } from '@/hooks/use-openrouter-models';
import { ConfirmDialog } from '@/components/shared/confirm-dialog';
import type { Profile } from '@/lib/api-client';
import { cn } from '@/lib/utils';
@@ -37,6 +38,9 @@ export function ApiPage() {
const [isCreateDialogOpen, setCreateDialogOpen] = useState(false);
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
// Prefetch OpenRouter models when page loads (lazy - won't block render)
useOpenRouterModels();
// Memoize profiles to maintain stable reference
const profiles = useMemo(() => data?.profiles || [], [data?.profiles]);