mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 02:11:28 +00:00
refactor(ui): centralize provider metadata and fallbacks
- move logo, label, and device-code copy into provider-config - handle unknown or non-string providers without UI crashes - replace duplicated hardcoded mappings in cliproxy table/logo
This commit is contained in:
@@ -27,21 +27,12 @@ import { MoreHorizontal, Trash2, User, Pencil } from 'lucide-react';
|
||||
import { useDeleteVariant } from '@/hooks/use-cliproxy';
|
||||
import { CliproxyEditDialog } from './cliproxy-edit-dialog';
|
||||
import type { Variant } from '@/lib/api-client';
|
||||
import { getProviderDisplayName } from '@/lib/provider-config';
|
||||
|
||||
interface CliproxyTableProps {
|
||||
data: Variant[];
|
||||
}
|
||||
|
||||
const providerLabels: Record<string, string> = {
|
||||
gemini: 'Google Gemini',
|
||||
codex: 'OpenAI Codex',
|
||||
agy: 'Antigravity',
|
||||
qwen: 'Alibaba Qwen',
|
||||
iflow: 'iFlow',
|
||||
kiro: 'Kiro (AWS)',
|
||||
ghcp: 'GitHub Copilot (OAuth)',
|
||||
};
|
||||
|
||||
export function CliproxyTable({ data }: CliproxyTableProps) {
|
||||
const deleteMutation = useDeleteVariant();
|
||||
const [editingVariant, setEditingVariant] = useState<Variant | null>(null);
|
||||
@@ -59,7 +50,7 @@ export function CliproxyTable({ data }: CliproxyTableProps) {
|
||||
if (row.original.type === 'composite') {
|
||||
return <Badge variant="secondary">composite</Badge>;
|
||||
}
|
||||
return providerLabels[row.original.provider] || row.original.provider;
|
||||
return getProviderDisplayName(row.original.provider);
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -4,6 +4,11 @@
|
||||
*/
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
getProviderFallbackVisual,
|
||||
getProviderLogoAsset,
|
||||
providerNeedsDarkLogoBackground,
|
||||
} from '@/lib/provider-config';
|
||||
|
||||
interface ProviderLogoProps {
|
||||
provider: string;
|
||||
@@ -11,35 +16,6 @@ interface ProviderLogoProps {
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
/** Provider image assets mapping */
|
||||
const PROVIDER_IMAGES: Record<string, string> = {
|
||||
gemini: '/assets/providers/gemini-color.svg',
|
||||
codex: '/assets/providers/openai.svg',
|
||||
agy: '/assets/providers/agy.png',
|
||||
qwen: '/assets/providers/qwen-color.svg',
|
||||
iflow: '/assets/providers/iflow.png',
|
||||
kiro: '/assets/providers/kiro.png',
|
||||
ghcp: '/assets/providers/copilot.svg',
|
||||
claude: '/assets/providers/claude.svg',
|
||||
kimi: '/assets/providers/kimi.svg',
|
||||
};
|
||||
|
||||
/** Provider color configuration (for fallback only - no background for image logos) */
|
||||
const PROVIDER_CONFIG: Record<string, { text: string; letter: string }> = {
|
||||
gemini: { text: 'text-blue-600', letter: 'G' },
|
||||
claude: { text: 'text-orange-600', letter: 'C' },
|
||||
codex: { text: 'text-emerald-600', letter: 'X' },
|
||||
agy: { text: 'text-violet-600', letter: 'A' },
|
||||
qwen: { text: 'text-cyan-600', letter: 'Q' },
|
||||
iflow: { text: 'text-indigo-600', letter: 'i' },
|
||||
kiro: { text: 'text-teal-600', letter: 'K' },
|
||||
ghcp: { text: 'text-green-600', letter: 'C' },
|
||||
kimi: { text: 'text-orange-500', letter: 'K' },
|
||||
};
|
||||
|
||||
/** Providers whose logos require a dark background */
|
||||
const DARK_BG_PROVIDERS = new Set(['kimi']);
|
||||
|
||||
/** Size configuration */
|
||||
const SIZE_CONFIG = {
|
||||
sm: { container: 'w-6 h-6', icon: 'w-4 h-4', text: 'text-xs' },
|
||||
@@ -48,19 +24,16 @@ const SIZE_CONFIG = {
|
||||
};
|
||||
|
||||
export function ProviderLogo({ provider, className, size = 'md' }: ProviderLogoProps) {
|
||||
const providerKey = provider.toLowerCase();
|
||||
const config = PROVIDER_CONFIG[providerKey] || {
|
||||
text: 'text-gray-600',
|
||||
letter: provider[0]?.toUpperCase() || '?',
|
||||
};
|
||||
const fallback = getProviderFallbackVisual(provider);
|
||||
const sizeConfig = SIZE_CONFIG[size];
|
||||
const imageSrc = PROVIDER_IMAGES[providerKey];
|
||||
const imageSrc = getProviderLogoAsset(provider);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center rounded-md',
|
||||
imageSrc && (DARK_BG_PROVIDERS.has(providerKey) ? 'bg-gray-900 p-1' : 'bg-white p-1'),
|
||||
imageSrc &&
|
||||
(providerNeedsDarkLogoBackground(provider) ? 'bg-gray-900 p-1' : 'bg-white p-1'),
|
||||
sizeConfig.container,
|
||||
className
|
||||
)}
|
||||
@@ -72,7 +45,9 @@ export function ProviderLogo({ provider, className, size = 'md' }: ProviderLogoP
|
||||
className={cn(sizeConfig.icon, 'object-contain')}
|
||||
/>
|
||||
) : (
|
||||
<span className={cn('font-semibold', config.text, sizeConfig.text)}>{config.letter}</span>
|
||||
<span className={cn('font-semibold', fallback.textClass, sizeConfig.text)}>
|
||||
{fallback.letter}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -17,6 +17,10 @@ import {
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ExternalLink, Copy, Check, Loader2, KeyRound } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
getDeviceCodeProviderDisplayName,
|
||||
getDeviceCodeProviderInstruction,
|
||||
} from '@/lib/provider-config';
|
||||
|
||||
interface DeviceCodeDialogProps {
|
||||
open: boolean;
|
||||
@@ -28,18 +32,6 @@ interface DeviceCodeDialogProps {
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
/** Provider display names */
|
||||
const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
|
||||
ghcp: 'GitHub Copilot',
|
||||
qwen: 'Qwen Code',
|
||||
};
|
||||
|
||||
/** Provider specific instructions */
|
||||
const PROVIDER_INSTRUCTIONS: Record<string, string> = {
|
||||
ghcp: 'Sign in with your GitHub account that has Copilot access.',
|
||||
qwen: 'Sign in with your Qwen account to authorize access.',
|
||||
};
|
||||
|
||||
export function DeviceCodeDialog({
|
||||
open,
|
||||
onClose,
|
||||
@@ -91,9 +83,12 @@ export function DeviceCodeDialog({
|
||||
window.open(verificationUrl, '_blank', 'noopener,noreferrer');
|
||||
}, [verificationUrl]);
|
||||
|
||||
const providerDisplay = PROVIDER_DISPLAY_NAMES[provider] || provider;
|
||||
const instructions =
|
||||
PROVIDER_INSTRUCTIONS[provider] || 'Complete the authorization in your browser.';
|
||||
const providerDisplay = getDeviceCodeProviderDisplayName(provider);
|
||||
const instructions = getDeviceCodeProviderInstruction(provider);
|
||||
const openActionLabel =
|
||||
providerDisplay === 'Unknown provider'
|
||||
? 'Open verification page'
|
||||
: `Open ${providerDisplay.split(' ')[0]}`;
|
||||
|
||||
// Format remaining time
|
||||
const formatTime = (seconds: number): string => {
|
||||
@@ -155,7 +150,7 @@ export function DeviceCodeDialog({
|
||||
<div className="flex flex-col gap-3">
|
||||
<Button onClick={handleOpenUrl} className="w-full">
|
||||
<ExternalLink className="w-4 h-4 mr-2" />
|
||||
Open {providerDisplay.split(' ')[0]}
|
||||
{openActionLabel}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleCopyCode} className="w-full">
|
||||
{hasCopied ? (
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { getDeviceCodeProviderDisplayName } from '@/lib/provider-config';
|
||||
|
||||
export interface DeviceCodePrompt {
|
||||
sessionId: string;
|
||||
@@ -23,12 +24,13 @@ interface DeviceCodeState {
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/** Provider display names for user-friendly messages */
|
||||
const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
|
||||
ghcp: 'GitHub Copilot',
|
||||
kiro: 'Kiro (AWS)',
|
||||
qwen: 'Qwen Code',
|
||||
};
|
||||
function coerceProvider(value: unknown): string {
|
||||
if (typeof value !== 'string') {
|
||||
return 'unknown';
|
||||
}
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return normalized || 'unknown';
|
||||
}
|
||||
|
||||
export function useDeviceCode() {
|
||||
const [state, setState] = useState<DeviceCodeState>({
|
||||
@@ -44,14 +46,15 @@ export function useDeviceCode() {
|
||||
|
||||
if (data.type === 'deviceCodeReceived') {
|
||||
console.log('[DeviceCode] Received prompt:', data.sessionId);
|
||||
const displayName = PROVIDER_DISPLAY_NAMES[data.provider as string] || data.provider;
|
||||
const provider = coerceProvider(data.provider);
|
||||
const displayName = getDeviceCodeProviderDisplayName(provider);
|
||||
toast.info(`${displayName} authorization required`);
|
||||
|
||||
setState({
|
||||
isOpen: true,
|
||||
prompt: {
|
||||
sessionId: data.sessionId as string,
|
||||
provider: data.provider as string,
|
||||
provider,
|
||||
userCode: data.userCode as string,
|
||||
verificationUrl: data.verificationUrl as string,
|
||||
expiresAt: data.expiresAt as number,
|
||||
@@ -62,8 +65,7 @@ export function useDeviceCode() {
|
||||
console.log('[DeviceCode] Auth completed:', data.sessionId);
|
||||
setState((prev) => {
|
||||
if (prev.prompt && prev.prompt.sessionId === data.sessionId) {
|
||||
const displayName =
|
||||
PROVIDER_DISPLAY_NAMES[prev.prompt.provider] || prev.prompt.provider;
|
||||
const displayName = getDeviceCodeProviderDisplayName(prev.prompt.provider);
|
||||
toast.success(`${displayName} authentication successful!`);
|
||||
return { isOpen: false, prompt: null, error: null };
|
||||
}
|
||||
@@ -73,8 +75,7 @@ export function useDeviceCode() {
|
||||
console.log('[DeviceCode] Auth failed:', data.sessionId, data.error);
|
||||
setState((prev) => {
|
||||
if (prev.prompt && prev.prompt.sessionId === data.sessionId) {
|
||||
const displayName =
|
||||
PROVIDER_DISPLAY_NAMES[prev.prompt.provider] || prev.prompt.provider;
|
||||
const displayName = getDeviceCodeProviderDisplayName(prev.prompt.provider);
|
||||
toast.error(`${displayName} authentication failed`);
|
||||
return { isOpen: false, prompt: null, error: data.error as string };
|
||||
}
|
||||
|
||||
@@ -30,6 +30,10 @@ export function isValidProvider(provider: string): provider is CLIProxyProvider
|
||||
return CLIPROXY_PROVIDERS.includes(provider as CLIProxyProvider);
|
||||
}
|
||||
|
||||
function normalizeProviderInput(provider: unknown): string {
|
||||
return typeof provider === 'string' ? provider.trim().toLowerCase() : '';
|
||||
}
|
||||
|
||||
interface ProviderMetadata {
|
||||
displayName: string;
|
||||
description: string;
|
||||
@@ -75,7 +79,7 @@ export const PROVIDER_METADATA: Record<CLIProxyProvider, ProviderMetadata> = {
|
||||
};
|
||||
|
||||
// Map provider names to asset filenames (only providers with actual logos)
|
||||
export const PROVIDER_ASSETS: Record<string, string> = {
|
||||
export const PROVIDER_ASSETS: Record<CLIProxyProvider, string> = {
|
||||
gemini: '/assets/providers/gemini-color.svg',
|
||||
agy: '/assets/providers/agy.png',
|
||||
codex: '/assets/providers/openai.svg',
|
||||
@@ -87,6 +91,56 @@ export const PROVIDER_ASSETS: Record<string, string> = {
|
||||
kimi: '/assets/providers/kimi.svg',
|
||||
};
|
||||
|
||||
interface ProviderFallbackVisual {
|
||||
textClass: string;
|
||||
letter: string;
|
||||
}
|
||||
|
||||
const DEFAULT_PROVIDER_FALLBACK_VISUAL: ProviderFallbackVisual = {
|
||||
textClass: 'text-gray-600',
|
||||
letter: '?',
|
||||
};
|
||||
|
||||
/** Fallback visual style when a provider logo asset is unavailable. */
|
||||
export const PROVIDER_FALLBACK_VISUALS: Record<CLIProxyProvider, ProviderFallbackVisual> = {
|
||||
gemini: { textClass: 'text-blue-600', letter: 'G' },
|
||||
claude: { textClass: 'text-orange-600', letter: 'C' },
|
||||
codex: { textClass: 'text-emerald-600', letter: 'X' },
|
||||
agy: { textClass: 'text-violet-600', letter: 'A' },
|
||||
qwen: { textClass: 'text-cyan-600', letter: 'Q' },
|
||||
iflow: { textClass: 'text-indigo-600', letter: 'i' },
|
||||
kiro: { textClass: 'text-teal-600', letter: 'K' },
|
||||
ghcp: { textClass: 'text-green-600', letter: 'C' },
|
||||
kimi: { textClass: 'text-orange-500', letter: 'K' },
|
||||
};
|
||||
|
||||
/** Providers whose logo looks better on dark background. */
|
||||
export const PROVIDERS_WITH_DARK_LOGO_BG: ReadonlySet<CLIProxyProvider> = new Set(['kimi']);
|
||||
|
||||
export function getProviderLogoAsset(provider: unknown): string | undefined {
|
||||
const normalized = normalizeProviderInput(provider);
|
||||
if (!isValidProvider(normalized)) {
|
||||
return undefined;
|
||||
}
|
||||
return PROVIDER_ASSETS[normalized];
|
||||
}
|
||||
|
||||
export function getProviderFallbackVisual(provider: unknown): ProviderFallbackVisual {
|
||||
const normalized = normalizeProviderInput(provider);
|
||||
if (isValidProvider(normalized)) {
|
||||
return PROVIDER_FALLBACK_VISUALS[normalized];
|
||||
}
|
||||
return {
|
||||
...DEFAULT_PROVIDER_FALLBACK_VISUAL,
|
||||
letter: normalized[0]?.toUpperCase() || DEFAULT_PROVIDER_FALLBACK_VISUAL.letter,
|
||||
};
|
||||
}
|
||||
|
||||
export function providerNeedsDarkLogoBackground(provider: unknown): boolean {
|
||||
const normalized = normalizeProviderInput(provider);
|
||||
return isValidProvider(normalized) && PROVIDERS_WITH_DARK_LOGO_BG.has(normalized);
|
||||
}
|
||||
|
||||
// Provider brand colors
|
||||
export const PROVIDER_COLORS: Record<string, string> = {
|
||||
gemini: '#4285F4',
|
||||
@@ -110,13 +164,17 @@ const PROVIDER_NAMES: Record<string, string> = {
|
||||
};
|
||||
|
||||
// Map provider to display name
|
||||
export function getProviderDisplayName(provider: string): string {
|
||||
return PROVIDER_NAMES[provider.toLowerCase()] || provider;
|
||||
export function getProviderDisplayName(provider: unknown): string {
|
||||
const normalized = normalizeProviderInput(provider);
|
||||
if (!normalized) {
|
||||
return 'Unknown provider';
|
||||
}
|
||||
return PROVIDER_NAMES[normalized] || String(provider);
|
||||
}
|
||||
|
||||
/** Map provider to user-facing short description */
|
||||
export function getProviderDescription(provider: string): string {
|
||||
const normalized = provider.toLowerCase();
|
||||
export function getProviderDescription(provider: unknown): string {
|
||||
const normalized = normalizeProviderInput(provider);
|
||||
if (!isValidProvider(normalized)) return '';
|
||||
return PROVIDER_METADATA[normalized].description;
|
||||
}
|
||||
@@ -127,17 +185,57 @@ export function getProviderDescription(provider: string): string {
|
||||
*/
|
||||
export const DEVICE_CODE_PROVIDERS: CLIProxyProvider[] = ['ghcp', 'kiro', 'qwen', 'kimi'];
|
||||
|
||||
const DEVICE_CODE_PROVIDER_DISPLAY_NAMES: Readonly<Partial<Record<CLIProxyProvider, string>>> =
|
||||
Object.freeze({
|
||||
ghcp: 'GitHub Copilot',
|
||||
kiro: 'Kiro (AWS)',
|
||||
qwen: 'Qwen Code',
|
||||
});
|
||||
|
||||
const DEVICE_CODE_PROVIDER_INSTRUCTIONS: Readonly<Partial<Record<CLIProxyProvider, string>>> =
|
||||
Object.freeze({
|
||||
ghcp: 'Sign in with your GitHub account that has Copilot access.',
|
||||
qwen: 'Sign in with your Qwen account to authorize access.',
|
||||
kiro: 'Sign in with your selected Kiro auth provider to continue.',
|
||||
kimi: 'Sign in with your Kimi account and finish the device authorization.',
|
||||
});
|
||||
|
||||
/** Check if provider uses Device Code flow */
|
||||
export function isDeviceCodeProvider(provider: string): boolean {
|
||||
return DEVICE_CODE_PROVIDERS.includes(provider as CLIProxyProvider);
|
||||
export function isDeviceCodeProvider(provider: unknown): boolean {
|
||||
const normalized = normalizeProviderInput(provider);
|
||||
return isValidProvider(normalized) && DEVICE_CODE_PROVIDERS.includes(normalized);
|
||||
}
|
||||
|
||||
/** Provider display name tuned for device-code UX copy. */
|
||||
export function getDeviceCodeProviderDisplayName(provider: unknown): string {
|
||||
const normalized = normalizeProviderInput(provider);
|
||||
if (!normalized) {
|
||||
return 'Unknown provider';
|
||||
}
|
||||
if (isValidProvider(normalized)) {
|
||||
return DEVICE_CODE_PROVIDER_DISPLAY_NAMES[normalized] || getProviderDisplayName(normalized);
|
||||
}
|
||||
return String(provider);
|
||||
}
|
||||
|
||||
/** Provider-specific helper text for device-code dialog. */
|
||||
export function getDeviceCodeProviderInstruction(provider: unknown): string {
|
||||
const normalized = normalizeProviderInput(provider);
|
||||
if (isValidProvider(normalized)) {
|
||||
return (
|
||||
DEVICE_CODE_PROVIDER_INSTRUCTIONS[normalized] || 'Complete the authorization in your browser.'
|
||||
);
|
||||
}
|
||||
return 'Complete the authorization in your browser.';
|
||||
}
|
||||
|
||||
/** Providers that require nickname because token payload may not include email. */
|
||||
export const NICKNAME_REQUIRED_PROVIDERS: CLIProxyProvider[] = ['ghcp', 'kiro'];
|
||||
|
||||
/** Check if provider requires user-supplied nickname in auth flow */
|
||||
export function isNicknameRequiredProvider(provider: string): boolean {
|
||||
return NICKNAME_REQUIRED_PROVIDERS.includes(provider as CLIProxyProvider);
|
||||
export function isNicknameRequiredProvider(provider: unknown): boolean {
|
||||
const normalized = normalizeProviderInput(provider);
|
||||
return isValidProvider(normalized) && NICKNAME_REQUIRED_PROVIDERS.includes(normalized);
|
||||
}
|
||||
|
||||
/** Kiro auth methods exposed in CCS UI (aligned with CLIProxyAPIPlus support). */
|
||||
|
||||
Reference in New Issue
Block a user