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:
Tam Nhu Tran committed 2026-02-20 23:24:11 +07:00
1 parent 5b12ce7f00
commit 9ad8e64e85
5 files changed
+145 -85

No files matched your search

+2 -11
View File
@@ -27,21 +27,12 @@ import { MoreHorizontal, Trash2, User, Pencil } from 'lucide-react';
import { useDeleteVariant } from '@/hooks/use-cliproxy'; import { useDeleteVariant } from '@/hooks/use-cliproxy';
import { CliproxyEditDialog } from './cliproxy-edit-dialog'; import { CliproxyEditDialog } from './cliproxy-edit-dialog';
import type { Variant } from '@/lib/api-client'; import type { Variant } from '@/lib/api-client';
import { getProviderDisplayName } from '@/lib/provider-config';
interface CliproxyTableProps { interface CliproxyTableProps {
data: Variant[]; 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) { export function CliproxyTable({ data }: CliproxyTableProps) {
const deleteMutation = useDeleteVariant(); const deleteMutation = useDeleteVariant();
const [editingVariant, setEditingVariant] = useState<Variant | null>(null); const [editingVariant, setEditingVariant] = useState<Variant | null>(null);
@@ -59,7 +50,7 @@ export function CliproxyTable({ data }: CliproxyTableProps) {
if (row.original.type === 'composite') { if (row.original.type === 'composite') {
return <Badge variant="secondary">composite</Badge>; return <Badge variant="secondary">composite</Badge>;
} }
return providerLabels[row.original.provider] || row.original.provider; return getProviderDisplayName(row.original.provider);
}, },
}, },
{ {
+12 -37
View File
@@ -4,6 +4,11 @@
*/ */
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import {
getProviderFallbackVisual,
getProviderLogoAsset,
providerNeedsDarkLogoBackground,
} from '@/lib/provider-config';
interface ProviderLogoProps { interface ProviderLogoProps {
provider: string; provider: string;
@@ -11,35 +16,6 @@ interface ProviderLogoProps {
size?: 'sm' | 'md' | 'lg'; 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 */ /** Size configuration */
const SIZE_CONFIG = { const SIZE_CONFIG = {
sm: { container: 'w-6 h-6', icon: 'w-4 h-4', text: 'text-xs' }, 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) { export function ProviderLogo({ provider, className, size = 'md' }: ProviderLogoProps) {
const providerKey = provider.toLowerCase(); const fallback = getProviderFallbackVisual(provider);
const config = PROVIDER_CONFIG[providerKey] || {
text: 'text-gray-600',
letter: provider[0]?.toUpperCase() || '?',
};
const sizeConfig = SIZE_CONFIG[size]; const sizeConfig = SIZE_CONFIG[size];
const imageSrc = PROVIDER_IMAGES[providerKey]; const imageSrc = getProviderLogoAsset(provider);
return ( return (
<div <div
className={cn( className={cn(
'flex items-center justify-center rounded-md', '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, sizeConfig.container,
className className
)} )}
@@ -72,7 +45,9 @@ export function ProviderLogo({ provider, className, size = 'md' }: ProviderLogoP
className={cn(sizeConfig.icon, 'object-contain')} 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> </div>
); );
+11 -16
View File
@@ -17,6 +17,10 @@ import {
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { ExternalLink, Copy, Check, Loader2, KeyRound } from 'lucide-react'; import { ExternalLink, Copy, Check, Loader2, KeyRound } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import {
getDeviceCodeProviderDisplayName,
getDeviceCodeProviderInstruction,
} from '@/lib/provider-config';
interface DeviceCodeDialogProps { interface DeviceCodeDialogProps {
open: boolean; open: boolean;
@@ -28,18 +32,6 @@ interface DeviceCodeDialogProps {
expiresAt: number; 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({ export function DeviceCodeDialog({
open, open,
onClose, onClose,
@@ -91,9 +83,12 @@ export function DeviceCodeDialog({
window.open(verificationUrl, '_blank', 'noopener,noreferrer'); window.open(verificationUrl, '_blank', 'noopener,noreferrer');
}, [verificationUrl]); }, [verificationUrl]);
const providerDisplay = PROVIDER_DISPLAY_NAMES[provider] || provider; const providerDisplay = getDeviceCodeProviderDisplayName(provider);
const instructions = const instructions = getDeviceCodeProviderInstruction(provider);
PROVIDER_INSTRUCTIONS[provider] || 'Complete the authorization in your browser.'; const openActionLabel =
providerDisplay === 'Unknown provider'
? 'Open verification page'
: `Open ${providerDisplay.split(' ')[0]}`;
// Format remaining time // Format remaining time
const formatTime = (seconds: number): string => { const formatTime = (seconds: number): string => {
@@ -155,7 +150,7 @@ export function DeviceCodeDialog({
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<Button onClick={handleOpenUrl} className="w-full"> <Button onClick={handleOpenUrl} className="w-full">
<ExternalLink className="w-4 h-4 mr-2" /> <ExternalLink className="w-4 h-4 mr-2" />
Open {providerDisplay.split(' ')[0]} {openActionLabel}
</Button> </Button>
<Button variant="outline" onClick={handleCopyCode} className="w-full"> <Button variant="outline" onClick={handleCopyCode} className="w-full">
{hasCopied ? ( {hasCopied ? (
+13 -12
View File
@@ -8,6 +8,7 @@
import { useState, useEffect, useCallback, useMemo } from 'react'; import { useState, useEffect, useCallback, useMemo } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { getDeviceCodeProviderDisplayName } from '@/lib/provider-config';
export interface DeviceCodePrompt { export interface DeviceCodePrompt {
sessionId: string; sessionId: string;
@@ -23,12 +24,13 @@ interface DeviceCodeState {
error: string | null; error: string | null;
} }
/** Provider display names for user-friendly messages */ function coerceProvider(value: unknown): string {
const PROVIDER_DISPLAY_NAMES: Record<string, string> = { if (typeof value !== 'string') {
ghcp: 'GitHub Copilot', return 'unknown';
kiro: 'Kiro (AWS)', }
qwen: 'Qwen Code', const normalized = value.trim().toLowerCase();
}; return normalized || 'unknown';
}
export function useDeviceCode() { export function useDeviceCode() {
const [state, setState] = useState<DeviceCodeState>({ const [state, setState] = useState<DeviceCodeState>({
@@ -44,14 +46,15 @@ export function useDeviceCode() {
if (data.type === 'deviceCodeReceived') { if (data.type === 'deviceCodeReceived') {
console.log('[DeviceCode] Received prompt:', data.sessionId); 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`); toast.info(`${displayName} authorization required`);
setState({ setState({
isOpen: true, isOpen: true,
prompt: { prompt: {
sessionId: data.sessionId as string, sessionId: data.sessionId as string,
provider: data.provider as string, provider,
userCode: data.userCode as string, userCode: data.userCode as string,
verificationUrl: data.verificationUrl as string, verificationUrl: data.verificationUrl as string,
expiresAt: data.expiresAt as number, expiresAt: data.expiresAt as number,
@@ -62,8 +65,7 @@ export function useDeviceCode() {
console.log('[DeviceCode] Auth completed:', data.sessionId); console.log('[DeviceCode] Auth completed:', data.sessionId);
setState((prev) => { setState((prev) => {
if (prev.prompt && prev.prompt.sessionId === data.sessionId) { if (prev.prompt && prev.prompt.sessionId === data.sessionId) {
const displayName = const displayName = getDeviceCodeProviderDisplayName(prev.prompt.provider);
PROVIDER_DISPLAY_NAMES[prev.prompt.provider] || prev.prompt.provider;
toast.success(`${displayName} authentication successful!`); toast.success(`${displayName} authentication successful!`);
return { isOpen: false, prompt: null, error: null }; return { isOpen: false, prompt: null, error: null };
} }
@@ -73,8 +75,7 @@ export function useDeviceCode() {
console.log('[DeviceCode] Auth failed:', data.sessionId, data.error); console.log('[DeviceCode] Auth failed:', data.sessionId, data.error);
setState((prev) => { setState((prev) => {
if (prev.prompt && prev.prompt.sessionId === data.sessionId) { if (prev.prompt && prev.prompt.sessionId === data.sessionId) {
const displayName = const displayName = getDeviceCodeProviderDisplayName(prev.prompt.provider);
PROVIDER_DISPLAY_NAMES[prev.prompt.provider] || prev.prompt.provider;
toast.error(`${displayName} authentication failed`); toast.error(`${displayName} authentication failed`);
return { isOpen: false, prompt: null, error: data.error as string }; return { isOpen: false, prompt: null, error: data.error as string };
} }
+107 -9
View File
@@ -30,6 +30,10 @@ export function isValidProvider(provider: string): provider is CLIProxyProvider
return CLIPROXY_PROVIDERS.includes(provider as CLIProxyProvider); return CLIPROXY_PROVIDERS.includes(provider as CLIProxyProvider);
} }
function normalizeProviderInput(provider: unknown): string {
return typeof provider === 'string' ? provider.trim().toLowerCase() : '';
}
interface ProviderMetadata { interface ProviderMetadata {
displayName: string; displayName: string;
description: 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) // 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', gemini: '/assets/providers/gemini-color.svg',
agy: '/assets/providers/agy.png', agy: '/assets/providers/agy.png',
codex: '/assets/providers/openai.svg', codex: '/assets/providers/openai.svg',
@@ -87,6 +91,56 @@ export const PROVIDER_ASSETS: Record<string, string> = {
kimi: '/assets/providers/kimi.svg', 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 // Provider brand colors
export const PROVIDER_COLORS: Record<string, string> = { export const PROVIDER_COLORS: Record<string, string> = {
gemini: '#4285F4', gemini: '#4285F4',
@@ -110,13 +164,17 @@ const PROVIDER_NAMES: Record<string, string> = {
}; };
// Map provider to display name // Map provider to display name
export function getProviderDisplayName(provider: string): string { export function getProviderDisplayName(provider: unknown): string {
return PROVIDER_NAMES[provider.toLowerCase()] || provider; const normalized = normalizeProviderInput(provider);
if (!normalized) {
return 'Unknown provider';
}
return PROVIDER_NAMES[normalized] || String(provider);
} }
/** Map provider to user-facing short description */ /** Map provider to user-facing short description */
export function getProviderDescription(provider: string): string { export function getProviderDescription(provider: unknown): string {
const normalized = provider.toLowerCase(); const normalized = normalizeProviderInput(provider);
if (!isValidProvider(normalized)) return ''; if (!isValidProvider(normalized)) return '';
return PROVIDER_METADATA[normalized].description; 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']; 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 */ /** Check if provider uses Device Code flow */
export function isDeviceCodeProvider(provider: string): boolean { export function isDeviceCodeProvider(provider: unknown): boolean {
return DEVICE_CODE_PROVIDERS.includes(provider as CLIProxyProvider); 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. */ /** Providers that require nickname because token payload may not include email. */
export const NICKNAME_REQUIRED_PROVIDERS: CLIProxyProvider[] = ['ghcp', 'kiro']; export const NICKNAME_REQUIRED_PROVIDERS: CLIProxyProvider[] = ['ghcp', 'kiro'];
/** Check if provider requires user-supplied nickname in auth flow */ /** Check if provider requires user-supplied nickname in auth flow */
export function isNicknameRequiredProvider(provider: string): boolean { export function isNicknameRequiredProvider(provider: unknown): boolean {
return NICKNAME_REQUIRED_PROVIDERS.includes(provider as CLIProxyProvider); const normalized = normalizeProviderInput(provider);
return isValidProvider(normalized) && NICKNAME_REQUIRED_PROVIDERS.includes(normalized);
} }
/** Kiro auth methods exposed in CCS UI (aligned with CLIProxyAPIPlus support). */ /** Kiro auth methods exposed in CCS UI (aligned with CLIProxyAPIPlus support). */