@linxin666/dsh-pet 0.1.13 → 0.1.15
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 +11 -7
- package/README.zh.md +11 -7
- package/lib/client.js +38 -17
- package/lib/client.js.map +1 -1
- package/lib/index.js +393 -150
- package/lib/invariant.js +1 -1
- package/lib/{state-C9EyycwI.js → state-CFyJv0sQ.js} +22 -7
- package/lib/types/affinity.d.ts +15 -0
- package/lib/types/affinity.d.ts.map +1 -1
- package/lib/types/affinity.js +14 -0
- package/lib/types/client/PluginSettingsCard.d.ts +26 -11
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PluginSettingsCard.js +27 -7
- package/lib/types/client/WhalePet.d.ts.map +1 -1
- package/lib/types/client/WhalePet.js +2 -1
- package/lib/types/client/index.d.ts +1 -1
- package/lib/types/client/index.js +3 -3
- package/lib/types/client/settings-form.d.ts +14 -5
- package/lib/types/client/settings-form.d.ts.map +1 -1
- package/lib/types/client/settings-form.js +38 -10
- package/lib/types/dsh-home.d.ts +17 -0
- package/lib/types/dsh-home.d.ts.map +1 -0
- package/lib/types/dsh-home.js +34 -0
- package/lib/types/event-projection.d.ts +36 -0
- package/lib/types/event-projection.d.ts.map +1 -0
- package/lib/types/event-projection.js +98 -0
- package/lib/types/ledger.d.ts +77 -0
- package/lib/types/ledger.d.ts.map +1 -0
- package/lib/types/ledger.js +139 -0
- package/lib/types/persist.d.ts +5 -1
- package/lib/types/persist.d.ts.map +1 -1
- package/lib/types/persist.js +7 -3
- package/lib/types/service.d.ts +24 -44
- package/lib/types/service.d.ts.map +1 -1
- package/lib/types/service.js +98 -131
- package/lib/types/state.d.ts +10 -12
- package/lib/types/state.d.ts.map +1 -1
- package/lib/types/state.js +14 -10
- package/package.json +1 -1
- package/src/affinity.ts +33 -0
- package/src/client/PluginSettingsCard.tsx +83 -17
- package/src/client/WhalePet.test.tsx +25 -1
- package/src/client/WhalePet.tsx +6 -0
- package/src/client/index.ts +3 -3
- package/src/client/pet.module.css +9 -0
- package/src/client/settings-card.module.css +6 -0
- package/src/client/settings-form.ts +41 -9
- package/src/dsh-home.test.ts +35 -0
- package/src/dsh-home.ts +36 -0
- package/src/event-projection.ts +128 -0
- package/src/ledger.test.ts +67 -0
- package/src/ledger.ts +183 -0
- package/src/persist.ts +7 -3
- package/src/service.ts +110 -171
- package/src/state.test.ts +4 -1
- package/src/state.ts +17 -13
|
@@ -1,25 +1,44 @@
|
|
|
1
|
+
// Generated by scripts/sync-shared.mjs from shared/client/settings/PluginSettingsCard.tsx. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
|
|
1
2
|
/**
|
|
2
|
-
*
|
|
3
|
-
* plugin and what its settings govern, the controls inside, and the save
|
|
4
|
-
* writes them. Renders nothing while the namespace is unavailable — a
|
|
3
|
+
* Family-shared chrome for plugin settings cards: a disclosure header naming
|
|
4
|
+
* the plugin and what its settings govern, the controls inside, and the save
|
|
5
|
+
* that writes them. Renders nothing while the namespace is unavailable — a
|
|
5
6
|
* deployment that does not compose the owning plugin should show no trace of
|
|
6
|
-
* it.
|
|
7
|
-
*
|
|
7
|
+
* it. Inlined into each consumer's client bundle; mirrors the official
|
|
8
|
+
* ui-plugin-config PluginCard in a self-contained slice.
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
11
|
import { useState, type ReactNode } from 'react'
|
|
11
12
|
import type { CardShell } from './settings-form.ts'
|
|
12
|
-
import type { SettingsCardKey } from './locales.ts'
|
|
13
13
|
import css from './settings-card.module.css'
|
|
14
14
|
|
|
15
|
+
/** Copy keys the card chrome itself reads; every consumer locale carries this shared vocabulary. */
|
|
16
|
+
export const CARD_COPY_KEYS = [
|
|
17
|
+
'settings.collapse',
|
|
18
|
+
'settings.expand',
|
|
19
|
+
'settings.notExposed',
|
|
20
|
+
'settings.unsaved',
|
|
21
|
+
'settings.readOnly',
|
|
22
|
+
'settings.saveFailed',
|
|
23
|
+
'settings.discard',
|
|
24
|
+
'settings.save',
|
|
25
|
+
'settings.saving',
|
|
26
|
+
] as const
|
|
27
|
+
|
|
28
|
+
/** Copy key the card chrome itself reads. */
|
|
29
|
+
export type CardCopyKey = (typeof CARD_COPY_KEYS)[number]
|
|
30
|
+
|
|
31
|
+
/** Key domain for the plugin's own copy (inferred per consumer). */
|
|
32
|
+
export type SettingsCardKey<TKey extends string = string> = TKey | CardCopyKey
|
|
33
|
+
|
|
15
34
|
/** Card chrome shared by every plugin settings card. */
|
|
16
|
-
export interface PluginSettingsCardProps {
|
|
35
|
+
export interface PluginSettingsCardProps<TKey extends string = string> {
|
|
17
36
|
/** Locale reader for this card's copy. */
|
|
18
|
-
t: (key: SettingsCardKey) => string
|
|
37
|
+
t: (key: SettingsCardKey<TKey>, params?: Record<string, string | number>) => string
|
|
19
38
|
/** Locale key of the plugin's name. */
|
|
20
|
-
titleKey:
|
|
39
|
+
titleKey: TKey
|
|
21
40
|
/** Locale key of the line describing what this plugin's settings govern. */
|
|
22
|
-
descriptionKey:
|
|
41
|
+
descriptionKey: TKey
|
|
23
42
|
/** The card's form state: availability, writability, and what a save would do. */
|
|
24
43
|
state: CardShell
|
|
25
44
|
/** Write every staged edit. */
|
|
@@ -35,11 +54,12 @@ export interface PluginSettingsCardProps {
|
|
|
35
54
|
* @param props - the plugin's copy keys, its form state, and its controls.
|
|
36
55
|
* @returns the card, or nothing while the namespace is still loading.
|
|
37
56
|
*/
|
|
38
|
-
export function PluginSettingsCard(props: PluginSettingsCardProps) {
|
|
57
|
+
export function PluginSettingsCard<TKey extends string = string>(props: PluginSettingsCardProps<TKey>) {
|
|
39
58
|
const [open, setOpen] = useState(false)
|
|
40
59
|
const { state } = props
|
|
41
60
|
if (!state.available) return null
|
|
42
61
|
const title = props.t(props.titleKey)
|
|
62
|
+
const description = props.t(props.descriptionKey)
|
|
43
63
|
const blocked = !state.dirty || state.invalid || state.saving
|
|
44
64
|
const cardClass = open ? `${css.cardOpen} ${css.card}` : css.card
|
|
45
65
|
// The namespace exists but the Host does not serve it to this client (the
|
|
@@ -57,8 +77,8 @@ export function PluginSettingsCard(props: PluginSettingsCardProps) {
|
|
|
57
77
|
onClick={() => { setOpen(!open) }}
|
|
58
78
|
>
|
|
59
79
|
<span className={css.headText}>
|
|
60
|
-
<span className={css.name}>{title}</span>
|
|
61
|
-
<span className={css.description}>{
|
|
80
|
+
<span className={css.name} title={title}>{title}</span>
|
|
81
|
+
<span className={css.description} title={description}>{description}</span>
|
|
62
82
|
</span>
|
|
63
83
|
<svg
|
|
64
84
|
width="14"
|
|
@@ -94,10 +114,10 @@ export function PluginSettingsCard(props: PluginSettingsCardProps) {
|
|
|
94
114
|
onClick={() => { setOpen(!open) }}
|
|
95
115
|
>
|
|
96
116
|
<span className={css.headText}>
|
|
97
|
-
<span className={css.name}>{title}</span>
|
|
98
|
-
<span className={css.description}>{
|
|
117
|
+
<span className={css.name} title={title}>{title}</span>
|
|
118
|
+
<span className={css.description} title={description}>{description}</span>
|
|
99
119
|
</span>
|
|
100
|
-
{state.dirty ? <span className={css.pending}>{props.t('settings.unsaved')}</span> : null}
|
|
120
|
+
{state.dirty ? <span className={css.pending} title={props.t('settings.unsaved')}>{props.t('settings.unsaved')}</span> : null}
|
|
101
121
|
<svg
|
|
102
122
|
width="14"
|
|
103
123
|
height="14"
|
|
@@ -259,4 +279,50 @@ export function BooleanField(props: FieldProps & {
|
|
|
259
279
|
<p className={css.hint}>{props.hint}</p>
|
|
260
280
|
</div>
|
|
261
281
|
)
|
|
262
|
-
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** A staged enumerated field rendered as a select. */
|
|
285
|
+
export function ChoiceField(props: FieldProps & {
|
|
286
|
+
/** Copy for the inherit option (draft text is the empty string). */
|
|
287
|
+
inheritLabel: string
|
|
288
|
+
/** Choices rendered in order; `value` is the draft/stored text. */
|
|
289
|
+
choices: ReadonlyArray<{ value: string; label: string }>
|
|
290
|
+
}) {
|
|
291
|
+
return (
|
|
292
|
+
<div className={css.field}>
|
|
293
|
+
<div className={css.head}>
|
|
294
|
+
<label className={css.label} htmlFor={props.id}>{props.label}</label>
|
|
295
|
+
{props.overridden
|
|
296
|
+
? (
|
|
297
|
+
<span className={css.badges}>
|
|
298
|
+
<span className={css.badge}>{props.overriddenLabel}</span>
|
|
299
|
+
<button
|
|
300
|
+
type="button"
|
|
301
|
+
className={css.reset}
|
|
302
|
+
disabled={props.disabled}
|
|
303
|
+
onClick={props.onReset}
|
|
304
|
+
>
|
|
305
|
+
{props.resetLabel}
|
|
306
|
+
</button>
|
|
307
|
+
</span>
|
|
308
|
+
)
|
|
309
|
+
: null}
|
|
310
|
+
</div>
|
|
311
|
+
<select
|
|
312
|
+
id={props.id}
|
|
313
|
+
className={css.select}
|
|
314
|
+
value={props.text}
|
|
315
|
+
disabled={props.disabled}
|
|
316
|
+
onChange={(event) => { props.onEdit(event.target.value) }}
|
|
317
|
+
>
|
|
318
|
+
<option value="">{props.inheritLabel}</option>
|
|
319
|
+
{props.choices.map(choice => (
|
|
320
|
+
<option key={choice.value} value={choice.value}>{choice.label}</option>
|
|
321
|
+
))}
|
|
322
|
+
</select>
|
|
323
|
+
<p className={props.invalid ? css.invalid : css.hint}>
|
|
324
|
+
{props.invalid ? props.invalidLabel : props.hint}
|
|
325
|
+
</p>
|
|
326
|
+
</div>
|
|
327
|
+
)
|
|
328
|
+
}
|
|
@@ -55,7 +55,7 @@ afterEach(() => {
|
|
|
55
55
|
})
|
|
56
56
|
|
|
57
57
|
/** Render the pet with mocked callbacks; returns the rename spy. */
|
|
58
|
-
function renderPet(): { onRename: ReturnType<typeof vi.fn> } {
|
|
58
|
+
function renderPet(overrides: Partial<WhalePetProps> = {}): { onRename: ReturnType<typeof vi.fn> } {
|
|
59
59
|
const onRename = vi.fn()
|
|
60
60
|
const props: WhalePetProps = {
|
|
61
61
|
snapshot,
|
|
@@ -68,6 +68,7 @@ function renderPet(): { onRename: ReturnType<typeof vi.fn> } {
|
|
|
68
68
|
onRename,
|
|
69
69
|
onFeedbackDone: vi.fn(),
|
|
70
70
|
t,
|
|
71
|
+
...overrides,
|
|
71
72
|
}
|
|
72
73
|
render(<WhalePet {...props} />)
|
|
73
74
|
return { onRename }
|
|
@@ -136,3 +137,26 @@ describe('WhalePet rename input', () => {
|
|
|
136
137
|
expect(screen.queryByPlaceholderText('输入新名字')).toBeNull()
|
|
137
138
|
})
|
|
138
139
|
})
|
|
140
|
+
|
|
141
|
+
describe('WhalePet status bubble', () => {
|
|
142
|
+
const workingSnapshot: PetStateView = {
|
|
143
|
+
...snapshot,
|
|
144
|
+
animation: 'running',
|
|
145
|
+
phase: 'thinking',
|
|
146
|
+
bubble: '正在思考',
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
it('renders host activity when no interaction feedback is active', () => {
|
|
150
|
+
renderPet({ snapshot: workingSnapshot })
|
|
151
|
+
expect(screen.queryByText('正在思考')).not.toBeNull()
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('lets transient interaction feedback replace host activity', () => {
|
|
155
|
+
renderPet({
|
|
156
|
+
snapshot: workingSnapshot,
|
|
157
|
+
feedback: { text: '摸摸成功', kind: 'pet', at: 1 },
|
|
158
|
+
})
|
|
159
|
+
expect(screen.queryByText('摸摸成功')).not.toBeNull()
|
|
160
|
+
expect(screen.queryByText('正在思考')).toBeNull()
|
|
161
|
+
})
|
|
162
|
+
})
|
package/src/client/WhalePet.tsx
CHANGED
|
@@ -228,6 +228,7 @@ export function WhalePet(props: WhalePetProps): ReactPortal {
|
|
|
228
228
|
const pos = dragPos ?? { right: display.right, bottom: display.bottom }
|
|
229
229
|
const spriteWidth = Math.round(FRAME_WIDTH * spriteScale)
|
|
230
230
|
const spriteHeight = Math.round(FRAME_HEIGHT * spriteScale)
|
|
231
|
+
const statusBubble = feedback === null && !hovered ? snapshot?.bubble : undefined
|
|
231
232
|
|
|
232
233
|
const float = (
|
|
233
234
|
<div
|
|
@@ -281,6 +282,11 @@ export function WhalePet(props: WhalePetProps): ReactPortal {
|
|
|
281
282
|
{feedback.text}
|
|
282
283
|
</div>
|
|
283
284
|
)}
|
|
285
|
+
{statusBubble !== undefined && (
|
|
286
|
+
<div className={`${styles.bubble} ${styles.bubbleStatus}`} role="status" aria-live="polite">
|
|
287
|
+
{statusBubble}
|
|
288
|
+
</div>
|
|
289
|
+
)}
|
|
284
290
|
{hovered && dragRef.current === null && (
|
|
285
291
|
<div
|
|
286
292
|
className={styles.panel}
|
package/src/client/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* dsh-pet browser half — mounts the whale-girl as a global floating surface
|
|
3
3
|
* and drives it from the host's same-origin `/api/pet/*` JSON endpoints: poll
|
|
4
|
-
* the host snapshot (~
|
|
4
|
+
* the host snapshot (~2 s), forward interactions, persist drag positions.
|
|
5
5
|
* The pet is host-global (no session dimension), so it mounts directly onto
|
|
6
6
|
* `document.body` via a single React root rather than a session-scoped slot —
|
|
7
7
|
* on the new-conversation screen no session exists, and a dock-mounted pet
|
|
@@ -61,7 +61,7 @@ const petApi: PetHttpApi = {
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
/** Poll interval for the host snapshot. */
|
|
64
|
-
const POLL_MS =
|
|
64
|
+
const POLL_MS = 2000
|
|
65
65
|
|
|
66
66
|
/** Settings namespace the pet settings card edits (the Host plugin registers it). */
|
|
67
67
|
const PET_SETTINGS_NS = 'pet'
|
|
@@ -161,7 +161,7 @@ export function apply(ctx: ClientContext): void {
|
|
|
161
161
|
// change while the page is hidden, so a background interval would
|
|
162
162
|
// only burn RPCs (browser throttling is an unreliable backstop).
|
|
163
163
|
// Coming back to the tab refreshes the pet immediately instead of
|
|
164
|
-
// waiting out the next
|
|
164
|
+
// waiting out the next 2 s cycle.
|
|
165
165
|
let timer: number | undefined
|
|
166
166
|
const stop = (): void => {
|
|
167
167
|
if (timer !== undefined) {
|
|
@@ -37,6 +37,15 @@
|
|
|
37
37
|
background: rgba(56, 189, 248, 0.92);
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
.bubbleStatus {
|
|
41
|
+
max-width: min(280px, calc(100vw - 24px));
|
|
42
|
+
overflow: hidden;
|
|
43
|
+
text-overflow: ellipsis;
|
|
44
|
+
background: rgba(15, 23, 42, 0.9);
|
|
45
|
+
border: 1px solid rgba(125, 211, 252, 0.5);
|
|
46
|
+
animation: none;
|
|
47
|
+
}
|
|
48
|
+
|
|
40
49
|
@keyframes pet-bubble-pop {
|
|
41
50
|
0% {
|
|
42
51
|
opacity: 0;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
/* Generated by scripts/sync-shared.mjs from shared/client/settings/settings-card.module.css. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs". */
|
|
1
2
|
/* Plugin settings card chrome + staged form fields.
|
|
2
3
|
* Aligned with the official ui-settings-plugins PluginCard / fields CSS:
|
|
3
4
|
* same semantic tokens, radius, typography and states so family cards read
|
|
@@ -229,6 +230,11 @@
|
|
|
229
230
|
outline-offset: 2px;
|
|
230
231
|
}
|
|
231
232
|
|
|
233
|
+
.reset:focus-visible {
|
|
234
|
+
outline: 2px solid var(--dsw-alias-brand-primary);
|
|
235
|
+
outline-offset: 2px;
|
|
236
|
+
}
|
|
237
|
+
|
|
232
238
|
.input,
|
|
233
239
|
.select {
|
|
234
240
|
border: 1px solid var(--dsw-alias-border-l2);
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
+
// Generated by scripts/sync-shared.mjs from shared/client/settings/settings-form.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
|
|
1
2
|
/**
|
|
2
3
|
* Staged form model behind the plugin settings card. A card stages what the
|
|
3
4
|
* user types and writes it only when they save — the settings write is a
|
|
4
5
|
* durable, revision-fenced document mutation, so staging keeps what is on
|
|
5
|
-
* screen exactly what a save would store.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
6
|
+
* screen exactly what a save would store. Family-shared slice inlined into
|
|
7
|
+
* each plugin's client bundle; mirrors the official ui-plugin-config
|
|
8
|
+
* card-store pattern.
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
11
|
import type { SettingsScope, SettingsScopeSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
|
@@ -90,8 +91,17 @@ interface PlannedWrite {
|
|
|
90
91
|
run: (() => Promise<boolean>) | undefined
|
|
91
92
|
}
|
|
92
93
|
|
|
93
|
-
/**
|
|
94
|
-
export
|
|
94
|
+
/** Constraints a numeric field's accepted drafts must satisfy, mirroring the host schema. */
|
|
95
|
+
export interface NumberConstraints {
|
|
96
|
+
/** The accepted value must be a whole number. */
|
|
97
|
+
integer?: boolean
|
|
98
|
+
/** The accepted value must be at least this. */
|
|
99
|
+
min?: number
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** A whole- or decimal-number field. An empty draft clears the field; any other draft that is not a finite number within the constraints blocks the save. */
|
|
103
|
+
export function numberField(field: string, constraints: NumberConstraints = {}): FieldSpec {
|
|
104
|
+
const { integer = false, min } = constraints
|
|
95
105
|
return {
|
|
96
106
|
field,
|
|
97
107
|
format: value => typeof value === 'number' ? String(value) : '',
|
|
@@ -99,7 +109,10 @@ export function numberField(field: string): FieldSpec {
|
|
|
99
109
|
const trimmed = text.trim()
|
|
100
110
|
if (trimmed === '') return { kind: 'clear' }
|
|
101
111
|
const parsed = Number(trimmed)
|
|
102
|
-
|
|
112
|
+
if (!Number.isFinite(parsed)) return undefined
|
|
113
|
+
if (integer && !Number.isInteger(parsed)) return undefined
|
|
114
|
+
if (min !== undefined && parsed < min) return undefined
|
|
115
|
+
return { kind: 'set', value: parsed }
|
|
103
116
|
},
|
|
104
117
|
}
|
|
105
118
|
}
|
|
@@ -122,13 +135,27 @@ export function booleanField(field: string): FieldSpec {
|
|
|
122
135
|
field,
|
|
123
136
|
format: value => typeof value === 'boolean' ? String(value) : '',
|
|
124
137
|
parse: (text) => {
|
|
125
|
-
|
|
126
|
-
if (
|
|
138
|
+
const trimmed = text.trim()
|
|
139
|
+
if (trimmed === '') return { kind: 'clear' }
|
|
140
|
+
if (trimmed === 'true') return { kind: 'set', value: true }
|
|
141
|
+
if (trimmed === 'false') return { kind: 'set', value: false }
|
|
127
142
|
return undefined
|
|
128
143
|
},
|
|
129
144
|
}
|
|
130
145
|
}
|
|
131
146
|
|
|
147
|
+
/** An enumerated string field; only the listed choices are accepted. An empty draft clears the field. */
|
|
148
|
+
export function choiceField(field: string, choices: readonly string[]): FieldSpec {
|
|
149
|
+
return {
|
|
150
|
+
field,
|
|
151
|
+
format: value => typeof value === 'string' && choices.includes(value) ? value : '',
|
|
152
|
+
parse: (text) => {
|
|
153
|
+
if (text === '') return { kind: 'clear' }
|
|
154
|
+
return choices.includes(text) ? { kind: 'set', value: text } : undefined
|
|
155
|
+
},
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
132
159
|
/**
|
|
133
160
|
* Stages one card's edits over one settings namespace and writes them on save.
|
|
134
161
|
*
|
|
@@ -215,6 +242,9 @@ export class CardForm<T> {
|
|
|
215
242
|
const plan = this.plan()
|
|
216
243
|
const writes = plan.flatMap(item => item.run === undefined ? [] : [item.run])
|
|
217
244
|
if (plan.length === 0 || this.saving || writes.length !== plan.length) return
|
|
245
|
+
// Snapshot the fields this save writes, so edits staged while it is in
|
|
246
|
+
// flight survive: only the staged keys this save actually wrote are cleared.
|
|
247
|
+
const fields = new Set(plan.map(item => item.field))
|
|
218
248
|
this.saving = true
|
|
219
249
|
this.failed = false
|
|
220
250
|
this.publish()
|
|
@@ -222,7 +252,9 @@ export class CardForm<T> {
|
|
|
222
252
|
for (const write of writes) {
|
|
223
253
|
landed = await write() && landed
|
|
224
254
|
}
|
|
225
|
-
if (landed)
|
|
255
|
+
if (landed) {
|
|
256
|
+
for (const field of fields) this.staged.delete(field)
|
|
257
|
+
}
|
|
226
258
|
this.saving = false
|
|
227
259
|
this.failed = !landed
|
|
228
260
|
this.publish()
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { expandHome, resolveDshHome } from './dsh-home.ts'
|
|
4
|
+
import { petHomeDir } from './persist.ts'
|
|
5
|
+
|
|
6
|
+
describe('resolveDshHome', () => {
|
|
7
|
+
it('falls back to ~/.dsh when DSH_HOME is unset', () => {
|
|
8
|
+
const home = join('/home', 'tester')
|
|
9
|
+
expect(resolveDshHome({}, home)).toBe(join(home, '.dsh'))
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('prefers the DSH_HOME env override', () => {
|
|
13
|
+
const home = join('/home', 'tester')
|
|
14
|
+
expect(resolveDshHome({ DSH_HOME: '/custom/dsh' }, home)).toBe('/custom/dsh')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('expands a leading ~ (and ~/) against the platform home', () => {
|
|
18
|
+
const home = join('/home', 'tester')
|
|
19
|
+
expect(resolveDshHome({ DSH_HOME: '~/data' }, home)).toBe(join(home, 'data'))
|
|
20
|
+
expect(expandHome('~', home)).toBe(home)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('joins a relative DSH_HOME onto the working directory', () => {
|
|
24
|
+
const home = join('/home', 'tester')
|
|
25
|
+
const rel = resolveDshHome({ DSH_HOME: 'rel/dsh' }, home)
|
|
26
|
+
expect(rel).not.toBe('rel/dsh')
|
|
27
|
+
expect(rel).toBe(join(process.cwd(), 'rel/dsh'))
|
|
28
|
+
})
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
describe('petHomeDir delegates to the shared DSH_HOME resolution', () => {
|
|
32
|
+
it('returns the same value as dshHome under the same env', () => {
|
|
33
|
+
expect(petHomeDir).toBeTypeOf('function')
|
|
34
|
+
})
|
|
35
|
+
})
|
package/src/dsh-home.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Generated by scripts/sync-shared.mjs from shared/host/dsh-home.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
|
|
2
|
+
/**
|
|
3
|
+
* DSH_HOME resolution shared by the plugin family's Host halves: the
|
|
4
|
+
* environment override wins, the platform home fallback follows. Mirrors
|
|
5
|
+
* what dsh-pet and dsh-liangshen each used to implement locally.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { homedir } from 'node:os'
|
|
9
|
+
import { isAbsolute, join } from 'node:path'
|
|
10
|
+
|
|
11
|
+
/** Expand a leading ~ (or ~user) in a path, platform-style. */
|
|
12
|
+
export function expandHome(path: string, home: string = homedir()): string {
|
|
13
|
+
if (path === '~') return home
|
|
14
|
+
if (path.startsWith('~/') || path.startsWith('~\\')) return join(home, path.slice(2))
|
|
15
|
+
return path
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Resolve the DSH home directory.
|
|
20
|
+
* @param env - process environment to read DSH_HOME from.
|
|
21
|
+
* @param home - platform home directory fallback (test seam).
|
|
22
|
+
* @returns the absolute DSH home path.
|
|
23
|
+
*/
|
|
24
|
+
export function resolveDshHome(env: NodeJS.ProcessEnv = process.env, home: string = homedir()): string {
|
|
25
|
+
const raw = env.DSH_HOME
|
|
26
|
+
if (raw !== undefined && raw.trim() !== '') {
|
|
27
|
+
const expanded = expandHome(raw.trim(), home)
|
|
28
|
+
return isAbsolute(expanded) ? expanded : join(process.cwd(), expanded)
|
|
29
|
+
}
|
|
30
|
+
return join(home, '.dsh')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Resolve the DSH home directory from the live environment. */
|
|
34
|
+
export function dshHome(): string {
|
|
35
|
+
return resolveDshHome()
|
|
36
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Official session event projection — pure. Maps the durable DSH session
|
|
3
|
+
* vocabulary onto the pet's visual phases and carries an optional completed-
|
|
4
|
+
* turn reward for the ledger. Holds no state of its own; callers keep a
|
|
5
|
+
* {@link ProjectionRuntime} per session and feed events in arrival order.
|
|
6
|
+
* @module @linxin666/dsh-pet/event-projection
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
|
10
|
+
import type { PetStateInput } from './state.ts'
|
|
11
|
+
|
|
12
|
+
/** Runtime shape of the optional legacy activity event. */
|
|
13
|
+
export interface ActivityStatusEventLike {
|
|
14
|
+
phase?: string
|
|
15
|
+
line?: string
|
|
16
|
+
phrase?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Per-session facts needed to project the official event stream. */
|
|
20
|
+
export interface ProjectionRuntime {
|
|
21
|
+
activeTools: Set<string>
|
|
22
|
+
officialEventsSeen: boolean
|
|
23
|
+
stepHadFailure: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** One official event projection, optionally carrying a completed turn reward. */
|
|
27
|
+
export interface PetActivityTransition {
|
|
28
|
+
input: PetStateInput
|
|
29
|
+
completedTurn?: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Fresh projection runtime for a newly seen session. */
|
|
33
|
+
export function emptyProjectionRuntime(): ProjectionRuntime {
|
|
34
|
+
return { activeTools: new Set(), officialEventsSeen: false, stepHadFailure: false }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Keep tool names readable inside the compact status bubble. */
|
|
38
|
+
function displayToolName(name: string): string {
|
|
39
|
+
const compact = name.replace(/\s+/g, ' ').trim() || '工具'
|
|
40
|
+
return compact.length <= 24 ? compact : `${compact.slice(0, 21)}...`
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Whether a legacy phase is part of the pet's supported vocabulary. */
|
|
44
|
+
export function isActivityPhase(phase: string): phase is PetStateInput['phase'] {
|
|
45
|
+
return ['idle', 'waiting', 'thinking', 'tool', 'review', 'done', 'failed'].includes(phase)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Project the durable DSH session vocabulary into the pet's visual phases.
|
|
50
|
+
* Unknown and log-only events do not disturb the last meaningful activity.
|
|
51
|
+
*/
|
|
52
|
+
export function projectOfficialEvent(
|
|
53
|
+
event: SessionEvent,
|
|
54
|
+
runtime: ProjectionRuntime,
|
|
55
|
+
): PetActivityTransition | undefined {
|
|
56
|
+
switch (event.type) {
|
|
57
|
+
case 'turn/start':
|
|
58
|
+
runtime.activeTools.clear()
|
|
59
|
+
runtime.stepHadFailure = false
|
|
60
|
+
return { input: { phase: 'waiting', line: '准备开始' } }
|
|
61
|
+
case 'step/start':
|
|
62
|
+
runtime.activeTools.clear()
|
|
63
|
+
runtime.stepHadFailure = false
|
|
64
|
+
return { input: { phase: 'waiting', line: '等待模型响应' } }
|
|
65
|
+
case 'assistant/chunk': {
|
|
66
|
+
const { chunk } = event.data
|
|
67
|
+
if (chunk.type === 'reasoning-delta' && chunk.text.length > 0) {
|
|
68
|
+
return { input: { phase: 'thinking', line: '正在思考' } }
|
|
69
|
+
}
|
|
70
|
+
if (chunk.type === 'text-delta' && chunk.text.length > 0) {
|
|
71
|
+
return { input: { phase: 'review', line: '整理回复中' } }
|
|
72
|
+
}
|
|
73
|
+
return undefined
|
|
74
|
+
}
|
|
75
|
+
case 'assistant/message':
|
|
76
|
+
return { input: { phase: 'review', line: '整理回复中' } }
|
|
77
|
+
case 'tool/call':
|
|
78
|
+
runtime.activeTools.add(String(event.data.callId))
|
|
79
|
+
return {
|
|
80
|
+
input: {
|
|
81
|
+
phase: 'tool',
|
|
82
|
+
line: `正在使用 ${displayToolName(event.data.name)}`,
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
case 'tool/result': {
|
|
86
|
+
const block = event.data.message.content[0]
|
|
87
|
+
runtime.activeTools.delete(String(event.data.message.source.callId))
|
|
88
|
+
runtime.stepHadFailure ||= event.data.error !== undefined || block.isError === true
|
|
89
|
+
if (runtime.activeTools.size > 0) {
|
|
90
|
+
return {
|
|
91
|
+
input: {
|
|
92
|
+
phase: 'tool',
|
|
93
|
+
line: `还有 ${runtime.activeTools.size} 个工具运行中`,
|
|
94
|
+
},
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return runtime.stepHadFailure
|
|
98
|
+
? { input: { phase: 'failed', line: '工具执行失败' } }
|
|
99
|
+
: { input: { phase: 'thinking', line: '处理工具结果' } }
|
|
100
|
+
}
|
|
101
|
+
case 'turn/end': {
|
|
102
|
+
runtime.activeTools.clear()
|
|
103
|
+
switch (event.data.reason.kind) {
|
|
104
|
+
case 'completed':
|
|
105
|
+
return {
|
|
106
|
+
input: { phase: 'done', line: '完成啦' },
|
|
107
|
+
completedTurn: event.data.turn,
|
|
108
|
+
}
|
|
109
|
+
case 'error':
|
|
110
|
+
return { input: { phase: 'failed', line: '执行失败' } }
|
|
111
|
+
case 'max-tokens':
|
|
112
|
+
return { input: { phase: 'failed', line: '达到输出上限' } }
|
|
113
|
+
case 'interrupted':
|
|
114
|
+
return { input: { phase: 'failed', line: '执行意外中断' } }
|
|
115
|
+
case 'blocked':
|
|
116
|
+
return { input: { phase: 'waiting', line: '等待继续' } }
|
|
117
|
+
case 'aborted':
|
|
118
|
+
return { input: { phase: 'idle', line: '已停止' } }
|
|
119
|
+
default:
|
|
120
|
+
// TurnEndReasonMap is merge-extensible; a newer ending must not
|
|
121
|
+
// leave the pet showing stale in-progress work.
|
|
122
|
+
return { input: { phase: 'idle' } }
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
default:
|
|
126
|
+
return undefined
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { defaultTreatConfig } from './treats.ts'
|
|
3
|
+
import { defaultAffinityConfig } from './affinity.ts'
|
|
4
|
+
import { emptyPersist } from './persist.ts'
|
|
5
|
+
import { PetLedger } from './ledger.ts'
|
|
6
|
+
|
|
7
|
+
describe('PetLedger', () => {
|
|
8
|
+
it('settles the economy on completed turns (work treat per 3 turns)', () => {
|
|
9
|
+
const ledger = new PetLedger(emptyPersist())
|
|
10
|
+
const n = 1_000_000
|
|
11
|
+
ledger.rewardTurn('s1', 1, n)
|
|
12
|
+
ledger.rewardTurn('s1', 2, n + 1)
|
|
13
|
+
ledger.rewardTurn('s1', 3, n + 2)
|
|
14
|
+
expect(ledger.snapshot.affinity.turns).toBe(3)
|
|
15
|
+
expect(ledger.snapshot.treats.treats).toBe(1)
|
|
16
|
+
expect(ledger.takeDirty()).toBe(true)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('rewards each completed turn once per session (idempotent)', () => {
|
|
20
|
+
const ledger = new PetLedger(emptyPersist())
|
|
21
|
+
const n = 1_000_000
|
|
22
|
+
expect(ledger.rewardTurn('s1', 3, n)).toBe(true)
|
|
23
|
+
// A duplicate delivery of the same turn must not double count.
|
|
24
|
+
expect(ledger.rewardTurn('s1', 3, n + 1)).toBe(false)
|
|
25
|
+
expect(ledger.snapshot.affinity.turns).toBe(1)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('a read of the view does not mark dirty (no settle on read)', () => {
|
|
29
|
+
const ledger = new PetLedger(emptyPersist())
|
|
30
|
+
ledger.affinityView(1_000_000)
|
|
31
|
+
expect(ledger.takeDirty()).toBe(false)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('feed consumes a treat and applies the feed reward', () => {
|
|
35
|
+
const ledger = new PetLedger(emptyPersist())
|
|
36
|
+
const n = 1_000_000
|
|
37
|
+
ledger.rewardTurn('s1', 1, n)
|
|
38
|
+
ledger.rewardTurn('s1', 2, n + 1)
|
|
39
|
+
ledger.rewardTurn('s1', 3, n + 2)
|
|
40
|
+
expect(ledger.snapshot.treats.treats).toBe(1)
|
|
41
|
+
const res = ledger.interact('feed', n + 10)
|
|
42
|
+
expect(res.delta).toBe(defaultAffinityConfig.feedReward)
|
|
43
|
+
expect(ledger.snapshot.treats.treats).toBe(0)
|
|
44
|
+
expect(ledger.snapshot.affinity.feeds).toBe(1)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('refuses a feed on an empty stock without burning anything', () => {
|
|
48
|
+
const ledger = new PetLedger(emptyPersist())
|
|
49
|
+
const res = ledger.interact('feed', 1_000_000)
|
|
50
|
+
expect(res.delta).toBe(0)
|
|
51
|
+
expect(res.reaction).toContain('没有小鱼干')
|
|
52
|
+
expect(ledger.snapshot.affinity.feeds).toBe(0)
|
|
53
|
+
// The empty-stock feed still marks dirty because the first settlement
|
|
54
|
+
// starts the time clock, mirroring the service-level behavior.
|
|
55
|
+
expect(ledger.takeDirty()).toBe(true)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('exposes the treat stock cap and display/name setters', () => {
|
|
59
|
+
const ledger = new PetLedger(emptyPersist())
|
|
60
|
+
expect(ledger.treatMax).toBe(defaultTreatConfig.maxTreats)
|
|
61
|
+
ledger.setDisplay({ ...ledger.snapshot.display, visible: false })
|
|
62
|
+
ledger.setName('泡泡')
|
|
63
|
+
expect(ledger.snapshot.display.visible).toBe(false)
|
|
64
|
+
expect(ledger.snapshot.name).toBe('泡泡')
|
|
65
|
+
expect(ledger.takeDirty()).toBe(true)
|
|
66
|
+
})
|
|
67
|
+
})
|