fix(i18n): address translation, installer and coverage review findings

This commit is contained in:
07akioni committed 2026-10-22 22:14:47 +08:00
1 parent 7f2f9b8856
commit 1552f524f7
61 files changed
+330 -196

No files matched your search

+1 -1
View File
@@ -1,5 +1,5 @@
{
"nsisLanguage": "SPANISH",
"nsisLanguage": "SPANISHINTERNATIONAL",
"strings": {
"INSTALLER_INSTALL": "Instalar ahora",
"INSTALLER_FINISH": "Finalizar",
+3 -1
View File
@@ -10,7 +10,8 @@ const defaultDirectory = fileURLToPath(new URL('../installer/locales/', import.m
* @returns {{id: string, nsisLanguage: string, strings: Record<string, string>, uninstaller: Record<string, string>}[]} Complete language resources.
*/
export function readInstallerLocales(directory = defaultDirectory) {
const locales = readdirSync(directory).filter(name => name.endsWith('.json')).sort().map(name => {
const locales = readdirSync(directory).filter(name => name.endsWith('.json')).sort((a, b) =>
a === 'en_US.json' ? -1 : b === 'en_US.json' ? 1 : a.localeCompare(b)).map(name => {
if (!/^[a-z]{2}_[A-Z]{2}\.json$/u.test(name)) throw new Error(`${name}: invalid installer language filename`)
/** @type {unknown} */
const resource = JSON.parse(readFileSync(`${directory}/${name}`, 'utf8'))
@@ -25,6 +26,7 @@ export function readInstallerLocales(directory = defaultDirectory) {
const result = {}
for (const [key, text] of Object.entries(value)) {
if (typeof text !== 'string' || text.trim() === '') throw new Error(`${name}: ${key} must be nonempty text`)
if (/[\u200b\ufeff]/u.test(text)) throw new Error(`${name}: ${key} contains an invisible locale character`)
result[key] = text
}
return result
+45 -46
View File
@@ -557,7 +557,7 @@ export const es = {
showAllApplications: 'Mostrar todo',
quitApplication: 'Salir de DeepSeek Harness',
openApplication: 'Abrir DeepSeek Harness',
quit: 'dejar de fumar',
quit: 'Salir',
cancel: 'Cancelar',
quitTitle: '¿Salir de DeepSeek Harness?',
quitActiveTasks: 'Las tareas en ejecución se interrumpirán.',
@@ -664,7 +664,7 @@ export const es = {
policyLoginFailed: 'El inicio de sesión de Feishu no se completó. Por favor inténtalo de nuevo.',
policyLoginLoading: 'Cargando página de inicio de sesión...',
mandatoryNoRelease: 'No se encontró ninguna actualización compatible. Verifique nuevamente o comuníquese con el soporte.',
mandatoryRefresh: 'comprobar de nuevo',
mandatoryRefresh: 'Comprobar de nuevo',
mandatoryPage: 'Descargar desde el sitio web oficial.',
mandatoryCopy: 'Copiar enlace de descarga',
mandatoryPageFailed: 'No se pudo abrir la página de descarga oficial. Copie el enlace y ábralo en su navegador.',
@@ -702,7 +702,7 @@ export const fr = {
cliCommandNotInstalled: 'Ajoutez la commande Bureau à votre terminal.',
cliCommandBroken: 'La commande Bureau doit être réparée.',
cliCommandInstall: 'Installer',
cliCommandRepair: 'Réparation',
cliCommandRepair: 'Réparer',
cliCommandRemove: 'Supprimer',
cliCommandClose: 'Fermer',
cliCommandSwitch: 'Continuer avec la commande Bureau ?',
@@ -718,7 +718,7 @@ export const fr = {
cliCommandChanged: "La commande ou PATH a changé pendant que cette boîte de dialogue était ouverte. Ouvrez à nouveau la commande Gérer dsh pour vérifier l'état actuel.",
cliCommandOwnershipError: "L'enregistrement ou la sauvegarde de la commande a été modifiée. Aucune commande sans rapport n'a été supprimée.",
cliCommandFailed: "La commande n'a pas pu être mise à jour. Vérifiez que l'application est installée et que la destination est accessible en écriture, puis réessayez.",
application: 'Demande',
application: 'Application',
fileMenu: 'Fichier',
closePage: 'Fermer la page ou la fenêtre',
aboutMenu: 'À propos de DeepSeek Harness',
@@ -864,12 +864,12 @@ export const fr = {
/** German dictionary, checked against the English keys. */
export const de = {
cliCommandMenu: 'dsh-Befehl verwalten…',
cliCommandTitle: 'Verwalten Sie den dsh-Befehl',
cliCommandTitle: 'dsh-Befehl verwalten',
cliCommandLocation: 'Desktop-Befehl: {path}',
cliCommandSelected: 'Aktueller dsh-Befehl: {path}',
cliCommandTarget: 'Aktuelles Launcher-Ziel: {path}',
cliCommandShadowed: 'Ein anderer dsh hat Vorrang. Entfernen Sie diese Installation oder ordnen Sie sie neu an, um standardmäßig den Desktop-Befehl zu verwenden. Sie können den Desktop-Befehl auch über seinen vollständigen Pfad ausführen.',
cliCommandSelectionUnknown: 'Ihr Shell-Befehl konnte nicht überprüft werden. Ein Alias ​​oder eine andere dsh-Installation kann Vorrang haben.',
cliCommandSelectionUnknown: 'Ihr Shell-Befehl konnte nicht überprüft werden. Ein Alias oder eine andere dsh-Installation kann Vorrang haben.',
cliCommandInstalled: 'Der Desktop-Befehl ist installiert.',
cliCommandNotInstalled: 'Fügen Sie den Desktop-Befehl zu Ihrem Terminal hinzu.',
cliCommandBroken: 'Der Desktop-Befehl muss repariert werden.',
@@ -890,7 +890,7 @@ export const de = {
cliCommandChanged: 'Der Befehl oder PATH hat sich geändert, während dieses Dialogfeld geöffnet war. Öffnen Sie den Befehl „dsh verwalten“ erneut, um den aktuellen Status zu überprüfen.',
cliCommandOwnershipError: 'Die Befehlsregistrierung oder Sicherung wurde geändert. Es wurde kein nicht verwandter Befehl entfernt.',
cliCommandFailed: 'Der Befehl konnte nicht aktualisiert werden. Überprüfen Sie, ob die Anwendung installiert ist und das Ziel beschreibbar ist, und versuchen Sie es dann erneut.',
application: 'Bewerbung',
application: 'Anwendung',
fileMenu: 'Datei',
closePage: 'Seite oder Fenster schließen',
aboutMenu: 'Über DeepSeek Harness',
@@ -898,10 +898,10 @@ export const de = {
aboutVersion: 'Version V{version}',
hideApplication: 'DeepSeek Harness ausblenden',
hideOtherApplications: 'Andere ausblenden',
showAllApplications: 'Alle anzeigen',
quitApplication: 'Beenden Sie DeepSeek Harness',
openApplication: 'Öffnen Sie DeepSeek Harness',
quit: 'Hör auf',
showAllApplications: 'Alle einblenden',
quitApplication: 'DeepSeek Harness beenden',
openApplication: 'DeepSeek Harness öffnen',
quit: 'Beenden',
cancel: 'Abbrechen',
quitTitle: 'DeepSeek Harness beenden?',
quitActiveTasks: 'Laufende Aufgaben werden unterbrochen.',
@@ -914,17 +914,17 @@ export const de = {
delete: 'Löschen',
undo: 'Rückgängig machen',
redo: 'Wiederholen',
cut: 'Schneiden',
cut: 'Ausschneiden',
copy: 'Kopieren',
paste: 'Einfügen',
selectAll: 'Wählen Sie „Alle“ aus',
selectAll: 'Alles auswählen',
startupFailed: 'DeepSeek Harness ist nicht verfügbar',
fatalSummary: 'Die Anwendung konnte nicht gestartet werden oder wurde unerwartet gestoppt.',
startupAddressInUse: 'Eine andere DSH-Instanz (z. B. dsh Web oder die Desktop-App) wird ausgeführt. Sie können nicht gleichzeitig starten. Beenden Sie die andere laufende DSH-Instanz und starten Sie sie neu.',
diagnosticTruncated: '… Fehlerdetails gekürzt.',
reportWrittenTo: 'Diagnosebericht: {path}',
startupReinstallAdvice: 'Wenn Anwendungsdateien fehlen oder beschädigt sind, schließen Sie die Anwendung und installieren Sie sie erneut. Ihre Aufgaben werden separat gespeichert.',
exitApplication: 'Ausstieg',
exitApplication: 'Beenden',
restartApplication: 'Neustart',
recoveryOperationFailed: 'Der Wiederherstellungsvorgang ist fehlgeschlagen',
disableThirdPartyPlugins: 'Deaktivieren Sie Plugins von Drittanbietern, sichern Sie den Profil-Patch und starten Sie neu',
@@ -934,7 +934,7 @@ export const de = {
welcomeTaglineBrand: 'DeepSeek Harness',
welcomeTaglineAfter: '',
welcomeDescription: 'Potenzial aufbauen. Entdecken Sie Intelligenz.',
welcomeAuthStarting: 'Eröffnungsanmeldung…',
welcomeAuthStarting: 'Anmeldung wird geöffnet…',
welcomeAuthWaiting: 'Browser wurde nicht automatisch geöffnet?',
welcomeAuthWaitingDescription: 'Kopieren Sie den Anmeldelink und öffnen Sie ihn in Ihrem Browser, um sich anzumelden.',
welcomeAuthExchanging: 'Anmeldung wird abgeschlossen…',
@@ -945,23 +945,23 @@ export const de = {
welcomeAuthCopied: 'Kopiert',
welcomeAuthCopyFailed: 'Konnte nicht kopiert werden. Versuchen Sie es erneut.',
welcomeAuthCancel: 'Abbrechen',
welcomeAuthRetry: 'Melden Sie sich erneut an',
welcomeSignIn: 'Melden Sie sich an',
welcomeApiKey: 'Fügen Sie den API-Schlüssel hinzu',
welcomeAuthRetry: 'Erneut anmelden',
welcomeSignIn: 'Anmelden',
welcomeApiKey: 'API-Schlüssel hinzufügen',
welcomeKeyTitle: 'Fügen Sie einen API-Schlüssel hinzu, um loszulegen',
welcomeKeyDescription: 'Konfigurieren Sie offizielle DeepSeek-Modelle für die Verwendung von Harness',
welcomeKeyPlaceholder: 'Geben Sie den Schlüssel API ein',
welcomeKeyPlaceholder: 'API-Schlüssel eingeben',
welcomeKeySave: 'Speichern und fortfahren',
welcomeKeyLater: 'Später einrichten',
welcomeKeyBack: 'Zurück zur Anmeldung',
welcomeSessionExpired: 'Sie haben sich von Ihrem Konto abgemeldet. Bitte melden Sie sich erneut an.',
welcomeKeyBlank: 'Geben Sie einen API-Schlüssel ein.',
welcomeKeyInvalid: 'Geben Sie den API-Schlüssel selbst ein, ohne Anführungszeichen, Leerzeichen oder eine Umgebungsvariablenzuweisung.',
welcomeKeyFailed: 'Der Schlüssel API konnte nicht gespeichert werden. Bitte versuchen Sie es erneut.',
welcomeKeyFailed: 'Der API-Schlüssel konnte nicht gespeichert werden. Bitte versuchen Sie es erneut.',
welcomeContinueFailed: 'Der Arbeitsbereich konnte nicht geöffnet werden. Bitte versuchen Sie es erneut.',
checkUpdatesMenu: 'Nach Updates suchen…',
reloadPageMenu: 'Seite neu laden',
restartAppHostMenu: 'Starten Sie App und Host neu',
restartAppHostMenu: 'Anwendung und Host neu starten',
updateCheckFailedTitle: 'Update-Überprüfung fehlgeschlagen',
updateCheckFailed: 'Es konnte nicht nach Updates gesucht werden. Bitte versuchen Sie es später noch einmal.',
updateDownloadFailed: 'Das Update konnte nicht heruntergeladen werden. Bitte versuchen Sie es erneut.',
@@ -970,9 +970,9 @@ export const de = {
updateDownloadNetworkFailed: 'Das Update konnte nicht heruntergeladen werden. Überprüfen Sie Ihre Verbindung und versuchen Sie es erneut.',
updateInstallNetworkFailed: 'Das Update konnte nicht installiert werden. Überprüfen Sie Ihre Verbindung und versuchen Sie es erneut.',
unknownError: 'Unbekannter Fehler',
updateCheckTitle: 'Suchen Sie nach Updates',
updateCheckTitle: 'Nach Updates suchen',
updateCurrentDetail: 'Aktuelle Version: {version}',
updateCurrent: 'Du bist auf dem Laufenden!',
updateCurrent: 'Sie verwenden die neueste Version.',
updateChecking: 'Suche nach Updates…',
updateDownload: 'Update herunterladen',
updateDownloadedTitle: 'Die Version {version} ist zur Installation bereit',
@@ -987,7 +987,7 @@ export const de = {
updateRetry: 'Update erneut versuchen',
updateActiveTasks: 'Aufgaben sind noch in Bearbeitung',
updateActiveTasksDetail: 'Durch die Aktualisierung werden die laufenden Aufgaben gestoppt und die App neu gestartet. Weitermachen?',
updateStopTasks: 'Stoppen Sie Aufgaben und aktualisieren Sie sie',
updateStopTasks: 'Aufgaben beenden und Update installieren',
updateTasksChanged: 'Neue Aufgaben haben begonnen. Bestätigen Sie erneut, um die Aufgaben zu stoppen und zu aktualisieren.',
updateTasksUnavailable: 'Der Aufgabenstatus ist nicht verfügbar. Versuchen Sie erneut, die Aktualisierung durchzuführen, wenn der Arbeitsbereich bereit ist.',
updateFullscreenTimeout: 'Der Vollbildmodus konnte nicht beendet werden. Beenden Sie den Vollbildmodus und versuchen Sie dann erneut, das Update durchzuführen.',
@@ -1008,7 +1008,7 @@ export const de = {
policyLoginFailed: 'Die Feishu-Anmeldung wurde nicht abgeschlossen. Bitte versuchen Sie es erneut.',
policyLoginLoading: 'Anmeldeseite wird geladen…',
mandatoryNoRelease: 'Es wurde kein kompatibles Update gefunden. Überprüfen Sie es erneut oder wenden Sie sich an den Support.',
mandatoryRefresh: 'Überprüfen Sie es noch einmal',
mandatoryRefresh: 'Erneut prüfen',
mandatoryPage: 'Von der offiziellen Website herunterladen',
mandatoryCopy: 'Download-Link kopieren',
mandatoryPageFailed: 'Die offizielle Download-Seite konnte nicht geöffnet werden. Kopieren Sie den Link und öffnen Sie ihn in Ihrem Browser.',
@@ -1019,14 +1019,14 @@ export const de = {
mandatoryVersion: 'Neue Version: {version}',
mandatoryReadyDetail: 'Die App wird während des Updates geschlossen und nach Abschluss automatisch wieder geöffnet.',
mandatoryDeferred: 'Laufende Aufgaben werden weiterhin ausgeführt. Schließen Sie das Update ab, bevor Sie die App erneut verwenden.',
mandatoryContinue: 'Weiter aktualisieren',
mandatoryContinue: 'Update fortsetzen',
mandatoryInspecting: 'Aufgaben prüfen…',
mandatoryStopping: 'Aufgaben stoppen…',
mandatoryRestarting: 'Die App wird in Kürze neu gestartet. Bitte warten.',
mandatoryDownloadFailed: 'Die Update-Dateien konnten nicht heruntergeladen oder vorbereitet werden. Bitte versuchen Sie es erneut.',
mandatoryInstallFailed: 'Das Update wurde nicht installiert. Überprüfen Sie die Aufgaben erneut und versuchen Sie es erneut.',
mandatoryOpenHelp: 'Seite wurde nicht geöffnet?',
mandatoryReopen: 'Öffnen Sie die offizielle Download-Seite erneut',
mandatoryReopen: 'Offizielle Downloadseite erneut öffnen',
mandatoryCopied: 'Link kopiert',
mandatoryCopyFailed: 'Der Link konnte nicht kopiert werden. Wählen Sie es aus und kopieren Sie es unten.',
mandatoryAddress: 'Download-Link',
@@ -1037,25 +1037,25 @@ export const de = {
export const ru = {
cliCommandMenu: 'Управление командой dsh…',
cliCommandTitle: 'Управление командой dsh',
cliCommandLocation: 'Команда рабочего стола: {path}',
cliCommandLocation: 'Команда Desktop: {path}',
cliCommandSelected: 'Текущая команда dsh: {path}.',
cliCommandTarget: 'Текущая цель лаунчера: {path}',
cliCommandShadowed: 'Другой dsh имеет приоритет. Удалите или измените порядок установки, чтобы использовать команду «Рабочий стол» по умолчанию. Вы также можете запустить команду «Рабочий стол» по ее полному пути.',
cliCommandShadowed: 'Другой dsh имеет приоритет. Удалите или измените порядок установки, чтобы использовать команду «Desktop» по умолчанию. Вы также можете запустить команду «Desktop» по ее полному пути.',
cliCommandSelectionUnknown: 'Не удалось проверить вашу команду оболочки. Псевдоним или другая установка dsh может иметь приоритет.',
cliCommandInstalled: 'Команда «Рабочий стол» установлена.',
cliCommandNotInstalled: 'Добавьте команду «Рабочий стол» в свой терминал.',
cliCommandInstalled: 'Команда «Desktop» установлена.',
cliCommandNotInstalled: 'Добавьте команду «Desktop» в свой терминал.',
cliCommandBroken: 'Команда Desktop требует ремонта.',
cliCommandInstall: 'Установить',
cliCommandRepair: 'Ремонт',
cliCommandRepair: 'Исправить',
cliCommandRemove: 'Удалить',
cliCommandClose: 'Закрыть',
cliCommandSwitch: 'Продолжить команду «Рабочий стол»?',
cliCommandSwitch: 'Продолжить команду «Desktop»?',
cliCommandPreserve: 'Существующая команда будет сохранена. Другие установки и файлы запуска оболочки не будут изменены.',
cliCommandContinue: 'Продолжить',
cliCommandInstallApp: 'Установите Desktop в папку «Приложения», прежде чем управлять командой dsh.',
cliCommandUpdating: 'Идет установка обновления. Управляйте командой после завершения установки.',
cliCommandNewTerminal: 'Откройте новый терминал и запустите dsh --version.',
cliCommandRemoved: 'Регистрация команд рабочего стола удалена.',
cliCommandRemoved: 'Регистрация команд Desktop удалена.',
cliCommandPreviousRestored: 'Предыдущий лаунчер восстановлен.',
cliCommandOtherKept: 'Остальные командные установки остались на месте.',
cliCommandBackupKept: 'Предыдущий лаунчер сохранился по адресу: {path}.',
@@ -1144,7 +1144,7 @@ export const ru = {
unknownError: 'Неизвестная ошибка',
updateCheckTitle: 'Проверьте наличие обновлений',
updateCurrentDetail: 'Текущая версия: {version}.',
updateCurrent: 'Вы в курсе!',
updateCurrent: 'У вас последняя версия.',
updateChecking: 'Проверка обновлений…',
updateDownload: 'Скачать обновление',
updateDownloadedTitle: 'Версия {version} готова к установке.',
@@ -1186,7 +1186,7 @@ export const ru = {
mandatoryPageFailed: 'Не удалось открыть официальную страницу загрузки. Скопируйте ссылку и откройте ее в браузере.',
mandatoryViewDownload: 'Открыть страницу загрузки',
mandatoryViewPageFailed: 'Не удалось открыть страницу загрузки. Пожалуйста, попробуйте еще раз.',
mandatoryActionFailed: 'Акция провалилась. Пожалуйста, попробуйте еще раз. Вы сможете снова использовать приложение после завершения обновления.',
mandatoryActionFailed: 'Не удалось выполнить действие. Повторите попытку. Вы сможете снова использовать приложение после завершения обновления.',
mandatoryReady: 'Обновление готово',
mandatoryVersion: 'Новая версия: {version}',
mandatoryReadyDetail: 'Приложение закроется во время обновления и автоматически откроется после его завершения.',
@@ -1234,7 +1234,7 @@ export const pt = {
cliCommandChanged: 'O comando ou PATH foi alterado enquanto esta caixa de diálogo estava aberta. Abra o comando Gerenciar dsh novamente para revisar o estado atual.',
cliCommandOwnershipError: 'O registro ou backup do comando foi alterado. Nenhum comando não relacionado foi removido.',
cliCommandFailed: 'O comando não pôde ser atualizado. Verifique se o aplicativo está instalado e se o destino é gravável e tente novamente.',
application: 'Aplicação',
application: 'Aplicativo',
fileMenu: 'Arquivo',
closePage: 'Fechar página ou janela',
aboutMenu: 'Sobre DeepSeek Harness',
@@ -1294,7 +1294,7 @@ export const pt = {
welcomeApiKey: 'Adicionar chave API',
welcomeKeyTitle: 'Adicione uma chave API para começar',
welcomeKeyDescription: 'Configure modelos DeepSeek oficiais para começar a usar o Harness',
welcomeKeyPlaceholder: 'Insira a tecla API',
welcomeKeyPlaceholder: 'Insira a chave de API',
welcomeKeySave: 'Salvar e continuar',
welcomeKeyLater: 'Configurar mais tarde',
welcomeKeyBack: 'Voltar para fazer login',
@@ -1373,10 +1373,12 @@ export const pt = {
mandatoryReopen: 'Abra a página oficial de download novamente',
mandatoryCopied: 'Link copiado',
mandatoryCopyFailed: 'Não foi possível copiar o link. Selecione e copie abaixo.',
mandatoryAddress: 'Baixar link',
mandatoryAddress: 'Link de download',
mandatoryNotification: 'Retorne ao aplicativo para confirmar a instalação e reinicie.',
} satisfies DesktopMessages
const dictionaries = { en, zh, ja, es, fr, de, ru, pt }
/** Locale payload exposed to the Desktop-owned renderer. */
export interface DesktopLocale {
readonly id: 'en' | 'zh-CN' | 'ja' | 'es' | 'fr' | 'de' | 'ru' | 'pt'
@@ -1386,14 +1388,11 @@ export interface DesktopLocale {
/** Resolve Electron's locale to one shipped Desktop dictionary. */
export function resolveDesktopLocale(locale: string): DesktopLocale {
const primary = locale.toLowerCase().split('-')[0]
const dictionaries = { ja, es, fr, de, ru, pt }
if (primary !== undefined && Object.hasOwn(dictionaries, primary)) {
const id = primary as keyof typeof dictionaries
return { id, messages: dictionaries[id] }
return { id: id === 'zh' ? 'zh-CN' : id, messages: dictionaries[id] }
}
return primary === 'zh'
? { id: 'zh-CN', messages: zh }
: { id: 'en', messages: en }
return { id: 'en', messages: en }
}
/**
@@ -1404,10 +1403,10 @@ export function resolveDesktopLocale(locale: string): DesktopLocale {
*/
export function resolveDesktopStartupLocale(preference: string | null, languages: readonly string[]): DesktopLocale {
const selected = preference?.toLowerCase()
if (selected !== undefined && ['zh', 'en', 'ja', 'es', 'fr', 'de', 'ru', 'pt'].includes(selected)) return resolveDesktopLocale(selected)
if (selected !== undefined && Object.hasOwn(dictionaries, selected)) return resolveDesktopLocale(selected)
for (const language of languages) {
const primary = language.toLowerCase().split('-')[0]
if (primary !== undefined && ['zh', 'en', 'ja', 'es', 'fr', 'de', 'ru', 'pt'].includes(primary)) return resolveDesktopLocale(primary)
if (primary !== undefined && Object.hasOwn(dictionaries, primary)) return resolveDesktopLocale(primary)
}
return resolveDesktopLocale('en')
}
+20 -1
View File
@@ -1,3 +1,5 @@
import { SHIPPED_LOCALE_IDS } from '../../../scripts/shipped-locales.ts'
import { createRequire } from 'node:module'
/** Per-language installer resources feed both the packaging language list and the NSIS compiler. */
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
@@ -16,7 +18,7 @@ function fixture(): string {
it('generates Japanese NSIS strings from the same files that select packaged languages', () => {
const locales = readInstallerLocales()
expect(locales.map(locale => locale.id)).toEqual(['de_DE', 'en_US', 'es_ES', 'fr_FR', 'ja_JP', 'pt_BR', 'ru_RU', 'zh_CN'])
expect(locales.map(locale => locale.id)).toEqual(['en_US', 'de_DE', 'es_ES', 'fr_FR', 'ja_JP', 'pt_BR', 'ru_RU', 'zh_CN'])
const source = renderInstallerStrings(locales)
expect(source).toContain('LangString INSTALLER_INSTALL ${LANG_JAPANESE} "今すぐインストール"')
expect(source).toContain('LangString INSTALLER_NATIVE_PROGRESS ${LANG_JAPANESE} "インストールを準備しています… %d%%"')
@@ -41,6 +43,8 @@ it.each([
['extra format specifier', (resource: ReturnType<typeof readInstallerLocales>[number]) => { resource.strings.INSTALLER_NATIVE_PROGRESS = '%d%% %q' }, 'format placeholders'],
['translated command', (resource: ReturnType<typeof readInstallerLocales>[number]) => { resource.strings.INSTALLER_THEME_ERROR = 'Use /THEME=claro.' }, 'format placeholders'],
['duplicate language', (resource: ReturnType<typeof readInstallerLocales>[number]) => { resource.nsisLanguage = 'ENGLISH' }, 'duplicate NSIS language'],
['invisible character', (resource: ReturnType<typeof readInstallerLocales>[number]) => { resource.strings.INSTALLER_INSTALL = 'Install\u200b' }, 'invisible'],
['byte-order mark', (resource: ReturnType<typeof readInstallerLocales>[number]) => { resource.strings.INSTALLER_INSTALL = 'Install\ufeff' }, 'invisible'],
['invalid compiler token', (resource: ReturnType<typeof readInstallerLocales>[number]) => { resource.nsisLanguage = 'JA\n!error' }, 'invalid NSIS language'],
])('rejects %s', (_name, mutate, diagnostic) => {
const directory = fixture()
@@ -55,3 +59,18 @@ it('escapes literal NSIS syntax without changing translated text', () => {
expect(renderInstallerStrings([{ ...locale, strings: { INSTALLER_INSTALL: '"$name"\nnext\rline' } }]))
.toContain('"$\\"$$name$\\"$\\nnext$\\rline"')
})
it('uses the real electron-builder language identifiers and English fallback', () => {
const require = createRequire(import.meta.url)
const { LangConfigurator, createAddLangsMacro } = require('app-builder-lib/out/targets/nsis/nsisLang.js') as {
LangConfigurator: new (options: { installerLanguages: string[] }) => object
createAddLangsMacro: (generator: { macro: (name: string, lines: string[]) => void }, languages: object) => void
}
const locales = readInstallerLocales()
expect(locales[0]?.id).toBe('en_US')
expect(locales.map(locale => locale.id.split('_')[0]).sort()).toEqual([...SHIPPED_LOCALE_IDS].sort())
createAddLangsMacro({ macro: (_name, lines) => {
expect(lines.map(line => /MUI_LANGUAGE "([^" ]+)"/u.exec(line)?.[1]?.toUpperCase()))
.toEqual(locales.map(locale => locale.nsisLanguage))
} }, new LangConfigurator({ installerLanguages: locales.map(locale => locale.id) }))
})
@@ -1,3 +1,4 @@
import { readInstallerLocales } from '../scripts/installer-locales.mjs'
import { tmpdir } from 'node:os'
import { readFileSync } from 'node:fs'
import { Arch, Platform } from 'electron-builder'
@@ -54,7 +55,7 @@ describe('installer preparation preserves application dependencies', () => {
expect(appInfo.copyright).toBe(`Copyright © ${new Date().getFullYear()} DeepSeek Harness`)
if (platform === 'win32') {
expect(expandMacro(config.nsis.uninstallDisplayName, null, appInfo, {}, false)).toBe('DeepSeek Harness')
expect(config.nsis.installerLanguages).toEqual(['en_US', 'ja_JP', 'zh_CN'])
expect(config.nsis.installerLanguages).toEqual(readInstallerLocales().map(locale => locale.id))
}
const aboutIcon = config.extraResources.find(resource => resource.to === 'icon.png')
expect(aboutIcon).toBeDefined()
+7 -1
View File
@@ -1,8 +1,9 @@
import { SHIPPED_LOCALE_IDS } from '../../../scripts/shipped-locales.ts'
import { describe, expect, it } from 'vitest'
import { en, formatDesktopMessage, resolveDesktopLocale, resolveDesktopStartupLocale, ja, zh, es, fr, de, ru, pt } from '../src/locale.ts'
describe('desktop locale dictionaries', () => {
it('ships the same key set in English, Chinese, and Japanese', () => {
it('ships baseline and Japanese keys and falls back for unsupported languages', () => {
expect(Object.keys(zh)).toEqual(Object.keys(en))
expect(Object.keys(ja)).toEqual(Object.keys(en))
expect(resolveDesktopLocale('JA-jp')).toEqual({ id: 'ja', messages: ja })
@@ -25,6 +26,11 @@ describe('desktop locale dictionaries', () => {
}
})
it.each(SHIPPED_LOCALE_IDS)('resolves every shipped language %s without fallback', (id) => {
expect(resolveDesktopLocale(id).id).toBe(id === 'zh' ? 'zh-CN' : id)
expect(resolveDesktopStartupLocale(null, [id]).id).toBe(id === 'zh' ? 'zh-CN' : id)
})
it('formats named values without consuming unknown placeholders', () => {
expect(formatDesktopMessage('{name}@{version} {missing}', { name: 'plugin', version: '1.2.3' }))
.toBe('plugin@1.2.3 {missing}')
+2 -1
View File
@@ -1,3 +1,4 @@
import { SHIPPED_LOCALE_IDS } from '../../../scripts/shipped-locales.ts'
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
import { readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
@@ -51,7 +52,7 @@ describe('desktop macOS release signature', () => {
expect(config.protocols).toEqual([{ name: 'DeepSeek Harness', schemes: ['dsh'] }])
expect(portablePath(config.directories.output)).toContain('/.desktop-build/targets/mac-arm64/artifacts')
expect(config.mac.extendInfo.NSMicrophoneUsageDescription).toContain('microphone')
expect(config.mac.extendInfo.CFBundleLocalizations).toEqual(['en', 'zh_CN', 'ja', 'es', 'fr', 'de', 'ru', 'pt'])
expect(config.mac.extendInfo.CFBundleLocalizations).toEqual(SHIPPED_LOCALE_IDS.map(id => id === 'zh' ? 'zh_CN' : id))
expect(config.mac.entitlementsInherit).toBe(config.mac.entitlements)
const entitlements = readFileSync(config.mac.entitlements, 'utf8')
for (const key of ['com.apple.security.cs.allow-jit', 'com.apple.security.cs.allow-unsigned-executable-memory',
+3
View File
@@ -47,6 +47,9 @@ it.each(languages)('selects %s from the browser, localizes metadata and persists
return { panel: element.scrollWidth <= element.clientWidth + 1, content: content.scrollWidth <= content.clientWidth + 1 }
})).toEqual({ panel: true, content: true })
await dialog.getByRole('button', { name: label, exact: true }).click()
await page.getByRole('menuitem', { name: label, exact: true }).click()
await expect.poll(() => readFile(join(scaffold.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml'), 'utf8')).toContain(`preference: ${id}`)
await dialog.getByRole('button', { name: label, exact: true }).click()
await page.getByRole('menuitem', { name: 'English', exact: true }).click()
await expect.poll(() => readFile(join(scaffold.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml'), 'utf8')).toContain('preference: en')
const warningsBeforeReload = tripwire.warnings.length
+14 -14
View File
@@ -977,18 +977,18 @@ describe('web e2e: settings modal and General preferences', () => {
it('opens a browser asking for no shipped language in English', async () => {
// The product default for "no usable signal": a French browser ships
// neither zh nor en, so resolution falls to FALLBACK_LOCALE (en) rather
// names no shipped language, so resolution falls to FALLBACK_LOCALE (en) rather
// than to Chinese.
const fresh = await launchWebScaffold({ developerTools: false })
const frPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: 'fr-FR' })
const frTripwire = watchConsole(frPage)
onTestFailed(() => saveSettingsFailureShot(frPage, 'settings-unshipped-language'))
const unsupportedPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: 'it-IT' })
const unsupportedTripwire = watchConsole(unsupportedPage)
onTestFailed(() => saveSettingsFailureShot(unsupportedPage, 'settings-unshipped-language'))
try {
await frPage.goto(fresh.authenticatedUrl, { waitUntil: 'load' })
await frPage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
expect(await frPage.evaluate(() => localStorage.getItem('dsh.locale'))).toBeNull()
await openSettings(frPage, 'en')
const dialog = frPage.getByRole('dialog', { name: 'Settings' })
await unsupportedPage.goto(fresh.authenticatedUrl, { waitUntil: 'load' })
await unsupportedPage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
expect(await unsupportedPage.evaluate(() => localStorage.getItem('dsh.locale'))).toBeNull()
await openSettings(unsupportedPage, 'en')
const dialog = unsupportedPage.getByRole('dialog', { name: 'Settings' })
await dialog.waitFor({ timeout: 10_000 })
await dialog.getByRole('button', { name: 'English' }).waitFor({ timeout: 10_000 })
// A locale-owned nav label proves the dictionaries resolved to en.
@@ -996,17 +996,17 @@ describe('web e2e: settings modal and General preferences', () => {
// The markup already ships `en`, so this alone cannot prove the sync ran
// — the zh scenario above is the discriminating half. Asserted here too
// so a future change that resolves en but writes the wrong tag is caught.
expect(await frPage.evaluate(() => document.documentElement.lang)).toBe('en')
expect(await unsupportedPage.evaluate(() => document.documentElement.lang)).toBe('en')
// Golden of the English fallback dialog — the visible output this change
// produces. The zh golden above covers the detected-locale surface, so
// the pair pins both directions of the resolution.
await dialog.getByText(`Current version: ${version}`, { exact: true }).waitFor()
const snapshot = await captureStableAria(frPage, '[role="dialog"]', fresh.workspaceCwd, versionCapture)
const snapshot = await captureStableAria(unsupportedPage, '[role="dialog"]', fresh.workspaceCwd, versionCapture)
await compareOrRefreshGolden(DIALOG_EN_EXPECTED, snapshot, MODE)
expect(frTripwire.pageErrors).toEqual([])
expect(frTripwire.warnings).toEqual([])
expect(unsupportedTripwire.pageErrors).toEqual([])
expect(unsupportedTripwire.warnings).toEqual([])
} finally {
await frPage.close()
await unsupportedPage.close()
await fresh.close()
}
}, 90_000)
+4 -4
View File
@@ -9,8 +9,8 @@
en: 9365090d78467dea
zh: 74b6fbb9769d795f
/deepseek-ai-dsh-client-locale/summary:
en: d11ef1c4b5b4fe86
zh: 86eb53d8578eadbf
en: cbbe8f61f5e19f39
zh: 2cec7ec41f1a7e84
/deepseek-ai-dsh-client-locale/table-of-contents:
en: d152484eb41ac6b4
zh: bda879a892f240f0
@@ -21,8 +21,8 @@
en: 2b82458e185ba321
zh: e55a7e709a3f0158
/deepseek-ai-dsh-client-locale/use-this-package/registering-a-dictionary:
en: 525a74ea5e5f152f
zh: 06fd145b189f37ee
en: f19407c3ce8a94cc
zh: 3e5300ce7fa02987
/deepseek-ai-dsh-client-locale/use-this-package/resolving-package-text:
en: 3b63e84fe0fdf8f4
zh: e97b5d1b92dff7ec
+2 -2
View File
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
## Summary
Use `dsh-client-locale` to switch the web GUI between the shipped English, Chinese, Japanese, Spanish, French, German, Russian, and Portuguese locales or languages added by client plugins. User selections take effect immediately; every authenticated browser persists them as `locale.preference` in `$DSH_HOME/cordis.patch.yml` through the Host settings service. New browsers use the first supported language requested by the browser until the Host read delivers a stored preference. Plugin authors add typed namespace dictionaries and translate through the public locale API; slot-rendered copy updates without a reload.
Use `dsh-client-locale` to switch the web GUI between the shipped English, Chinese, Japanese, Spanish, French, German, Russian, and Brazilian Portuguese (`pt`, including `pt-PT` browser requests) locales or languages added by client plugins. User selections take effect immediately; every authenticated browser persists them as `locale.preference` in `$DSH_HOME/cordis.patch.yml` through the Host settings service. New browsers use the first supported language requested by the browser until the Host read delivers a stored preference. Plugin authors add typed namespace dictionaries and translate through the public locale API; slot-rendered copy updates without a reload.
## Table of Contents
@@ -37,7 +37,7 @@ Native shells may provide `__DSH_LOCALE__` with an asynchronous `read()` and an
### Registering a dictionary
Call `ctx.locale.register(ns, { zh, en, ja })` with a namespace merged into `LocaleNamespaceMap`; the compiler checks every key against the namespace's typed key union and requires both baseline locales (`zh` and `en`). Additional locale ids are optional, and their dictionaries are checked against the same namespace keys. Consumers translate through `ctx.locale.bind(ns)` or the framework-injected `t` seat. A dictionary registered after the UI is already mounted is picked up without a remount. Preserve named placeholders, slash commands, and API field names verbatim in translations; `pnpm run check:i18n` checks first-party dictionaries.
Call `ctx.locale.register(ns, { zh, en, ja })` with a namespace merged into `LocaleNamespaceMap`; the compiler checks every key against the namespace's typed key union and requires both baseline locales (`zh` and `en`). Additional locale ids are optional, and their dictionaries are checked against the same namespace keys. Consumers translate through `ctx.locale.bind(ns)` or the framework-injected `t` seat. A dictionary registered after the UI is already mounted is picked up without a remount. Preserve named placeholders, slash commands, and API field names verbatim in translations; `pnpm run check:i18n` checks first-party dictionaries and rejects zero-width spaces (U+200B) and byte-order marks (U+FEFF) in locale resources.
### Resolving package text
+2 -2
View File
@@ -9,7 +9,7 @@ kind: "package-reference"
## 概述
使用 `dsh-client-locale` 可在 web GUI 中切换内置的英文、中文、日语、西班牙语、法语、德语、俄语和葡萄牙语 locale,或 client 插件添加的语言。用户选择会立即生效;每个已认证浏览器都通过 Host settings 服务把选择以 `locale.preference` 持久化到 `$DSH_HOME/cordis.patch.yml`。全新浏览器会使用浏览器请求的第一个受支持语言,直到 Host 读取送达已存储偏好。插件作者可添加类型化命名空间字典,并通过公开 locale API 翻译;经 slot 渲染的文案无需重新加载即可随语言切换更新。
使用 `dsh-client-locale` 可在 web GUI 中切换内置的英文、中文、日语、西班牙语、法语、德语、俄语和巴西葡萄牙语(`pt`,也匹配 `pt-PT` 浏览器请求)locale,或 client 插件添加的语言。用户选择会立即生效;每个已认证浏览器都通过 Host settings 服务把选择以 `locale.preference` 持久化到 `$DSH_HOME/cordis.patch.yml`。全新浏览器会使用浏览器请求的第一个受支持语言,直到 Host 读取送达已存储偏好。插件作者可添加类型化命名空间字典,并通过公开 locale API 翻译;经 slot 渲染的文案无需重新加载即可随语言切换更新。
## 目录
@@ -37,7 +37,7 @@ kind: "package-reference"
### 注册字典
用已合并进 `LocaleNamespaceMap` 的命名空间调用 `ctx.locale.register(ns, { zh, en, ja })`;编译器会对照该命名空间的类型化键并集检查每个键,并要求两个基础 locale(`zh` 和 `en`)齐全。其他 locale id 为可选项,其字典同样按该命名空间的键检查。消费方通过 `ctx.locale.bind(ns)` 或框架注入的 `t` 席位翻译。UI 已挂载后再注册的字典无需重新挂载即可生效。 翻译必须原样保留命名占位符、斜杠命令和 API 字段名;`pnpm run check:i18n` 检查仓库内的字典。
用已合并进 `LocaleNamespaceMap` 的命名空间调用 `ctx.locale.register(ns, { zh, en, ja })`;编译器会对照该命名空间的类型化键并集检查每个键,并要求两个基础 locale(`zh` 和 `en`)齐全。其他 locale id 为可选项,其字典同样按该命名空间的键检查。消费方通过 `ctx.locale.bind(ns)` 或框架注入的 `t` 席位翻译。UI 已挂载后再注册的字典无需重新挂载即可生效。翻译必须原样保留命名占位符、斜杠命令和 API 字段名;`pnpm run check:i18n` 检查仓库内的字典,并拒绝语言资源中的零宽空格(U+200B)和字节顺序标记(U+FEFF)。
### 解析包文本
+1 -1
View File
@@ -41,7 +41,7 @@ export const de = {
'truncated': 'Abgeschnitten',
'json.label': 'JSON',
'markdown.footnotes': 'Fußnoten',
'markdown.truncatedCharacters': '… bei {total}-Zeichen abgeschnitten',
'markdown.truncatedCharacters': '… nach {total} Zeichen abgeschnitten',
'number.thousand': '{value}K',
'number.million': '{value}M',
} satisfies Record<keyof typeof en, string>
+2 -2
View File
@@ -38,10 +38,10 @@ export const es = {
'workspace.defaultName': 'Espacio de trabajo predeterminado',
'unknown': 'Desconocido',
'none': 'Ninguno',
'truncated': 'truncado',
'truncated': 'Truncado',
'json.label': 'JSON',
'markdown.footnotes': 'Notas a pie de página',
'markdown.truncatedCharacters': '… truncado en caracteres {total}',
'markdown.truncatedCharacters': '… truncado a {total} caracteres',
'number.thousand': '{value}K',
'number.million': '{value}M',
} satisfies Record<keyof typeof en, string>
+1 -1
View File
@@ -41,7 +41,7 @@ export const fr = {
'truncated': 'Tronqué',
'json.label': 'JSON',
'markdown.footnotes': 'Notes de bas de page',
'markdown.truncatedCharacters': '… tronqué aux caractères {total}',
'markdown.truncatedCharacters': '… tronqué à {total} caractères',
'number.thousand': '{value}K',
'number.million': '{value}M',
} satisfies Record<keyof typeof en, string>
+1 -1
View File
@@ -41,7 +41,7 @@ export const ru = {
'truncated': 'Усечено',
'json.label': 'JSON',
'markdown.footnotes': 'Сноски',
'markdown.truncatedCharacters': '… усекается до символов {total}',
'markdown.truncatedCharacters': '… обрезано до {total} символов',
'number.thousand': '{value}K',
'number.million': '{value}M',
} satisfies Record<keyof typeof en, string>
@@ -1,3 +1,4 @@
import { SHIPPED_LOCALE_IDS } from '../../../../scripts/shipped-locales.ts'
/** locale apply wiring: service + dictionaries provision, declaration-aware
* Language row registration, snapshot projection into the row store, and
* recovery after an HMR collapse of the declaring entry. */
@@ -155,7 +156,7 @@ describe('locale apply', () => {
const { entry, instance, face } = faceOf(b.slots)
// The inject-time re-sync sealed the init window: the mirror is current.
expect(instance.getSnapshot().active).toBe('en')
expect(instance.getSnapshot().options.map(o => o.id)).toEqual(['zh', 'en', 'ja', 'es', 'fr', 'de', 'ru', 'pt'])
expect(instance.getSnapshot().options.map(o => o.id).sort()).toEqual([...SHIPPED_LOCALE_IDS].sort())
// Copy rides the standard locale seat: the entry declares the namespace.
expect(entry.locale).toBe(SETTINGS_NS)
expect(locale.bind(SETTINGS_NS)('language.title')).toBe('Language')
@@ -103,7 +103,7 @@ describe('document language', () => {
const observer = new MutationObserver(() => {})
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['lang'] })
try {
const remove = locale.addLanguage({ id: 'fr', label: 'Français', fallback: 'en' })
const remove = locale.addLanguage({ id: 'it', label: 'Italiano', fallback: 'en' })
expect(observer.takeRecords()).toHaveLength(0)
remove()
expect(observer.takeRecords()).toHaveLength(0)
@@ -205,7 +205,7 @@ export const guideEs = {
'### Lo que llega al modelo',
'Los resultados de la herramienta llegan primero al programa, que puede filtrarlos y combinarlos. El modelo recibe lo que el programa imprime o devuelve; Los resultados de la imagen se adjuntan por separado. Las llamadas a herramientas anidadas aún se registran y permanecen sujetas a los permisos de la herramienta.',
'### Comparado con el modo Estándar',
'Ambos modos pueden manejar tareas de codificación y por lotes. El modo estándar expone herramientas individuales directamente; PTC organiza llamadas a herramientas en código. El ajuste preestablecido actual PTC ​​deja las herramientas de administración de complementos y flujo de trabajo deshabilitadas. La velocidad y el uso de tokens dependen de la tarea y de cómo el programa maneja sus resultados.',
'Ambos modos pueden manejar tareas de codificación y por lotes. El modo estándar expone herramientas individuales directamente; PTC organiza llamadas a herramientas en código. El ajuste preestablecido actual PTC deja las herramientas de administración de complementos y flujo de trabajo deshabilitadas. La velocidad y el uso de tokens dependen de la tarea y de cómo el programa maneja sus resultados.',
].join('\n\n\n\n'),
guidePtcUsage: [
'### Verifique un conjunto de archivos de configuración',
+1 -1
View File
@@ -1585,7 +1585,7 @@ export const pt = {
'message.turnUsage.reasoning': ' ({tokens} raciocínio)',
'message.turnUsage.count': '{count} tok',
'duration.secondUnit': 's',
'duration.minuteUnit': 'eu ',
'duration.minuteUnit': 'm ',
'duration.hourUnit': 'h ',
'command.running': 'Correndo…',
'command.failed': 'Falha no comando',
@@ -39,7 +39,7 @@ import { derivePresentationPolicy, type CollapseTiming, type FoldedReplies } fro
import { CHAT_FLOW_INJECT, CHAT_NODE_INJECT } from '../src/client/apply.ts'
import { ChatView } from '../src/client/chat/ChatView.tsx'
import { ChatFlow } from '../src/client/chat/ChatFlow.tsx'
import { en, zh } from '../src/client/locale.ts'
import { en, zh, pt } from '../src/client/locale.ts'
import { AssistantNodeView } from '../src/client/chat/AssistantNodeView.tsx'
import { CommandNodeView, ManualCompactionNodeView } from '../src/client/chat/CommandNodeView.tsx'
import {
@@ -4612,3 +4612,7 @@ describe('ChatView', () => {
})
const fileDrag = { prepare: vi.fn(async () => ({ start: () => true, release: () => {} })), reportFailure: vi.fn() }
it('formats Portuguese elapsed minutes with numeric units', () => {
expect(formatRunDuration(90_000, key => pt[key])).toBe('1m 30s')
})
@@ -161,7 +161,7 @@ export const fr = {
'search.placeholder': 'Rechercher…',
'search.aria': 'Options de filtrage',
'status.loading': 'Options de chargement…',
'status.applying': 'Candidature…',
'status.applying': 'Application…',
'status.empty': 'Aucune option',
'overlay.aria': 'Options /{command}',
'listbox.aria': '/{command} correspondances',
@@ -193,7 +193,7 @@ export const de = {
'search.placeholder': 'Suchen…',
'search.aria': 'Filteroptionen',
'status.loading': 'Ladeoptionen…',
'status.applying': 'Bewerben…',
'status.applying': 'Wird angewendet…',
'status.empty': 'Keine Optionen',
'overlay.aria': '/{command}-Optionen',
'listbox.aria': '/{command} stimmt überein',
+20 -20
View File
@@ -140,10 +140,10 @@ export const ja = {
/** Spanish dictionary, checked against the English keys. */
export const es = {
'count.live.one': 'Trabajo en segundo plano {count} ejecutándose',
'count.live.other': 'Trabajos en segundo plano {count} en ejecución',
'count.idle.one': 'Trabajo en segundo plano {count}',
'count.idle.other': 'Trabajos en segundo plano {count}',
'count.live.one': 'Tareas en segundo plano en ejecución: {count}',
'count.live.other': 'Tareas en segundo plano en ejecución: {count}',
'count.idle.one': 'Tareas en segundo plano: {count}',
'count.idle.other': 'Tareas en segundo plano: {count}',
'list.aria': 'Trabajos en segundo plano',
'section.live': 'corriendo',
'section.settledCount': 'Terminado {count}',
@@ -184,10 +184,10 @@ export const es = {
/** French dictionary, checked against the English keys. */
export const fr = {
'count.live.one': "Travail en arrière-plan {count} en cours d'exécution",
'count.live.other': "Travaux en arrière-plan {count} en cours d'exécution",
'count.idle.one': 'Travail en arrière-plan {count}',
'count.idle.other': "Travaux d'arrière-plan {count}",
'count.live.one': 'Tâches en arrière-plan en cours : {count}',
'count.live.other': 'Tâches en arrière-plan en cours : {count}',
'count.idle.one': 'Tâches en arrière-plan : {count}',
'count.idle.other': 'Tâches en arrière-plan : {count}',
'list.aria': 'Travaux en arrière-plan',
'section.live': 'Courir',
'section.settledCount': 'Terminé {count}',
@@ -228,10 +228,10 @@ export const fr = {
/** German dictionary, checked against the English keys. */
export const de = {
'count.live.one': '{count} Hintergrundjob läuft',
'count.live.other': '{count} Hintergrundjobs werden ausgeführt',
'count.idle.one': '{count} Hintergrundjob',
'count.idle.other': '{count} Hintergrundjobs',
'count.live.one': 'Laufende Hintergrundaufgaben: {count}',
'count.live.other': 'Laufende Hintergrundaufgaben: {count}',
'count.idle.one': 'Hintergrundaufgaben: {count}',
'count.idle.other': 'Hintergrundaufgaben: {count}',
'list.aria': 'Hintergrundjobs',
'section.live': 'Laufen',
'section.settledCount': 'Fertig {count}',
@@ -272,10 +272,10 @@ export const de = {
/** Russian dictionary, checked against the English keys. */
export const ru = {
'count.live.one': '{count} фоновое задание выполняется',
'count.live.other': '{count} выполняются фоновые задания',
'count.idle.one': '{count} фоновое задание',
'count.idle.other': '{count} фоновые задания',
'count.live.one': 'Фоновые задачи выполняются: {count}',
'count.live.other': 'Фоновые задачи выполняются: {count}',
'count.idle.one': 'Фоновые задачи: {count}',
'count.idle.other': 'Фоновые задачи: {count}',
'list.aria': 'Фоновые задания',
'section.live': 'Бег',
'section.settledCount': 'Закончено {count}',
@@ -316,10 +316,10 @@ export const ru = {
/** Portuguese dictionary, checked against the English keys. */
export const pt = {
'count.live.one': '{count} trabalho em segundo plano em execução',
'count.live.other': '{count} jobs em segundo plano em execução',
'count.idle.one': '{count} tarefa em segundo plano',
'count.idle.other': '{count} trabalhos em segundo plano',
'count.live.one': 'Tarefas em segundo plano em execução: {count}',
'count.live.other': 'Tarefas em segundo plano em execução: {count}',
'count.idle.one': 'Tarefas em segundo plano: {count}',
'count.idle.other': 'Tarefas em segundo plano: {count}',
'list.aria': 'Trabalhos em segundo plano',
'section.live': 'Correndo',
'section.settledCount': 'Concluído {count}',
@@ -5,7 +5,7 @@ import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import type { JobsSnapshot, JobView, ObservedJob } from '@deepseek-ai/dsh-api-job-controller/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { JobListAction, type JobListActionProps } from '../src/client/JobListAction.tsx'
import { zh } from '../src/client/locales.ts'
import { zh, ru } from '../src/client/locales.ts'
afterEach(() => {
cleanup()
@@ -590,3 +590,12 @@ describe('JobListAction human kill', () => {
expect(document.querySelector('[data-kill-state]')).toBeNull()
})
})
it.each([1, 2, 5, 21])('renders count-independent Russian labels for %i jobs', (count) => {
const jobs = Array.from({ length: count }, (_, i) => job({ id: `job-${i}` as JobView['id'] }))
const translate: JobListActionProps['t'] = makeTranslate(ru)
const view = render(<JobListAction {...props(jobs)} t={translate} />)
expect(screen.getByRole('button', { name: `Фоновые задачи выполняются: ${count}` })).toBeDefined()
view.rerender(<JobListAction {...props(jobs.map(item => ({ ...item, status: 'completed', finishedAt: 1_700_000_003_000 })))} t={translate} />)
expect(screen.getByRole('button', { name: `Фоновые задачи: ${count}` })).toBeDefined()
})
@@ -627,7 +627,7 @@ export const fr = {
'timing.time': 'Temps',
'timing.hour': 'Heure',
'timing.minute': 'Minutes',
'timing.second': 'Deuxième',
'timing.second': 'Seconde',
'timing.prevMonth': 'Mois précédent',
'timing.nextMonth': 'Le mois prochain',
'timing.zone': 'Fuseau horaire',
@@ -768,7 +768,7 @@ export const de = {
'timing.time': 'Zeit',
'timing.hour': 'Stunde',
'timing.minute': 'Minute',
'timing.second': 'Zweitens',
'timing.second': 'Sekunde',
'timing.prevMonth': 'Vorheriger Monat',
'timing.nextMonth': 'Nächsten Monat',
'timing.zone': 'Zeitzone',
@@ -803,7 +803,7 @@ export const de = {
'rule.weekdayOption': 'Wochentag {weekday}',
'rule.unsaved': 'Nicht gespeicherte Änderungen',
'rule.save': 'Änderungen speichern',
'rule.saving': 'Sparen…',
'rule.saving': 'Speichern…',
'rule.cancel': 'Abbrechen',
'rule.invalidTitle': 'Geben Sie einen Aufgabennamen mit maximal 120 Zeichen ein.',
'rule.invalidPrompt': 'Geben Sie eine Aufgabenanweisung ein.',
@@ -909,7 +909,7 @@ export const ru = {
'timing.time': 'Время',
'timing.hour': 'Час',
'timing.minute': 'минута',
'timing.second': 'Второй',
'timing.second': 'Секунда',
'timing.prevMonth': 'Предыдущий месяц',
'timing.nextMonth': 'В следующем месяце',
'timing.zone': 'Часовой пояс',
@@ -108,7 +108,7 @@ export const de = {
readOnly: 'Diese Bereitstellung speichert Einstellungen schreibgeschützt.',
unavailable: 'Dieses Plugin ist nicht geladen und kann daher derzeit nicht konfiguriert werden.',
save: 'Speichern',
saving: 'Sparen…',
saving: 'Speichern…',
saveFailed: 'Die Bereitstellung hat diese Werte nicht akzeptiert. Sie mussten sie korrigieren.',
invalidNumber: 'Geben Sie eine Zahl ein oder lassen Sie das Feld leer, um den Standardwert zu verwenden.',
} satisfies Record<keyof typeof en, string>
@@ -462,7 +462,7 @@ export const es = {
loadFailed: 'Error al cargar el directorio de proveedores',
conflict: 'Alguien más cambió esta configuración mientras esta tarjeta estaba abierta. Ciérrelo y vuelva a abrirlo para editar los valores actuales.',
retry: 'Reintentar',
keyInput: 'Tecla API',
keyInput: 'Clave de API',
keyReveal: 'Mostrar secreto',
keyHide: 'Ocultar secreto',
keyPlaceholder: 'Ingrese su clave API',
@@ -593,8 +593,8 @@ export const fr = {
provider: 'Fournisseur',
close: 'Fermer',
cancel: 'Annuler',
apply: 'Postuler',
applying: 'Candidature…',
apply: 'Appliquer',
applying: 'Application…',
savedProvider: '{provider} enregistré.',
credentialConfigured: 'Clé API configurée',
credentialMissing: 'Clé API manquante',
@@ -660,8 +660,8 @@ export const fr = {
modelIdRequired: 'L’ID du modèle est requis.',
modelIdDuplicate: "L'ID du modèle doit être unique.",
modelNameInvalid: 'Le nom d’affichage ne peut pas être vide.',
modelContextInvalid: 'La fenêtre contextuelle doit être un nombre positif, comme 131072, 256 Ko ou 1 M.',
modelMaxTokensInvalid: 'Le nombre maximum de jetons de sortie doit être un nombre positif, comme 8 192, 64 Ko ou 1 M.',
modelContextInvalid: 'La fenêtre contextuelle doit être un nombre positif, comme 131072, 256K ou 1M.',
modelMaxTokensInvalid: 'Le nombre maximum de jetons de sortie doit être un nombre positif, comme 8192, 64K ou 1M.',
advancedHint: "D'autres champs se trouvent dans cordis.patch.yml ; modifiez cette section directement.",
modelCapacityInvalid: 'Une capacité doit être un nombre, éventuellement suivi du suffixe K ou M.',
modelDuplicate: 'Chaque ID de modèle peut apparaître une fois.',
@@ -727,15 +727,15 @@ export const de = {
addMode: 'So fügen Sie hinzu',
addCatalog: 'Drittanbieter von Modellen',
addCustom: 'Kundenspezifisches Modell API',
addCatalogHint: 'Wählen Sie OpenAI, Anthropic, Kimi oder einen anderen Anbieter aus dem integrierten Katalog aus und geben Sie den Schlüssel API ein.',
addCatalogHint: 'Wählen Sie OpenAI, Anthropic, Kimi oder einen anderen Anbieter aus dem integrierten Katalog aus und geben Sie den API-Schlüssel ein.',
addCustomHint: 'Verbinden Sie ein Relay, einen selbst gehosteten Server oder einen anderen OpenAI- oder Anthropic-kompatiblen Endpunkt über seine Basis-URL, sein Protokoll und seine Modelle.',
addCatalogExhausted: 'Jeder Kataloganbieter ist bereits konfiguriert.',
addCustomUnavailable: 'Es ist kein API-Protokoll zur Deklaration verfügbar.',
provider: 'Anbieter',
close: 'Schließen',
cancel: 'Abbrechen',
apply: 'Bewerben',
applying: 'Bewerben…',
apply: 'Anwenden',
applying: 'Wird angewendet…',
savedProvider: 'Gespeichert {provider}.',
credentialConfigured: 'API-Schlüssel konfiguriert',
credentialMissing: 'API-Schlüssel fehlt',
@@ -795,14 +795,14 @@ export const de = {
reorderModel: 'Modell neu anordnen',
reorderModelHelp: 'Ziehen Sie, um die Reihenfolge neu anzuordnen, oder verwenden Sie die Aufwärts- und Abwärtspfeiltasten.',
modelsEmpty: 'Im Selektor werden keine Modelle angezeigt. Nicht gelistete Ausweise können weiterhin direkt versendet werden.',
keyBlank: 'Geben Sie den Schlüssel API ein oder lassen Sie das Feld leer, um den gespeicherten Schlüssel beizubehalten.',
keyBlank: 'Geben Sie den API-Schlüssel ein oder lassen Sie das Feld leer, um den gespeicherten Schlüssel beizubehalten.',
keyBlankNew: 'Geben Sie den API-Schlüssel ein oder lassen Sie das Feld leer, wenn dieser Anbieter auf andere Weise authentifiziert.',
keyIllegalCharacters: 'Dieser API-Schlüssel hat kein gültiges Format. Bitte überprüfen Sie es.',
modelIdRequired: 'Modell-ID ist erforderlich.',
modelIdDuplicate: 'Die Modell-ID muss eindeutig sein.',
modelNameInvalid: 'Der Anzeigename darf nicht leer sein.',
modelContextInvalid: 'Das Kontextfenster muss eine positive Zahl sein, z. B. 131072, 256 KB oder 1 Mio.',
modelMaxTokensInvalid: 'Die maximale Anzahl an Ausgabetokens muss eine positive Zahl sein, z. B. 8192, 64 KB oder 1 Mio.',
modelContextInvalid: 'Das Kontextfenster muss eine positive Zahl sein, z. B. 131072, 256K oder 1M.',
modelMaxTokensInvalid: 'Die maximale Anzahl an Ausgabetokens muss eine positive Zahl sein, z. B. 8192, 64K oder 1M.',
advancedHint: 'Andere Felder befinden sich in cordis.patch.yml; Bearbeiten Sie diesen Abschnitt direkt.',
modelCapacityInvalid: 'Eine Kapazität muss eine Zahl sein, optional mit dem Suffix K oder M.',
modelDuplicate: 'Jede Modell-ID darf einmal vorkommen.',
@@ -845,7 +845,7 @@ export const de = {
onboardingDescription: 'Konfigurieren Sie den offiziellen DeepSeek-Anbieter, um mit der Erstellung zu beginnen.',
onboardingLater: 'Später konfigurieren',
onboardingSave: 'Speichern und fortfahren',
onboardingSaving: 'Sparen…',
onboardingSaving: 'Speichern…',
keyRequired: 'Geben Sie einen API-Schlüssel ein, um fortzufahren.',
} satisfies Record<keyof typeof en, string>
@@ -942,8 +942,8 @@ export const ru = {
modelIdRequired: 'Требуется идентификатор модели.',
modelIdDuplicate: 'Идентификатор модели должен быть уникальным.',
modelNameInvalid: 'Отображаемое имя не может быть пустым.',
modelContextInvalid: 'Контекстное окно должно иметь положительное значение, например 131072, 256 КБ или 1 М.',
modelMaxTokensInvalid: 'Максимальное количество выходных токенов должно иметь положительное значение, например 8192, 64 КБ или 1 М.',
modelContextInvalid: 'Контекстное окно должно иметь положительное значение, например 131072, 256K или 1M.',
modelMaxTokensInvalid: 'Максимальное количество выходных токенов должно иметь положительное значение, например 8192, 64K или 1M.',
advancedHint: 'Остальные поля находятся в файле cordis.patch.yml; отредактируйте этот раздел напрямую.',
modelCapacityInvalid: 'Емкость должна быть числом, возможно с суффиксом K или M.',
modelDuplicate: 'Каждый идентификатор модели может появиться один раз.',
@@ -1128,5 +1128,5 @@ export const pt = {
onboardingLater: 'Configurar mais tarde',
onboardingSave: 'Salvar e continuar',
onboardingSaving: 'Salvando…',
keyRequired: 'Insira uma tecla API para continuar.',
keyRequired: 'Insira uma chave de API para continuar.',
} satisfies Record<keyof typeof en, string>
@@ -1,3 +1,4 @@
import { es, fr, de, ru, pt } from '../src/client/locales.ts'
// @vitest-environment jsdom
/** Section, setup-card, and hand-written editor behavior over a scripted wire face. */
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
@@ -2423,3 +2424,16 @@ it('renders the localized account row as signed in without an edit action', asyn
expect(within(item).getByText(en.accountSignedIn)).toBeTruthy()
expect(within(item).queryByText(en.edit)).toBeNull()
})
it.each([es, fr, de, ru, pt])('keeps capacity examples in translated validation messages parseable', (dictionary) => {
for (const [key, examples] of [
['modelContextInvalid', ['131072', '256K', '1M']],
['modelMaxTokensInvalid', ['8192', '64K', '1M']],
] as const) {
for (const example of examples) {
const token = dictionary[key].match(new RegExp(`(?<![0-9])${example}(?![0-9A-Za-z])`, 'u'))?.[0]
expect(token, dictionary[key]).toBe(example)
expect(parseCapacity(token!)).toBeGreaterThan(0)
}
}
})
@@ -121,7 +121,7 @@ export const de = {
readOnly: 'Diese Bereitstellung speichert Einstellungen schreibgeschützt.',
unavailable: 'Dieses Plugin ist nicht geladen und kann daher derzeit nicht konfiguriert werden.',
save: 'Speichern',
saving: 'Sparen…',
saving: 'Speichern…',
saveFailed: 'Die Bereitstellung hat diese Werte nicht akzeptiert. Sie mussten sie korrigieren.',
invalidNumber: 'Geben Sie eine Zahl ein oder lassen Sie das Feld leer, um den Standardwert zu verwenden.',
} satisfies Record<keyof typeof en, string>
@@ -232,7 +232,7 @@ export const de = {
readOnly: 'Diese Bereitstellung speichert Einstellungen schreibgeschützt.',
unavailable: 'Dieses Plugin ist nicht geladen und kann daher derzeit nicht konfiguriert werden.',
save: 'Speichern',
saving: 'Sparen…',
saving: 'Speichern…',
saveFailed: 'Die Bereitstellung hat diese Werte nicht akzeptiert. Sie mussten sie korrigieren.',
subagentTitle: 'Subagent',
subagentDescription: 'Legen Sie die Rekursionstiefe, Anzahl und Modelle des Subagenten fest.',
@@ -120,7 +120,7 @@ export const es = {
useSearchApiHint: 'Desactivado de forma predeterminada: la búsqueda se ejecuta a través de Mensajes DeepSeek. Para conocer las características y los precios del nuevo Web Search API, consulte el ',
searchGuide: 'Guía de búsqueda y recuperación',
searchGuideUrl: 'https://api-docs.deepseek.com/guides/search',
apiKey: 'Tecla API',
apiKey: 'Clave de API',
apiKeyHint: 'Almacenado fuera del archivo de configuración. Déjelo en blanco para conservar la clave actual.',
apiKeySet: 'Se configura una clave.',
apiKeyUnset: 'No hay ninguna clave configurada; solo se pueden buscar conversaciones que utilicen un modelo de cuenta DeepSeek, a través del punto final predeterminado.',
@@ -199,7 +199,7 @@ export const de = {
readOnly: 'Diese Bereitstellung speichert Einstellungen schreibgeschützt.',
unavailable: 'Dieses Plugin ist nicht geladen und kann daher derzeit nicht konfiguriert werden.',
save: 'Speichern',
saving: 'Sparen…',
saving: 'Speichern…',
saveFailed: 'Die Bereitstellung hat diese Werte nicht akzeptiert. Sie mussten sie korrigieren.',
invalidNumber: 'Geben Sie eine Zahl ein oder lassen Sie das Feld leer, um den Standardwert zu verwenden.',
invalidCount: 'Geben Sie eine Ganzzahl zwischen 1 und 50 ein oder lassen Sie das Feld leer, um den Standardwert zu verwenden.',
@@ -232,7 +232,7 @@ export const de = {
'global-hint': 'Von überall aus öffnen', 'clear-search': 'Suche löschen',
title: 'Tastaturkürzel', open: 'Öffnen Sie Tastaturkürzel', settings: 'Tastaturkürzel', view: 'Verknüpfungen bearbeiten',
description: 'Verfügbare Verknüpfungen und Eingabeaktionen anzeigen und bearbeiten', search: 'Suchverknüpfungen', close: 'Tastaturkürzel schließen',
application: 'Bewerbung', input: 'Nachrichteneingabe', menus: 'Menüs und Dialoge', approval: 'Genehmigungsbereich', unbound: 'Keine Abkürzung',
application: 'Anwendung', input: 'Nachrichteneingabe', menus: 'Menüs und Dialoge', approval: 'Genehmigungsbereich', unbound: 'Keine Abkürzung',
empty: 'Keine passenden Verknüpfungen',
move: 'Menüauswahl verschieben', select: 'Menüpunkt auswählen', dismiss: 'Menü oder oberen Dialog schließen',
} satisfies Record<keyof typeof en, string>
@@ -11,7 +11,7 @@ import { BrowserTitle } from '../src/client/view/BrowserTitle.tsx'
import type { BrowserInjected } from '../src/client/browser/BrowserController.ts'
import { BROWSER_ID, BROWSER_KIND } from '../src/client/definition.tsx'
import { apply, inject } from '../src/client/index.ts'
import { en, ja, zh } from '../src/client/locales.ts'
import { en, ja, zh, es, fr, de, ru, pt } from '../src/client/locales.ts'
import { createBrowserStore } from '../src/client/browser/store.ts'
import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
import type { DesktopBrowserBridge, DesktopBrowserLeaseId } from '../src/types.ts'
@@ -125,7 +125,7 @@ describe('ui-sidebar-browser apply', () => {
expect(definition?.title('sidebar://browser')).toBe('type.label')
expect(definition?.guide?.map(entry => [entry.order, entry.title(), entry.description?.()]))
.toEqual([[30, 'guide.title', 'guide.description']])
expect(dictionaries.get('sidebarBrowser')).toEqual({ zh, en, ja })
expect(dictionaries.get('sidebarBrowser')).toEqual({ zh, en, ja, es, fr, de, ru, pt })
expect(dictionaries.get('sidebarBrowser')).toMatchObject({
zh: { 'guide.description': '浏览网页' },
en: { 'guide.description': 'Browse web pages' },
@@ -31,7 +31,7 @@ import { LazyPdfBody } from '../src/client/pdf/LazyPdfBody.tsx'
import { LazyExcelBody } from '../src/client/excel/LazyExcelBody.tsx'
import { PDF_BODY_ID } from '../src/client/pdf/index.ts'
import { CodeBody } from '../src/client/code/CodeBody.tsx'
import { en, ja, zh } from '../src/client/locales.ts'
import { en, ja, zh, es, fr, de, ru, pt } from '../src/client/locales.ts'
import type { textFace } from '../src/client/face.ts'
import type { TextStore } from '../src/client/store.ts'
import { FILE, SESSION, TAB_ID, byteResult, createResources, page } from './fixtures.client.ts'
@@ -86,7 +86,7 @@ describe('ui-sidebar-documentpreview apply', () => {
const { tabs, registered, dictionaries } = await boot()
expect(tabs.get(TEXTPREVIEW_KIND)?.priority).toBe('fallback')
expect(tabs.get(TEXTPREVIEW_KIND)?.id).toBe(TEXTPREVIEW_ID)
expect(dictionaries.get('sidebarDocumentPreview')).toEqual({ zh, en, ja })
expect(dictionaries.get('sidebarDocumentPreview')).toEqual({ zh, en, ja, es, fr, de, ru, pt })
// The seat key is the implementation's id, not the kind: an extension may
// take the kind over, and the seat must still find this body.
expect(registered.map(entry => [entry.name, entry.key, entry.locale, entry.component])).toEqual([
@@ -6,7 +6,7 @@ import { binaryDocumentPath, DocumentPreviewRegistry } from '../src/client/docum
import { Config } from '../src/config.ts'
import { apply } from '../src/client/excel/index.ts'
import { LazyExcelBody } from '../src/client/excel/LazyExcelBody.tsx'
import { en, ja, zh } from '../src/client/excel/locales.ts'
import { en, ja, zh, es, fr, de, ru, pt } from '../src/client/excel/locales.ts'
it('registers complete binary reads and removes the slot and locale on disposal', async () => {
const ctx = new Context()
@@ -28,7 +28,7 @@ it('registers complete binary reads and removes the slot and locale on disposal'
expect(candidate.title()).toBe('Spreadsheet')
expect(binaryDocumentPath(registry.getSnapshot(), path)).toBe(/\.(xlsx|xls)$/iu.test(path))
}
expect(registerLocale).toHaveBeenCalledWith('sidebarExcel', { zh, en, ja })
expect(registerLocale).toHaveBeenCalledWith('sidebarExcel', { zh, en, ja, es, fr, de, ru, pt })
expect(register).toHaveBeenCalledOnce()
expect(register.mock.calls[0]![1]).toBe(LazyExcelBody)
const options = register.mock.calls[0]![0] as { inject: () => unknown }
@@ -7,7 +7,7 @@ import { sessionFileAddress } from '@deepseek-ai/dsh-util-workspace-path'
import { DocumentPreviewRegistry } from '../src/client/document/registry.ts'
import { apply, HTML_BODY_ID, htmlBodyDefinition } from '../src/client/html/index.ts'
import { HtmlBody } from '../src/client/html/HtmlBody.tsx'
import { en, ja, zh } from '../src/client/html/locales.ts'
import { en, ja, zh, es, fr, de, ru, pt } from '../src/client/html/locales.ts'
import type { HtmlBodyProps } from '../src/client/html/HtmlBody.tsx'
type Registration = {
@@ -54,7 +54,7 @@ describe('HTML registration', () => {
await fiber.await()
expect(registry.candidates('INDEX.HTM').map(entry => entry.id)).toEqual([HTML_BODY_ID])
expect(registry.getSnapshot()[0]?.title()).toBe(en.title)
expect(dictionaries.get('documentHtml')).toEqual({ zh, en, ja })
expect(dictionaries.get('documentHtml')).toEqual({ zh, en, ja, es, fr, de, ru, pt })
expect(register).toHaveBeenCalledOnce()
const registration = register.mock.calls[0]?.[0]
expect(registration).toMatchObject({ name: 'sidebar.right.tab.document', key: HTML_BODY_ID, locale: 'documentHtml' })
@@ -6,7 +6,7 @@ import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
import { DocumentPreviewRegistry } from '../src/client/document/registry.ts'
import { ImageBody } from '../src/client/image/ImageBody.tsx'
import { apply, BINARY_IMAGE_EXTENSIONS, IMAGE_BODY_ID, IMAGE_EXTENSIONS, imageBodyDefinition } from '../src/client/image/index.ts'
import { en, ja, zh } from '../src/client/image/locales.ts'
import { en, ja, zh, es, fr, de, ru, pt } from '../src/client/image/locales.ts'
import type { ZoomInjected, ZoomStore } from '../src/client/zoom/store.ts'
let dispose: (() => Promise<void>) | undefined
@@ -64,7 +64,7 @@ describe('image registration', () => {
expect(registry.candidates(`ASSET.${extension}`).map(entry => entry.id)).toEqual([IMAGE_BODY_ID])
}
expect(registry.getSnapshot()[0]?.title()).toBe(en.title)
expect(dictionaries.get('sidebarImage')).toEqual({ zh, en, ja })
expect(dictionaries.get('sidebarImage')).toEqual({ zh, en, ja, es, fr, de, ru, pt })
expect(register).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({
name: 'sidebar.right.tab.document', key: IMAGE_BODY_ID, locale: 'sidebarImage',
}), ImageBody)
@@ -13,7 +13,7 @@ import { Config } from '../src/config.ts'
import { OfficeBody, type OfficeBodyInjected } from '../src/client/office/OfficeBody.tsx'
import type { OfficeStore } from '../src/client/office/store.ts'
import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
import { en, ja, zh } from '../src/client/office/locales.ts'
import { en, ja, zh, es, fr, de, ru, pt } from '../src/client/office/locales.ts'
import { en as documentEn } from '../src/client/locales.ts'
vi.mock('../src/client/office/face.ts', async (importOriginal) => {
@@ -91,7 +91,7 @@ async function harness(config: Partial<Config['office']> = {}, missing?: 'remote
it.each(['remote', 'render', 'files'] as const)('keeps Word and PowerPoint registration and guidance when %s is absent', async (missing) => {
const h = await harness(undefined, missing)
try {
expect(h.locale.register).toHaveBeenCalledWith('sidebarOffice', { zh, en, ja })
expect(h.locale.register).toHaveBeenCalledWith('sidebarOffice', { zh, en, ja, es, fr, de, ru, pt })
for (const path of ['a.DOC', 'b.DOCX', 'c.PPT', 'd.pptx']) {
expect(h.registry.candidates(path)[0]!.binaryExtensions).toEqual(['doc', 'docx', 'ppt', 'pptx'])
expect(h.registry.candidates(path)[0]!.title()).toBe(en.title)
@@ -16,7 +16,7 @@ const renderedPdf = vi.hoisted(() => vi.fn(() => null))
vi.mock('../src/client/pdf/pdf.tsx', () => ({ PdfBody: renderedPdf }))
import { apply, PDF_BODY_ID } from '../src/client/pdf/index.ts'
import { LazyPdfBody } from '../src/client/pdf/LazyPdfBody.tsx'
import { en, ja, zh } from '../src/client/pdf/locales.ts'
import { en, ja, zh, es, fr, de, ru, pt } from '../src/client/pdf/locales.ts'
afterEach(() => { cleanup(); renderedPdf.mockClear() })
@@ -58,7 +58,7 @@ describe('PDF registration', () => {
{ id: PDF_BODY_ID, extensions: ['pdf'], priority: 'builtin', loading: 'bytes-complete', wrap: false },
])
expect(previews.candidates('report.pdf')[0]!.title()).toBe('PDF')
expect(dictionaries.get('sidebarPdf')).toEqual({ zh, en, ja })
expect(dictionaries.get('sidebarPdf')).toEqual({ zh, en, ja, es, fr, de, ru, pt })
expect(entries[0]).toMatchObject({ name: 'sidebar.right.tab.document', key: PDF_BODY_ID, locale: 'sidebarPdf' })
const instance = entries[0]!.store.create()
const face = entries[0]!.inject('s1' as SessionId, instance.actions)
@@ -16,7 +16,7 @@ import { apply, inject } from '../src/client/index.ts'
import { apply as hostApply } from '../src/index.ts'
import { FilesBody } from '../src/client/FilesBody.tsx'
import { FilesTitle } from '../src/client/FilesTitle.tsx'
import { en, ja, zh } from '../src/client/locales.ts'
import { en, ja, zh, es, fr, de, ru, pt } from '../src/client/locales.ts'
interface Recorded {
name: string
@@ -94,7 +94,7 @@ describe('ui-sidebar-files apply', () => {
expect(definition?.title('sidebar://files')).toBe('type.label')
expect(definition?.guide?.map(entry => [entry.order, entry.title(), entry.description?.()]))
.toEqual([[10, 'guide.title', 'guide.description']])
expect(dictionaries.get('sidebarFiles')).toEqual({ zh, en, ja })
expect(dictionaries.get('sidebarFiles')).toEqual({ zh, en, ja, es, fr, de, ru, pt })
// The seat key is the implementation's id, not the kind: an extension may
// take the kind over, and the seat must still find this body.
expect(registered.map(entry => [entry.name, entry.key, entry.locale, entry.component])).toEqual([
@@ -27,7 +27,7 @@ import { ExpandButton } from '../src/client/shell/ExpandButton.tsx'
import { GuideBody } from '../src/client/tabs/guide/GuideBody.tsx'
import { GuideTitle } from '../src/client/tabs/guide/GuideTitle.tsx'
import { GUIDE_ID } from '../src/client/tabs/guide/definition.ts'
import { en, ja, zh } from '../src/client/locales.ts'
import { en, ja, zh, es, fr, de, ru, pt } from '../src/client/locales.ts'
const SHORTCUT_CATALOG: readonly never[] = []
@@ -124,7 +124,7 @@ describe('ui-sidebar-right apply', () => {
expect(ctx.sidebarRightTabs).toBeInstanceOf(SidebarRightTabRegistry)
expect(ctx.sidebarRight).toBeInstanceOf(SidebarRightController)
expect('adopt' in ctx.sidebarRight).toBe(false)
expect(dictionaries.get('sidebarRight')).toEqual({ zh, en, ja })
expect(dictionaries.get('sidebarRight')).toEqual({ zh, en, ja, es, fr, de, ru, pt })
const guide = ctx.sidebarRightTabs.get('guide')
expect(guide?.id).toBe(GUIDE_ID)
expect(guide?.priority).toBe('builtin')
@@ -18,7 +18,7 @@ import { TerminalGuide, type TerminalGuideInjected } from '../src/client/Termina
import { LazyTerminalBody } from '../src/client/LazyTerminalBody.tsx'
import { TerminalTitle } from '../src/client/TerminalTitle.tsx'
import type { TerminalBodyInjected } from '../src/client/face.ts'
import { en, ja, zh } from '../src/client/locales.ts'
import { en, ja, zh, es, fr, de, ru, pt } from '../src/client/locales.ts'
vi.mock('@xterm/xterm', () => ({ Terminal: vi.fn() }))
const SHORTCUT_CATALOG: readonly never[] = []
@@ -97,7 +97,7 @@ it('registers terminal views without recovery or cleanup slots, then releases co
expect(renderToStaticMarkup(createElement(Icon, { size: 22 }))).toContain('width="22"')
expect(renderToStaticMarkup(createElement(Icon))).toContain('width="36"')
expect(definition.multiple).toBe(true)
expect(h.dictionaries.get('sidebarTerminal')).toEqual({ en, zh, ja })
expect(h.dictionaries.get('sidebarTerminal')).toEqual({ en, zh, ja, es, fr, de, ru, pt })
expect(h.entries.map(entry => [entry.name, entry.component, entry.locale])).toEqual([
['sidebar.right.tab.guide.entry', TerminalGuide, 'sidebarTerminal'],
['sidebar.right.pane.tab', LazyTerminalBody, 'sidebarTerminal'],
@@ -1,3 +1,4 @@
import { es, fr, de, ru, pt } from '../src/client/locales.ts'
/**
* ui-skill browser half: source and keyed toolview registration +
* locale dictionaries + source duplicate-name proof +
@@ -141,6 +142,7 @@ describe('apply', () => {
expect(entry?.component).toBe(SkillToolRow)
expect(presentation.dictionaries).toEqual([{
namespace: 'skill', dictionaries: {
es, fr, de, ru, pt,
zh: {
'row.title': '加载技能',
'row.running': '正在加载 skill',
@@ -664,7 +664,7 @@ export const es = {
'timing.sessionTimestampsRunning': 'Marcas de tiempo de sesión (en ejecución)',
'timing.request': 'Solicitar tiempo',
'unit.milliseconds': '{value} ms',
'unit.seconds': '{value}',
'unit.seconds': '{value} s',
'unit.tokens': 'Tok {value}',
'unit.tokensPerSecond': '{value} tok/s',
'usage.tokens': 'Fichas',
@@ -1449,7 +1449,7 @@ export const pt = {
'timing.request': 'Tempo de solicitação',
'unit.milliseconds': '{value} ms',
'unit.seconds': '{value} segundos',
'unit.tokens': '{value} valor',
'unit.tokens': '{value} tok',
'unit.tokensPerSecond': '{value} tok/s',
'usage.tokens': 'Fichas',
'usage.reasoning': 'Raciocínio',
@@ -1,3 +1,5 @@
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { es, pt } from '../src/client/locales.ts'
// @vitest-environment jsdom
/** Trajectory ledger selection, details, status, and fold behavior. */
@@ -1350,3 +1352,8 @@ describe('TrajectoryTable', () => {
expect(onInspectApplied).not.toHaveBeenCalled()
})
})
it('preserves seconds and token units in translated metric labels', () => {
expect(makeTranslate(es)('unit.seconds', { value: '1.25' })).toBe('1.25 s')
expect(makeTranslate(pt)('unit.tokens', { value: 42 })).toBe('42 tok')
})
@@ -1,6 +1,6 @@
{
"meta": {
"title": "Lokaler Speicher für Anmeldeinformationen",
"description": "Speichern Sie Anmeldeinformationen wie den Schlüssel API und lesen Sie Umgebungsvariablen und lokale Anmeldeinformationsdateien in der Reihenfolge ihrer Priorität."
"description": "Speichern Sie Anmeldeinformationen wie den API-Schlüssel und lesen Sie Umgebungsvariablen und lokale Anmeldeinformationsdateien in der Reihenfolge ihrer Priorität."
}
}
@@ -138,7 +138,7 @@ export const de = {
translating: 'Argumentation übersetzen', invalidProvider: 'Wählen Sie einen Übersetzungsdienst.', invalidLanguage: 'Geben Sie auto oder einen Sprachcode ein, z. B. zh, en oder ja.',
overridden: 'Überschrieben', reset: 'Auf Standard zurücksetzen',
unavailable: 'Übersetzungseinstellungen sind nicht verfügbar.', readOnly: 'Diese Bereitstellung speichert Einstellungen schreibgeschützt.',
saveFailed: 'Die Bereitstellung hat diese Werte nicht akzeptiert. Sie mussten sie korrigieren.', save: 'Speichern', saving: 'Sparen…',
saveFailed: 'Die Bereitstellung hat diese Werte nicht akzeptiert. Sie mussten sie korrigieren.', save: 'Speichern', saving: 'Speichern…',
copy: 'Kopieren', copied: 'Kopiert', codeTitle: 'Codeblock', wrap: 'Zeilen umbrechen', unwrap: 'Zeilen abwickeln', footnotes: 'Fußnoten',
} satisfies Record<keyof typeof en, string>
@@ -90,7 +90,7 @@ export const zh = {
inputLevel: '麦克风输入电平',
deviceUnavailable: '不可用',
deviceMissing: '麦克风不可用,请选择其他输入设备',
auto: '自动识别', zh: '中文', en: '英语', yue: '粤语', ja: '日语', es: '日语', fr: '日语', de: '日语', ru: '日语', pt: '日语', ko: '韩语',
auto: '自动识别', zh: '中文', en: '英语', yue: '粤语', ja: '日语', es: '西班牙语', fr: '法语', de: '德语', ru: '俄语', pt: '葡萄牙语', ko: '韩语',
start: '开始录音', stop: '停止并识别', cancel: '取消', insert: '插入文字',
loading: '正在读取识别服务…', requesting: '请允许使用麦克风…', recording: '正在录音…',
interrupted: '录音中断,请重试。',
@@ -194,7 +194,7 @@ export const en: Record<VoiceKey, string> = {
inputLevel: 'Microphone input level',
deviceUnavailable: 'Unavailable',
deviceMissing: 'Microphone unavailable. Choose another input device.',
auto: 'Detect automatically', zh: 'Chinese', en: 'English', yue: 'Cantonese', ja: 'Japanese', es: 'Japanese', fr: 'Japanese', de: 'Japanese', ru: 'Japanese', pt: 'Japanese', ko: 'Korean',
auto: 'Detect automatically', zh: 'Chinese', en: 'English', yue: 'Cantonese', ja: 'Japanese', es: 'Spanish', fr: 'French', de: 'German', ru: 'Russian', pt: 'Portuguese', ko: 'Korean',
start: 'Start recording', stop: 'Stop and transcribe', cancel: 'Cancel', insert: 'Insert text',
loading: 'Loading recognition services…', requesting: 'Allow microphone access to continue…', recording: 'Recording…',
interrupted: 'Recording was interrupted. Please try again.',
@@ -307,7 +307,7 @@ export const ja = {
'zh': '中国語',
'en': '英語',
'yue': '広東語',
'ja': '日本語', es: '日本語', fr: '日本語', de: '日本語', ru: '日本語', pt: '日本語',
'ja': '日本語', es: 'スペイン語', fr: 'フランス語', de: 'ドイツ語', ru: 'ロシア語', pt: 'ポルトガル語',
'ko': '韓国語',
'start': '録音を開始',
'stop': '停止して文字起こし',
@@ -415,7 +415,7 @@ export const es = {
inputLevel: 'Nivel de entrada del micrófono',
deviceUnavailable: 'No disponible',
deviceMissing: 'Micrófono no disponible. Elija otro dispositivo de entrada.',
auto: 'Detectar automáticamente', zh: 'chino', en: 'ingles', yue: 'cantonés', ja: 'japonés', es: 'japonés', fr: 'japonés', de: 'japonés', ru: 'japonés', pt: 'japonés', ko: 'coreano',
auto: 'Detectar automáticamente', zh: 'chino', en: 'ingles', yue: 'cantonés', ja: 'japonés', es: 'español', fr: 'francés', de: 'alemán', ru: 'ruso', pt: 'portugués', ko: 'coreano',
start: 'Empezar a grabar', stop: 'Detener y transcribir', cancel: 'Cancelar', insert: 'Insertar texto',
loading: 'Cargando servicios de reconocimiento…', requesting: 'Permitir el acceso al micrófono para continuar...', recording: 'Grabando…',
interrupted: 'La grabación fue interrumpida. Por favor inténtalo de nuevo.',
@@ -516,7 +516,7 @@ export const fr = {
inputLevel: "Niveau d'entrée du microphone",
deviceUnavailable: 'Indisponible',
deviceMissing: "Micro indisponible. Choisissez un autre périphérique d'entrée.",
auto: 'Détecter automatiquement', zh: 'Chinois', en: 'Anglais', yue: 'Cantonais', ja: 'Japonais', es: 'Japonais', fr: 'Japonais', de: 'Japonais', ru: 'Japonais', pt: 'Japonais', ko: 'Coréen',
auto: 'Détecter automatiquement', zh: 'Chinois', en: 'Anglais', yue: 'Cantonais', ja: 'Japonais', es: 'Espagnol', fr: 'Français', de: 'Allemand', ru: 'Russe', pt: 'Portugais', ko: 'Coréen',
start: "Commencer l'enregistrement", stop: 'Arrêtez et transcrivez', cancel: 'Annuler', insert: 'Insérer du texte',
loading: 'Chargement des services de reconnaissance…', requesting: 'Autoriser l’accès au microphone pour continuer…', recording: 'Enregistrement…',
interrupted: "L'enregistrement a été interrompu. Veuillez réessayer.",
@@ -617,7 +617,7 @@ export const de = {
inputLevel: 'Mikrofoneingangspegel',
deviceUnavailable: 'Nicht verfügbar',
deviceMissing: 'Mikrofon nicht verfügbar. Wählen Sie ein anderes Eingabegerät.',
auto: 'Automatisch erkennen', zh: 'Chinesisch', en: 'Englisch', yue: 'Kantonesisch', ja: 'Japanisch', es: 'Japanisch', fr: 'Japanisch', de: 'Japanisch', ru: 'Japanisch', pt: 'Japanisch', ko: 'Koreanisch',
auto: 'Automatisch erkennen', zh: 'Chinesisch', en: 'Englisch', yue: 'Kantonesisch', ja: 'Japanisch', es: 'Spanisch', fr: 'Französisch', de: 'Deutsch', ru: 'Russisch', pt: 'Portugiesisch', ko: 'Koreanisch',
start: 'Starten Sie die Aufnahme', stop: 'Stoppen Sie und transkribieren Sie', cancel: 'Abbrechen', insert: 'Text einfügen',
loading: 'Erkennungsdienste werden geladen…', requesting: 'Mikrofonzugriff erlauben, um fortzufahren…', recording: 'Aufnahme…',
interrupted: 'Die Aufnahme wurde unterbrochen. Bitte versuchen Sie es erneut.',
@@ -718,7 +718,7 @@ export const ru = {
inputLevel: 'Входной уровень микрофона',
deviceUnavailable: 'Недоступно',
deviceMissing: 'Микрофон недоступен. Выберите другое устройство ввода.',
auto: 'Обнаруживать автоматически', zh: 'китайский', en: 'английский', yue: 'Кантонский диалект', ja: 'японский', es: 'японский', fr: 'японский', de: 'японский', ru: 'японский', pt: 'японский', ko: 'корейский',
auto: 'Обнаруживать автоматически', zh: 'китайский', en: 'английский', yue: 'Кантонский диалект', ja: 'японский', es: 'испанский', fr: 'французский', de: 'немецкий', ru: 'русский', pt: 'португальский', ko: 'корейский',
start: 'Начать запись', stop: 'Остановись и перепиши', cancel: 'Отмена', insert: 'Вставить текст',
loading: 'Загрузка служб распознавания…', requesting: 'Разрешите доступ к микрофону, чтобы продолжить…', recording: 'Запись…',
interrupted: 'Запись была прервана. Пожалуйста, попробуйте еще раз.',
@@ -819,7 +819,7 @@ export const pt = {
inputLevel: 'Nível de entrada do microfone',
deviceUnavailable: 'Indisponível',
deviceMissing: 'Microfone indisponível. Escolha outro dispositivo de entrada.',
auto: 'Detectar automaticamente', zh: 'Chinês', en: 'Inglês', yue: 'cantonês', ja: 'Japonês', es: 'Japonês', fr: 'Japonês', de: 'Japonês', ru: 'Japonês', pt: 'Japonês', ko: 'Coreano',
auto: 'Detectar automaticamente', zh: 'Chinês', en: 'Inglês', yue: 'cantonês', ja: 'Japonês', es: 'espanhol', fr: 'francês', de: 'alemão', ru: 'russo', pt: 'português', ko: 'Coreano',
start: 'Comece a gravar', stop: 'Pare e transcreva', cancel: 'Cancelar', insert: 'Inserir texto',
loading: 'Carregando serviços de reconhecimento…', requesting: 'Permitir que o acesso ao microfone continue…', recording: 'Gravando…',
interrupted: 'A gravação foi interrompida. Por favor, tente novamente.',
@@ -10,7 +10,7 @@ import { PreparationCard, VoicePreparation } from '../src/client/PreparationCard
import type { VoiceInputProps } from '../src/client/VoiceInput.tsx'
import type { SpeechReadiness } from '../src/client/readiness.ts'
import { Recording } from '../src/client/audio.ts'
import { zh } from '../src/client/locales.ts'
import { zh, en, ja, es, fr, de, ru, pt } from '../src/client/locales.ts'
afterEach(() => { cleanup(); vi.useRealTimers() })
const id = 'local' as SpeechProviderId, t = makeTranslate(zh, commonZh)
@@ -129,7 +129,7 @@ it('persists settings and reports disconnection in the detail card', async () =>
expect(screen.getByText(zh.cloud)).toBeTruthy()
expect(within(screen.getByLabelText(zh.language)).getAllByRole<HTMLOptionElement>('option').map(item => item.value))
.toEqual(['auto', 'en', 'zh', 'ja', 'fr'])
expect(within(screen.getByLabelText(zh.language)).getByRole('option', { name: 'fr' })).toBeTruthy()
expect(within(screen.getByLabelText(zh.language)).getByRole('option', { name: zh.fr })).toBeTruthy()
configure.mockRejectedValueOnce(new Error('settings are read-only'))
fireEvent.change(screen.getByLabelText(zh.language), { target: { value: 'zh' } })
await screen.findByText('语音识别失败:settings are read-only')
@@ -212,3 +212,14 @@ it('locks source changes while a preparation request is pending and hides them d
b.rerender(<PreparationCard {...b.props} provider={{ ...provider, preparation: { phase: 'downloading', resource: 'model.onnx', completedBytes: 0 } }} />)
expect(screen.queryByLabelText(zh.sourceChoice)).toBeNull()
})
it('gives each speech recognition language a distinct localized name', () => {
for (const dictionary of [zh, en, ja, es, fr, de, ru, pt]) {
const names = [
dictionary.zh, dictionary.en, dictionary.yue, dictionary.ja, dictionary.es,
dictionary.fr, dictionary.de, dictionary.ru, dictionary.pt, dictionary.ko,
]
expect(new Set(names).size).toBe(names.length)
}
expect(zh.fr).toBe('法语')
})
@@ -192,7 +192,7 @@ export const fr = {
permissionHint: "Gérez les autorisations dans les paramètres système pour l'application nommée par macOS.",
granted: 'Autorisé', unknown: 'Inconnu', request: "Demander l'autorisation", settings: 'Ouvrir les paramètres système', retry: 'Réessayer',
allowedApps: 'Applications toujours autorisées',
blockedApps: 'Candidatures non autorisées', retryApproval: 'Autoriser la demande à nouveau',
blockedApps: 'Applications non autorisées', retryApproval: 'Autoriser la demande à nouveau',
retryAllowed: "La prochaine sélection de candidatures demandera à nouveau ; l'accès n'a pas été accordé",
noApps: 'Aucune application toujours autorisée ou bloquée.',
revoke: 'Révoquer', revoked: "Accès à l'application révoqué", requested: "Consultez l'invite macOS ou ouvrez les paramètres système si aucune invite n'apparaît.", opened: 'Paramètres système ouverts',
@@ -229,7 +229,7 @@ export const de = {
permissionHint: 'Verwalten Sie Berechtigungen in den Systemeinstellungen für die Anwendung mit dem Namen macOS.',
granted: 'Erlaubt', unknown: 'Unbekannt', request: 'Bitte um Erlaubnis', settings: 'Öffnen Sie die Systemeinstellungen', retry: 'Erneut versuchen',
allowedApps: 'Immer zulässige Anwendungen',
blockedApps: 'Bewerbungen nicht erlaubt', retryApproval: 'Erlaube noch einmal nachzufragen',
blockedApps: 'Nicht erlaubte Anwendungen', retryApproval: 'Erlaube noch einmal nachzufragen',
retryAllowed: 'Bei der nächsten Anwendungsauswahl werden Sie erneut gefragt; Der Zugriff wurde nicht gewährt',
noApps: 'Keine immer erlaubten oder blockierten Anwendungen.',
revoke: 'Widerrufen', revoked: 'Anwendungszugriff widerrufen', requested: 'Überprüfen Sie die macOS-Eingabeaufforderung oder öffnen Sie die Systemeinstellungen, wenn keine Eingabeaufforderung angezeigt wird', opened: 'Systemeinstellungen geöffnet',
+1 -1
View File
@@ -19,7 +19,7 @@ export const ja = {
question: 'Computer Use によるこのアプリの起動、読み取り、画面キャプチャ、操作を許可しますか?',
detail: 'セッションの許可は DSH の再起動または Computer Use の無効化まで有効です。',
approve: 'このセッションで許可',
} satisfies typeof en
} satisfies Record<keyof typeof en, string>
/** Spanish dictionary, checked against the English keys. */
export const es = {
@@ -1,17 +1,17 @@
---
name: cordis-plugin-development
description: Use when creating, reviewing, installing, enabling, disabling, configuring, or debugging Harness plugins, bundles, features, tools, MCP connections, pages, panels, decorations or widgets. Visual requests without another destination mean an installed UI plugin in Harness Web UI.
description: Use when designing, reviewing, adding, enabling, disabling, installing, configuring, or debugging a plugin, bundle, feature, page, panel, tool, or MCP connection in the current Harness profile, including a shipped plugin that is disabled by default, and for any visual object, decoration, or widget request that names no other destination, which means an installed UI plugin rendered in the Harness Web UI.
---
# Persistent Harness plugins
Author a workspace bundle, then install it in the current profile with `plugin_manager` (`action: install_bundle`, absolute package directory as `target`). It persists across sessions and restarts. Load `editing-cordis-compositions` for agent preset changes.
For implementation, use ordinary workspace files to author a bundle, then `plugin_manager` with `action: install_bundle` and the absolute package directory as `target` to install it in the current profile. Changes affect every session in that profile and survive restart. Load `editing-cordis-compositions` for agent preset changes.
Do not write the profile's `package.json` or `cordis.patch.yml`, create packages under `$DSH_HOME`, or run pnpm in the profile directory: `install_bundle` performs those steps, and each hand-made write outside the workspace needs its own approval. Every `plugin_manager` action, including `list_plugins` and `list_bundles`, also needs approval without Full access, so call it only when its result decides the next step.
## Design or review
Inspect the plugin against current APIs using the applicable references below. Report choices or findings; write files, install bundles, or change profile state only when implementation is requested.
Read the applicable references below and inspect the proposed or existing plugin against current APIs. Report design choices or findings instead of following the installation workflow. Do not write files, install bundles, or change profile state unless the user requests implementation.
## Enable a shipped plugin
@@ -35,7 +35,7 @@ A shipped bundle can resolve a plugin row and leave it `disabled`. The row id an
## Read next
The table lists every file in the base directory reported by `skill`; do not enumerate it. In every deployment, including source checkouts, use file-read/file-write tools to copy these files into the workspace, then read the copies to verify them. Desktop stores the directory in `app.asar`: only Host file reads work there. Shell commands, native glob/search tools, `node`, and pnpm cannot read it. Never install or syntax-check a template in place; copy its contents into the workspace first.
The files below live in this skill's base directory, which the `skill` tool reported, and the table is the complete list: do not enumerate that directory. In every deployment, including a source checkout, read these files with the file-read tool, write copies into the workspace with the file-write tool, and verify a copy by reading it back. In Desktop the directory sits inside `app.asar`, which only the Host process's own file reads can open; shell commands (`ls`, `cat`, `cp`, `cmp`), the glob and search tools (they run a native ripgrep process), `node`, and pnpm all fail on it. Never install or syntax-check a template in place; copy its contents into the workspace first.
| Task | File |
|---|---|
@@ -43,8 +43,8 @@ The table lists every file in the base directory reported by `skill`; do not enu
| Web UI: Client manifest, module loader, slot registration | `references/ui-plugin.md` |
| Connecting an MCP server through a configuration-only bundle | `references/mcp-bundle.md` |
| Page/panel design checks and verification without browser control | `references/verification.md` |
| UI plugin template and display resources | `templates/decoration/package.json`, `templates/decoration/cordis.patch.yml`, `templates/decoration/index.js`, `templates/decoration/client.js`, `templates/decoration/locale/en.json`, `templates/decoration/locale/ja.json`, `templates/decoration/locale/es.json`, `templates/decoration/locale/fr.json`, `templates/decoration/locale/de.json`, `templates/decoration/locale/ru.json`, `templates/decoration/locale/pt.json`, `templates/decoration/icon.svg` |
| MCP template and display resources | `templates/mcp/package.json`, `templates/mcp/cordis.patch.yml`, `templates/mcp/locale/en.json`, `templates/mcp/locale/ja.json`, `templates/mcp/locale/es.json`, `templates/mcp/locale/fr.json`, `templates/mcp/locale/de.json`, `templates/mcp/locale/ru.json`, `templates/mcp/locale/pt.json`, `templates/mcp/icon.svg` |
| UI plugin template, including display resources | `templates/decoration/package.json`, `templates/decoration/cordis.patch.yml`, `templates/decoration/index.js`, `templates/decoration/client.js`, `templates/decoration/locale`, `templates/decoration/icon.svg` |
| MCP bundle template, including display resources | `templates/mcp/package.json`, `templates/mcp/cordis.patch.yml`, `templates/mcp/locale`, `templates/mcp/icon.svg` |
| Loader patch dialect and the list of installable plugin packages | the `cordis-composition-reference` skill |
| Choosing extension points, contexts, and state mechanisms for upgrade stability and performance | `references/practices.md` |
| Sharing application operations between UI actions and agent tools | `references/user-actions.md` |
@@ -126,7 +126,7 @@ describe('the shipped creator skills', () => {
...locales, manifest.dsh.bundle.patch])
for (const resource of resources) {
const file = resource.replace(/^\.\//u, '')
expect(body('cordis-plugin-development'), file).toContain(`\`templates/${name}/${file}\``)
expect(body('cordis-plugin-development'), file).toContain(`\`templates/${name}/${file.startsWith('locale/') ? 'locale' : file}\``)
expect(statSync(join(dir, file)).isFile(), file).toBe(true)
if (file !== 'package.json') expect(manifest.files.some(pattern => matchesGlob(file, pattern)), file).toBe(true)
}
+1 -1
View File
@@ -17,7 +17,7 @@ export const ja = {
/** Spanish dictionary, checked against the English keys. */
export const es = {
escalation: (mode: string, justification: string): string => `Permite esta operación con permisos ${mode}: ${justification}`,
escalation: (mode: string, justification: string): string => `Permitir esta operación con permisos ${mode}: ${justification}`,
} satisfies typeof en
/** French dictionary, checked against the English keys. */
+15 -10
View File
@@ -27,7 +27,7 @@ import { fileURLToPath } from 'node:url'
import ts from 'typescript'
import { describe, expect, it } from 'vitest'
import { SHIPPED_LOCALE_IDS, localeTokens, type ShippedLocaleId } from './shipped-locales.ts'
import { SHIPPED_LOCALE_IDS, localeTokens, hasInvisibleLocaleCharacters, type ShippedLocaleId } from './shipped-locales.ts'
const root = fileURLToPath(new URL('..', import.meta.url))
@@ -326,7 +326,13 @@ describe('shipped locale dictionaries', () => {
const perFile = new Map<string, Dictionary[]>()
for (const file of files) {
const dicts = dictionariesIn(file)
if (dicts.length > 0) perFile.set(relative(file), dicts)
if (dicts.length > 0) {
expect(hasInvisibleLocaleCharacters(readFileSync(file, 'utf8')), relative(file)).toBe(false)
for (const dictionary of dicts) {
for (const text of Object.values(dictionary.templates)) expect(hasInvisibleLocaleCharacters(text), relative(file)).toBe(false)
}
perFile.set(relative(file), dicts)
}
}
const groups = new Map<string, Map<ShippedLocaleId, Dictionary>>()
@@ -372,26 +378,25 @@ describe('shipped locale dictionaries', () => {
if (zhOnly.length > 0) problems.push(`${zh.file} ${zh.name} has keys absent from ${en.name}: ${zhOnly.join(', ')}`)
if (enOnly.length > 0) problems.push(`${en.file} ${en.name} has keys absent from ${zh.name}: ${enOnly.join(', ')}`)
for (const language of SHIPPED_LOCALE_IDS.filter(id => id !== 'en' && id !== 'zh')) {
const ja = slot.get(language)
if (ja === undefined) {
const translatedDictionary = slot.get(language)
if (translatedDictionary === undefined) {
problems.push(`${key} has no ${language} dictionary`)
} else {
const missing = en.keys.filter(k => !ja.keys.includes(k))
const extra = ja.keys.filter(k => !en.keys.includes(k))
const missing = en.keys.filter(k => !translatedDictionary.keys.includes(k))
const extra = translatedDictionary.keys.filter(k => !en.keys.includes(k))
if (missing.length > 0 || extra.length > 0) {
problems.push(`${ja.file} ${ja.name}: missing [${missing.join(', ')}], extra [${extra.join(', ')}]`)
problems.push(`${translatedDictionary.file} ${translatedDictionary.name}: missing [${missing.join(', ')}], extra [${extra.join(', ')}]`)
}
for (const [name, text] of Object.entries(en.templates)) {
const translated = ja.templates[name]
const translated = translatedDictionary.templates[name]
if (translated === undefined) continue
const placeholders = (value: string): string => localeTokens(value).join(', ')
if (placeholders(text) !== placeholders(translated)) {
problems.push(`${ja.file} ${ja.name}.${name} changes placeholders or literal tokens from ${en.name}`)
problems.push(`${translatedDictionary.file} ${translatedDictionary.name}.${name} changes placeholders or literal tokens from ${en.name}`)
}
}
}
}
}
// The shipped dictionary count only grows; a collapse means discovery or
+1 -1
View File
@@ -393,7 +393,7 @@ function i18nGates(includeDictionaries: boolean): Gate[] {
return [
pnpmScript('installer-locales', 'verify-installer-locales', { label: 'Windows installer locales' }),
pnpmScript('locale-metadata', 'verify-locale-metadata', { label: 'plugin locale metadata' }),
...includeDictionaries ? [pnpmExec('locale-dictionaries', ['vitest', 'run', 'scripts/locale-dictionary-parity.spec.ts'], { label: 'locale dictionaries' })] : [],
...includeDictionaries ? [pnpmExec('locale-dictionaries', ['vitest', 'run', 'scripts/locale-dictionary-parity.spec.ts', 'scripts/shipped-locales.spec.ts', 'packages/client/locale/tests/apply.client.spec.ts'], { label: 'locale dictionaries' })] : [],
]
}
+23 -1
View File
@@ -1,6 +1,9 @@
import { readFileSync } from 'node:fs'
import * as desktop from '../apps/desktop/src/locale.ts'
import { readInstallerLocales } from '../apps/desktop/scripts/installer-locales.mjs'
/** Translations preserve executable command examples and named interpolation fields. */
import { expect, it } from 'vitest'
import { localeTokens } from './shipped-locales.ts'
import { SHIPPED_LOCALE_IDS, localeTokens, hasInvisibleLocaleCharacters } from './shipped-locales.ts'
it('retains repeated placeholders, commands, and API identifiers', () => {
expect(localeTokens('Use /plan off, /compact and /THEME=dark with {count}, {count} and reasoning_effort.'))
@@ -20,3 +23,22 @@ it.each([['/plan off', '/plan désactivé'], ['/THEME=light', '/THEME=claro'], [
it('recognizes commands at the start of copy or after CJK punctuation', () => {
expect(localeTokens('/THEME=auto、/THEME=light')).toEqual(['/THEME=auto', '/THEME=light'])
})
it.each(['\u200b', '\ufeff'])('rejects an invisible translation character', (character) => {
expect(hasInvisibleLocaleCharacters(`text${character}text`)).toBe(true)
expect(hasInvisibleLocaleCharacters('normal text')).toBe(false)
})
it('matches Desktop dictionaries, resolution and installer resources to the shipped catalog', () => {
const dictionaries = Object.keys(desktop).filter(name => /^[a-z]{2}$/u.test(name)).sort()
expect(dictionaries).toEqual([...SHIPPED_LOCALE_IDS].sort())
for (const id of SHIPPED_LOCALE_IDS) {
expect(desktop.resolveDesktopLocale(id).id).toBe(id === 'zh' ? 'zh-CN' : id)
expect(desktop.resolveDesktopStartupLocale(null, [id]).id).toBe(id === 'zh' ? 'zh-CN' : id)
}
expect(readInstallerLocales().map(locale => locale.id.split('_')[0]).sort()).toEqual(dictionaries)
})
it('rejects invisible characters in the Desktop locale source', () => {
expect(hasInvisibleLocaleCharacters(readFileSync(new URL('../apps/desktop/src/locale.ts', import.meta.url), 'utf8'))).toBe(false)
})
+9
View File
@@ -12,3 +12,12 @@ export type ShippedLocaleId = typeof SHIPPED_LOCALE_IDS[number]
export function localeTokens(text: string): string[] {
return (text.match(/\{\w+\}|(?<![A-Za-z0-9/])\/[A-Za-z][\w-]*(?:=[\w-]+| (?:on|off))?|\b[a-z]+_[a-z_]+\b/gu) ?? []).sort()
}
/**
* Detect translation-service artifacts that must not enter locale resources.
* @param text - Raw resource or localized text.
* @returns Whether the text contains a zero-width space or byte-order mark.
*/
export function hasInvisibleLocaleCharacters(text: string): boolean {
return /[\u200b\ufeff]/u.test(text)
}
+19 -4
View File
@@ -1,5 +1,5 @@
/** Metadata coverage rejects missing translations through the same checker used by CI. */
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, expect, it } from 'vitest'
@@ -19,7 +19,7 @@ function fixture(ja: unknown = { meta: { title: '名前 {name}', description: '
writeFileSync(join(directory, 'zh.json'), JSON.stringify({ meta: { title: '名称 {name}', description: '描述' } }))
writeFileSync(join(directory, 'ja.json'), JSON.stringify(ja))
for (const language of ['es', 'fr', 'de', 'ru', 'pt']) {
writeFileSync(join(directory, `${language}.json`), JSON.stringify({ meta: { title: 'Name {name}', description: 'Description.' } }))
writeFileSync(join(directory, `${language}.json`), JSON.stringify({ meta: { title: 'Name {name}', description: 'Localized description.' } }))
}
return root
}
@@ -46,7 +46,7 @@ it.each([
it('checks referenced detail documents', () => {
const root = fixture()
const directory = join(root, 'packages/demo/plugin/locale')
const meta = { title: 'Name {name}', description: 'Description.', detail: 'docs/DETAIL.md' }
const meta = { title: 'Name {name}', detail: 'docs/DETAIL.md' }
for (const language of SHIPPED_LOCALE_IDS) writeFileSync(join(directory, `${language}.json`), JSON.stringify({ meta }))
expect(localeMetadataProblems(root).join('\n')).toContain('missing detail resource')
mkdirSync(join(directory, '../docs'))
@@ -103,7 +103,7 @@ it('requires sentence punctuation for English descriptions', () => {
const file = join(root, 'packages/demo/plugin/locale/en.json')
writeFileSync(file, JSON.stringify({ meta: { title: 'Name {name}', description: 'Description' } }))
expect(localeMetadataProblems(root).join('\n')).toContain('sentence punctuation')
writeFileSync(file, JSON.stringify({ meta: { title: 'Name {name}', description: 'Description.' } }))
writeFileSync(file, JSON.stringify({ meta: { title: 'Name {name}', description: 'English description.' } }))
expect(localeMetadataProblems(root)).toEqual([])
})
@@ -135,6 +135,21 @@ it.each(['es', 'fr', 'de', 'ru', 'pt'])('rejects missing %s metadata', (language
it.each(['es', 'fr', 'de', 'ru', 'pt'])('rejects untranslated Chinese descriptions in %s metadata', (language) => {
const root = fixture()
writeFileSync(join(root, 'packages/demo/plugin/locale/en.json'), JSON.stringify({ meta: { title: 'Name {name}', description: 'English description.' } }))
writeFileSync(join(root, `packages/demo/plugin/locale/${language}.json`), JSON.stringify({ meta: { title: 'Name {name}', description: '描述' } }))
expect(localeMetadataProblems(root).join('\n')).toContain(`${language}.json: meta.description contains untranslated source text`)
})
it.each(['es', 'fr', 'de', 'ru', 'pt'])('rejects copied English descriptions in %s metadata', (language) => {
const root = fixture()
writeFileSync(join(root, 'packages/demo/plugin/locale/en.json'), JSON.stringify({ meta: { title: 'Name {name}', description: 'English description.' } }))
writeFileSync(join(root, `packages/demo/plugin/locale/${language}.json`), JSON.stringify({ meta: { title: 'Name {name}', description: 'English description.' } }))
expect(localeMetadataProblems(root).join('\n')).toContain(`${language}.json: meta.description contains untranslated source text`)
})
it.each(['\u200b', '\ufeff', String.raw`\u200b`, String.raw`\ufeff`])('rejects invisible characters in JSON resources', (character) => {
const root = fixture()
const file = join(root, 'packages/demo/plugin/locale/es.json')
writeFileSync(file, readFileSync(file, 'utf8').replace('Localized description.', `Localized${character} description.`))
expect(localeMetadataProblems(root).join('\n')).toContain('invisible locale character')
})
+6 -3
View File
@@ -3,7 +3,7 @@ import { existsSync, globSync, readFileSync } from 'node:fs'
import { basename, posix, join, resolve } from 'node:path'
import { detailPath } from '../packages/util/plugin-detail/src/document.ts'
import { PRODUCT_PACKAGE_POLICY } from './product-package-policy.ts'
import { SHIPPED_LOCALE_IDS, localeTokens } from './shipped-locales.ts'
import { SHIPPED_LOCALE_IDS, localeTokens, hasInvisibleLocaleCharacters } from './shipped-locales.ts'
import { collectConfigCatalog } from './gen-config-catalog.ts'
/** Flatten metadata strings and reject values that cannot be translated. */
@@ -35,7 +35,9 @@ export function localeMetadataProblems(root: string): string[] {
try {
const read = (language: string): Record<string, string> => {
const file = join(root, directory, `${language}.json`)
const value: unknown = JSON.parse(readFileSync(file, 'utf8'))
const source = readFileSync(file, 'utf8')
if (hasInvisibleLocaleCharacters(source) || /\\u(?:200b|feff)/iu.test(source)) throw new Error(`${language}.json: invisible locale character`)
const value: unknown = JSON.parse(source)
const dictionary = strings(value)
const detail = detailPath(dictionary['meta.detail'], `${directory}/${language}.json: meta.detail`)
const packageDir = directory.slice(0, directory.lastIndexOf('/locale'))
@@ -55,7 +57,8 @@ export function localeMetadataProblems(root: string): string[] {
if (translated[key] === undefined) problems.push(`${directory}/${language}.json: missing ${key}`)
else if (expected[key] === undefined) problems.push(`${directory}/${language}.json: unexpected ${key}`)
else {
if (language !== 'ja' && key === 'meta.description' && /\p{Script=Han}/u.test(expected[key]) && translated[key] === expected[key]) {
if (language !== 'ja' && key === 'meta.description' && ((zh[key] !== undefined && /\p{Script=Han}/u.test(zh[key]) && translated[key] === zh[key])
|| (en[key] !== undefined && translated[key] === en[key]))) {
problems.push(`${directory}/${language}.json: ${key} contains untranslated source text`)
}
const placeholders = (text: string): string => localeTokens(text).join(',')
+1
View File
@@ -39,6 +39,7 @@
"packages/extensions/ui-cordis/src/css-modules.d.ts",
"packages/client/tsdown.client.ts",
"scripts/client-build-environment.ts",
"scripts/shipped-locales.ts",
"scripts/bundle-input-isolation.ts",
"scripts/experimental-package-policy.ts",
"scripts/*.client.spec.ts",