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
2026-02-20 23:24:11 +07:00
parent 5b12ce7f00
commit 9ad8e64e85
5 changed files with 145 additions and 85 deletions
+2 -11
View File
@@ -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);
},
},
{
+12 -37
View File
@@ -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>
);
+11 -16
View File
@@ -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 ? (
+13 -12
View File
@@ -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 };
}
+107 -9
View File
@@ -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). */