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/README.md +2 -0
- package/desktop/main.js +47 -4
- package/desktop/preload.js +7 -2
- package/lib/client.core.js +487 -53
- package/lib/client.js +391 -57
- package/lib/client.menu.js +14 -4
- package/lib/client.scene.js +134 -7
- package/lib/config-page.html +82 -45
- package/lib/config-page.js +1271 -252
- package/lib/core.js +487 -53
- package/lib/desktop-page.js +298 -44
- package/lib/desktop.js +18 -1
- package/lib/global-page.html +172 -101
- package/lib/index.js +196 -58
- package/lib/menu.js +14 -4
- package/lib/model-picker.js +139 -49
- package/lib/picker-page.html +34 -8
- package/lib/picker-page.js +192 -11
- package/lib/scene.js +134 -7
- package/lib/setup-page.html +68 -49
- package/lib/setup-page.js +395 -42
- package/lib/vrm-info.js +16 -1
- package/lib/whale.js +191 -0
- package/package.json +1 -1
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
|
-
|
|
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)
|
|
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
|
-
|
|
452
|
+
say('pickerMissing', null, 'warn');
|
|
218
453
|
return null;
|
|
219
454
|
}
|
|
220
455
|
picker = api.createModelPicker({
|
|
221
456
|
mount: mount,
|
|
222
|
-
language
|
|
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
|
-
|
|
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
|
-
|
|
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 ? '
|
|
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
|
-
|
|
415
|
-
|
|
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
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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 = '
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
714
|
+
if (last) say('motionImportedName', { name: last.name, clips: last.clips });
|
|
715
|
+
else say('motionImported');
|
|
469
716
|
}).catch(function (error) {
|
|
470
|
-
|
|
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
|
-
|
|
725
|
+
say('motionRemoved');
|
|
479
726
|
}).catch(function (error) {
|
|
480
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = '
|
|
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
|
-
|
|
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
|
-
|
|
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))
|
|
1009
|
+
if (!(picker && picker.state().switching)) say('readSettings');
|
|
656
1010
|
},
|
|
657
1011
|
}).start();
|
|
658
1012
|
}
|
|
659
1013
|
|
|
660
1014
|
function boot() {
|
|
661
|
-
|
|
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
|
|
665
|
-
// dropped into a form field, and the picker's
|
|
666
|
-
|
|
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
|
|