mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-08 07:58:38 -10:00
feat(web-shell): add Russian locale for the Goal card and Goals dialog
The web-shell i18n shipped only en and zh-CN and collapsed every other browser locale (e.g. ru-RU) to en in normalizeLanguage, so a Russian user saw the Goal card and the Goals dialog in English around a Russian objective. Add a partial ru catalog for the goal.*/goals.* keys with Russian pluralisation (ход/хода/ходов, токен/токена/токенов, активная цель/активные цели/активных целей). getTranslator already falls back per key to EN (messages[key] ?? EN[key] ?? key), and the partial catalog relies on that for every other key — the behaviour this locale has been running with as an installer patch. - WEB_SHELL_LANGUAGES / LANGUAGE_LABELS: add 'ru' / 'Русский [ru]' to the settings picker - normalizeLanguage: accept ru / ru-ru / russian / русский - languageSettingToWebShellLanguage: accept the same spellings for the daemon's general.language setting - WebShellProps.language / WebShellTranscriptProps.language: accept the 'ru' and 'ru-ru' raw values - surfaces with their own WebShellLanguage-keyed copy tables (StandaloneAuth, RootErrorFallback, conversation search, desktop relay) reuse the EN copy for ru, same partial-catalog policy - re-measure the transcript entry size budget for the catalog (+4,649 to 1,325,015; ceiling raised to 1,328,000) Part of #13391
This commit is contained in:
9 files changed
+326
-69
No files matched your search
@@ -1340,7 +1340,7 @@ export interface WebShellProps {
|
||||
*/
|
||||
onThemeResolved?: (theme: WebShellTheme) => void;
|
||||
/** UI language for the web-shell. Defaults to `?language=` or browser language. */
|
||||
language?: 'en' | 'zh-CN' | 'zh' | 'zh-cn';
|
||||
language?: 'en' | 'zh-CN' | 'zh' | 'zh-cn' | 'ru' | 'ru-ru';
|
||||
/** Called when `/language ui` changes the web-shell UI language. */
|
||||
onLanguageChange?: (language: WebShellLanguage) => void;
|
||||
/**
|
||||
|
||||
@@ -404,13 +404,14 @@ describe('build artifact — transcript entry (#11031)', () => {
|
||||
// main grew the same entry in parallel — 1,309,207 at 1aba19c878, plus
|
||||
// 3,033 for table fullscreen and Shadow DOM keyboard handling, reaching
|
||||
// 1,312,240. Merging the two measures 1,320,366 in CI at d4304811. The
|
||||
// ceiling leaves a small margin around that intentional UI;
|
||||
// ru goal/goals catalog (#13391) adds 4,649 to 1,325,015. The ceiling
|
||||
// leaves a small margin around that intentional UI;
|
||||
// re-measure and lower it if the entry gets leaner.
|
||||
const js = readTranscriptBundle().replace(
|
||||
/^const __qwenWebShellCss=[^\n]*\n/,
|
||||
'',
|
||||
);
|
||||
expect(js.length).toBeLessThan(1_323_000);
|
||||
expect(js.length).toBeLessThan(1_328_000);
|
||||
});
|
||||
|
||||
it('carries no Live Voice strings and looks none up', () => {
|
||||
|
||||
@@ -141,7 +141,12 @@ type CopyKey = keyof (typeof COPY)['en'];
|
||||
|
||||
function useDesktopRelayCopy(): (key: CopyKey) => string {
|
||||
const { language } = useI18n();
|
||||
return useCallback((key: CopyKey) => COPY[language][key], [language]);
|
||||
// No ru copy for the desktop relay yet — same partial-catalog policy as the
|
||||
// ru table in i18n.tsx (#13391): ru reuses the EN copy.
|
||||
return useCallback(
|
||||
(key: CopyKey) => COPY[language === 'ru' ? 'en' : language][key],
|
||||
[language],
|
||||
);
|
||||
}
|
||||
|
||||
export interface DesktopRelayStatus {
|
||||
|
||||
@@ -24,19 +24,24 @@ interface FallbackCopy {
|
||||
// This surface renders OUTSIDE the in-app I18nProvider (the boundary wraps the
|
||||
// whole App, which owns that provider), so it cannot call useI18n. It carries
|
||||
// its own minimal copy instead of pulling the full translation table.
|
||||
const EN_COPY: FallbackCopy = {
|
||||
title: 'Something went wrong',
|
||||
body: 'An unexpected error occurred and this content could not be displayed.',
|
||||
retry: 'Try again',
|
||||
reload: 'Reload page',
|
||||
};
|
||||
|
||||
const COPY: Record<WebShellLanguage, FallbackCopy> = {
|
||||
en: {
|
||||
title: 'Something went wrong',
|
||||
body: 'An unexpected error occurred and this content could not be displayed.',
|
||||
retry: 'Try again',
|
||||
reload: 'Reload page',
|
||||
},
|
||||
en: EN_COPY,
|
||||
'zh-CN': {
|
||||
title: '出了点问题',
|
||||
body: '发生意外错误,无法显示此内容。',
|
||||
retry: '重试',
|
||||
reload: '重新加载',
|
||||
},
|
||||
// Not translated yet — same partial-catalog policy as the ru table in
|
||||
// i18n.tsx (#13391): ru reuses the EN copy.
|
||||
ru: EN_COPY,
|
||||
};
|
||||
|
||||
// The boundary wraps the whole App, so this surface renders when the themed
|
||||
|
||||
@@ -75,41 +75,43 @@ interface AuthCopy {
|
||||
|
||||
// This gate renders before the app (and therefore before its I18nProvider), so
|
||||
// it carries its own copy table instead of calling useI18n.
|
||||
const EN_COPY: AuthCopy = {
|
||||
pairingFailed:
|
||||
'Pairing failed or the QR code expired. Scan a fresh QR code, or enter the daemon token.',
|
||||
heading: 'Connect to Qwen Code',
|
||||
connecting: 'Connecting…',
|
||||
starting: 'Daemon is starting…',
|
||||
unreachable: 'Cannot reach the daemon. Retrying…',
|
||||
remoteUnreachable:
|
||||
'Cannot reach the daemon. Retrying… Check the address, network, HTTPS certificate, and --allow-origin for this page origin.',
|
||||
notReady: 'Daemon is not ready. Retrying…',
|
||||
startFailed: 'Daemon failed to start.',
|
||||
invalidToken:
|
||||
'Invalid or expired token. Enter the token from the daemon terminal.',
|
||||
enterToken: 'Enter the bearer token from the daemon terminal.',
|
||||
policyBlocked:
|
||||
'Access blocked by the daemon Origin or Host policy. Open its direct address, or check --allow-origin for cross-origin access.',
|
||||
invalidAddress: 'Invalid daemon address. Enter an HTTP or HTTPS origin.',
|
||||
addressChanged:
|
||||
'Connection paused. Select Connect to use the entered address.',
|
||||
confirmTarget:
|
||||
'This page points to a daemon this browser has not connected to before. Connect only if you trust it.',
|
||||
remoteHint:
|
||||
'The token is sent to the address shown above. Enter only a token issued by that daemon.',
|
||||
switchUnavailable:
|
||||
'Browser storage is unavailable, so the token could not be carried to that daemon.',
|
||||
addressLabel: 'Daemon address',
|
||||
tokenLabel: 'Bearer token (optional)',
|
||||
connect: 'Connect',
|
||||
local: 'Return to local workspaces',
|
||||
remoteAddCancel: 'Cancel adding workspace',
|
||||
connectionAddCancel: 'Cancel adding connection',
|
||||
retry: 'Retry',
|
||||
hint: 'This token grants full access to the daemon. Only enter it on a page you opened from the daemon terminal or its QR code.',
|
||||
};
|
||||
|
||||
const COPY: Record<WebShellLanguage, AuthCopy> = {
|
||||
en: {
|
||||
pairingFailed:
|
||||
'Pairing failed or the QR code expired. Scan a fresh QR code, or enter the daemon token.',
|
||||
heading: 'Connect to Qwen Code',
|
||||
connecting: 'Connecting…',
|
||||
starting: 'Daemon is starting…',
|
||||
unreachable: 'Cannot reach the daemon. Retrying…',
|
||||
remoteUnreachable:
|
||||
'Cannot reach the daemon. Retrying… Check the address, network, HTTPS certificate, and --allow-origin for this page origin.',
|
||||
notReady: 'Daemon is not ready. Retrying…',
|
||||
startFailed: 'Daemon failed to start.',
|
||||
invalidToken:
|
||||
'Invalid or expired token. Enter the token from the daemon terminal.',
|
||||
enterToken: 'Enter the bearer token from the daemon terminal.',
|
||||
policyBlocked:
|
||||
'Access blocked by the daemon Origin or Host policy. Open its direct address, or check --allow-origin for cross-origin access.',
|
||||
invalidAddress: 'Invalid daemon address. Enter an HTTP or HTTPS origin.',
|
||||
addressChanged:
|
||||
'Connection paused. Select Connect to use the entered address.',
|
||||
confirmTarget:
|
||||
'This page points to a daemon this browser has not connected to before. Connect only if you trust it.',
|
||||
remoteHint:
|
||||
'The token is sent to the address shown above. Enter only a token issued by that daemon.',
|
||||
switchUnavailable:
|
||||
'Browser storage is unavailable, so the token could not be carried to that daemon.',
|
||||
addressLabel: 'Daemon address',
|
||||
tokenLabel: 'Bearer token (optional)',
|
||||
connect: 'Connect',
|
||||
local: 'Return to local workspaces',
|
||||
remoteAddCancel: 'Cancel adding workspace',
|
||||
connectionAddCancel: 'Cancel adding connection',
|
||||
retry: 'Retry',
|
||||
hint: 'This token grants full access to the daemon. Only enter it on a page you opened from the daemon terminal or its QR code.',
|
||||
},
|
||||
en: EN_COPY,
|
||||
'zh-CN': {
|
||||
pairingFailed:
|
||||
'配对失败或二维码已过期。请扫描新的二维码,或输入 daemon 令牌。',
|
||||
@@ -140,6 +142,9 @@ const COPY: Record<WebShellLanguage, AuthCopy> = {
|
||||
retry: '重试',
|
||||
hint: '该令牌拥有守护进程的完整访问权限。请仅在从守护进程终端或其二维码打开的页面中输入。',
|
||||
},
|
||||
// Not translated yet — same partial-catalog policy as the ru table in
|
||||
// i18n.tsx (#13391): ru reuses the EN copy.
|
||||
ru: EN_COPY,
|
||||
};
|
||||
|
||||
/** `Retry-After` in milliseconds (delta-seconds or HTTP-date), or undefined. */
|
||||
|
||||
@@ -61,7 +61,7 @@ export interface WebShellTranscriptProps {
|
||||
blocks: readonly DaemonTranscriptBlock[];
|
||||
renderMode?: 'readonly' | 'document';
|
||||
theme?: WebShellTheme;
|
||||
language?: 'en' | 'zh-CN' | 'zh' | 'zh-cn';
|
||||
language?: 'en' | 'zh-CN' | 'zh' | 'zh-cn' | 'ru' | 'ru-ru';
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
chatMaxWidth?: number;
|
||||
|
||||
@@ -10,29 +10,31 @@ import { useI18n, type WebShellLanguage } from '../i18n';
|
||||
type Message = string | ((vars?: Record<string, string | number>) => string);
|
||||
|
||||
// 搜索文案只随交互组件加载,避免进入只读 HTML 导出的共享翻译包。
|
||||
const EN_MESSAGES: Record<string, Message> = {
|
||||
'chat.searchConversation': 'Search this conversation',
|
||||
'chat.searchConversationPlaceholder': 'Search messages and code…',
|
||||
'chat.searchConversationHint': 'Enter a keyword to find earlier content.',
|
||||
'chat.searchingConversation': 'Searching conversation history…',
|
||||
'chat.searchLoadedOnly':
|
||||
'Only loaded messages are available. Update Qwen Code to search all history.',
|
||||
'chat.searchResultPosition': (vars) =>
|
||||
`${vars?.['current']} / ${vars?.['total']} results`,
|
||||
'chat.searchNoResults': 'No matching messages.',
|
||||
'chat.searchPrevious': 'Previous result',
|
||||
'chat.searchNext': 'Next result',
|
||||
'chat.searchResults': 'Search results',
|
||||
'chat.searchResultsLimited': (vars) =>
|
||||
`Showing the first ${vars?.['count']} results. Refine your search for more specific matches.`,
|
||||
'chat.searchFailed':
|
||||
'Could not search all history. Results may be incomplete.',
|
||||
'chat.searchLocateFailed':
|
||||
'This result could not be located. Search again to refresh it.',
|
||||
'chat.searchUser': 'You',
|
||||
'chat.searchAssistant': 'Assistant',
|
||||
};
|
||||
|
||||
const messages: Record<WebShellLanguage, Record<string, Message>> = {
|
||||
en: {
|
||||
'chat.searchConversation': 'Search this conversation',
|
||||
'chat.searchConversationPlaceholder': 'Search messages and code…',
|
||||
'chat.searchConversationHint': 'Enter a keyword to find earlier content.',
|
||||
'chat.searchingConversation': 'Searching conversation history…',
|
||||
'chat.searchLoadedOnly':
|
||||
'Only loaded messages are available. Update Qwen Code to search all history.',
|
||||
'chat.searchResultPosition': (vars) =>
|
||||
`${vars?.['current']} / ${vars?.['total']} results`,
|
||||
'chat.searchNoResults': 'No matching messages.',
|
||||
'chat.searchPrevious': 'Previous result',
|
||||
'chat.searchNext': 'Next result',
|
||||
'chat.searchResults': 'Search results',
|
||||
'chat.searchResultsLimited': (vars) =>
|
||||
`Showing the first ${vars?.['count']} results. Refine your search for more specific matches.`,
|
||||
'chat.searchFailed':
|
||||
'Could not search all history. Results may be incomplete.',
|
||||
'chat.searchLocateFailed':
|
||||
'This result could not be located. Search again to refresh it.',
|
||||
'chat.searchUser': 'You',
|
||||
'chat.searchAssistant': 'Assistant',
|
||||
},
|
||||
en: EN_MESSAGES,
|
||||
'zh-CN': {
|
||||
'chat.searchConversation': '搜索当前会话',
|
||||
'chat.searchConversationPlaceholder': '搜索消息和代码…',
|
||||
@@ -53,6 +55,9 @@ const messages: Record<WebShellLanguage, Record<string, Message>> = {
|
||||
'chat.searchUser': '你',
|
||||
'chat.searchAssistant': '助手',
|
||||
},
|
||||
// Not translated yet — same partial-catalog policy as the ru table in
|
||||
// i18n.tsx (#13391): ru reuses the EN copy.
|
||||
ru: EN_MESSAGES,
|
||||
};
|
||||
|
||||
export function useConversationSearchI18n() {
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getTranslator } from './i18n';
|
||||
import {
|
||||
WEB_SHELL_LANGUAGES,
|
||||
getTranslator,
|
||||
languageLabel,
|
||||
languageSettingToWebShellLanguage,
|
||||
normalizeLanguage,
|
||||
} from './i18n';
|
||||
|
||||
// getTranslator returns the raw key when EN has no entry. SettingsMessage
|
||||
// translateSettingText then substitutes the settingsSchema.ts description,
|
||||
@@ -52,3 +58,103 @@ describe('web-shell i18n catalog', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// #13391: ru ships a PARTIAL catalog covering the Goal card and the Goals
|
||||
// dialog only; every other key must fall back to EN in getTranslator. These
|
||||
// pins guard the normalizer branches, the pluralisation and that fallback.
|
||||
describe('web-shell ru locale', () => {
|
||||
it('normalizes ru spellings to ru and leaves the en/zh mappings untouched', () => {
|
||||
for (const value of [
|
||||
'ru',
|
||||
'ru-ru',
|
||||
'russian',
|
||||
'русский',
|
||||
'RU',
|
||||
' RU-RU ',
|
||||
]) {
|
||||
expect(normalizeLanguage(value)).toBe('ru');
|
||||
}
|
||||
expect(normalizeLanguage('en')).toBe('en');
|
||||
expect(normalizeLanguage('EN-US')).toBe('en');
|
||||
expect(normalizeLanguage('zh')).toBe('zh-CN');
|
||||
expect(normalizeLanguage('zh-cn')).toBe('zh-CN');
|
||||
expect(normalizeLanguage('zh_cn')).toBe('zh-CN');
|
||||
// Unrelated locales still collapse to en (fr, ja, … are not shipped).
|
||||
expect(normalizeLanguage('ru-anything-else')).toBe('en');
|
||||
expect(normalizeLanguage('fr-FR')).toBe('en');
|
||||
expect(normalizeLanguage(undefined)).toBe('en');
|
||||
});
|
||||
|
||||
it('maps daemon language settings to ru, underscore form included', () => {
|
||||
for (const value of ['ru', 'ru-ru', 'ru_ru', 'russian', 'русский', 'RU']) {
|
||||
expect(languageSettingToWebShellLanguage(value)).toBe('ru');
|
||||
}
|
||||
expect(languageSettingToWebShellLanguage('zh')).toBe('zh-CN');
|
||||
expect(languageSettingToWebShellLanguage('en')).toBe('en');
|
||||
expect(languageSettingToWebShellLanguage('fr')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('lists ru in the picker with the "Русский [ru]" label', () => {
|
||||
expect(WEB_SHELL_LANGUAGES).toEqual(['en', 'zh-CN', 'ru']);
|
||||
expect(languageLabel('ru')).toBe('Русский [ru]');
|
||||
});
|
||||
|
||||
it('pluralises turn counters the Russian way', () => {
|
||||
const t = getTranslator('ru');
|
||||
expect(t('goal.turns', { count: 1 })).toBe('1 ход');
|
||||
expect(t('goal.turns', { count: 2 })).toBe('2 хода');
|
||||
expect(t('goal.turns', { count: 5 })).toBe('5 ходов');
|
||||
expect(t('goal.turns', { count: 11 })).toBe('11 ходов');
|
||||
expect(t('goal.turns', { count: 21 })).toBe('21 ход');
|
||||
// The budget side pluralises on the budget, not the used count.
|
||||
expect(t('goal.turnsOfBudget', { count: 1, budget: 1 })).toBe('1 / 1 ход');
|
||||
expect(t('goal.turnsOfBudget', { count: 1, budget: 2 })).toBe('1 / 2 хода');
|
||||
expect(t('goal.turnsOfBudget', { count: 3, budget: 11 })).toBe(
|
||||
'3 / 11 ходов',
|
||||
);
|
||||
});
|
||||
|
||||
it('pluralises token spend the Russian way', () => {
|
||||
const t = getTranslator('ru');
|
||||
expect(t('goal.tokens', { used: 1 })).toBe('1 токен');
|
||||
expect(t('goal.tokens', { used: 3 })).toBe('3 токена');
|
||||
expect(t('goal.tokens', { used: 5 })).toBe('5 токенов');
|
||||
expect(t('goal.tokens', { used: 11 })).toBe('11 токенов');
|
||||
expect(t('goal.tokens', { used: 21 })).toBe('21 токен');
|
||||
});
|
||||
|
||||
it('pluralises the goals counter the Russian way', () => {
|
||||
const t = getTranslator('ru');
|
||||
expect(t('goals.count', { count: 1 })).toBe('1 активная цель');
|
||||
expect(t('goals.count', { count: 2 })).toBe('2 активные цели');
|
||||
expect(t('goals.count', { count: 5 })).toBe('5 активных целей');
|
||||
expect(t('goals.count', { count: 11 })).toBe('11 активных целей');
|
||||
expect(t('goals.count', { count: 21 })).toBe('21 активная цель');
|
||||
});
|
||||
|
||||
it('renders the Goal card from the ru catalog, not from EN', () => {
|
||||
const t = getTranslator('ru');
|
||||
const en = getTranslator('en');
|
||||
expect(t('goal.blocked')).toBe('Цель заблокирована');
|
||||
expect(t('goal.lastCheck')).toBe('Последняя проверка');
|
||||
expect(t('goal.status.blocked')).toBe('Заблокирована');
|
||||
expect(t('goals.title')).toBe('Цели');
|
||||
expect(t('goals.empty')).toBe(
|
||||
'Нет активных целей. Задайте: /goal <условие>.',
|
||||
);
|
||||
expect(t('goal.blocked')).not.toBe(en('goal.blocked'));
|
||||
});
|
||||
|
||||
it('falls back to EN for keys the partial ru catalog lacks', () => {
|
||||
const t = getTranslator('ru');
|
||||
const en = getTranslator('en');
|
||||
// Plain and parameterised EN values both resolve, not the raw key.
|
||||
expect(t('welcome.prompt')).toBe(en('welcome.prompt'));
|
||||
expect(t('welcome.prompt')).not.toBe('welcome.prompt');
|
||||
expect(t('capacityChoice.capacity', { used: 1, limit: 2 })).toBe(
|
||||
en('capacityChoice.capacity', { used: 1, limit: 2 }),
|
||||
);
|
||||
// Unknown in every catalog: the key itself, as in all locales.
|
||||
expect(t('no.such.key')).toBe('no.such.key');
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
SETTINGS_MESSAGES_ZH,
|
||||
} from './settings/messages.js';
|
||||
|
||||
export const WEB_SHELL_LANGUAGES = ['en', 'zh-CN'] as const;
|
||||
export const WEB_SHELL_LANGUAGES = ['en', 'zh-CN', 'ru'] as const;
|
||||
|
||||
export type WebShellLanguage = (typeof WEB_SHELL_LANGUAGES)[number];
|
||||
|
||||
@@ -32,6 +32,26 @@ function plural(count: string | number | undefined, noun: string): string {
|
||||
return `${safe} ${noun}${safe === 1 ? '' : 's'}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Russian plural pick: 1 → `one`, 2–4 → `few`, otherwise → `many`, with the
|
||||
* 11–14 exception per decade (ход/ходов, токен/токенов, цель/целей).
|
||||
*/
|
||||
function ruPlural(
|
||||
count: string | number | undefined,
|
||||
one: string,
|
||||
few: string,
|
||||
many: string,
|
||||
): string {
|
||||
const value = typeof count === 'number' ? count : Number(count ?? 0);
|
||||
const safe = Number.isFinite(value) ? value : 0;
|
||||
const last = safe % 10;
|
||||
const lastTwo = safe % 100;
|
||||
if (lastTwo >= 11 && lastTwo <= 14) return many;
|
||||
if (last === 1) return one;
|
||||
if (last >= 2 && last <= 4) return few;
|
||||
return many;
|
||||
}
|
||||
|
||||
const EN: Messages = {
|
||||
'capacityChoice.persistenceUnconfirmed':
|
||||
'Saving the last interrupted turn could not be confirmed.',
|
||||
@@ -8299,14 +8319,108 @@ const ZH: Messages = {
|
||||
...SETTINGS_MESSAGES_ZH,
|
||||
};
|
||||
|
||||
/**
|
||||
* Russian catalog — intentionally partial: it covers the Goal card and the
|
||||
* Goals dialog (#13391). `getTranslator` falls back to EN for every other
|
||||
* key (`messages[key] ?? EN[key] ?? key`), which is the behaviour this locale
|
||||
* has been running with since it was first introduced as an installer patch.
|
||||
*/
|
||||
const RU: Messages = {
|
||||
'goal.aborted': 'Цель прервана',
|
||||
'goal.blocked': 'Цель заблокирована',
|
||||
'goal.usageLimited': 'Лимит использования цели',
|
||||
'goal.paused': 'Цель на паузе',
|
||||
'goal.achieved': 'Цель достигнута',
|
||||
'goal.check': 'Проверка цели',
|
||||
'goal.cleared': 'Цель снята',
|
||||
'goal.failed': 'Цель не достигнута',
|
||||
'goal.judge': 'Оценка',
|
||||
'goal.label': 'Цель',
|
||||
'goal.lastCheck': 'Последняя проверка',
|
||||
'goal.notYetMet': 'условие не выполнено',
|
||||
'goal.set': 'Цель задана',
|
||||
'goal.statusActive': '/goal активен',
|
||||
'goal.status.active': 'В работе',
|
||||
'goal.status.paused': 'Пауза',
|
||||
'goal.status.blocked': 'Заблокирована',
|
||||
'goal.status.usage_limited': 'Лимит исчерпан',
|
||||
'goal.status.complete': 'Выполнена',
|
||||
'goal.activity.idle': 'Ожидание',
|
||||
'goal.activity.running': 'Работает',
|
||||
'goal.activity.verifying': 'Проверка',
|
||||
'goal.edit': 'Изменить цель',
|
||||
'goal.pause': 'Поставить на паузу',
|
||||
'goal.resume': 'Продолжить',
|
||||
'goal.turn': (v) =>
|
||||
`${v?.count ?? 0} ${ruPlural(v?.count, 'ход', 'хода', 'ходов')}`,
|
||||
'goal.turnLabel': (v) => `ход ${v?.count ?? 0}`,
|
||||
'goal.turns': (v) =>
|
||||
`${v?.count ?? 0} ${ruPlural(v?.count, 'ход', 'хода', 'ходов')}`,
|
||||
'goal.turnsOfBudget': (v) =>
|
||||
`${v?.count ?? 0} / ${v?.budget ?? 0} ${ruPlural(v?.budget, 'ход', 'хода', 'ходов')}`,
|
||||
'goal.activeOfBudget': (v) => `${v?.used ?? ''} / ${v?.budget ?? ''}`,
|
||||
'goal.tokens': (v) =>
|
||||
`${v?.used ?? 0} ${ruPlural(v?.used, 'токен', 'токена', 'токенов')}`,
|
||||
'goal.tokensOfBudget': (v) =>
|
||||
`${v?.used ?? 0} / ${v?.budget ?? 0} ${ruPlural(v?.budget, 'токен', 'токена', 'токенов')}`,
|
||||
'goals.title': 'Цели',
|
||||
'goals.subtitle':
|
||||
'Цель удерживает сессию в работе, пока условие не выполнено. Здесь видны только загруженные сейчас сессии — цель двигается, пока её сессия работает.',
|
||||
'goals.loading': 'Загрузка целей…',
|
||||
'goals.count': (v) =>
|
||||
`${v?.count ?? 0} ${ruPlural(v?.count, 'активная цель', 'активные цели', 'активных целей')}`,
|
||||
'goals.empty': 'Нет активных целей. Задайте: /goal <условие>.',
|
||||
'goals.refresh': 'Обновить',
|
||||
'goals.new': 'Новая цель',
|
||||
'goals.newHint':
|
||||
'Цель запускается в новой сессии и работает, пока условие не выполняется. Жёсткий лимит по числу ходов или времени задаётся в чате: /config model.goalMaxTurns=20 или /config model.goalMaxActiveMinutes=30 (применяется после перезапуска демона, к целям, созданным позже).',
|
||||
'goals.condition': 'Условие',
|
||||
'goals.conditionPlaceholder':
|
||||
'напр. `npm test` завершается с кодом 0 и `npm run lint` без предупреждений (вставить вывод); не менять тестовые файлы; как указание модели — остановиться как blocked после 20 ходов',
|
||||
'goals.cancel': 'Отмена',
|
||||
'goals.create': 'Задать цель',
|
||||
'goals.creating': 'Запуск…',
|
||||
'goals.saving': 'Сохранение…',
|
||||
'goals.save': 'Сохранить',
|
||||
'goals.edit': 'Изменить цель',
|
||||
'goals.objective': 'Целевое условие',
|
||||
'goals.clear': 'Снять цель',
|
||||
'goals.clearConfirm': (v) => `Снять цель "${v?.condition ?? ''}"?`,
|
||||
'goals.running': 'Работает',
|
||||
'goals.idle': 'Ожидание',
|
||||
'goals.dropped': (v) =>
|
||||
`не удалось достичь ${ruPlural(v?.count, 'сеанс', 'сеанса', 'сеансов')} — цели, которые они выполняли, отсутствуют в этом списке.`,
|
||||
'goals.notYetEvaluated': 'ещё не оценено',
|
||||
'goals.openSessionHint': 'Открыть сессию этой цели',
|
||||
'goals.error.emptyCondition': 'Введите условие для цели.',
|
||||
'goals.error.clearKeyword': (v) =>
|
||||
`"${v?.word ?? ''}" снимает цель, а не задаёт её. Опишите условие, к которому нужно работать.`,
|
||||
'goals.error.createFailed': 'Не удалось запустить цель',
|
||||
'goals.error.saveFailed': 'Не удалось сохранить цель',
|
||||
'goals.error.goalUnavailable': 'Цель больше недоступна.',
|
||||
'goals.error.requiresObjective': (v) =>
|
||||
`/goal ${v?.keyword ?? 'set'} требует целевого условия.`,
|
||||
'goals.error.invalidCommand': 'Некорректная команда /goal',
|
||||
'goals.error.goalsUnavailable': 'Панель целей недоступна в этом интерфейсе.',
|
||||
'goals.error.attachmentsUnsupported': 'Уберите вложения перед /goal.',
|
||||
'goals.error.editFailed': 'Не удалось изменить цель',
|
||||
'goals.error.pauseFailed': 'Не удалось поставить цель на паузу',
|
||||
'goals.error.resumeFailed': 'Не удалось продолжить цель',
|
||||
'goals.error.clearFailed': 'Не удалось снять цель',
|
||||
'goals.error.controlBusy':
|
||||
'Другое управление целью ещё выполняется. Повторите, когда оно завершится.',
|
||||
};
|
||||
|
||||
const MESSAGES: Record<WebShellLanguage, Messages> = {
|
||||
en: EN,
|
||||
'zh-CN': ZH,
|
||||
ru: RU,
|
||||
};
|
||||
|
||||
const LANGUAGE_LABELS: Record<WebShellLanguage, string> = {
|
||||
en: 'English [en]',
|
||||
'zh-CN': '中文 [zh-CN]',
|
||||
ru: 'Русский [ru]',
|
||||
};
|
||||
|
||||
const Context = createContext<{
|
||||
@@ -8325,6 +8439,14 @@ export function normalizeLanguage(
|
||||
if (normalized === 'zh' || normalized === 'zh-cn' || normalized === 'zh_cn') {
|
||||
return 'zh-CN';
|
||||
}
|
||||
if (
|
||||
normalized === 'ru' ||
|
||||
normalized === 'ru-ru' ||
|
||||
normalized === 'russian' ||
|
||||
normalized === 'русский'
|
||||
) {
|
||||
return 'ru';
|
||||
}
|
||||
return 'en';
|
||||
}
|
||||
|
||||
@@ -8347,6 +8469,14 @@ export function languageSettingToWebShellLanguage(
|
||||
) {
|
||||
return 'zh-CN';
|
||||
}
|
||||
if (
|
||||
normalized === 'ru' ||
|
||||
normalized === 'ru-ru' ||
|
||||
normalized === 'russian' ||
|
||||
normalized === 'русский'
|
||||
) {
|
||||
return 'ru';
|
||||
}
|
||||
if (
|
||||
normalized === 'en' ||
|
||||
normalized === 'en-us' ||
|
||||
|
||||
Reference in new issue
Block a user