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/client.js CHANGED
@@ -76,6 +76,98 @@
76
76
  try { return JSON.stringify(error); } catch (e) { return String(error); }
77
77
  }
78
78
 
79
+ /**
80
+ * The DSH locale service's own answer, or null when it has none to give.
81
+ *
82
+ * `ctx.locale` is a cordis service whose snapshot is a plain object, and which field carries the language has
83
+ * moved between builds (`language`, then `locale`, then `current`), so all three are asked for in that order. A
84
+ * service that is absent, shaped differently or throws is NOT an error here: it means this deployment has no app
85
+ * locale to consult and the browser's own hints answer instead, which is what the try/catch is for.
86
+ */
87
+ function localeHintOf(locale) {
88
+ try {
89
+ var snapshot = locale && typeof locale.getSnapshot === 'function' ? locale.getSnapshot() : undefined;
90
+ var value = snapshot && (snapshot.language || snapshot.locale || snapshot.current);
91
+ return typeof value === 'string' && value ? value : null;
92
+ } catch (error) {
93
+ return null;
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Which of the two languages this surface draws in — the ONE rule, asked every time it is needed.
99
+ *
100
+ * `core.resolveLanguage` owns the rule (an explicit 'zh'/'en' wins; 'auto' takes the FIRST hint that says
101
+ * anything, else English), and the hints are gathered here in their order of authority: the DSH locale first,
102
+ * because DSH is the application the pet lives inside, then the browser's own two (`navigator.language`, then the
103
+ * document's `lang`). English is what nothing-at-all answers, which is also the plugin's own fallback.
104
+ *
105
+ * Deliberately NOT cached in a module-level variable: the language can change while the app runs (the 界面语言
106
+ * preference, or DSH's own locale), and a captured answer is a surface stuck in the language it started in.
107
+ */
108
+ function readLanguage(locale, preference) {
109
+ var core = globalThis.__VRMPET3D_CORE__;
110
+ var hints = [localeHintOf(locale)];
111
+ if (core && typeof core.browserLanguageHints === 'function') {
112
+ hints = hints.concat(core.browserLanguageHints());
113
+ } else {
114
+ /**
115
+ * A core chunk that predates the rule — the stale-delivery case this repository has eaten more than once.
116
+ * The same two hints, in the same order, from the page itself, so an older host costs the SHARING of the rule
117
+ * rather than the language.
118
+ */
119
+ try {
120
+ hints.push(globalThis.navigator && globalThis.navigator.language);
121
+ hints.push(globalThis.document && globalThis.document.documentElement && globalThis.document.documentElement.lang);
122
+ } catch (error) { /* no window: the hints stay empty */ }
123
+ }
124
+ if (core && typeof core.resolveLanguage === 'function') return core.resolveLanguage(preference, hints);
125
+ if (preference === 'zh' || preference === 'en') return preference;
126
+ for (var i = 0; i < hints.length; i += 1) {
127
+ var hint = hints[i];
128
+ if (typeof hint !== 'string' || !hint) continue;
129
+ return /^(zh|cn|chinese)/i.test(hint.trim()) ? 'zh' : 'en';
130
+ }
131
+ return 'en';
132
+ }
133
+
134
+ /**
135
+ * The language a message with NO widget behind it is written in.
136
+ *
137
+ * The chunk loader runs before any widget exists — and before `ctx.locale` is reachable at all — so the browser's
138
+ * own two hints are the whole of what there is, and English is what nothing-at-all answers. Every message that
139
+ * HAS a widget reads that widget's own resolved language instead.
140
+ */
141
+ function ambientLanguage() {
142
+ return readLanguage(null, null);
143
+ }
144
+
145
+ /**
146
+ * One language's half of {@link STRINGS}, read at CALL time.
147
+ *
148
+ * 'zh' is the only value that selects the Chinese half: a widget whose language is missing (a stand-in built
149
+ * from the prototype) or anything unexpected reads the English half, which every entry is required to have.
150
+ */
151
+ function words(language) {
152
+ return STRINGS[language === 'zh' ? 'zh' : 'en'] || STRINGS.en;
153
+ }
154
+
155
+ /**
156
+ * One entry out of {@link STRINGS}, with `{name}` placeholders filled from `values`.
157
+ *
158
+ * A key missing from BOTH halves returns the key, so a gap is visible rather than silently blank — the rule
159
+ * `core.textOf` follows, which this cannot call because it is also used before a core chunk arrives.
160
+ */
161
+ function word(language, key, values) {
162
+ var text = words(language)[key];
163
+ if (typeof text !== 'string') text = STRINGS.en[key];
164
+ if (typeof text !== 'string') return key;
165
+ if (!values) return text;
166
+ return text.replace(/\{(\w+)\}/g, function (match, name) {
167
+ return Object.prototype.hasOwnProperty.call(values, name) ? String(values[name]) : match;
168
+ });
169
+ }
170
+
79
171
  /**
80
172
  * Every chunk request this half makes, resolved through the module system.
81
173
  *
@@ -108,7 +200,12 @@
108
200
  }
109
201
  return require.async(chunk.file).then(function (exports) {
110
202
  var value = exports || globalThis[chunk.global];
111
- if (!value) throw new Error('vrmpet3d: ' + chunk.file + ' loaded without publishing ' + chunk.global);
203
+ if (!value) {
204
+ // This failure has no widget to ask, so `ambientLanguage` answers: the page's own hints, else English.
205
+ throw new Error('vrmpet3d: ' + word(ambientLanguage(), 'chunkNoPublish', {
206
+ file: chunk.file, global: chunk.global,
207
+ }));
208
+ }
112
209
  loaded[chunk.global] = value;
113
210
  return publish(chunk, value);
114
211
  }, function (error) {
@@ -122,7 +219,7 @@
122
219
 
123
220
  return pending.then(function () {
124
221
  if (missing.length) {
125
- throw new Error('vrmpet3d: DSH did not deliver ' + missing.join('; '));
222
+ throw new Error('vrmpet3d: ' + word(ambientLanguage(), 'chunksMissing', { list: missing.join('; ') }));
126
223
  }
127
224
  return loaded;
128
225
  });
@@ -203,6 +300,7 @@
203
300
  */
204
301
  '.vrmpet3d-status[data-tone="gold"]{color:light-dark(#8a5a00,#ffc94d);--dsw-alias-label-shimmer:light-dark(#ffe0a3,#fff0c9)}',
205
302
  '.vrmpet3d-status[data-tone="gold"][data-bold="1"]{font-weight:700}',
303
+ '.vrmpet3d-status[data-attention="1"]{pointer-events:auto;cursor:pointer;text-shadow:0 0 4px currentColor,0 0 10px currentColor,0 0 18px currentColor}',
206
304
  '.vrmpet3d-status[data-tone="danger"]{color:light-dark(#b3261e,#ff6b6b);--dsw-alias-label-shimmer:light-dark(#ffc9c4,#ffd0cc)}',
207
305
  /**
208
306
  * 碎碎念: the pet's own short remark, in a bubble above the status prompt.
@@ -314,6 +412,7 @@
314
412
  '.vrmpet3d-status-content{display:flex;align-items:center;min-width:0}',
315
413
  /* `nowrap`, always: the operator's "提示文字永不换行". One line, however long the line is. */
316
414
  '.vrmpet3d-status-text{color:inherit;font-variant-numeric:tabular-nums;white-space:nowrap}',
415
+ '.vrmpet3d-status-dot{pointer-events:auto;cursor:pointer}',
317
416
  /* The words are rendered twice from ONE string: `attr()` feeds both copies, so the two cannot drift. */
318
417
  '.vrmpet3d-status-text[data-shimmer-text]::after{content:attr(data-shimmer-text)}',
319
418
  '.vrmpet3d-status-decoration{position:absolute;inset:0;overflow:clip;pointer-events:none;user-select:none}',
@@ -367,7 +466,12 @@
367
466
  */
368
467
  function Placement(preferences, now, core) {
369
468
  if (!core || typeof core.stepFall !== 'function') {
370
- throw new Error('vrmpet3d: Placement needs the core module (stepFall).');
469
+ /**
470
+ * Said in the language the preferences name, because a Placement is built before any widget exists to be
471
+ * asked — the first one is constructed by the widget itself, and the floating window's by its own page.
472
+ */
473
+ throw new Error('vrmpet3d: ' + word(
474
+ readLanguage(null, preferences && preferences.language), 'placementNoCore'));
371
475
  }
372
476
  this.core = core;
373
477
  this.preferences = preferences;
@@ -673,25 +777,46 @@
673
777
 
674
778
  /**
675
779
  * @param {HTMLElement} host the element the slot gave us
676
- * @param {object} options { vendor, language, onScopeChange, onModelChange }
780
+ * @param {object} options { locale, language, onScopeChange, onModelChange }
677
781
  */
678
782
  function VrmPet3DWidget(host, options) {
679
783
  options = options || {};
680
784
  this.host = host;
785
+ this.openSession = options.openSession || function () {};
786
+ this.conversationVisible = options.conversationVisible || function () { return true; };
681
787
  this.core = globalThis.__VRMPET3D_CORE__;
682
788
  this.sceneApi = globalThis.__VRMPET3D_SCENE__;
683
- if (!this.core) throw new Error('vrmpet3d: lib/core.js is not loaded.');
684
- if (!this.sceneApi) throw new Error('vrmpet3d: lib/scene.js is not loaded.');
685
-
686
- this.language = options.language === 'zh' ? 'zh' : 'en';
789
+ /**
790
+ * The DSH locale SERVICE itself, kept rather than a snapshot of it.
791
+ *
792
+ * The language has to be re-resolvable while the pet runs (`adoptLanguage`), because
793
+ * `preferences.language === 'auto'` means "whatever DSH says" and DSH's own locale can change under it.
794
+ */
795
+ this.locale = options.locale || null;
796
+ /**
797
+ * An EXPLICIT language is an override, and that is what the probes and the suites inject (`{language:'zh'}`);
798
+ * the real mount passes `locale` and lets the user's preference decide.
799
+ */
800
+ this.injectedLanguage = options.language === 'zh' || options.language === 'en' ? options.language : null;
801
+ /**
802
+ * Resolved BEFORE the two failures below, because those messages are shown to the user in a badge and have to
803
+ * be said in the same language as everything else. The preference is not readable yet (`readStorage` needs
804
+ * `this.document`, and that needs a core), so this pass has DSH's locale and the browser's hints only; the
805
+ * line after `readStorage()` re-resolves with the user's own answer.
806
+ */
807
+ this.language = this.injectedLanguage || readLanguage(this.locale, null);
808
+ if (!this.core) throw new Error('vrmpet3d: ' + word(this.language, 'mountNoCore'));
809
+ if (!this.sceneApi) throw new Error('vrmpet3d: ' + word(this.language, 'mountNoScene'));
687
810
  this.onScopeChange = typeof options.onScopeChange === 'function' ? options.onScopeChange : function () {};
688
811
  this.onModelChange = typeof options.onModelChange === 'function' ? options.onModelChange : function () {};
689
812
  this.document = host.ownerDocument || document;
690
813
 
691
814
  this.preferences = this.readStorage();
815
+ this.language = this.injectedLanguage || readLanguage(this.locale, this.preferences.language);
692
816
  this.placement = new Placement(this.preferences, function () { return Date.now(); }, this.core);
693
817
 
694
818
  this.machine = new this.core.PetMachine(function () { return Date.now(); }, this.preferences.sleepAfterMs);
819
+ this.machine.requireViewedAcknowledgement = typeof options.conversationVisible === 'function';
695
820
  /**
696
821
  * The machine reads the settings it needs through the WIDGET, not from a copy.
697
822
  *
@@ -985,6 +1110,13 @@
985
1110
  // The model is ONE setting for both surfaces, so a change made in the window or in the settings
986
1111
  // window moves this pet too - there is no per-surface model to keep in step.
987
1112
  self.preferences = next;
1113
+ /**
1114
+ * 界面语言 follows the snapshot like every other preference, and only NOW is the answer readable:
1115
+ * `preferences.language` may be an explicit 中文/English or `auto` (what DSH's own locale says). A language
1116
+ * that actually CHANGED re-says this surface's own words and rebuilds the shared menu; nothing else about
1117
+ * the pet is touched.
1118
+ */
1119
+ self.adoptLanguage();
988
1120
  if (self.scene) self.scene.applyPreferences(Object.assign({}, next, { motions: self.effectiveMotions() }));
989
1121
  self.applyMotions();
990
1122
  self.applyPlacement();
@@ -1008,6 +1140,7 @@
1008
1140
  */
1009
1141
  if (this.core.watchHostPreview) {
1010
1142
  this.previewSync = this.core.watchHostPreview({
1143
+ onNavigate: function (id) { self.openSession(id); },
1011
1144
  onPreview: function (preview) {
1012
1145
  /**
1013
1146
  * A PREVIEW ENDS A DANCE — and a preview of the DANCING state STARTS one.
@@ -1052,6 +1185,8 @@
1052
1185
  if (event && event.key && !known) return;
1053
1186
  var next = self.readStorage();
1054
1187
  self.preferences = next;
1188
+ // The other document may have been the settings window's 界面语言 field: same rule as the host snapshot.
1189
+ self.adoptLanguage();
1055
1190
  if (self.scene) self.scene.applyPreferences(Object.assign({}, next, { motions: self.effectiveMotions() }));
1056
1191
  self.applyMotions();
1057
1192
  self.applyPlacement();
@@ -1095,6 +1230,7 @@
1095
1230
  // ---- DOM -----------------------------------------------------------------
1096
1231
 
1097
1232
  VrmPet3DWidget.prototype.build = function () {
1233
+ var self = this;
1098
1234
  var doc = this.document;
1099
1235
  var root = doc.createElement('div');
1100
1236
  root.className = 'vrmpet3d-root';
@@ -1159,6 +1295,10 @@
1159
1295
  this.canvas = root.querySelector('.vrmpet3d-pet canvas');
1160
1296
  this.badge = root.querySelector('.vrmpet3d-badge');
1161
1297
  this.statusNode = root.querySelector('.vrmpet3d-status');
1298
+ this.statusNode.addEventListener('click', function (event) {
1299
+ event.stopPropagation();
1300
+ self.openStatusSession();
1301
+ });
1162
1302
  this.statusIcon = root.querySelector('.vrmpet3d-status-icon');
1163
1303
  /**
1164
1304
  * The green dot, when a completion is waiting elsewhere.
@@ -1169,6 +1309,11 @@
1169
1309
  * puts beside that session in its list, and it disappears when the user goes back there.
1170
1310
  */
1171
1311
  this.statusDot = root.querySelector('.vrmpet3d-status-dot');
1312
+ this.statusDot.addEventListener('click', function (event) {
1313
+ event.stopPropagation();
1314
+ self.openQueuedSession('completed');
1315
+ });
1316
+ this.statusDot.title = this.language === 'en' ? 'Open completed conversation' : '打开已完成的会话';
1172
1317
  this.statusDecoration = root.querySelector('.vrmpet3d-status-decoration');
1173
1318
  /**
1174
1319
  * The prompt's two text copies — the real one and the masked highlight — which always render the SAME
@@ -1198,7 +1343,7 @@
1198
1343
  };
1199
1344
 
1200
1345
  VrmPet3DWidget.prototype.applyStrings = function () {
1201
- var text = STRINGS[this.language];
1346
+ var text = words(this.language);
1202
1347
  /**
1203
1348
  * The ONE label this surface still writes by hand.
1204
1349
  *
@@ -1210,6 +1355,37 @@
1210
1355
  this.pet.setAttribute('aria-label', text.ariaPet);
1211
1356
  };
1212
1357
 
1358
+ /**
1359
+ * Follow 界面语言 — the preference, DSH's locale, and this surface's own words.
1360
+ *
1361
+ * Called from every path that can change the preference (a host snapshot, a storage write from another
1362
+ * document) because 'auto' means "whatever DSH says" and DSH's locale can change under a running pet. Only a
1363
+ * CHANGE does anything: this rebuilds the menu, so running it on every poll would throw away and rebuild a live
1364
+ * element every few seconds for nothing.
1365
+ *
1366
+ * What is re-said, and why exactly these three:
1367
+ * * the restore label and the aria name are the only strings this surface CAPTURES — everything else is read
1368
+ * out of the table when it is drawn, which is the rule that makes a live language change possible at all;
1369
+ * * the shared menu carries its words from `createMenu` and cannot be re-worded in place, so it is rebuilt
1370
+ * (the old element is destroyed by `buildMenu`, not left in the document);
1371
+ * * the prompt is drawn from `core.statusFor(..., this.language)` on the next frame, and its cached key is
1372
+ * dropped so "next frame" is when the new words arrive rather than "the next time the line differs";
1373
+ * * the 碎碎念 already on screen is the machine's own held line and is left alone — it expires on its own
1374
+ * clock, and the remark after it is picked in the new language. Clearing the machine here would also throw
1375
+ * away its throttle memory, which is a change nobody asked for.
1376
+ */
1377
+ VrmPet3DWidget.prototype.adoptLanguage = function () {
1378
+ // A stand-in built from the prototype (a probe's own widget) has no language to change and no DOM to re-say.
1379
+ if (!this.language) return;
1380
+ var wanted = this.injectedLanguage
1381
+ || readLanguage(this.locale, this.preferences && this.preferences.language);
1382
+ if (wanted === this.language) return;
1383
+ this.language = wanted;
1384
+ this.applyStrings();
1385
+ this.buildMenu();
1386
+ this.headStatusKey = null;
1387
+ };
1388
+
1213
1389
  VrmPet3DWidget.prototype.bindMenu = function () {
1214
1390
  var self = this;
1215
1391
 
@@ -1510,7 +1686,7 @@
1510
1686
  var fallback = function (reason) {
1511
1687
  var opened = null;
1512
1688
  try { opened = globalThis.open(url, '_blank'); } catch (error) { opened = null; }
1513
- if (!opened) self.showStatus((STRINGS[self.language].configNote || '') + ' ' + (reason || ''));
1689
+ if (!opened) self.showStatus((words(self.language).configNote || '') + ' ' + (reason || ''));
1514
1690
  };
1515
1691
  try {
1516
1692
  fetch('/vrmpet3d/desktop', {
@@ -1523,9 +1699,11 @@
1523
1699
  // placeholder while a VRM was being shown.
1524
1700
  body: JSON.stringify({ action: which, modelId: self.currentModelId() }),
1525
1701
  }).then(function (response) {
1526
- if (!response.ok) fallback('the launcher could not open a window');
1702
+ // Both reasons are APPENDED to `configNote` and shown in the badge, so they are user-visible sentences of
1703
+ // this surface's own and come out of the table like the rest.
1704
+ if (!response.ok) fallback(words(self.language).settingsNoWindow);
1527
1705
  }).catch(function () {
1528
- fallback('the host is not reachable');
1706
+ fallback(words(self.language).settingsUnreachable);
1529
1707
  });
1530
1708
  } catch (error) {
1531
1709
  fallback(error && error.message);
@@ -1577,7 +1755,7 @@
1577
1755
  */
1578
1756
  var opened = null;
1579
1757
  try { opened = globalThis.open('/vrmpet3d/setup', '_blank'); } catch (error) { opened = null; }
1580
- if (!opened) self.showStatus((STRINGS[self.language].setupNote || ''));
1758
+ if (!opened) self.showStatus((words(self.language).setupNote || ''));
1581
1759
  return null;
1582
1760
  });
1583
1761
  })
@@ -1600,6 +1778,7 @@
1600
1778
  */
1601
1779
  VrmPet3DWidget.prototype.openMenuAt = function (clientX, clientY) {
1602
1780
  if (!this.menu) return false;
1781
+ if (typeof this.menu.setSurfaceVisible === 'function') this.menu.setSurfaceVisible(this.canUseIndependentPet());
1603
1782
  var doc = this.document;
1604
1783
  var view = doc.defaultView;
1605
1784
  var viewportWidth = Number(view && view.innerWidth) || doc.documentElement.clientWidth || 0;
@@ -1612,6 +1791,7 @@
1612
1791
  * it is read when the menu is built.
1613
1792
  */
1614
1793
  this.menu.setSurfaceAllowed(this.preferences.petSurface !== 'both');
1794
+ if (this.menu.setDanceState) this.menu.setDanceState(this.machine.state);
1615
1795
  /**
1616
1796
  * 重力's check mark, read at the same moment and for the same reason: a host snapshot or a settings-window
1617
1797
  * edit can change the preference while the page keeps running, so a menu that cached it would show the
@@ -1655,6 +1835,14 @@
1655
1835
  */
1656
1836
  VrmPet3DWidget.prototype.buildMenu = function () {
1657
1837
  var api = globalThis.__VRMPET3D_MENU__;
1838
+ /**
1839
+ * A REBUILD replaces the element rather than leaving the old one in the document — the menu is DOM, and a
1840
+ * second copy would keep its own document listeners and its own words. `adoptLanguage` is the one caller that
1841
+ * rebuilds (the menu carries its labels from `createMenu` and cannot be re-worded in place), and the first
1842
+ * call finds `this.menu` null.
1843
+ */
1844
+ if (this.menu && typeof this.menu.destroy === 'function') this.menu.destroy();
1845
+ this.menu = null;
1658
1846
  if (!api || typeof api.createMenu !== 'function') return null;
1659
1847
  this.menuAvailable = true;
1660
1848
  api.injectStyle(this.document, 'vrmpet3d-menu-style');
@@ -1664,6 +1852,7 @@
1664
1852
  container: this.root,
1665
1853
  language: this.language,
1666
1854
  toSurface: 'window',
1855
+ showSurface: this.canUseIndependentPet(),
1667
1856
  onChoose: function (id) { self.onMenuChoice(id); },
1668
1857
  });
1669
1858
  return this.menu;
@@ -2000,7 +2189,7 @@
2000
2189
  */
2001
2190
  VrmPet3DWidget.prototype.applySurface = function (attempt) {
2002
2191
  var self = this;
2003
- var strings = STRINGS[this.language] || STRINGS.en;
2192
+ var strings = words(this.language);
2004
2193
  var surface = this.preferences.petSurface || 'auto';
2005
2194
  /**
2006
2195
  * What was last ASKED for, which is not the same as what is running: the window can fail to start. It is
@@ -2066,7 +2255,7 @@
2066
2255
 
2067
2256
  VrmPet3DWidget.prototype.setDesktopWindow = function (enabled) {
2068
2257
  var self = this;
2069
- var strings = STRINGS[this.language] || STRINGS.en;
2258
+ var strings = words(this.language);
2070
2259
  this.syncDesktop(null, strings.desktopStarting);
2071
2260
  return fetch('/vrmpet3d/desktop', {
2072
2261
  method: 'POST',
@@ -2099,7 +2288,7 @@
2099
2288
  .catch(function () {
2100
2289
  // A session without the route (an older host half) is RECORDED rather than thrown: `desktopNote` is kept
2101
2290
  // as data (see `syncDesktop`), so "the window could not start" stays answerable.
2102
- var strings = STRINGS[self.language] || STRINGS.en;
2291
+ var strings = words(self.language);
2103
2292
  self.syncDesktop(null, strings.desktopUnavailable);
2104
2293
  return null;
2105
2294
  });
@@ -2114,8 +2303,14 @@
2114
2303
  * the widget's own state. "The window is up but the pet is not drawing in it" stays answerable that way, which
2115
2304
  * is the whole reason the host reports the page's own verdict.
2116
2305
  */
2306
+ VrmPet3DWidget.prototype.canUseIndependentPet = function () {
2307
+ // The host can borrow Electron even when it is itself a plain Node process.
2308
+ // Its runtime result, rather than the UI bridge or process type, decides this.
2309
+ return Boolean(this.desktopStatus && this.desktopStatus.electronAvailable === true);
2310
+ };
2311
+
2117
2312
  VrmPet3DWidget.prototype.syncDesktop = function (status, note) {
2118
- var strings = STRINGS[this.language] || STRINGS.en;
2313
+ var strings = words(this.language);
2119
2314
  this.desktopStatus = status || null;
2120
2315
  if (note) {
2121
2316
  this.desktopNote = note;
@@ -2200,6 +2395,7 @@
2200
2395
  * place that knows it. A caller that omits it gets the click pool, which is what an older cached chunk does.
2201
2396
  */
2202
2397
  VrmPet3DWidget.prototype.reactSpeech = function (anatomy, gesture) {
2398
+ if (this.machine && this.machine.state === 'dancing') return null;
2203
2399
  if (!this.speech) return null;
2204
2400
  var random = this.scene && typeof this.scene.random === 'function' ? this.scene.random : null;
2205
2401
  var line = this.speech.react(
@@ -2796,7 +2992,7 @@
2796
2992
  var self = this;
2797
2993
  return fetch('/vrmpet3d/catalog', { credentials: 'omit' })
2798
2994
  .then(function (response) {
2799
- if (!response.ok) throw new Error('catalog route returned HTTP ' + response.status);
2995
+ if (!response.ok) throw new Error(word(self.language, 'catalogRoute', { status: response.status }));
2800
2996
  return response.json();
2801
2997
  })
2802
2998
  .then(function (body) {
@@ -2882,19 +3078,28 @@
2882
3078
  */
2883
3079
  VrmPet3DWidget.prototype.startDance = function (motionId) {
2884
3080
  if (!this.machine || typeof this.machine.setDancing !== 'function') return false;
3081
+ if (['resting', 'offline', 'dancing'].indexOf(this.machine.resolve(this.aggregate.snapshot())) === -1) return false;
2885
3082
  var wanted = typeof motionId === 'string' && motionId ? motionId : null;
2886
3083
  if (!wanted) {
2887
3084
  var ids = this.danceClipIds();
2888
3085
  if (!ids.length) {
2889
- this.showStatus('没有可用的舞蹈动作(assets/motions/dance 是空的)');
3086
+ // The same sentence the floating window's 跳支舞 puts in its bubble (see `lib/desktop-page.js`).
3087
+ this.showStatus(words(this.language).danceEmpty);
2890
3088
  return false;
2891
3089
  }
3090
+ var current = this.machine.dancing && this.machine.dancing.motionId;
3091
+ var alternatives = ids.filter(function (id) { return id !== current; });
3092
+ if (alternatives.length) ids = alternatives;
2892
3093
  wanted = ids[Math.floor(Math.random() * ids.length)];
2893
3094
  }
2894
3095
  // A generous deadline: the clip's own end is what normally stops this, and a dance that outstays its welcome is worse
2895
3096
  // than one that is cut short at three minutes.
2896
3097
  if (!this.machine.setDancing(wanted, 180000)) return false;
2897
- if (this.scene) this.scene.setPreview(null);
3098
+ if (this.scene) {
3099
+ this.scene.setPreview(null);
3100
+ // A previous run may already be finished, even with no preview left to clear.
3101
+ if (this.scene.playback && this.scene.playback.state === 'dancing') this.scene.stopPlayback(null);
3102
+ }
2898
3103
  var self = this;
2899
3104
  var dance = this.machine.dancing;
2900
3105
  this.ensureMotion(wanted, false).then(function () {
@@ -2902,13 +3107,19 @@
2902
3107
  self.scene.setPreview({ state: 'dancing', motionId: wanted });
2903
3108
  }
2904
3109
  });
3110
+ // Publish the accepted dance immediately, including when this surface is hidden.
3111
+ // The floating window must not wait for the next background animation frame.
3112
+ if (!this.disposed) this.pump();
2905
3113
  return true;
2906
3114
  };
2907
3115
 
2908
3116
  /** End a dance early — the double-click gesture, and the clip's own end. */
2909
3117
  VrmPet3DWidget.prototype.stopDance = function () {
2910
3118
  if (!this.machine || typeof this.machine.stopDancing !== 'function') return null;
2911
- return this.machine.stopDancing();
3119
+ var stopped = this.machine.stopDancing();
3120
+ if (this.scene && this.scene.preview && this.scene.preview.state === 'dancing') this.scene.setPreview(null);
3121
+ if (stopped && !this.disposed) this.pump();
3122
+ return stopped;
2912
3123
  };
2913
3124
 
2914
3125
  VrmPet3DWidget.prototype.reloadModel = function () { var self = this;
@@ -2919,10 +3130,15 @@
2919
3130
  * answered from the HTTP cache — and "reload" that returns the cached bytes is a button that does nothing.
2920
3131
  */
2921
3132
  if (typeof this.scene.bustModelCache === 'function') this.scene.bustModelCache();
2922
- this.showStatus('正在重载模型…');
3133
+ // The badge's three lines of 重载模型, read out of the table at each moment it is said — the language can change
3134
+ // while the load is in flight — and the same three the floating window says in its bubble.
3135
+ this.showStatus(words(this.language).reloading);
2923
3136
  return this.loadModel(id).then(function (status) {
2924
3137
  // Say what happened, because the visible difference between a good reload and a cached one is nothing at all.
2925
- if (self.statusNode) self.showStatus(status && status.ok ? '模型已重载' : ((status && status.error) || '模型重载失败'));
3138
+ if (self.statusNode) {
3139
+ var done = words(self.language);
3140
+ self.showStatus(status && status.ok ? done.reloaded : ((status && status.error) || done.reloadFailed));
3141
+ }
2926
3142
  return status;
2927
3143
  });
2928
3144
  };
@@ -3097,7 +3313,7 @@
3097
3313
  * where the clips are chosen.
3098
3314
  */
3099
3315
  VrmPet3DWidget.prototype.refreshMotionNotice = function () {
3100
- var text = STRINGS[this.language];
3316
+ var text = words(this.language);
3101
3317
  var message = null;
3102
3318
 
3103
3319
  if (!this.motionCatalog) {
@@ -3161,6 +3377,11 @@
3161
3377
  running: snap.running === true,
3162
3378
  working: Number(snap.workingCount) || 0,
3163
3379
  waiting: Number(snap.waitingCount) || 0,
3380
+ statusSessionId: this.machine.state === 'waiting' ? snap.waitingSessionId
3381
+ : (this.machine.notice && this.machine.notice.sessionId) || null,
3382
+ completedSessionId: snap.completedSessionId || null,
3383
+ waitingSessionIds: snap.waitingSessionIds || [],
3384
+ completedSessionIds: snap.completedSessionIds || [],
3164
3385
  unread: Number(snap.unreadCount) || 0,
3165
3386
  /**
3166
3387
  * The breakdown behind that count, as numbers — why the green dot is lit, with no session id or title in it.
@@ -3455,7 +3676,7 @@
3455
3676
  VrmPet3DWidget.prototype.fetchMotionBytes = function (motionId) {
3456
3677
  var url = '/vrmpet3d/motion?m=' + encodeURIComponent(motionId);
3457
3678
  return fetch(url, { credentials: 'omit' }).then(function (response) {
3458
- if (!response.ok) throw new Error('motion route returned HTTP ' + response.status);
3679
+ if (!response.ok) throw new Error(word(this.language, 'motionRoute', { status: response.status }));
3459
3680
  return response.arrayBuffer();
3460
3681
  });
3461
3682
  };
@@ -3516,7 +3737,10 @@
3516
3737
  self.motionLoaded[motionId] = status;
3517
3738
  self.motionLoadedLoop[motionId] = declaredLoop;
3518
3739
  } else {
3519
- var message = (status && status.reason) || ('vrmpet3d: could not load motion ' + motionId);
3740
+ // A scene that gave a reason has already said it in its own words; this is the widget's own sentence, and
3741
+ // `vrmpet3d:` stays in front of it because that prefix is what a report greps for.
3742
+ var message = (status && status.reason)
3743
+ || ('vrmpet3d: ' + word(self.language, 'motionLoad', { id: motionId }));
3520
3744
  self.showStatus(message);
3521
3745
  self.motionStatusMessage = message;
3522
3746
  }
@@ -3747,6 +3971,9 @@
3747
3971
  var now = Date.now();
3748
3972
  if (now - (this.focusCheckedAt || 0) >= FOCUS_POLL_MS) {
3749
3973
  this.focusCheckedAt = now;
3974
+ // Locale changes do not advance the plugin settings version. Re-read alongside focus,
3975
+ // including background/wake pumps, so auto follows DSH without another settings write.
3976
+ this.adoptLanguage();
3750
3977
  var doc = this.document;
3751
3978
  var focused = true;
3752
3979
  try {
@@ -3760,8 +3987,11 @@
3760
3987
  }
3761
3988
  }
3762
3989
 
3990
+ this.acknowledgeVisibleConversation();
3763
3991
  var snapshot = this.aggregate.snapshot();
3764
3992
  var state = this.machine.resolve(snapshot);
3993
+ this.completedUnreadCount = snapshot.completedUnreadCount;
3994
+ if (this.menu && this.menu.setDanceState) this.menu.setDanceState(state);
3765
3995
  // Dance previews belong to the dance lifetime, including work interruption.
3766
3996
  if (this.scene.preview && this.scene.preview.state === 'dancing' && state !== 'dancing') {
3767
3997
  this.scene.setPreview(null);
@@ -3793,11 +4023,13 @@
3793
4023
  */
3794
4024
  var clock = this.machine && typeof this.machine.statusClock === 'function' ? this.machine.statusClock() : null;
3795
4025
  var clockKey = clock ? clock.startedAt + ':' + (clock.endedAt || '') : '';
4026
+ var completedKey = (snapshot.completedSessionIds || []).join(',') + '|' + (snapshot.waitingSessionIds || []).join(',');
3796
4027
  if (state !== this.lastPublishedState || speech !== this.lastPublishedSpeech || touch !== null
3797
- || clockKey !== this.lastPublishedClockKey) {
4028
+ || clockKey !== this.lastPublishedClockKey || completedKey !== this.lastPublishedCompletedKey) {
3798
4029
  this.lastPublishedState = state;
3799
4030
  this.lastPublishedSpeech = speech;
3800
4031
  this.lastPublishedClockKey = clockKey;
4032
+ this.lastPublishedCompletedKey = completedKey;
3801
4033
  /**
3802
4034
  * `clock` rides along because the window has no turn frames of its own, and the UNREAD count because the window
3803
4035
  * has no session store either: its green dot is DSH's `completionUnread`, which only this half can see.
@@ -3806,7 +4038,7 @@
3806
4038
  * question with an answer (`/vrmpet3d/state`) instead of a guess. See `decisionWhy`.
3807
4039
  */
3808
4040
  if (this.publishState) {
3809
- this.publishState(state, speech, touch, clock, null, this.machine ? this.machine.unreadCount : 0, this.decisionWhy());
4041
+ this.publishState(state, speech, touch, clock, null, snapshot.completedUnreadCount || 0, this.decisionWhy());
3810
4042
  }
3811
4043
  }
3812
4044
 
@@ -3827,7 +4059,7 @@
3827
4059
  // The TURN clock, not the state's own age: 用时 is a claim about DSH's turn (`PetMachine.statusClock`), and the
3828
4060
  // token is dropped entirely while no turn is open — DSH's own token-free 「深度求索中」.
3829
4061
  var clock = this.machine && typeof this.machine.statusClock === 'function' ? this.machine.statusClock() : undefined;
3830
- var line = this.core.statusFor(this.preferences, state, this.machine.stateSince, Date.now(), clock);
4062
+ var line = this.core.statusFor(this.preferences, state, this.machine.stateSince, Date.now(), clock, this.language, this.completedUnreadCount);
3831
4063
  /**
3832
4064
  * A state with no line clears the prompt rather than leaving the previous one up: a caption that
3833
4065
  * outlives its state is how a pet ends up saying 深度求索中 over a finished turn.
@@ -3855,6 +4087,8 @@
3855
4087
  * other, and the attribute is cheap. `data-tone` is what the stylesheet reads, so the colours live in CSS
3856
4088
  * (where the two surfaces can differ) and the DECISION lives in `core.statusFor` (where they cannot).
3857
4089
  */
4090
+ if (['done', 'waiting', 'error'].indexOf(state) !== -1) node.setAttribute('data-attention', '1');
4091
+ else node.removeAttribute('data-attention');
3858
4092
  if (line.tone) node.setAttribute('data-tone', line.tone);
3859
4093
  else node.removeAttribute('data-tone');
3860
4094
  if (line.bold) node.setAttribute('data-bold', '1');
@@ -3882,8 +4116,8 @@
3882
4116
  // The dot is about ANOTHER session's unread completion, so it is drawn from the machine's count and not from
3883
4117
  // the line: the line is what is happening here, the dot is what finished elsewhere.
3884
4118
  if (this.statusDot) {
3885
- this.statusDot.hidden = !(line.state === 'working'
3886
- && this.machine && Number(this.machine.unreadCount) > 0);
4119
+ this.statusDot.hidden = !(['working', 'waiting'].indexOf(line.state) !== -1
4120
+ && Number(this.completedUnreadCount) > 0);
3887
4121
  }
3888
4122
  }
3889
4123
  // Every frame, not only when the words change: the caption is anchored to the HEAD, so an orbit, a zoom
@@ -4014,6 +4248,27 @@
4014
4248
  return line;
4015
4249
  };
4016
4250
 
4251
+ VrmPet3DWidget.prototype.openStatusSession = function () {
4252
+ var state = this.machine.state;
4253
+ if (state === 'waiting' || state === 'done') return this.openQueuedSession(state === 'waiting' ? 'waiting' : 'completed');
4254
+ var snap = this.aggregate.snapshot();
4255
+ var id = state === 'waiting' ? snap.waitingSessionId
4256
+ : ((state === 'done' || state === 'error') && this.machine.notice ? this.machine.notice.sessionId : null);
4257
+ if (id) this.openSession(id);
4258
+ };
4259
+
4260
+ VrmPet3DWidget.prototype.openQueuedSession = function (kind) {
4261
+ var snap = this.aggregate.snapshot();
4262
+ var ids = kind === 'waiting' ? snap.waitingSessionIds : snap.completedSessionIds;
4263
+ if (!ids || !ids.length) return null;
4264
+ this.queueCursor = this.queueCursor || {};
4265
+ var index = ids.indexOf(this.queueCursor[kind]);
4266
+ var id = ids[(index + 1) % ids.length];
4267
+ this.queueCursor[kind] = id;
4268
+ this.openSession(id);
4269
+ return id;
4270
+ };
4271
+
4017
4272
  /**
4018
4273
  * Ask the host for a generated line, when the machine has asked for one and nobody has yet.
4019
4274
  *
@@ -4180,7 +4435,8 @@
4180
4435
  if (this.machine && typeof this.machine.dancingNow === 'function' && this.machine.dancing) {
4181
4436
  var dancing = this.machine.dancingNow();
4182
4437
  var playing = this.scene.playback;
4183
- if (dancing && playing && playing.state === 'dancing' && playing.record && playing.record.finished === true) {
4438
+ if (dancing && playing && playing.state === 'dancing' && playing.id === dancing.motionId && playing.record
4439
+ && this.scene.motionFinished(playing.record)) {
4184
4440
  this.machine.stopDancing();
4185
4441
  }
4186
4442
  }
@@ -4243,22 +4499,27 @@
4243
4499
  // …and a badge the scene wrote is retracted once the scene is happy again (see `retractSceneStatus`).
4244
4500
  this.retractSceneStatus();
4245
4501
 
4246
- // The aim is measured against the pet's own centre each frame, from the placement
4247
- // the pet has THIS frame: the cursor can sit anywhere in the window (the listener is
4248
- // on the document), and the pet can be dragged or falling while it looks. Using the
4249
- // placement instead of `getBoundingClientRect` keeps this out of layout.
4502
+ // The rendered eyes, rather than the slot's centre, are the zero of the aim.
4250
4503
  if (this.preferences.lookAtPointer && this.pointer) {
4251
4504
  var pixels = this.placement.pixels();
4505
+ var eye = typeof this.scene.gazeOrigin === 'function' ? this.scene.gazeOrigin() : null;
4506
+ var gazeView = this.document && this.document.defaultView;
4507
+ var gazeScreenWidth = Number(gazeView && gazeView.screen && gazeView.screen.width) || this.placement.viewportWidth;
4252
4508
  var aim = this.core.pointerAim({
4253
4509
  pointerX: this.pointer.x,
4254
4510
  pointerY: this.pointer.y,
4255
- centreX: pixels.left + this.placement.width / 2,
4256
- centreY: pixels.top + this.placement.height / 2,
4511
+ centreX: pixels.left + (eye ? eye.x * this.placement.width / this.scene.width : this.placement.width / 2),
4512
+ centreY: pixels.top + (eye ? eye.y * this.placement.height / this.scene.height : this.placement.height / 2),
4257
4513
  // The same two settings as the floating window's (the 模型参数 window's 视线 section), with the
4258
- // same "auto" default: 0 px means this frame's width, which here is the app window.
4259
- spanX: this.core.resolveGazeSpan(this.preferences.gazeSpanX, this.placement.viewportWidth),
4260
- spanY: this.core.resolveGazeSpan(this.preferences.gazeSpanY, this.core.GAZE_SPAN.y),
4261
- curve: this.preferences.gazeCurve
4514
+ // same "auto" default: 0 px means half the screen width for both axes.
4515
+ spanX: this.core.resolveGazeSpan(this.preferences.gazeSpanX, gazeScreenWidth),
4516
+ spanY: this.core.resolveGazeSpan(this.preferences.gazeSpanY, gazeScreenWidth),
4517
+ curve: this.preferences.gazeCurve,
4518
+ curveY: this.preferences.gazeCurveY,
4519
+ curveRadial: this.preferences.gazeCurveRadial,
4520
+ curveSpatial: this.preferences.gazeCurveSpatial,
4521
+ distance: this.preferences.gazeDistance,
4522
+ mode: this.preferences.gazeCurveMode
4262
4523
  });
4263
4524
  this.scene.motion.setGaze(this.scene.gazeFromAim(aim));
4264
4525
  } else {
@@ -4270,15 +4531,30 @@
4270
4531
 
4271
4532
  // ---- session bridge ------------------------------------------------------
4272
4533
 
4534
+ VrmPet3DWidget.prototype.acknowledgeVisibleConversation = function () {
4535
+ var focused = typeof this.document.hasFocus === 'function' ? this.document.hasFocus() : this.machine.focused;
4536
+ if (!focused || this.document.visibilityState === 'hidden' || this.machine.interaction || !this.conversationVisible()) return;
4537
+ // Read the visible conversation on every host update and pump, regardless of how it was opened.
4538
+ var input = this.aggregate.input;
4539
+ this.aggregate.acknowledgeViewed(selectedSessionId(input.catalog) || input.currentSessionId);
4540
+ };
4541
+
4273
4542
  VrmPet3DWidget.prototype.setInput = function (input) {
4274
4543
  this.aggregate.setInput(input);
4544
+ this.acknowledgeVisibleConversation();
4275
4545
  var snapshot = this.aggregate.snapshot();
4276
4546
  this.machine.resolve(snapshot);
4277
4547
  };
4278
4548
 
4279
4549
  VrmPet3DWidget.prototype.boundary = function (event) {
4280
4550
  this.aggregate.boundary(event);
4281
- this.machine.boundary(this.aggregate.snapshot(), event);
4551
+ if (event && event.sessionId && !this.aggregate.mainSession(event.sessionId)) {
4552
+ this.machine.resolve(this.aggregate.snapshot());
4553
+ return;
4554
+ }
4555
+ this.machine.boundary(this.aggregate.snapshot(), Object.assign({}, event, {
4556
+ confirmed: event && event.reason === 'completed' && this.aggregate.running(event.sessionId) === false,
4557
+ }));
4282
4558
  };
4283
4559
 
4284
4560
  VrmPet3DWidget.prototype.resetAggregate = function (identities) {
@@ -4528,6 +4804,10 @@
4528
4804
 
4529
4805
  function selectedSessionId(catalog) {
4530
4806
  if (!catalog) return undefined;
4807
+ var visible = Object.values(catalog.byId || {}).find(function (row) {
4808
+ return row && row.retainedBy && Number(row.retainedBy.mainView) > 0;
4809
+ });
4810
+ if (visible && typeof visible.id === 'string') return visible.id;
4531
4811
  if (typeof catalog.currentSessionId === 'string') return catalog.currentSessionId;
4532
4812
  if (catalog.selectedSessionId) return catalog.selectedSessionId;
4533
4813
  if (catalog.current && typeof catalog.current.id === 'string') return catalog.current.id;
@@ -4598,7 +4878,14 @@
4598
4878
  return out;
4599
4879
  }
4600
4880
 
4601
- /** UI strings. Two languages, chosen from the DSH locale service. */
4881
+ /**
4882
+ * This surface's own words, in the two languages.
4883
+ *
4884
+ * One table per surface, `{ zh: {...}, en: {...} }` with IDENTICAL key sets — the repository's shape (see
4885
+ * `lib/menu.js`'s `LABELS`), so a language half that is missing an entry is a line that reads in the other
4886
+ * language rather than a line that is blank. Read through `words`/`word` at CALL time, never captured into a
4887
+ * variable: the language can change while the pet runs.
4888
+ */
4602
4889
  var STRINGS = {
4603
4890
  en: {
4604
4891
  /* The floating window's own state, kept as data by `syncDesktop`. */
@@ -4625,6 +4912,32 @@
4625
4912
  vrm: 'This VRM model is driven by humanoid bones, so only .vrma clips work here — no .vrma clip was found.',
4626
4913
  other: 'The loaded avatar has no skeleton, so no motion clip can drive it. Load a VRM model first.',
4627
4914
  },
4915
+ /*
4916
+ * The pet's own OPERATIONS, said in the badge: 跳支舞 with nothing to dance to, and the three moments of
4917
+ * 重载模型. These are the SAME four events the floating window puts in its bubble, so the two surfaces'
4918
+ * wording is kept in step by hand — a drift here is the pet telling the user two different things about one
4919
+ * gesture. `assets/motions/dance` is a path, not words, and is written the same way in both languages.
4920
+ */
4921
+ danceEmpty: 'No dance clip is available (assets/motions/dance is empty).',
4922
+ reloading: 'Reloading the model…',
4923
+ reloaded: 'Model reloaded',
4924
+ reloadFailed: 'Could not reload the model',
4925
+ /*
4926
+ * DIAGNOSTICS, shown in the badge (a chunk that never arrived, a constructor that threw). `vrmpet3d:` stays
4927
+ * in front of every one of them because it is a greppable marker in a report and a bug report quotes it; the
4928
+ * sentence AFTER it is what a user reads. The names in braces are data — a file, a global, an id, a number.
4929
+ */
4930
+ mountNoCore: 'lib/core.js is not loaded.',
4931
+ mountNoScene: 'lib/scene.js is not loaded.',
4932
+ placementNoCore: 'Placement needs the core module (stepFall).',
4933
+ chunkNoPublish: '{file} loaded without publishing {global}',
4934
+ chunksMissing: 'DSH did not deliver {list}',
4935
+ catalogRoute: 'catalog route returned HTTP {status}',
4936
+ motionRoute: 'motion route returned HTTP {status}',
4937
+ motionLoad: 'could not load motion {id}',
4938
+ // The two reasons 打开设置 can fail, appended to `configNote` and shown in the badge.
4939
+ settingsNoWindow: 'the launcher could not open a window',
4940
+ settingsUnreachable: 'the host is not reachable',
4628
4941
  },
4629
4942
  zh: {
4630
4943
  /* 悬浮窗自身的状态,由 `syncDesktop` 作为数据保存。 */
@@ -4648,6 +4961,23 @@
4648
4961
  vrm: 'VRM 模型使用人形骨骼,因此只能用 .vrma 动作文件——未找到 .vrma 文件。',
4649
4962
  other: '当前替身没有骨骼,任何动作文件都无法驱动它。请先加载 VRM 模型。',
4650
4963
  },
4964
+ /* 宠物自身的操作提示,写在角标里;与外置悬浮窗气泡里的同一件事保持同一套措辞。路径不翻译。 */
4965
+ danceEmpty: '没有可用的舞蹈动作(assets/motions/dance 是空的)',
4966
+ reloading: '正在重载模型…',
4967
+ reloaded: '模型已重载',
4968
+ reloadFailed: '模型重载失败',
4969
+ /* 诊断信息:角标里给用户看的。`vrmpet3d:` 前缀保留,它是报告里可检索的标记;花括号里是数据。 */
4970
+ mountNoCore: '未加载 lib/core.js。',
4971
+ mountNoScene: '未加载 lib/scene.js。',
4972
+ placementNoCore: 'Placement 需要 core 模块(stepFall)。',
4973
+ chunkNoPublish: '{file} 加载后没有发布 {global}',
4974
+ chunksMissing: 'DSH 没有下发 {list}',
4975
+ catalogRoute: '模型目录路由返回 HTTP {status}',
4976
+ motionRoute: '动作路由返回 HTTP {status}',
4977
+ motionLoad: '无法加载动作 {id}',
4978
+ /* 打开设置失败的两种原因,接在 `configNote` 后面显示在角标里。 */
4979
+ settingsNoWindow: '启动器无法打开窗口',
4980
+ settingsUnreachable: '无法连接到宿主',
4651
4981
  }
4652
4982
  };
4653
4983
 
@@ -4655,18 +4985,6 @@
4655
4985
  // Plugin
4656
4986
  // ===========================================================================
4657
4987
 
4658
- function readLanguage(locale) {
4659
- try {
4660
- var snapshot = locale && typeof locale.getSnapshot === 'function' ? locale.getSnapshot() : undefined;
4661
- var value = snapshot && (snapshot.language || snapshot.locale || snapshot.current);
4662
- if (typeof value === 'string' && value.toLowerCase().indexOf('zh') === 0) return 'zh';
4663
- var lang = document.documentElement.lang || '';
4664
- return lang.toLowerCase().indexOf('zh') === 0 ? 'zh' : 'en';
4665
- } catch (error) {
4666
- return 'en';
4667
- }
4668
- }
4669
-
4670
4988
  /**
4671
4989
  * Subscribe to live turn boundaries over the plugin's own host service.
4672
4990
  *
@@ -4819,7 +5137,23 @@
4819
5137
  if (disposed) return;
4820
5138
  try {
4821
5139
  widget = new VrmPet3DWidget(host, {
4822
- language: readLanguage(ctx.locale),
5140
+ /**
5141
+ * The DSH locale SERVICE, not a snapshot of it: the widget resolves 界面语言 itself — from the
5142
+ * user's `preferences.language`, this locale and the browser's hints — and re-resolves it
5143
+ * whenever the settings change (`adoptLanguage`), because `auto` means "whatever DSH says".
5144
+ */
5145
+ locale: ctx.locale,
5146
+ conversationVisible: function () {
5147
+ var layout = typeof ctx.get === 'function' ? ctx.get('layout') : ctx.layout;
5148
+ return !layout || !layout.panelInfo || layout.panelInfo.getSnapshot().activePanelId === null;
5149
+ },
5150
+ openSession: function (id) {
5151
+ var entries = ctx.slots.entries('sidebar.workspaces');
5152
+ for (var i = 0; i < entries.length; i += 1) {
5153
+ var injected = typeof entries[i].inject === 'function' ? entries[i].inject() : entries[i].inject;
5154
+ if (injected && typeof injected.open === 'function') { injected.open(id); return; }
5155
+ }
5156
+ },
4823
5157
  onScopeChange: function (scope) { if (changeScope.current) changeScope.current(scope); },
4824
5158
  /**
4825
5159
  * Report the state to the host, so the floating window can show the same prompt.