Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions scripts/runtimeText.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { describe, expect, test } from 'bun:test';
import { localizeRuntimeText } from '../src/i18n/runtimeText';

describe('localizeRuntimeText', () => {
test('translates known core status messages for English', () => {
expect(localizeRuntimeText('en', '未安装 CPA 内核,请先安装最新版'))
.toBe('CPA core is not installed. Install the latest version first.');
expect(localizeRuntimeText('en', 'CPA 内核正在运行')).toBe('CPA core is running');
});

test('translates port errors while preserving the port number', () => {
expect(localizeRuntimeText('en', '端口 8317 已被其他程序占用,请更换端口后重试'))
.toBe('Port 8317 is already in use. Choose another port and try again.');
});

test('never returns Han text for an unknown runtime error in English', () => {
expect(localizeRuntimeText('en', '未知的中文运行时错误')).toBe('The operation failed.');
});

test('leaves non-Chinese technical errors unchanged', () => {
expect(localizeRuntimeText('en', 'Connection refused: http://127.0.0.1:8317'))
.toBe('Connection refused: http://127.0.0.1:8317');
});
});
42 changes: 40 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,16 @@ import {
Languages,
LogIn,
MessageCircle,
Monitor,
Moon,
Network,
ServerCog,
Settings,
Sun,
} from 'lucide-react';
import appLogo from './assets/logo.jpg';
import { CoreRuntimeProvider, useCoreRuntime } from './coreRuntime';
import { useTheme, type ThemeMode } from './theme';
import { ConfigPanelPage } from './pages/ConfigPanel';
import { ApiAccessPage } from './pages/ApiAccessPage';
import { AuthFileManagementPage } from './pages/AuthFileManagementPage';
Expand Down Expand Up @@ -89,6 +93,16 @@ const pages = [
},
] as const;

const themeOptions: ReadonlyArray<{
value: ThemeMode;
labelKey: 'app.theme.system' | 'app.theme.light' | 'app.theme.dark';
icon: typeof Monitor;
}> = [
{ value: 'system', labelKey: 'app.theme.system', icon: Monitor },
{ value: 'light', labelKey: 'app.theme.light', icon: Sun },
{ value: 'dark', labelKey: 'app.theme.dark', icon: Moon },
];

type PageId = (typeof pages)[number]['id'];
type WindowsCloseAction = 'exit' | 'minimize-to-tray';

Expand All @@ -106,7 +120,8 @@ function App() {
}

function AppContent() {
const { locale, setLocale, t } = useI18n();
const { locale, setLocale, t, localizeText } = useI18n();
const { mode, setMode } = useTheme();
const [active, setActive] = useState<PageId>('kernel');
const [languageMenuOpen, setLanguageMenuOpen] = useState(false);
const [windowsClosePrompt, setWindowsClosePrompt] = useState<WindowsClosePrompt | null>(null);
Expand Down Expand Up @@ -263,6 +278,29 @@ function AppContent() {
</nav>

<div className="sidebar-bottom">
<div className="sidebar-theme" aria-label={t('app.theme')}>
<span className="sidebar-theme-label">{t('app.theme')}</span>
<div className="sidebar-theme-options" role="group" aria-label={t('app.theme')}>
{themeOptions.map((option) => {
const Icon = option.icon;
const selected = option.value === mode;
return (
<button
key={option.value}
type="button"
className={selected ? 'selected' : ''}
aria-pressed={selected}
aria-label={t(option.labelKey)}
title={t(option.labelKey)}
onClick={() => setMode(option.value)}
>
<Icon size={14} aria-hidden="true" />
<span>{t(option.labelKey)}</span>
</button>
);
})}
</div>
</div>
<div ref={languageMenuRef} className="sidebar-language">
<button
ref={languageButtonRef}
Expand Down Expand Up @@ -360,7 +398,7 @@ function AppContent() {
</p>
{windowsClosePrompt.error ? (
<div className="close-dialog-error" role="alert">
{windowsClosePrompt.error}
{localizeText(windowsClosePrompt.error)}
</div>
) : null}
<div className="close-dialog-actions">
Expand Down
4 changes: 2 additions & 2 deletions src/AppErrorBoundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,12 @@ export class AppErrorBoundary extends Component<Props, State> {
}

function AppErrorFallback({ error }: { error: Error }) {
const { t } = useI18n();
const { t, localizeText } = useI18n();
return (
<main className="app-error-boundary">
<section className="empty-state">
<strong>{t('error.render.title')}</strong>
<span>{error.message || t('error.unknown')}</span>
<span>{localizeText(error.message) || t('error.unknown')}</span>
<button type="button" className="primary-button" onClick={() => window.location.reload()}>
{t('error.reload')}
</button>
Expand Down
19 changes: 16 additions & 3 deletions src/i18n/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { en, ja, zhCN, zhTW, type MessageKey, type MessageVariables } from './resources';
import { localizeRuntimeText } from './runtimeText';

export type AppLocale = 'zh-CN' | 'zh-TW' | 'ja' | 'en';

Expand Down Expand Up @@ -55,14 +56,22 @@ function interpolate(template: string, variables?: MessageVariables): string {
);
}

function fallbackResource(locale: AppLocale): Record<MessageKey, string> {
if (locale === 'zh-TW') return zhTW;
if (locale === 'zh-CN') return zhCN;
return en;
}

export function translate(locale: AppLocale, key: MessageKey, variables?: MessageVariables): string {
return interpolate(resources[locale][key] ?? zhCN[key], variables);
const message = resources[locale][key] ?? fallbackResource(locale)[key] ?? key;
return interpolate(message, variables);
}

type I18nContextValue = {
locale: AppLocale;
setLocale: (locale: AppLocale) => void;
t: (key: MessageKey, variables?: MessageVariables) => string;
localizeText: (value: string | null | undefined) => string;
formatNumber: (value: number, options?: Intl.NumberFormatOptions) => string;
formatDate: (value: Date | number | string, options?: Intl.DateTimeFormatOptions) => string;
};
Expand Down Expand Up @@ -94,6 +103,10 @@ export function I18nProvider({ children }: { children: ReactNode }) {
(key: MessageKey, variables?: MessageVariables) => translate(locale, key, variables),
[locale],
);
const localizeText = useCallback(
(value: string | null | undefined) => localizeRuntimeText(locale, value),
[locale],
);
const formatNumber = useCallback(
(value: number, options?: Intl.NumberFormatOptions) =>
new Intl.NumberFormat(locale, options).format(value),
Expand All @@ -108,8 +121,8 @@ export function I18nProvider({ children }: { children: ReactNode }) {
);

const context = useMemo<I18nContextValue>(
() => ({ locale, setLocale, t, formatNumber, formatDate }),
[formatDate, formatNumber, locale, setLocale, t],
() => ({ locale, setLocale, t, localizeText, formatNumber, formatDate }),
[formatDate, formatNumber, localizeText, locale, setLocale, t],
);

return <I18nContext.Provider value={context}>{children}</I18nContext.Provider>;
Expand Down
4 changes: 4 additions & 0 deletions src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import type { MessageKey } from './resources';

export const jaOverrides = {
'app.language': '言語',
'app.theme': 'テーマ',
'app.theme.system': 'システム',
'app.theme.light': 'ライト',
'app.theme.dark': 'ダーク',
'app.desktopConsole': 'デスクトップコンソール',
'app.navigation': 'メインナビゲーション',
'app.nav.kernel': 'コア',
Expand Down
8 changes: 8 additions & 0 deletions src/i18n/resources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ export type MessageVariables = Record<string, string | number>;

export const zhCN = {
'app.language': '语言',
'app.theme': '主题',
'app.theme.system': '跟随系统',
'app.theme.light': '浅色',
'app.theme.dark': '深色',
'app.desktopConsole': '桌面控制台',
'app.navigation': '主导航',
'app.nav.kernel': '内核',
Expand Down Expand Up @@ -678,6 +682,10 @@ export type MessageKey = keyof typeof zhCN;

export const en: Record<MessageKey, string> = {
'app.language': 'Language',
'app.theme': 'Theme',
'app.theme.system': 'System',
'app.theme.light': 'Light',
'app.theme.dark': 'Dark',
'app.desktopConsole': 'Desktop Console',
'app.navigation': 'Main navigation',
'app.nav.kernel': 'Core',
Expand Down
65 changes: 65 additions & 0 deletions src/i18n/runtimeText.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
export type RuntimeLocale = 'zh-CN' | 'zh-TW' | 'ja' | 'en';

const HAN_RE = /[㐀-鿿]/u;

const localizedMessages: Record<Exclude<RuntimeLocale, 'zh-CN'>, Record<string, string>> = {
en: {
'未安装 CPA 内核,请先安装最新版': 'CPA core is not installed. Install the latest version first.',
'CPA 内核正在运行': 'CPA core is running',
'CPA 内核已安装,当前未运行': 'CPA core is installed but not running',
'CPA 内核已经在运行': 'CPA core is already running',
'等待内核启动': 'Waiting for the CPA core to start',
'使用记录采集中': 'Collecting usage records',
},
'zh-TW': {
'未安装 CPA 内核,请先安装最新版': '尚未安裝 CPA 核心,請先安裝最新版本。',
'CPA 内核正在运行': 'CPA 核心正在執行',
'CPA 内核已安装,当前未运行': 'CPA 核心已安裝,目前未執行',
'CPA 内核已经在运行': 'CPA 核心已在執行',
'等待内核启动': '等待 CPA 核心啟動',
'使用记录采集中': '正在收集使用記錄',
},
ja: {
'未安装 CPA 内核,请先安装最新版': 'CPA コアがインストールされていません。最新版を先にインストールしてください。',
'CPA 内核正在运行': 'CPA コアは実行中です',
'CPA 内核已安装,当前未运行': 'CPA コアはインストール済みですが、現在は停止しています',
'CPA 内核已经在运行': 'CPA コアはすでに実行中です',
'等待内核启动': 'CPA コアの起動を待機中',
'使用记录采集中': '使用記録を収集中',
},
};

const genericFailure: Record<Exclude<RuntimeLocale, 'zh-CN'>, string> = {
en: 'The operation failed.',
'zh-TW': '操作失敗。',
ja: '操作に失敗しました。',
};

function localizePortError(locale: Exclude<RuntimeLocale, 'zh-CN'>, text: string): string | null {
const match = text.match(/^端口\s+(\d+)\s+已被其他程序占用/);
if (!match) return null;
if (locale === 'en') return `Port ${match[1]} is already in use. Choose another port and try again.`;
if (locale === 'ja') return `ポート ${match[1]} はすでに使用されています。別のポートを選択して再試行してください。`;
return `連接埠 ${match[1]} 已被其他程式使用。請選擇其他連接埠後重試。`;
}

function localizeInstallMessage(locale: Exclude<RuntimeLocale, 'zh-CN'>, text: string): string | null {
const completed = text.match(/^(.+)\s+安装完成$/);
if (completed) {
if (locale === 'en') return `${completed[1]} installation completed.`;
if (locale === 'ja') return `${completed[1]} のインストールが完了しました。`;
return `${completed[1]} 安裝完成。`;
}
return null;
}

export function localizeRuntimeText(locale: RuntimeLocale, text: string | null | undefined): string {
if (!text || locale === 'zh-CN' || !HAN_RE.test(text)) return text ?? '';

const exact = localizedMessages[locale][text];
if (exact) return exact;

return localizePortError(locale, text)
?? localizeInstallMessage(locale, text)
?? genericFailure[locale];
}
4 changes: 4 additions & 0 deletions src/i18n/traditional.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
const phraseReplacements: ReadonlyArray<readonly [string, string]> = [
['繁体中文', '繁體中文'],
['简体中文', '簡體中文'],
['跟随系统', '跟隨系統'],
['主题', '主題'],
['浅色', '淺色'],
['深色', '深色'],
['智能体', '智慧代理'],
['使用记录', '使用記錄'],
['认证文件', '認證檔案'],
Expand Down
9 changes: 6 additions & 3 deletions src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,17 @@ import { createRoot } from 'react-dom/client';
import { AppErrorBoundary } from './AppErrorBoundary';
import App from './App';
import { I18nProvider } from './i18n';
import { ThemeProvider } from './theme';
import './styles.css';

createRoot(document.getElementById('root')!).render(
<StrictMode>
<I18nProvider>
<AppErrorBoundary>
<App />
</AppErrorBoundary>
<ThemeProvider>
<AppErrorBoundary>
<App />
</AppErrorBoundary>
</ThemeProvider>
</I18nProvider>
</StrictMode>
);
14 changes: 7 additions & 7 deletions src/pages/AgentsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,7 @@ function AgentModelPicker({
onChange,
onRefresh,
}: AgentModelPickerProps) {
const { t } = useI18n();
const { t, localizeText } = useI18n();
const [open, setOpen] = useState(false);
const [search, setSearch] = useState('');
const [activeIndex, setActiveIndex] = useState(0);
Expand Down Expand Up @@ -414,7 +414,7 @@ function AgentModelPicker({
{loading && models.length === 0 ? (
<div className="agent-model-empty"><LoaderCircle size={18} className="spin" />{t('agents.model.fetching')}</div>
) : error && models.length === 0 ? (
<div className="agent-model-empty error"><strong>{t('agents.model.loadFailed')}</strong><span>{error}</span></div>
<div className="agent-model-empty error"><strong>{t('agents.model.loadFailed')}</strong><span>{localizeText(error)}</span></div>
) : choices.length === 0 ? (
<div className="agent-model-empty">
<strong>{search.trim() ? t('agents.model.noMatch') : t('agents.model.unavailable')}</strong>
Expand Down Expand Up @@ -452,7 +452,7 @@ function AgentModelPicker({
}

export function AgentsPage() {
const { t } = useI18n();
const { t, localizeText } = useI18n();
const [selected, setSelected] = useState<AgentClientId>(readSelectedAgentClient);
const [statuses, setStatuses] = useState<AgentConfigStatus[]>([]);
const [models, setModels] = useState<ModelOption[]>([]);
Expand Down Expand Up @@ -696,8 +696,8 @@ export function AgentsPage() {
</header>

<div className="agent-feedback-slot" aria-live="polite">
{error ? <div className="management-alert error">{error}</div> : null}
{!error && notice ? <div className="management-alert success">{notice}</div> : null}
{error ? <div className="management-alert error">{localizeText(error)}</div> : null}
{!error && notice ? <div className="management-alert success">{localizeText(notice)}</div> : null}
</div>

<div className="agent-workbench">
Expand Down Expand Up @@ -739,9 +739,9 @@ export function AgentsPage() {
</div>

<div className="agent-config-message-slot">
{activeStatus?.error ? <div className="management-alert error">{activeStatus.error}</div> : null}
{activeStatus?.error ? <div className="management-alert error">{localizeText(activeStatus.error)}</div> : null}
{activeStatus?.warnings.length && !activeStatus.error ? (
<div className="agent-warning-line">{activeStatus.warnings.join('')}</div>
<div className="agent-warning-line">{activeStatus.warnings.map((warning) => localizeText(warning)).join(' · ')}</div>
) : null}
</div>

Expand Down
Loading