@linxin666/dsh-pet 0.3.4 → 0.3.6
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 +53 -28
- package/cordis.patch.yml +1 -1
- package/lib/client.js +166 -30
- package/lib/client.js.map +1 -1
- package/lib/index.js +400 -478
- package/lib/invariant.js +1 -1
- package/lib/{state-DrMX22GL.js → state-NvDEoCln.js} +5 -2
- package/lib/types/access.d.ts +5 -4
- package/lib/types/access.d.ts.map +1 -1
- package/lib/types/access.js +2 -12
- package/lib/types/affinity.d.ts +2 -0
- package/lib/types/affinity.d.ts.map +1 -1
- package/lib/types/affinity.js +12 -0
- 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 +23 -18
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +26 -9
- package/lib/types/client/locales.d.ts +18 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +18 -0
- package/lib/types/client/renderers/frames2d.d.ts +12 -0
- package/lib/types/client/renderers/frames2d.d.ts.map +1 -1
- package/lib/types/client/renderers/frames2d.js +143 -18
- 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/ledger.d.ts +4 -2
- package/lib/types/ledger.d.ts.map +1 -1
- package/lib/types/ledger.js +4 -4
- package/lib/types/pair-access.d.ts +35 -0
- package/lib/types/pair-access.d.ts.map +1 -0
- package/lib/types/pair-access.js +19 -0
- package/lib/types/remarks.d.ts +1 -1
- package/lib/types/remarks.d.ts.map +1 -1
- package/lib/types/remarks.js +11 -2
- 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 +31 -20
- package/lib/types/voice-pack.d.ts +11 -7
- package/lib/types/voice-pack.d.ts.map +1 -1
- package/lib/types/voice-pack.js +80 -49
- package/package.json +1 -1
- package/src/access.ts +7 -27
- package/src/affinity.ts +13 -0
- package/src/chatter.test.ts +68 -38
- package/src/chatter.ts +269 -316
- package/src/client/PetSprite.test.tsx +50 -15
- package/src/client/PetSprite.tsx +30 -25
- package/src/client/index.test.tsx +10 -1
- package/src/client/index.ts +29 -10
- package/src/client/locales.ts +18 -0
- package/src/client/renderers/frames2d.test.ts +101 -0
- package/src/client/renderers/frames2d.ts +138 -18
- package/src/event-projection.ts +57 -16
- package/src/ledger.ts +6 -4
- package/src/pair-access.ts +49 -0
- package/src/remarks.test.ts +10 -0
- package/src/remarks.ts +9 -2
- package/src/routes.ts +9 -4
- package/src/service.ts +36 -30
- package/src/voice-pack.test.ts +87 -25
- package/src/voice-pack.ts +87 -49
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/ledger.ts
CHANGED
|
@@ -32,6 +32,8 @@ export interface LedgerConfig {
|
|
|
32
32
|
treats?: Partial<TreatConfig>
|
|
33
33
|
/** Per-pet remark pools for the selected pet (custom slots override built-ins). */
|
|
34
34
|
remarks?: PetRemarks
|
|
35
|
+
/** Voice-pack fallback remark pools (global or pet voice pack). */
|
|
36
|
+
voiceRemarks?: PetRemarks
|
|
35
37
|
}
|
|
36
38
|
|
|
37
39
|
/** Result of one ledger interaction (the shape the pet RPC returns). */
|
|
@@ -63,7 +65,7 @@ export class PetLedger {
|
|
|
63
65
|
constructor(persist: PetPersist, config: LedgerConfig = {}) {
|
|
64
66
|
this.affinityConfig = { ...defaultAffinityConfig, ...(config.affinity ?? {}) }
|
|
65
67
|
this.treatConfig = { ...defaultTreatConfig, ...(config.treats ?? {}) }
|
|
66
|
-
this.picker = new RemarkPicker(config.remarks)
|
|
68
|
+
this.picker = new RemarkPicker(config.remarks, config.voiceRemarks)
|
|
67
69
|
this.current = persist
|
|
68
70
|
}
|
|
69
71
|
|
|
@@ -124,10 +126,10 @@ export class PetLedger {
|
|
|
124
126
|
|
|
125
127
|
/**
|
|
126
128
|
* Swap the reaction pools to another pet's custom remarks (called on pet
|
|
127
|
-
* selection). Slots the pet does not declare fall back to built-ins.
|
|
129
|
+
* selection). Slots the pet does not declare fall back to voice packs or built-ins.
|
|
128
130
|
*/
|
|
129
|
-
setRemarks(remarks?: PetRemarks): void {
|
|
130
|
-
this.picker = new RemarkPicker(remarks)
|
|
131
|
+
setRemarks(remarks?: PetRemarks, voiceRemarks?: PetRemarks): void {
|
|
132
|
+
this.picker = new RemarkPicker(remarks, voiceRemarks)
|
|
131
133
|
}
|
|
132
134
|
|
|
133
135
|
/**
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Generated by scripts/sync-shared.mjs from shared/host/pair-access.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
|
|
2
|
+
/**
|
|
3
|
+
* Pairing trust fence shared by plugins that expose host routes: loopback
|
|
4
|
+
* (the desktop) always passes; a live paired-device cookie is an additional
|
|
5
|
+
* allow path when remote-web-ui is loaded. The consuming plugin never
|
|
6
|
+
* depends on that plugin — without the service the fence stays
|
|
7
|
+
* loopback-only.
|
|
8
|
+
*
|
|
9
|
+
* Per-package wrappers (access.ts) call this with their own name so each
|
|
10
|
+
* plugin keeps a self-describing export; the security decision lives only
|
|
11
|
+
* here.
|
|
12
|
+
*/
|
|
13
|
+
import type { IncomingMessage } from 'node:http'
|
|
14
|
+
import { isLoopbackRequest } from './loopback.ts'
|
|
15
|
+
|
|
16
|
+
/** Structural pairing lookup (no package dependency on remote-web-ui). */
|
|
17
|
+
interface PairingAccess {
|
|
18
|
+
isPairedDevice(request: IncomingMessage): boolean
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Structural host-context shape: shared sources carry no @deepseek-ai
|
|
23
|
+
* dependency (the shared package must typecheck standalone), so the fence
|
|
24
|
+
* reads only the two members it needs; cordis Context satisfies this.
|
|
25
|
+
* ctx.get is optional on the test harness; production Context always has it.
|
|
26
|
+
*/
|
|
27
|
+
interface LookupCtx {
|
|
28
|
+
get?(name: string, strict?: boolean): unknown
|
|
29
|
+
remoteWebUiPairing?: PairingAccess
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Whether this request may enter the plugin's host routes.
|
|
34
|
+
* @param ctx - host context; may expose remoteWebUiPairing.
|
|
35
|
+
* @param request - the incoming HTTP request.
|
|
36
|
+
* @returns true for loopback, or a live paired-device cookie.
|
|
37
|
+
*/
|
|
38
|
+
export function isPairedOrLoopbackAllowed(ctx: LookupCtx, request: IncomingMessage): boolean {
|
|
39
|
+
if (isLoopbackRequest(request)) return true
|
|
40
|
+
const fromGet = typeof ctx.get === 'function' ? ctx.get('remoteWebUiPairing', false) : undefined
|
|
41
|
+
const pairing = (isPairingAccess(fromGet) ? fromGet : ctx.remoteWebUiPairing)
|
|
42
|
+
return pairing?.isPairedDevice(request) === true
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isPairingAccess(value: unknown): value is PairingAccess {
|
|
46
|
+
return value !== undefined
|
|
47
|
+
&& value !== null
|
|
48
|
+
&& typeof (value as PairingAccess).isPairedDevice === 'function'
|
|
49
|
+
}
|
package/src/remarks.test.ts
CHANGED
|
@@ -54,6 +54,16 @@ describe('RemarkPicker', () => {
|
|
|
54
54
|
expect(picker.pick('pet')).toBe('专属摸头台词')
|
|
55
55
|
expect(picker.pick('feed')).toBe(BUILTIN_REMARKS.feed[0])
|
|
56
56
|
})
|
|
57
|
+
|
|
58
|
+
it('layers pet custom lines over voice pack lines, falling back to built-ins (#1226)', () => {
|
|
59
|
+
const picker = new RemarkPicker(
|
|
60
|
+
{ pet: ['Pet-level line'] },
|
|
61
|
+
{ pet: ['Voice-level pet line'], feed: ['Voice-level feed line'] },
|
|
62
|
+
)
|
|
63
|
+
expect(picker.pick('pet')).toBe('Pet-level line')
|
|
64
|
+
expect(picker.pick('feed')).toBe('Voice-level feed line')
|
|
65
|
+
expect(picker.pick('noTreats')).toBe(BUILTIN_REMARKS.noTreats[0])
|
|
66
|
+
})
|
|
57
67
|
})
|
|
58
68
|
|
|
59
69
|
describe('normalizePetRemarks', () => {
|
package/src/remarks.ts
CHANGED
|
@@ -184,11 +184,18 @@ export class RemarkPicker {
|
|
|
184
184
|
private readonly counters = new Map<RemarkKind, number>()
|
|
185
185
|
private readonly pools: Record<RemarkKind, readonly string[]>
|
|
186
186
|
|
|
187
|
-
constructor(overrides?: PetRemarks) {
|
|
187
|
+
constructor(overrides?: PetRemarks, voiceOverrides?: PetRemarks) {
|
|
188
188
|
this.pools = {} as Record<RemarkKind, readonly string[]>
|
|
189
189
|
for (const kind of REMARK_KINDS) {
|
|
190
190
|
const custom = overrides?.[kind]
|
|
191
|
-
|
|
191
|
+
const voice = voiceOverrides?.[kind]
|
|
192
|
+
if (custom !== undefined && custom.length > 0) {
|
|
193
|
+
this.pools[kind] = custom
|
|
194
|
+
} else if (voice !== undefined && voice.length > 0) {
|
|
195
|
+
this.pools[kind] = voice
|
|
196
|
+
} else {
|
|
197
|
+
this.pools[kind] = BUILTIN_REMARKS[kind]
|
|
198
|
+
}
|
|
192
199
|
}
|
|
193
200
|
}
|
|
194
201
|
|
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
|
|
@@ -301,10 +297,12 @@ export class PetService extends Service {
|
|
|
301
297
|
persist = { ...persist, petId: this.registry.defaultEntry().id }
|
|
302
298
|
}
|
|
303
299
|
const selected = this.registry.byId(persist.petId) ?? this.registry.defaultEntry()
|
|
300
|
+
const voiceRemarks = mergeVoicePacks(this.registry.globalVoice, selected.voice)?.remarks
|
|
304
301
|
const ledgerConfig: LedgerConfig = {
|
|
305
302
|
affinity: config.affinity,
|
|
306
303
|
treats: config.treats,
|
|
307
304
|
remarks: selected.remarks,
|
|
305
|
+
voiceRemarks,
|
|
308
306
|
}
|
|
309
307
|
this.ledger = new PetLedger(persist, ledgerConfig)
|
|
310
308
|
this.stateConfig = { ...defaultPetStateConfig, ...(config.state ?? {}) }
|
|
@@ -338,8 +336,8 @@ export class PetService extends Service {
|
|
|
338
336
|
}
|
|
339
337
|
|
|
340
338
|
/** RPC: current pet state snapshot. */
|
|
341
|
-
async state(): Promise<PetStateView> {
|
|
342
|
-
return this.view()
|
|
339
|
+
async state(currentSessionId?: string): Promise<PetStateView> {
|
|
340
|
+
return this.view(currentSessionId)
|
|
343
341
|
}
|
|
344
342
|
|
|
345
343
|
/** Current persisted display config (read-only view). */
|
|
@@ -394,7 +392,8 @@ export class PetService extends Service {
|
|
|
394
392
|
const entry = this.registry.byId(petId)
|
|
395
393
|
if (entry === undefined) return { ok: false, error: 'unknown-pet' }
|
|
396
394
|
this.ledger.setPetId(entry.id)
|
|
397
|
-
this.
|
|
395
|
+
const voiceRemarks = mergeVoicePacks(this.registry.globalVoice, entry.voice)?.remarks
|
|
396
|
+
this.ledger.setRemarks(entry.remarks, voiceRemarks)
|
|
398
397
|
this.flush()
|
|
399
398
|
this.syncSettingsFromPet()
|
|
400
399
|
return { ok: true, petId: entry.id }
|
|
@@ -763,37 +762,45 @@ export class PetService extends Service {
|
|
|
763
762
|
if (this.ledger.rewardLegacyTurn(Date.now())) this.flush()
|
|
764
763
|
}
|
|
765
764
|
|
|
766
|
-
private view(): PetStateView {
|
|
765
|
+
private view(currentSessionId?: string): PetStateView {
|
|
767
766
|
const snapshot = this.machine.render()
|
|
768
767
|
const entry = this.activeEntry()
|
|
769
|
-
// One bubble per concurrently active TOP-LEVEL session
|
|
770
|
-
//
|
|
771
|
-
//
|
|
772
|
-
//
|
|
773
|
-
//
|
|
774
|
-
//
|
|
768
|
+
// One bubble per concurrently active TOP-LEVEL session. The GUI's current
|
|
769
|
+
// session leads the stack when reported (the browser half passes its
|
|
770
|
+
// session list's 'current'); everything else keeps the most recent
|
|
771
|
+
// meaningful event order. Subagent children render no bubble of their own
|
|
772
|
+
// (their activity already shows through the spawning conversation's
|
|
773
|
+
// bubble/display, and the bubble buttons navigate to GUI sessions, which
|
|
774
|
+
// subagents are not). Sessions whose own machine has settled (no bubble
|
|
775
|
+
// copy) drop out, so a finished turn does not leave a stale bubble behind.
|
|
775
776
|
const sessions: PetSessionView[] = []
|
|
776
777
|
for (const [session, activity] of [...this.sessionActivity.entries()].reverse()) {
|
|
777
778
|
if (sessions.length >= MAX_SESSION_BUBBLES) break
|
|
778
779
|
if (session.header?.origin === 'subagent') continue
|
|
779
780
|
const perSession = activity.machine.render()
|
|
780
781
|
if (perSession.bubble === undefined) continue
|
|
782
|
+
// Each session's whisper rides its own bubble while fresh; an expired
|
|
783
|
+
// whisper simply stops appearing (the client's 2 s poll drops it).
|
|
784
|
+
const whisper = activity.whisper
|
|
785
|
+
const freshWhisper = whisper !== undefined && Date.now() - whisper.at < WHISPER_TTL_MS
|
|
786
|
+
? whisper.text
|
|
787
|
+
: undefined
|
|
781
788
|
sessions.push({
|
|
782
789
|
sessionId: String(session.id),
|
|
783
790
|
animation: perSession.animation,
|
|
784
791
|
bubble: perSession.bubble,
|
|
785
792
|
phase: perSession.phase,
|
|
793
|
+
...(freshWhisper === undefined ? {} : { whisper: freshWhisper }),
|
|
786
794
|
})
|
|
787
795
|
}
|
|
788
|
-
// The
|
|
789
|
-
//
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
: undefined
|
|
796
|
+
// The browser half reports the session the user is currently on; that
|
|
797
|
+
// session's bubble leads the stack (its whisper then speaks on the
|
|
798
|
+
// collapsed single bubble). An unreported or absent session keeps the
|
|
799
|
+
// legacy most-recent-first order.
|
|
800
|
+
if (currentSessionId !== undefined) {
|
|
801
|
+
const index = sessions.findIndex(session => session.sessionId === currentSessionId)
|
|
802
|
+
if (index > 0) sessions.unshift(sessions.splice(index, 1)[0]!)
|
|
803
|
+
}
|
|
797
804
|
const decoration = this.activeDecoration()
|
|
798
805
|
// Gameplay view: a read-only projection. The settle runs on a copy, so
|
|
799
806
|
// polling never writes pet.json (verbs settle and persist).
|
|
@@ -812,7 +819,6 @@ export class PetService extends Service {
|
|
|
812
819
|
phase: snapshot.phase,
|
|
813
820
|
sessionActive: snapshot.sessionActive,
|
|
814
821
|
sessions,
|
|
815
|
-
...(freshWhisper === undefined ? {} : { whisper: freshWhisper }),
|
|
816
822
|
...(decoration === undefined ? {} : { decoration }),
|
|
817
823
|
affinity: this.ledger.affinityView(Date.now()),
|
|
818
824
|
display: { ...this.ledger.snapshot.display },
|
package/src/voice-pack.test.ts
CHANGED
|
@@ -12,7 +12,8 @@ import {
|
|
|
12
12
|
normalizePanel,
|
|
13
13
|
normalizePool,
|
|
14
14
|
normalizeVoicePack,
|
|
15
|
-
|
|
15
|
+
normalizeWhisperCategories,
|
|
16
|
+
normalizeWhisperResults,
|
|
16
17
|
PANEL_ACTIONS,
|
|
17
18
|
PANEL_LABEL_KEYS,
|
|
18
19
|
PANEL_STAT_KEYS,
|
|
@@ -21,7 +22,7 @@ import {
|
|
|
21
22
|
VOICE_PACK_V1,
|
|
22
23
|
WHISPER_KEYS,
|
|
23
24
|
} from './voice-pack.ts'
|
|
24
|
-
import { STATUS_SCENES, TOOL_CATEGORIES } from './chatter.ts'
|
|
25
|
+
import { STATUS_SCENES, TOOL_CATEGORIES, WHISPER_CATEGORIES, WHISPER_RESULTS } from './chatter.ts'
|
|
25
26
|
import { petPackageRoot } from './registry.ts'
|
|
26
27
|
|
|
27
28
|
function collectWarnings(pack: unknown): { pack: ReturnType<typeof normalizeVoicePack>; warnings: string[] } {
|
|
@@ -52,6 +53,45 @@ describe('normalizeVoicePack structure', () => {
|
|
|
52
53
|
expect(warnings.join('\n')).toContain('mystery')
|
|
53
54
|
expect(warnings.join('\n')).toContain('voicePackVersion')
|
|
54
55
|
})
|
|
56
|
+
|
|
57
|
+
it('normalizes top-level remarks and ranks (#1226)', () => {
|
|
58
|
+
const { pack, warnings } = collectWarnings({
|
|
59
|
+
remarks: {
|
|
60
|
+
pet: ['Purr~ So comfortable!'],
|
|
61
|
+
feed: 'Yummy fish!',
|
|
62
|
+
},
|
|
63
|
+
ranks: {
|
|
64
|
+
'0': 'Baby Whale',
|
|
65
|
+
'幼鲸': 'Little Whale',
|
|
66
|
+
},
|
|
67
|
+
})
|
|
68
|
+
expect(pack).toBeDefined()
|
|
69
|
+
expect(pack?.remarks?.pet).toEqual(['Purr~ So comfortable!'])
|
|
70
|
+
expect(pack?.remarks?.feed).toEqual(['Yummy fish!'])
|
|
71
|
+
expect(pack?.ranks).toEqual({
|
|
72
|
+
'0': 'Baby Whale',
|
|
73
|
+
'幼鲸': 'Little Whale',
|
|
74
|
+
})
|
|
75
|
+
expect(warnings).toHaveLength(0)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('merges remarks and ranks across layers (#1226)', () => {
|
|
79
|
+
const base = normalizeVoicePack({
|
|
80
|
+
remarks: { pet: ['Base pet'], feed: ['Base feed'] },
|
|
81
|
+
ranks: { '0': 'Base 0', '25': 'Base 25' },
|
|
82
|
+
})
|
|
83
|
+
const layer = normalizeVoicePack({
|
|
84
|
+
remarks: { pet: ['Layer pet'] },
|
|
85
|
+
ranks: { '0': 'Layer 0' },
|
|
86
|
+
})
|
|
87
|
+
const merged = mergeVoicePacks(base, layer)
|
|
88
|
+
expect(merged?.remarks?.pet).toEqual(['Layer pet'])
|
|
89
|
+
expect(merged?.remarks?.feed).toEqual(['Base feed'])
|
|
90
|
+
expect(merged?.ranks).toEqual({
|
|
91
|
+
'0': 'Layer 0',
|
|
92
|
+
'25': 'Base 25',
|
|
93
|
+
})
|
|
94
|
+
})
|
|
55
95
|
})
|
|
56
96
|
|
|
57
97
|
describe('normalizePool', () => {
|
|
@@ -85,31 +125,40 @@ describe('normalizePool', () => {
|
|
|
85
125
|
})
|
|
86
126
|
|
|
87
127
|
it('preserves an explicit empty array (mute semantics) but not absent', () => {
|
|
88
|
-
expect(normalizePool([], '
|
|
89
|
-
expect(normalizePool(undefined, '
|
|
128
|
+
expect(normalizePool([], 'whisperCategory')).toEqual([])
|
|
129
|
+
expect(normalizePool(undefined, 'whisperCategory')).toBeUndefined()
|
|
90
130
|
})
|
|
91
131
|
})
|
|
92
132
|
|
|
93
|
-
describe('
|
|
94
|
-
it('
|
|
133
|
+
describe('normalizeWhisperCategories', () => {
|
|
134
|
+
it('keeps known categories, keeps an explicit empty pool (mute) and warns on unknown keys', () => {
|
|
95
135
|
const warnings: string[] = []
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
{ keywords: ['有词'], pool: [] },
|
|
100
|
-
], m => warnings.push(m))
|
|
101
|
-
expect(rules).toEqual([{ keywords: ['测试通过'], pool: ['全绿'] }])
|
|
102
|
-
expect(warnings.length).toBeGreaterThan(0)
|
|
136
|
+
const pools = normalizeWhisperCategories({ thinking: ['自定义思考'], running: [], bogus: ['x'] }, m => warnings.push(m))
|
|
137
|
+
expect(pools).toEqual({ thinking: ['自定义思考'], running: [] })
|
|
138
|
+
expect(warnings.join('\n')).toContain('unknown whisper category')
|
|
103
139
|
})
|
|
104
140
|
|
|
105
|
-
it('
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
expect(
|
|
141
|
+
it('returns undefined for an absent or non-object section', () => {
|
|
142
|
+
expect(normalizeWhisperCategories(undefined)).toBeUndefined()
|
|
143
|
+
const warnings: string[] = []
|
|
144
|
+
expect(normalizeWhisperCategories(['x'], m => warnings.push(m))).toBeUndefined()
|
|
145
|
+
expect(warnings.join('\n')).toContain('must be an object')
|
|
146
|
+
})
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
describe('normalizeWhisperResults', () => {
|
|
150
|
+
it('keeps known outcomes, keeps an explicit empty pool (mute) and warns on unknown keys', () => {
|
|
151
|
+
const warnings: string[] = []
|
|
152
|
+
const pools = normalizeWhisperResults({ pass: ['自定义全绿'], done: [], bogus: ['x'] }, m => warnings.push(m))
|
|
153
|
+
expect(pools).toEqual({ pass: ['自定义全绿'], done: [] })
|
|
154
|
+
expect(warnings.join('\n')).toContain('unknown whisper result')
|
|
109
155
|
})
|
|
110
156
|
|
|
111
|
-
it('returns
|
|
112
|
-
expect(
|
|
157
|
+
it('returns undefined for an absent or non-object section', () => {
|
|
158
|
+
expect(normalizeWhisperResults(undefined)).toBeUndefined()
|
|
159
|
+
const warnings: string[] = []
|
|
160
|
+
expect(normalizeWhisperResults('x', m => warnings.push(m))).toBeUndefined()
|
|
161
|
+
expect(warnings.join('\n')).toContain('must be an object')
|
|
113
162
|
})
|
|
114
163
|
})
|
|
115
164
|
|
|
@@ -153,12 +202,22 @@ describe('mergeVoicePacks', () => {
|
|
|
153
202
|
expect(merged?.panel?.labels).toEqual({ feed: '宠物投喂', hide: '宠物藏' })
|
|
154
203
|
})
|
|
155
204
|
|
|
156
|
-
it('lets the pet pack replace whisper
|
|
157
|
-
const global = normalizeVoicePack({ whispers: {
|
|
158
|
-
const pet = normalizeVoicePack({ whispers: {
|
|
205
|
+
it('lets the pet pack replace whisper keys while the global stays', () => {
|
|
206
|
+
const global = normalizeVoicePack({ whispers: { categories: { thinking: ['全局思考'] }, results: { pass: ['全局全绿'] } } })
|
|
207
|
+
const pet = normalizeVoicePack({ whispers: { categories: { thinking: ['宠物思考'] } } })
|
|
159
208
|
const merged = mergeVoicePacks(global, pet)
|
|
160
|
-
expect(merged?.overrides.whispers?.
|
|
161
|
-
expect(merged?.overrides.whispers?.
|
|
209
|
+
expect(merged?.overrides.whispers?.categories?.thinking).toEqual(['宠物思考'])
|
|
210
|
+
expect(merged?.overrides.whispers?.results?.pass).toEqual(['全局全绿'])
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('warns and ignores the legacy generic / rules whisper fields', () => {
|
|
214
|
+
const { pack, warnings } = collectWarnings({
|
|
215
|
+
whispers: { generic: ['老环境池'], rules: [{ keywords: ['测试'], pool: ['老全绿'] }], categories: { thinking: ['新思考'] } },
|
|
216
|
+
})
|
|
217
|
+
expect(pack?.overrides.whispers?.categories?.thinking).toEqual(['新思考'])
|
|
218
|
+
expect(pack?.overrides.whispers?.categories?.generic).toBeUndefined()
|
|
219
|
+
expect(warnings.join('\n')).toContain('generic')
|
|
220
|
+
expect(warnings.join('\n')).toContain('rules')
|
|
162
221
|
})
|
|
163
222
|
|
|
164
223
|
it('returns undefined when every layer is empty', () => {
|
|
@@ -200,7 +259,10 @@ describe('voice-pack schema file drift lock', () => {
|
|
|
200
259
|
const props = schema.properties
|
|
201
260
|
expect(new Set(Object.keys(props.status?.properties ?? {}))).toEqual(new Set(STATUS_SCENES))
|
|
202
261
|
expect(new Set(Object.keys(props.tools?.properties ?? {}))).toEqual(new Set(TOOL_CATEGORIES))
|
|
203
|
-
|
|
262
|
+
const whispers = props.whispers?.properties ?? {}
|
|
263
|
+
expect(new Set(Object.keys(whispers))).toEqual(WHISPER_KEYS)
|
|
264
|
+
expect(new Set(Object.keys((whispers.categories as { properties?: Record<string, unknown> })?.properties ?? {}))).toEqual(new Set(WHISPER_CATEGORIES))
|
|
265
|
+
expect(new Set(Object.keys((whispers.results as { properties?: Record<string, unknown> })?.properties ?? {}))).toEqual(new Set(WHISPER_RESULTS))
|
|
204
266
|
const panel = props.panel?.properties ?? {}
|
|
205
267
|
const labels = (panel.labels as { properties?: Record<string, unknown> } | undefined)?.properties ?? {}
|
|
206
268
|
const stats = (panel.stats as { properties?: Record<string, unknown> } | undefined)?.properties ?? {}
|