@linxin666/dsh-pet 0.1.19 → 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 +19 -12
- package/README.zh.md +19 -12
- package/assets/whale/pet.json +9 -0
- package/lib/client.js +202 -38
- package/lib/client.js.map +1 -1
- package/lib/index.js +897 -42
- package/lib/invariant.js +1 -1
- package/lib/{state-Diiq8vjk.js → state-Ch3f4luZ.js} +51 -23
- package/lib/types/affinity.d.ts +22 -18
- 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/PetDockEntry.d.ts +2 -0
- package/lib/types/client/PetDockEntry.d.ts.map +1 -1
- package/lib/types/client/PetDockEntry.js +2 -2
- package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PetSettingsCard.js +2 -2
- package/lib/types/client/PetSprite.d.ts +2 -0
- package/lib/types/client/PetSprite.d.ts.map +1 -1
- package/lib/types/client/PetSprite.js +100 -25
- package/lib/types/client/PluginSettingsCard.d.ts +7 -1
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PluginSettingsCard.js +5 -3
- package/lib/types/client/index.d.ts +1 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +31 -6
- package/lib/types/client/locales.d.ts +82 -80
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +2 -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/client/settings-form.d.ts.map +1 -1
- package/lib/types/client/spritesheet.d.ts +1 -1
- package/lib/types/client/spritesheet.d.ts.map +1 -1
- package/lib/types/client/spritesheet.js +3 -12
- 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/index.d.ts +2 -1
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +15 -13
- package/lib/types/invariant.js +2 -2
- package/lib/types/ledger.d.ts.map +1 -1
- package/lib/types/ledger.js +18 -10
- package/lib/types/mount-once.d.ts +25 -0
- package/lib/types/mount-once.d.ts.map +1 -0
- package/lib/types/mount-once.js +42 -0
- package/lib/types/persist.d.ts.map +1 -1
- package/lib/types/persist.js +6 -4
- package/lib/types/registry.d.ts +7 -1
- package/lib/types/registry.d.ts.map +1 -1
- package/lib/types/registry.js +37 -1
- 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/routes.js +2 -2
- package/lib/types/service.d.ts +16 -6
- package/lib/types/service.d.ts.map +1 -1
- package/lib/types/service.js +26 -8
- 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/PetDockEntry.tsx +3 -0
- package/src/client/PetSprite.test.tsx +246 -4
- package/src/client/PetSprite.tsx +132 -36
- package/src/client/PluginSettingsCard.tsx +10 -0
- package/src/client/index.ts +26 -3
- package/src/client/locales.ts +2 -0
- package/src/client/pet-css.test.ts +42 -0
- package/src/client/pet.module.css +173 -22
- package/src/client/sequences.test.ts +33 -0
- package/src/client/sequences.ts +33 -0
- package/src/client/spritesheet.ts +3 -13
- package/src/event-projection.ts +49 -16
- package/src/index.ts +4 -1
- package/src/ledger.test.ts +42 -1
- package/src/ledger.ts +15 -7
- package/src/mount-once.ts +48 -0
- 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 +42 -9
- package/src/state.test.ts +13 -0
- package/src/state.ts +24 -18
package/src/client/index.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* @module @linxin666/dsh-pet/client
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import type { ClientContext, SettingsScope, SettingsScopeSpec } from '@deepseek-ai/dsh-client-runtime/client'
|
|
14
|
+
import type { ClientContext, ISessions, SessionId, SettingsScope, SettingsScopeSpec } from '@deepseek-ai/dsh-client-runtime/client'
|
|
15
15
|
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
|
|
16
16
|
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
|
17
17
|
// Type-only: pulls the settings-surface Context merge (ctx.settingsScope).
|
|
@@ -72,8 +72,8 @@ const POLL_MS = 2000
|
|
|
72
72
|
/** Settings namespace the pet settings card edits (the Host plugin registers it). */
|
|
73
73
|
const PET_SETTINGS_NS = 'pet'
|
|
74
74
|
|
|
75
|
-
/** Required services. */
|
|
76
|
-
export const inject = ['slots', 'locale', 'connection', 'settingsScope', 'remote']
|
|
75
|
+
/** Required services (sessions powers bubble-to-session navigation). */
|
|
76
|
+
export const inject = ['slots', 'locale', 'connection', 'settingsScope', 'remote', 'sessions']
|
|
77
77
|
|
|
78
78
|
/** Re-exported for consumers that type against the injected face. */
|
|
79
79
|
export type { PetInjected, PetDockEntryProps } from './PetDockEntry.tsx'
|
|
@@ -152,6 +152,11 @@ export function apply(ctx: ClientContext): void {
|
|
|
152
152
|
// tick tries again. After it lands, one list feeds both the sprite and
|
|
153
153
|
// the settings card's choices.
|
|
154
154
|
let petsLoaded = false
|
|
155
|
+
// Latest-wins guard: the 2s tick, visibility recovery, and
|
|
156
|
+
// interaction-triggered refreshes can overlap; only the newest
|
|
157
|
+
// response may publish, so a slow older one can never roll the
|
|
158
|
+
// snapshot back.
|
|
159
|
+
let stateSeq = 0
|
|
155
160
|
const pollNow = (): void => {
|
|
156
161
|
if (!petsLoaded) {
|
|
157
162
|
petApi.pets().then((list) => {
|
|
@@ -161,9 +166,13 @@ export function apply(ctx: ClientContext): void {
|
|
|
161
166
|
// Retry on the next poll tick.
|
|
162
167
|
})
|
|
163
168
|
}
|
|
169
|
+
const seq = stateSeq + 1
|
|
170
|
+
stateSeq = seq
|
|
164
171
|
petApi.state().then((snapshot) => {
|
|
172
|
+
if (seq !== stateSeq) return
|
|
165
173
|
setSnapshot(snapshot)
|
|
166
174
|
}, () => {
|
|
175
|
+
if (seq !== stateSeq) return
|
|
167
176
|
setState('error', 'pet.state transport error')
|
|
168
177
|
})
|
|
169
178
|
}
|
|
@@ -202,9 +211,23 @@ export function apply(ctx: ClientContext): void {
|
|
|
202
211
|
}
|
|
203
212
|
}, 'pet: poll')
|
|
204
213
|
|
|
214
|
+
// Clicking a session bubble jumps the GUI to that session. A bubble
|
|
215
|
+
// can outlive its disposed session by one poll tick, and the sessions
|
|
216
|
+
// service fails loud on unknown ids, so consult the live list first.
|
|
217
|
+
// The pet's type program also loads the host-side dsh-session package
|
|
218
|
+
// through the service types, whose Context merge declares a different
|
|
219
|
+
// 'sessions' face; pin the browser runtime's outward face here.
|
|
220
|
+
const sessions = ctx.sessions as unknown as ISessions
|
|
221
|
+
const openSession = (sessionId: string): void => {
|
|
222
|
+
const list = sessions.list.getSnapshot()
|
|
223
|
+
if (list.byId[sessionId as SessionId] === undefined) return
|
|
224
|
+
sessions.open(sessionId as SessionId)
|
|
225
|
+
}
|
|
226
|
+
|
|
205
227
|
const injected = (): PetInjected => ({
|
|
206
228
|
store: petStore,
|
|
207
229
|
ensure: pollNow,
|
|
230
|
+
openSession,
|
|
208
231
|
pet: () => {
|
|
209
232
|
petApi.interact('pet').then((result) => {
|
|
210
233
|
setFeedback({
|
package/src/client/locales.ts
CHANGED
|
@@ -19,6 +19,7 @@ export const zh = {
|
|
|
19
19
|
'pet.treats': '小鱼干 ×{n}',
|
|
20
20
|
'pet.state.loading': '宠物正在赶来…',
|
|
21
21
|
'pet.state.error': '宠物迷路了(连接失败)',
|
|
22
|
+
'pet.openSessionHint': '点击跳转到对应会话',
|
|
22
23
|
// 一级设置页(settings.section 席位)。
|
|
23
24
|
'settings.title': '宠物',
|
|
24
25
|
'settings.description': '选择宠物并调整它的显示布局。',
|
|
@@ -64,6 +65,7 @@ export const en = {
|
|
|
64
65
|
'pet.treats': 'Treats ×{n}',
|
|
65
66
|
'pet.state.loading': 'The pet is on its way…',
|
|
66
67
|
'pet.state.error': 'The pet is lost (connection failed)',
|
|
68
|
+
'pet.openSessionHint': 'Click to jump to this session',
|
|
67
69
|
// First-level settings section (the `settings.section` seat).
|
|
68
70
|
'settings.title': 'Pet',
|
|
69
71
|
'settings.description': 'Pick a pet and tune its display layout.',
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
|
|
4
|
+
const css = readFileSync(new URL('./pet.module.css', import.meta.url), 'utf8')
|
|
5
|
+
|
|
6
|
+
describe('pet hover panel css', () => {
|
|
7
|
+
it('anchors the panel below the pet', () => {
|
|
8
|
+
const panel = css.match(/\.panel\s*\{([^}]*)\}/)?.[1] ?? ''
|
|
9
|
+
expect(panel).toContain('top: 100%')
|
|
10
|
+
expect(panel).toContain('margin-top: 8px')
|
|
11
|
+
expect(panel).not.toContain('right: 100%')
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('extends the hover bridge upward across the panel gap', () => {
|
|
15
|
+
const bridge = css.match(/\.panel::after\s*\{([^}]*)\}/)?.[1] ?? ''
|
|
16
|
+
expect(bridge).toContain('bottom: 100%')
|
|
17
|
+
expect(bridge).toContain('height: 14px')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('styles the whisper bubble as a distinct non-interactive inner voice', () => {
|
|
21
|
+
const whisper = css.match(/\.bubbleWhisper\s*\{([^}]*)\}/)?.[1] ?? ''
|
|
22
|
+
expect(whisper).toContain('font-style: italic')
|
|
23
|
+
expect(whisper).toContain('pointer-events: none')
|
|
24
|
+
expect(css).toContain('@keyframes pet-whisper-in')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('gives the panel and status bubbles entrance animations', () => {
|
|
28
|
+
expect(css).toContain('@keyframes pet-panel-in')
|
|
29
|
+
expect(css).toContain('@keyframes pet-bubble-in')
|
|
30
|
+
const panel = css.match(/\.panel\s*\{([^}]*)\}/)?.[1] ?? ''
|
|
31
|
+
expect(panel).toContain('animation: pet-panel-in')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('can place the panel above and bridge the lower gap', () => {
|
|
35
|
+
const panel = css.match(/\.panelAbove\s*\{([^}]*)\}/)?.[1] ?? ''
|
|
36
|
+
expect(panel).toContain('bottom: 100%')
|
|
37
|
+
expect(panel).toContain('margin-bottom: 8px')
|
|
38
|
+
const bridge = css.match(/\.panelAbove::after\s*\{([^}]*)\}/)?.[1] ?? ''
|
|
39
|
+
expect(bridge).toContain('top: 100%')
|
|
40
|
+
expect(bridge).toContain('bottom: auto')
|
|
41
|
+
})
|
|
42
|
+
})
|
|
@@ -37,18 +37,63 @@
|
|
|
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
|
/* 多会话气泡栈:并行会话各自一个气泡,自精灵头顶向上堆叠,
|
|
50
95
|
最近更新的会话在最上。栈内气泡改流入列布局,不再绝对定位
|
|
51
|
-
|
|
96
|
+
互相覆盖。气泡可点击:点击跳转到对应会话。 */
|
|
52
97
|
.bubbleStack {
|
|
53
98
|
position: absolute;
|
|
54
99
|
bottom: 100%;
|
|
@@ -56,13 +101,68 @@
|
|
|
56
101
|
flex-direction: column;
|
|
57
102
|
align-items: center;
|
|
58
103
|
gap: 4px;
|
|
59
|
-
pointer-events:
|
|
104
|
+
pointer-events: auto;
|
|
60
105
|
}
|
|
61
106
|
|
|
62
107
|
.bubbleStack .bubble {
|
|
63
108
|
position: relative;
|
|
64
109
|
bottom: auto;
|
|
65
110
|
margin-bottom: 0;
|
|
111
|
+
pointer-events: auto;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
.bubbleClickable {
|
|
115
|
+
cursor: pointer;
|
|
116
|
+
font: inherit;
|
|
117
|
+
text-align: center;
|
|
118
|
+
appearance: none;
|
|
119
|
+
transition: filter 120ms ease, box-shadow 120ms ease;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
.bubbleClickable:hover {
|
|
123
|
+
filter: brightness(1.12);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.bubbleClickable:focus-visible {
|
|
127
|
+
outline: none;
|
|
128
|
+
/* 2px accent ring on the pet's own palette, theme-agnostic. */
|
|
129
|
+
box-shadow: 0 0 0 2px rgba(56, 189, 248, 0.85);
|
|
130
|
+
}
|
|
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
|
+
}
|
|
66
166
|
}
|
|
67
167
|
|
|
68
168
|
@keyframes pet-bubble-pop {
|
|
@@ -86,37 +186,58 @@
|
|
|
86
186
|
}
|
|
87
187
|
}
|
|
88
188
|
|
|
189
|
+
/* 悬浮面板默认位于精灵脚下,不再占据精灵上方的空间:
|
|
190
|
+
会话气泡栈独占精灵头顶区域,两者互不遮挡 —— 气泡因此可以始终
|
|
191
|
+
显示并保持可点击,不必在 hover 时让位给面板。仅当视口底部空间
|
|
192
|
+
不足时,面板才回退到精灵上方。 */
|
|
89
193
|
.panel {
|
|
90
194
|
position: absolute;
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
border
|
|
95
|
-
|
|
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;
|
|
96
201
|
color: #e2e8f0;
|
|
97
202
|
font-size: 12px;
|
|
98
|
-
box-shadow:
|
|
99
|
-
|
|
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);
|
|
100
208
|
display: flex;
|
|
101
209
|
flex-direction: column;
|
|
102
|
-
gap:
|
|
103
|
-
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;
|
|
104
214
|
}
|
|
105
215
|
|
|
106
|
-
/*
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
抵达按钮前就收起。这段透明伪元素把命中区向下延伸、覆盖图标顶部,
|
|
110
|
-
使指针在图标与按钮之间始终停留在 hover 区域内。 */
|
|
216
|
+
/* 面板顶边与精灵底边之间的 hover 桥接(issue #49):
|
|
217
|
+
面板在精灵下方留有 8px 间隙,透明伪元素向上覆盖间隙,
|
|
218
|
+
使指针在精灵与按钮之间始终停留在 hover 区域。 */
|
|
111
219
|
.panel::after {
|
|
112
220
|
content: '';
|
|
113
221
|
position: absolute;
|
|
114
|
-
|
|
222
|
+
bottom: 100%;
|
|
115
223
|
left: 0;
|
|
116
224
|
right: 0;
|
|
117
225
|
height: 14px;
|
|
118
226
|
}
|
|
119
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;
|
|
239
|
+
}
|
|
240
|
+
|
|
120
241
|
.rankRow {
|
|
121
242
|
display: flex;
|
|
122
243
|
justify-content: space-between;
|
|
@@ -126,6 +247,28 @@
|
|
|
126
247
|
|
|
127
248
|
.nameCell {
|
|
128
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;
|
|
129
272
|
}
|
|
130
273
|
|
|
131
274
|
.renameRow {
|
|
@@ -166,15 +309,20 @@
|
|
|
166
309
|
cursor: pointer;
|
|
167
310
|
color: #0f172a;
|
|
168
311
|
background: linear-gradient(180deg, #7dd3fc, #38bdf8);
|
|
169
|
-
|
|
312
|
+
box-shadow: 0 2px 6px rgba(56, 189, 248, 0.25);
|
|
313
|
+
transition: filter 120ms ease, box-shadow 120ms ease, transform 120ms ease;
|
|
170
314
|
}
|
|
171
315
|
|
|
172
316
|
.action:hover {
|
|
173
317
|
filter: brightness(1.08);
|
|
318
|
+
transform: translateY(-1px);
|
|
319
|
+
box-shadow: 0 4px 10px rgba(56, 189, 248, 0.35);
|
|
174
320
|
}
|
|
175
321
|
|
|
176
322
|
.action:active {
|
|
177
323
|
filter: brightness(0.94);
|
|
324
|
+
transform: translateY(0);
|
|
325
|
+
box-shadow: 0 1px 4px rgba(56, 189, 248, 0.25);
|
|
178
326
|
}
|
|
179
327
|
|
|
180
328
|
.action:focus-visible {
|
|
@@ -215,7 +363,10 @@
|
|
|
215
363
|
/* Reduced motion: freeze the reaction bubble and kill the overlay transitions
|
|
216
364
|
so the pet's motion is static or minimal without hiding interaction. */
|
|
217
365
|
@media (prefers-reduced-motion: reduce) {
|
|
218
|
-
.bubble
|
|
366
|
+
.bubble,
|
|
367
|
+
.bubbleStatus,
|
|
368
|
+
.bubbleWhisper,
|
|
369
|
+
.panel {
|
|
219
370
|
animation: none;
|
|
220
371
|
opacity: 1;
|
|
221
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
|
+
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* @module @linxin666/dsh-pet/client/spritesheet
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import type
|
|
10
|
+
import { rowOf, type PetAnimation } from '../state.ts'
|
|
11
11
|
import type { PetCell, PetTrackDef } from '../registry.ts'
|
|
12
12
|
|
|
13
13
|
/** Animation track shape the frame loop consumes. */
|
|
@@ -15,18 +15,8 @@ export type TrackDef = PetTrackDef
|
|
|
15
15
|
|
|
16
16
|
/** Row index of one animation track (the fixed 9-row contract). */
|
|
17
17
|
export function rowOfTrack(animation: PetAnimation): number {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
'running-right': 1,
|
|
21
|
-
'running-left': 2,
|
|
22
|
-
waving: 3,
|
|
23
|
-
jumping: 4,
|
|
24
|
-
failed: 5,
|
|
25
|
-
waiting: 6,
|
|
26
|
-
running: 7,
|
|
27
|
-
review: 8,
|
|
28
|
-
}
|
|
29
|
-
return rows[animation]
|
|
18
|
+
// The table itself lives in state.ts (rowOf) — the single source of truth.
|
|
19
|
+
return rowOf(animation)
|
|
30
20
|
}
|
|
31
21
|
|
|
32
22
|
/**
|
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/index.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { PetService, PET_SETTINGS_NAMESPACE, type PetConfig, type PetSettingsSec
|
|
|
19
19
|
import { makePetRoutes } from './routes.ts'
|
|
20
20
|
import { loadPetRegistry, petPackageRoot } from './registry.ts'
|
|
21
21
|
import { DISPLAY_INSET_MAX, DISPLAY_SIZE_MAX, DISPLAY_SIZE_MIN } from './persist.ts'
|
|
22
|
+
import { mountOnce } from './mount-once.ts'
|
|
22
23
|
|
|
23
24
|
export { PetService, MAX_SESSION_BUBBLES } from './service.ts'
|
|
24
25
|
export type {
|
|
@@ -137,7 +138,9 @@ export function makePetSettingsSchema(fallbackPetId: string) {
|
|
|
137
138
|
}
|
|
138
139
|
|
|
139
140
|
/** Register the pet service and its API + asset routes on the context. */
|
|
140
|
-
export
|
|
141
|
+
export const apply = mountOnce('@linxin666/dsh-pet', applyImpl)
|
|
142
|
+
|
|
143
|
+
function applyImpl(ctx: Context, config: PetConfig = {}): void {
|
|
141
144
|
const registry = config.registry
|
|
142
145
|
?? loadPetRegistry({
|
|
143
146
|
packageRoot: petPackageRoot(import.meta.url),
|