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