Files
AIclinicalresearch/frontend-v2/src/modules/aia/protocol-agent/components/ChatArea.tsx
HaHafeng 303dd78c54 feat(aia): Protocol Agent MVP complete with one-click generation and Word export
- Add one-click research protocol generation with streaming output

- Implement Word document export via Pandoc integration

- Add dynamic dual-panel layout with resizable split pane

- Implement collapsible content for StatePanel stages

- Add conversation history management with title auto-update

- Fix scroll behavior, markdown rendering, and UI layout issues

- Simplify conversation creation logic for reliability
2026-01-25 19:16:36 +08:00

555 lines
18 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<string, unknown>;
label: string;
disabled?: boolean;
}
interface ActionCard {
id: string;
type: string;
title: string;
description?: string;
actionUrl?: string;
actionParams?: Record<string, unknown>;
}
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<string, string> = {
scientific_question: '科学问题梳理',
pico: 'PICO要素',
study_design: '研究设计',
sample_size: '样本量计算',
endpoints: '观察指标',
};
/**
* 从 AI 响应中解析 extracted_data XML 标签
*/
function parseExtractedData(content: string): {
cleanContent: string;
extractedData: Record<string, unknown> | null;
} {
const regex = /<extracted_data>([\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<ChatAreaProps> = ({
conversationId,
context,
onContextUpdate,
onTitleUpdate,
}) => {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const [isLoadingHistory, setIsLoadingHistory] = useState(false);
const chatContainerRef = useRef<HTMLDivElement>(null);
const prevConversationIdRef = useRef<string | undefined>(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<string, unknown>) => {
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 (
<section className="chat-area">
{/* 聊天历史 */}
<div className="chat-container" ref={chatContainerRef}>
{/* 加载历史消息时显示加载状态 */}
{isLoadingHistory && (
<div className="loading-history">
<div className="loading-spinner" />
<span>...</span>
</div>
)}
{messages.map(msg => (
<div key={msg.id}>
{msg.role === 'user' && (
<div className="message-row user-row">
<div className="avatar user-avatar">
<User size={16} />
</div>
<div className="message-content">
<div className="message-meta"> {formatTime(msg.timestamp)}</div>
<div className="chat-bubble user-bubble">
{msg.content}
</div>
</div>
</div>
)}
{msg.role === 'assistant' && (
<div className="message-row assistant-row">
<div className="avatar assistant-avatar">
<Sparkles size={16} />
</div>
<div className="message-content">
<div className="message-meta">
Protocol Agent
{msg.stageName && <span className="stage-tag">{msg.stageName}</span>}
<span className="timestamp"> {formatTime(msg.timestamp)}</span>
</div>
{/* 深度思考内容 */}
{msg.thinkingContent && (
<ThinkingBlock content={msg.thinkingContent} />
)}
<div className="chat-bubble assistant-bubble">
<MarkdownContent content={msg.content} />
</div>
{/* 同步按钮 */}
{msg.syncButton && !msg.syncButton.disabled && (
<SyncButton
syncData={msg.syncButton}
onSync={handleSync}
/>
)}
{/* 动作卡片 */}
{msg.actionCards && msg.actionCards.length > 0 && (
<div className="action-cards">
{msg.actionCards.map(card => (
<div
key={card.id}
className="action-card"
onClick={() => handleActionCard(card)}
>
<div className="action-card-title">{card.title}</div>
{card.description && (
<div className="action-card-desc">{card.description}</div>
)}
<ExternalLink size={14} className="action-card-icon" />
</div>
))}
</div>
)}
</div>
</div>
)}
{msg.role === 'system' && (
<div className="message-row system-row">
<div className="system-message">
{msg.content}
</div>
</div>
)}
</div>
))}
{/* 流式输出中的消息(打字机效果) */}
{(isStreaming || isThinking) && (
<div className="message-row assistant-row">
<div className="avatar assistant-avatar">
<Sparkles size={16} />
</div>
<div className="message-content">
<div className="message-meta">
Protocol Agent
{context?.stageName && <span className="stage-tag">{context.stageName}</span>}
<span className="timestamp"> ...</span>
</div>
{/* 深度思考内容(流式) */}
{isThinking && streamThinking && (
<ThinkingBlock content={streamThinking} isThinking />
)}
{/* 流式内容 */}
{streamContent ? (
<div className="chat-bubble assistant-bubble">
<MarkdownContent content={streamContent} />
<span className="streaming-cursor"></span>
</div>
) : (
<div className="chat-bubble assistant-bubble loading">
<Loader2 size={16} className="spinner" />
<span>AI ...</span>
</div>
)}
</div>
</div>
)}
</div>
{/* 输入区 */}
<div className="input-area">
<div className="input-wrapper">
<input
type="text"
className="input-field"
placeholder="输入您的问题或想法..."
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
disabled={isStreaming || !conversationId}
/>
<button
className="send-btn"
onClick={handleSend}
disabled={isStreaming || !input.trim() || !conversationId}
>
{isStreaming ? <Loader2 size={16} className="spinner" /> : <Send size={16} />}
</button>
</div>
</div>
</section>
);
};
/**
* 格式化时间
*/
function formatTime(date: Date): string {
return date.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit'
});
}