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/index.js CHANGED
@@ -1,4 +1,4 @@
1
- // dsh-plugin-vrmpet3d ?host half.
1
+ // dsh-plugin-vrmpet3d — host half.
2
2
  //
3
3
  // Responsibilities, and deliberately nothing else:
4
4
  //
@@ -7,8 +7,8 @@
7
7
  // text, tool arguments and attachments are never read, stored or forwarded.
8
8
  // 2. Serve the user's own model and authored-motion files to the page over the
9
9
  // web carrier's `/vrmpet3d` routes. A plugin's `assets/` directory is NOT served
10
- // to the browser by DSH ?the only plugin-owned browser routes are
11
- // `/plugins/<id>/client*.js` ?so a model or a `.vrma` the user drops
10
+ // to the browser by DSH — the only plugin-owned browser routes are
11
+ // `/plugins/<id>/client*.js` — so a model or a `.vrma` the user drops
12
12
  // on disk can only reach the page through a host route. Every route is
13
13
  // allowlisted to the configured directories and never exposes an absolute
14
14
  // path.
@@ -26,7 +26,7 @@ import { SettingsStore, settingsFileFor } from './settings.js';
26
26
  // `VRMPET3D_*` with the pre-rename `PET3D_*` names as a fallback — see the module's own note.
27
27
  import { envVar } from './env.js';
28
28
 
29
- // `lib/core.js` is a classic script that publishes itself on the global ?that is
29
+ // `lib/core.js` is a classic script that publishes itself on the global — that is
30
30
  // how the client half reads it too, so importing it here is what keeps ONE list
31
31
  // of file extensions in the project. A host-side copy of the motion extensions is
32
32
  // exactly the kind of mirror that drifts: the browser would offer the user a
@@ -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
@@ -75,7 +78,7 @@ const IGNORED_DIRECTORIES = new Set(['node_modules', '.git', '.svn', '__pycache_
75
78
  *
76
79
  * Taken from `lib/core.js` rather than listed again here. The motion catalog
77
80
  * beside it is built from `core.MOTION_EXTENSIONS`, and the two must stay
78
- * disjoint ?a file offered by both catalogs, or by neither, is a class of bug
81
+ * disjoint — a file offered by both catalogs, or by neither, is a class of bug
79
82
  * that no single route can see, so membership is decided in one place.
80
83
  */
81
84
  const MODEL_EXTENSIONS = new Set(core.MODEL_EXTENSIONS);
@@ -127,14 +130,14 @@ export const ROUTES = {
127
130
  /** The desktop window's page, its sources, and its on/off control. */
128
131
  window: '/vrmpet3d/window',
129
132
  /**
130
- * The page's own sources, as a prefix ?WITHOUT a trailing slash.
133
+ * The page's own sources, as a prefix — WITHOUT a trailing slash.
131
134
  *
132
135
  * DSH's matcher decides a prefix hit with `pathname === prefix || pathname.startsWith(prefix + '/')`,
133
136
  * so a registered prefix that already ends in `/` can only ever match the literal
134
137
  * `/vrmpet3d/window/lib//...`, which no browser sends. The first version of this route had the
135
138
  * trailing slash and every source 404'd: the window opened, the page loaded, the glue never
136
139
  * ran, and the pet was simply invisible. Every other prefix here works because it has no
137
- * trailing slash ?which is the tell that this one was wrong.
140
+ * trailing slash — which is the tell that this one was wrong.
138
141
  */
139
142
  windowLib: '/vrmpet3d/window/lib',
140
143
  desktop: '/vrmpet3d/desktop',
@@ -1227,7 +1230,7 @@ export function motionLoops(manifest, name) {
1227
1230
  *
1228
1231
  * Compares by ID, never by object identity. `agents.list()` is a live registry
1229
1232
  * whose returned objects are not guaranteed to be the same references that
1230
- * `agents.roots()` holds ?a copy, a proxy or a re-read all produce
1233
+ * `agents.roots()` holds — a copy, a proxy or a re-read all produce
1231
1234
  * `roots().includes(agent) === false` and would silently classify EVERY session
1232
1235
  * as a subagent, which turns the whole pet off. That failure is invisible: the
1233
1236
  * pet simply never reacts.
@@ -1281,7 +1284,7 @@ export function isInside(root, absolute) {
1281
1284
  * directory that holds it, but a legitimate request also addresses the model's own
1282
1285
  * file, so paths are interpreted relative to that directory. With `..` permitted,
1283
1286
  * a path can climb above the declared directory and still resolve to something
1284
- * inside the root's parent ?a containment check against the wrong baseline serves
1287
+ * inside the root's parent — a containment check against the wrong baseline serves
1285
1288
  * it quite happily. Rejecting `..` outright removes the class instead of arguing
1286
1289
  * about which baselines happen to be safe.
1287
1290
  */
@@ -1310,7 +1313,7 @@ export function resolveAssetPath(root, relative) {
1310
1313
 
1311
1314
  let modelIdCounter = 0;
1312
1315
 
1313
- /** Absolute path ?id, so a rescan hands the same file back the same id. */
1316
+ /** Absolute path → id, so a rescan hands the same file back the same id. */
1314
1317
  const modelIdsByPath = new Map();
1315
1318
 
1316
1319
  /**
@@ -1324,7 +1327,7 @@ const modelIdsByPath = new Map();
1324
1327
  *
1325
1328
  * That breaks the page in a way that looks like a broken model rather than a broken
1326
1329
  * id: the client persists the selected `modelId`, reloads it on mount and after a
1327
- * page refresh, and asks `/vrmpet3d/asset?m=<saved id>` ?which then 404s and drops the
1330
+ * page refresh, and asks `/vrmpet3d/asset?m=<saved id>` — which then 404s and drops the
1328
1331
  * pet back to the placeholder. The id is therefore a property of the path, not of the
1329
1332
  * scan.
1330
1333
  *
@@ -1427,7 +1430,7 @@ function scanDirectory(root, options = {}) {
1427
1430
  }
1428
1431
 
1429
1432
  /**
1430
- * Build the catalog and the id ?file mapping in one pass.
1433
+ * Build the catalog and the id → file mapping in one pass.
1431
1434
  *
1432
1435
  * @param {object} config
1433
1436
  * @param {string[]} [config.modelDirs] extra directories supplied by the user
@@ -1495,7 +1498,7 @@ export function buildCatalog(config = {}, limits = {}) {
1495
1498
 
1496
1499
  let motionIdCounter = 0;
1497
1500
 
1498
- /** Absolute path ?id, so a rescan hands the same file back the same id. */
1501
+ /** Absolute path → id, so a rescan hands the same file back the same id. */
1499
1502
  const motionIdsByPath = new Map();
1500
1503
 
1501
1504
  /**
@@ -1505,7 +1508,7 @@ const motionIdsByPath = new Map();
1505
1508
  *
1506
1509
  * * The `'a'` prefix gives motions a namespace of their own. Model ids carry an
1507
1510
  * `'m'` and come from a separate counter that also starts at 1, so without
1508
- * the prefix the first motion and the first model would both be `1` ?the two
1511
+ * the prefix the first motion and the first model would both be `1` — the two
1509
1512
  * maps hold different files, and a page asking one route for the other's id
1510
1513
  * would be served the wrong kind of file by a lookup that reported success.
1511
1514
  * * The id does not encode the path, because an id travels to the browser and
@@ -1536,7 +1539,7 @@ export function bundledMotionDir() {
1536
1539
  }
1537
1540
 
1538
1541
  /**
1539
- * Build the motion catalog and the id ?file mapping in one pass.
1542
+ * Build the motion catalog and the id → file mapping in one pass.
1540
1543
  *
1541
1544
  * The plugin's own `assets/motions` is scanned FIRST (source `bundled`), exactly as `assets/models` is for
1542
1545
  * models, so the shipped pack is offered out of the box instead of waiting for a user to import a directory.
@@ -1545,7 +1548,7 @@ export function bundledMotionDir() {
1545
1548
  * apart by the same key the model catalog uses.
1546
1549
  *
1547
1550
  * Membership is decided by the extension, never by the bytes. That is the only
1548
- * test that can keep this catalog disjoint from the model catalog ?a GLB that
1551
+ * test that can keep this catalog disjoint from the model catalog — a GLB that
1549
1552
  * happens to carry `VRMC_vrm_animation` is still a `.glb`, and it must not turn
1550
1553
  * up in both lists because the page fetches the two lists by different routes.
1551
1554
  * `core.detectMotionFormat` remains the client's check that a file it was handed
@@ -1802,7 +1805,7 @@ function sendJson(req, res, payload) {
1802
1805
  * Read a small JSON request body, or answer 413/400 and return without calling back.
1803
1806
  *
1804
1807
  * One policy for every POST route: a bounded body, an honest status for each refusal. Two
1805
- * copies of this drifted before ?one capped at 64 KB and one not ?and the route without the
1808
+ * copies of this drifted before — one capped at 64 KB and one not — and the route without the
1806
1809
  * cap is the one that reads an arbitrary upload into memory.
1807
1810
  */
1808
1811
  function readJsonBody(req, res, limit, onBody) {
@@ -2012,7 +2015,7 @@ export function createModelInfoHandler(store) {
2012
2015
  }
2013
2016
 
2014
2017
  /**
2015
- * `/vrmpet3d/asset?m=<id>&p=<relative>` ?stream one model or texture file.
2018
+ * `/vrmpet3d/asset?m=<id>&p=<relative>` — stream one model or texture file.
2016
2019
  *
2017
2020
  * Streamed rather than buffered: VRM files are routinely tens of MB and
2018
2021
  * a read-into-memory route would spike the host's heap on every reload. Only
@@ -2160,11 +2163,11 @@ export function createModelThumbnailHandler(store) {
2160
2163
  }
2161
2164
 
2162
2165
  /**
2163
- * `/vrmpet3d/motion?m=<id>` ?stream one authored-motion file.
2166
+ * `/vrmpet3d/motion?m=<id>` — stream one authored-motion file.
2164
2167
  *
2165
2168
  * Deliberately has no `p` parameter. A model needs one because its textures sit
2166
2169
  * in the same directory; a `.vrma` is self-contained, so the only file
2167
- * this route can ever name is the exact one an id was scanned from ?nothing
2170
+ * this route can ever name is the exact one an id was scanned from — nothing
2168
2171
  * beside it becomes reachable, and there is no relative path to get wrong.
2169
2172
  *
2170
2173
  * A COMPRESSED clip (`<name>.vrma.br`, the dance pack) is unpacked HERE, so the bytes that reach the renderer
@@ -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); },
@@ -2393,7 +2420,7 @@ export function apply(ctx, config = {}) {
2393
2420
  };
2394
2421
 
2395
2422
  /**
2396
- * `/vrmpet3d/motions` ?the motion catalog, shaped like `/vrmpet3d/catalog`.
2423
+ * `/vrmpet3d/motions` — the motion catalog, shaped like `/vrmpet3d/catalog`.
2397
2424
  *
2398
2425
  * Its own route rather than a field of the model catalog: the two are rebuilt
2399
2426
  * from separate directories, and the settings panel opens one picker at a time.
@@ -2770,7 +2797,7 @@ export function apply(ctx, config = {}) {
2770
2797
  * The always-on-top window, as a separate process.
2771
2798
  *
2772
2799
  * A plugin cannot ask DSH for a window (the host half has no Electron, the client half's IPC
2773
- * surface has no such channel ?see HANDOFF sec.12), so the window is started here: the
2800
+ * surface has no such channel — see HANDOFF sec.12), so the window is started here: the
2774
2801
  * launcher in `desktop/` is run against a bare-Electron runtime borrowed from the
2775
2802
  * installation by hard links. `lib/desktop.js` owns all of that; this half owns the routes
2776
2803
  * that switch it on and off, and the requirement that it dies with the plugin.
@@ -2834,10 +2861,12 @@ export function apply(ctx, config = {}) {
2834
2861
  * repeatable, the counter is not, so a preview that is set twice in a row is still noticed once.
2835
2862
  */
2836
2863
  let previewValue = null;
2864
+ let navigation = { seq: 0, sessionId: null };
2865
+ const navigationCursor = { waiting: null, completed: null };
2837
2866
  let previewSeq = 0;
2838
2867
  const previewHandler = (req, res) => {
2839
2868
  if (req.method === 'GET' || req.method === 'HEAD') {
2840
- sendJson(req, res, { seq: previewSeq, preview: previewValue });
2869
+ sendJson(req, res, { seq: previewSeq, preview: previewValue, navigation });
2841
2870
  return;
2842
2871
  }
2843
2872
  if (req.method !== 'POST') {
@@ -2909,9 +2938,13 @@ export function apply(ctx, config = {}) {
2909
2938
  const value = Number(raw[key]);
2910
2939
  if (Number.isFinite(value)) out[key] = Math.floor(value);
2911
2940
  }
2912
- for (const key of ['scope', 'session', 'interaction']) {
2941
+ for (const key of ['scope', 'session', 'interaction', 'statusSessionId', 'completedSessionId']) {
2913
2942
  if (typeof raw[key] === 'string' && raw[key]) out[key] = raw[key].slice(0, 64);
2914
2943
  }
2944
+ for (const key of ['waitingSessionIds', 'completedSessionIds']) {
2945
+ if (Array.isArray(raw[key])) out[key] = [...new Set(raw[key].filter(id => typeof id === 'string' && id))]
2946
+ .slice(0, 256).map(id => id.slice(0, 64));
2947
+ }
2915
2948
  if (Array.isArray(raw.runningIds)) {
2916
2949
  out.runningIds = raw.runningIds
2917
2950
  .filter((id) => typeof id === 'string' && id)
@@ -3426,41 +3459,125 @@ export function apply(ctx, config = {}) {
3426
3459
  return { text, reasoning, finish, types };
3427
3460
  };
3428
3461
 
3462
+ /**
3463
+ * Every refusal this channel can answer with, in the two languages a 碎碎念 can be asked for.
3464
+ *
3465
+ * This is the ONE surface in this file whose words reach a user rather than a log: the settings page's 试一句 row
3466
+ * prints the `error` these become, so a Chinese line was the only thing it could ever show an English user while
3467
+ * the pet itself answered in English (the system prompt below already branches on the same `language`). The
3468
+ * SHAPE is the repository's usual `{ zh: {...}, en: {...} }` table with identical key sets, read at call time.
3469
+ *
3470
+ * A message with a DIAGNOSTIC in it spells the hole `{name}`: `{detail}`, `{status}`, `{choices}`, `{finish}`,
3471
+ * `{length}`, `{ms}`. Only the sentence around the diagnostic is translated — the diagnosis itself (a finish
3472
+ * reason, an HTTP status, a character count, the adapter's own failure text) is reported verbatim in both
3473
+ * languages, because it is the thing the operator reads to find out what actually happened.
3474
+ */
3475
+ const SAY_TEXT = {
3476
+ zh: {
3477
+ requestFailed: '模型请求失败({detail})',
3478
+ noLine: '模型没有给出可用的一句话({detail})',
3479
+ noLlmService: '这个 DSH 没有提供 LLM 服务',
3480
+ noProvider: '还没有选择 DSH 的 provider',
3481
+ noModel: '还没有选择 DSH 的模型',
3482
+ noBaseUrl: '还没有填写 Base URL',
3483
+ noModelName: '还没有填写模型名',
3484
+ providerHttp: 'provider 返回 HTTP {status}',
3485
+ noLineHttp: '模型没有给出可用的一句话(HTTP 200;choices={choices};finish={finish};正文 {length} 字)',
3486
+ busy: '已经有一句在生成中了',
3487
+ noResponse: '模型没有给出可用的一句话(没有任何回应)',
3488
+ timeout: '超过 {ms} 毫秒没有回应',
3489
+ /* The WHY of a refusal, one clause per fact, in the language of the refusal itself. */
3490
+ detailReasoning: '思考 {n} 字',
3491
+ detailText: '正文 {n} 字',
3492
+ detailDeltas: '分片 {list}',
3493
+ detailJoin: ';',
3494
+ },
3495
+ en: {
3496
+ requestFailed: 'The model request failed ({detail})',
3497
+ noLine: 'The model did not return a usable line ({detail})',
3498
+ noLlmService: 'This DSH does not offer an LLM service',
3499
+ noProvider: 'No DSH provider is selected yet',
3500
+ noModel: 'No DSH model is selected yet',
3501
+ noBaseUrl: 'Base URL is empty',
3502
+ noModelName: 'Model name is empty',
3503
+ providerHttp: 'the provider answered HTTP {status}',
3504
+ noLineHttp: 'The model did not return a usable line (HTTP 200; choices={choices}; finish={finish}; {length} chars of answer)',
3505
+ busy: 'A line is already being generated',
3506
+ noResponse: 'The model did not return a usable line (no reply yet)',
3507
+ timeout: 'no reply within {ms} ms',
3508
+ detailReasoning: '{n} chars of reasoning',
3509
+ detailText: '{n} chars of answer',
3510
+ detailDeltas: 'deltas {list}',
3511
+ detailJoin: '; ',
3512
+ },
3513
+ };
3514
+
3515
+ /**
3516
+ * One of those refusals, in the language THIS request arrived in.
3517
+ *
3518
+ * The `language` is the one the route already resolved off the wire ('zh' unless the request said `'en'`) and
3519
+ * already hands to the two providers for the system prompt; nothing here re-reads the request, so a line and the
3520
+ * instruction that produced it can never disagree about which language was asked for.
3521
+ *
3522
+ * A missing key answers the KEY rather than an empty string, which is `core.textOf`'s own rule and the reason a
3523
+ * gap shows up in a suite instead of as a blank message on screen.
3524
+ */
3525
+ const sayText = (language, key, values) => {
3526
+ const line = core.textFor(SAY_TEXT, language)[key];
3527
+ if (typeof line !== 'string') return key;
3528
+ if (!values) return line;
3529
+ return line.replace(/\{(\w+)\}/g, (slot, name) => (
3530
+ Object.prototype.hasOwnProperty.call(values, name) ? String(values[name]) : slot
3531
+ ));
3532
+ };
3533
+
3429
3534
  /**
3430
3535
  * One streamed answer as a line, or a refusal that says WHAT ARRIVED.
3431
3536
  *
3432
3537
  * The detail list is the whole point: "the model produced nothing" has four causes a user cannot tell apart
3433
3538
  * (a terminal failure, a budget spent on thinking, a genuinely empty answer, a route that sent no deltas), and
3434
3539
  * 试一句 has nowhere else to look.
3540
+ *
3541
+ * `language` is threaded in rather than resolved here: this function is only ever called from the DSH branch of
3542
+ * the request, which already received it.
3435
3543
  */
3436
- const speechTextFrom = (result) => {
3544
+ const speechTextFrom = (result, language) => {
3437
3545
  const reason = result.finish || {};
3438
3546
  const kind = typeof reason.kind === 'string' ? reason.kind : null;
3439
3547
  const failure = reason.failure && typeof reason.failure === 'object' ? reason.failure : {};
3548
+ /**
3549
+ * The diagnostic's own labels are translated too, and they are the reason this builder takes a language.
3550
+ *
3551
+ * The three clauses are joined into ONE sentence that is then dropped inside a refusal — so a Chinese label
3552
+ * inside an English refusal (`正文 12 字`) reads as a half-translated error, which is exactly what an English
3553
+ * user would report. The zh halves are byte-for-byte what they were; only the en ones are new.
3554
+ */
3440
3555
  const detail = [
3441
3556
  `finish=${kind || 'none'}`,
3442
3557
  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(';');
3558
+ result.reasoning ? sayText(language, 'detailReasoning', { n: result.reasoning }) : null,
3559
+ sayText(language, 'detailText', { n: result.text.length }),
3560
+ sayText(language, 'detailDeltas', {
3561
+ list: [...result.types.entries()].map(([type, count]) => `${type}×${count}`).join(' ') || 'none',
3562
+ }),
3563
+ ].filter(Boolean).join(sayText(language, 'detailJoin'));
3447
3564
  /**
3448
3565
  * A stream that FAILED is a failure even when it produced text on the way down: half a sentence from a
3449
3566
  * request that never finished is not something to put in the pet's mouth, and what the caller needs is the
3450
3567
  * reason — `AUTH`, `QUOTA`, a cancellation — not the fragment that preceded it.
3451
3568
  */
3452
- if (kind === 'error' || kind === 'aborted') return { error: `模型请求失败(${detail})` };
3569
+ if (kind === 'error' || kind === 'aborted') return { error: sayText(language, 'requestFailed', { detail }) };
3453
3570
  const text = core.cleanSpeechText(result.text, SPEECH_LINE_MAX);
3454
3571
  // `max-tokens` and `tool-calls` are NOT failures here: a budget that ran out after the one line we asked for
3455
3572
  // is a line, and the sentence is already capped. Only "nothing usable arrived" is a refusal.
3456
3573
  if (text) return { text };
3457
- return { error: `模型没有给出可用的一句话(${detail})` };
3574
+ return { error: sayText(language, 'noLine', { detail }) };
3458
3575
  };
3459
3576
 
3460
3577
  /** One call through DSH's own LLM service, which owns DSH's own credentials. */
3461
3578
  const askDshLlm = async (speech, prompt, language) => {
3462
3579
  const llm = llmService();
3463
- if (!llm || typeof llm.stream !== 'function') throw new Error('这个 DSH 没有提供 LLM 服务');
3580
+ if (!llm || typeof llm.stream !== 'function') throw new Error(sayText(language, 'noLlmService'));
3464
3581
  /**
3465
3582
  * The two fields are refused SEPARATELY, and that is worth a line of its own.
3466
3583
  *
@@ -3470,8 +3587,8 @@ export function apply(ctx, config = {}) {
3470
3587
  * first option, so the pair looked complete. One message for two missing fields is what made that
3471
3588
  * unanswerable from the screen; naming the model is what points at the picker that was lying.
3472
3589
  */
3473
- if (!speech.ai.dshProvider) throw new Error('还没有选择 DSH 的 provider');
3474
- if (!speech.ai.dshModel) throw new Error('还没有选择 DSH 的模型');
3590
+ if (!speech.ai.dshProvider) throw new Error(sayText(language, 'noProvider'));
3591
+ if (!speech.ai.dshModel) throw new Error(sayText(language, 'noModel'));
3475
3592
  const controller = new AbortController();
3476
3593
  const timer = setTimeout(() => controller.abort(), speech.ai.timeoutMs);
3477
3594
  try {
@@ -3485,7 +3602,7 @@ export function apply(ctx, config = {}) {
3485
3602
  reasoningEffort: SPEECH_REASONING_EFFORT,
3486
3603
  purpose: 'vrmpet3d-speech',
3487
3604
  signal: controller.signal,
3488
- })));
3605
+ })), language);
3489
3606
  } finally {
3490
3607
  clearTimeout(timer);
3491
3608
  }
@@ -3493,8 +3610,8 @@ export function apply(ctx, config = {}) {
3493
3610
 
3494
3611
  /** One call to the OpenAI-compatible endpoint the user named. */
3495
3612
  const askCustomLlm = async (speech, prompt, language) => {
3496
- if (!speech.ai.baseUrl) throw new Error('还没有填写 Base URL');
3497
- if (!speech.ai.model) throw new Error('还没有填写模型名');
3613
+ if (!speech.ai.baseUrl) throw new Error(sayText(language, 'noBaseUrl'));
3614
+ if (!speech.ai.model) throw new Error(sayText(language, 'noModelName'));
3498
3615
  const controller = new AbortController();
3499
3616
  const timer = setTimeout(() => controller.abort(), speech.ai.timeoutMs);
3500
3617
  try {
@@ -3517,7 +3634,7 @@ export function apply(ctx, config = {}) {
3517
3634
  }),
3518
3635
  signal: controller.signal,
3519
3636
  });
3520
- if (!response.ok) throw new Error(`provider 返回 HTTP ${response.status}`);
3637
+ if (!response.ok) throw new Error(sayText(language, 'providerHttp', { status: response.status }));
3521
3638
  const body = await response.json();
3522
3639
  const choice = body && Array.isArray(body.choices) ? body.choices[0] : null;
3523
3640
  const message = choice && choice.message ? choice.message : null;
@@ -3528,8 +3645,11 @@ export function apply(ctx, config = {}) {
3528
3645
  */
3529
3646
  if (!core.cleanSpeechText(text, SPEECH_LINE_MAX)) {
3530
3647
  return {
3531
- error: `模型没有给出可用的一句话(HTTP 200;choices=${Array.isArray(body && body.choices) ? body.choices.length : 'none'};`
3532
- + `finish=${(choice && choice.finish_reason) || 'none'};正文 ${text.length} 字)`,
3648
+ error: sayText(language, 'noLineHttp', {
3649
+ choices: Array.isArray(body && body.choices) ? body.choices.length : 'none',
3650
+ finish: (choice && choice.finish_reason) || 'none',
3651
+ length: text.length,
3652
+ }),
3533
3653
  };
3534
3654
  }
3535
3655
  return { text };
@@ -3574,7 +3694,7 @@ export function apply(ctx, config = {}) {
3574
3694
  if (sayInFlight) {
3575
3695
  sendJson(req, res, {
3576
3696
  seq: saySeq, state: box, part: null, structure: null, chain: null, text: null,
3577
- error: '已经有一句在生成中了',
3697
+ error: sayText(language, 'busy'),
3578
3698
  });
3579
3699
  return;
3580
3700
  }
@@ -3611,7 +3731,7 @@ export function apply(ctx, config = {}) {
3611
3731
  // Both providers answer the same way: `{ text }` or `{ error }` with the reason it could not be one.
3612
3732
  if (answer && answer.error) throw new Error(answer.error);
3613
3733
  const text = core.cleanSpeechText(answer && answer.text, SPEECH_LINE_MAX);
3614
- if (!text) throw new Error('模型没有给出可用的一句话(没有任何回应)');
3734
+ if (!text) throw new Error(sayText(language, 'noResponse'));
3615
3735
  sendJson(req, res, {
3616
3736
  seq,
3617
3737
  state: box,
@@ -3631,7 +3751,7 @@ export function apply(ctx, config = {}) {
3631
3751
  })
3632
3752
  .catch((error) => {
3633
3753
  const reason = error && error.name === 'AbortError'
3634
- ? `超过 ${speech.ai.timeoutMs} 毫秒没有回应`
3754
+ ? sayText(language, 'timeout', { ms: speech.ai.timeoutMs })
3635
3755
  : (error && error.message ? error.message : String(error));
3636
3756
  ctx.logger?.info?.(`vrmpet3d: the 碎碎念 request failed: ${reason}`);
3637
3757
  sendJson(req, res, {
@@ -3837,31 +3957,34 @@ export function apply(ctx, config = {}) {
3837
3957
  * (`RunningWhaleTail` in `dsh-client-ui-chat`), with a static SVG for reduced motion, forced colours, or a
3838
3958
  * browser without alpha masks. This serves the animated half of that pair to the pet's two prompts.
3839
3959
  *
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.
3960
+ * The bytes are still NOT committed to this repository: `docs/ASSET-CONTRACT.md` §1 is the rule for assets
3961
+ * the project did not make — load them where they already live, never copy them into the tree. Since
3962
+ * 2026-10-16 that "where they already live" is the RUNNING DSH itself: `resolveWhaleMark` (called once at
3963
+ * activation, above) reads the mark out of `dsh-client-ui-chat`'s own client bundle, falling back to
3964
+ * `assets/whale-tail.apng` and then to the copy it caches in the plugin's state directory. This handler
3965
+ * only serves what that resolution found, which is why a machine with no DSH to read answers 404 and both
3966
+ * prompts fall back to the static SVG — the app's own fallback, and a complete mark on its own.
3967
+ *
3968
+ * The bytes therefore live in MEMORY for the life of the activation: the mark is one image for two
3969
+ * prompts, and re-reading a 552 KB bundle (or the fallback file) per prompt would be work per frame of the
3970
+ * user's attention rather than per boot.
3846
3971
  */
3847
3972
  const whaleHandler = (req, res) => {
3848
3973
  if (req.method !== 'GET' && req.method !== 'HEAD') {
3849
3974
  sendStatus(res, 405, 'method not allowed');
3850
3975
  return;
3851
3976
  }
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');
3977
+ const bytes = whale.bytes;
3978
+ if (!bytes) {
3979
+ sendStatus(res, 404, `the animated whale mark is not available (${whale.error}); both prompts fall back to the static SVG`);
3857
3980
  return;
3858
3981
  }
3859
3982
  res.writeHead(200, {
3860
3983
  'content-type': 'image/apng',
3861
3984
  'content-length': bytes.length,
3862
3985
  /**
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
3986
+ * `no-store` like every other route here: the image is 21 KB, it is already in memory, and a prompt
3987
+ * that kept a stale mark after a DSH update handed over a new one would be a mystery rather than a
3865
3988
  * cache hit.
3866
3989
  */
3867
3990
  'cache-control': 'no-store',
@@ -3939,6 +4062,21 @@ export function apply(ctx, config = {}) {
3939
4062
  }
3940
4063
  readJsonBody(req, res, 16 * 1024, (body) => {
3941
4064
  const action = typeof body?.action === 'string' ? body.action : '';
4065
+ if (action === 'open-status-session' || action === 'open-completed-session') {
4066
+ let sessionId = petStateStale ? null : action === 'open-completed-session'
4067
+ ? (['working', 'waiting'].includes(petStateValue) && petStateUnread > 0 ? petStateWhy?.completedSessionId : null)
4068
+ : (['done', 'waiting', 'error'].includes(petStateValue) ? petStateWhy?.statusSessionId : null);
4069
+ const kind = action === 'open-completed-session' || petStateValue === 'done' ? 'completed' : 'waiting';
4070
+ const ids = petStateWhy?.[kind === 'completed' ? 'completedSessionIds' : 'waitingSessionIds'];
4071
+ if (sessionId && petStateValue !== 'error' && Array.isArray(ids) && ids.length) {
4072
+ sessionId = ids[(ids.indexOf(navigationCursor[kind]) + 1) % ids.length];
4073
+ navigationCursor[kind] = sessionId;
4074
+ }
4075
+ if (!sessionId) { sendJson(req, res, { ok: false }); return; }
4076
+ navigation = { seq: navigation.seq + 1, sessionId };
4077
+ sendJson(req, res, { ok: true });
4078
+ return;
4079
+ }
3942
4080
  /**
3943
4081
  * The window reporting its own bubble numbers — see pageMetrics in the GET above. Answered by the HOST rather than
3944
4082
  * forwarded to the launcher, so an older launcher cannot reject it.
@@ -4092,7 +4230,7 @@ export function apply(ctx, config = {}) {
4092
4230
  *
4093
4231
  * `ctx.inject` is the declarative fix: Cordis calls the callback with a scope in
4094
4232
  * which `webServer` exists, whenever that becomes true, and disposes the scope if
4095
- * it goes away. The service is an OPTIONAL dependency on purpose ?a hard
4233
+ * it goes away. The service is an OPTIONAL dependency on purpose — a hard
4096
4234
  * `inject: ['webServer']` would leave the plugin waiting for activation forever
4097
4235
  * in a composition with no web server, which is a far worse failure than a pet
4098
4236
  * that cannot load model files.
package/lib/menu.js CHANGED
@@ -105,7 +105,7 @@
105
105
  reloadModel: 'Reload model',
106
106
  dance: 'Dance for me',
107
107
  modelSettings: "This model's parameters",
108
- surfaceWindow: 'Move to the floating window',
108
+ surfaceWindow: 'Switch to an independent pet',
109
109
  surfaceApp: 'Move back inside the app',
110
110
  surfaceOff: 'disabled while debug mode is on',
111
111
  gravity: 'Gravity',
@@ -133,7 +133,7 @@
133
133
  reloadModel: '重载模型',
134
134
  dance: '跳支舞',
135
135
  modelSettings: '当前模型参数设置',
136
- surfaceWindow: '切换回窗口外',
136
+ surfaceWindow: '切换为独立宠物',
137
137
  surfaceApp: '切换回窗口内',
138
138
  surfaceOff: '调试模式下不生效',
139
139
  gravity: '重力',
@@ -228,6 +228,7 @@
228
228
  var onChoose = typeof options.onChoose === 'function' ? options.onChoose : function () {};
229
229
  var toSurface = options.toSurface === 'app' ? 'app' : 'window';
230
230
  var surfaceAllowed = options.surfaceAllowed !== false;
231
+ var danceState = 'resting';
231
232
  /** Whether this surface offers the 切换 row at all. See the `showSurface` option. */
232
233
  var showSurface = options.showSurface !== false;
233
234
 
@@ -254,6 +255,8 @@
254
255
 
255
256
  /** The label for one id, from the state this menu was built with. */
256
257
  function labelFor(id) {
258
+ if (id === 'dance') return danceState === 'dancing' ? (options.language === 'en' ? 'Another dance' : '换一支舞')
259
+ : (['resting', 'offline'].indexOf(danceState) === -1 ? (options.language === 'en' ? 'The pet is busy…' : '宠物目前很忙…') : text.dance);
257
260
  if (id === 'surface') return toSurface === 'app' ? text.surfaceApp : text.surfaceWindow;
258
261
  if (id === 'closePet') return toSurface === 'app' ? text.closePetWindow : text.closePetApp;
259
262
  return text[id] || id;
@@ -267,7 +270,8 @@
267
270
  ITEMS.forEach(function (id) {
268
271
  // The 切换 row is the one entry a surface may omit — the window's 关闭独立宠物 already does its job.
269
272
  if (id === 'surface' && !showSurface) return;
270
- var disabled = id === 'surface' && surfaceAllowed !== true;
273
+ var disabled = (id === 'surface' && surfaceAllowed !== true)
274
+ || (id === 'dance' && ['resting', 'offline', 'dancing'].indexOf(danceState) === -1);
271
275
  var toggle = TOGGLES.indexOf(id) !== -1;
272
276
  var checked = toggle && checkedState[id] === true;
273
277
  var item = doc.createElement('button');
@@ -292,7 +296,7 @@
292
296
  * waiting to happen ("the switch does nothing"). The reason is the operator's own qualifier —
293
297
  * 非debug模式生效 — spelled out for the mode that turns it off.
294
298
  */
295
- item.title = text.surfaceOff;
299
+ item.title = id === 'dance' ? label : text.surfaceOff;
296
300
  } else {
297
301
  if (toggle && !checked) item.title = text.gravityOff;
298
302
  item.addEventListener('click', function (event) {
@@ -448,6 +452,12 @@
448
452
  setSurface: function (next) { toSurface = next === 'app' ? 'app' : 'window'; },
449
453
  /** Whether that row may be used at all (false in debug mode). */
450
454
  setSurfaceAllowed: function (allowed) { surfaceAllowed = allowed !== false; },
455
+ setSurfaceVisible: function (visible) { showSurface = visible === true; },
456
+ setDanceState: function (state) {
457
+ if (danceState === state) return;
458
+ danceState = state;
459
+ if (!element.hidden) render();
460
+ },
451
461
  /**
452
462
  * Which way a SWITCH row is thrown, read at OPEN time by the surface that owns the preference.
453
463
  *