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:
@@ -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'
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user