@roaming-ai/dsh-group-chat 0.3.2 → 0.4.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.
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 发言人执行态点阵光球(live 行头像内容):角色色单色点阵 Canvas 2D 动画。
|
|
3
|
+
* thinking(轨道热斑游走=等待首字节/推理中)与 listening(向外涟漪=正文
|
|
4
|
+
* 流出)两态间弹簧缩放 + 权重交叉淡入;裁掉参考实现 MatrixOrb 的 label、
|
|
5
|
+
* level 与 idle 态(本场景只有执行中一种挂载时机),尺寸钉死为头像内容盒。
|
|
6
|
+
* @module dsh-group-chat/client/SpeakerOrb
|
|
7
|
+
*/
|
|
8
|
+
import { type ReactNode } from 'react';
|
|
9
|
+
export type SpeakerOrbState = 'thinking' | 'listening';
|
|
10
|
+
export declare function SpeakerOrb(props: {
|
|
11
|
+
state: SpeakerOrbState;
|
|
12
|
+
color: string;
|
|
13
|
+
}): ReactNode;
|
package/package.json
CHANGED
|
@@ -8,6 +8,7 @@ import { WINDOW_SIZE } from '../../core/constraints.ts'
|
|
|
8
8
|
import { Icon, P } from '../lib/ui.ts'
|
|
9
9
|
import { Bubble } from './Bubble.tsx'
|
|
10
10
|
import { ConstraintList } from './ConstraintList.tsx'
|
|
11
|
+
import { SpeakerOrb, type SpeakerOrbState } from './SpeakerOrb.tsx'
|
|
11
12
|
import { ThinkRow } from './ThinkRow.tsx'
|
|
12
13
|
import { resolveFailedRole } from '../../core/errors.ts'
|
|
13
14
|
import { roleById, type ClientSnapshot } from '../lib/model.ts'
|
|
@@ -30,15 +31,18 @@ export function MessageFlow(props: MessageFlowProps): ReactNode {
|
|
|
30
31
|
const replaceMsg = replaceId ? msgById[replaceId] : null
|
|
31
32
|
const liveRoleId = snap.run.currentRoleId || (replaceMsg && (replaceMsg.failedRoleId || replaceMsg.speaker)) || null
|
|
32
33
|
const lr = busyNow && liveRoleId ? roleById(snap, liveRoleId) : null
|
|
34
|
+
const liveColor = lr ? (lr.color || '#888') : '#888'
|
|
35
|
+
// 执行阶段映射:正文流出 → listening 涟漪;等待首字节/推理中 → thinking 热斑游走
|
|
36
|
+
const orbState: SpeakerOrbState = snap.run.partial ? 'listening' : 'thinking'
|
|
33
37
|
|
|
34
38
|
const live = lr
|
|
35
39
|
? (
|
|
36
40
|
<div key="__live" className="dsgc-msg live">
|
|
37
41
|
<div
|
|
38
42
|
className="dsgc-avatar"
|
|
39
|
-
style={{ border: '2px solid ' +
|
|
43
|
+
style={{ border: '2px solid ' + liveColor, '--role-color': liveColor } as CSSProperties}
|
|
40
44
|
>
|
|
41
|
-
{
|
|
45
|
+
<SpeakerOrb state={orbState} color={liveColor} />
|
|
42
46
|
</div>
|
|
43
47
|
<div className="dsgc-msgbody">
|
|
44
48
|
<div className="dsgc-msghead">
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 发言人执行态点阵光球(live 行头像内容):角色色单色点阵 Canvas 2D 动画。
|
|
3
|
+
* thinking(轨道热斑游走=等待首字节/推理中)与 listening(向外涟漪=正文
|
|
4
|
+
* 流出)两态间弹簧缩放 + 权重交叉淡入;裁掉参考实现 MatrixOrb 的 label、
|
|
5
|
+
* level 与 idle 态(本场景只有执行中一种挂载时机),尺寸钉死为头像内容盒。
|
|
6
|
+
* @module dsh-group-chat/client/SpeakerOrb
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { useEffect, useRef, useSyncExternalStore, type ReactNode } from 'react'
|
|
10
|
+
|
|
11
|
+
export type SpeakerOrbState = 'thinking' | 'listening'
|
|
12
|
+
|
|
13
|
+
const TAU = Math.PI * 2
|
|
14
|
+
const STATES: SpeakerOrbState[] = ['thinking', 'listening']
|
|
15
|
+
|
|
16
|
+
/** 头像内容盒:28px 外框减 2px 描边 ×2;圆形轮廓由 d 截断近似。 */
|
|
17
|
+
const SIZE = 24
|
|
18
|
+
const GRID = 7
|
|
19
|
+
/** 点阵铺开比例:外圈点贴近描边又不被 border-radius 裁切。 */
|
|
20
|
+
const SPREAD = 0.86
|
|
21
|
+
|
|
22
|
+
const SCALE: Record<SpeakerOrbState, number> = { thinking: 0.94, listening: 1 }
|
|
23
|
+
const STIFFNESS = 180
|
|
24
|
+
const DAMPING = 26
|
|
25
|
+
const ATTACK = 0.22
|
|
26
|
+
const RELEASE = 0.08
|
|
27
|
+
const BLEND = 0.16
|
|
28
|
+
|
|
29
|
+
/** thinking 态热斑轨道(点阵归一化坐标)。 */
|
|
30
|
+
const ORBITERS = [
|
|
31
|
+
{ radius: 0.62, speed: 2.2, phase: 0, spread: 0.42 },
|
|
32
|
+
{ radius: 0.4, speed: -1.7, phase: 2.1, spread: 0.36 },
|
|
33
|
+
{ radius: 0.8, speed: 1.15, phase: 4, spread: 0.34 },
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
// no Math.abs here, its corners read as a snap at every trough
|
|
37
|
+
function envelope(t: number) {
|
|
38
|
+
const slow = 0.5 + 0.5 * Math.sin(t * 0.62 + 0.4)
|
|
39
|
+
const fast = 0.5 + 0.5 * Math.sin(t * 1.9 + 1.1)
|
|
40
|
+
return 0.22 + 0.78 * (0.45 + 0.55 * slow) * fast
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function intensityOf(
|
|
44
|
+
state: SpeakerOrbState,
|
|
45
|
+
d: number,
|
|
46
|
+
nx: number,
|
|
47
|
+
ny: number,
|
|
48
|
+
t: number,
|
|
49
|
+
amplitude: number,
|
|
50
|
+
) {
|
|
51
|
+
if (state === 'listening') {
|
|
52
|
+
const ripple = 0.5 + 0.5 * Math.sin(d * 4.2 - t * 3)
|
|
53
|
+
return 0.32 + amplitude * (0.34 + 0.38 * ripple)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let heat = 0
|
|
57
|
+
for (const o of ORBITERS) {
|
|
58
|
+
const a = t * o.speed + o.phase
|
|
59
|
+
const dx = nx - Math.cos(a) * o.radius
|
|
60
|
+
const dy = ny - Math.sin(a) * o.radius
|
|
61
|
+
heat += Math.exp(-(dx * dx + dy * dy) / (o.spread * o.spread))
|
|
62
|
+
}
|
|
63
|
+
return 0.26 + 0.8 * Math.min(1, heat)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// zoom changes devicePixelRatio, and a buffer built for the old one gets upscaled
|
|
67
|
+
function subscribeToZoom(onChange: () => void) {
|
|
68
|
+
window.addEventListener('resize', onChange)
|
|
69
|
+
return () => window.removeEventListener('resize', onChange)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function useDevicePixelRatio() {
|
|
73
|
+
return useSyncExternalStore(
|
|
74
|
+
subscribeToZoom,
|
|
75
|
+
() => Math.min(window.devicePixelRatio || 1, 4),
|
|
76
|
+
() => 1,
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function SpeakerOrb(props: { state: SpeakerOrbState, color: string }): ReactNode {
|
|
81
|
+
const { state, color } = props
|
|
82
|
+
const canvasRef = useRef<HTMLCanvasElement>(null)
|
|
83
|
+
const stateRef = useRef(state)
|
|
84
|
+
const redrawRef = useRef<(() => void) | null>(null)
|
|
85
|
+
const dpr = useDevicePixelRatio()
|
|
86
|
+
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
stateRef.current = state
|
|
89
|
+
}, [state])
|
|
90
|
+
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
const canvas = canvasRef.current
|
|
93
|
+
const ctx = canvas?.getContext('2d')
|
|
94
|
+
if (!canvas || !ctx) return
|
|
95
|
+
|
|
96
|
+
// scaling by buffer/size, not dpr, keeps the transform exact when it rounds
|
|
97
|
+
const buffer = Math.round(SIZE * dpr)
|
|
98
|
+
canvas.width = canvas.height = buffer
|
|
99
|
+
ctx.scale(buffer / SIZE, buffer / SIZE)
|
|
100
|
+
ctx.fillStyle = color
|
|
101
|
+
|
|
102
|
+
const half = (GRID - 1) / 2
|
|
103
|
+
const spacing = (SIZE * SPREAD) / (GRID - 1)
|
|
104
|
+
const maxRadius = spacing * 0.6
|
|
105
|
+
const center = SIZE / 2
|
|
106
|
+
|
|
107
|
+
const weights: Record<SpeakerOrbState, number> = { thinking: 0, listening: 0 }
|
|
108
|
+
weights[stateRef.current] = 1
|
|
109
|
+
|
|
110
|
+
const draw = (t: number, amplitude: number, scale: number) => {
|
|
111
|
+
ctx.clearRect(0, 0, SIZE, SIZE)
|
|
112
|
+
|
|
113
|
+
for (let iy = 0; iy < GRID; iy++) {
|
|
114
|
+
for (let ix = 0; ix < GRID; ix++) {
|
|
115
|
+
const nx = (ix - half) / half
|
|
116
|
+
const ny = (iy - half) / half
|
|
117
|
+
const d = Math.hypot(nx, ny)
|
|
118
|
+
// 1.12, not the square's 1.41 corner, is what makes the outline round
|
|
119
|
+
if (d > 1.12) continue
|
|
120
|
+
|
|
121
|
+
let blended = 0
|
|
122
|
+
for (const s of STATES) {
|
|
123
|
+
if (weights[s] < 0.001) continue
|
|
124
|
+
blended += weights[s] * intensityOf(s, d, nx, ny, t, amplitude)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const intensity = Math.min(1, Math.max(0, blended))
|
|
128
|
+
const radius = maxRadius * Math.exp(-d * d * 1.7) * intensity * scale
|
|
129
|
+
// anything under half a device pixel renders as haze, not a dot
|
|
130
|
+
if (radius * dpr < 0.5) continue
|
|
131
|
+
|
|
132
|
+
ctx.beginPath()
|
|
133
|
+
ctx.arc(
|
|
134
|
+
center + (ix - half) * spacing * scale,
|
|
135
|
+
center + (iy - half) * spacing * scale,
|
|
136
|
+
radius,
|
|
137
|
+
0,
|
|
138
|
+
TAU,
|
|
139
|
+
)
|
|
140
|
+
ctx.fill()
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const reduce =
|
|
146
|
+
typeof window.matchMedia === 'function' &&
|
|
147
|
+
window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
148
|
+
|
|
149
|
+
if (reduce) {
|
|
150
|
+
redrawRef.current = () => {
|
|
151
|
+
const current = stateRef.current
|
|
152
|
+
for (const s of STATES) weights[s] = s === current ? 1 : 0
|
|
153
|
+
draw(0, envelope(0), SCALE[current])
|
|
154
|
+
}
|
|
155
|
+
redrawRef.current()
|
|
156
|
+
return () => {
|
|
157
|
+
redrawRef.current = null
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
let t = 0
|
|
162
|
+
let amplitude = 0
|
|
163
|
+
let scale = SCALE[stateRef.current]
|
|
164
|
+
let velocity = 0
|
|
165
|
+
let last = performance.now()
|
|
166
|
+
let raf = 0
|
|
167
|
+
|
|
168
|
+
const frame = (now: number) => {
|
|
169
|
+
const dt = Math.min((now - last) / 1000, 0.05)
|
|
170
|
+
last = now
|
|
171
|
+
t += dt
|
|
172
|
+
|
|
173
|
+
const current = stateRef.current
|
|
174
|
+
const target = envelope(t)
|
|
175
|
+
const rate = target > amplitude ? ATTACK : RELEASE
|
|
176
|
+
amplitude += (target - amplitude) * (1 - Math.pow(1 - rate, dt * 60))
|
|
177
|
+
|
|
178
|
+
// per-state weights, so interrupting a change blends from what is on screen
|
|
179
|
+
const step = 1 - Math.pow(1 - BLEND, dt * 60)
|
|
180
|
+
for (const s of STATES) {
|
|
181
|
+
weights[s] += ((s === current ? 1 : 0) - weights[s]) * step
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
velocity += (-STIFFNESS * (scale - SCALE[current]) - DAMPING * velocity) * dt
|
|
185
|
+
scale += velocity * dt
|
|
186
|
+
|
|
187
|
+
draw(t, amplitude, scale)
|
|
188
|
+
raf = requestAnimationFrame(frame)
|
|
189
|
+
}
|
|
190
|
+
raf = requestAnimationFrame(frame)
|
|
191
|
+
|
|
192
|
+
return () => cancelAnimationFrame(raf)
|
|
193
|
+
// state stays out of the deps on purpose: the loop retargets, it never restarts
|
|
194
|
+
}, [color, dpr])
|
|
195
|
+
|
|
196
|
+
useEffect(() => {
|
|
197
|
+
redrawRef.current?.()
|
|
198
|
+
}, [state])
|
|
199
|
+
|
|
200
|
+
return <canvas ref={canvasRef} aria-hidden className="dsgc-orb" style={{ width: SIZE, height: SIZE }} />
|
|
201
|
+
}
|
package/src/client/lib/styles.ts
CHANGED
|
@@ -104,6 +104,9 @@ export const CSS = [
|
|
|
104
104
|
'.dsgc-msg.mine{flex-direction:row-reverse}',
|
|
105
105
|
'.dsgc-avatar{width:28px;height:28px;box-sizing:border-box;border:2px solid var(--dsw-alias-border-l3,rgba(128,128,128,.4));border-radius:50%;background:var(--dsw-alias-bg-module-platform,rgba(128,128,128,.12));color:var(--dsw-alias-label-primary,inherit);display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:600;flex:none;margin-top:1px}',
|
|
106
106
|
'.dsgc-avatar.mine{border-color:transparent;background:var(--dsw-alias-button-info-fill,#4f6ef7);color:var(--dsw-alias-label-primary-foreground,#fff)}',
|
|
107
|
+
/* 执行中头像点阵光球(SpeakerOrb):inline canvas 需 block 消除基线空隙,
|
|
108
|
+
尺寸由组件内联 style 钉死为头像内容盒 24px */
|
|
109
|
+
'.dsgc-avatar .dsgc-orb{display:block}',
|
|
107
110
|
'.dsgc-msgbody{max-width:76%;min-width:0;display:flex;flex-direction:column;gap:5px}',
|
|
108
111
|
'.dsgc-msg.mine .dsgc-msgbody{align-items:flex-end}',
|
|
109
112
|
'.dsgc-msghead{display:flex;gap:6px;align-items:center;min-width:0;font-size:12px;color:var(--dsw-alias-label-secondary,inherit)}',
|