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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -537,6 +537,7 @@ VTS_TOKEN_FILE=vts_auth_token.json
# BEGIN GENERATED CONFIG: presentation
# Language & captions
# AMADEUS_PRESENTATION_LOCALE=en-US # en-US | zh-CN | ja-JP
AMADEUS_SUBTITLE_TRANSLATION_LANGUAGE=zh-CN # zh-CN | en-US
# AMADEUS_CHAT_TRANSLATION_SUBTITLES_ENABLED=false
# AMADEUS_WALLPAPER_CAPTION_MODE=translated # translated | source | bilingual | off
# END GENERATED CONFIG: presentation
Expand Down
10 changes: 10 additions & 0 deletions config/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,16 @@ cannot prevent native/file-origin backend clients from starting.

## Shared configuration declarations

The Language & captions group includes `AMADEUS_SUBTITLE_TRANSLATION_LANGUAGE`
(`zh-CN` by default, or `en-US`). It selects the target language of the existing
shared Japanese subtitle translator for Chat and ordinary Wallpaper playback.
Saving applies without a backend restart: Chat refreshes its translations and
new Wallpaper captions use the selected language. A caption already on screen
finishes its normal playback lifetime. The two surfaces keep their independent
enablement/display modes; speech and conversation history are unchanged.
VN retains its separate translation settings and cache, including VN captions
shown on Wallpaper. Slice process language is also independent.

The shared catalog covers the TTS selector, all four built-in TTS connection/model
groups, graphics, voice input/reference controls, model connections, model roles,
Provider connections, character, desktop and live presentation/vision settings.
Expand Down
23 changes: 21 additions & 2 deletions config/catalog/general/presentation.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,25 @@
"ja-JP"
]
},
"AMADEUS_SUBTITLE_TRANSLATION_LANGUAGE": {
"type": "enum",
"title": {
"en-US": "Subtitle translation language",
"zh-CN": "字幕翻译语言"
},
"description": {
"en-US": "Shared by Chat and ordinary Wallpaper subtitles. VN subtitles keep their own translation policy.",
"zh-CN": "Chat 和普通壁纸字幕共用;VN 字幕保持独立的翻译策略。"
},
"default": "zh-CN",
"scope": "session",
"runtime_key": "subtitle_translation_language",
"options": [
{ "value": "zh-CN", "label": { "en-US": "Simplified Chinese", "zh-CN": "简体中文" } },
{ "value": "en-US", "label": { "en-US": "English", "zh-CN": "English" } }
],
"example_active": true
},
"AMADEUS_CHAT_TRANSLATION_SUBTITLES_ENABLED": {
"type": "boolean",
"title": {
Expand All @@ -38,8 +57,8 @@
},
"default": false,
"description": {
"en-US": "Show Simplified Chinese below completed Japanese assistant messages. Display-only; never added to conversation history.",
"zh-CN": "在完成的日语助手消息下显示简体中文。仅用于显示,不会写入对话历史。"
"en-US": "Show translations below completed Japanese assistant messages in the selected subtitle language. Display-only; never added to conversation history.",
"zh-CN": "在完成的日语助手消息下显示所选语言的译文。仅用于显示,不会写入对话历史。"
},
"scope": "session",
"runtime_key": "chat_translation_subtitles_enabled",
Expand Down
21 changes: 21 additions & 0 deletions docs/evidence/subtitle-target-language/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Shared subtitle target language

The existing Chat/Wallpaper translator now offers Simplified Chinese (the
unchanged default) and English in Settings > General > Language & captions.
VN translation remains separate, including VN captions rendered on Wallpaper.

The images use hidden Electron windows with isolated synthetic settings and
network access blocked. The before image shows the pre-change local build;
the after image shows this branch with English selected. The offline save
notice indicates that no backend was attached to this screenshot profile.

| Before | After |
| --- | --- |
| ![Before](before.png) | ![English subtitle target](after.png) |

Validation covers both languages across the existing DeepSeek, OpenAI-compatible
and Gemini request paths using synthetic provider responses. Chat's real effects
are exercised with cached and delayed translations during a language switch.
Wallpaper cache tests cover old successes, failures and tasks queued before a
switch. The offline settings smoke exercises persistence and the Host application
policy. No live translation-model quality assessment is claimed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
17 changes: 17 additions & 0 deletions electron/scripts/smoke-startup-settings.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ app.whenReady().then(async () => {
session.defaultSession.webRequest.onBeforeRequest({ urls: ['http://*/*', 'https://*/*', 'ws://*/*', 'wss://*/*'] },
(_request, callback) => callback({ cancel: true }))
const { DesktopSettingsStore } = await import(pathToFileURL(path.join(root, 'electron/dist/main/desktopSettings.js')).href)
const { catalogApplication } = await import(pathToFileURL(path.join(root, 'electron/dist/shared/configCatalog.js')).href)
const { ExtensionInstaller, ExtensionPackageHelper } = await import(pathToFileURL(path.join(root, 'electron/dist/main/extensions.js')).href)
const { isWallpaperStartup } = await import(pathToFileURL(path.join(root, 'electron/dist/main/startupMode.js')).href)
const file = path.join(profile, 'settings.json')
Expand All @@ -39,6 +40,7 @@ app.whenReady().then(async () => {
GRAPHICS_PROFILE: null, RENDER_MAX_FPS: null, RENDER_TEXTURE_SAMPLING: null,
WORK_EXECUTION_PROVIDER: null, COOPERATIVE_CHAT_PROVIDER: 'openclaw',
AMADEUS_VISION_ENABLED: 'false', AMADEUS_VISION_MODE: 'watching',
AMADEUS_SUBTITLE_TRANSLATION_LANGUAGE: null,
} })
store.markApplied({})
ipcMain.handle('get-backend-connection', () => null)
Expand Down Expand Up @@ -82,6 +84,21 @@ app.whenReady().then(async () => {
fs.mkdirSync(output, { recursive: true })
fs.writeFileSync(path.join(output, 'startup-settings.png'), (await win.webContents.capturePage(undefined, { stayHidden: true })).toPNG())
console.log('PASS offline GUI startup choices persist and select the next launch without backend restart')
await until(`Boolean(document.querySelector('select[aria-label="Subtitle translation language"]'))`)
assert.equal(await win.webContents.executeJavaScript(`document.querySelector('select[aria-label="Subtitle translation language"]').value`), 'zh-CN')
for (const language of ['en-US', 'zh-CN']) {
await win.webContents.executeJavaScript(`(() => {
const select = document.querySelector('select[aria-label="Subtitle translation language"]');
select.value = ${JSON.stringify(language)};
select.dispatchEvent(new Event('change', { bubbles: true })); return true;
})()`)
await until(`(async () => (await window.amadeus.getDesktopSettings()).values.AMADEUS_SUBTITLE_TRANSLATION_LANGUAGE === ${JSON.stringify(language)})()`)
const saved = new DesktopSettingsStore(file, '').snapshot({})
assert.equal(saved.values.AMADEUS_SUBTITLE_TRANSLATION_LANGUAGE, language)
assert.equal(catalogApplication('AMADEUS_SUBTITLE_TRANSLATION_LANGUAGE'), 'host')
assert.ok(saved.pendingRevisions.AMADEUS_SUBTITLE_TRANSLATION_LANGUAGE)
}
console.log('PASS shared subtitle language persists offline and waits for live application without restart')
await win.webContents.executeJavaScript(`(() => {
const section = document.getElementById('settings-vision');
section.querySelector('button[aria-pressed]').click(); return true;
Expand Down
17 changes: 10 additions & 7 deletions electron/src/renderer/components/ChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ export default function ChatPage({ send, subscribe, connected, renderActive, ren
const { t } = useI18n()
const [messages, setMessages] = useState<Message[]>([])
const [chatTranslationEnabled, setChatTranslationEnabled] = useState(false)
const [chatTranslationLanguage, setChatTranslationLanguage] = useState('zh-CN')
const [chatTranslations, setChatTranslations] = useState<Record<string, string>>({})
const [workActivities, setWorkActivities] = useState<ChatWorkActivityRun[]>([])
const [attentionRequests, setAttentionRequests] = useState<AttentionRequest[]>([])
Expand Down Expand Up @@ -334,6 +335,7 @@ export default function ChatPage({ send, subscribe, connected, renderActive, ren
const enabled = values.chat_translation_subtitles_enabled === true
if (!cancelled) {
setChatTranslationEnabled(enabled)
setChatTranslationLanguage(String(values.subtitle_translation_language || 'zh-CN'))
}
}
const unsubscribe = subscribe('system.config', applyConfig)
Expand Down Expand Up @@ -389,12 +391,13 @@ export default function ChatPage({ send, subscribe, connected, renderActive, ren
}, [attentionResolving, connected, send])

useEffect(() => {
if (!chatTranslationEnabled) {
chatTranslationGenerationRef.current += 1
chatTranslationRequestedRef.current.clear()
setChatTranslations({})
return
}
chatTranslationGenerationRef.current += 1
chatTranslationRequestedRef.current.clear()
setChatTranslations({})
}, [chatTranslationEnabled, chatTranslationLanguage])

useEffect(() => {
if (!chatTranslationEnabled) return

const generation = chatTranslationGenerationRef.current
for (const candidate of chatTranslationCandidates(messages)) {
Expand All @@ -417,7 +420,7 @@ export default function ChatPage({ send, subscribe, connected, renderActive, ren
}
})
}
}, [chatTranslationEnabled, messages, send])
}, [chatTranslationEnabled, chatTranslationLanguage, messages, send])

// auto-scroll
useEffect(() => {
Expand Down
2 changes: 1 addition & 1 deletion electron/src/renderer/components/SettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1440,7 +1440,7 @@ export default function SettingsPage({ send, subscribe, connected, reconnectBack
<RoleAssignmentCard
icon="Language"
title="Presentation translation"
description="Shared translation used by Chat, Wallpaper, and VN presentation policies."
description="Shared subtitle translation for Chat and Wallpaper. VN uses its own translation settings."
assignment={configuredTranslationModels.length ? `${t('Automatic')} · ${configuredTranslationModels.join(', ')}` : 'Automatic · No configured model'}
policy="Automatic selection"
status={configuredTranslationModels.length ? 'Available' : 'Needs setup'}
Expand Down
2 changes: 1 addition & 1 deletion electron/src/renderer/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -880,7 +880,7 @@
"Shared capabilities, implementations, and scene use.": "共享能力、实现来源与场景使用情况。",
"Shared fast first-sentence endpoint. Hybrid pairs it with Bedrock, Hybrid2 with DeepSeek, and Hybrid3 with OpenAI-compatible. The optional Hybrid BAT launcher shares the llama.cpp executable and GGUF settings above.": "共享的快速首句端点。Hybrid 搭配 Bedrock,Hybrid2 搭配 DeepSeek,Hybrid3 搭配 OpenAI 兼容服务;可选的 Hybrid BAT 启动器与上方配置共用 llama.cpp 可执行文件和 GGUF 设置。",
"Shared model-backed translation with independent display and speech policies in each scene.": "由共享模型提供翻译,各场景分别管理显示与语音策略。",
"Shared translation used by Chat, Wallpaper, and VN presentation policies.": "聊天、壁纸与 VN 呈现策略共用的翻译职责。",
"Shared subtitle translation for Chat and Wallpaper. VN uses its own translation settings.": "Chat 和壁纸共用字幕翻译;VN 使用独立的翻译设置。",
"Shared visual understanding and capture paths": "共享视觉理解与采集路径",
"Shared voice setup": "共享语音配置",
"Shorten the highlighted field before saving.": "请缩短标出的内容后再保存。",
Expand Down
35 changes: 33 additions & 2 deletions electron/src/shared/configCatalog.generated.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

77 changes: 77 additions & 0 deletions electron/tests/chatTranslationLanguage.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import test from 'node:test'
import vm from 'node:vm'
import ts from 'typescript'
import { chatTranslationCandidates } from '../src/renderer/components/chatTranslationState.ts'

// Exercise ChatPage's real effects and dependencies, including delayed replies.
const source = fs.readFileSync(new URL('../src/renderer/components/ChatPage.tsx', import.meta.url), 'utf8')
const tree = ts.createSourceFile('ChatPage.tsx', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
const effects = []
function visit(node) {
if (ts.isCallExpression(node) && node.expression.getText(tree) === 'useEffect'
&& /setChatTranslationEnabled|chatTranslationGenerationRef/.test(node.arguments[0]?.getText(tree))) {
effects.push(node.getText(tree))
}
ts.forEachChild(node, visit)
}
visit(tree)
const compiled = ts.transpileModule(effects.join('\n'), {
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS },
}).outputText

test('changing the shared language replaces cached Chat subtitles and ignores old pending replies', async () => {
const messages = [
{ role: 'assistant', text: '一つ目よ。', turnId: 'one' },
{ role: 'assistant', text: '二つ目よ。', turnId: 'two' },
]
const original = structuredClone(messages)
const requests = [], slots = []
let notify, translations = {}, index = 0
const context = vm.createContext({
connected: true, messages, chatTranslationCandidates,
chatTranslationEnabled: false, chatTranslationLanguage: 'zh-CN',
chatTranslationGenerationRef: { current: 0 }, chatTranslationRequestedRef: { current: new Set() },
setChatTranslationEnabled: value => { context.chatTranslationEnabled = value },
setChatTranslationLanguage: value => { context.chatTranslationLanguage = value },
setChatTranslations: value => { translations = typeof value === 'function' ? value(translations) : value },
subscribe: (_method, callback) => { notify = callback; return () => {} },
send: (method, params) => {
if (method === 'system.get_config') return Promise.resolve({
chat_translation_subtitles_enabled: true, subtitle_translation_language: 'zh-CN',
})
assert.equal(method, 'chat.translate')
return new Promise(resolve => requests.push({ params, resolve }))
},
useEffect: (callback, deps) => {
const slot = index++
if (slots[slot] && deps.every((value, i) => Object.is(value, slots[slot][i]))) return
slots[slot] = deps
callback()
},
})
const render = () => { index = 0; vm.runInContext(compiled, context) }
render()
await Promise.resolve()
render()
assert.equal(requests.length, 2)
requests[0].resolve({ translation: '第一句。' })
await Promise.resolve()
assert.deepEqual(Object.values(translations), ['第一句。'])

notify({ values: { chat_translation_subtitles_enabled: true, subtitle_translation_language: 'en-US' } })
render()
assert.deepEqual(Object.values(translations), [])
assert.equal(requests.length, 4)
requests[1].resolve({ translation: '迟到的旧中文。' })
await Promise.resolve()
assert.deepEqual(Object.values(translations), [])
requests[2].resolve({ translation: 'First line.' })
requests[3].resolve({ translation: 'Second line.' })
await Promise.resolve()
assert.deepEqual(Object.values(translations), ['First line.', 'Second line.'])
render()
assert.equal(requests.length, 4, 'ordinary renders reuse current-language subtitles')
assert.deepEqual(messages, original, 'translations remain display-only')
})
Loading
Loading