@linxin666/dsh-pet 0.1.20 → 0.2.1
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 +18 -11
- package/README.zh.md +18 -11
- package/assets/whale/pet.json +9 -0
- package/lib/client.js +394 -58
- package/lib/client.js.map +1 -1
- package/lib/index.js +1007 -43
- package/lib/invariant.js +1 -1
- package/lib/{state-Diiq8vjk.js → state-DrMX22GL.js} +86 -28
- package/lib/types/affinity.d.ts +4 -0
- package/lib/types/affinity.d.ts.map +1 -1
- package/lib/types/affinity.js +32 -6
- package/lib/types/chatter.d.ts +111 -0
- package/lib/types/chatter.d.ts.map +1 -0
- package/lib/types/chatter.js +756 -0
- package/lib/types/client/PetDockEntry.d.ts.map +1 -1
- package/lib/types/client/PetDockEntry.js +1 -1
- package/lib/types/client/PetSprite.d.ts.map +1 -1
- package/lib/types/client/PetSprite.js +133 -16
- package/lib/types/client/PluginSettingsCard.d.ts +28 -0
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PluginSettingsCard.js +147 -6
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +1 -0
- package/lib/types/client/locales.d.ts +4 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +4 -0
- package/lib/types/client/sequences.d.ts +10 -0
- package/lib/types/client/sequences.d.ts.map +1 -0
- package/lib/types/client/sequences.js +20 -0
- package/lib/types/event-projection.d.ts +14 -1
- package/lib/types/event-projection.d.ts.map +1 -1
- package/lib/types/event-projection.js +38 -17
- package/lib/types/ledger.d.ts.map +1 -1
- package/lib/types/ledger.js +15 -7
- package/lib/types/loopback.d.ts +25 -0
- package/lib/types/loopback.d.ts.map +1 -0
- package/lib/types/loopback.js +68 -0
- package/lib/types/persist.d.ts.map +1 -1
- package/lib/types/persist.js +3 -1
- package/lib/types/registry.d.ts +7 -1
- package/lib/types/registry.d.ts.map +1 -1
- package/lib/types/registry.js +47 -3
- package/lib/types/remarks.d.ts +2 -0
- package/lib/types/remarks.d.ts.map +1 -1
- package/lib/types/remarks.js +35 -0
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +14 -0
- package/lib/types/service.d.ts +6 -0
- package/lib/types/service.d.ts.map +1 -1
- package/lib/types/service.js +15 -2
- package/lib/types/state.d.ts +7 -4
- package/lib/types/state.d.ts.map +1 -1
- package/lib/types/state.js +20 -20
- package/package.json +9 -9
- package/src/affinity.test.ts +4 -2
- package/src/affinity.ts +37 -6
- package/src/chatter.test.ts +154 -0
- package/src/chatter.ts +787 -0
- package/src/client/PetDockEntry.test.tsx +93 -0
- package/src/client/PetDockEntry.tsx +1 -0
- package/src/client/PetSprite.test.tsx +253 -3
- package/src/client/PetSprite.tsx +192 -37
- package/src/client/PluginSettingsCard.tsx +229 -18
- package/src/client/index.test.tsx +95 -0
- package/src/client/index.ts +1 -0
- package/src/client/locales.ts +4 -0
- package/src/client/pet-css.test.ts +54 -0
- package/src/client/pet.module.css +215 -57
- package/src/client/sequences.test.ts +33 -0
- package/src/client/sequences.ts +33 -0
- package/src/client/settings-card.module.css +90 -1
- package/src/event-projection.ts +49 -16
- package/src/ledger.test.ts +42 -1
- package/src/ledger.ts +15 -7
- package/src/loopback.ts +63 -0
- package/src/persist.test.ts +18 -1
- package/src/persist.ts +3 -1
- package/src/registry.test.ts +79 -4
- package/src/registry.ts +58 -6
- package/src/remarks.ts +36 -0
- package/src/routes.ts +11 -0
- package/src/service.ts +27 -2
- package/src/state.test.ts +13 -0
- package/src/state.ts +24 -18
package/src/registry.ts
CHANGED
|
@@ -21,7 +21,7 @@ import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
|
21
21
|
import { homedir } from 'node:os'
|
|
22
22
|
import { basename, dirname, isAbsolute, join, resolve } from 'node:path'
|
|
23
23
|
import { fileURLToPath } from 'node:url'
|
|
24
|
-
import type { PetAnimation } from './state.ts'
|
|
24
|
+
import type { ActivityPhase, PetAnimation } from './state.ts'
|
|
25
25
|
import { normalizePetRemarks, type PetRemarks, type PetRemarksManifest } from './remarks.ts'
|
|
26
26
|
|
|
27
27
|
/** Fixed row order of the 9-state animation contract. */
|
|
@@ -135,6 +135,8 @@ export interface PetManifest {
|
|
|
135
135
|
frames?: number[]
|
|
136
136
|
/** Optional per-track rhythm overrides; omitted tracks use the defaults. */
|
|
137
137
|
tracks?: Partial<Record<PetAnimation, PetTrackOverride>>
|
|
138
|
+
/** Optional per-scene track sequences; every declared sequence has at least 5 items. */
|
|
139
|
+
sequences?: Partial<Record<ActivityPhase, PetAnimation[]>>
|
|
138
140
|
/**
|
|
139
141
|
* Optional witty-remark overrides the pet speaks on interactions
|
|
140
142
|
* (community contributions use this to give their pet its own voice).
|
|
@@ -165,8 +167,12 @@ export interface PetDefinition {
|
|
|
165
167
|
columns: number
|
|
166
168
|
/** Per-row frame counts (length 9, row order above). */
|
|
167
169
|
rows: number[]
|
|
170
|
+
/** Total atlas rows (9 for v1, 11 for v2 look-row atlases). */
|
|
171
|
+
atlasRows: number
|
|
168
172
|
/** Fully resolved animation tracks (frames + durations + loop/fallback). */
|
|
169
173
|
tracks: Record<PetAnimation, PetTrackDef>
|
|
174
|
+
/** Validated per-scene track sequences; omitted scenes keep single-track playback. */
|
|
175
|
+
sequences?: Partial<Record<ActivityPhase, PetAnimation[]>>
|
|
170
176
|
/** Browser URL of the atlas (served by the host asset route). */
|
|
171
177
|
atlasUrl: string
|
|
172
178
|
/** Browser URL of the manifest (served by the host asset route). */
|
|
@@ -209,6 +215,39 @@ const PET_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/
|
|
|
209
215
|
/** Safe path-segment charset for atlas files. */
|
|
210
216
|
const PATH_SEGMENT_PATTERN = /^[A-Za-z0-9._-]+$/
|
|
211
217
|
const PET_NAME_MAX_LENGTH = 80
|
|
218
|
+
const PET_PHASES: readonly ActivityPhase[] = ['idle', 'waiting', 'thinking', 'tool', 'review', 'done', 'failed']
|
|
219
|
+
|
|
220
|
+
/** Validate optional scene sequences without rejecting an otherwise usable pet. */
|
|
221
|
+
function normalizeSequences(
|
|
222
|
+
raw: unknown,
|
|
223
|
+
id: string,
|
|
224
|
+
warn: (message: string) => void,
|
|
225
|
+
): Partial<Record<ActivityPhase, PetAnimation[]>> | undefined {
|
|
226
|
+
if (raw === undefined) return undefined
|
|
227
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
228
|
+
warn('manifest ' + id + ': sequences must be an object keyed by activity phase')
|
|
229
|
+
return undefined
|
|
230
|
+
}
|
|
231
|
+
const sequences: Partial<Record<ActivityPhase, PetAnimation[]>> = {}
|
|
232
|
+
for (const [phase, value] of Object.entries(raw as Record<string, unknown>)) {
|
|
233
|
+
if (!PET_PHASES.includes(phase as ActivityPhase)) {
|
|
234
|
+
warn('manifest ' + id + ': unknown sequence phase ' + JSON.stringify(phase))
|
|
235
|
+
continue
|
|
236
|
+
}
|
|
237
|
+
if (!Array.isArray(value) || value.length < 5) {
|
|
238
|
+
warn('manifest ' + id + ': sequence ' + phase + ' must contain at least 5 animations')
|
|
239
|
+
continue
|
|
240
|
+
}
|
|
241
|
+
const unknownIndex = value.findIndex(animation => typeof animation !== 'string' || !PET_ROW_ORDER.includes(animation as PetAnimation))
|
|
242
|
+
if (unknownIndex !== -1) {
|
|
243
|
+
const unknown = value[unknownIndex]
|
|
244
|
+
warn('manifest ' + id + ': sequence ' + phase + ' contains unknown animation ' + JSON.stringify(unknown))
|
|
245
|
+
continue
|
|
246
|
+
}
|
|
247
|
+
sequences[phase as ActivityPhase] = value as PetAnimation[]
|
|
248
|
+
}
|
|
249
|
+
return Object.keys(sequences).length === 0 ? undefined : sequences
|
|
250
|
+
}
|
|
212
251
|
|
|
213
252
|
/**
|
|
214
253
|
* Normalize one parsed manifest into a renderable pet entry, or undefined
|
|
@@ -256,11 +295,14 @@ export function resolvePetManifest(
|
|
|
256
295
|
height: finiteInt(rawCell.height, DEFAULT_PET_CELL.height, 2048),
|
|
257
296
|
}
|
|
258
297
|
const columns = finiteInt(source.columns, DEFAULT_PET_COLUMNS, 32)
|
|
298
|
+
// v2 atlases (spriteVersionNumber 2) hold 11 rows: 9 animation rows + 2 look rows.
|
|
299
|
+
const atlasRowCount = source.spriteVersionNumber === 2 ? 11 : DEFAULT_PET_ROW_COUNT
|
|
259
300
|
const rows = DEFAULT_FRAME_COUNTS.map((fallback, index) => {
|
|
260
301
|
const value = Array.isArray(source.frames) ? source.frames[index] : undefined
|
|
261
302
|
return finiteInt(value, fallback, columns)
|
|
262
303
|
})
|
|
263
304
|
const remarks = normalizePetRemarks(source.remarks, message => warn('manifest ' + id + ': ' + message))
|
|
305
|
+
const sequences = normalizeSequences(source.sequences, id, warn)
|
|
264
306
|
const trackOverrides = (typeof source.tracks === 'object' && source.tracks !== null ? source.tracks : {}) as Partial<Record<PetAnimation, PetTrackOverride>>
|
|
265
307
|
const tracks = {} as Record<PetAnimation, PetTrackDef>
|
|
266
308
|
for (const [row, animation] of PET_ROW_ORDER.entries()) {
|
|
@@ -295,7 +337,9 @@ export function resolvePetManifest(
|
|
|
295
337
|
cell,
|
|
296
338
|
columns,
|
|
297
339
|
rows,
|
|
340
|
+
atlasRows: atlasRowCount,
|
|
298
341
|
tracks,
|
|
342
|
+
...(sequences === undefined ? {} : { sequences }),
|
|
299
343
|
atlasUrl: assetUrl(assetPrefix, id, spritesheet),
|
|
300
344
|
manifestUrl: assetUrl(assetPrefix, id, 'pet.json'),
|
|
301
345
|
dir,
|
|
@@ -366,10 +410,18 @@ export function loadPetRegistry(options: PetRegistryOptions): PetRegistry {
|
|
|
366
410
|
}
|
|
367
411
|
|
|
368
412
|
for (const manifest of options.extra ?? []) {
|
|
369
|
-
const
|
|
413
|
+
const raw = manifest.spritesheetPath
|
|
414
|
+
const dir = raw === undefined || isAbsolute(raw)
|
|
370
415
|
? join(packageRoot, 'assets', 'extra')
|
|
371
|
-
: dirname(resolve(packageRoot,
|
|
372
|
-
|
|
416
|
+
: dirname(resolve(packageRoot, raw))
|
|
417
|
+
// petAtlasFile joins entry.dir (already the spritesheet's parent when the
|
|
418
|
+
// path is package-relative) with entry.spritesheetPath, so the stored path
|
|
419
|
+
// must be the basename only — otherwise the directory segment is applied
|
|
420
|
+
// twice and the atlas 404s.
|
|
421
|
+
const source = raw === undefined || isAbsolute(raw)
|
|
422
|
+
? manifest
|
|
423
|
+
: { ...manifest, spritesheetPath: basename(raw) }
|
|
424
|
+
const entry = resolvePetManifest(source, dir, { assetPrefix, warnings })
|
|
373
425
|
if (entry === undefined) continue
|
|
374
426
|
if (byId.has(entry.id)) warnings.push('composed pet ' + entry.id + ' overrides an earlier registration')
|
|
375
427
|
byId.set(entry.id, entry)
|
|
@@ -393,7 +445,9 @@ export function petEntryView(entry: PetEntry): PetDefinition {
|
|
|
393
445
|
cell: entry.cell,
|
|
394
446
|
columns: entry.columns,
|
|
395
447
|
rows: entry.rows,
|
|
448
|
+
atlasRows: entry.atlasRows,
|
|
396
449
|
tracks: entry.tracks,
|
|
450
|
+
...(entry.sequences === undefined ? {} : { sequences: entry.sequences }),
|
|
397
451
|
atlasUrl: entry.atlasUrl,
|
|
398
452
|
manifestUrl: entry.manifestUrl,
|
|
399
453
|
}
|
|
@@ -408,5 +462,3 @@ export function petAtlasFile(entry: PetEntry): string {
|
|
|
408
462
|
export function petDirAlias(entry: PetEntry): string {
|
|
409
463
|
return basename(entry.dir)
|
|
410
464
|
}
|
|
411
|
-
|
|
412
|
-
|
package/src/remarks.ts
CHANGED
|
@@ -53,6 +53,12 @@ export const BUILTIN_REMARKS: Readonly<Record<RemarkKind, readonly string[]>> =
|
|
|
53
53
|
'蹭蹭你的手心,这是回礼~',
|
|
54
54
|
'多摸摸我,亲密度会涨哦!',
|
|
55
55
|
'闭眼享受中,请勿打扰~',
|
|
56
|
+
'头再低一点,够不着了~',
|
|
57
|
+
'呼噜呼噜,声音都冒出来了',
|
|
58
|
+
'这手感,比小鱼干还上瘾',
|
|
59
|
+
'摸到第三下,满意',
|
|
60
|
+
'耳朵后面,别漏了……啊,舒服',
|
|
61
|
+
'被摸得尾巴都卷起来了',
|
|
56
62
|
],
|
|
57
63
|
petCooldown: [
|
|
58
64
|
'摸过头啦,让鲸鱼娘歇口气~',
|
|
@@ -65,6 +71,12 @@ export const BUILTIN_REMARKS: Readonly<Record<RemarkKind, readonly string[]>> =
|
|
|
65
71
|
'歇一歇,摸摸的手感会更好哦~',
|
|
66
72
|
'咕……等我回个蓝~',
|
|
67
73
|
'让我先消化一下刚才的爱!',
|
|
74
|
+
'痒痒的,先让我缓一下……',
|
|
75
|
+
'再摸就掉线了,真的',
|
|
76
|
+
'库存的呼噜声用完了',
|
|
77
|
+
'手歇会儿,我也要补个蓝',
|
|
78
|
+
'舒服归舒服,得缓缓呀',
|
|
79
|
+
'再摸下去,我就要融化了',
|
|
68
80
|
],
|
|
69
81
|
feed: [
|
|
70
82
|
'呜哇!小鱼干好好吃!',
|
|
@@ -77,6 +89,12 @@ export const BUILTIN_REMARKS: Readonly<Record<RemarkKind, readonly string[]>> =
|
|
|
77
89
|
'饱餐一顿,马上满血复活~',
|
|
78
90
|
'这个味道,是幸福的味道!',
|
|
79
91
|
'吃完了还不忘舔舔爪子~',
|
|
92
|
+
'这小鱼干,是今天的顶配',
|
|
93
|
+
'一口下去,精神头全回来了',
|
|
94
|
+
'脆!香!就是这个味儿',
|
|
95
|
+
'边吃边摇尾巴,形象不要了',
|
|
96
|
+
'好吃到眼睛都眯起来了',
|
|
97
|
+
'这块鱼干我记住了,懂我的',
|
|
80
98
|
],
|
|
81
99
|
feedCooldown: [
|
|
82
100
|
'吃饱啦,晚点再喂~',
|
|
@@ -89,6 +107,12 @@ export const BUILTIN_REMARKS: Readonly<Record<RemarkKind, readonly string[]>> =
|
|
|
89
107
|
'肚皮已经鼓鼓的啦~',
|
|
90
108
|
'好吃是好吃,可也得节制呀~',
|
|
91
109
|
'等我饿了会告诉你哦~',
|
|
110
|
+
'胃说它满了,脑子说还能吃',
|
|
111
|
+
'这条得留着慢慢品',
|
|
112
|
+
'先消消食,待会儿再战',
|
|
113
|
+
'塞不下了,真塞不下了',
|
|
114
|
+
'闻着香,可惜没地方放了',
|
|
115
|
+
'嗝……这顿值了',
|
|
92
116
|
],
|
|
93
117
|
noTreats: [
|
|
94
118
|
'没有小鱼干了,多陪我工作一会儿吧~',
|
|
@@ -101,6 +125,12 @@ export const BUILTIN_REMARKS: Readonly<Record<RemarkKind, readonly string[]>> =
|
|
|
101
125
|
'饿着肚子等你完成下一轮任务~',
|
|
102
126
|
'小鱼干藏在你的工作里,去找找看!',
|
|
103
127
|
'先工作后干饭,我们的约定哦~',
|
|
128
|
+
'粮仓见底,全靠感情撑着了',
|
|
129
|
+
'饿是真饿,活也是真得干',
|
|
130
|
+
'画饼充饥……不对,画鱼干充饥',
|
|
131
|
+
'没鱼干的日子,靠意志力过',
|
|
132
|
+
'下一轮任务,我闻到了鱼干味',
|
|
133
|
+
'先记账上,欠我两条,记住啦',
|
|
104
134
|
],
|
|
105
135
|
}
|
|
106
136
|
|
|
@@ -174,4 +204,10 @@ export class RemarkPicker {
|
|
|
174
204
|
this.counters.set(kind, index + 1)
|
|
175
205
|
return pool[index]!
|
|
176
206
|
}
|
|
207
|
+
|
|
208
|
+
/** Select a line from a stable external counter without changing local picker state. */
|
|
209
|
+
pickAt(kind: RemarkKind, count: number): string {
|
|
210
|
+
const pool = this.pools[kind]
|
|
211
|
+
return pool[Math.max(0, Math.floor(count)) % pool.length]!
|
|
212
|
+
}
|
|
177
213
|
}
|
package/src/routes.ts
CHANGED
|
@@ -17,6 +17,7 @@ import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
|
|
17
17
|
import type { PetService } from './service.ts'
|
|
18
18
|
import type { PetInteraction } from './affinity.ts'
|
|
19
19
|
import { petEntryView, type PetEntry, type PetRegistry } from './registry.ts'
|
|
20
|
+
import { isLoopbackRequest } from './loopback.ts'
|
|
20
21
|
|
|
21
22
|
/** Browser-facing base path of the pet API. */
|
|
22
23
|
export const PET_API_PREFIX = '/api/pet'
|
|
@@ -86,12 +87,20 @@ function readJsonBody(req: IncomingMessage): Promise<unknown> {
|
|
|
86
87
|
})
|
|
87
88
|
}
|
|
88
89
|
|
|
90
|
+
/** Shared route fence: the browser UI is a loopback client; LAN hosts stay out. */
|
|
91
|
+
function guard(req: IncomingMessage, res: ServerResponse): boolean {
|
|
92
|
+
if (isLoopbackRequest(req)) return true
|
|
93
|
+
json(res, 403, { ok: false, error: 'forbidden: loopback-only' })
|
|
94
|
+
return false
|
|
95
|
+
}
|
|
96
|
+
|
|
89
97
|
/** Wrap one async service call as a GET JSON route. */
|
|
90
98
|
function getRoute(path: string, run: () => Promise<unknown>): WebRoute {
|
|
91
99
|
return {
|
|
92
100
|
kind: 'exact',
|
|
93
101
|
path,
|
|
94
102
|
handler: (req: IncomingMessage, res: ServerResponse): void => {
|
|
103
|
+
if (!guard(req, res)) return
|
|
95
104
|
if (!requireMethod(req, res, 'GET')) return
|
|
96
105
|
run().then((value) => json(res, 200, value), (error) => {
|
|
97
106
|
json(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) })
|
|
@@ -106,6 +115,7 @@ function postRoute(path: string, run: (body: Record<string, unknown>) => Promise
|
|
|
106
115
|
kind: 'exact',
|
|
107
116
|
path,
|
|
108
117
|
handler: (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
118
|
+
if (!guard(req, res)) return Promise.resolve()
|
|
109
119
|
if (!requireMethod(req, res, 'POST')) return Promise.resolve()
|
|
110
120
|
return readJsonBody(req).then((body) => {
|
|
111
121
|
const record = (typeof body === 'object' && body !== null) ? body as Record<string, unknown> : {}
|
|
@@ -141,6 +151,7 @@ function dirAliases(registry: PetRegistry): Map<string, PetEntry> {
|
|
|
141
151
|
function assetHandler(registry: PetRegistry): WebRoute['handler'] {
|
|
142
152
|
const aliases = dirAliases(registry)
|
|
143
153
|
return (req: IncomingMessage, res: ServerResponse): void => {
|
|
154
|
+
if (!guard(req, res)) return
|
|
144
155
|
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
145
156
|
res.writeHead(405)
|
|
146
157
|
res.end()
|
package/src/service.ts
CHANGED
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
type PetManifest,
|
|
45
45
|
type PetRegistry,
|
|
46
46
|
} from './registry.ts'
|
|
47
|
+
import { WHISPER_TTL_MS } from './chatter.ts'
|
|
47
48
|
import {
|
|
48
49
|
defaultPetStateConfig,
|
|
49
50
|
PetStateMachine,
|
|
@@ -152,6 +153,12 @@ export interface PetStateView {
|
|
|
152
153
|
/** Stock cap. */
|
|
153
154
|
max: number
|
|
154
155
|
}
|
|
156
|
+
/**
|
|
157
|
+
* The display session's fresh inner whisper (碎碎念), when one is within
|
|
158
|
+
* its TTL — short inner-voice copy woken by the model's own output,
|
|
159
|
+
* rendered by the client as a distinct whisper bubble.
|
|
160
|
+
*/
|
|
161
|
+
whisper?: string
|
|
155
162
|
}
|
|
156
163
|
|
|
157
164
|
/** Result of `pet.interact`. */
|
|
@@ -169,6 +176,13 @@ interface SessionActivity {
|
|
|
169
176
|
machine: PetStateMachine
|
|
170
177
|
/** The session's most recent meaningful input (for display fallback). */
|
|
171
178
|
lastInput?: PetStateInput
|
|
179
|
+
/** Latest inner whisper woken by this session's model output (碎碎念). */
|
|
180
|
+
whisper?: {
|
|
181
|
+
/** Whisper copy. */
|
|
182
|
+
text: string
|
|
183
|
+
/** Epoch ms when it appeared (view-side TTL applies). */
|
|
184
|
+
at: number
|
|
185
|
+
}
|
|
172
186
|
}
|
|
173
187
|
|
|
174
188
|
/**
|
|
@@ -319,7 +333,7 @@ export class PetService extends Service {
|
|
|
319
333
|
const transition = projectOfficialEvent(event, runtime)
|
|
320
334
|
if (transition === undefined) return
|
|
321
335
|
runtime.officialEventsSeen = true
|
|
322
|
-
this.applyActivity(session, transition.input)
|
|
336
|
+
this.applyActivity(session, transition.input, transition.whisper)
|
|
323
337
|
if (transition.completedTurn !== undefined) {
|
|
324
338
|
this.rewardTurn(String(session.id), transition.completedTurn)
|
|
325
339
|
}
|
|
@@ -364,9 +378,10 @@ export class PetService extends Service {
|
|
|
364
378
|
* the session becomes the host-global display session (most recent
|
|
365
379
|
* meaningful event wins the sprite animation).
|
|
366
380
|
*/
|
|
367
|
-
private applyActivity(session: Session, input: PetStateInput): void {
|
|
381
|
+
private applyActivity(session: Session, input: PetStateInput, whisper?: string): void {
|
|
368
382
|
const activity = this.activityOf(session)
|
|
369
383
|
activity.lastInput = input
|
|
384
|
+
if (whisper !== undefined) activity.whisper = { text: whisper, at: Date.now() }
|
|
370
385
|
activity.machine.onActivityStatus(input)
|
|
371
386
|
activity.machine.onSessionActive()
|
|
372
387
|
// Move to the tail so map order reads most-recent-last, then trim the
|
|
@@ -495,6 +510,15 @@ export class PetService extends Service {
|
|
|
495
510
|
phase: perSession.phase,
|
|
496
511
|
})
|
|
497
512
|
}
|
|
513
|
+
// The display session's inner whisper rides the global view while fresh;
|
|
514
|
+
// an expired whisper simply stops appearing (the client's 2s poll drops it).
|
|
515
|
+
const displayActivity = this.displaySession === undefined
|
|
516
|
+
? undefined
|
|
517
|
+
: this.sessionActivity.get(this.displaySession)
|
|
518
|
+
const whisper = displayActivity?.whisper
|
|
519
|
+
const freshWhisper = whisper !== undefined && Date.now() - whisper.at < WHISPER_TTL_MS
|
|
520
|
+
? whisper.text
|
|
521
|
+
: undefined
|
|
498
522
|
// Read-only: the ledger settles on economic events only, never on a read,
|
|
499
523
|
// so polling the state cannot trigger pet.json writes.
|
|
500
524
|
return {
|
|
@@ -503,6 +527,7 @@ export class PetService extends Service {
|
|
|
503
527
|
phase: snapshot.phase,
|
|
504
528
|
sessionActive: snapshot.sessionActive,
|
|
505
529
|
sessions,
|
|
530
|
+
...(freshWhisper === undefined ? {} : { whisper: freshWhisper }),
|
|
506
531
|
affinity: this.ledger.affinityView(Date.now()),
|
|
507
532
|
display: { ...this.ledger.snapshot.display },
|
|
508
533
|
pet: {
|
package/src/state.test.ts
CHANGED
|
@@ -33,6 +33,19 @@ describe('PetStateMachine', () => {
|
|
|
33
33
|
expect(machine.render().bubble).toBeUndefined()
|
|
34
34
|
})
|
|
35
35
|
|
|
36
|
+
it('shows failure briefly, then clears its bubble', () => {
|
|
37
|
+
let now = 1_000_000
|
|
38
|
+
const machine = new PetStateMachine({ celebrateMs: 2400, failureMs: 2400 }, () => now)
|
|
39
|
+
machine.onSessionActive()
|
|
40
|
+
machine.onActivityStatus({ phase: 'failed', line: '执行失败' })
|
|
41
|
+
expect(machine.render()).toMatchObject({ animation: 'failed', bubble: '执行失败' })
|
|
42
|
+
now += 2399
|
|
43
|
+
expect(machine.render()).toMatchObject({ animation: 'failed', bubble: '执行失败' })
|
|
44
|
+
now += 1
|
|
45
|
+
expect(machine.render()).toMatchObject({ animation: 'idle' })
|
|
46
|
+
expect(machine.render().bubble).toBeUndefined()
|
|
47
|
+
})
|
|
48
|
+
|
|
36
49
|
it('shows the phrase bubble when present, else the line', () => {
|
|
37
50
|
const machine = new PetStateMachine(defaultPetStateConfig, () => 1_000)
|
|
38
51
|
machine.onActivityStatus({ phase: 'thinking', phrase: '查资料中', line: 'tool: grep' })
|
package/src/state.ts
CHANGED
|
@@ -53,9 +53,11 @@ export interface PetStateSnapshot {
|
|
|
53
53
|
export interface PetStateConfig {
|
|
54
54
|
/** Celebration window after `done` before settling to idle, ms (default 2400). */
|
|
55
55
|
celebrateMs: number
|
|
56
|
+
/** Failure display window before settling to idle, ms (default 2400). */
|
|
57
|
+
failureMs: number
|
|
56
58
|
}
|
|
57
59
|
|
|
58
|
-
export const defaultPetStateConfig: PetStateConfig = { celebrateMs: 2400 }
|
|
60
|
+
export const defaultPetStateConfig: PetStateConfig = { celebrateMs: 2400, failureMs: 2400 }
|
|
59
61
|
|
|
60
62
|
/**
|
|
61
63
|
* Map one activity phase onto the animation contract.
|
|
@@ -63,7 +65,7 @@ export const defaultPetStateConfig: PetStateConfig = { celebrateMs: 2400 }
|
|
|
63
65
|
* - review → `review` while answer text is streaming.
|
|
64
66
|
* - waiting → `waiting` (expectant pose, needs user input).
|
|
65
67
|
* - done → `jumping` (celebration), then back to `idle` after the window.
|
|
66
|
-
* - failed → `failed`
|
|
68
|
+
* - failed → `failed` briefly, then back to `idle`.
|
|
67
69
|
* - idle → `idle` (calm breathing loop).
|
|
68
70
|
*/
|
|
69
71
|
export function animationForPhase(phase: ActivityPhase): PetAnimation {
|
|
@@ -96,7 +98,7 @@ export function rowOf(animation: PetAnimation): number {
|
|
|
96
98
|
|
|
97
99
|
/**
|
|
98
100
|
* PetStateMachine — one instance per host process. Holds only the latest
|
|
99
|
-
* input snapshot and
|
|
101
|
+
* input snapshot and terminal-state timing; no storage, no side effects.
|
|
100
102
|
*/
|
|
101
103
|
export class PetStateMachine {
|
|
102
104
|
private phase: ActivityPhase = 'idle'
|
|
@@ -104,18 +106,23 @@ export class PetStateMachine {
|
|
|
104
106
|
private phrase: string | undefined
|
|
105
107
|
private sessionActive = false
|
|
106
108
|
private doneAt: number | undefined
|
|
109
|
+
private failedAt: number | undefined
|
|
110
|
+
private readonly config: PetStateConfig
|
|
107
111
|
|
|
108
112
|
constructor(
|
|
109
|
-
|
|
113
|
+
config: Partial<PetStateConfig> = defaultPetStateConfig,
|
|
110
114
|
private readonly now: () => number = Date.now,
|
|
111
|
-
) {
|
|
115
|
+
) {
|
|
116
|
+
this.config = { ...defaultPetStateConfig, ...config }
|
|
117
|
+
}
|
|
112
118
|
|
|
113
119
|
/** Consume one projected activity update. */
|
|
114
120
|
onActivityStatus(input: PetStateInput): void {
|
|
115
121
|
this.phase = input.phase
|
|
116
122
|
this.line = input.line
|
|
117
123
|
this.phrase = input.phrase
|
|
118
|
-
|
|
124
|
+
this.doneAt = input.phase === 'done' ? this.now() : undefined
|
|
125
|
+
this.failedAt = input.phase === 'failed' ? this.now() : undefined
|
|
119
126
|
}
|
|
120
127
|
|
|
121
128
|
/** A session became the active one (or a fresh session started). */
|
|
@@ -130,24 +137,23 @@ export class PetStateMachine {
|
|
|
130
137
|
this.line = undefined
|
|
131
138
|
this.phrase = undefined
|
|
132
139
|
this.doneAt = undefined
|
|
140
|
+
this.failedAt = undefined
|
|
133
141
|
}
|
|
134
142
|
|
|
135
143
|
/** Render the current animation decision. */
|
|
136
144
|
render(): PetStateSnapshot {
|
|
137
145
|
const nowMs = this.now()
|
|
138
146
|
let animation = animationForPhase(this.phase)
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
//
|
|
148
|
-
|
|
149
|
-
const settled = this.phase === 'idle'
|
|
150
|
-
|| (this.phase === 'done' && this.doneAt !== undefined && nowMs - this.doneAt >= this.config.celebrateMs)
|
|
147
|
+
const doneSettled = this.phase === 'done'
|
|
148
|
+
&& this.doneAt !== undefined
|
|
149
|
+
&& nowMs - this.doneAt >= this.config.celebrateMs
|
|
150
|
+
const failedSettled = this.phase === 'failed'
|
|
151
|
+
&& this.failedAt !== undefined
|
|
152
|
+
&& nowMs - this.failedAt >= this.config.failureMs
|
|
153
|
+
if (doneSettled || failedSettled) animation = 'idle'
|
|
154
|
+
// Settled sessions never bubble: idle (e.g. an aborted/stopped turn),
|
|
155
|
+
// completed celebration expiry, and failed display expiry all fall silent.
|
|
156
|
+
const settled = this.phase === 'idle' || doneSettled || failedSettled
|
|
151
157
|
const bubble = settled ? undefined : this.phrase ?? this.line
|
|
152
158
|
return {
|
|
153
159
|
animation,
|