dsh-plugin-vrmpet3d 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/setup-page.js CHANGED
@@ -23,6 +23,176 @@
23
23
  var core = globalThis.__VRMPET3D_CORE__;
24
24
  if (!core) return;
25
25
 
26
+ /**
27
+ * This page's own words, in the two languages — the shape every surface here uses (`lib/menu.js`'s `LABELS`,
28
+ * `lib/model-picker.js`'s `TEXT`): `{ zh: {...}, en: {...} }`, one table per surface rather than one dictionary
29
+ * for the whole plugin, and the SAME KEYS in both halves (a suite asserts that).
30
+ *
31
+ * The markup carries its Chinese inline as the no-JS fallback and names the key that replaces it
32
+ * (`core.applyI18n`); the entries below are what this script writes at run time, read through `t()` at CALL time
33
+ * and never captured in a module-level variable — a captured string would freeze the first language the page
34
+ * happened to boot in. `{...}` tokens are filled from `t`'s second argument, which is also how the core's own
35
+ * status lines name their clock.
36
+ *
37
+ * What is NOT here: data (provider, model and file names, folder paths, counts — they are translated around,
38
+ * never translated), and the 碎碎念 content, which belongs to `core.js` and already ships in both languages.
39
+ */
40
+ var TEXT = {
41
+ zh: {
42
+ // The chrome, and the stepper the script reads its own words back from.
43
+ pageTitle: 'vrmpet3d · 初次安装引导',
44
+ guideTitle: '初次安装引导',
45
+ guideDoneTitle: '配置完成',
46
+ reading: '正在读取…',
47
+ stepsAria: '引导进度',
48
+ stepWelcome: '欢迎',
49
+ stepModel: '偏好模型',
50
+ stepSurface: '桌宠界面',
51
+ stepSpeech: 'AI 碎碎念',
52
+ stepMotions: '模型动作',
53
+ stepDone: '完成',
54
+ // 界面语言 — the one control the welcome screen adds, and the only choice applied before 完成.
55
+ language: '界面语言',
56
+ languageAuto: '自动',
57
+ languageZh: '中文',
58
+ languageEn: 'English',
59
+ // ① 欢迎
60
+ welcomeTitle: '欢迎,也先说清楚风险',
61
+ welcomeRisk: '这是一个<strong>早期版本</strong>的插件,作者也是第一次开发 DSH 插件。它<strong>还没有经过系统性的验证</strong>,可能存在 bug,也可能带来性能下降;设置页面的排版也可能比较混乱。',
62
+ welcomeWarning: '如果你在使用中遇到任何性能问题,请<strong>第一时间禁用这个插件</strong>,再慢慢排查。',
63
+ // ③ 桌宠界面, including the runtime folder the launcher is about to create.
64
+ surfaceTitle: '桌宠出现在哪里',
65
+ surfaceAuto: '自动',
66
+ surfaceAutoNote: 'Desktop端自动开启独立桌宠',
67
+ surfaceApp: '只在内置桌宠',
68
+ runtimeBorrow: '<strong>浮窗会借用一份 DSH 自带的运行时。</strong>独立浮窗需要无边框、置顶、点击穿透的系统窗口,这只有 Electron 能给,而插件不自带(那会多出两百多兆)。所以启用浮窗时,启动器会在 <strong>DSH 安装目录所在的分区</strong>里建一个运行时目录,用 <strong>硬链接</strong>指向 DSH 自己的那批客户端文件 —— 于是它看起来就是一份没改动过的 DSH。',
69
+ runtimeLinks: '<strong>硬链接与 DSH 共享同一份数据,不额外占盘</strong>(新增的只有一个小 app 壳),并且它<strong>必须与 DSH 同盘</strong>:硬链接不能跨卷。删掉这个目录是安全的,下次启用浮窗会重新建一份。',
70
+ runtimeDir: '这次用的运行时目录:{dir}',
71
+ // ④ AI 碎碎念
72
+ speechTitle: 'AI 碎碎念',
73
+ speechEnabled: '开启碎碎念',
74
+ speechBuiltin: '内置台词',
75
+ speechBuiltinNote: '内置台词基本已经够用',
76
+ speechAi: 'AI 生成',
77
+ speechProvider: 'AI 来源',
78
+ speechAiNote: '人格信息和不同状态的提示词,都在 通用设置 → 碎碎念 里设置。',
79
+ speechDisclaimerA: '<strong>碎碎念只是玩笑,请勿当真。</strong>这些话要么来自内置台词,要么由你配置的模型即兴生成,都属于角色扮演:不是宠物的真实判断,也不构成任何建议。',
80
+ speechDisclaimerB: '插件<strong>不会对你的项目或文件做任何实际操作</strong>(不执行命令、不改动代码),只读取会话的<strong>运行状态</strong>(进行中 / 等待确认 / 完成 等)来决定表情和动作,会话文字、工具参数与附件都不会被它读取;你导入的模型或动作,只会被复制到插件自己的目录里。<strong>它也不会干扰 DSH 本身的运行</strong>,且不带任何运行时依赖。不过在配置较差的机器上,3D 渲染与弹簧骨物理可能带来轻微的性能影响 —— 关掉桌宠,或把尺寸调小一些,就能缓解。',
81
+ noProviders: '没有可用的 provider。',
82
+ // ⑤ 模型动作
83
+ motionsTitle: '模型动作',
84
+ motionsPick: '选择动作文件夹…',
85
+ motionsManual: '手动输入路径',
86
+ motionsPath: '文件夹路径',
87
+ motionsAdd: '导入',
88
+ motionsImportedCount: '已导入 {count} 个动作文件夹',
89
+ motionsImportedRow: '已导入',
90
+ motionsClips: '{name}({clips} 支)',
91
+ motionsRemove: '移除',
92
+ motionDefaultsKnown: '各状态的默认动作:{list}。这些默认值可以在通用设置 → 状态与动作里改。',
93
+ motionDefaultsNone: '动作包已按工作状态分好目录({count} 支):每个状态的选择框里只会列出它自己目录里的动作。当前这版还没有预置状态→动作的默认映射,所以你需要在通用设置 → 状态与动作里为状态勾选动作。',
94
+ motionImporting: '正在导入动作…',
95
+ motionImportedName: '已导入动作文件夹:{name}({clips} 支)',
96
+ motionImported: '已导入动作。',
97
+ motionImportFailed: '导入失败:{error}',
98
+ motionRemoved: '已移除插件复制的那一份。',
99
+ motionRemoveFailed: '移除失败:{error}',
100
+ // The list separators `motionDefaultsKnown` is assembled with — prose punctuation, so it travels with the words.
101
+ sepList: '、',
102
+ sepClause: ';',
103
+ // 完成, and the footer.
104
+ doneTitle: '完成',
105
+ doneSaved: '配置已保存。',
106
+ finishButton: '完成',
107
+ closeWindow: '关闭窗口',
108
+ later: '稍后配置',
109
+ back: '上一步',
110
+ next: '下一步',
111
+ // Status lines this script writes (the picker's own status is the component's, not this table's).
112
+ hostNoStore: '这个宿主没有提供设置接口,改动只在本页生效。',
113
+ coreNoStore: '这个版本的 core 没有设置接口,页面只能展示默认值。',
114
+ pickerMissing: '这个页面没有加载模型选择组件(model-picker.js)。',
115
+ noNativePicker: '这个窗口没有原生选择器(不是在 DSH 桌面版里打开的),请在下面直接输入路径。',
116
+ readSettings: '已读取当前设置。',
117
+ savedNoGuide: '已保存:这个引导不会再出现了。',
118
+ setupComplete: '配置完成。',
119
+ saveFailed: '保存失败:{error}',
120
+ skipped: '已跳过:下次插件启动时会再提醒你一次。',
121
+ },
122
+ en: {
123
+ pageTitle: 'vrmpet3d · First-run guide',
124
+ guideTitle: 'First-run guide',
125
+ guideDoneTitle: 'Setup complete',
126
+ reading: 'Reading…',
127
+ stepsAria: 'Guide progress',
128
+ stepWelcome: 'Welcome',
129
+ stepModel: 'Preferred model',
130
+ stepSurface: 'Pet surface',
131
+ stepSpeech: 'AI chit-chat',
132
+ stepMotions: 'Model motions',
133
+ stepDone: 'Done',
134
+ language: 'Interface language',
135
+ languageAuto: 'Auto',
136
+ languageZh: '中文',
137
+ languageEn: 'English',
138
+ welcomeTitle: 'Welcome — and the risks first',
139
+ welcomeRisk: 'This is an <strong>early build</strong> of the plugin, and its author is writing a DSH plugin for the first time. It has <strong>not been through systematic testing</strong> yet: it may have bugs and may cost you performance, and the settings pages may be laid out awkwardly.',
140
+ welcomeWarning: 'If you run into any performance problem while using it, <strong>disable the plugin first</strong> and then work out what happened.',
141
+ surfaceTitle: 'Where the pet appears',
142
+ surfaceAuto: 'Automatic',
143
+ surfaceAutoNote: 'On DSH Desktop the floating pet opens by itself',
144
+ surfaceApp: 'In-app pet only',
145
+ runtimeBorrow: '<strong>The floating pet borrows a runtime that ships with DSH.</strong> An independent floating window needs a borderless, always-on-top, click-through system window, and only Electron can provide one — which this plugin does not ship (that would add a couple of hundred megabytes). So when the floating pet is enabled, the launcher creates a runtime folder <strong>on the same volume as the DSH install</strong> and <strong>hard-links</strong> it to DSH\'s own client files, which is why it looks like an untouched copy of DSH.',
146
+ runtimeLinks: '<strong>Hard links share DSH\'s own data and take no extra disk space</strong> (the only new thing is a small app shell), and the folder <strong>has to be on the same volume as DSH</strong>: hard links cannot cross volumes. Deleting the folder is safe — the next time the floating pet is enabled, it is created again.',
147
+ runtimeDir: 'Runtime folder in use: {dir}',
148
+ speechTitle: 'AI chit-chat',
149
+ speechEnabled: 'Turn chit-chat on',
150
+ speechBuiltin: 'Built-in lines',
151
+ speechBuiltinNote: 'The built-in lines are usually enough',
152
+ speechAi: 'Generated by AI',
153
+ speechProvider: 'AI source',
154
+ speechAiNote: 'The persona and the per-state prompts live in General settings → Chit-chat.',
155
+ speechDisclaimerA: '<strong>The chit-chat is a joke — do not take it seriously.</strong> The lines either come from the built-in script or are improvised by the model you configured; either way it is role-play, not the pet\'s real judgement, and not advice of any kind.',
156
+ speechDisclaimerB: 'The plugin <strong>takes no real action on your project or files</strong> (it runs no commands and changes no code). It reads only the session\'s <strong>run state</strong> (working / waiting for confirmation / done, and so on) to choose expressions and motions — session text, tool arguments and attachments are never read, and the models or motions you import are only copied into the plugin\'s own folder. <strong>It does not interfere with DSH itself</strong> and has no runtime dependencies. On a weak machine, though, the 3D rendering and spring-bone physics can cost a little performance — turning the pet off or making it smaller eases that.',
157
+ noProviders: 'No provider is available.',
158
+ motionsTitle: 'Model motions',
159
+ motionsPick: 'Choose a motions folder…',
160
+ motionsManual: 'Type a path',
161
+ motionsPath: 'Folder path',
162
+ motionsAdd: 'Import',
163
+ motionsImportedCount: '{count} motion folders imported',
164
+ motionsImportedRow: 'Imported',
165
+ motionsClips: '{name} ({clips} clips)',
166
+ motionsRemove: 'Remove',
167
+ motionDefaultsKnown: 'Default motions by state: {list}. They can be changed in General settings → States & motions.',
168
+ motionDefaultsNone: 'The pack is already split into per-state folders ({count} clips): a state\'s picker only lists the clips in its own folder. This build ships no state→motion defaults yet, so you need to tick motions per state in General settings → States & motions.',
169
+ motionImporting: 'Importing motions…',
170
+ motionImportedName: 'Imported the motions folder {name} ({clips} clips)',
171
+ motionImported: 'Motions imported.',
172
+ motionImportFailed: 'Import failed: {error}',
173
+ motionRemoved: 'Removed the plugin\'s own copy.',
174
+ motionRemoveFailed: 'Could not remove it: {error}',
175
+ sepList: ', ',
176
+ sepClause: '; ',
177
+ doneTitle: 'Done',
178
+ doneSaved: 'Your settings are saved.',
179
+ finishButton: 'Finish',
180
+ closeWindow: 'Close window',
181
+ later: 'Configure later',
182
+ back: 'Back',
183
+ next: 'Next',
184
+ hostNoStore: 'This host offers no settings API — a change applies to this page only.',
185
+ coreNoStore: 'This build of core offers no settings API; the page can only show the defaults.',
186
+ pickerMissing: 'This page did not load the model picker (model-picker.js).',
187
+ noNativePicker: 'This window has no native picker (it was not opened by DSH Desktop) — type the path below instead.',
188
+ readSettings: 'Read the current settings.',
189
+ savedNoGuide: 'Saved: this guide will not appear again.',
190
+ setupComplete: 'Setup complete.',
191
+ saveFailed: 'Could not save: {error}',
192
+ skipped: 'Skipped: the guide will ask once more the next time the plugin starts.',
193
+ },
194
+ };
195
+
26
196
  /**
27
197
  * Every screen the markup carries — INCLUDING the ones this run may not visit.
28
198
  *
@@ -36,9 +206,35 @@
36
206
  var hasSnapshot = false;
37
207
  var preferences = core.cleanPreferences({});
38
208
  var hostSync = null;
209
+ /**
210
+ * The language this page is drawn in RIGHT NOW: the stored answer resolved against the page's own environment.
211
+ *
212
+ * It starts as the markup's own language and is replaced by `applyLanguage` before anything is drawn. Every string
213
+ * is read through `t()` against this value, so a language change has no captured copy to go stale.
214
+ */
215
+ var language = 'zh';
216
+ /**
217
+ * A 界面语言 pick made BEFORE the first snapshot — the one case where "local first, write when allowed" is not
218
+ * enough on its own.
219
+ *
220
+ * `patchPreferences` refuses to write before the snapshot (the data-loss latch, see the header), and this page's
221
+ * language control sits on the FIRST screen: a reader who cannot read the page reaches it in the first second,
222
+ * well before the store has answered. The pick is applied locally either way, and this remembers it so the first
223
+ * snapshot can write it rather than putting the stored answer back over it.
224
+ */
225
+ var pendingLanguage = null;
39
226
  /** The imported lists, as the host reports them: what to show and what a 移除 acts on. */
40
227
  var importedModels = [];
41
228
  var importedMotions = [];
229
+ /**
230
+ * The last `/vrmpet3d/motions` payload, kept so a language change can re-say the defaults line.
231
+ *
232
+ * That line is built from the pack's own data (`renderMotionDefaults`) and the route is fetched once at boot;
233
+ * caching the payload is what makes it re-renderable without asking the host a second time. `null` until the
234
+ * first reply, and `renderMotionDefaults` is skipped while it is — the line's "no data" branch would otherwise
235
+ * claim an empty pack on every boot.
236
+ */
237
+ var motionBody = null;
42
238
  /** The shared model picker the ② 偏好模型 step mounts (`lib/model-picker.js`). */
43
239
  var picker = null;
44
240
  /** Which model a surface reports it is showing, from the snapshot's `live` — what ends the progress bar. */
@@ -48,6 +244,43 @@
48
244
 
49
245
  function el(id) { return document.getElementById(id); }
50
246
 
247
+ /** One of this page's own strings, in the language it is drawn in right now; `{name}` tokens are filled in. */
248
+ function t(key, values) {
249
+ var out = core.textOf(TEXT, language, key);
250
+ if (!values) return out;
251
+ Object.keys(values).forEach(function (name) {
252
+ out = out.split('{' + name + '}').join(String(values[name]));
253
+ });
254
+ return out;
255
+ }
256
+
257
+ /**
258
+ * The status line's own last state, so a LANGUAGE CHANGE can say it again in the new language.
259
+ *
260
+ * `core.applyI18n` walks the whole document, and without this it would put the shipped 「正在读取…」 back over a
261
+ * line this page has since replaced ("已导入动作文件夹:…", "保存失败:…"). Every line this page writes comes from a
262
+ * table KEY, which is what makes re-saying it possible; the shared picker's own status is the exception (those
263
+ * words are the component's, and arrive in the component's language), so it passes through as raw text.
264
+ */
265
+ var statusLine = { key: 'reading', values: null, kind: '', text: '' };
266
+
267
+ /** Say one of this page's own strings — and remember the key, so a language change says it again. */
268
+ function say(key, values, kind) {
269
+ statusLine = { key: key, values: values || null, kind: kind || '', text: '' };
270
+ setStatus(t(key, values), kind);
271
+ }
272
+
273
+ /** Say a line whose words are NOT this table's (the shared picker's own status). */
274
+ function noteStatus(text, kind) {
275
+ statusLine = { key: '', values: null, kind: kind || '', text: text || '' };
276
+ setStatus(text, kind);
277
+ }
278
+
279
+ /** The status line again, in the current language — what `applyLanguage` calls after the markup table. */
280
+ function restateStatus() {
281
+ setStatus(statusLine.key ? t(statusLine.key, statusLine.values) : statusLine.text, statusLine.kind);
282
+ }
283
+
51
284
  function setStatus(text, kind) {
52
285
  var node = el('status');
53
286
  if (!node) return;
@@ -156,9 +389,11 @@
156
389
  var back = el('setup-back');
157
390
  var next = el('setup-next');
158
391
  if (back) back.disabled = current === 0;
159
- if (next) next.textContent = current === last ? '完成' : '下一步';
392
+ // Both of these are re-stated from the table rather than written once: `goto` is what `applyLanguage` calls, so
393
+ // this is the function that has to know the language as it is NOW.
394
+ if (next) next.textContent = t(current === last ? 'finishButton' : 'next');
160
395
  var header = document.querySelector('.page-header h1');
161
- if (header) header.textContent = current === last ? '配置完成' : '初次安装引导';
396
+ if (header) header.textContent = t(current === last ? 'guideDoneTitle' : 'guideTitle');
162
397
  // The finish screen's own button is the one that WRITES; the footer says the same thing for a user who
163
398
  // navigates by keyboard.
164
399
  if (next) next.hidden = false;
@@ -175,7 +410,7 @@
175
410
  function patchPreferences(patch) {
176
411
  Object.keys(patch).forEach(function (key) { preferences[key] = patch[key]; });
177
412
  if (!hasSnapshot || !hostSync || typeof hostSync.push !== 'function') {
178
- if (!hostSync) setStatus('这个宿主没有提供设置接口,改动只在本页生效。', 'warn');
413
+ if (!hostSync) say('hostNoStore', null, 'warn');
179
414
  return;
180
415
  }
181
416
  hostSync.push({ preferences: patch });
@@ -214,12 +449,14 @@
214
449
  var mount = el('setup-model-picker');
215
450
  var api = globalThis.__VRMPET3D_MODEL_PICKER__;
216
451
  if (!mount || !api || typeof api.createModelPicker !== 'function') {
217
- setStatus('这个页面没有加载模型选择组件(model-picker.js)。', 'warn');
452
+ say('pickerMissing', null, 'warn');
218
453
  return null;
219
454
  }
220
455
  picker = api.createModelPicker({
221
456
  mount: mount,
222
- language: 'zh',
457
+ // The component is BUILT in this language; `applyLanguage` hands it a later change through `setLanguage`
458
+ // (which relabels the same nodes and keeps the catalog and any switch in flight).
459
+ language: language,
223
460
  currentModelId: function () { return preferences.modelId || ''; },
224
461
  /**
225
462
  * Which model a pet reports it is SHOWING (`snapshot.live`), so the progress bar is about the LOAD rather
@@ -229,7 +466,8 @@
229
466
  // Choosing a model in a guide called 「偏好模型」 and then not choosing it would be a strange half-step:
230
467
  // the component calls this for a CLICK and after an import, and both mean "this is my model now".
231
468
  onSwitch: function (id) { patchPreferences({ modelId: id }); },
232
- onStatus: setStatus,
469
+ // The component's own lines, in the component's own language (see `statusLine`).
470
+ onStatus: noteStatus,
233
471
  });
234
472
  return picker;
235
473
  }
@@ -283,7 +521,8 @@
283
521
  .then(function (response) { return response.ok ? response.json() : null; })
284
522
  .then(function (status) {
285
523
  var dir = status && typeof status.runtimeDir === 'string' ? status.runtimeDir : '';
286
- line.textContent = dir ? '这次用的运行时目录:' + dir : '';
524
+ // The path is data; the sentence around it is this table's, and is read at THIS moment.
525
+ line.textContent = dir ? t('runtimeDir', { dir: dir }) : '';
287
526
  })
288
527
  .catch(function () { line.textContent = ''; });
289
528
  }
@@ -359,9 +598,7 @@
359
598
  if (note) {
360
599
  var available = providerList().length;
361
600
  // The count is data; the sentence around it was explanation, so only a real problem is stated.
362
- note.textContent = speech.source === 'ai' && !available
363
- ? '没有可用的 provider。'
364
- : '';
601
+ note.textContent = speech.source === 'ai' && !available ? t('noProviders') : '';
365
602
  }
366
603
  }
367
604
 
@@ -404,15 +641,16 @@
404
641
  var list = el('setup-motion-list');
405
642
  var note = el('setup-motion-note');
406
643
  var count = el('setup-motion-count');
407
- if (count) count.textContent = importedMotions.length ? '已导入 ' + importedMotions.length + ' 个动作文件夹' : '';
644
+ if (count) count.textContent = importedMotions.length ? t('motionsImportedCount', { count: importedMotions.length }) : '';
408
645
  if (list) {
409
646
  clear(list);
410
647
  importedMotions.forEach(function (entry) {
411
648
  var row = document.createElement('div');
412
649
  row.className = 'row';
413
- row.appendChild(text('span', 'k', '已导入'));
414
- row.appendChild(text('span', 'grow note', entry.name + '(' + entry.clips + ' 支)')).title = entry.source || entry.dir;
415
- var remove = text('button', 'ghost', '移除');
650
+ row.appendChild(text('span', 'k', t('motionsImportedRow')));
651
+ // The name and the clip count are the host's data; only the words around them are translated.
652
+ row.appendChild(text('span', 'grow note', t('motionsClips', { name: entry.name, clips: entry.clips }))).title = entry.source || entry.dir;
653
+ var remove = text('button', 'ghost', t('motionsRemove'));
416
654
  remove.addEventListener('click', function () { removeMotion(entry.dir); });
417
655
  row.appendChild(remove);
418
656
  list.appendChild(row);
@@ -426,6 +664,8 @@
426
664
  function refreshMotions() {
427
665
  return getJson('/vrmpet3d/motions').then(function (body) {
428
666
  importedMotions = body && Array.isArray(body.importedMotions) ? body.importedMotions : [];
667
+ // Kept for `applyLanguage`: this route is fetched once, and the defaults line below is built from its payload.
668
+ motionBody = body || null;
429
669
  renderMotions();
430
670
  renderMotionDefaults(body);
431
671
  return body;
@@ -448,26 +688,33 @@
448
688
  names.forEach(function (entry) { byId[entry.id] = entry.name; });
449
689
  var states = (core.MOTION_STATES || []).filter(function (state) { return (defaults[state] || []).length; });
450
690
  if (states.length) {
451
- node.textContent = '各状态的默认动作:' + states.map(function (state) {
452
- return state + ' → ' + (defaults[state] || []).map(function (id) { return byId[id] || id; }).join('、');
453
- }).join(';') + '。这些默认值可以在通用设置 → 状态与动作里改。';
691
+ // The states and the clips are the pack's own names; the sentence and its list punctuation are this table's.
692
+ node.textContent = t('motionDefaultsKnown', {
693
+ list: states.map(function (state) {
694
+ return state + ' → ' + (defaults[state] || []).map(function (id) { return byId[id] || id; }).join(t('sepList'));
695
+ }).join(t('sepClause')),
696
+ });
454
697
  return;
455
698
  }
456
- node.textContent = '动作包已按工作状态分好目录(' + names.length + ' 支):每个状态的选择框里只会列出它自己目录里的动作。'
457
- + '当前这版还没有预置状态→动作的默认映射,所以你需要在通用设置 → 状态与动作里为状态勾选动作。';
699
+ node.textContent = t('motionDefaultsNone', { count: names.length });
458
700
  }
459
701
 
460
702
  function importMotion(path) {
461
703
  if (!path) return Promise.resolve();
462
- setStatus('正在导入动作…');
704
+ say('motionImporting');
463
705
  return post('/vrmpet3d/motions', { importDir: path }).then(function (payload) {
464
706
  importedMotions = payload && Array.isArray(payload.importedMotions) ? payload.importedMotions : importedMotions;
465
707
  renderMotions();
466
- if (payload) renderMotionDefaults(payload);
708
+ if (payload) {
709
+ // The same shape `refreshMotions` caches: an import reply carries the whole catalog (`lib/index.js`).
710
+ motionBody = payload;
711
+ renderMotionDefaults(payload);
712
+ }
467
713
  var last = importedMotions[importedMotions.length - 1];
468
- setStatus(last ? '已导入动作文件夹:' + last.name + '(' + last.clips + ' 支)' : '已导入动作。');
714
+ if (last) say('motionImportedName', { name: last.name, clips: last.clips });
715
+ else say('motionImported');
469
716
  }).catch(function (error) {
470
- setStatus('导入失败:' + (error && error.message ? error.message : error), 'warn');
717
+ say('motionImportFailed', { error: error && error.message ? error.message : error }, 'warn');
471
718
  });
472
719
  }
473
720
 
@@ -475,9 +722,9 @@
475
722
  return post('/vrmpet3d/motions', { unimportDir: dir }).then(function (payload) {
476
723
  importedMotions = payload && Array.isArray(payload.importedMotions) ? payload.importedMotions : importedMotions;
477
724
  renderMotions();
478
- setStatus('已移除插件复制的那一份。');
725
+ say('motionRemoved');
479
726
  }).catch(function (error) {
480
- setStatus('移除失败:' + (error && error.message ? error.message : error), 'warn');
727
+ say('motionRemoveFailed', { error: error && error.message ? error.message : error }, 'warn');
481
728
  });
482
729
  }
483
730
 
@@ -501,7 +748,7 @@
501
748
  if (!choose) {
502
749
  if (row) row.hidden = false;
503
750
  if (input) input.focus();
504
- setStatus('这个窗口没有原生选择器(不是在 DSH 桌面版里打开的),请在下面直接输入路径。', 'warn');
751
+ say('noNativePicker', null, 'warn');
505
752
  return;
506
753
  }
507
754
  Promise.resolve(choose()).then(function (chosen) { return chosen ? onChosen(chosen) : null; });
@@ -543,9 +790,9 @@
543
790
  */
544
791
  var complete = function () {
545
792
  post('/vrmpet3d/setup-state', { completed: true }).then(function () {
546
- if (note) note.textContent = '已保存:这个引导不会再出现了。';
793
+ if (note) note.textContent = t('savedNoGuide');
547
794
  if (finish) finish.disabled = true;
548
- setStatus('配置完成。');
795
+ say('setupComplete');
549
796
  closeWindow();
550
797
  /**
551
798
  * And if the window is STILL here, offer the button rather than a dead end.
@@ -558,11 +805,11 @@
558
805
  view.setTimeout(function () {
559
806
  if (view.closed === true) return;
560
807
  if (close) close.hidden = false;
561
- if (note) note.textContent = '已保存:这个引导不会再出现了。';
808
+ if (note) note.textContent = t('savedNoGuide');
562
809
  }, 300);
563
810
  }
564
811
  }).catch(function (error) {
565
- if (note) note.textContent = '保存失败:' + (error && error.message ? error.message : error);
812
+ if (note) note.textContent = t('saveFailed', { error: error && error.message ? error.message : error });
566
813
  });
567
814
  };
568
815
  if (finish) finish.addEventListener('click', complete);
@@ -584,13 +831,36 @@
584
831
  var later = el('setup-later');
585
832
  if (later) {
586
833
  later.addEventListener('click', function () {
587
- setStatus('已跳过:下次插件启动时会再提醒你一次。');
834
+ say('skipped');
588
835
  closeWindow();
589
836
  });
590
837
  }
591
838
  }
592
839
 
593
840
  function wireChoices() {
841
+ /**
842
+ * 界面语言, on the welcome screen — and the ONE choice here that is not saved for 完成.
843
+ *
844
+ * Operator, 2026-10-15: 「在初始安装引导页的第一页欢迎页面中加上语言选择」, and the point of putting it on the FIRST
845
+ * screen is that it has to work BEFORE the reader has finished reading: a reader who cannot read 中文 picks
846
+ * English and wants the sentence in front of them to change, not the next screen. So this patches the setting
847
+ * through the page's own path ({@link patchPreferences}, snapshot-guarded and local-first) and then re-labels
848
+ * everything in place ({@link applyLanguage}).
849
+ *
850
+ * The value written is the STORED answer — `auto` included, never a resolved 'zh'/'en' (`core.cleanLanguage`
851
+ * documents why: a profile that froze the resolved language could never follow the machine again).
852
+ */
853
+ var lang = el('setup-language');
854
+ if (lang) {
855
+ lang.addEventListener('change', function () {
856
+ var choice = core.cleanLanguage(lang.value);
857
+ // See `pendingLanguage`: before the first snapshot this pick cannot be written yet, and the snapshot that
858
+ // arrives next would otherwise overwrite it with the stored answer.
859
+ pendingLanguage = hasSnapshot ? null : choice;
860
+ patchPreferences({ language: choice });
861
+ applyLanguage();
862
+ });
863
+ }
594
864
  var surface = el('setup-surface');
595
865
  if (surface) {
596
866
  Array.prototype.forEach.call(surface.querySelectorAll('input[name="setup-surface"]'), function (input) {
@@ -623,6 +893,71 @@
623
893
  }
624
894
  }
625
895
 
896
+ /** The language THIS page would draw in: the stored answer resolved against the page's own environment. */
897
+ function resolvedLanguage() {
898
+ // `browserLanguageHints` is the browser's answer first and the document's own `lang` second (see core). The
899
+ // document is marked by `applyLanguage` itself, so on every pass after the first the second hint is the
900
+ // language already in force — stable, and still overruled by an explicit 中文/English or by the browser.
901
+ return core.resolveLanguage(preferences.language, core.browserLanguageHints());
902
+ }
903
+
904
+ /** The selector shows the STORED answer — `auto` included — never the language it resolved to. */
905
+ function renderLanguage() {
906
+ var select = el('setup-language');
907
+ if (select) select.value = core.cleanLanguage(preferences.language);
908
+ }
909
+
910
+ /**
911
+ * Draw this page in the language it should be drawn in — the ONE place that knows how, and the only thing a
912
+ * language change calls.
913
+ *
914
+ * It has three jobs rather than one, because this page's words live in three places and each needs its own step:
915
+ *
916
+ * 1. the MARKUP, which keeps its Chinese inline as the no-JS fallback and names its key — `core.applyI18n`;
917
+ * 2. the strings this script writes — every one of them is read through `t()` at call time, so re-running the
918
+ * section renderers (`goto`, `renderSurface`, `renderSpeech`, `renderMotions`, `renderMotionDefaults`) is
919
+ * what re-states them;
920
+ * 3. the SHARED model picker, which is BUILT in one language (`createModelPicker`'s `language`) and relabels
921
+ * through its own `setLanguage` — the same nodes, the same catalog, a switch in flight left alone. A picker
922
+ * without that call (an older component) is mounted again instead: `destroy` removes exactly the subtree it
923
+ * owns, and the mount point, the store callbacks and the catalog refresh are all reused.
924
+ *
925
+ * `documentElement.lang` follows the resolved language, which is what makes a host-served page honest about its
926
+ * own language (and is where `browserLanguageHints`' second hint comes from on the next pass).
927
+ *
928
+ * The status line is the one node `applyI18n` would overwrite with a shipped default, so it is re-stated from
929
+ * the key it was last written with — see `statusLine`.
930
+ */
931
+ function applyLanguage() {
932
+ language = resolvedLanguage();
933
+ if (document.documentElement) document.documentElement.lang = language;
934
+ core.applyI18n(document, TEXT, language);
935
+ renderLanguage();
936
+ if (!picker) {
937
+ mountModelPicker();
938
+ refreshModels();
939
+ } else if (typeof picker.setLanguage === 'function') {
940
+ picker.setLanguage(language);
941
+ } else {
942
+ // An older component that only takes its words at construction: mount it again, and re-read the catalog the
943
+ // new instance does not have yet. `destroy` is its own API and the mount is cleared as well, so a component
944
+ // that grew a different teardown cannot leave a stale grid behind the new one.
945
+ if (typeof picker.destroy === 'function') picker.destroy();
946
+ var mount = el('setup-model-picker');
947
+ if (mount) clear(mount);
948
+ picker = null;
949
+ mountModelPicker();
950
+ refreshModels();
951
+ }
952
+ goto(current);
953
+ renderSurface();
954
+ renderSpeech();
955
+ renderMotions();
956
+ // Skipped until the first `/vrmpet3d/motions` reply: its "no data" branch would claim an empty pack.
957
+ if (motionBody) renderMotionDefaults(motionBody);
958
+ restateStatus();
959
+ }
960
+
626
961
  /**
627
962
  * The host's settings store, the same contract the two settings pages use.
628
963
  *
@@ -631,7 +966,7 @@
631
966
  */
632
967
  function watchSettings() {
633
968
  if (typeof core.watchHostSettings !== 'function') {
634
- setStatus('这个版本的 core 没有设置接口,页面只能展示默认值。', 'warn');
969
+ say('coreNoStore', null, 'warn');
635
970
  renderSurface();
636
971
  renderSpeech();
637
972
  return;
@@ -643,6 +978,25 @@
643
978
  onSnapshot: function (snapshot) {
644
979
  preferences = core.cleanPreferences(snapshot.preferences || {});
645
980
  hasSnapshot = true;
981
+ if (pendingLanguage) {
982
+ // The reader's own pick, made while the page had no write permission: this is the first moment it can be
983
+ // written, and it outranks the snapshot that was already in flight when they made it. The store merges by
984
+ // key, so the patch carries this one setting and nothing else.
985
+ preferences.language = pendingLanguage;
986
+ if (hostSync && typeof hostSync.push === 'function') hostSync.push({ preferences: { language: pendingLanguage } });
987
+ pendingLanguage = null;
988
+ }
989
+ /**
990
+ * THE LANGUAGE FIRST, because the snapshot is where the stored answer arrives.
991
+ *
992
+ * The selector always follows the STORE, and when the answer RESOLVES to a different language than the one
993
+ * the page booted in — a profile set to 中文 opened on an English machine, or the other way round — the
994
+ * whole page is re-labelled before the rest of this snapshot paints. A change that resolves to the same
995
+ * language needs nothing but the selector.
996
+ */
997
+ var before = language;
998
+ renderLanguage();
999
+ if (resolvedLanguage() !== before) applyLanguage();
646
1000
  // The live report, for the picker's progress bar: which model a pet says it is actually showing.
647
1001
  var live = snapshot && snapshot.live ? snapshot.live : null;
648
1002
  liveModel = live ? (live.app || live.window || null) : null;
@@ -652,27 +1006,26 @@
652
1006
  // and this is the call that asks whether an in-flight switch has finished.
653
1007
  if (picker) picker.setCurrent();
654
1008
  // Never over a switch's own line: while the bar is up, that line is what the user is reading.
655
- if (!(picker && picker.state().switching)) setStatus('已读取当前设置。');
1009
+ if (!(picker && picker.state().switching)) say('readSettings');
656
1010
  },
657
1011
  }).start();
658
1012
  }
659
1013
 
660
1014
  function boot() {
661
- setStatus('正在读取…');
1015
+ // The language is resolved FIRST: every line below is written through `t`, and the model picker takes its own
1016
+ // language when `applyLanguage` mounts it.
1017
+ language = resolvedLanguage();
1018
+ say('reading');
662
1019
  wireChoices();
663
1020
  wireFinish();
664
- // The model step is the shared picker; the motion step keeps its pick-or-type row (a folder cannot be
665
- // dropped into a form field, and the picker's own component has no folder mode).
666
- mountModelPicker();
1021
+ // The model step is the shared picker, mounted by `applyLanguage` because its words are fixed at construction;
1022
+ // the motion step keeps its pick-or-type row (a folder cannot be dropped into a form field, and the picker's
1023
+ // own component has no folder mode).
667
1024
  wirePicker('setup-motion-pick', 'setup-motion-manual', 'setup-motion-manual-row', 'setup-motion-path', 'setup-motion-add',
668
1025
  'pickMotionDir', importMotion);
1026
+ applyLanguage();
669
1027
  watchSettings();
670
- renderSurface();
671
- renderSpeech();
672
- renderMotions();
673
- goto(0);
674
1028
  loadLlm();
675
- refreshModels();
676
1029
  refreshMotions();
677
1030
  }
678
1031