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

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