/** * Chat Area - Protocol Agent 聊天区域 * * 方案A: 使用 Protocol Agent 独立 API * - POST /api/v1/aia/protocol-agent/message 发送消息 * - 后端返回结构化 AgentResponse(含 syncButton, actionCards) */ import React, { useState, useRef, useEffect, useCallback } from 'react'; import { Send, Sparkles, User, Loader2, ExternalLink } from 'lucide-react'; import { ThinkingBlock, useAIStream } from '@/shared/components/Chat'; import { getAccessToken } from '../../../../framework/auth/api'; import type { ProtocolContext } from '../types'; import { SyncButton } from './SyncButton'; import { MarkdownContent } from './MarkdownContent'; // ============================================ // 类型定义(与后端 AgentResponse 对应) // ============================================ interface SyncButtonData { stageCode: string; extractedData: Record; label: string; disabled?: boolean; } interface ActionCard { id: string; type: string; title: string; description?: string; actionUrl?: string; actionParams?: Record; } interface Message { id: string; role: 'user' | 'assistant' | 'system'; content: string; thinkingContent?: string; stage?: string; stageName?: string; syncButton?: SyncButtonData; actionCards?: ActionCard[]; timestamp: Date; } interface ChatAreaProps { conversationId?: string; context: ProtocolContext | null; onContextUpdate: () => void; /** 更新对话标题(首次发送消息时调用) */ onTitleUpdate?: (title: string) => void; } // ============================================ // 阶段常量 // ============================================ const STAGE_NAMES: Record = { scientific_question: '科学问题梳理', pico: 'PICO要素', study_design: '研究设计', sample_size: '样本量计算', endpoints: '观察指标', }; /** * 从 AI 响应中解析 extracted_data XML 标签 */ function parseExtractedData(content: string): { cleanContent: string; extractedData: Record | null; } { const regex = /([\s\S]*?)<\/extracted_data>/; const match = content.match(regex); if (!match) { return { cleanContent: content, extractedData: null }; } try { const jsonStr = match[1].trim(); const extractedData = JSON.parse(jsonStr); const cleanContent = content.replace(regex, '').trim(); return { cleanContent, extractedData }; } catch (e) { console.warn('[ChatArea] Failed to parse extracted_data:', e); return { cleanContent: content, extractedData: null }; } } // ============================================ // 主组件 // ============================================ export const ChatArea: React.FC = ({ conversationId, context, onContextUpdate, onTitleUpdate, }) => { const [messages, setMessages] = useState([]); const [input, setInput] = useState(''); const [isLoadingHistory, setIsLoadingHistory] = useState(false); const chatContainerRef = useRef(null); const prevConversationIdRef = useRef(undefined); const isFirstMount = useRef(true); // 使用通用 useAIStream hook 实现流式输出(打字机效果) const { content: streamContent, thinking: streamThinking, status: streamStatus, isStreaming, isThinking, error: streamError, sendMessage: sendStreamMessage, reset: resetStream, } = useAIStream({ apiEndpoint: `/api/v1/aia/protocol-agent/message`, headers: { Authorization: `Bearer ${getAccessToken()}`, }, }); // 自动滚动到底部 const scrollToBottom = useCallback(() => { if (chatContainerRef.current) { chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight; } }, []); useEffect(() => { scrollToBottom(); }, [messages, streamContent, scrollToBottom]); // 生成欢迎消息(紧凑版本) const createWelcomeMessage = useCallback((currentStage?: string): Message => { const stage = currentStage || 'scientific_question'; const stageName = STAGE_NAMES[stage] || '科学问题梳理'; return { id: 'welcome', role: 'assistant', content: `您好!我是研究方案制定助手,将帮助您系统地完成临床研究方案的核心要素设计。我们将一起完成以下5个关键步骤: 1️⃣ **科学问题梳理** - 明确研究要解决的核心问题 2️⃣ **PICO要素** - 确定研究人群、干预、对照和结局 3️⃣ **研究设计** - 选择合适的研究类型和方法 4️⃣ **样本量计算** - 估算所需的样本量 5️⃣ **观察指标** - 定义基线、暴露、结局指标和混杂因素 完成这5个要素后,您可以**一键生成完整的研究方案**并下载为Word文档。 📍 **当前阶段**: ${stageName} 让我们开始吧!请先告诉我,您想研究什么问题?或者描述一下您的研究背景和想法。`, stage, stageName, timestamp: new Date(), }; }, []); // 加载历史消息(当 conversationId 变化时) useEffect(() => { // 首次挂载或 conversationId 变化时执行 const shouldUpdate = isFirstMount.current || conversationId !== prevConversationIdRef.current; if (!shouldUpdate) { return; } isFirstMount.current = false; prevConversationIdRef.current = conversationId; // 如果没有 conversationId,显示欢迎消息(等待自动创建会话) if (!conversationId) { setMessages([createWelcomeMessage(context?.currentStage)]); return; } const loadHistoryMessages = async () => { setIsLoadingHistory(true); try { const token = getAccessToken(); const response = await fetch(`/api/v1/aia/protocol-agent/messages/${conversationId}`, { headers: { Authorization: `Bearer ${token}`, }, }); if (response.ok) { const result = await response.json(); const historyMessages = result.data?.messages || []; if (historyMessages.length > 0) { // 有历史消息:加载历史 + 欢迎消息在最前 const loadedMessages: Message[] = historyMessages.map((m: any) => ({ id: m.id, role: m.role, content: m.content, thinkingContent: m.thinkingContent, timestamp: new Date(m.createdAt), })); // 在历史消息前添加欢迎消息 setMessages([createWelcomeMessage(context?.currentStage), ...loadedMessages]); } else { // 没有历史消息:只显示欢迎消息 setMessages([createWelcomeMessage(context?.currentStage)]); } } else { // API 失败:显示欢迎消息 setMessages([createWelcomeMessage(context?.currentStage)]); } } catch (error) { console.error('[ChatArea] 加载历史消息失败:', error); // 出错也显示欢迎消息 setMessages([createWelcomeMessage(context?.currentStage)]); } finally { setIsLoadingHistory(false); } }; loadHistoryMessages(); }, [conversationId, context?.currentStage, createWelcomeMessage]); // 处理流式响应完成 useEffect(() => { if (streamStatus === 'complete' && streamContent) { // 解析 AI 响应中的 extracted_data(用于同步按钮) const { cleanContent, extractedData } = parseExtractedData(streamContent); // 构建同步按钮数据 let syncButton: SyncButtonData | undefined; if (extractedData) { const stageCode = context?.currentStage || 'scientific_question'; syncButton = { stageCode, extractedData, label: `✅ 同步「${STAGE_NAMES[stageCode]}」到方案`, disabled: false, }; } // 添加 AI 消息 const aiMessage: Message = { id: Date.now().toString(), role: 'assistant', content: cleanContent, thinkingContent: streamThinking || undefined, stage: context?.currentStage, stageName: context?.stageName, syncButton, timestamp: new Date(), }; setMessages(prev => [...prev, aiMessage]); resetStream(); // 刷新上下文状态 onContextUpdate(); } }, [streamStatus, streamContent, streamThinking, context, resetStream, onContextUpdate]); // 处理流式错误 useEffect(() => { if (streamStatus === 'error' && streamError) { setMessages(prev => [...prev, { id: Date.now().toString(), role: 'system', content: `❌ 发送失败:${streamError}`, timestamp: new Date(), }]); resetStream(); } }, [streamStatus, streamError, resetStream]); /** * 发送消息(流式) */ const handleSend = useCallback(async () => { if (!input.trim() || !conversationId || isStreaming) return; const userContent = input.trim(); // 检查是否是首次用户消息(只有欢迎消息时) const isFirstUserMessage = messages.filter(m => m.role === 'user').length === 0; // 添加用户消息 const userMessage: Message = { id: Date.now().toString(), role: 'user', content: userContent, timestamp: new Date(), }; setMessages(prev => [...prev, userMessage]); setInput(''); // 首次消息时更新对话标题(锦上添花,不影响核心功能) if (isFirstUserMessage && onTitleUpdate) { try { const newTitle = userContent.length > 20 ? userContent.slice(0, 20) + '...' : userContent; onTitleUpdate(newTitle); } catch (e) { console.warn('[ChatArea] 更新标题失败,不影响对话:', e); } } // 发送消息 await sendStreamMessage(userContent, { conversationId, }); }, [input, conversationId, isStreaming, sendStreamMessage, messages, onTitleUpdate]); /** * 处理同步到方案 */ const handleSync = useCallback(async (stageCode: string, data: Record) => { if (!conversationId) return; try { const token = getAccessToken(); const response = await fetch('/api/v1/aia/protocol-agent/sync', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}`, }, body: JSON.stringify({ conversationId, stageCode, data, }), }); const result = await response.json(); if (response.ok && result.success) { // 添加成功提示 setMessages(prev => [...prev, { id: Date.now().toString(), role: 'system', content: `✅ ${result.data?.message || `已同步「${STAGE_NAMES[stageCode] || stageCode}」到方案`}`, timestamp: new Date(), }]); // 刷新上下文状态 onContextUpdate(); } else { throw new Error(result.error || '同步失败'); } } catch (err) { console.error('[ChatArea] handleSync error:', err); setMessages(prev => [...prev, { id: Date.now().toString(), role: 'system', content: `❌ 同步失败:${err instanceof Error ? err.message : '请重试'}`, timestamp: new Date(), }]); } }, [conversationId, onContextUpdate]); /** * 处理动作卡片点击 */ const handleActionCard = useCallback((card: ActionCard) => { console.log('[ChatArea] Action card clicked:', card); if (card.actionUrl) { // 对于 API 调用类型的动作卡片 if (card.actionUrl.startsWith('/api/')) { // TODO: 实现 API 调用(如一键生成) setMessages(prev => [...prev, { id: Date.now().toString(), role: 'system', content: `⏳ 正在执行:${card.title}...`, timestamp: new Date(), }]); } else { // 跳转到工具页面 window.open(card.actionUrl, '_blank'); } } }, []); // 按 Enter 发送 const handleKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); } }, [handleSend]); return (
{/* 聊天历史 */}
{/* 加载历史消息时显示加载状态 */} {isLoadingHistory && (
加载历史消息...
)} {messages.map(msg => (
{msg.role === 'user' && (
您 • {formatTime(msg.timestamp)}
{msg.content}
)} {msg.role === 'assistant' && (
Protocol Agent {msg.stageName && {msg.stageName}} • {formatTime(msg.timestamp)}
{/* 深度思考内容 */} {msg.thinkingContent && ( )}
{/* 同步按钮 */} {msg.syncButton && !msg.syncButton.disabled && ( )} {/* 动作卡片 */} {msg.actionCards && msg.actionCards.length > 0 && (
{msg.actionCards.map(card => (
handleActionCard(card)} >
{card.title}
{card.description && (
{card.description}
)}
))}
)}
)} {msg.role === 'system' && (
{msg.content}
)}
))} {/* 流式输出中的消息(打字机效果) */} {(isStreaming || isThinking) && (
Protocol Agent {context?.stageName && {context.stageName}} • 正在回复...
{/* 深度思考内容(流式) */} {isThinking && streamThinking && ( )} {/* 流式内容 */} {streamContent ? (
) : (
AI 正在思考...
)}
)}
{/* 输入区 */}
setInput(e.target.value)} onKeyDown={handleKeyDown} disabled={isStreaming || !conversationId} />
); }; /** * 格式化时间 */ function formatTime(date: Date): string { return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }); }