- 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
555 lines
18 KiB
TypeScript
555 lines
18 KiB
TypeScript
/**
|
||
* 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'
|
||
});
|
||
}
|