mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-12 18:15:48 +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 parent
3cd21bb67b
commit
677f9d1e72
6 files changed
+150
-2
No files matched your search
@@ -1,11 +1,16 @@
|
|||||||
/**
|
/**
|
||||||
* Friendly UI Section
|
* Friendly UI Section
|
||||||
* Left column with environment variables and info tabs
|
* 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 { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||||
import { EnvEditorSection } from './env-editor-section';
|
import { EnvEditorSection } from './env-editor-section';
|
||||||
import { InfoSection } from './info-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';
|
import type { Settings, SettingsResponse } from './types';
|
||||||
|
|
||||||
interface FriendlyUISectionProps {
|
interface FriendlyUISectionProps {
|
||||||
@@ -16,6 +21,7 @@ interface FriendlyUISectionProps {
|
|||||||
onNewEnvKeyChange: (key: string) => void;
|
onNewEnvKeyChange: (key: string) => void;
|
||||||
onEnvValueChange: (key: string, value: string) => void;
|
onEnvValueChange: (key: string, value: string) => void;
|
||||||
onAddEnvVar: () => void;
|
onAddEnvVar: () => void;
|
||||||
|
onEnvBulkChange?: (env: Record<string, string>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FriendlyUISection({
|
export function FriendlyUISection({
|
||||||
@@ -26,7 +32,45 @@ export function FriendlyUISection({
|
|||||||
onNewEnvKeyChange,
|
onNewEnvKeyChange,
|
||||||
onEnvValueChange,
|
onEnvValueChange,
|
||||||
onAddEnvVar,
|
onAddEnvVar,
|
||||||
|
onEnvBulkChange,
|
||||||
}: FriendlyUISectionProps) {
|
}: 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 (
|
return (
|
||||||
<div className="h-full flex flex-col">
|
<div className="h-full flex flex-col">
|
||||||
<Tabs defaultValue="env" className="h-full flex flex-col">
|
<Tabs defaultValue="env" className="h-full flex flex-col">
|
||||||
@@ -46,6 +90,27 @@ export function FriendlyUISection({
|
|||||||
value="env"
|
value="env"
|
||||||
className="flex-1 mt-0 border-0 p-0 data-[state=inactive]:hidden flex flex-col overflow-hidden"
|
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
|
<EnvEditorSection
|
||||||
currentSettings={currentSettings}
|
currentSettings={currentSettings}
|
||||||
newEnvKey={newEnvKey}
|
newEnvKey={newEnvKey}
|
||||||
|
|||||||
@@ -6,10 +6,14 @@
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Save, Loader2, Trash2, RefreshCw } from 'lucide-react';
|
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 {
|
interface HeaderSectionProps {
|
||||||
profileName: string;
|
profileName: string;
|
||||||
data: { path?: string; mtime: number } | undefined;
|
data: { path?: string; mtime: number } | undefined;
|
||||||
|
settings?: Settings;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isSaving: boolean;
|
isSaving: boolean;
|
||||||
hasChanges: boolean;
|
hasChanges: boolean;
|
||||||
@@ -22,6 +26,7 @@ interface HeaderSectionProps {
|
|||||||
export function HeaderSection({
|
export function HeaderSection({
|
||||||
profileName,
|
profileName,
|
||||||
data,
|
data,
|
||||||
|
settings,
|
||||||
isLoading,
|
isLoading,
|
||||||
isSaving,
|
isSaving,
|
||||||
hasChanges,
|
hasChanges,
|
||||||
@@ -40,6 +45,7 @@ export function HeaderSection({
|
|||||||
{data.path.replace(/^.*\//, '')}
|
{data.path.replace(/^.*\//, '')}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
{isOpenRouterProfile(settings) && <OpenRouterBadge className="ml-1" />}
|
||||||
</div>
|
</div>
|
||||||
{data && (
|
{data && (
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
|||||||
@@ -139,6 +139,7 @@ export function ProfileEditor({ profileName, onDelete }: ProfileEditorProps) {
|
|||||||
<HeaderSection
|
<HeaderSection
|
||||||
profileName={profileName}
|
profileName={profileName}
|
||||||
data={data}
|
data={data}
|
||||||
|
settings={currentSettings}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
isSaving={saveMutation.isPending}
|
isSaving={saveMutation.isPending}
|
||||||
hasChanges={computedHasChanges}
|
hasChanges={computedHasChanges}
|
||||||
@@ -214,5 +215,5 @@ export { RawEditorSection } from './raw-editor-section';
|
|||||||
export { HeaderSection } from './header-section';
|
export { HeaderSection } from './header-section';
|
||||||
export { FriendlyUISection } from './friendly-ui-section';
|
export { FriendlyUISection } from './friendly-ui-section';
|
||||||
export { useProfileEditor } from './use-profile-editor';
|
export { useProfileEditor } from './use-profile-editor';
|
||||||
export { isSensitiveKey } from './utils';
|
export { isSensitiveKey, isOpenRouterProfile, extractTierMapping, applyTierMapping } from './utils';
|
||||||
export type { Settings, SettingsResponse, ProfileEditorProps } from './types';
|
export type { Settings, SettingsResponse, ProfileEditorProps } from './types';
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
* Utility functions for Profile Editor
|
* Utility functions for Profile Editor
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { Settings } from './types';
|
||||||
|
|
||||||
/** Check if a key is considered sensitive (API keys, tokens, etc.) */
|
/** Check if a key is considered sensitive (API keys, tokens, etc.) */
|
||||||
export function isSensitiveKey(key: string): boolean {
|
export function isSensitiveKey(key: string): boolean {
|
||||||
const sensitivePatterns = [
|
const sensitivePatterns = [
|
||||||
@@ -15,3 +17,58 @@ export function isSensitiveKey(key: string): boolean {
|
|||||||
];
|
];
|
||||||
return sensitivePatterns.some((pattern) => pattern.test(key));
|
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 { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { SettingsIcon, PlayIcon } from 'lucide-react';
|
import { SettingsIcon, PlayIcon } from 'lucide-react';
|
||||||
|
import { isOpenRouterProfile } from './editor/utils';
|
||||||
|
import type { Settings } from './editor/types';
|
||||||
|
|
||||||
interface ProfileCardProps {
|
interface ProfileCardProps {
|
||||||
profile: {
|
profile: {
|
||||||
@@ -12,18 +15,30 @@ interface ProfileCardProps {
|
|||||||
lastUsed?: string;
|
lastUsed?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
};
|
};
|
||||||
|
/** Optional settings for OpenRouter detection */
|
||||||
|
settings?: Settings;
|
||||||
onSwitch?: () => void;
|
onSwitch?: () => void;
|
||||||
onConfig?: () => void;
|
onConfig?: () => void;
|
||||||
onTest?: () => 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 (
|
return (
|
||||||
<Card className={profile.isActive ? 'border-primary' : ''}>
|
<Card className={profile.isActive ? 'border-primary' : ''}>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h3 className="font-semibold">{profile.name}</h3>
|
<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 && (
|
{profile.isActive && (
|
||||||
<Badge variant="default" className="text-xs">
|
<Badge variant="default" className="text-xs">
|
||||||
Active
|
Active
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
import { ProfileEditor } from '@/components/profile-editor';
|
import { ProfileEditor } from '@/components/profile-editor';
|
||||||
import { ProfileCreateDialog } from '@/components/profiles/profile-create-dialog';
|
import { ProfileCreateDialog } from '@/components/profiles/profile-create-dialog';
|
||||||
import { useProfiles, useDeleteProfile } from '@/hooks/use-profiles';
|
import { useProfiles, useDeleteProfile } from '@/hooks/use-profiles';
|
||||||
|
import { useOpenRouterModels } from '@/hooks/use-openrouter-models';
|
||||||
import { ConfirmDialog } from '@/components/shared/confirm-dialog';
|
import { ConfirmDialog } from '@/components/shared/confirm-dialog';
|
||||||
import type { Profile } from '@/lib/api-client';
|
import type { Profile } from '@/lib/api-client';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -37,6 +38,9 @@ export function ApiPage() {
|
|||||||
const [isCreateDialogOpen, setCreateDialogOpen] = useState(false);
|
const [isCreateDialogOpen, setCreateDialogOpen] = useState(false);
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
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
|
// Memoize profiles to maintain stable reference
|
||||||
const profiles = useMemo(() => data?.profiles || [], [data?.profiles]);
|
const profiles = useMemo(() => data?.profiles || [], [data?.profiles]);
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user