dsh-plugin-vrmpet3d 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/desktop/main.js +47 -4
- package/desktop/preload.js +7 -2
- package/lib/client.core.js +487 -53
- package/lib/client.js +391 -57
- package/lib/client.menu.js +14 -4
- package/lib/client.scene.js +134 -7
- package/lib/config-page.html +82 -45
- package/lib/config-page.js +1271 -252
- package/lib/core.js +487 -53
- package/lib/desktop-page.js +298 -44
- package/lib/desktop.js +18 -1
- package/lib/global-page.html +172 -101
- package/lib/index.js +196 -58
- package/lib/menu.js +14 -4
- package/lib/model-picker.js +139 -49
- package/lib/picker-page.html +34 -8
- package/lib/picker-page.js +192 -11
- package/lib/scene.js +134 -7
- package/lib/setup-page.html +68 -49
- package/lib/setup-page.js +395 -42
- package/lib/vrm-info.js +16 -1
- package/lib/whale.js +191 -0
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// dsh-plugin-vrmpet3d
|
|
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
|
|
11
|
-
// `/plugins/<id>/client*.js`
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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>`
|
|
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
|
|
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
|
|
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`
|
|
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
|
|
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
|
|
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
|
|
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>`
|
|
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>`
|
|
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
|
|
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`
|
|
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
|
|
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 ?
|
|
3444
|
-
|
|
3445
|
-
|
|
3446
|
-
|
|
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:
|
|
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:
|
|
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(
|
|
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(
|
|
3474
|
-
if (!speech.ai.dshModel) throw new Error(
|
|
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(
|
|
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(
|
|
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:
|
|
3532
|
-
|
|
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
|
-
?
|
|
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
|
|
3841
|
-
* project did not make — load them where they already live, never copy them into the tree.
|
|
3842
|
-
*
|
|
3843
|
-
*
|
|
3844
|
-
*
|
|
3845
|
-
*
|
|
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
|
-
|
|
3853
|
-
|
|
3854
|
-
|
|
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
|
|
3864
|
-
*
|
|
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
|
|
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: '
|
|
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
|
*
|