From 4d7d97ca19e8b3f567d1e2b6b905d6e07126447c Mon Sep 17 00:00:00 2001 From: HaHafeng Date: Sat, 24 Jan 2026 23:06:33 +0800 Subject: [PATCH] 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 --- backend/prisma/seed-protocol-agent.ts | 390 +++++++++++++++ backend/prisma/seed.ts | 1 + .../controllers/ProtocolAgentController.ts | 253 +++++++++- .../protocol/services/ProtocolOrchestrator.ts | 150 +++++- .../src/modules/aia/services/agentService.ts | 16 +- .../04-开发计划/05-一键生成研究方案开发计划.md | 351 ++++++++++++++ .../04-开发计划/Novel_vs_BlockNote_深度对比分析.md | 150 ++++++ .../04-开发计划/编辑器选型深度评估与落地建议.md | 118 +++++ frontend-v2/src/modules/aia/index.tsx | 18 +- .../aia/protocol-agent/ProtocolAgentPage.tsx | 59 ++- .../protocol-agent/components/ChatArea.tsx | 383 ++++++++++----- .../components/MarkdownContent.tsx | 222 +++++++++ .../protocol-agent/components/StageCard.tsx | 18 +- .../components/StageEditModal.tsx | 266 +++++++++++ .../protocol-agent/components/StatePanel.tsx | 29 +- .../hooks/useProtocolContext.ts | 22 +- .../protocol-agent/styles/protocol-agent.css | 450 +++++++++++++++++- .../src/modules/aia/protocol-agent/types.ts | 4 +- 18 files changed, 2708 insertions(+), 192 deletions(-) create mode 100644 backend/prisma/seed-protocol-agent.ts create mode 100644 docs/03-业务模块/AIA-AI智能问答/04-开发计划/05-一键生成研究方案开发计划.md create mode 100644 docs/03-业务模块/AIA-AI智能问答/04-开发计划/Novel_vs_BlockNote_深度对比分析.md create mode 100644 docs/03-业务模块/AIA-AI智能问答/04-开发计划/编辑器选型深度评估与落地建议.md create mode 100644 frontend-v2/src/modules/aia/protocol-agent/components/MarkdownContent.tsx create mode 100644 frontend-v2/src/modules/aia/protocol-agent/components/StageEditModal.tsx diff --git a/backend/prisma/seed-protocol-agent.ts b/backend/prisma/seed-protocol-agent.ts new file mode 100644 index 00000000..70855050 --- /dev/null +++ b/backend/prisma/seed-protocol-agent.ts @@ -0,0 +1,390 @@ +/** + * Protocol Agent 独立 Seed 脚本 + * + * 只初始化 Protocol Agent 相关的配置数据,不影响其他模块 + * + * 运行方式: + * npx ts-node prisma/seed-protocol-agent.ts + * 或者: + * npx tsx prisma/seed-protocol-agent.ts + */ + +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +async function seedProtocolAgent() { + console.log('🌱 开始初始化 Protocol Agent 配置...\n'); + + try { + // ============================================ + // 1. 创建 Agent 定义 + // ============================================ + console.log('📌 创建 Agent 定义...'); + + const protocolAgent = await prisma.agentDefinition.upsert({ + where: { code: 'protocol_agent' }, + update: { + name: '全流程研究方案制定', + description: '引导用户完成5个核心阶段(科学问题、PICO、研究设计、样本量、观察指标),最终一键生成研究方案', + version: '1.0.0', + config: { + defaultModel: 'deepseek-v3', + maxTurns: 100, + timeout: 120000, + enableTrace: true, + enableReflexion: true, + }, + isActive: true, + }, + create: { + code: 'protocol_agent', + name: '全流程研究方案制定', + description: '引导用户完成5个核心阶段(科学问题、PICO、研究设计、样本量、观察指标),最终一键生成研究方案', + version: '1.0.0', + config: { + defaultModel: 'deepseek-v3', + maxTurns: 100, + timeout: 120000, + enableTrace: true, + enableReflexion: true, + }, + isActive: true, + }, + }); + console.log(` ✅ Agent 定义创建成功: ${protocolAgent.name} (ID: ${protocolAgent.id})`); + + // ============================================ + // 2. 创建 5 个阶段 + // ============================================ + console.log('📌 创建 5 个阶段配置...'); + + const stages = [ + { + stageCode: 'scientific_question', + stageName: '科学问题梳理', + sortOrder: 1, + isInitial: true, + isFinal: false, + nextStages: ['pico'] + }, + { + stageCode: 'pico', + stageName: 'PICO要素', + sortOrder: 2, + isInitial: false, + isFinal: false, + nextStages: ['study_design'] + }, + { + stageCode: 'study_design', + stageName: '研究设计', + sortOrder: 3, + isInitial: false, + isFinal: false, + nextStages: ['sample_size'] + }, + { + stageCode: 'sample_size', + stageName: '样本量计算', + sortOrder: 4, + isInitial: false, + isFinal: false, + nextStages: ['endpoints'] + }, + { + stageCode: 'endpoints', + stageName: '观察指标', + sortOrder: 5, + isInitial: false, + isFinal: true, + nextStages: [] + }, + ]; + + const stageMap = new Map(); + + for (const stage of stages) { + const created = await prisma.agentStage.upsert({ + where: { + agentId_stageCode: { + agentId: protocolAgent.id, + stageCode: stage.stageCode + } + }, + update: { + stageName: stage.stageName, + sortOrder: stage.sortOrder, + isInitial: stage.isInitial, + isFinal: stage.isFinal, + nextStages: stage.nextStages, + }, + create: { + agentId: protocolAgent.id, + stageCode: stage.stageCode, + stageName: stage.stageName, + sortOrder: stage.sortOrder, + isInitial: stage.isInitial, + isFinal: stage.isFinal, + nextStages: stage.nextStages, + }, + }); + stageMap.set(stage.stageCode, created.id); + console.log(` ✅ 阶段 ${stage.sortOrder}: ${stage.stageName}`); + } + + // ============================================ + // 3. 创建系统 Prompt + // ============================================ + console.log('📌 创建系统 Prompt...'); + + const systemPromptContent = `你是一位资深的临床研究方法学专家,正在帮助医生设计临床研究方案。 + +## 你的角色 +- 你是一位友好、专业的研究方案设计顾问 +- 你会引导用户一步步完成研究方案的核心要素 +- 你善于提问,帮助用户理清思路 + +## 当前工作流程 +你将引导用户完成5个核心阶段: +1. 科学问题梳理 - 明确研究要解决的核心问题 +2. PICO要素 - 确定研究人群(P)、干预(I)、对照(C)和结局(O) +3. 研究设计 - 选择合适的研究类型和方法 +4. 样本量计算 - 估算所需的样本量 +5. 观察指标 - 定义基线、暴露、结局指标和混杂因素 + +## 输出要求 +1. 回复要简洁、专业、有针对性 +2. 每次只关注当前阶段的问题 +3. 当用户提供了足够信息后,整理成结构化内容 +4. 整理完成后,在回复末尾输出提取的数据(用于同步到方案) + +## 数据提取格式 +当你认为当前阶段的信息已经收集完整,请在回复末尾添加: + +{ + "字段1": "值1", + "字段2": "值2" +} + + +注意:只有在信息收集完整时才输出 extracted_data 标签。`; + + await prisma.agentPrompt.upsert({ + where: { + agentId_promptCode_version: { + agentId: protocolAgent.id, + promptCode: 'system', + version: 1 + } + }, + update: { + content: systemPromptContent, + isActive: true, + }, + create: { + agentId: protocolAgent.id, + promptType: 'system', + promptCode: 'system', + content: systemPromptContent, + variables: ['context', 'intent'], + version: 1, + isActive: true, + }, + }); + console.log(` ✅ 系统 Prompt 创建成功`); + + // ============================================ + // 4. 创建各阶段 Prompt + // ============================================ + console.log('📌 创建各阶段 Prompt...'); + + const stagePrompts = [ + { + stageCode: 'scientific_question', + content: `## 当前阶段:科学问题梳理 + +### 目标 +帮助用户明确研究要解决的核心科学问题。 + +### 引导方向 +1. 了解用户的研究背景和动机 +2. 明确研究的核心问题是什么 +3. 确认问题的科学价值和临床意义 + +### 提问示例 +- "您想研究什么问题?" +- "这个研究的临床背景是什么?" +- "您希望通过这个研究解决什么问题?" + +### 数据提取 +当信息完整时,提取以下字段: +- content: 科学问题的完整描述 +- background: 研究背景 +- significance: 研究意义`, + }, + { + stageCode: 'pico', + content: `## 当前阶段:PICO要素 + +### 目标 +帮助用户确定PICO四要素。 + +### PICO定义 +- P (Population): 研究人群 - 谁是研究对象? +- I (Intervention): 干预措施 - 研究什么干预/暴露? +- C (Comparison): 对照 - 与什么比较? +- O (Outcome): 结局 - 关注什么结果? + +### 引导方向 +1. 逐一确认每个要素 +2. 确保定义清晰、可操作 + +### 数据提取 +当信息完整时,提取以下字段: +- population: 研究人群描述 +- intervention: 干预措施描述 +- comparison: 对照描述 +- outcome: 结局指标描述`, + }, + { + stageCode: 'study_design', + content: `## 当前阶段:研究设计 + +### 目标 +帮助用户选择合适的研究设计类型。 + +### 常见研究类型 +- 随机对照试验 (RCT) +- 队列研究 (Cohort) +- 病例对照研究 (Case-Control) +- 横断面研究 (Cross-sectional) +- 前后对照研究 (Before-After) + +### 引导方向 +1. 根据研究问题推荐合适的设计类型 +2. 讨论设计的优缺点 +3. 确定关键设计要素(如盲法、随机化方法等) + +### 数据提取 +当信息完整时,提取以下字段: +- studyType: 研究类型 +- design: 设计要素列表 +- features: 特殊设计特征`, + }, + { + stageCode: 'sample_size', + content: `## 当前阶段:样本量计算 + +### 目标 +帮助用户估算所需的样本量。 + +### 关键参数 +- α (显著性水平): 通常0.05 +- β (检验效能): 通常0.80或0.90 +- 效应量: 预期的效果大小 +- 脱落率: 预计的失访比例 + +### 引导方向 +1. 了解主要结局指标的类型 +2. 讨论预期的效应量 +3. 考虑脱落率 + +### 数据提取 +当信息完整时,提取以下字段: +- sampleSize: 计算的样本量 +- alpha: 显著性水平 +- power: 检验效能 +- effectSize: 效应量 +- dropoutRate: 脱落率`, + }, + { + stageCode: 'endpoints', + content: `## 当前阶段:观察指标 + +### 目标 +帮助用户定义完整的观察指标体系。 + +### 指标分类 +1. 基线指标 - 人口学特征、病史、实验室检查 +2. 暴露/干预指标 - 干预措施的具体内容 +3. 结局指标 - 主要结局、次要结局、安全性指标 +4. 混杂因素 - 需要控制的混杂变量 + +### 引导方向 +1. 逐类确认各项指标 +2. 确保指标定义清晰、可测量 +3. 确定测量时点 + +### 数据提取 +当信息完整时,提取以下字段: +- baseline: 基线指标列表 +- exposure: 暴露/干预指标 +- outcomes: 结局指标(primary, secondary, safety) +- confounders: 混杂因素列表`, + }, + ]; + + for (const prompt of stagePrompts) { + const stageId = stageMap.get(prompt.stageCode); + await prisma.agentPrompt.upsert({ + where: { + agentId_promptCode_version: { + agentId: protocolAgent.id, + promptCode: `stage_${prompt.stageCode}`, + version: 1 + } + }, + update: { + stageId: stageId, + content: prompt.content, + isActive: true, + }, + create: { + agentId: protocolAgent.id, + stageId: stageId, + promptType: 'stage', + promptCode: `stage_${prompt.stageCode}`, + content: prompt.content, + variables: ['context'], + version: 1, + isActive: true, + }, + }); + console.log(` ✅ 阶段 Prompt: ${prompt.stageCode}`); + } + + // ============================================ + // 完成 + // ============================================ + console.log('\n🎉 Protocol Agent 配置初始化完成!\n'); + console.log('╔════════════════════════════════════════════════════════════╗'); + console.log('║ Protocol Agent 配置摘要 ║'); + console.log('╠════════════════════════════════════════════════════════════╣'); + console.log(`║ Agent ID: ${protocolAgent.id} ║`); + console.log('║ Agent Code: protocol_agent ║'); + console.log('║ 阶段数量: 5 ║'); + console.log('║ Prompt 数量: 6 (1 系统 + 5 阶段) ║'); + console.log('╠════════════════════════════════════════════════════════════╣'); + console.log('║ 阶段流程: ║'); + console.log('║ 1. 科学问题梳理 → 2. PICO要素 → 3. 研究设计 ║'); + console.log('║ → 4. 样本量计算 → 5. 观察指标 → 一键生成 ║'); + console.log('╚════════════════════════════════════════════════════════════╝'); + + } catch (error) { + console.error('❌ Protocol Agent 初始化失败:', error); + throw error; + } +} + +// 执行 +seedProtocolAgent() + .then(async () => { + await prisma.$disconnect(); + }) + .catch(async (e) => { + console.error(e); + await prisma.$disconnect(); + process.exit(1); + }); + diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts index 4b7cbc71..34f506a8 100644 --- a/backend/prisma/seed.ts +++ b/backend/prisma/seed.ts @@ -395,6 +395,7 @@ async function main() { // 11. 跳过Prompt模板(表尚未创建) // ============================================ console.log('📌 跳过Prompt模板创建(capability_schema.prompt_templates 尚未创建)'); + console.log('💡 提示:Protocol Agent 配置请运行独立脚本: npx tsx prisma/seed-protocol-agent.ts'); // ============================================ // 12. 创建租户模块订阅 diff --git a/backend/src/modules/agent/protocol/controllers/ProtocolAgentController.ts b/backend/src/modules/agent/protocol/controllers/ProtocolAgentController.ts index 1c5517e0..05408e7e 100644 --- a/backend/src/modules/agent/protocol/controllers/ProtocolAgentController.ts +++ b/backend/src/modules/agent/protocol/controllers/ProtocolAgentController.ts @@ -10,6 +10,9 @@ import { PrismaClient } from '@prisma/client'; import { ProtocolOrchestrator } from '../services/ProtocolOrchestrator.js'; import { LLMServiceInterface } from '../../services/BaseAgentOrchestrator.js'; import { ProtocolStageCode } from '../../types/index.js'; +import { createStreamingService } from '../../../../common/streaming/index.js'; +import type { OpenAIMessage } from '../../../../common/streaming/index.js'; +import { logger } from '../../../../common/logging/index.js'; // 请求类型定义 interface SendMessageBody { @@ -38,46 +41,95 @@ interface GetContextParams { export class ProtocolAgentController { private orchestrator: ProtocolOrchestrator; + private prisma: PrismaClient; constructor(prisma: PrismaClient, llmService: LLMServiceInterface) { + this.prisma = prisma; this.orchestrator = new ProtocolOrchestrator({ prisma, llmService }); } /** - * 发送消息 + * 发送消息(流式输出) * POST /api/aia/protocol-agent/message + * + * 使用通用 StreamingService 实现打字机效果 */ async sendMessage( request: FastifyRequest<{ Body: SendMessageBody }>, reply: FastifyReply ): Promise { + const { conversationId, content } = request.body; + const userId = (request as any).user?.userId; + + if (!userId) { + reply.code(401).send({ error: 'Unauthorized' }); + return; + } + + if (!conversationId || !content) { + reply.code(400).send({ error: 'Missing required fields: conversationId, content' }); + return; + } + try { - const { conversationId, content, messageId } = request.body; - const userId = (request as any).user?.userId; + // 1. 确保上下文存在 + const contextService = this.orchestrator.getContextService(); + const context = await contextService.getOrCreateContext(conversationId, userId); + + // 2. 构建包含上下文的消息 + const messages = await this.buildMessagesWithContext(conversationId, content, context); + + // 3. 保存用户消息到数据库 + await this.prisma.message.create({ + data: { + conversationId, + role: 'user', + content, + }, + }); - if (!userId) { - reply.code(401).send({ error: 'Unauthorized' }); - return; - } - - if (!conversationId || !content) { - reply.code(400).send({ error: 'Missing required fields: conversationId, content' }); - return; - } - - const response = await this.orchestrator.handleMessage({ - conversationId, + // 4. 使用通用 StreamingService 流式输出 + const streamingService = createStreamingService(reply, { + model: 'deepseek-v3', + temperature: 0.7, + maxTokens: 4096, + enableDeepThinking: false, userId, - content, - messageId, + conversationId, }); - reply.send({ - success: true, - data: response, + await streamingService.streamGenerate(messages, { + onComplete: async (fullContent, thinkingContent) => { + // 5. 保存 AI 回复到数据库 + await this.prisma.message.create({ + data: { + conversationId, + role: 'assistant', + content: fullContent, + thinkingContent: thinkingContent || null, + model: 'deepseek-v3', + }, + }); + + // 6. 更新对话时间 + await this.prisma.conversation.update({ + where: { id: conversationId }, + data: { updatedAt: new Date() }, + }); + + logger.info('[ProtocolAgent] 消息发送完成', { + conversationId, + stage: context.currentStage, + hasThinking: !!thinkingContent, + }); + }, + onError: (error) => { + logger.error('[ProtocolAgent] 流式生成失败', { error, conversationId }); + }, }); + } catch (error) { - console.error('[ProtocolAgentController] sendMessage error:', error); + logger.error('[ProtocolAgentController] sendMessage error:', error); reply.code(500).send({ success: false, error: error instanceof Error ? error.message : 'Internal server error', @@ -85,6 +137,165 @@ export class ProtocolAgentController { } } + /** + * 构建包含上下文的消息列表 + */ + private async buildMessagesWithContext( + conversationId: string, + userContent: string, + context: any + ): Promise { + // 获取历史消息 + const historyMessages = await this.prisma.message.findMany({ + where: { conversationId }, + orderBy: { createdAt: 'asc' }, + take: 20, + }); + + // 构建系统 Prompt(包含当前阶段和已完成的数据) + const systemPrompt = this.buildSystemPrompt(context); + + const messages: OpenAIMessage[] = [ + { role: 'system', content: systemPrompt }, + ]; + + // 添加历史消息 + for (const msg of historyMessages) { + messages.push({ + role: msg.role as 'user' | 'assistant', + content: msg.content, + }); + } + + // 添加当前用户消息 + messages.push({ role: 'user', content: userContent }); + + return messages; + } + + /** + * 构建系统 Prompt(包含上下文数据) + */ + private buildSystemPrompt(context: any): string { + const stageNames: Record = { + scientific_question: '科学问题梳理', + pico: 'PICO要素', + study_design: '研究设计', + sample_size: '样本量计算', + endpoints: '观察指标', + }; + + const currentStageName = stageNames[context.currentStage] || context.currentStage; + + // 构建已完成阶段的数据摘要(从 ProtocolContextData 各字段读取) + let completedDataSummary = ''; + const completedStages = context.completedStages || []; + + if (completedStages.includes('scientific_question') && context.scientificQuestion) { + completedDataSummary += `\n\n### 已确认的科学问题\n${JSON.stringify(context.scientificQuestion, null, 2)}`; + } + if (completedStages.includes('pico') && context.pico) { + completedDataSummary += `\n\n### 已确认的PICO要素\n${JSON.stringify(context.pico, null, 2)}`; + } + if (completedStages.includes('study_design') && context.studyDesign) { + completedDataSummary += `\n\n### 已确认的研究设计\n${JSON.stringify(context.studyDesign, null, 2)}`; + } + if (completedStages.includes('sample_size') && context.sampleSize) { + completedDataSummary += `\n\n### 已确认的样本量\n${JSON.stringify(context.sampleSize, null, 2)}`; + } + if (completedStages.includes('endpoints') && context.endpoints) { + completedDataSummary += `\n\n### 已确认的观察指标\n${JSON.stringify(context.endpoints, null, 2)}`; + } + + // 计算进度 + const progress = Math.round((completedStages.length / 5) * 100); + + // 获取当前阶段需要输出的字段格式 + const stageOutputFormat = this.getStageOutputFormat(context.currentStage); + + return `你是一位资深的临床研究方法学专家,正在帮助医生设计临床研究方案。 + +## 当前状态 +- **当前阶段**: ${currentStageName} +- **已完成阶段**: ${completedStages.map((s: string) => stageNames[s]).join(', ') || '无'} +- **进度**: ${progress}% + +## 已收集的数据${completedDataSummary || '\n暂无已确认的数据'} + +## 你的任务 +1. **只围绕「${currentStageName}」阶段与用户对话**,不要跨阶段讨论 +2. 引导用户提供当前阶段所需的完整信息 +3. 当信息收集完整时,先用文字总结,然后**必须**在回复末尾输出结构化数据 + +## 当前阶段「${currentStageName}」的输出格式 +当信息完整时,**必须**在回复末尾添加以下格式的数据提取标签: + +${stageOutputFormat} + +## 重要提示 +- 只有当用户提供了足够的信息后才输出 标签 +- 输出的 JSON 必须是有效格式 +- 每次对话只关注当前阶段「${currentStageName}」 +- 回复使用 Markdown 格式,简洁专业`; + } + + /** + * 获取不同阶段的输出格式说明 + */ + private getStageOutputFormat(stageCode: string): string { + const formats: Record = { + scientific_question: ` +{ + "content": "一句话科学问题(不超过50字)" +} +`, + + pico: ` +{ + "population": "研究人群(不超过20字)", + "intervention": "干预措施(不超过20字)", + "comparison": "对照组(不超过20字)", + "outcome": "结局指标(不超过20字)" +} +`, + + study_design: ` +{ + "studyType": "研究类型,如:回顾性队列研究、前瞻性队列研究、随机对照试验等", + "design": ["设计特征1", "设计特征2"] +} +`, + + sample_size: ` +{ + "sampleSize": 样本量数字, + "calculation": { + "alpha": 显著性水平(如0.05), + "power": 检验效能(如0.8), + "effectSize": "效应量描述" + } +} +`, + + endpoints: ` +{ + "outcomes": { + "primary": ["主要结局指标1", "主要结局指标2"], + "secondary": ["次要结局指标1"], + "safety": ["安全性指标"] + }, + "confounders": ["混杂因素1", "混杂因素2"] +} +`, + }; + + return formats[stageCode] || ` +{ + "key": "value" +} +`; + } + /** * 同步阶段数据 * POST /api/aia/protocol-agent/sync diff --git a/backend/src/modules/agent/protocol/services/ProtocolOrchestrator.ts b/backend/src/modules/agent/protocol/services/ProtocolOrchestrator.ts index 6b3b0c8c..c8ec0841 100644 --- a/backend/src/modules/agent/protocol/services/ProtocolOrchestrator.ts +++ b/backend/src/modules/agent/protocol/services/ProtocolOrchestrator.ts @@ -127,39 +127,63 @@ export class ProtocolOrchestrator extends BaseAgentOrchestrator { /** * 处理Protocol同步请求 + * 支持两种场景:1. 首次同步(从对话中提取)2. 编辑更新(用户手动修改) */ async handleProtocolSync( conversationId: string, userId: string, stageCode: string, - data: Record + data: Record, + isEdit: boolean = false // 是否是编辑更新 ): Promise<{ success: boolean; context: ProtocolContextData; nextStage?: ProtocolStageCode; message?: string; + condensedData?: Record; }> { const stage = stageCode as ProtocolStageCode; + // 获取当前上下文 + const existingContext = await this.contextService.getContext(conversationId); + const isAlreadyCompleted = existingContext?.completedStages.includes(stage); + + let finalData: Record; + + if (isEdit || isAlreadyCompleted) { + // 编辑模式:直接使用用户提供的数据,不再凝练 + finalData = data; + } else { + // 首次同步:使用 LLM 凝练数据 + finalData = await this.condenseStageData(stage, data); + } + // 保存阶段数据 await this.contextService.updateStageData(conversationId, stage, { - ...data, + ...finalData, confirmed: true, confirmedAt: new Date(), }); - // 获取下一阶段 - const currentIndex = STAGE_ORDER.indexOf(stage); - const nextStage = currentIndex < STAGE_ORDER.length - 1 - ? STAGE_ORDER[currentIndex + 1] - : undefined; + let context: ProtocolContextData; + let nextStage: ProtocolStageCode | undefined; - // 标记当前阶段完成,更新到下一阶段 - const context = await this.contextService.completeStage( - conversationId, - stage, - nextStage - ); + if (isAlreadyCompleted) { + // 已完成的阶段:只更新数据,不改变当前阶段 + context = await this.contextService.getContext(conversationId) as ProtocolContextData; + } else { + // 首次完成:标记完成并进入下一阶段 + const currentIndex = STAGE_ORDER.indexOf(stage); + nextStage = currentIndex < STAGE_ORDER.length - 1 + ? STAGE_ORDER[currentIndex + 1] + : undefined; + + context = await this.contextService.completeStage( + conversationId, + stage, + nextStage + ); + } // 检查是否所有阶段都已完成 const allCompleted = this.contextService.isAllStagesCompleted(context); @@ -168,14 +192,104 @@ export class ProtocolOrchestrator extends BaseAgentOrchestrator { success: true, context, nextStage, - message: allCompleted - ? '🎉 所有核心要素已完成!您可以点击「一键生成研究方案」生成完整方案。' - : nextStage - ? `已同步${STAGE_NAMES[stage]},进入${STAGE_NAMES[nextStage]}阶段` - : `已同步${STAGE_NAMES[stage]}`, + condensedData: finalData, + message: isAlreadyCompleted + ? `✅ 已更新「${STAGE_NAMES[stage]}」` + : allCompleted + ? '🎉 所有核心要素已完成!您可以点击「一键生成研究方案」生成完整方案。' + : nextStage + ? `已同步${STAGE_NAMES[stage]},进入${STAGE_NAMES[nextStage]}阶段` + : `已同步${STAGE_NAMES[stage]}`, }; } + /** + * 使用 LLM 凝练阶段数据 + */ + private async condenseStageData( + stageCode: ProtocolStageCode, + data: Record + ): Promise> { + // 构建凝练 Prompt + const condensePrompts: Record = { + scientific_question: `请将以下科学问题内容凝练成一句话(不超过50字),保留核心要点: + +原始内容: +${JSON.stringify(data, null, 2)} + +要求: +- 输出格式:{ "content": "一句话科学问题" } +- 只输出 JSON,不要其他内容`, + + pico: `请将以下 PICO 要素凝练成简短描述: + +原始内容: +${JSON.stringify(data, null, 2)} + +要求: +- 每个要素不超过20字 +- 输出格式:{ "population": "...", "intervention": "...", "comparison": "...", "outcome": "..." } +- 只输出 JSON,不要其他内容`, + + study_design: `请将以下研究设计凝练成关键标签: + +原始内容: +${JSON.stringify(data, null, 2)} + +要求: +- 输出格式:{ "studyType": "研究类型", "design": ["特征1", "特征2"] } +- 只输出 JSON,不要其他内容`, + + sample_size: `请提取样本量关键数据: + +原始内容: +${JSON.stringify(data, null, 2)} + +要求: +- 输出格式:{ "sampleSize": 数字, "calculation": { "alpha": 数字, "power": 数字 } } +- 只输出 JSON,不要其他内容`, + + endpoints: `请将以下观察指标凝练成简短列表: + +原始内容: +${JSON.stringify(data, null, 2)} + +要求: +- 每个指标不超过10字 +- 输出格式:{ "baseline": {...}, "exposure": {...}, "outcomes": {...}, "confounders": [...] } +- 只输出 JSON,不要其他内容`, + }; + + try { + const prompt = condensePrompts[stageCode]; + if (!prompt) { + return data; + } + + const response = await this.llmService.chat({ + messages: [ + { role: 'system', content: '你是一位专业的临床研究方法学专家,擅长提炼和凝练研究要素。请严格按照要求的 JSON 格式输出。' }, + { role: 'user', content: prompt }, + ], + temperature: 0.3, + maxTokens: 500, + }); + + // 解析 LLM 返回的 JSON + const jsonMatch = response.content.match(/\{[\s\S]*\}/); + if (jsonMatch) { + const condensed = JSON.parse(jsonMatch[0]); + return condensed; + } + + return data; + } catch (error) { + console.error('[ProtocolOrchestrator] condenseStageData error:', error); + // 凝练失败时返回原始数据 + return data; + } + } + /** * 获取Protocol上下文服务 */ diff --git a/backend/src/modules/aia/services/agentService.ts b/backend/src/modules/aia/services/agentService.ts index a51b9ef7..b9de94e2 100644 --- a/backend/src/modules/aia/services/agentService.ts +++ b/backend/src/modules/aia/services/agentService.ts @@ -24,6 +24,7 @@ import type { Agent, AgentStage } from '../types/index.js'; * 用于从 PromptService 获取对应的提示词 */ const AGENT_TO_PROMPT_CODE: Record = { + 'PROTOCOL_AGENT': 'PROTOCOL_SYSTEM', // Protocol Agent使用自己的Prompt系统 'TOPIC_01': 'AIA_SCIENTIFIC_QUESTION', 'TOPIC_02': 'AIA_PICO_ANALYSIS', 'TOPIC_03': 'AIA_TOPIC_EVALUATION', @@ -39,9 +40,22 @@ const AGENT_TO_PROMPT_CODE: Record = { // ==================== 智能体配置 ==================== /** - * 12个智能体配置(与前端保持一致) + * 13个智能体配置(与前端保持一致) + * 包含:12个传统智能体 + 1个Protocol Agent */ const AGENTS: Agent[] = [ + // Protocol Agent: 全流程研究方案制定 + { + id: 'PROTOCOL_AGENT', + name: '全流程研究方案制定', + description: '一站式完成研究方案核心要素:科学问题→PICO→研究设计→样本量→观察指标,支持一键生成完整方案。', + icon: '🚀', + stage: 'protocol', + color: '#6366F1', + systemPrompt: `你是研究方案制定助手,将引导用户系统地完成临床研究方案的核心要素设计。`, + welcomeMessage: '您好!我是研究方案制定助手。让我们开始制定您的研究方案吧!', + }, + // Phase 1: 选题优化智能体 { id: 'TOPIC_01', diff --git a/docs/03-业务模块/AIA-AI智能问答/04-开发计划/05-一键生成研究方案开发计划.md b/docs/03-业务模块/AIA-AI智能问答/04-开发计划/05-一键生成研究方案开发计划.md new file mode 100644 index 00000000..b0b31574 --- /dev/null +++ b/docs/03-业务模块/AIA-AI智能问答/04-开发计划/05-一键生成研究方案开发计划.md @@ -0,0 +1,351 @@ +# 一键生成研究方案 - 开发计划 + +> **版本**: v1.1 +> **创建日期**: 2026-01-24 +> **最后更新**: 2026-01-24 +> **负责人**: AI Assistant +> **状态**: 待开发 + +--- + +## 一、功能概述 + +### 目标 +基于 Protocol Agent 收集的 5 个核心要素,一键生成完整的临床研究方案文档,支持在线编辑和 Word 导出。 + +### 核心价值 +- 将 5-10 小时的方案撰写工作缩短至 30 分钟 +- AI 生成 + 人工编辑,保证专业性和个性化 +- 输出符合伦理委员会审查要求的标准文档 + +--- + +## 二、交互设计:两阶段渐进式生成 + +### 第一阶段:对话框生成摘要 + +``` +用户点击"一键生成研究方案" + ↓ +AI 在对话框中流式输出研究方案摘要(约500字) + ↓ +用户确认摘要 → 进入第二阶段 +用户不满意 → 在对话中继续调整要素 +``` + +**摘要内容**: +- 研究题目 +- 研究目的(主要/次要) +- 研究设计概述 +- 样本量结论 +- 主要结局指标 + +### 第二阶段:方案编辑器生成完整方案 + +``` +用户点击"生成完整方案" + ↓ +跳转到方案编辑器页面 + ↓ +流式生成完整研究方案(5000-8000字) + ↓ +用户在线编辑 / AI协作润色 + ↓ +导出 Word 文档 +``` + +--- + +## 三、研究方案结构 + +```markdown +# 临床研究方案 + +## 1. 研究题目 +## 2. 研究背景与立题依据 +## 3. 研究目的 +## 4. 研究设计 +## 5. 研究对象(纳入/排除标准) +## 6. 样本量估算 +## 7. 研究实施步骤与技术路线 +## 8. 观察指标 +## 9. 数据管理与质量控制 +## 10. 安全性评价 +## 11. 统计分析计划 +## 12. 伦理与知情同意 +## 13. 研究时间表 +## 14. 参考文献 +``` + +--- + +## 四、方案编辑器设计 + +### 布局结构 + +``` +┌────────────────────────────────────────────────────────────────┐ +│ ← 返回 📄 研究方案编辑器 [自动保存✓] [导出Word] [发布] │ +├──────────┬─────────────────────────────────────┬───────────────┤ +│ 📑 大纲 │ 📝 编辑区 │ 🤖 AI助手 │ +│ │ │ │ +│ 可点击 │ Notion 风格分块编辑 │ 选中文本后: │ +│ 快速跳转 │ 支持 Markdown + 富文本 │ - /ai 润色 │ +│ │ Slash 命令 (/) │ - /ai 扩写 │ +│ │ 拖拽排序章节 │ - /ai 精简 │ +└──────────┴─────────────────────────────────────┴───────────────┘ +``` + +### 核心功能 + +| 功能 | 说明 | 优先级 | +|------|------|--------| +| **Slash 命令** | 输入 / 唤起菜单,支持 /ai 调用生成 | P0 | +| **分块编辑** | 每个章节独立编辑,支持拖拽排序 | P0 | +| **大纲导航** | 左侧目录,点击跳转 | P0 | +| **自动保存** | 每30秒 + 失焦时保存 | P0 | +| **导出Word** | Tiptap JSON → docx | P0 | +| **AI润色** | 选中文本,/ai polish 优化 | P1 | +| **AI扩写** | 选中章节,/ai expand 补充 | P1 | +| **Ghost Text** | AI 生成时显示幽灵文字预览 | P1 | +| **版本历史** | 查看修改记录,回滚 | P2 | + +--- + +## 五、技术方案 + +### 技术选型:Novel (Fork) + +**选型结论**:Fork [Novel](https://github.com/steven-tey/novel) 源码,而非 npm 包引入。 + +**选择 Novel 的理由**: + +| 因素 | Novel 优势 | +|------|-----------| +| **AI 原生** | 专为 AI 写作设计,已处理流式生成的 UX 细节(Ghost Text、光标锁定) | +| **标准 Tiptap** | 直接暴露 Tiptap 配置,可插入交互组件(样本量计算器、引用卡片) | +| **可控性** | 源码在手,100% 可定制 UI 和逻辑 | +| **对接成本** | 替换 useCompletion → useAIStream 即可对接现有后端 | + +**Fork 策略**: + +``` +不要 npm install novel + +将 novel/packages/core/src 复制到: +frontend-v2/src/shared/components/ProtocolEditor/ + +目录结构: +├── index.tsx # 主组件 +├── extensions/ # Tiptap 扩展 +│ ├── ai-autocomplete.ts # 替换为 useAIStream +│ ├── slash-command.tsx # 保留 Slash 菜单 +│ ├── citation.ts # Phase 2: 文献引用 +│ └── medical-table.ts # Phase 2: 复杂表格 +├── components/ # UI 组件 +│ ├── EditorContent.tsx +│ ├── SlashMenu.tsx +│ └── BubbleMenu.tsx +└── styles/ # 样式(处理 Tailwind 冲突) + └── editor.css +``` + +**Tailwind CSS 冲突处理**: + +```css +/* 方案:CSS 命名空间隔离 */ +.novel-editor-scope { + /* Novel 的 Tailwind 样式限制在此作用域 */ +} +``` + +### 技术栈总览 + +``` +前端编辑器:Novel (Fork) - 基于 Tiptap/ProseMirror +├── 优点:AI 原生、Notion 风格、源码可控 +├── 核心:Slash 命令、Ghost Text、拖拽排序 +│ +AI 调用:复用现有 useAIStream Hook +├── 替换 Novel 的 useCompletion (Vercel AI SDK) +├── 对接 /api/v1/aia/protocol-agent/generate +│ +文档导出:docx.js 或 @tiptap-pro/extension-export-docx +│ +数据存储:PostgreSQL (protocol_generations 表) +``` + +### 数据模型 + +```sql +-- 方案生成记录表 +CREATE TABLE protocol_generations ( + id UUID PRIMARY KEY, + conversation_id UUID REFERENCES conversations(id), + user_id UUID REFERENCES users(id), + + -- 内容 + summary TEXT, -- 摘要(第一阶段) + full_content JSONB, -- 完整方案(Tiptap JSON,标准格式) + + -- 状态 + status VARCHAR(20), -- draft | generating | completed + version INT DEFAULT 1, + + -- 元数据 + word_file_url TEXT, -- 导出的Word文件URL + created_at TIMESTAMP, + updated_at TIMESTAMP +); +``` + +### API 设计 + +```typescript +// 第一阶段:生成摘要(流式) +POST /api/v1/aia/protocol-agent/generate/summary +Request: { conversationId: string } +Response: SSE 流式输出摘要 + +// 第二阶段:生成完整方案(流式) +POST /api/v1/aia/protocol-agent/generate/full +Request: { conversationId: string } +Response: SSE 流式输出完整方案 + +// 保存编辑 +PUT /api/v1/aia/protocol-agent/generation/:id +Request: { content: TiptapJSON } + +// 导出Word +POST /api/v1/aia/protocol-agent/generation/:id/export +Response: { downloadUrl: string } + +// AI编辑(润色/扩写) +POST /api/v1/aia/protocol-agent/generation/:id/ai-edit +Request: { + action: 'polish' | 'expand' | 'simplify', + selectedText: string, + context: string +} +Response: SSE 流式输出 +``` + +--- + +## 六、开发计划 + +### Phase 1:Fork Novel + 对接后端(3天) + +**目标**:3天内跑通"编辑器 + AI 流式生成" + +| 天数 | 任务 | 交付物 | +|------|------|--------| +| Day 1 | Fork Novel 源码 + 项目集成 | 编辑器基础渲染、Slash 菜单可用 | +| Day 2 | 替换 AI 调用 → useAIStream | /ai 命令可调用后端生成 | +| Day 3 | 摘要生成 API + 编辑器页面路由 | 完整的"对话→摘要→编辑器"流程 | + +**Phase 1 交付**: +- ✅ Notion 风格编辑器可用 +- ✅ /ai 命令可调用 Protocol Agent +- ✅ 支持 Markdown 导出 + +### Phase 2:完整方案生成 + Word 导出(3天) + +| 天数 | 任务 | 交付物 | +|------|------|--------| +| Day 4 | 完整方案生成 API(流式) | 编辑器中流式显示完整方案 | +| Day 5 | 自动保存 + 版本管理 | 数据库存储、草稿恢复 | +| Day 6 | Word 导出功能 | docx 文件下载 | + +**Phase 2 交付**: +- ✅ 完整方案流式生成 +- ✅ 自动保存 +- ✅ Word 导出 + +### Phase 3:医疗特性增强(4天) + +| 天数 | 任务 | 交付物 | +|------|------|--------| +| Day 7 | 集成 Tiptap Table 扩展 | 复杂表格支持(访视排期表) | +| Day 8 | 开发 CitationBlock | 文献引用组件(对接 PKB) | +| Day 9 | AI 润色/扩写优化 | 选中文本 AI 编辑体验 | +| Day 10 | 测试 + UI 美化 | 完整功能测试 | + +**Phase 3 交付**: +- ✅ 复杂表格支持 +- ✅ 文献引用功能 +- ✅ AI 协作编辑完善 + +--- + +## 七、风险与依赖 + +| 风险 | 影响 | 缓解措施 | +|------|------|----------| +| Tailwind CSS 冲突 | 样式混乱 | CSS 命名空间隔离 | +| Novel 源码维护成本 | 后续升级困难 | 代码量小(~2000行),可独立维护 | +| LLM 生成质量不稳定 | 方案内容不专业 | 优化 Prompt + 人工模板 | +| 长文本生成超时 | 用户等待过久 | 分章节流式生成 | +| Word 导出格式问题 | 格式错乱 | 预设 Word 模板 | + +### 依赖项 + +- [x] Protocol Agent 5阶段数据收集(已完成) +- [x] StreamingService 流式输出(已完成) +- [x] useAIStream Hook(已完成) +- [ ] Novel 源码 Fork(待执行) +- [ ] docx.js 导出功能(待开发) + +### 兜底方案 + +如果 Fork 的 Novel 代码难以维护: +- 可回退到 **Tiptap Headless** +- 用 Ant Design 重写 UI +- **数据模型 (Tiptap JSON) 完全兼容**,用户数据不丢失 + +--- + +## 八、验收标准 + +### 功能验收 + +- [ ] 点击"一键生成",对话框流式输出摘要 +- [ ] 点击"生成完整方案",跳转编辑器并流式生成 +- [ ] 编辑器支持 Slash 命令 (/) +- [ ] 编辑器支持章节拖拽排序 +- [ ] 选中文本可调用 AI 润色/扩写 +- [ ] 可导出标准格式的 Word 文档 +- [ ] 支持复杂表格编辑 + +### 性能指标 + +| 指标 | 目标 | +|------|------| +| 摘要生成时间 | < 30秒 | +| 完整方案生成时间 | < 3分钟 | +| 自动保存延迟 | < 1秒 | +| Word导出时间 | < 5秒 | + +--- + +## 九、后续迭代 + +- **v1.1**: 方案模板库(不同研究类型) +- **v1.2**: 多人协作编辑 +- **v1.3**: 方案审核流程 +- **v1.4**: 与伦理系统对接 + +--- + +## 十、参考文档 + +- [Novel GitHub](https://github.com/steven-tey/novel) +- [Tiptap 官方文档](https://tiptap.dev/) +- [编辑器选型深度评估](./编辑器选型深度评估与落地建议.md) +- [Novel vs BlockNote 对比分析](./Novel_vs_BlockNote_深度对比分析.md) + +--- + +**文档更新记录**: +- 2026-01-24 v1.0: 初始版本(技术选型:Tiptap/BlockNote) +- 2026-01-24 v1.1: 技术选型改为 **Novel (Fork)**,更新开发计划 diff --git a/docs/03-业务模块/AIA-AI智能问答/04-开发计划/Novel_vs_BlockNote_深度对比分析.md b/docs/03-业务模块/AIA-AI智能问答/04-开发计划/Novel_vs_BlockNote_深度对比分析.md new file mode 100644 index 00000000..e5dde099 --- /dev/null +++ b/docs/03-业务模块/AIA-AI智能问答/04-开发计划/Novel_vs_BlockNote_深度对比分析.md @@ -0,0 +1,150 @@ +# **深度选型报告:Novel vs. BlockNote** + +**评估对象**: Novel (Fork源码方案) vs. BlockNote (npm包方案) + +**业务场景**: AI 驱动的临床研究方案生成编辑器 + +**核心结论**: **推荐 Novel (Fork)**。虽然初期成本略高,但在 AI 集成和 UI 定制上拥有绝对的主动权。 + +## **1\. 核心架构对比 (The Architecture)** + +两者底层都是 **Tiptap (ProseMirror)**,但封装哲学完全不同。 + +| 维度 | BlockNote | Novel | +| :---- | :---- | :---- | +| **封装程度** | **极高**。它试图隐藏 Tiptap 的复杂性,创造了一套自己的 Schema (BlockNote Schema)。 | **中等**。它本质上只是 Tiptap 的一个 React Wrapper,直接暴露 Tiptap 的配置。 | +| **数据结构** | **自定义 JSON Blocks**。它把 Tiptap 的 Document 转换成了自己的 Block 数组。 | **标准 Tiptap JSON**。完全遵循 ProseMirror 标准结构。 | +| **UI 耦合度** | **高**。Slash Menu 和 Side Menu 是硬编码在库里的,很难改样式。 | **低 (Fork后)**。所有 UI 组件(Menu, Bubble)都是 React 代码,你可以随意魔改。 | +| **React 亲和度** | ⭐⭐⭐⭐⭐ (专为 React 设计) | ⭐⭐⭐⭐⭐ (专为 React/Next.js 设计) | + +**🧐 分析**: + +* 如果您只是想做一个简单的笔记应用,BlockNote 完胜。 +* 但您要做的是 **"AI Protocol Editor"**,需要插入复杂的 **"样本量计算器按钮"**、**"引用卡片"**。BlockNote 的自定义 Schema 可能会成为绊脚石,而 Novel 直接操作 Tiptap,灵活性无限。 + +## **2\. AI 集成维度 (The AI Factor)** + +这是您最关心的部分:如何与你们自研的 StreamingService 对接。 + +### **BlockNote 的表现** + +* **现状**: 它没有内置 AI 自动补全逻辑。 +* **实现路径**: 您需要自己监听后端流式数据,然后手动调用 editor.insertBlocks()。 +* **痛点**: 处理“流式打字机效果”时,BlockNote 的 Block 更新机制可能会导致光标跳动或性能问题。 + +### **Novel 的表现** + +* **现状**: 它是**为 AI 而生**的。它内置了 useCompletion (Vercel SDK) 的完整逻辑。 +* **优势**: 它已经处理好了 "AI 生成时的 Ghost Text (幽灵文字)"、"生成后的 Markdown 解析"、"生成中的光标锁定" 等细节。 +* **实现路径 (Fork)**: 您只需要把 useCompletion 替换为你们的 useAIStream,剩下的 UI 交互(如 /ai 唤起生成框)都是现成的。 + +**🏆 胜出者**: **Novel**。它在 AI 交互体验上已经帮您踩平了坑。 + +## **3\. 医疗特性扩展性 (Medical Features)** + +### **场景 A:访视排期表 (复杂表格)** + +* **BlockNote**: 对表格支持较弱,很难实现合并单元格。 +* **Novel**: 可以直接引入 @tiptap/extension-table。虽然 Novel 默认表格也不强,但您可以直接修改源码,引入更强的表格插件。 + +### **场景 B:文献引用 (Citation)** + +* **BlockNote**: 需要学习它的 Custom Schema API 来定义一个 Inline Content。文档相对较少。 +* **Novel**: 直接写一个标准的 Tiptap NodeView (React 组件)。网上的 Tiptap 教程都能用。 + +### **场景 C:导出 Word** + +* **BlockNote**: 它的 JSON 结构是非标准的,您需要先转成 Markdown/HTML,再转 Word。 +* **Novel**: 它的 JSON 是 Tiptap 标准的。社区里有现成的 tiptap-to-docx 转换库,或者直接用 pandoc。 + +**🏆 胜出者**: **Novel**。标准的 Tiptap 生态远比 BlockNote 的私有生态强大。 + +## **4\. 潜在风险与成本 (The "Gotchas")** + +### **🔴 Novel (Fork) 的风险** + +1. **代码维护成本**: 您 Copy 了几千行代码进项目,以后 Novel 官方更新了,您得手动同步(或者不仅同步,直接断开维护)。 +2. **样式污染**: Novel 使用 Tailwind CSS,类名可能会与您的 Ant Design 冲突。需要小心处理 CSS 作用域。 +3. **学习曲线**: 您的团队需要懂一点 Tiptap 的底层原理(Node, Mark, Extension)。 + +### **🔴 BlockNote 的风险** + +1. **天花板效应**: 开发到第 2 个月,产品经理说“我要在编辑器里画一个甘特图”,BlockNote 可能直接做不到。 +2. **黑盒**: 作为一个 npm 包,如果它有 Bug,您只能等官方修,或者发 Patch。 + +## **5\. 最终决策建议** + +**如果您满足以下条件,请坚决选择 Novel (Fork):** + +1. 您的团队有能力读懂并修改 React 源码。 +2. 您不仅需要“写文字”,还需要在文档里插入“交互式组件”(如计算器、图表)。 +3. 您对 UI 的精细度要求很高(必须符合 Ant Design X 风格)。 + +**如果您满足以下条件,才退而求其次选择 BlockNote:** + +1. 您的团队只有 1 个前端,且不熟悉富文本原理。 +2. 您只需要一个“能打字的 Notion”,不需要太复杂的 AI 交互。 + +### **🚀 您的选择确认** + +您之前的 **Phase 1 (Fork Novel)** 计划是非常明智的。 + +**不要纠结了,Fork Novel 是正确的路。** BlockNote 看起来简单,但在深度 AI 集成场景下,它是\*\*“先甜后苦”**;而 Novel 是**“先苦后甜”\*\*,一旦您掌握了源码,天空才是极限。 + +鉴于 Novel 的代码质量很高(基于 Tiptap + React),完全放弃它太可惜。 + +我的最终建议是:不要作为 npm 包引入 Novel,而是将其源码 Copy 到项目中作为起点 (Fork & Own)。 + +这样既利用了它现成的 UI (Slash Menu, Drag Handle),又拥有了 100% 的控制权,可以随意修改 AI 逻辑和组件。 + +🗺️ 推荐落地路线图 (Phase 1-3) +这个路线图将确保你们在 Phase 1 快速上线,同时为 Phase 2 的医疗特性预留空间。 + +Phase 1: Fork Novel + 对接你们的 StreamingService (快速上线) +目标:3天内 跑通“编辑器 + AI 流式生成”。 + +Action 1: 源码引入 + +不要 npm install novel。 + +去 GitHub 把 novel/packages/core/src 下的核心代码复制到 frontend-v2/src/shared/components/Editor。 + +Action 2: 剥离 Vercel SDK,对接自研后端 + +找到 useCompletion 或 AI 调用的 hook。 + +替换为 你们现有的 useAIStream Hook (来自 shared/components/Chat/hooks/useAIStream.ts)。 + +修改 API 路径:指向 /api/v1/aia/protocol-agent/generate。 + +Action 3: 保留核心交互 + +保留 Slash 命令 (/) 和 拖拽手柄 (DragHandle)。这些是 Notion 体验的灵魂,自己写很费劲。 + +交付物:一个长得像 Notion、实际上调用的你们 Fastify 后端的编辑器。支持 Markdown 导出。 + +Phase 2: 医疗特性增强 (差异化竞争) +目标:解决“表格”和“引用”这两个痛点。 + +Action 1: 集成 Tiptap Table 扩展 + +Novel 默认可能对表格支持较弱。 + +引入 @tiptap/extension-table,并定制渲染组件,支持合并单元格(访视排期表刚需)。 + +Action 2: 开发 CitationBlock (对接 PKB) + +这是 Novel 没有的功能。 + +基于 Tiptap 的 NodeView,开发一个 React 组件:。 + +点击时,右侧弹窗显示 PKB 中的文献详情。 + +Action 3: 导出 Word + +使用 docx.js 或 pandoc (后端服务),将 Tiptap JSON 转换为 .docx。注意:表格的转换是难点,需预留时间调试。 + +Phase 3: 如需更深定制 (兜底方案) +如果发现 Fork 来的代码太乱,难以维护。 + +回退策略:此时你们已经熟悉了 Tiptap 的 API。可以丢弃 Novel 的 UI 代码,仅保留 Tiptap 核心,用 Ant Design X 重写工具栏。数据模型 (JSON) 是完全兼容的,用户数据不会丢。 \ No newline at end of file diff --git a/docs/03-业务模块/AIA-AI智能问答/04-开发计划/编辑器选型深度评估与落地建议.md b/docs/03-业务模块/AIA-AI智能问答/04-开发计划/编辑器选型深度评估与落地建议.md new file mode 100644 index 00000000..77256a99 --- /dev/null +++ b/docs/03-业务模块/AIA-AI智能问答/04-开发计划/编辑器选型深度评估与落地建议.md @@ -0,0 +1,118 @@ +# **临床研究方案编辑器选型评估与落地建议** + +**评估对象**: BlockNote vs. Tiptap vs. Others + +**业务场景**: AI 驱动的一键生成临床研究方案 (Protocol) + +**技术栈**: React 19, TypeScript, Ant Design X + +## **1\. 为什么 BlockNote (Tiptap Wrapper) 是最佳选择?** + +在 "AI \+ 文档编辑器" 领域,目前业界的主流标准其实就是 **Tiptap**。 + +* **Vercel AI SDK** 的官方示例使用的是 Tiptap (Novel)。 +* **Notion** 风格的开源实现大多基于 Tiptap。 + +### **✅ BlockNote 的核心优势(针对你们的项目)** + +1. **结构化数据 (JSON)**: BlockNote 默认保存为 JSON Blocks。这对于后端解析、存储、甚至未来做 "结构化数据提取"(比如从方案中提取入排标准存入数据库)非常有利。 + * *对比*: 纯 Markdown 编辑器(Milkdown)在处理复杂嵌套结构时,解析成本较高。 +2. **Slash Command (/命令)**: 开箱即用的 "/" 菜单,非常适合集成 "AI 续写"、"插入统计公式"、"插入 CRF 表格" 等医疗特有指令。 +3. **React 19 兼容性**: 它是专为 React 设计的,集成到你们现在的 frontend-v2 没有任何阻碍。 + +## **2\. 医疗科研场景的特殊挑战 (Risk Assessment)** + +虽然 BlockNote 很棒,但在**临床研究方案**这个垂直场景下,有两个潜在的大坑,你需要提前规划: + +### **🚨 挑战 A:复杂的医学表格 (Schedule of Events)** + +临床方案中必须包含 **"访视排期表" (Schedule of Events)**,这是一个极其复杂的二维表格(行是检查项目,列是访视时间点,中间是 X)。 + +* **BlockNote 现状**: 表格支持相对基础。 +* **Tiptap 能力**: Tiptap 拥有非常强大的 Table 扩展(支持合并单元格、列宽调整)。 +* **建议**: 如果 BlockNote 的表格无法满足需求,你需要利用它 "基于 Tiptap" 的特性,**混入 Tiptap 原生的 Table 插件**,或者开发一个自定义的 React Component Block 来渲染复杂的访视表。 + +### **🚨 挑战 B:参考文献管理 (Citations)** + +研究方案必须引用文献(如 \[1\], \[2\]),且文末要有参考文献列表。 + +* **现状**: 通用编辑器通常不自带引用管理。 +* **建议**: 这需要自定义开发。利用 BlockNote 的 Inline Content 扩展能力,创建一个 Citation 节点。点击 \[1\] 时,侧边栏(利用 Ant Design X)显示文献详情(从 PKB 知识库获取)。 + +## **3\. 推荐的 AI 集成架构 (Streaming Architecture)** + +不要只把编辑器当文本框。在 React 19 \+ AI 场景下,建议采用以下模式: + +### **3.1 影子文档模式 (Shadow Document)** + +当 AI 在生成方案时,不要直接操作用户的编辑器实例(会导致光标跳动、冲突)。 + +* **方案**: 使用一个不可见的 "Shadow Editor" 接收流式数据,或者在当前编辑器中插入一个 "Ghost Block"(幽灵块,灰色文字),流式结束后转为正式内容。 + +### **3.2 交互式 AI 块 (Interactive AI Blocks)** + +利用 BlockNote 的自定义 Block 能力,不仅仅生成文本,而是生成**交互组件**。 + +* *场景*: AI 觉得样本量太小,不仅生成文字建议,还生成一个 **"样本量重算按钮"** 插入到文档中。点击按钮,弹窗调用你们的 **SSA (统计分析)** 模块。 + +## **4\. 最终选型结论与 Roadmap** + +### **🏆 最终推荐:BlockNote (Phase 1\) \-\> Tiptap Custom (Phase 2\)** + +这个路线是完全可行的,因为 BlockNote 就是 Tiptap 的一层优美的皮肤。 + +| 阶段 | 技术选型 | 关键任务 | +| :---- | :---- | :---- | +| **Phase 1 (快速上线)** | **BlockNote** (标准版) | 1\. 快速实现文档读写 2\. 实现 /ai 命令调用你们的 Protocol Agent 3\. 实现 Markdown/Word 导出 | +| **Phase 2 (专业增强)** | **BlockNote \+ 自定义 React Blocks** | 1\. 开发 CitationBlock (关联 PKB 模块) 2\. 开发 ScheduleTableBlock (复杂表格) 3\. 开发 ReviewComment (批注功能,复用 RVW 模块逻辑) | +| **Phase 3 (极致定制)** | **Tiptap Headless** (如果 BlockNote 限制太大) | 如果 BlockNote 的交互逻辑(如拖拽)严重干扰了医学文档的严谨性,此时可以剥离 BlockNote UI,回退到 Tiptap 核心重写 UI,**数据模型无需迁移**。 | + +### **💡 另外一个强有力的竞争者:Novel (novel.sh)** + +* **简介**: Novel 是一个开源的、基于 Tiptap 的、类似 Notion 的编辑器,专门为 AI 写作设计。 +* **优势**: 它已经把 "AI 自动补全" (Vercel AI SDK) 集成得非常好了。 +* **建议**: 你们可以看一眼 [Novel 的源码](https://github.com/steven-tey/novel)。如果觉得 BlockNote 封装太深,可以直接 Fork Novel 进行修改。Novel 的代码结构可能更适合做深度定制。 + +### **5\. 代码落地示例 (React 19 \+ BlockNote \+ AI Stream)** + +// components/ProtocolEditor/index.tsx +import { useCreateBlockNote } from "@blocknote/react"; +import { BlockNoteView } from "@blocknote/mantine"; +import "@blocknote/mantine/style.css"; + +export default function ProtocolEditor({ aiStreamContent }) { + // 1\. 初始化编辑器 + const editor \= useCreateBlockNote({ + initialContent: \[ + { type: "heading", content: "1. 研究背景" }, + { type: "paragraph", content: "在此输入背景..." } + \], + }); + + // 2\. 监听 AI 流式输出 (Hooks) + useEffect(() \=\> { + if (aiStreamContent) { + // 实时将 AI 的内容插入到当前光标位置或指定 Block + editor.insertBlocks( + \[{ type: "paragraph", content: aiStreamContent }\], + editor.getTextCursorPosition().block, + "after" + ); + } + }, \[aiStreamContent\]); + + // 3\. 自定义渲染 (集成 Ant Design X 风格) + return ( + \
+ \ + {/\* 自定义 Slash Menu 组件 \*/} + \ + \ + \ + ); +} diff --git a/frontend-v2/src/modules/aia/index.tsx b/frontend-v2/src/modules/aia/index.tsx index 2a68b29f..739b7676 100644 --- a/frontend-v2/src/modules/aia/index.tsx +++ b/frontend-v2/src/modules/aia/index.tsx @@ -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; } diff --git a/frontend-v2/src/modules/aia/protocol-agent/ProtocolAgentPage.tsx b/frontend-v2/src/modules/aia/protocol-agent/ProtocolAgentPage.tsx index 31c687d8..87a3f414 100644 --- a/frontend-v2/src/modules/aia/protocol-agent/ProtocolAgentPage.tsx +++ b/frontend-v2/src/modules/aia/protocol-agent/ProtocolAgentPage.tsx @@ -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) => { + 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 (
{ borderRadius: '50%', animation: 'spin 1s linear infinite' }} /> -

正在创建新对话...

+

正在初始化...

); @@ -234,7 +261,7 @@ export const ProtocolAgentPage: React.FC = () => { /> {/* 状态面板 */} - +
diff --git a/frontend-v2/src/modules/aia/protocol-agent/components/ChatArea.tsx b/frontend-v2/src/modules/aia/protocol-agent/components/ChatArea.tsx index 850adca8..7dde2cc8 100644 --- a/frontend-v2/src/modules/aia/protocol-agent/components/ChatArea.tsx +++ b/frontend-v2/src/modules/aia/protocol-agent/components/ChatArea.tsx @@ -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; + label: string; + disabled?: boolean; +} + +interface ActionCard { + id: string; + type: string; + title: string; + description?: string; + actionUrl?: string; + actionParams?: Record; } 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 = { + scientific_question: '科学问题梳理', + pico: 'PICO要素', + study_design: '研究设计', + sample_size: '样本量计算', + endpoints: '观察指标', +}; + +/** + * 从 AI 响应中解析 extracted_data XML 标签 + */ +function parseExtractedData(content: string): { + cleanContent: string; + extractedData: Record | null; +} { + const regex = /([\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 = ({ conversationId, @@ -37,23 +100,42 @@ export const ChatArea: React.FC = ({ }) => { const [messages, setMessages] = useState([]); const [input, setInput] = useState(''); - const [loading, setLoading] = useState(false); const chatContainerRef = useRef(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 = ({ 完成这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) => { + const handleSync = useCallback(async (stageCode: string, data: Record) => { 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 = ({ }), }); - 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 (
@@ -207,7 +329,7 @@ export const ChatArea: React.FC = ({
-
User • {formatTime(msg.timestamp)}
+
您 • {formatTime(msg.timestamp)}
{msg.content}
@@ -221,24 +343,44 @@ export const ChatArea: React.FC = ({
-
Protocol Agent • {formatTime(msg.timestamp)}
+
+ Protocol Agent + {msg.stageName && {msg.stageName}} + • {formatTime(msg.timestamp)} +
+ + {/* 深度思考内容 */} + {msg.thinkingContent && ( + + )} +
- {msg.content} +
{/* 同步按钮 */} - {msg.syncButton && ( + {msg.syncButton && !msg.syncButton.disabled && ( )} - - {/* Action Cards */} + + {/* 动作卡片 */} {msg.actionCards && msg.actionCards.length > 0 && (
{msg.actionCards.map(card => ( - +
handleActionCard(card)} + > +
{card.title}
+ {card.description && ( +
{card.description}
+ )} + +
))}
)} @@ -256,17 +398,36 @@ export const ChatArea: React.FC = ({
))} - {loading && ( + {/* 流式输出中的消息(打字机效果) */} + {(isStreaming || isThinking) && (
-
- - - +
+ Protocol Agent + {context?.stageName && {context.stageName}} + • 正在回复...
+ + {/* 深度思考内容(流式) */} + {isThinking && streamThinking && ( + + )} + + {/* 流式内容 */} + {streamContent ? ( +
+ + +
+ ) : ( +
+ + AI 正在思考... +
+ )}
)} @@ -278,18 +439,18 @@ export const ChatArea: React.FC = ({ setInput(e.target.value)} onKeyDown={handleKeyDown} - disabled={loading || !conversationId} + disabled={isStreaming || !conversationId} />
@@ -306,5 +467,3 @@ function formatTime(date: Date): string { minute: '2-digit' }); } - - diff --git a/frontend-v2/src/modules/aia/protocol-agent/components/MarkdownContent.tsx b/frontend-v2/src/modules/aia/protocol-agent/components/MarkdownContent.tsx new file mode 100644 index 00000000..4be0331c --- /dev/null +++ b/frontend-v2/src/modules/aia/protocol-agent/components/MarkdownContent.tsx @@ -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( + + {listItems.map((item, i) => ( +
  • {formatInline(item)}
  • + ))} +
    + ); + listItems = []; + listType = null; + } + }; + + for (const line of lines) { + // 处理标题 + if (line.startsWith('### ')) { + flushList(); + elements.push(

    {formatInline(line.slice(4))}

    ); + continue; + } + if (line.startsWith('## ')) { + flushList(); + elements.push(

    {formatInline(line.slice(3))}

    ); + continue; + } + if (line.startsWith('# ')) { + flushList(); + elements.push(

    {formatInline(line.slice(2))}

    ); + 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(
    ); + continue; + } + + // 普通段落 + elements.push(

    {formatInline(line)}

    ); + } + + // 处理剩余列表 + 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({part.content}); + } 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({match[1]}); + 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 = /(? lastIndex) { + parts.push({text.slice(lastIndex, match.index)}); + } + parts.push({match[1]}); + lastIndex = match.index + match[0].length; + } + + if (lastIndex < text.length) { + parts.push({text.slice(lastIndex)}); + } + + if (parts.length === 0) { + parts.push({text}); + } + + return parts; +} + +export const MarkdownContent: React.FC = ({ + content, + className = '' +}) => { + const elements = parseMarkdown(content); + + return ( +
    + {elements} +
    + ); +}; + +export default MarkdownContent; + diff --git a/frontend-v2/src/modules/aia/protocol-agent/components/StageCard.tsx b/frontend-v2/src/modules/aia/protocol-agent/components/StageCard.tsx index 14d59f19..92cc76f9 100644 --- a/frontend-v2/src/modules/aia/protocol-agent/components/StageCard.tsx +++ b/frontend-v2/src/modules/aia/protocol-agent/components/StageCard.tsx @@ -18,6 +18,7 @@ import type { interface StageCardProps { stage: StageInfo; index: number; + onEdit?: () => void; } const STAGE_TITLES: Record = { @@ -28,7 +29,7 @@ const STAGE_TITLES: Record = { endpoints: '观察指标', }; -export const StageCard: React.FC = ({ stage, index }) => { +export const StageCard: React.FC = ({ 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 = ({ stage, index }) => {

    {number} {title}

    - {status === 'completed' && } - {status === 'current' && } +
    + {status === 'completed' && onEdit && ( + + )} + {status === 'completed' && } + {status === 'current' && } +
    {data && } diff --git a/frontend-v2/src/modules/aia/protocol-agent/components/StageEditModal.tsx b/frontend-v2/src/modules/aia/protocol-agent/components/StageEditModal.tsx new file mode 100644 index 00000000..2ded1a70 --- /dev/null +++ b/frontend-v2/src/modules/aia/protocol-agent/components/StageEditModal.tsx @@ -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) => Promise; + onClose: () => void; +} + +const STAGE_TITLES: Record = { + scientific_question: '科学问题', + pico: 'PICO要素', + study_design: '研究设计', + sample_size: '样本量', + endpoints: '观察指标', +}; + +export const StageEditModal: React.FC = ({ stage, onSave, onClose }) => { + const [formData, setFormData] = useState>({}); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (stage.data) { + setFormData({ ...stage.data as Record }); + } + }, [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 ( +
    + +