feat(ui): add provider presets and OpenRouter promo components

- add provider-presets.ts with OpenRouter/GLM/GLMT/Kimi configs
- add OpenRouterBanner for announcement header
- add OpenRouterPromoCard for sidebar promotion
- add OpenRouterQuickStart for default right panel
- export new components from profiles index
This commit is contained in:
kaitranntt
2025-12-20 19:47:58 -05:00
parent a1cbd4d923
commit 9c90e1dc2c
5 changed files with 311 additions and 0 deletions
+3
View File
@@ -15,6 +15,9 @@ export type { Settings, SettingsResponse, ProfileEditorProps } from './editor';
// OpenRouter components
export { OpenRouterBadge } from './openrouter-badge';
export { OpenRouterBanner } from './openrouter-banner';
export { OpenRouterModelPicker } from './openrouter-model-picker';
export { OpenRouterPromoCard } from './openrouter-promo-card';
export { OpenRouterQuickStart } from './openrouter-quick-start';
export { ModelTierMapping } from './model-tier-mapping';
export type { TierMapping } from './model-tier-mapping';
@@ -0,0 +1,84 @@
/**
* OpenRouter Feature Banner
* Dismissible announcement banner for OpenRouter integration
*/
/* eslint-disable react-hooks/set-state-in-effect */
import { useState, useEffect } from 'react';
import { X, Sparkles, ExternalLink } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useOpenRouterReady } from '@/hooks/use-openrouter-models';
const BANNER_DISMISSED_KEY = 'ccs:openrouter-banner-dismissed';
interface OpenRouterBannerProps {
onCreateClick?: () => void;
}
export function OpenRouterBanner({ onCreateClick }: OpenRouterBannerProps) {
const [dismissed, setDismissed] = useState(true); // Start hidden to avoid flash
const { modelCount, isLoading } = useOpenRouterReady();
// Check localStorage on mount
useEffect(() => {
const isDismissed = localStorage.getItem(BANNER_DISMISSED_KEY) === 'true';
setDismissed(isDismissed);
}, []);
const handleDismiss = () => {
localStorage.setItem(BANNER_DISMISSED_KEY, 'true');
setDismissed(true);
};
if (dismissed) return null;
return (
<div className="bg-gradient-to-r from-orange-500 to-orange-600 text-white px-4 py-3 relative shrink-0">
<div className="flex items-center justify-between gap-4 max-w-screen-xl mx-auto">
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className="p-1.5 bg-white/20 rounded-md shrink-0">
<Sparkles className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-sm">NEW: OpenRouter Integration</p>
<p className="text-xs text-white/80 truncate">
Browse {isLoading ? '300+' : `${modelCount}+`} models from OpenAI, Anthropic, Google,
Meta and more.
</p>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{onCreateClick && (
<Button
size="sm"
variant="secondary"
onClick={onCreateClick}
className="bg-white text-orange-600 hover:bg-white/90 h-8"
>
Try it now
</Button>
)}
<a
href="https://openrouter.ai"
target="_blank"
rel="noopener noreferrer"
className="text-xs text-white/80 hover:text-white hidden sm:flex items-center gap-1"
>
Learn more
<ExternalLink className="w-3 h-3" />
</a>
<Button
size="icon"
variant="ghost"
onClick={handleDismiss}
className="h-7 w-7 text-white/70 hover:text-white hover:bg-white/20"
>
<X className="w-4 h-4" />
<span className="sr-only">Dismiss</span>
</Button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,41 @@
/**
* OpenRouter Promo Card
* Permanent promotional card for OpenRouter - always visible in sidebar footer
*/
import { Button } from '@/components/ui/button';
import { useOpenRouterReady } from '@/hooks/use-openrouter-models';
import { Zap } from 'lucide-react';
interface OpenRouterPromoCardProps {
onCreateClick: () => void;
}
export function OpenRouterPromoCard({ onCreateClick }: OpenRouterPromoCardProps) {
const { modelCount, isLoading } = useOpenRouterReady();
return (
<div className="p-3 border-t bg-gradient-to-r from-orange-50 to-orange-100/50 dark:from-orange-950/30 dark:to-orange-900/20">
<div className="flex items-center gap-2">
<div className="p-1.5 bg-orange-100 dark:bg-orange-900/40 rounded shrink-0">
<img src="/icons/openrouter.svg" alt="" className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<p className="text-xs font-medium text-orange-700 dark:text-orange-300">OpenRouter</p>
<p className="text-[10px] text-muted-foreground truncate">
{isLoading ? '300+' : `${modelCount}+`} models available
</p>
</div>
<Button
size="sm"
variant="ghost"
onClick={onCreateClick}
className="h-7 px-2 text-orange-600 hover:text-orange-700 hover:bg-orange-100 dark:hover:bg-orange-900/30"
>
<Zap className="w-3 h-3 mr-1" />
<span className="text-xs">Add</span>
</Button>
</div>
</div>
);
}
@@ -0,0 +1,98 @@
/**
* OpenRouter Quick Start Card
* Prominent CTA for new users to create OpenRouter profile
*/
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { useOpenRouterReady } from '@/hooks/use-openrouter-models';
import { Sparkles, ExternalLink, ArrowRight, Zap } from 'lucide-react';
interface OpenRouterQuickStartProps {
onOpenRouterClick: () => void;
onCustomClick: () => void;
}
export function OpenRouterQuickStart({
onOpenRouterClick,
onCustomClick,
}: OpenRouterQuickStartProps) {
const { modelCount, isLoading } = useOpenRouterReady();
return (
<div className="flex-1 flex items-center justify-center bg-muted/20 p-8">
<div className="max-w-lg w-full space-y-6">
{/* Main OpenRouter Card */}
<Card className="border-orange-200 dark:border-orange-800/50 bg-gradient-to-br from-orange-50/50 to-background dark:from-orange-950/20">
<CardHeader className="pb-3">
<div className="flex items-center gap-3 mb-2">
<div className="p-2 rounded-lg bg-orange-100 dark:bg-orange-900/30">
<img src="/icons/openrouter.svg" alt="OpenRouter" className="w-6 h-6" />
</div>
<Badge
variant="secondary"
className="bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300"
>
Recommended
</Badge>
</div>
<CardTitle className="text-xl">Start with OpenRouter</CardTitle>
<CardDescription className="text-base">
Access {isLoading ? '300+' : `${modelCount}+`} models from OpenAI, Anthropic, Google,
Meta and more - all through one API.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Key Features */}
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="flex items-center gap-2 text-muted-foreground">
<Zap className="w-4 h-4 text-orange-500" />
<span>One API, all providers</span>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<Sparkles className="w-4 h-4 text-orange-500" />
<span>Model tier mapping</span>
</div>
</div>
<Button
onClick={onOpenRouterClick}
className="w-full bg-orange-600 hover:bg-orange-700 text-white"
size="lg"
>
Create OpenRouter Profile
<ArrowRight className="w-4 h-4 ml-2" />
</Button>
<p className="text-xs text-center text-muted-foreground">
Get your API key at{' '}
<a
href="https://openrouter.ai/keys"
target="_blank"
rel="noopener noreferrer"
className="text-orange-600 hover:underline inline-flex items-center gap-1"
>
openrouter.ai/keys
<ExternalLink className="w-3 h-3" />
</a>
</p>
</CardContent>
</Card>
{/* Divider */}
<div className="flex items-center gap-4">
<Separator className="flex-1" />
<span className="text-xs text-muted-foreground">or</span>
<Separator className="flex-1" />
</div>
{/* Custom Option */}
<Button variant="outline" onClick={onCustomClick} className="w-full">
Create Custom API Profile
</Button>
</div>
</div>
);
}
+85
View File
@@ -0,0 +1,85 @@
/**
* Provider Presets Configuration
* Pre-configured templates for common API providers
*/
export interface ProviderPreset {
id: string;
name: string;
description: string;
baseUrl: string;
defaultProfileName: string;
badge?: string;
featured?: boolean;
icon?: string;
defaultModel?: string;
requiresApiKey: boolean;
apiKeyPlaceholder: string;
apiKeyHint?: string;
}
export const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1';
export const PROVIDER_PRESETS: ProviderPreset[] = [
{
id: 'openrouter',
name: 'OpenRouter',
description: '349+ models from OpenAI, Anthropic, Google, Meta',
baseUrl: OPENROUTER_BASE_URL,
defaultProfileName: 'openrouter',
badge: '349+ models',
featured: true,
icon: '/icons/openrouter.svg',
defaultModel: 'anthropic/claude-sonnet-4',
requiresApiKey: true,
apiKeyPlaceholder: 'sk-or-...',
apiKeyHint: 'Get your API key at openrouter.ai/keys',
},
{
id: 'glm',
name: 'GLM',
description: 'Claude via Z.AI (GitHub Copilot)',
baseUrl: 'https://api.z.ai/api/anthropic',
defaultProfileName: 'glm',
badge: 'Free',
defaultModel: 'glm-4.6',
requiresApiKey: true,
apiKeyPlaceholder: 'ghp_...',
apiKeyHint: 'Get your API key from Z.AI',
},
{
id: 'glmt',
name: 'GLMT',
description: 'GLM with Thinking mode support',
baseUrl: 'https://api.z.ai/api/coding/paas/v4/chat/completions',
defaultProfileName: 'glmt',
badge: 'Thinking',
defaultModel: 'glm-4.6',
requiresApiKey: true,
apiKeyPlaceholder: 'ghp_...',
apiKeyHint: 'Same API key as GLM',
},
{
id: 'kimi',
name: 'Kimi',
description: 'Moonshot AI - Fast reasoning model',
baseUrl: 'https://api.kimi.com/coding/',
defaultProfileName: 'kimi',
badge: 'Reasoning',
defaultModel: 'kimi-k2-thinking-turbo',
requiresApiKey: true,
apiKeyPlaceholder: 'sk-...',
apiKeyHint: 'Get your API key from Moonshot AI',
},
];
/** Get preset by ID */
export function getPresetById(id: string): ProviderPreset | undefined {
return PROVIDER_PRESETS.find((p) => p.id === id);
}
/** Check if a URL matches a known preset */
export function detectPresetFromUrl(baseUrl: string): ProviderPreset | undefined {
const normalizedUrl = baseUrl.toLowerCase().trim();
return PROVIDER_PRESETS.find((p) => normalizedUrl.includes(p.baseUrl.toLowerCase()));
}