Commit 43bcc18d by luoqi

feat(web): 详情页桌宠「小牙」— 动作/物理/互动 + LLM 环境发言

Q 版磨牙桌宠,挂在 plans/layout(替代原助手 Bot FAB),三层架构:
- 感知 pet-events(语义事件 + DirectorScript 受限词表,LLM 接入口)
- 大脑 pet-brain(FSM 基线姿态 + idle 动作轮盘:张望/牙线/闪亮/咬合/牙医组合)
- 身体 pet-body(程序化 SVG,无外部资产)+ 运动层 pet-locomotion(物理)

动作/物理:重力抛掷·撞墙反弹·任意位栖息(自动发现带边框元素)·走边缘踩空·
爬楼(背朝外悬挂垫脚,可上可下)·动锚点钟摆套绳荡飞(绳端钉实时鼠标)。
互动:摸摸/惊吓/好奇凑近/眼神追随;圈选文字 → 走过去+爬楼+放大镜照+爬下楼。
组合派生:牙医诊疗(检查→洗牙/补牙/终止→抛光/涂氟护盾/终止→收尾,概率分叉)。
NPC:蛀牙菌(地板/边框两种 lane,追/逃/敲爆;隐藏 10% 超人模式:变身→直飞秒杀)。
仪式:坐稳卡片刷牙→闪亮→漱口→涂氟护盾。5 分钟无操作打盹。

LLM:新增 POST /pac/v1/assistant/pet-say(SSE,无工具,≤30 字流式),
前端 use-pet-voice 仅页面激活 + 低概率 + 4min 冷却时观察环境说一句(唯一带气泡的动作);
其余动作全为纯动作/道具,不出说话框。

/pet-lab 为动作陈列 + 触发调试页(dev 用)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent 8910c3d3
......@@ -84,6 +84,39 @@ export class AssistantController {
return { text: r.text };
}
/** 桌宠发言:输入一段环境观察,流式吐一句 ≤30 字的台词(无工具,极轻)。 */
@Post('pet-say')
@ApiOperation({ summary: '桌宠环境观察发言(SSE,简短一句)' })
async petSay(
@Req() req: Request,
@Res() res: Response,
@Body() body: { observation?: string },
): Promise<void> {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache, no-transform');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders?.();
const send = (event: Record<string, unknown>): void => {
res.write(`data: ${JSON.stringify(event)}\n\n`);
};
const ac = new AbortController();
req.on('close', () => ac.abort());
try {
const r = this.assistant.petSay({
observation: String(body.observation ?? '').slice(0, 400), // 观察必须简短,后端兜底截断
abortSignal: ac.signal,
});
for await (const text of r.textStream) {
if (text) send({ type: 'text', text });
}
send({ type: 'done' });
} catch (err) {
send({ type: 'error', error: err instanceof Error ? err.message : String(err) });
} finally {
res.end();
}
}
@Post('chat')
@ApiOperation({ summary: '助手对话(SSE)— 模型自主调 PAC MCP 工具' })
async chat(@Req() req: Request, @Res() res: Response, @Body() body: ChatBody): Promise<void> {
......
......@@ -24,6 +24,13 @@ const SYSTEM_PROMPT = `你是一个通用智能助手,目前在为牙科诊所
用中文,简洁专业、友好。`;
/** 桌宠"小牙"的人设(pet-say 专用,无工具、极短输出)。 */
const PET_SYSTEM_PROMPT = `你是牙科客服工作台 PAC 的桌面宠物"小牙"——一颗 Q 版小磨牙。
根据给你的环境观察,用第一人称说一句话:中文,不超过 30 个字,口语化、俏皮但不油腻,最多一个 emoji。
只评论观察里出现的事,不编造患者信息,不给医疗建议,不自称 AI 或模型;
不假设用户的性别/称呼(不要叫哥哥姐姐,直接用"你"或不称呼)。
直接输出台词本身,不要引号、不要解释。`;
export interface AssistantChatInput {
userToken: string;
modelId?: string;
......@@ -96,4 +103,18 @@ export class AssistantService {
abortSignal: input.abortSignal,
});
}
/** 桌宠环境观察发言 —— 无工具、限长、流式;失败由前端静默降级(宠物只是不说话)。 */
petSay(input: { observation: string; abortSignal?: AbortSignal }): { textStream: AsyncIterable<string> } {
const { model } = this.provider.resolve('deepseek');
return streamText({
model,
system: PET_SYSTEM_PROMPT,
prompt: `环境观察:${input.observation}`,
// 注:思考型模型 reasoning 也计入 output token,上限给宽;台词长度靠系统提示词约束(≤30 字)
maxOutputTokens: 500,
temperature: 1.0,
abortSignal: input.abortSignal,
});
}
}
'use client';
import { useState } from 'react';
import { emitPetEvent } from '@/lib/pet-events';
import { usePlanSyncStore } from '@/stores/plan-sync-store';
import { PetBody, type PetBodyPose } from '@/components/pet/pet-body';
import { PetFab } from '@/components/pet/pet-widget';
const POSES: PetBodyPose[] = ['idle', 'think', 'celebrate', 'sleep', 'walk', 'fall', 'sit', 'happy', 'brush', 'rinse', 'floss', 'shine', 'chomp', 'fly', 'vet_exam', 'vet_scale', 'vet_fill', 'vet_polish', 'vet_fluoride', 'climb', 'magnify'];
/**
* 宠物实验室(dev 调试页,无需登录)— 上:各姿态静态陈列(直接喂 PetBody);
* 真·PetFab(带大脑 + 运动层)在页面里自由活动:拖起有重力、空闲散步、
* 可跳上下方的 data-pet-perch 栖息台。按钮模拟感知事件,验证 FSM 流转。
* 不进生产导航,仅供调动画/调行为时手动访问 /pet-lab。
*/
export default function PetLabPage() {
const [glance, setGlance] = useState(0);
return (
<div className="min-h-screen bg-slate-50 p-8">
<h1 className="mb-6 text-lg font-semibold text-slate-700">宠物实验室 /pet-lab</h1>
{/* 姿态卡片本身也是栖息点(data-pet-perch):宠物会跳到卡片上边框蹲着 */}
<section className="mb-8 flex flex-wrap gap-6">
{POSES.map((p) => (
<figure
key={p}
data-pet-perch
className="flex flex-col items-center gap-2 rounded-xl border border-slate-100 bg-white p-4 shadow-sm"
>
<PetBody pose={p} glanceSeq={p === 'idle' ? glance : 0} size={96} />
<figcaption className="text-xs text-slate-500">{p}</figcaption>
</figure>
))}
{/* 涂氟护盾(与姿态正交的 buff,叠在 idle 上展示) */}
<figure
data-pet-perch
className="flex flex-col items-center gap-2 rounded-xl border border-slate-100 bg-white p-4 shadow-sm"
>
<PetBody pose="idle" glanceSeq={0} shield size={96} />
<figcaption className="text-xs text-slate-500">shield(涂氟护盾)</figcaption>
</figure>
</section>
<section className="flex flex-wrap gap-2">
<LabBtn label="张望一下" onClick={() => setGlance((g) => g + 1)} />
<LabBtn label="AI 开始思考" onClick={() => emitPetEvent({ type: 'ai_thinking_start' })} />
<LabBtn label="AI 结束" onClick={() => emitPetEvent({ type: 'ai_thinking_end' })} />
<LabBtn
label="模拟成约(庆祝)"
onClick={() => {
const s = usePlanSyncStore.getState();
s.setCurrent({ planId: `lab-${Date.now()}`, patientName: '测试患者' });
s.notify('lab-plan', 'completed');
}}
/>
<LabBtn
label="模拟切患者(打招呼)"
onClick={() =>
usePlanSyncStore.getState().setCurrent({ planId: `lab-${Date.now()}`, patientName: '王小明' })
}
/>
<LabBtn
label="去栖息(跳上卡片/台子)"
onClick={() => window.dispatchEvent(new CustomEvent('pac-pet-debug', { detail: { cmd: 'perch' } }))}
/>
<LabBtn
label="随便散步"
onClick={() => window.dispatchEvent(new CustomEvent('pac-pet-debug', { detail: { cmd: 'stroll' } }))}
/>
<LabBtn
label="刷牙刷字(需先坐上卡片)"
onClick={() => window.dispatchEvent(new CustomEvent('pac-pet-debug', { detail: { cmd: 'brush' } }))}
/>
<LabBtn
label="套绳荡飞(鼠标停高处=套鼠标)"
onClick={() => window.dispatchEvent(new CustomEvent('pac-pet-debug', { detail: { cmd: 'swing' } }))}
/>
<LabBtn
label="放一只蛀牙菌 🦠"
onClick={() => window.dispatchEvent(new CustomEvent('pac-pet-debug', { detail: { cmd: 'germ' } }))}
/>
<LabBtn
label="超人模式 🦸(强制)"
onClick={() => window.dispatchEvent(new CustomEvent('pac-pet-debug', { detail: { cmd: 'germ-super' } }))}
/>
<LabBtn
label="牙医组合 🦷(检查→治疗→处置)"
onClick={() => window.dispatchEvent(new CustomEvent('pac-pet-debug', { detail: { cmd: 'vet' } }))}
/>
<LabBtn
label="爬下楼 🧗(需先坐上卡片)"
onClick={() => window.dispatchEvent(new CustomEvent('pac-pet-debug', { detail: { cmd: 'climb' } }))}
/>
<LabBtn
label="说句话 💬(LLM,需登录态)"
onClick={() => window.dispatchEvent(new CustomEvent('pac-pet-debug', { detail: { cmd: 'say' } }))}
/>
</section>
<p className="mt-4 text-xs text-slate-400">
真 PetFab 在页面里自由活动:拖起松手会摔到底边(落地压扁);静置 25-40s 它会自己散步,
有概率跳上下面的栖息台(蹲够了走到台边踩空摔下);90s 无操作打盹;AI 思考中若助手窗开着会走过去蹲守。
<br />
鼠标互动:悬停它身上一会 = 摸摸(眯眼笑冒爱心);鼠标快速从它身边掠过 = 吓一跳蹦开;
鼠标在它附近慢慢停留几秒 = 好奇凑过来看你;拖着甩出去 = 带惯性抛飞、撞墙反弹。
坐上卡片 8-18 秒后会自己掏牙刷把脚下"刷出泡沫",刷完闪✨再鼓腮漱口吐泡泡(也可用按钮立即触发)。
下落/抛飞/荡飞途中穿过任何带边框元素的顶边都会被接住落在上面;划选一段文字,它会走到选区正下方围观。
把鼠标停在它头顶高处不动 25s+,它有概率甩绳子套住你的光标荡秋千 —— 荡的时候绳子一端永远钉在鼠标上
(移动鼠标整条摆跟着走),此时不撞墙;荡够松手才变抛飞,这时才撞墙/被边框接住。
爱牙时间:每隔约 1-2 分钟可能刷出一只蛀牙菌(地板底边,或骑在某个边框上)——它会追着打,菌被追急了会逃,
在地板就走过去敲、在边框就纵身一跃去敲(不会卡在中间楼层),爆成星星然后庆祝;追不上就让它溜了。
隐藏 10%:进入超人模式 🦸 —— 先原地变身(披风展开+金色光环蓄力)再直线飞过去秒掉它(lab 可强制)。它还会不定期冒护牙小贴士。
牙医组合 🦷:闲时小概率化身牙医跑一套诊疗 —— 检查 → 概率(洗牙/补牙/收工)→ 概率(抛光/涂氟拿护盾/收工)→ 收尾;每次流程随机长短,
中途拖它/出蛀虫就丢下工具去忙别的(lab 可一键触发)。
闲时小动作轮盘:张望 / 牙线自理 / 闪亮微笑 / 咬合操,隔 7-16 秒随机出一个;
完成"刷牙→闪亮→漱口"全套仪式后获得 12 秒涂氟护盾(青色光罩)。
</p>
{/* 栖息台:离地 ~120px,在可跳高度内(data-pet-perch 机制演示) */}
<div
data-pet-perch
className="fixed bottom-[120px] left-1/2 h-3 w-56 -translate-x-1/2 rounded-full bg-teal-100 shadow-inner"
title="栖息台(data-pet-perch)"
/>
{/* 真 FAB(带大脑 + 运动层) */}
<PetFab open={false} onToggle={() => console.log('[pet-lab] fab clicked')} watchRect={null} />
</div>
);
}
function LabBtn({ label, onClick }: { label: string; onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
className="rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-sm text-slate-700 shadow-sm hover:bg-slate-50"
>
{label}
</button>
);
}
'use client';
import { useEffect, useRef, useState } from 'react';
import { Bot } from 'lucide-react';
import { useState } from 'react';
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 { AssistantChat } from './assistant-chat';
/**
......@@ -19,8 +19,8 @@ import { AssistantChat } from './assistant-chat';
export function AssistantWidget() {
const allowed = useHasPermission(Permission.AGENT_INVOKE);
const [open, setOpen] = useState(false);
// 窗口落点:打开瞬间按"钮的当前位置"计算(拖钮的意义所在),并夹紧在视窗内
const [winPos, setWinPos] = useState<{ x: number; y: number } | null>(null);
// 窗口落点 + 尺寸:打开瞬间按"钮的当前位置"计算(拖钮的意义所在),并夹紧在视窗内
const [winRect, setWinRect] = useState<{ x: number; y: number; w: number; h: number } | null>(null);
const openAt = (fab: DOMRect) => {
const W = Math.min(400, window.innerWidth - 16);
......@@ -32,7 +32,7 @@ export function AssistantWidget() {
if (y < 8) y = fab.bottom + 8;
x = Math.min(Math.max(x, 8), window.innerWidth - W - 8);
y = Math.min(Math.max(y, 8), window.innerHeight - H - 8);
setWinPos({ x, y });
setWinRect({ x, y, w: W, h: H });
setOpen(true);
};
// 场景化开场建议(规则,非 AI):有当前患者 → 围绕该患者;否则兜底通用
......@@ -51,10 +51,10 @@ export function AssistantWidget() {
<>
{/* 吸附窗:常驻挂载,关着时 invisible(状态不丢) */}
<div
style={winPos ? { left: winPos.x, top: winPos.y } : undefined}
style={winRect ? { left: winRect.x, top: winRect.y } : undefined}
className={cn(
'fixed z-[60] flex flex-col overflow-hidden rounded-xl border border-slate-100 shadow-2xl',
!winPos && 'bottom-4 right-4',
!winRect && 'bottom-4 right-4',
'h-[620px] max-h-[78vh] w-[400px] max-w-[calc(100vw-2rem)]',
'transition-[opacity,transform] duration-150',
open ? 'translate-y-0 opacity-100' : 'pointer-events-none invisible translate-y-2 opacity-0',
......@@ -62,82 +62,12 @@ export function AssistantWidget() {
>
<AssistantChat variant="widget" onClose={() => setOpen(false)} examples={examples} />
</div>
{/* 悬浮开关钮(可拖移:位移 >4px 判定为拖,松手即记忆位置;否则当点击打开) */}
{!open && <DraggableFab onOpen={openAt} />}
{/* 助手宠物(替代原 Bot FAB:可拖有重力、会散步;点击开/关助手窗;AI 生成中会走到窗边蹲守) */}
<PetFab
open={open}
onToggle={(r) => (open ? setOpen(false) : openAt(r))}
watchRect={open ? winRect : null}
/>
</>
);
}
const FAB_POS_KEY = 'pac-assistant-fab-pos';
/** 可拖移悬浮钮:pointer 事件拖动(阈值 4px 区分点击),位置 localStorage 记忆并随窗口缩放夹紧。 */
function DraggableFab({ onOpen }: { onOpen: (fabRect: DOMRect) => void }) {
const [pos, setPos] = useState<{ x: number; y: number } | null>(null); // null = 默认右下角
const drag = useRef<{ startX: number; startY: number; baseX: number; baseY: number; moved: boolean } | null>(null);
const btnRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
try {
const raw = localStorage.getItem(FAB_POS_KEY);
if (raw) setPos(clampPos(JSON.parse(raw) as { x: number; y: number }));
} catch {
/* 忽略坏数据 */
}
}, []);
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 && Math.hypot(dx, dy) < 4) return;
d.moved = true;
setPos(clampPos({ x: d.baseX + dx, y: d.baseY + dy }));
};
const onPointerUp = () => {
const d = drag.current;
drag.current = null;
if (!d) return;
if (d.moved) {
setPos((p) => {
if (p) localStorage.setItem(FAB_POS_KEY, JSON.stringify(p));
return p;
});
} else {
const r = btnRef.current?.getBoundingClientRect();
if (r) onOpen(r);
}
};
return (
<button
ref={btnRef}
type="button"
title="打开助手(可拖动)"
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
style={pos ? { left: pos.x, top: pos.y } : undefined}
className={cn(
'fixed z-[60] inline-flex h-12 w-12 touch-none items-center justify-center rounded-full bg-teal-600 text-white shadow-lg hover:bg-teal-700',
!pos && 'bottom-4 right-4',
)}
>
<Bot className="h-5 w-5" />
</button>
);
}
function clampPos(p: { x: number; y: number }): { x: number; y: number } {
const size = 48; // h-12 w-12
return {
x: Math.min(Math.max(p.x, 4), window.innerWidth - size - 4),
y: Math.min(Math.max(p.y, 4), window.innerHeight - size - 4),
};
}
......@@ -2,6 +2,7 @@
import { useCallback, useRef, useState } from 'react';
import { env } from '@/lib/env';
import { emitPetEvent } from '@/lib/pet-events';
import { useAuthStore } from '@/stores/auth-store';
/** 一步工具调用(Claude 式透明步骤:看到调了哪个工具、入参、返回数据)。 */
......@@ -144,6 +145,7 @@ export function useAssistantChat() {
setMessages((prev) => [...prev, userMsg, assistantMsg]);
setStatus('streaming');
emitPetEvent({ type: 'ai_thinking_start' }); // 宠物演"思考"(详情页助手宠物)
// 更新 assistant 消息 blocks 的小工具
const patch = (fn: (blocks: Block[]) => Block[]) =>
......@@ -279,6 +281,7 @@ export function useAssistantChat() {
}
} finally {
abortRef.current = null;
emitPetEvent({ type: 'ai_thinking_end' });
}
},
[messages, model, status],
......
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { usePlanSyncStore } from '@/stores/plan-sync-store';
import { emitPetEvent, onPetEvent, type DirectorScript } from '@/lib/pet-events';
/** 身体能演的姿态(基线姿态 + 一次性演出共用同一词表)。 */
export type PetPose =
| 'idle'
| 'think'
| 'celebrate'
| 'sleep'
| 'floss'
| 'shine'
| 'chomp'
| 'vet_exam'
| 'vet_scale'
| 'vet_fill'
| 'vet_polish'
| 'vet_fluoride';
/** gesture → 一次性姿态映射(greet/think 只有台词/基线,没有专属一次性姿态)。 */
const ONE_SHOT_POSE: Partial<Record<string, PetPose>> = {
celebrate: 'celebrate',
sleep: 'sleep',
floss: 'floss',
shine: 'shine',
chomp: 'chomp',
vet_exam: 'vet_exam',
vet_scale: 'vet_scale',
vet_fill: 'vet_fill',
vet_polish: 'vet_polish',
vet_fluoride: 'vet_fluoride',
};
const SLEEP_AFTER_MS = 300_000; // 无操作 5 分钟 → 打盹
const GLANCE_MIN_MS = 7_000; // idle 小动作(随机张望)间隔
const GLANCE_MAX_MS = 16_000;
/**
* 宠物大脑 — 反射层(纯规则,毫秒级,永远在线)。
*
* 基线姿态按优先级推导:AI 生成中 think > 用户久未操作 sleep > idle;
* 一次性演出(celebrate/气泡)由 play(DirectorScript) 叠加,ttl 到点复位回基线。
* play 是唯一指令入口 —— 将来 LLM 导演的输出也走这里,断了就只是"不说话",宠物照活。
*/
export function usePetBrain() {
const [pose, setPose] = useState<PetPose>('idle');
const [bubble, setBubble] = useState<string | null>(null);
/** 自增信号:身体收到后做一次随机张望(idle 防机械感)。 */
const [glanceSeq, setGlanceSeq] = useState(0);
const thinking = useRef(false);
const voiceThinking = useRef(false); // LLM 已发起、第一个词还没到 → 思考脸
const voiceTalking = useRef(false); // 流式吐字中 → 嘴动(复用 chomp)
const lastActiveAt = useRef(Date.now());
const gestureTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const bubbleTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
/** 没有一次性演出时应处的基线姿态。 */
const basePose = useCallback((): PetPose => {
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';
return 'idle';
}, []);
/** 演出脚本唯一入口(规则导演现在用,LLM 导演将来同入口)。 */
const play = useCallback(
(script: DirectorScript) => {
const ttl = script.ttlMs ?? 3_000;
if (script.gesture === 'none') {
// 显式打断:立即清掉一次性演出,复位基线(组合动作中途被打断用)
if (gestureTimer.current) {
clearTimeout(gestureTimer.current);
gestureTimer.current = null;
}
setPose(basePose());
} else if (script.gesture) {
const oneShot = ONE_SHOT_POSE[script.gesture] ?? null;
if (oneShot) {
if (gestureTimer.current) clearTimeout(gestureTimer.current);
setPose(oneShot);
gestureTimer.current = setTimeout(() => {
gestureTimer.current = null;
setPose(basePose());
}, ttl);
}
}
if (script.bubble) {
if (bubbleTimer.current) clearTimeout(bubbleTimer.current);
setBubble(script.bubble);
bubbleTimer.current = setTimeout(() => {
bubbleTimer.current = null;
setBubble(null);
}, ttl);
}
},
[basePose],
);
/** 回到基线(若没有一次性演出占着)。 */
const settle = useCallback(() => {
if (!gestureTimer.current) setPose(basePose());
}, [basePose]);
// ── 感知:AI 生成中(use-assistant-chat 发事件)+ director 脚本直达 ──
useEffect(
() =>
onPetEvent((e) => {
if (e.type === 'director') {
play(e.script);
return;
}
if (e.type === 'pet_voice') {
// think → talk → end:驱动基线姿态(思考脸 / 嘴动 / 复位),气泡走 director
voiceThinking.current = e.phase === 'think';
voiceTalking.current = e.phase === 'talk';
// 清掉残留的一次性 gesture(如刚才的咬合操),让说话姿态立即生效
if (e.phase !== 'end' && gestureTimer.current) {
clearTimeout(gestureTimer.current);
gestureTimer.current = null;
}
setPose(basePose());
return;
}
if (e.type === 'ai_thinking_start') thinking.current = true;
if (e.type === 'ai_thinking_end') thinking.current = false;
settle();
}),
[settle, play, basePose],
);
// ── 感知:业务事件(直接订阅工作台同步 store,业务代码零改动)──
// 注:除 LLM 语音外,所有动作都不带说话框 —— 成约只演庆祝动作,切患者只抬头看一眼。
useEffect(() => {
let prevSeq = usePlanSyncStore.getState().seq;
let prevPlanId = usePlanSyncStore.getState().current?.planId ?? null;
return usePlanSyncStore.subscribe((s) => {
// 成约 → 庆祝动作(无气泡)
if (s.seq !== prevSeq) {
prevSeq = s.seq;
if (s.status === 'completed') play({ gesture: 'celebrate', ttlMs: 3_600 });
}
// 切患者 → 抬头看一眼(无语言)
const planId = s.current?.planId ?? null;
if (planId && planId !== prevPlanId) {
prevPlanId = planId;
if (s.current?.patientName) setGlanceSeq((g) => g + 1);
}
});
}, [play]);
// ── 感知:用户活跃度(打盹/唤醒)+ 周期对账基线姿态 ──
useEffect(() => {
const onActivity = () => {
lastActiveAt.current = Date.now();
// 在睡 → 立即醒(不等 tick)
if (!gestureTimer.current) setPose((p) => (p === 'sleep' ? basePose() : p));
};
window.addEventListener('pointermove', onActivity, { passive: true });
window.addEventListener('keydown', onActivity, { passive: true });
window.addEventListener('wheel', onActivity, { passive: true });
const tick = setInterval(settle, 5_000);
return () => {
window.removeEventListener('pointermove', onActivity);
window.removeEventListener('keydown', onActivity);
window.removeEventListener('wheel', onActivity);
clearInterval(tick);
};
}, [basePose, settle]);
// ── idle 小动作轮盘:张望 / 牙线自理 / 闪亮微笑 / 咬合操(防机械循环)──
useEffect(() => {
if (pose !== 'idle') return;
let timer: ReturnType<typeof setTimeout>;
const schedule = () => {
timer = setTimeout(() => {
const r = Math.random();
if (r < 0.42) setGlanceSeq((s) => s + 1);
else if (r < 0.6) play({ gesture: 'floss', ttlMs: 3_000 });
else if (r < 0.76) play({ gesture: 'shine', ttlMs: 1_800 });
else if (r < 0.9) play({ gesture: 'chomp', ttlMs: 2_400 });
else emitPetEvent({ type: 'vet_combo' }); // ~10%:交给 widget 跑牙医诊疗组合
schedule();
}, GLANCE_MIN_MS + Math.random() * (GLANCE_MAX_MS - GLANCE_MIN_MS));
};
schedule();
return () => clearTimeout(timer);
}, [pose, play]);
// 卸载清理
useEffect(
() => () => {
if (gestureTimer.current) clearTimeout(gestureTimer.current);
if (bubbleTimer.current) clearTimeout(bubbleTimer.current);
},
[],
);
return { pose, bubble, glanceSeq };
}
'use client';
import { useEffect, useRef } from 'react';
import { env } from '@/lib/env';
import { emitPetEvent } from '@/lib/pet-events';
import { useAuthStore } from '@/stores/auth-store';
import { usePlanSyncStore } from '@/stores/plan-sync-store';
/**
* 宠物开口说话(LLM 导演的第一个落地)— 观察周围环境,低频说一句简短的话。
*
* 纪律(对齐 PAC 原则 5):
* - 只在页面激活(visible + focus)时才可能触发;低概率 + 长冷却,绝不刷屏;
* - 输入极简(一段 ≤400 字的环境观察文本),输出极短(≤30 字,流式进气泡);
* - LLM 挂了 = 静默不说话,反射层(规则宠物)完全不受影响。
*/
const CHECK_MS = 75_000; // 检查节拍
const SPEAK_PROB = 0.12; // 每拍触发概率
const COOLDOWN_MS = 4 * 60_000; // 两次发言最小间隔
/** 环境感知:拼一段简短的观察文本(宠物"看到"什么)。 */
function sense(btn: HTMLElement | null): string {
const parts: string[] = [];
const h = new Date().getHours();
parts.push(
`现在是${h < 6 ? '深夜' : h < 9 ? '清晨' : h < 12 ? '上午' : h < 14 ? '中午' : h < 18 ? '下午' : '晚上'}`,
);
const cur = usePlanSyncStore.getState().current;
if (cur?.patientName) parts.push(`客服正在跟进患者「${cur.patientName}」的召回工作台`);
const r = btn?.getBoundingClientRect();
if (r) {
const onFloor = r.bottom > window.innerHeight - 70;
if (onFloor) {
parts.push('我正站在屏幕底边');
} else {
// 身下是什么卡片:取其最近容器里的标题文字
const below = document.elementFromPoint(
Math.min(Math.max(r.left + r.width / 2, 1), window.innerWidth - 1),
Math.min(r.bottom + 6, window.innerHeight - 1),
);
const card = below?.closest('section, aside, figure, header, [data-pet-perch]');
const title = card
?.querySelector('h1,h2,h3,figcaption,[class*="font-semibold"]')
?.textContent?.trim()
.slice(0, 24);
parts.push(title ? `我正蹲在「${title}」卡片的边框上` : '我蹲在一个卡片边框上');
}
}
return parts.join(';');
}
export function usePetVoice(btnRef: React.RefObject<HTMLElement | null>, canSpeak: () => boolean) {
const lastAt = useRef(0);
const speaking = useRef(false);
useEffect(() => {
const speak = async () => {
if (speaking.current) return;
speaking.current = true;
lastAt.current = Date.now();
// 一发起就进"思考"(还没收到第一个词)→ 思考脸 + 头顶冒点
emitPetEvent({ type: 'pet_voice', phase: 'think' });
let talking = false;
try {
const token = useAuthStore.getState().accessToken;
const res = await fetch(new URL('/pac/v1/assistant/pet-say', env.apiBaseUrl), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'text/event-stream',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ observation: sense(btnRef.current) }),
});
if (!res.ok || !res.body) return; // 静默降级(finally 会收尾)
let acc = '';
let buffer = '';
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += value;
let sep: number;
while ((sep = buffer.indexOf('\n\n')) !== -1) {
const frame = buffer.slice(0, sep);
buffer = buffer.slice(sep + 2);
const line = frame.split('\n').find((l) => l.startsWith('data:'));
if (!line) continue;
try {
const evt = JSON.parse(line.slice('data:'.length).trim()) as { type?: string; text?: string };
if (evt.type === 'text' && evt.text) {
if (!talking) {
talking = true;
emitPetEvent({ type: 'pet_voice', phase: 'talk' }); // 第一个词到 → 切"说话"(嘴动)
}
acc += evt.text;
// 流式进气泡:每个增量重发(ttl 给长,防中途消失;说完再收短)
emitPetEvent({ type: 'director', script: { bubble: acc, ttlMs: 15_000 } });
}
} catch {
/* 半截帧忽略 */
}
}
}
if (acc) emitPetEvent({ type: 'director', script: { bubble: acc, ttlMs: 4_500 } });
} catch {
/* LLM 不可用 → 保持沉默,反射层照常活 */
} finally {
emitPetEvent({ type: 'pet_voice', phase: 'end' }); // 停止说话动作,复位基线
speaking.current = false;
}
};
const iv = setInterval(() => {
if (document.visibilityState !== 'visible' || !document.hasFocus()) return; // 只在页面激活时
if (Date.now() - lastAt.current < COOLDOWN_MS) return;
if (Math.random() > SPEAK_PROB) return;
if (!canSpeak()) return;
void speak();
}, CHECK_MS);
// lab 一键触发(跳过概率/冷却,保留激活态要求)
const h = (e: Event) => {
if ((e as CustomEvent<{ cmd?: string }>).detail?.cmd === 'say') void speak();
};
window.addEventListener('pac-pet-debug', h);
return () => {
clearInterval(iv);
window.removeEventListener('pac-pet-debug', h);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}
......@@ -227,9 +227,11 @@ export function PatientPickerRail({
{!loading && items.length === 0 && !error && (
<div className="p-6 text-center text-[11.5px] text-slate-400">没有匹配的患者</div>
)}
{/* data-pet-perch:助手宠物的栖息点(滚到列表底时它会跳上来蹲;按钮被压住也无妨,滚动加载兜底) */}
{!loading && hasMore && (
<button
type="button"
data-pet-perch
onClick={loadMore}
className="block w-full py-2 text-center text-[11px] text-teal-700 hover:bg-teal-50/50"
>
......
/**
* 宠物神经系统 — 语义事件总线 + 演出脚本契约。
*
* 三层架构(感知 → 大脑 → 身体,单向):
* 感知层:业务代码/传感器只 emitPetEvent(语义事件),不关心宠物怎么演;
* 大脑层:pet-brain 订阅事件,经"导演"产出 DirectorScript;
* 身体层:pet-body 纯展示,按姿态渲染。
*
* DirectorScript 是大脑的唯一指令入口:现在由规则导演(模板)产出;
* 将来 LLM 导演产同构 JSON(受限词表 + zod 校验)走同一入口 —— 主流程确定性,
* LLM 只补台词,断了即退回纯规则(对齐 PAC 原则 5:Agent 路径必须可降级)。
*/
/** 动作词表(受限枚举,LLM 导演也只能从这里选,不许自由发挥)。 */
export type PetGesture =
| 'none'
| 'think'
| 'celebrate'
| 'greet'
| 'sleep'
| 'floss' // 牙线自理
| 'shine' // 闪亮微笑(牙面 ding 一下)
| 'chomp' // 咬合操(张合嘴)
// 牙医组合派生动作(检查 → 治疗 → 处置)
| 'vet_exam' // 看牙检查(戴额镜 + 口镜)
| 'vet_scale' // 洗牙(超声洁治 + 水珠)
| 'vet_fill' // 补牙(电钻 + 火星)
| 'vet_polish' // 抛光(抛光杯 + ✨)
| 'vet_fluoride'; // 涂氟(applicator,随后获护盾)
/** 语义事件 — 感知层发,大脑订阅。 */
export type PetEvent =
| { type: 'ai_thinking_start' }
| { type: 'ai_thinking_end' }
/** 直接递一段演出脚本给大脑(规则模块现在用,LLM 导演将来发同构事件) */
| { type: 'director'; script: DirectorScript }
/** 宠物开口说话的三个阶段:think=已发起 LLM 还没吐第一个词 / talk=流式中(嘴动) / end=说完 */
| { type: 'pet_voice'; phase: 'think' | 'talk' | 'end' }
/** 大脑 idle 轮盘抽中"牙医组合"→ 通知 widget 跑诊疗派生链 */
| { type: 'vet_combo' };
/** 演出脚本 — 现在规则导演用,将来 LLM 导演同入口。 */
export interface DirectorScript {
gesture?: PetGesture;
/** 唯一自由文本:气泡台词(LLM 的本职就在这一个字段)。 */
bubble?: string;
/** 演出时长,到点自动复位回基线姿态。 */
ttlMs?: number;
}
type Listener = (e: PetEvent) => void;
const listeners = new Set<Listener>();
export function emitPetEvent(e: PetEvent): void {
listeners.forEach((l) => l(e));
}
export function onPetEvent(l: Listener): () => void {
listeners.add(l);
return () => listeners.delete(l);
}
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