From f6cf2b999ad4979ca9eb76623b4bac5e460fbd7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=8B=A5=E9=BA=92?= Date: Fri, 14 Aug 2026 18:55:09 +0800 Subject: [PATCH] feat(config): add bilingual UI with in-place language switching --- .../commands/src/commands/config/scenarios.ts | 135 ++++++- .../commands/src/commands/config/ui-html.ts | 296 +++++++++----- .../commands/src/commands/config/ui-i18n.ts | 369 ++++++++++++++++++ packages/commands/src/commands/config/ui.ts | 39 +- packages/commands/tests/config-ui.test.ts | 117 +++++- 5 files changed, 837 insertions(+), 119 deletions(-) create mode 100644 packages/commands/src/commands/config/ui-i18n.ts diff --git a/packages/commands/src/commands/config/scenarios.ts b/packages/commands/src/commands/config/scenarios.ts index c458690..8f2cb9a 100644 --- a/packages/commands/src/commands/config/scenarios.ts +++ b/packages/commands/src/commands/config/scenarios.ts @@ -1,3 +1,5 @@ +import type { Language } from "bailian-cli-core"; + /** * Curated "Playground" scenarios surfaced in the config UI. * @@ -23,7 +25,7 @@ export interface Scenario { inputs?: ScenarioInput[]; } -export const SCENARIOS: Scenario[] = [ +export const SCENARIOS = [ // ---- 图像 ---- { id: "image-generate", @@ -150,17 +152,138 @@ export const SCENARIOS: Scenario[] = [ prompt: "为当前工作目录的项目生成一个结构清晰的 README.md,包含:项目简介、安装步骤、使用示例、目录结构说明。请先阅读现有代码与配置再撰写,内容必须与实际实现一致。", }, -]; +] as const satisfies readonly Scenario[]; + +type ScenarioTranslation = Pick; +type ScenarioId = (typeof SCENARIOS)[number]["id"]; + +const EN_US_SCENARIOS = { + "image-generate": { + title: "Text to image", + description: "Generate a sample image and save it to the output directory.", + category: "Image", + prompt: + "Use bl's image generation capability (such as `bl image generate`) to create a sample image: a corgi holding an umbrella in the rain, watercolor style, with soft lighting. Save it to the output directory and tell me the file path.", + }, + "image-describe": { + title: "Image understanding", + description: "Pick an image from the output directory and describe its content and style.", + category: "Image", + prompt: + "Pick an image from the output directory (output/images by default). Describe its subject, composition, colors, and style in detail, then suggest suitable use cases. If the directory is empty, say so.", + }, + "image-alt-batch": { + title: "Batch alt text", + description: "Generate accessible alt text for images in the output directory.", + category: "Image", + prompt: + "Scan all images in the output directory (output/images by default) and write concise, accurate accessibility alt text for each one. Summarize the results in a File name -> Alt text table. If the directory is empty, say so.", + }, + "image-to-code": { + title: "Screenshot to code", + description: "Recreate a UI screenshot from the output directory with HTML and CSS.", + category: "Image", + prompt: + "Find a UI screenshot in the output directory (output/images by default) and recreate its layout, spacing, and colors as closely as possible with HTML and CSS. Save it as a single file that opens directly in a browser and briefly explain your approach. If no screenshot is available, say so.", + }, + "speech-generate": { + title: "Text to speech", + description: "Turn a sample sentence into natural speech.", + category: "Audio", + prompt: + "Use bl's speech synthesis capability (such as a `bl speech` command) to turn this sentence into natural speech: Welcome to Alibaba Cloud Model Studio CLI, making multimodal creation easier. Save the audio to the output directory and tell me the file path.", + }, + "audio-summarize": { + title: "Transcribe and summarize audio", + description: "Transcribe an audio file from the output directory and summarize its key points.", + category: "Audio", + prompt: + "Find an audio file in the output directory (output/speech by default), transcribe it, provide the full transcript, and then summarize the key points as a list. If the directory is empty or transcription is unavailable, explain that and try to complete the task with the capabilities available.", + }, + "video-generate": { + title: "Text to video", + description: "Generate a sample short video.", + category: "Video", + prompt: + "Use bl's video generation capability (such as `bl video generate`) to create a sample short video: a teenager running along the beach at sunset, cinematic, in slow motion. Save it to the output directory and tell me the file path.", + }, + "video-storyboard": { + title: "Video storyboard", + description: "Create a storyboard suitable for text-to-video generation.", + category: "Video", + prompt: + "Create a storyboard for a 15-30 second short video themed The first cup of coffee in the city at dawn. For each shot, provide the visual description, duration, subtitles or narration, and an English prompt ready for text-to-video generation.", + }, + "media-prompt-craft": { + title: "Multimodal prompts", + description: "Expand a sample idea into image, video, and speech prompts.", + category: "Multimodal", + prompt: + "Expand the idea A night market in a futuristic cyber city into three high-quality generation prompts: 1) text to image, 2) text to video, and 3) speech style. Provide each prompt in both Chinese and English, with brief parameter recommendations.", + }, + "image-story-narration": { + title: "Image narration", + description: "Write narration for an image in the output directory.", + category: "Multimodal", + prompt: + "Pick an image from the output directory (output/images by default) and write an engaging English narration of about 60 seconds. Then provide a plain-text version ready for speech synthesis. If the directory is empty, say so.", + }, + "summarize-project": { + title: "Summarize this project", + description: + "Read the current directory and summarize its architecture, stack, and main modules.", + category: "Code", + prompt: + "Inspect the project structure and key source files in the current working directory, then concisely summarize: 1) what it does, 2) its technology stack, 3) its main modules and their responsibilities, and 4) notable design choices. Inspect the code before drawing conclusions; do not guess.", + }, + "write-tests": { + title: "Add tests for a core module", + description: "Choose an under-tested core module and add unit tests.", + category: "Code", + prompt: + "Choose a core module in the current project that has no tests or weak coverage. Add comprehensive unit tests for its main branches and edge cases, following the project's existing test framework and style. Read the relevant files and dependencies before writing tests.", + }, + "code-review": { + title: "Code review", + description: "Review core project code and identify concrete improvements.", + category: "Code", + prompt: + "Review the current project's core source code for potential bugs, security risks, performance issues, and maintainability problems. Give specific, actionable recommendations ordered by severity. Inspect the project structure and select the key files before reviewing them.", + }, + "explain-code": { + title: "Explain core code", + description: "Choose an entry point or core module and explain how it works.", + category: "Code", + prompt: + "Choose the current project's entry point or a core module and explain its responsibilities, key execution flow, and dependencies. Use clear English and include the call relationships when useful.", + }, + "generate-readme": { + title: "Generate README", + description: "Generate a clear README.md that matches the implementation.", + category: "Documentation", + prompt: + "Generate a clear README.md for the project in the current working directory. Include an overview, installation steps, usage examples, and a directory structure guide. Read the existing source code and configuration first; the content must match the actual implementation.", + }, +} satisfies Record; + +export function localizeScenarios(language: Language): Scenario[] { + if (language === "zh-CN") return SCENARIOS.map((scenario) => ({ ...scenario })); + + return SCENARIOS.map((scenario) => ({ + ...scenario, + ...EN_US_SCENARIOS[scenario.id], + })); +} /** Look up a scenario by id, or undefined when unknown. */ -export function getScenario(id: string): Scenario | undefined { - return SCENARIOS.find((s) => s.id === id); +export function getScenario(id: string, language: Language): Scenario | undefined { + return localizeScenarios(language).find((scenario) => scenario.id === id); } /** Fill a scenario's `{{placeholder}}` tokens from user-provided values. */ export function renderScenarioPrompt(scenario: Scenario, values: Record): string { return scenario.prompt.replace(/\{\{(\w+)\}\}/g, (_match, key: string) => { - const v = values[key]; - return typeof v === "string" ? v.trim() : ""; + const value = values[key]; + return typeof value === "string" ? value.trim() : ""; }); } diff --git a/packages/commands/src/commands/config/ui-html.ts b/packages/commands/src/commands/config/ui-html.ts index 6f9a04a..0043a27 100644 --- a/packages/commands/src/commands/config/ui-html.ts +++ b/packages/commands/src/commands/config/ui-html.ts @@ -4,7 +4,10 @@ // fetches carry the session token from the page URL. Visual language mirrors // the bailian landing design system (Inter / Geist Mono, gradient accents, // lift-on-hover cards). -export const PAGE_HTML = ` +import type { Language } from "bailian-cli-core"; +import { renderConfigUiShell } from "./ui-i18n.ts"; + +const PAGE_HTML = ` @@ -30,6 +33,7 @@ export const PAGE_HTML = ` --t-lift: .3s cubic-bezier(.2,.7,.2,1); --t-fast: .15s ease; } * { box-sizing: border-box; } + html:not(.i18n-ready) body { visibility: hidden; } body { margin: 0; font-family: var(--font); font-size: 14px; line-height: 1.5; color: var(--ink); background: #fafafc; -webkit-font-smoothing: antialiased; } #app { display: flex; min-height: 100vh; } @@ -694,6 +698,8 @@ export const PAGE_HTML = ` var token = new URLSearchParams(location.search).get('token') || ''; var KEYS = [], SECRETS = [], ENUMS = {}, BOOLEANS = [], FIELD_DEFAULTS = {}, MODEL_CATALOG = {}, DATA = { default: {}, named: {} }, CURRENT = '', ACTIVE = 'default'; var loaded = { skills: false, mcp: false, agents: false, assets: false, playground: false, start: false }; + var UI_LANGUAGE = '__BL_CONFIG_UI_LANGUAGE__'; + var UI_TRANSLATIONS = __BL_CONFIG_UI_TRANSLATIONS__; var SCENARIOS = [], BUILTIN_SCENARIOS = [], DISPATCH_AGENTS = [], SCN_Q = '', SCN_FILTER = 'all', SCN_PAGE = 1, DISPATCH_SCN = null; var ASSETS = [], ASSET_FILTER = 'all', ASSET_SORT = 'new', ASSET_Q = '', ASSET_PAGE = 1; var AGENTS = [], AGENT_FILTER = 'all', AGENT_Q = '', AGENT_PAGE = 1; @@ -717,6 +723,67 @@ export const PAGE_HTML = ` // Signed-in avatar photo. The generated colour + person icon stay as the // background fallback while the image loads or if it fails. var AVATAR_URL = 'https://oss.aliyuncs.com/aliyun_id_photo_bucket/default_handsome.jpg'; + var UI_STATIC_COPY = []; + var UI_DOCUMENT_TITLE = document.title; + function translateUiText(value) { + var text = value == null ? '' : String(value); + if (UI_LANGUAGE !== 'zh-CN') return text; + for (var translationIndex = 0; translationIndex < UI_TRANSLATIONS.length; translationIndex++) { + var pair = UI_TRANSLATIONS[translationIndex]; + text = text.split(pair[0]).join(pair[1]); + } + return text; + } + function captureStaticUiCopy() { + var root = document.getElementById('app'); + function visit(node) { + if (node.nodeType === 3) { + if (node.nodeValue && node.nodeValue.trim()) UI_STATIC_COPY.push({ node: node, text: node.nodeValue }); + return; + } + if (node.nodeType !== 1) return; + var tag = node.tagName; + if (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'CODE' || tag === 'PRE') return; + ['title', 'placeholder', 'aria-label', 'alt'].forEach(function (name) { + if (node.hasAttribute(name)) UI_STATIC_COPY.push({ node: node, attr: name, text: node.getAttribute(name) }); + }); + Array.prototype.forEach.call(node.childNodes, visit); + } + visit(root); + } + function applyStaticUiCopy() { + document.documentElement.lang = UI_LANGUAGE; + document.title = translateUiText(UI_DOCUMENT_TITLE); + UI_STATIC_COPY.forEach(function (entry) { + var value = translateUiText(entry.text); + if (entry.attr) entry.node.setAttribute(entry.attr, value); + else entry.node.nodeValue = value; + }); + document.documentElement.classList.add('i18n-ready'); + } + function applyUiLanguage(language) { + if (language !== 'en-US' && language !== 'zh-CN') return; + UI_LANGUAGE = language; + SCN_FILTER = 'all'; + SCN_PAGE = 1; + applyStaticUiCopy(); + renderProfiles(); + renderForm(); + if (loaded.start) loadQuickStart(); + if (loaded.skills) renderSkills(); + if (loaded.mcp) renderMcp(); + if (loaded.agents) { renderAgentFilters(); renderAgents(); } + if (loaded.assets) { renderAssetFilters(); renderAssets(); } + if (loaded.playground) { loaded.playground = false; loadPlayground(); } + if (AUTH) renderAccount(AUTH); + var sidebar = document.getElementById('sidebar'); + var sidebarToggle = document.getElementById('sidebarToggle'); + if (sidebar && sidebarToggle) { + var sidebarLabel = sidebar.classList.contains('is-collapsed') ? 'Expand sidebar' : 'Collapse sidebar'; + sidebarToggle.setAttribute('aria-label', translateUiText(sidebarLabel)); + sidebarToggle.title = translateUiText(sidebarLabel); + } + } function setAvatar(elm, seed) { elm.style.background = avatarStyle(seed); elm.innerHTML = PERSON_SVG; @@ -736,13 +803,16 @@ export const PAGE_HTML = ` if (text !== undefined && text !== null) e.textContent = text; return e; } + function uiEl(tag, cls, text) { + return el(tag, cls, translateUiText(text)); + } function originBadge(origin) { var o = origin === 'remote' ? 'remote' : 'local'; - return el('span', 'origin ' + o, o === 'remote' ? 'Remote' : 'Local'); + return el('span', 'origin ' + o, translateUiText(o === 'remote' ? 'Remote' : 'Local')); } function setStatus(msg, isErr) { var s = document.getElementById('status'); - s.textContent = msg || ''; + s.textContent = translateUiText(msg || ''); s.className = isErr ? 'err' : 'muted'; } function setCount(view, n) { @@ -786,18 +856,18 @@ export const PAGE_HTML = ` renderQuickStart(HEALTH, auth, agents); }).catch(function (e) { renderError(body, e); }); } - function qsDesc(html) { var p = el('p', 'qs-desc'); p.innerHTML = html; return p; } + function qsDesc(html) { var p = el('p', 'qs-desc'); p.innerHTML = translateUiText(html); return p; } function qsBtn(label, onclick, primary) { - var b = el('button', primary ? 'btn-primary' : 'btn-soft', label); + var b = el('button', primary ? 'btn-primary' : 'btn-soft', translateUiText(label)); b.type = 'button'; b.onclick = onclick; return b; } function qsStep(n, done, locked, title, descNode, actNode) { var step = el('div', 'qs-step' + (done ? ' done' : '') + (locked ? ' locked' : '')); var head = el('div', 'qs-head'); head.appendChild(el('span', 'qs-ic', done ? '\u2713' : String(n))); - if (done) head.appendChild(el('span', 'qs-done-tag', 'Done')); + if (done) head.appendChild(el('span', 'qs-done-tag', translateUiText('Done'))); step.appendChild(head); - step.appendChild(el('div', 'qs-title', title)); + step.appendChild(el('div', 'qs-title', translateUiText(title))); if (descNode) step.appendChild(descNode); if (actNode && !done) { var wrap = el('div', 'qs-act'); wrap.appendChild(actNode); step.appendChild(wrap); } return step; @@ -884,7 +954,7 @@ export const PAGE_HTML = ` t.onclick = function () { CURRENT = name; renderProfiles(); renderForm(); setStatus(''); openDrawer(); }; grid.appendChild(t); }); - var add = el('div', 'tile tile-add', '+ New profile'); + var add = uiEl('div', 'tile tile-add', '+ New profile'); add.onclick = newProfile; grid.appendChild(add); } @@ -913,7 +983,7 @@ export const PAGE_HTML = ` } function detailSection(label, node, action) { var sec = el('div', 'detail-sec'); - var head = el('div', 'detail-label', label); + var head = el('div', 'detail-label', translateUiText(label)); if (action) { head.classList.add('detail-label-row'); head.appendChild(action); } sec.appendChild(head); sec.appendChild(node); @@ -1022,7 +1092,7 @@ export const PAGE_HTML = ` return html; } function renderMarkdownInto(container, md) { - if (!md) { container.appendChild(el('div', 'loading', '(empty)')); return; } + if (!md) { container.appendChild(uiEl('div', 'loading', '(empty)')); return; } var wrap = el('div', 'md-body'); wrap.innerHTML = renderMarkdown(md); container.appendChild(wrap); @@ -1039,10 +1109,10 @@ export const PAGE_HTML = ` { source: 'windsurf', label: 'Windsurf' }, { source: 'gemini', label: 'Gemini' } ]; - function setSkillErr(msg) { var e = document.getElementById('skillErr'); if (e) e.textContent = msg || ''; } + function setSkillErr(msg) { var e = document.getElementById('skillErr'); if (e) e.textContent = translateUiText(msg || ''); } function openSkillInstall() { var title = document.getElementById('infoTitle'); - title.textContent = ''; title.appendChild(el('span', '', 'Add skill')); + title.textContent = ''; title.appendChild(uiEl('span', '', 'Add skill')); var body = document.getElementById('infoBody'); body.innerHTML = ''; var sel = el('select', 'select'); sel.id = 'skillNewSource'; SKILL_TARGETS.forEach(function (s) { var o = el('option', '', s.label); o.value = s.source; sel.appendChild(o); }); @@ -1053,21 +1123,21 @@ export const PAGE_HTML = ` var fileInp = el('input'); fileInp.id = 'skillFile'; fileInp.type = 'file'; fileInp.accept = '.zip,application/zip'; fileInp.style.display = 'none'; var picker = el('div', 'file-picker'); - var pickBtn = el('button', 'file-pick-btn', 'Choose .zip file'); pickBtn.type = 'button'; - var fileName = el('span', 'file-name', 'No file selected'); + var pickBtn = uiEl('button', 'file-pick-btn', 'Choose .zip file'); pickBtn.type = 'button'; + var fileName = uiEl('span', 'file-name', 'No file selected'); pickBtn.onclick = function () { fileInp.click(); }; fileInp.onchange = function () { var f = fileInp.files && fileInp.files[0]; - fileName.textContent = f ? f.name : 'No file selected'; + fileName.textContent = f ? f.name : translateUiText('No file selected'); fileName.classList.toggle('has-file', !!f); }; picker.appendChild(pickBtn); picker.appendChild(fileName); picker.appendChild(fileInp); body.appendChild(detailSection('Skill package (.zip)', picker)); - body.appendChild(el('p', 'mcp-note', 'The .zip must contain a SKILL.md at its root or inside a single top-level folder.')); + body.appendChild(uiEl('p', 'mcp-note', 'The .zip must contain a SKILL.md at its root or inside a single top-level folder.')); var err = el('div', 'modal-err'); err.id = 'skillErr'; body.appendChild(err); openInfoDrawer(); var foot = document.getElementById('infoFoot'); foot.innerHTML = ''; foot.hidden = false; - var install = el('button', 'btn-primary', 'Install'); install.type = 'button'; + var install = uiEl('button', 'btn-primary', 'Install'); install.type = 'button'; install.onclick = function () { doInstallSkill(install); }; foot.appendChild(install); } @@ -1106,7 +1176,7 @@ export const PAGE_HTML = ` chips.appendChild(el('span', 'chip', s.fileCount + ' files')); body.appendChild(detailSection('Installed in', chips)); body.appendChild(detailSection('Path', el('div', 'detail-path', s.path))); - var codeSec = detailSection('SKILL.md', el('div', 'loading', 'Loading…')); + var codeSec = detailSection('SKILL.md', uiEl('div', 'loading', 'Loading…')); body.appendChild(codeSec); openInfoDrawer(); api('/api/skill?id=' + encodeURIComponent(s.id)).then(function (r) { return r.json(); }).then(function (j) { @@ -1114,14 +1184,14 @@ export const PAGE_HTML = ` renderMarkdownInto(codeSec, (j && j.content) || ''); }).catch(function (e) { codeSec.removeChild(codeSec.lastChild); - codeSec.appendChild(el('div', 'err', 'Failed to load: ' + e)); + codeSec.appendChild(uiEl('div', 'err', 'Failed to load: ' + e)); }); } function makeSelect(key, options, current) { var sel = document.createElement('select'); sel.id = 'f_' + key; sel.name = key; sel.className = 'select'; - var blank = document.createElement('option'); blank.value = ''; blank.textContent = '(unset)'; + var blank = document.createElement('option'); blank.value = ''; blank.textContent = translateUiText('(unset)'); sel.appendChild(blank); options.forEach(function (opt) { var o = document.createElement('option'); o.value = opt; o.textContent = opt; @@ -1134,12 +1204,12 @@ export const PAGE_HTML = ` function renderForm() { var form = document.getElementById('form'); form.innerHTML = ''; - document.getElementById('currentName').textContent = CURRENT === '' ? 'default (top-level)' : CURRENT; + document.getElementById('currentName').textContent = CURRENT === '' ? translateUiText('default (top-level)') : CURRENT; document.getElementById('deleteBtn').style.display = CURRENT === '' ? 'none' : ''; var selectedName = CURRENT === '' ? 'default' : CURRENT; var useBtn = document.getElementById('useBtn'); useBtn.disabled = selectedName === ACTIVE; - useBtn.textContent = selectedName === ACTIVE ? 'Active' : 'Save & Activate'; + useBtn.textContent = translateUiText(selectedName === ACTIVE ? 'Active' : 'Save & Activate'); var data = profileData(CURRENT); KEYS.forEach(function (key) { var row = el('div', 'row'); @@ -1154,10 +1224,10 @@ export const PAGE_HTML = ` // the key). 'new-password' reliably suppresses saved-credential autofill. input.autocomplete = 'new-password'; input.setAttribute('autocorrect', 'off'); input.spellcheck = false; - var toggle = el('button', 'toggle', 'show'); toggle.type = 'button'; + var toggle = el('button', 'toggle', translateUiText('show')); toggle.type = 'button'; toggle.onclick = function () { - if (input.type === 'password') { input.type = 'text'; toggle.textContent = 'hide'; } - else { input.type = 'password'; toggle.textContent = 'show'; } + if (input.type === 'password') { input.type = 'text'; toggle.textContent = translateUiText('hide'); } + else { input.type = 'password'; toggle.textContent = translateUiText('show'); } }; var wrap = el('div', 'inputwrap'); wrap.appendChild(input); wrap.appendChild(toggle); row.appendChild(label); row.appendChild(wrap); @@ -1190,7 +1260,7 @@ export const PAGE_HTML = ` function modelCatalogBlock(key, input) { var opts = MODEL_CATALOG[key] || []; var box = el('div', 'model-cat'); - box.appendChild(el('div', 'model-cat-hint', 'Available ' + modelCatLabel(key) + ' models · click to use')); + box.appendChild(el('div', 'model-cat-hint', translateUiText('Available ' + modelCatLabel(key) + ' models · click to use'))); var chips = el('div', 'model-chips'); function mark() { var cur = input.value.trim(); @@ -1211,7 +1281,7 @@ export const PAGE_HTML = ` var note = modelCatNote(key); if (note) { var noteEl = el('div', 'model-cat-note'); - noteEl.innerHTML = note; + noteEl.innerHTML = translateUiText(note); box.appendChild(noteEl); } setTimeout(mark, 0); @@ -1235,6 +1305,9 @@ export const PAGE_HTML = ` if (!result.ok) throw new Error((result.json && result.json.error) || 'error'); var saved = result.json.saved || {}; if (name === '') DATA.default = saved; else DATA.named[name] = saved; + if (result.json.uiLanguage && result.json.uiLanguage !== UI_LANGUAGE) { + applyUiLanguage(result.json.uiLanguage); + } return saved; }); } @@ -1259,17 +1332,17 @@ export const PAGE_HTML = ` } function setModalErr(msg) { - document.getElementById('modalErr').textContent = msg || ''; + document.getElementById('modalErr').textContent = translateUiText(msg || ''); } var _confirmOnOk = null; function openConfirm(opts) { opts = opts || {}; - document.getElementById('confirmTitle').textContent = opts.title || 'Are you sure?'; - document.getElementById('confirmMsg').textContent = opts.message || ''; + document.getElementById('confirmTitle').textContent = translateUiText(opts.title || 'Are you sure?'); + document.getElementById('confirmMsg').textContent = translateUiText(opts.message || ''); var ok = document.getElementById('confirmOk'); var cancel = document.getElementById('confirmCancel'); - ok.textContent = opts.okLabel || 'Delete'; + ok.textContent = translateUiText(opts.okLabel || 'Delete'); ok.className = opts.danger === false ? 'btn-primary' : 'btn-danger-solid'; cancel.hidden = !!opts.hideCancel; _confirmOnOk = opts.onConfirm || null; @@ -1322,6 +1395,9 @@ export const PAGE_HTML = ` }).then(function (result) { if (!result.ok) throw new Error('Saved, but activation failed: ' + ((result.json && result.json.error) || 'error')); ACTIVE = result.json.activeProfile || 'default'; + if (result.json.uiLanguage && result.json.uiLanguage !== UI_LANGUAGE) { + applyUiLanguage(result.json.uiLanguage); + } renderProfiles(); renderForm(); setStatus('Saved and activated.'); }).catch(function (err) { setStatus(err.message || String(err), true); }); } @@ -1350,8 +1426,8 @@ export const PAGE_HTML = ` function renderEmpty(container, msg, hint) { container.innerHTML = ''; var box = el('div', 'empty'); - box.appendChild(el('div', '', msg)); - if (hint) { var p = el('p', 'muted'); p.style.marginTop = '10px'; p.innerHTML = hint; box.appendChild(p); } + box.appendChild(el('div', '', translateUiText(msg))); + if (hint) { var p = el('p', 'muted'); p.style.marginTop = '10px'; p.innerHTML = translateUiText(hint); box.appendChild(p); } container.appendChild(box); } function renderError(container, e) { renderEmpty(container, 'Failed to load: ' + e); } @@ -1406,7 +1482,7 @@ export const PAGE_HTML = ` } }); bar.appendChild(navBtn('\u203a', info.page + 1, info.page >= info.pages)); - var sel = el('select', 'pg-size'); sel.title = 'Items per page'; + var sel = el('select', 'pg-size'); sel.title = translateUiText('Items per page'); PAGE_SIZES.forEach(function (s) { var o = el('option', '', s + ' / page'); o.value = String(s); if (s === getPageSize(view)) o.selected = true; @@ -1461,7 +1537,7 @@ export const PAGE_HTML = ` if (s.description) t.appendChild(el('p', 'tile-desc', s.description)); var srcRow = el('div', 'tile-foot'); (s.sources || []).forEach(function (src) { srcRow.appendChild(el('span', 'chip blue', src)); }); - srcRow.appendChild(el('span', 'chip', s.fileCount + ' files')); + srcRow.appendChild(uiEl('span', 'chip', s.fileCount + ' files')); t.appendChild(srcRow); grid.appendChild(t); }); @@ -1537,16 +1613,16 @@ export const PAGE_HTML = ` body.appendChild(detailSection('Configuration' + (editable ? ' (editable)' : ''), block, copyButton(function () { return editable ? document.getElementById('mcpEdit').value : json; }))); - if (!editable) body.appendChild(el('p', 'mcp-note', 'This source is read-only here (its config is TOML). Edit it directly in ' + (m.source || 'its config file') + '.')); + if (!editable) body.appendChild(uiEl('p', 'mcp-note', 'This source is read-only here (its config is TOML). Edit it directly in ' + (m.source || 'its config file') + '.')); var err = el('div', 'modal-err'); err.id = 'mcpErr'; body.appendChild(err); openInfoDrawer(); if (editable) { var foot = document.getElementById('infoFoot'); foot.innerHTML = ''; foot.hidden = false; - var del = el('button', 'btn-danger', 'Delete'); del.type = 'button'; + var del = uiEl('button', 'btn-danger', 'Delete'); del.type = 'button'; del.onclick = function () { openConfirm({ title: 'Delete MCP server?', message: 'Remove "' + m.name + '" from ' + m.source + '? This rewrites the source config file.', onConfirm: function () { doDeleteMcp(m); } }); }; - var save = el('button', 'btn-primary', 'Save'); save.type = 'button'; + var save = uiEl('button', 'btn-primary', 'Save'); save.type = 'button'; save.onclick = function () { doSaveMcp(m, save); }; foot.appendChild(del); foot.appendChild(save); } @@ -1564,16 +1640,16 @@ export const PAGE_HTML = ` { id: 'qoderwork', label: 'QoderWork' } ]; function copyButton(getText) { - var b = el('button', 'copy-btn', 'Copy'); b.type = 'button'; + var b = uiEl('button', 'copy-btn', 'Copy'); b.type = 'button'; b.onclick = function () { var text = getText(); - var done = function () { b.classList.add('copied'); b.textContent = 'Copied'; setTimeout(function () { b.classList.remove('copied'); b.textContent = 'Copy'; }, 1500); }; + var done = function () { b.classList.add('copied'); b.textContent = translateUiText('Copied'); setTimeout(function () { b.classList.remove('copied'); b.textContent = translateUiText('Copy'); }, 1500); }; if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(text).then(done).catch(function () { fallbackCopy(text); done(); }); } else { fallbackCopy(text); done(); } }; return b; } - function setMcpErr(msg) { var e = document.getElementById('mcpErr'); if (e) e.textContent = msg || ''; } + function setMcpErr(msg) { var e = document.getElementById('mcpErr'); if (e) e.textContent = translateUiText(msg || ''); } function parseMcpEditor() { var parsed = JSON.parse(document.getElementById('mcpEdit').value); if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('Config must be a JSON object.'); @@ -1605,7 +1681,7 @@ export const PAGE_HTML = ` } function openMcpCreate() { var title = document.getElementById('infoTitle'); - title.textContent = ''; title.appendChild(el('span', '', 'New MCP server')); + title.textContent = ''; title.appendChild(uiEl('span', '', 'New MCP server')); var body = document.getElementById('infoBody'); body.innerHTML = ''; var sel = el('select', 'select'); sel.id = 'mcpNewSource'; MCP_SOURCES.forEach(function (s) { var o = el('option', '', s.label); o.value = s.id; sel.appendChild(o); }); @@ -1618,7 +1694,7 @@ export const PAGE_HTML = ` var err = el('div', 'modal-err'); err.id = 'mcpErr'; body.appendChild(err); openInfoDrawer(); var foot = document.getElementById('infoFoot'); foot.innerHTML = ''; foot.hidden = false; - var create = el('button', 'btn-primary', 'Create'); create.type = 'button'; + var create = uiEl('button', 'btn-primary', 'Create'); create.type = 'button'; create.onclick = function () { doCreateMcp(create); }; foot.appendChild(create); } @@ -1664,7 +1740,7 @@ export const PAGE_HTML = ` var labels = { all: 'All', local: 'Local', remote: 'Remote' }; ['all', 'local', 'remote'].forEach(function (cat) { var b = el('button', 'filter' + (cat === AGENT_FILTER ? ' is-active' : '')); - b.appendChild(el('span', '', labels[cat])); + b.appendChild(uiEl('span', '', labels[cat])); b.appendChild(el('span', 'n', String(counts[cat] || 0))); b.onclick = function () { AGENT_FILTER = cat; AGENT_PAGE = 1; renderAgentFilters(); renderAgents(); }; bar.appendChild(b); @@ -1701,9 +1777,9 @@ export const PAGE_HTML = ` var meta = el('div', 'tile-meta'); meta.appendChild(originBadge(a.origin)); var pill; - if (a.installed && a.configured) pill = el('span', 'pill ok', 'Connected'); - else if (a.installed) pill = el('span', 'pill neutral', 'Installed'); - else pill = el('span', 'pill off', 'Not installed'); + if (a.installed && a.configured) pill = uiEl('span', 'pill ok', 'Connected'); + else if (a.installed) pill = uiEl('span', 'pill neutral', 'Installed'); + else pill = uiEl('span', 'pill off', 'Not installed'); meta.appendChild(pill); t.appendChild(meta); var foot = el('div', 'tile-foot'); @@ -1718,17 +1794,17 @@ export const PAGE_HTML = ` var actions = el('div', 'tile-actions'); var launch = el('button', 'icon-run'); launch.innerHTML = PLAY_SVG; - launch.setAttribute('aria-label', 'Quick launch'); + launch.setAttribute('aria-label', translateUiText('Quick launch')); var connected = a.installed && a.configured; var st = el('span', 'launch-status'); if (connected && a.launchable) { - launch.title = 'Open a new terminal and start this agent'; + launch.title = translateUiText('Open a new terminal and start this agent'); launch.onclick = function (e) { e.stopPropagation(); launchAgentCli(a, launch, st); }; } else { launch.disabled = true; - if (!a.installed) launch.title = 'Install this agent before launching'; - else if (!connected) launch.title = 'Connect this agent to bailian-cli before launching'; - else launch.title = 'The CLI for this agent was not found on your PATH — install it before launching'; + if (!a.installed) launch.title = translateUiText('Install this agent before launching'); + else if (!connected) launch.title = translateUiText('Connect this agent to bailian-cli before launching'); + else launch.title = translateUiText('The CLI for this agent was not found on your PATH — install it before launching'); } actions.appendChild(st); actions.appendChild(launch); @@ -1741,15 +1817,15 @@ export const PAGE_HTML = ` function launchAgentCli(a, btn, st) { btn.disabled = true; - st.textContent = 'Launching…'; st.className = 'launch-status'; + st.textContent = translateUiText('Launching…'); st.className = 'launch-status'; api('/api/agent/launch?id=' + encodeURIComponent(a.id), { method: 'POST' }) .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, j: j }; }); }) .then(function (res) { btn.disabled = false; - if (!res.ok) { st.textContent = (res.j && res.j.error) || 'Launch failed'; st.className = 'launch-status err'; return; } - st.textContent = 'Launched → ' + (res.j.command || a.id); st.className = 'launch-status ok'; + if (!res.ok) { st.textContent = (res.j && res.j.error) || translateUiText('Launch failed'); st.className = 'launch-status err'; return; } + st.textContent = translateUiText('Launched → ') + (res.j.command || a.id); st.className = 'launch-status ok'; }) - .catch(function (e) { btn.disabled = false; st.textContent = 'Launch failed: ' + e; st.className = 'launch-status err'; }); + .catch(function (e) { btn.disabled = false; st.textContent = translateUiText('Launch failed: ') + e; st.className = 'launch-status err'; }); } function openAgentDetail(a) { @@ -1770,9 +1846,9 @@ export const PAGE_HTML = ` var chips = el('div', 'detail-chips'); chips.appendChild(el('span', 'chip', d.id)); var pill; - if (d.installed && d.configured) pill = el('span', 'pill ok', 'Connected'); - else if (d.installed) pill = el('span', 'pill neutral', 'Installed'); - else pill = el('span', 'pill off', 'Not installed'); + if (d.installed && d.configured) pill = uiEl('span', 'pill ok', 'Connected'); + else if (d.installed) pill = uiEl('span', 'pill neutral', 'Installed'); + else pill = uiEl('span', 'pill off', 'Not installed'); chips.appendChild(pill); body.appendChild(detailSection('Status', chips)); if (d.fields && d.fields.length) { @@ -1784,13 +1860,13 @@ export const PAGE_HTML = ` row.appendChild(val); if (f.secret && f.raw) { var shown = false; - var btn = el('button', 'kv-reveal', 'Show'); + var btn = uiEl('button', 'kv-reveal', 'Show'); btn.type = 'button'; btn.onclick = function () { shown = !shown; val.textContent = shown ? f.raw : f.value; if (shown) { val.classList.remove('secret'); } else { val.classList.add('secret'); } - btn.textContent = shown ? 'Hide' : 'Show'; + btn.textContent = translateUiText(shown ? 'Hide' : 'Show'); }; row.appendChild(btn); } @@ -1798,9 +1874,9 @@ export const PAGE_HTML = ` }); body.appendChild(detailSection('Configuration', kv)); } else if (d.installed) { - body.appendChild(detailSection('Configuration', el('div', 'detail-path', 'bailian-cli is not wired into this agent yet.'))); + body.appendChild(detailSection('Configuration', uiEl('div', 'detail-path', 'bailian-cli is not wired into this agent yet.'))); } else { - body.appendChild(detailSection('Configuration', el('div', 'detail-path', 'This agent is not installed yet.'))); + body.appendChild(detailSection('Configuration', uiEl('div', 'detail-path', 'This agent is not installed yet.'))); } if (d.files && d.files.length) { var wrap = el('div', ''); @@ -1821,9 +1897,9 @@ export const PAGE_HTML = ` item.appendChild(el('pre', 'detail-code', fl.text)); sw.appendChild(item); }); - var openBtn = el('button', 'kv-reveal', 'Open'); + var openBtn = uiEl('button', 'kv-reveal', 'Open'); openBtn.type = 'button'; - openBtn.title = 'Open the config file with the system default app'; + openBtn.title = translateUiText('Open the config file with the system default app'); openBtn.onclick = function () { openAgentSettings(d); }; body.appendChild(detailSection('Settings', sw, openBtn)); } @@ -1848,18 +1924,23 @@ export const PAGE_HTML = ` renderScenarios(); }).catch(function (e) { renderError(body, e); }); } - var SCN_ORDER = ['图像', '音频', '视频', '多模态', '代码', '文档']; + function scenarioOrder() { + return UI_LANGUAGE === 'zh-CN' + ? ['图像', '音频', '视频', '多模态', '代码', '文档'] + : ['Image', 'Audio', 'Video', 'Multimodal', 'Code', 'Documentation']; + } function scenarioCategories() { var present = {}; + var order = scenarioOrder(); SCENARIOS.forEach(function (s) { if (s.category) present[s.category] = true; }); - var cats = SCN_ORDER.filter(function (c) { return present[c]; }); - Object.keys(present).forEach(function (c) { if (SCN_ORDER.indexOf(c) < 0) cats.push(c); }); + var cats = order.filter(function (c) { return present[c]; }); + Object.keys(present).forEach(function (c) { if (order.indexOf(c) < 0) cats.push(c); }); return cats; } function renderScenarioFilters() { var bar = document.getElementById('scenarioFilters'); bar.innerHTML = ''; - var defs = [{ key: 'all', label: 'All', n: SCENARIOS.length }]; + var defs = [{ key: 'all', label: translateUiText('All'), n: SCENARIOS.length }]; scenarioCategories().forEach(function (c) { defs.push({ key: c, label: c, n: SCENARIOS.filter(function (s) { return s.category === c; }).length }); }); @@ -1875,7 +1956,7 @@ export const PAGE_HTML = ` return matchQ(s.title, q) || matchQ(s.description, q) || matchQ(s.category, q); } function pgWarnNode() { - return el('div', 'pg-warn', 'No connected agent can accept tasks yet. Install and connect qwen-code (or another supported agent) first, and make sure its CLI is on your PATH.'); + return uiEl('div', 'pg-warn', 'No connected agent can accept tasks yet. Install and connect qwen-code (or another supported agent) first, and make sure its CLI is on your PATH.'); } function renderScenarios() { var body = document.getElementById('playgroundBody'); @@ -1896,7 +1977,7 @@ export const PAGE_HTML = ` var grid = el('div', 'grid'); info.items.forEach(function (s) { var t = el('div', 'tile clickable'); - t.title = 'Click to edit this scenario'; + t.title = translateUiText('Click to edit this scenario'); t.onclick = function () { openScnDrawer(s); }; var top = el('div', 'tile-top'); top.appendChild(el('span', 'tile-name', s.title)); @@ -1908,12 +1989,12 @@ export const PAGE_HTML = ` var actions = el('div', 'tile-actions'); var run = el('button', 'icon-run'); run.innerHTML = PLAY_SVG; - run.setAttribute('aria-label', 'Run it'); + run.setAttribute('aria-label', translateUiText('Run it')); if (DISPATCH_AGENTS.length) { - run.title = 'Dispatch this task to a local agent'; + run.title = translateUiText('Dispatch this task to a local agent'); run.onclick = function (e) { e.stopPropagation(); openDispatch(s); }; } else { - run.disabled = true; run.title = 'No connected agent available'; + run.disabled = true; run.title = translateUiText('No connected agent available'); } actions.appendChild(run); t.appendChild(actions); @@ -1943,7 +2024,7 @@ export const PAGE_HTML = ` if (!DISPATCH_SCN) return; document.getElementById('dispatchPreview').textContent = fillPrompt(DISPATCH_SCN, dispatchValues()); } - function setDispatchErr(m) { document.getElementById('dispatchErr').textContent = m || ''; } + function setDispatchErr(m) { document.getElementById('dispatchErr').textContent = translateUiText(m || ''); } var LAST_AGENT_KEY = 'bl.lastDispatchAgent'; function openDispatch(s) { DISPATCH_SCN = s; @@ -2052,7 +2133,7 @@ export const PAGE_HTML = ` return arr.filter(function (s) { return s && s.id && s.title && s.prompt; }).map(function (s) { return { id: s.id, title: s.title, description: s.description || '', - category: s.category || 'Custom', prompt: s.prompt, + category: s.category || translateUiText('Custom'), prompt: s.prompt, inputs: Array.isArray(s.inputs) ? s.inputs : [], custom: true }; }); @@ -2072,12 +2153,12 @@ export const PAGE_HTML = ` var l = el('input'); l.className = 'scn-l'; l.placeholder = 'label'; l.autocomplete = 'off'; l.spellcheck = false; var p = el('input'); p.className = 'scn-p'; p.placeholder = 'placeholder (optional)'; p.autocomplete = 'off'; p.spellcheck = false; if (inp) { k.value = inp.key || ''; l.value = inp.label || ''; p.value = inp.placeholder || ''; } - var del = el('button', 'btn-mini', '\u00d7'); del.type = 'button'; del.title = 'Remove'; del.onclick = function () { row.remove(); }; + var del = el('button', 'btn-mini', '\u00d7'); del.type = 'button'; del.title = translateUiText('Remove'); del.onclick = function () { row.remove(); }; row.appendChild(k); row.appendChild(l); row.appendChild(p); row.appendChild(del); return row; } function addScnInputRow(inp) { document.getElementById('scnInputRows').appendChild(scnInputRow(inp)); } - function setScnErr(m) { document.getElementById('scnDrawerErr').textContent = m || ''; } + function setScnErr(m) { document.getElementById('scnDrawerErr').textContent = translateUiText(m || ''); } function fillScnCatList() { var dl = document.getElementById('scnCatList'); dl.innerHTML = ''; scenarioCategories().forEach(function (c) { var o = document.createElement('option'); o.value = c; dl.appendChild(o); }); @@ -2086,9 +2167,9 @@ export const PAGE_HTML = ` SCN_EDIT_ID = existing ? existing.id : null; SCN_EDIT_KIND = existing ? (existing.custom ? 'custom' : 'builtin') : 'new'; var isEdit = !!existing; - document.getElementById('scnDrawerTitle').textContent = isEdit ? (existing.custom ? 'Edit scenario' : 'Edit preset scenario') : 'Custom scenario'; + document.getElementById('scnDrawerTitle').textContent = translateUiText(isEdit ? (existing.custom ? 'Edit scenario' : 'Edit preset scenario') : 'Custom scenario'); document.getElementById('scnTitle').value = existing ? existing.title : ''; - document.getElementById('scnCategory').value = existing ? (existing.category || '') : 'Custom'; + document.getElementById('scnCategory').value = existing ? (existing.category || '') : translateUiText('Custom'); document.getElementById('scnDesc').value = existing ? (existing.description || '') : ''; document.getElementById('scnPrompt').value = existing ? existing.prompt : ''; var rows = document.getElementById('scnInputRows'); rows.innerHTML = ''; @@ -2096,7 +2177,7 @@ export const PAGE_HTML = ` fillScnCatList(); var del = document.getElementById('scnDelete'); del.hidden = !isEdit; - del.textContent = 'Delete'; + del.textContent = translateUiText('Delete'); setScnErr(''); document.getElementById('scnDrawer').hidden = false; document.body.style.overflow = 'hidden'; @@ -2131,7 +2212,7 @@ export const PAGE_HTML = ` if (!prompt) { setScnErr('Prompt template is required.'); return; } var res = collectScnInputs(); if (res.error) { setScnErr(res.error); return; } - var cat = document.getElementById('scnCategory').value.trim() || 'Custom'; + var cat = document.getElementById('scnCategory').value.trim() || translateUiText('Custom'); var desc = document.getElementById('scnDesc').value.trim(); if (SCN_EDIT_KIND === 'builtin' && SCN_EDIT_ID && isBuiltin(SCN_EDIT_ID)) { var ov = loadOverrides(); @@ -2187,10 +2268,10 @@ export const PAGE_HTML = ` return '\uD83D\uDCC4'; } function assetKindLabel(kind) { - if (kind === 'image') return 'Image'; - if (kind === 'video') return 'Video'; - if (kind === 'audio') return 'Audio'; - return 'File'; + if (kind === 'image') return translateUiText('Image'); + if (kind === 'video') return translateUiText('Video'); + if (kind === 'audio') return translateUiText('Audio'); + return translateUiText('File'); } function fmtDim(w, h) { return w && h ? w + ' \u00d7 ' + h : ''; } @@ -2243,14 +2324,14 @@ export const PAGE_HTML = ` tabs.forEach(function (t) { var cat = t[0]; var b = el('button', 'filter' + (cat === ASSET_FILTER ? ' is-active' : '')); - b.appendChild(el('span', '', t[1])); + b.appendChild(uiEl('span', '', t[1])); b.appendChild(el('span', 'n', String(counts[cat] || 0))); b.onclick = function () { ASSET_FILTER = cat; ASSET_PAGE = 1; renderAssetFilters(); renderAssets(); }; bar.appendChild(b); }); var sort = el('button', 'filter sort-toggle'); - sort.title = 'Toggle sort by generation time'; - sort.textContent = ASSET_SORT === 'new' ? '↓ Newest first' : '↑ Oldest first'; + sort.title = translateUiText('Toggle sort by generation time'); + sort.textContent = translateUiText(ASSET_SORT === 'new' ? '↓ Newest first' : '↑ Oldest first'); sort.onclick = function () { ASSET_SORT = ASSET_SORT === 'new' ? 'old' : 'new'; ASSET_PAGE = 1; renderAssetFilters(); renderAssets(); }; bar.appendChild(sort); } @@ -2285,7 +2366,7 @@ export const PAGE_HTML = ` var mediaEl = null; if (a.kind === 'image') { var img = el('img'); img.src = src; img.loading = 'lazy'; img.alt = a.name; - img.title = 'View details'; + img.title = translateUiText('View details'); media.appendChild(img); mediaEl = img; } else if (a.kind === 'video') { var vid = el('video'); vid.src = src; vid.controls = true; vid.preload = 'metadata'; @@ -2297,16 +2378,16 @@ export const PAGE_HTML = ` media.appendChild(au); } else { var icon = el('span', 'asset-icon', assetIcon(a.kind)); - icon.title = 'View details'; + icon.title = translateUiText('View details'); media.appendChild(icon); } card.appendChild(media); card.appendChild(el('span', 'asset-cat', assetKindLabel(a.kind))); - var del = el('button', 'asset-del', '×'); del.title = 'Delete'; + var del = el('button', 'asset-del', '×'); del.title = translateUiText('Delete'); del.onclick = function (e) { e.stopPropagation(); deleteAsset(a); }; card.appendChild(del); var b = el('div', 'asset-body'); - var nm = el('div', 'asset-name link', a.name); nm.title = 'View details — ' + a.relPath; + var nm = el('div', 'asset-name link', a.name); nm.title = translateUiText('View details — ') + a.relPath; b.appendChild(nm); var folder = assetFolder(a.relPath); if (folder) { @@ -2363,9 +2444,9 @@ export const PAGE_HTML = ` body.appendChild(detailSection('Details', chips)); body.appendChild(detailSection('Path', el('div', 'detail-path', a.relPath))); openInfoDrawer(); - var openBtn = el('button', 'btn-primary', 'Open locally'); + var openBtn = uiEl('button', 'btn-primary', 'Open locally'); openBtn.onclick = function () { openAsset(a); }; - var delBtn = el('button', 'btn-danger', 'Delete'); + var delBtn = uiEl('button', 'btn-danger', 'Delete'); delBtn.onclick = function () { deleteAsset(a, true); }; var foot = document.getElementById('infoFoot'); foot.appendChild(openBtn); foot.appendChild(delBtn); @@ -2393,6 +2474,8 @@ export const PAGE_HTML = ` } /* ---------- wiring ---------- */ + captureStaticUiCopy(); + applyStaticUiCopy(); var navItems = document.querySelectorAll('.nav-item'); for (var n = 0; n < navItems.length; n++) { navItems[n].onclick = function () { showView(this.getAttribute('data-view')); }; @@ -2468,8 +2551,8 @@ export const PAGE_HTML = ` var btn = document.getElementById('copyUrl'); btn.onclick = function () { var done = function () { - btn.textContent = 'Copied'; btn.classList.add('copied'); - setTimeout(function () { btn.textContent = 'Copy'; btn.classList.remove('copied'); }, 1400); + btn.textContent = translateUiText('Copied'); btn.classList.add('copied'); + setTimeout(function () { btn.textContent = translateUiText('Copy'); btn.classList.remove('copied'); }, 1400); }; if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(url).then(done).catch(function () { fallbackCopy(url); done(); }); @@ -2485,10 +2568,10 @@ export const PAGE_HTML = ` } function authMethodLabel(p) { - if (p === 'console') return 'Console gateway'; - if (p === 'apiKey') return 'API key'; + if (p === 'console') return translateUiText('Console gateway'); + if (p === 'apiKey') return translateUiText('API key'); if (p === 'openapi') return 'OpenAPI (AK/SK)'; - return 'Account'; + return translateUiText('Account'); } function acctHue(seed) { var h = 0, s = seed || 'bl'; @@ -2533,7 +2616,7 @@ export const PAGE_HTML = ` var meta = []; if (st.region) meta.push(st.region); if (st.site) meta.push(st.site); - document.getElementById('acctMeta').textContent = meta.join(' · ') || 'Authenticated'; + document.getElementById('acctMeta').textContent = meta.join(' · ') || translateUiText('Authenticated'); var tok = document.getElementById('acctToken'); tok.textContent = st.masked || ''; tok.hidden = !st.masked; @@ -2542,7 +2625,7 @@ export const PAGE_HTML = ` closeAcctMenu(); loginBtn.hidden = false; loginBtn.disabled = false; - loginBtn.querySelector('span').textContent = 'Log in'; + loginBtn.querySelector('span').textContent = translateUiText('Log in'); } } function toggleAcctMenu() { @@ -2560,8 +2643,9 @@ export const PAGE_HTML = ` } function setLoginUi(busy, label) { var b = document.getElementById('loginBtn'); - if (b) { b.disabled = busy; b.querySelector('span').textContent = label; } - if (QS_LOGIN_BTN && document.body.contains(QS_LOGIN_BTN)) { QS_LOGIN_BTN.disabled = busy; QS_LOGIN_BTN.textContent = label; } + var translatedLabel = translateUiText(label); + if (b) { b.disabled = busy; b.querySelector('span').textContent = translatedLabel; } + if (QS_LOGIN_BTN && document.body.contains(QS_LOGIN_BTN)) { QS_LOGIN_BTN.disabled = busy; QS_LOGIN_BTN.textContent = translatedLabel; } } function startLogin() { setLoginUi(true, 'Opening browser…'); @@ -2607,3 +2691,7 @@ export const PAGE_HTML = ` `; + +export function renderConfigUiHtml(language: Language): string { + return renderConfigUiShell(PAGE_HTML, language); +} diff --git a/packages/commands/src/commands/config/ui-i18n.ts b/packages/commands/src/commands/config/ui-i18n.ts new file mode 100644 index 0000000..d0907d8 --- /dev/null +++ b/packages/commands/src/commands/config/ui-i18n.ts @@ -0,0 +1,369 @@ +import type { Language } from "bailian-cli-core"; + +/** + * Config UI is a self-contained HTML document, so this catalog is embedded in + * the page and applied only to UI-owned text and attributes in the browser. + * Replacements are longest-first to keep a short label such as "Save" from + * changing a longer sentence before it is matched. + */ +const ZH_CN_REPLACEMENTS: ReadonlyArray = [ + ["Config - Alibaba Cloud Model Studio CLI", "配置 - 阿里云百炼 CLI"], + [ + 'The login was not completed in time. Click "Log in" to try again.', + "登录未能及时完成。请点击“登录”重试。", + ], + [ + "No connected agent can accept tasks yet. Install and connect qwen-code (or another supported agent) first, and make sure its CLI is on your PATH.", + "当前没有可接收任务的已连接 Agent。请先安装并连接 qwen-code(或其他受支持的 Agent),并确保其 CLI 位于 PATH 中。", + ], + [ + "Complete a few steps to check your local environment and finish first-time setup: sign in to Bailian, connect a local coding agent, and run your first task. Each circle turns from gray to green as you go.", + "完成以下步骤以检查本地环境并完成首次配置:登录百炼、连接本地编程 Agent,并运行第一个任务。完成后,每个圆点会由灰色变为绿色。", + ], + [ + 'Pick a scenario to dispatch a preset task to a connected local coding agent, running in a new terminal. Tasks run in the directory where bl config ui was started.', + '选择一个场景,将预设任务发送给已连接的本地编程 Agent,并在新终端中运行。任务会在启动 bl config ui 的目录中执行。', + ], + [ + "Pick a scenario to dispatch a preset task to a connected local coding agent, running in a new terminal. Tasks run in the directory where ", + "选择一个场景,将预设任务发送给已连接的本地编程 Agent,并在新终端中运行。任务会在启动 ", + ], + [" was started.", " 的目录中执行。"], + [ + "Credentials and default models. The active profile (marked with a star) is used by every bl command. Click a profile to edit its settings.", + "管理凭证和默认模型。所有 bl 命令都会使用带星标的已激活 Profile。点击 Profile 可编辑其设置。", + ], + [ + 'Agent skills discovered across every local agent module (~/.agents/skills plus each agent\'s skills folder). Installed via npx skills add.', + '展示从本地各 Agent 模块中发现的 Skill(~/.agents/skills 以及各 Agent 的 skills 目录),可通过 npx skills add 安装。', + ], + [ + "Agent skills discovered across every local agent module (~/.agents/skills plus each agent's skills folder). Installed via ", + "展示从本地各 Agent 模块中发现的 Skill(~/.agents/skills 以及各 Agent 的 skills 目录),可通过 ", + ], + [ + "Media that bl writes into the output directory, grouped by type (images, videos, audio, files) and generation time.", + "bl 写入输出目录的媒体文件,按类型(图片、视频、音频、文件)和生成时间展示。", + ], + [ + 'Frameworks bl can configure. "Connected" means the bailian-cli provider is wired into that agent.', + 'bl 可以配置的编程 Agent。"已连接"表示该 Agent 已接入 bailian-cli provider。', + ], + [ + "Model Context Protocol servers declared in your local coding-agent configs.", + "本地编程 Agent 配置中声明的 Model Context Protocol 服务器。", + ], + [ + "Create a named profile with its own credentials and default models.", + "创建一个拥有独立凭证和默认模型的命名 Profile。", + ], + [ + "Inputs become fillable fields when dispatching. Reference each one in the prompt as {{key}}.", + "任务发送时,输入项会显示为可填写字段。请在提示词中使用 {{key}} 引用对应输入。", + ], + [ + "The .zip must contain a SKILL.md at its root or inside a single top-level folder.", + ".zip 根目录或唯一的顶层目录中必须包含 SKILL.md。", + ], + ["The active profile is used by every bl command.", "所有 bl 命令都会使用已激活的 Profile。"], + ['Delete profile "', "删除 Profile“"], + [ + '"? This permanently removes its credentials and default models.', + "”?这会永久删除其中的凭证和默认模型。", + ], + ['Delete asset "', "删除资产“"], + ['"? This removes the file from disk.', "”?这会从磁盘中删除该文件。"], + [ + "MCP servers configured in Claude Code, Codex, Qwen Code or OpenCode will appear here.", + "在 Claude Code、Codex、Qwen Code 或 OpenCode 中配置的 MCP 服务器会显示在这里。", + ], + [ + "Assets from bl image, bl video, bl speech and bl omni will appear here.", + "通过 bl image、bl video、bl speech 和 bl omni 生成的资产会显示在这里。", + ], + ["Remote agents loaded from a URL will appear here.", "通过 URL 加载的远程 Agent 会显示在这里。"], + [ + "The CLI for this agent was not found on your PATH — install it before launching", + "未在 PATH 中找到该 Agent 的 CLI,请先安装再启动", + ], + [ + "Connect this agent to bailian-cli before launching", + "请先将该 Agent 连接到 bailian-cli 再启动", + ], + ["Install this agent before launching", "请先安装该 Agent 再启动"], + ["Open a new terminal and start this agent", "在新终端中启动该 Agent"], + ["Open the config file with the system default app", "使用系统默认应用打开配置文件"], + ["Task sent to ", "任务已发送至 "], + [". Check the newly opened terminal window.", "。请查看新打开的终端窗口。"], + ["Please enter a profile name.", "请输入 Profile 名称。"], + ["Only letters, numbers, - and _ are allowed.", "仅允许使用字母、数字、- 和 _。"], + ['"default" is reserved for the top-level profile.', "“default”保留用于顶层 Profile。"], + ['A profile named "', "名为“"], + ['" already exists.', "”的 Profile 已存在。"], + ["Saved, but activation failed: ", "已保存,但激活失败:"], + ["Could not read runtime environment info.", "无法读取运行环境信息。"], + ["Runtime Node ", "运行时 Node "], + [" · platform ", " · 平台 "], + ["Signed in: ", "已登录:"], + ["Connected: ", "已连接:"], + ["All agents (~/.agents/skills)", "所有 Agent(~/.agents/skills)"], + [ + "Optional — folder name (defaults to the archive folder)", + "可选 — 文件夹名称(默认使用压缩包中的文件夹名称)", + ], + ["Skill name (optional)", "Skill 名称(可选)"], + ["Skill package (.zip)", "Skill 包(.zip)"], + ["Please choose a .zip file.", "请选择 .zip 文件。"], + [ + "Install with npx skills add modelstudioai/cli --all -g", + "使用 npx skills add modelstudioai/cli --all -g 安装", + ], + ['Skill "', "Skill“"], + ['MCP server "', "MCP 服务器“"], + ['" installed (', "”已安装("], + [" files) to ", " 个文件),位置:"], + ['" was updated.', "”已更新。"], + ['" was added to ', "”已添加至 "], + ['Remove "', "移除“"], + ['" from ', "”(来源:"], + ["? This rewrites the source config file.", ")?此操作会重写来源配置文件。"], + ["Could not delete this server.", "无法删除该服务器。"], + ["Delete MCP server?", "删除 MCP 服务器?"], + ["Config must be a JSON object.", "配置必须是 JSON 对象。"], + ["Invalid JSON: ", "无效的 JSON:"], + ["Please enter a server name.", "请输入服务器名称。"], + ["No remote agents yet.", "暂无远程 Agent。"], + ["No agents in this category.", "该分类下暂无 Agent。"], + ['No agents match "', "没有匹配“"], + ["bailian-cli is not wired into this agent yet.", "该 Agent 尚未连接 bailian-cli。"], + ["This agent is not installed yet.", "该 Agent 尚未安装。"], + ["No scenarios in this category.", "该分类下暂无场景。"], + ['No scenarios match "', "没有匹配“"], + ["Click to edit this scenario", "点击编辑此场景"], + ["Dispatch this task to a local agent", "将此任务发送至本地 Agent"], + ["No connected agent available", "没有可用的已连接 Agent"], + ["Please fill in: ", "请填写:"], + ['Input key "', "输入键“"], + ['" may only use letters, digits, underscore.', "”只能使用字母、数字和下划线。"], + ["Duplicate input key: ", "输入键重复:"], + ["Title is required.", "标题为必填项。"], + ["Prompt template is required.", "提示词模板为必填项。"], + ['No assets match "', "没有匹配“"], + ["Toggle sort by generation time", "切换生成时间排序"], + ["↓ Newest first", "↓ 最新优先"], + ["↑ Oldest first", "↑ 最早优先"], + ["View details — ", "查看详情 — "], + ["Available ", "可用的 "], + [" models · click to use", " 模型 · 点击使用"], + [ + "Applies to bl video generate only (text/image-to-video). ", + "仅适用于 bl video generate(文生视频/图生视频)。", + ], + [ + "bl video ref (multi-image) and bl video edit keep their own ", + "bl video ref(多图)和 bl video edit 仍使用各自的", + ], + [ + "fixed models — pass --model to override those per run.", + "固定模型;可在每次运行时通过 --model 覆盖。", + ], + ['No skills match "', "没有匹配“"], + ['No MCP servers match "', "没有匹配“"], + ["Install to", "安装到"], + ["Transport / Source", "传输方式 / 来源"], + ["Target agent config", "目标 Agent 配置"], + ["Server name", "服务器名称"], + ["Quick launch", "快速启动"], + ["Config files", "配置文件"], + ["Environment check", "环境检查"], + ["Edit preset scenario", "编辑预设场景"], + ["Edit scenario", "编辑场景"], + ["Save & Activate", "保存并激活"], + ["Are you sure?", "确认执行此操作吗?"], + ["Save failed", "保存失败"], + ["Create failed", "创建失败"], + ["Install failed", "安装失败"], + ["Launch failed", "启动失败"], + ["Dispatch failed", "发送失败"], + ["Delete failed", "删除失败"], + ["Created", "已创建"], + ["Dispatched", "已发送"], + ["Saved", "已保存"], + ["OK", "确定"], + ["Configuration", "配置"], + ["Settings", "设置"], + ["Status", "状态"], + ["Open", "打开"], + ["Remove", "移除"], + ["Show", "显示"], + ["Hide", "隐藏"], + ["All", "全部"], + ["Images", "图片"], + ["Videos", "视频"], + ["Files", "文件"], + ["Image", "图片"], + ["Video", "视频"], + ["Audio", "音频"], + ["File", "文件"], + [" files", " 个文件"], + ["MCPs", "MCP 服务"], + ["Coding", "编程"], + ["Generated", "生成的"], + ["API key", "API 密钥"], + ["Console gateway", "控制台网关"], + ["Account", "账户"], + ["(empty)", "(空)"], + [" (editable)", "(可编辑)"], + [" (missing)", "(缺失)"], + ["default (top-level)", "default(顶层)"], + [ + "This source is read-only here (its config is TOML). Edit it directly in ", + "该来源在此处为只读(配置格式为 TOML)。请直接编辑:", + ], + [ + "No connected local coding agent detected (e.g. qwen-code). Open the Agents page to install and connect one.", + "未检测到已连接的本地编程 Agent(例如 qwen-code)。请打开 Agent 页面进行安装和连接。", + ], + ["Installed but not wired into bl: ", "已安装但尚未接入 bl:"], + [". Open the Agents page to finish connecting.", "。请打开 Agent 页面完成连接。"], + [ + "Finish the previous step first (connect a dispatchable agent), then come back to Playground to run your first scenario.", + "请先完成上一步(连接可接收任务的 Agent),再回到 Playground 运行第一个场景。", + ], + [ + "Go to Playground, pick a scenario and click Run it to complete your first dispatch.", + "前往 Playground,选择一个场景并点击“运行”,完成首次任务发送。", + ], + [ + "Sign in to the Bailian console to obtain credentials (opens a login page in your browser).", + "登录百炼控制台以获取凭证(将在浏览器中打开登录页面)。", + ], + ["You have dispatched at least one task.", "你已经成功发送过至少一个任务。"], + ["Node 18 or newer is recommended.", "建议使用 Node.js 18 或更高版本。"], + ["Save & Activate", "保存并激活"], + ["Save scenario", "保存场景"], + ["Custom scenario", "自定义场景"], + ["+ Custom scenario", "+ 自定义场景"], + ["New profile", "新建 Profile"], + ["+ New profile", "+ 新建 Profile"], + ["Profile name", "Profile 名称"], + ["Target agent", "目标 Agent"], + ["Task to dispatch", "要发送的任务"], + ["Prompt template", "提示词模板"], + ["One-line description (optional)", "一句话描述(可选)"], + ["Use {{key}} placeholders for inputs", "使用 {{key}} 作为输入占位符"], + ["e.g. Translate docs to English", "例如:将文档翻译成英文"], + ["e.g. Image / Custom", "例如:图像 / 自定义"], + ["e.g. work, intl, test", "例如:work、intl、test"], + ["Search scenarios…", "搜索场景…"], + ["Search skills…", "搜索 Skill…"], + ["Search MCP servers…", "搜索 MCP 服务器…"], + ["Search agents…", "搜索 Agent…"], + ["Search assets…", "搜索资产…"], + ["Installed Skills", "已安装的 Skill"], + ["Coding Agents", "编程 Agent"], + ["Generated Assets", "生成资产"], + ["Get Started", "开始使用"], + ["Quick Start", "快速开始"], + ["Playground", "Playground"], + ["Extensions", "扩展"], + ["Workspace", "工作区"], + ["Skills", "Skill"], + ["Agents", "Agent"], + ["Assets", "资产"], + ["Config", "配置"], + ["Log in", "登录"], + ["Log out", "退出登录"], + ["Copy this URL", "复制此链接"], + ["QR code for this session URL", "当前会话链接的二维码"], + ["Collapse sidebar", "收起侧边栏"], + ["Expand sidebar", "展开侧边栏"], + ["Model Studio CLI home", "百炼 CLI 首页"], + ["Loading…", "加载中…"], + ["Delete profile", "删除 Profile"], + ["Delete asset", "删除资产"], + ["Open failed", "打开失败"], + ["Open locally", "在本地打开"], + ["View details", "查看详情"], + ["Jump backward 5 pages", "向前跳转 5 页"], + ["Jump forward 5 pages", "向后跳转 5 页"], + ["Items per page", "每页数量"], + ["No generated assets yet.", "暂无生成资产。"], + ["No assets in this category.", "该分类下暂无资产。"], + ["No skills installed.", "尚未安装 Skill。"], + ["No local MCP servers found.", "未发现本地 MCP 服务器。"], + ["No coding agents found.", "未发现编程 Agent。"], + ["No scenarios found.", "未发现场景。"], + ["Failed to load: ", "加载失败:"], + ["Load failed: ", "加载失败:"], + ["Save failed: ", "保存失败:"], + ["Create failed: ", "创建失败:"], + ["Install failed: ", "安装失败:"], + ["Launch failed: ", "启动失败:"], + ["Saving…", "正在保存…"], + ["Saved and activated.", "已保存并激活。"], + ["Saved.", "已保存。"], + ["Profile created and saved.", "Profile 已创建并保存。"], + ["Opening browser…", "正在打开浏览器…"], + ["Waiting for login…", "等待登录…"], + ["Signed in…", "已登录…"], + ["Login timed out", "登录超时"], + ["Authenticated", "已认证"], + ["Connect a local agent", "连接本地 Agent"], + ["Run your first task", "运行第一个任务"], + ["Sign in to Bailian", "登录百炼"], + ["Check local environment", "检查本地环境"], + ["Go to Playground", "前往 Playground"], + ["Go to Agents", "前往 Agent"], + ["Run it", "运行"], + ["Done", "已完成"], + ["Connected", "已连接"], + ["Not installed", "未安装"], + ["Installed", "已安装"], + ["Launching…", "正在启动…"], + ["Launched → ", "已启动 → "], + ["Remote", "远程"], + ["Local", "本地"], + ["Add skill", "添加 Skill"], + ["+ Add skill", "+ 添加 Skill"], + ["Choose .zip file", "选择 .zip 文件"], + ["No file selected", "未选择文件"], + ["New MCP server", "新建 MCP 服务器"], + ["+ Add MCP", "+ 添加 MCP"], + ["Description", "描述"], + ["Installed in", "安装位置"], + ["Details", "详情"], + ["Type", "类型"], + ["Path", "路径"], + ["Scope", "范围"], + ["Title", "标题"], + ["Category", "分类"], + ["Inputs", "输入项"], + ["Add input", "添加输入"], + ["Create", "创建"], + ["Install", "安装"], + ["Delete", "删除"], + ["Cancel", "取消"], + ["Close", "关闭"], + ["Active", "已激活"], + ["Save", "保存"], + ["Copy", "复制"], + ["Copied", "已复制"], + ["show", "显示"], + ["hide", "隐藏"], + ["(unset)", "(未设置)"], + [" / page", " / 页"], +]; + +function serializeTranslations(): string { + return JSON.stringify( + [...ZH_CN_REPLACEMENTS].sort(([englishA], [englishB]) => englishB.length - englishA.length), + ).replaceAll("<", "\\u003c"); +} + +export function renderConfigUiShell(html: string, language: Language): string { + return html + .replace('', ``) + .replace("__BL_CONFIG_UI_LANGUAGE__", language) + .replace("__BL_CONFIG_UI_TRANSLATIONS__", serializeTranslations()); +} diff --git a/packages/commands/src/commands/config/ui.ts b/packages/commands/src/commands/config/ui.ts index cb1cc87..8e86d92 100644 --- a/packages/commands/src/commands/config/ui.ts +++ b/packages/commands/src/commands/config/ui.ts @@ -12,13 +12,15 @@ import { readConfigFile, writeConfigFile, deleteConfigProfile, + DEFAULT_LANGUAGE, REGIONS, type ConfigStore, type FlagsDef, + type Language, } from "bailian-cli-core"; import { emitResult, emitBare } from "bailian-cli-runtime"; import { listenLocalServer, openInBrowser, openPath } from "../shared/local-server.ts"; -import { PAGE_HTML } from "./ui-html.ts"; +import { renderConfigUiHtml } from "./ui-html.ts"; import { UI_VALID_KEYS, UI_ENUM_KEYS, @@ -40,7 +42,12 @@ import { installSkillZip, } from "./inventory.ts"; import { launchAgent, agentLaunchable, agentSupportsPrompt } from "./agent-launch.ts"; -import { SCENARIOS, getScenario, renderScenarioPrompt, type Scenario } from "./scenarios.ts"; +import { + getScenario, + localizeScenarios, + renderScenarioPrompt, + type Scenario, +} from "./scenarios.ts"; import { qrSvg } from "./qr.ts"; import { makeAuthUiBridge, type AuthUiBridge } from "../auth/console-ui.ts"; import { listAssets, resolveAssetPath, defaultOutputBase, contentType } from "./assets.ts"; @@ -78,6 +85,10 @@ function sendJson(res: http.ServerResponse, status: number, obj: unknown): void res.end(JSON.stringify(obj)); } +function configUiLanguage(configStore: ConfigStore): Language { + return configStore.read().language ?? DEFAULT_LANGUAGE; +} + function readBody(req: http.IncomingMessage): Promise { return new Promise((resolve, reject) => { let size = 0; @@ -165,6 +176,13 @@ export function createConfigUiServer( outputBase: string = defaultOutputBase(), authBridge?: AuthUiBridge, ): http.Server { + let activatedUiProfile: string | null = null; + const uiLanguage = (): Language => { + if (activatedUiProfile === null) return configUiLanguage(configStore); + const configName = activatedUiProfile === "default" ? undefined : activatedUiProfile; + return readConfigFile(configName).language ?? DEFAULT_LANGUAGE; + }; + return http.createServer(async (req, res) => { try { const host = (req.headers.host || "").split(":")[0]; @@ -195,7 +213,7 @@ export function createConfigUiServer( "img-src 'self' data: https://img.alicdn.com https://oss.aliyuncs.com; " + "media-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'", }); - res.end(PAGE_HTML); + res.end(renderConfigUiHtml(uiLanguage())); return; } @@ -380,7 +398,10 @@ export function createConfigUiServer( dispatchable: launchable[i] && agentSupportsPrompt(a.id), })) .filter((a) => a.dispatchable); - sendJson(res, 200, { scenarios: SCENARIOS, agents: targets }); + sendJson(res, 200, { + scenarios: localizeScenarios(uiLanguage()), + agents: targets, + }); return; } @@ -543,7 +564,10 @@ export function createConfigUiServer( inputs, }; } else { - scenario = typeof body.scenario === "string" ? getScenario(body.scenario) : undefined; + scenario = + typeof body.scenario === "string" + ? getScenario(body.scenario, uiLanguage()) + : undefined; } if (!scenario) { sendJson(res, 400, { error: "unknown scenario" }); @@ -588,7 +612,8 @@ export function createConfigUiServer( const body = parsed as { name?: unknown }; try { const activeProfile = await configStore.activate(body.name); - sendJson(res, 200, { activeProfile }); + activatedUiProfile = activeProfile; + sendJson(res, 200, { activeProfile, uiLanguage: uiLanguage() }); } catch (err) { sendJson(res, 400, { error: errMessage(err) }); } @@ -621,7 +646,7 @@ export function createConfigUiServer( const existing = readConfigFile(normalized) as Record; const saved = mergeUnmanagedProfileFields(existing, cleaned); await writeConfigFile(saved, normalized); - sendJson(res, 200, { saved }); + sendJson(res, 200, { saved, uiLanguage: uiLanguage() }); return; } diff --git a/packages/commands/tests/config-ui.test.ts b/packages/commands/tests/config-ui.test.ts index dbbabaa..0e48992 100644 --- a/packages/commands/tests/config-ui.test.ts +++ b/packages/commands/tests/config-ui.test.ts @@ -2,6 +2,7 @@ import http from "node:http"; import { mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { Script } from "node:vm"; import { expect, test } from "vite-plus/test"; import { activateConfigProfile, @@ -51,11 +52,11 @@ function httpJson( } /** 隔离临时配置目录 + 启动 UI server,跑完清理。 */ -async function withServer(fn: (port: number) => Promise): Promise { +async function withServer(fn: (port: number) => Promise, configName?: string): Promise { const saved = process.env.BAILIAN_CONFIG_DIR; const dir = mkdtempSync(join(tmpdir(), "bl-ui-")); process.env.BAILIAN_CONFIG_DIR = dir; - const server = createConfigUiServer(TOKEN, makeConfigStore()); + const server = createConfigUiServer(TOKEN, makeConfigStore(configName)); await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve())); const addr = server.address(); const port = addr && typeof addr === "object" ? addr.port : 0; @@ -69,6 +70,118 @@ async function withServer(fn: (port: number) => Promise): Promise { } } +test("GET / follows the launch Profile language", async () => { + await withServer(async (port) => { + await writeConfigFile({ language: "zh-CN" }); + + const chinese = await httpJson(port, "GET", `/?token=${TOKEN}`); + expect(chinese.status).toBe(200); + expect(chinese.text).toContain(''); + expect(chinese.text).toContain("快速开始"); + const inlineScript = chinese.text.match(/