From 1d43df8edef19f5ef39e04a822bf8283ae8c0ebf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=98=BF=E7=A6=8F?= Date: Sat, 15 Aug 2026 17:24:19 +0800 Subject: [PATCH] feat: add complete Chinese localization --- packages/desktop/src/main.ts | 66 ++++---- packages/desktop/src/modelInstall.test.ts | 14 +- packages/desktop/src/modelInstall.ts | 9 +- packages/desktop/src/onboarding.test.ts | 25 ++- packages/desktop/src/onboarding.ts | 8 +- packages/desktop/src/probes.test.ts | 27 ++- packages/desktop/src/probes.ts | 39 +++-- packages/desktop/src/voicePack.ts | 62 +++++-- packages/server/src/settings/registry.ts | 131 +++++++-------- packages/server/src/settings/store.ts | 11 +- packages/web/src/app.ts | 80 +++++---- packages/web/src/bubbles.ts | 2 +- packages/web/src/live2d/faceData.ts | 20 +-- packages/web/src/live2d/perfFlags.ts | 14 +- packages/web/src/ui/bootGate.ts | 41 +++-- packages/web/src/ui/cuteBubbleView.ts | 2 +- packages/web/src/ui/diaryBook.test.ts | 68 ++++++-- packages/web/src/ui/diaryBook.ts | 39 ++++- packages/web/src/ui/layout.ts | 140 +++++++++++----- packages/web/src/ui/mainMenu.ts | 41 +++-- packages/web/src/ui/modulesConfig.ts | 46 +++--- packages/web/src/ui/mood.ts | 30 ++-- packages/web/src/ui/packDrop.ts | 34 ++-- packages/web/src/ui/personaEditor.ts | 12 +- packages/web/src/ui/reconfigure.ts | 4 +- packages/web/src/ui/settingsPage.ts | 31 ++-- packages/web/src/ui/settingsView.ts | 6 +- packages/web/src/ui/setupCopy.test.ts | 8 +- packages/web/src/ui/setupCopy.ts | 92 ++++++++--- packages/web/src/ui/setupView.ts | 32 ++-- packages/web/src/ui/setupWizard.ts | 145 ++++++++++++---- packages/web/src/ui/time.test.ts | 8 +- packages/web/src/ui/time.ts | 6 +- packages/web/src/ui/toolLabels.test.ts | 10 +- packages/web/src/ui/toolLabels.ts | 51 +++--- packages/web/src/ui/workbench.test.ts | 46 +++++- packages/web/src/ui/workbench.ts | 191 +++++++++++++++------- 37 files changed, 1038 insertions(+), 553 deletions(-) diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index 1c88aa8..caab2d4 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -478,8 +478,8 @@ ipcMain.handle('luna:probe-weather', async (_event, raw: ProviderFields) => ); ipcMain.handle('luna:onboarding-submit', async (_event, raw: OnboardingFields): Promise => { - if (!paths) return { ok: false, error: 'Not ready — try again in a moment.' }; - if (onboardingInFlight) return { ok: false, error: 'Setup already in progress…' }; + if (!paths) return { ok: false, error: '还没准备好——请稍等片刻。' }; + if (onboardingInFlight) return { ok: false, error: '配置已经在进行中…' }; onboardingInFlight = true; try { const baseUrl = asStr(raw?.baseUrl); @@ -497,7 +497,7 @@ ipcMain.handle('luna:onboarding-submit', async (_event, raw: OnboardingFields): // Apply the keys live: re-spawn the sidecar against the new env (it may never have started). supervisor?.restart(sidecarEnv(paths, parseEnvFile(merged))); const up = await waitForPort(SERVER_PORT); - if (!up) return { ok: false, error: 'Saved, but the server did not start. Check the logs.' }; + if (!up) return { ok: false, error: '已经保存,但服务没有启动。请检查日志。' }; // Swap the setup window for the real app window (createWindow reads the resolved petMode). const fresh = createWindow('app'); for (const w of BrowserWindow.getAllWindows()) if (w !== fresh) w.close(); @@ -537,7 +537,7 @@ ipcMain.on('luna:open-setup', () => { // if the renderer is a white screen. Standard roles keep copy/paste/devtools working. function installAppMenu(): void { const setupItem = { - label: 'Setup Wizard… / 重新配置', + label: '配置向导… / 重新配置', accelerator: 'CmdOrCtrl+,', click: (): void => openSetupWindow(), }; @@ -547,7 +547,7 @@ function installAppMenu(): void { { role: 'editMenu' }, { role: 'viewMenu' }, { role: 'windowMenu' }, - { label: 'Setup', submenu: [setupItem] }, + { label: '设置', submenu: [setupItem] }, ]; Menu.setApplicationMenu(Menu.buildFromTemplate(template)); } @@ -557,15 +557,15 @@ function installAppMenu(): void { // are present (a bad key is never persisted, v0.28.0 rule), ONE luna.env merge + ONE sidecar // restart at the end. Values ride this one direction; the verdict never echoes them. ipcMain.handle('luna:wizard-submit', async (_event, raw: unknown): Promise => { - if (!paths) return { ok: false, error: 'Not ready — try again in a moment.' }; - if (onboardingInFlight) return { ok: false, error: 'Setup already in progress…' }; + if (!paths) return { ok: false, error: '还没准备好——请稍等片刻。' }; + if (onboardingInFlight) return { ok: false, error: '配置已经在进行中…' }; onboardingInFlight = true; try { const fields = filterWizardFields(raw); const baseUrl = fields['ANTHROPIC_BASE_URL']; const apiKey = fields['ANTHROPIC_API_KEY']; if (baseUrl !== undefined || apiKey !== undefined) { - if (!baseUrl || !apiKey) return { ok: false, error: 'Enter a base URL and an API key.' }; + if (!baseUrl || !apiKey) return { ok: false, error: '请填写接口地址和 API 密钥。' }; const verdict = await probeConnection(baseUrl, apiKey, fields['LUNA_MODEL'] ?? ''); if (!verdict.ok) return verdict; } @@ -577,7 +577,7 @@ ipcMain.handle('luna:wizard-submit', async (_event, raw: unknown): Promise => { const picked = dialog.showOpenDialogSync({ - title: 'Choose a Live2D model folder', + title: '选择 Live2D 模型文件夹', properties: ['openDirectory'], }); const src = picked?.[0]; - if (!src) return { ok: false, error: 'cancelled' }; + if (!src) return { ok: false, error: '已取消' }; return installModelAndReload(src); }); @@ -625,7 +625,7 @@ ipcMain.handle('luna:choose-model', async (): Promise<{ ok: boolean; modelUrl?: // (webUtils.getPathForFile) and only the path string crosses IPC. ipcMain.handle('luna:install-model-path', async (_event, raw: unknown) => { const src = asStr(raw); - if (!src) return { ok: false, error: 'No folder received.' }; + if (!src) return { ok: false, error: '所选文件夹不存在。' }; return installModelAndReload(src); }); @@ -634,7 +634,7 @@ ipcMain.handle('luna:install-model-path', async (_event, raw: unknown) => { ipcMain.handle('luna:scan-voice-pack', async (_event, raw: unknown) => { const root = asStr(raw); if (!root || !existsSync(root) || !statSync(root).isDirectory()) - return { ok: false, error: 'That is not a folder.' }; + return { ok: false, error: '这不是一个文件夹。' }; const scan = scanVoicePack(root); const valid = validateVoicePack(scan); if (!valid.ok) return valid; @@ -653,11 +653,11 @@ ipcMain.handle('luna:scan-voice-pack', async (_event, raw: unknown) => { ipcMain.handle('luna:choose-tts-runtime', async () => { const picked = dialog.showOpenDialogSync({ - title: 'Choose your GPT-SoVITS folder', + title: '选择 GPT-SoVITS 文件夹', properties: ['openDirectory'], }); const dir = picked?.[0]; - if (!dir) return { ok: false, error: 'cancelled' }; + if (!dir) return { ok: false, error: '已取消' }; const check = validateRuntimeDir(dir); if (!check.ok) return check; return { ok: true, dir, venv: !!check.venvPython }; @@ -665,7 +665,7 @@ ipcMain.handle('luna:choose-tts-runtime', async () => { type VoiceInstallRaw = Record; ipcMain.handle('luna:install-voice-pack', async (_event, raw: VoiceInstallRaw) => { - if (!paths) return { ok: false, error: 'Not ready — try again in a moment.' }; + if (!paths) return { ok: false, error: '还没准备好——请稍等片刻。' }; const root = asStr(raw?.['root']); const picks = { gptCkpt: asStr(raw?.['gptCkpt']), @@ -676,7 +676,7 @@ ipcMain.handle('luna:install-voice-pack', async (_event, raw: VoiceInstallRaw) = // The picks must come from the scanned pack — a stray absolute path can't smuggle files in. const inRoot = (p: string): boolean => p === '' || p.startsWith(root.endsWith(sep) ? root : root + sep); if (!root || !inRoot(picks.gptCkpt) || !inRoot(picks.sovitsPth) || !inRoot(picks.referenceWav)) - return { ok: false, error: 'Picked files must come from the dropped folder — re-scan it.' }; + return { ok: false, error: '所选文件必须来自刚才拖入的文件夹——请重新扫描。' }; const installed = installVoicePack(root, picks, { ttsDir: join(paths.userData, 'tts'), envFile: paths.envFile, @@ -774,16 +774,16 @@ function firstFileByExt(dir: string, ext: string): string | undefined { // discipline as the wizard finish, minus its restart-and-swap. A .bak of the previous file lands // first, so a bad save is one copy away from undone. ipcMain.handle('luna:save-config', (_e, raw: unknown) => { - if (!paths) return { ok: false, error: 'Not ready — try again in a moment.' }; + if (!paths) return { ok: false, error: '还没准备好——请稍等片刻。' }; const fields = filterWizardFields(raw); - if (Object.keys(fields).length === 0) return { ok: false, error: 'Nothing to save.' }; + if (Object.keys(fields).length === 0) return { ok: false, error: '没有需要保存的内容。' }; try { const current = readFileSync(paths.envFile, 'utf8'); writeFileSync(`${paths.envFile}.bak`, current); writeFileSync(paths.envFile, mergeEnvFile(current, fields)); return { ok: true }; } catch (err) { - return { ok: false, error: err instanceof Error ? err.message : 'write failed' }; + return { ok: false, error: err instanceof Error ? err.message : '写入失败。' }; } }); @@ -806,13 +806,13 @@ ipcMain.handle('luna:provision-status', () => { return { ...provisionStatus, inFlight: provisionInFlight }; }); ipcMain.handle('luna:provision-start', () => { - if (!paths) return { ok: false, error: 'Not ready — try again in a moment.' }; + if (!paths) return { ok: false, error: '还没准备好——请稍等片刻。' }; const p = paths; const env = freshUserEnv(p); // v0.37.9: ON by default. A one-click install that first needs you to hand-edit a config file is // not one-click — it is the terminal step this initiative exists to delete, wearing a disguise. // LUNA_TTS_PROVISION=0 is the opt-OUT. - if (env['LUNA_TTS_PROVISION'] === '0') return { ok: false, error: 'One-click deploy is disabled (LUNA_TTS_PROVISION=0).' }; + if (env['LUNA_TTS_PROVISION'] === '0') return { ok: false, error: '一键部署已关闭(LUNA_TTS_PROVISION=0)。' }; if (provisionInFlight) return { ok: true }; provisionInFlight = true; const ttsDir = join(p.userData, 'tts'); @@ -887,7 +887,7 @@ async function smokeProbe(win: BrowserWindow): Promise { // actually rendered inside the packaged shell (the desktop-specific wiring a page probe misses). document.querySelector('.settings-panel')?.classList.add('on'); const petInput = [...document.querySelectorAll('.settings-panel label')] - .find((l) => l.textContent.includes('Desktop pet'))?.querySelector('input'); + .find((l) => l.textContent.includes('Desktop pet') || l.textContent.includes('桌面宠物'))?.querySelector('input'); // v0.44.2: the data surface, THROUGH the same-origin forward — proves serve.ts → sidecar. const dataStatus = await fetch('/api/data/diaries').then((r) => r.status).catch(() => 0); return JSON.stringify({ @@ -1052,9 +1052,9 @@ void app.whenReady().then(async () => { if (!SMOKE) dialog.showMessageBoxSync({ type: 'error', - message: 'Luna is already running', - detail: `The local web host port (${DESKTOP_WEB_PORT}) is in use — another Luna window likely has it. Close it and try again.`, - buttons: ['Close'], + message: 'Luna 已经在运行', + detail: `本地网页服务端口(${DESKTOP_WEB_PORT})已被占用——可能已经打开了另一个 Luna 窗口。请关闭它后重试。`, + buttons: ['关闭'], }); app.quit(); }, @@ -1156,9 +1156,9 @@ void app.whenReady().then(async () => { if (!up) { const choice = dialog.showMessageBoxSync({ type: 'warning', - message: 'Luna\'s dev stack did not start', - detail: `No response on 127.0.0.1:${SERVER_PORT}. Check that bun + the repo are present (or set LUNA_BUN_PATH in ${p.envFile}).`, - buttons: ['Open Setup', 'Close'], + message: 'Luna 开发服务没有启动', + detail: `127.0.0.1:${SERVER_PORT} 没有响应。请确认 Bun 和项目目录存在(或在 ${p.envFile} 中设置 LUNA_BUN_PATH)。`, + buttons: ['打开设置', '关闭'], defaultId: 0, }); if (choice === 0) { @@ -1180,9 +1180,9 @@ void app.whenReady().then(async () => { // to the wizard right here instead of pointing at a file path. const choice = dialog.showMessageBoxSync({ type: 'warning', - message: 'Luna\'s server did not start', - detail: `No response on 127.0.0.1:${SERVER_PORT}. Check ${p.envFile} and the logs — or re-run the setup wizard to fix the configuration.`, - buttons: ['Open Setup', 'Close'], + message: 'Luna 服务没有启动', + detail: `127.0.0.1:${SERVER_PORT} 没有响应。请检查 ${p.envFile} 和日志,或重新运行配置向导修复配置。`, + buttons: ['打开设置', '关闭'], defaultId: 0, }); if (choice === 0) { diff --git a/packages/desktop/src/modelInstall.test.ts b/packages/desktop/src/modelInstall.test.ts index f2ddc9e..e2ccc6e 100644 --- a/packages/desktop/src/modelInstall.test.ts +++ b/packages/desktop/src/modelInstall.test.ts @@ -1,5 +1,13 @@ import { afterEach, describe, expect, test } from 'bun:test'; -import { mkdtempSync, mkdirSync, readdirSync, rmSync, writeFileSync, existsSync, readFileSync } from 'node:fs'; +import { + mkdtempSync, + mkdirSync, + readdirSync, + rmSync, + writeFileSync, + existsSync, + readFileSync, +} from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { installModelFolder, resolveModelDir } from './modelInstall'; @@ -65,7 +73,7 @@ describe('installModelFolder (v0.35.2)', () => { writeFileSync(join(src, 'readme.txt'), 'hi'); const opts = setup(); const r = installModelFolder(src, opts); - expect(r).toEqual({ ok: false, error: 'No .model3.json found in that folder.' }); + expect(r).toEqual({ ok: false, error: '这个文件夹里没有找到 .model3.json。' }); expect(readdirSync(opts.modelsDir)).toEqual([]); expect(parseEnvFile(readFileSync(opts.envFile, 'utf8'))['LUNA_MODEL_URL']).toBe(''); }); @@ -75,7 +83,7 @@ describe('installModelFolder (v0.35.2)', () => { const file = join(base, 'model.zip'); writeFileSync(file, 'zip'); const r = installModelFolder(file, setup()); - expect(r).toEqual({ ok: false, error: 'That is not a folder.' }); + expect(r).toEqual({ ok: false, error: '这不是一个文件夹。' }); }); test('copy-only contract: the source folder is untouched after install', () => { diff --git a/packages/desktop/src/modelInstall.ts b/packages/desktop/src/modelInstall.ts index ce26cff..4d0d63e 100644 --- a/packages/desktop/src/modelInstall.ts +++ b/packages/desktop/src/modelInstall.ts @@ -29,13 +29,16 @@ export function installModelFolder( opts: { modelsDir: string; envFile: string }, ): ModelInstallResult { if (!existsSync(src) || !statSync(src).isDirectory()) { - return { ok: false, error: 'That is not a folder.' }; + return { ok: false, error: '这不是一个文件夹。' }; } const resolved = resolveModelDir(src); - if (!resolved) return { ok: false, error: 'No .model3.json found in that folder.' }; + if (!resolved) return { ok: false, error: '这个文件夹里没有找到 .model3.json。' }; const name = basename(resolved.dir); cpSync(resolved.dir, join(opts.modelsDir, name), { recursive: true }); const modelUrl = `/models/${name}/${resolved.manifest}`; - writeFileSync(opts.envFile, mergeEnvFile(readFileSync(opts.envFile, 'utf8'), { LUNA_MODEL_URL: modelUrl })); + writeFileSync( + opts.envFile, + mergeEnvFile(readFileSync(opts.envFile, 'utf8'), { LUNA_MODEL_URL: modelUrl }), + ); return { ok: true, modelUrl }; } diff --git a/packages/desktop/src/onboarding.test.ts b/packages/desktop/src/onboarding.test.ts index c841f71..a6d3a81 100644 --- a/packages/desktop/src/onboarding.test.ts +++ b/packages/desktop/src/onboarding.test.ts @@ -1,5 +1,13 @@ import { describe, expect, test } from 'bun:test'; -import { WIZARD_KEYS, classifyProbe, filterWizardFields, mergeEnvFile, needsOnboarding, wizardFlagEnabled, wizardPrefill } from './onboarding'; +import { + WIZARD_KEYS, + classifyProbe, + filterWizardFields, + mergeEnvFile, + needsOnboarding, + wizardFlagEnabled, + wizardPrefill, +} from './onboarding'; import { parseEnvFile } from './envfile'; describe('wizardFlagEnabled (v0.35.4 default flip)', () => { @@ -131,15 +139,15 @@ describe('classifyProbe', () => { test('401/403 → key rejected', () => { expect(classifyProbe(401)).toMatchObject({ ok: false }); expect(classifyProbe(403).ok).toBe(false); - expect(classifyProbe(401).error).toContain('key'); + expect(classifyProbe(401).error).toContain('密钥'); }); test('404 → base URL / endpoint', () => { expect(classifyProbe(404).ok).toBe(false); - expect(classifyProbe(404).error).toContain('base URL'); + expect(classifyProbe(404).error).toContain('接口地址'); }); test('null (fetch threw) → unreachable URL', () => { expect(classifyProbe(null).ok).toBe(false); - expect(classifyProbe(null).error).toContain('URL'); + expect(classifyProbe(null).error).toContain('地址'); }); test('other non-2xx (5xx/429) → surfaced, not ok', () => { expect(classifyProbe(500).ok).toBe(false); @@ -147,14 +155,13 @@ describe('classifyProbe', () => { }); }); - describe('bug scenario: baseUrl with = characters', () => { test('complete end-to-end: merge, write, read back URL with query params', () => { // Simulate the exact scenario from the bug claim const baseUrl = 'https://x.com?a=1&b=2'; const apiKey = 'sk-valid'; const model = 'claude-opus-4-8'; - + // Step 1: probeConnection succeeds (mocked as passed above) // Step 2: mergeEnvFile is called with unescaped URL const template = `# Luna desktop configuration @@ -162,17 +169,17 @@ ANTHROPIC_API_KEY= ANTHROPIC_BASE_URL= LUNA_MODEL=claude-sonnet-4-6 `; - + const merged = mergeEnvFile(template, { ANTHROPIC_BASE_URL: baseUrl, ANTHROPIC_API_KEY: apiKey, LUNA_MODEL: model, }); - + // Step 3: file is written (simulated by the merged string) // Step 4: sidecarEnv calls parseEnvFile to read the merged content const parsed = parseEnvFile(merged); - + // Verify the file is NOT corrupted — values must parse exactly expect(parsed['ANTHROPIC_BASE_URL']).toBe(baseUrl); expect(parsed['ANTHROPIC_API_KEY']).toBe(apiKey); diff --git a/packages/desktop/src/onboarding.ts b/packages/desktop/src/onboarding.ts index 1416b38..c77b91c 100644 --- a/packages/desktop/src/onboarding.ts +++ b/packages/desktop/src/onboarding.ts @@ -129,10 +129,10 @@ export type ProbeVerdict = { ok: boolean; error?: string }; // OR a 400 both mean the request was authenticated and reached the model (a 400 is a request-shape // detail like max_tokens), so the key + URL are good. 401/403 = key rejected; 404 = wrong endpoint. export function classifyProbe(status: number | null): ProbeVerdict { - if (status === null) return { ok: false, error: "Couldn't reach that URL — check the base URL." }; - if (status === 401 || status === 403) return { ok: false, error: 'The API key was rejected.' }; + if (status === null) return { ok: false, error: '无法访问这个地址——请检查接口地址。' }; + if (status === 401 || status === 403) return { ok: false, error: 'API 密钥被拒绝了。' }; if (status === 404) - return { ok: false, error: 'Endpoint not found — check the base URL (e.g. https://api.anthropic.com).' }; + return { ok: false, error: '找不到接口——请检查接口地址(例如 https://api.anthropic.com)。' }; if (status < 400 || status === 400) return { ok: true }; - return { ok: false, error: `The server returned ${status}. Check your settings and try again.` }; + return { ok: false, error: `服务器返回了 ${status}。请检查设置后重试。` }; } diff --git a/packages/desktop/src/probes.test.ts b/packages/desktop/src/probes.test.ts index 82addf6..bbf8ef3 100644 --- a/packages/desktop/src/probes.test.ts +++ b/packages/desktop/src/probes.test.ts @@ -3,7 +3,10 @@ import { probeEmbedding, probeSearch, probeWeather, type ProbeFetch } from './pr const KEY = 'sk-secret-do-not-echo'; -function fetcher(status: number, body = '{}'): { fn: ProbeFetch; calls: Array<{ url: string; init?: RequestInit }> } { +function fetcher( + status: number, + body = '{}', +): { fn: ProbeFetch; calls: Array<{ url: string; init?: RequestInit }> } { const calls: Array<{ url: string; init?: RequestInit }> = []; const fn: ProbeFetch = (url, init) => { calls.push({ url, init }); @@ -15,7 +18,11 @@ function fetcher(status: number, body = '{}'): { fn: ProbeFetch; calls: Array<{ const throwing: ProbeFetch = () => Promise.reject(new Error('ENOTFOUND')); describe('probeEmbedding (v0.35.1)', () => { - const fields = { baseUrl: 'https://api.openai.com/', apiKey: KEY, model: 'text-embedding-3-large' }; + const fields = { + baseUrl: 'https://api.openai.com/', + apiKey: KEY, + model: 'text-embedding-3-large', + }; test('2xx → ok, and the request hits {base}/v1/embeddings with the model', async () => { const { fn, calls } = fetcher(200); @@ -26,14 +33,14 @@ describe('probeEmbedding (v0.35.1)', () => { test('401 → key-rejected message naming the provider console', async () => { const v = await probeEmbedding(fields, fetcher(401).fn); expect(v.ok).toBe(false); - expect(v.error).toContain('key'); + expect(v.error).toContain('密钥'); }); test('404 → base URL / model hint', async () => { const v = await probeEmbedding(fields, fetcher(404).fn); - expect(v.error).toContain('base URL'); + expect(v.error).toContain('接口地址'); }); test('thrown fetch → unreachable hint; empty fields → prompt without fetching', async () => { - expect((await probeEmbedding(fields, throwing)).error).toContain('reach'); + expect((await probeEmbedding(fields, throwing)).error).toContain('访问'); const { fn, calls } = fetcher(200); await probeEmbedding({ baseUrl: '', apiKey: '', model: '' }, fn); expect(calls.length).toBe(0); @@ -80,13 +87,13 @@ describe('probeWeather', () => { test('body code 401 → key hint even under HTTP 200', async () => { const v = await probeWeather(fields, fetcher(200, '{"code":"401"}').fn); expect(v.ok).toBe(false); - expect(v.error).toContain('key'); + expect(v.error).toContain('密钥'); }); test('Invalid Host body / 404 → the per-account API-host hint', async () => { const v404 = await probeWeather(fields, fetcher(404, 'nope').fn); - expect(v404.error).toContain('host'); + expect(v404.error).toContain('主机'); const vBody = await probeWeather(fields, fetcher(200, 'Invalid Host').fn); - expect(vBody.error).toContain('host'); + expect(vBody.error).toContain('主机'); }); test('host guard: a non-QWeather host is rejected BEFORE any fetch', async () => { const { fn, calls } = fetcher(200, '{"code":"200"}'); @@ -96,7 +103,9 @@ describe('probeWeather', () => { }); test('protocol prefix + trailing slash are normalized, legacy qweather.com allowed', async () => { const { fn, calls } = fetcher(200, '{"code":"200"}'); - expect((await probeWeather({ apiKey: KEY, apiHost: 'https://devapi.qweather.com/' }, fn)).ok).toBe(true); + expect( + (await probeWeather({ apiKey: KEY, apiHost: 'https://devapi.qweather.com/' }, fn)).ok, + ).toBe(true); expect(calls[0]?.url.startsWith('https://devapi.qweather.com/v7/')).toBe(true); }); test('custody: the key never appears in a verdict', async () => { diff --git a/packages/desktop/src/probes.ts b/packages/desktop/src/probes.ts index 9f645df..6cf87a5 100644 --- a/packages/desktop/src/probes.ts +++ b/packages/desktop/src/probes.ts @@ -17,7 +17,7 @@ export async function probeEmbedding( doFetch: ProbeFetch = realFetch, ): Promise { const base = fields.baseUrl.trim().replace(/\/+$/, ''); - if (!base || !fields.apiKey) return { ok: false, error: 'Enter an embedding base URL and API key.' }; + if (!base || !fields.apiKey) return { ok: false, error: '请填写记忆向量接口地址和 API 密钥。' }; try { const res = await doFetch(`${base}/v1/embeddings`, { method: 'POST', @@ -26,12 +26,12 @@ export async function probeEmbedding( }); if (res.status < 300) return { ok: true }; if (res.status === 401 || res.status === 403) - return { ok: false, error: 'The embedding API key was rejected — check it on your provider console.' }; + return { ok: false, error: '记忆向量 API 密钥被拒绝了——请到服务商控制台检查。' }; if (res.status === 404) - return { ok: false, error: 'Embeddings endpoint or model not found — check the base URL and model name.' }; - return { ok: false, error: `The embedding server returned ${res.status}.` }; + return { ok: false, error: '找不到记忆向量接口或模型——请检查接口地址和模型名称。' }; + return { ok: false, error: `记忆向量服务返回了 ${res.status}。` }; } catch { - return { ok: false, error: "Couldn't reach the embedding base URL — check it." }; + return { ok: false, error: '无法访问记忆向量接口地址——请检查地址。' }; } } @@ -41,7 +41,7 @@ export async function probeSearch( fields: { apiKey: string }, doFetch: ProbeFetch = realFetch, ): Promise { - if (!fields.apiKey) return { ok: false, error: 'Enter a Tavily API key.' }; + if (!fields.apiKey) return { ok: false, error: '请填写 Tavily API 密钥。' }; try { const res = await doFetch('https://api.tavily.com/search', { method: 'POST', @@ -50,10 +50,13 @@ export async function probeSearch( }); if (res.status < 300) return { ok: true }; if (res.status === 401 || res.status === 403 || res.status === 432) - return { ok: false, error: 'Tavily rejected this key — check it at app.tavily.com.' }; - return { ok: false, error: `Tavily returned ${res.status} — check your key/plan at app.tavily.com.` }; + return { ok: false, error: 'Tavily 拒绝了这个密钥——请到 app.tavily.com 检查。' }; + return { + ok: false, + error: `Tavily 返回了 ${res.status}——请到 app.tavily.com 检查密钥和套餐。`, + }; } catch { - return { ok: false, error: "Couldn't reach api.tavily.com — check your network." }; + return { ok: false, error: '无法访问 api.tavily.com——请检查网络。' }; } } @@ -66,12 +69,16 @@ export async function probeWeather( fields: { apiKey: string; apiHost: string }, doFetch: ProbeFetch = realFetch, ): Promise { - const host = fields.apiHost.trim().replace(/^https?:\/\//, '').replace(/\/+$/, ''); - if (!fields.apiKey || !host) return { ok: false, error: 'Enter a QWeather key and your account API host.' }; + const host = fields.apiHost + .trim() + .replace(/^https?:\/\//, '') + .replace(/\/+$/, ''); + if (!fields.apiKey || !host) return { ok: false, error: '请填写和风天气密钥和账户 API 主机。' }; if (!/^[a-z0-9-]+(\.[a-z0-9-]+)*\.(qweatherapi\.com|qweather\.com)$/i.test(host)) { return { ok: false, - error: 'That does not look like a QWeather API host (expected xxxx.qweatherapi.com — see console.qweather.com → Settings).', + error: + '这不像是和风天气 API 主机(应为 xxxx.qweatherapi.com,请到 console.qweather.com → 设置查看)。', }; } try { @@ -88,14 +95,14 @@ export async function probeWeather( } if (res.status < 300 && code === '200') return { ok: true }; if (res.status === 401 || res.status === 403 || code === '401' || code === '403') - return { ok: false, error: 'QWeather rejected this key — check it in the console (dev.qweather.com).' }; + return { ok: false, error: '和风天气拒绝了这个密钥——请到控制台(dev.qweather.com)检查。' }; if (res.status === 404 || body.includes('Invalid Host')) return { ok: false, - error: 'Wrong API host — use your account\'s dedicated host (xxxx.qweatherapi.com), not the legacy devapi.', + error: 'API 主机不正确——请使用账户专属主机(xxxx.qweatherapi.com),不要使用旧版 devapi。', }; - return { ok: false, error: `QWeather returned ${code || res.status}.` }; + return { ok: false, error: `和风天气返回了 ${code || res.status}。` }; } catch { - return { ok: false, error: "Couldn't reach that API host — check it for typos." }; + return { ok: false, error: '无法访问这个 API 主机——请检查是否有拼写错误。' }; } } diff --git a/packages/desktop/src/voicePack.ts b/packages/desktop/src/voicePack.ts index 80c2535..4955c42 100644 --- a/packages/desktop/src/voicePack.ts +++ b/packages/desktop/src/voicePack.ts @@ -10,7 +10,12 @@ import { basename, join } from 'node:path'; import { mergeEnvFile } from './onboarding'; import { parseEnvFile } from './envfile'; -export type VoiceScan = { gpt: string[]; sovits: string[]; refWavs: string[]; transcripts: string[] }; +export type VoiceScan = { + gpt: string[]; + sovits: string[]; + refWavs: string[]; + transcripts: string[]; +}; // Runtime-bundle directories weights never live in. GPT_SoVITS holds the PRETRAINED s1/s2 models — // skipping it is what keeps a 整合包 scan from offering the base models as "your voice". @@ -34,7 +39,8 @@ export function scanVoicePack(root: string, maxDepth = 6): VoiceScan { for (const e of readdirSync(dir, { withFileTypes: true })) { const p = join(dir, e.name); if (e.isDirectory()) { - if (SKIP_DIRS.has(e.name) || e.name.startsWith('python') || e.name.startsWith('.')) continue; + if (SKIP_DIRS.has(e.name) || e.name.startsWith('python') || e.name.startsWith('.')) + continue; walk(p, depth + 1); } else if (e.isFile()) { const lower = e.name.toLowerCase(); @@ -50,9 +56,12 @@ export function scanVoicePack(root: string, maxDepth = 6): VoiceScan { } export function validateVoicePack(scan: VoiceScan): { ok: boolean; error?: string } { - if (scan.gpt.length === 0) return { ok: false, error: 'No GPT weight (.ckpt) found in the folder.' }; - if (scan.sovits.length === 0) return { ok: false, error: 'No SoVITS weight (.pth) found in the folder.' }; - if (scan.refWavs.length === 0) return { ok: false, error: 'No reference audio (.wav) found in the folder.' }; + if (scan.gpt.length === 0) + return { ok: false, error: '这个文件夹里没有找到 GPT 权重(.ckpt)。' }; + if (scan.sovits.length === 0) + return { ok: false, error: '这个文件夹里没有找到 SoVITS 权重(.pth)。' }; + if (scan.refWavs.length === 0) + return { ok: false, error: '这个文件夹里没有找到参考音频(.wav)。' }; return { ok: true }; } @@ -78,10 +87,16 @@ export type VoiceInstall = { export function installVoicePack( root: string, picks: VoicePicks, - opts: { ttsDir: string; envFile: string; promptText?: string; promptLang?: string; textLang?: string }, + opts: { + ttsDir: string; + envFile: string; + promptText?: string; + promptLang?: string; + textLang?: string; + }, ): VoiceInstall { for (const p of [picks.gptCkpt, picks.sovitsPth, picks.referenceWav]) { - if (!p || !existsSync(p)) return { ok: false, error: 'A picked file no longer exists — re-scan the folder.' }; + if (!p || !existsSync(p)) return { ok: false, error: '所选文件已经不存在——请重新扫描文件夹。' }; } const packDir = join(opts.ttsDir, basename(root)); const gptDir = join(packDir, 'GPT'); @@ -107,7 +122,8 @@ export function installVoicePack( LUNA_TTS_PROMPT_LANG: opts.promptLang?.trim() || 'en', LUNA_TTS_TEXT_LANG: opts.textLang?.trim() || 'auto', }; - if ((parseEnvFile(existing)['LUNA_TTS_URL'] ?? '') === '') fields['LUNA_TTS_URL'] = 'http://127.0.0.1:9880'; + if ((parseEnvFile(existing)['LUNA_TTS_URL'] ?? '') === '') + fields['LUNA_TTS_URL'] = 'http://127.0.0.1:9880'; if (promptText !== '') fields['LUNA_TTS_PROMPT_TEXT'] = promptText; writeFileSync(opts.envFile, mergeEnvFile(existing, fields)); return { ok: true, packDir, gptCkpt, sovitsPth, refAudio, promptText }; @@ -118,19 +134,37 @@ export type RuntimeCheck = { ok: boolean; venvPython?: string; error?: string }; // A GPT-SoVITS checkout per the reference instance: api_v2.py at the root, the two pretrained // model dirs under GPT_SoVITS/pretrained_models, optionally a .venv. v0.38.0: the venv layout is // `.venv\Scripts\python.exe` on win32, `.venv/bin/python` elsewhere. -export function validateRuntimeDir(dir: string, platform: NodeJS.Platform = process.platform): RuntimeCheck { +export function validateRuntimeDir( + dir: string, + platform: NodeJS.Platform = process.platform, +): RuntimeCheck { if (!existsSync(join(dir, 'api_v2.py'))) - return { ok: false, error: 'api_v2.py not found — point at a GPT-SoVITS checkout (github.com/RVC-Boss/GPT-SoVITS).' }; + return { + ok: false, + error: '没有找到 api_v2.py——请选择 GPT-SoVITS 项目目录(github.com/RVC-Boss/GPT-SoVITS)。', + }; const pre = join(dir, 'GPT_SoVITS', 'pretrained_models'); - if (!existsSync(join(pre, 'chinese-roberta-wwm-ext-large')) || !existsSync(join(pre, 'chinese-hubert-base'))) - return { ok: false, error: 'Pretrained models missing under GPT_SoVITS/pretrained_models — finish the GPT-SoVITS setup first.' }; + if ( + !existsSync(join(pre, 'chinese-roberta-wwm-ext-large')) || + !existsSync(join(pre, 'chinese-hubert-base')) + ) + return { + ok: false, + error: 'GPT_SoVITS/pretrained_models 下缺少预训练模型——请先完成 GPT-SoVITS 配置。', + }; const venv = - platform === 'win32' ? join(dir, '.venv', 'Scripts', 'python.exe') : join(dir, '.venv', 'bin', 'python'); + platform === 'win32' + ? join(dir, '.venv', 'Scripts', 'python.exe') + : join(dir, '.venv', 'bin', 'python'); return existsSync(venv) ? { ok: true, venvPython: venv } : { ok: true }; } // The reference instance's tts_infer.runtime.yaml `custom:` section, field for field. -export function generateTtsYaml(o: { checkout: string; gptCkpt: string; sovitsPth: string }): string { +export function generateTtsYaml(o: { + checkout: string; + gptCkpt: string; + sovitsPth: string; +}): string { return [ 'custom:', ` bert_base_path: ${join(o.checkout, 'GPT_SoVITS', 'pretrained_models', 'chinese-roberta-wwm-ext-large')}`, diff --git a/packages/server/src/settings/registry.ts b/packages/server/src/settings/registry.ts index 032fff2..b888e5e 100644 --- a/packages/server/src/settings/registry.ts +++ b/packages/server/src/settings/registry.ts @@ -30,7 +30,7 @@ function validQuietHours(value: string): string | null { const parts = value.split(',').map((s) => s.trim()); for (const p of parts) { if (!/^\d{1,2}$/.test(p) || Number(p) > 23) { - return 'quiet hours must be comma-separated hours 0-23 (e.g. "0,1,2,3,4,5")'; + return '安静时段必须是用逗号分隔的 0–23 点(例如“0,1,2,3,4,5”)'; } } return null; @@ -40,17 +40,18 @@ function validActiveness(value: string): string | null { if (value.trim() === '') return null; return ['aloof', 'balanced', 'clingy'].includes(value.trim()) ? null - : 'activeness must be one of: aloof, balanced, clingy'; + : '主动程度只能是 aloof、balanced 或 clingy'; } function validLatLon(value: string): string | null { if (value.trim() === '') return null; const m = value.split(',').map((s) => Number(s.trim())); if (m.length !== 2 || m.some((n) => !Number.isFinite(n))) { - return 'location must be "lat,lon" (e.g. "40.71,-74.01")'; + return '位置必须是“纬度,经度”(例如“40.71,-74.01”)'; } const [lat, lon] = m as [number, number]; - if (lat < -90 || lat > 90 || lon < -180 || lon > 180) return 'lat must be -90..90, lon -180..180'; + if (lat < -90 || lat > 90 || lon < -180 || lon > 180) + return '纬度范围是 -90..90,经度范围是 -180..180'; return null; } @@ -59,18 +60,18 @@ export const SETTING_SPECS: readonly SettingSpec[] = [ { key: 'proactive.enabled', env: 'LUNA_PROACTIVE', - label: 'Proactive messages', - hint: 'She may reach out on her own when you go quiet', - category: 'Companion', + label: '主动消息', + hint: '你安静下来时,她可能会主动来找你', + category: '陪伴', kind: 'boolean', defaultValue: '1', }, { key: 'proactive.quiet_hours', env: 'LUNA_PROACTIVE_QUIET_HOURS', - label: 'Quiet hours', - hint: 'Local hours she stays silent, comma-separated', - category: 'Companion', + label: '安静时段', + hint: '她保持安静的本地时间,用逗号分隔小时', + category: '陪伴', kind: 'text', defaultValue: '0,1,2,3,4,5', validate: validQuietHours, @@ -78,9 +79,9 @@ export const SETTING_SPECS: readonly SettingSpec[] = [ { key: 'proactive.activeness', env: 'LUNA_PROACTIVE_ACTIVENESS', - label: 'Outreach intensity', - hint: 'How eagerly she opens first: aloof, balanced, or clingy (still capped by the safety rails)', - category: 'Companion', + label: '主动程度', + hint: '她主动开口的积极程度:aloof、balanced 或 clingy(仍受安全限制)', + category: '陪伴', kind: 'text', defaultValue: 'balanced', validate: validActiveness, @@ -88,18 +89,18 @@ export const SETTING_SPECS: readonly SettingSpec[] = [ { key: 'selfcont.enabled', env: 'LUNA_SELFCONT', - label: 'Follow-up thoughts', - hint: 'She may add a second thought shortly after replying', - category: 'Companion', + label: '追问式补充', + hint: '她回复后可能很快再补充一句', + category: '陪伴', kind: 'boolean', defaultValue: '1', }, { key: 'selfcont.probability', env: 'LUNA_SELFCONT_PROBABILITY', - label: 'Follow-up chance', - hint: '0 = never, 1 = always', - category: 'Companion', + label: '补充概率', + hint: '0 = 从不,1 = 总是', + category: '陪伴', kind: 'number', defaultValue: '0.35', min: 0, @@ -109,27 +110,27 @@ export const SETTING_SPECS: readonly SettingSpec[] = [ { key: 'time.aware', env: 'LUNA_TIME_AWARE', - label: 'Time awareness', - hint: 'She knows the clock, the date, and how long you were away', - category: 'Perception', + label: '时间感知', + hint: '她知道现在几点、哪一天,以及你离开了多久', + category: '感知', kind: 'boolean', defaultValue: '1', }, { key: 'weather.ambient', env: 'LUNA_WEATHER_AMBIENT', - label: 'Weather awareness', - hint: 'Real weather colors her mood and small talk', - category: 'Perception', + label: '天气感知', + hint: '真实天气会影响她的心情和闲聊', + category: '感知', kind: 'boolean', defaultValue: '1', }, { key: 'weather.lat_lon', env: 'LUNA_LAT_LON', - label: 'Location (lat,lon)', - hint: 'Where she checks the weather, e.g. "40.71,-74.01"', - category: 'Perception', + label: '位置(纬度,经度)', + hint: '她查询天气的位置,例如“40.71,-74.01”', + category: '感知', kind: 'text', defaultValue: '', validate: validLatLon, @@ -137,9 +138,9 @@ export const SETTING_SPECS: readonly SettingSpec[] = [ { key: 'time.zone', env: 'LUNA_TZ', - label: 'Timezone', - hint: 'IANA zone like America/New_York; empty = system', - category: 'Perception', + label: '时区', + hint: 'IANA 时区,例如 America/New_York;留空则使用系统设置', + category: '感知', kind: 'text', defaultValue: '', }, @@ -147,9 +148,9 @@ export const SETTING_SPECS: readonly SettingSpec[] = [ { key: 'web.search', env: 'LUNA_WEB_SEARCH', - label: 'Web search', - hint: 'She can search the web (needs a search API key)', - category: 'Abilities', + label: '联网搜索', + hint: '她可以搜索网页(需要搜索服务 API 密钥)', + category: '能力', kind: 'boolean', defaultValue: '1', restartRequired: true, @@ -157,9 +158,9 @@ export const SETTING_SPECS: readonly SettingSpec[] = [ { key: 'web.fetch', env: 'LUNA_WEB_FETCH', - label: 'Read web pages', - hint: 'She can open and read URLs', - category: 'Abilities', + label: '读取网页', + hint: '她可以打开并阅读 URL', + category: '能力', kind: 'boolean', defaultValue: '1', restartRequired: true, @@ -167,9 +168,9 @@ export const SETTING_SPECS: readonly SettingSpec[] = [ { key: 'skills.enabled', env: 'LUNA_SKILLS', - label: 'Skill library', - hint: 'She keeps + reuses saved procedures (save_skill / recall_skill + the skill shelf)', - category: 'Abilities', + label: '技能库', + hint: '她会保存并复用已学会的流程(save_skill / recall_skill 和技能页)', + category: '能力', kind: 'boolean', defaultValue: '1', restartRequired: true, @@ -177,18 +178,18 @@ export const SETTING_SPECS: readonly SettingSpec[] = [ { key: 'skills.dream_distill', env: 'LUNA_DREAM_SKILLS', - label: 'Dream skill distillation', - hint: 'Her dream turns the day’s significant moments into reusable skills (audited, undoable)', - category: 'Memory', + label: '梦境技能沉淀', + hint: '她会在梦里把当天的重要经历沉淀成可复用技能(有记录、可撤销)', + category: '记忆', kind: 'boolean', defaultValue: '1', }, { key: 'weather.tool', env: 'LUNA_WEATHER', - label: 'Weather lookups', - hint: 'She can check the forecast on demand', - category: 'Abilities', + label: '查询天气', + hint: '她可以按需查询天气预报', + category: '能力', kind: 'boolean', defaultValue: '1', restartRequired: true, @@ -196,9 +197,9 @@ export const SETTING_SPECS: readonly SettingSpec[] = [ { key: 'code.write', env: 'LUNA_CODE_WRITE', - label: 'Code editing', - hint: 'She can edit files in her workspace', - category: 'Abilities', + label: '编辑代码', + hint: '她可以编辑工作区里的文件', + category: '能力', kind: 'boolean', defaultValue: '1', restartRequired: true, @@ -206,9 +207,9 @@ export const SETTING_SPECS: readonly SettingSpec[] = [ { key: 'shell.enabled', env: 'LUNA_SHELL', - label: 'Shell commands', - hint: 'She can run commands in her workspace', - category: 'Abilities', + label: '终端命令', + hint: '她可以在工作区里运行命令', + category: '能力', kind: 'boolean', defaultValue: '1', restartRequired: true, @@ -217,18 +218,18 @@ export const SETTING_SPECS: readonly SettingSpec[] = [ { key: 'memory.inject', env: 'LUNA_MEMORY_INJECT', - label: 'Memory in context', - hint: 'Core memory and recalled moments shape her replies', - category: 'Memory', + label: '将记忆注入上下文', + hint: '核心记忆和召回的经历会影响她的回复', + category: '记忆', kind: 'boolean', defaultValue: '1', }, { key: 'dream.shutdown', env: 'LUNA_SHUTDOWN_DREAM', - label: 'Dream on quit', - hint: 'She consolidates memories before shutting down (at most once every few hours, not every close)', - category: 'Memory', + label: '退出时进入梦境', + hint: '退出前整理记忆(每几小时最多一次,不是每次关闭都触发)', + category: '记忆', kind: 'boolean', defaultValue: '1', }, @@ -236,9 +237,9 @@ export const SETTING_SPECS: readonly SettingSpec[] = [ { key: 'model.id', env: 'LUNA_MODEL', - label: 'Model', - hint: 'The LLM she thinks with; empty = built-in default', - category: 'Model', + label: '模型', + hint: '她用来思考的大语言模型;留空则使用内置默认值', + category: '模型', kind: 'text', defaultValue: '', restartRequired: true, @@ -252,13 +253,13 @@ export function specFor(key: string): SettingSpec | undefined { // Returns an error message, or null when the value is acceptable for the spec. export function validateValue(spec: SettingSpec, value: string): string | null { if (spec.kind === 'boolean') { - return value === '0' || value === '1' ? null : `${spec.label} must be '1' or '0'`; + return value === '0' || value === '1' ? null : `${spec.label} 必须是“1”或“0”`; } if (spec.kind === 'number') { const n = Number(value); - if (value.trim() === '' || !Number.isFinite(n)) return `${spec.label} must be a number`; - if (spec.min !== undefined && n < spec.min) return `${spec.label} must be ≥ ${spec.min}`; - if (spec.max !== undefined && n > spec.max) return `${spec.label} must be ≤ ${spec.max}`; + if (value.trim() === '' || !Number.isFinite(n)) return `${spec.label} 必须是数字`; + if (spec.min !== undefined && n < spec.min) return `${spec.label} 必须大于等于 ${spec.min}`; + if (spec.max !== undefined && n > spec.max) return `${spec.label} 必须小于等于 ${spec.max}`; return null; } return spec.validate ? spec.validate(value) : null; diff --git a/packages/server/src/settings/store.ts b/packages/server/src/settings/store.ts index 294dba9..93a614d 100644 --- a/packages/server/src/settings/store.ts +++ b/packages/server/src/settings/store.ts @@ -58,9 +58,10 @@ export function initSettings(database: Database | null): void { originalEnv.set(s.env, v); } if (!db) return; - const rows = db - .query('SELECT key, value FROM settings') - .all() as Array<{ key: string; value: string }>; + const rows = db.query('SELECT key, value FROM settings').all() as Array<{ + key: string; + value: string; + }>; for (const row of rows) { const spec = specFor(row.key); // Rows for removed specs or values a newer validator rejects are ignored, not deleted — @@ -96,9 +97,9 @@ export function settingsState(): Setting[] { export type SetResult = { ok: true } | { ok: false; error: string }; export function setSetting(key: string, value: string | null): SetResult { - if (!initialized) return { ok: false, error: 'settings not initialized' }; + if (!initialized) return { ok: false, error: '设置尚未初始化' }; const spec = specFor(key); - if (!spec) return { ok: false, error: `unknown setting: ${key}` }; + if (!spec) return { ok: false, error: `未知的设置项:${key}` }; if (value === null) { pins.delete(key); db?.run('DELETE FROM settings WHERE key = ?', [key]); diff --git a/packages/web/src/app.ts b/packages/web/src/app.ts index dae5ec0..4d71dd9 100644 --- a/packages/web/src/app.ts +++ b/packages/web/src/app.ts @@ -4,7 +4,13 @@ import { LunaWsClient, type WsStatus } from './wsClient'; import { resolveWsUrl } from './wsUrl'; import { isInteractivePoint, modelRectFromVars } from './ui/petHitTest'; import { lastGeoFix, requestGeolocation, setGeoFix } from './geo'; -import { consoleLive2DSink, noopAudioSink, type AudioSink, type Live2DSink, type Live2DState } from './sinks'; +import { + consoleLive2DSink, + noopAudioSink, + type AudioSink, + type Live2DSink, + type Live2DState, +} from './sinks'; import { CuteBubbleView } from './ui/cuteBubbleView'; import { SpeechStackView } from './ui/speechStackView'; import { RouterBubbleView } from './ui/routerBubbleView'; @@ -43,7 +49,11 @@ import { mountSettingsPage } from './ui/settingsPage'; // overlay, thinking indicator, mood pip, scroll pill, settings). Degrades to the // placeholder + silence if WebGL/audio are unavailable; chat works regardless. -const STATUS_TEXT: Record = { connecting: 'Connecting…', open: 'Online', closed: 'Reconnecting…' }; +const STATUS_TEXT: Record = { + connecting: '连接中…', + open: '在线', + closed: '重新连接中…', +}; // Backend WS endpoint: fixed 127.0.0.1 + `?ws=` override (isolated dev: `:5273/?ws=8888`). // v0.26.0: no longer derived from location.hostname — a desktop shell's origin must not decide // where the local server lives. @@ -97,7 +107,11 @@ async function boot(): Promise { benchSink = await createPixiLive2DSink(bench.stage, { modelUrl }); } if (benchSink) bench.stage.querySelector('.model-placeholder')?.remove(); - else applyEmptyState(bench.stage, !modelUrl ? 'none' : webglAvailable() ? 'load-failed' : 'webgl-off'); + else + applyEmptyState( + bench.stage, + !modelUrl ? 'none' : webglAvailable() ? 'load-failed' : 'webgl-off', + ); window.addEventListener('pagehide', () => bench.dispose()); return; } @@ -164,10 +178,10 @@ async function boot(): Promise { if (skipped) return; gate.setStatus( res === 'unavailable' - ? 'No voice service detected, entering…' + ? '没有检测到语音服务,进入中…' : res === 'failed' - ? 'Voice failed to load, entering muted' - : 'Voice ready ✓', + ? '语音加载失败,将以静音模式进入' + : '语音已就绪 ✓', ); globalThis.setTimeout(() => gate.done(), res === 'ready' ? 300 : 900); }); @@ -226,7 +240,7 @@ async function boot(): Promise { function setDream(on: boolean): void { dreaming = on; refs.input.disabled = on; - refs.input.placeholder = on ? 'Luna is dreaming…' : 'Say something to Luna…'; + refs.input.placeholder = on ? 'Luna 正在做梦…' : '和 Luna 说点什么…'; if (on) { clearTimeout(dreamHideTimer); dreamShownAt = Date.now(); @@ -299,7 +313,9 @@ async function boot(): Promise { audio = new WebAudioSink({ onMouth: (frame) => live2d.setMouth(frame), onUnspoken: (text) => { - console.warn(`[voice] her voice is unavailable — skipping this line: ${text.slice(0, 40)}`); + console.warn( + `[voice] her voice is unavailable — skipping this line: ${text.slice(0, 40)}`, + ); }, }); } @@ -663,11 +679,11 @@ async function boot(): Promise { const row = document.createElement('label'); row.className = 'setting-row rerun-setup-row'; const name = document.createElement('span'); - name.textContent = 'Setup wizard'; + name.textContent = '配置向导'; const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'setting-reset'; - btn.textContent = 'Re-run…'; + btn.textContent = '重新运行…'; btn.addEventListener('click', () => openSetup()); row.append(name, btn); petRow.after(row); @@ -773,9 +789,11 @@ async function boot(): Promise { // her diary never wakes her); the settings page ADOPTS the old panel's live rows, so the // controls keep their exact wiring wherever they are displayed. pageBody: (id) => - id === 'diary' ? mountDiaryBook(document) - : id === 'skills' ? mountSkillsPage(document) - : mountSettingsPage(document, refs), + id === 'diary' + ? mountDiaryBook(document) + : id === 'skills' + ? mountSkillsPage(document) + : mountSettingsPage(document, refs), ...(quitBridge ? { quit: () => quitBridge() } : {}), }); }; @@ -786,7 +804,7 @@ async function boot(): Promise { const returnBtn = document.createElement('button'); returnBtn.type = 'button'; returnBtn.className = 'menu-return-btn'; - returnBtn.textContent = '← Menu'; + returnBtn.textContent = '← 菜单'; returnBtn.addEventListener('click', () => { returnGate.request(() => { clearTimeout(swapTimer); @@ -804,7 +822,11 @@ async function boot(): Promise { // stays as the donor the settings page adopts its rows from. Esc in chat = the quick way home. refs.settingsBtn.style.display = 'none'; document.addEventListener('keydown', (e) => { - if (e.key === 'Escape' && menuHandle === null && !refs.settingsPanel.classList.contains('on')) { + if ( + e.key === 'Escape' && + menuHandle === null && + !refs.settingsPanel.classList.contains('on') + ) { returnBtn.click(); } }); @@ -819,9 +841,9 @@ function applyEmptyState(stage: HTMLElement, state: 'none' | 'webgl-off' | 'load const ph = stage.querySelector('.model-placeholder'); if (!ph) return; const copy: Record = { - none: ['No avatar installed', 'Drop a Live2D model in public/models/ — see docs/SETUP.md'], - 'webgl-off': ['WebGL unavailable', "This browser can't render the avatar"], - 'load-failed': ['Model failed to load', 'Check the model files in public/models/'], + none: ['还没有安装模型', '把 Live2D 模型放进 public/models/,详见 docs/SETUP.md'], + 'webgl-off': ['WebGL 不可用', '当前浏览器无法渲染模型'], + 'load-failed': ['模型加载失败', '请检查 public/models/ 里的模型文件'], }; const [labelText, subText] = copy[state]; const label = ph.querySelector('.label'); @@ -837,7 +859,7 @@ function applyEmptyState(stage: HTMLElement, state: 'none' | 'webgl-off' | 'load const btn = ph.ownerDocument.createElement('button'); btn.className = 'choose-model-btn'; btn.type = 'button'; - btn.textContent = 'Choose model folder…'; + btn.textContent = '选择模型文件夹…'; btn.addEventListener('click', () => void chooseModel()); ph.appendChild(btn); } @@ -847,14 +869,15 @@ function applyEmptyState(stage: HTMLElement, state: 'none' | 'webgl-off' | 'load // states, so performances are visibly testable without the backend. MVP for the // 表演编排 / 挂机 / 睡眠 inspection ask. function buildDevPanel(live2d: Live2DSink): void { - const btn = 'background:#20242f;color:#e7e9ef;border:1px solid #2c3140;border-radius:6px;padding:3px 8px;cursor:pointer;font:inherit;'; + const btn = + 'background:#20242f;color:#e7e9ef;border:1px solid #2c3140;border-radius:6px;padding:3px 8px;cursor:pointer;font:inherit;'; const panel = document.createElement('div'); panel.style.cssText = 'position:fixed;left:10px;bottom:10px;z-index:9999;background:rgba(20,22,28,.92);color:#e7e9ef;' + 'border:1px solid #2c3140;border-radius:10px;padding:10px;font:12px ui-monospace,monospace;' + 'display:flex;flex-direction:column;gap:6px;max-width:250px;'; const title = document.createElement('div'); - title.textContent = '🎭 dev · trigger performance'; + title.textContent = '🎭 开发面板 · 触发表演'; title.style.cssText = 'color:#ffa7d1;font-weight:600;'; panel.appendChild(title); @@ -862,7 +885,8 @@ function buildDevPanel(live2d: Live2DSink): void { const row = document.createElement('div'); row.style.cssText = 'display:flex;gap:6px;'; const sel = document.createElement('select'); - sel.style.cssText = 'flex:1;background:#20242f;color:inherit;border:1px solid #2c3140;border-radius:6px;padding:3px;'; + sel.style.cssText = + 'flex:1;background:#20242f;color:inherit;border:1px solid #2c3140;border-radius:6px;padding:3px;'; for (const id of emotions) { const o = document.createElement('option'); o.value = id; @@ -870,7 +894,7 @@ function buildDevPanel(live2d: Live2DSink): void { sel.appendChild(o); } const play = document.createElement('button'); - play.textContent = '▶ Play'; + play.textContent = '▶ 播放'; play.style.cssText = btn; play.addEventListener('click', () => live2d.triggerEmotion?.(sel.value)); row.append(sel, play); @@ -879,10 +903,10 @@ function buildDevPanel(live2d: Live2DSink): void { const srow = document.createElement('div'); srow.style.cssText = 'display:flex;gap:4px;flex-wrap:wrap;'; const states: Array<[string, Live2DState]> = [ - ['Idle', 'neutral'], - ['Thinking', 'thinking'], - ['Speaking', 'speaking'], - ['Sleeping', 'sleeping'], + ['待机', 'neutral'], + ['思考', 'thinking'], + ['说话', 'speaking'], + ['睡眠', 'sleeping'], ]; for (const [label, st] of states) { const b = document.createElement('button'); @@ -895,7 +919,7 @@ function buildDevPanel(live2d: Live2DSink): void { if (!emotions.length) { const note = document.createElement('div'); - note.textContent = '(model not loaded — placeholder sink)'; + note.textContent = '(模型尚未加载 — 当前为占位模式)'; note.style.cssText = 'color:#8b93a7;'; panel.appendChild(note); } diff --git a/packages/web/src/bubbles.ts b/packages/web/src/bubbles.ts index 56123cc..0994718 100644 --- a/packages/web/src/bubbles.ts +++ b/packages/web/src/bubbles.ts @@ -122,7 +122,7 @@ export class DomBubbleView implements BubbleView { const leaf = doc.createElement('button'); leaf.type = 'button'; leaf.className = 'luna-leaf'; - leaf.setAttribute('aria-label', 'something she quietly did'); + leaf.setAttribute('aria-label', '她悄悄做过的一件事'); const glyph = doc.createElement('span'); glyph.className = 'leaf-glyph'; glyph.textContent = '🍃'; diff --git a/packages/web/src/live2d/faceData.ts b/packages/web/src/live2d/faceData.ts index 6d73277..2431121 100644 --- a/packages/web/src/live2d/faceData.ts +++ b/packages/web/src/live2d/faceData.ts @@ -113,12 +113,12 @@ export const ALL_OVERLAY_PARAMS = Object.values(OVERLAYS).flatMap((o) => Object. // `ParamarmupL/R` is deliberately absent — a raised arm is gesture material, not something to wear — // and so is `Paramdown1`, which `adorable` already drives as an overlay. export const COSTUME: Record = { - eyepatch: { pid: 'Paramyanzhao', label: 'Eyepatch' }, - mic: { pid: 'Paramhuatong', label: 'Microphone' }, - puppy: { pid: 'Paramxiaogou', label: 'Floating puppy' }, + eyepatch: { pid: 'Paramyanzhao', label: '眼罩' }, + mic: { pid: 'Paramhuatong', label: '麦克风' }, + puppy: { pid: 'Paramxiaogou', label: '漂浮小狗' }, // The two hairstyles are mutually exclusive at the UI layer; both off = the drawn default. - longHair: { pid: 'Paramlonghair', label: 'Short hair 1', group: 'hair' }, - shortHair2: { pid: 'Paramlonghair2', label: 'Short hair 2', group: 'hair' }, + longHair: { pid: 'Paramlonghair', label: '短发造型 1', group: 'hair' }, + shortHair2: { pid: 'Paramlonghair2', label: '短发造型 2', group: 'hair' }, }; export const COSTUME_IDS: readonly string[] = Object.keys(COSTUME); @@ -239,11 +239,11 @@ export type IdleProfileId = | 'sweetBounceV1'; export const IDLE_PROFILES: ReadonlyArray<{ id: IdleProfileId; label: string }> = [ - { id: 'defaultIdleV1', label: 'Default' }, - { id: 'cuteSwayV1', label: 'Cute sway' }, - { id: 'peekyIdleV1', label: 'Peek' }, - { id: 'shyDriftV1', label: 'Shy drift' }, - { id: 'sweetBounceV1', label: 'Sweet bounce' }, + { id: 'defaultIdleV1', label: '默认待机' }, + { id: 'cuteSwayV1', label: '可爱摇摆' }, + { id: 'peekyIdleV1', label: '探头张望' }, + { id: 'shyDriftV1', label: '害羞轻晃' }, + { id: 'sweetBounceV1', label: '甜甜弹跳' }, ]; export const IDLE_PROFILE_IDS: readonly string[] = IDLE_PROFILES.map((p) => p.id); diff --git a/packages/web/src/live2d/perfFlags.ts b/packages/web/src/live2d/perfFlags.ts index 531ac9a..a696704 100644 --- a/packages/web/src/live2d/perfFlags.ts +++ b/packages/web/src/live2d/perfFlags.ts @@ -20,13 +20,13 @@ export const IDLE_PROFILE_KEY = 'luna:idle-profile'; // Labels match the Avatar settings card word for word — the workbench and the shipped panel must // not name the same switch two different things. export const PERF_FLAGS: readonly PerfFlag[] = [ - { key: GAZE_KEY, label: 'Gaze follow', hint: 'eyes track the pointer' }, - { key: AFFECT_KEY, label: 'Mood memory', hint: 'the continuous VAD undertone' }, - { key: LIVE_PEAK_KEY, label: 'Living expressions', hint: 'idle leaks through a playing clip' }, - { key: SHORT_CLIPS_KEY, label: 'Brief performances', hint: '~2.5 s clips instead of ~6 s' }, - { key: IDLE_ACTIONS_KEY, label: 'Idle gestures', hint: 'a gesture every 8–20 s when idle' }, - { key: LISTENING_KEY, label: 'Attentive listening', hint: 'turns toward you while you type; thinks visibly' }, - { key: SPEECH_PERF_KEY, label: 'Speaking performance', hint: 'nods on the stresses of what she says' }, + { key: GAZE_KEY, label: '视线跟随', hint: '眼睛跟着指针移动' }, + { key: AFFECT_KEY, label: '情绪记忆', hint: '持续保留一层情绪底色' }, + { key: LIVE_PEAK_KEY, label: '灵动表情', hint: '表演动作中仍保留待机细节' }, + { key: SHORT_CLIPS_KEY, label: '短时表现', hint: '约 2.5 秒,而不是约 6 秒的动作片段' }, + { key: IDLE_ACTIONS_KEY, label: '待机动作', hint: '待机时每 8–20 秒做一次小动作' }, + { key: LISTENING_KEY, label: '专注倾听', hint: '你输入时她会转向你,并表现出思考' }, + { key: SPEECH_PERF_KEY, label: '说话表现', hint: '会在说话重音处点头' }, ]; export function flagOn(key: string, storage?: Pick | null): boolean { diff --git a/packages/web/src/ui/bootGate.ts b/packages/web/src/ui/bootGate.ts index 936b577..9c183ba 100644 --- a/packages/web/src/ui/bootGate.ts +++ b/packages/web/src/ui/bootGate.ts @@ -21,11 +21,11 @@ export function createBootGate(root: HTMLElement): BootGate { card.innerHTML = '
🌙
' + '
' + - '
Luna is waking up…
' + - '
First launch loads the voice model, one moment…
' + - '
Connecting…
' + + '
Luna 正在醒来…
' + + '
首次启动需要加载语音模型,请稍等…
' + + '
连接中…
' + '
' + - ''; + ''; el.appendChild(card); root.appendChild(el); @@ -35,7 +35,7 @@ export function createBootGate(root: HTMLElement): BootGate { const start = performance.now(); const timer = globalThis.setInterval(() => { - elapsedEl.textContent = `elapsed ${Math.round((performance.now() - start) / 1000)}s`; + elapsedEl.textContent = `已用时 ${Math.round((performance.now() - start) / 1000)} 秒`; }, 1000); return { @@ -55,15 +55,15 @@ export function createBootGate(root: HTMLElement): BootGate { } const TTS_STATE_LABEL: Record = { - idle: 'Preparing voice…', - starting: 'Starting the voice engine…', - spawning: 'Starting the voice engine…', - booting: 'Starting the voice engine…', - restarting: 'Voice engine restarting…', - loading: 'Loading the voice model…', - loading_model: 'Loading the voice model…', - warming: 'Loading the voice model…', - ready: 'Voice ready ✓', + idle: '准备语音…', + starting: '启动语音引擎…', + spawning: '启动语音引擎…', + booting: '启动语音引擎…', + restarting: '语音引擎重启中…', + loading: '加载语音模型…', + loading_model: '加载语音模型…', + warming: '加载语音模型…', + ready: '语音已就绪 ✓', }; type HealthShape = { backend?: { ready?: boolean; state?: string } }; @@ -74,7 +74,12 @@ function isManagedWait(state: string | undefined): boolean { return state === 'starting' || state === 'restarting'; } -export type WarmUpTiming = { pollMs?: number; deadlineMs?: number; synthRetryMs?: number; synthTimeoutMs?: number }; +export type WarmUpTiming = { + pollMs?: number; + deadlineMs?: number; + synthRetryMs?: number; + synthTimeoutMs?: number; +}; // Warms the TTS backend: returns 'unavailable' fast if no sidecar is configured, // 'ready' once warm (firing one synth — which completes only after the model is @@ -103,7 +108,7 @@ export async function warmUpTts( if (isReady(j0)) return 'ready'; // already warm (e.g. a reload) let lastState = j0?.backend?.state; if (lastState === 'gave-up') return 'failed'; // the managed child crash-looped out — fail fast - onStatus(TTS_STATE_LABEL[lastState ?? 'idle'] ?? 'Preparing voice…', lastState); + onStatus(TTS_STATE_LABEL[lastState ?? 'idle'] ?? '准备语音…', lastState); // Resolve as soon as EITHER /health reports ready (the model is loaded — don't // wait for the warmup synth to finish) OR the warmup synth returns. Firing @@ -133,7 +138,7 @@ export async function warmUpTts( finish('failed'); // supervisor exhausted its restarts — don't burn the deadline return; } - if (st) onStatus(TTS_STATE_LABEL[st] ?? `Voice engine: ${st}…`, st); + if (st) onStatus(TTS_STATE_LABEL[st] ?? `语音引擎:${st}…`, st); if (isReady(j)) finish('ready'); } catch { /* transient — keep polling */ @@ -152,7 +157,7 @@ export async function warmUpTts( const r = await fetch(`${base}/speak`, { method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ text: 'Ready when you are' }), + body: JSON.stringify({ text: '我准备好了' }), signal: ctl.signal, }); if (r.ok) await r.arrayBuffer().catch(() => undefined); // drain + discard the warmup audio diff --git a/packages/web/src/ui/cuteBubbleView.ts b/packages/web/src/ui/cuteBubbleView.ts index 3b4f469..0ac901b 100644 --- a/packages/web/src/ui/cuteBubbleView.ts +++ b/packages/web/src/ui/cuteBubbleView.ts @@ -164,7 +164,7 @@ export class CuteBubbleView implements BubbleView { if (turns.length) { const div = this.host.ownerDocument.createElement('div'); div.className = 'history-divider'; - div.textContent = '— earlier conversation —'; + div.textContent = '— 更早的对话 —'; div.style.cssText = 'text-align:center;font-size:11px;opacity:0.5;margin:10px 0 4px;letter-spacing:1px;'; this.host.appendChild(div); diff --git a/packages/web/src/ui/diaryBook.test.ts b/packages/web/src/ui/diaryBook.test.ts index b35098c..db8b243 100644 --- a/packages/web/src/ui/diaryBook.test.ts +++ b/packages/web/src/ui/diaryBook.test.ts @@ -12,7 +12,12 @@ import { translateStep, } from './diaryBook'; -const diary = (period_key: string): DiaryEntry => ({ kind: 'day', period_key, text: 't', generated_ms: 1 }); +const diary = (period_key: string): DiaryEntry => ({ + kind: 'day', + period_key, + text: 't', + generated_ms: 1, +}); const dream = (started_ms: number, over: Partial = {}): DreamRecord => ({ cycle_id: `c${started_ms}`, started_ms, @@ -94,36 +99,63 @@ describe('nextLitDay — arrows travel lit days only', () => { // counts extracted, actions stated, no feelings ascribed, unknown steps degrade to themselves. describe('the dream translation layer (M10)', () => { test('the real pipeline steps read as what she did that night', () => { - expect(translateStep({ step: 'rate_salience', status: 'ok', detail: 'rated 9 turns', ms: 1 })).toBe('回看了 9 个瞬间。'); - expect(translateStep({ step: 'refine_semantic', status: 'ok', detail: 'removed 2, added 2', ms: 1 })).toBe('放下了 2 件事,记住了 2 件。'); - expect(translateStep({ step: 'memory_audit', status: 'ok', detail: 'removed 0, added 3', ms: 1 })).toBe('整理了记忆的抽屉(−0 / +3)。'); - expect(translateStep({ step: 'persona_update', status: 'ok', detail: 'self+bond', ms: 1 })).toBe('对自己的认识动了动(self+bond)。'); - expect(translateStep({ step: 'run_diaries', status: 'ok', detail: '2 diaries written', ms: 1 })).toBe('写下了 2 篇日记。'); - expect(translateStep({ step: 'distill_skills', status: 'ok', detail: 'new:live2d-gesture-control', ms: 1 })).toBe( - '学会了一件新事:live2d-gesture-control。', - ); + expect( + translateStep({ step: 'rate_salience', status: 'ok', detail: 'rated 9 turns', ms: 1 }), + ).toBe('回看了 9 个瞬间。'); + expect( + translateStep({ step: 'refine_semantic', status: 'ok', detail: 'removed 2, added 2', ms: 1 }), + ).toBe('放下了 2 件事,记住了 2 件。'); + expect( + translateStep({ step: 'memory_audit', status: 'ok', detail: 'removed 0, added 3', ms: 1 }), + ).toBe('整理了记忆的抽屉(−0 / +3)。'); + expect( + translateStep({ step: 'persona_update', status: 'ok', detail: 'self+bond', ms: 1 }), + ).toBe('对自己的认识动了动(self+bond)。'); + expect( + translateStep({ step: 'run_diaries', status: 'ok', detail: '2 diaries written', ms: 1 }), + ).toBe('写下了 2 篇日记。'); + expect( + translateStep({ + step: 'distill_skills', + status: 'ok', + detail: 'new:live2d-gesture-control', + ms: 1, + }), + ).toBe('学会了一件新事:live2d-gesture-control。'); }); test('a skipped fold reads as the plan wrote it', () => { - expect(translateStep({ step: 'refine_layer1', status: 'skipped', detail: 'nothing to fold', ms: 1 })).toBe( - '略过——没什么要折叠的。', - ); + expect( + translateStep({ step: 'refine_layer1', status: 'skipped', detail: 'nothing to fold', ms: 1 }), + ).toBe('略过——没什么要折叠的。'); }); test('an unknown step renders raw — a future dream stage must not crash the book', () => { - expect(translateStep({ step: 'new_stage', status: 'ok', detail: 'did a thing', ms: 1 })).toBe('new_stage: did a thing'); + expect(translateStep({ step: 'new_stage', status: 'ok', detail: 'did a thing', ms: 1 })).toBe( + 'new_stage: did a thing', + ); }); test('the timings never surface — the pipeline cost is not part of her night', () => { - const line = translateStep({ step: 'rate_salience', status: 'ok', detail: 'rated 9 turns', ms: 99999 }); + const line = translateStep({ + step: 'rate_salience', + status: 'ok', + detail: 'rated 9 turns', + ms: 99999, + }); expect(line).not.toContain('99999'); }); test('aborted and empty dreams both read as a broken dream', () => { - expect(dreamNarrative(dream(1, { aborted: true }))).toEqual({ broken: true, lines: [DREAM_BROKE] }); + expect(dreamNarrative(dream(1, { aborted: true }))).toEqual({ + broken: true, + lines: [DREAM_BROKE], + }); expect(dreamNarrative(dream(1, { steps: [] }))).toEqual({ broken: true, lines: [DREAM_BROKE] }); const full = dreamNarrative( - dream(1, { steps: [{ step: 'rate_salience', status: 'ok', detail: 'rated 3 turns', ms: 1 }] }), + dream(1, { + steps: [{ step: 'rate_salience', status: 'ok', detail: 'rated 3 turns', ms: 1 }], + }), ); expect(full.broken).toBe(false); expect(full.lines).toEqual(['回看了 3 个瞬间。']); @@ -153,7 +185,7 @@ describe('the page-turn queue — one turn at a time, latest click wins', () => describe('pageHeading', () => { test('reads like a diary date', () => { - expect(pageHeading('2026-07-31')).toBe('JULY 31, 2026'); - expect(pageHeading('2026-01-05')).toBe('JANUARY 5, 2026'); + expect(pageHeading('2026-07-31')).toBe('2026年7月31日'); + expect(pageHeading('2026-01-05')).toBe('2026年1月5日'); }); }); diff --git a/packages/web/src/ui/diaryBook.ts b/packages/web/src/ui/diaryBook.ts index fb7e62b..59bfa64 100644 --- a/packages/web/src/ui/diaryBook.ts +++ b/packages/web/src/ui/diaryBook.ts @@ -1,4 +1,10 @@ -import { DataDiaries, DataDreams, type DiaryEntry, type DreamRecord, type DreamStep } from '@luna/protocol'; +import { + DataDiaries, + DataDreams, + type DiaryEntry, + type DreamRecord, + type DreamStep, +} from '@luna/protocol'; // v0.44.3 — the diary book. What she writes by day and what she digests by night are the same // evening's two faces, so they share one book (D7): a two-page spread, calendar left, content @@ -64,7 +70,8 @@ export function monthGrid(year: number, month0: number, index: BookIndex): Calen const first = new Date(year, month0, 1); const daysInMonth = new Date(year, month0 + 1, 0).getDate(); const cells: CalendarCell[] = []; - for (let i = 0; i < first.getDay(); i++) cells.push({ day: null, key: null, hasDiary: false, hasDream: false }); + for (let i = 0; i < first.getDay(); i++) + cells.push({ day: null, key: null, hasDiary: false, hasDream: false }); for (let day = 1; day <= daysInMonth; day++) { const key = `${year}-${`${month0 + 1}`.padStart(2, '0')}-${`${day}`.padStart(2, '0')}`; const entry = index.days.get(key); @@ -79,7 +86,11 @@ export function monthGrid(year: number, month0: number, index: BookIndex): Calen } // Arrow keys travel between LIT days only (the grey ones are not places). -export function nextLitDay(litDays: readonly string[], current: string, delta: 1 | -1): string | null { +export function nextLitDay( + litDays: readonly string[], + current: string, + delta: 1 | -1, +): string | null { if (litDays.length === 0) return null; const i = litDays.indexOf(current); if (i < 0) return litDays[0] ?? null; @@ -114,7 +125,8 @@ export function translateStep(s: DreamStep): string { case 'memory_audit': { const removed = num(s.detail, /removed (\d+)/); const added = num(s.detail, /added (\d+)/); - if (removed !== null && added !== null) return `整理了记忆的抽屉(−${removed} / +${added})。`; + if (removed !== null && added !== null) + return `整理了记忆的抽屉(−${removed} / +${added})。`; return '整理了记忆的抽屉。'; } case 'refine_layer1': @@ -190,12 +202,25 @@ export function createTurnQueue(runTurn: (to: string, done: () => void) => void) // ── formatting ─────────────────────────────────────────────────────────────────────────────── -const MONTHS = ['JANUARY', 'FEBRUARY', 'MARCH', 'APRIL', 'MAY', 'JUNE', 'JULY', 'AUGUST', 'SEPTEMBER', 'OCTOBER', 'NOVEMBER', 'DECEMBER']; +const MONTHS = [ + '一月', + '二月', + '三月', + '四月', + '五月', + '六月', + '七月', + '八月', + '九月', + '十月', + '十一月', + '十二月', +]; export function pageHeading(dayKey: string): string { const [y, m, d] = dayKey.split('-').map((v) => Number.parseInt(v, 10)); if (!y || !m || !d) return dayKey; - return `${MONTHS[m - 1]} ${d}, ${y}`; + return `${y}年${m}月${d}日`; } // ── fetch + mount ──────────────────────────────────────────────────────────────────────────── @@ -284,7 +309,7 @@ function assemble(doc: Document, book: HTMLElement, index: BookIndex): void { for (const f of ['diary', 'dream'] as const) { const b = doc.createElement('button'); b.type = 'button'; - b.textContent = f === 'diary' ? 'Diary' : 'Dream'; + b.textContent = f === 'diary' ? '日记' : '梦境'; b.classList.toggle('on', face === f); b.addEventListener('click', () => { if (face === f || halfTurning) return; diff --git a/packages/web/src/ui/layout.ts b/packages/web/src/ui/layout.ts index f0364ff..6a02a5d 100644 --- a/packages/web/src/ui/layout.ts +++ b/packages/web/src/ui/layout.ts @@ -93,7 +93,13 @@ function tabPane(parent: Element, name: string, active: boolean): HTMLElement { } // v0.36.4: one icon button in the left rail. `data-tab` links it to its pane. -function railBtn(rail: Element, icon: string, label: string, name: string, active: boolean): HTMLButtonElement { +function railBtn( + rail: Element, + icon: string, + label: string, + name: string, + active: boolean, +): HTMLButtonElement { const doc = rail.ownerDocument; const btn = doc.createElement('button'); btn.type = 'button'; @@ -152,12 +158,12 @@ export function buildLayout(root: HTMLElement): LayoutRefs { const stage = add(root, 'div', 'stage'); - const statusBadge = add(stage, 'div', 'status-badge', 'Connecting…'); + const statusBadge = add(stage, 'div', 'status-badge', '连接中…'); const settingsBtn = doc.createElement('button'); settingsBtn.className = 'settings-btn'; settingsBtn.type = 'button'; - settingsBtn.setAttribute('aria-label', 'Settings'); + settingsBtn.setAttribute('aria-label', '设置'); settingsBtn.textContent = '⚙'; stage.appendChild(settingsBtn); @@ -173,32 +179,64 @@ export function buildLayout(root: HTMLElement): LayoutRefs { const generalTab = tabPane(settingsBody, 'general', true); const avatarTab = tabPane(settingsBody, 'avatar', false); const serverTab = tabPane(settingsBody, 'server', false); - railBtn(settingsRail, '🎚', 'General', 'general', true); - const avatarRailBtn = railBtn(settingsRail, '✨', 'Avatar', 'avatar', false); - railBtn(settingsRail, '☁️', 'Server', 'server', false); + railBtn(settingsRail, '🎚', '通用', 'general', true); + const avatarRailBtn = railBtn(settingsRail, '✨', '形象', 'avatar', false); + railBtn(settingsRail, '☁️', '服务', 'server', false); wireTabs(settingsRail, [generalTab, avatarTab, serverTab]); const generalCard = add(generalTab, 'div', 'settings-card'); - const ttsToggle = toggleRow(generalCard, 'Voice', localStorage.getItem('luna:tts') !== '0'); + const ttsToggle = toggleRow(generalCard, '声音', localStorage.getItem('luna:tts') !== '0'); // Desktop-shell only: app.ts hides the row when no lunaPet bridge exists (plain browser) and // sets checked from the actual mode (?pet=1). The Setup wizard re-run row is inserted right after // it by app.ts (petRow.after), so it lands in this same card. - const petToggle = toggleRow(generalCard, 'Desktop pet', false); + const petToggle = toggleRow(generalCard, '桌面宠物', false); petToggle.closest('label')?.classList.add('pet-mode-row'); - add(generalTab, 'div', 'hint', 'Voice / model changes need a refresh · scroll to zoom · double-click to reset'); + add(generalTab, 'div', 'hint', '声音或模型变更需要刷新 · 滚轮缩放 · 双击还原'); const avatarCard = add(avatarTab, 'div', 'settings-card'); - const live2dToggle = toggleRow(avatarCard, 'Live2D model', localStorage.getItem('luna:live2d') !== '0'); - const gazeToggle = toggleRow(avatarCard, 'Gaze follow', localStorage.getItem('luna:gaze-follow') !== '0'); - const affectToggle = toggleRow(avatarCard, 'Mood memory', localStorage.getItem('luna:affect') !== '0'); - const livePeakToggle = toggleRow(avatarCard, 'Living expressions', localStorage.getItem('luna:live-peak') !== '0'); - const shortClipsToggle = toggleRow(avatarCard, 'Brief performances', localStorage.getItem('luna:short-clips') !== '0'); - const idleActionsToggle = toggleRow(avatarCard, 'Idle gestures', localStorage.getItem('luna:idle-actions') !== '0'); - const listeningToggle = toggleRow(avatarCard, 'Attentive listening', localStorage.getItem('luna:listening') !== '0'); - const speechPerfToggle = toggleRow(avatarCard, 'Speaking performance', localStorage.getItem('luna:speech-performance') !== '0'); + const live2dToggle = toggleRow( + avatarCard, + 'Live2D 模型', + localStorage.getItem('luna:live2d') !== '0', + ); + const gazeToggle = toggleRow( + avatarCard, + '视线跟随', + localStorage.getItem('luna:gaze-follow') !== '0', + ); + const affectToggle = toggleRow( + avatarCard, + '情绪记忆', + localStorage.getItem('luna:affect') !== '0', + ); + const livePeakToggle = toggleRow( + avatarCard, + '灵动表情', + localStorage.getItem('luna:live-peak') !== '0', + ); + const shortClipsToggle = toggleRow( + avatarCard, + '短时表现', + localStorage.getItem('luna:short-clips') !== '0', + ); + const idleActionsToggle = toggleRow( + avatarCard, + '待机动作', + localStorage.getItem('luna:idle-actions') !== '0', + ); + const listeningToggle = toggleRow( + avatarCard, + '专注倾听', + localStorage.getItem('luna:listening') !== '0', + ); + const speechPerfToggle = toggleRow( + avatarCard, + '说话表现', + localStorage.getItem('luna:speech-performance') !== '0', + ); const idleSelect = selectRow( avatarCard, - 'Idle animation', + '待机动画', IDLE_PROFILES, localStorage.getItem('luna:idle-profile') ?? DEFAULT_IDLE_PROFILE, ); @@ -206,26 +244,26 @@ export function buildLayout(root: HTMLElement): LayoutRefs { // those tune how the expression system behaves, these are things the owner puts on her and that // stay on until he takes them off. Checked state is filled in by app.ts from `luna:costume`. const costumeCard = add(avatarTab, 'div', 'settings-card costume-card'); - add(costumeCard, 'div', 'card-title', 'Costume'); + add(costumeCard, 'div', 'card-title', '装扮'); const costumeToggles: Record = {}; for (const [id, item] of Object.entries(COSTUME)) { const box = toggleRow(costumeCard, item.label, false); box.dataset['costume'] = id; costumeToggles[id] = box; } - add(costumeCard, 'div', 'hint', 'Yours to set — her expressions never put these on or take them off'); + add(costumeCard, 'div', 'hint', '由你决定 — 她的表情不会自动穿戴或摘下这些装扮'); // v0.43.7: the way into the Live2D workbench. A row rather than a rail tab — the bench replaces // the whole page (no WS, no chat), so it is a departure, not another settings pane. const workbenchBtn = doc.createElement('button'); workbenchBtn.className = 'workbench-btn'; workbenchBtn.type = 'button'; - workbenchBtn.textContent = '🎛 Live2D workbench'; + workbenchBtn.textContent = '🎛 Live2D 工作台'; avatarCard.appendChild(workbenchBtn); // v0.27.1: the server-driven half — settingsView.ts fills this from settings.state. const serverSettings = add(serverTab, 'div', 'server-settings'); - add(serverTab, 'div', 'hint server-empty', 'No server settings yet — Luna is still connecting.'); + add(serverTab, 'div', 'hint server-empty', '还没有服务设置 — Luna 仍在连接中。'); const motifLayer = add(stage, 'div', 'motif-layer'); for (const m of MOTIFS) { @@ -244,12 +282,12 @@ export function buildLayout(root: HTMLElement): LayoutRefs { const chatBody = add(panel, 'div', 'chat-body'); const header = add(chatBody, 'div', 'chat-header'); add(header, 'span', 'dot'); - add(header, 'span', undefined, 'Luna · online'); + add(header, 'span', undefined, 'Luna · 在线'); const chatLog = add(chatBody, 'div', 'chat-log'); const scrollPill = doc.createElement('button'); scrollPill.className = 'scroll-pill'; scrollPill.type = 'button'; - scrollPill.textContent = '↓ New messages'; + scrollPill.textContent = '↓ 新消息'; chatBody.appendChild(scrollPill); const inputRow = add(panel, 'div', 'chat-input-row'); @@ -258,19 +296,19 @@ export function buildLayout(root: HTMLElement): LayoutRefs { const collapseBtn = doc.createElement('button'); collapseBtn.className = 'collapse-btn'; collapseBtn.type = 'button'; - collapseBtn.setAttribute('aria-label', 'Collapse chat'); + collapseBtn.setAttribute('aria-label', '收起对话'); collapseBtn.textContent = '⌄'; inputRow.appendChild(collapseBtn); const input = doc.createElement('input'); input.className = 'chat-input'; input.type = 'text'; - input.placeholder = 'Say something to Luna…'; + input.placeholder = '和 Luna 说点什么…'; input.autocomplete = 'off'; inputRow.appendChild(input); const sendBtn = doc.createElement('button'); sendBtn.className = 'send-btn'; sendBtn.type = 'button'; - sendBtn.setAttribute('aria-label', 'Send'); + sendBtn.setAttribute('aria-label', '发送'); sendBtn.textContent = '➤'; inputRow.appendChild(sendBtn); @@ -280,12 +318,12 @@ export function buildLayout(root: HTMLElement): LayoutRefs { add(moodPip, 'span', 'mood-label', ''); const ph = add(modelStage, 'div', 'model-placeholder'); add(ph, 'div', 'ph-circle', '🌙'); - add(ph, 'div', 'label', 'No avatar installed'); - add(ph, 'div', 'sub', 'Add a Live2D model to see Luna'); + add(ph, 'div', 'label', '还没有安装模型'); + add(ph, 'div', 'sub', '添加 Live2D 模型后就能看到 Luna'); const dreamBtn = doc.createElement('button'); dreamBtn.className = 'dream-btn'; dreamBtn.type = 'button'; - dreamBtn.textContent = '🌙 Dream'; + dreamBtn.textContent = '🌙 梦境'; modelStage.appendChild(dreamBtn); const dreamOverlay = add(root, 'div', 'dream-overlay'); @@ -298,19 +336,47 @@ export function buildLayout(root: HTMLElement): LayoutRefs { s.style.animationDelay = st.delay; } add(dreamOverlay, 'div', 'moon', '🌙'); - add(dreamOverlay, 'div', 'dream-title', 'Luna is dreaming…'); + add(dreamOverlay, 'div', 'dream-title', 'Luna 正在做梦…'); const dreamCaption = add(dreamOverlay, 'div', 'dream-caption', ''); const dreamWakeBtn = doc.createElement('button'); dreamWakeBtn.className = 'wake-btn'; dreamWakeBtn.type = 'button'; - dreamWakeBtn.textContent = '☀️ Wake'; + dreamWakeBtn.textContent = '☀️ 醒来'; dreamOverlay.appendChild(dreamWakeBtn); return { - statusBadge, chatLog, chatHeader: header, input, inputRow, sendBtn, collapseBtn, dreamBtn, modelStage, - moodPip, scrollPill, dreamOverlay, dreamWakeBtn, dreamCaption, - settingsBtn, settingsPanel, settingsBackdrop, ttsToggle, live2dToggle, gazeToggle, idleSelect, - petToggle, serverSettings, avatarTab, avatarRailBtn, affectToggle, livePeakToggle, shortClipsToggle, idleActionsToggle, listeningToggle, speechPerfToggle, - workbenchBtn, costumeToggles, + statusBadge, + chatLog, + chatHeader: header, + input, + inputRow, + sendBtn, + collapseBtn, + dreamBtn, + modelStage, + moodPip, + scrollPill, + dreamOverlay, + dreamWakeBtn, + dreamCaption, + settingsBtn, + settingsPanel, + settingsBackdrop, + ttsToggle, + live2dToggle, + gazeToggle, + idleSelect, + petToggle, + serverSettings, + avatarTab, + avatarRailBtn, + affectToggle, + livePeakToggle, + shortClipsToggle, + idleActionsToggle, + listeningToggle, + speechPerfToggle, + workbenchBtn, + costumeToggles, }; } diff --git a/packages/web/src/ui/mainMenu.ts b/packages/web/src/ui/mainMenu.ts index 202832c..fcf6d3b 100644 --- a/packages/web/src/ui/mainMenu.ts +++ b/packages/web/src/ui/mainMenu.ts @@ -22,18 +22,22 @@ export type MenuItem = { // tab's close button is not ours to duplicate. export function menuItems(opts: { hasQuit: boolean; dreamEnabled: boolean }): MenuItem[] { const items: MenuItem[] = [ - { id: 'talk', label: 'Talk', primary: true }, - { id: 'diary', label: 'Diary', primary: true }, - { id: 'skills', label: 'Skills', primary: true }, - { id: 'dream', label: 'Dream', primary: true, disabled: !opts.dreamEnabled }, - { id: 'settings', label: 'Settings', primary: false }, + { id: 'talk', label: '对话', primary: true }, + { id: 'diary', label: '日记', primary: true }, + { id: 'skills', label: '技能', primary: true }, + { id: 'dream', label: '梦境', primary: true, disabled: !opts.dreamEnabled }, + { id: 'settings', label: '设置', primary: false }, ]; - if (opts.hasQuit) items.push({ id: 'quit', label: 'Quit', primary: false }); + if (opts.hasQuit) items.push({ id: 'quit', label: '退出', primary: false }); return items; } // Arrow-key cycling over the enabled items only — a disabled Dream is skipped, not a dead stop. -export function nextFocusIndex(current: number, delta: 1 | -1, enabled: readonly boolean[]): number { +export function nextFocusIndex( + current: number, + delta: 1 | -1, + enabled: readonly boolean[], +): number { const n = enabled.length; if (n === 0 || !enabled.some(Boolean)) return -1; let i = current; @@ -59,7 +63,9 @@ export function springLinear(k = 190, c = 11, m = 1, samples = 28): string { let x: number; if (zeta < 1) { const wd = omega * Math.sqrt(1 - zeta * zeta); - x = 1 - Math.exp(-zeta * omega * t) * (Math.cos(wd * t) + ((zeta * omega) / wd) * Math.sin(wd * t)); + x = + 1 - + Math.exp(-zeta * omega * t) * (Math.cos(wd * t) + ((zeta * omega) / wd) * Math.sin(wd * t)); } else { // Overdamped/critical: no oscillation, plain exponential approach — never overshoots. x = 1 - Math.exp(-omega * t) * (1 + omega * t); @@ -101,14 +107,17 @@ export function mountMainMenu( const menu = doc.createElement('nav'); menu.className = 'main-menu'; menu.style.setProperty('--spring-ease', springLinear()); - menu.setAttribute('aria-label', 'Main menu'); + menu.setAttribute('aria-label', '主菜单'); const mark = doc.createElement('div'); mark.className = 'menu-mark'; mark.textContent = 'LUNA'; menu.appendChild(mark); - const items = menuItems({ hasQuit: deps.quit !== undefined, dreamEnabled: deps.onDream !== undefined }); + const items = menuItems({ + hasQuit: deps.quit !== undefined, + dreamEnabled: deps.onDream !== undefined, + }); const buttons: HTMLButtonElement[] = []; for (const item of items) { const b = doc.createElement('button'); @@ -172,10 +181,10 @@ export function mountMainMenu( const back = doc.createElement('button'); back.type = 'button'; back.className = 'menu-page-back'; - back.textContent = '← Menu'; + back.textContent = '← 返回'; back.addEventListener('click', returnToMenu); const title = doc.createElement('h2'); - title.textContent = id === 'diary' ? 'Diary' : id === 'skills' ? 'Skills' : 'Settings'; + title.textContent = id === 'diary' ? '日记' : id === 'skills' ? '技能' : '设置'; page.append(back, title); const body = deps.pageBody?.(id) ?? null; if (body) page.appendChild(body); @@ -183,9 +192,11 @@ export function mountMainMenu( const ph = doc.createElement('p'); ph.className = 'menu-page-placeholder'; ph.textContent = - id === 'diary' ? 'Her diary opens here soon.' - : id === 'skills' ? 'Her skills gather here soon.' - : 'Settings assemble here soon.'; + id === 'diary' + ? '她的日记会在这里展开。' + : id === 'skills' + ? '她学会的技能会在这里汇集。' + : '设置会在这里展开。'; page.appendChild(ph); } root.appendChild(page); diff --git a/packages/web/src/ui/modulesConfig.ts b/packages/web/src/ui/modulesConfig.ts index e346e1b..7b7fadb 100644 --- a/packages/web/src/ui/modulesConfig.ts +++ b/packages/web/src/ui/modulesConfig.ts @@ -27,46 +27,46 @@ export type ModuleCard = { export const MODULE_CARDS: readonly ModuleCard[] = [ { id: 'chat', - title: 'Chat LLM', + title: '聊天模型', blurb: '她说话用的脑子', probe: 'chat', fields: [ - { key: 'ANTHROPIC_BASE_URL', label: 'Base URL', placeholder: 'https://…' }, - { key: 'ANTHROPIC_API_KEY', label: 'API key', secret: true, placeholder: 'sk-…' }, - { key: 'LUNA_MODEL', label: 'Model' }, - { key: 'LUNA_MAX_TOKENS', label: 'Max tokens' }, + { key: 'ANTHROPIC_BASE_URL', label: '接口地址', placeholder: 'https://…' }, + { key: 'ANTHROPIC_API_KEY', label: 'API 密钥', secret: true, placeholder: 'sk-…' }, + { key: 'LUNA_MODEL', label: '模型' }, + { key: 'LUNA_MAX_TOKENS', label: '最大令牌数' }, ], }, { id: 'embedding', - title: 'Embedding', + title: '记忆向量', blurb: '她回忆的检索向量', probe: 'embedding', fields: [ - { key: 'LUNA_EMBEDDING_BASE_URL', label: 'Base URL', placeholder: 'https://…' }, - { key: 'LUNA_EMBEDDING_API_KEY', label: 'API key', secret: true, placeholder: 'sk-…' }, - { key: 'LUNA_EMBEDDING_MODEL', label: 'Model' }, + { key: 'LUNA_EMBEDDING_BASE_URL', label: '接口地址', placeholder: 'https://…' }, + { key: 'LUNA_EMBEDDING_API_KEY', label: 'API 密钥', secret: true, placeholder: 'sk-…' }, + { key: 'LUNA_EMBEDDING_MODEL', label: '模型' }, ], }, { id: 'search', - title: 'Web search', + title: '联网搜索', blurb: '她查外面世界的手', probe: 'search', fields: [ - { key: 'LUNA_WEB_SEARCH_PROVIDER', label: 'Provider', placeholder: 'tavily' }, - { key: 'LUNA_WEB_SEARCH_API_KEY', label: 'API key', secret: true, placeholder: 'tvly-…' }, + { key: 'LUNA_WEB_SEARCH_PROVIDER', label: '服务商', placeholder: 'tavily' }, + { key: 'LUNA_WEB_SEARCH_API_KEY', label: 'API 密钥', secret: true, placeholder: 'tvly-…' }, ], }, { id: 'weather', - title: 'Weather', + title: '天气', blurb: '她看窗外的眼睛', probe: 'weather', fields: [ - { key: 'LUNA_WEATHER_PROVIDER', label: 'Provider', placeholder: 'qweather' }, - { key: 'LUNA_WEATHER_API_KEY', label: 'API key', secret: true }, - { key: 'LUNA_WEATHER_API_HOST', label: 'API host', placeholder: 'xxxx.qweatherapi.com' }, + { key: 'LUNA_WEATHER_PROVIDER', label: '服务商', placeholder: 'qweather' }, + { key: 'LUNA_WEATHER_API_KEY', label: 'API 密钥', secret: true }, + { key: 'LUNA_WEATHER_API_HOST', label: 'API 主机', placeholder: 'xxxx.qweatherapi.com' }, ], }, ]; @@ -126,7 +126,11 @@ export function probeFieldsFor( export type ModulesBridges = { prefill?: () => Promise<{ values?: Record; configured?: string[] }>; - probeChat?: (fields: { baseUrl: string; apiKey: string; model: string }) => Promise<{ ok: boolean; error?: string }>; + probeChat?: (fields: { + baseUrl: string; + apiKey: string; + model: string; + }) => Promise<{ ok: boolean; error?: string }>; probeProvider?: ( kind: 'embedding' | 'search' | 'weather', fields: Record, @@ -207,7 +211,7 @@ export function mountModulesSection(doc: Document, bridges: ModulesBridges): HTM const probeBtn = doc.createElement('button'); probeBtn.type = 'button'; probeBtn.className = 'module-btn'; - probeBtn.textContent = 'Probe'; + probeBtn.textContent = '测试'; probeBtn.addEventListener('click', () => { verdict.textContent = '探测中…'; verdict.dataset['state'] = 'busy'; @@ -216,7 +220,7 @@ export function mountModulesSection(doc: Document, bridges: ModulesBridges): HTM card.probe === 'chat' ? bridges.probeChat?.(fields as { baseUrl: string; apiKey: string; model: string }) : bridges.probeProvider?.(card.probe, fields); - void (run ?? Promise.resolve({ ok: false, error: 'no bridge' })).then((v) => { + void (run ?? Promise.resolve({ ok: false, error: '桌面桥接不可用' })).then((v) => { verdict.textContent = v.ok ? '通 ✓' : (v.error ?? '失败'); verdict.dataset['state'] = v.ok ? 'ok' : 'bad'; }); @@ -224,7 +228,7 @@ export function mountModulesSection(doc: Document, bridges: ModulesBridges): HTM const saveBtn = doc.createElement('button'); saveBtn.type = 'button'; saveBtn.className = 'module-btn primary'; - saveBtn.textContent = 'Save'; + saveBtn.textContent = '保存'; saveBtn.addEventListener('click', () => { const cardEdits = new Map([...edits].filter(([k]) => card.fields.some((f) => f.key === k))); const fields = changedFields([card], cardEdits); @@ -256,7 +260,7 @@ export function mountModulesSection(doc: Document, bridges: ModulesBridges): HTM const btn = doc.createElement('button'); btn.type = 'button'; btn.className = 'module-btn primary'; - btn.textContent = 'Restart Luna'; + btn.textContent = '重启 Luna'; btn.addEventListener('click', () => bridges.relaunch?.()); restartRow.appendChild(btn); } else { diff --git a/packages/web/src/ui/mood.ts b/packages/web/src/ui/mood.ts index 5c4b2a7..ccaf296 100644 --- a/packages/web/src/ui/mood.ts +++ b/packages/web/src/ui/mood.ts @@ -2,21 +2,21 @@ import type { ExpressionKey } from '@luna/protocol'; // The mood pip's affect → emoji + short label (the 15 ExpressionKeys). export const MOOD: Record = { - curious_attention: { emoji: '👀', label: 'Curious' }, - gentle_concern: { emoji: '🥺', label: 'Concerned' }, - open_reengagement: { emoji: '🙂', label: 'Receptive' }, - playful_brightness: { emoji: '😜', label: 'Playful' }, - focused_engagement: { emoji: '🧐', label: 'Focused' }, - steady_presence: { emoji: '😌', label: 'Calm' }, - soft_warmth: { emoji: '🥰', label: 'Tender' }, - listening_attention: { emoji: '👂', label: 'Listening' }, - alert_surprise: { emoji: '😮', label: 'Surprised' }, - bright_delight: { emoji: '✨', label: 'Delighted' }, - amused_smirk: { emoji: '😏', label: 'Amused' }, - shy_softness: { emoji: '😳', label: 'Shy' }, - awkward_lightness: { emoji: '😅', label: 'Awkward' }, - guarded_distance: { emoji: '😐', label: 'Guarded' }, - annoyed_resistance: { emoji: '😤', label: 'Annoyed' }, + curious_attention: { emoji: '👀', label: '好奇' }, + gentle_concern: { emoji: '🥺', label: '担心' }, + open_reengagement: { emoji: '🙂', label: '接纳' }, + playful_brightness: { emoji: '😜', label: '调皮' }, + focused_engagement: { emoji: '🧐', label: '专注' }, + steady_presence: { emoji: '😌', label: '平静' }, + soft_warmth: { emoji: '🥰', label: '温柔' }, + listening_attention: { emoji: '👂', label: '倾听' }, + alert_surprise: { emoji: '😮', label: '惊讶' }, + bright_delight: { emoji: '✨', label: '开心' }, + amused_smirk: { emoji: '😏', label: '偷笑' }, + shy_softness: { emoji: '😳', label: '害羞' }, + awkward_lightness: { emoji: '😅', label: '尴尬' }, + guarded_distance: { emoji: '😐', label: '戒备' }, + annoyed_resistance: { emoji: '😤', label: '不耐烦' }, }; export function moodOf(key: ExpressionKey): { emoji: string; label: string } { diff --git a/packages/web/src/ui/packDrop.ts b/packages/web/src/ui/packDrop.ts index 35a1ddf..1c4949f 100644 --- a/packages/web/src/ui/packDrop.ts +++ b/packages/web/src/ui/packDrop.ts @@ -3,8 +3,18 @@ // Desktop-only (needs the lunaSetup scan/install bridges); ambiguous packs (multiple candidate // weights) are routed to the wizard where the full picker lives. -export type PackScan = { gpt: string[]; sovits: string[]; refWavs: string[]; transcripts: string[] }; -export type PackPicks = { gptCkpt: string; sovitsPth: string; referenceWav: string; transcriptTxt?: string }; +export type PackScan = { + gpt: string[]; + sovits: string[]; + refWavs: string[]; + transcripts: string[]; +}; +export type PackPicks = { + gptCkpt: string; + sovitsPth: string; + referenceWav: string; + transcriptTxt?: string; +}; // Single-candidate packs auto-pick (the bilibili pack shape); anything ambiguous → null (use the wizard). export function autoPicksFrom(scan: PackScan): PackPicks | null { @@ -18,10 +28,10 @@ export function autoPicksFrom(scan: PackScan): PackPicks | null { } export function swapResultText(r: Record): string { - if (r['ok'] !== true) return typeof r['error'] === 'string' ? r['error'] : 'Voice install failed'; - if (r['managed'] === true && r['ready'] === true) return '✓ 音色已切换 · Voice swapped'; - if (r['managed'] === true) return '已安装 — 语音服务就绪后自动应用 · Installed, applies when the runtime is ready'; - return '已安装 — 请手动重启你的语音服务 · Installed — restart your voice server'; + if (r['ok'] !== true) return typeof r['error'] === 'string' ? r['error'] : '音色安装失败'; + if (r['managed'] === true && r['ready'] === true) return '✓ 音色已切换'; + if (r['managed'] === true) return '已安装 — 语音服务就绪后自动应用'; + return '已安装 — 请手动重启语音服务'; } export type PackDropBridge = { @@ -58,26 +68,26 @@ export function mountPackDrop(doc: Document, bridge: PackDropBridge): () => void ev.preventDefault(); void bridge.scanVoicePack(file).then((r) => { if (r['ok'] !== true || typeof r['root'] !== 'string') { - flash(typeof r['error'] === 'string' ? r['error'] : '不是音色包 · Not a voice pack'); + flash(typeof r['error'] === 'string' ? r['error'] : '不是有效的音色包'); return; } const root = r['root']; const scan = r['scan'] as PackScan | undefined; const picks = scan ? autoPicksFrom(scan) : null; if (!picks) { - flash('候选不止一个——请到设置向导里安装 · Ambiguous pack — install it from the setup wizard'); + flash('候选文件不止一个——请到设置向导里安装'); return; } const preview = typeof r['transcriptPreview'] === 'string' ? r['transcriptPreview'] : ''; show((el) => { const label = doc.createElement('span'); - label.textContent = `换成这个音色包?· Swap voice to “${root.split('/').pop() ?? root}”?`; + label.textContent = `要换成这个音色包吗?·「${root.split('/').pop() ?? root}」`; const apply = doc.createElement('button'); apply.type = 'button'; - apply.textContent = '应用 · Apply'; + apply.textContent = '应用'; apply.addEventListener('click', () => { apply.disabled = true; - label.textContent = '安装中… · Installing…'; + label.textContent = '安装中…'; void bridge .installVoicePack({ root, @@ -93,7 +103,7 @@ export function mountPackDrop(doc: Document, bridge: PackDropBridge): () => void }); const cancel = doc.createElement('button'); cancel.type = 'button'; - cancel.textContent = '取消 · Cancel'; + cancel.textContent = '取消'; cancel.addEventListener('click', dismiss); el.append(label, apply, cancel); }); diff --git a/packages/web/src/ui/personaEditor.ts b/packages/web/src/ui/personaEditor.ts index 9b0de26..9a2318d 100644 --- a/packages/web/src/ui/personaEditor.ts +++ b/packages/web/src/ui/personaEditor.ts @@ -32,7 +32,8 @@ export function mountPersonaSection(doc: Document, fetchFn: typeof fetch = fetch const intro = doc.createElement('p'); intro.className = 'settings-page-note'; - intro.textContent = 'Fixed 是你定的底色,她改不了;Evolving 是她自己长出来的,你看,但在这里不改。'; + intro.textContent = + '固定核心是你定的底色,她改不了;成长部分是她自己长出来的,你可以看,但不在这里修改。'; host.appendChild(intro); let savedFixed = ''; @@ -53,15 +54,15 @@ export function mountPersonaSection(doc: Document, fetchFn: typeof fetch = fetch const previewBtn = doc.createElement('button'); previewBtn.type = 'button'; previewBtn.className = 'module-btn'; - previewBtn.textContent = 'Preview diff'; + previewBtn.textContent = '预览改动'; const saveBtn = doc.createElement('button'); saveBtn.type = 'button'; saveBtn.className = 'module-btn primary'; - saveBtn.textContent = 'Save fixed core'; + saveBtn.textContent = '保存固定核心'; foot.append(previewBtn, saveBtn, verdict); const evolvingHead = doc.createElement('h4'); - evolvingHead.textContent = 'Evolving — 她自己长的'; + evolvingHead.textContent = '成长部分 — 她自己长出来的'; const evolving = doc.createElement('pre'); evolving.className = 'persona-evolving'; evolving.textContent = '…'; @@ -72,7 +73,8 @@ export function mountPersonaSection(doc: Document, fetchFn: typeof fetch = fetch for (const line of diff) { const p = doc.createElement('p'); p.className = `diff-${line.kind}`; - p.textContent = (line.kind === 'added' ? '+ ' : line.kind === 'removed' ? '− ' : ' ') + line.text; + p.textContent = + (line.kind === 'added' ? '+ ' : line.kind === 'removed' ? '− ' : ' ') + line.text; diffView.appendChild(p); } diffView.hidden = false; diff --git a/packages/web/src/ui/reconfigure.ts b/packages/web/src/ui/reconfigure.ts index 0e13614..7d877e7 100644 --- a/packages/web/src/ui/reconfigure.ts +++ b/packages/web/src/ui/reconfigure.ts @@ -19,8 +19,8 @@ export function mountReconfigureButton( const btn = doc.createElement('button'); btn.type = 'button'; btn.className = 'reconfigure-btn'; - btn.textContent = '⚙ 重新配置 / Setup'; - btn.title = 'Open the setup wizard (fix keys, model, voice)'; + btn.textContent = '⚙ 重新配置'; + btn.title = '打开配置向导,修改密钥、模型或声音'; btn.style.display = 'none'; btn.addEventListener('click', () => openSetup()); badge.after(btn); diff --git a/packages/web/src/ui/settingsPage.ts b/packages/web/src/ui/settingsPage.ts index 5d855cd..a691fde 100644 --- a/packages/web/src/ui/settingsPage.ts +++ b/packages/web/src/ui/settingsPage.ts @@ -12,26 +12,20 @@ import { mountPersonaSection } from './personaEditor'; // page never mounts and the old panel keeps its rows, untouched. export type SettingsCategoryId = - | 'voice' - | 'expression' - | 'appearance' - | 'behaviour' - | 'persona' - | 'modules' - | 'system'; + 'voice' | 'expression' | 'appearance' | 'behaviour' | 'persona' | 'modules' | 'system'; export type SettingsCategory = { id: SettingsCategoryId; label: string; blurb: string }; export const SETTINGS_CATEGORIES: readonly SettingsCategory[] = [ - { id: 'voice', label: 'Voice', blurb: '她的声音' }, - { id: 'expression', label: 'Expression & Motion', blurb: '她的表情与动作' }, - { id: 'appearance', label: 'Appearance', blurb: '她的样子与这间屋子' }, - { id: 'behaviour', label: 'Behaviour', blurb: '她自己的行为' }, + { id: 'voice', label: '声音', blurb: '她的声音' }, + { id: 'expression', label: '表情与动作', blurb: '她的表情与动作' }, + { id: 'appearance', label: '外观', blurb: '她的样子与这间屋子' }, + { id: 'behaviour', label: '行为', blurb: '她自己的行为' }, // v0.44.6: persona is its own category (it is ABOUT her, not about widgets), and the four module // cards get their own too — four cards under System would have buried both. - { id: 'persona', label: 'Persona', blurb: '她是谁' }, - { id: 'modules', label: 'Modules', blurb: '接进来的能力' }, - { id: 'system', label: 'System', blurb: '底层与工具' }, + { id: 'persona', label: '人格', blurb: '她是谁' }, + { id: 'modules', label: '能力模块', blurb: '接进来的能力' }, + { id: 'system', label: '系统', blurb: '底层与工具' }, ]; // The reconciliation artifact (the version's core risk is losing a switch in the move): every @@ -118,7 +112,11 @@ export function mountSettingsPage(doc: Document, refs: LayoutRefs): HTMLElement const body = (await r.json().catch(() => null)) as { backend?: { state?: string } } | null; const state = body?.backend?.state ?? (r.ok ? 'ready' : 'down'); health.textContent = - state === 'ready' ? '声音服务:在跑 ✓' : state === 'starting' || state === 'restarting' ? '声音服务:正在启动…' : '声音服务:没有在跑'; + state === 'ready' + ? '声音服务:在跑 ✓' + : state === 'starting' || state === 'restarting' + ? '声音服务:正在启动…' + : '声音服务:没有在跑'; }) .catch(() => { health.textContent = '声音服务:没有在跑'; @@ -147,7 +145,8 @@ export function mountSettingsPage(doc: Document, refs: LayoutRefs): HTMLElement // surfacing them is its own decision, not a side effect of moving furniture. ── const note = doc.createElement('p'); note.className = 'settings-page-note'; - note.textContent = '她的主动行为(何时来找你、多久说一次)暂时还住在配置文件里——搬进这里是之后的一版。'; + note.textContent = + '她的主动行为(何时来找你、多久说一次)暂时还住在配置文件里——搬进这里是之后的一版。'; sections.get('behaviour')?.appendChild(note); // ── Persona (v0.44.6) — the soul endpoints; the self-edit firewall lives in the tool layer. ── diff --git a/packages/web/src/ui/settingsView.ts b/packages/web/src/ui/settingsView.ts index 5fa6a6c..5977de3 100644 --- a/packages/web/src/ui/settingsView.ts +++ b/packages/web/src/ui/settingsView.ts @@ -100,8 +100,8 @@ export function renderServerSettings( if (s.restart_required) { const badge = doc.createElement('span'); badge.className = 'setting-badge'; - badge.textContent = 'restart'; - badge.title = 'Takes effect after Luna restarts'; + badge.textContent = '需重启'; + badge.title = '重启 Luna 后生效'; name.appendChild(badge); } const right = doc.createElement('span'); @@ -111,7 +111,7 @@ export function renderServerSettings( reset.type = 'button'; reset.className = 'setting-reset'; reset.textContent = '↺'; - reset.title = 'Reset to default'; + reset.title = '恢复默认值'; reset.addEventListener('click', (e) => { e.preventDefault(); // inside a