@linxin666/dsh-pet 0.3.6 → 0.3.9
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 +4 -0
- package/README.zh.md +4 -0
- package/lib/client.js +187 -88
- package/lib/client.js.map +1 -1
- package/lib/index.js +89 -11
- package/lib/live2d-vendor.js +106 -95
- package/lib/live2d-vendor.js.map +1 -1
- package/lib/types/announce.d.ts +53 -0
- package/lib/types/announce.d.ts.map +1 -0
- package/lib/types/announce.js +80 -0
- package/lib/types/client/PetDockEntry.d.ts +14 -5
- package/lib/types/client/PetDockEntry.d.ts.map +1 -1
- package/lib/types/client/PetDockEntry.js +6 -5
- package/lib/types/client/PetSettingsCard.d.ts +2 -1
- package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PetSprite.d.ts +8 -0
- package/lib/types/client/PetSprite.d.ts.map +1 -1
- package/lib/types/client/PetSprite.js +29 -4
- package/lib/types/client/index.d.ts +8 -5
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +11 -5
- package/lib/types/client/locales.d.ts +2 -2
- package/lib/types/client/locales.js +2 -2
- package/lib/types/client/pet-store.d.ts +1 -1
- package/lib/types/client/pet-store.d.ts.map +1 -1
- package/lib/types/client/pet-store.js +1 -1
- package/lib/types/client/settings-form.d.ts +35 -43
- package/lib/types/client/settings-form.d.ts.map +1 -1
- package/lib/types/client/settings-form.js +60 -53
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +10 -9
- package/lib/types/service.d.ts +18 -0
- package/lib/types/service.d.ts.map +1 -1
- package/lib/types/service.js +22 -0
- package/package.json +17 -13
- package/src/announce.ts +103 -0
- package/src/client/PetDockEntry.test.tsx +1 -1
- package/src/client/PetDockEntry.tsx +15 -4
- package/src/client/PetSettingsCard.tsx +2 -1
- package/src/client/PetSprite.test.tsx +18 -0
- package/src/client/PetSprite.tsx +66 -3
- package/src/client/gameplay-hud.test.tsx +1 -1
- package/src/client/index.test.tsx +2 -2
- package/src/client/index.ts +17 -6
- package/src/client/locales.ts +2 -2
- package/src/client/pet-store.ts +2 -2
- package/src/client/pet.module.css +104 -0
- package/src/client/settings-form.ts +78 -92
- package/src/dsh-home.ts +1 -1
- package/src/index.ts +17 -15
- package/src/service.ts +28 -1
package/src/client/PetSprite.tsx
CHANGED
|
@@ -16,6 +16,7 @@ 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
|
+
import { announcementFresh, type PetAnnouncement } from '../announce.ts'
|
|
19
20
|
import type { PetDefinition } from '../registry.ts'
|
|
20
21
|
import type { DecorationView } from '../contracts/status-decoration.ts'
|
|
21
22
|
import type { PetFeedback } from './pet-store.ts'
|
|
@@ -78,6 +79,14 @@ export interface PetSpriteProps {
|
|
|
78
79
|
onGameplayMenu?: () => void
|
|
79
80
|
/** Disable the drag gesture (gameplay work mode blocks dragging). */
|
|
80
81
|
dragDisabled?: boolean
|
|
82
|
+
/**
|
|
83
|
+
* DOM node the floating chrome portals into. Defaults to document.body
|
|
84
|
+
* (the legacy behavior); the plugin apply passes its owning root (the
|
|
85
|
+
* [data-dsh-plugin="pet"] container) so the root owns the whole surface
|
|
86
|
+
* and a root-keyed suppressor (the portrait mobile layer) hides the pet
|
|
87
|
+
* as one unit instead of missing the portaled sprite.
|
|
88
|
+
*/
|
|
89
|
+
portalTarget?: Element
|
|
81
90
|
/** Locale translate seat (namespace-bound). */
|
|
82
91
|
t: TranslateNS<typeof NS>
|
|
83
92
|
}
|
|
@@ -176,6 +185,52 @@ function StatusOrnament(props: { decoration: DecorationView; phase: ActivityPhas
|
|
|
176
185
|
)
|
|
177
186
|
}
|
|
178
187
|
|
|
188
|
+
/**
|
|
189
|
+
* The announcement bubble (dsh-usage linkage): a dedicated, specially
|
|
190
|
+
* designed surface for sibling-plugin facts — a balance or today-spend pill,
|
|
191
|
+
* or a plan-quota card with a tone-tinted accent, a mini meter for percent
|
|
192
|
+
* windows, and the reset instant. It rides the top of the session bubble
|
|
193
|
+
* stack (column-reverse puts the DOM-last child farthest from the sprite)
|
|
194
|
+
* and persists for its TTL instead of the short feedback pop.
|
|
195
|
+
*/
|
|
196
|
+
function UsageAnnouncementBubble(props: { announcement: PetAnnouncement }): ReactNode {
|
|
197
|
+
const { announcement } = props
|
|
198
|
+
const tone = announcement.tone === 'low'
|
|
199
|
+
? styles.bubbleUsageLow
|
|
200
|
+
: announcement.tone === 'warn'
|
|
201
|
+
? styles.bubbleUsageWarn
|
|
202
|
+
: styles.bubbleUsageOk
|
|
203
|
+
return (
|
|
204
|
+
<div
|
|
205
|
+
className={clsx(styles.bubble, styles.bubbleUsage, tone)}
|
|
206
|
+
role="status"
|
|
207
|
+
aria-live="polite"
|
|
208
|
+
data-dsh-pet-announcement={announcement.source}
|
|
209
|
+
>
|
|
210
|
+
<span className={styles.bubbleUsageHead}>
|
|
211
|
+
<span className={styles.bubbleUsageTitle}>{announcement.title}</span>
|
|
212
|
+
{(announcement.kind === 'balance' || announcement.kind === 'cost') && announcement.amount !== undefined && (
|
|
213
|
+
<span className={styles.bubbleUsageValue}>{announcement.amount}</span>
|
|
214
|
+
)}
|
|
215
|
+
{announcement.kind === 'plan' && announcement.percent !== undefined && (
|
|
216
|
+
<span className={styles.bubbleUsageValue}>{Math.round(announcement.percent) + '%'}</span>
|
|
217
|
+
)}
|
|
218
|
+
</span>
|
|
219
|
+
{announcement.kind === 'plan' && announcement.percent !== undefined && (
|
|
220
|
+
<span className={styles.bubbleUsageMeter}>
|
|
221
|
+
<span
|
|
222
|
+
className={styles.bubbleUsageMeterFill}
|
|
223
|
+
style={{ width: Math.min(100, Math.max(0, announcement.percent)) + '%' }}
|
|
224
|
+
/>
|
|
225
|
+
</span>
|
|
226
|
+
)}
|
|
227
|
+
{announcement.note !== undefined && (
|
|
228
|
+
<span className={styles.bubbleUsageNote}>{announcement.note}</span>
|
|
229
|
+
)}
|
|
230
|
+
</div>
|
|
231
|
+
)
|
|
232
|
+
}
|
|
233
|
+
|
|
179
234
|
/**
|
|
180
235
|
* The floating pet. The spritesheet frame advances on requestAnimationFrame
|
|
181
236
|
* with per-frame durations from the definition's tracks; the atlas image is
|
|
@@ -432,6 +487,13 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
432
487
|
const statusBubble = feedback === null && sessionBubbles.length === 0
|
|
433
488
|
? snapshot?.bubble
|
|
434
489
|
: undefined
|
|
490
|
+
// The freshest plugin-authored announcement (dsh-usage linkage): a
|
|
491
|
+
// dedicated, specially styled bubble above the session stack. The host
|
|
492
|
+
// already TTL-filters; this client-side check covers the last poll tick.
|
|
493
|
+
const announcement = snapshot?.announcement
|
|
494
|
+
const usageAnnouncement = feedback === null && announcement !== undefined && announcementFresh(announcement, Date.now())
|
|
495
|
+
? announcement
|
|
496
|
+
: undefined
|
|
435
497
|
// Each session's inner whisper (碎碎念) rides its own bubble — short
|
|
436
498
|
// inner-voice copy woken by that session's activity, never the model's or
|
|
437
499
|
// another session's. Instead of a second bubble of its own, a fresh
|
|
@@ -439,7 +501,7 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
439
501
|
// never wears two voices at once. Interaction feedback takes over the
|
|
440
502
|
// whole bubble area while it plays, so whispers yield to it like status
|
|
441
503
|
// copy.
|
|
442
|
-
const bubblePresent = feedback !== null || sessionBubbles.length > 0 || statusBubble !== undefined
|
|
504
|
+
const bubblePresent = feedback !== null || sessionBubbles.length > 0 || statusBubble !== undefined || usageAnnouncement !== undefined
|
|
443
505
|
const displayName = snapshot?.name ?? definition.displayName
|
|
444
506
|
// The host-served status decoration (M5, #567); absent = text-only bubbles.
|
|
445
507
|
const decoration = snapshot?.decoration
|
|
@@ -549,7 +611,7 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
549
611
|
{feedback.text}
|
|
550
612
|
</div>
|
|
551
613
|
)}
|
|
552
|
-
{feedback === null && (sessionBubbles.length > 0 || statusBubble !== undefined) && (
|
|
614
|
+
{feedback === null && (sessionBubbles.length > 0 || statusBubble !== undefined || usageAnnouncement !== undefined) && (
|
|
553
615
|
<div
|
|
554
616
|
ref={bubbleRef}
|
|
555
617
|
className={styles.bubbleStack}
|
|
@@ -620,6 +682,7 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
620
682
|
{statusBubble}
|
|
621
683
|
</div>
|
|
622
684
|
)}
|
|
685
|
+
{usageAnnouncement !== undefined && <UsageAnnouncementBubble announcement={usageAnnouncement} />}
|
|
623
686
|
</div>
|
|
624
687
|
)}
|
|
625
688
|
{hovered && dragRef.current === null && (
|
|
@@ -738,5 +801,5 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
738
801
|
</div>
|
|
739
802
|
)
|
|
740
803
|
|
|
741
|
-
return createPortal(float, document.body)
|
|
804
|
+
return createPortal(float, props.portalTarget ?? document.body)
|
|
742
805
|
}
|
|
@@ -9,7 +9,7 @@ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
|
|
9
9
|
// The npm SDK's client half is a closure-factory bundle for the GUI's
|
|
10
10
|
// __ModuleLoader__ (not importable under vitest); provide the defineStore
|
|
11
11
|
// the pet store needs (same fake-store pattern as PetDockEntry.test.tsx).
|
|
12
|
-
vi.mock('@deepseek-ai/dsh-client-
|
|
12
|
+
vi.mock('@deepseek-ai/dsh-client-store', () => ({
|
|
13
13
|
defineStore: (spec: {
|
|
14
14
|
init: () => unknown
|
|
15
15
|
actions: Record<string, (draft: never, ...args: never[]) => void>
|
|
@@ -12,7 +12,7 @@ import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
|
|
12
12
|
// The npm SDK's client half is a closure-factory bundle for the GUI's
|
|
13
13
|
// __ModuleLoader__ (not importable under vitest); provide defineStore /
|
|
14
14
|
// createSnapshotStore (same fake-store pattern as the settings-card tests).
|
|
15
|
-
vi.mock('@deepseek-ai/dsh-client-
|
|
15
|
+
vi.mock('@deepseek-ai/dsh-client-store', () => ({
|
|
16
16
|
defineStore: (spec: {
|
|
17
17
|
init: () => unknown
|
|
18
18
|
actions: Record<string, (draft: never, ...args: never[]) => void>
|
|
@@ -48,7 +48,7 @@ vi.mock('@deepseek-ai/dsh-client-runtime/client', () => ({
|
|
|
48
48
|
}
|
|
49
49
|
},
|
|
50
50
|
}))
|
|
51
|
-
import type { ClientContext } from '@deepseek-ai/
|
|
51
|
+
import type { Context as ClientContext } from '@deepseek-ai/cordis'
|
|
52
52
|
import { apply } from './index.ts'
|
|
53
53
|
|
|
54
54
|
beforeAll(() => {
|
package/src/client/index.ts
CHANGED
|
@@ -4,19 +4,26 @@
|
|
|
4
4
|
* endpoints: fetch the registry list once, poll the host snapshot (~2 s),
|
|
5
5
|
* forward interactions, persist drag positions. The pet is host-global (no
|
|
6
6
|
* session dimension), so it mounts directly onto 'document.body' via a
|
|
7
|
-
* single React root
|
|
8
|
-
* new-conversation screen no session exists, and
|
|
9
|
-
* vanish there (issue #48).
|
|
10
|
-
*
|
|
7
|
+
* single React root — the [data-dsh-plugin="pet"] container — rather than a
|
|
8
|
+
* session-scoped slot: on the new-conversation screen no session exists, and
|
|
9
|
+
* a dock-mounted pet would vanish there (issue #48). The sprite chrome
|
|
10
|
+
* portals into that same root, so the root owns the whole surface and a
|
|
11
|
+
* root-keyed suppressor can hide it as one unit. When the pet is hidden the
|
|
12
|
+
* entry becomes a fixed-position summon button.
|
|
11
13
|
* @module @linxin666/dsh-pet/client
|
|
12
14
|
*/
|
|
13
15
|
|
|
14
|
-
import type {
|
|
16
|
+
import type { Context as ClientContext } from '@deepseek-ai/cordis'
|
|
17
|
+
import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client'
|
|
18
|
+
import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
|
|
19
|
+
import type { SettingsScope, SettingsScopeSpec } from '@deepseek-ai/dsh-client-ui-settings/client'
|
|
15
20
|
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
|
|
16
21
|
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
|
17
22
|
// Type-only: pulls the settings-surface Context merge (ctx.settingsScope).
|
|
18
23
|
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
|
19
24
|
import type {} from '@deepseek-ai/dsh-client-ui-slots'
|
|
25
|
+
// Type-only: pulls the ctx.slots merge (the renderer owns the slot registry since 0.1.2).
|
|
26
|
+
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
|
|
20
27
|
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
|
21
28
|
import type { PetDisplayConfig } from '../persist.ts'
|
|
22
29
|
import type { PetGameplayVerbResult, PetInteractResult, PetStateView } from '../service.ts'
|
|
@@ -381,7 +388,11 @@ export function apply(ctx: ClientContext): void {
|
|
|
381
388
|
container.dataset.dshPlugin = 'pet'
|
|
382
389
|
document.body.appendChild(container)
|
|
383
390
|
const petRoot = createRoot(container)
|
|
384
|
-
|
|
391
|
+
// The sprite chrome portals into THIS root (not document.body): the
|
|
392
|
+
// root then owns the whole surface, so a root-keyed suppressor (the
|
|
393
|
+
// portrait mobile layer, which hides [data-dsh-plugin="pet"]) really
|
|
394
|
+
// hides the sprite instead of missing the portaled float.
|
|
395
|
+
petRoot.render(createElement(PetDockEntry, { ...injected(), t, portalTarget: container }))
|
|
385
396
|
|
|
386
397
|
let uiGone = false
|
|
387
398
|
disposeUi = () => {
|
package/src/client/locales.ts
CHANGED
|
@@ -76,7 +76,7 @@ export const zh = {
|
|
|
76
76
|
'settings.off': '关',
|
|
77
77
|
'settings.overridden': '已覆盖',
|
|
78
78
|
'settings.reset': '恢复默认',
|
|
79
|
-
'settings.notExposed': '当前 DSH 版本未向设置页暴露本插件的配置命名空间,表单不可用。可编辑
|
|
79
|
+
'settings.notExposed': '当前 DSH 版本未向设置页暴露本插件的配置命名空间,表单不可用。可编辑 $DSH_HOME/settings.yaml 直接配置,或确认提供该命名空间的插件已挂载其设置域并重启。',
|
|
80
80
|
'settings.readOnly': '当前部署的设置只读。',
|
|
81
81
|
'settings.expand': '展开设置',
|
|
82
82
|
'settings.collapse': '收起设置',
|
|
@@ -158,7 +158,7 @@ export const en = {
|
|
|
158
158
|
'settings.off': 'Off',
|
|
159
159
|
'settings.overridden': 'Overridden',
|
|
160
160
|
'settings.reset': 'Reset to default',
|
|
161
|
-
'settings.notExposed': 'This DSH version does not expose this plugin\'s settings namespace to the configuration page, so the form is unavailable. Edit
|
|
161
|
+
'settings.notExposed': 'This DSH version does not expose this plugin\'s settings namespace to the configuration page, so the form is unavailable. Edit $DSH_HOME/settings.yaml directly, or confirm that the plugin owning the namespace is mounted with its settings domain and restart.',
|
|
162
162
|
'settings.readOnly': 'This deployment stores settings read-only.',
|
|
163
163
|
'settings.expand': 'Show settings',
|
|
164
164
|
'settings.collapse': 'Hide settings',
|
package/src/client/pet-store.ts
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
* @module @linxin666/dsh-pet/client/pet-store
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { defineStore } from '@deepseek-ai/dsh-client-
|
|
10
|
-
import type { EngineStoreHandle, EngineStoreInstance } from '@deepseek-ai/dsh-client-
|
|
9
|
+
import { defineStore } from '@deepseek-ai/dsh-client-store'
|
|
10
|
+
import type { EngineStoreHandle, EngineStoreInstance } from '@deepseek-ai/dsh-client-store'
|
|
11
11
|
import type { PetGameplayStateView, PetStateView } from '../service.ts'
|
|
12
12
|
import type { PetInteraction } from '../affinity.ts'
|
|
13
13
|
import type { PetDefinition } from '../registry.ts'
|
|
@@ -604,3 +604,107 @@
|
|
|
604
604
|
}
|
|
605
605
|
}
|
|
606
606
|
|
|
607
|
+
|
|
608
|
+
/* 公告气泡(dsh-usage 联动):兄弟插件推送的结构化事实(余额 / 套餐用量)。
|
|
609
|
+
与状态气泡同一片蓝黑玻璃家族,但自有版式:标题 + 数值两端对齐,
|
|
610
|
+
套餐窗口带微型计量条,色调按 ok/warn/low 染描边与计量条。
|
|
611
|
+
自带长驻动画(进场后保持),不走 2.6s 反馈气泡的淡出轨道。 */
|
|
612
|
+
.bubbleUsage {
|
|
613
|
+
display: flex;
|
|
614
|
+
flex-direction: column;
|
|
615
|
+
align-items: stretch;
|
|
616
|
+
gap: 3px;
|
|
617
|
+
min-width: 128px;
|
|
618
|
+
max-width: min(280px, calc(100vw - 24px));
|
|
619
|
+
padding: 6px 12px;
|
|
620
|
+
white-space: nowrap;
|
|
621
|
+
background: linear-gradient(160deg, rgba(19, 28, 54, 0.92), rgba(7, 11, 26, 0.95));
|
|
622
|
+
border: 1px solid rgba(126, 152, 255, 0.45);
|
|
623
|
+
box-shadow:
|
|
624
|
+
0 4px 12px rgba(2, 6, 23, 0.35),
|
|
625
|
+
inset 0 1px 0 rgba(226, 232, 255, 0.1),
|
|
626
|
+
0 0 12px rgba(77, 107, 254, 0.18);
|
|
627
|
+
backdrop-filter: blur(8px);
|
|
628
|
+
letter-spacing: 0.02em;
|
|
629
|
+
animation: pet-usage-in 280ms cubic-bezier(0.22, 1, 0.36, 1);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
.bubbleUsageHead {
|
|
633
|
+
display: flex;
|
|
634
|
+
align-items: baseline;
|
|
635
|
+
justify-content: space-between;
|
|
636
|
+
gap: 10px;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
.bubbleUsageTitle {
|
|
640
|
+
overflow: hidden;
|
|
641
|
+
text-overflow: ellipsis;
|
|
642
|
+
opacity: 0.85;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
.bubbleUsageValue {
|
|
646
|
+
font-weight: 600;
|
|
647
|
+
font-variant-numeric: tabular-nums;
|
|
648
|
+
flex-shrink: 0;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
.bubbleUsageNote {
|
|
652
|
+
overflow: hidden;
|
|
653
|
+
text-overflow: ellipsis;
|
|
654
|
+
font-size: 10px;
|
|
655
|
+
opacity: 0.55;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
.bubbleUsageMeter {
|
|
659
|
+
display: block;
|
|
660
|
+
height: 4px;
|
|
661
|
+
border-radius: 999px;
|
|
662
|
+
background: rgba(226, 232, 255, 0.14);
|
|
663
|
+
overflow: hidden;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
.bubbleUsageMeterFill {
|
|
667
|
+
display: block;
|
|
668
|
+
height: 100%;
|
|
669
|
+
border-radius: 999px;
|
|
670
|
+
background: #6ee7b7;
|
|
671
|
+
transition: width 300ms ease;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/* 色调:ok 沿用品牌蓝描边;warn 琥珀、low 珊瑚红,染描边与计量条。 */
|
|
675
|
+
.bubbleUsageOk {
|
|
676
|
+
border-color: rgba(126, 152, 255, 0.45);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
.bubbleUsageWarn {
|
|
680
|
+
border-color: rgba(251, 191, 36, 0.55);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
.bubbleUsageWarn .bubbleUsageMeterFill {
|
|
684
|
+
background: #fbbf24;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
.bubbleUsageLow {
|
|
688
|
+
border-color: rgba(248, 113, 113, 0.6);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
.bubbleUsageLow .bubbleUsageMeterFill {
|
|
692
|
+
background: #f87171;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
@keyframes pet-usage-in {
|
|
696
|
+
from {
|
|
697
|
+
opacity: 0;
|
|
698
|
+
transform: translateY(6px) scale(0.96);
|
|
699
|
+
}
|
|
700
|
+
to {
|
|
701
|
+
opacity: 1;
|
|
702
|
+
transform: translateY(0) scale(1);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
@media (prefers-reduced-motion: reduce) {
|
|
707
|
+
.bubbleUsage {
|
|
708
|
+
animation: none;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
@@ -8,8 +8,9 @@
|
|
|
8
8
|
* card-store pattern.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import type { SettingsScope, SettingsScopeSnapshot
|
|
12
|
-
import {
|
|
11
|
+
import type { SettingsScope, SettingsScopeSnapshot } from '@deepseek-ai/dsh-client-ui-settings/client'
|
|
12
|
+
import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
|
|
13
|
+
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
|
13
14
|
|
|
14
15
|
/** The write one field's staged text performs when the card is saved. */
|
|
15
16
|
export type FieldWrite =
|
|
@@ -23,9 +24,11 @@ export interface FieldSpec {
|
|
|
23
24
|
/**
|
|
24
25
|
* Whether the Host treats this field as a secret and redacts its value from
|
|
25
26
|
* the read-back (role('secret') in the section schema). Redacted secrets are
|
|
26
|
-
* never compared against the draft on save
|
|
27
|
-
*
|
|
28
|
-
*
|
|
27
|
+
* never compared against the draft on save: the Host strips them from every
|
|
28
|
+
* wire view layer, so the settled snapshot carries nothing to read back. A
|
|
29
|
+
* staged secret set is judged by the mutation settling; the rest of its
|
|
30
|
+
* batch, when one exists, is still judged by read-back, and the atomic
|
|
31
|
+
* mutation lands every write or none.
|
|
29
32
|
*/
|
|
30
33
|
secret?: boolean
|
|
31
34
|
/** Render a stored value as draft text; the empty string when the section carries none. */
|
|
@@ -53,8 +56,8 @@ export interface CardShell {
|
|
|
53
56
|
available: boolean
|
|
54
57
|
/**
|
|
55
58
|
* Whether the namespace is actually served to this client. False when the
|
|
56
|
-
* Host deployment does not expose it (e.g. the
|
|
57
|
-
*
|
|
59
|
+
* Host deployment does not expose it (e.g. the owning plugin's settings
|
|
60
|
+
* domain is not mounted): the card renders an explanation
|
|
58
61
|
* instead of its form, so a missing namespace never looks like a missing
|
|
59
62
|
* plugin.
|
|
60
63
|
*/
|
|
@@ -101,13 +104,17 @@ interface StagedEdit {
|
|
|
101
104
|
interface PlannedWrite {
|
|
102
105
|
/** Field this entry writes. */
|
|
103
106
|
field: string
|
|
104
|
-
/** The durable write this entry performs,
|
|
107
|
+
/** The durable write this entry performs, inside the save's one atomic mutation. */
|
|
105
108
|
op: BatchedWrite
|
|
106
|
-
/**
|
|
107
|
-
|
|
109
|
+
/**
|
|
110
|
+
* Read the settled snapshot back and report whether the Host holds this
|
|
111
|
+
* write's effect. Undefined when the draft is not a value the field
|
|
112
|
+
* accepts: there is nothing to write, and the entry blocks the save.
|
|
113
|
+
*/
|
|
114
|
+
judge: (() => boolean) | undefined
|
|
108
115
|
}
|
|
109
116
|
|
|
110
|
-
/** One durable write
|
|
117
|
+
/** One durable write inside the save's atomic scope mutation. */
|
|
111
118
|
export interface BatchedWrite {
|
|
112
119
|
/** Field this entry writes. */
|
|
113
120
|
field: string
|
|
@@ -117,36 +124,6 @@ export interface BatchedWrite {
|
|
|
117
124
|
value?: unknown
|
|
118
125
|
}
|
|
119
126
|
|
|
120
|
-
/** Per-field outcome of one batched scope write. */
|
|
121
|
-
export interface BatchedFieldResult {
|
|
122
|
-
/** Field this entry writes. */
|
|
123
|
-
field: string
|
|
124
|
-
/** Whether the Host accepted this field's write (per the read-back view). */
|
|
125
|
-
landed: boolean
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/**
|
|
129
|
-
* Result of a batched scope write. The bridge scope posts every planned write
|
|
130
|
-
* in one /mutate so the Host validate hook judges baseURL+model together; a
|
|
131
|
-
* batched refusal fails the whole save rather than per-field.
|
|
132
|
-
*/
|
|
133
|
-
export interface BatchResult {
|
|
134
|
-
/** Whether the whole mutate was accepted. */
|
|
135
|
-
ok: boolean
|
|
136
|
-
/** Per-field success, in the request order (always present when ok). */
|
|
137
|
-
fields: BatchedFieldResult[]
|
|
138
|
-
/** Host rejection code (mutate refused). */
|
|
139
|
-
code?: string
|
|
140
|
-
/** Host rejection message (mutate refused). */
|
|
141
|
-
message?: string
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/** The optional batch surface the bridge scope adds over the SettingsScope contract. */
|
|
145
|
-
interface BatchedSettingsScope {
|
|
146
|
-
/** Write every operation in one scope mutation, reporting per-field success. */
|
|
147
|
-
mutate: (writes: BatchedWrite[]) => Promise<BatchResult>
|
|
148
|
-
}
|
|
149
|
-
|
|
150
127
|
/** Constraints a numeric field's accepted drafts must satisfy, mirroring the host schema. */
|
|
151
128
|
export interface NumberConstraints {
|
|
152
129
|
/** The accepted value must be a whole number. */
|
|
@@ -188,8 +165,8 @@ export function textField(field: string): FieldSpec {
|
|
|
188
165
|
/**
|
|
189
166
|
* A free-text field the Host treats as a secret and redacts from the read-back
|
|
190
167
|
* (role('secret') in the section schema). The card still edits it like text,
|
|
191
|
-
* but a save never compares the redacted value back
|
|
192
|
-
*
|
|
168
|
+
* but a save never compares the redacted value back: the staged set is judged
|
|
169
|
+
* by the mutation settling (see {@link FieldSpec.secret}).
|
|
193
170
|
*/
|
|
194
171
|
export function secretField(field: string): FieldSpec {
|
|
195
172
|
return { ...textField(field), secret: true }
|
|
@@ -277,7 +254,7 @@ export class CardForm<T> {
|
|
|
277
254
|
exposed: snapshot.status === 'ready',
|
|
278
255
|
writable: snapshot.writable,
|
|
279
256
|
dirty: plan.length > 0,
|
|
280
|
-
invalid: plan.some(item => item.
|
|
257
|
+
invalid: plan.some(item => item.judge === undefined),
|
|
281
258
|
saving: this.saving,
|
|
282
259
|
failed: this.failed,
|
|
283
260
|
...this.failedReason === undefined ? {} : { failedReason: this.failedReason },
|
|
@@ -318,21 +295,25 @@ export class CardForm<T> {
|
|
|
318
295
|
}
|
|
319
296
|
|
|
320
297
|
/**
|
|
321
|
-
* Write every staged edit, then re-seed from
|
|
298
|
+
* Write every staged edit in one atomic scope mutation, then re-seed from
|
|
299
|
+
* what the Host accepted.
|
|
322
300
|
*
|
|
323
|
-
*
|
|
324
|
-
*
|
|
325
|
-
*
|
|
326
|
-
*
|
|
327
|
-
*
|
|
328
|
-
*
|
|
329
|
-
*
|
|
301
|
+
* The whole batch rides one mutate, so cross-field validate hooks
|
|
302
|
+
* (baseURL+model) judge it as a unit: the Host either applies every write
|
|
303
|
+
* or refuses the batch. The 0.1.2 scope contract never rejects a refused
|
|
304
|
+
* mutation — the scope recovers with a fresh Host view and resolves — so
|
|
305
|
+
* resolution alone proves nothing: the outcome is judged by reading the
|
|
306
|
+
* settled snapshot back, one planned write at a time, and one missed write
|
|
307
|
+
* fails the whole save. A scope that still rejects on refusal (the dsh-web
|
|
308
|
+
* bridge scope) reports through the same failure path with its rejection
|
|
309
|
+
* message. A save that did not land keeps its drafts, so the user can
|
|
310
|
+
* correct them instead of retyping.
|
|
311
|
+
* @returns settlement after the mutation and the read-back.
|
|
330
312
|
*/
|
|
331
313
|
async save(): Promise<void> {
|
|
332
314
|
const plan = this.plan()
|
|
333
|
-
const valid = plan.filter(item => item.
|
|
315
|
+
const valid = plan.filter((item): item is PlannedWrite & { judge: () => boolean } => item.judge !== undefined)
|
|
334
316
|
if (plan.length === 0 || this.saving || valid.length !== plan.length) return
|
|
335
|
-
const plannedWrites = valid.map(item => item.op)
|
|
336
317
|
// Snapshot the staged entries this save writes, so an edit staged while it
|
|
337
318
|
// is in flight (which replaces the same key) survives: only delete the key
|
|
338
319
|
// when the entry is still the one this save started from.
|
|
@@ -342,36 +323,34 @@ export class CardForm<T> {
|
|
|
342
323
|
this.failed = false
|
|
343
324
|
this.failedReason = undefined
|
|
344
325
|
this.publish()
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
} else {
|
|
357
|
-
for (const item of valid) {
|
|
358
|
-
if (await item.run!()) landed.add(item.field)
|
|
359
|
-
}
|
|
326
|
+
// One atomic namespace mutation: the 0.1.2 scope contract takes ordered
|
|
327
|
+
// path operations, so the whole staged batch is validated, persisted, and
|
|
328
|
+
// recovered together — either every write lands or none does.
|
|
329
|
+
const ops: Array<{ op: 'set'; path: string[]; value: string | number | boolean } | { op: 'unset'; path: string[] }> = valid.map(item => item.op.op === 'set'
|
|
330
|
+
? { op: 'set', path: [item.field], value: (item.op as { value: string | number | boolean }).value }
|
|
331
|
+
: { op: 'unset', path: [item.field] })
|
|
332
|
+
let failedReason: string | undefined
|
|
333
|
+
try {
|
|
334
|
+
await this.scope.mutate(ops)
|
|
335
|
+
} catch (error) {
|
|
336
|
+
failedReason = error instanceof Error ? error.message : String(error)
|
|
360
337
|
}
|
|
338
|
+
// The 0.1.2 scope resolves even a refused mutation (it recovers with a
|
|
339
|
+
// fresh view instead of throwing), so resolution alone proves nothing:
|
|
340
|
+
// judge every planned write against the settled snapshot. The mutation is
|
|
341
|
+
// atomic, so one missed write fails the whole save and keeps the drafts.
|
|
342
|
+
const landed = failedReason === undefined && valid.every(item => item.judge())
|
|
361
343
|
for (const [field, before] of pending) {
|
|
362
|
-
if (landed
|
|
344
|
+
if (landed && this.staged.get(field) === before) this.staged.delete(field)
|
|
363
345
|
}
|
|
364
346
|
this.saving = false
|
|
365
|
-
this.failed = landed
|
|
347
|
+
this.failed = !landed
|
|
348
|
+
// A read-back failure carries no server reason: the card surfaces its
|
|
349
|
+
// generic failure copy; a rejecting scope (the bridge) adds its message.
|
|
350
|
+
this.failedReason = failedReason
|
|
366
351
|
this.publish()
|
|
367
352
|
}
|
|
368
353
|
|
|
369
|
-
/** The scope's batch surface when it supports one; undefined conservatively otherwise. */
|
|
370
|
-
private batchedScope(): BatchedSettingsScope | undefined {
|
|
371
|
-
const candidate = this.scope as unknown as BatchedSettingsScope | undefined
|
|
372
|
-
return typeof candidate?.mutate === 'function' ? candidate : undefined
|
|
373
|
-
}
|
|
374
|
-
|
|
375
354
|
/**
|
|
376
355
|
* Every staged edit a save would write. An entry whose draft is not a value
|
|
377
356
|
* its field accepts carries no write: the form is still dirty, and the save
|
|
@@ -384,33 +363,40 @@ export class CardForm<T> {
|
|
|
384
363
|
for (const [field, staged] of this.staged) {
|
|
385
364
|
const spec = this.specOf(field)
|
|
386
365
|
if (staged.clear) {
|
|
387
|
-
if (this.stored(field)) plan.push({ field, op: { field, op: 'unset' },
|
|
366
|
+
if (this.stored(field)) plan.push({ field, op: { field, op: 'unset' }, judge: () => this.landedUnset(field) })
|
|
388
367
|
continue
|
|
389
368
|
}
|
|
390
369
|
if (staged.text === spec.format(this.sectionValue(field))) continue
|
|
391
370
|
const write = spec.parse(staged.text)
|
|
392
|
-
if (write === undefined) plan.push({ field, op: { field, op: 'unset' },
|
|
393
|
-
else if (write.kind === 'clear') plan.push({ field, op: { field, op: 'unset' },
|
|
394
|
-
else plan.push({ field, op: { field, op: 'set', value: write.value },
|
|
371
|
+
if (write === undefined) plan.push({ field, op: { field, op: 'unset' }, judge: undefined })
|
|
372
|
+
else if (write.kind === 'clear') plan.push({ field, op: { field, op: 'unset' }, judge: () => this.landedUnset(field) })
|
|
373
|
+
else plan.push({ field, op: { field, op: 'set', value: write.value }, judge: () => this.landedSet(field, write.value) })
|
|
395
374
|
}
|
|
396
375
|
return plan
|
|
397
376
|
}
|
|
398
377
|
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
//
|
|
407
|
-
//
|
|
408
|
-
//
|
|
409
|
-
// the per-field path the scope resolved, so the write is landed.
|
|
378
|
+
/**
|
|
379
|
+
* Read-back judgment for a planned set: the user layer must hold the
|
|
380
|
+
* intended value once the mutation has settled.
|
|
381
|
+
*/
|
|
382
|
+
private landedSet(field: string, value: unknown): boolean {
|
|
383
|
+
// A redacted secret never appears in any wire view layer: the Host strips
|
|
384
|
+
// role('secret') fields and reports them through a sidecar the scope
|
|
385
|
+
// snapshot does not expose, so there is nothing to compare the draft
|
|
386
|
+
// against. Settling is the only signal the form has; the rest of the
|
|
387
|
+
// batch, when one exists, still carries the atomic verdict by read-back.
|
|
410
388
|
if (this.specOf(field).secret) return true
|
|
411
389
|
return this.userLayer()?.[field] === value
|
|
412
390
|
}
|
|
413
391
|
|
|
392
|
+
/**
|
|
393
|
+
* Read-back judgment for a planned unset: the field must be gone from the
|
|
394
|
+
* user layer once the mutation has settled.
|
|
395
|
+
*/
|
|
396
|
+
private landedUnset(field: string): boolean {
|
|
397
|
+
return !this.stored(field)
|
|
398
|
+
}
|
|
399
|
+
|
|
414
400
|
private stage(field: string, edit: StagedEdit): void {
|
|
415
401
|
this.staged.set(field, edit)
|
|
416
402
|
this.failed = false
|
package/src/dsh-home.ts
CHANGED
package/src/index.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { Context } from '@deepseek-ai/cordis'
|
|
15
|
-
import {
|
|
15
|
+
import type { SettingsNamespace } from '@deepseek-ai/dsh-settings'
|
|
16
16
|
import type {} from '@deepseek-ai/dsh-host-webserver'
|
|
17
17
|
import z from 'schemastery'
|
|
18
18
|
import { PetService, PET_SETTINGS_NAMESPACE, type PetConfig, type PetSettingsSection } from './service.ts'
|
|
@@ -189,20 +189,22 @@ function applyImpl(ctx: Context, config: PetConfig = {}): void {
|
|
|
189
189
|
disposeRoutes = undefined
|
|
190
190
|
}
|
|
191
191
|
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
192
|
+
ctx.inject(['settings'], (settingsCtx) => {
|
|
193
|
+
settingsCtx.settings.installSection(
|
|
194
|
+
ctx,
|
|
195
|
+
PET_SETTINGS_NAMESPACE as SettingsNamespace,
|
|
196
|
+
makePetSettingsSchema(service.selectedPetId()),
|
|
197
|
+
base,
|
|
198
|
+
{
|
|
199
|
+
setSource: (source) => { current = source },
|
|
200
|
+
onChange: () => {
|
|
201
|
+
const section = current()
|
|
202
|
+
service.applySettingsSection(section)
|
|
203
|
+
service.setEnabled(section.enabled ?? true)
|
|
204
|
+
syncRoutes()
|
|
205
|
+
},
|
|
204
206
|
},
|
|
205
|
-
|
|
206
|
-
)
|
|
207
|
+
)
|
|
208
|
+
})
|
|
207
209
|
syncRoutes()
|
|
208
210
|
}
|