@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.
Files changed (90) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +66 -3
  3. package/README.zh.md +66 -3
  4. package/assets/whale/pet.json +169 -20
  5. package/assets/whale-refined/pet.json +17 -2
  6. package/lib/client.js +438 -21
  7. package/lib/client.js.map +1 -1
  8. package/lib/index.js +752 -36
  9. package/lib/live2d-vendor.js +995 -0
  10. package/lib/live2d-vendor.js.map +1 -0
  11. package/lib/types/client/PetDockEntry.d.ts.map +1 -1
  12. package/lib/types/client/PetDockEntry.js +2 -1
  13. package/lib/types/client/PetSettingsCard.d.ts +10 -0
  14. package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
  15. package/lib/types/client/PetSettingsCard.js +22 -1
  16. package/lib/types/client/PetSprite.d.ts +7 -1
  17. package/lib/types/client/PetSprite.d.ts.map +1 -1
  18. package/lib/types/client/PetSprite.js +17 -8
  19. package/lib/types/client/index.d.ts.map +1 -1
  20. package/lib/types/client/index.js +5 -0
  21. package/lib/types/client/locales.d.ts +10 -0
  22. package/lib/types/client/locales.d.ts.map +1 -1
  23. package/lib/types/client/locales.js +10 -0
  24. package/lib/types/client/phase-stream.d.ts +22 -0
  25. package/lib/types/client/phase-stream.d.ts.map +1 -0
  26. package/lib/types/client/phase-stream.js +28 -0
  27. package/lib/types/client/renderers/PetRendererSwitch.d.ts +25 -0
  28. package/lib/types/client/renderers/PetRendererSwitch.d.ts.map +1 -0
  29. package/lib/types/client/renderers/PetRendererSwitch.js +24 -0
  30. package/lib/types/client/renderers/live2d/Live2dVisualMount.d.ts +22 -0
  31. package/lib/types/client/renderers/live2d/Live2dVisualMount.d.ts.map +1 -0
  32. package/lib/types/client/renderers/live2d/Live2dVisualMount.js +75 -0
  33. package/lib/types/client/renderers/live2d/runtime.d.ts +89 -0
  34. package/lib/types/client/renderers/live2d/runtime.d.ts.map +1 -0
  35. package/lib/types/client/renderers/live2d/runtime.js +63 -0
  36. package/lib/types/client/renderers/live2d/vendor-entry.d.ts +15 -0
  37. package/lib/types/client/renderers/live2d/vendor-entry.d.ts.map +1 -0
  38. package/lib/types/client/renderers/live2d/vendor-entry.js +14 -0
  39. package/lib/types/client/renderers/live2d.d.ts +52 -0
  40. package/lib/types/client/renderers/live2d.d.ts.map +1 -0
  41. package/lib/types/client/renderers/live2d.js +180 -0
  42. package/lib/types/client/renderers/registry.d.ts +32 -0
  43. package/lib/types/client/renderers/registry.d.ts.map +1 -0
  44. package/lib/types/client/renderers/registry.js +49 -0
  45. package/lib/types/contracts/renderer.d.ts +50 -0
  46. package/lib/types/contracts/renderer.d.ts.map +1 -0
  47. package/lib/types/contracts/renderer.js +15 -0
  48. package/lib/types/manifest-v2.d.ts +129 -0
  49. package/lib/types/manifest-v2.d.ts.map +1 -0
  50. package/lib/types/manifest-v2.js +387 -0
  51. package/lib/types/model3.d.ts +26 -0
  52. package/lib/types/model3.d.ts.map +1 -0
  53. package/lib/types/model3.js +96 -0
  54. package/lib/types/registry.d.ts +57 -3
  55. package/lib/types/registry.d.ts.map +1 -1
  56. package/lib/types/registry.js +208 -33
  57. package/lib/types/routes.d.ts +39 -2
  58. package/lib/types/routes.d.ts.map +1 -1
  59. package/lib/types/routes.js +183 -12
  60. package/lib/types/service.d.ts +5 -1
  61. package/lib/types/service.d.ts.map +1 -1
  62. package/lib/types/service.js +4 -0
  63. package/package.json +3 -1
  64. package/src/client/PetDockEntry.tsx +19 -11
  65. package/src/client/PetSettingsCard.tsx +39 -0
  66. package/src/client/PetSprite.test.tsx +11 -0
  67. package/src/client/PetSprite.tsx +24 -9
  68. package/src/client/index.ts +6 -0
  69. package/src/client/locales.ts +10 -0
  70. package/src/client/phase-stream.test.ts +76 -0
  71. package/src/client/phase-stream.ts +39 -0
  72. package/src/client/renderers/PetRendererSwitch.test.tsx +66 -0
  73. package/src/client/renderers/PetRendererSwitch.tsx +48 -0
  74. package/src/client/renderers/live2d/Live2dVisualMount.tsx +98 -0
  75. package/src/client/renderers/live2d/runtime.test.ts +61 -0
  76. package/src/client/renderers/live2d/runtime.ts +119 -0
  77. package/src/client/renderers/live2d/vendor-entry.ts +14 -0
  78. package/src/client/renderers/live2d.test.ts +242 -0
  79. package/src/client/renderers/live2d.ts +218 -0
  80. package/src/client/renderers/registry.ts +58 -0
  81. package/src/client/settings-section.module.css +23 -0
  82. package/src/contracts/renderer.ts +54 -0
  83. package/src/manifest-v2.test.ts +251 -0
  84. package/src/manifest-v2.ts +414 -0
  85. package/src/model3.test.ts +79 -0
  86. package/src/model3.ts +91 -0
  87. package/src/registry.test.ts +215 -2
  88. package/src/registry.ts +246 -34
  89. package/src/routes.ts +202 -13
  90. package/src/service.ts +6 -0
@@ -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 declared spritesheet path, and optional 'previews/<name>'
137
- * media. Composed pets without a manifest file get a synthesized pet.json.
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 && rel === entry.spritesheetPath) {
202
- file = join(entry.dir, entry.spritesheetPath);
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
- const resolved = file;
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
- /** Build the full route family (API + assets) for one service. */
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';
@@ -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":"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"}
1
+ {"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../../src/service.ts"],"names":[],"mappings":"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"}
@@ -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.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
- <PetSprite
79
- snapshot={snapshot}
79
+ <PetRendererSwitch
80
80
  definition={definition}
81
- display={snapshot.display}
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()
@@ -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
- backgroundImage: imageReady ? 'url(' + definition.atlasUrl + ')' : undefined,
347
- backgroundSize: (cell.width * columns * spriteScale) + 'px ' + (cell.height * (definition.atlasRows ?? rows.length) * spriteScale) + 'px',
348
- backgroundRepeat: 'no-repeat',
349
- backgroundPosition: '0 0',
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}
@@ -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 => {
@@ -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.',