@linxin666/dsh-pet 0.2.2 → 0.2.3
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.i18n.yaml +2 -2
- package/README.md +66 -3
- package/README.zh.md +66 -3
- package/assets/whale/pet.json +169 -20
- package/assets/whale-refined/pet.json +17 -2
- package/lib/client.js +438 -21
- package/lib/client.js.map +1 -1
- package/lib/index.js +752 -36
- package/lib/live2d-vendor.js +995 -0
- package/lib/live2d-vendor.js.map +1 -0
- package/lib/types/client/PetDockEntry.d.ts.map +1 -1
- package/lib/types/client/PetDockEntry.js +2 -1
- package/lib/types/client/PetSettingsCard.d.ts +10 -0
- package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PetSettingsCard.js +22 -1
- package/lib/types/client/PetSprite.d.ts +7 -1
- package/lib/types/client/PetSprite.d.ts.map +1 -1
- package/lib/types/client/PetSprite.js +17 -8
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +5 -0
- package/lib/types/client/locales.d.ts +10 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +10 -0
- package/lib/types/client/phase-stream.d.ts +22 -0
- package/lib/types/client/phase-stream.d.ts.map +1 -0
- package/lib/types/client/phase-stream.js +28 -0
- package/lib/types/client/renderers/PetRendererSwitch.d.ts +25 -0
- package/lib/types/client/renderers/PetRendererSwitch.d.ts.map +1 -0
- package/lib/types/client/renderers/PetRendererSwitch.js +24 -0
- package/lib/types/client/renderers/live2d/Live2dVisualMount.d.ts +22 -0
- package/lib/types/client/renderers/live2d/Live2dVisualMount.d.ts.map +1 -0
- package/lib/types/client/renderers/live2d/Live2dVisualMount.js +75 -0
- package/lib/types/client/renderers/live2d/runtime.d.ts +89 -0
- package/lib/types/client/renderers/live2d/runtime.d.ts.map +1 -0
- package/lib/types/client/renderers/live2d/runtime.js +63 -0
- package/lib/types/client/renderers/live2d/vendor-entry.d.ts +15 -0
- package/lib/types/client/renderers/live2d/vendor-entry.d.ts.map +1 -0
- package/lib/types/client/renderers/live2d/vendor-entry.js +14 -0
- package/lib/types/client/renderers/live2d.d.ts +52 -0
- package/lib/types/client/renderers/live2d.d.ts.map +1 -0
- package/lib/types/client/renderers/live2d.js +180 -0
- package/lib/types/client/renderers/registry.d.ts +32 -0
- package/lib/types/client/renderers/registry.d.ts.map +1 -0
- package/lib/types/client/renderers/registry.js +49 -0
- package/lib/types/contracts/renderer.d.ts +50 -0
- package/lib/types/contracts/renderer.d.ts.map +1 -0
- package/lib/types/contracts/renderer.js +15 -0
- package/lib/types/manifest-v2.d.ts +129 -0
- package/lib/types/manifest-v2.d.ts.map +1 -0
- package/lib/types/manifest-v2.js +387 -0
- package/lib/types/model3.d.ts +26 -0
- package/lib/types/model3.d.ts.map +1 -0
- package/lib/types/model3.js +96 -0
- package/lib/types/registry.d.ts +57 -3
- package/lib/types/registry.d.ts.map +1 -1
- package/lib/types/registry.js +208 -33
- package/lib/types/routes.d.ts +39 -2
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +183 -12
- package/lib/types/service.d.ts +5 -1
- package/lib/types/service.d.ts.map +1 -1
- package/lib/types/service.js +4 -0
- package/package.json +3 -1
- package/src/client/PetDockEntry.tsx +19 -11
- package/src/client/PetSettingsCard.tsx +39 -0
- package/src/client/PetSprite.test.tsx +11 -0
- package/src/client/PetSprite.tsx +24 -9
- package/src/client/index.ts +6 -0
- package/src/client/locales.ts +10 -0
- package/src/client/phase-stream.test.ts +76 -0
- package/src/client/phase-stream.ts +39 -0
- package/src/client/renderers/PetRendererSwitch.test.tsx +66 -0
- package/src/client/renderers/PetRendererSwitch.tsx +48 -0
- package/src/client/renderers/live2d/Live2dVisualMount.tsx +98 -0
- package/src/client/renderers/live2d/runtime.test.ts +61 -0
- package/src/client/renderers/live2d/runtime.ts +119 -0
- package/src/client/renderers/live2d/vendor-entry.ts +14 -0
- package/src/client/renderers/live2d.test.ts +242 -0
- package/src/client/renderers/live2d.ts +218 -0
- package/src/client/renderers/registry.ts +58 -0
- package/src/client/settings-section.module.css +23 -0
- package/src/contracts/renderer.ts +54 -0
- package/src/manifest-v2.test.ts +251 -0
- package/src/manifest-v2.ts +414 -0
- package/src/model3.test.ts +79 -0
- package/src/model3.ts +91 -0
- package/src/registry.test.ts +215 -2
- package/src/registry.ts +246 -34
- package/src/routes.ts +202 -13
- package/src/service.ts +6 -0
package/lib/types/routes.js
CHANGED
|
@@ -8,11 +8,12 @@
|
|
|
8
8
|
* optional previews), so adding a pet never touches route wiring.
|
|
9
9
|
* @module @linxin666/dsh-pet/routes
|
|
10
10
|
*/
|
|
11
|
-
import { existsSync } from 'node:fs';
|
|
11
|
+
import { existsSync, realpathSync, statSync } from 'node:fs';
|
|
12
12
|
import { readFile } from 'node:fs/promises';
|
|
13
|
-
import { join } from 'node:path';
|
|
14
|
-
import { petEntryView } from './registry.js';
|
|
13
|
+
import { join, sep } from 'node:path';
|
|
14
|
+
import { petEntryView, petPackageRoot } from './registry.js';
|
|
15
15
|
import { isLoopbackRequest } from './loopback.js';
|
|
16
|
+
import { dshHome } from './dsh-home.js';
|
|
16
17
|
/** Browser-facing base path of the pet API. */
|
|
17
18
|
export const PET_API_PREFIX = '/api/pet';
|
|
18
19
|
/** Browser-facing base path of the pet asset routes ('/pet/<id>/...'). */
|
|
@@ -20,6 +21,43 @@ export const PET_ASSET_PREFIX = '/pet';
|
|
|
20
21
|
const MANIFEST_FILE = 'pet.json';
|
|
21
22
|
const PREVIEW_DIR = 'previews';
|
|
22
23
|
const PREVIEW_PATTERN = /^[A-Za-z0-9._-]+$/;
|
|
24
|
+
/**
|
|
25
|
+
* Per-class size ceilings for served pet assets, in bytes (pet-center M2 P3,
|
|
26
|
+
* issue #623). Constants are tested directly; makePetRoutes accepts an
|
|
27
|
+
* override so tests can exercise the 413 path with tiny caps.
|
|
28
|
+
*/
|
|
29
|
+
export const PET_ASSET_CAPS = {
|
|
30
|
+
/** pet.json manifest. */
|
|
31
|
+
manifest: 64 * 1024,
|
|
32
|
+
/** Atlas, preview and Live2D texture imagery. */
|
|
33
|
+
image: 20 * 1024 * 1024,
|
|
34
|
+
/** Live2D model closure files (.moc3, motion/physics/expression JSON; M3). */
|
|
35
|
+
model: 32 * 1024 * 1024,
|
|
36
|
+
};
|
|
37
|
+
/** Imagery extensions classify into the image cap; everything else served from a closure is model-class. */
|
|
38
|
+
const IMAGE_EXTENSIONS = new Set(['.webp', '.png', '.gif', '.jpg', '.jpeg']);
|
|
39
|
+
/** Lowercased file extension ('' when none). */
|
|
40
|
+
function extensionOf(file) {
|
|
41
|
+
const dot = file.lastIndexOf('.');
|
|
42
|
+
return dot < 0 ? '' : file.slice(dot).toLowerCase();
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* realpath containment: resolve both sides and require the candidate to stay
|
|
46
|
+
* inside the base directory. A pet directory (or an atlas/preview inside it)
|
|
47
|
+
* that is a symlink escaping its root is rejected, never followed.
|
|
48
|
+
*/
|
|
49
|
+
export function containedRealpath(base, candidate) {
|
|
50
|
+
try {
|
|
51
|
+
const realBase = realpathSync(base);
|
|
52
|
+
const realCandidate = realpathSync(candidate);
|
|
53
|
+
return realCandidate === realBase || realCandidate.startsWith(realBase + sep)
|
|
54
|
+
? realCandidate
|
|
55
|
+
: undefined;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
23
61
|
const MIME_BY_EXT = {
|
|
24
62
|
'.webp': 'image/webp',
|
|
25
63
|
'.png': 'image/png',
|
|
@@ -133,10 +171,14 @@ function dirAliases(registry) {
|
|
|
133
171
|
/**
|
|
134
172
|
* The one asset handler behind the '/pet' prefix. Resolves the pet by id (or
|
|
135
173
|
* legacy directory alias), then serves exactly the files a manifest declares:
|
|
136
|
-
* pet.json, the
|
|
137
|
-
*
|
|
174
|
+
* pet.json, the entry's servable set (the sprite2d atlas, or the live2d
|
|
175
|
+
* model3.json plus its reference closure — pet-center M3), and optional
|
|
176
|
+
* 'previews/<name>' media. The servable match is an exact string comparison
|
|
177
|
+
* against scan-time normalized paths, so crafted '..' or '.' segments never
|
|
178
|
+
* match; containedRealpath stays as the second layer. Composed pets without
|
|
179
|
+
* a manifest file get a synthesized pet.json.
|
|
138
180
|
*/
|
|
139
|
-
function assetHandler(registry) {
|
|
181
|
+
function assetHandler(registry, caps) {
|
|
140
182
|
const aliases = dirAliases(registry);
|
|
141
183
|
return (req, res) => {
|
|
142
184
|
if (!guard(req, res))
|
|
@@ -198,8 +240,8 @@ function assetHandler(registry) {
|
|
|
198
240
|
if (file === undefined)
|
|
199
241
|
synthesized = true;
|
|
200
242
|
}
|
|
201
|
-
else if (rest.length > 0 &&
|
|
202
|
-
file = join(entry.dir,
|
|
243
|
+
else if (rest.length > 0 && entry.servable.includes(rel)) {
|
|
244
|
+
file = join(entry.dir, rel);
|
|
203
245
|
}
|
|
204
246
|
else if (rest.length === 2 && rest[0] === PREVIEW_DIR && PREVIEW_PATTERN.test(rest[1])) {
|
|
205
247
|
const preview = join(entry.dir, PREVIEW_DIR, rest[1]);
|
|
@@ -224,7 +266,29 @@ function assetHandler(registry) {
|
|
|
224
266
|
res.end();
|
|
225
267
|
return;
|
|
226
268
|
}
|
|
227
|
-
|
|
269
|
+
// realpath containment: a symlink escaping the pet directory is refused.
|
|
270
|
+
const resolved = containedRealpath(entry.dir, file);
|
|
271
|
+
if (resolved === undefined) {
|
|
272
|
+
res.writeHead(403);
|
|
273
|
+
res.end();
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
// Enforce the size ceiling before the file is read into memory.
|
|
277
|
+
const cap = rest.length === 1 && rest[0] === MANIFEST_FILE
|
|
278
|
+
? caps.manifest
|
|
279
|
+
: IMAGE_EXTENSIONS.has(extensionOf(rel)) ? caps.image : caps.model;
|
|
280
|
+
try {
|
|
281
|
+
if (statSync(resolved).size > cap) {
|
|
282
|
+
res.writeHead(413);
|
|
283
|
+
res.end();
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
res.writeHead(404);
|
|
289
|
+
res.end();
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
228
292
|
readFile(resolved).then((body) => {
|
|
229
293
|
res.writeHead(200, {
|
|
230
294
|
'content-type': mimeFor(resolved),
|
|
@@ -242,12 +306,111 @@ function assetHandler(registry) {
|
|
|
242
306
|
});
|
|
243
307
|
};
|
|
244
308
|
}
|
|
245
|
-
/**
|
|
309
|
+
/** Browser-facing base path of the plugin runtime files (pet-center M3). */
|
|
310
|
+
export const PET_RUNTIME_PREFIX = PET_API_PREFIX + '/runtime';
|
|
311
|
+
/**
|
|
312
|
+
* The runtime files the route may serve, by exact name (no slashes, no
|
|
313
|
+
* user-controlled path segments, so traversal is structurally impossible):
|
|
314
|
+
* the user-supplied Cubism Core from the pet runtime directory (the plugin
|
|
315
|
+
* never bundles or downloads it — issue #623 M1 §0) and the plugin-shipped
|
|
316
|
+
* MIT vendor bundle from the package lib directory.
|
|
317
|
+
*/
|
|
318
|
+
const RUNTIME_FILES = {
|
|
319
|
+
'live2dcubismcore.min.js': { root: 'runtimeDir' },
|
|
320
|
+
'live2d-vendor.js': { root: 'vendorDir' },
|
|
321
|
+
'live2d-vendor.js.map': { root: 'vendorDir' },
|
|
322
|
+
};
|
|
323
|
+
/** Size ceiling for one runtime file (the Cubism Core is ~200 KB today). */
|
|
324
|
+
export const PET_RUNTIME_CAP = 16 * 1024 * 1024;
|
|
325
|
+
/**
|
|
326
|
+
* The runtime handler behind '/api/pet/runtime/<name>'. A missing file
|
|
327
|
+
* answers 404 with a JSON marker the client renderer turns into install
|
|
328
|
+
* guidance (the Cubism Core is user-supplied, so its absence is a normal
|
|
329
|
+
* state, not an error).
|
|
330
|
+
*/
|
|
331
|
+
function runtimeHandler(roots) {
|
|
332
|
+
return (req, res) => {
|
|
333
|
+
if (!guard(req, res))
|
|
334
|
+
return;
|
|
335
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
336
|
+
res.writeHead(405);
|
|
337
|
+
res.end();
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
let pathname;
|
|
341
|
+
try {
|
|
342
|
+
pathname = new URL(req.url ?? '/', 'http://pet.local').pathname;
|
|
343
|
+
}
|
|
344
|
+
catch {
|
|
345
|
+
res.writeHead(400);
|
|
346
|
+
res.end();
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
const rest = pathname.slice(PET_RUNTIME_PREFIX.length).replace(/^\/+/, '');
|
|
350
|
+
let name;
|
|
351
|
+
try {
|
|
352
|
+
name = decodeURIComponent(rest);
|
|
353
|
+
}
|
|
354
|
+
catch {
|
|
355
|
+
res.writeHead(400);
|
|
356
|
+
res.end();
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
const spec = RUNTIME_FILES[name];
|
|
360
|
+
// Exact-name allow-list: anything with a path separator never matches.
|
|
361
|
+
if (spec === undefined) {
|
|
362
|
+
res.writeHead(404);
|
|
363
|
+
res.end();
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
const base = spec.root === 'runtimeDir' ? roots.runtimeDir : roots.vendorDir;
|
|
367
|
+
const file = join(base, name);
|
|
368
|
+
if (!existsSync(file)) {
|
|
369
|
+
json(res, 404, { ok: false, error: 'runtime-file-missing', file: name });
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
const resolved = containedRealpath(base, file);
|
|
373
|
+
if (resolved === undefined) {
|
|
374
|
+
res.writeHead(403);
|
|
375
|
+
res.end();
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
try {
|
|
379
|
+
if (statSync(resolved).size > PET_RUNTIME_CAP) {
|
|
380
|
+
res.writeHead(413);
|
|
381
|
+
res.end();
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
catch {
|
|
386
|
+
res.writeHead(404);
|
|
387
|
+
res.end();
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
readFile(resolved).then((body) => {
|
|
391
|
+
res.writeHead(200, {
|
|
392
|
+
'content-type': name.endsWith('.map') ? 'application/json' : 'application/javascript; charset=utf-8',
|
|
393
|
+
'content-length': String(body.byteLength),
|
|
394
|
+
'cache-control': 'no-cache',
|
|
395
|
+
});
|
|
396
|
+
if (req.method === 'HEAD') {
|
|
397
|
+
res.end();
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
res.end(body);
|
|
401
|
+
}, () => {
|
|
402
|
+
res.writeHead(404);
|
|
403
|
+
res.end();
|
|
404
|
+
});
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
/** Build the full route family (API + assets + runtime) for one service. */
|
|
246
408
|
export function makePetRoutes(deps) {
|
|
247
409
|
const { service } = deps;
|
|
248
410
|
const apiRoutes = [
|
|
249
411
|
getRoute(PET_API_PREFIX + '/state', () => service.state()),
|
|
250
412
|
getRoute(PET_API_PREFIX + '/pets', () => service.pets()),
|
|
413
|
+
getRoute(PET_API_PREFIX + '/diagnostics', () => service.diagnostics()),
|
|
251
414
|
postRoute(PET_API_PREFIX + '/interact', (body) => {
|
|
252
415
|
const kind = body.kind;
|
|
253
416
|
if (kind !== 'pet' && kind !== 'feed')
|
|
@@ -282,9 +445,17 @@ export function makePetRoutes(deps) {
|
|
|
282
445
|
const assetRoute = {
|
|
283
446
|
kind: 'prefix',
|
|
284
447
|
path: PET_ASSET_PREFIX,
|
|
285
|
-
handler: assetHandler(service.registrySnapshot()),
|
|
448
|
+
handler: assetHandler(service.registrySnapshot(), deps.assetCaps ?? PET_ASSET_CAPS),
|
|
449
|
+
};
|
|
450
|
+
const runtimeRoute = {
|
|
451
|
+
kind: 'prefix',
|
|
452
|
+
path: PET_RUNTIME_PREFIX,
|
|
453
|
+
handler: runtimeHandler({
|
|
454
|
+
runtimeDir: deps.runtimeDir ?? join(dshHome(), 'pets', '.runtime'),
|
|
455
|
+
vendorDir: deps.vendorDir ?? join(petPackageRoot(import.meta.url), 'lib'),
|
|
456
|
+
}),
|
|
286
457
|
};
|
|
287
|
-
return [...apiRoutes, assetRoute];
|
|
458
|
+
return [...apiRoutes, assetRoute, runtimeRoute];
|
|
288
459
|
}
|
|
289
460
|
// Re-exported for the package surface (the registry owns the definition now).
|
|
290
461
|
export { petPackageRoot } from './registry.js';
|
package/lib/types/service.d.ts
CHANGED
|
@@ -16,7 +16,7 @@ import type { AffinityConfig, PetAffinityView, PetInteraction } from './affinity
|
|
|
16
16
|
import type { TreatConfig } from './treats.ts';
|
|
17
17
|
import { type LedgerInteractionResult } from './ledger.ts';
|
|
18
18
|
import { type PetDisplayConfig } from './persist.ts';
|
|
19
|
-
import { type PetDefinition, type PetManifest, type PetRegistry } from './registry.ts';
|
|
19
|
+
import { type PetDefinition, type PetManifest, type PetRegistry, type PetRegistryDiagnostic } from './registry.ts';
|
|
20
20
|
import { type PetStateConfig, type PetStateSnapshot } from './state.ts';
|
|
21
21
|
/** Plugin configuration. */
|
|
22
22
|
export interface PetConfig {
|
|
@@ -161,6 +161,10 @@ export declare class PetService extends Service {
|
|
|
161
161
|
pets(): Promise<PetDefinition[]>;
|
|
162
162
|
/** The loaded registry (the asset routes serve its entries). */
|
|
163
163
|
registrySnapshot(): PetRegistry;
|
|
164
|
+
/** RPC: structured registry diagnostics (pet-center M2, issue #623). */
|
|
165
|
+
diagnostics(): Promise<{
|
|
166
|
+
diagnostics: PetRegistryDiagnostic[];
|
|
167
|
+
}>;
|
|
164
168
|
/** The selected pet's registry entry. */
|
|
165
169
|
activeEntry(): NonNullable<PetRegistry['entries'][number]>;
|
|
166
170
|
/** Currently selected pet id (persisted). */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../../src/service.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAEtD,OAAO,KAAK,EAAE,cAAc,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,eAAe,CAAA;AACpF,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAA;AAQ9C,OAAO,EAAgC,KAAK,uBAAuB,EAAE,MAAM,aAAa,CAAA;AACxF,OAAO,EASL,KAAK,gBAAgB,EACtB,MAAM,cAAc,CAAA;AACrB,OAAO,EAIL,KAAK,aAAa,EAClB,KAAK,WAAW,EAChB,KAAK,WAAW,
|
|
1
|
+
{"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../../src/service.ts"],"names":[],"mappings":"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"}
|
package/lib/types/service.js
CHANGED
|
@@ -94,6 +94,10 @@ export class PetService extends Service {
|
|
|
94
94
|
registrySnapshot() {
|
|
95
95
|
return this.registry;
|
|
96
96
|
}
|
|
97
|
+
/** RPC: structured registry diagnostics (pet-center M2, issue #623). */
|
|
98
|
+
async diagnostics() {
|
|
99
|
+
return { diagnostics: this.registry.diagnostics };
|
|
100
|
+
}
|
|
97
101
|
/** The selected pet's registry entry. */
|
|
98
102
|
activeEntry() {
|
|
99
103
|
return this.registry.byId(this.selectedPetId()) ?? this.registry.defaultEntry();
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@linxin666/dsh-pet",
|
|
3
3
|
"description": "Multi-pet companion plugin for the dsh web GUI: a registry-driven floating pet that reacts to model activity, with per-pet naming, petting/feeding interactions and an affinity score",
|
|
4
|
-
"version": "0.2.
|
|
4
|
+
"version": "0.2.3",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": "^22.19.0 || >=24.0.0"
|
|
@@ -72,10 +72,12 @@
|
|
|
72
72
|
"@types/react": "~18.3.1",
|
|
73
73
|
"@types/react-dom": "~18.3.0",
|
|
74
74
|
"jsdom": "29.1.1",
|
|
75
|
+
"pixi.js": "^8.19.0",
|
|
75
76
|
"react": "^18.2.0",
|
|
76
77
|
"react-dom": "^18.2.0",
|
|
77
78
|
"tsdown": "^0.22.2",
|
|
78
79
|
"typescript": "^6.0.3",
|
|
80
|
+
"untitled-pixi-live2d-engine": "1.3.5",
|
|
79
81
|
"vite-tsconfig-paths": "^6.1.1",
|
|
80
82
|
"vitest": "^4.1.8"
|
|
81
83
|
},
|
|
@@ -16,6 +16,7 @@ import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
|
|
16
16
|
import type { PetDisplayConfig } from '../persist.ts'
|
|
17
17
|
import type { PetStoreInstance } from './pet-store.ts'
|
|
18
18
|
import { PetSprite } from './PetSprite.tsx'
|
|
19
|
+
import { PetRendererSwitch } from './renderers/PetRendererSwitch.tsx'
|
|
19
20
|
import { NS } from './locales.ts'
|
|
20
21
|
import styles from './pet.module.css'
|
|
21
22
|
|
|
@@ -75,20 +76,27 @@ export function PetDockEntry(props: PetDockEntryProps): ReactElement {
|
|
|
75
76
|
{snapshot === null || definition === null
|
|
76
77
|
? null
|
|
77
78
|
: (
|
|
78
|
-
<
|
|
79
|
-
snapshot={snapshot}
|
|
79
|
+
<PetRendererSwitch
|
|
80
80
|
definition={definition}
|
|
81
|
-
|
|
82
|
-
feedback={feedback}
|
|
81
|
+
phase={snapshot?.phase ?? 'idle'}
|
|
83
82
|
onPet={props.pet}
|
|
84
|
-
onFeed={props.feed}
|
|
85
|
-
onHide={props.hide}
|
|
86
|
-
onDragEnd={props.dragEnd}
|
|
87
|
-
onRename={props.rename}
|
|
88
|
-
onOpenSession={props.openSession}
|
|
89
|
-
onFeedbackDone={props.feedbackDone}
|
|
90
83
|
t={props.t}
|
|
91
|
-
|
|
84
|
+
>
|
|
85
|
+
<PetSprite
|
|
86
|
+
snapshot={snapshot}
|
|
87
|
+
definition={definition}
|
|
88
|
+
display={snapshot.display}
|
|
89
|
+
feedback={feedback}
|
|
90
|
+
onPet={props.pet}
|
|
91
|
+
onFeed={props.feed}
|
|
92
|
+
onHide={props.hide}
|
|
93
|
+
onDragEnd={props.dragEnd}
|
|
94
|
+
onRename={props.rename}
|
|
95
|
+
onOpenSession={props.openSession}
|
|
96
|
+
onFeedbackDone={props.feedbackDone}
|
|
97
|
+
t={props.t}
|
|
98
|
+
/>
|
|
99
|
+
</PetRendererSwitch>
|
|
92
100
|
)}
|
|
93
101
|
</span>
|
|
94
102
|
)
|
|
@@ -48,6 +48,8 @@ export interface PetSettingsCardState extends CardShell {
|
|
|
48
48
|
petId: CardFieldState
|
|
49
49
|
/** Pet choices (registry ids + display names), loaded from the host. */
|
|
50
50
|
petChoices: readonly { value: string; label: string }[]
|
|
51
|
+
/** Registry diagnostics (v1 migration hints, invalid entries), host-served. */
|
|
52
|
+
petDiagnostics: readonly PetDiagnosticView[]
|
|
51
53
|
}
|
|
52
54
|
|
|
53
55
|
/** The registration-side face the card's slot entry injects. */
|
|
@@ -64,6 +66,12 @@ interface PetChoice {
|
|
|
64
66
|
displayName: string
|
|
65
67
|
}
|
|
66
68
|
|
|
69
|
+
/** One registry diagnostic as served by '/api/pet/diagnostics' (#623). */
|
|
70
|
+
export interface PetDiagnosticView {
|
|
71
|
+
level: 'error' | 'warning'
|
|
72
|
+
message: string
|
|
73
|
+
}
|
|
74
|
+
|
|
67
75
|
/** Fetch the registry list (the same data the sprite renders from). */
|
|
68
76
|
async function fetchPetChoices(): Promise<PetChoice[]> {
|
|
69
77
|
const response = await fetch('/api/pet/pets')
|
|
@@ -71,6 +79,14 @@ async function fetchPetChoices(): Promise<PetChoice[]> {
|
|
|
71
79
|
return (await response.json()) as PetChoice[]
|
|
72
80
|
}
|
|
73
81
|
|
|
82
|
+
/** Fetch the registry diagnostics (v1 migration hints, invalid entries). */
|
|
83
|
+
async function fetchPetDiagnostics(): Promise<PetDiagnosticView[]> {
|
|
84
|
+
const response = await fetch('/api/pet/diagnostics')
|
|
85
|
+
if (!response.ok) throw new Error('pet diagnostics failed: ' + response.status)
|
|
86
|
+
const body = (await response.json()) as { diagnostics?: PetDiagnosticView[] }
|
|
87
|
+
return body.diagnostics ?? []
|
|
88
|
+
}
|
|
89
|
+
|
|
74
90
|
/** Bridges the 'pet' scope onto the card's staged form. */
|
|
75
91
|
export class PetSettingsCardController {
|
|
76
92
|
private readonly form: CardForm<PetSettings>
|
|
@@ -80,6 +96,7 @@ export class PetSettingsCardController {
|
|
|
80
96
|
// without rebuilding the form.
|
|
81
97
|
private readonly petChoices: string[] = []
|
|
82
98
|
private readonly petLabels = new Map<string, string>()
|
|
99
|
+
private diagnostics: PetDiagnosticView[] = []
|
|
83
100
|
private loaded = false
|
|
84
101
|
private attempts = 0
|
|
85
102
|
|
|
@@ -95,6 +112,17 @@ export class PetSettingsCardController {
|
|
|
95
112
|
])
|
|
96
113
|
this.store = this.form.bind(() => this.projection())
|
|
97
114
|
void this.loadPets()
|
|
115
|
+
void this.loadDiagnostics()
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Fetch registry diagnostics once (soft-fail: an empty list on error). */
|
|
119
|
+
private async loadDiagnostics(): Promise<void> {
|
|
120
|
+
try {
|
|
121
|
+
this.diagnostics = await fetchPetDiagnostics()
|
|
122
|
+
this.store.set(this.projection())
|
|
123
|
+
} catch {
|
|
124
|
+
this.diagnostics = []
|
|
125
|
+
}
|
|
98
126
|
}
|
|
99
127
|
|
|
100
128
|
/** Resolve the registry choices once (retried a few times on failure). */
|
|
@@ -124,6 +152,7 @@ export class PetSettingsCardController {
|
|
|
124
152
|
bottom: this.form.field('bottom'),
|
|
125
153
|
petId: this.form.field('petId'),
|
|
126
154
|
petChoices: this.petChoices.map(id => ({ value: id, label: this.petLabels.get(id) ?? id })),
|
|
155
|
+
petDiagnostics: this.diagnostics,
|
|
127
156
|
}
|
|
128
157
|
}
|
|
129
158
|
|
|
@@ -197,6 +226,16 @@ export function PetSettingsCard(props: PetSettingsCardProps) {
|
|
|
197
226
|
onEdit={(text) => { props.edit('petId', text) }}
|
|
198
227
|
onReset={() => { props.resetField('petId') }}
|
|
199
228
|
/>
|
|
229
|
+
{state.petDiagnostics.length === 0 ? null : (
|
|
230
|
+
<li className={sectionCss.diagnostics} data-dsh-part="diagnostics">
|
|
231
|
+
<span className={sectionCss.diagnosticsTitle}>{t('settings.diagnosticsTitle')}</span>
|
|
232
|
+
<ul>
|
|
233
|
+
{state.petDiagnostics.map((diagnostic, index) => (
|
|
234
|
+
<li key={index} data-level={diagnostic.level}>{diagnostic.message}</li>
|
|
235
|
+
))}
|
|
236
|
+
</ul>
|
|
237
|
+
</li>
|
|
238
|
+
)}
|
|
200
239
|
<BooleanField
|
|
201
240
|
id="settings-pet-visible"
|
|
202
241
|
label={t('settings.visible')}
|
|
@@ -24,6 +24,7 @@ function petDefinition(): PetDefinition {
|
|
|
24
24
|
id: 'whale-girl',
|
|
25
25
|
displayName: '鲸鱼娘',
|
|
26
26
|
description: '测试用鲸鱼娘',
|
|
27
|
+
renderer: 'sprite2d',
|
|
27
28
|
cell: { width: 192, height: 208 },
|
|
28
29
|
columns: 8,
|
|
29
30
|
rows: [6, 8, 8, 4, 5, 8, 6, 6, 6],
|
|
@@ -142,6 +143,16 @@ function fireComposingKeydown(target: Element, key: string): void {
|
|
|
142
143
|
fireEvent.compositionEnd(target)
|
|
143
144
|
}
|
|
144
145
|
|
|
146
|
+
describe('PetSprite custom visual (pet-center M3)', () => {
|
|
147
|
+
it('renders the visual inside the sprite box and skips the atlas background', () => {
|
|
148
|
+
renderPet({ visual: <canvas data-testid="custom-visual" /> })
|
|
149
|
+
const spriteEl = screen.getByRole('button', { name: '鲸鱼娘' })
|
|
150
|
+
expect(spriteEl.querySelector('[data-testid="custom-visual"]')).toBeTruthy()
|
|
151
|
+
// The atlas background never applies while a renderer visual owns the box.
|
|
152
|
+
expect(spriteEl.style.backgroundImage).toBe('')
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
|
|
145
156
|
describe('PetSprite rename input', () => {
|
|
146
157
|
it('submits the draft on Enter outside composition', () => {
|
|
147
158
|
const { onRename } = renderPet()
|
package/src/client/PetSprite.tsx
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
|
13
|
-
import type { CSSProperties, PointerEvent as ReactPointerEvent, ReactPortal } from 'react'
|
|
13
|
+
import type { CSSProperties, PointerEvent as ReactPointerEvent, ReactNode, ReactPortal } from 'react'
|
|
14
14
|
import { createPortal } from 'react-dom'
|
|
15
15
|
import clsx from 'clsx'
|
|
16
16
|
import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
|
|
@@ -48,6 +48,12 @@ export interface PetSpriteProps {
|
|
|
48
48
|
onOpenSession: (sessionId: string) => void
|
|
49
49
|
/** Clear the reaction bubble (after its CSS animation). */
|
|
50
50
|
onFeedbackDone: () => void
|
|
51
|
+
/**
|
|
52
|
+
* Custom visual replacing the sprite2d atlas animation (pet-center M3).
|
|
53
|
+
* The chrome (drag, bubbles, panel, tap economy) is untouched: the visual
|
|
54
|
+
* renders inside the sprite box, and the atlas load + frame loop skip.
|
|
55
|
+
*/
|
|
56
|
+
visual?: ReactNode
|
|
51
57
|
/** Locale translate seat (namespace-bound). */
|
|
52
58
|
t: TranslateNS<typeof NS>
|
|
53
59
|
}
|
|
@@ -106,8 +112,10 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
106
112
|
const sequences = definition.sequences
|
|
107
113
|
|
|
108
114
|
// Load the atlas once; the definition carries the authoritative per-row
|
|
109
|
-
// frame counts and per-track durations, so nothing else is fetched.
|
|
115
|
+
// frame counts and per-track durations, so nothing else is fetched. A
|
|
116
|
+
// custom visual (pet-center M3) replaces the atlas entirely.
|
|
110
117
|
useEffect(() => {
|
|
118
|
+
if (props.visual !== undefined) return
|
|
111
119
|
let cancelled = false
|
|
112
120
|
const img = new Image()
|
|
113
121
|
img.onload = () => {
|
|
@@ -118,7 +126,7 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
118
126
|
cancelled = true
|
|
119
127
|
img.onload = null
|
|
120
128
|
}
|
|
121
|
-
}, [definition.atlasUrl])
|
|
129
|
+
}, [definition.atlasUrl, props.visual])
|
|
122
130
|
|
|
123
131
|
// Frame loop: advance the current track and write background-position.
|
|
124
132
|
// Offsets must be in SCALED coordinates (background-position applies to the
|
|
@@ -132,6 +140,7 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
132
140
|
const scaleRef = useRef(spriteScale)
|
|
133
141
|
scaleRef.current = spriteScale
|
|
134
142
|
useEffect(() => {
|
|
143
|
+
if (props.visual !== undefined) return
|
|
135
144
|
const reduceMotion = typeof window !== 'undefined'
|
|
136
145
|
&& window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches === true
|
|
137
146
|
const sequence = animation === animationForPhase(phase) ? sequences?.[phase] : undefined
|
|
@@ -200,7 +209,7 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
200
209
|
}
|
|
201
210
|
raf = requestAnimationFrame(tick)
|
|
202
211
|
return () => cancelAnimationFrame(raf)
|
|
203
|
-
}, [animation, phase, cell, columns, rows, tracks, sequences])
|
|
212
|
+
}, [animation, phase, cell, columns, rows, tracks, sequences, props.visual])
|
|
204
213
|
|
|
205
214
|
// Auto-clear the feedback bubble after its CSS animation. The callback
|
|
206
215
|
// rides a ref so re-renders never reset the timer: the 2s poll rebuilds
|
|
@@ -343,10 +352,14 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
343
352
|
style={{
|
|
344
353
|
width: spriteWidth,
|
|
345
354
|
height: spriteHeight,
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
355
|
+
...(props.visual === undefined
|
|
356
|
+
? {
|
|
357
|
+
backgroundImage: imageReady ? 'url(' + definition.atlasUrl + ')' : undefined,
|
|
358
|
+
backgroundSize: (cell.width * columns * spriteScale) + 'px ' + (cell.height * (definition.atlasRows ?? rows.length) * spriteScale) + 'px',
|
|
359
|
+
backgroundRepeat: 'no-repeat',
|
|
360
|
+
backgroundPosition: '0 0',
|
|
361
|
+
}
|
|
362
|
+
: {}),
|
|
350
363
|
cursor: dragRef.current === null ? 'grab' : 'grabbing',
|
|
351
364
|
}}
|
|
352
365
|
onPointerDown={onPointerDown}
|
|
@@ -360,7 +373,9 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
360
373
|
}}
|
|
361
374
|
role="button"
|
|
362
375
|
aria-label={definition.displayName}
|
|
363
|
-
|
|
376
|
+
>
|
|
377
|
+
{props.visual}
|
|
378
|
+
</div>
|
|
364
379
|
{feedback !== null && (
|
|
365
380
|
<div key={feedback.at} ref={bubbleRef} className={clsx(styles.bubble, feedback.kind === 'feed' ? styles.bubbleFeed : styles.bubblePet)}>
|
|
366
381
|
{feedback.text}
|
package/src/client/index.ts
CHANGED
|
@@ -26,6 +26,8 @@ import { createElement } from 'react'
|
|
|
26
26
|
import { createRoot } from 'react-dom/client'
|
|
27
27
|
import { createPetStore, type PetStoreInstance } from './pet-store.ts'
|
|
28
28
|
import { PetDockEntry, type PetInjected } from './PetDockEntry.tsx'
|
|
29
|
+
import { defaultPetRendererRegistry } from './renderers/registry.ts'
|
|
30
|
+
import { live2dRenderer } from './renderers/live2d.ts'
|
|
29
31
|
import { PetSettingsSection, PetSettingsCardController, type PetSettings } from './PetSettingsCard.tsx'
|
|
30
32
|
import { NS, en, zh, t } from './locales.ts'
|
|
31
33
|
|
|
@@ -103,6 +105,10 @@ declare module '@deepseek-ai/cordis' {
|
|
|
103
105
|
export function apply(ctx: ClientContext): void {
|
|
104
106
|
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'pet: dictionaries')
|
|
105
107
|
|
|
108
|
+
// Built-in renderers dispatch through the plugin-wide registry (pet-center
|
|
109
|
+
// M3). Registration is idempotent (id wins), so re-applies stay clean.
|
|
110
|
+
defaultPetRendererRegistry.register(live2dRenderer)
|
|
111
|
+
|
|
106
112
|
const binder = ctx.get('webUiSettings') ?? ctx.settingsScope
|
|
107
113
|
const settingsScope = binder.bind<PetSettings>({ namespace: PET_SETTINGS_NS })
|
|
108
114
|
const enabled = (): boolean => {
|
package/src/client/locales.ts
CHANGED
|
@@ -19,11 +19,16 @@ export const zh = {
|
|
|
19
19
|
'pet.treats': '小鱼干 ×{n}',
|
|
20
20
|
'pet.state.loading': '宠物正在赶来…',
|
|
21
21
|
'pet.state.error': '宠物迷路了(连接失败)',
|
|
22
|
+
'pet.renderer.unavailable': '这只宠物需要的渲染器({renderer})在当前版本不可用。',
|
|
23
|
+
'pet.live2d.core-missing': 'Live2D 核心未安装:请把官方 live2dcubismcore.min.js 放入 $DSH_HOME/pets/.runtime/ 后刷新(步骤见宠物插件 README)。',
|
|
24
|
+
'pet.live2d.vendor-missing': 'Live2D 组件缺失,请升级宠物插件。',
|
|
25
|
+
'pet.live2d.load-failed': 'Live2D 模型加载失败,请检查该宠物目录的完整性。',
|
|
22
26
|
'pet.openSessionHint': '点击跳转到对应会话',
|
|
23
27
|
'pet.moreSessions': '展开其余 {n} 个会话的气泡',
|
|
24
28
|
'pet.collapseSessions': '收起会话气泡',
|
|
25
29
|
// 一级设置页(settings.section 席位)。
|
|
26
30
|
'settings.title': '宠物',
|
|
31
|
+
'settings.diagnosticsTitle': '宠物目录诊断',
|
|
27
32
|
'settings.description': '选择宠物并调整它的显示布局。',
|
|
28
33
|
'settings.pet': '宠物',
|
|
29
34
|
'settings.petHint': '选择显示哪只宠物;每只宠物独立命名,可在宠物悬浮面板改名。',
|
|
@@ -67,11 +72,16 @@ export const en = {
|
|
|
67
72
|
'pet.treats': 'Treats ×{n}',
|
|
68
73
|
'pet.state.loading': 'The pet is on its way…',
|
|
69
74
|
'pet.state.error': 'The pet is lost (connection failed)',
|
|
75
|
+
'pet.renderer.unavailable': 'This pet needs a renderer ({renderer}) that is not available in this build.',
|
|
76
|
+
'pet.live2d.core-missing': 'Live2D Cubism Core is not installed: place the official live2dcubismcore.min.js under $DSH_HOME/pets/.runtime/ and refresh (see the pet plugin README).',
|
|
77
|
+
'pet.live2d.vendor-missing': 'The Live2D component is missing; please update the pet plugin.',
|
|
78
|
+
'pet.live2d.load-failed': 'The Live2D model failed to load; check the pet directory is complete.',
|
|
70
79
|
'pet.openSessionHint': 'Click to jump to this session',
|
|
71
80
|
'pet.moreSessions': 'Expand {n} more session bubbles',
|
|
72
81
|
'pet.collapseSessions': 'Collapse session bubbles',
|
|
73
82
|
// First-level settings section (the `settings.section` seat).
|
|
74
83
|
'settings.title': 'Pet',
|
|
84
|
+
'settings.diagnosticsTitle': 'Pet directory diagnostics',
|
|
75
85
|
'settings.description': 'Pick a pet and tune its display layout.',
|
|
76
86
|
'settings.pet': 'Pet',
|
|
77
87
|
'settings.petHint': 'Choose which pet shows. Names are stored per pet; rename from the pet hover panel.',
|