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
This commit is contained in:
2026-01-25 19:16:36 +08:00
parent 4d7d97ca19
commit 303dd78c54
332 changed files with 6204 additions and 617 deletions

View File

@@ -50,6 +50,8 @@ interface ChatAreaProps {
conversationId?: string;
context: ProtocolContext | null;
onContextUpdate: () => void;
/** 更新对话标题(首次发送消息时调用) */
onTitleUpdate?: (title: string) => void;
}
// ============================================
@@ -96,11 +98,15 @@ function parseExtractedData(content: string): {
export const ChatArea: React.FC<ChatAreaProps> = ({
conversationId,
context,
onContextUpdate
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 {
@@ -130,19 +136,15 @@ export const ChatArea: React.FC<ChatAreaProps> = ({
scrollToBottom();
}, [messages, streamContent, scrollToBottom]);
// 初始化欢迎消息
useEffect(() => {
if (conversationId && messages.length === 0) {
const currentStage = context?.currentStage || 'scientific_question';
const stageName = STAGE_NAMES[currentStage] || '科学问题梳理';
setMessages([{
id: 'welcome',
role: 'assistant',
content: `您好!我是研究方案制定助手,将帮助您系统地完成临床研究方案的核心要素设计。
我们将一起完成以下5个关键步骤
// 生成欢迎消息(紧凑版本)
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⃣ **研究设计** - 选择合适的研究类型和方法
@@ -151,17 +153,77 @@ export const ChatArea: React.FC<ChatAreaProps> = ({
完成这5个要素后您可以**一键生成完整的研究方案**并下载为Word文档。
---
📍 **当前阶段**: ${stageName}
让我们开始吧!请先告诉我,您想研究什么问题?或者描述一下您的研究背景和想法。`,
stage: currentStage,
stageName,
timestamp: new Date(),
}]);
stage,
stageName,
timestamp: new Date(),
};
}, []);
// 加载历史消息(当 conversationId 变化时)
useEffect(() => {
// 首次挂载或 conversationId 变化时执行
const shouldUpdate = isFirstMount.current || conversationId !== prevConversationIdRef.current;
if (!shouldUpdate) {
return;
}
}, [conversationId, messages.length, context]);
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(() => {
@@ -222,6 +284,9 @@ export const ChatArea: React.FC<ChatAreaProps> = ({
const userContent = input.trim();
// 检查是否是首次用户消息(只有欢迎消息时)
const isFirstUserMessage = messages.filter(m => m.role === 'user').length === 0;
// 添加用户消息
const userMessage: Message = {
id: Date.now().toString(),
@@ -232,11 +297,23 @@ export const ChatArea: React.FC<ChatAreaProps> = ({
setMessages(prev => [...prev, userMessage]);
setInput('');
// 使用 useAIStream 发送消息(流式输出
// 首次消息时更新对话标题(锦上添花,不影响核心功能
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]);
}, [input, conversationId, isStreaming, sendStreamMessage, messages, onTitleUpdate]);
/**
* 处理同步到方案
@@ -321,6 +398,14 @@ export const ChatArea: React.FC<ChatAreaProps> = ({
<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' && (