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

@@ -1,8 +1,9 @@
/**
* Protocol Agent 页面
*
* 100%还原原型图0119的精致设计
* 三栏布局:可折叠侧边栏 + 聊天区 + 状态面板
* V2: 支持动态布局 + 文档预览
* - 要素收集阶段: Chat 65% : Context 35%
* - 方案生成阶段: Chat 35% : Document 65%
*/
import React, { useState, useEffect, useRef, useCallback } from 'react';
@@ -13,11 +14,21 @@ import {
} from 'lucide-react';
import { ChatArea } from './components/ChatArea';
import { StatePanel } from './components/StatePanel';
import { DocumentPanel } from './components/DocumentPanel';
import { ResizableSplitPane } from './components/ResizableSplitPane';
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 }>();
@@ -32,11 +43,27 @@ export const ProtocolAgentPage: React.FC = () => {
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;
@@ -63,33 +90,31 @@ export const ProtocolAgentPage: React.FC = () => {
await refreshContext();
}, [currentConversation?.id, refreshContext]);
// 使用ref避免无限循环
const hasTriedCreate = useRef(false);
// 自动创建会话(首次进入时)
const hasAutoCreated = useRef(false);
useEffect(() => {
// 只在首次进入且无conversationId时尝试创建一次
if (!conversationId && !currentConversation && !hasTriedCreate.current) {
hasTriedCreate.current = true;
console.log('[ProtocolAgentPage] 自动创建新对话...');
createConversation().then(newConv => {
if (newConv) {
console.log('[ProtocolAgentPage] 新对话创建成功:', newConv.id);
navigate(`/ai-qa/protocol-agent/${newConv.id}`, { replace: true });
} else {
console.error('[ProtocolAgentPage] 创建对话失败');
}
}).catch(err => {
console.error('[ProtocolAgentPage] 创建对话异常:', err);
});
// 如果已有 conversationId 或 currentConversation无需创建
if (conversationId || currentConversation || hasAutoCreated.current) {
return;
}
}, [conversationId, currentConversation, navigate]); // 移除createConversation依赖
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) {
@@ -110,6 +135,35 @@ export const ProtocolAgentPage: React.FC = () => {
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 (
@@ -182,17 +236,17 @@ export const ProtocolAgentPage: React.FC = () => {
<div className="sidebar-content">
<div className="sidebar-header">
<button
className="icon-btn"
onClick={() => setSidebarCollapsed(true)}
className="back-to-hub-btn"
onClick={handleBack}
>
<ChevronLeft size={20} />
<ChevronLeft size={18} />
<span></span>
</button>
<span className="sidebar-title"></span>
</div>
<button className="new-chat-btn" onClick={handleNewConversation}>
<Plus size={18} />
<span></span>
<button className="new-chat-btn compact" onClick={handleNewConversation}>
<Plus size={14} />
<span></span>
</button>
<div className="conversations-list">
@@ -241,6 +295,13 @@ export const ProtocolAgentPage: React.FC = () => {
</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>
@@ -251,17 +312,43 @@ export const ProtocolAgentPage: React.FC = () => {
</div>
</header>
{/* 聊天 + 状态面板 */}
{/* 聊天 + 右侧面板(动态切换) */}
<div className="workspace-body">
{/* 聊天区域 */}
<ChatArea
conversationId={currentConversation?.id}
context={context}
onContextUpdate={refreshContext}
<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}
/>
)
}
/>
{/* 状态面板 */}
<StatePanel context={context} onStageUpdate={handleStageUpdate} />
</div>
</main>
</div>