mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-17 06:17:09 +00:00
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:
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user