Skip to content

Commit cf76a18

Browse files
committed
feat(assistant): 水母受惊弹开+右键固定+用户活跃检测(10min空闲阈值)
1 parent 71304db commit cf76a18

8 files changed

Lines changed: 433 additions & 9 deletions

File tree

‎client/src/features/assistant/AssistantRoot.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { useChat } from './hooks/useChat';
1212
import { useProactiveEngine, reportBubbleDismissed, reportBubbleResponded } from './hooks/useProactiveEngine';
1313
import { useAssistantAudio } from './hooks/useAssistantAudio';
1414
import { useBehaviorSignals } from './hooks/useBehaviorSignals';
15+
import { useUserActivity } from './hooks/useUserActivity';
1516
import { useBedtimeReminder } from './hooks/useBedtimeReminder';
1617
import { useIntentionCoach } from './hooks/useIntentionCoach';
1718
import { findTopDueDeck } from './lib/bedtimeReview';
@@ -30,6 +31,8 @@ export function AssistantRoot() {
3031
const { sendMessage, retryLastMessage, dismissError } = useChat();
3132
const { playSound } = useAssistantAudio();
3233
useProactiveEngine();
34+
// 用户活跃状态检测:空闲 10 分钟 → 发射 user:idle,交互恢复 → 发射 user:active
35+
useUserActivity();
3336
// A1/A5 行为信号源:情绪分级与认知负荷共用,只发事件不干预
3437
useBehaviorSignals();
3538
// F3 睡前复习推荐:晚间窗口检测到期卡,只发事件由引擎决策是否触发

‎client/src/features/assistant/components/ConversationPanel.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export function ConversationPanel({ onSend, onClose, onRetry, onDismiss }: Props
2424

2525
return (
2626
<motion.div
27+
data-work-area="conversation-panel"
2728
initial={{ x: PANEL_WIDTH, opacity: 0 }}
2829
animate={{ x: 0, opacity: 1 }}
2930
exit={{ x: PANEL_WIDTH, opacity: 0 }}

‎client/src/features/assistant/components/CreatureAvatar.tsx‎

Lines changed: 135 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,21 @@
88
* idle 时自主漫游(随机目标点 + 贝塞尔缓动),拖拽后 5s 恢复漫游。
99
* 位置持久化到 localStorage,下次打开应用水母在老位置。
1010
*/
11-
import { useRef, useEffect, useCallback } from 'react';
11+
import { useRef, useEffect, useCallback, useState, useMemo } from 'react';
1212
import { motion, useMotionValue, animate } from 'framer-motion';
13+
import { Pin } from 'lucide-react';
1314
import { useAssistantStore } from '../store/useAssistantStore';
1415
import { CreatureBubble } from './CreatureBubble';
16+
import { useWorkAreaBounds } from '../hooks/useWorkAreaBounds';
17+
import { ContextMenu } from '@/components/ui/ContextMenu';
18+
import type { ContextMenuGroup } from '@/components/ui/ContextMenu';
1519
import {
1620
CREATURE_SIZE_IDLE,
1721
WANDER_INTERVAL_MIN_MS, WANDER_INTERVAL_MAX_MS,
1822
WANDER_DURATION_MIN_MS, WANDER_DURATION_MAX_MS,
1923
WANDER_RESUME_DELAY_MS, WANDER_BOUNDS,
2024
CREATURE_POS_STORAGE_KEY,
25+
ESCAPE_SPRING_STIFFNESS, ESCAPE_SPRING_DAMPING,
2126
} from '../constants';
2227
import type { CreatureState } from '../types';
2328

@@ -64,6 +69,15 @@ export function CreatureAvatar({ onClick, onBubbleClick, onBubbleDismiss }: Prop
6469
const creatureState = useAssistantStore(s => s.creatureState);
6570
const bubbleMessage = useAssistantStore(s => s.bubbleMessage);
6671
const panelState = useAssistantStore(s => s.panelState);
72+
const userActive = useAssistantStore(s => s.userActive);
73+
const isFixed = useAssistantStore(s => s.isFixed);
74+
const setIsFixed = useAssistantStore(s => s.setIsFixed);
75+
const setAutoFixed = useAssistantStore(s => s.setAutoFixed);
76+
77+
const { isInWorkArea, getRandomTargetOutside } = useWorkAreaBounds();
78+
79+
// 右键菜单状态
80+
const [ctxMenuPos, setCtxMenuPos] = useState<{ x: number; y: number } | null>(null);
6781

6882
// 位置由 MotionValue 驱动(GPU 合成,不触发 React 重渲染)
6983
const initial = useRef(loadSavedPosition());
@@ -75,11 +89,43 @@ export function CreatureAvatar({ onClick, onBubbleClick, onBubbleDismiss }: Prop
7589
const wanderAnimRef = useRef<{ stop: () => void } | null>(null);
7690
const isDraggingRef = useRef(false);
7791

92+
// 受惊弹开动画状态(一过性 scale 脉冲)
93+
const [startledScale, setStartledScale] = useState<number[] | null>(null);
94+
const prevUserActiveRef = useRef(userActive);
95+
96+
// 用 ref 保持 userActive 最新值供异步回调(wanderTo setTimeout 闭包)
97+
const userActiveRef = useRef(userActive);
98+
userActiveRef.current = userActive;
99+
78100
/** 在视口漫游区域内随机选取目标点并平滑移动 */
79101
const wanderTo = useCallback(() => {
80-
const { xMin, xMax, yMin, yMax } = WANDER_BOUNDS;
81-
const targetX = window.innerWidth * (xMin + Math.random() * (xMax - xMin));
82-
const targetY = window.innerHeight * (yMin + Math.random() * (yMax - yMin));
102+
// 固定状态不漫游
103+
if (useAssistantStore.getState().isFixed) return;
104+
105+
const vw = window.innerWidth;
106+
const vh = window.innerHeight;
107+
108+
let targetX: number;
109+
let targetY: number;
110+
111+
if (userActiveRef.current) {
112+
// 用户活跃时:仅在工作区外漫游
113+
const target = getRandomTargetOutside(vw, vh, CREATURE_SIZE_IDLE);
114+
targetX = target.x;
115+
targetY = target.y;
116+
117+
// 检查目标是否仍在工作区内(小窗口无安全区时自动固定)
118+
if (isInWorkArea(targetX, targetY, CREATURE_SIZE_IDLE)) {
119+
setAutoFixed(true);
120+
return;
121+
}
122+
} else {
123+
// 用户空闲时:全屏漫游
124+
const { xMin, xMax, yMin, yMax } = WANDER_BOUNDS;
125+
targetX = vw * (xMin + Math.random() * (xMax - xMin));
126+
targetY = vh * (yMin + Math.random() * (yMax - yMin));
127+
}
128+
83129
const duration = (WANDER_DURATION_MIN_MS + Math.random() * (WANDER_DURATION_MAX_MS - WANDER_DURATION_MIN_MS)) / 1000;
84130

85131
// 停止上一次漫游动画
@@ -89,7 +135,7 @@ export function CreatureAvatar({ onClick, onBubbleClick, onBubbleDismiss }: Prop
89135
ease: 'easeInOut',
90136
});
91137
animate(posY, targetY, { duration, ease: 'easeInOut' });
92-
}, [posX, posY]);
138+
}, [posX, posY, getRandomTargetOutside, isInWorkArea, setAutoFixed]);
93139

94140
/** 调度下一次漫游(随机间隔) */
95141
const scheduleWander = useCallback(() => {
@@ -112,6 +158,45 @@ export function CreatureAvatar({ onClick, onBubbleClick, onBubbleDismiss }: Prop
112158
};
113159
}, [scheduleWander]);
114160

161+
// 用户空闲→活跃转换:如果在工作区内则受惊弹开
162+
useEffect(() => {
163+
const wasIdle = prevUserActiveRef.current === false;
164+
const nowActive = userActive === true;
165+
prevUserActiveRef.current = userActive;
166+
167+
if (wasIdle && nowActive) {
168+
const cx = posX.get();
169+
const cy = posY.get();
170+
if (isInWorkArea(cx, cy, CREATURE_SIZE_IDLE)) {
171+
// 停止当前漫游
172+
wanderAnimRef.current?.stop();
173+
if (wanderTimerRef.current) clearTimeout(wanderTimerRef.current);
174+
175+
// 选取逃逸目标点
176+
const target = getRandomTargetOutside(window.innerWidth, window.innerHeight, CREATURE_SIZE_IDLE);
177+
178+
// 弹簧动画:受惊弹开
179+
wanderAnimRef.current = animate(posX, target.x, {
180+
type: 'spring',
181+
stiffness: ESCAPE_SPRING_STIFFNESS,
182+
damping: ESCAPE_SPRING_DAMPING,
183+
});
184+
animate(posY, target.y, {
185+
type: 'spring',
186+
stiffness: ESCAPE_SPRING_STIFFNESS,
187+
damping: ESCAPE_SPRING_DAMPING,
188+
});
189+
190+
// 触发受惊视觉反馈(scale 弹性脉冲)
191+
setStartledScale([1, 1.35, 0.9, 1.1, 1]);
192+
setTimeout(() => setStartledScale(null), 600);
193+
194+
// 逃逸完成后重新调度漫游
195+
setTimeout(() => { if (!isDraggingRef.current) scheduleWander(); }, ESCAPE_SPRING_DAMPING * 20);
196+
}
197+
}
198+
}, [userActive, posX, posY, isInWorkArea, getRandomTargetOutside, scheduleWander]);
199+
115200
// 拖拽结束:持久化位置 + 5s 后恢复漫游
116201
const handleDragEnd = useCallback(() => {
117202
isDraggingRef.current = false;
@@ -120,7 +205,38 @@ export function CreatureAvatar({ onClick, onBubbleClick, onBubbleDismiss }: Prop
120205
setTimeout(() => { if (!isDraggingRef.current) wanderTo(); }, WANDER_RESUME_DELAY_MS);
121206
}, [posX, posY, wanderTo]);
122207

208+
// 右键菜单组
209+
const ctxMenuGroups = useMemo<ContextMenuGroup[]>(() => [
210+
{
211+
items: [
212+
{
213+
key: isFixed ? 'unfix' : 'fix',
214+
label: isFixed ? '恢复漫游' : '固定位置',
215+
icon: <Pin className="w-4 h-4" strokeWidth={1.5} />,
216+
},
217+
],
218+
},
219+
], [isFixed]);
220+
221+
const handleContextMenu = useCallback((e: React.MouseEvent) => {
222+
e.preventDefault();
223+
e.stopPropagation();
224+
setCtxMenuPos({ x: e.clientX, y: e.clientY });
225+
}, []);
226+
227+
const handleCtxMenuSelect = useCallback((key: string) => {
228+
if (key === 'fix') {
229+
setIsFixed(true);
230+
} else if (key === 'unfix') {
231+
setIsFixed(false);
232+
// 恢复漫游
233+
scheduleWander();
234+
}
235+
setCtxMenuPos(null);
236+
}, [setIsFixed, scheduleWander]);
237+
123238
return (
239+
<>
124240
<motion.div
125241
className="fixed top-0 left-0 z-50 flex flex-col items-end"
126242
style={{ x: posX, y: posY, width: CREATURE_SIZE_IDLE }}
@@ -147,15 +263,16 @@ export function CreatureAvatar({ onClick, onBubbleClick, onBubbleDismiss }: Prop
147263
{/* 水母主体 */}
148264
<motion.button
149265
onClick={onClick}
266+
onContextMenu={handleContextMenu}
150267
className="relative outline-none border-none bg-transparent cursor-pointer"
151268
style={{ width: CREATURE_SIZE_IDLE, height: CREATURE_SIZE_IDLE }}
152269
animate={{
153270
y: creatureState === 'resting' ? [0, 4, 0] : [0, -3, 0],
154-
scale: creatureState === 'alerting' ? [1, 1.08, 1] : 1,
271+
scale: startledScale ?? (creatureState === 'alerting' ? [1, 1.08, 1] : 1),
155272
}}
156273
transition={{
157274
y: { duration: creatureState === 'resting' ? 6 : 4, repeat: Infinity, ease: 'easeInOut' },
158-
scale: { duration: 0.6, repeat: creatureState === 'alerting' ? 3 : 0 },
275+
scale: { duration: startledScale ? 0.5 : 0.6, repeat: startledScale ? 0 : (creatureState === 'alerting' ? 3 : 0) },
159276
}}
160277
whileHover={{ scale: 1.1 }}
161278
whileTap={{ scale: 0.95 }}
@@ -182,5 +299,16 @@ export function CreatureAvatar({ onClick, onBubbleClick, onBubbleDismiss }: Prop
182299
))}
183300
</motion.button>
184301
</motion.div>
302+
303+
{ctxMenuPos && (
304+
<ContextMenu
305+
groups={ctxMenuGroups}
306+
position={ctxMenuPos}
307+
context={null}
308+
onSelect={(key) => handleCtxMenuSelect(key)}
309+
onClose={() => setCtxMenuPos(null)}
310+
/>
311+
)}
312+
</>
185313
);
186314
}

‎client/src/features/assistant/constants.ts‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export const MAX_TRIGGERS_PER_HOUR = 2;
1212
/** 连续忽略多少次后当日不再触发 */
1313
export const MAX_CONSECUTIVE_IGNORES = 3;
1414
/** 空闲检测阈值(ms) */
15-
export const IDLE_THRESHOLD_MS = 3 * 60 * 1000;
15+
export const IDLE_THRESHOLD_MS = 10 * 60 * 1000;
1616
/** 久别回归阈值(ms):超过 24h 未打开 */
1717
export const RETURN_THRESHOLD_MS = 24 * 60 * 60 * 1000;
1818

@@ -85,11 +85,20 @@ export const WANDER_DURATION_MIN_MS = 6000;
8585
export const WANDER_DURATION_MAX_MS = 10000;
8686
/** 拖拽后恢复漫游的延迟(ms) */
8787
export const WANDER_RESUME_DELAY_MS = 5000;
88-
/** 漫游活动区域(视口百分比范围) */
88+
/** 漫游活动区域(视口百分比范围)——空闲时全屏,活跃时由 useWorkAreaBounds 动态计算 */
8989
export const WANDER_BOUNDS = { xMin: 0.05, xMax: 0.85, yMin: 0.15, yMax: 0.8 };
9090
/** 水母位置持久化键 */
9191
export const CREATURE_POS_STORAGE_KEY = 'assistant_creature_pos';
9292

93+
// ── 受惊弹开动画 ──────────────────────────────────────────────
94+
95+
/** 受惊弹开动画时长(ms) */
96+
export const ESCAPE_DURATION_MS = 600;
97+
/** 受惊弹开弹簧刚度 */
98+
export const ESCAPE_SPRING_STIFFNESS = 500;
99+
/** 受惊弹开弹簧阻尼(低阻尼产生弹性效果) */
100+
export const ESCAPE_SPRING_DAMPING = 12;
101+
93102
// ── 默认偏好 ──────────────────────────────────────────────────
94103

95104
export const DEFAULT_PREFERENCES: AssistantPreferences = {
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
/**
2+
* 用户活跃/空闲状态检测 Hook
3+
*
4+
* @ai-context: 监听全局交互事件(鼠标移动、键盘、点击、滚轮、触摸),
5+
* 最后一次交互后 IDLE_THRESHOLD_MS(10分钟)无操作视为空闲,
6+
* 检测到交互时标记为活跃。窗口隐藏时视为空闲。
7+
* 状态转换时发射 user:idle / user:active 事件并更新 store。
8+
* 在 AssistantRoot 中挂载一次。
9+
*/
10+
import { useEffect, useRef } from 'react';
11+
import { useAssistantStore } from '../store/useAssistantStore';
12+
import { assistantEventBus } from '../lib/eventBus';
13+
import { IDLE_THRESHOLD_MS } from '../constants';
14+
15+
/** 活跃交互事件列表 */
16+
const ACTIVITY_EVENTS = ['mousemove', 'keydown', 'click', 'scroll', 'touchstart'] as const;
17+
18+
export function useUserActivity(): void {
19+
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
20+
const isActiveRef = useRef(true); // 初始为活跃
21+
22+
useEffect(() => {
23+
const setUserActive = useAssistantStore.getState().setUserActive;
24+
25+
/** 重置空闲计时器 */
26+
function resetIdleTimer(): void {
27+
if (timerRef.current) clearTimeout(timerRef.current);
28+
timerRef.current = setTimeout(() => {
29+
// 变为空闲
30+
if (isActiveRef.current) {
31+
isActiveRef.current = false;
32+
setUserActive(false);
33+
const currentHour = new Date().getHours();
34+
assistantEventBus.emit('user:idle', { currentHour });
35+
}
36+
}, IDLE_THRESHOLD_MS);
37+
}
38+
39+
/** 处理交互事件:标记为活跃 + 重置空闲计时器 */
40+
function handleActivity(): void {
41+
// 如果之前是空闲,发射 user:active 事件
42+
if (!isActiveRef.current) {
43+
isActiveRef.current = true;
44+
setUserActive(true);
45+
const currentHour = new Date().getHours();
46+
assistantEventBus.emit('user:active', { currentHour });
47+
}
48+
resetIdleTimer();
49+
}
50+
51+
/** 处理可见性变化:隐藏时视为空闲,显示时视为活跃 */
52+
function handleVisibilityChange(): void {
53+
if (document.hidden) {
54+
// 窗口隐藏 → 空闲
55+
if (timerRef.current) clearTimeout(timerRef.current);
56+
if (isActiveRef.current) {
57+
isActiveRef.current = false;
58+
setUserActive(false);
59+
const currentHour = new Date().getHours();
60+
assistantEventBus.emit('user:idle', { currentHour });
61+
}
62+
} else {
63+
// 窗口显示 → 活跃
64+
handleActivity();
65+
}
66+
}
67+
68+
// 注册事件
69+
for (const evt of ACTIVITY_EVENTS) {
70+
window.addEventListener(evt, handleActivity, { passive: true });
71+
}
72+
document.addEventListener('visibilitychange', handleVisibilityChange);
73+
74+
// 启动空闲计时器
75+
resetIdleTimer();
76+
77+
return () => {
78+
for (const evt of ACTIVITY_EVENTS) {
79+
window.removeEventListener(evt, handleActivity);
80+
}
81+
document.removeEventListener('visibilitychange', handleVisibilityChange);
82+
if (timerRef.current) clearTimeout(timerRef.current);
83+
};
84+
}, []);
85+
}

0 commit comments

Comments
 (0)