Files
AIclinicalresearch/frontend-v2/src/modules/aia/protocol-agent/ProtocolAgentPage.tsx

360 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
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.
/**
* Protocol Agent 页面
*
* V2: 支持动态布局 + 文档预览
* - 要素收集阶段: Chat 65% : Context 35%
* - 方案生成阶段: Chat 35% : Document 65%
*/
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import {
Bot, Settings, ChevronLeft, Menu, Plus,
MessageSquare, Trash2
} from 'lucide-react';
import { ChatArea } from './components/ChatArea';
import { StatePanel } from './components/StatePanel';
import { DocumentPanel } from './components/DocumentPanel';
import { ResizableSplitPane } from '@/shared/components/Layout';
import { ViewSwitcher } from './components/ViewSwitcher';
import { useProtocolContext } from './hooks/useProtocolContext';
import { useProtocolConversations } from './hooks/useProtocolConversations';
import { useProtocolGeneration } from './hooks/useProtocolGeneration';
import { getAccessToken } from '../../../framework/auth/api';
import './styles/protocol-agent.css';
// 布局比例配置
const LAYOUT_RATIOS = {
context: 65, // 要素收集阶段Chat 65%
document: 35, // 方案生成阶段Chat 35%
};
export const ProtocolAgentPage: React.FC = () => {
const navigate = useNavigate();
const { conversationId } = useParams<{ conversationId?: string }>();
// 侧边栏折叠状态
const [sidebarCollapsed, setSidebarCollapsed] = useState(true);
// 会话管理
const {
conversations,
currentConversation,
createConversation,
selectConversation,
deleteConversation,
updateConversationTitle,
} = useProtocolConversations(conversationId);
// 上下文状态
const { context, refreshContext } = useProtocolContext(currentConversation?.id);
// 方案生成状态
const {
generatedContent,
currentSection,
isGenerating,
hasGeneratedContent,
viewMode,
setViewMode,
startGeneration,
exportWord,
} = useProtocolGeneration({ conversationId: currentConversation?.id });
// Word 导出状态
const [isExporting, setIsExporting] = useState(false);
// 处理阶段数据编辑更新
const handleStageUpdate = useCallback(async (stageCode: string, data: Record<string, unknown>) => {
if (!currentConversation?.id) return;
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: currentConversation.id,
stageCode,
data,
}),
});
if (!response.ok) {
throw new Error('更新失败');
}
// 刷新上下文
await refreshContext();
}, [currentConversation?.id, refreshContext]);
// 自动创建会话(首次进入时)
const hasAutoCreated = useRef(false);
useEffect(() => {
// 如果已有 conversationId 或 currentConversation无需创建
if (conversationId || currentConversation || hasAutoCreated.current) {
return;
}
hasAutoCreated.current = true;
console.log('[ProtocolAgentPage] 自动创建新对话...');
createConversation().then(newConv => {
if (newConv) {
console.log('[ProtocolAgentPage] 新对话创建成功:', newConv.id);
navigate(`/ai-qa/protocol-agent/${newConv.id}`, { replace: true });
}
});
}, [conversationId, currentConversation, createConversation, navigate]);
// 获取当前阶段信息
const currentStageName = context?.stageName || '科学问题梳理';
const currentStageIndex = context?.stages?.findIndex(s => s.status === 'current') ?? 0;
// 创建新对话(立即创建,保证功能正常)
const handleNewConversation = async () => {
const newConv = await createConversation();
if (newConv) {
navigate(`/ai-qa/protocol-agent/${newConv.id}`);
setSidebarCollapsed(true);
}
};
// 选择对话
const handleSelectConversation = (id: string) => {
selectConversation(id);
navigate(`/ai-qa/protocol-agent/${id}`);
setSidebarCollapsed(true);
};
// 返回AgentHub
const handleBack = () => {
navigate('/ai-qa');
};
// 更新对话标题
const handleTitleUpdate = (title: string) => {
if (currentConversation?.id) {
updateConversationTitle(currentConversation.id, title);
}
};
// 处理 Word 导出
const handleExportWord = async () => {
setIsExporting(true);
try {
await exportWord();
} catch (error) {
alert(`导出失败: ${error instanceof Error ? error.message : '未知错误'}`);
} finally {
setIsExporting(false);
}
};
// 处理章节讨论Phase 2
const handleSectionDiscuss = (sectionId: string, sectionTitle: string) => {
// TODO: 实现章节讨论模式
console.log('讨论章节:', sectionId, sectionTitle);
alert(`"讨论与优化" 功能将在 Phase 2 实现\n\n章节: ${sectionTitle}`);
};
// 计算当前布局比例
const currentLayoutRatio = LAYOUT_RATIOS[viewMode];
// 如果没有conversationId显示等待状态
if (!conversationId) {
return (
<div className="protocol-agent-page">
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
flexDirection: 'column',
gap: '16px'
}}>
<div style={{
width: '40px',
height: '40px',
border: '4px solid #E5E7EB',
borderTopColor: '#6366F1',
borderRadius: '50%',
animation: 'spin 1s linear infinite'
}} />
<p style={{ color: '#6B7280', fontSize: '14px' }}>...</p>
</div>
</div>
);
}
return (
<div className="protocol-agent-page">
{/* 可折叠侧边栏 - Gemini风格 */}
<aside className={`sidebar ${sidebarCollapsed ? 'collapsed' : 'expanded'}`}>
{/* 折叠状态:图标栏 */}
<div className="sidebar-icons">
<button
className="icon-btn menu-btn"
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
title="展开会话列表"
>
<Menu size={20} />
</button>
<button
className="icon-btn new-btn"
onClick={handleNewConversation}
title="新建对话"
>
<Plus size={20} />
</button>
{/* 最近会话图标 */}
<div className="recent-icons">
{conversations.slice(0, 5).map((conv) => (
<button
key={conv.id}
className={`icon-btn conv-icon ${conv.id === currentConversation?.id ? 'active' : ''}`}
onClick={() => handleSelectConversation(conv.id)}
title={conv.title}
>
<MessageSquare size={18} />
</button>
))}
</div>
<div className="sidebar-bottom">
<button className="icon-btn" title="设置">
<Settings size={20} />
</button>
</div>
</div>
{/* 展开状态:完整列表 */}
<div className="sidebar-content">
<div className="sidebar-header">
<button
className="back-to-hub-btn"
onClick={handleBack}
>
<ChevronLeft size={18} />
<span></span>
</button>
</div>
<button className="new-chat-btn compact" onClick={handleNewConversation}>
<Plus size={14} />
<span></span>
</button>
<div className="conversations-list">
{conversations.map((conv) => (
<div
key={conv.id}
className={`conv-item ${conv.id === currentConversation?.id ? 'active' : ''}`}
onClick={() => handleSelectConversation(conv.id)}
>
<MessageSquare size={16} />
<span className="conv-title">{conv.title}</span>
<button
className="conv-delete"
onClick={(e) => {
e.stopPropagation();
deleteConversation(conv.id);
}}
>
<Trash2 size={14} />
</button>
</div>
))}
</div>
</div>
</aside>
{/* 主工作区 */}
<main className="workspace">
{/* 顶部导航 */}
<header className="workspace-header">
<div className="header-left">
<button className="back-btn" onClick={handleBack}>
<ChevronLeft size={20} />
</button>
<div className="agent-info">
<div className="agent-icon">
<Bot size={20} />
</div>
<div className="agent-meta">
<h1 className="agent-name"> Agent</h1>
<div className="agent-status">
<span className="status-dot" />
<span className="status-text">Orchestrator Online</span>
</div>
</div>
</div>
</div>
<div className="header-right">
{/* 视图切换器 */}
<ViewSwitcher
currentView={viewMode}
onViewChange={setViewMode}
documentDisabled={!hasGeneratedContent && !isGenerating}
documentDisabledTooltip="请先生成研究方案"
/>
<div className="current-stage-badge">
<span className="badge-label">:</span>
<span className="badge-value">Step {currentStageIndex + 1}: {currentStageName}</span>
</div>
<button className="icon-btn">
<Settings size={20} />
</button>
</div>
</header>
{/* 聊天 + 右侧面板(动态切换) */}
<div className="workspace-body">
<ResizableSplitPane
defaultLeftRatio={currentLayoutRatio}
minLeftRatio={25}
maxLeftRatio={80}
enableDrag={true}
storageKey={`protocol-agent-split-${viewMode}`}
leftPanel={
<ChatArea
conversationId={currentConversation?.id}
context={context}
onContextUpdate={refreshContext}
onTitleUpdate={handleTitleUpdate}
/>
}
rightPanel={
viewMode === 'context' ? (
<StatePanel
context={context}
onStageUpdate={handleStageUpdate}
onStartGeneration={startGeneration}
canGenerate={context?.canGenerate}
isGenerating={isGenerating}
/>
) : (
<DocumentPanel
content={generatedContent}
isGenerating={isGenerating}
currentSection={currentSection || undefined}
onExportWord={handleExportWord}
isExporting={isExporting}
onSectionDiscuss={handleSectionDiscuss}
/>
)
}
/>
</div>
</main>
</div>
);
};
export default ProtocolAgentPage;