Commit 02f05176 by luoqi

feat(web): P3 — /assistant 独立聊天页(Claude 式透明步骤,接入 PAC MCP)

GPT/Claude 式聊天页:模型经 MCP 自主调用 PAC 患者工具,过程透明可见。
- use-assistant-chat:fetch+ReadableStream 消费 /assistant/chat 的 SSE(镜像 use-script-stream,
  Bearer header;EventSource 不支持自定义 header),把 text/tool_call/tool_result 事件组织成
  消息内的有序 blocks(文本 + 工具步骤)。
- assistant-chat:消息气泡 + **工具步骤卡(可展开看 入参 + 返回数据)**= Claude 式"看得到怎么做到"
  + 模型切换(deepseek/gemini/qwen)+ 流式渲染 + 示例 prompt + 停止。
- /assistant 路由(AuthGate + AGENT_INVOKE 权限)。

前端 tsc 0 错误;后端 P1(MCP)+P2(agent loop)已端到端验证。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent f86e39a5
'use client';
import { Permission } from '@pac/types';
import { Can } from '@/components/can';
import { AssistantChat } from '@/components/assistant/assistant-chat';
/**
* /assistant — PAC 助手(模拟外部 agent)。
*
* 一个 Claude/GPT 式聊天页:模型经 MCP 自主调用 PAC 患者工具(find_patient / get_persona /
* get_facts / get_recall_plan / get_patient_overview / list_recall_queue),
* 过程透明可见(工具调用 + 入参 + 返回数据)。鉴权:(app) AuthGate + AGENT_INVOKE 权限。
*/
export default function AssistantPage() {
return (
<Can perm={Permission.AGENT_INVOKE}>
<div className="px-4">
<AssistantChat />
</div>
</Can>
);
}
'use client';
import { useEffect, useRef, useState } from 'react';
import { Bot, Check, ChevronRight, Loader2, Send, Square, User, Wrench } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { useAssistantChat, type Block, type ChatMessage, type ToolStep } from './use-assistant-chat';
const MODELS = [
{ value: 'deepseek', label: 'DeepSeek' },
{ value: 'gemini', label: 'Gemini' },
{ value: 'qwen', label: '通义千问' },
];
const EXAMPLES = [
'查一下患者孙柯的画像价值分群和当前召回计划',
'孙柯有哪些潜在治疗和应治未治的牙位?',
'现在召回池里优先级最高的几位患者是谁?',
];
/** 把工具返回(MCP 文本,通常是 JSON 字符串)尽量格式化展示。 */
function pretty(v: unknown): string {
if (v == null) return '';
if (typeof v === 'string') {
try {
return JSON.stringify(JSON.parse(v), null, 2);
} catch {
return v;
}
}
return JSON.stringify(v, null, 2);
}
function ToolStepView({ step }: { step: ToolStep }) {
const [open, setOpen] = useState(false);
return (
<div className="my-2 rounded-lg border border-amber-200 bg-amber-50/60 text-sm">
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="flex w-full items-center gap-2 px-3 py-2 text-left"
>
<ChevronRight className={cn('h-3.5 w-3.5 shrink-0 transition-transform', open && 'rotate-90')} />
<Wrench className="h-3.5 w-3.5 shrink-0 text-amber-600" />
<span className="font-medium text-amber-900">调用工具</span>
<code className="rounded bg-amber-100 px-1.5 py-0.5 font-mono text-xs text-amber-800">
{step.tool}
</code>
<span className="ml-auto">
{step.status === 'running' ? (
<Loader2 className="h-3.5 w-3.5 animate-spin text-amber-500" />
) : step.status === 'error' ? (
<span className="text-xs text-red-600">出错</span>
) : (
<Check className="h-3.5 w-3.5 text-emerald-600" />
)}
</span>
</button>
{open && (
<div className="space-y-2 border-t border-amber-200 px-3 py-2">
<div>
<div className="mb-1 text-xs font-medium text-amber-700">入参</div>
<pre className="overflow-x-auto rounded bg-white/70 p-2 text-xs text-slate-700">
{pretty(step.args) || '{}'}
</pre>
</div>
{step.result !== undefined && (
<div>
<div className="mb-1 text-xs font-medium text-amber-700">返回数据</div>
<pre className="max-h-72 overflow-auto rounded bg-white/70 p-2 text-xs text-slate-700">
{pretty(step.result)}
</pre>
</div>
)}
</div>
)}
</div>
);
}
function BlockView({ block }: { block: Block }) {
if (block.kind === 'tool') return <ToolStepView step={block.step} />;
return <div className="whitespace-pre-wrap leading-relaxed">{block.text}</div>;
}
function MessageView({ message }: { message: ChatMessage }) {
const isUser = message.role === 'user';
return (
<div className={cn('flex gap-3', isUser && 'flex-row-reverse')}>
<div
className={cn(
'flex h-8 w-8 shrink-0 items-center justify-center rounded-full',
isUser ? 'bg-slate-700 text-white' : 'bg-emerald-600 text-white',
)}
>
{isUser ? <User className="h-4 w-4" /> : <Bot className="h-4 w-4" />}
</div>
<div
className={cn(
'min-w-0 max-w-[80%] rounded-2xl px-4 py-2.5 text-sm',
isUser ? 'bg-slate-700 text-white' : 'border bg-white text-slate-800',
)}
>
{message.blocks.length === 0 ? (
<Loader2 className="h-4 w-4 animate-spin text-slate-400" />
) : (
message.blocks.map((b, i) => <BlockView key={i} block={b} />)
)}
</div>
</div>
);
}
export function AssistantChat() {
const { messages, status, model, setModel, send, stop } = useAssistantChat();
const [input, setInput] = useState('');
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
const submit = () => {
if (!input.trim() || status === 'streaming') return;
void send(input);
setInput('');
};
return (
<div className="mx-auto flex h-[calc(100vh-120px)] max-w-3xl flex-col">
{/* header */}
<div className="flex items-center gap-3 border-b pb-3">
<Bot className="h-5 w-5 text-emerald-600" />
<div className="font-semibold">PAC 助手</div>
<span className="rounded bg-emerald-50 px-2 py-0.5 text-xs text-emerald-700">
经 MCP 调用患者工具
</span>
<select
value={model}
onChange={(e) => setModel(e.target.value)}
className="ml-auto rounded-md border px-2 py-1 text-sm"
disabled={status === 'streaming'}
>
{MODELS.map((m) => (
<option key={m.value} value={m.value}>
{m.label}
</option>
))}
</select>
</div>
{/* messages */}
<div className="flex-1 space-y-5 overflow-y-auto py-5">
{messages.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center gap-4 text-center text-slate-500">
<Bot className="h-10 w-10 text-emerald-500" />
<div className="text-sm">问我关于患者的问题,我会经 MCP 工具查 PAC 实时数据(过程透明可见)。</div>
<div className="flex flex-col gap-2">
{EXAMPLES.map((ex) => (
<button
key={ex}
type="button"
onClick={() => send(ex)}
className="rounded-full border px-4 py-1.5 text-sm text-slate-600 hover:bg-slate-50"
>
{ex}
</button>
))}
</div>
</div>
) : (
messages.map((m) => <MessageView key={m.id} message={m} />)
)}
<div ref={bottomRef} />
</div>
{/* input */}
<div className="flex items-end gap-2 border-t pt-3">
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
submit();
}
}}
placeholder="问关于患者画像 / 事实 / 召回计划的问题…(Enter 发送,Shift+Enter 换行)"
rows={2}
className="flex-1 resize-none rounded-lg border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-200"
/>
{status === 'streaming' ? (
<Button variant="outline" size="icon" onClick={stop} title="停止">
<Square className="h-4 w-4" />
</Button>
) : (
<Button size="icon" onClick={submit} disabled={!input.trim()} title="发送">
<Send className="h-4 w-4" />
</Button>
)}
</div>
</div>
);
}
'use client';
import { useCallback, useRef, useState } from 'react';
import { env } from '@/lib/env';
import { useAuthStore } from '@/stores/auth-store';
/** 一步工具调用(Claude 式透明步骤:看到调了哪个工具、入参、返回数据)。 */
export interface ToolStep {
id: string;
tool: string;
args: unknown;
result?: unknown;
status: 'running' | 'done' | 'error';
error?: string;
}
export type Block = { kind: 'text'; text: string } | { kind: 'tool'; step: ToolStep };
export interface ChatMessage {
id: string;
role: 'user' | 'assistant';
blocks: Block[];
}
export type ChatStatus = 'idle' | 'streaming' | 'error';
let _id = 0;
const nextId = () => `m${Date.now()}_${_id++}`;
/** 把一条消息压平成后端要的 {role, content} 文本(工具块不回传,模型自行重新决策)。 */
function toApiMessage(m: ChatMessage): { role: 'user' | 'assistant'; content: string } | null {
const text = m.blocks
.filter((b): b is Extract<Block, { kind: 'text' }> => b.kind === 'text')
.map((b) => b.text)
.join('')
.trim();
if (!text) return null;
return { role: m.role, content: text };
}
export function useAssistantChat() {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [status, setStatus] = useState<ChatStatus>('idle');
const [model, setModel] = useState('deepseek');
const abortRef = useRef<AbortController | null>(null);
const stop = useCallback(() => {
abortRef.current?.abort();
abortRef.current = null;
setStatus('idle');
}, []);
const send = useCallback(
async (text: string) => {
const trimmed = text.trim();
if (!trimmed || status === 'streaming') return;
const userMsg: ChatMessage = {
id: nextId(),
role: 'user',
blocks: [{ kind: 'text', text: trimmed }],
};
const assistantId = nextId();
const assistantMsg: ChatMessage = { id: assistantId, role: 'assistant', blocks: [] };
// 历史(含新 user)→ apiMessages
const apiMessages = [...messages, userMsg]
.map(toApiMessage)
.filter((x): x is { role: 'user' | 'assistant'; content: string } => x !== null);
setMessages((prev) => [...prev, userMsg, assistantMsg]);
setStatus('streaming');
// 更新 assistant 消息 blocks 的小工具
const patch = (fn: (blocks: Block[]) => Block[]) =>
setMessages((prev) =>
prev.map((m) => (m.id === assistantId ? { ...m, blocks: fn(m.blocks) } : m)),
);
const appendText = (t: string) =>
patch((blocks) => {
const last = blocks[blocks.length - 1];
if (last && last.kind === 'text') {
return [...blocks.slice(0, -1), { kind: 'text', text: last.text + t }];
}
return [...blocks, { kind: 'text', text: t }];
});
const onEvent = (evt: Record<string, unknown>) => {
switch (evt.type) {
case 'text':
appendText((evt.text as string) ?? '');
break;
case 'tool_call':
patch((blocks) => [
...blocks,
{
kind: 'tool',
step: {
id: nextId(),
tool: String(evt.tool ?? '工具'),
args: evt.args,
status: 'running',
},
},
]);
break;
case 'tool_result':
patch((blocks) => {
const idx = [...blocks]
.reverse()
.findIndex((b) => b.kind === 'tool' && b.step.status === 'running');
if (idx === -1) return blocks;
const realIdx = blocks.length - 1 - idx;
const b = blocks[realIdx] as Extract<Block, { kind: 'tool' }>;
const updated: Block = {
kind: 'tool',
step: { ...b.step, result: evt.result, status: 'done' },
};
return blocks.map((x, i) => (i === realIdx ? updated : x));
});
break;
case 'error':
appendText(`\n\n⚠️ 出错:${String(evt.error)}`);
break;
default:
break;
}
};
const controller = new AbortController();
abortRef.current = controller;
try {
const token = useAuthStore.getState().accessToken;
const res = await fetch(new URL('/pac/v1/assistant/chat', env.apiBaseUrl), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'text/event-stream',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ messages: apiMessages, model }),
signal: controller.signal,
});
if (!res.ok || !res.body) {
throw new Error(`HTTP ${res.status}`);
}
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
let buffer = '';
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;
const raw = line.slice('data:'.length).trim();
if (!raw) continue;
try {
onEvent(JSON.parse(raw) as Record<string, unknown>);
} catch {
/* 忽略半截帧 */
}
}
}
setStatus('idle');
} catch (err) {
if ((err as Error).name !== 'AbortError') {
appendText(`\n\n⚠️ 连接失败:${(err as Error).message}`);
setStatus('error');
} else {
setStatus('idle');
}
} finally {
abortRef.current = null;
}
},
[messages, model, status],
);
return { messages, status, model, setModel, send, stop };
}
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