@linxin666/dsh-pet 0.3.4 → 0.3.5
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 +13 -10
- package/README.zh.md +13 -10
- package/contracts/voice-pack-v1.schema.json +23 -28
- package/lib/client.js +23 -15
- package/lib/client.js.map +1 -1
- package/lib/index.js +344 -461
- package/lib/types/chatter.d.ts +68 -46
- package/lib/types/chatter.d.ts.map +1 -1
- package/lib/types/chatter.js +243 -301
- package/lib/types/client/PetSprite.d.ts.map +1 -1
- package/lib/types/client/PetSprite.js +16 -17
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +26 -9
- package/lib/types/event-projection.d.ts +9 -4
- package/lib/types/event-projection.d.ts.map +1 -1
- package/lib/types/event-projection.js +46 -14
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +8 -3
- package/lib/types/service.d.ts +7 -11
- package/lib/types/service.d.ts.map +1 -1
- package/lib/types/service.js +27 -19
- package/lib/types/voice-pack.d.ts +6 -7
- package/lib/types/voice-pack.d.ts.map +1 -1
- package/lib/types/voice-pack.js +44 -47
- package/package.json +1 -1
- package/src/chatter.test.ts +68 -38
- package/src/chatter.ts +269 -316
- package/src/client/PetSprite.test.tsx +32 -15
- package/src/client/PetSprite.tsx +21 -24
- package/src/client/index.test.tsx +10 -1
- package/src/client/index.ts +29 -10
- package/src/event-projection.ts +57 -16
- package/src/routes.ts +9 -4
- package/src/service.ts +32 -29
- package/src/voice-pack.test.ts +48 -25
- package/src/voice-pack.ts +50 -47
|
@@ -424,27 +424,18 @@ describe('PetSprite status bubble', () => {
|
|
|
424
424
|
expect(screen.queryByText('正在使用 grep')).not.toBeNull()
|
|
425
425
|
})
|
|
426
426
|
|
|
427
|
-
it('lets
|
|
428
|
-
renderPet({ snapshot: { ...workingSnapshot, whisper: '哼哧哼哧,大脑转得飞快~' } })
|
|
429
|
-
// The whisper speaks THROUGH the bubble: it replaces (not accompanies)
|
|
430
|
-
// the status copy, so the pet never shows two bubbles at once.
|
|
431
|
-
expect(screen.queryByText('哼哧哼哧,大脑转得飞快~')).not.toBeNull()
|
|
432
|
-
expect(screen.queryByText('正在思考')).toBeNull()
|
|
433
|
-
})
|
|
434
|
-
|
|
435
|
-
it('lets the whisper take over the display session bubble in the stack', () => {
|
|
427
|
+
it('lets a session whisper take over its own bubble while others stay collapsed', () => {
|
|
436
428
|
const { onOpenSession } = renderPet({
|
|
437
429
|
snapshot: {
|
|
438
430
|
...workingSnapshot,
|
|
439
|
-
whisper: '我在这儿陪着你呢,别急别急',
|
|
440
431
|
sessions: [
|
|
441
|
-
{ sessionId: 's-a', animation: 'running', phase: 'thinking', bubble: '正在思考' },
|
|
432
|
+
{ sessionId: 's-a', animation: 'running', phase: 'thinking', bubble: '正在思考', whisper: '我在这儿陪着你呢,别急别急' },
|
|
442
433
|
{ sessionId: 's-b', animation: 'running-right', phase: 'tool', bubble: '正在使用 grep' },
|
|
443
434
|
],
|
|
444
435
|
},
|
|
445
436
|
})
|
|
446
|
-
// The
|
|
447
|
-
//
|
|
437
|
+
// The lead session's bubble speaks ITS whisper instead of its status
|
|
438
|
+
// copy; the collapsed extra session hides behind the badge.
|
|
448
439
|
expect(screen.queryByText('我在这儿陪着你呢,别急别急')).not.toBeNull()
|
|
449
440
|
expect(screen.queryByText('正在思考')).toBeNull()
|
|
450
441
|
expect(screen.queryByText('正在使用 grep')).toBeNull()
|
|
@@ -456,9 +447,28 @@ describe('PetSprite status bubble', () => {
|
|
|
456
447
|
expect(onOpenSession).toHaveBeenCalledWith('s-a')
|
|
457
448
|
})
|
|
458
449
|
|
|
450
|
+
it('renders each session whisper on its own expanded bubble', () => {
|
|
451
|
+
renderPet({
|
|
452
|
+
snapshot: {
|
|
453
|
+
...workingSnapshot,
|
|
454
|
+
sessions: [
|
|
455
|
+
{ sessionId: 's-a', animation: 'running', phase: 'thinking', bubble: '正在思考', whisper: 'A 的碎碎念' },
|
|
456
|
+
{ sessionId: 's-b', animation: 'running-right', phase: 'tool', bubble: '正在使用 grep', whisper: 'B 的碎碎念' },
|
|
457
|
+
],
|
|
458
|
+
},
|
|
459
|
+
})
|
|
460
|
+
fireEvent.pointerOver(screen.getByText('A 的碎碎念').closest('div')!)
|
|
461
|
+
expect(screen.queryByText('A 的碎碎念')).not.toBeNull()
|
|
462
|
+
expect(screen.queryByText('B 的碎碎念')).not.toBeNull()
|
|
463
|
+
expect(screen.queryByText('正在思考')).toBeNull()
|
|
464
|
+
})
|
|
465
|
+
|
|
459
466
|
it('lets transient interaction feedback take over the whisper too', () => {
|
|
460
467
|
renderPet({
|
|
461
|
-
snapshot: {
|
|
468
|
+
snapshot: {
|
|
469
|
+
...workingSnapshot,
|
|
470
|
+
sessions: [{ sessionId: 's-a', animation: 'running', phase: 'thinking', bubble: '正在思考', whisper: '哼哧哼哧,大脑转得飞快~' }],
|
|
471
|
+
},
|
|
462
472
|
feedback: { text: '摸摸成功', kind: 'pet', at: 1 },
|
|
463
473
|
})
|
|
464
474
|
expect(screen.queryByText('摸摸成功')).not.toBeNull()
|
|
@@ -754,7 +764,14 @@ describe('PetSprite status decoration (pet-center M5, #567)', () => {
|
|
|
754
764
|
})
|
|
755
765
|
|
|
756
766
|
it('yields the bubble to the whisper (voice moment hides the ornament)', () => {
|
|
757
|
-
renderPet({
|
|
767
|
+
renderPet({
|
|
768
|
+
snapshot: {
|
|
769
|
+
...snapshot,
|
|
770
|
+
phase: 'thinking',
|
|
771
|
+
decoration,
|
|
772
|
+
sessions: [{ sessionId: 's-a', animation: 'running', phase: 'thinking', bubble: '正在思考', whisper: '冲了冲了' }],
|
|
773
|
+
},
|
|
774
|
+
})
|
|
758
775
|
expect(ornament()).toBeNull()
|
|
759
776
|
expect(document.body.textContent).toContain('冲了冲了')
|
|
760
777
|
})
|
package/src/client/PetSprite.tsx
CHANGED
|
@@ -432,14 +432,14 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
432
432
|
const statusBubble = feedback === null && sessionBubbles.length === 0
|
|
433
433
|
? snapshot?.bubble
|
|
434
434
|
: undefined
|
|
435
|
-
//
|
|
436
|
-
// woken by
|
|
437
|
-
//
|
|
438
|
-
//
|
|
439
|
-
// voices at once. Interaction feedback takes over the
|
|
440
|
-
// while it plays, so whispers yield to it like status
|
|
441
|
-
|
|
442
|
-
const bubblePresent = feedback !== null || sessionBubbles.length > 0 || statusBubble !== undefined
|
|
435
|
+
// Each session's inner whisper (碎碎念) rides its own bubble — short
|
|
436
|
+
// inner-voice copy woken by that session's activity, never the model's or
|
|
437
|
+
// another session's. Instead of a second bubble of its own, a fresh
|
|
438
|
+
// whisper takes over its session's bubble and re-tints it, so the pet
|
|
439
|
+
// never wears two voices at once. Interaction feedback takes over the
|
|
440
|
+
// whole bubble area while it plays, so whispers yield to it like status
|
|
441
|
+
// copy.
|
|
442
|
+
const bubblePresent = feedback !== null || sessionBubbles.length > 0 || statusBubble !== undefined
|
|
443
443
|
const displayName = snapshot?.name ?? definition.displayName
|
|
444
444
|
// The host-served status decoration (M5, #567); absent = text-only bubbles.
|
|
445
445
|
const decoration = snapshot?.decoration
|
|
@@ -549,7 +549,7 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
549
549
|
{feedback.text}
|
|
550
550
|
</div>
|
|
551
551
|
)}
|
|
552
|
-
{feedback === null && (sessionBubbles.length > 0 || statusBubble !== undefined
|
|
552
|
+
{feedback === null && (sessionBubbles.length > 0 || statusBubble !== undefined) && (
|
|
553
553
|
<div
|
|
554
554
|
ref={bubbleRef}
|
|
555
555
|
className={styles.bubbleStack}
|
|
@@ -557,15 +557,14 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
557
557
|
onPointerLeave={() => setStackPeek(false)}
|
|
558
558
|
>
|
|
559
559
|
{visibleSessions.map((session, index) => {
|
|
560
|
-
//
|
|
561
|
-
//
|
|
562
|
-
//
|
|
563
|
-
//
|
|
564
|
-
|
|
565
|
-
const speaksWhisper = index === 0 && whisper !== undefined
|
|
560
|
+
// A session's whisper rides ITS OWN bubble (the stack lead when
|
|
561
|
+
// the current session has one, any bubble when the stack is
|
|
562
|
+
// expanded). The key swap restarts the entrance animation so the
|
|
563
|
+
// mood change reads as the bubble re-speaking.
|
|
564
|
+
const speaksWhisper = session.whisper !== undefined
|
|
566
565
|
const bubble = (
|
|
567
566
|
<button
|
|
568
|
-
key={speaksWhisper ? 'whisper:' + whisper : session.sessionId}
|
|
567
|
+
key={speaksWhisper ? 'whisper:' + session.whisper : session.sessionId}
|
|
569
568
|
type="button"
|
|
570
569
|
className={clsx(
|
|
571
570
|
styles.bubble,
|
|
@@ -579,7 +578,7 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
579
578
|
{index === 0 && !speaksWhisper && decoration !== undefined && (
|
|
580
579
|
<StatusOrnament decoration={decoration} phase={phase} />
|
|
581
580
|
)}
|
|
582
|
-
{
|
|
581
|
+
{session.whisper ?? session.bubble}
|
|
583
582
|
</button>
|
|
584
583
|
)
|
|
585
584
|
// The primary bubble carries the '+N' badge while other sessions
|
|
@@ -608,19 +607,17 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
608
607
|
</span>
|
|
609
608
|
)
|
|
610
609
|
})}
|
|
611
|
-
{sessionBubbles.length === 0 &&
|
|
612
|
-
// The key swap (status copy <-> whisper) restarts the entrance
|
|
613
|
-
// animation on every mood change.
|
|
610
|
+
{sessionBubbles.length === 0 && statusBubble !== undefined && (
|
|
614
611
|
<div
|
|
615
|
-
key=
|
|
616
|
-
className={clsx(styles.bubble, styles.bubbleStatus
|
|
612
|
+
key="status"
|
|
613
|
+
className={clsx(styles.bubble, styles.bubbleStatus)}
|
|
617
614
|
role="status"
|
|
618
615
|
aria-live="polite"
|
|
619
616
|
>
|
|
620
|
-
{
|
|
617
|
+
{decoration !== undefined && (
|
|
621
618
|
<StatusOrnament decoration={decoration} phase={phase} />
|
|
622
619
|
)}
|
|
623
|
-
{
|
|
620
|
+
{statusBubble}
|
|
624
621
|
</div>
|
|
625
622
|
)}
|
|
626
623
|
</div>
|
|
@@ -110,7 +110,16 @@ function fakeContext(): FakeClientLifecycle {
|
|
|
110
110
|
},
|
|
111
111
|
register: () => () => {},
|
|
112
112
|
},
|
|
113
|
-
sessions:
|
|
113
|
+
sessions: {
|
|
114
|
+
list: {
|
|
115
|
+
getSnapshot: () => ({ current: undefined, byId: {} }),
|
|
116
|
+
subscribe: (listener: () => void) => {
|
|
117
|
+
const set = new Set<() => void>([listener])
|
|
118
|
+
return () => { set.delete(listener) }
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
open: () => {},
|
|
122
|
+
},
|
|
114
123
|
} as unknown as ClientContext
|
|
115
124
|
let disposed = false
|
|
116
125
|
const lifecycle: FakeClientLifecycle = {
|
package/src/client/index.ts
CHANGED
|
@@ -36,7 +36,8 @@ import { reportDailyHeartbeat } from './telemetry.ts'
|
|
|
36
36
|
|
|
37
37
|
/** The host pet API as the browser sees it (same-origin JSON endpoints). */
|
|
38
38
|
interface PetHttpApi {
|
|
39
|
-
|
|
39
|
+
/** Poll the host snapshot; the GUI's current session id rides the query. */
|
|
40
|
+
state(currentSessionId?: string): Promise<PetStateView>
|
|
40
41
|
pets(): Promise<PetDefinition[]>
|
|
41
42
|
interact(kind: PetInteraction): Promise<PetInteractResult>
|
|
42
43
|
setVisible(visible: boolean): Promise<{ ok: true; display: PetDisplayConfig }>
|
|
@@ -66,7 +67,8 @@ async function petFetch<T>(path: string, body?: unknown): Promise<T> {
|
|
|
66
67
|
|
|
67
68
|
/** The live host API instance (always defined; failures surface per call). */
|
|
68
69
|
const petApi: PetHttpApi = {
|
|
69
|
-
state: () => petFetch('/api/pet/state'
|
|
70
|
+
state: (currentSessionId) => petFetch('/api/pet/state'
|
|
71
|
+
+ (currentSessionId === undefined ? '' : '?current=' + encodeURIComponent(currentSessionId))),
|
|
70
72
|
pets: () => petFetch('/api/pet/pets'),
|
|
71
73
|
interact: (kind) => petFetch('/api/pet/interact', { kind }),
|
|
72
74
|
setVisible: (visible) => petFetch('/api/pet/set-visible', { visible }),
|
|
@@ -197,6 +199,20 @@ export function apply(ctx: ClientContext): void {
|
|
|
197
199
|
const setState = petStore.actions.setState
|
|
198
200
|
const setFeedback = petStore.actions.setFeedback
|
|
199
201
|
|
|
202
|
+
// Clicking a session bubble jumps the GUI to that session; the same
|
|
203
|
+
// sessions face reports which session the user is currently on, so the
|
|
204
|
+
// host can lead the bubble stack with it. A bubble can outlive its
|
|
205
|
+
// disposed session by one poll tick, and the sessions service fails
|
|
206
|
+
// loud on unknown ids, so consult the live list first. The pet's type
|
|
207
|
+
// program also loads the host-side dsh-session package through the
|
|
208
|
+
// service types, whose Context merge declares a different 'sessions'
|
|
209
|
+
// face; pin the browser runtime's outward face here.
|
|
210
|
+
const sessions = ctx.sessions as unknown as ISessions
|
|
211
|
+
const currentSessionId = (): string | undefined => {
|
|
212
|
+
const current = sessions.list.getSnapshot().current
|
|
213
|
+
return current === undefined ? undefined : String(current)
|
|
214
|
+
}
|
|
215
|
+
|
|
200
216
|
// The registry list is fetched lazily with retries baked into the poll
|
|
201
217
|
// cycle: until it lands, the dock entry renders nothing and every 2s
|
|
202
218
|
// tick tries again. After it lands, one list feeds both the sprite and
|
|
@@ -218,7 +234,7 @@ export function apply(ctx: ClientContext): void {
|
|
|
218
234
|
}
|
|
219
235
|
const seq = stateSeq + 1
|
|
220
236
|
stateSeq = seq
|
|
221
|
-
petApi.state().then((snapshot) => {
|
|
237
|
+
petApi.state(currentSessionId()).then((snapshot) => {
|
|
222
238
|
if (seq !== stateSeq) return
|
|
223
239
|
setSnapshot(snapshot)
|
|
224
240
|
}, () => {
|
|
@@ -261,13 +277,16 @@ export function apply(ctx: ClientContext): void {
|
|
|
261
277
|
}
|
|
262
278
|
}, 'pet: poll')
|
|
263
279
|
|
|
264
|
-
//
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
280
|
+
// A current-session switch should re-order the bubble stack right away,
|
|
281
|
+
// not on the next 2s tick. Poll only while the tab is visible, as the
|
|
282
|
+
// poll loop itself does.
|
|
283
|
+
const disposeSessionWatch = ctx.effect(() => {
|
|
284
|
+
const unsubscribe = sessions.list.subscribe(() => {
|
|
285
|
+
if (document.visibilityState === 'visible') pollNow()
|
|
286
|
+
})
|
|
287
|
+
return unsubscribe
|
|
288
|
+
}, 'pet: current-session watch')
|
|
289
|
+
|
|
271
290
|
const openSession = (sessionId: string): void => {
|
|
272
291
|
const list = sessions.list.getSnapshot()
|
|
273
292
|
if (list.byId[sessionId as SessionId] === undefined) return
|
package/src/event-projection.ts
CHANGED
|
@@ -5,15 +5,26 @@
|
|
|
5
5
|
* {@link ProjectionRuntime} per session and feed events in arrival order.
|
|
6
6
|
*
|
|
7
7
|
* Status copy comes from the chatter voice (big rotating pools, per-tool
|
|
8
|
-
* families, real-argument hints), and
|
|
9
|
-
*
|
|
8
|
+
* families, real-argument hints), and the projection feeds the murmur engine
|
|
9
|
+
* (碎碎念) with the SITUATION — thinking/writing during the stream, the
|
|
10
|
+
* running tool family on tool calls, and the STRUCTURED outcome on
|
|
11
|
+
* tool/result and turn/end — so the pet's inner voice always roughly knows
|
|
12
|
+
* what is going on and never mis-fires on output text. The wall clock is
|
|
10
13
|
* injected by the caller, keeping every projection reproducible.
|
|
11
14
|
* @module @linxin666/dsh-pet/event-projection
|
|
12
15
|
*/
|
|
13
16
|
|
|
14
17
|
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
|
15
18
|
import type { PetStateInput } from './state.ts'
|
|
16
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
StatusVoice,
|
|
21
|
+
toolArgHint,
|
|
22
|
+
toolCategory,
|
|
23
|
+
WhisperEngine,
|
|
24
|
+
whisperCategoryOf,
|
|
25
|
+
looksLikeTestTool,
|
|
26
|
+
type VoicePoolsProvider,
|
|
27
|
+
} from './chatter.ts'
|
|
17
28
|
|
|
18
29
|
/** Runtime shape of the optional legacy activity event. */
|
|
19
30
|
export interface ActivityStatusEventLike {
|
|
@@ -25,11 +36,13 @@ export interface ActivityStatusEventLike {
|
|
|
25
36
|
/** Per-session facts needed to project the official event stream. */
|
|
26
37
|
export interface ProjectionRuntime {
|
|
27
38
|
activeTools: Set<string>
|
|
39
|
+
/** callIds whose tool looked like a test run, marked at tool/call (pet M6). */
|
|
40
|
+
testCalls: Set<string>
|
|
28
41
|
officialEventsSeen: boolean
|
|
29
42
|
stepHadFailure: boolean
|
|
30
43
|
/** Round-robin status copy voice (scene-stable, cadence-rotated). */
|
|
31
44
|
voice: StatusVoice
|
|
32
|
-
/** Inner-whisper engine fed by the
|
|
45
|
+
/** Inner-whisper engine fed by the projection's situation and outcomes. */
|
|
33
46
|
whispers: WhisperEngine
|
|
34
47
|
}
|
|
35
48
|
|
|
@@ -37,7 +50,7 @@ export interface ProjectionRuntime {
|
|
|
37
50
|
export interface PetActivityTransition {
|
|
38
51
|
input: PetStateInput
|
|
39
52
|
completedTurn?: number
|
|
40
|
-
/** A fresh inner whisper woken by this event
|
|
53
|
+
/** A fresh inner whisper woken by this event, when any. */
|
|
41
54
|
whisper?: string
|
|
42
55
|
}
|
|
43
56
|
|
|
@@ -50,6 +63,7 @@ export interface PetActivityTransition {
|
|
|
50
63
|
export function emptyProjectionRuntime(pools?: VoicePoolsProvider): ProjectionRuntime {
|
|
51
64
|
return {
|
|
52
65
|
activeTools: new Set(),
|
|
66
|
+
testCalls: new Set(),
|
|
53
67
|
officialEventsSeen: false,
|
|
54
68
|
stepHadFailure: false,
|
|
55
69
|
voice: new StatusVoice(pools),
|
|
@@ -81,6 +95,7 @@ export function projectOfficialEvent(
|
|
|
81
95
|
switch (event.type) {
|
|
82
96
|
case 'turn/start':
|
|
83
97
|
runtime.activeTools.clear()
|
|
98
|
+
runtime.testCalls.clear()
|
|
84
99
|
runtime.stepHadFailure = false
|
|
85
100
|
return { input: { phase: 'waiting', line: runtime.voice.scene('prepare', nowMs) } }
|
|
86
101
|
case 'step/start':
|
|
@@ -90,14 +105,14 @@ export function projectOfficialEvent(
|
|
|
90
105
|
case 'assistant/chunk': {
|
|
91
106
|
const { chunk } = event.data
|
|
92
107
|
if (chunk.type === 'reasoning-delta' && chunk.text.length > 0) {
|
|
93
|
-
const whisper = runtime.whispers.feed(
|
|
108
|
+
const whisper = runtime.whispers.feed('thinking', nowMs)
|
|
94
109
|
return {
|
|
95
110
|
input: { phase: 'thinking', line: runtime.voice.scene('thinking', nowMs) },
|
|
96
111
|
...(whisper === undefined ? {} : { whisper }),
|
|
97
112
|
}
|
|
98
113
|
}
|
|
99
114
|
if (chunk.type === 'text-delta' && chunk.text.length > 0) {
|
|
100
|
-
const whisper = runtime.whispers.feed(
|
|
115
|
+
const whisper = runtime.whispers.feed('writing', nowMs)
|
|
101
116
|
return {
|
|
102
117
|
input: { phase: 'review', line: runtime.voice.scene('review', nowMs) },
|
|
103
118
|
...(whisper === undefined ? {} : { whisper }),
|
|
@@ -107,8 +122,13 @@ export function projectOfficialEvent(
|
|
|
107
122
|
}
|
|
108
123
|
case 'assistant/message':
|
|
109
124
|
return { input: { phase: 'review', line: runtime.voice.scene('review', nowMs) } }
|
|
110
|
-
case 'tool/call':
|
|
111
|
-
|
|
125
|
+
case 'tool/call': {
|
|
126
|
+
const callId = String(event.data.callId)
|
|
127
|
+
runtime.activeTools.add(callId)
|
|
128
|
+
// A test-looking call is remembered so its result (the only place the
|
|
129
|
+
// outcome is known) can wake the test-green mood later.
|
|
130
|
+
if (looksLikeTestTool(event.data.name, event.data.arguments)) runtime.testCalls.add(callId)
|
|
131
|
+
const whisper = runtime.whispers.feed(whisperCategoryOf(toolCategory(event.data.name)), nowMs)
|
|
112
132
|
return {
|
|
113
133
|
input: {
|
|
114
134
|
phase: 'tool',
|
|
@@ -119,33 +139,54 @@ export function projectOfficialEvent(
|
|
|
119
139
|
nowMs,
|
|
120
140
|
),
|
|
121
141
|
},
|
|
142
|
+
...(whisper === undefined ? {} : { whisper }),
|
|
122
143
|
}
|
|
144
|
+
}
|
|
123
145
|
case 'tool/result': {
|
|
124
146
|
const block = event.data.message.content[0]
|
|
125
|
-
|
|
126
|
-
|
|
147
|
+
const callId = String(event.data.message.source.callId)
|
|
148
|
+
const failed = event.data.error !== undefined || block.isError === true
|
|
149
|
+
const wasTest = runtime.testCalls.delete(callId)
|
|
150
|
+
runtime.activeTools.delete(callId)
|
|
151
|
+
runtime.stepHadFailure ||= failed
|
|
152
|
+
const whisper = failed
|
|
153
|
+
? runtime.whispers.result('fail', nowMs)
|
|
154
|
+
: wasTest
|
|
155
|
+
? runtime.whispers.result('pass', nowMs)
|
|
156
|
+
: undefined
|
|
157
|
+
const whisperSpread = whisper === undefined ? {} : { whisper }
|
|
127
158
|
if (runtime.activeTools.size > 0) {
|
|
128
159
|
return {
|
|
129
160
|
input: {
|
|
130
161
|
phase: 'tool',
|
|
131
162
|
line: runtime.voice.toolRemaining(runtime.activeTools.size, nowMs),
|
|
132
163
|
},
|
|
164
|
+
...whisperSpread,
|
|
133
165
|
}
|
|
134
166
|
}
|
|
135
167
|
return runtime.stepHadFailure
|
|
136
|
-
? { input: { phase: 'failed', line: runtime.voice.scene('toolFailed', nowMs) } }
|
|
137
|
-
: { input: { phase: 'thinking', line: runtime.voice.scene('toolResult', nowMs) } }
|
|
168
|
+
? { input: { phase: 'failed', line: runtime.voice.scene('toolFailed', nowMs) }, ...whisperSpread }
|
|
169
|
+
: { input: { phase: 'thinking', line: runtime.voice.scene('toolResult', nowMs) }, ...whisperSpread }
|
|
138
170
|
}
|
|
139
171
|
case 'turn/end': {
|
|
140
172
|
runtime.activeTools.clear()
|
|
173
|
+
runtime.testCalls.clear()
|
|
141
174
|
switch (event.data.reason.kind) {
|
|
142
|
-
case 'completed':
|
|
175
|
+
case 'completed': {
|
|
176
|
+
const whisper = runtime.whispers.result('done', nowMs)
|
|
143
177
|
return {
|
|
144
178
|
input: { phase: 'done', line: runtime.voice.scene('done', nowMs) },
|
|
145
179
|
completedTurn: event.data.turn,
|
|
180
|
+
...(whisper === undefined ? {} : { whisper }),
|
|
146
181
|
}
|
|
147
|
-
|
|
148
|
-
|
|
182
|
+
}
|
|
183
|
+
case 'error': {
|
|
184
|
+
const whisper = runtime.whispers.result('fail', nowMs)
|
|
185
|
+
return {
|
|
186
|
+
input: { phase: 'failed', line: runtime.voice.scene('failed', nowMs) },
|
|
187
|
+
...(whisper === undefined ? {} : { whisper }),
|
|
188
|
+
}
|
|
189
|
+
}
|
|
149
190
|
case 'max-tokens':
|
|
150
191
|
return { input: { phase: 'failed', line: runtime.voice.scene('maxTokens', nowMs) } }
|
|
151
192
|
case 'interrupted':
|
package/src/routes.ts
CHANGED
|
@@ -112,15 +112,15 @@ function guard(ctx: Context, req: IncomingMessage, res: ServerResponse): boolean
|
|
|
112
112
|
return false
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
-
/** Wrap one async service call as a GET JSON route. */
|
|
116
|
-
function getRoute(ctx: Context, path: string, run: () => Promise<unknown>): WebRoute {
|
|
115
|
+
/** Wrap one async service call as a GET JSON route (request passed through for query params). */
|
|
116
|
+
function getRoute(ctx: Context, path: string, run: (req: IncomingMessage) => Promise<unknown>): WebRoute {
|
|
117
117
|
return {
|
|
118
118
|
kind: 'exact',
|
|
119
119
|
path,
|
|
120
120
|
handler: (req: IncomingMessage, res: ServerResponse): void => {
|
|
121
121
|
if (!guard(ctx, req, res)) return
|
|
122
122
|
if (!requireMethod(req, res, 'GET')) return
|
|
123
|
-
run().then((value) => writeJson(res, 200, value), (error) => {
|
|
123
|
+
run(req).then((value) => writeJson(res, 200, value), (error) => {
|
|
124
124
|
writeJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) })
|
|
125
125
|
})
|
|
126
126
|
},
|
|
@@ -525,7 +525,12 @@ function decorationHandler(ctx: Context, registry: PetRegistry, caps: PetAssetCa
|
|
|
525
525
|
export function makePetRoutes(deps: { service: PetService; ctx: Context; assetCaps?: PetAssetCaps } & PetRuntimeRoots): WebRoute[] {
|
|
526
526
|
const { service, ctx } = deps
|
|
527
527
|
const apiRoutes: WebRoute[] = [
|
|
528
|
-
getRoute(ctx, PET_API_PREFIX + '/state', () =>
|
|
528
|
+
getRoute(ctx, PET_API_PREFIX + '/state', (req) => {
|
|
529
|
+
// The browser half reports the GUI's current session id so the bubble
|
|
530
|
+
// stack can lead with the session the user is actually looking at.
|
|
531
|
+
const current = new URL(req.url ?? '/', 'http://pet.local').searchParams.get('current')
|
|
532
|
+
return service.state(current === null || current === '' ? undefined : current)
|
|
533
|
+
}),
|
|
529
534
|
getRoute(ctx, PET_API_PREFIX + '/pets', () => service.pets()),
|
|
530
535
|
getRoute(ctx, PET_API_PREFIX + '/diagnostics', () => service.diagnostics()),
|
|
531
536
|
postRoute(ctx, PET_API_PREFIX + '/interact', (body) => {
|
package/src/service.ts
CHANGED
|
@@ -137,6 +137,8 @@ export interface PetSessionView {
|
|
|
137
137
|
bubble: string
|
|
138
138
|
/** This session's raw activity phase. */
|
|
139
139
|
phase: PetStateSnapshot['phase']
|
|
140
|
+
/** This session's fresh inner whisper (碎碎念), when one is within its TTL. */
|
|
141
|
+
whisper?: string
|
|
140
142
|
}
|
|
141
143
|
|
|
142
144
|
/** Hard cap on simultaneously displayed session bubbles (most recent first). */
|
|
@@ -149,10 +151,10 @@ export interface PetStateView {
|
|
|
149
151
|
phase: PetStateSnapshot['phase']
|
|
150
152
|
sessionActive: boolean
|
|
151
153
|
/**
|
|
152
|
-
* Per-session bubbles for every concurrently active TOP-LEVEL session
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
* session.
|
|
154
|
+
* Per-session bubbles for every concurrently active TOP-LEVEL session:
|
|
155
|
+
* the GUI's current session first when reported, the rest most recently
|
|
156
|
+
* active first; optional so older hosts without the multi-session view
|
|
157
|
+
* stay consumable. The single 'bubble' above mirrors the display session.
|
|
156
158
|
*/
|
|
157
159
|
sessions?: PetSessionView[]
|
|
158
160
|
/** Affinity ledger snapshot. */
|
|
@@ -177,12 +179,6 @@ export interface PetStateView {
|
|
|
177
179
|
/** Stock cap. */
|
|
178
180
|
max: number
|
|
179
181
|
}
|
|
180
|
-
/**
|
|
181
|
-
* The display session's fresh inner whisper (碎碎念), when one is within
|
|
182
|
-
* its TTL — short inner-voice copy woken by the model's own output,
|
|
183
|
-
* rendered by the client as a distinct whisper bubble.
|
|
184
|
-
*/
|
|
185
|
-
whisper?: string
|
|
186
182
|
/**
|
|
187
183
|
* The active status decoration (pet-center M5, #567), when the master
|
|
188
184
|
* switch is on and the default decoration entry exists. Absent means the
|
|
@@ -338,8 +334,8 @@ export class PetService extends Service {
|
|
|
338
334
|
}
|
|
339
335
|
|
|
340
336
|
/** RPC: current pet state snapshot. */
|
|
341
|
-
async state(): Promise<PetStateView> {
|
|
342
|
-
return this.view()
|
|
337
|
+
async state(currentSessionId?: string): Promise<PetStateView> {
|
|
338
|
+
return this.view(currentSessionId)
|
|
343
339
|
}
|
|
344
340
|
|
|
345
341
|
/** Current persisted display config (read-only view). */
|
|
@@ -763,37 +759,45 @@ export class PetService extends Service {
|
|
|
763
759
|
if (this.ledger.rewardLegacyTurn(Date.now())) this.flush()
|
|
764
760
|
}
|
|
765
761
|
|
|
766
|
-
private view(): PetStateView {
|
|
762
|
+
private view(currentSessionId?: string): PetStateView {
|
|
767
763
|
const snapshot = this.machine.render()
|
|
768
764
|
const entry = this.activeEntry()
|
|
769
|
-
// One bubble per concurrently active TOP-LEVEL session
|
|
770
|
-
//
|
|
771
|
-
//
|
|
772
|
-
//
|
|
773
|
-
//
|
|
774
|
-
//
|
|
765
|
+
// One bubble per concurrently active TOP-LEVEL session. The GUI's current
|
|
766
|
+
// session leads the stack when reported (the browser half passes its
|
|
767
|
+
// session list's 'current'); everything else keeps the most recent
|
|
768
|
+
// meaningful event order. Subagent children render no bubble of their own
|
|
769
|
+
// (their activity already shows through the spawning conversation's
|
|
770
|
+
// bubble/display, and the bubble buttons navigate to GUI sessions, which
|
|
771
|
+
// subagents are not). Sessions whose own machine has settled (no bubble
|
|
772
|
+
// copy) drop out, so a finished turn does not leave a stale bubble behind.
|
|
775
773
|
const sessions: PetSessionView[] = []
|
|
776
774
|
for (const [session, activity] of [...this.sessionActivity.entries()].reverse()) {
|
|
777
775
|
if (sessions.length >= MAX_SESSION_BUBBLES) break
|
|
778
776
|
if (session.header?.origin === 'subagent') continue
|
|
779
777
|
const perSession = activity.machine.render()
|
|
780
778
|
if (perSession.bubble === undefined) continue
|
|
779
|
+
// Each session's whisper rides its own bubble while fresh; an expired
|
|
780
|
+
// whisper simply stops appearing (the client's 2 s poll drops it).
|
|
781
|
+
const whisper = activity.whisper
|
|
782
|
+
const freshWhisper = whisper !== undefined && Date.now() - whisper.at < WHISPER_TTL_MS
|
|
783
|
+
? whisper.text
|
|
784
|
+
: undefined
|
|
781
785
|
sessions.push({
|
|
782
786
|
sessionId: String(session.id),
|
|
783
787
|
animation: perSession.animation,
|
|
784
788
|
bubble: perSession.bubble,
|
|
785
789
|
phase: perSession.phase,
|
|
790
|
+
...(freshWhisper === undefined ? {} : { whisper: freshWhisper }),
|
|
786
791
|
})
|
|
787
792
|
}
|
|
788
|
-
// The
|
|
789
|
-
//
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
: undefined
|
|
793
|
+
// The browser half reports the session the user is currently on; that
|
|
794
|
+
// session's bubble leads the stack (its whisper then speaks on the
|
|
795
|
+
// collapsed single bubble). An unreported or absent session keeps the
|
|
796
|
+
// legacy most-recent-first order.
|
|
797
|
+
if (currentSessionId !== undefined) {
|
|
798
|
+
const index = sessions.findIndex(session => session.sessionId === currentSessionId)
|
|
799
|
+
if (index > 0) sessions.unshift(sessions.splice(index, 1)[0]!)
|
|
800
|
+
}
|
|
797
801
|
const decoration = this.activeDecoration()
|
|
798
802
|
// Gameplay view: a read-only projection. The settle runs on a copy, so
|
|
799
803
|
// polling never writes pet.json (verbs settle and persist).
|
|
@@ -812,7 +816,6 @@ export class PetService extends Service {
|
|
|
812
816
|
phase: snapshot.phase,
|
|
813
817
|
sessionActive: snapshot.sessionActive,
|
|
814
818
|
sessions,
|
|
815
|
-
...(freshWhisper === undefined ? {} : { whisper: freshWhisper }),
|
|
816
819
|
...(decoration === undefined ? {} : { decoration }),
|
|
817
820
|
affinity: this.ledger.affinityView(Date.now()),
|
|
818
821
|
display: { ...this.ledger.snapshot.display },
|