@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.
Files changed (87) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +65 -2
  3. package/README.zh.md +65 -2
  4. package/assets/decorations/whale/decoration.json +20 -0
  5. package/assets/decorations/whale/whale-frames.png +0 -0
  6. package/contracts/pet-manifest-v2.schema.json +281 -0
  7. package/contracts/status-decoration-v1.schema.json +144 -0
  8. package/contracts/voice-pack-v1.schema.json +234 -0
  9. package/lib/client.js +251 -43
  10. package/lib/client.js.map +1 -1
  11. package/lib/index.js +1139 -100
  12. package/lib/types/access.d.ts +16 -0
  13. package/lib/types/access.d.ts.map +1 -0
  14. package/lib/types/access.js +20 -0
  15. package/lib/types/chatter.d.ts +61 -3
  16. package/lib/types/chatter.d.ts.map +1 -1
  17. package/lib/types/chatter.js +71 -12
  18. package/lib/types/client/PetSettingsCard.d.ts +4 -0
  19. package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
  20. package/lib/types/client/PetSettingsCard.js +11 -3
  21. package/lib/types/client/PetSprite.d.ts.map +1 -1
  22. package/lib/types/client/PetSprite.js +111 -4
  23. package/lib/types/client/locales.d.ts +4 -0
  24. package/lib/types/client/locales.d.ts.map +1 -1
  25. package/lib/types/client/locales.js +4 -0
  26. package/lib/types/client/renderers/live2d/Live2dVisualMount.d.ts.map +1 -1
  27. package/lib/types/client/renderers/live2d/Live2dVisualMount.js +1 -0
  28. package/lib/types/client/renderers/live2d/runtime.d.ts +13 -1
  29. package/lib/types/client/renderers/live2d/runtime.d.ts.map +1 -1
  30. package/lib/types/client/renderers/live2d.d.ts.map +1 -1
  31. package/lib/types/client/renderers/live2d.js +133 -25
  32. package/lib/types/client/settings-form.d.ts.map +1 -1
  33. package/lib/types/client/settings-form.js +9 -6
  34. package/lib/types/contracts/status-decoration.d.ts +85 -0
  35. package/lib/types/contracts/status-decoration.d.ts.map +1 -0
  36. package/lib/types/contracts/status-decoration.js +21 -0
  37. package/lib/types/decoration.d.ts +39 -0
  38. package/lib/types/decoration.d.ts.map +1 -0
  39. package/lib/types/decoration.js +210 -0
  40. package/lib/types/event-projection.d.ts +8 -3
  41. package/lib/types/event-projection.d.ts.map +1 -1
  42. package/lib/types/event-projection.js +9 -4
  43. package/lib/types/image-dimensions.d.ts +26 -0
  44. package/lib/types/image-dimensions.d.ts.map +1 -0
  45. package/lib/types/image-dimensions.js +77 -0
  46. package/lib/types/index.d.ts +2 -0
  47. package/lib/types/index.d.ts.map +1 -1
  48. package/lib/types/index.js +3 -1
  49. package/lib/types/registry.d.ts +55 -2
  50. package/lib/types/registry.d.ts.map +1 -1
  51. package/lib/types/registry.js +240 -16
  52. package/lib/types/routes.d.ts +6 -1
  53. package/lib/types/routes.d.ts.map +1 -1
  54. package/lib/types/routes.js +163 -32
  55. package/lib/types/service.d.ts +33 -0
  56. package/lib/types/service.d.ts.map +1 -1
  57. package/lib/types/service.js +42 -3
  58. package/lib/types/voice-pack.d.ts +98 -0
  59. package/lib/types/voice-pack.d.ts.map +1 -0
  60. package/lib/types/voice-pack.js +384 -0
  61. package/package.json +11 -10
  62. package/src/access.ts +40 -0
  63. package/src/chatter.test.ts +89 -2
  64. package/src/chatter.ts +120 -14
  65. package/src/client/PetSettingsCard.tsx +26 -2
  66. package/src/client/PetSprite.test.tsx +325 -12
  67. package/src/client/PetSprite.tsx +150 -25
  68. package/src/client/locales.ts +4 -0
  69. package/src/client/renderers/live2d/Live2dVisualMount.test.tsx +72 -0
  70. package/src/client/renderers/live2d/Live2dVisualMount.tsx +1 -0
  71. package/src/client/renderers/live2d/runtime.ts +8 -2
  72. package/src/client/renderers/live2d.test.ts +233 -9
  73. package/src/client/renderers/live2d.ts +143 -30
  74. package/src/client/settings-form.ts +8 -6
  75. package/src/contracts/status-decoration.ts +78 -0
  76. package/src/decoration.test.ts +178 -0
  77. package/src/decoration.ts +220 -0
  78. package/src/event-projection.ts +10 -5
  79. package/src/image-dimensions.test.ts +85 -0
  80. package/src/image-dimensions.ts +76 -0
  81. package/src/index.ts +3 -1
  82. package/src/registry.test.ts +276 -0
  83. package/src/registry.ts +274 -15
  84. package/src/routes.ts +162 -33
  85. package/src/service.ts +59 -3
  86. package/src/voice-pack.test.ts +216 -0
  87. package/src/voice-pack.ts +413 -0
@@ -10,17 +10,18 @@
10
10
  */
11
11
 
12
12
  import { useEffect, useLayoutEffect, useRef, useState } from 'react'
13
- import type { CSSProperties, PointerEvent as ReactPointerEvent, ReactNode, ReactPortal } from 'react'
13
+ import type { CSSProperties, PointerEvent as ReactPointerEvent, ReactElement, 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'
17
17
  import type { PetDisplayConfig } from '../persist.ts'
18
18
  import type { PetStateView } from '../service.ts'
19
19
  import type { PetDefinition } from '../registry.ts'
20
+ import type { DecorationView } from '../contracts/status-decoration.ts'
20
21
  import type { PetFeedback } from './pet-store.ts'
21
22
  import { framePosition, rowOfTrack, trimTrack } from './spritesheet.ts'
22
23
  import { sequenceFrameAt } from './sequences.ts'
23
- import { animationForPhase, type PetAnimation } from '../state.ts'
24
+ import { animationForPhase, type ActivityPhase, type PetAnimation } from '../state.ts'
24
25
  import { NS } from './locales.ts'
25
26
  import styles from './pet.module.css'
26
27
 
@@ -63,6 +64,89 @@ function clampOffset(value: number, max: number): number {
63
64
  return Math.max(0, Math.min(max, value))
64
65
  }
65
66
 
67
+ /**
68
+ * The status decoration ornament (pet-center M5, #567). Renders the active
69
+ * phase's frame segment as a CSS-background strip at a compact bubble
70
+ * height; prefers-reduced-motion holds the segment's first frame, and a
71
+ * missing or undecodable asset simply paints nothing (CSS background
72
+ * failure) — the bubble text is never disturbed. The span is aria-hidden;
73
+ * the bubble keeps its own semantics untouched.
74
+ */
75
+ function StatusOrnament(props: { decoration: DecorationView; phase: ActivityPhase }): ReactElement | null {
76
+ const { decoration, phase } = props
77
+ const segment = decoration.phases[phase]
78
+ const shown = segment !== undefined && segment !== 'hide'
79
+ const segmentKey = segment !== undefined && segment !== 'hide' ? segment.from + ':' + segment.to : 'none'
80
+ const spanRef = useRef<HTMLSpanElement | null>(null)
81
+ const scale = 18 / decoration.cell.height
82
+ const frameWidth = Math.round(decoration.cell.width * scale)
83
+ const stripWidth = decoration.columns * frameWidth
84
+ // Value-stable dependency key: the host serves a fresh DecorationView
85
+ // object on every state poll (2 s), so the effect must not depend on the
86
+ // object identity — otherwise each poll would cancel and restart the
87
+ // frame loop and the animation would jump back to its first frame.
88
+ const durationsKey = decoration.durations.join(',')
89
+ useEffect(() => {
90
+ if (segment === undefined || segment === 'hide') return
91
+ const el = spanRef.current
92
+ if (el === null) return
93
+ const position = (index: number): string => (-index * frameWidth) + 'px 0px'
94
+ const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches === true
95
+ el.style.backgroundPosition = position(segment.from)
96
+ // A single-frame segment (from === to) has nothing to animate: with
97
+ // loop=true the wrap branch would reset index to the same frame and the
98
+ // tick would keep rescheduling a no-op rAF forever. Settle on the one
99
+ // frame instead — same as the reduced-motion static hold.
100
+ if (reduceMotion || segment.from === segment.to) return
101
+ let raf = 0
102
+ let index = segment.from
103
+ let elapsed = 0
104
+ let last = performance.now()
105
+ const tick = (ts: number): void => {
106
+ const delta = ts - last
107
+ last = ts
108
+ elapsed += delta
109
+ const duration = decoration.durations[index] ?? 160
110
+ if (elapsed >= duration) {
111
+ elapsed = 0
112
+ if (index < segment.to) index += 1
113
+ else if (decoration.loop) index = segment.from
114
+ // Only advance the background when the frame actually changes:
115
+ // the segment's frame rate (duration ms, typically 90-160) is far
116
+ // below the rAF cadence, so writing the same position every frame
117
+ // would churn style recalculations for no visual change.
118
+ el.style.backgroundPosition = position(index)
119
+ }
120
+ // A non-looping segment settles on its last frame; stop scheduling
121
+ // instead of repainting the same position every frame.
122
+ if (!decoration.loop && index === segment.to) return
123
+ raf = requestAnimationFrame(tick)
124
+ }
125
+ raf = requestAnimationFrame(tick)
126
+ return () => cancelAnimationFrame(raf)
127
+ }, [shown, segmentKey, frameWidth, decoration.loop, durationsKey])
128
+ if (!shown) return null
129
+ return (
130
+ <span
131
+ ref={spanRef}
132
+ aria-hidden="true"
133
+ data-dsh-pet-decoration={decoration.id}
134
+ style={{
135
+ display: 'inline-block',
136
+ width: frameWidth,
137
+ height: 18,
138
+ marginRight: 6,
139
+ verticalAlign: 'middle',
140
+ flexShrink: 0,
141
+ backgroundImage: 'url(' + decoration.entryUrl + ')',
142
+ backgroundSize: stripWidth + 'px 18px',
143
+ backgroundRepeat: 'no-repeat',
144
+ backgroundPosition: '0px 0px',
145
+ }}
146
+ />
147
+ )
148
+ }
149
+
66
150
  /**
67
151
  * The floating pet. The spritesheet frame advances on requestAnimationFrame
68
152
  * with per-frame durations from the definition's tracks; the atlas image is
@@ -110,6 +194,33 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
110
194
  const rows = definition.rows
111
195
  const tracks = definition.tracks
112
196
  const sequences = definition.sequences
197
+ // Hover-panel chrome from the pet's voice pack (pet-center M4, issue
198
+ // #677): every slot falls back to the i18n dictionary when unset. Stat
199
+ // formats carry {rank}/{n}/{points} placeholders the host validated.
200
+ const panel = definition.panel
201
+ const panelLabel = (slot: 'feed' | 'rename' | 'hide' | 'confirm', i18n: string): string =>
202
+ panel?.labels?.[slot] ?? i18n
203
+ const panelStat = (
204
+ slot: 'rank' | 'treats' | 'points',
205
+ i18nKey: 'pet.rank' | 'pet.treats' | 'pet.points',
206
+ values: Record<string, string | number>,
207
+ ): string => {
208
+ const format = panel?.stats?.[slot] ?? props.t(i18nKey, values)
209
+ if (panel?.stats?.[slot] === undefined) return format
210
+ // The host whitelists {rank}/{n}/{points} in every stat slot, so a pack
211
+ // format may reference any of them; substitute all three live values
212
+ // (the slot's own value plus the siblings) instead of only the slot's.
213
+ const all: Record<string, string | number> = {
214
+ rank: snapshot?.affinity.rank ?? '?',
215
+ n: snapshot?.treats.stocked ?? 0,
216
+ points: snapshot?.affinity.points ?? 0,
217
+ }
218
+ let text = format
219
+ for (const [name, value] of Object.entries(all)) text = text.replaceAll('{' + name + '}', String(value))
220
+ return text
221
+ }
222
+ const panelShows = (action: 'feed' | 'rename' | 'hide'): boolean =>
223
+ panel?.actions === undefined || panel.actions.includes(action)
113
224
 
114
225
  // Load the atlas once; the definition carries the authoritative per-row
115
226
  // frame counts and per-track durations, so nothing else is fetched. A
@@ -287,6 +398,8 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
287
398
  const whisper = feedback === null ? snapshot?.whisper : undefined
288
399
  const bubblePresent = feedback !== null || sessionBubbles.length > 0 || statusBubble !== undefined || whisper !== undefined
289
400
  const displayName = snapshot?.name ?? definition.displayName
401
+ // The host-served status decoration (M5, #567); absent = text-only bubbles.
402
+ const decoration = snapshot?.decoration
290
403
 
291
404
  // A settled session list can no longer stay pinned open.
292
405
  useEffect(() => {
@@ -408,6 +521,9 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
408
521
  title={props.t('pet.openSessionHint')}
409
522
  onClick={() => { props.onOpenSession(session.sessionId) }}
410
523
  >
524
+ {index === 0 && !speaksWhisper && decoration !== undefined && (
525
+ <StatusOrnament decoration={decoration} phase={phase} />
526
+ )}
411
527
  {speaksWhisper ? whisper : session.bubble}
412
528
  </button>
413
529
  )
@@ -446,6 +562,9 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
446
562
  role="status"
447
563
  aria-live="polite"
448
564
  >
565
+ {whisper === undefined && decoration !== undefined && (
566
+ <StatusOrnament decoration={decoration} phase={phase} />
567
+ )}
449
568
  {whisper ?? statusBubble}
450
569
  </div>
451
570
  )}
@@ -507,39 +626,45 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
507
626
  }
508
627
  }}
509
628
  >
510
- {props.t('pet.confirm')}
629
+ {panelLabel('confirm', props.t('pet.confirm'))}
511
630
  </button>
512
631
  </div>
513
632
  ) : (
514
633
  <>
515
634
  <div className={styles.rankRow}>
516
635
  <span className={styles.nameCell}>{displayName}</span>
517
- <span className={styles.statRank}>{props.t('pet.rank', { rank: snapshot?.affinity.rank ?? '?' })}</span>
636
+ <span className={styles.statRank}>{panelStat('rank', 'pet.rank', { rank: snapshot?.affinity.rank ?? '?' })}</span>
518
637
  </div>
519
638
  <div className={styles.rankRow}>
520
- <span className={styles.statTreats}>{props.t('pet.treats', { n: snapshot?.treats.stocked ?? 0 })}</span>
521
- <span className={styles.statPoints}>{props.t('pet.points', { points: snapshot?.affinity.points ?? 0 })}</span>
639
+ <span className={styles.statTreats}>{panelStat('treats', 'pet.treats', { n: snapshot?.treats.stocked ?? 0 })}</span>
640
+ <span className={styles.statPoints}>{panelStat('points', 'pet.points', { points: snapshot?.affinity.points ?? 0 })}</span>
522
641
  </div>
523
642
  <div className={styles.actions}>
524
- <button type="button" className={styles.action} onClick={props.onFeed}>
525
- {props.t('pet.feed')}
526
- </button>
527
- <button
528
- type="button"
529
- className={styles.action}
530
- onClick={() => {
531
- // Cancel any pending hide so the rename box cannot
532
- // unmount right as the user starts typing (#303).
533
- clearHideTimer()
534
- setNameDraft(displayName)
535
- setRenaming(true)
536
- }}
537
- >
538
- {props.t('pet.rename')}
539
- </button>
540
- <button type="button" className={styles.action} onClick={props.onHide}>
541
- {props.t('pet.hide')}
542
- </button>
643
+ {panelShows('feed') && (
644
+ <button type="button" className={styles.action} onClick={props.onFeed}>
645
+ {panelLabel('feed', props.t('pet.feed'))}
646
+ </button>
647
+ )}
648
+ {panelShows('rename') && (
649
+ <button
650
+ type="button"
651
+ className={styles.action}
652
+ onClick={() => {
653
+ // Cancel any pending hide so the rename box cannot
654
+ // unmount right as the user starts typing (#303).
655
+ clearHideTimer()
656
+ setNameDraft(displayName)
657
+ setRenaming(true)
658
+ }}
659
+ >
660
+ {panelLabel('rename', props.t('pet.rename'))}
661
+ </button>
662
+ )}
663
+ {panelShows('hide') && (
664
+ <button type="button" className={styles.action} onClick={props.onHide}>
665
+ {panelLabel('hide', props.t('pet.hide'))}
666
+ </button>
667
+ )}
543
668
  </div>
544
669
  </>
545
670
  )}
@@ -34,6 +34,8 @@ export const zh = {
34
34
  'settings.petHint': '选择显示哪只宠物;每只宠物独立命名,可在宠物悬浮面板改名。',
35
35
  'settings.enabled': '启用宠物',
36
36
  'settings.enabledHint': '关闭后隐藏宠物并停止轮询,可在设置里重新启用。',
37
+ 'settings.decoration': '状态装饰',
38
+ 'settings.decorationHint': '在宠物状态气泡里显示喷水鲸鱼等状态装饰;关闭后气泡只剩文字。',
37
39
  'settings.visible': '显示宠物',
38
40
  'settings.visibleHint': '关闭后宠物隐藏,可从聊天输入区重新召唤。',
39
41
  'settings.size': '大小(px)',
@@ -87,6 +89,8 @@ export const en = {
87
89
  'settings.petHint': 'Choose which pet shows. Names are stored per pet; rename from the pet hover panel.',
88
90
  'settings.enabled': 'Enable the pet',
89
91
  'settings.enabledHint': 'When off, the pet hides and polling stops; re-enable it here.',
92
+ 'settings.decoration': 'Status decoration',
93
+ 'settings.decorationHint': 'Show ornaments like the spouting whale inside the pet status bubbles; when off, bubbles stay text-only.',
90
94
  'settings.visible': 'Show the pet',
91
95
  'settings.visibleHint': 'When off, the pet hides; summon it again from the input row.',
92
96
  'settings.size': 'Size (px)',
@@ -0,0 +1,72 @@
1
+ // @vitest-environment jsdom
2
+ import { act, cleanup, render } from '@testing-library/react'
3
+ import { afterEach, describe, expect, it, vi } from 'vitest'
4
+ import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
5
+ import type { PetRenderer, PetRendererContext } from '../../../contracts/renderer.ts'
6
+ import type { PetDefinition } from '../../../registry.ts'
7
+ import { defaultPetRendererRegistry } from '../registry.ts'
8
+ import type { Live2dErrorCode } from '../live2d.ts'
9
+ import { Live2dVisualMount } from './Live2dVisualMount.tsx'
10
+
11
+ const t = ((key: string) => key) as PropsLocale<'pet'>['t']
12
+ const noop = (): void => {}
13
+
14
+ function definition(id: string): PetDefinition {
15
+ return {
16
+ id,
17
+ displayName: id,
18
+ description: '',
19
+ renderer: 'live2d',
20
+ live2d: { modelPath: id + '.model3.json', modelUrl: '/pet/' + id + '/' + id + '.model3.json', motions: { idle: 'Idle' } },
21
+ } as unknown as PetDefinition
22
+ }
23
+
24
+ describe('Live2dVisualMount', () => {
25
+ afterEach(() => {
26
+ cleanup()
27
+ defaultPetRendererRegistry.clear()
28
+ })
29
+
30
+ it('clears a failed activation error when switching to another pet', () => {
31
+ const errorSinks: ((code: Live2dErrorCode) => void)[] = []
32
+ const disposes: ReturnType<typeof vi.fn>[] = []
33
+ defaultPetRendererRegistry.register({
34
+ id: 'live2d',
35
+ apiVersion: 'test',
36
+ validateConfig: (config) => config,
37
+ mount: (ctx: PetRendererContext) => {
38
+ const canvas = document.createElement('canvas')
39
+ ctx.container.appendChild(canvas)
40
+ const dispose = vi.fn(() => { canvas.remove() })
41
+ disposes.push(dispose)
42
+ return {
43
+ dispose,
44
+ tap: () => {},
45
+ onError: (listener: (code: Live2dErrorCode) => void) => { errorSinks.push(listener) },
46
+ }
47
+ },
48
+ } as PetRenderer)
49
+
50
+ const first = definition('broken')
51
+ const second = definition('healthy')
52
+ const { container, rerender, unmount } = render(<Live2dVisualMount definition={first} phase="idle" onPet={noop} t={t} />)
53
+ expect(container.querySelectorAll('canvas')).toHaveLength(1)
54
+ act(() => { errorSinks[0]?.('load-failed') })
55
+ expect(container.querySelector('[data-dsh-pet-live2d-error="load-failed"]')).toBeTruthy()
56
+
57
+ rerender(<Live2dVisualMount definition={second} phase="idle" onPet={noop} t={t} />)
58
+ expect(disposes[0]).toHaveBeenCalledTimes(1)
59
+ expect(container.querySelector('[data-dsh-pet-live2d="healthy"]')).toBeTruthy()
60
+ expect(container.querySelector('[data-dsh-pet-live2d-error]')).toBeNull()
61
+ expect(container.querySelectorAll('canvas')).toHaveLength(1)
62
+
63
+ rerender(<Live2dVisualMount definition={first} phase="idle" onPet={noop} t={t} />)
64
+ expect(disposes[1]).toHaveBeenCalledTimes(1)
65
+ expect(container.querySelector('[data-dsh-pet-live2d="broken"]')).toBeTruthy()
66
+ expect(container.querySelector('[data-dsh-pet-live2d-error]')).toBeNull()
67
+ expect(container.querySelectorAll('canvas')).toHaveLength(1)
68
+ unmount()
69
+ expect(disposes[2]).toHaveBeenCalledTimes(1)
70
+ expect(container.querySelectorAll('canvas')).toHaveLength(0)
71
+ })
72
+ })
@@ -33,6 +33,7 @@ export function Live2dVisualMount(props: {
33
33
 
34
34
  // One activation per pet definition: build the contract context and mount.
35
35
  useEffect(() => {
36
+ setError(null)
36
37
  const container = containerRef.current
37
38
  const live2d = props.definition.live2d
38
39
  if (container === null || live2d === undefined) return undefined
@@ -21,13 +21,18 @@ const VENDOR_URL = '/api/pet/runtime/live2d-vendor.js'
21
21
  export interface Live2dVendorApp {
22
22
  canvas: HTMLCanvasElement
23
23
  stage: { addChild(child: unknown): unknown }
24
- renderer: { readonly width: number; readonly height: number }
24
+ renderer: {
25
+ readonly width: number
26
+ readonly height: number
27
+ resize(width: number, height: number): void
28
+ }
25
29
  init(options: Record<string, unknown>): Promise<void>
26
- destroy(removeView?: boolean, options?: Record<string, unknown>): void
30
+ destroy(rendererOptions?: boolean | { removeView?: boolean; releaseGlobalResources?: boolean }, options?: Record<string, unknown>): void
27
31
  }
28
32
 
29
33
  /** The Live2DModel slice the renderer uses. */
30
34
  export interface Live2dVendorModel {
35
+ automator: { autoUpdate: boolean }
31
36
  anchor: { set(x: number, y?: number): void }
32
37
  position: { set(x: number, y: number): void }
33
38
  scale: { set(x: number, y?: number): void }
@@ -43,6 +48,7 @@ export interface Live2dVendorModel {
43
48
  expression(name?: string): unknown
44
49
  hitTest(x: number, y: number): string[]
45
50
  on(event: string, fn: () => void): unknown
51
+ destroy(options?: { children?: boolean; texture?: boolean; baseTexture?: boolean }): void
46
52
  }
47
53
 
48
54
  /** The vendor bundle global (window.__dshPetLive2d). */