@linxin666/dsh-pet 0.1.20 → 0.2.0
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 +17 -10
- package/README.zh.md +17 -10
- package/assets/whale/pet.json +9 -0
- package/lib/client.js +158 -26
- package/lib/client.js.map +1 -1
- package/lib/index.js +853 -41
- package/lib/invariant.js +1 -1
- package/lib/{state-Diiq8vjk.js → state-Ch3f4luZ.js} +51 -23
- 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 +666 -0
- package/lib/types/client/PetSprite.d.ts.map +1 -1
- package/lib/types/client/PetSprite.js +88 -14
- package/lib/types/client/PluginSettingsCard.d.ts +6 -0
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PluginSettingsCard.js +5 -3
- 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/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 +36 -0
- package/lib/types/remarks.d.ts +2 -0
- package/lib/types/remarks.d.ts.map +1 -1
- package/lib/types/remarks.js +5 -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 +697 -0
- package/src/client/PetSprite.test.tsx +190 -1
- package/src/client/PetSprite.tsx +112 -25
- package/src/client/PluginSettingsCard.tsx +10 -0
- package/src/client/pet-css.test.ts +42 -0
- package/src/client/pet.module.css +154 -26
- package/src/client/sequences.test.ts +33 -0
- package/src/client/sequences.ts +33 -0
- package/src/event-projection.ts +49 -16
- package/src/ledger.test.ts +42 -1
- package/src/ledger.ts +15 -7
- package/src/persist.test.ts +18 -1
- package/src/persist.ts +3 -1
- package/src/registry.test.ts +50 -3
- package/src/registry.ts +47 -3
- package/src/remarks.ts +6 -0
- package/src/service.ts +27 -2
- package/src/state.test.ts +13 -0
- package/src/state.ts +24 -18
|
@@ -37,13 +37,58 @@
|
|
|
37
37
|
background: rgba(56, 189, 248, 0.92);
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
/* 状态气泡:深色渐变玻璃 + 青色描边辉光 + 进场浮现,
|
|
41
|
+
让工作汇报从“标签”升级为“会呼吸的状态条”。 */
|
|
40
42
|
.bubbleStatus {
|
|
41
43
|
max-width: min(280px, calc(100vw - 24px));
|
|
42
44
|
overflow: hidden;
|
|
43
45
|
text-overflow: ellipsis;
|
|
44
|
-
background: rgba(15, 23, 42, 0.
|
|
45
|
-
border: 1px solid rgba(125, 211, 252, 0.
|
|
46
|
-
|
|
46
|
+
background: linear-gradient(160deg, rgba(30, 41, 59, 0.88), rgba(15, 23, 42, 0.92));
|
|
47
|
+
border: 1px solid rgba(125, 211, 252, 0.55);
|
|
48
|
+
box-shadow:
|
|
49
|
+
0 4px 14px rgba(2, 6, 23, 0.4),
|
|
50
|
+
0 0 0 1px rgba(125, 211, 252, 0.12),
|
|
51
|
+
0 0 18px rgba(56, 189, 248, 0.18);
|
|
52
|
+
backdrop-filter: blur(8px);
|
|
53
|
+
letter-spacing: 0.02em;
|
|
54
|
+
animation: pet-bubble-in 240ms ease-out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/* 碎碎念气泡:宠物的内心悄悄话。紫粉渐变玻璃、虚线描边与斜体,
|
|
58
|
+
配合「」引号,与青色的工作状态气泡一眼区分;不可点击、自动消散。 */
|
|
59
|
+
.bubbleWhisper {
|
|
60
|
+
position: relative;
|
|
61
|
+
max-width: min(260px, calc(100vw - 24px));
|
|
62
|
+
padding: 4px 12px;
|
|
63
|
+
border-radius: 999px;
|
|
64
|
+
font-size: 11.5px;
|
|
65
|
+
font-style: italic;
|
|
66
|
+
line-height: 1.45;
|
|
67
|
+
letter-spacing: 0.03em;
|
|
68
|
+
white-space: nowrap;
|
|
69
|
+
overflow: hidden;
|
|
70
|
+
text-overflow: ellipsis;
|
|
71
|
+
color: #f5d0fe;
|
|
72
|
+
background: linear-gradient(150deg, rgba(88, 28, 135, 0.52), rgba(157, 23, 77, 0.44));
|
|
73
|
+
border: 1px dashed rgba(240, 171, 252, 0.55);
|
|
74
|
+
box-shadow:
|
|
75
|
+
0 3px 12px rgba(46, 16, 101, 0.4),
|
|
76
|
+
0 0 14px rgba(217, 70, 239, 0.16);
|
|
77
|
+
backdrop-filter: blur(8px);
|
|
78
|
+
pointer-events: none;
|
|
79
|
+
animation: pet-whisper-in 320ms cubic-bezier(0.22, 1, 0.36, 1);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.bubbleWhisper::before {
|
|
83
|
+
content: '「';
|
|
84
|
+
margin-right: 2px;
|
|
85
|
+
opacity: 0.75;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.bubbleWhisper::after {
|
|
89
|
+
content: '」';
|
|
90
|
+
margin-left: 2px;
|
|
91
|
+
opacity: 0.75;
|
|
47
92
|
}
|
|
48
93
|
|
|
49
94
|
/* 多会话气泡栈:并行会话各自一个气泡,自精灵头顶向上堆叠,
|
|
@@ -84,6 +129,42 @@
|
|
|
84
129
|
box-shadow: 0 0 0 2px rgba(56, 189, 248, 0.85);
|
|
85
130
|
}
|
|
86
131
|
|
|
132
|
+
@keyframes pet-bubble-in {
|
|
133
|
+
from {
|
|
134
|
+
opacity: 0;
|
|
135
|
+
transform: translateY(6px) scale(0.96);
|
|
136
|
+
}
|
|
137
|
+
to {
|
|
138
|
+
opacity: 1;
|
|
139
|
+
transform: translateY(0) scale(1);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
@keyframes pet-whisper-in {
|
|
144
|
+
0% {
|
|
145
|
+
opacity: 0;
|
|
146
|
+
transform: translateY(8px) scale(0.9);
|
|
147
|
+
}
|
|
148
|
+
60% {
|
|
149
|
+
transform: translateY(-1px) scale(1.02);
|
|
150
|
+
}
|
|
151
|
+
100% {
|
|
152
|
+
opacity: 1;
|
|
153
|
+
transform: translateY(0) scale(1);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
@keyframes pet-panel-in {
|
|
158
|
+
from {
|
|
159
|
+
opacity: 0;
|
|
160
|
+
transform: translateY(4px) scale(0.97);
|
|
161
|
+
}
|
|
162
|
+
to {
|
|
163
|
+
opacity: 1;
|
|
164
|
+
transform: translateY(0) scale(1);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
87
168
|
@keyframes pet-bubble-pop {
|
|
88
169
|
0% {
|
|
89
170
|
opacity: 0;
|
|
@@ -105,39 +186,56 @@
|
|
|
105
186
|
}
|
|
106
187
|
}
|
|
107
188
|
|
|
108
|
-
/*
|
|
189
|
+
/* 悬浮面板默认位于精灵脚下,不再占据精灵上方的空间:
|
|
109
190
|
会话气泡栈独占精灵头顶区域,两者互不遮挡 —— 气泡因此可以始终
|
|
110
|
-
显示并保持可点击,不必在 hover
|
|
191
|
+
显示并保持可点击,不必在 hover 时让位给面板。仅当视口底部空间
|
|
192
|
+
不足时,面板才回退到精灵上方。 */
|
|
111
193
|
.panel {
|
|
112
194
|
position: absolute;
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
border:
|
|
118
|
-
|
|
119
|
-
padding: 8px 10px;
|
|
195
|
+
top: 100%;
|
|
196
|
+
margin-top: 8px;
|
|
197
|
+
background: linear-gradient(165deg, rgba(30, 41, 59, 0.94), rgba(15, 23, 42, 0.96));
|
|
198
|
+
border: 1px solid rgba(148, 163, 184, 0.4);
|
|
199
|
+
border-radius: 12px;
|
|
200
|
+
padding: 10px 12px;
|
|
120
201
|
color: #e2e8f0;
|
|
121
202
|
font-size: 12px;
|
|
122
|
-
box-shadow:
|
|
123
|
-
|
|
203
|
+
box-shadow:
|
|
204
|
+
0 8px 24px rgba(2, 6, 23, 0.45),
|
|
205
|
+
0 0 0 1px rgba(125, 211, 252, 0.08),
|
|
206
|
+
inset 0 1px 0 rgba(226, 232, 240, 0.08);
|
|
207
|
+
backdrop-filter: blur(10px);
|
|
124
208
|
display: flex;
|
|
125
209
|
flex-direction: column;
|
|
126
|
-
gap:
|
|
127
|
-
min-width:
|
|
210
|
+
gap: 7px;
|
|
211
|
+
min-width: 148px;
|
|
212
|
+
animation: pet-panel-in 200ms cubic-bezier(0.22, 1, 0.36, 1);
|
|
213
|
+
transform-origin: 50% 0;
|
|
128
214
|
}
|
|
129
215
|
|
|
130
|
-
/*
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
向右延伸、覆盖间隙,使指针在图标与按钮之间始终停留在 hover 区域。 */
|
|
216
|
+
/* 面板顶边与精灵底边之间的 hover 桥接(issue #49):
|
|
217
|
+
面板在精灵下方留有 8px 间隙,透明伪元素向上覆盖间隙,
|
|
218
|
+
使指针在精灵与按钮之间始终停留在 hover 区域。 */
|
|
134
219
|
.panel::after {
|
|
135
220
|
content: '';
|
|
136
221
|
position: absolute;
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
222
|
+
bottom: 100%;
|
|
223
|
+
left: 0;
|
|
224
|
+
right: 0;
|
|
225
|
+
height: 14px;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
.panelAbove {
|
|
229
|
+
top: auto;
|
|
230
|
+
bottom: 100%;
|
|
231
|
+
margin-top: 0;
|
|
232
|
+
margin-bottom: 8px;
|
|
233
|
+
transform-origin: 50% 100%;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
.panelAbove::after {
|
|
237
|
+
top: 100%;
|
|
238
|
+
bottom: auto;
|
|
141
239
|
}
|
|
142
240
|
|
|
143
241
|
.rankRow {
|
|
@@ -149,6 +247,28 @@
|
|
|
149
247
|
|
|
150
248
|
.nameCell {
|
|
151
249
|
font-weight: 600;
|
|
250
|
+
letter-spacing: 0.02em;
|
|
251
|
+
background: linear-gradient(90deg, #7dd3fc, #38bdf8 60%, #818cf8);
|
|
252
|
+
-webkit-background-clip: text;
|
|
253
|
+
background-clip: text;
|
|
254
|
+
color: transparent;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/* 面板数值的专属配色:称号紫、小鱼干青、亲密度琥珀,
|
|
258
|
+
让两行数据扫一眼就能分辨。 */
|
|
259
|
+
.statRank {
|
|
260
|
+
color: #c4b5fd;
|
|
261
|
+
font-weight: 600;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
.statTreats {
|
|
265
|
+
color: #7dd3fc;
|
|
266
|
+
font-weight: 600;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
.statPoints {
|
|
270
|
+
color: #fcd34d;
|
|
271
|
+
font-weight: 600;
|
|
152
272
|
}
|
|
153
273
|
|
|
154
274
|
.renameRow {
|
|
@@ -189,15 +309,20 @@
|
|
|
189
309
|
cursor: pointer;
|
|
190
310
|
color: #0f172a;
|
|
191
311
|
background: linear-gradient(180deg, #7dd3fc, #38bdf8);
|
|
192
|
-
|
|
312
|
+
box-shadow: 0 2px 6px rgba(56, 189, 248, 0.25);
|
|
313
|
+
transition: filter 120ms ease, box-shadow 120ms ease, transform 120ms ease;
|
|
193
314
|
}
|
|
194
315
|
|
|
195
316
|
.action:hover {
|
|
196
317
|
filter: brightness(1.08);
|
|
318
|
+
transform: translateY(-1px);
|
|
319
|
+
box-shadow: 0 4px 10px rgba(56, 189, 248, 0.35);
|
|
197
320
|
}
|
|
198
321
|
|
|
199
322
|
.action:active {
|
|
200
323
|
filter: brightness(0.94);
|
|
324
|
+
transform: translateY(0);
|
|
325
|
+
box-shadow: 0 1px 4px rgba(56, 189, 248, 0.25);
|
|
201
326
|
}
|
|
202
327
|
|
|
203
328
|
.action:focus-visible {
|
|
@@ -238,7 +363,10 @@
|
|
|
238
363
|
/* Reduced motion: freeze the reaction bubble and kill the overlay transitions
|
|
239
364
|
so the pet's motion is static or minimal without hiding interaction. */
|
|
240
365
|
@media (prefers-reduced-motion: reduce) {
|
|
241
|
-
.bubble
|
|
366
|
+
.bubble,
|
|
367
|
+
.bubbleStatus,
|
|
368
|
+
.bubbleWhisper,
|
|
369
|
+
.panel {
|
|
242
370
|
animation: none;
|
|
243
371
|
opacity: 1;
|
|
244
372
|
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import type { PetTrackDef } from '../registry.ts'
|
|
3
|
+
import type { PetAnimation } from '../state.ts'
|
|
4
|
+
import { sequenceFrameAt } from './sequences.ts'
|
|
5
|
+
|
|
6
|
+
const track = (durations: number[]): PetTrackDef => ({
|
|
7
|
+
frames: durations.map((_, index) => index),
|
|
8
|
+
durations,
|
|
9
|
+
loop: true,
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
const tracks = {
|
|
13
|
+
running: track([100, 200]),
|
|
14
|
+
waiting: track([300, 100]),
|
|
15
|
+
} as Record<PetAnimation, PetTrackDef>
|
|
16
|
+
|
|
17
|
+
describe('sequenceFrameAt', () => {
|
|
18
|
+
const sequence: PetAnimation[] = ['running', 'waiting']
|
|
19
|
+
|
|
20
|
+
it('finishes every frame of one track before advancing', () => {
|
|
21
|
+
expect(sequenceFrameAt(sequence, tracks, 0)).toEqual({ animation: 'running', frameIndex: 0 })
|
|
22
|
+
expect(sequenceFrameAt(sequence, tracks, 100)).toEqual({ animation: 'running', frameIndex: 1 })
|
|
23
|
+
expect(sequenceFrameAt(sequence, tracks, 299)).toEqual({ animation: 'running', frameIndex: 1 })
|
|
24
|
+
expect(sequenceFrameAt(sequence, tracks, 300)).toEqual({ animation: 'waiting', frameIndex: 0 })
|
|
25
|
+
expect(sequenceFrameAt(sequence, tracks, 600)).toEqual({ animation: 'waiting', frameIndex: 1 })
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('loops the whole sequence and preserves overshoot', () => {
|
|
29
|
+
expect(sequenceFrameAt(sequence, tracks, 700)).toEqual({ animation: 'running', frameIndex: 0 })
|
|
30
|
+
expect(sequenceFrameAt(sequence, tracks, 850)).toEqual({ animation: 'running', frameIndex: 1 })
|
|
31
|
+
expect(sequenceFrameAt(sequence, tracks, 1_420)).toEqual({ animation: 'running', frameIndex: 0 })
|
|
32
|
+
})
|
|
33
|
+
})
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** Pure timing helpers for manifest-defined scene animation sequences. */
|
|
2
|
+
|
|
3
|
+
import type { PetTrackDef } from '../registry.ts'
|
|
4
|
+
import type { PetAnimation } from '../state.ts'
|
|
5
|
+
|
|
6
|
+
export interface SequenceFrame {
|
|
7
|
+
animation: PetAnimation
|
|
8
|
+
frameIndex: number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Resolve the active track and frame after elapsed milliseconds of a looping sequence. */
|
|
12
|
+
export function sequenceFrameAt(
|
|
13
|
+
sequence: readonly PetAnimation[],
|
|
14
|
+
tracks: Record<PetAnimation, PetTrackDef>,
|
|
15
|
+
elapsedMs: number,
|
|
16
|
+
): SequenceFrame {
|
|
17
|
+
const itemDurations = sequence.map(animation => tracks[animation].durations.reduce((sum, value) => sum + value, 0))
|
|
18
|
+
const sequenceDuration = itemDurations.reduce((sum, value) => sum + value, 0)
|
|
19
|
+
let offset = Math.max(0, elapsedMs) % sequenceDuration
|
|
20
|
+
let itemIndex = 0
|
|
21
|
+
while (itemIndex < sequence.length - 1 && offset >= itemDurations[itemIndex]!) {
|
|
22
|
+
offset -= itemDurations[itemIndex]!
|
|
23
|
+
itemIndex += 1
|
|
24
|
+
}
|
|
25
|
+
const animation = sequence[itemIndex]!
|
|
26
|
+
const track = tracks[animation]
|
|
27
|
+
let frameIndex = 0
|
|
28
|
+
while (frameIndex < track.frames.length - 1 && offset >= track.durations[frameIndex]!) {
|
|
29
|
+
offset -= track.durations[frameIndex]!
|
|
30
|
+
frameIndex += 1
|
|
31
|
+
}
|
|
32
|
+
return { animation, frameIndex }
|
|
33
|
+
}
|
package/src/event-projection.ts
CHANGED
|
@@ -3,11 +3,17 @@
|
|
|
3
3
|
* vocabulary onto the pet's visual phases and carries an optional completed-
|
|
4
4
|
* turn reward for the ledger. Holds no state of its own; callers keep a
|
|
5
5
|
* {@link ProjectionRuntime} per session and feed events in arrival order.
|
|
6
|
+
*
|
|
7
|
+
* Status copy comes from the chatter voice (big rotating pools, per-tool
|
|
8
|
+
* families, real-argument hints), and streamed model output feeds the murmur
|
|
9
|
+
* engine so the pet can whisper its inner voice (碎碎念). The wall clock is
|
|
10
|
+
* injected by the caller, keeping every projection reproducible.
|
|
6
11
|
* @module @linxin666/dsh-pet/event-projection
|
|
7
12
|
*/
|
|
8
13
|
|
|
9
14
|
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
|
10
15
|
import type { PetStateInput } from './state.ts'
|
|
16
|
+
import { StatusVoice, toolArgHint, WhisperEngine } from './chatter.ts'
|
|
11
17
|
|
|
12
18
|
/** Runtime shape of the optional legacy activity event. */
|
|
13
19
|
export interface ActivityStatusEventLike {
|
|
@@ -21,23 +27,35 @@ export interface ProjectionRuntime {
|
|
|
21
27
|
activeTools: Set<string>
|
|
22
28
|
officialEventsSeen: boolean
|
|
23
29
|
stepHadFailure: boolean
|
|
30
|
+
/** Round-robin status copy voice (scene-stable, cadence-rotated). */
|
|
31
|
+
voice: StatusVoice
|
|
32
|
+
/** Inner-whisper engine fed by the model's own streamed output. */
|
|
33
|
+
whispers: WhisperEngine
|
|
24
34
|
}
|
|
25
35
|
|
|
26
36
|
/** One official event projection, optionally carrying a completed turn reward. */
|
|
27
37
|
export interface PetActivityTransition {
|
|
28
38
|
input: PetStateInput
|
|
29
39
|
completedTurn?: number
|
|
40
|
+
/** A fresh inner whisper woken by this event's model output, when any. */
|
|
41
|
+
whisper?: string
|
|
30
42
|
}
|
|
31
43
|
|
|
32
44
|
/** Fresh projection runtime for a newly seen session. */
|
|
33
45
|
export function emptyProjectionRuntime(): ProjectionRuntime {
|
|
34
|
-
return {
|
|
46
|
+
return {
|
|
47
|
+
activeTools: new Set(),
|
|
48
|
+
officialEventsSeen: false,
|
|
49
|
+
stepHadFailure: false,
|
|
50
|
+
voice: new StatusVoice(),
|
|
51
|
+
whispers: new WhisperEngine(),
|
|
52
|
+
}
|
|
35
53
|
}
|
|
36
54
|
|
|
37
55
|
/** Keep tool names readable inside the compact status bubble. */
|
|
38
56
|
function displayToolName(name: string): string {
|
|
39
57
|
const compact = name.replace(/\s+/g, ' ').trim() || '工具'
|
|
40
|
-
return compact.length <= 24 ? compact :
|
|
58
|
+
return compact.length <= 24 ? compact : compact.slice(0, 21) + '...'
|
|
41
59
|
}
|
|
42
60
|
|
|
43
61
|
/** Whether a legacy phase is part of the pet's supported vocabulary. */
|
|
@@ -48,38 +66,53 @@ export function isActivityPhase(phase: string): phase is PetStateInput['phase']
|
|
|
48
66
|
/**
|
|
49
67
|
* Project the durable DSH session vocabulary into the pet's visual phases.
|
|
50
68
|
* Unknown and log-only events do not disturb the last meaningful activity.
|
|
69
|
+
* @param nowMs - injected wall clock for copy rotation and whisper pacing.
|
|
51
70
|
*/
|
|
52
71
|
export function projectOfficialEvent(
|
|
53
72
|
event: SessionEvent,
|
|
54
73
|
runtime: ProjectionRuntime,
|
|
74
|
+
nowMs: number = Date.now(),
|
|
55
75
|
): PetActivityTransition | undefined {
|
|
56
76
|
switch (event.type) {
|
|
57
77
|
case 'turn/start':
|
|
58
78
|
runtime.activeTools.clear()
|
|
59
79
|
runtime.stepHadFailure = false
|
|
60
|
-
return { input: { phase: 'waiting', line: '
|
|
80
|
+
return { input: { phase: 'waiting', line: runtime.voice.scene('prepare', nowMs) } }
|
|
61
81
|
case 'step/start':
|
|
62
82
|
runtime.activeTools.clear()
|
|
63
83
|
runtime.stepHadFailure = false
|
|
64
|
-
return { input: { phase: 'waiting', line: '
|
|
84
|
+
return { input: { phase: 'waiting', line: runtime.voice.scene('waiting', nowMs) } }
|
|
65
85
|
case 'assistant/chunk': {
|
|
66
86
|
const { chunk } = event.data
|
|
67
87
|
if (chunk.type === 'reasoning-delta' && chunk.text.length > 0) {
|
|
68
|
-
|
|
88
|
+
const whisper = runtime.whispers.feed(chunk.text, nowMs)
|
|
89
|
+
return {
|
|
90
|
+
input: { phase: 'thinking', line: runtime.voice.scene('thinking', nowMs) },
|
|
91
|
+
...(whisper === undefined ? {} : { whisper }),
|
|
92
|
+
}
|
|
69
93
|
}
|
|
70
94
|
if (chunk.type === 'text-delta' && chunk.text.length > 0) {
|
|
71
|
-
|
|
95
|
+
const whisper = runtime.whispers.feed(chunk.text, nowMs)
|
|
96
|
+
return {
|
|
97
|
+
input: { phase: 'review', line: runtime.voice.scene('review', nowMs) },
|
|
98
|
+
...(whisper === undefined ? {} : { whisper }),
|
|
99
|
+
}
|
|
72
100
|
}
|
|
73
101
|
return undefined
|
|
74
102
|
}
|
|
75
103
|
case 'assistant/message':
|
|
76
|
-
return { input: { phase: 'review', line: '
|
|
104
|
+
return { input: { phase: 'review', line: runtime.voice.scene('review', nowMs) } }
|
|
77
105
|
case 'tool/call':
|
|
78
106
|
runtime.activeTools.add(String(event.data.callId))
|
|
79
107
|
return {
|
|
80
108
|
input: {
|
|
81
109
|
phase: 'tool',
|
|
82
|
-
line:
|
|
110
|
+
line: runtime.voice.tool(
|
|
111
|
+
event.data.name,
|
|
112
|
+
displayToolName(event.data.name),
|
|
113
|
+
toolArgHint(event.data.name, event.data.arguments),
|
|
114
|
+
nowMs,
|
|
115
|
+
),
|
|
83
116
|
},
|
|
84
117
|
}
|
|
85
118
|
case 'tool/result': {
|
|
@@ -90,30 +123,30 @@ export function projectOfficialEvent(
|
|
|
90
123
|
return {
|
|
91
124
|
input: {
|
|
92
125
|
phase: 'tool',
|
|
93
|
-
line:
|
|
126
|
+
line: runtime.voice.toolRemaining(runtime.activeTools.size, nowMs),
|
|
94
127
|
},
|
|
95
128
|
}
|
|
96
129
|
}
|
|
97
130
|
return runtime.stepHadFailure
|
|
98
|
-
? { input: { phase: 'failed', line: '
|
|
99
|
-
: { input: { phase: 'thinking', line: '
|
|
131
|
+
? { input: { phase: 'failed', line: runtime.voice.scene('toolFailed', nowMs) } }
|
|
132
|
+
: { input: { phase: 'thinking', line: runtime.voice.scene('toolResult', nowMs) } }
|
|
100
133
|
}
|
|
101
134
|
case 'turn/end': {
|
|
102
135
|
runtime.activeTools.clear()
|
|
103
136
|
switch (event.data.reason.kind) {
|
|
104
137
|
case 'completed':
|
|
105
138
|
return {
|
|
106
|
-
input: { phase: 'done', line: '
|
|
139
|
+
input: { phase: 'done', line: runtime.voice.scene('done', nowMs) },
|
|
107
140
|
completedTurn: event.data.turn,
|
|
108
141
|
}
|
|
109
142
|
case 'error':
|
|
110
|
-
return { input: { phase: 'failed', line: '
|
|
143
|
+
return { input: { phase: 'failed', line: runtime.voice.scene('failed', nowMs) } }
|
|
111
144
|
case 'max-tokens':
|
|
112
|
-
return { input: { phase: 'failed', line: '
|
|
145
|
+
return { input: { phase: 'failed', line: runtime.voice.scene('maxTokens', nowMs) } }
|
|
113
146
|
case 'interrupted':
|
|
114
|
-
return { input: { phase: 'failed', line: '
|
|
147
|
+
return { input: { phase: 'failed', line: runtime.voice.scene('interrupted', nowMs) } }
|
|
115
148
|
case 'blocked':
|
|
116
|
-
return { input: { phase: 'waiting', line: '
|
|
149
|
+
return { input: { phase: 'waiting', line: runtime.voice.scene('blocked', nowMs) } }
|
|
117
150
|
case 'aborted':
|
|
118
151
|
// A stopped session settles to idle without a bubble: the pet
|
|
119
152
|
// visibly calms down and the session drops out of the bubble stack.
|
package/src/ledger.test.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { defaultTreatConfig } from './treats.ts'
|
|
|
3
3
|
import { defaultAffinityConfig } from './affinity.ts'
|
|
4
4
|
import { emptyPersist } from './persist.ts'
|
|
5
5
|
import { PetLedger } from './ledger.ts'
|
|
6
|
+
import { BUILTIN_REMARKS } from './remarks.ts'
|
|
6
7
|
|
|
7
8
|
describe('PetLedger', () => {
|
|
8
9
|
it('settles the economy on completed turns (work treat per 30 turns)', () => {
|
|
@@ -66,13 +67,53 @@ describe('PetLedger', () => {
|
|
|
66
67
|
expect(ledger.interact('feed', n + 2).reaction).toBe('没有小鱼干了,多陪我工作一会儿吧~')
|
|
67
68
|
})
|
|
68
69
|
|
|
70
|
+
it('selects success remarks from persisted lifetime interaction counts', () => {
|
|
71
|
+
const n = 1_000_000
|
|
72
|
+
const persist = emptyPersist()
|
|
73
|
+
persist.affinity.pets = 1
|
|
74
|
+
persist.affinity.feeds = 1
|
|
75
|
+
persist.treats.treats = 1
|
|
76
|
+
persist.treats.lastTreatGrantAt = n
|
|
77
|
+
const ledger = new PetLedger(persist, {
|
|
78
|
+
remarks: {
|
|
79
|
+
pet: ['摸头一', '摸头二'],
|
|
80
|
+
feed: ['喂食一', '喂食二'],
|
|
81
|
+
},
|
|
82
|
+
})
|
|
83
|
+
expect(ledger.interact('pet', n).reaction).toBe('摸头二')
|
|
84
|
+
expect(ledger.interact('feed', n).reaction).toBe('喂食二')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('rotates cooldown remarks by persisted rejection counts', () => {
|
|
88
|
+
const n = 1_000_000
|
|
89
|
+
const remarks = {
|
|
90
|
+
petCooldown: ['摸头冷却一', '摸头冷却二', '摸头冷却三'],
|
|
91
|
+
feedCooldown: ['喂食冷却一', '喂食冷却二', '喂食冷却三'],
|
|
92
|
+
}
|
|
93
|
+
const persist = emptyPersist()
|
|
94
|
+
persist.affinity.lastPetAt = n
|
|
95
|
+
persist.affinity.lastFeedAt = n
|
|
96
|
+
const ledger = new PetLedger(persist, { remarks })
|
|
97
|
+
expect(ledger.interact('pet', n + 1).reaction).toBe('摸头冷却一')
|
|
98
|
+
expect(ledger.interact('pet', n + 2).reaction).toBe('摸头冷却二')
|
|
99
|
+
expect(ledger.interact('feed', n + 1).reaction).toBe('喂食冷却一')
|
|
100
|
+
expect(ledger.interact('feed', n + 2).reaction).toBe('喂食冷却二')
|
|
101
|
+
expect(ledger.snapshot.affinity.petRejects).toBe(2)
|
|
102
|
+
expect(ledger.snapshot.affinity.feedRejects).toBe(2)
|
|
103
|
+
|
|
104
|
+
const restored = new PetLedger(ledger.snapshot, { remarks })
|
|
105
|
+
expect(restored.interact('pet', n + 3).reaction).toBe('摸头冷却三')
|
|
106
|
+
expect(restored.interact('feed', n + 3).reaction).toBe('喂食冷却三')
|
|
107
|
+
})
|
|
108
|
+
|
|
69
109
|
it('swaps remark pools when the selected pet changes', () => {
|
|
70
110
|
const ledger = new PetLedger(emptyPersist(), { remarks: { pet: ['旧宠物台词'] } })
|
|
71
111
|
expect(ledger.interact('pet', 1_000_000).reaction).toBe('旧宠物台词')
|
|
72
112
|
ledger.setRemarks({ pet: ['新宠物台词'] })
|
|
73
113
|
expect(ledger.interact('pet', 1_011_000).reaction).toBe('新宠物台词')
|
|
74
114
|
ledger.setRemarks(undefined)
|
|
75
|
-
|
|
115
|
+
// Changing pools does not reset the persisted lifetime interaction count.
|
|
116
|
+
expect(ledger.interact('pet', 1_022_000).reaction).toBe(BUILTIN_REMARKS.pet[2])
|
|
76
117
|
})
|
|
77
118
|
|
|
78
119
|
it('refuses a feed on an empty stock without burning anything', () => {
|
package/src/ledger.ts
CHANGED
|
@@ -182,12 +182,19 @@ export class PetLedger {
|
|
|
182
182
|
*/
|
|
183
183
|
interact(kind: PetInteraction, nowMs: number): LedgerInteractionResult {
|
|
184
184
|
if (kind === 'feed') this.settleTreats(nowMs)
|
|
185
|
-
const
|
|
185
|
+
const before = this.current.affinity
|
|
186
|
+
const outcome = applyInteraction(before, kind, nowMs, this.affinityConfig)
|
|
186
187
|
// Reactions come from the picker pools (custom per-pet slots override
|
|
187
188
|
// the built-in library per slot); outcome.reaction stays the fallback
|
|
188
189
|
// copy for direct applyInteraction callers.
|
|
189
190
|
if (kind === 'feed' && !outcome.accepted) {
|
|
190
|
-
|
|
191
|
+
this.current = { ...this.current, affinity: outcome.affinity }
|
|
192
|
+
this.dirty = true
|
|
193
|
+
return {
|
|
194
|
+
reaction: this.picker.pickAt('feedCooldown', before.feedRejects),
|
|
195
|
+
delta: 0,
|
|
196
|
+
affinity: this.affinityView(nowMs),
|
|
197
|
+
}
|
|
191
198
|
}
|
|
192
199
|
if (kind === 'feed') {
|
|
193
200
|
const consume = consumeTreat(this.current.treats)
|
|
@@ -201,12 +208,13 @@ export class PetLedger {
|
|
|
201
208
|
this.current = { ...this.current, treats: consume.ledger }
|
|
202
209
|
this.dirty = true
|
|
203
210
|
}
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
211
|
+
this.current = { ...this.current, affinity: outcome.affinity }
|
|
212
|
+
this.dirty = true
|
|
213
|
+
const count = kind === 'pet'
|
|
214
|
+
? outcome.accepted ? before.pets : before.petRejects
|
|
215
|
+
: before.feeds
|
|
208
216
|
return {
|
|
209
|
-
reaction: this.picker.
|
|
217
|
+
reaction: this.picker.pickAt(outcome.accepted ? kind : 'petCooldown', count),
|
|
210
218
|
delta: outcome.delta,
|
|
211
219
|
affinity: this.affinityView(nowMs),
|
|
212
220
|
}
|
package/src/persist.test.ts
CHANGED
|
@@ -20,6 +20,10 @@ function tempDir(): string {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
describe('loadPetPersist', () => {
|
|
23
|
+
it('reserves enough default space for the hover panel below the pet', () => {
|
|
24
|
+
expect(defaultDisplayConfig.bottom).toBeGreaterThanOrEqual(100)
|
|
25
|
+
})
|
|
26
|
+
|
|
23
27
|
it('falls back to defaults when the file is missing', () => {
|
|
24
28
|
const dir = tempDir()
|
|
25
29
|
try {
|
|
@@ -67,6 +71,8 @@ describe('loadPetPersist', () => {
|
|
|
67
71
|
expect(loaded.petId).toBe(DEFAULT_PET_ID)
|
|
68
72
|
expect(loaded.names).toEqual({ [DEFAULT_PET_ID]: '泡泡' })
|
|
69
73
|
expect(loaded.affinity.points).toBe(5)
|
|
74
|
+
expect(loaded.affinity.petRejects).toBe(0)
|
|
75
|
+
expect(loaded.affinity.feedRejects).toBe(0)
|
|
70
76
|
expect(loaded.display).toEqual(defaultDisplayConfig)
|
|
71
77
|
} finally {
|
|
72
78
|
rmSync(dir, { recursive: true, force: true })
|
|
@@ -108,7 +114,16 @@ describe('loadPetPersist', () => {
|
|
|
108
114
|
writeFileSync(join(dir, 'pet.json'), JSON.stringify({
|
|
109
115
|
name: ' ',
|
|
110
116
|
names: { bad: ' ' },
|
|
111
|
-
affinity: {
|
|
117
|
+
affinity: {
|
|
118
|
+
points: AFFINITY_MAX + 5000,
|
|
119
|
+
lastPetAt: -5,
|
|
120
|
+
lastFeedAt: 'x',
|
|
121
|
+
pets: -1,
|
|
122
|
+
feeds: 1.5,
|
|
123
|
+
turns: 0,
|
|
124
|
+
petRejects: 4,
|
|
125
|
+
feedRejects: -2,
|
|
126
|
+
},
|
|
112
127
|
treats: { treats: 150, lastTreatGrantAt: -1, turnsAtLastTreatGrant: 0 },
|
|
113
128
|
display: { visible: 'yes', size: -10, right: 1e12, bottom: 20 },
|
|
114
129
|
}), 'utf8')
|
|
@@ -120,6 +135,8 @@ describe('loadPetPersist', () => {
|
|
|
120
135
|
expect(loaded.affinity.lastFeedAt).toBe(0)
|
|
121
136
|
expect(loaded.affinity.pets).toBe(0)
|
|
122
137
|
expect(loaded.affinity.feeds).toBe(1.5) // finite numbers pass through
|
|
138
|
+
expect(loaded.affinity.petRejects).toBe(4)
|
|
139
|
+
expect(loaded.affinity.feedRejects).toBe(0)
|
|
123
140
|
expect(loaded.treats.treats).toBe(defaultTreatConfig.maxTreats)
|
|
124
141
|
expect(loaded.treats.lastTreatGrantAt).toBe(0)
|
|
125
142
|
expect(loaded.display.visible).toBe(defaultDisplayConfig.visible)
|
package/src/persist.ts
CHANGED
|
@@ -27,7 +27,7 @@ export const defaultDisplayConfig: PetDisplayConfig = {
|
|
|
27
27
|
visible: true,
|
|
28
28
|
size: 160,
|
|
29
29
|
right: 24,
|
|
30
|
-
bottom:
|
|
30
|
+
bottom: 120,
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
/** Display value bounds (shared by load-time validation and setConfig). */
|
|
@@ -117,6 +117,8 @@ export function loadPetPersist(dir: string = petHomeDir()): PetPersist {
|
|
|
117
117
|
lastFeedAt: clamp(finiteNum(rawAffinity.lastFeedAt, 0), Number.MAX_SAFE_INTEGER),
|
|
118
118
|
pets: clamp(finiteNum(rawAffinity.pets, 0), Number.MAX_SAFE_INTEGER),
|
|
119
119
|
feeds: clamp(finiteNum(rawAffinity.feeds, 0), Number.MAX_SAFE_INTEGER),
|
|
120
|
+
petRejects: clamp(finiteNum(rawAffinity.petRejects, 0), Number.MAX_SAFE_INTEGER),
|
|
121
|
+
feedRejects: clamp(finiteNum(rawAffinity.feedRejects, 0), Number.MAX_SAFE_INTEGER),
|
|
120
122
|
turns: clamp(finiteNum(rawAffinity.turns, 0), Number.MAX_SAFE_INTEGER),
|
|
121
123
|
}
|
|
122
124
|
const rawTreats = (parsed.treats ?? {}) as Partial<TreatLedger>
|