feat(aia): Protocol Agent streaming + editable state panel + protocol generation plan
Day 2 Development (2026-01-24): Backend Enhancements: - Implement SSE streaming in ProtocolAgentController using createStreamingService - Add data condensation via LLM in ProtocolOrchestrator.handleProtocolSync - Support stage editing without resetting progress - Add explicit JSON output format for each stage in system prompt - Create independent seed script for Protocol Agent (seed-protocol-agent.ts) Frontend Improvements: - Integrate useAIStream hook for typewriter effect in ChatArea - Add MarkdownContent component for basic Markdown rendering - Implement StageEditModal for editing stage data (scientific question, PICO, etc.) - Add edit button to StageCard (visible on hover) - Fix routing paths from /aia to /ai-qa - Enhance CSS with full-screen layout and Markdown styles New Documentation: - One-click protocol generation development plan (v1.1) - Editor selection evaluation (Novel vs BlockNote vs Tiptap) - Novel fork strategy for AI-native editing Technical Decisions: - Choose Novel (Fork) as protocol editor for AI-first design - Two-stage progressive generation: summary in chat, full protocol in editor - 10-day development plan for protocol generation feature Code Stats: - Backend: 3 files modified, 1 new file - Frontend: 9 files modified, 2 new files - Docs: 3 new files Status: Streaming and editable features working, protocol generation pending
This commit is contained in:
@@ -2,9 +2,9 @@
|
||||
* AIA - AI Intelligent Assistant 模块入口
|
||||
*
|
||||
* 路由管理:
|
||||
* - /aia -> Hub: 智能体大厅(12个模块展示)
|
||||
* - /aia/chat -> Chat: 沉浸式对话工作台(原12个智能体)
|
||||
* - /aia/protocol-agent/:conversationId? -> Protocol Agent(全流程方案制定)
|
||||
* - /ai-qa -> Hub: 智能体大厅(12个模块展示)
|
||||
* - /ai-qa/chat -> Chat: 沉浸式对话工作台(原12个智能体)
|
||||
* - /ai-qa/protocol-agent/:conversationId? -> Protocol Agent(全流程方案制定)
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
@@ -40,12 +40,12 @@ const AIAHub: React.FC = () => {
|
||||
|
||||
if (agent.isProtocolAgent) {
|
||||
// Protocol Agent:跳转专属页面
|
||||
console.log('[AIAHub] Navigating to /aia/protocol-agent');
|
||||
navigate('/aia/protocol-agent');
|
||||
console.log('[AIAHub] Navigating to /ai-qa/protocol-agent');
|
||||
navigate('/ai-qa/protocol-agent');
|
||||
} else {
|
||||
// 传统智能体:跳转对话页面
|
||||
console.log('[AIAHub] Navigating to /aia/chat');
|
||||
navigate('/aia/chat', { state: { agent, initialQuery: agent.initialQuery } });
|
||||
console.log('[AIAHub] Navigating to /ai-qa/chat');
|
||||
navigate('/ai-qa/chat', { state: { agent, initialQuery: agent.initialQuery } });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -61,11 +61,11 @@ const AIAChat: React.FC = () => {
|
||||
const state = location.state as { agent: AgentConfig; initialQuery?: string } | null;
|
||||
|
||||
const handleBack = () => {
|
||||
navigate('/aia');
|
||||
navigate('/ai-qa');
|
||||
};
|
||||
|
||||
if (!state?.agent) {
|
||||
navigate('/aia');
|
||||
navigate('/ai-qa');
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* 三栏布局:可折叠侧边栏 + 聊天区 + 状态面板
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import {
|
||||
Bot, Settings, ChevronLeft, Menu, Plus,
|
||||
@@ -15,6 +15,7 @@ import { ChatArea } from './components/ChatArea';
|
||||
import { StatePanel } from './components/StatePanel';
|
||||
import { useProtocolContext } from './hooks/useProtocolContext';
|
||||
import { useProtocolConversations } from './hooks/useProtocolConversations';
|
||||
import { getAccessToken } from '../../../framework/auth/api';
|
||||
import './styles/protocol-agent.css';
|
||||
|
||||
export const ProtocolAgentPage: React.FC = () => {
|
||||
@@ -36,27 +37,53 @@ export const ProtocolAgentPage: React.FC = () => {
|
||||
// 上下文状态
|
||||
const { context, refreshContext } = useProtocolContext(currentConversation?.id);
|
||||
|
||||
// 首次进入且无conversationId时,自动创建新对话
|
||||
const [isCreating, setIsCreating] = 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]);
|
||||
|
||||
// 使用ref避免无限循环
|
||||
const hasTriedCreate = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!conversationId && !currentConversation && !isCreating) {
|
||||
// 只在首次进入且无conversationId时尝试创建一次
|
||||
if (!conversationId && !currentConversation && !hasTriedCreate.current) {
|
||||
hasTriedCreate.current = true;
|
||||
console.log('[ProtocolAgentPage] 自动创建新对话...');
|
||||
setIsCreating(true);
|
||||
|
||||
createConversation().then(newConv => {
|
||||
if (newConv) {
|
||||
console.log('[ProtocolAgentPage] 新对话创建成功:', newConv.id);
|
||||
navigate(`/aia/protocol-agent/${newConv.id}`, { replace: true });
|
||||
navigate(`/ai-qa/protocol-agent/${newConv.id}`, { replace: true });
|
||||
} else {
|
||||
console.error('[ProtocolAgentPage] 创建对话失败');
|
||||
setIsCreating(false);
|
||||
}
|
||||
}).catch(err => {
|
||||
console.error('[ProtocolAgentPage] 创建对话异常:', err);
|
||||
setIsCreating(false);
|
||||
});
|
||||
}
|
||||
}, [conversationId, currentConversation, isCreating, createConversation, navigate]);
|
||||
}, [conversationId, currentConversation, navigate]); // 移除createConversation依赖
|
||||
|
||||
// 获取当前阶段信息
|
||||
const currentStageName = context?.stageName || '科学问题梳理';
|
||||
@@ -66,7 +93,7 @@ export const ProtocolAgentPage: React.FC = () => {
|
||||
const handleNewConversation = async () => {
|
||||
const newConv = await createConversation();
|
||||
if (newConv) {
|
||||
navigate(`/aia/protocol-agent/${newConv.id}`);
|
||||
navigate(`/ai-qa/protocol-agent/${newConv.id}`);
|
||||
setSidebarCollapsed(true);
|
||||
}
|
||||
};
|
||||
@@ -74,17 +101,17 @@ export const ProtocolAgentPage: React.FC = () => {
|
||||
// 选择对话
|
||||
const handleSelectConversation = (id: string) => {
|
||||
selectConversation(id);
|
||||
navigate(`/aia/protocol-agent/${id}`);
|
||||
navigate(`/ai-qa/protocol-agent/${id}`);
|
||||
setSidebarCollapsed(true);
|
||||
};
|
||||
|
||||
// 返回AgentHub
|
||||
const handleBack = () => {
|
||||
navigate('/aia');
|
||||
navigate('/ai-qa');
|
||||
};
|
||||
|
||||
// 加载状态
|
||||
if (isCreating || (!conversationId && !currentConversation)) {
|
||||
// 如果没有conversationId,显示等待状态
|
||||
if (!conversationId) {
|
||||
return (
|
||||
<div className="protocol-agent-page">
|
||||
<div style={{
|
||||
@@ -103,7 +130,7 @@ export const ProtocolAgentPage: React.FC = () => {
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite'
|
||||
}} />
|
||||
<p style={{ color: '#6B7280', fontSize: '14px' }}>正在创建新对话...</p>
|
||||
<p style={{ color: '#6B7280', fontSize: '14px' }}>正在初始化...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -234,7 +261,7 @@ export const ProtocolAgentPage: React.FC = () => {
|
||||
/>
|
||||
|
||||
{/* 状态面板 */}
|
||||
<StatePanel context={context} />
|
||||
<StatePanel context={context} onStageUpdate={handleStageUpdate} />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,21 +1,37 @@
|
||||
/**
|
||||
* Chat Area - Protocol Agent 聊天区域
|
||||
*
|
||||
* 基于通用Chat组件,扩展Protocol Agent特有功能
|
||||
* 方案A: 使用 Protocol Agent 独立 API
|
||||
* - POST /api/v1/aia/protocol-agent/message 发送消息
|
||||
* - 后端返回结构化 AgentResponse(含 syncButton, actionCards)
|
||||
*/
|
||||
|
||||
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 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';
|
||||
|
||||
interface ChatAreaProps {
|
||||
conversationId?: string;
|
||||
context: ProtocolContext | null;
|
||||
onContextUpdate: () => void;
|
||||
// ============================================
|
||||
// 类型定义(与后端 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 {
|
||||
@@ -23,12 +39,59 @@ interface Message {
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
thinkingContent?: string;
|
||||
syncButton?: AgentResponse['syncButton'];
|
||||
actionCards?: AgentResponse['actionCards'];
|
||||
stage?: string;
|
||||
stageName?: string;
|
||||
syncButton?: SyncButtonData;
|
||||
actionCards?: ActionCard[];
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
const API_BASE = '/api/v1/aia/protocol-agent';
|
||||
interface ChatAreaProps {
|
||||
conversationId?: string;
|
||||
context: ProtocolContext | null;
|
||||
onContextUpdate: () => 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,
|
||||
@@ -37,23 +100,42 @@ export const ChatArea: React.FC<ChatAreaProps> = ({
|
||||
}) => {
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const chatContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 使用通用 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 = () => {
|
||||
const scrollToBottom = useCallback(() => {
|
||||
if (chatContainerRef.current) {
|
||||
chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages]);
|
||||
}, [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',
|
||||
@@ -69,89 +151,102 @@ export const ChatArea: React.FC<ChatAreaProps> = ({
|
||||
|
||||
完成这5个要素后,您可以**一键生成完整的研究方案**并下载为Word文档。
|
||||
|
||||
---
|
||||
|
||||
📍 **当前阶段**: ${stageName}
|
||||
|
||||
让我们开始吧!请先告诉我,您想研究什么问题?或者描述一下您的研究背景和想法。`,
|
||||
stage: currentStage,
|
||||
stageName,
|
||||
timestamp: new Date(),
|
||||
}]);
|
||||
}
|
||||
}, [conversationId, messages.length]);
|
||||
}, [conversationId, messages.length, context]);
|
||||
|
||||
// 处理流式响应完成
|
||||
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 = async () => {
|
||||
if (!input.trim() || !conversationId) return;
|
||||
const handleSend = useCallback(async () => {
|
||||
if (!input.trim() || !conversationId || isStreaming) return;
|
||||
|
||||
const userContent = input.trim();
|
||||
|
||||
// 添加用户消息
|
||||
const userMessage: Message = {
|
||||
id: Date.now().toString(),
|
||||
role: 'user',
|
||||
content: input.trim(),
|
||||
content: userContent,
|
||||
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);
|
||||
}
|
||||
};
|
||||
// 使用 useAIStream 发送消息(流式输出)
|
||||
await sendStreamMessage(userContent, {
|
||||
conversationId,
|
||||
});
|
||||
}, [input, conversationId, isStreaming, sendStreamMessage]);
|
||||
|
||||
/**
|
||||
* 处理同步
|
||||
* 处理同步到方案
|
||||
*/
|
||||
const handleSync = async (stageCode: string, data: Record<string, unknown>) => {
|
||||
const handleSync = useCallback(async (stageCode: string, data: Record<string, unknown>) => {
|
||||
if (!conversationId) return;
|
||||
|
||||
try {
|
||||
const token = getAccessToken();
|
||||
const response = await fetch(`${API_BASE}/sync`, {
|
||||
const response = await fetch('/api/v1/aia/protocol-agent/sync', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -164,36 +259,63 @@ export const ChatArea: React.FC<ChatAreaProps> = ({
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to sync data');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
// 添加系统消息
|
||||
const systemMsg: Message = {
|
||||
|
||||
if (response.ok && result.success) {
|
||||
// 添加成功提示
|
||||
setMessages(prev => [...prev, {
|
||||
id: Date.now().toString(),
|
||||
role: 'system',
|
||||
content: result.data.message || '✅ 已同步到方案',
|
||||
content: `✅ ${result.data?.message || `已同步「${STAGE_NAMES[stageCode] || stageCode}」到方案`}`,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
setMessages(prev => [...prev, systemMsg]);
|
||||
|
||||
// 刷新上下文
|
||||
}]);
|
||||
|
||||
// 刷新上下文状态
|
||||
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]);
|
||||
|
||||
// 按Enter发送
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
/**
|
||||
* 处理动作卡片点击
|
||||
*/
|
||||
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">
|
||||
@@ -207,7 +329,7 @@ export const ChatArea: React.FC<ChatAreaProps> = ({
|
||||
<User size={16} />
|
||||
</div>
|
||||
<div className="message-content">
|
||||
<div className="message-meta">User • {formatTime(msg.timestamp)}</div>
|
||||
<div className="message-meta">您 • {formatTime(msg.timestamp)}</div>
|
||||
<div className="chat-bubble user-bubble">
|
||||
{msg.content}
|
||||
</div>
|
||||
@@ -221,24 +343,44 @@ export const ChatArea: React.FC<ChatAreaProps> = ({
|
||||
<Sparkles size={16} />
|
||||
</div>
|
||||
<div className="message-content">
|
||||
<div className="message-meta">Protocol Agent • {formatTime(msg.timestamp)}</div>
|
||||
<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">
|
||||
{msg.content}
|
||||
<MarkdownContent content={msg.content} />
|
||||
</div>
|
||||
|
||||
{/* 同步按钮 */}
|
||||
{msg.syncButton && (
|
||||
{msg.syncButton && !msg.syncButton.disabled && (
|
||||
<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
|
||||
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>
|
||||
)}
|
||||
@@ -256,17 +398,36 @@ export const ChatArea: React.FC<ChatAreaProps> = ({
|
||||
</div>
|
||||
))}
|
||||
|
||||
{loading && (
|
||||
{/* 流式输出中的消息(打字机效果) */}
|
||||
{(isStreaming || isThinking) && (
|
||||
<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 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>
|
||||
)}
|
||||
@@ -278,18 +439,18 @@ export const ChatArea: React.FC<ChatAreaProps> = ({
|
||||
<input
|
||||
type="text"
|
||||
className="input-field"
|
||||
placeholder="输入您的指令..."
|
||||
placeholder="输入您的问题或想法..."
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={loading || !conversationId}
|
||||
disabled={isStreaming || !conversationId}
|
||||
/>
|
||||
<button
|
||||
className="send-btn"
|
||||
onClick={handleSend}
|
||||
disabled={loading || !input.trim() || !conversationId}
|
||||
disabled={isStreaming || !input.trim() || !conversationId}
|
||||
>
|
||||
<Send size={16} />
|
||||
{isStreaming ? <Loader2 size={16} className="spinner" /> : <Send size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -306,5 +467,3 @@ function formatTime(date: Date): string {
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* MarkdownContent - 简单 Markdown 渲染组件
|
||||
*
|
||||
* 处理基本的 Markdown 格式:
|
||||
* - **粗体**
|
||||
* - *斜体*
|
||||
* - 换行
|
||||
* - 标题 (###)
|
||||
* - 列表 (- / 1.)
|
||||
* - 代码 `code`
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
interface MarkdownContentProps {
|
||||
content: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 Markdown 文本转换为 React 元素
|
||||
*/
|
||||
function parseMarkdown(text: string): React.ReactNode[] {
|
||||
const lines = text.split('\n');
|
||||
const elements: React.ReactNode[] = [];
|
||||
let listItems: string[] = [];
|
||||
let listType: 'ul' | 'ol' | null = null;
|
||||
let key = 0;
|
||||
|
||||
const flushList = () => {
|
||||
if (listItems.length > 0) {
|
||||
const ListTag = listType === 'ol' ? 'ol' : 'ul';
|
||||
elements.push(
|
||||
<ListTag key={key++}>
|
||||
{listItems.map((item, i) => (
|
||||
<li key={i}>{formatInline(item)}</li>
|
||||
))}
|
||||
</ListTag>
|
||||
);
|
||||
listItems = [];
|
||||
listType = null;
|
||||
}
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
// 处理标题
|
||||
if (line.startsWith('### ')) {
|
||||
flushList();
|
||||
elements.push(<h3 key={key++}>{formatInline(line.slice(4))}</h3>);
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('## ')) {
|
||||
flushList();
|
||||
elements.push(<h2 key={key++}>{formatInline(line.slice(3))}</h2>);
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('# ')) {
|
||||
flushList();
|
||||
elements.push(<h1 key={key++}>{formatInline(line.slice(2))}</h1>);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 处理无序列表
|
||||
if (line.match(/^[\-\*]\s+/)) {
|
||||
if (listType !== 'ul') {
|
||||
flushList();
|
||||
listType = 'ul';
|
||||
}
|
||||
listItems.push(line.replace(/^[\-\*]\s+/, ''));
|
||||
continue;
|
||||
}
|
||||
|
||||
// 处理有序列表
|
||||
if (line.match(/^\d+\.\s+/)) {
|
||||
if (listType !== 'ol') {
|
||||
flushList();
|
||||
listType = 'ol';
|
||||
}
|
||||
listItems.push(line.replace(/^\d+\.\s+/, ''));
|
||||
continue;
|
||||
}
|
||||
|
||||
// 非列表内容,先清空列表
|
||||
flushList();
|
||||
|
||||
// 空行
|
||||
if (line.trim() === '') {
|
||||
elements.push(<br key={key++} />);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 普通段落
|
||||
elements.push(<p key={key++}>{formatInline(line)}</p>);
|
||||
}
|
||||
|
||||
// 处理剩余列表
|
||||
flushList();
|
||||
|
||||
return elements;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理行内格式(粗体、斜体、代码)
|
||||
*/
|
||||
function formatInline(text: string): React.ReactNode {
|
||||
// 按顺序处理:代码块 > 粗体 > 斜体
|
||||
const parts: React.ReactNode[] = [];
|
||||
let remaining = text;
|
||||
let key = 0;
|
||||
|
||||
// 处理行内代码 `code`
|
||||
const codeRegex = /`([^`]+)`/g;
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
|
||||
const tempParts: Array<{ type: 'text' | 'code'; content: string }> = [];
|
||||
|
||||
while ((match = codeRegex.exec(remaining)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
tempParts.push({ type: 'text', content: remaining.slice(lastIndex, match.index) });
|
||||
}
|
||||
tempParts.push({ type: 'code', content: match[1] });
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
if (lastIndex < remaining.length) {
|
||||
tempParts.push({ type: 'text', content: remaining.slice(lastIndex) });
|
||||
}
|
||||
|
||||
// 处理每个部分
|
||||
for (const part of tempParts) {
|
||||
if (part.type === 'code') {
|
||||
parts.push(<code key={key++}>{part.content}</code>);
|
||||
} else {
|
||||
// 处理文本中的粗体和斜体
|
||||
parts.push(...formatBoldItalic(part.content, key));
|
||||
key += 10; // 预留足够的 key 空间
|
||||
}
|
||||
}
|
||||
|
||||
return parts.length === 1 ? parts[0] : <>{parts}</>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理粗体和斜体
|
||||
*/
|
||||
function formatBoldItalic(text: string, startKey: number): React.ReactNode[] {
|
||||
const parts: React.ReactNode[] = [];
|
||||
let key = startKey;
|
||||
|
||||
// 先处理粗体 **text**
|
||||
const boldRegex = /\*\*([^*]+)\*\*/g;
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
|
||||
while ((match = boldRegex.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
// 处理前面的普通文本(可能包含斜体)
|
||||
parts.push(...formatItalic(text.slice(lastIndex, match.index), key));
|
||||
key += 5;
|
||||
}
|
||||
parts.push(<strong key={key++}>{match[1]}</strong>);
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(...formatItalic(text.slice(lastIndex), key));
|
||||
}
|
||||
|
||||
if (parts.length === 0) {
|
||||
parts.push(...formatItalic(text, key));
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理斜体
|
||||
*/
|
||||
function formatItalic(text: string, startKey: number): React.ReactNode[] {
|
||||
const parts: React.ReactNode[] = [];
|
||||
let key = startKey;
|
||||
|
||||
// 处理斜体 *text*(但不是 **)
|
||||
const italicRegex = /(?<!\*)\*([^*]+)\*(?!\*)/g;
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
|
||||
while ((match = italicRegex.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(<span key={key++}>{text.slice(lastIndex, match.index)}</span>);
|
||||
}
|
||||
parts.push(<em key={key++}>{match[1]}</em>);
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(<span key={key++}>{text.slice(lastIndex)}</span>);
|
||||
}
|
||||
|
||||
if (parts.length === 0) {
|
||||
parts.push(<span key={key++}>{text}</span>);
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
export const MarkdownContent: React.FC<MarkdownContentProps> = ({
|
||||
content,
|
||||
className = ''
|
||||
}) => {
|
||||
const elements = parseMarkdown(content);
|
||||
|
||||
return (
|
||||
<div className={`markdown-content ${className}`}>
|
||||
{elements}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MarkdownContent;
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
interface StageCardProps {
|
||||
stage: StageInfo;
|
||||
index: number;
|
||||
onEdit?: () => void;
|
||||
}
|
||||
|
||||
const STAGE_TITLES: Record<string, string> = {
|
||||
@@ -28,7 +29,7 @@ const STAGE_TITLES: Record<string, string> = {
|
||||
endpoints: '观察指标',
|
||||
};
|
||||
|
||||
export const StageCard: React.FC<StageCardProps> = ({ stage, index }) => {
|
||||
export const StageCard: React.FC<StageCardProps> = ({ stage, index, onEdit }) => {
|
||||
const { stageCode, status, data } = stage;
|
||||
const title = STAGE_TITLES[stageCode] || stage.stageName;
|
||||
const number = (index + 1).toString().padStart(2, '0');
|
||||
@@ -45,8 +46,19 @@ export const StageCard: React.FC<StageCardProps> = ({ stage, index }) => {
|
||||
<div className={cardClasses}>
|
||||
<div className="stage-header">
|
||||
<h3 className="stage-number">{number} {title}</h3>
|
||||
{status === 'completed' && <Check size={14} className="check-icon" />}
|
||||
{status === 'current' && <Loader2 size={14} className="loader-icon animate-spin" />}
|
||||
<div className="stage-actions">
|
||||
{status === 'completed' && onEdit && (
|
||||
<button
|
||||
className="edit-btn"
|
||||
onClick={onEdit}
|
||||
title="编辑"
|
||||
>
|
||||
<Edit2 size={12} />
|
||||
</button>
|
||||
)}
|
||||
{status === 'completed' && <Check size={14} className="check-icon" />}
|
||||
{status === 'current' && <Loader2 size={14} className="loader-icon animate-spin" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data && <StageDataRenderer stageCode={stageCode} data={data} />}
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* Stage Edit Modal - 阶段数据编辑弹窗
|
||||
*
|
||||
* 支持用户编辑已同步的阶段数据
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, Save } from 'lucide-react';
|
||||
import type { StageInfo } from '../types';
|
||||
|
||||
interface StageEditModalProps {
|
||||
stage: StageInfo;
|
||||
onSave: (stageCode: string, data: Record<string, unknown>) => Promise<void>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const STAGE_TITLES: Record<string, string> = {
|
||||
scientific_question: '科学问题',
|
||||
pico: 'PICO要素',
|
||||
study_design: '研究设计',
|
||||
sample_size: '样本量',
|
||||
endpoints: '观察指标',
|
||||
};
|
||||
|
||||
export const StageEditModal: React.FC<StageEditModalProps> = ({ stage, onSave, onClose }) => {
|
||||
const [formData, setFormData] = useState<Record<string, any>>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (stage.data) {
|
||||
setFormData({ ...stage.data as Record<string, any> });
|
||||
}
|
||||
}, [stage]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave(stage.stageCode, formData);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderFormFields = () => {
|
||||
switch (stage.stageCode) {
|
||||
case 'scientific_question':
|
||||
return (
|
||||
<div className="form-group">
|
||||
<label>科学问题(一句话)</label>
|
||||
<textarea
|
||||
value={formData.content || ''}
|
||||
onChange={(e) => setFormData({ ...formData, content: e.target.value })}
|
||||
rows={3}
|
||||
placeholder="请输入凝练后的科学问题..."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'pico':
|
||||
return (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label>P - 研究人群</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.population || ''}
|
||||
onChange={(e) => setFormData({ ...formData, population: e.target.value })}
|
||||
placeholder="研究人群..."
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>I - 干预措施</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.intervention || ''}
|
||||
onChange={(e) => setFormData({ ...formData, intervention: e.target.value })}
|
||||
placeholder="干预措施..."
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>C - 对照</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.comparison || ''}
|
||||
onChange={(e) => setFormData({ ...formData, comparison: e.target.value })}
|
||||
placeholder="对照组..."
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>O - 结局</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.outcome || ''}
|
||||
onChange={(e) => setFormData({ ...formData, outcome: e.target.value })}
|
||||
placeholder="结局指标..."
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
case 'study_design':
|
||||
return (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label>研究类型</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.studyType || ''}
|
||||
onChange={(e) => setFormData({ ...formData, studyType: e.target.value })}
|
||||
placeholder="如:随机对照试验、队列研究..."
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>设计特征(逗号分隔)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={Array.isArray(formData.design) ? formData.design.join(', ') : ''}
|
||||
onChange={(e) => setFormData({
|
||||
...formData,
|
||||
design: e.target.value.split(',').map(s => s.trim()).filter(Boolean)
|
||||
})}
|
||||
placeholder="如:双盲, 平行对照..."
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
case 'sample_size':
|
||||
return (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label>总样本量</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.sampleSize || ''}
|
||||
onChange={(e) => setFormData({ ...formData, sampleSize: parseInt(e.target.value) || 0 })}
|
||||
placeholder="样本量数字..."
|
||||
/>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group half">
|
||||
<label>α (显著性水平)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={formData.calculation?.alpha || ''}
|
||||
onChange={(e) => setFormData({
|
||||
...formData,
|
||||
calculation: { ...formData.calculation, alpha: parseFloat(e.target.value) || 0.05 }
|
||||
})}
|
||||
placeholder="0.05"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group half">
|
||||
<label>Power (检验效能)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={formData.calculation?.power || ''}
|
||||
onChange={(e) => setFormData({
|
||||
...formData,
|
||||
calculation: { ...formData.calculation, power: parseFloat(e.target.value) || 0.8 }
|
||||
})}
|
||||
placeholder="0.80"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
case 'endpoints':
|
||||
return (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label>主要结局指标(逗号分隔)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.outcomes?.primary?.join(', ') || ''}
|
||||
onChange={(e) => setFormData({
|
||||
...formData,
|
||||
outcomes: {
|
||||
...formData.outcomes,
|
||||
primary: e.target.value.split(',').map(s => s.trim()).filter(Boolean)
|
||||
}
|
||||
})}
|
||||
placeholder="如:死亡率, 住院时间..."
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>次要结局指标(逗号分隔)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.outcomes?.secondary?.join(', ') || ''}
|
||||
onChange={(e) => setFormData({
|
||||
...formData,
|
||||
outcomes: {
|
||||
...formData.outcomes,
|
||||
secondary: e.target.value.split(',').map(s => s.trim()).filter(Boolean)
|
||||
}
|
||||
})}
|
||||
placeholder="如:生活质量, 并发症..."
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>混杂因素(逗号分隔)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.confounders?.join(', ') || ''}
|
||||
onChange={(e) => setFormData({
|
||||
...formData,
|
||||
confounders: e.target.value.split(',').map(s => s.trim()).filter(Boolean)
|
||||
})}
|
||||
placeholder="如:年龄, 性别, 基础疾病..."
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<div className="form-group">
|
||||
<label>数据 (JSON)</label>
|
||||
<textarea
|
||||
value={JSON.stringify(formData, null, 2)}
|
||||
onChange={(e) => {
|
||||
try {
|
||||
setFormData(JSON.parse(e.target.value));
|
||||
} catch {}
|
||||
}}
|
||||
rows={6}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="stage-edit-modal-overlay" onClick={onClose}>
|
||||
<div className="stage-edit-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h3>编辑 {STAGE_TITLES[stage.stageCode] || stage.stageName}</h3>
|
||||
<button className="close-btn" onClick={onClose}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="modal-body">
|
||||
{renderFormFields()}
|
||||
</div>
|
||||
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="cancel-btn" onClick={onClose}>
|
||||
取消
|
||||
</button>
|
||||
<button type="submit" className="save-btn" disabled={saving}>
|
||||
{saving ? '保存中...' : <><Save size={14} /> 保存</>}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,18 +2,33 @@
|
||||
* State Panel - 方案状态面板
|
||||
*
|
||||
* 100%还原原型图右侧状态面板设计
|
||||
* 支持编辑已同步的数据
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { FileText, Check, Loader2 } from 'lucide-react';
|
||||
import type { ProtocolContext, StageInfo } from '../types';
|
||||
import { StageCard } from './StageCard';
|
||||
import { StageEditModal } from './StageEditModal';
|
||||
|
||||
interface StatePanelProps {
|
||||
context: ProtocolContext | null;
|
||||
onStageUpdate?: (stageCode: string, data: Record<string, unknown>) => Promise<void>;
|
||||
}
|
||||
|
||||
export const StatePanel: React.FC<StatePanelProps> = ({ context }) => {
|
||||
export const StatePanel: React.FC<StatePanelProps> = ({ context, onStageUpdate }) => {
|
||||
const [editingStage, setEditingStage] = useState<StageInfo | null>(null);
|
||||
|
||||
const handleEdit = (stage: StageInfo) => {
|
||||
setEditingStage(stage);
|
||||
};
|
||||
|
||||
const handleSaveEdit = async (stageCode: string, newData: Record<string, unknown>) => {
|
||||
if (onStageUpdate) {
|
||||
await onStageUpdate(stageCode, newData);
|
||||
}
|
||||
setEditingStage(null);
|
||||
};
|
||||
if (!context) {
|
||||
return (
|
||||
<aside className="state-panel">
|
||||
@@ -67,9 +82,19 @@ export const StatePanel: React.FC<StatePanelProps> = ({ context }) => {
|
||||
key={stage.stageCode}
|
||||
stage={stage}
|
||||
index={index}
|
||||
onEdit={stage.status === 'completed' ? () => handleEdit(stage) : undefined}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 编辑弹窗 */}
|
||||
{editingStage && (
|
||||
<StageEditModal
|
||||
stage={editingStage}
|
||||
onSave={handleSaveEdit}
|
||||
onClose={() => setEditingStage(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 一键生成按钮 */}
|
||||
{canGenerate && (
|
||||
<div className="generate-section">
|
||||
|
||||
@@ -35,8 +35,20 @@ export function useProtocolContext(conversationId?: string) {
|
||||
});
|
||||
|
||||
if (response.status === 404) {
|
||||
// 上下文不存在,返回空
|
||||
setContext(null);
|
||||
// 上下文不存在,返回默认上下文(允许用户开始对话)
|
||||
setContext({
|
||||
currentStage: 'scientific_question',
|
||||
stageName: '科学问题梳理',
|
||||
progress: 0,
|
||||
stages: [
|
||||
{ stageCode: 'scientific_question', stageName: '科学问题梳理', status: 'current', data: null },
|
||||
{ stageCode: 'pico', stageName: 'PICO要素', status: 'pending', data: null },
|
||||
{ stageCode: 'study_design', stageName: '研究设计', status: 'pending', data: null },
|
||||
{ stageCode: 'sample_size', stageName: '样本量计算', status: 'pending', data: null },
|
||||
{ stageCode: 'endpoints', stageName: '观察指标', status: 'pending', data: null },
|
||||
],
|
||||
canGenerate: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -57,10 +69,10 @@ export function useProtocolContext(conversationId?: string) {
|
||||
}, [conversationId]);
|
||||
|
||||
/**
|
||||
* 刷新上下文
|
||||
* 刷新上下文(返回 Promise 以便等待完成)
|
||||
*/
|
||||
const refreshContext = useCallback(() => {
|
||||
fetchContext();
|
||||
const refreshContext = useCallback(async () => {
|
||||
await fetchContext();
|
||||
}, [fetchContext]);
|
||||
|
||||
// 初次加载和conversationId变化时获取上下文
|
||||
|
||||
@@ -6,11 +6,16 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Noto+Sans+SC:wght@300;400;500;700&display=swap');
|
||||
|
||||
/* ============================================ */
|
||||
/* 全局样式 */
|
||||
/* 全局样式 - 全屏覆盖(与ChatWorkspace一致) */
|
||||
/* ============================================ */
|
||||
.protocol-agent-page {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
font-family: 'Inter', 'Noto Sans SC', sans-serif;
|
||||
background: #FFFFFF;
|
||||
@@ -445,8 +450,252 @@
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
line-height: 1.8;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Markdown 格式支持 */
|
||||
.chat-bubble h1,
|
||||
.chat-bubble h2,
|
||||
.chat-bubble h3 {
|
||||
margin: 16px 0 8px 0;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.chat-bubble h1 { font-size: 1.25em; }
|
||||
.chat-bubble h2 { font-size: 1.15em; }
|
||||
.chat-bubble h3 { font-size: 1.05em; }
|
||||
|
||||
.chat-bubble p {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.chat-bubble ul,
|
||||
.chat-bubble ol {
|
||||
margin: 8px 0;
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
.chat-bubble li {
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.chat-bubble strong,
|
||||
.chat-bubble b {
|
||||
font-weight: 700;
|
||||
color: #1F2937;
|
||||
}
|
||||
|
||||
.chat-bubble em,
|
||||
.chat-bubble i {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.chat-bubble code {
|
||||
background: #F3F4F6;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Monaco', 'Consolas', monospace;
|
||||
font-size: 0.9em;
|
||||
color: #6366F1;
|
||||
}
|
||||
|
||||
.chat-bubble pre {
|
||||
background: #1F2937;
|
||||
color: #F9FAFB;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.chat-bubble pre code {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.chat-bubble blockquote {
|
||||
border-left: 4px solid #6366F1;
|
||||
margin: 12px 0;
|
||||
padding: 8px 16px;
|
||||
background: #F9FAFB;
|
||||
color: #4B5563;
|
||||
}
|
||||
|
||||
/* MarkdownContent 组件样式 */
|
||||
.markdown-content {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.markdown-content p {
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
|
||||
.markdown-content p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.markdown-content h1,
|
||||
.markdown-content h2,
|
||||
.markdown-content h3 {
|
||||
margin: 16px 0 8px 0;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.markdown-content h1:first-child,
|
||||
.markdown-content h2:first-child,
|
||||
.markdown-content h3:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.markdown-content ul,
|
||||
.markdown-content ol {
|
||||
margin: 8px 0 12px 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.markdown-content li {
|
||||
margin: 6px 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.markdown-content li::marker {
|
||||
color: #6366F1;
|
||||
}
|
||||
|
||||
.markdown-content strong {
|
||||
font-weight: 700;
|
||||
color: #1F2937;
|
||||
}
|
||||
|
||||
.markdown-content em {
|
||||
font-style: italic;
|
||||
color: #4B5563;
|
||||
}
|
||||
|
||||
.markdown-content code {
|
||||
background: rgba(99, 102, 241, 0.1);
|
||||
color: #6366F1;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Monaco', 'Consolas', 'Courier New', monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/* ============================================ */
|
||||
/* 阶段标签 */
|
||||
/* ============================================ */
|
||||
|
||||
.message-meta .stage-tag {
|
||||
display: inline-block;
|
||||
background: linear-gradient(135deg, #6366F1, #8B5CF6);
|
||||
color: white;
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
margin-left: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.message-meta .timestamp {
|
||||
color: #9CA3AF;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
/* ============================================ */
|
||||
/* 动作卡片 */
|
||||
/* ============================================ */
|
||||
|
||||
.action-cards {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.action-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 14px 18px;
|
||||
background: linear-gradient(135deg, #F8FAFC, #F1F5F9);
|
||||
border: 1px solid #E2E8F0;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
min-width: 200px;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.action-card:hover {
|
||||
background: linear-gradient(135deg, #EEF2FF, #E0E7FF);
|
||||
border-color: #6366F1;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.15);
|
||||
}
|
||||
|
||||
.action-card-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1F2937;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.action-card-desc {
|
||||
font-size: 12px;
|
||||
color: #6B7280;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.action-card-icon {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
right: 14px;
|
||||
color: #9CA3AF;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.action-card:hover .action-card-icon {
|
||||
color: #6366F1;
|
||||
}
|
||||
|
||||
/* ============================================ */
|
||||
/* 加载状态 */
|
||||
/* ============================================ */
|
||||
|
||||
.chat-bubble.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: #6B7280;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* 流式输出光标 */
|
||||
.streaming-cursor {
|
||||
display: inline-block;
|
||||
animation: blink 0.8s step-end infinite;
|
||||
color: #6366F1;
|
||||
margin-left: 2px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
.assistant-bubble {
|
||||
@@ -1190,6 +1439,201 @@
|
||||
background: #94A3B8;
|
||||
}
|
||||
|
||||
/* ============================================ */
|
||||
/* 阶段编辑弹窗 */
|
||||
/* ============================================ */
|
||||
.stage-edit-modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 200;
|
||||
}
|
||||
|
||||
.stage-edit-modal {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
width: 480px;
|
||||
max-width: 90%;
|
||||
max-height: 80vh;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.stage-edit-modal .modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #E5E7EB;
|
||||
background: #F9FAFB;
|
||||
}
|
||||
|
||||
.stage-edit-modal .modal-header h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.stage-edit-modal .close-btn {
|
||||
padding: 4px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
color: #6B7280;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.stage-edit-modal .close-btn:hover {
|
||||
background: #E5E7EB;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.stage-edit-modal .modal-body {
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
max-height: 50vh;
|
||||
}
|
||||
|
||||
.stage-edit-modal .form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.stage-edit-modal .form-group label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.stage-edit-modal .form-group input,
|
||||
.stage-edit-modal .form-group textarea {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #D1D5DB;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
color: #111827;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.stage-edit-modal .form-group input:focus,
|
||||
.stage-edit-modal .form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: #3B82F6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.stage-edit-modal .form-group textarea {
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.stage-edit-modal .form-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stage-edit-modal .form-group.half {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stage-edit-modal .modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid #E5E7EB;
|
||||
background: #F9FAFB;
|
||||
}
|
||||
|
||||
.stage-edit-modal .cancel-btn {
|
||||
padding: 8px 16px;
|
||||
background: white;
|
||||
border: 1px solid #D1D5DB;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
color: #374151;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.stage-edit-modal .cancel-btn:hover {
|
||||
background: #F3F4F6;
|
||||
}
|
||||
|
||||
.stage-edit-modal .save-btn {
|
||||
padding: 8px 16px;
|
||||
background: #3B82F6;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.stage-edit-modal .save-btn:hover:not(:disabled) {
|
||||
background: #2563EB;
|
||||
}
|
||||
|
||||
.stage-edit-modal .save-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 编辑按钮样式 */
|
||||
.stage-header .stage-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.stage-header .edit-btn {
|
||||
padding: 4px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: #9CA3AF;
|
||||
transition: all 0.2s;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.stage-card:hover .edit-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.stage-header .edit-btn:hover {
|
||||
background: #E5E7EB;
|
||||
color: #3B82F6;
|
||||
}
|
||||
|
||||
/* 流式输出光标 */
|
||||
.streaming-cursor {
|
||||
display: inline-block;
|
||||
animation: blink 1s step-end infinite;
|
||||
color: #3B82F6;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 50% { opacity: 1; }
|
||||
51%, 100% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* ============================================ */
|
||||
/* 响应式 */
|
||||
/* ============================================ */
|
||||
|
||||
@@ -110,10 +110,10 @@ export interface ProtocolContext {
|
||||
* 同步按钮数据
|
||||
*/
|
||||
export interface SyncButtonData {
|
||||
stageCode: ProtocolStageCode;
|
||||
stageCode: ProtocolStageCode | string;
|
||||
extractedData: Record<string, unknown>;
|
||||
label: string;
|
||||
disabled: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user