Commit ce6bec2d by luoqi

feat(web): 助手抽公用 — 详情页右下角吸附小窗(与 /assistant 同能力)

- AssistantChat 加 variant('page'|'widget'):page 整页原样(/assistant 保留零回归);
  widget 紧凑头部(助手 + 模型切换 + ⌄收起)+ h-full 适配容器。
- 新 AssistantWidget:右下角悬浮圆钮 ⇄ 400×620(max 78vh)吸附窗;
  收起为 CSS 隐藏不卸载 → 对话/工具步骤/artifact/听写状态保留;
  挂在 plans/layout(路由段之上)→ 切患者对话不丢;权限 AGENT_INVOKE 同 /assistant。
- 功能与 /assistant 完全一致(MCP 工具透明步骤 / markdown / HTML 卡片 / 实时听写)。

web tsc 0 + Next 生产构建过。仅本地,未部署。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent 4c0af7a8
...@@ -5,6 +5,7 @@ import { useSelectedLayoutSegment } from 'next/navigation'; ...@@ -5,6 +5,7 @@ import { useSelectedLayoutSegment } from 'next/navigation';
import { Users, X } from 'lucide-react'; import { Users, X } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { PatientPickerRail } from '@/components/plans/patient-picker-rail'; import { PatientPickerRail } from '@/components/plans/patient-picker-rail';
import { AssistantWidget } from '@/components/assistant/assistant-widget';
/** 详情 TopBar 经 portal 渲到这个槽 → header 延伸到最左(在左栏之上)。 */ /** 详情 TopBar 经 portal 渲到这个槽 → header 延伸到最左(在左栏之上)。 */
export const PLAN_WORKSPACE_HEADER_SLOT = 'plan-workspace-header'; export const PLAN_WORKSPACE_HEADER_SLOT = 'plan-workspace-header';
...@@ -69,6 +70,8 @@ export default function PlansLayout({ children }: { children: React.ReactNode }) ...@@ -69,6 +70,8 @@ export default function PlansLayout({ children }: { children: React.ReactNode })
)} )}
<div className="min-h-0 min-w-0 flex-1">{children}</div> <div className="min-h-0 min-w-0 flex-1">{children}</div>
</div> </div>
{/* 右下角吸附助手(挂在布局层 → 切患者对话不丢) */}
<AssistantWidget />
</div> </div>
); );
} }
...@@ -424,7 +424,14 @@ function ModelSelect({ ...@@ -424,7 +424,14 @@ function ModelSelect({
); );
} }
export function AssistantChat() { export function AssistantChat({
variant = 'page',
onClose,
}: {
/** page = /assistant 整页;widget = 详情页右下角吸附小窗(紧凑头部 + 可收起) */
variant?: 'page' | 'widget';
onClose?: () => void;
} = {}) {
const { messages, status, model, setModel, send, stop } = useAssistantChat(); const { messages, status, model, setModel, send, stop } = useAssistantChat();
const [input, setInput] = useState(''); const [input, setInput] = useState('');
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
...@@ -552,17 +559,40 @@ export function AssistantChat() { ...@@ -552,17 +559,40 @@ export function AssistantChat() {
} }
}; };
const isWidget = variant === 'widget';
return ( return (
<div className="flex h-[100dvh] flex-col bg-slate-50"> <div className={cn('flex flex-col bg-slate-50', isWidget ? 'h-full' : 'h-[100dvh]')}>
{/* Header */} {/* Header */}
<header className="flex-none border-b border-slate-200 bg-white/90 backdrop-blur"> <header className="flex-none border-b border-slate-200 bg-white/90 backdrop-blur">
<div className="mx-auto flex h-[3.25rem] max-w-3xl items-center gap-2.5 px-4 py-2.5"> <div
<span className="inline-flex h-7 w-7 flex-none items-center justify-center rounded-lg bg-teal-600 text-white"> className={cn(
<Bot className="h-4 w-4" /> 'mx-auto flex items-center gap-2.5',
isWidget ? 'h-10 px-2.5' : 'h-[3.25rem] max-w-3xl px-4 py-2.5',
)}
>
<span
className={cn(
'inline-flex flex-none items-center justify-center rounded-lg bg-teal-600 text-white',
isWidget ? 'h-6 w-6' : 'h-7 w-7',
)}
>
<Bot className={isWidget ? 'h-3.5 w-3.5' : 'h-4 w-4'} />
</span> </span>
<h1 className="text-[14px] font-semibold text-slate-900">外部助手</h1> <h1 className={cn('font-semibold text-slate-900', isWidget ? 'text-[13px]' : 'text-[14px]')}>
<div className="ml-auto"> {isWidget ? '助手' : '外部助手'}
</h1>
<div className="ml-auto flex items-center gap-1.5">
<ModelSelect model={model} setModel={setModel} disabled={status === 'streaming'} /> <ModelSelect model={model} setModel={setModel} disabled={status === 'streaming'} />
{onClose && (
<button
type="button"
onClick={onClose}
title="收起"
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-slate-400 hover:bg-slate-100 hover:text-slate-600"
>
<ChevronDown className="h-4 w-4" />
</button>
)}
</div> </div>
</div> </div>
</header> </header>
...@@ -571,7 +601,7 @@ export function AssistantChat() { ...@@ -571,7 +601,7 @@ export function AssistantChat() {
<div ref={scrollRef} onScroll={onScroll} className="flex-1 overflow-y-auto"> <div ref={scrollRef} onScroll={onScroll} className="flex-1 overflow-y-auto">
<div className="mx-auto max-w-3xl space-y-5 px-4 py-5"> <div className="mx-auto max-w-3xl space-y-5 px-4 py-5">
{messages.length === 0 ? ( {messages.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-4 py-20 text-center"> <div className={cn('flex flex-col items-center justify-center gap-4 text-center', isWidget ? 'py-8' : 'py-20')}>
<span className="inline-flex h-12 w-12 items-center justify-center rounded-2xl bg-teal-600 text-white"> <span className="inline-flex h-12 w-12 items-center justify-center rounded-2xl bg-teal-600 text-white">
<Bot className="h-6 w-6" /> <Bot className="h-6 w-6" />
</span> </span>
......
'use client';
import { useState } from 'react';
import { Bot } from 'lucide-react';
import { Permission } from '@pac/types';
import { cn } from '@/lib/utils';
import { useHasPermission } from '@/hooks/use-permission';
import { AssistantChat } from './assistant-chat';
/**
* AssistantWidget — 详情页右下角吸附助手(与 /assistant 同一能力,复用 AssistantChat)。
*
* 收起 = 右下角圆形悬浮钮;展开 = 400×~75vh 吸附小窗。
* 关键:收起用 CSS 隐藏而非卸载 → 对话/工具步骤/听写状态全保留;
* 挂在 plans/layout(路由段之上)→ 切患者对话也不丢。
* 权限同 /assistant 页(AGENT_INVOKE),无权限不渲染。
*/
export function AssistantWidget() {
const allowed = useHasPermission(Permission.AGENT_INVOKE);
const [open, setOpen] = useState(false);
if (!allowed) return null;
return (
<>
{/* 吸附窗:常驻挂载,关着时 invisible(状态不丢) */}
<div
className={cn(
'fixed bottom-4 right-4 z-40 flex flex-col overflow-hidden rounded-xl border border-slate-200 shadow-2xl',
'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',
)}
>
<AssistantChat variant="widget" onClose={() => setOpen(false)} />
</div>
{/* 悬浮开关钮 */}
{!open && (
<button
type="button"
onClick={() => setOpen(true)}
title="打开助手"
className="fixed bottom-4 right-4 z-40 inline-flex h-12 w-12 items-center justify-center rounded-full bg-teal-600 text-white shadow-lg transition-transform hover:scale-105 hover:bg-teal-700"
>
<Bot className="h-5 w-5" />
</button>
)}
</>
);
}
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