feat: added i18n support; added Simplified Chinese support

This commit is contained in:
Xiaohan-Tian
2026-05-30 20:02:55 -07:00
parent c17d169dc6
commit 957f6db950
58 changed files with 3354 additions and 520 deletions
@@ -59,6 +59,7 @@ describe('processUserMessage slash commands', () => {
configState.set('general.claude_openrouter.api_key', '');
configState.set('general.openai_compatible.base_url', '');
configState.set('general.openai_compatible.model', '');
configState.set('general.language', 'en_us');
configManagerMock.getIsInitialized.mockReturnValue(true);
configManagerMock.initialize.mockClear();
@@ -99,6 +100,17 @@ describe('processUserMessage slash commands', () => {
expect(result.pseudoAssistantResponse).toContain('welcome_local_llm.md');
});
it('uses the localized welcome asset when zh-CN is selected', async () => {
configState.set('general.language', 'zh_cn');
configState.set('general.llm_provider', 'local_browser');
const result = await processUserMessage('/welcome');
expect(fetch).toHaveBeenCalledWith(expect.stringContaining('chat/welcome_local_llm-zh_cn.md'));
expect(result.metadata).toMatchObject({ command: 'welcome', variant: 'local' });
expect(result.pseudoAssistantResponse).toContain('welcome_local_llm-zh_cn.md');
});
it('uses the new-user welcome for non-local providers without required config', async () => {
configState.set('general.llm_provider', 'openai');
configState.set('general.openai.api_key', '');
@@ -132,6 +144,59 @@ describe('processUserMessage slash commands', () => {
expect(message?.content).toContain('welcome_local_llm.md');
});
it('uses localized welcome assets for addWelcomeMessage under auto + Chinese locale', async () => {
configState.set('general.language', 'auto');
vi.stubGlobal('navigator', {
languages: ['zh-CN', 'en-US'],
language: 'zh-CN',
});
const message = await addWelcomeMessage();
expect(fetch).toHaveBeenCalledWith(expect.stringContaining('chat/welcome_local_llm-zh_cn.md'));
expect(message?.content).toContain('welcome_local_llm-zh_cn.md');
});
it('fetches the localized help guide for /help under zh-CN', async () => {
configState.set('general.language', 'zh_cn');
const result = await processUserMessage('/help');
expect(fetch).toHaveBeenCalledWith(expect.stringContaining('chat/help-zh_cn.md'));
expect(result).toMatchObject({
displayUserMessage: false,
sendToLLM: false,
finalMessageForLLM: null,
metadata: { command: 'help' },
});
expect(result.pseudoAssistantResponse).toContain('chat/help-zh_cn.md');
});
it('falls back to the English help guide when the localized file is missing', async () => {
configState.set('general.language', 'zh_cn');
vi.stubGlobal('fetch', vi.fn(async (input: string | URL | Request) => {
const url = String(input);
if (url.includes('chat/help-zh_cn.md')) {
return {
ok: false,
status: 404,
text: async () => '',
};
}
return {
ok: true,
status: 200,
text: async () => `content:${url}`,
};
}));
const result = await processUserMessage('/help');
expect(fetch).toHaveBeenNthCalledWith(1, expect.stringContaining('chat/help-zh_cn.md'));
expect(fetch).toHaveBeenNthCalledWith(2, expect.stringContaining('chat/help.md'));
expect(result.pseudoAssistantResponse).toContain('chat/help.md');
});
it('fetches the hotkeys guide for /hotkeys', async () => {
const result = await processUserMessage('/hotkeys');
@@ -145,6 +210,15 @@ describe('processUserMessage slash commands', () => {
expect(result.pseudoAssistantResponse).toContain('chat/hotkeys.md');
});
it('fetches the localized hotkeys guide for /hotkeys under zh-CN', async () => {
configState.set('general.language', 'zh_cn');
const result = await processUserMessage('/hotkeys');
expect(fetch).toHaveBeenCalledWith(expect.stringContaining('chat/hotkeys-zh_cn.md'));
expect(result.pseudoAssistantResponse).toContain('chat/hotkeys-zh_cn.md');
});
it('supports /hotkey as an alias of /hotkeys', async () => {
const result = await processUserMessage('/hotkey');
+56 -16
View File
@@ -3,6 +3,8 @@ import { useProjectStore } from '../../stores/projectStore';
import { ConfigManager } from '../../core/config/ConfigManager';
import { SystemPrompts } from '../../agent/core/SystemPrompts';
import { detectLocalLLMRuntimeSupport, LOCAL_LLM_PROVIDER_KEY } from '../localLLMConfig';
import { normalizeLanguageSetting, resolveLanguageSetting } from '../../i18n/locale';
import type { ResolvedLocaleCode } from '../../i18n/types';
export interface UserMessageFilterResult {
// Whether to render the user message bubble (div.message-user)
@@ -59,6 +61,48 @@ function getWelcomeUrl(variant: 'local' | 'new' | 'again'): string {
}
}
function resolveCurrentCommandLocale(configManager: ConfigManager): ResolvedLocaleCode {
const languageSetting = normalizeLanguageSetting(configManager.get('general.language'));
return resolveLanguageSetting(languageSetting);
}
function resolveLocalizedChatMarkdownUrl(baseFileName: string, locale: ResolvedLocaleCode): string {
if (locale === 'en_us') {
return `${import.meta.env.BASE_URL}chat/${baseFileName}`;
}
const extensionIndex = baseFileName.lastIndexOf('.');
const localizedFileName = extensionIndex >= 0
? `${baseFileName.slice(0, extensionIndex)}-${locale}${baseFileName.slice(extensionIndex)}`
: `${baseFileName}-${locale}`;
return `${import.meta.env.BASE_URL}chat/${localizedFileName}`;
}
async function fetchLocalizedChatMarkdown(
configManager: ConfigManager,
baseFileName: string,
): Promise<string> {
const locale = resolveCurrentCommandLocale(configManager);
const localizedUrl = resolveLocalizedChatMarkdownUrl(baseFileName, locale);
const fallbackUrl = `${import.meta.env.BASE_URL}chat/${baseFileName}`;
const urlsToTry = locale === 'en_us' || localizedUrl === fallbackUrl
? [fallbackUrl]
: [localizedUrl, fallbackUrl];
let lastStatus = 'unknown';
for (const url of urlsToTry) {
const resp = await fetch(url);
if (resp.ok) {
return await resp.text();
}
lastStatus = String(resp.status);
}
throw new Error(`Failed to fetch chat markdown ${baseFileName}: ${lastStatus}`);
}
/**
* Process a user message before it is displayed or sent to the LLM.
* Handles slash-commands and returns a structured decision.
@@ -96,12 +140,8 @@ export async function processUserMessage(originalMessage: string): Promise<UserM
}
const variant = getWelcomeVariant(configManager);
const url = getWelcomeUrl(variant);
const resp = await fetch(url);
if (!resp.ok) {
throw new Error(`Failed to fetch ${url}: ${resp.status}`);
}
const md = await resp.text();
const baseFileName = getWelcomeUrl(variant).split('/').pop() ?? 'welcome_new.md';
const md = await fetchLocalizedChatMarkdown(configManager, baseFileName);
return {
displayUserMessage: false,
sendToLLM: false,
@@ -123,12 +163,12 @@ export async function processUserMessage(originalMessage: string): Promise<UserM
case '/help': {
try {
const url = `${import.meta.env.BASE_URL}chat/help.md`;
const resp = await fetch(url);
if (!resp.ok) {
throw new Error(`Failed to fetch ${url}: ${resp.status}`);
const configManager = ConfigManager.instance();
if (!configManager.getIsInitialized()) {
await configManager.initialize();
}
const md = await resp.text();
const md = await fetchLocalizedChatMarkdown(configManager, 'help.md');
return {
displayUserMessage: false,
sendToLLM: false,
@@ -151,12 +191,12 @@ export async function processUserMessage(originalMessage: string): Promise<UserM
case '/hotkeys':
case '/hotkey': {
try {
const url = `${import.meta.env.BASE_URL}chat/hotkeys.md`;
const resp = await fetch(url);
if (!resp.ok) {
throw new Error(`Failed to fetch ${url}: ${resp.status}`);
const configManager = ConfigManager.instance();
if (!configManager.getIsInitialized()) {
await configManager.initialize();
}
const md = await resp.text();
const md = await fetchLocalizedChatMarkdown(configManager, 'hotkeys.md');
return {
displayUserMessage: false,
sendToLLM: false,