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:
2026-01-24 23:06:33 +08:00
parent 596f2dfc02
commit 4d7d97ca19
18 changed files with 2708 additions and 192 deletions

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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'
});
}

View File

@@ -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;

View File

@@ -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} />}

View File

@@ -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>
);
};

View File

@@ -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">

View File

@@ -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变化时获取上下文

View File

@@ -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; }
}
/* ============================================ */
/* 响应式 */
/* ============================================ */

View File

@@ -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;
}
/**