@linxin666/dsh-pet 0.2.3 → 0.2.5
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 +65 -2
- package/README.zh.md +65 -2
- package/assets/decorations/whale/decoration.json +20 -0
- package/assets/decorations/whale/whale-frames.png +0 -0
- package/contracts/pet-manifest-v2.schema.json +281 -0
- package/contracts/status-decoration-v1.schema.json +144 -0
- package/contracts/voice-pack-v1.schema.json +234 -0
- package/lib/client.js +251 -43
- package/lib/client.js.map +1 -1
- package/lib/index.js +1139 -100
- package/lib/types/access.d.ts +16 -0
- package/lib/types/access.d.ts.map +1 -0
- package/lib/types/access.js +20 -0
- package/lib/types/chatter.d.ts +61 -3
- package/lib/types/chatter.d.ts.map +1 -1
- package/lib/types/chatter.js +71 -12
- package/lib/types/client/PetSettingsCard.d.ts +4 -0
- package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PetSettingsCard.js +11 -3
- package/lib/types/client/PetSprite.d.ts.map +1 -1
- package/lib/types/client/PetSprite.js +111 -4
- package/lib/types/client/locales.d.ts +4 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +4 -0
- package/lib/types/client/renderers/live2d/Live2dVisualMount.d.ts.map +1 -1
- package/lib/types/client/renderers/live2d/Live2dVisualMount.js +1 -0
- package/lib/types/client/renderers/live2d/runtime.d.ts +13 -1
- package/lib/types/client/renderers/live2d/runtime.d.ts.map +1 -1
- package/lib/types/client/renderers/live2d.d.ts.map +1 -1
- package/lib/types/client/renderers/live2d.js +133 -25
- package/lib/types/client/settings-form.d.ts.map +1 -1
- package/lib/types/client/settings-form.js +9 -6
- package/lib/types/contracts/status-decoration.d.ts +85 -0
- package/lib/types/contracts/status-decoration.d.ts.map +1 -0
- package/lib/types/contracts/status-decoration.js +21 -0
- package/lib/types/decoration.d.ts +39 -0
- package/lib/types/decoration.d.ts.map +1 -0
- package/lib/types/decoration.js +210 -0
- package/lib/types/event-projection.d.ts +8 -3
- package/lib/types/event-projection.d.ts.map +1 -1
- package/lib/types/event-projection.js +9 -4
- package/lib/types/image-dimensions.d.ts +26 -0
- package/lib/types/image-dimensions.d.ts.map +1 -0
- package/lib/types/image-dimensions.js +77 -0
- package/lib/types/index.d.ts +2 -0
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +3 -1
- package/lib/types/registry.d.ts +55 -2
- package/lib/types/registry.d.ts.map +1 -1
- package/lib/types/registry.js +240 -16
- package/lib/types/routes.d.ts +6 -1
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +163 -32
- package/lib/types/service.d.ts +33 -0
- package/lib/types/service.d.ts.map +1 -1
- package/lib/types/service.js +42 -3
- package/lib/types/voice-pack.d.ts +98 -0
- package/lib/types/voice-pack.d.ts.map +1 -0
- package/lib/types/voice-pack.js +384 -0
- package/package.json +11 -10
- package/src/access.ts +40 -0
- package/src/chatter.test.ts +89 -2
- package/src/chatter.ts +120 -14
- package/src/client/PetSettingsCard.tsx +26 -2
- package/src/client/PetSprite.test.tsx +325 -12
- package/src/client/PetSprite.tsx +150 -25
- package/src/client/locales.ts +4 -0
- package/src/client/renderers/live2d/Live2dVisualMount.test.tsx +72 -0
- package/src/client/renderers/live2d/Live2dVisualMount.tsx +1 -0
- package/src/client/renderers/live2d/runtime.ts +8 -2
- package/src/client/renderers/live2d.test.ts +233 -9
- package/src/client/renderers/live2d.ts +143 -30
- package/src/client/settings-form.ts +8 -6
- package/src/contracts/status-decoration.ts +78 -0
- package/src/decoration.test.ts +178 -0
- package/src/decoration.ts +220 -0
- package/src/event-projection.ts +10 -5
- package/src/image-dimensions.test.ts +85 -0
- package/src/image-dimensions.ts +76 -0
- package/src/index.ts +3 -1
- package/src/registry.test.ts +276 -0
- package/src/registry.ts +274 -15
- package/src/routes.ts +162 -33
- package/src/service.ts +59 -3
- package/src/voice-pack.test.ts +216 -0
- package/src/voice-pack.ts +413 -0
package/src/routes.ts
CHANGED
|
@@ -5,7 +5,10 @@
|
|
|
5
5
|
* domains are platform-registered, so the pet serves its own API and media —
|
|
6
6
|
* the same pattern as dsh-remote-web-ui's '/api/pair' family. The asset route
|
|
7
7
|
* is one prefix registration serving every registry entry (manifest, atlas,
|
|
8
|
-
* optional previews), so adding a pet never touches route wiring.
|
|
8
|
+
* optional previews), so adding a pet never touches route wiring. Both the
|
|
9
|
+
* JSON API, the asset prefix, and the Live2D runtime prefix are loopback-only
|
|
10
|
+
* by default; a live paired-device cookie is an extra allow path when
|
|
11
|
+
* remote-web-ui is loaded.
|
|
9
12
|
* @module @linxin666/dsh-pet/routes
|
|
10
13
|
*/
|
|
11
14
|
|
|
@@ -13,11 +16,12 @@ import { existsSync, realpathSync, statSync } from 'node:fs'
|
|
|
13
16
|
import { readFile } from 'node:fs/promises'
|
|
14
17
|
import { join, sep } from 'node:path'
|
|
15
18
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
19
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
16
20
|
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
|
17
21
|
import type { PetService } from './service.ts'
|
|
18
22
|
import type { PetInteraction } from './affinity.ts'
|
|
19
|
-
import { petEntryView, petPackageRoot, type PetEntry, type PetRegistry } from './registry.ts'
|
|
20
|
-
import {
|
|
23
|
+
import { DECORATION_ASSET_PREFIX, petEntryView, petPackageRoot, type PetEntry, type PetRegistry } from './registry.ts'
|
|
24
|
+
import { isPetAllowed } from './access.ts'
|
|
21
25
|
import { dshHome } from './dsh-home.ts'
|
|
22
26
|
|
|
23
27
|
/** Browser-facing base path of the pet API. */
|
|
@@ -135,20 +139,20 @@ function readJsonBody(req: IncomingMessage): Promise<unknown> {
|
|
|
135
139
|
})
|
|
136
140
|
}
|
|
137
141
|
|
|
138
|
-
/** Shared route fence:
|
|
139
|
-
function guard(req: IncomingMessage, res: ServerResponse): boolean {
|
|
140
|
-
if (
|
|
142
|
+
/** Shared route fence: loopback always passes; a live paired-device cookie is an extra allow path. */
|
|
143
|
+
function guard(ctx: Context, req: IncomingMessage, res: ServerResponse): boolean {
|
|
144
|
+
if (isPetAllowed(ctx, req)) return true
|
|
141
145
|
json(res, 403, { ok: false, error: 'forbidden: loopback-only' })
|
|
142
146
|
return false
|
|
143
147
|
}
|
|
144
148
|
|
|
145
149
|
/** Wrap one async service call as a GET JSON route. */
|
|
146
|
-
function getRoute(path: string, run: () => Promise<unknown>): WebRoute {
|
|
150
|
+
function getRoute(ctx: Context, path: string, run: () => Promise<unknown>): WebRoute {
|
|
147
151
|
return {
|
|
148
152
|
kind: 'exact',
|
|
149
153
|
path,
|
|
150
154
|
handler: (req: IncomingMessage, res: ServerResponse): void => {
|
|
151
|
-
if (!guard(req, res)) return
|
|
155
|
+
if (!guard(ctx, req, res)) return
|
|
152
156
|
if (!requireMethod(req, res, 'GET')) return
|
|
153
157
|
run().then((value) => json(res, 200, value), (error) => {
|
|
154
158
|
json(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) })
|
|
@@ -158,12 +162,12 @@ function getRoute(path: string, run: () => Promise<unknown>): WebRoute {
|
|
|
158
162
|
}
|
|
159
163
|
|
|
160
164
|
/** Wrap one async service call as a POST JSON route (body passed through). */
|
|
161
|
-
function postRoute(path: string, run: (body: Record<string, unknown>) => Promise<unknown>): WebRoute {
|
|
165
|
+
function postRoute(ctx: Context, path: string, run: (body: Record<string, unknown>) => Promise<unknown>): WebRoute {
|
|
162
166
|
return {
|
|
163
167
|
kind: 'exact',
|
|
164
168
|
path,
|
|
165
169
|
handler: (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
166
|
-
if (!guard(req, res)) return Promise.resolve()
|
|
170
|
+
if (!guard(ctx, req, res)) return Promise.resolve()
|
|
167
171
|
if (!requireMethod(req, res, 'POST')) return Promise.resolve()
|
|
168
172
|
return readJsonBody(req).then((body) => {
|
|
169
173
|
const record = (typeof body === 'object' && body !== null) ? body as Record<string, unknown> : {}
|
|
@@ -200,10 +204,10 @@ function dirAliases(registry: PetRegistry): Map<string, PetEntry> {
|
|
|
200
204
|
* match; containedRealpath stays as the second layer. Composed pets without
|
|
201
205
|
* a manifest file get a synthesized pet.json.
|
|
202
206
|
*/
|
|
203
|
-
function assetHandler(registry: PetRegistry, caps: PetAssetCaps): WebRoute['handler'] {
|
|
207
|
+
function assetHandler(ctx: Context, registry: PetRegistry, caps: PetAssetCaps): WebRoute['handler'] {
|
|
204
208
|
const aliases = dirAliases(registry)
|
|
205
|
-
return (req: IncomingMessage, res: ServerResponse)
|
|
206
|
-
if (!guard(req, res)) return
|
|
209
|
+
return ((req: IncomingMessage, res: ServerResponse) => {
|
|
210
|
+
if (!guard(ctx, req, res)) return
|
|
207
211
|
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
208
212
|
res.writeHead(405)
|
|
209
213
|
res.end()
|
|
@@ -263,7 +267,7 @@ function assetHandler(registry: PetRegistry, caps: PetAssetCaps): WebRoute['hand
|
|
|
263
267
|
file = existsSync(preview) ? preview : undefined
|
|
264
268
|
}
|
|
265
269
|
if (synthesized) {
|
|
266
|
-
const body = Buffer.from(JSON.stringify(petEntryView(entry), null, 2), 'utf8')
|
|
270
|
+
const body = Buffer.from(JSON.stringify(petEntryView(entry, registry.globalVoice), null, 2), 'utf8')
|
|
267
271
|
res.writeHead(200, {
|
|
268
272
|
'content-type': 'application/json; charset=utf-8',
|
|
269
273
|
'content-length': String(body.byteLength),
|
|
@@ -303,7 +307,7 @@ function assetHandler(registry: PetRegistry, caps: PetAssetCaps): WebRoute['hand
|
|
|
303
307
|
res.end()
|
|
304
308
|
return
|
|
305
309
|
}
|
|
306
|
-
readFile(resolved).then((body) => {
|
|
310
|
+
return readFile(resolved).then((body) => {
|
|
307
311
|
res.writeHead(200, {
|
|
308
312
|
'content-type': mimeFor(resolved),
|
|
309
313
|
'content-length': String(body.byteLength),
|
|
@@ -318,7 +322,7 @@ function assetHandler(registry: PetRegistry, caps: PetAssetCaps): WebRoute['hand
|
|
|
318
322
|
res.writeHead(404)
|
|
319
323
|
res.end()
|
|
320
324
|
})
|
|
321
|
-
}
|
|
325
|
+
}) as WebRoute['handler']
|
|
322
326
|
}
|
|
323
327
|
|
|
324
328
|
/** Browser-facing base path of the plugin runtime files (pet-center M3). */
|
|
@@ -354,9 +358,9 @@ export interface PetRuntimeRoots {
|
|
|
354
358
|
* guidance (the Cubism Core is user-supplied, so its absence is a normal
|
|
355
359
|
* state, not an error).
|
|
356
360
|
*/
|
|
357
|
-
function runtimeHandler(roots: { runtimeDir: string; vendorDir: string }): WebRoute['handler'] {
|
|
358
|
-
return (req: IncomingMessage, res: ServerResponse)
|
|
359
|
-
if (!guard(req, res)) return
|
|
361
|
+
function runtimeHandler(ctx: Context, roots: { runtimeDir: string; vendorDir: string }): WebRoute['handler'] {
|
|
362
|
+
return ((req: IncomingMessage, res: ServerResponse) => {
|
|
363
|
+
if (!guard(ctx, req, res)) return
|
|
360
364
|
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
361
365
|
res.writeHead(405)
|
|
362
366
|
res.end()
|
|
@@ -409,7 +413,7 @@ function runtimeHandler(roots: { runtimeDir: string; vendorDir: string }): WebRo
|
|
|
409
413
|
res.end()
|
|
410
414
|
return
|
|
411
415
|
}
|
|
412
|
-
readFile(resolved).then((body) => {
|
|
416
|
+
return readFile(resolved).then((body) => {
|
|
413
417
|
res.writeHead(200, {
|
|
414
418
|
'content-type': name.endsWith('.map') ? 'application/json' : 'application/javascript; charset=utf-8',
|
|
415
419
|
'content-length': String(body.byteLength),
|
|
@@ -424,38 +428,157 @@ function runtimeHandler(roots: { runtimeDir: string; vendorDir: string }): WebRo
|
|
|
424
428
|
res.writeHead(404)
|
|
425
429
|
res.end()
|
|
426
430
|
})
|
|
431
|
+
}) as WebRoute['handler']
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* The decoration asset handler behind '/api/pet/decoration/<id>/<file>'
|
|
436
|
+
* (pet-center M5, #567). Serves exactly the files a decoration descriptor
|
|
437
|
+
* declares — decoration.json and the PNG/WebP strip — by exact allow-list
|
|
438
|
+
* match, with realpath containment and the same size ceilings as pet
|
|
439
|
+
* assets. Crafted '..' or '.' segments never match the normalized closure.
|
|
440
|
+
*/
|
|
441
|
+
function decorationHandler(ctx: Context, registry: PetRegistry, caps: PetAssetCaps): WebRoute['handler'] {
|
|
442
|
+
return (req: IncomingMessage, res: ServerResponse): void => {
|
|
443
|
+
if (!guard(ctx, req, res)) return
|
|
444
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
445
|
+
res.writeHead(405)
|
|
446
|
+
res.end()
|
|
447
|
+
return
|
|
448
|
+
}
|
|
449
|
+
let pathname: string
|
|
450
|
+
try {
|
|
451
|
+
pathname = new URL(req.url ?? '/', 'http://pet.local').pathname
|
|
452
|
+
} catch {
|
|
453
|
+
res.writeHead(400)
|
|
454
|
+
res.end()
|
|
455
|
+
return
|
|
456
|
+
}
|
|
457
|
+
const segments = pathname.split('/').filter(segment => segment !== '')
|
|
458
|
+
const prefixSegments = DECORATION_ASSET_PREFIX.split('/').filter(segment => segment !== '')
|
|
459
|
+
if (segments.length < prefixSegments.length + 2) {
|
|
460
|
+
res.writeHead(404)
|
|
461
|
+
res.end()
|
|
462
|
+
return
|
|
463
|
+
}
|
|
464
|
+
for (let i = 0; i < prefixSegments.length; i += 1) {
|
|
465
|
+
if (segments[i] !== prefixSegments[i]) {
|
|
466
|
+
res.writeHead(404)
|
|
467
|
+
res.end()
|
|
468
|
+
return
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
let id: string
|
|
472
|
+
try {
|
|
473
|
+
id = decodeURIComponent(segments[prefixSegments.length])
|
|
474
|
+
} catch {
|
|
475
|
+
res.writeHead(400)
|
|
476
|
+
res.end()
|
|
477
|
+
return
|
|
478
|
+
}
|
|
479
|
+
const entry = registry.decorationById?.(id)
|
|
480
|
+
if (entry === undefined) {
|
|
481
|
+
res.writeHead(404)
|
|
482
|
+
res.end()
|
|
483
|
+
return
|
|
484
|
+
}
|
|
485
|
+
const rest: string[] = []
|
|
486
|
+
for (const segment of segments.slice(prefixSegments.length + 1)) {
|
|
487
|
+
let decoded: string
|
|
488
|
+
try {
|
|
489
|
+
decoded = decodeURIComponent(segment)
|
|
490
|
+
} catch {
|
|
491
|
+
res.writeHead(400)
|
|
492
|
+
res.end()
|
|
493
|
+
return
|
|
494
|
+
}
|
|
495
|
+
rest.push(decoded)
|
|
496
|
+
}
|
|
497
|
+
const rel = rest.join('/')
|
|
498
|
+
if (!entry.servable.includes(rel)) {
|
|
499
|
+
res.writeHead(404)
|
|
500
|
+
res.end()
|
|
501
|
+
return
|
|
502
|
+
}
|
|
503
|
+
const file = join(entry.dir, rel)
|
|
504
|
+
const resolved = containedRealpath(entry.dir, file)
|
|
505
|
+
if (resolved === undefined) {
|
|
506
|
+
res.writeHead(403)
|
|
507
|
+
res.end()
|
|
508
|
+
return
|
|
509
|
+
}
|
|
510
|
+
const cap = rel === 'decoration.json' ? caps.manifest : caps.image
|
|
511
|
+
let stat: ReturnType<typeof statSync>
|
|
512
|
+
try {
|
|
513
|
+
stat = statSync(resolved)
|
|
514
|
+
if (stat.size > cap) {
|
|
515
|
+
res.writeHead(413)
|
|
516
|
+
res.end()
|
|
517
|
+
return
|
|
518
|
+
}
|
|
519
|
+
} catch {
|
|
520
|
+
res.writeHead(404)
|
|
521
|
+
res.end()
|
|
522
|
+
return
|
|
523
|
+
}
|
|
524
|
+
// Weak ETag from size + mtime: 'no-cache' forces revalidation, and the
|
|
525
|
+
// validator lets repeat requests settle as 304 — the ornament remounts
|
|
526
|
+
// on whisper and display-session flips, and without a validator each
|
|
527
|
+
// remount would re-download the full strip body.
|
|
528
|
+
const etag = '"' + stat.size.toString(16) + '-' + Math.round(stat.mtimeMs).toString(16) + '"'
|
|
529
|
+
if (req.headers['if-none-match'] === etag) {
|
|
530
|
+
res.writeHead(304, { etag, 'cache-control': 'no-cache' })
|
|
531
|
+
res.end()
|
|
532
|
+
return
|
|
533
|
+
}
|
|
534
|
+
readFile(resolved).then((body) => {
|
|
535
|
+
res.writeHead(200, {
|
|
536
|
+
'content-type': mimeFor(resolved),
|
|
537
|
+
'content-length': String(body.byteLength),
|
|
538
|
+
'cache-control': 'no-cache',
|
|
539
|
+
etag,
|
|
540
|
+
})
|
|
541
|
+
if (req.method === 'HEAD') {
|
|
542
|
+
res.end()
|
|
543
|
+
return
|
|
544
|
+
}
|
|
545
|
+
res.end(body)
|
|
546
|
+
}, () => {
|
|
547
|
+
res.writeHead(404)
|
|
548
|
+
res.end()
|
|
549
|
+
})
|
|
427
550
|
}
|
|
428
551
|
}
|
|
429
552
|
|
|
430
553
|
/** Build the full route family (API + assets + runtime) for one service. */
|
|
431
|
-
export function makePetRoutes(deps: { service: PetService; assetCaps?: PetAssetCaps } & PetRuntimeRoots): WebRoute[] {
|
|
432
|
-
const { service } = deps
|
|
554
|
+
export function makePetRoutes(deps: { service: PetService; ctx: Context; assetCaps?: PetAssetCaps } & PetRuntimeRoots): WebRoute[] {
|
|
555
|
+
const { service, ctx } = deps
|
|
433
556
|
const apiRoutes: WebRoute[] = [
|
|
434
|
-
getRoute(PET_API_PREFIX + '/state', () => service.state()),
|
|
435
|
-
getRoute(PET_API_PREFIX + '/pets', () => service.pets()),
|
|
436
|
-
getRoute(PET_API_PREFIX + '/diagnostics', () => service.diagnostics()),
|
|
437
|
-
postRoute(PET_API_PREFIX + '/interact', (body) => {
|
|
557
|
+
getRoute(ctx, PET_API_PREFIX + '/state', () => service.state()),
|
|
558
|
+
getRoute(ctx, PET_API_PREFIX + '/pets', () => service.pets()),
|
|
559
|
+
getRoute(ctx, PET_API_PREFIX + '/diagnostics', () => service.diagnostics()),
|
|
560
|
+
postRoute(ctx, PET_API_PREFIX + '/interact', (body) => {
|
|
438
561
|
const kind = body.kind as PetInteraction | undefined
|
|
439
562
|
if (kind !== 'pet' && kind !== 'feed') return Promise.reject(new Error('invalid-kind'))
|
|
440
563
|
return service.interact(kind)
|
|
441
564
|
}),
|
|
442
|
-
postRoute(PET_API_PREFIX + '/set-visible', (body) => {
|
|
565
|
+
postRoute(ctx, PET_API_PREFIX + '/set-visible', (body) => {
|
|
443
566
|
const visible = body.visible
|
|
444
567
|
if (typeof visible !== 'boolean') return Promise.reject(new Error('invalid-visible'))
|
|
445
568
|
return service.setVisible(visible)
|
|
446
569
|
}),
|
|
447
|
-
postRoute(PET_API_PREFIX + '/set-config', (body) => service.setConfig({
|
|
570
|
+
postRoute(ctx, PET_API_PREFIX + '/set-config', (body) => service.setConfig({
|
|
448
571
|
...(typeof body.size === 'number' ? { size: body.size } : {}),
|
|
449
572
|
...(typeof body.right === 'number' ? { right: body.right } : {}),
|
|
450
573
|
...(typeof body.bottom === 'number' ? { bottom: body.bottom } : {}),
|
|
451
574
|
...(typeof body.visible === 'boolean' ? { visible: body.visible } : {}),
|
|
452
575
|
})),
|
|
453
|
-
postRoute(PET_API_PREFIX + '/set-name', (body) => {
|
|
576
|
+
postRoute(ctx, PET_API_PREFIX + '/set-name', (body) => {
|
|
454
577
|
const name = body.name
|
|
455
578
|
if (typeof name !== 'string') return Promise.reject(new Error('invalid-name'))
|
|
456
579
|
return service.setName(name)
|
|
457
580
|
}),
|
|
458
|
-
postRoute(PET_API_PREFIX + '/set-pet', (body) => {
|
|
581
|
+
postRoute(ctx, PET_API_PREFIX + '/set-pet', (body) => {
|
|
459
582
|
const petId = body.petId
|
|
460
583
|
if (typeof petId !== 'string') return Promise.reject(new Error('invalid-pet'))
|
|
461
584
|
return service.setPetId(petId)
|
|
@@ -465,19 +588,25 @@ export function makePetRoutes(deps: { service: PetService; assetCaps?: PetAssetC
|
|
|
465
588
|
const assetRoute: WebRoute = {
|
|
466
589
|
kind: 'prefix',
|
|
467
590
|
path: PET_ASSET_PREFIX,
|
|
468
|
-
handler: assetHandler(service.registrySnapshot(), deps.assetCaps ?? PET_ASSET_CAPS),
|
|
591
|
+
handler: assetHandler(ctx, service.registrySnapshot(), deps.assetCaps ?? PET_ASSET_CAPS),
|
|
469
592
|
}
|
|
470
593
|
|
|
471
594
|
const runtimeRoute: WebRoute = {
|
|
472
595
|
kind: 'prefix',
|
|
473
596
|
path: PET_RUNTIME_PREFIX,
|
|
474
|
-
handler: runtimeHandler({
|
|
597
|
+
handler: runtimeHandler(ctx, {
|
|
475
598
|
runtimeDir: deps.runtimeDir ?? join(dshHome(), 'pets', '.runtime'),
|
|
476
599
|
vendorDir: deps.vendorDir ?? join(petPackageRoot(import.meta.url), 'lib'),
|
|
477
600
|
}),
|
|
478
601
|
}
|
|
479
602
|
|
|
480
|
-
|
|
603
|
+
const decorationRoute: WebRoute = {
|
|
604
|
+
kind: 'prefix',
|
|
605
|
+
path: DECORATION_ASSET_PREFIX,
|
|
606
|
+
handler: decorationHandler(ctx, service.registrySnapshot(), deps.assetCaps ?? PET_ASSET_CAPS),
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
return [...apiRoutes, assetRoute, runtimeRoute, decorationRoute]
|
|
481
610
|
}
|
|
482
611
|
|
|
483
612
|
// Re-exported for the package surface (the registry owns the definition now).
|
package/src/service.ts
CHANGED
|
@@ -36,6 +36,8 @@ import {
|
|
|
36
36
|
type PetDisplayConfig,
|
|
37
37
|
} from './persist.ts'
|
|
38
38
|
import {
|
|
39
|
+
DEFAULT_DECORATION_ID,
|
|
40
|
+
decorationView,
|
|
39
41
|
loadPetRegistry,
|
|
40
42
|
petEntryView,
|
|
41
43
|
petPackageRoot,
|
|
@@ -44,7 +46,9 @@ import {
|
|
|
44
46
|
type PetRegistry,
|
|
45
47
|
type PetRegistryDiagnostic,
|
|
46
48
|
} from './registry.ts'
|
|
47
|
-
import { WHISPER_TTL_MS } from './chatter.ts'
|
|
49
|
+
import { WHISPER_TTL_MS, type VoicePackOverrides, type VoicePoolsProvider } from './chatter.ts'
|
|
50
|
+
import { mergeVoicePacks } from './voice-pack.ts'
|
|
51
|
+
import type { DecorationView } from './contracts/status-decoration.ts'
|
|
48
52
|
import {
|
|
49
53
|
defaultPetStateConfig,
|
|
50
54
|
PetStateMachine,
|
|
@@ -65,6 +69,8 @@ export interface PetConfig {
|
|
|
65
69
|
persistDir?: string
|
|
66
70
|
/** Master switch for the plugin (browser half + host routes). */
|
|
67
71
|
enabled?: boolean
|
|
72
|
+
/** Status-decoration master switch (pet-center M5, #567); defaults to on. */
|
|
73
|
+
decorationEnabled?: boolean
|
|
68
74
|
/** Prebuilt registry (tests); defaults to scanning the package + user dirs. */
|
|
69
75
|
registry?: PetRegistry
|
|
70
76
|
/** Extra manifest entries composed by the embedding application. */
|
|
@@ -91,6 +97,11 @@ export interface PetSettingsSection {
|
|
|
91
97
|
bottom: number
|
|
92
98
|
/** Master switch for the plugin (browser half + host routes). */
|
|
93
99
|
enabled?: boolean
|
|
100
|
+
/**
|
|
101
|
+
* Status-decoration master switch (pet-center M5, #567). Defaults to on;
|
|
102
|
+
* the settings surface mirrors this field and can turn it off.
|
|
103
|
+
*/
|
|
104
|
+
decorationEnabled?: boolean
|
|
94
105
|
}
|
|
95
106
|
|
|
96
107
|
/** Settings namespace of the pet capability. Spelled here rather than imported: the browser half spells the same value. */
|
|
@@ -159,6 +170,12 @@ export interface PetStateView {
|
|
|
159
170
|
* rendered by the client as a distinct whisper bubble.
|
|
160
171
|
*/
|
|
161
172
|
whisper?: string
|
|
173
|
+
/**
|
|
174
|
+
* The active status decoration (pet-center M5, #567), when the master
|
|
175
|
+
* switch is on and the default decoration entry exists. Absent means the
|
|
176
|
+
* browser half renders no ornament.
|
|
177
|
+
*/
|
|
178
|
+
decoration?: DecorationView
|
|
162
179
|
}
|
|
163
180
|
|
|
164
181
|
/** Result of `pet.interact`. */
|
|
@@ -200,9 +217,17 @@ export class PetService extends Service {
|
|
|
200
217
|
private readonly registry: PetRegistry
|
|
201
218
|
private readonly persistDir: string
|
|
202
219
|
private enabled: boolean
|
|
220
|
+
/** Status-decoration master switch (M5, #567); mirrored from settings. */
|
|
221
|
+
private decorationEnabled: boolean
|
|
203
222
|
private disposeActivity: (() => void) | undefined
|
|
204
223
|
/** Session whose most recent meaningful event currently drives the global pet. */
|
|
205
224
|
private displaySession: Session | undefined
|
|
225
|
+
/**
|
|
226
|
+
* Effective voice-pack overrides for the currently selected pet (M4,
|
|
227
|
+
* #677). Cached per pet id; the registry is an immutable snapshot, so the
|
|
228
|
+
* global pack and each entry's pack cannot change behind the cache.
|
|
229
|
+
*/
|
|
230
|
+
private voiceCache: { petId: string; overrides: VoicePackOverrides } | undefined
|
|
206
231
|
/**
|
|
207
232
|
* Per-session activity, most recent last (Map insertion order). Bounded by
|
|
208
233
|
* MAX_SESSION_BUBBLES so a burst of sessions cannot grow it without bound;
|
|
@@ -237,10 +262,28 @@ export class PetService extends Service {
|
|
|
237
262
|
this.stateConfig = { ...defaultPetStateConfig, ...(config.state ?? {}) }
|
|
238
263
|
this.machine = new PetStateMachine(this.stateConfig)
|
|
239
264
|
this.enabled = config.enabled ?? true
|
|
265
|
+
this.decorationEnabled = config.decorationEnabled ?? true
|
|
240
266
|
|
|
241
267
|
this.syncActivity()
|
|
242
268
|
}
|
|
243
269
|
|
|
270
|
+
/**
|
|
271
|
+
* The draw-time voice-pool provider handed to every projection runtime.
|
|
272
|
+
* It re-resolves when the selected pet changes, so live engines re-voice
|
|
273
|
+
* on the next draw without being rebuilt (M4, #677).
|
|
274
|
+
*/
|
|
275
|
+
private voicePools(): VoicePoolsProvider {
|
|
276
|
+
return () => {
|
|
277
|
+
const entry = this.activeEntry()
|
|
278
|
+
if (this.voiceCache !== undefined && this.voiceCache.petId === entry.id) {
|
|
279
|
+
return this.voiceCache.overrides
|
|
280
|
+
}
|
|
281
|
+
const overrides = mergeVoicePacks(this.registry.globalVoice, entry.voice)?.overrides ?? {}
|
|
282
|
+
this.voiceCache = { petId: entry.id, overrides }
|
|
283
|
+
return overrides
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
244
287
|
/** Whether the pet service consumes session activity while enabled. */
|
|
245
288
|
isEnabled(): boolean {
|
|
246
289
|
return this.enabled
|
|
@@ -258,7 +301,7 @@ export class PetService extends Service {
|
|
|
258
301
|
|
|
259
302
|
/** RPC: the registry entries the browser half renders and selects from. */
|
|
260
303
|
async pets(): Promise<PetDefinition[]> {
|
|
261
|
-
return this.registry.entries.map(petEntryView)
|
|
304
|
+
return this.registry.entries.map(entry => petEntryView(entry, this.registry.globalVoice))
|
|
262
305
|
}
|
|
263
306
|
|
|
264
307
|
/** The loaded registry (the asset routes serve its entries). */
|
|
@@ -271,6 +314,16 @@ export class PetService extends Service {
|
|
|
271
314
|
return { diagnostics: this.registry.diagnostics }
|
|
272
315
|
}
|
|
273
316
|
|
|
317
|
+
/**
|
|
318
|
+
* The active status decoration view (M5, #567): the default 'whale' entry
|
|
319
|
+
* (user directories override built-ins by id), gated by the master switch.
|
|
320
|
+
*/
|
|
321
|
+
private activeDecoration(): DecorationView | undefined {
|
|
322
|
+
if (!this.decorationEnabled) return undefined
|
|
323
|
+
const entry = this.registry.decorationById?.(DEFAULT_DECORATION_ID)
|
|
324
|
+
return entry === undefined ? undefined : decorationView(entry)
|
|
325
|
+
}
|
|
326
|
+
|
|
274
327
|
/** The selected pet's registry entry. */
|
|
275
328
|
activeEntry(): NonNullable<PetRegistry['entries'][number]> {
|
|
276
329
|
return this.registry.byId(this.selectedPetId()) ?? this.registry.defaultEntry()
|
|
@@ -370,7 +423,7 @@ export class PetService extends Service {
|
|
|
370
423
|
let activity = this.sessionActivity.get(session)
|
|
371
424
|
if (activity === undefined) {
|
|
372
425
|
activity = {
|
|
373
|
-
runtime: emptyProjectionRuntime(),
|
|
426
|
+
runtime: emptyProjectionRuntime(this.voicePools()),
|
|
374
427
|
machine: new PetStateMachine(this.stateConfig),
|
|
375
428
|
}
|
|
376
429
|
this.sessionActivity.set(session, activity)
|
|
@@ -449,6 +502,7 @@ export class PetService extends Service {
|
|
|
449
502
|
* @param section - the resolved settings section.
|
|
450
503
|
*/
|
|
451
504
|
applySettingsSection(section: PetSettingsSection): void {
|
|
505
|
+
this.decorationEnabled = section.decorationEnabled ?? true
|
|
452
506
|
const selected = typeof section.petId === 'string' ? this.registry.byId(section.petId) : undefined
|
|
453
507
|
if (selected !== undefined) {
|
|
454
508
|
this.ledger.setPetId(selected.id)
|
|
@@ -524,6 +578,7 @@ export class PetService extends Service {
|
|
|
524
578
|
const freshWhisper = whisper !== undefined && Date.now() - whisper.at < WHISPER_TTL_MS
|
|
525
579
|
? whisper.text
|
|
526
580
|
: undefined
|
|
581
|
+
const decoration = this.activeDecoration()
|
|
527
582
|
// Read-only: the ledger settles on economic events only, never on a read,
|
|
528
583
|
// so polling the state cannot trigger pet.json writes.
|
|
529
584
|
return {
|
|
@@ -533,6 +588,7 @@ export class PetService extends Service {
|
|
|
533
588
|
sessionActive: snapshot.sessionActive,
|
|
534
589
|
sessions,
|
|
535
590
|
...(freshWhisper === undefined ? {} : { whisper: freshWhisper }),
|
|
591
|
+
...(decoration === undefined ? {} : { decoration }),
|
|
536
592
|
affinity: this.ledger.affinityView(Date.now()),
|
|
537
593
|
display: { ...this.ledger.snapshot.display },
|
|
538
594
|
pet: {
|