feat(cli): support LLM output language configuration in Settings Dialog

This commit is contained in:
tanzhenxin 2026-01-20 17:00:19 +08:00
parent 0c960add8d
commit 4ae8584c81
15 changed files with 1122 additions and 474 deletions

View file

@ -0,0 +1,378 @@
/**
* @license
* Copyright 2025 Qwen team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import * as fs from 'node:fs';
// Mock fs module
vi.mock('node:fs', () => ({
existsSync: vi.fn(),
mkdirSync: vi.fn(),
writeFileSync: vi.fn(),
readFileSync: vi.fn(),
}));
// Mock i18n module
vi.mock('../i18n/index.js', () => ({
detectSystemLanguage: vi.fn(),
getLanguageNameFromLocale: vi.fn((locale: string) => {
const map: Record<string, string> = {
en: 'English',
zh: 'Chinese',
ru: 'Russian',
de: 'German',
ja: 'Japanese',
ko: 'Korean',
fr: 'French',
es: 'Spanish',
};
return map[locale] || 'English';
}),
}));
// Mock @qwen-code/qwen-code-core
vi.mock('@qwen-code/qwen-code-core', () => ({
Storage: {
getGlobalQwenDir: vi.fn(() => '/mock/home/.qwen'),
},
}));
import * as i18n from '../i18n/index.js';
import {
OUTPUT_LANGUAGE_AUTO,
isAutoLanguage,
normalizeOutputLanguage,
resolveOutputLanguage,
writeOutputLanguageFile,
updateOutputLanguageFile,
initializeLlmOutputLanguage,
} from './languageUtils.js';
describe('languageUtils', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('OUTPUT_LANGUAGE_AUTO', () => {
it('should be "auto"', () => {
expect(OUTPUT_LANGUAGE_AUTO).toBe('auto');
});
});
describe('isAutoLanguage', () => {
it('should return true for "auto"', () => {
expect(isAutoLanguage('auto')).toBe(true);
});
it('should return true for "AUTO" (case insensitive)', () => {
expect(isAutoLanguage('AUTO')).toBe(true);
});
it('should return true for "Auto" (case insensitive)', () => {
expect(isAutoLanguage('Auto')).toBe(true);
});
it('should return true for undefined', () => {
expect(isAutoLanguage(undefined)).toBe(true);
});
it('should return true for null', () => {
expect(isAutoLanguage(null)).toBe(true);
});
it('should return true for empty string', () => {
expect(isAutoLanguage('')).toBe(true);
});
it('should return false for explicit language', () => {
expect(isAutoLanguage('Chinese')).toBe(false);
});
it('should return false for locale code', () => {
expect(isAutoLanguage('zh')).toBe(false);
});
});
describe('normalizeOutputLanguage', () => {
it('should convert "en" to "English"', () => {
expect(normalizeOutputLanguage('en')).toBe('English');
});
it('should convert "zh" to "Chinese"', () => {
expect(normalizeOutputLanguage('zh')).toBe('Chinese');
});
it('should convert "ru" to "Russian"', () => {
expect(normalizeOutputLanguage('ru')).toBe('Russian');
});
it('should convert "de" to "German"', () => {
expect(normalizeOutputLanguage('de')).toBe('German');
});
it('should convert "ja" to "Japanese"', () => {
expect(normalizeOutputLanguage('ja')).toBe('Japanese');
});
it('should be case insensitive for locale codes', () => {
expect(normalizeOutputLanguage('ZH')).toBe('Chinese');
expect(normalizeOutputLanguage('Ru')).toBe('Russian');
});
it('should preserve explicit language names as-is', () => {
expect(normalizeOutputLanguage('Japanese')).toBe('Japanese');
expect(normalizeOutputLanguage('French')).toBe('French');
});
it('should preserve unknown language names as-is', () => {
expect(normalizeOutputLanguage('CustomLanguage')).toBe('CustomLanguage');
expect(normalizeOutputLanguage('日本語')).toBe('日本語');
});
});
describe('resolveOutputLanguage', () => {
it('should resolve "auto" to detected system language', () => {
vi.mocked(i18n.detectSystemLanguage).mockReturnValue('zh');
expect(resolveOutputLanguage('auto')).toBe('Chinese');
expect(i18n.detectSystemLanguage).toHaveBeenCalled();
});
it('should resolve undefined to detected system language', () => {
vi.mocked(i18n.detectSystemLanguage).mockReturnValue('ru');
expect(resolveOutputLanguage(undefined)).toBe('Russian');
});
it('should resolve null to detected system language', () => {
vi.mocked(i18n.detectSystemLanguage).mockReturnValue('de');
expect(resolveOutputLanguage(null)).toBe('German');
});
it('should normalize explicit locale codes', () => {
expect(resolveOutputLanguage('zh')).toBe('Chinese');
expect(i18n.detectSystemLanguage).not.toHaveBeenCalled();
});
it('should preserve explicit language names', () => {
expect(resolveOutputLanguage('Japanese')).toBe('Japanese');
});
});
describe('writeOutputLanguageFile', () => {
beforeEach(() => {
vi.mocked(fs.mkdirSync).mockImplementation(() => undefined);
vi.mocked(fs.writeFileSync).mockImplementation(() => undefined);
});
it('should create directory and write file', () => {
writeOutputLanguageFile('Chinese');
expect(fs.mkdirSync).toHaveBeenCalledWith('/mock/home/.qwen', {
recursive: true,
});
expect(fs.writeFileSync).toHaveBeenCalledWith(
'/mock/home/.qwen/output-language.md',
expect.any(String),
'utf-8',
);
});
it('should include language in file content', () => {
writeOutputLanguageFile('Japanese');
const writtenContent = vi.mocked(fs.writeFileSync).mock.calls[0][1];
expect(writtenContent).toContain('Japanese');
expect(writtenContent).toContain(
'# Output language preference: Japanese',
);
});
it('should include machine-readable marker', () => {
writeOutputLanguageFile('Chinese');
const writtenContent = vi.mocked(fs.writeFileSync).mock.calls[0][1];
expect(writtenContent).toContain(
'<!-- qwen-code:llm-output-language: Chinese -->',
);
});
it('should sanitize language for marker (remove dangerous characters)', () => {
writeOutputLanguageFile('Test--Language');
const writtenContent = vi.mocked(fs.writeFileSync).mock.calls[0][1];
// The marker should have -- removed, but the heading preserves original
expect(writtenContent).toContain(
'# Output language preference: Test--Language',
);
expect(writtenContent).toContain(
'<!-- qwen-code:llm-output-language: TestLanguage -->',
);
});
});
describe('updateOutputLanguageFile', () => {
beforeEach(() => {
vi.mocked(fs.mkdirSync).mockImplementation(() => undefined);
vi.mocked(fs.writeFileSync).mockImplementation(() => undefined);
});
it('should resolve "auto" and write resolved language', () => {
vi.mocked(i18n.detectSystemLanguage).mockReturnValue('zh');
updateOutputLanguageFile('auto');
const writtenContent = vi.mocked(fs.writeFileSync).mock.calls[0][1];
expect(writtenContent).toContain('Chinese');
});
it('should normalize locale codes and write full name', () => {
updateOutputLanguageFile('ja');
const writtenContent = vi.mocked(fs.writeFileSync).mock.calls[0][1];
expect(writtenContent).toContain('Japanese');
});
it('should write explicit language names directly', () => {
updateOutputLanguageFile('French');
const writtenContent = vi.mocked(fs.writeFileSync).mock.calls[0][1];
expect(writtenContent).toContain('French');
});
});
describe('initializeLlmOutputLanguage', () => {
beforeEach(() => {
vi.mocked(fs.existsSync).mockReturnValue(false);
vi.mocked(fs.mkdirSync).mockImplementation(() => undefined);
vi.mocked(fs.writeFileSync).mockImplementation(() => undefined);
vi.mocked(fs.readFileSync).mockReturnValue('');
});
it('should create file when it does not exist', () => {
vi.mocked(fs.existsSync).mockReturnValue(false);
vi.mocked(i18n.detectSystemLanguage).mockReturnValue('en');
initializeLlmOutputLanguage();
expect(fs.mkdirSync).toHaveBeenCalled();
expect(fs.writeFileSync).toHaveBeenCalledWith(
expect.stringContaining('output-language.md'),
expect.stringContaining('English'),
'utf-8',
);
});
it('should NOT overwrite file when content matches resolved language', () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(i18n.detectSystemLanguage).mockReturnValue('en');
vi.mocked(fs.readFileSync).mockReturnValue(
`# Output language preference: English
<!-- qwen-code:llm-output-language: English -->
`,
);
initializeLlmOutputLanguage();
expect(fs.writeFileSync).not.toHaveBeenCalled();
});
it('should overwrite file when language setting differs', () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(
`# Output language preference: English
<!-- qwen-code:llm-output-language: English -->
`,
);
initializeLlmOutputLanguage('Japanese');
expect(fs.writeFileSync).toHaveBeenCalledWith(
expect.stringContaining('output-language.md'),
expect.stringContaining('Japanese'),
'utf-8',
);
});
it('should resolve "auto" to detected system language', () => {
vi.mocked(fs.existsSync).mockReturnValue(false);
vi.mocked(i18n.detectSystemLanguage).mockReturnValue('zh');
initializeLlmOutputLanguage('auto');
expect(fs.writeFileSync).toHaveBeenCalledWith(
expect.stringContaining('output-language.md'),
expect.stringContaining('Chinese'),
'utf-8',
);
});
it('should detect Chinese locale and create Chinese rule file', () => {
vi.mocked(fs.existsSync).mockReturnValue(false);
vi.mocked(i18n.detectSystemLanguage).mockReturnValue('zh');
initializeLlmOutputLanguage();
expect(fs.writeFileSync).toHaveBeenCalledWith(
expect.stringContaining('output-language.md'),
expect.stringContaining('Chinese'),
'utf-8',
);
});
it('should detect Russian locale and create Russian rule file', () => {
vi.mocked(fs.existsSync).mockReturnValue(false);
vi.mocked(i18n.detectSystemLanguage).mockReturnValue('ru');
initializeLlmOutputLanguage();
expect(fs.writeFileSync).toHaveBeenCalledWith(
expect.stringContaining('output-language.md'),
expect.stringContaining('Russian'),
'utf-8',
);
});
it('should detect German locale and create German rule file', () => {
vi.mocked(fs.existsSync).mockReturnValue(false);
vi.mocked(i18n.detectSystemLanguage).mockReturnValue('de');
initializeLlmOutputLanguage();
expect(fs.writeFileSync).toHaveBeenCalledWith(
expect.stringContaining('output-language.md'),
expect.stringContaining('German'),
'utf-8',
);
});
it('should handle file read errors gracefully', () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockImplementation(() => {
throw new Error('Read error');
});
vi.mocked(i18n.detectSystemLanguage).mockReturnValue('en');
// Should not throw, and should create new file
expect(() => initializeLlmOutputLanguage()).not.toThrow();
expect(fs.writeFileSync).toHaveBeenCalled();
});
it('should parse legacy heading format', () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(
'# CRITICAL: Chinese Output Language Rule - HIGHEST PRIORITY',
);
vi.mocked(i18n.detectSystemLanguage).mockReturnValue('zh');
initializeLlmOutputLanguage();
// Should not overwrite since file already has Chinese
expect(fs.writeFileSync).not.toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,190 @@
/**
* @license
* Copyright 2025 Qwen team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Utilities for managing the LLM output language rule file.
* This file handles the creation and maintenance of ~/.qwen/output-language.md
* which instructs the LLM to respond in the user's preferred language.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { Storage } from '@qwen-code/qwen-code-core';
import {
detectSystemLanguage,
getLanguageNameFromLocale,
} from '../i18n/index.js';
const LLM_OUTPUT_LANGUAGE_RULE_FILENAME = 'output-language.md';
const LLM_OUTPUT_LANGUAGE_MARKER_PREFIX = 'qwen-code:llm-output-language:';
/** Special value meaning "detect from system settings" */
export const OUTPUT_LANGUAGE_AUTO = 'auto';
/**
* Checks if a value represents the "auto" setting.
*/
export function isAutoLanguage(value: string | undefined | null): boolean {
return !value || value.toLowerCase() === OUTPUT_LANGUAGE_AUTO;
}
/**
* Normalizes a language input to its canonical form.
* Converts known locale codes (e.g., "zh", "ru") to full names (e.g., "Chinese", "Russian").
* Unknown inputs are returned as-is to support any language name.
*/
export function normalizeOutputLanguage(language: string): string {
const lowered = language.toLowerCase();
const fullName = getLanguageNameFromLocale(lowered);
// getLanguageNameFromLocale returns 'English' as default for unknown codes.
// Only use the result if it's a known code or explicitly 'en'.
if (fullName !== 'English' || lowered === 'en') {
return fullName;
}
return language;
}
/**
* Resolves the output language, converting 'auto' to the detected system language.
*/
export function resolveOutputLanguage(
value: string | undefined | null,
): string {
if (isAutoLanguage(value)) {
const detectedLocale = detectSystemLanguage();
return getLanguageNameFromLocale(detectedLocale);
}
return normalizeOutputLanguage(value!);
}
/**
* Returns the path to the LLM output language rule file (~/.qwen/output-language.md).
*/
function getOutputLanguageFilePath(): string {
return path.join(
Storage.getGlobalQwenDir(),
LLM_OUTPUT_LANGUAGE_RULE_FILENAME,
);
}
/**
* Sanitizes a language string for use in an HTML comment marker.
* Removes characters that could break HTML comment syntax.
*/
function sanitizeForMarker(language: string): string {
return language
.replace(/[\r\n]/g, ' ')
.replace(/--!?>/g, '')
.replace(/--/g, '');
}
/**
* Generates the content for the LLM output language rule file.
*/
function generateOutputLanguageFileContent(language: string): string {
const safeLanguage = sanitizeForMarker(language);
return `# Output language preference: ${language}
<!-- ${LLM_OUTPUT_LANGUAGE_MARKER_PREFIX} ${safeLanguage} -->
## Goal
Prefer responding in **${language}** for normal assistant messages and explanations.
## Keep technical artifacts unchanged
Do **not** translate or rewrite:
- Code blocks, CLI commands, file paths, stack traces, logs, JSON keys, identifiers
- Exact quoted text from the user (keep quotes verbatim)
## When a conflict exists
If higher-priority instructions (system/developer) require a different behavior, follow them.
## Tool / system outputs
Raw tool/system outputs may contain fixed-format English. Preserve them verbatim, and if needed, add a short **${language}** explanation below.
`;
}
/**
* Extracts the language from the content of an output language rule file.
* Supports both the new marker format and legacy heading format.
*/
function parseOutputLanguageFromContent(content: string): string | null {
// Primary: machine-readable marker (e.g., <!-- qwen-code:llm-output-language: 中文 -->)
const markerRegex = new RegExp(
String.raw`<!--\s*${LLM_OUTPUT_LANGUAGE_MARKER_PREFIX}\s*(.*?)\s*-->`,
'i',
);
const markerMatch = content.match(markerRegex);
if (markerMatch?.[1]?.trim()) {
return markerMatch[1].trim();
}
// Fallback: legacy heading format (e.g., # CRITICAL: Chinese Output Language Rule)
const headingMatch = content.match(
/^#.*?CRITICAL:\s*(.*?)\s+Output Language Rule\b/im,
);
if (headingMatch?.[1]?.trim()) {
return headingMatch[1].trim();
}
return null;
}
/**
* Reads the current output language from the rule file.
* Returns null if the file doesn't exist or can't be parsed.
*/
function readOutputLanguageFromFile(): string | null {
const filePath = getOutputLanguageFilePath();
if (!fs.existsSync(filePath)) {
return null;
}
try {
const content = fs.readFileSync(filePath, 'utf-8');
return parseOutputLanguageFromContent(content);
} catch {
return null;
}
}
/**
* Writes the output language rule file with the given language.
*/
export function writeOutputLanguageFile(language: string): void {
const filePath = getOutputLanguageFilePath();
const content = generateOutputLanguageFileContent(language);
const dir = path.dirname(filePath);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(filePath, content, 'utf-8');
}
/**
* Updates the LLM output language rule file based on the setting value.
* Resolves 'auto' to the detected system language before writing.
*/
export function updateOutputLanguageFile(settingValue: string): void {
const resolved = resolveOutputLanguage(settingValue);
writeOutputLanguageFile(resolved);
}
/**
* Initializes the LLM output language rule file on application startup.
*
* @param outputLanguage - The output language setting value (e.g., 'auto', 'Chinese', etc.)
*
* Behavior:
* - Resolves the setting value ('auto' -> detected system language, or use as-is)
* - Ensures the rule file matches the resolved language
* - Creates the file if it doesn't exist
*/
export function initializeLlmOutputLanguage(outputLanguage?: string): void {
// Resolve 'auto' or undefined to the detected system language
const resolved = resolveOutputLanguage(outputLanguage);
const currentFileLanguage = readOutputLanguageFromFile();
// Only write if the file doesn't match the resolved language
if (currentFileLanguage !== resolved) {
writeOutputLanguageFile(resolved);
}
}

View file

@ -17,6 +17,7 @@ import type {
} from '../config/settingsSchema.js';
import { getSettingsSchema } from '../config/settingsSchema.js';
import { t } from '../i18n/index.js';
import { isAutoLanguage } from './languageUtils.js';
// The schema is now nested, but many parts of the UI and logic work better
// with a flattened structure and dot-notation keys. This section flattens the
@ -268,13 +269,16 @@ const SETTINGS_DIALOG_ORDER: readonly string[] = [
// Localization - users often set this first
'general.language',
'general.outputLanguage',
// Theme
'ui.theme',
// Editor/Shell Experience
'general.vimMode',
'tools.shell.enableInteractiveShell',
// Display Preferences
'ui.theme',
'general.preferredEditor',
'ide.enabled',
'ui.showLineNumbers',
@ -465,15 +469,21 @@ export function saveModifiedSettings(
path,
);
if (value === undefined) {
return;
}
const existsInOriginalFile = settingExistsInScope(
settingKey,
loadedSettings.forScope(scope).settings,
);
if (value === undefined) {
// Treat `undefined` as "unset" when the key exists in the scope file.
// LoadedSettings.setValue(..., undefined) is used elsewhere in the codebase
// to remove optional settings from disk.
if (existsInOriginalFile) {
loadedSettings.setValue(scope, settingKey, undefined);
}
return;
}
const isDefaultValue = value === getDefaultValue(settingKey);
if (existsInOriginalFile || !isDefaultValue) {
@ -509,7 +519,10 @@ export function getDisplayValue(
let valueString = String(value);
if (definition?.type === 'enum' && definition.options) {
// Special handling for outputLanguage 'auto' value
if (key === 'general.outputLanguage' && isAutoLanguage(value as string)) {
valueString = t('Auto (detect from system)');
} else if (definition?.type === 'enum' && definition.options) {
const option = definition.options?.find((option) => option.value === value);
if (option?.label) {
valueString = t(option.label) || option.label;