@linxin666/dsh-pet 0.2.0 → 0.2.2
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 +15 -5
- package/README.zh.md +15 -5
- package/assets/whale/pet.json +1 -1
- package/assets/whale-refined/pet.json +7 -0
- package/assets/whale-refined/spritesheet.webp +0 -0
- package/lib/client.js +257 -53
- package/lib/client.js.map +1 -1
- package/lib/index.js +235 -83
- package/lib/invariant.js +1 -1
- package/lib/{state-Ch3f4luZ.js → state-DrMX22GL.js} +35 -5
- package/lib/types/chatter.d.ts.map +1 -1
- package/lib/types/chatter.js +167 -77
- package/lib/types/client/PetDockEntry.d.ts.map +1 -1
- package/lib/types/client/PetDockEntry.js +1 -1
- package/lib/types/client/PetSprite.d.ts.map +1 -1
- package/lib/types/client/PetSprite.js +54 -11
- package/lib/types/client/PluginSettingsCard.d.ts +22 -0
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PluginSettingsCard.js +142 -3
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +1 -0
- 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/spritesheet.d.ts +1 -3
- package/lib/types/client/spritesheet.d.ts.map +1 -1
- package/lib/types/client/spritesheet.js +1 -5
- package/lib/types/loopback.d.ts +25 -0
- package/lib/types/loopback.d.ts.map +1 -0
- package/lib/types/loopback.js +68 -0
- package/lib/types/registry.d.ts +0 -2
- package/lib/types/registry.d.ts.map +1 -1
- package/lib/types/registry.js +11 -7
- package/lib/types/remarks.d.ts.map +1 -1
- package/lib/types/remarks.js +30 -0
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +14 -0
- package/lib/types/service.d.ts.map +1 -1
- package/package.json +1 -2
- package/src/chatter.ts +167 -77
- package/src/client/PetDockEntry.test.tsx +93 -0
- package/src/client/PetDockEntry.tsx +1 -0
- package/src/client/PetSprite.test.tsx +72 -11
- package/src/client/PetSprite.tsx +98 -30
- package/src/client/PluginSettingsCard.tsx +219 -18
- package/src/client/index.test.tsx +95 -0
- package/src/client/index.ts +1 -0
- package/src/client/locales.ts +4 -0
- package/src/client/pet-css.test.ts +15 -3
- package/src/client/pet.module.css +101 -71
- package/src/client/settings-card.module.css +90 -1
- package/src/client/spritesheet.ts +1 -6
- package/src/loopback.ts +63 -0
- package/src/registry.test.ts +49 -1
- package/src/registry.ts +11 -7
- package/src/remarks.ts +30 -0
- package/src/routes.ts +11 -0
- package/src/service.ts +0 -1
package/src/client/PetSprite.tsx
CHANGED
|
@@ -73,6 +73,13 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
73
73
|
const bubbleRef = useRef<HTMLDivElement | null>(null)
|
|
74
74
|
const [imageReady, setImageReady] = useState(false)
|
|
75
75
|
const [hovered, setHovered] = useState(false)
|
|
76
|
+
// Multi-session bubble stack: collapsed by default (only the display
|
|
77
|
+
// session's bubble + a '+N' badge), expanded on stack hover (peek) or by
|
|
78
|
+
// tapping the badge (pinned, for touch). The display session's bubble
|
|
79
|
+
// anchors the bottom of the stack and never moves when extras open above
|
|
80
|
+
// it, so the pointer target cannot flicker.
|
|
81
|
+
const [stackPeek, setStackPeek] = useState(false)
|
|
82
|
+
const [stackPinned, setStackPinned] = useState(false)
|
|
76
83
|
const [renaming, setRenaming] = useState(false)
|
|
77
84
|
const [panelAbove, setPanelAbove] = useState(false)
|
|
78
85
|
// Extra margin-bottom for the above-panel so it stacks clear of the
|
|
@@ -134,7 +141,7 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
134
141
|
// Paint one static sprite frame up front either way, so the pet is never
|
|
135
142
|
// blank while the loop heat-up runs.
|
|
136
143
|
const leadCol = track.frames[0]!
|
|
137
|
-
const lead = framePosition(cell,
|
|
144
|
+
const lead = framePosition(cell, row, leadCol, scaleRef.current)
|
|
138
145
|
if (spriteRef.current !== null) {
|
|
139
146
|
spriteRef.current.style.backgroundPosition = lead.x + 'px ' + lead.y + 'px'
|
|
140
147
|
}
|
|
@@ -154,7 +161,7 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
154
161
|
rows[currentRow] ?? tracks[current.animation].frames.length,
|
|
155
162
|
)
|
|
156
163
|
const col = currentTrack.frames[current.frameIndex]!
|
|
157
|
-
const pos = framePosition(cell,
|
|
164
|
+
const pos = framePosition(cell, currentRow, col, scaleRef.current)
|
|
158
165
|
if (spriteRef.current !== null) {
|
|
159
166
|
spriteRef.current.style.backgroundPosition = pos.x + 'px ' + pos.y + 'px'
|
|
160
167
|
}
|
|
@@ -185,7 +192,7 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
185
192
|
}
|
|
186
193
|
}
|
|
187
194
|
const col = track.frames[st.index]!
|
|
188
|
-
const pos = framePosition(cell,
|
|
195
|
+
const pos = framePosition(cell, row, col, scaleRef.current)
|
|
189
196
|
if (spriteRef.current !== null) {
|
|
190
197
|
spriteRef.current.style.backgroundPosition = pos.x + 'px ' + pos.y + 'px'
|
|
191
198
|
}
|
|
@@ -217,6 +224,11 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
217
224
|
}
|
|
218
225
|
}
|
|
219
226
|
|
|
227
|
+
// Clear any pending auto-hide timer on unmount: a stray callback after
|
|
228
|
+
// teardown reads window through react-dom and failed CI runs with
|
|
229
|
+
// "window is not defined" (slow-runner timing, PetSprite.test.tsx).
|
|
230
|
+
useEffect(() => () => clearHideTimer(), [])
|
|
231
|
+
|
|
220
232
|
const onPointerDown = (e: ReactPointerEvent<HTMLDivElement>): void => {
|
|
221
233
|
e.preventDefault()
|
|
222
234
|
;(e.target as HTMLElement).setPointerCapture?.(e.pointerId)
|
|
@@ -245,21 +257,33 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
245
257
|
const spriteWidth = Math.round(cell.width * spriteScale)
|
|
246
258
|
const spriteHeight = Math.round(cell.height * spriteScale)
|
|
247
259
|
|
|
248
|
-
// Concurrent sessions
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
// the
|
|
260
|
+
// Concurrent sessions share one bubble slot: only the display session
|
|
261
|
+
// speaks by default, and the rest hide behind a '+N' badge until the stack
|
|
262
|
+
// is hovered/pinned open. The legacy single 'bubble' is the fallback when
|
|
263
|
+
// the host serves no per-session list. The hover panel normally sits below
|
|
264
|
+
// the sprite, so the bubbles stay visible and clickable — no region swap.
|
|
252
265
|
const sessionBubbles = snapshot?.sessions ?? []
|
|
266
|
+
const stackOpen = stackPeek || stackPinned
|
|
267
|
+
const collapsed = !stackOpen && sessionBubbles.length > 1
|
|
268
|
+
const visibleSessions = collapsed ? sessionBubbles.slice(0, 1) : sessionBubbles
|
|
253
269
|
const statusBubble = feedback === null && sessionBubbles.length === 0
|
|
254
270
|
? snapshot?.bubble
|
|
255
271
|
: undefined
|
|
256
272
|
// The display session's inner whisper (碎碎念) — short inner-voice copy
|
|
257
|
-
// woken by the model's output.
|
|
258
|
-
//
|
|
273
|
+
// woken by the model's output. Instead of a second bubble of its own, a
|
|
274
|
+
// fresh whisper takes over the display session's bubble (the stack top, or
|
|
275
|
+
// the single status bubble) and re-tints it, so the pet never wears two
|
|
276
|
+
// voices at once. Interaction feedback takes over the whole bubble area
|
|
277
|
+
// while it plays, so whispers yield to it like status copy.
|
|
259
278
|
const whisper = feedback === null ? snapshot?.whisper : undefined
|
|
260
279
|
const bubblePresent = feedback !== null || sessionBubbles.length > 0 || statusBubble !== undefined || whisper !== undefined
|
|
261
280
|
const displayName = snapshot?.name ?? definition.displayName
|
|
262
281
|
|
|
282
|
+
// A settled session list can no longer stay pinned open.
|
|
283
|
+
useEffect(() => {
|
|
284
|
+
if (sessionBubbles.length <= 1) setStackPinned(false)
|
|
285
|
+
}, [sessionBubbles.length])
|
|
286
|
+
|
|
263
287
|
useLayoutEffect(() => {
|
|
264
288
|
if (!hovered) {
|
|
265
289
|
setPanelAbove(false)
|
|
@@ -282,7 +306,7 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
282
306
|
updatePanelPlacement()
|
|
283
307
|
window.addEventListener('resize', updatePanelPlacement)
|
|
284
308
|
return () => window.removeEventListener('resize', updatePanelPlacement)
|
|
285
|
-
}, [hovered, renaming, pos.right, pos.bottom, display.size, bubblePresent, sessionBubbles.length, feedback])
|
|
309
|
+
}, [hovered, renaming, pos.right, pos.bottom, display.size, bubblePresent, sessionBubbles.length, stackOpen, feedback])
|
|
286
310
|
|
|
287
311
|
const float = (
|
|
288
312
|
<div
|
|
@@ -343,27 +367,71 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
343
367
|
</div>
|
|
344
368
|
)}
|
|
345
369
|
{feedback === null && (sessionBubbles.length > 0 || statusBubble !== undefined || whisper !== undefined) && (
|
|
346
|
-
<div
|
|
347
|
-
{
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
370
|
+
<div
|
|
371
|
+
ref={bubbleRef}
|
|
372
|
+
className={styles.bubbleStack}
|
|
373
|
+
onPointerEnter={() => setStackPeek(true)}
|
|
374
|
+
onPointerLeave={() => setStackPeek(false)}
|
|
375
|
+
>
|
|
376
|
+
{visibleSessions.map((session, index) => {
|
|
377
|
+
// The whisper rides the display session's bubble — the stack's
|
|
378
|
+
// primary entry (DOM-first, rendered bottom-most by the reversed
|
|
379
|
+
// column so it stays glued to the sprite when extras open above).
|
|
380
|
+
// The key swap restarts the entrance animation so the mood change
|
|
381
|
+
// reads as the bubble re-speaking.
|
|
382
|
+
const speaksWhisper = index === 0 && whisper !== undefined
|
|
383
|
+
const bubble = (
|
|
384
|
+
<button
|
|
385
|
+
key={speaksWhisper ? 'whisper:' + whisper : session.sessionId}
|
|
386
|
+
type="button"
|
|
387
|
+
className={clsx(
|
|
388
|
+
styles.bubble,
|
|
389
|
+
styles.bubbleStatus,
|
|
390
|
+
styles.bubbleClickable,
|
|
391
|
+
speaksWhisper && styles.bubbleWhisper,
|
|
392
|
+
)}
|
|
393
|
+
title={props.t('pet.openSessionHint')}
|
|
394
|
+
onClick={() => { props.onOpenSession(session.sessionId) }}
|
|
395
|
+
>
|
|
396
|
+
{speaksWhisper ? whisper : session.bubble}
|
|
397
|
+
</button>
|
|
398
|
+
)
|
|
399
|
+
// The primary bubble carries the '+N' badge while other sessions
|
|
400
|
+
// hide behind it; the badge toggles the pinned (touch) expansion.
|
|
401
|
+
if (index !== 0 || sessionBubbles.length <= 1) return bubble
|
|
402
|
+
return (
|
|
403
|
+
<span key="primary" className={styles.bubbleAnchor}>
|
|
404
|
+
{bubble}
|
|
405
|
+
<button
|
|
406
|
+
type="button"
|
|
407
|
+
className={styles.bubbleMore}
|
|
408
|
+
title={stackOpen
|
|
409
|
+
? props.t('pet.collapseSessions')
|
|
410
|
+
: props.t('pet.moreSessions', { n: sessionBubbles.length - 1 })}
|
|
411
|
+
aria-label={stackOpen
|
|
412
|
+
? props.t('pet.collapseSessions')
|
|
413
|
+
: props.t('pet.moreSessions', { n: sessionBubbles.length - 1 })}
|
|
414
|
+
aria-expanded={stackOpen}
|
|
415
|
+
onClick={(e) => {
|
|
416
|
+
e.stopPropagation()
|
|
417
|
+
setStackPinned(open => !open)
|
|
418
|
+
}}
|
|
419
|
+
>
|
|
420
|
+
{stackOpen ? '×' : '+' + String(sessionBubbles.length - 1)}
|
|
421
|
+
</button>
|
|
422
|
+
</span>
|
|
423
|
+
)
|
|
424
|
+
})}
|
|
425
|
+
{sessionBubbles.length === 0 && (statusBubble !== undefined || whisper !== undefined) && (
|
|
426
|
+
// The key swap (status copy <-> whisper) restarts the entrance
|
|
427
|
+
// animation on every mood change.
|
|
428
|
+
<div
|
|
429
|
+
key={whisper === undefined ? 'status' : 'whisper:' + whisper}
|
|
430
|
+
className={clsx(styles.bubble, styles.bubbleStatus, whisper !== undefined && styles.bubbleWhisper)}
|
|
431
|
+
role="status"
|
|
432
|
+
aria-live="polite"
|
|
354
433
|
>
|
|
355
|
-
{
|
|
356
|
-
</button>
|
|
357
|
-
))}
|
|
358
|
-
{sessionBubbles.length === 0 && statusBubble !== undefined && (
|
|
359
|
-
<div className={clsx(styles.bubble, styles.bubbleStatus)} role="status" aria-live="polite">
|
|
360
|
-
{statusBubble}
|
|
361
|
-
</div>
|
|
362
|
-
)}
|
|
363
|
-
{whisper !== undefined && (
|
|
364
|
-
// key restarts the entrance animation per fresh whisper.
|
|
365
|
-
<div key={whisper} className={styles.bubbleWhisper}>
|
|
366
|
-
{whisper}
|
|
434
|
+
{whisper ?? statusBubble}
|
|
367
435
|
</div>
|
|
368
436
|
)}
|
|
369
437
|
</div>
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* ui-plugin-config PluginCard in a self-contained slice.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { useState, type ReactNode } from 'react'
|
|
11
|
+
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type KeyboardEvent, type ReactNode } from 'react'
|
|
12
12
|
import type { CardShell } from './settings-form.ts'
|
|
13
13
|
import css from './settings-card.module.css'
|
|
14
14
|
|
|
@@ -255,6 +255,210 @@ export function ValueField(props: FieldProps & {
|
|
|
255
255
|
)
|
|
256
256
|
}
|
|
257
257
|
|
|
258
|
+
const NON_SKIN_BODY_MARKERS = new Set(['dshSkinCenter', 'dshSidebarCollapsed'])
|
|
259
|
+
// 检测使用使用皮肤,用了皮肤退回原生select样式,防止样式冲突。默认外观下使用优化后的select样式。
|
|
260
|
+
function isSkinActive(): boolean {
|
|
261
|
+
const datasetList = Object.keys(document.body.dataset)
|
|
262
|
+
const isActive = datasetList.some(key => key.startsWith('dsh') && !NON_SKIN_BODY_MARKERS.has(key))
|
|
263
|
+
return isActive
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
interface SelectOption {
|
|
267
|
+
value: string
|
|
268
|
+
label: string
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const SELECT_CLOSE_MS = 100
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* The shared dual-mode select control. While an appearance skin is active it
|
|
275
|
+
* renders the legacy native `<select>` untouched, so element-level skin
|
|
276
|
+
* selectors keep working; under the default appearance it renders a
|
|
277
|
+
* self-drawn `role="listbox"` popup whose open/close is transition-animated.
|
|
278
|
+
* Staged cards reach it through BooleanField/ChoiceField; immediate-apply
|
|
279
|
+
* editors (the side-card prefs) bind it directly through onEdit.
|
|
280
|
+
* 双模式下拉框:皮肤激活时用原生 select,默认外观用自绘动画弹层。
|
|
281
|
+
*/
|
|
282
|
+
export function SelectField(props: {
|
|
283
|
+
id: string
|
|
284
|
+
options: ReadonlyArray<SelectOption>
|
|
285
|
+
value: string
|
|
286
|
+
disabled: boolean
|
|
287
|
+
invalid: boolean
|
|
288
|
+
onEdit: (text: string) => void
|
|
289
|
+
}) {
|
|
290
|
+
const { id, options, value } = props
|
|
291
|
+
const [open, setOpen] = useState(false)
|
|
292
|
+
const [closing, setClosing] = useState(false)
|
|
293
|
+
const [phase, setPhase] = useState<'initial' | 'open'>('initial')
|
|
294
|
+
const [activeIndex, setActiveIndex] = useState(0)
|
|
295
|
+
const closeTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
|
296
|
+
const wrapRef = useRef<HTMLDivElement | null>(null)
|
|
297
|
+
const popupRef = useRef<HTMLDivElement | null>(null)
|
|
298
|
+
|
|
299
|
+
const currentIndex = () => {
|
|
300
|
+
const index = options.findIndex(option => option.value === value)
|
|
301
|
+
return index >= 0 ? index : 0
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const close = useCallback(() => {
|
|
305
|
+
if (closeTimer.current !== undefined) clearTimeout(closeTimer.current)
|
|
306
|
+
setClosing(true)
|
|
307
|
+
closeTimer.current = setTimeout(() => {
|
|
308
|
+
setClosing(false)
|
|
309
|
+
setOpen(false)
|
|
310
|
+
}, SELECT_CLOSE_MS)
|
|
311
|
+
}, [])
|
|
312
|
+
|
|
313
|
+
const openPopup = () => {
|
|
314
|
+
if (closeTimer.current !== undefined) clearTimeout(closeTimer.current)
|
|
315
|
+
setActiveIndex(currentIndex())
|
|
316
|
+
setPhase('initial')
|
|
317
|
+
setClosing(false)
|
|
318
|
+
setOpen(true)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const commit = (index: number) => {
|
|
322
|
+
const option = options[index]
|
|
323
|
+
if (option) props.onEdit(option.value)
|
|
324
|
+
close()
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const onTriggerClick = () => {
|
|
328
|
+
if (props.disabled) return
|
|
329
|
+
if (open && !closing) close()
|
|
330
|
+
else openPopup()
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
|
|
334
|
+
if (props.disabled) return
|
|
335
|
+
const count = options.length
|
|
336
|
+
switch (event.key) {
|
|
337
|
+
case 'ArrowDown':
|
|
338
|
+
case 'ArrowUp':
|
|
339
|
+
case 'Enter':
|
|
340
|
+
case ' ':
|
|
341
|
+
event.preventDefault()
|
|
342
|
+
if (!open) {
|
|
343
|
+
openPopup()
|
|
344
|
+
} else if (!closing) {
|
|
345
|
+
if (event.key === 'ArrowDown') setActiveIndex(index => (index + 1) % count)
|
|
346
|
+
else if (event.key === 'ArrowUp') setActiveIndex(index => (index - 1 + count) % count)
|
|
347
|
+
else commit(activeIndex)
|
|
348
|
+
}
|
|
349
|
+
break
|
|
350
|
+
case 'Escape':
|
|
351
|
+
if (open) {
|
|
352
|
+
event.preventDefault()
|
|
353
|
+
event.stopPropagation()
|
|
354
|
+
close()
|
|
355
|
+
}
|
|
356
|
+
break
|
|
357
|
+
case 'Tab':
|
|
358
|
+
if (open) close()
|
|
359
|
+
break
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
useEffect(() => () => {
|
|
364
|
+
if (closeTimer.current !== undefined) clearTimeout(closeTimer.current)
|
|
365
|
+
}, [])
|
|
366
|
+
useLayoutEffect(() => {
|
|
367
|
+
if (open && !closing && phase === 'initial') {
|
|
368
|
+
void popupRef.current?.offsetHeight
|
|
369
|
+
setPhase('open')
|
|
370
|
+
}
|
|
371
|
+
}, [open, closing, phase])
|
|
372
|
+
|
|
373
|
+
useEffect(() => {
|
|
374
|
+
if (!open) return
|
|
375
|
+
const onPointerDown = (event: PointerEvent) => {
|
|
376
|
+
const target = event.target
|
|
377
|
+
if (target instanceof Node && !wrapRef.current?.contains(target)) close()
|
|
378
|
+
}
|
|
379
|
+
document.addEventListener('pointerdown', onPointerDown)
|
|
380
|
+
return () => document.removeEventListener('pointerdown', onPointerDown)
|
|
381
|
+
}, [open, close])
|
|
382
|
+
|
|
383
|
+
useEffect(() => {
|
|
384
|
+
if (props.disabled && open) close()
|
|
385
|
+
}, [props.disabled, open, close])
|
|
386
|
+
|
|
387
|
+
// 使用了皮肤则退回原生select样式防止样式冲突。
|
|
388
|
+
if (isSkinActive()) {
|
|
389
|
+
return (
|
|
390
|
+
<select
|
|
391
|
+
id={id}
|
|
392
|
+
className={css.select}
|
|
393
|
+
value={value}
|
|
394
|
+
disabled={props.disabled}
|
|
395
|
+
onChange={(event) => { props.onEdit(event.target.value) }}
|
|
396
|
+
>
|
|
397
|
+
{options.map(option => (
|
|
398
|
+
<option key={option.value} value={option.value}>{option.label}</option>
|
|
399
|
+
))}
|
|
400
|
+
</select>
|
|
401
|
+
)
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const label = options.find(option => option.value === value)?.label ?? ''
|
|
405
|
+
const popupClass = closing
|
|
406
|
+
? `${css.selectPopup} ${css.selectPopupClose}`
|
|
407
|
+
: phase === 'open'
|
|
408
|
+
? `${css.selectPopup} ${css.selectPopupOpen}`
|
|
409
|
+
: css.selectPopup
|
|
410
|
+
return (
|
|
411
|
+
<div className={css.selectWrap} ref={wrapRef}>
|
|
412
|
+
<button
|
|
413
|
+
type="button"
|
|
414
|
+
id={id}
|
|
415
|
+
className={`${css.select} ${css.selectButton}`}
|
|
416
|
+
disabled={props.disabled}
|
|
417
|
+
aria-haspopup="listbox"
|
|
418
|
+
aria-expanded={open}
|
|
419
|
+
aria-activedescendant={open ? `${id}-o${activeIndex}` : undefined}
|
|
420
|
+
aria-invalid={props.invalid || undefined}
|
|
421
|
+
onClick={onTriggerClick}
|
|
422
|
+
onKeyDown={onKeyDown}
|
|
423
|
+
>
|
|
424
|
+
<span className={css.selectLabel}>{label}</span>
|
|
425
|
+
<svg
|
|
426
|
+
width="14"
|
|
427
|
+
height="14"
|
|
428
|
+
viewBox="0 0 14 14"
|
|
429
|
+
fill="none"
|
|
430
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
431
|
+
className={open ? `${css.selectChevron} ${css.selectChevronOpen}` : css.selectChevron}
|
|
432
|
+
aria-hidden="true"
|
|
433
|
+
>
|
|
434
|
+
<path
|
|
435
|
+
d="M11.8486 5.5L11.4238 5.92383L8.69727 8.65137C8.44157 8.90706 8.21562 9.13382 8.01172 9.29785C7.79912 9.46883 7.55595 9.61756 7.25 9.66602C7.08435 9.69222 6.91565 9.69222 6.75 9.66602C6.44405 9.61756 6.20088 9.46883 5.98828 9.29785C5.78438 9.13382 5.55843 8.90706 5.30273 8.65137L2.57617 5.92383L2.15137 5.5L3 4.65137L3.42383 5.07617L6.15137 7.80273C6.42595 8.07732 6.59876 8.24849 6.74023 8.3623C6.87291 8.46904 6.92272 8.47813 6.9375 8.48047C6.97895 8.48703 7.02105 8.48703 7.0625 8.48047C7.07728 8.47813 7.12709 8.46904 7.25977 8.3623C7.40124 8.24849 7.57405 8.07732 7.84863 7.80273L10.5762 5.07617L11 4.65137L11.8486 5.5Z"
|
|
436
|
+
fill="currentColor"
|
|
437
|
+
/>
|
|
438
|
+
</svg>
|
|
439
|
+
</button>
|
|
440
|
+
{open
|
|
441
|
+
? (
|
|
442
|
+
<div className={popupClass} role="listbox" ref={popupRef}>
|
|
443
|
+
{options.map((option, index) => (
|
|
444
|
+
<div
|
|
445
|
+
key={option.value}
|
|
446
|
+
id={`${id}-o${index}`}
|
|
447
|
+
role="option"
|
|
448
|
+
aria-selected={option.value === value}
|
|
449
|
+
className={`${css.selectOption}${option.value === value ? ` ${css.selectOptionSelected}` : ''}${index === activeIndex && !closing ? ` ${css.selectOptionActive}` : ''}`}
|
|
450
|
+
onClick={() => { commit(index) }}
|
|
451
|
+
>
|
|
452
|
+
{option.label}
|
|
453
|
+
</div>
|
|
454
|
+
))}
|
|
455
|
+
</div>
|
|
456
|
+
)
|
|
457
|
+
: null}
|
|
458
|
+
</div>
|
|
459
|
+
)
|
|
460
|
+
}
|
|
461
|
+
|
|
258
462
|
/** A staged boolean field: 继承 / 开 / 关. */
|
|
259
463
|
export function BooleanField(props: FieldProps & {
|
|
260
464
|
/** Copy for the inherit option. */
|
|
@@ -284,17 +488,18 @@ export function BooleanField(props: FieldProps & {
|
|
|
284
488
|
)
|
|
285
489
|
: null}
|
|
286
490
|
</div>
|
|
287
|
-
<
|
|
491
|
+
<SelectField
|
|
288
492
|
id={props.id}
|
|
289
|
-
|
|
493
|
+
options={[
|
|
494
|
+
{ value: '', label: props.inheritLabel },
|
|
495
|
+
{ value: 'true', label: props.onLabel },
|
|
496
|
+
{ value: 'false', label: props.offLabel },
|
|
497
|
+
]}
|
|
290
498
|
value={props.text}
|
|
291
499
|
disabled={props.disabled}
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
<option value="true">{props.onLabel}</option>
|
|
296
|
-
<option value="false">{props.offLabel}</option>
|
|
297
|
-
</select>
|
|
500
|
+
invalid={props.invalid}
|
|
501
|
+
onEdit={props.onEdit}
|
|
502
|
+
/>
|
|
298
503
|
<p className={css.hint}>{props.hint}</p>
|
|
299
504
|
</div>
|
|
300
505
|
)
|
|
@@ -327,18 +532,14 @@ export function ChoiceField(props: FieldProps & {
|
|
|
327
532
|
)
|
|
328
533
|
: null}
|
|
329
534
|
</div>
|
|
330
|
-
<
|
|
535
|
+
<SelectField
|
|
331
536
|
id={props.id}
|
|
332
|
-
|
|
537
|
+
options={[{ value: '', label: props.inheritLabel }, ...props.choices]}
|
|
333
538
|
value={props.text}
|
|
334
539
|
disabled={props.disabled}
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
{props.choices.map(choice => (
|
|
339
|
-
<option key={choice.value} value={choice.value}>{choice.label}</option>
|
|
340
|
-
))}
|
|
341
|
-
</select>
|
|
540
|
+
invalid={props.invalid}
|
|
541
|
+
onEdit={props.onEdit}
|
|
542
|
+
/>
|
|
342
543
|
<p className={props.invalid ? css.invalid : css.hint}>
|
|
343
544
|
{props.invalid ? props.invalidLabel : props.hint}
|
|
344
545
|
</p>
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* The global pet entry container opts into the L2 semantic attributes
|
|
4
|
+
* (issue #506): the apply body mounts [data-dsh-pet-root] with
|
|
5
|
+
* data-dsh-plugin="pet" so skins can target the pet subtree.
|
|
6
|
+
*/
|
|
7
|
+
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
|
8
|
+
// The npm SDK's client half is a closure-factory bundle for the GUI's
|
|
9
|
+
// __ModuleLoader__ (not importable under vitest); provide defineStore /
|
|
10
|
+
// createSnapshotStore (same fake-store pattern as the settings-card tests).
|
|
11
|
+
vi.mock('@deepseek-ai/dsh-client-runtime/client', () => ({
|
|
12
|
+
defineStore: (spec: {
|
|
13
|
+
init: () => unknown
|
|
14
|
+
actions: Record<string, (draft: never, ...args: never[]) => void>
|
|
15
|
+
}) => ({
|
|
16
|
+
create: () => {
|
|
17
|
+
let value = spec.init()
|
|
18
|
+
const listeners = new Set<() => void>()
|
|
19
|
+
const actions: Record<string, (...args: unknown[]) => void> = {}
|
|
20
|
+
for (const [name, fn] of Object.entries(spec.actions)) {
|
|
21
|
+
actions[name] = (...args: unknown[]) => {
|
|
22
|
+
fn(value as never, ...(args as never[]))
|
|
23
|
+
for (const listener of listeners) listener()
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
getSnapshot: () => value,
|
|
28
|
+
subscribe: (listener: () => void) => {
|
|
29
|
+
listeners.add(listener)
|
|
30
|
+
return () => { listeners.delete(listener) }
|
|
31
|
+
},
|
|
32
|
+
actions,
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
}),
|
|
36
|
+
createSnapshotStore: (init: unknown) => {
|
|
37
|
+
let value = init
|
|
38
|
+
const listeners = new Set<() => void>()
|
|
39
|
+
return {
|
|
40
|
+
getSnapshot: () => value,
|
|
41
|
+
set: (next: unknown) => { value = next; for (const listener of listeners) listener() },
|
|
42
|
+
update: (mutator: (draft: never) => void) => { mutator(value as never); for (const listener of listeners) listener() },
|
|
43
|
+
subscribe: (listener: () => void) => { listeners.add(listener); return () => { listeners.delete(listener) } },
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
}))
|
|
47
|
+
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
|
48
|
+
import { apply } from './index.ts'
|
|
49
|
+
|
|
50
|
+
beforeAll(() => {
|
|
51
|
+
document.documentElement.lang = 'zh'
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
afterEach(() => {
|
|
55
|
+
document.body.replaceChildren()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
/** A minimal client root context: ready settings scope, no-op slot system. */
|
|
59
|
+
function fakeContext(): ClientContext {
|
|
60
|
+
const scope = {
|
|
61
|
+
getSnapshot: () => ({
|
|
62
|
+
status: 'ready',
|
|
63
|
+
writable: true,
|
|
64
|
+
value: undefined,
|
|
65
|
+
base: undefined,
|
|
66
|
+
user: {},
|
|
67
|
+
revision: 1,
|
|
68
|
+
mode: 'host',
|
|
69
|
+
}),
|
|
70
|
+
subscribe: () => () => {},
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
effect: (fn: () => unknown) => {
|
|
74
|
+
const dispose = fn()
|
|
75
|
+
return typeof dispose === 'function' ? dispose : () => {}
|
|
76
|
+
},
|
|
77
|
+
locale: { register: () => () => {} },
|
|
78
|
+
get: () => undefined,
|
|
79
|
+
settingsScope: { bind: () => scope },
|
|
80
|
+
slots: {
|
|
81
|
+
inject: (_name: string, callback: () => () => void) => callback(),
|
|
82
|
+
register: () => () => {},
|
|
83
|
+
},
|
|
84
|
+
sessions: undefined,
|
|
85
|
+
} as unknown as ClientContext
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
describe('pet client apply L2 semantic attributes (#506)', () => {
|
|
89
|
+
it('mounts the pet root container with data-dsh-plugin="pet"', () => {
|
|
90
|
+
apply(fakeContext())
|
|
91
|
+
const root = document.body.querySelector('[data-dsh-pet-root]')
|
|
92
|
+
expect(root).not.toBeNull()
|
|
93
|
+
expect(root!.getAttribute('data-dsh-plugin')).toBe('pet')
|
|
94
|
+
})
|
|
95
|
+
})
|
package/src/client/index.ts
CHANGED
|
@@ -292,6 +292,7 @@ export function apply(ctx: ClientContext): void {
|
|
|
292
292
|
// when visible, and the hidden-state summon button is fixed-positioned.
|
|
293
293
|
const container = document.createElement('div')
|
|
294
294
|
container.dataset.dshPetRoot = ''
|
|
295
|
+
container.dataset.dshPlugin = 'pet'
|
|
295
296
|
document.body.appendChild(container)
|
|
296
297
|
const petRoot = createRoot(container)
|
|
297
298
|
petRoot.render(createElement(PetDockEntry, { ...injected(), t }))
|
package/src/client/locales.ts
CHANGED
|
@@ -20,6 +20,8 @@ export const zh = {
|
|
|
20
20
|
'pet.state.loading': '宠物正在赶来…',
|
|
21
21
|
'pet.state.error': '宠物迷路了(连接失败)',
|
|
22
22
|
'pet.openSessionHint': '点击跳转到对应会话',
|
|
23
|
+
'pet.moreSessions': '展开其余 {n} 个会话的气泡',
|
|
24
|
+
'pet.collapseSessions': '收起会话气泡',
|
|
23
25
|
// 一级设置页(settings.section 席位)。
|
|
24
26
|
'settings.title': '宠物',
|
|
25
27
|
'settings.description': '选择宠物并调整它的显示布局。',
|
|
@@ -66,6 +68,8 @@ export const en = {
|
|
|
66
68
|
'pet.state.loading': 'The pet is on its way…',
|
|
67
69
|
'pet.state.error': 'The pet is lost (connection failed)',
|
|
68
70
|
'pet.openSessionHint': 'Click to jump to this session',
|
|
71
|
+
'pet.moreSessions': 'Expand {n} more session bubbles',
|
|
72
|
+
'pet.collapseSessions': 'Collapse session bubbles',
|
|
69
73
|
// First-level settings section (the `settings.section` seat).
|
|
70
74
|
'settings.title': 'Pet',
|
|
71
75
|
'settings.description': 'Pick a pet and tune its display layout.',
|
|
@@ -17,11 +17,23 @@ describe('pet hover panel css', () => {
|
|
|
17
17
|
expect(bridge).toContain('height: 14px')
|
|
18
18
|
})
|
|
19
19
|
|
|
20
|
-
it('
|
|
20
|
+
it('keeps the whisper on the shared bubble glass for a unified palette', () => {
|
|
21
21
|
const whisper = css.match(/\.bubbleWhisper\s*\{([^}]*)\}/)?.[1] ?? ''
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
// Unified palette: the whisper inherits background/border/shadow/color
|
|
23
|
+
// from .bubble/.bubbleStatus, so a whispering bubble and a status bubble
|
|
24
|
+
// stacked side by side share the same DeepSeek-blue glass; only the mood
|
|
25
|
+
// (quotes, letter-spacing, entrance) is overridden.
|
|
26
|
+
expect(whisper).not.toContain('background')
|
|
27
|
+
expect(whisper).not.toContain('border')
|
|
28
|
+
expect(whisper).not.toContain('box-shadow')
|
|
29
|
+
expect(whisper).not.toContain('color')
|
|
30
|
+
expect(whisper).toContain('animation: pet-whisper-in')
|
|
31
|
+
expect(whisper).not.toContain('font-style: italic')
|
|
32
|
+
expect(whisper).not.toContain('pointer-events')
|
|
33
|
+
expect(whisper).not.toContain('position:')
|
|
24
34
|
expect(css).toContain('@keyframes pet-whisper-in')
|
|
35
|
+
// The mood overrides must be declared after .bubbleStatus to win the cascade.
|
|
36
|
+
expect(css.indexOf('.bubbleWhisper {')).toBeGreaterThan(css.indexOf('.bubbleStatus {'))
|
|
25
37
|
})
|
|
26
38
|
|
|
27
39
|
it('gives the panel and status bubbles entrance animations', () => {
|