dsh-plugin-vrmpet3d 0.1.0 → 0.1.1

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/index.js CHANGED
@@ -38,7 +38,10 @@ import './core.js';
38
38
  // Its own module because it is the one part of this half that starts a process, and because
39
39
  // its pure helpers (env sanitisation, the link plan, the install-root search) are what the
40
40
  // suite tests directly.
41
- import { DesktopWindow, stateDir } from './desktop.js';
41
+ import { DesktopWindow, findInstallRoot, stateDir } from './desktop.js';
42
+ // DSH's own animated whale mark, read out of the running installation at activation — see the module's own
43
+ // note for why the source is the app rather than a file in this package, and what it falls back to.
44
+ import { resolveWhaleMark } from './whale.js';
42
45
  // Reads expressions, spring chains, materials and licence straight out of a model file, so the
43
46
  // settings window can describe a model with no renderer running. `clearModelInfoCache` is the explicit
44
47
  // half of that cache: the import route says "this file changed" rather than hoping the size/mtime check
@@ -2278,6 +2281,30 @@ export function apply(ctx, config = {}) {
2278
2281
  const agents = ctx.get('agents');
2279
2282
  const hub = new VrmPet3DBoundaryHub({ agents });
2280
2283
 
2284
+ // ---- 0. DSH's own animated whale mark, read ONCE, here ------------------
2285
+ /**
2286
+ * The status prompt masks this image, and it is DeepSeek's, not this project's, so the source is the
2287
+ * running DSH itself and nothing is committed (`docs/ASSET-CONTRACT.md` §1; `lib/whale.js` has the whole
2288
+ * rule). Read at ACTIVATION rather than per request — the operator, 2026-10-16: 「宿主最早加载插件的
2289
+ * 的时候就抄下来,抄不到就逐级回落」 — and the fallbacks run from DSH's own bundle, to this package's
2290
+ * `assets/whale-tail.apng`, to the copy a previous activation left in the state directory, to nothing
2291
+ * (404, and both prompts draw the static SVG, which is the app's own fallback).
2292
+ *
2293
+ * A failure here is never fatal: the plugin activates with no mark exactly as it does on a machine whose
2294
+ * checkout was never pre-seeded, so every branch is reported and none of them throws.
2295
+ */
2296
+ const whale = resolveWhaleMark({
2297
+ asar: envVar('DSH_ASAR') || undefined,
2298
+ resourcesPath: process.resourcesPath,
2299
+ installRoot: findInstallRoot(dirname(process.execPath)),
2300
+ localFile: envVar('WHALE_FILE') || join(PLUGIN_ROOT, 'assets', 'whale-tail.apng'),
2301
+ stateFile: join(importRootDir(), 'whale-tail.apng'),
2302
+ log: (message) => ctx.logger?.warn?.(message),
2303
+ });
2304
+ ctx.logger?.info?.(whale.bytes
2305
+ ? `vrmpet3d: the whale mark came from ${whale.source} (${whale.frames} frames)`
2306
+ : `vrmpet3d: no animated whale mark (${whale.error}); both prompts use the static SVG`);
2307
+
2281
2308
  // ---- 1. the pet's status stream ----------------------------------------
2282
2309
  const service = {
2283
2310
  watch(signal) { return hub.watch(signal); },
@@ -3426,41 +3453,125 @@ export function apply(ctx, config = {}) {
3426
3453
  return { text, reasoning, finish, types };
3427
3454
  };
3428
3455
 
3456
+ /**
3457
+ * Every refusal this channel can answer with, in the two languages a 碎碎念 can be asked for.
3458
+ *
3459
+ * This is the ONE surface in this file whose words reach a user rather than a log: the settings page's 试一句 row
3460
+ * prints the `error` these become, so a Chinese line was the only thing it could ever show an English user while
3461
+ * the pet itself answered in English (the system prompt below already branches on the same `language`). The
3462
+ * SHAPE is the repository's usual `{ zh: {...}, en: {...} }` table with identical key sets, read at call time.
3463
+ *
3464
+ * A message with a DIAGNOSTIC in it spells the hole `{name}`: `{detail}`, `{status}`, `{choices}`, `{finish}`,
3465
+ * `{length}`, `{ms}`. Only the sentence around the diagnostic is translated — the diagnosis itself (a finish
3466
+ * reason, an HTTP status, a character count, the adapter's own failure text) is reported verbatim in both
3467
+ * languages, because it is the thing the operator reads to find out what actually happened.
3468
+ */
3469
+ const SAY_TEXT = {
3470
+ zh: {
3471
+ requestFailed: '模型请求失败({detail})',
3472
+ noLine: '模型没有给出可用的一句话({detail})',
3473
+ noLlmService: '这个 DSH 没有提供 LLM 服务',
3474
+ noProvider: '还没有选择 DSH 的 provider',
3475
+ noModel: '还没有选择 DSH 的模型',
3476
+ noBaseUrl: '还没有填写 Base URL',
3477
+ noModelName: '还没有填写模型名',
3478
+ providerHttp: 'provider 返回 HTTP {status}',
3479
+ noLineHttp: '模型没有给出可用的一句话(HTTP 200;choices={choices};finish={finish};正文 {length} 字)',
3480
+ busy: '已经有一句在生成中了',
3481
+ noResponse: '模型没有给出可用的一句话(没有任何回应)',
3482
+ timeout: '超过 {ms} 毫秒没有回应',
3483
+ /* The WHY of a refusal, one clause per fact, in the language of the refusal itself. */
3484
+ detailReasoning: '思考 {n} 字',
3485
+ detailText: '正文 {n} 字',
3486
+ detailDeltas: '分片 {list}',
3487
+ detailJoin: ';',
3488
+ },
3489
+ en: {
3490
+ requestFailed: 'The model request failed ({detail})',
3491
+ noLine: 'The model did not return a usable line ({detail})',
3492
+ noLlmService: 'This DSH does not offer an LLM service',
3493
+ noProvider: 'No DSH provider is selected yet',
3494
+ noModel: 'No DSH model is selected yet',
3495
+ noBaseUrl: 'Base URL is empty',
3496
+ noModelName: 'Model name is empty',
3497
+ providerHttp: 'the provider answered HTTP {status}',
3498
+ noLineHttp: 'The model did not return a usable line (HTTP 200; choices={choices}; finish={finish}; {length} chars of answer)',
3499
+ busy: 'A line is already being generated',
3500
+ noResponse: 'The model did not return a usable line (no reply yet)',
3501
+ timeout: 'no reply within {ms} ms',
3502
+ detailReasoning: '{n} chars of reasoning',
3503
+ detailText: '{n} chars of answer',
3504
+ detailDeltas: 'deltas {list}',
3505
+ detailJoin: '; ',
3506
+ },
3507
+ };
3508
+
3509
+ /**
3510
+ * One of those refusals, in the language THIS request arrived in.
3511
+ *
3512
+ * The `language` is the one the route already resolved off the wire ('zh' unless the request said `'en'`) and
3513
+ * already hands to the two providers for the system prompt; nothing here re-reads the request, so a line and the
3514
+ * instruction that produced it can never disagree about which language was asked for.
3515
+ *
3516
+ * A missing key answers the KEY rather than an empty string, which is `core.textOf`'s own rule and the reason a
3517
+ * gap shows up in a suite instead of as a blank message on screen.
3518
+ */
3519
+ const sayText = (language, key, values) => {
3520
+ const line = core.textFor(SAY_TEXT, language)[key];
3521
+ if (typeof line !== 'string') return key;
3522
+ if (!values) return line;
3523
+ return line.replace(/\{(\w+)\}/g, (slot, name) => (
3524
+ Object.prototype.hasOwnProperty.call(values, name) ? String(values[name]) : slot
3525
+ ));
3526
+ };
3527
+
3429
3528
  /**
3430
3529
  * One streamed answer as a line, or a refusal that says WHAT ARRIVED.
3431
3530
  *
3432
3531
  * The detail list is the whole point: "the model produced nothing" has four causes a user cannot tell apart
3433
3532
  * (a terminal failure, a budget spent on thinking, a genuinely empty answer, a route that sent no deltas), and
3434
3533
  * 试一句 has nowhere else to look.
3534
+ *
3535
+ * `language` is threaded in rather than resolved here: this function is only ever called from the DSH branch of
3536
+ * the request, which already received it.
3435
3537
  */
3436
- const speechTextFrom = (result) => {
3538
+ const speechTextFrom = (result, language) => {
3437
3539
  const reason = result.finish || {};
3438
3540
  const kind = typeof reason.kind === 'string' ? reason.kind : null;
3439
3541
  const failure = reason.failure && typeof reason.failure === 'object' ? reason.failure : {};
3542
+ /**
3543
+ * The diagnostic's own labels are translated too, and they are the reason this builder takes a language.
3544
+ *
3545
+ * The three clauses are joined into ONE sentence that is then dropped inside a refusal — so a Chinese label
3546
+ * inside an English refusal (`正文 12 字`) reads as a half-translated error, which is exactly what an English
3547
+ * user would report. The zh halves are byte-for-byte what they were; only the en ones are new.
3548
+ */
3440
3549
  const detail = [
3441
3550
  `finish=${kind || 'none'}`,
3442
3551
  failure.code ? `${failure.code}${failure.message ? `: ${failure.message}` : ''}` : null,
3443
- result.reasoning ? `思考 ${result.reasoning} 字` : null,
3444
- `正文 ${result.text.length} 字`,
3445
- `分片 ${[...result.types.entries()].map(([type, count]) => `${type}×${count}`).join(' ') || 'none'}`,
3446
- ].filter(Boolean).join(';');
3552
+ result.reasoning ? sayText(language, 'detailReasoning', { n: result.reasoning }) : null,
3553
+ sayText(language, 'detailText', { n: result.text.length }),
3554
+ sayText(language, 'detailDeltas', {
3555
+ list: [...result.types.entries()].map(([type, count]) => `${type}×${count}`).join(' ') || 'none',
3556
+ }),
3557
+ ].filter(Boolean).join(sayText(language, 'detailJoin'));
3447
3558
  /**
3448
3559
  * A stream that FAILED is a failure even when it produced text on the way down: half a sentence from a
3449
3560
  * request that never finished is not something to put in the pet's mouth, and what the caller needs is the
3450
3561
  * reason — `AUTH`, `QUOTA`, a cancellation — not the fragment that preceded it.
3451
3562
  */
3452
- if (kind === 'error' || kind === 'aborted') return { error: `模型请求失败(${detail})` };
3563
+ if (kind === 'error' || kind === 'aborted') return { error: sayText(language, 'requestFailed', { detail }) };
3453
3564
  const text = core.cleanSpeechText(result.text, SPEECH_LINE_MAX);
3454
3565
  // `max-tokens` and `tool-calls` are NOT failures here: a budget that ran out after the one line we asked for
3455
3566
  // is a line, and the sentence is already capped. Only "nothing usable arrived" is a refusal.
3456
3567
  if (text) return { text };
3457
- return { error: `模型没有给出可用的一句话(${detail})` };
3568
+ return { error: sayText(language, 'noLine', { detail }) };
3458
3569
  };
3459
3570
 
3460
3571
  /** One call through DSH's own LLM service, which owns DSH's own credentials. */
3461
3572
  const askDshLlm = async (speech, prompt, language) => {
3462
3573
  const llm = llmService();
3463
- if (!llm || typeof llm.stream !== 'function') throw new Error('这个 DSH 没有提供 LLM 服务');
3574
+ if (!llm || typeof llm.stream !== 'function') throw new Error(sayText(language, 'noLlmService'));
3464
3575
  /**
3465
3576
  * The two fields are refused SEPARATELY, and that is worth a line of its own.
3466
3577
  *
@@ -3470,8 +3581,8 @@ export function apply(ctx, config = {}) {
3470
3581
  * first option, so the pair looked complete. One message for two missing fields is what made that
3471
3582
  * unanswerable from the screen; naming the model is what points at the picker that was lying.
3472
3583
  */
3473
- if (!speech.ai.dshProvider) throw new Error('还没有选择 DSH 的 provider');
3474
- if (!speech.ai.dshModel) throw new Error('还没有选择 DSH 的模型');
3584
+ if (!speech.ai.dshProvider) throw new Error(sayText(language, 'noProvider'));
3585
+ if (!speech.ai.dshModel) throw new Error(sayText(language, 'noModel'));
3475
3586
  const controller = new AbortController();
3476
3587
  const timer = setTimeout(() => controller.abort(), speech.ai.timeoutMs);
3477
3588
  try {
@@ -3485,7 +3596,7 @@ export function apply(ctx, config = {}) {
3485
3596
  reasoningEffort: SPEECH_REASONING_EFFORT,
3486
3597
  purpose: 'vrmpet3d-speech',
3487
3598
  signal: controller.signal,
3488
- })));
3599
+ })), language);
3489
3600
  } finally {
3490
3601
  clearTimeout(timer);
3491
3602
  }
@@ -3493,8 +3604,8 @@ export function apply(ctx, config = {}) {
3493
3604
 
3494
3605
  /** One call to the OpenAI-compatible endpoint the user named. */
3495
3606
  const askCustomLlm = async (speech, prompt, language) => {
3496
- if (!speech.ai.baseUrl) throw new Error('还没有填写 Base URL');
3497
- if (!speech.ai.model) throw new Error('还没有填写模型名');
3607
+ if (!speech.ai.baseUrl) throw new Error(sayText(language, 'noBaseUrl'));
3608
+ if (!speech.ai.model) throw new Error(sayText(language, 'noModelName'));
3498
3609
  const controller = new AbortController();
3499
3610
  const timer = setTimeout(() => controller.abort(), speech.ai.timeoutMs);
3500
3611
  try {
@@ -3517,7 +3628,7 @@ export function apply(ctx, config = {}) {
3517
3628
  }),
3518
3629
  signal: controller.signal,
3519
3630
  });
3520
- if (!response.ok) throw new Error(`provider 返回 HTTP ${response.status}`);
3631
+ if (!response.ok) throw new Error(sayText(language, 'providerHttp', { status: response.status }));
3521
3632
  const body = await response.json();
3522
3633
  const choice = body && Array.isArray(body.choices) ? body.choices[0] : null;
3523
3634
  const message = choice && choice.message ? choice.message : null;
@@ -3528,8 +3639,11 @@ export function apply(ctx, config = {}) {
3528
3639
  */
3529
3640
  if (!core.cleanSpeechText(text, SPEECH_LINE_MAX)) {
3530
3641
  return {
3531
- error: `模型没有给出可用的一句话(HTTP 200;choices=${Array.isArray(body && body.choices) ? body.choices.length : 'none'};`
3532
- + `finish=${(choice && choice.finish_reason) || 'none'};正文 ${text.length} 字)`,
3642
+ error: sayText(language, 'noLineHttp', {
3643
+ choices: Array.isArray(body && body.choices) ? body.choices.length : 'none',
3644
+ finish: (choice && choice.finish_reason) || 'none',
3645
+ length: text.length,
3646
+ }),
3533
3647
  };
3534
3648
  }
3535
3649
  return { text };
@@ -3574,7 +3688,7 @@ export function apply(ctx, config = {}) {
3574
3688
  if (sayInFlight) {
3575
3689
  sendJson(req, res, {
3576
3690
  seq: saySeq, state: box, part: null, structure: null, chain: null, text: null,
3577
- error: '已经有一句在生成中了',
3691
+ error: sayText(language, 'busy'),
3578
3692
  });
3579
3693
  return;
3580
3694
  }
@@ -3611,7 +3725,7 @@ export function apply(ctx, config = {}) {
3611
3725
  // Both providers answer the same way: `{ text }` or `{ error }` with the reason it could not be one.
3612
3726
  if (answer && answer.error) throw new Error(answer.error);
3613
3727
  const text = core.cleanSpeechText(answer && answer.text, SPEECH_LINE_MAX);
3614
- if (!text) throw new Error('模型没有给出可用的一句话(没有任何回应)');
3728
+ if (!text) throw new Error(sayText(language, 'noResponse'));
3615
3729
  sendJson(req, res, {
3616
3730
  seq,
3617
3731
  state: box,
@@ -3631,7 +3745,7 @@ export function apply(ctx, config = {}) {
3631
3745
  })
3632
3746
  .catch((error) => {
3633
3747
  const reason = error && error.name === 'AbortError'
3634
- ? `超过 ${speech.ai.timeoutMs} 毫秒没有回应`
3748
+ ? sayText(language, 'timeout', { ms: speech.ai.timeoutMs })
3635
3749
  : (error && error.message ? error.message : String(error));
3636
3750
  ctx.logger?.info?.(`vrmpet3d: the 碎碎念 request failed: ${reason}`);
3637
3751
  sendJson(req, res, {
@@ -3837,31 +3951,34 @@ export function apply(ctx, config = {}) {
3837
3951
  * (`RunningWhaleTail` in `dsh-client-ui-chat`), with a static SVG for reduced motion, forced colours, or a
3838
3952
  * browser without alpha masks. This serves the animated half of that pair to the pet's two prompts.
3839
3953
  *
3840
- * The bytes are NOT committed to this repository: `docs/ASSET-CONTRACT.md` §1 is the rule for assets the
3841
- * project did not make — load them where they already live, never copy them into the tree. So
3842
- * `scripts/whale-asset.mjs` reads the image out of the installed DSH and writes it to
3843
- * `assets/whale-tail.apng`, and this route streams that file. A checkout with no DSH, or one where the
3844
- * script has not been run, answers 404 — and both prompts fall back to the static SVG, which is the app's
3845
- * own fallback and a complete mark on its own.
3954
+ * The bytes are still NOT committed to this repository: `docs/ASSET-CONTRACT.md` §1 is the rule for assets
3955
+ * the project did not make — load them where they already live, never copy them into the tree. Since
3956
+ * 2026-10-16 that "where they already live" is the RUNNING DSH itself: `resolveWhaleMark` (called once at
3957
+ * activation, above) reads the mark out of `dsh-client-ui-chat`'s own client bundle, falling back to
3958
+ * `assets/whale-tail.apng` and then to the copy it caches in the plugin's state directory. This handler
3959
+ * only serves what that resolution found, which is why a machine with no DSH to read answers 404 and both
3960
+ * prompts fall back to the static SVG — the app's own fallback, and a complete mark on its own.
3961
+ *
3962
+ * The bytes therefore live in MEMORY for the life of the activation: the mark is one image for two
3963
+ * prompts, and re-reading a 552 KB bundle (or the fallback file) per prompt would be work per frame of the
3964
+ * user's attention rather than per boot.
3846
3965
  */
3847
3966
  const whaleHandler = (req, res) => {
3848
3967
  if (req.method !== 'GET' && req.method !== 'HEAD') {
3849
3968
  sendStatus(res, 405, 'method not allowed');
3850
3969
  return;
3851
3970
  }
3852
- let bytes = null;
3853
- try {
3854
- bytes = readFileSync(join(PLUGIN_ROOT, 'assets', 'whale-tail.apng'));
3855
- } catch (error) {
3856
- sendStatus(res, 404, 'the animated whale mark is not extracted; run: node scripts/whale-asset.mjs');
3971
+ const bytes = whale.bytes;
3972
+ if (!bytes) {
3973
+ sendStatus(res, 404, `the animated whale mark is not available (${whale.error}); both prompts fall back to the static SVG`);
3857
3974
  return;
3858
3975
  }
3859
3976
  res.writeHead(200, {
3860
3977
  'content-type': 'image/apng',
3861
3978
  'content-length': bytes.length,
3862
3979
  /**
3863
- * `no-store` like every other route here: the file is 21 KB, it is read from the same disk, and a
3864
- * prompt that kept a stale mark after the asset was re-extracted would be a mystery rather than a
3980
+ * `no-store` like every other route here: the image is 21 KB, it is already in memory, and a prompt
3981
+ * that kept a stale mark after a DSH update handed over a new one would be a mystery rather than a
3865
3982
  * cache hit.
3866
3983
  */
3867
3984
  'cache-control': 'no-store',
@@ -30,47 +30,73 @@
30
30
  var MODEL_ACCEPT = '.vrm,.glb,.gltf';
31
31
  var MODEL_PATTERN = /\.(vrm|glb|gltf)$/i;
32
32
 
33
+ /**
34
+ * This surface's own wording, in the two languages it draws in: `TEXT.zh` is the Chinese half, and the English
35
+ * twin is the literal {@link resolveText} returns for `'en'`.
36
+ *
37
+ * TWO LITERALS, one per language, rather than two columns of one table — because each half has to be written as
38
+ * its own language rather than as a translation of the other, which is also why `lib/menu.js` spells its
39
+ * `LABELS` out twice. Both halves are written in FULL: a half that is merely a gloss on the other is what makes
40
+ * a half-translated interface.
41
+ *
42
+ * THE TWO KEY SETS MUST BE IDENTICAL. A key the Chinese half has and the English does not falls back to Chinese
43
+ * mid-sentence (`core.textFor`), which is the defect a suite asserts against — and that includes the
44
+ * FUNCTION-valued entries: they stay functions in both halves because that is what lets the English put its
45
+ * words in its own order (`creditAuthor(author)`) rather than in the Chinese sentence's order.
46
+ */
33
47
  var TEXT = {
34
- dropTitle: '点击选择模型文件',
35
- dropSub: '或把 ' + MODEL_ACCEPT.split(',').join(' / ') + ' 拖到这里',
36
- reading: '正在读取模型列表…',
37
- listFailed: '模型列表读取失败。',
38
- empty: '还没有可用的模型。',
39
- count: function (total, imported) {
40
- return '共 ' + total + ' 个模型' + (imported ? '(其中 ' + imported + ' 个是导入的)' : '') + '。';
48
+ zh: {
49
+ dropTitle: '点击选择模型文件',
50
+ dropSub: '或把 ' + MODEL_ACCEPT.split(',').join(' / ') + ' 拖到这里',
51
+ reading: '正在读取模型列表…',
52
+ listFailed: '模型列表读取失败。',
53
+ empty: '还没有可用的模型。',
54
+ gridLabel: '现有模型',
55
+ count: function (total, imported) {
56
+ return '共 ' + total + ' 个模型' + (imported ? '(其中 ' + imported + ' 个是导入的)' : '') + '。';
57
+ },
58
+ tagBundled: '自带',
59
+ tagUser: '已导入',
60
+ using: '使用中',
61
+ remove: '移除这个导入的模型',
62
+ importing: '正在导入模型…',
63
+ importingFile: function (name) { return '正在导入 ' + name + '…'; },
64
+ imported: function (name) { return '已导入并切换:' + name; },
65
+ importedNoSwitch: function (name) { return '已导入:' + name; },
66
+ importFailed: '导入失败:',
67
+ switched: function (name) { return '已切换模型:' + name; },
68
+ switching: function (name) { return '正在切换模型:' + name + '…'; },
69
+ switchingBusy: '正在切换中,等这一只加载完再点。',
70
+ switchingSlow: '仍在加载。',
71
+ switchingTimeout: function (name) { return '切换超时:' + name + ' 可能没能加载,看看桌宠上的提示。'; },
72
+ noModel: '这个文件不是模型(只接受 .vrm / .glb / .gltf)。',
73
+ dropBusy: '正在导入,请等它读完。',
74
+ noFetchUpload: '这个页面不能上传文件。',
75
+ removed: '已移除插件复制的那一份。',
76
+ removeFailed: '移除失败:',
77
+ /**
78
+ * 自带模型的来源与作者 — the operator's own requirement (2026-10-13: 「需要注明来源和作者」).
79
+ *
80
+ * The WORDS live here with every other string; the FACTS come from the host, which is the only writer of a
81
+ * catalogue entry's `credit` (`BUNDLED_MODEL_CREDITS` in `lib/index.js`). A credit typed into this file as
82
+ * well would be a second copy of who made what.
83
+ */
84
+ creditTitle: '插件自带模型',
85
+ creditName: function (title, file) { return title ? title + '(' + file + ')' : file; },
86
+ creditAuthor: function (author) { return '作者:' + author; },
87
+ creditLicense: function (license) { return '授权:' + license; },
88
+ creditSource: function (source) { return '来源:' + source; },
41
89
  },
42
- tagBundled: '自带',
43
- tagUser: '已导入',
44
- using: '使用中',
45
- remove: '移除这个导入的模型',
46
- importing: '正在导入模型…',
47
- importingFile: function (name) { return '正在导入 ' + name + '…'; },
48
- imported: function (name) { return '已导入并切换:' + name; },
49
- importedNoSwitch: function (name) { return '已导入:' + name; },
50
- importFailed: '导入失败:',
51
- switched: function (name) { return '已切换模型:' + name; },
52
- switching: function (name) { return '正在切换模型:' + name + '…'; },
53
- switchingBusy: '正在切换中,等这一只加载完再点。',
54
- switchingSlow: '仍在加载。',
55
- switchingTimeout: function (name) { return '切换超时:' + name + ' 可能没能加载,看看桌宠上的提示。'; },
56
- noModel: '这个文件不是模型(只接受 .vrm / .glb / .gltf)。',
57
- dropBusy: '正在导入,请等它读完。',
58
- removed: '已移除插件复制的那一份。',
59
- removeFailed: '移除失败:',
60
- /**
61
- * 自带模型的来源与作者 — the operator's own requirement (2026-10-13: 「需要注明来源和作者」).
62
- *
63
- * The WORDS live here with every other string; the FACTS come from the host, which is the only writer of a
64
- * catalogue entry's `credit` (`BUNDLED_MODEL_CREDITS` in `lib/index.js`). A credit typed into this file as
65
- * well would be a second copy of who made what.
66
- */
67
- creditTitle: '插件自带模型',
68
- creditName: function (title, file) { return title ? title + '(' + file + ')' : file; },
69
- creditAuthor: function (author) { return '作者:' + author; },
70
- creditLicense: function (license) { return '授权:' + license; },
71
- creditSource: function (source) { return '来源:' + source; },
72
90
  };
73
91
 
92
+ /**
93
+ * This surface's words in one of its two languages — the accessor every caller goes through, and what
94
+ * `__VRMPET3D_MODEL_PICKER__.strings` exposes to a suite.
95
+ *
96
+ * `'zh'` (and anything that is not `'en'`, which includes a caller that passed no language at all) gets the
97
+ * Chinese half, the default this component has always had. `'auto'` is NOT resolved here: the caller knows what
98
+ * the environment says and passes the answer in, so a component that never sees a store cannot guess wrong.
99
+ */
74
100
  function resolveText(language) {
75
101
  if (language === 'en') {
76
102
  return {
@@ -79,8 +105,9 @@
79
105
  reading: 'Reading the model list…',
80
106
  listFailed: 'Could not read the model list.',
81
107
  empty: 'No models available yet.',
108
+ gridLabel: 'Available models',
82
109
  count: function (total, imported) {
83
- return total + ' models' + (imported ? ' (' + imported + ' imported)' : '') + '.';
110
+ return total + (total === 1 ? ' model' : ' models') + (imported ? ' (' + imported + ' imported)' : '') + '.';
84
111
  },
85
112
  tagBundled: 'built-in',
86
113
  tagUser: 'imported',
@@ -96,18 +123,19 @@
96
123
  switchingBusy: 'A switch is already running — wait for this one to load.',
97
124
  switchingSlow: 'Still loading.',
98
125
  switchingTimeout: function (name) { return 'The switch timed out: ' + name + ' may not have loaded (check the pet for a message).'; },
99
- noModel: 'That is not a model this plugin loads (.vrm / .glb / .gltf only).',
100
- dropBusy: 'An import is already running.',
126
+ noModel: 'That file is not a model this plugin can load (.vrm / .glb / .gltf only).',
127
+ dropBusy: 'An import is already running — wait for it to finish.',
128
+ noFetchUpload: 'This page cannot upload files.',
101
129
  removed: 'Removed the plugin\'s own copy.',
102
130
  removeFailed: 'Could not remove it: ',
103
- creditTitle: 'Models this plugin ships',
131
+ creditTitle: 'Bundled models',
104
132
  creditName: function (title, file) { return title ? title + ' (' + file + ')' : file; },
105
133
  creditAuthor: function (author) { return 'Author: ' + author; },
106
- creditLicense: function (license) { return 'Terms: ' + license; },
134
+ creditLicense: function (license) { return 'License: ' + license; },
107
135
  creditSource: function (source) { return 'Source: ' + source; },
108
136
  };
109
137
  }
110
- return TEXT;
138
+ return TEXT.zh;
111
139
  }
112
140
 
113
141
  function element(doc, tag, className, value) {
@@ -133,7 +161,9 @@
133
161
  * @param {object} options
134
162
  * document the document to build in (defaults to the global one)
135
163
  * mount the element to fill — the component owns everything inside it
136
- * language 'zh' (default) or 'en'
164
+ * language 'zh' (default) or 'en' — the language it is BUILT in; `api.setLanguage` changes it later
165
+ * without a rebuild, which is how a caller whose language comes from the settings store
166
+ * (the 切换模型 page) follows a change while the grid is on screen
137
167
  * bridge the launcher bridge, or null to force the browser path (defaults to
138
168
  * `globalThis.vrmpet3dDesktop`); a page with no bridge still gets a working picker
139
169
  * fetchImpl fetch override, for a probe
@@ -154,7 +184,16 @@
154
184
  var mount = settings.mount;
155
185
  if (!doc || !mount) throw new Error('createModelPicker needs a document and a mount element');
156
186
 
157
- var text = resolveText(settings.language);
187
+ /**
188
+ * The language this picker draws in, and its table.
189
+ *
190
+ * Resolved ONCE here, from `options.language` — 'zh' unless the caller says 'en', which is the default this
191
+ * option has always had (the wizard mounts with 'zh'). A caller whose language can change while the picker is on
192
+ * screen calls {@link api.setLanguage} instead of rebuilding: the 切换模型 page is exactly that caller, because
193
+ * its language comes from the settings store it polls.
194
+ */
195
+ var language = settings.language === 'en' ? 'en' : 'zh';
196
+ var text = resolveText(language);
158
197
  var onSwitch = typeof settings.onSwitch === 'function' ? settings.onSwitch : function () {};
159
198
  var onStatus = typeof settings.onStatus === 'function' ? settings.onStatus : function () {};
160
199
  var doFetch = settings.fetchImpl || (typeof fetch === 'function' ? fetch.bind(globalThis) : null);
@@ -212,7 +251,7 @@
212
251
  input.hidden = true;
213
252
 
214
253
  var gridHead = element(doc, 'div', 'model-grid-head');
215
- var gridLabel = element(doc, 'span', 'k', '现有模型');
254
+ var gridLabel = element(doc, 'span', 'k', text.gridLabel);
216
255
  var gridCount = element(doc, 'span', 'note model-grid-count');
217
256
  gridHead.appendChild(gridLabel);
218
257
  gridHead.appendChild(gridCount);
@@ -385,7 +424,7 @@
385
424
  }
386
425
 
387
426
  function requestJson(path, body) {
388
- if (!doFetch) return Promise.reject(new Error('no fetch in this document'));
427
+ if (!doFetch) return Promise.reject(new Error(text.noFetchUpload));
389
428
  return doFetch(path, {
390
429
  method: 'POST',
391
430
  credentials: 'omit',
@@ -494,6 +533,32 @@
494
533
  });
495
534
  }
496
535
 
536
+ /**
537
+ * Draw everything again in the CURRENT `text` — the whole of a language change.
538
+ *
539
+ * Three static labels are written at construction (the drop zone's two lines and the grid's heading) and one
540
+ * more is stateful (the progress bar, whose two lines are set when a switch STARTS). Everything else already
541
+ * comes out of `render()`, so repainting the grid covers the tiles, the tags, the 使用中 badge, the count, the
542
+ * empty note and the credit lines — and the bar is rewritten here so a switch that is in flight says the same
543
+ * thing in the new language instead of freezing in the old one until it settles.
544
+ */
545
+ function relabel() {
546
+ dropTitle.textContent = text.dropTitle;
547
+ dropSub.textContent = text.dropSub;
548
+ gridLabel.textContent = text.gridLabel;
549
+ if (switching) {
550
+ progressText.textContent = text.switching(switching.name);
551
+ progressNote.textContent = switching.slow ? text.switchingSlow : '';
552
+ progress.setAttribute('aria-valuetext', text.switching(switching.name));
553
+ // And the CALLER's line, which is where the same sentence usually lives: without this the bar would say
554
+ // 「正在切换模型…」 while the status line above it stayed in the language the switch STARTED in. The
555
+ // component owns this text, so it is the component that says it again — through the same channel it was
556
+ // first said on.
557
+ onStatus(text.switching(switching.name));
558
+ }
559
+ render();
560
+ }
561
+
497
562
  /**
498
563
  * Write out 来源 and 作者 for every bundled model the host sent a credit for.
499
564
  *
@@ -621,7 +686,7 @@
621
686
  return Promise.resolve(null);
622
687
  }
623
688
  if (!doFetch) {
624
- onStatus(text.importFailed + 'no fetch in this document', 'warn');
689
+ onStatus(text.noFetchUpload, 'warn');
625
690
  return Promise.resolve(null);
626
691
  }
627
692
  setBusy(true, text.importingFile(name));
@@ -690,7 +755,7 @@
690
755
  function pickNative() {
691
756
  var chooser = bridge && typeof bridge.pickModelFile === 'function' ? bridge.pickModelFile : null;
692
757
  if (!chooser) return false;
693
- Promise.resolve(chooser()).then(function (chosen) { return chosen ? importPath(chosen) : null; });
758
+ Promise.resolve(chooser(language)).then(function (chosen) { return chosen ? importPath(chosen) : null; });
694
759
  return true;
695
760
  }
696
761
 
@@ -750,6 +815,30 @@
750
815
  * handler calls it — see `settleSwitch`.
751
816
  */
752
817
  setCurrent: function () { pendingId = null; render(); settleSwitch(); },
818
+ /**
819
+ * Draw in another language, WITHOUT being rebuilt.
820
+ *
821
+ * `options.language` is fixed at construction, and that is right for a wizard step whose language was settled
822
+ * before the step was reached. The 切换模型 page is the other case: its language comes from the settings store
823
+ * it polls, so it can change while the picker is on screen. Rebuilding would be the easy answer and the wrong
824
+ * one — it would throw away a switch's progress bar, its pending tile and the catalogue it already has, so a
825
+ * language change would cost a second round trip and lose the user's click. `relabel()` is what this changes:
826
+ * the same nodes, the same state, new words.
827
+ *
828
+ * A language this component does not know ('auto', undefined, a typo) is IGNORED rather than treated as
829
+ * Chinese: the picker keeps the table it has, so a caller that passes the store's raw preference through
830
+ * cannot flip a page to Chinese by accident.
831
+ *
832
+ * @returns {boolean} whether the picker redrew — false for "same language" and for "not a language I know"
833
+ */
834
+ setLanguage: function (next) {
835
+ var wanted = next === 'en' ? 'en' : (next === 'zh' ? 'zh' : null);
836
+ if (!wanted || wanted === language) return false;
837
+ language = wanted;
838
+ text = resolveText(language);
839
+ relabel();
840
+ return true;
841
+ },
753
842
  /** Adopt a catalog payload a caller already has (the guide's own GET, a probe). */
754
843
  apply: apply,
755
844
  /** Import a path (the launcher's dialog) or a File (a drop, a browser input). */
@@ -766,6 +855,7 @@
766
855
  }),
767
856
  total: models.length,
768
857
  imported: imported.length,
858
+ language: language,
769
859
  current: currentId(),
770
860
  busy: busy,
771
861
  switching: switching ? switching.id : null,