Commit 8b12272e by luoqi

fix(web): 助手入口降级为静态可拖动的脸(桌宠那套仅留 pet-lab)

老板反馈原桌宠"做得太过"(满屏漫游/爬卡片/追光标/抓蛀牙菌/套绳)。工作台改用新的
静态 AssistantFab:固定不漫游、不爬 UI、不响应光标移动、无重力甩落;保留可拖动(拖到哪停哪、
位置记 localStorage)+ 点击开助手 + hover 被摸表情 + 有意义时刻原地反馈(AI思考/说话、成约、切患者)。
usePetBrain 加 calm 模式(不打盹、关 idle 轮盘)。完整桌宠保留在 PetFab + /pet-lab。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent 1d462aa1
'use client';
import { useEffect, useRef, useState } from 'react';
import { usePetBrain } from '@/components/pet/pet-brain';
import { PetBody, type PetBodyPose } from '@/components/pet/pet-body';
const SIZE = 56;
const POS_KEY = 'pac-assistant-fab-pos';
/**
* AssistantFab — 助手入口(静态版,工作台用)。
*
* 老板反馈原桌宠"做得太过":满屏漫游 / 爬卡片 / 追光标 / 抓蛀牙菌 / 套绳…在客服工作台里抢戏。
* 这里降级为"助手的脸":不漫游、不爬 UI、不响应光标移动、不带重力甩落;
* 只保留有意义的原地反馈(AI 思考/说话、成约庆祝、切患者打招呼,usePetBrain calm 驱动)+ hover"被摸"表情。
* **保留一个动作:可拖动** —— 拖到哪停哪(不掉落),位置记住;点击 = 开/关助手窗。
*
* 注:完整桌宠(漫游/物理/狩猎)仍保留在 PetFab + /pet-lab 演示页,只是不进工作区。
*/
export function AssistantFab({
open,
onToggle,
}: {
open: boolean;
onToggle: (fabRect: DOMRect) => void;
}) {
const { pose, bubble, glanceSeq } = usePetBrain({ calm: true });
const btnRef = useRef<HTMLButtonElement>(null);
// 位置:null = 默认右下角;拖动后存 {x,y}(屏内夹紧),并记到 localStorage
const [pos, setPos] = useState<{ x: number; y: number } | null>(null);
useEffect(() => {
try {
const raw = localStorage.getItem(POS_KEY);
if (raw) {
const p = JSON.parse(raw) as { x: number; y: number };
if (typeof p?.x === 'number' && typeof p?.y === 'number') setPos(clamp(p.x, p.y));
}
} catch {
/* 私密模式 / 坏数据:忽略,用默认角 */
}
}, []);
// hover ≥300ms 算"被摸"(原地眯眼笑),路过不触发;拖动中不触发
const [cuddled, setCuddled] = useState(false);
const cuddleTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const onEnter = () => {
if (drag.current) return;
if (cuddleTimer.current) clearTimeout(cuddleTimer.current);
cuddleTimer.current = setTimeout(() => setCuddled(true), 300);
};
const onLeave = () => {
if (cuddleTimer.current) clearTimeout(cuddleTimer.current);
cuddleTimer.current = null;
setCuddled(false);
};
useEffect(() => () => void (cuddleTimer.current && clearTimeout(cuddleTimer.current)), []);
// ── 拖动(4px 阈值区分点击;拖到哪停哪,无重力) ──
const drag = useRef<{ startX: number; startY: number; baseX: number; baseY: number; moved: boolean } | null>(null);
const onPointerDown = (e: React.PointerEvent) => {
const r = btnRef.current!.getBoundingClientRect();
drag.current = { startX: e.clientX, startY: e.clientY, baseX: r.left, baseY: r.top, moved: false };
btnRef.current!.setPointerCapture(e.pointerId);
};
const onPointerMove = (e: React.PointerEvent) => {
const d = drag.current;
if (!d) return;
const dx = e.clientX - d.startX;
const dy = e.clientY - d.startY;
if (!d.moved) {
if (Math.hypot(dx, dy) < 4) return; // 抖动阈值内仍算点击
d.moved = true;
setCuddled(false);
if (cuddleTimer.current) clearTimeout(cuddleTimer.current);
}
setPos(clamp(d.baseX + dx, d.baseY + dy));
};
const onPointerUp = () => {
const d = drag.current;
drag.current = null;
if (!d) return;
if (d.moved) {
try {
const r = btnRef.current?.getBoundingClientRect();
if (r) localStorage.setItem(POS_KEY, JSON.stringify({ x: r.left, y: r.top }));
} catch {
/* ignore */
}
} else {
const r = btnRef.current?.getBoundingClientRect();
if (r) onToggle(r); // 没拖动 = 点击 → 开/关助手
}
};
// 被摸只在安静(idle)时覆盖;思考/说话/庆祝等"有意义"姿态优先
const bodyPose: PetBodyPose = cuddled && pose === 'idle' ? 'happy' : pose;
// 气泡弹向:在屏幕左半 → 朝右展开,右半 → 朝左(避免出界)
const onLeftHalf = pos ? pos.x + SIZE / 2 < window.innerWidth / 2 : false;
return (
<button
ref={btnRef}
type="button"
title={open ? '收起助手' : '打开助手(可拖动)'}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerEnter={onEnter}
onPointerLeave={onLeave}
className="fixed z-[61] touch-none select-none"
style={pos ? { left: pos.x, top: pos.y } : { right: 16, bottom: 16 }}
>
{bubble && (
<span
className={
'absolute bottom-full mb-1.5 w-max max-w-[230px] whitespace-normal break-words rounded-xl border border-slate-100 bg-white px-3 py-1.5 text-xs leading-snug text-slate-700 shadow-lg ' +
(onLeftHalf ? 'left-0' : 'right-0')
}
>
{bubble}
</span>
)}
<span className="block drop-shadow-md transition-transform hover:scale-105">
<PetBody pose={bodyPose} glanceSeq={glanceSeq} size={SIZE} />
</span>
</button>
);
}
/** 夹紧到视窗内(留 8px 边距),避免拖出屏幕拿不回来。 */
function clamp(x: number, y: number): { x: number; y: number } {
if (typeof window === 'undefined') return { x, y };
return {
x: Math.min(Math.max(x, 8), window.innerWidth - SIZE - 8),
y: Math.min(Math.max(y, 8), window.innerHeight - SIZE - 8),
};
}
......@@ -5,7 +5,7 @@ import { Permission } from '@pac/types';
import { cn } from '@/lib/utils';
import { useHasPermission } from '@/hooks/use-permission';
import { usePlanSyncStore } from '@/stores/plan-sync-store';
import { PetFab } from '@/components/pet/pet-widget';
import { AssistantFab } from './assistant-fab';
import { AssistantChat } from './assistant-chat';
/**
......@@ -63,12 +63,9 @@ export function AssistantWidget() {
>
<AssistantChat variant="widget" onClose={() => setOpen(false)} examples={examples} />
</div>
{/* 助手宠物(替代原 Bot FAB:可拖有重力、会散步;点击开/关助手窗;AI 生成中会走到窗边蹲守) */}
<PetFab
open={open}
onToggle={(r) => (open ? setOpen(false) : openAt(r))}
watchRect={open ? winRect : null}
/>
{/* 助手入口(静态版):固定右下角,点击开/关助手窗;只在 AI 思考/说话、成约、切患者时原地反馈。
完整桌宠(漫游/物理/狩猎)保留在 /pet-lab 演示页,不进工作区(老板反馈"做得太过")。 */}
<AssistantFab open={open} onToggle={(r) => (open ? setOpen(false) : openAt(r))} />
</>
);
}
......@@ -44,7 +44,10 @@ const GLANCE_MAX_MS = 16_000;
* 一次性演出(celebrate/气泡)由 play(DirectorScript) 叠加,ttl 到点复位回基线。
* play 是唯一指令入口 —— 将来 LLM 导演的输出也走这里,断了就只是"不说话",宠物照活。
*/
export function usePetBrain() {
export function usePetBrain(opts: { calm?: boolean } = {}) {
// calm 模式(助手的脸):不打盹、不跑 idle 小动作轮盘 —— 只在"有意义时刻"
// (AI 思考/说话、成约庆祝、切患者打招呼)动,平时安静待命。
const calm = !!opts.calm;
const [pose, setPose] = useState<PetPose>('idle');
const [bubble, setBubble] = useState<string | null>(null);
/** 自增信号:身体收到后做一次随机张望(idle 防机械感)。 */
......@@ -62,9 +65,9 @@ export function usePetBrain() {
if (voiceThinking.current) return 'think'; // 说话前先思考(等第一个词)
if (voiceTalking.current) return 'chomp'; // 说话中嘴一张一合
if (thinking.current) return 'think';
if (Date.now() - lastActiveAt.current > SLEEP_AFTER_MS) return 'sleep';
if (!calm && Date.now() - lastActiveAt.current > SLEEP_AFTER_MS) return 'sleep';
return 'idle';
}, []);
}, [calm]);
/** 演出脚本唯一入口(规则导演现在用,LLM 导演将来同入口)。 */
const play = useCallback(
......@@ -175,8 +178,9 @@ export function usePetBrain() {
}, [basePose, settle]);
// ── idle 小动作轮盘:张望 / 牙线自理 / 闪亮微笑 / 咬合操(防机械循环)──
// calm 模式(助手的脸)关掉整个轮盘:工作区里安静待命,不做环境娱乐动作。
useEffect(() => {
if (pose !== 'idle') return;
if (calm || pose !== 'idle') return;
let timer: ReturnType<typeof setTimeout>;
const schedule = () => {
timer = setTimeout(() => {
......@@ -191,7 +195,7 @@ export function usePetBrain() {
};
schedule();
return () => clearTimeout(timer);
}, [pose, play]);
}, [calm, pose, play]);
// 卸载清理
useEffect(
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment