@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.
Files changed (52) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +4 -0
  3. package/README.zh.md +4 -0
  4. package/lib/client.js +187 -88
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +89 -11
  7. package/lib/live2d-vendor.js +106 -95
  8. package/lib/live2d-vendor.js.map +1 -1
  9. package/lib/types/announce.d.ts +53 -0
  10. package/lib/types/announce.d.ts.map +1 -0
  11. package/lib/types/announce.js +80 -0
  12. package/lib/types/client/PetDockEntry.d.ts +14 -5
  13. package/lib/types/client/PetDockEntry.d.ts.map +1 -1
  14. package/lib/types/client/PetDockEntry.js +6 -5
  15. package/lib/types/client/PetSettingsCard.d.ts +2 -1
  16. package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
  17. package/lib/types/client/PetSprite.d.ts +8 -0
  18. package/lib/types/client/PetSprite.d.ts.map +1 -1
  19. package/lib/types/client/PetSprite.js +29 -4
  20. package/lib/types/client/index.d.ts +8 -5
  21. package/lib/types/client/index.d.ts.map +1 -1
  22. package/lib/types/client/index.js +11 -5
  23. package/lib/types/client/locales.d.ts +2 -2
  24. package/lib/types/client/locales.js +2 -2
  25. package/lib/types/client/pet-store.d.ts +1 -1
  26. package/lib/types/client/pet-store.d.ts.map +1 -1
  27. package/lib/types/client/pet-store.js +1 -1
  28. package/lib/types/client/settings-form.d.ts +35 -43
  29. package/lib/types/client/settings-form.d.ts.map +1 -1
  30. package/lib/types/client/settings-form.js +60 -53
  31. package/lib/types/index.d.ts.map +1 -1
  32. package/lib/types/index.js +10 -9
  33. package/lib/types/service.d.ts +18 -0
  34. package/lib/types/service.d.ts.map +1 -1
  35. package/lib/types/service.js +22 -0
  36. package/package.json +17 -13
  37. package/src/announce.ts +103 -0
  38. package/src/client/PetDockEntry.test.tsx +1 -1
  39. package/src/client/PetDockEntry.tsx +15 -4
  40. package/src/client/PetSettingsCard.tsx +2 -1
  41. package/src/client/PetSprite.test.tsx +18 -0
  42. package/src/client/PetSprite.tsx +66 -3
  43. package/src/client/gameplay-hud.test.tsx +1 -1
  44. package/src/client/index.test.tsx +2 -2
  45. package/src/client/index.ts +17 -6
  46. package/src/client/locales.ts +2 -2
  47. package/src/client/pet-store.ts +2 -2
  48. package/src/client/pet.module.css +104 -0
  49. package/src/client/settings-form.ts +78 -92
  50. package/src/dsh-home.ts +1 -1
  51. package/src/index.ts +17 -15
  52. package/src/service.ts +28 -1
@@ -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-runtime/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-runtime/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/dsh-client-runtime/client'
51
+ import type { Context as ClientContext } from '@deepseek-ai/cordis'
52
52
  import { apply } from './index.ts'
53
53
 
54
54
  beforeAll(() => {
@@ -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 rather than a session-scoped sloton 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
10
- * fixed-position summon button.
7
+ * single React root the [data-dsh-plugin="pet"] containerrather 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 { ClientContext, ISessions, SessionId, SettingsScope, SettingsScopeSpec } from '@deepseek-ai/dsh-client-runtime/client'
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
- petRoot.render(createElement(PetDockEntry, { ...injected(), t }))
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 = () => {
@@ -76,7 +76,7 @@ export const zh = {
76
76
  'settings.off': '关',
77
77
  'settings.overridden': '已覆盖',
78
78
  'settings.reset': '恢复默认',
79
- 'settings.notExposed': '当前 DSH 版本未向设置页暴露本插件的配置命名空间,表单不可用。可编辑 ~/.dsh/settings.yaml 直接配置,或为 dsh-host-apiproxy 的 WEB_SETTINGS_NAMESPACES 白名单补充本命名空间后重启。',
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 ~/.dsh/settings.yaml directly, or add the namespace to dsh-host-apiproxy\'s WEB_SETTINGS_NAMESPACES allowlist and restart.',
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',
@@ -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-runtime/client'
10
- import type { EngineStoreHandle, EngineStoreInstance } from '@deepseek-ai/dsh-client-runtime/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, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
12
- import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
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; the field lands when the scope
27
- * reports the write succeeded (its secret-set marker under the bridge), so
28
- * a successful secret save is not misreported as failed.
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 official apiproxy settings
57
- * allowlist omits third-party namespaces): the card renders an explanation
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, described for a batched scope. */
107
+ /** The durable write this entry performs, inside the save's one atomic mutation. */
105
108
  op: BatchedWrite
106
- /** Perform the write and report whether the Host holds the staged value afterwards. */
107
- run: (() => Promise<boolean>) | undefined
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 a batched settings scope performs. */
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 and relies on the scope
192
- * reporting the write landed.
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.run === undefined),
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 what the Host accepted.
298
+ * Write every staged edit in one atomic scope mutation, then re-seed from
299
+ * what the Host accepted.
322
300
  *
323
- * When the scope carries the optional batch surface (the dsh-web
324
- * bridge scope), every planned write rides one mutation so cross-field
325
- * validate hooks (baseURL+model) judge the batch as a unit instead of
326
- * deadlocking on per-field writes. Otherwise the per-field loop runs.
327
- * A field lands only when the Host reports it held the staged value; a
328
- * landed field's draft is dropped, a failed one stays staged for the user.
329
- * @returns settlement after every write and the read-back.
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.run !== undefined)
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
- const landed = new Set<string>()
346
- const batch = this.batchedScope()
347
- if (batch !== undefined) {
348
- const result = await batch.mutate(plannedWrites)
349
- if (result.ok) {
350
- for (const field of result.fields) {
351
- if (field.landed) landed.add(field.field)
352
- }
353
- } else {
354
- this.failedReason = result.message
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.has(field) && this.staged.get(field) === before) this.staged.delete(field)
344
+ if (landed && this.staged.get(field) === before) this.staged.delete(field)
363
345
  }
364
346
  this.saving = false
365
- this.failed = landed.size !== pending.size
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' }, run: () => this.clear(field) })
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' }, run: undefined })
393
- else if (write.kind === 'clear') plan.push({ field, op: { field, op: 'unset' }, run: () => this.clear(field) })
394
- else plan.push({ field, op: { field, op: 'set', value: write.value }, run: () => this.store(field, 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
- private async clear(field: string): Promise<boolean> {
400
- await this.scope.unset(field)
401
- return !this.stored(field)
402
- }
403
-
404
- private async store(field: string, value: unknown): Promise<boolean> {
405
- await this.scope.set(field, value)
406
- // A redacted secret never appears in the user layer read-back; judging it
407
- // by value would misreport a successful secret save as failed. The bridge
408
- // reports secret writes through its secret-set markers (batch path); on
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
@@ -39,4 +39,4 @@ export function resolveDshHome(env: NodeJS.ProcessEnv = process.env, home: strin
39
39
  /** Resolve the DSH home directory from the live environment. */
40
40
  export function dshHome(): string {
41
41
  return resolveDshHome()
42
- }
42
+ }
package/src/index.ts CHANGED
@@ -12,7 +12,7 @@
12
12
  */
13
13
 
14
14
  import { Context } from '@deepseek-ai/cordis'
15
- import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
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
- installSettingsSection(
193
- ctx,
194
- settingsNamespace(PET_SETTINGS_NAMESPACE),
195
- makePetSettingsSchema(service.selectedPetId()),
196
- base,
197
- {
198
- setSource: (source) => { current = source },
199
- onChange: () => {
200
- const section = current()
201
- service.applySettingsSection(section)
202
- service.setEnabled(section.enabled ?? true)
203
- syncRoutes()
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
  }