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/README.md +2 -0
- package/desktop/main.js +45 -3
- package/desktop/preload.js +6 -2
- package/lib/client.core.js +209 -13
- package/lib/client.js +278 -42
- package/lib/config-page.html +71 -45
- package/lib/config-page.js +1120 -229
- package/lib/core.js +209 -13
- package/lib/desktop-page.js +144 -8
- package/lib/global-page.html +140 -99
- package/lib/index.js +150 -33
- package/lib/model-picker.js +139 -49
- package/lib/picker-page.html +34 -8
- package/lib/picker-page.js +192 -11
- package/lib/setup-page.html +68 -49
- package/lib/setup-page.js +395 -42
- package/lib/whale.js +191 -0
- package/package.json +1 -1
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 ?
|
|
3444
|
-
|
|
3445
|
-
|
|
3446
|
-
|
|
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:
|
|
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:
|
|
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(
|
|
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(
|
|
3474
|
-
if (!speech.ai.dshModel) throw new Error(
|
|
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(
|
|
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(
|
|
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:
|
|
3532
|
-
|
|
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
|
-
?
|
|
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
|
|
3841
|
-
* project did not make — load them where they already live, never copy them into the tree.
|
|
3842
|
-
*
|
|
3843
|
-
*
|
|
3844
|
-
*
|
|
3845
|
-
*
|
|
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
|
-
|
|
3853
|
-
|
|
3854
|
-
|
|
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
|
|
3864
|
-
*
|
|
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',
|
package/lib/model-picker.js
CHANGED
|
@@ -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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
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: '
|
|
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 '
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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,
|