feat(i18n): Added support for Chinese language pack

This commit is contained in:
lidong
2026-03-02 18:39:34 +08:00
parent bd389fda44
commit 7ffb8a4234
84 changed files with 4122 additions and 1281 deletions
@@ -12,6 +12,7 @@ import { MaskedInput } from '@/components/ui/masked-input';
import { Plus } from 'lucide-react';
import { isSensitiveKey } from './utils';
import type { Settings } from './types';
import { useTranslation } from 'react-i18next';
interface EnvEditorSectionProps {
currentSettings: Settings | undefined;
@@ -32,6 +33,7 @@ export function EnvEditorSection({
onEnvValueChange,
onAddEnvVar,
}: EnvEditorSectionProps) {
const { t } = useTranslation();
return (
<>
{/* Scrollable Environment Variables List */}
@@ -45,7 +47,7 @@ export function EnvEditorSection({
{key}
{isSensitiveKey(key) && (
<Badge variant="secondary" className="text-[10px] px-1 py-0 h-4">
sensitive
{t('envEditor.sensitive')}
</Badge>
)}
</Label>
@@ -67,10 +69,8 @@ export function EnvEditorSection({
</>
) : (
<div className="py-8 text-center text-muted-foreground bg-muted/30 rounded-lg border border-dashed text-sm">
<p>No environment variables configured.</p>
<p className="text-xs mt-1 opacity-70">
Add variables using the input below or edit the JSON directly.
</p>
<p>{t('envEditor.none')}</p>
<p className="text-xs mt-1 opacity-70">{t('envEditor.noneHint')}</p>
</div>
)}
</div>
@@ -79,18 +79,18 @@ export function EnvEditorSection({
{/* Fixed Add Input at Bottom */}
<div className="p-4 border-t bg-background shrink-0">
<Label className="text-xs font-medium text-muted-foreground">
Add Environment Variable
{t('envEditor.addVariable')}
</Label>
<div className="flex gap-2 mt-2">
<Input
placeholder="VARIABLE_NAME"
placeholder={t('envEditor.keyPlaceholder')}
value={newEnvKey}
onChange={(e) => onNewEnvKeyChange(e.target.value.toUpperCase())}
className="font-mono text-sm h-8 w-2/5"
onKeyDown={(e) => e.key === 'Enter' && newEnvKey.trim() && onAddEnvVar()}
/>
<Input
placeholder="value"
placeholder={t('envEditor.valuePlaceholder')}
value={newEnvValue}
onChange={(e) => onNewEnvValueChange(e.target.value)}
className="font-mono text-sm h-8 flex-1"
@@ -19,6 +19,7 @@ import { ChevronRight, Settings2, Plus } from 'lucide-react';
import { isOpenRouterProfile, extractTierMapping, applyTierMapping } from './utils';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import i18n from '@/lib/i18n';
import type { Settings, SettingsResponse } from './types';
import type { CliTarget } from '@/lib/api-client';
@@ -81,7 +82,7 @@ export function FriendlyUISection({
onEnvValueChange('ANTHROPIC_DEFAULT_HAIKU_MODEL', modelId);
}
// Show feedback toast
toast.success('Applied model to all tiers', { duration: 2000 });
toast.success(i18n.t('commonToast.appliedModelAllTiers'), { duration: 2000 });
};
// Handle tier mapping change
+4 -3
View File
@@ -16,6 +16,7 @@ import { FriendlyUISection } from './friendly-ui-section';
import { RawEditorSection } from './raw-editor-section';
import type { ProfileEditorProps, Settings, SettingsResponse } from './types';
import { api, type CliTarget } from '@/lib/api-client';
import i18n from '@/lib/i18n';
export function ProfileEditor({
profileName,
@@ -143,7 +144,7 @@ export function ProfileEditor({
queryClient.invalidateQueries({ queryKey: ['profiles'] });
setLocalEdits({});
setRawJsonEdits(null);
toast.success('Settings saved');
toast.success(i18n.t('commonToast.settingsSaved'));
},
onError: (error: Error) => {
if (error.message === 'CONFLICT') setConflictDialog(true);
@@ -158,12 +159,12 @@ export function ProfileEditor({
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['profiles'] });
toast.success('Default target updated');
toast.success(i18n.t('commonToast.defaultTargetUpdated'));
},
onError: (error: Error, target: CliTarget) => {
const targetLabel = target === 'droid' ? 'Factory Droid' : 'Claude Code';
const suffix = error.message.trim() ? `: ${error.message}` : '';
toast.error(`Failed to update default target to ${targetLabel}${suffix}`);
toast.error(i18n.t('commonToast.failedUpdateDefaultTarget', { target: targetLabel, suffix }));
},
});
@@ -3,6 +3,7 @@
* Displays profile information and usage commands
*/
import { useTranslation } from 'react-i18next';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Label } from '@/components/ui/label';
import { CopyButton } from '@/components/ui/copy-button';
@@ -17,6 +18,7 @@ interface InfoSectionProps {
}
export function InfoSection({ profileName, target, data }: InfoSectionProps) {
const { t } = useTranslation();
const isDroidTarget = target === 'droid';
return (
@@ -26,17 +28,21 @@ export function InfoSection({ profileName, target, data }: InfoSectionProps) {
<div>
<h3 className="text-sm font-medium flex items-center gap-2 mb-3">
<Info className="w-4 h-4" />
Profile Information
{t('profileEditor.profileInfo')}
</h3>
<div className="space-y-3 bg-card rounded-lg border p-4 shadow-sm">
{data && (
<>
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-center">
<span className="font-medium text-muted-foreground">Profile Name</span>
<span className="font-medium text-muted-foreground">
{t('profileEditor.profileName')}
</span>
<span className="font-mono">{data.profile}</span>
</div>
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-center">
<span className="font-medium text-muted-foreground">File Path</span>
<span className="font-medium text-muted-foreground">
{t('profileEditor.filePath')}
</span>
<div className="flex items-center gap-2 min-w-0">
<code className="bg-muted px-1.5 py-0.5 rounded text-xs break-all">
{data.path}
@@ -45,11 +51,15 @@ export function InfoSection({ profileName, target, data }: InfoSectionProps) {
</div>
</div>
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-center">
<span className="font-medium text-muted-foreground">Last Modified</span>
<span className="font-medium text-muted-foreground">
{t('profileEditor.lastModified')}
</span>
<span className="text-xs">{new Date(data.mtime).toLocaleString()}</span>
</div>
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-center">
<span className="font-medium text-muted-foreground">Default Target</span>
<span className="font-medium text-muted-foreground">
{t('profileEditor.defaultTarget')}
</span>
<span className="font-mono">{target}</span>
</div>
</>
@@ -59,10 +69,12 @@ export function InfoSection({ profileName, target, data }: InfoSectionProps) {
{/* Usage */}
<div>
<h3 className="text-sm font-medium mb-3">Quick Usage</h3>
<h3 className="text-sm font-medium mb-3">{t('profileEditor.quickUsage')}</h3>
<div className="space-y-3 bg-card rounded-lg border p-4 shadow-sm">
<div>
<Label className="text-xs text-muted-foreground">Run with profile</Label>
<Label className="text-xs text-muted-foreground">
{t('profileEditor.runWithProfile')}
</Label>
<div className="mt-1 flex gap-2">
<code className="flex-1 px-2 py-1.5 bg-muted rounded text-xs font-mono truncate">
ccs {profileName} "prompt"
@@ -72,7 +84,9 @@ export function InfoSection({ profileName, target, data }: InfoSectionProps) {
</div>
<div>
<Label className="text-xs text-muted-foreground">
{isDroidTarget ? 'Droid alias (explicit)' : 'Run on Droid'}
{isDroidTarget
? t('profileEditor.droidAliasExplicit')
: t('profileEditor.runOnDroid')}
</Label>
<div className="mt-1 flex gap-2">
<code className="flex-1 px-2 py-1.5 bg-muted rounded text-xs font-mono truncate">
@@ -93,7 +107,9 @@ export function InfoSection({ profileName, target, data }: InfoSectionProps) {
</div>
<div>
<Label className="text-xs text-muted-foreground">
{isDroidTarget ? 'Override to Claude' : 'Override to Claude (explicit)'}
{isDroidTarget
? t('profileEditor.overrideToClaude')
: t('profileEditor.overrideToClaudeExplicit')}
</Label>
<div className="mt-1 flex gap-2">
<code className="flex-1 px-2 py-1.5 bg-muted rounded text-xs font-mono truncate">
@@ -107,7 +123,9 @@ export function InfoSection({ profileName, target, data }: InfoSectionProps) {
</div>
</div>
<div>
<Label className="text-xs text-muted-foreground">Set as default</Label>
<Label className="text-xs text-muted-foreground">
{t('profileEditor.setAsDefault')}
</Label>
<div className="mt-1 flex gap-2">
<code className="flex-1 px-2 py-1.5 bg-muted rounded text-xs font-mono truncate">
ccs default {profileName}
@@ -12,6 +12,7 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/component
import { useOpenRouterCatalog } from '@/hooks/use-openrouter-models';
import { suggestTierMappings } from '@/lib/openrouter-utils';
import { cn } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
export interface TierMapping {
opus?: string;
@@ -32,6 +33,7 @@ export function ModelTierMapping({
onChange,
className,
}: ModelTierMappingProps) {
const { t } = useTranslation();
const { models } = useOpenRouterCatalog();
const suggestions = useMemo(() => {
@@ -53,18 +55,18 @@ export function ModelTierMapping({
<Collapsible className={cn('group', className)}>
<CollapsibleTrigger className="flex items-center gap-2 text-sm font-medium hover:underline">
<ChevronRight className="h-4 w-4 transition-transform group-data-[state=open]:rotate-90" />
Model Tier Mapping
<span className="text-muted-foreground font-normal">(Advanced)</span>
{t('modelTierMapping.title')}
<span className="text-muted-foreground font-normal">
({t('modelTierMapping.advanced')})
</span>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-3 pt-3">
<p className="text-muted-foreground text-sm">
Configure different models for Claude Code&apos;s opus/sonnet/haiku tiers.
</p>
<p className="text-muted-foreground text-sm">{t('modelTierMapping.description')}</p>
{hasSuggestions && (
<Button type="button" variant="outline" size="sm" onClick={handleAutoSuggest}>
<Wand2 className="mr-1 h-4 w-4" />
Auto-suggest based on {selectedModel?.split('/')[0]}
{t('modelTierMapping.autoSuggest', { provider: selectedModel?.split('/')[0] })}
</Button>
)}
@@ -77,7 +79,7 @@ export function ModelTierMapping({
id="tier-opus"
value={value.opus ?? ''}
onChange={(e) => updateTier('opus', e.target.value)}
placeholder="e.g., anthropic/claude-opus-4"
placeholder={t('modelTierMapping.opusPlaceholder')}
/>
</div>
<div className="grid grid-cols-[80px_1fr] items-center gap-2">
@@ -88,7 +90,7 @@ export function ModelTierMapping({
id="tier-sonnet"
value={value.sonnet ?? ''}
onChange={(e) => updateTier('sonnet', e.target.value)}
placeholder="e.g., anthropic/claude-sonnet-4"
placeholder={t('modelTierMapping.sonnetPlaceholder')}
/>
</div>
<div className="grid grid-cols-[80px_1fr] items-center gap-2">
@@ -99,15 +101,12 @@ export function ModelTierMapping({
id="tier-haiku"
value={value.haiku ?? ''}
onChange={(e) => updateTier('haiku', e.target.value)}
placeholder="e.g., anthropic/claude-3.5-haiku"
placeholder={t('modelTierMapping.haikuPlaceholder')}
/>
</div>
</div>
<p className="text-muted-foreground text-xs">
These set ANTHROPIC_DEFAULT_OPUS_MODEL, ANTHROPIC_DEFAULT_SONNET_MODEL,
ANTHROPIC_DEFAULT_HAIKU_MODEL.
</p>
<p className="text-muted-foreground text-xs">{t('modelTierMapping.footer')}</p>
</CollapsibleContent>
</Collapsible>
);
@@ -9,6 +9,7 @@ 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';
import { useTranslation } from 'react-i18next';
interface OpenRouterQuickStartProps {
onOpenRouterClick: () => void;
@@ -19,6 +20,7 @@ export function OpenRouterQuickStart({
onOpenRouterClick,
onCustomClick,
}: OpenRouterQuickStartProps) {
const { t } = useTranslation();
const { modelCount, isLoading } = useOpenRouterReady();
return (
@@ -35,13 +37,14 @@ export function OpenRouterQuickStart({
variant="secondary"
className="bg-accent/10 text-accent dark:bg-accent/20 dark:text-accent-foreground"
>
Recommended
{t('openrouterQuickStart.recommended')}
</Badge>
</div>
<CardTitle className="text-xl">Start with OpenRouter</CardTitle>
<CardTitle className="text-xl">{t('openrouterQuickStart.title')}</CardTitle>
<CardDescription className="text-base">
Access {isLoading ? '300+' : `${modelCount}+`} models from OpenAI, Anthropic, Google,
Meta and more - all through one API.
{t('openrouterQuickStart.description', {
modelCountLabel: isLoading ? '300+' : `${modelCount}+`,
})}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
@@ -49,11 +52,11 @@ export function OpenRouterQuickStart({
<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-accent" />
<span>One API, all providers</span>
<span>{t('openrouterQuickStart.featureOneApi')}</span>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<Sparkles className="w-4 h-4 text-accent" />
<span>Model tier mapping</span>
<span>{t('openrouterQuickStart.featureTierMapping')}</span>
</div>
</div>
@@ -62,12 +65,12 @@ export function OpenRouterQuickStart({
className="w-full bg-accent hover:bg-accent/90 text-white"
size="lg"
>
Create OpenRouter Profile
{t('openrouterQuickStart.createOpenRouterProfile')}
<ArrowRight className="w-4 h-4 ml-2" />
</Button>
<p className="text-xs text-center text-muted-foreground">
Get your API key at{' '}
{t('openrouterQuickStart.getApiKeyAt')}{' '}
<a
href="https://openrouter.ai/keys"
target="_blank"
@@ -84,13 +87,13 @@ export function OpenRouterQuickStart({
{/* Divider */}
<div className="flex items-center gap-4">
<Separator className="flex-1" />
<span className="text-xs text-muted-foreground">or</span>
<span className="text-xs text-muted-foreground">{t('openrouterQuickStart.or')}</span>
<Separator className="flex-1" />
</div>
{/* Custom Option */}
<Button variant="outline" onClick={onCustomClick} className="w-full">
Create Custom API Profile
{t('openrouterQuickStart.createCustomProfile')}
</Button>
</div>
</div>
@@ -32,6 +32,7 @@ import { Badge } from '@/components/ui/badge';
import { useCreateProfile } from '@/hooks/use-profiles';
import { useOpenRouterCatalog } from '@/hooks/use-openrouter-models';
import { Loader2, Plus, AlertTriangle, Info, Eye, EyeOff, Settings2, Sparkles } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import {
@@ -49,6 +50,7 @@ import {
} from '@/lib/openrouter-utils';
import type { CategorizedModel } from '@/lib/openrouter-types';
import type { CliTarget } from '@/lib/api-client';
import i18n from '@/lib/i18n';
const schema = z.object({
name: z
@@ -104,6 +106,7 @@ export function ProfileCreateDialog({
onSuccess,
initialMode = 'openrouter',
}: ProfileCreateDialogProps) {
const { t } = useTranslation();
const createMutation = useCreateProfile();
const [activeTab, setActiveTab] = useState('basic');
const [urlWarning, setUrlWarning] = useState<string | null>(null);
@@ -242,7 +245,7 @@ export function ProfileCreateDialog({
const onSubmit = async (data: FormData) => {
// Validate API key - required unless preset has requiresApiKey: false
if (currentPreset?.requiresApiKey !== false && !data.apiKey) {
toast.error('API key is required');
toast.error(i18n.t('commonToast.apiKeyRequired'));
return;
}
// Use user-provided baseUrl (allows customization of preset URLs)
@@ -290,7 +293,9 @@ export function ProfileCreateDialog({
<div className="px-6 py-3 border-b bg-muted/30 space-y-2">
{/* Main Options: OpenRouter + Custom */}
<div>
<Label className="text-xs text-muted-foreground mb-1.5 block">Provider</Label>
<Label className="text-xs text-muted-foreground mb-1.5 block">
{t('profileEditor.provider')}
</Label>
<div className="flex gap-2">
{RECOMMENDED_PRESETS.map((preset) => (
<CompactPresetCard
@@ -312,7 +317,7 @@ export function ProfileCreateDialog({
)}
>
<Settings2 className="w-4 h-4" />
<span>Custom</span>
<span>{t('profileEditor.custom')}</span>
</button>
</div>
</div>
+30 -18
View File
@@ -16,6 +16,7 @@ import { Label } from '@/components/ui/label';
import { useCreateProfile, useUpdateProfile } from '@/hooks/use-profiles';
import type { Profile } from '@/lib/api-client';
import { ChevronDown, ChevronRight } from 'lucide-react';
import { useTranslation } from 'react-i18next';
const DEFAULT_MODEL = 'claude-sonnet-4-5-20250929';
@@ -41,6 +42,7 @@ interface ProfileDialogProps {
}
export function ProfileDialog({ open, onClose, profile }: ProfileDialogProps) {
const { t } = useTranslation();
const createMutation = useCreateProfile();
const updateMutation = useUpdateProfile();
const [showModelMapping, setShowModelMapping] = useState(false);
@@ -115,34 +117,45 @@ export function ProfileDialog({ open, onClose, profile }: ProfileDialogProps) {
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{profile ? 'Edit Profile' : 'Create API Profile'}</DialogTitle>
<DialogTitle>
{profile ? t('profileDialog.editTitle') : t('profileDialog.createTitle')}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div>
<Label htmlFor="name">Name</Label>
<Input id="name" {...register('name')} placeholder="my-api" disabled={!!profile} />
<Label htmlFor="name">{t('profileDialog.name')}</Label>
<Input
id="name"
{...register('name')}
placeholder={t('profileDialog.namePlaceholder')}
disabled={!!profile}
/>
{errors.name && <span className="text-xs text-red-500">{errors.name.message}</span>}
</div>
<div>
<Label htmlFor="baseUrl">Base URL</Label>
<Input id="baseUrl" {...register('baseUrl')} placeholder="https://api.example.com" />
<Label htmlFor="baseUrl">{t('profileDialog.baseUrl')}</Label>
<Input
id="baseUrl"
{...register('baseUrl')}
placeholder={t('profileDialog.baseUrlPlaceholder')}
/>
{errors.baseUrl && (
<span className="text-xs text-red-500">{errors.baseUrl.message}</span>
)}
</div>
<div>
<Label htmlFor="apiKey">API Key</Label>
<Label htmlFor="apiKey">{t('profileDialog.apiKey')}</Label>
<Input id="apiKey" type="password" {...register('apiKey')} />
{errors.apiKey && <span className="text-xs text-red-500">{errors.apiKey.message}</span>}
</div>
<div>
<Label htmlFor="model">Default Model (ANTHROPIC_MODEL)</Label>
<Label htmlFor="model">{t('profileDialog.defaultModel')}</Label>
<Input id="model" {...register('model')} placeholder={DEFAULT_MODEL} />
<p className="text-xs text-muted-foreground mt-1">
Leave blank to use: {DEFAULT_MODEL}
{t('profileDialog.defaultModelHint', { model: DEFAULT_MODEL })}
</p>
</div>
@@ -153,7 +166,7 @@ export function ProfileDialog({ open, onClose, profile }: ProfileDialogProps) {
className="w-full flex items-center justify-between p-3 text-sm font-medium hover:bg-muted/50 transition-colors"
onClick={() => setShowModelMapping(!showModelMapping)}
>
<span>Model Mapping (Opus/Sonnet/Haiku)</span>
<span>{t('profileDialog.modelMappingTitle')}</span>
{showModelMapping ? (
<ChevronDown className="w-4 h-4" />
) : (
@@ -164,13 +177,12 @@ export function ProfileDialog({ open, onClose, profile }: ProfileDialogProps) {
{showModelMapping && (
<div className="p-3 pt-0 space-y-3 border-t">
<p className="text-xs text-muted-foreground">
Configure different model IDs for each tier. Useful for API proxies that route
different model types to different backends.
{t('profileDialog.modelMappingDesc')}
</p>
<div>
<Label htmlFor="opusModel" className="text-xs">
Opus Model (ANTHROPIC_DEFAULT_OPUS_MODEL)
{t('profileDialog.opusModel')}
</Label>
<Input
id="opusModel"
@@ -182,7 +194,7 @@ export function ProfileDialog({ open, onClose, profile }: ProfileDialogProps) {
<div>
<Label htmlFor="sonnetModel" className="text-xs">
Sonnet Model (ANTHROPIC_DEFAULT_SONNET_MODEL)
{t('profileDialog.sonnetModel')}
</Label>
<Input
id="sonnetModel"
@@ -194,7 +206,7 @@ export function ProfileDialog({ open, onClose, profile }: ProfileDialogProps) {
<div>
<Label htmlFor="haikuModel" className="text-xs">
Haiku Model (ANTHROPIC_DEFAULT_HAIKU_MODEL)
{t('profileDialog.haikuModel')}
</Label>
<Input
id="haikuModel"
@@ -209,14 +221,14 @@ export function ProfileDialog({ open, onClose, profile }: ProfileDialogProps) {
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={onClose}>
Cancel
{t('profileDialog.cancel')}
</Button>
<Button type="submit" disabled={createMutation.isPending || updateMutation.isPending}>
{createMutation.isPending || updateMutation.isPending
? 'Saving...'
? t('profileDialog.saving')
: profile
? 'Update'
: 'Create'}
? t('profileDialog.update')
: t('profileDialog.create')}
</Button>
</div>
</form>