@linxin666/dsh-pet 0.1.14 → 0.1.16
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 +73 -38
- package/README.zh.md +91 -55
- package/assets/whale/pet.json +12 -1
- package/cordis.patch.yml +4 -4
- package/lib/client.js +296 -347
- package/lib/client.js.map +1 -1
- package/lib/index.js +975 -317
- package/lib/invariant.js +1 -1
- package/lib/{state-P-wsVJ6O.js → state-CFyJv0sQ.js} +15 -1
- package/lib/types/affinity.d.ts +15 -0
- package/lib/types/affinity.d.ts.map +1 -1
- package/lib/types/affinity.js +14 -0
- package/lib/types/client/PetDockEntry.d.ts +14 -12
- package/lib/types/client/PetDockEntry.d.ts.map +1 -1
- package/lib/types/client/PetDockEntry.js +13 -8
- package/lib/types/client/PetSettingsCard.d.ts +22 -9
- package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PetSettingsCard.js +42 -7
- package/lib/types/client/{WhalePet.d.ts → PetSprite.d.ts} +21 -20
- package/lib/types/client/PetSprite.d.ts.map +1 -0
- package/lib/types/client/{WhalePet.js → PetSprite.js} +44 -67
- package/lib/types/client/PluginSettingsCard.d.ts +26 -11
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PluginSettingsCard.js +30 -8
- package/lib/types/client/index.d.ts +11 -8
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +32 -14
- package/lib/types/client/locales.d.ts +10 -10
- package/lib/types/client/locales.js +10 -10
- package/lib/types/client/pet-store.d.ts +5 -0
- package/lib/types/client/pet-store.d.ts.map +1 -1
- package/lib/types/client/pet-store.js +4 -0
- package/lib/types/client/settings-form.d.ts +76 -5
- package/lib/types/client/settings-form.d.ts.map +1 -1
- package/lib/types/client/settings-form.js +97 -20
- package/lib/types/client/spritesheet.d.ts +14 -48
- package/lib/types/client/spritesheet.d.ts.map +1 -1
- package/lib/types/client/spritesheet.js +13 -81
- package/lib/types/dsh-home.d.ts +17 -0
- package/lib/types/dsh-home.d.ts.map +1 -0
- package/lib/types/dsh-home.js +34 -0
- package/lib/types/event-projection.d.ts +36 -0
- package/lib/types/event-projection.d.ts.map +1 -0
- package/lib/types/event-projection.js +98 -0
- package/lib/types/index.d.ts +24 -11
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +53 -33
- package/lib/types/ledger.d.ts +79 -0
- package/lib/types/ledger.d.ts.map +1 -0
- package/lib/types/ledger.js +146 -0
- package/lib/types/persist.d.ts +15 -4
- package/lib/types/persist.d.ts.map +1 -1
- package/lib/types/persist.js +39 -8
- package/lib/types/registry.d.ts +158 -0
- package/lib/types/registry.d.ts.map +1 -0
- package/lib/types/registry.js +276 -0
- package/lib/types/routes.d.ts +10 -10
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +169 -51
- package/lib/types/service.d.ts +59 -57
- package/lib/types/service.d.ts.map +1 -1
- package/lib/types/service.js +111 -211
- package/package.json +2 -2
- package/src/affinity.ts +33 -0
- package/src/client/PetDockEntry.tsx +34 -26
- package/src/client/PetSettingsCard.tsx +67 -22
- package/src/client/{WhalePet.test.tsx → PetSprite.test.tsx} +56 -8
- package/src/client/{WhalePet.tsx → PetSprite.tsx} +55 -73
- package/src/client/PluginSettingsCard.tsx +90 -18
- package/src/client/index.ts +38 -16
- package/src/client/locales.ts +10 -10
- package/src/client/pet-store.ts +9 -0
- package/src/client/settings-card.module.css +9 -0
- package/src/client/settings-form.ts +153 -19
- package/src/client/spritesheet.ts +16 -92
- package/src/dsh-home.test.ts +35 -0
- package/src/dsh-home.ts +36 -0
- package/src/event-projection.ts +128 -0
- package/src/index.test.ts +23 -0
- package/src/index.ts +89 -45
- package/src/ledger.test.ts +69 -0
- package/src/ledger.ts +190 -0
- package/src/persist.test.ts +52 -3
- package/src/persist.ts +49 -11
- package/src/registry.test.ts +132 -0
- package/src/registry.ts +400 -0
- package/src/routes.ts +166 -53
- package/src/service.ts +153 -285
- package/lib/types/client/WhalePet.d.ts.map +0 -1
|
@@ -1,47 +1,47 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Pet sprite companion component — the browser half's centerpiece. Renders a
|
|
3
3
|
* fixed-position floating sprite (React portal onto document.body), plays
|
|
4
|
-
* the
|
|
5
|
-
*
|
|
6
|
-
* reposition (persisted via setConfig).
|
|
7
|
-
*
|
|
4
|
+
* the track matching the host animation snapshot, and exposes the
|
|
5
|
+
* interaction surface: click to pet, hover panel with feed/rename/hide, drag
|
|
6
|
+
* to reposition (persisted via setConfig). Everything visual comes from the
|
|
7
|
+
* pet definition the host serves ('/api/pet/pets' + the state snapshot's
|
|
8
|
+
* pet id), so one component renders every registry entry.
|
|
9
|
+
* @module @linxin666/dsh-pet/client/PetSprite
|
|
8
10
|
*/
|
|
9
11
|
|
|
10
12
|
import { useEffect, useRef, useState } from 'react'
|
|
11
13
|
import type { PointerEvent as ReactPointerEvent, ReactPortal } from 'react'
|
|
12
14
|
import { createPortal } from 'react-dom'
|
|
15
|
+
import clsx from 'clsx'
|
|
13
16
|
import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
|
|
14
17
|
import type { PetDisplayConfig } from '../persist.ts'
|
|
15
18
|
import type { PetStateView } from '../service.ts'
|
|
19
|
+
import type { PetDefinition } from '../registry.ts'
|
|
16
20
|
import type { PetFeedback } from './pet-store.ts'
|
|
17
|
-
import { framePosition,
|
|
21
|
+
import { framePosition, rowOfTrack, trimTrack } from './spritesheet.ts'
|
|
18
22
|
import type { PetAnimation } from '../state.ts'
|
|
19
23
|
import { NS } from './locales.ts'
|
|
20
24
|
import styles from './pet.module.css'
|
|
21
25
|
|
|
22
|
-
/**
|
|
23
|
-
export
|
|
24
|
-
|
|
25
|
-
/** Browser URL of the whale-girl manifest (authoritative per-row frame counts). */
|
|
26
|
-
export const PET_MANIFEST_URL = '/pet/whale/pet.json'
|
|
27
|
-
|
|
28
|
-
/** Props injected by the slot registration (store actions + locale). */
|
|
29
|
-
export interface WhalePetProps {
|
|
26
|
+
/** Props injected by the plugin apply body (store actions + locale). */
|
|
27
|
+
export interface PetSpriteProps {
|
|
30
28
|
/** Latest host snapshot; null while loading. */
|
|
31
29
|
snapshot: PetStateView | null
|
|
30
|
+
/** The selected pet's registry definition (atlas URL + geometry + tracks). */
|
|
31
|
+
definition: PetDefinition
|
|
32
32
|
/** Display configuration (persisted by the host). */
|
|
33
33
|
display: PetDisplayConfig
|
|
34
34
|
/** Active reaction bubble, if any. */
|
|
35
35
|
feedback: PetFeedback | null
|
|
36
|
-
/** Pet the
|
|
36
|
+
/** Pet the sprite (click). */
|
|
37
37
|
onPet: () => void
|
|
38
|
-
/** Feed the
|
|
38
|
+
/** Feed the sprite (panel button). */
|
|
39
39
|
onFeed: () => void
|
|
40
|
-
/** Hide the
|
|
40
|
+
/** Hide the sprite (panel button). */
|
|
41
41
|
onHide: () => void
|
|
42
42
|
/** Persist a drag position. */
|
|
43
43
|
onDragEnd: (right: number, bottom: number) => void
|
|
44
|
-
/** Rename the pet (persisted by the host). */
|
|
44
|
+
/** Rename the selected pet (persisted by the host). */
|
|
45
45
|
onRename: (name: string) => void
|
|
46
46
|
/** Clear the reaction bubble (after its CSS animation). */
|
|
47
47
|
onFeedbackDone: () => void
|
|
@@ -56,16 +56,15 @@ function clampOffset(value: number, max: number): number {
|
|
|
56
56
|
|
|
57
57
|
/**
|
|
58
58
|
* The floating pet. The spritesheet frame advances on requestAnimationFrame
|
|
59
|
-
* with per-frame durations from
|
|
60
|
-
* the background position is written straight to the sprite
|
|
61
|
-
* per-frame React state).
|
|
59
|
+
* with per-frame durations from the definition's tracks; the atlas image is
|
|
60
|
+
* loaded once and the background position is written straight to the sprite
|
|
61
|
+
* element (no per-frame React state).
|
|
62
62
|
*/
|
|
63
|
-
export function
|
|
64
|
-
const { snapshot, display, feedback } = props
|
|
63
|
+
export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
64
|
+
const { snapshot, definition, display, feedback } = props
|
|
65
65
|
const spriteRef = useRef<HTMLDivElement | null>(null)
|
|
66
66
|
const floatRef = useRef<HTMLDivElement | null>(null)
|
|
67
67
|
const [imageReady, setImageReady] = useState(false)
|
|
68
|
-
const [frameCounts, setFrameCounts] = useState<number[] | null>(null)
|
|
69
68
|
const [hovered, setHovered] = useState(false)
|
|
70
69
|
const [renaming, setRenaming] = useState(false)
|
|
71
70
|
const [nameDraft, setNameDraft] = useState('')
|
|
@@ -78,39 +77,25 @@ export function WhalePet(props: WhalePetProps): ReactPortal {
|
|
|
78
77
|
elapsed: 0,
|
|
79
78
|
})
|
|
80
79
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
//
|
|
80
|
+
const cell = definition.cell
|
|
81
|
+
const columns = definition.columns
|
|
82
|
+
const rows = definition.rows
|
|
83
|
+
const tracks = definition.tracks
|
|
84
|
+
|
|
85
|
+
// Load the atlas once; the definition carries the authoritative per-row
|
|
86
|
+
// frame counts and per-track durations, so nothing else is fetched.
|
|
87
87
|
useEffect(() => {
|
|
88
88
|
let cancelled = false
|
|
89
89
|
const img = new Image()
|
|
90
90
|
img.onload = () => {
|
|
91
|
-
if (cancelled)
|
|
92
|
-
setImageReady(true)
|
|
93
|
-
fetch(PET_MANIFEST_URL)
|
|
94
|
-
.then((res) => (res.ok ? res.json() : Promise.resolve<{ frames?: unknown }>({})))
|
|
95
|
-
.then((manifest: { frames?: unknown }) => {
|
|
96
|
-
if (cancelled) return
|
|
97
|
-
const frames = manifest.frames
|
|
98
|
-
if (Array.isArray(frames) && frames.length === 9 && frames.every((n) => typeof n === 'number')) {
|
|
99
|
-
setFrameCounts(frames as number[])
|
|
100
|
-
} else {
|
|
101
|
-
setFrameCounts(detectFrameCounts(img))
|
|
102
|
-
}
|
|
103
|
-
})
|
|
104
|
-
.catch(() => {
|
|
105
|
-
if (!cancelled) setFrameCounts(detectFrameCounts(img))
|
|
106
|
-
})
|
|
91
|
+
if (!cancelled) setImageReady(true)
|
|
107
92
|
}
|
|
108
|
-
img.src =
|
|
93
|
+
img.src = definition.atlasUrl
|
|
109
94
|
return () => {
|
|
110
95
|
cancelled = true
|
|
111
96
|
img.onload = null
|
|
112
97
|
}
|
|
113
|
-
}, [])
|
|
98
|
+
}, [definition.atlasUrl])
|
|
114
99
|
|
|
115
100
|
// Frame loop: advance the current track and write background-position.
|
|
116
101
|
// Offsets must be in SCALED coordinates (background-position applies to the
|
|
@@ -118,23 +103,21 @@ export function WhalePet(props: WhalePetProps): ReactPortal {
|
|
|
118
103
|
// the loop reads every tick. Under prefers-reduced-motion the sprite holds
|
|
119
104
|
// its track's first frame instead of animating (presentation-only; the
|
|
120
105
|
// animation state machine is untouched).
|
|
121
|
-
const spriteScale = display.size /
|
|
106
|
+
const spriteScale = display.size / cell.height
|
|
122
107
|
const animation = snapshot?.animation ?? 'idle'
|
|
123
108
|
const scaleRef = useRef(spriteScale)
|
|
124
109
|
scaleRef.current = spriteScale
|
|
125
110
|
useEffect(() => {
|
|
126
111
|
const reduceMotion = typeof window !== 'undefined'
|
|
127
112
|
&& window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches === true
|
|
113
|
+
const row = rowOfTrack(animation)
|
|
114
|
+
const track = trimTrack(tracks[animation], rows[row] ?? tracks[animation].frames.length)
|
|
128
115
|
// Paint one static sprite frame up front either way, so the pet is never
|
|
129
116
|
// blank while the loop heat-up runs.
|
|
130
|
-
const row = rowOfTrack(animation)
|
|
131
|
-
const track = frameCounts === null
|
|
132
|
-
? TRACKS[animation]
|
|
133
|
-
: trimTrack(TRACKS[animation], frameCounts[row] ?? TRACKS[animation].frames.length)
|
|
134
117
|
const leadCol = track.frames[0]!
|
|
135
|
-
const lead = framePosition(row, leadCol, scaleRef.current)
|
|
118
|
+
const lead = framePosition(cell, columns, row, leadCol, scaleRef.current)
|
|
136
119
|
if (spriteRef.current !== null) {
|
|
137
|
-
spriteRef.current.style.backgroundPosition =
|
|
120
|
+
spriteRef.current.style.backgroundPosition = lead.x + 'px ' + lead.y + 'px'
|
|
138
121
|
}
|
|
139
122
|
if (reduceMotion) return
|
|
140
123
|
let raf = 0
|
|
@@ -145,9 +128,7 @@ export function WhalePet(props: WhalePetProps): ReactPortal {
|
|
|
145
128
|
// Trim the track to the row's real frame count (transparent cells
|
|
146
129
|
// would render as a vanishing pet).
|
|
147
130
|
const row = rowOfTrack(animation)
|
|
148
|
-
const track =
|
|
149
|
-
? TRACKS[animation]
|
|
150
|
-
: trimTrack(TRACKS[animation], frameCounts[row] ?? TRACKS[animation].frames.length)
|
|
131
|
+
const track = trimTrack(tracks[animation], rows[row] ?? tracks[animation].frames.length)
|
|
151
132
|
const st = frameRef.current
|
|
152
133
|
if (st.track !== animation) {
|
|
153
134
|
st.track = animation
|
|
@@ -169,18 +150,18 @@ export function WhalePet(props: WhalePetProps): ReactPortal {
|
|
|
169
150
|
}
|
|
170
151
|
}
|
|
171
152
|
const col = track.frames[st.index]!
|
|
172
|
-
const
|
|
153
|
+
const pos = framePosition(cell, columns, row, col, scaleRef.current)
|
|
173
154
|
if (spriteRef.current !== null) {
|
|
174
|
-
spriteRef.current.style.backgroundPosition =
|
|
155
|
+
spriteRef.current.style.backgroundPosition = pos.x + 'px ' + pos.y + 'px'
|
|
175
156
|
}
|
|
176
157
|
raf = requestAnimationFrame(tick)
|
|
177
158
|
}
|
|
178
159
|
raf = requestAnimationFrame(tick)
|
|
179
160
|
return () => cancelAnimationFrame(raf)
|
|
180
|
-
}, [animation,
|
|
161
|
+
}, [animation, cell, columns, rows, tracks])
|
|
181
162
|
|
|
182
163
|
// Auto-clear the feedback bubble after its CSS animation. The callback
|
|
183
|
-
// rides a ref so re-renders never reset the timer: the
|
|
164
|
+
// rides a ref so re-renders never reset the timer: the 2s poll rebuilds
|
|
184
165
|
// `props` every tick, and depending on it would starve the timeout.
|
|
185
166
|
const feedbackDoneRef = useRef(props.onFeedbackDone)
|
|
186
167
|
feedbackDoneRef.current = props.onFeedbackDone
|
|
@@ -192,7 +173,7 @@ export function WhalePet(props: WhalePetProps): ReactPortal {
|
|
|
192
173
|
|
|
193
174
|
// Dragging: pointer events on the sprite; position is right/bottom based.
|
|
194
175
|
// `draggedRef` records whether the pointer actually moved, so the browser's
|
|
195
|
-
// trailing click (fired after pointerup) does not pet the
|
|
176
|
+
// trailing click (fired after pointerup) does not pet the sprite.
|
|
196
177
|
const draggedRef = useRef(false)
|
|
197
178
|
const clearHideTimer = (): void => {
|
|
198
179
|
if (hideTimerRef.current !== null) {
|
|
@@ -226,9 +207,10 @@ export function WhalePet(props: WhalePetProps): ReactPortal {
|
|
|
226
207
|
}
|
|
227
208
|
|
|
228
209
|
const pos = dragPos ?? { right: display.right, bottom: display.bottom }
|
|
229
|
-
const spriteWidth = Math.round(
|
|
230
|
-
const spriteHeight = Math.round(
|
|
210
|
+
const spriteWidth = Math.round(cell.width * spriteScale)
|
|
211
|
+
const spriteHeight = Math.round(cell.height * spriteScale)
|
|
231
212
|
const statusBubble = feedback === null && !hovered ? snapshot?.bubble : undefined
|
|
213
|
+
const displayName = snapshot?.name ?? definition.displayName
|
|
232
214
|
|
|
233
215
|
const float = (
|
|
234
216
|
<div
|
|
@@ -245,7 +227,7 @@ export function WhalePet(props: WhalePetProps): ReactPortal {
|
|
|
245
227
|
// container. Treat a target still inside the container's DOM (the
|
|
246
228
|
// overflowed panel) as "still hovering"; otherwise give the pointer a
|
|
247
229
|
// short grace period to reach the panel across the gap above it. The
|
|
248
|
-
// bridge (
|
|
230
|
+
// bridge ('.panel::after') keeps the pointer inside the hit area, and
|
|
249
231
|
// the grace period covers a slow mouse crossing the remaining sliver.
|
|
250
232
|
const next = e.relatedTarget
|
|
251
233
|
if (next instanceof Node && floatRef.current?.contains(next)) return
|
|
@@ -259,8 +241,8 @@ export function WhalePet(props: WhalePetProps): ReactPortal {
|
|
|
259
241
|
style={{
|
|
260
242
|
width: spriteWidth,
|
|
261
243
|
height: spriteHeight,
|
|
262
|
-
backgroundImage: imageReady ?
|
|
263
|
-
backgroundSize:
|
|
244
|
+
backgroundImage: imageReady ? 'url(' + definition.atlasUrl + ')' : undefined,
|
|
245
|
+
backgroundSize: (cell.width * columns * spriteScale) + 'px ' + (cell.height * rows.length * spriteScale) + 'px',
|
|
264
246
|
backgroundRepeat: 'no-repeat',
|
|
265
247
|
backgroundPosition: '0 0',
|
|
266
248
|
cursor: dragRef.current === null ? 'grab' : 'grabbing',
|
|
@@ -275,15 +257,15 @@ export function WhalePet(props: WhalePetProps): ReactPortal {
|
|
|
275
257
|
props.onPet()
|
|
276
258
|
}}
|
|
277
259
|
role="button"
|
|
278
|
-
aria-label=
|
|
260
|
+
aria-label={definition.displayName}
|
|
279
261
|
/>
|
|
280
262
|
{feedback !== null && (
|
|
281
|
-
<div key={feedback.at} className={
|
|
263
|
+
<div key={feedback.at} className={clsx(styles.bubble, feedback.kind === 'feed' ? styles.bubbleFeed : styles.bubblePet)}>
|
|
282
264
|
{feedback.text}
|
|
283
265
|
</div>
|
|
284
266
|
)}
|
|
285
267
|
{statusBubble !== undefined && (
|
|
286
|
-
<div className={
|
|
268
|
+
<div className={clsx(styles.bubble, styles.bubbleStatus)} role="status" aria-live="polite">
|
|
287
269
|
{statusBubble}
|
|
288
270
|
</div>
|
|
289
271
|
)}
|
|
@@ -340,7 +322,7 @@ export function WhalePet(props: WhalePetProps): ReactPortal {
|
|
|
340
322
|
) : (
|
|
341
323
|
<>
|
|
342
324
|
<div className={styles.rankRow}>
|
|
343
|
-
<span className={styles.nameCell}>{
|
|
325
|
+
<span className={styles.nameCell}>{displayName}</span>
|
|
344
326
|
<span>{props.t('pet.rank', { rank: snapshot?.affinity.rank ?? '?' })}</span>
|
|
345
327
|
</div>
|
|
346
328
|
<div className={styles.rankRow}>
|
|
@@ -355,7 +337,7 @@ export function WhalePet(props: WhalePetProps): ReactPortal {
|
|
|
355
337
|
type="button"
|
|
356
338
|
className={styles.action}
|
|
357
339
|
onClick={() => {
|
|
358
|
-
setNameDraft(
|
|
340
|
+
setNameDraft(displayName)
|
|
359
341
|
setRenaming(true)
|
|
360
342
|
}}
|
|
361
343
|
>
|
|
@@ -1,25 +1,44 @@
|
|
|
1
|
+
// Generated by scripts/sync-shared.mjs from shared/client/settings/PluginSettingsCard.tsx. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
|
|
1
2
|
/**
|
|
2
|
-
*
|
|
3
|
-
* plugin and what its settings govern, the controls inside, and the save
|
|
4
|
-
* writes them. Renders nothing while the namespace is unavailable — a
|
|
3
|
+
* Family-shared chrome for plugin settings cards: a disclosure header naming
|
|
4
|
+
* the plugin and what its settings govern, the controls inside, and the save
|
|
5
|
+
* that writes them. Renders nothing while the namespace is unavailable — a
|
|
5
6
|
* deployment that does not compose the owning plugin should show no trace of
|
|
6
|
-
* it.
|
|
7
|
-
*
|
|
7
|
+
* it. Inlined into each consumer's client bundle; mirrors the official
|
|
8
|
+
* ui-plugin-config PluginCard in a self-contained slice.
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
11
|
import { useState, type ReactNode } from 'react'
|
|
11
12
|
import type { CardShell } from './settings-form.ts'
|
|
12
|
-
import type { SettingsCardKey } from './locales.ts'
|
|
13
13
|
import css from './settings-card.module.css'
|
|
14
14
|
|
|
15
|
+
/** Copy keys the card chrome itself reads; every consumer locale carries this shared vocabulary. */
|
|
16
|
+
export const CARD_COPY_KEYS = [
|
|
17
|
+
'settings.collapse',
|
|
18
|
+
'settings.expand',
|
|
19
|
+
'settings.notExposed',
|
|
20
|
+
'settings.unsaved',
|
|
21
|
+
'settings.readOnly',
|
|
22
|
+
'settings.saveFailed',
|
|
23
|
+
'settings.discard',
|
|
24
|
+
'settings.save',
|
|
25
|
+
'settings.saving',
|
|
26
|
+
] as const
|
|
27
|
+
|
|
28
|
+
/** Copy key the card chrome itself reads. */
|
|
29
|
+
export type CardCopyKey = (typeof CARD_COPY_KEYS)[number]
|
|
30
|
+
|
|
31
|
+
/** Key domain for the plugin's own copy (inferred per consumer). */
|
|
32
|
+
export type SettingsCardKey<TKey extends string = string> = TKey | CardCopyKey
|
|
33
|
+
|
|
15
34
|
/** Card chrome shared by every plugin settings card. */
|
|
16
|
-
export interface PluginSettingsCardProps {
|
|
35
|
+
export interface PluginSettingsCardProps<TKey extends string = string> {
|
|
17
36
|
/** Locale reader for this card's copy. */
|
|
18
|
-
t: (key: SettingsCardKey) => string
|
|
37
|
+
t: (key: SettingsCardKey<TKey>, params?: Record<string, string | number>) => string
|
|
19
38
|
/** Locale key of the plugin's name. */
|
|
20
|
-
titleKey:
|
|
39
|
+
titleKey: TKey
|
|
21
40
|
/** Locale key of the line describing what this plugin's settings govern. */
|
|
22
|
-
descriptionKey:
|
|
41
|
+
descriptionKey: TKey
|
|
23
42
|
/** The card's form state: availability, writability, and what a save would do. */
|
|
24
43
|
state: CardShell
|
|
25
44
|
/** Write every staged edit. */
|
|
@@ -35,11 +54,12 @@ export interface PluginSettingsCardProps {
|
|
|
35
54
|
* @param props - the plugin's copy keys, its form state, and its controls.
|
|
36
55
|
* @returns the card, or nothing while the namespace is still loading.
|
|
37
56
|
*/
|
|
38
|
-
export function PluginSettingsCard(props: PluginSettingsCardProps) {
|
|
57
|
+
export function PluginSettingsCard<TKey extends string = string>(props: PluginSettingsCardProps<TKey>) {
|
|
39
58
|
const [open, setOpen] = useState(false)
|
|
40
59
|
const { state } = props
|
|
41
60
|
if (!state.available) return null
|
|
42
61
|
const title = props.t(props.titleKey)
|
|
62
|
+
const description = props.t(props.descriptionKey)
|
|
43
63
|
const blocked = !state.dirty || state.invalid || state.saving
|
|
44
64
|
const cardClass = open ? `${css.cardOpen} ${css.card}` : css.card
|
|
45
65
|
// The namespace exists but the Host does not serve it to this client (the
|
|
@@ -57,8 +77,8 @@ export function PluginSettingsCard(props: PluginSettingsCardProps) {
|
|
|
57
77
|
onClick={() => { setOpen(!open) }}
|
|
58
78
|
>
|
|
59
79
|
<span className={css.headText}>
|
|
60
|
-
<span className={css.name}>{title}</span>
|
|
61
|
-
<span className={css.description}>{
|
|
80
|
+
<span className={css.name} title={title}>{title}</span>
|
|
81
|
+
<span className={css.description} title={description}>{description}</span>
|
|
62
82
|
</span>
|
|
63
83
|
<svg
|
|
64
84
|
width="14"
|
|
@@ -94,10 +114,10 @@ export function PluginSettingsCard(props: PluginSettingsCardProps) {
|
|
|
94
114
|
onClick={() => { setOpen(!open) }}
|
|
95
115
|
>
|
|
96
116
|
<span className={css.headText}>
|
|
97
|
-
<span className={css.name}>{title}</span>
|
|
98
|
-
<span className={css.description}>{
|
|
117
|
+
<span className={css.name} title={title}>{title}</span>
|
|
118
|
+
<span className={css.description} title={description}>{description}</span>
|
|
99
119
|
</span>
|
|
100
|
-
{state.dirty ? <span className={css.pending}>{props.t('settings.unsaved')}</span> : null}
|
|
120
|
+
{state.dirty ? <span className={css.pending} title={props.t('settings.unsaved')}>{props.t('settings.unsaved')}</span> : null}
|
|
101
121
|
<svg
|
|
102
122
|
width="14"
|
|
103
123
|
height="14"
|
|
@@ -118,7 +138,13 @@ export function PluginSettingsCard(props: PluginSettingsCardProps) {
|
|
|
118
138
|
{!state.writable ? <p className={css.readOnly} role="status">{props.t('settings.readOnly')}</p> : null}
|
|
119
139
|
{props.children}
|
|
120
140
|
<div className={css.footer}>
|
|
121
|
-
{state.failed
|
|
141
|
+
{state.failed
|
|
142
|
+
? (
|
|
143
|
+
<p className={css.failed} role="status">
|
|
144
|
+
{props.t('settings.saveFailed')}{state.failedReason ? ' - ' + state.failedReason : ''}
|
|
145
|
+
</p>
|
|
146
|
+
)
|
|
147
|
+
: null}
|
|
122
148
|
<button
|
|
123
149
|
type="button"
|
|
124
150
|
className={css.discard}
|
|
@@ -259,4 +285,50 @@ export function BooleanField(props: FieldProps & {
|
|
|
259
285
|
<p className={css.hint}>{props.hint}</p>
|
|
260
286
|
</div>
|
|
261
287
|
)
|
|
262
|
-
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** A staged enumerated field rendered as a select. */
|
|
291
|
+
export function ChoiceField(props: FieldProps & {
|
|
292
|
+
/** Copy for the inherit option (draft text is the empty string). */
|
|
293
|
+
inheritLabel: string
|
|
294
|
+
/** Choices rendered in order; `value` is the draft/stored text. */
|
|
295
|
+
choices: ReadonlyArray<{ value: string; label: string }>
|
|
296
|
+
}) {
|
|
297
|
+
return (
|
|
298
|
+
<div className={css.field}>
|
|
299
|
+
<div className={css.head}>
|
|
300
|
+
<label className={css.label} htmlFor={props.id}>{props.label}</label>
|
|
301
|
+
{props.overridden
|
|
302
|
+
? (
|
|
303
|
+
<span className={css.badges}>
|
|
304
|
+
<span className={css.badge}>{props.overriddenLabel}</span>
|
|
305
|
+
<button
|
|
306
|
+
type="button"
|
|
307
|
+
className={css.reset}
|
|
308
|
+
disabled={props.disabled}
|
|
309
|
+
onClick={props.onReset}
|
|
310
|
+
>
|
|
311
|
+
{props.resetLabel}
|
|
312
|
+
</button>
|
|
313
|
+
</span>
|
|
314
|
+
)
|
|
315
|
+
: null}
|
|
316
|
+
</div>
|
|
317
|
+
<select
|
|
318
|
+
id={props.id}
|
|
319
|
+
className={css.select}
|
|
320
|
+
value={props.text}
|
|
321
|
+
disabled={props.disabled}
|
|
322
|
+
onChange={(event) => { props.onEdit(event.target.value) }}
|
|
323
|
+
>
|
|
324
|
+
<option value="">{props.inheritLabel}</option>
|
|
325
|
+
{props.choices.map(choice => (
|
|
326
|
+
<option key={choice.value} value={choice.value}>{choice.label}</option>
|
|
327
|
+
))}
|
|
328
|
+
</select>
|
|
329
|
+
<p className={props.invalid ? css.invalid : css.hint}>
|
|
330
|
+
{props.invalid ? props.invalidLabel : props.hint}
|
|
331
|
+
</p>
|
|
332
|
+
</div>
|
|
333
|
+
)
|
|
334
|
+
}
|
package/src/client/index.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* dsh-pet browser half — mounts the
|
|
3
|
-
* and drives it from the host's same-origin
|
|
4
|
-
* the host snapshot (~
|
|
5
|
-
* The pet is host-global (no
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
2
|
+
* dsh-pet browser half — mounts the selected pet as a global floating
|
|
3
|
+
* surface and drives it from the host's same-origin '/api/pet/*' JSON
|
|
4
|
+
* endpoints: fetch the registry list once, poll the host snapshot (~2 s),
|
|
5
|
+
* forward interactions, persist drag positions. The pet is host-global (no
|
|
6
|
+
* session dimension), so it mounts directly onto 'document.body' via a
|
|
7
|
+
* single React root rather than a session-scoped slot — on the
|
|
8
|
+
* new-conversation screen no session exists, and a dock-mounted pet would
|
|
9
|
+
* vanish there (issue #48). When the pet is hidden the entry becomes a
|
|
9
10
|
* fixed-position summon button.
|
|
10
11
|
* @module @linxin666/dsh-pet/client
|
|
11
12
|
*/
|
|
@@ -20,6 +21,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
|
|
20
21
|
import type { PetDisplayConfig } from '../persist.ts'
|
|
21
22
|
import type { PetInteractResult, PetStateView } from '../service.ts'
|
|
22
23
|
import type { PetInteraction } from '../affinity.ts'
|
|
24
|
+
import type { PetDefinition } from '../registry.ts'
|
|
23
25
|
import { createElement } from 'react'
|
|
24
26
|
import { createRoot } from 'react-dom/client'
|
|
25
27
|
import { createPetStore, type PetStoreInstance } from './pet-store.ts'
|
|
@@ -30,10 +32,12 @@ import { NS, en, zh, t } from './locales.ts'
|
|
|
30
32
|
/** The host pet API as the browser sees it (same-origin JSON endpoints). */
|
|
31
33
|
interface PetHttpApi {
|
|
32
34
|
state(): Promise<PetStateView>
|
|
35
|
+
pets(): Promise<PetDefinition[]>
|
|
33
36
|
interact(kind: PetInteraction): Promise<PetInteractResult>
|
|
34
37
|
setVisible(visible: boolean): Promise<{ ok: true; display: PetDisplayConfig }>
|
|
35
38
|
setConfig(patch: Partial<PetDisplayConfig>): Promise<{ ok: true; display: PetDisplayConfig }>
|
|
36
39
|
setName(name: string): Promise<{ ok: true; name: string } | { ok: false; error: string }>
|
|
40
|
+
setPet(petId: string): Promise<{ ok: true; petId: string } | { ok: false; error: string }>
|
|
37
41
|
}
|
|
38
42
|
|
|
39
43
|
/** Same-origin JSON fetch helper (GET without body, POST with JSON body). */
|
|
@@ -46,7 +50,7 @@ async function petFetch<T>(path: string, body?: unknown): Promise<T> {
|
|
|
46
50
|
body: JSON.stringify(body),
|
|
47
51
|
})
|
|
48
52
|
if (!response.ok) {
|
|
49
|
-
throw new Error(
|
|
53
|
+
throw new Error('pet ' + path + ' failed: ' + response.status)
|
|
50
54
|
}
|
|
51
55
|
return (await response.json()) as T
|
|
52
56
|
}
|
|
@@ -54,14 +58,16 @@ async function petFetch<T>(path: string, body?: unknown): Promise<T> {
|
|
|
54
58
|
/** The live host API instance (always defined; failures surface per call). */
|
|
55
59
|
const petApi: PetHttpApi = {
|
|
56
60
|
state: () => petFetch('/api/pet/state'),
|
|
61
|
+
pets: () => petFetch('/api/pet/pets'),
|
|
57
62
|
interact: (kind) => petFetch('/api/pet/interact', { kind }),
|
|
58
63
|
setVisible: (visible) => petFetch('/api/pet/set-visible', { visible }),
|
|
59
64
|
setConfig: (patch) => petFetch('/api/pet/set-config', patch),
|
|
60
65
|
setName: (name) => petFetch('/api/pet/set-name', { name }),
|
|
66
|
+
setPet: (petId) => petFetch('/api/pet/set-pet', { petId }),
|
|
61
67
|
}
|
|
62
68
|
|
|
63
69
|
/** Poll interval for the host snapshot. */
|
|
64
|
-
const POLL_MS =
|
|
70
|
+
const POLL_MS = 2000
|
|
65
71
|
|
|
66
72
|
/** Settings namespace the pet settings card edits (the Host plugin registers it). */
|
|
67
73
|
const PET_SETTINGS_NS = 'pet'
|
|
@@ -71,14 +77,16 @@ export const inject = ['slots', 'locale', 'connection', 'settingsScope', 'remote
|
|
|
71
77
|
|
|
72
78
|
/** Re-exported for consumers that type against the injected face. */
|
|
73
79
|
export type { PetInjected, PetDockEntryProps } from './PetDockEntry.tsx'
|
|
80
|
+
export type { PetSpriteProps } from './PetSprite.tsx'
|
|
74
81
|
export type { PetUiState, PetFeedback } from './pet-store.ts'
|
|
75
82
|
export type { PetSettingsCardFace, PetSettingsCardState } from './PetSettingsCard.tsx'
|
|
83
|
+
export type { PetDefinition } from '../registry.ts'
|
|
76
84
|
|
|
77
85
|
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
|
78
86
|
interface SlotMap {
|
|
79
87
|
/**
|
|
80
88
|
* The child slot the Web UI plugin group declares; this card registers
|
|
81
|
-
* into the group instead of the top-level
|
|
89
|
+
* into the group instead of the top-level 'settings.plugin.item' list.
|
|
82
90
|
* Spelled here with the same shape so this package can register without
|
|
83
91
|
* depending on the sibling UI package.
|
|
84
92
|
*/
|
|
@@ -103,7 +111,6 @@ declare module '@deepseek-ai/cordis' {
|
|
|
103
111
|
}
|
|
104
112
|
}
|
|
105
113
|
|
|
106
|
-
|
|
107
114
|
/**
|
|
108
115
|
* Client plugin body: register dictionaries, mount the global pet entry and
|
|
109
116
|
* poll loop while the plugin is enabled, and seat the settings card in the
|
|
@@ -122,8 +129,9 @@ export function apply(ctx: ClientContext): void {
|
|
|
122
129
|
: snapshot.status === 'unavailable'
|
|
123
130
|
}
|
|
124
131
|
|
|
125
|
-
// Plugin configuration card: one staged form over the
|
|
126
|
-
// namespace, contributed to the Web UI plugin group.
|
|
132
|
+
// Plugin configuration card: one staged form over the 'pet' settings
|
|
133
|
+
// namespace, contributed to the Web UI plugin group. The controller loads
|
|
134
|
+
// the petId choices from the registry endpoint itself.
|
|
127
135
|
const petSettings = new PetSettingsCardController(settingsScope)
|
|
128
136
|
ctx.slots.inject('web-ui.plugin.item', () => ctx.slots.register({
|
|
129
137
|
name: 'web-ui.plugin.item',
|
|
@@ -145,10 +153,24 @@ export function apply(ctx: ClientContext): void {
|
|
|
145
153
|
// it stateless on the new-conversation screen (no session to scope by).
|
|
146
154
|
const petStore: PetStoreInstance = createPetStore().create()
|
|
147
155
|
const setSnapshot = petStore.actions.setSnapshot
|
|
156
|
+
const setPets = petStore.actions.setPets
|
|
148
157
|
const setState = petStore.actions.setState
|
|
149
158
|
const setFeedback = petStore.actions.setFeedback
|
|
150
159
|
|
|
160
|
+
// The registry list is fetched lazily with retries baked into the poll
|
|
161
|
+
// cycle: until it lands, the dock entry renders nothing and every 2s
|
|
162
|
+
// tick tries again. After it lands, one list feeds both the sprite and
|
|
163
|
+
// the settings card's choices.
|
|
164
|
+
let petsLoaded = false
|
|
151
165
|
const pollNow = (): void => {
|
|
166
|
+
if (!petsLoaded) {
|
|
167
|
+
petApi.pets().then((list) => {
|
|
168
|
+
petsLoaded = true
|
|
169
|
+
setPets(list)
|
|
170
|
+
}, () => {
|
|
171
|
+
// Retry on the next poll tick.
|
|
172
|
+
})
|
|
173
|
+
}
|
|
152
174
|
petApi.state().then((snapshot) => {
|
|
153
175
|
setSnapshot(snapshot)
|
|
154
176
|
}, () => {
|
|
@@ -161,7 +183,7 @@ export function apply(ctx: ClientContext): void {
|
|
|
161
183
|
// change while the page is hidden, so a background interval would
|
|
162
184
|
// only burn RPCs (browser throttling is an unreliable backstop).
|
|
163
185
|
// Coming back to the tab refreshes the pet immediately instead of
|
|
164
|
-
// waiting out the next
|
|
186
|
+
// waiting out the next 2 s cycle.
|
|
165
187
|
let timer: number | undefined
|
|
166
188
|
const stop = (): void => {
|
|
167
189
|
if (timer !== undefined) {
|
|
@@ -253,8 +275,8 @@ export function apply(ctx: ClientContext): void {
|
|
|
253
275
|
// for a global floating surface — the dock is session-scoped, so a pet
|
|
254
276
|
// mounted there would vanish on the new-conversation screen (issue #48).
|
|
255
277
|
// The entry therefore mounts straight onto document.body via a single
|
|
256
|
-
// React root for the page lifetime:
|
|
257
|
-
// visible, and the hidden-state summon button is fixed-positioned.
|
|
278
|
+
// React root for the page lifetime: PetSprite portals itself to body
|
|
279
|
+
// when visible, and the hidden-state summon button is fixed-positioned.
|
|
258
280
|
const container = document.createElement('div')
|
|
259
281
|
container.dataset.dshPetRoot = ''
|
|
260
282
|
document.body.appendChild(container)
|
package/src/client/locales.ts
CHANGED
|
@@ -17,11 +17,13 @@ export const zh = {
|
|
|
17
17
|
'pet.rank': '亲密度 {rank}',
|
|
18
18
|
'pet.points': '{points} 点',
|
|
19
19
|
'pet.treats': '小鱼干 ×{n}',
|
|
20
|
-
'pet.state.loading': '
|
|
21
|
-
'pet.state.error': '
|
|
20
|
+
'pet.state.loading': '宠物正在赶来…',
|
|
21
|
+
'pet.state.error': '宠物迷路了(连接失败)',
|
|
22
22
|
// 插件设置卡片(settings.plugin.item 席位)。
|
|
23
23
|
'settings.title': '宠物',
|
|
24
|
-
'settings.description': '
|
|
24
|
+
'settings.description': '选择宠物并调整它的显示布局。',
|
|
25
|
+
'settings.pet': '宠物',
|
|
26
|
+
'settings.petHint': '选择显示哪只宠物;每只宠物独立命名,可在宠物悬浮面板改名。',
|
|
25
27
|
'settings.enabled': '启用宠物',
|
|
26
28
|
'settings.enabledHint': '关闭后隐藏宠物并停止轮询,可在设置里重新启用。',
|
|
27
29
|
'settings.visible': '显示宠物',
|
|
@@ -32,8 +34,6 @@ export const zh = {
|
|
|
32
34
|
'settings.rightHint': '距视口右边缘的水平内缩距离。',
|
|
33
35
|
'settings.bottom': '距底部(px)',
|
|
34
36
|
'settings.bottomHint': '距视口底边的垂直内缩距离。',
|
|
35
|
-
'settings.name': '名字',
|
|
36
|
-
'settings.nameHint': '宠物显示名,1–20 个字符。',
|
|
37
37
|
'settings.inherit': '继承',
|
|
38
38
|
'settings.on': '开',
|
|
39
39
|
'settings.off': '关',
|
|
@@ -62,11 +62,13 @@ export const en = {
|
|
|
62
62
|
'pet.rank': 'Affinity {rank}',
|
|
63
63
|
'pet.points': '{points} pts',
|
|
64
64
|
'pet.treats': 'Treats ×{n}',
|
|
65
|
-
'pet.state.loading': 'The
|
|
66
|
-
'pet.state.error': 'The
|
|
65
|
+
'pet.state.loading': 'The pet is on its way…',
|
|
66
|
+
'pet.state.error': 'The pet is lost (connection failed)',
|
|
67
67
|
// Plugin settings card (the `settings.plugin.item` seat).
|
|
68
68
|
'settings.title': 'Pet',
|
|
69
|
-
'settings.description': '
|
|
69
|
+
'settings.description': 'Pick a pet and tune its display layout.',
|
|
70
|
+
'settings.pet': 'Pet',
|
|
71
|
+
'settings.petHint': 'Choose which pet shows. Names are stored per pet; rename from the pet hover panel.',
|
|
70
72
|
'settings.enabled': 'Enable the pet',
|
|
71
73
|
'settings.enabledHint': 'When off, the pet hides and polling stops; re-enable it here.',
|
|
72
74
|
'settings.visible': 'Show the pet',
|
|
@@ -77,8 +79,6 @@ export const en = {
|
|
|
77
79
|
'settings.rightHint': 'Horizontal inset from the viewport right edge.',
|
|
78
80
|
'settings.bottom': 'Bottom inset (px)',
|
|
79
81
|
'settings.bottomHint': 'Vertical inset from the viewport bottom edge.',
|
|
80
|
-
'settings.name': 'Name',
|
|
81
|
-
'settings.nameHint': 'The pet\u2019s display name, 1\u201320 characters.',
|
|
82
82
|
'settings.inherit': 'Inherit',
|
|
83
83
|
'settings.on': 'On',
|
|
84
84
|
'settings.off': 'Off',
|