feat(aia): Implement Protocol Agent MVP with reusable Agent framework

Sprint 1-3 Completed (Backend + Frontend):

Backend (Sprint 1-2):
- Implement 5-layer Agent framework (Query->Planner->Executor->Tools->Reflection)
- Create agent_schema with 6 tables (agent_definitions, stages, prompts, sessions, traces, reflexion_rules)
- Create protocol_schema with 2 tables (protocol_contexts, protocol_generations)
- Implement Protocol Agent core services (Orchestrator, ContextService, PromptBuilder)
- Integrate LLM service adapter (DeepSeek/Qwen/GPT-5/Claude)
- 6 API endpoints with full authentication
- 10/10 API tests passed

Frontend (Sprint 3):
- Add Protocol Agent entry in AgentHub (indigo theme card)
- Implement ProtocolAgentPage with 3-column layout
- Collapsible sidebar (Gemini style, 48px <-> 280px)
- StatePanel with 5 stage cards (scientific_question, pico, study_design, sample_size, endpoints)
- ChatArea with sync button and action cards integration
- 100% prototype design restoration (608 lines CSS)
- Detailed endpoints structure: baseline, exposure, outcomes, confounders

Features:
- 5-stage dialogue flow for research protocol design
- Conversation-driven interaction with sync-to-protocol button
- Real-time context state management
- One-click protocol generation button (UI ready, backend pending)

Database:
- agent_schema: 6 tables for reusable Agent framework
- protocol_schema: 2 tables for Protocol Agent
- Seed data: 1 agent + 5 stages + 9 prompts + 4 reflexion rules

Code Stats:
- Backend: 13 files, 4338 lines
- Frontend: 14 files, 2071 lines
- Total: 27 files, 6409 lines

Status: MVP core functionality completed, pending frontend-backend integration testing

Next: Sprint 4 - One-click protocol generation + Word export
This commit is contained in:
2026-01-24 17:29:24 +08:00
parent 61cdc97eeb
commit 96290d2f76
345 changed files with 13945 additions and 47 deletions

View File

@@ -0,0 +1,310 @@
/**
* Chat Area - Protocol Agent 聊天区域
*
* 基于通用Chat组件扩展Protocol Agent特有功能
*/
import React, { useState, useRef, useEffect } from 'react';
import { Send, Sparkles, User } from 'lucide-react';
import type { ProtocolContext, AgentResponse } from '../types';
import { SyncButton } from './SyncButton';
import { ActionCardComponent } from './ActionCard';
import { ReflexionMessage } from './ReflexionMessage';
import { getAccessToken } from '../../../../framework/auth/api';
interface ChatAreaProps {
conversationId?: string;
context: ProtocolContext | null;
onContextUpdate: () => void;
}
interface Message {
id: string;
role: 'user' | 'assistant' | 'system';
content: string;
thinkingContent?: string;
syncButton?: AgentResponse['syncButton'];
actionCards?: AgentResponse['actionCards'];
timestamp: Date;
}
const API_BASE = '/api/v1/aia/protocol-agent';
export const ChatArea: React.FC<ChatAreaProps> = ({
conversationId,
context,
onContextUpdate
}) => {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const chatContainerRef = useRef<HTMLDivElement>(null);
// 自动滚动到底部
const scrollToBottom = () => {
if (chatContainerRef.current) {
chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight;
}
};
useEffect(() => {
scrollToBottom();
}, [messages]);
// 初始化欢迎消息
useEffect(() => {
if (conversationId && messages.length === 0) {
setMessages([{
id: 'welcome',
role: 'assistant',
content: `您好!我是研究方案制定助手,将帮助您系统地完成临床研究方案的核心要素设计。
我们将一起完成以下5个关键步骤
1⃣ **科学问题梳理** - 明确研究要解决的核心问题
2⃣ **PICO要素** - 确定研究人群、干预、对照和结局
3⃣ **研究设计** - 选择合适的研究类型和方法
4⃣ **样本量计算** - 估算所需的样本量
5⃣ **观察指标** - 定义基线、暴露、结局指标和混杂因素
完成这5个要素后您可以**一键生成完整的研究方案**并下载为Word文档。
让我们开始吧!请先告诉我,您想研究什么问题?或者描述一下您的研究背景和想法。`,
timestamp: new Date(),
}]);
}
}, [conversationId, messages.length]);
/**
* 发送消息
*/
const handleSend = async () => {
if (!input.trim() || !conversationId) return;
const userMessage: Message = {
id: Date.now().toString(),
role: 'user',
content: input.trim(),
timestamp: new Date(),
};
setMessages(prev => [...prev, userMessage]);
setInput('');
setLoading(true);
try {
const token = getAccessToken();
const response = await fetch(`${API_BASE}/message`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
conversationId,
content: userMessage.content,
}),
});
if (!response.ok) {
throw new Error('Failed to send message');
}
const result = await response.json();
if (result.success && result.data) {
const aiResponse: AgentResponse = result.data;
const aiMessage: Message = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: aiResponse.content,
thinkingContent: aiResponse.thinkingContent,
syncButton: aiResponse.syncButton,
actionCards: aiResponse.actionCards,
timestamp: new Date(),
};
setMessages(prev => [...prev, aiMessage]);
// 刷新上下文状态
onContextUpdate();
}
} catch (err) {
console.error('[ChatArea] handleSend error:', err);
// 显示错误消息
setMessages(prev => [...prev, {
id: (Date.now() + 1).toString(),
role: 'system',
content: '抱歉,消息发送失败。请稍后重试。',
timestamp: new Date(),
}]);
} finally {
setLoading(false);
}
};
/**
* 处理同步
*/
const handleSync = async (stageCode: string, data: Record<string, unknown>) => {
if (!conversationId) return;
try {
const token = getAccessToken();
const response = await fetch(`${API_BASE}/sync`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
conversationId,
stageCode,
data,
}),
});
if (!response.ok) {
throw new Error('Failed to sync data');
}
const result = await response.json();
if (result.success) {
// 添加系统消息
const systemMsg: Message = {
id: Date.now().toString(),
role: 'system',
content: result.data.message || '✅ 已同步到方案',
timestamp: new Date(),
};
setMessages(prev => [...prev, systemMsg]);
// 刷新上下文
onContextUpdate();
}
} catch (err) {
console.error('[ChatArea] handleSync error:', err);
}
};
// 按Enter发送
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
return (
<section className="chat-area">
{/* 聊天历史 */}
<div className="chat-container" ref={chatContainerRef}>
{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">User {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 {formatTime(msg.timestamp)}</div>
<div className="chat-bubble assistant-bubble">
{msg.content}
</div>
{/* 同步按钮 */}
{msg.syncButton && (
<SyncButton
syncData={msg.syncButton}
onSync={handleSync}
/>
)}
{/* Action Cards */}
{msg.actionCards && msg.actionCards.length > 0 && (
<div className="action-cards">
{msg.actionCards.map(card => (
<ActionCardComponent key={card.id} card={card} />
))}
</div>
)}
</div>
</div>
)}
{msg.role === 'system' && (
<div className="message-row system-row">
<div className="system-message">
{msg.content}
</div>
</div>
)}
</div>
))}
{loading && (
<div className="message-row assistant-row">
<div className="avatar assistant-avatar">
<Sparkles size={16} />
</div>
<div className="message-content">
<div className="chat-bubble assistant-bubble thinking">
<span className="thinking-dot"></span>
<span className="thinking-dot"></span>
<span className="thinking-dot"></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={loading || !conversationId}
/>
<button
className="send-btn"
onClick={handleSend}
disabled={loading || !input.trim() || !conversationId}
>
<Send size={16} />
</button>
</div>
</div>
</section>
);
};
/**
* 格式化时间
*/
function formatTime(date: Date): string {
return date.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit'
});
}