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:
390
backend/prisma/seed-protocol-agent.ts
Normal file
390
backend/prisma/seed-protocol-agent.ts
Normal file
@@ -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<string, string>();
|
||||||
|
|
||||||
|
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. 整理完成后,在回复末尾输出提取的数据(用于同步到方案)
|
||||||
|
|
||||||
|
## 数据提取格式
|
||||||
|
当你认为当前阶段的信息已经收集完整,请在回复末尾添加:
|
||||||
|
<extracted_data>
|
||||||
|
{
|
||||||
|
"字段1": "值1",
|
||||||
|
"字段2": "值2"
|
||||||
|
}
|
||||||
|
</extracted_data>
|
||||||
|
|
||||||
|
注意:只有在信息收集完整时才输出 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);
|
||||||
|
});
|
||||||
|
|
||||||
@@ -395,6 +395,7 @@ async function main() {
|
|||||||
// 11. 跳过Prompt模板(表尚未创建)
|
// 11. 跳过Prompt模板(表尚未创建)
|
||||||
// ============================================
|
// ============================================
|
||||||
console.log('📌 跳过Prompt模板创建(capability_schema.prompt_templates 尚未创建)');
|
console.log('📌 跳过Prompt模板创建(capability_schema.prompt_templates 尚未创建)');
|
||||||
|
console.log('💡 提示:Protocol Agent 配置请运行独立脚本: npx tsx prisma/seed-protocol-agent.ts');
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// 12. 创建租户模块订阅
|
// 12. 创建租户模块订阅
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import { PrismaClient } from '@prisma/client';
|
|||||||
import { ProtocolOrchestrator } from '../services/ProtocolOrchestrator.js';
|
import { ProtocolOrchestrator } from '../services/ProtocolOrchestrator.js';
|
||||||
import { LLMServiceInterface } from '../../services/BaseAgentOrchestrator.js';
|
import { LLMServiceInterface } from '../../services/BaseAgentOrchestrator.js';
|
||||||
import { ProtocolStageCode } from '../../types/index.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 {
|
interface SendMessageBody {
|
||||||
@@ -38,21 +41,24 @@ interface GetContextParams {
|
|||||||
|
|
||||||
export class ProtocolAgentController {
|
export class ProtocolAgentController {
|
||||||
private orchestrator: ProtocolOrchestrator;
|
private orchestrator: ProtocolOrchestrator;
|
||||||
|
private prisma: PrismaClient;
|
||||||
|
|
||||||
constructor(prisma: PrismaClient, llmService: LLMServiceInterface) {
|
constructor(prisma: PrismaClient, llmService: LLMServiceInterface) {
|
||||||
|
this.prisma = prisma;
|
||||||
this.orchestrator = new ProtocolOrchestrator({ prisma, llmService });
|
this.orchestrator = new ProtocolOrchestrator({ prisma, llmService });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 发送消息
|
* 发送消息(流式输出)
|
||||||
* POST /api/aia/protocol-agent/message
|
* POST /api/aia/protocol-agent/message
|
||||||
|
*
|
||||||
|
* 使用通用 StreamingService 实现打字机效果
|
||||||
*/
|
*/
|
||||||
async sendMessage(
|
async sendMessage(
|
||||||
request: FastifyRequest<{ Body: SendMessageBody }>,
|
request: FastifyRequest<{ Body: SendMessageBody }>,
|
||||||
reply: FastifyReply
|
reply: FastifyReply
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
const { conversationId, content } = request.body;
|
||||||
const { conversationId, content, messageId } = request.body;
|
|
||||||
const userId = (request as any).user?.userId;
|
const userId = (request as any).user?.userId;
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
@@ -65,19 +71,65 @@ export class ProtocolAgentController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await this.orchestrator.handleMessage({
|
try {
|
||||||
|
// 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,
|
conversationId,
|
||||||
userId,
|
role: 'user',
|
||||||
content,
|
content,
|
||||||
messageId,
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
reply.send({
|
// 4. 使用通用 StreamingService 流式输出
|
||||||
success: true,
|
const streamingService = createStreamingService(reply, {
|
||||||
data: response,
|
model: 'deepseek-v3',
|
||||||
|
temperature: 0.7,
|
||||||
|
maxTokens: 4096,
|
||||||
|
enableDeepThinking: false,
|
||||||
|
userId,
|
||||||
|
conversationId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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) {
|
} catch (error) {
|
||||||
console.error('[ProtocolAgentController] sendMessage error:', error);
|
logger.error('[ProtocolAgentController] sendMessage error:', error);
|
||||||
reply.code(500).send({
|
reply.code(500).send({
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'Internal server error',
|
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<OpenAIMessage[]> {
|
||||||
|
// 获取历史消息
|
||||||
|
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<string, string> = {
|
||||||
|
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}
|
||||||
|
|
||||||
|
## 重要提示
|
||||||
|
- 只有当用户提供了足够的信息后才输出 <extracted_data> 标签
|
||||||
|
- 输出的 JSON 必须是有效格式
|
||||||
|
- 每次对话只关注当前阶段「${currentStageName}」
|
||||||
|
- 回复使用 Markdown 格式,简洁专业`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取不同阶段的输出格式说明
|
||||||
|
*/
|
||||||
|
private getStageOutputFormat(stageCode: string): string {
|
||||||
|
const formats: Record<string, string> = {
|
||||||
|
scientific_question: `<extracted_data>
|
||||||
|
{
|
||||||
|
"content": "一句话科学问题(不超过50字)"
|
||||||
|
}
|
||||||
|
</extracted_data>`,
|
||||||
|
|
||||||
|
pico: `<extracted_data>
|
||||||
|
{
|
||||||
|
"population": "研究人群(不超过20字)",
|
||||||
|
"intervention": "干预措施(不超过20字)",
|
||||||
|
"comparison": "对照组(不超过20字)",
|
||||||
|
"outcome": "结局指标(不超过20字)"
|
||||||
|
}
|
||||||
|
</extracted_data>`,
|
||||||
|
|
||||||
|
study_design: `<extracted_data>
|
||||||
|
{
|
||||||
|
"studyType": "研究类型,如:回顾性队列研究、前瞻性队列研究、随机对照试验等",
|
||||||
|
"design": ["设计特征1", "设计特征2"]
|
||||||
|
}
|
||||||
|
</extracted_data>`,
|
||||||
|
|
||||||
|
sample_size: `<extracted_data>
|
||||||
|
{
|
||||||
|
"sampleSize": 样本量数字,
|
||||||
|
"calculation": {
|
||||||
|
"alpha": 显著性水平(如0.05),
|
||||||
|
"power": 检验效能(如0.8),
|
||||||
|
"effectSize": "效应量描述"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</extracted_data>`,
|
||||||
|
|
||||||
|
endpoints: `<extracted_data>
|
||||||
|
{
|
||||||
|
"outcomes": {
|
||||||
|
"primary": ["主要结局指标1", "主要结局指标2"],
|
||||||
|
"secondary": ["次要结局指标1"],
|
||||||
|
"safety": ["安全性指标"]
|
||||||
|
},
|
||||||
|
"confounders": ["混杂因素1", "混杂因素2"]
|
||||||
|
}
|
||||||
|
</extracted_data>`,
|
||||||
|
};
|
||||||
|
|
||||||
|
return formats[stageCode] || `<extracted_data>
|
||||||
|
{
|
||||||
|
"key": "value"
|
||||||
|
}
|
||||||
|
</extracted_data>`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 同步阶段数据
|
* 同步阶段数据
|
||||||
* POST /api/aia/protocol-agent/sync
|
* POST /api/aia/protocol-agent/sync
|
||||||
|
|||||||
@@ -127,39 +127,63 @@ export class ProtocolOrchestrator extends BaseAgentOrchestrator {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理Protocol同步请求
|
* 处理Protocol同步请求
|
||||||
|
* 支持两种场景:1. 首次同步(从对话中提取)2. 编辑更新(用户手动修改)
|
||||||
*/
|
*/
|
||||||
async handleProtocolSync(
|
async handleProtocolSync(
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
stageCode: string,
|
stageCode: string,
|
||||||
data: Record<string, unknown>
|
data: Record<string, unknown>,
|
||||||
|
isEdit: boolean = false // 是否是编辑更新
|
||||||
): Promise<{
|
): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
context: ProtocolContextData;
|
context: ProtocolContextData;
|
||||||
nextStage?: ProtocolStageCode;
|
nextStage?: ProtocolStageCode;
|
||||||
message?: string;
|
message?: string;
|
||||||
|
condensedData?: Record<string, unknown>;
|
||||||
}> {
|
}> {
|
||||||
const stage = stageCode as ProtocolStageCode;
|
const stage = stageCode as ProtocolStageCode;
|
||||||
|
|
||||||
|
// 获取当前上下文
|
||||||
|
const existingContext = await this.contextService.getContext(conversationId);
|
||||||
|
const isAlreadyCompleted = existingContext?.completedStages.includes(stage);
|
||||||
|
|
||||||
|
let finalData: Record<string, unknown>;
|
||||||
|
|
||||||
|
if (isEdit || isAlreadyCompleted) {
|
||||||
|
// 编辑模式:直接使用用户提供的数据,不再凝练
|
||||||
|
finalData = data;
|
||||||
|
} else {
|
||||||
|
// 首次同步:使用 LLM 凝练数据
|
||||||
|
finalData = await this.condenseStageData(stage, data);
|
||||||
|
}
|
||||||
|
|
||||||
// 保存阶段数据
|
// 保存阶段数据
|
||||||
await this.contextService.updateStageData(conversationId, stage, {
|
await this.contextService.updateStageData(conversationId, stage, {
|
||||||
...data,
|
...finalData,
|
||||||
confirmed: true,
|
confirmed: true,
|
||||||
confirmedAt: new Date(),
|
confirmedAt: new Date(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// 获取下一阶段
|
let context: ProtocolContextData;
|
||||||
|
let nextStage: ProtocolStageCode | undefined;
|
||||||
|
|
||||||
|
if (isAlreadyCompleted) {
|
||||||
|
// 已完成的阶段:只更新数据,不改变当前阶段
|
||||||
|
context = await this.contextService.getContext(conversationId) as ProtocolContextData;
|
||||||
|
} else {
|
||||||
|
// 首次完成:标记完成并进入下一阶段
|
||||||
const currentIndex = STAGE_ORDER.indexOf(stage);
|
const currentIndex = STAGE_ORDER.indexOf(stage);
|
||||||
const nextStage = currentIndex < STAGE_ORDER.length - 1
|
nextStage = currentIndex < STAGE_ORDER.length - 1
|
||||||
? STAGE_ORDER[currentIndex + 1]
|
? STAGE_ORDER[currentIndex + 1]
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
// 标记当前阶段完成,更新到下一阶段
|
context = await this.contextService.completeStage(
|
||||||
const context = await this.contextService.completeStage(
|
|
||||||
conversationId,
|
conversationId,
|
||||||
stage,
|
stage,
|
||||||
nextStage
|
nextStage
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 检查是否所有阶段都已完成
|
// 检查是否所有阶段都已完成
|
||||||
const allCompleted = this.contextService.isAllStagesCompleted(context);
|
const allCompleted = this.contextService.isAllStagesCompleted(context);
|
||||||
@@ -168,7 +192,10 @@ export class ProtocolOrchestrator extends BaseAgentOrchestrator {
|
|||||||
success: true,
|
success: true,
|
||||||
context,
|
context,
|
||||||
nextStage,
|
nextStage,
|
||||||
message: allCompleted
|
condensedData: finalData,
|
||||||
|
message: isAlreadyCompleted
|
||||||
|
? `✅ 已更新「${STAGE_NAMES[stage]}」`
|
||||||
|
: allCompleted
|
||||||
? '🎉 所有核心要素已完成!您可以点击「一键生成研究方案」生成完整方案。'
|
? '🎉 所有核心要素已完成!您可以点击「一键生成研究方案」生成完整方案。'
|
||||||
: nextStage
|
: nextStage
|
||||||
? `已同步${STAGE_NAMES[stage]},进入${STAGE_NAMES[nextStage]}阶段`
|
? `已同步${STAGE_NAMES[stage]},进入${STAGE_NAMES[nextStage]}阶段`
|
||||||
@@ -176,6 +203,93 @@ export class ProtocolOrchestrator extends BaseAgentOrchestrator {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用 LLM 凝练阶段数据
|
||||||
|
*/
|
||||||
|
private async condenseStageData(
|
||||||
|
stageCode: ProtocolStageCode,
|
||||||
|
data: Record<string, unknown>
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
// 构建凝练 Prompt
|
||||||
|
const condensePrompts: Record<ProtocolStageCode, string> = {
|
||||||
|
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上下文服务
|
* 获取Protocol上下文服务
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import type { Agent, AgentStage } from '../types/index.js';
|
|||||||
* 用于从 PromptService 获取对应的提示词
|
* 用于从 PromptService 获取对应的提示词
|
||||||
*/
|
*/
|
||||||
const AGENT_TO_PROMPT_CODE: Record<string, string> = {
|
const AGENT_TO_PROMPT_CODE: Record<string, string> = {
|
||||||
|
'PROTOCOL_AGENT': 'PROTOCOL_SYSTEM', // Protocol Agent使用自己的Prompt系统
|
||||||
'TOPIC_01': 'AIA_SCIENTIFIC_QUESTION',
|
'TOPIC_01': 'AIA_SCIENTIFIC_QUESTION',
|
||||||
'TOPIC_02': 'AIA_PICO_ANALYSIS',
|
'TOPIC_02': 'AIA_PICO_ANALYSIS',
|
||||||
'TOPIC_03': 'AIA_TOPIC_EVALUATION',
|
'TOPIC_03': 'AIA_TOPIC_EVALUATION',
|
||||||
@@ -39,9 +40,22 @@ const AGENT_TO_PROMPT_CODE: Record<string, string> = {
|
|||||||
// ==================== 智能体配置 ====================
|
// ==================== 智能体配置 ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 12个智能体配置(与前端保持一致)
|
* 13个智能体配置(与前端保持一致)
|
||||||
|
* 包含:12个传统智能体 + 1个Protocol Agent
|
||||||
*/
|
*/
|
||||||
const AGENTS: Agent[] = [
|
const AGENTS: Agent[] = [
|
||||||
|
// Protocol Agent: 全流程研究方案制定
|
||||||
|
{
|
||||||
|
id: 'PROTOCOL_AGENT',
|
||||||
|
name: '全流程研究方案制定',
|
||||||
|
description: '一站式完成研究方案核心要素:科学问题→PICO→研究设计→样本量→观察指标,支持一键生成完整方案。',
|
||||||
|
icon: '🚀',
|
||||||
|
stage: 'protocol',
|
||||||
|
color: '#6366F1',
|
||||||
|
systemPrompt: `你是研究方案制定助手,将引导用户系统地完成临床研究方案的核心要素设计。`,
|
||||||
|
welcomeMessage: '您好!我是研究方案制定助手。让我们开始制定您的研究方案吧!',
|
||||||
|
},
|
||||||
|
|
||||||
// Phase 1: 选题优化智能体
|
// Phase 1: 选题优化智能体
|
||||||
{
|
{
|
||||||
id: 'TOPIC_01',
|
id: 'TOPIC_01',
|
||||||
|
|||||||
351
docs/03-业务模块/AIA-AI智能问答/04-开发计划/05-一键生成研究方案开发计划.md
Normal file
351
docs/03-业务模块/AIA-AI智能问答/04-开发计划/05-一键生成研究方案开发计划.md
Normal file
@@ -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)**,更新开发计划
|
||||||
150
docs/03-业务模块/AIA-AI智能问答/04-开发计划/Novel_vs_BlockNote_深度对比分析.md
Normal file
150
docs/03-业务模块/AIA-AI智能问答/04-开发计划/Novel_vs_BlockNote_深度对比分析.md
Normal file
@@ -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 组件:<Citation id="123" title="阿司匹林..." />。
|
||||||
|
|
||||||
|
点击时,右侧弹窗显示 PKB 中的文献详情。
|
||||||
|
|
||||||
|
Action 3: 导出 Word
|
||||||
|
|
||||||
|
使用 docx.js 或 pandoc (后端服务),将 Tiptap JSON 转换为 .docx。注意:表格的转换是难点,需预留时间调试。
|
||||||
|
|
||||||
|
Phase 3: 如需更深定制 (兜底方案)
|
||||||
|
如果发现 Fork 来的代码太乱,难以维护。
|
||||||
|
|
||||||
|
回退策略:此时你们已经熟悉了 Tiptap 的 API。可以丢弃 Novel 的 UI 代码,仅保留 Tiptap 核心,用 Ant Design X 重写工具栏。数据模型 (JSON) 是完全兼容的,用户数据不会丢。
|
||||||
118
docs/03-业务模块/AIA-AI智能问答/04-开发计划/编辑器选型深度评估与落地建议.md
Normal file
118
docs/03-业务模块/AIA-AI智能问答/04-开发计划/编辑器选型深度评估与落地建议.md
Normal file
@@ -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 (
|
||||||
|
\<div className="protocol-editor-container bg-white min-h-screen"\>
|
||||||
|
\<BlockNoteView
|
||||||
|
editor={editor}
|
||||||
|
theme="light"
|
||||||
|
// 可以在这里覆盖默认的 Slash Menu,集成你们的 Protocol Agent
|
||||||
|
slashMenu={false}
|
||||||
|
\>
|
||||||
|
{/\* 自定义 Slash Menu 组件 \*/}
|
||||||
|
\<MyCustomSlashMenu editor={editor} /\>
|
||||||
|
\</BlockNoteView\>
|
||||||
|
\</div\>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,9 +2,9 @@
|
|||||||
* AIA - AI Intelligent Assistant 模块入口
|
* AIA - AI Intelligent Assistant 模块入口
|
||||||
*
|
*
|
||||||
* 路由管理:
|
* 路由管理:
|
||||||
* - /aia -> Hub: 智能体大厅(12个模块展示)
|
* - /ai-qa -> Hub: 智能体大厅(12个模块展示)
|
||||||
* - /aia/chat -> Chat: 沉浸式对话工作台(原12个智能体)
|
* - /ai-qa/chat -> Chat: 沉浸式对话工作台(原12个智能体)
|
||||||
* - /aia/protocol-agent/:conversationId? -> Protocol Agent(全流程方案制定)
|
* - /ai-qa/protocol-agent/:conversationId? -> Protocol Agent(全流程方案制定)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
@@ -40,12 +40,12 @@ const AIAHub: React.FC = () => {
|
|||||||
|
|
||||||
if (agent.isProtocolAgent) {
|
if (agent.isProtocolAgent) {
|
||||||
// Protocol Agent:跳转专属页面
|
// Protocol Agent:跳转专属页面
|
||||||
console.log('[AIAHub] Navigating to /aia/protocol-agent');
|
console.log('[AIAHub] Navigating to /ai-qa/protocol-agent');
|
||||||
navigate('/aia/protocol-agent');
|
navigate('/ai-qa/protocol-agent');
|
||||||
} else {
|
} else {
|
||||||
// 传统智能体:跳转对话页面
|
// 传统智能体:跳转对话页面
|
||||||
console.log('[AIAHub] Navigating to /aia/chat');
|
console.log('[AIAHub] Navigating to /ai-qa/chat');
|
||||||
navigate('/aia/chat', { state: { agent, initialQuery: agent.initialQuery } });
|
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 state = location.state as { agent: AgentConfig; initialQuery?: string } | null;
|
||||||
|
|
||||||
const handleBack = () => {
|
const handleBack = () => {
|
||||||
navigate('/aia');
|
navigate('/ai-qa');
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!state?.agent) {
|
if (!state?.agent) {
|
||||||
navigate('/aia');
|
navigate('/ai-qa');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
* 三栏布局:可折叠侧边栏 + 聊天区 + 状态面板
|
* 三栏布局:可折叠侧边栏 + 聊天区 + 状态面板
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Bot, Settings, ChevronLeft, Menu, Plus,
|
Bot, Settings, ChevronLeft, Menu, Plus,
|
||||||
@@ -15,6 +15,7 @@ import { ChatArea } from './components/ChatArea';
|
|||||||
import { StatePanel } from './components/StatePanel';
|
import { StatePanel } from './components/StatePanel';
|
||||||
import { useProtocolContext } from './hooks/useProtocolContext';
|
import { useProtocolContext } from './hooks/useProtocolContext';
|
||||||
import { useProtocolConversations } from './hooks/useProtocolConversations';
|
import { useProtocolConversations } from './hooks/useProtocolConversations';
|
||||||
|
import { getAccessToken } from '../../../framework/auth/api';
|
||||||
import './styles/protocol-agent.css';
|
import './styles/protocol-agent.css';
|
||||||
|
|
||||||
export const ProtocolAgentPage: React.FC = () => {
|
export const ProtocolAgentPage: React.FC = () => {
|
||||||
@@ -36,27 +37,53 @@ export const ProtocolAgentPage: React.FC = () => {
|
|||||||
// 上下文状态
|
// 上下文状态
|
||||||
const { context, refreshContext } = useProtocolContext(currentConversation?.id);
|
const { context, refreshContext } = useProtocolContext(currentConversation?.id);
|
||||||
|
|
||||||
// 首次进入且无conversationId时,自动创建新对话
|
// 处理阶段数据编辑更新
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
const handleStageUpdate = useCallback(async (stageCode: string, data: Record<string, unknown>) => {
|
||||||
|
if (!currentConversation?.id) return;
|
||||||
|
|
||||||
|
const token = getAccessToken();
|
||||||
|
const response = await fetch('/api/v1/aia/protocol-agent/sync', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
conversationId: currentConversation.id,
|
||||||
|
stageCode,
|
||||||
|
data,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('更新失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 刷新上下文
|
||||||
|
await refreshContext();
|
||||||
|
}, [currentConversation?.id, refreshContext]);
|
||||||
|
|
||||||
|
// 使用ref避免无限循环
|
||||||
|
const hasTriedCreate = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!conversationId && !currentConversation && !isCreating) {
|
// 只在首次进入且无conversationId时尝试创建一次
|
||||||
|
if (!conversationId && !currentConversation && !hasTriedCreate.current) {
|
||||||
|
hasTriedCreate.current = true;
|
||||||
console.log('[ProtocolAgentPage] 自动创建新对话...');
|
console.log('[ProtocolAgentPage] 自动创建新对话...');
|
||||||
setIsCreating(true);
|
|
||||||
createConversation().then(newConv => {
|
createConversation().then(newConv => {
|
||||||
if (newConv) {
|
if (newConv) {
|
||||||
console.log('[ProtocolAgentPage] 新对话创建成功:', newConv.id);
|
console.log('[ProtocolAgentPage] 新对话创建成功:', newConv.id);
|
||||||
navigate(`/aia/protocol-agent/${newConv.id}`, { replace: true });
|
navigate(`/ai-qa/protocol-agent/${newConv.id}`, { replace: true });
|
||||||
} else {
|
} else {
|
||||||
console.error('[ProtocolAgentPage] 创建对话失败');
|
console.error('[ProtocolAgentPage] 创建对话失败');
|
||||||
setIsCreating(false);
|
|
||||||
}
|
}
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
console.error('[ProtocolAgentPage] 创建对话异常:', err);
|
console.error('[ProtocolAgentPage] 创建对话异常:', err);
|
||||||
setIsCreating(false);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [conversationId, currentConversation, isCreating, createConversation, navigate]);
|
}, [conversationId, currentConversation, navigate]); // 移除createConversation依赖
|
||||||
|
|
||||||
// 获取当前阶段信息
|
// 获取当前阶段信息
|
||||||
const currentStageName = context?.stageName || '科学问题梳理';
|
const currentStageName = context?.stageName || '科学问题梳理';
|
||||||
@@ -66,7 +93,7 @@ export const ProtocolAgentPage: React.FC = () => {
|
|||||||
const handleNewConversation = async () => {
|
const handleNewConversation = async () => {
|
||||||
const newConv = await createConversation();
|
const newConv = await createConversation();
|
||||||
if (newConv) {
|
if (newConv) {
|
||||||
navigate(`/aia/protocol-agent/${newConv.id}`);
|
navigate(`/ai-qa/protocol-agent/${newConv.id}`);
|
||||||
setSidebarCollapsed(true);
|
setSidebarCollapsed(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -74,17 +101,17 @@ export const ProtocolAgentPage: React.FC = () => {
|
|||||||
// 选择对话
|
// 选择对话
|
||||||
const handleSelectConversation = (id: string) => {
|
const handleSelectConversation = (id: string) => {
|
||||||
selectConversation(id);
|
selectConversation(id);
|
||||||
navigate(`/aia/protocol-agent/${id}`);
|
navigate(`/ai-qa/protocol-agent/${id}`);
|
||||||
setSidebarCollapsed(true);
|
setSidebarCollapsed(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 返回AgentHub
|
// 返回AgentHub
|
||||||
const handleBack = () => {
|
const handleBack = () => {
|
||||||
navigate('/aia');
|
navigate('/ai-qa');
|
||||||
};
|
};
|
||||||
|
|
||||||
// 加载状态
|
// 如果没有conversationId,显示等待状态
|
||||||
if (isCreating || (!conversationId && !currentConversation)) {
|
if (!conversationId) {
|
||||||
return (
|
return (
|
||||||
<div className="protocol-agent-page">
|
<div className="protocol-agent-page">
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -103,7 +130,7 @@ export const ProtocolAgentPage: React.FC = () => {
|
|||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
animation: 'spin 1s linear infinite'
|
animation: 'spin 1s linear infinite'
|
||||||
}} />
|
}} />
|
||||||
<p style={{ color: '#6B7280', fontSize: '14px' }}>正在创建新对话...</p>
|
<p style={{ color: '#6B7280', fontSize: '14px' }}>正在初始化...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -234,7 +261,7 @@ export const ProtocolAgentPage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 状态面板 */}
|
{/* 状态面板 */}
|
||||||
<StatePanel context={context} />
|
<StatePanel context={context} onStageUpdate={handleStageUpdate} />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,21 +1,37 @@
|
|||||||
/**
|
/**
|
||||||
* Chat Area - Protocol Agent 聊天区域
|
* 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 React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||||
import { Send, Sparkles, User } from 'lucide-react';
|
import { Send, Sparkles, User, Loader2, ExternalLink } from 'lucide-react';
|
||||||
import type { ProtocolContext, AgentResponse } from '../types';
|
import { ThinkingBlock, useAIStream } from '@/shared/components/Chat';
|
||||||
import { SyncButton } from './SyncButton';
|
|
||||||
import { ActionCardComponent } from './ActionCard';
|
|
||||||
import { ReflexionMessage } from './ReflexionMessage';
|
|
||||||
import { getAccessToken } from '../../../../framework/auth/api';
|
import { getAccessToken } from '../../../../framework/auth/api';
|
||||||
|
import type { ProtocolContext } from '../types';
|
||||||
|
import { SyncButton } from './SyncButton';
|
||||||
|
import { MarkdownContent } from './MarkdownContent';
|
||||||
|
|
||||||
interface ChatAreaProps {
|
// ============================================
|
||||||
conversationId?: string;
|
// 类型定义(与后端 AgentResponse 对应)
|
||||||
context: ProtocolContext | null;
|
// ============================================
|
||||||
onContextUpdate: () => void;
|
|
||||||
|
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 {
|
interface Message {
|
||||||
@@ -23,12 +39,59 @@ interface Message {
|
|||||||
role: 'user' | 'assistant' | 'system';
|
role: 'user' | 'assistant' | 'system';
|
||||||
content: string;
|
content: string;
|
||||||
thinkingContent?: string;
|
thinkingContent?: string;
|
||||||
syncButton?: AgentResponse['syncButton'];
|
stage?: string;
|
||||||
actionCards?: AgentResponse['actionCards'];
|
stageName?: string;
|
||||||
|
syncButton?: SyncButtonData;
|
||||||
|
actionCards?: ActionCard[];
|
||||||
timestamp: Date;
|
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> = ({
|
export const ChatArea: React.FC<ChatAreaProps> = ({
|
||||||
conversationId,
|
conversationId,
|
||||||
@@ -37,23 +100,42 @@ export const ChatArea: React.FC<ChatAreaProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const chatContainerRef = useRef<HTMLDivElement>(null);
|
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) {
|
if (chatContainerRef.current) {
|
||||||
chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight;
|
chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight;
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
}, [messages]);
|
}, [messages, streamContent, scrollToBottom]);
|
||||||
|
|
||||||
// 初始化欢迎消息
|
// 初始化欢迎消息
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (conversationId && messages.length === 0) {
|
if (conversationId && messages.length === 0) {
|
||||||
|
const currentStage = context?.currentStage || 'scientific_question';
|
||||||
|
const stageName = STAGE_NAMES[currentStage] || '科学问题梳理';
|
||||||
|
|
||||||
setMessages([{
|
setMessages([{
|
||||||
id: 'welcome',
|
id: 'welcome',
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
@@ -69,89 +151,102 @@ export const ChatArea: React.FC<ChatAreaProps> = ({
|
|||||||
|
|
||||||
完成这5个要素后,您可以**一键生成完整的研究方案**并下载为Word文档。
|
完成这5个要素后,您可以**一键生成完整的研究方案**并下载为Word文档。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
📍 **当前阶段**: ${stageName}
|
||||||
|
|
||||||
让我们开始吧!请先告诉我,您想研究什么问题?或者描述一下您的研究背景和想法。`,
|
让我们开始吧!请先告诉我,您想研究什么问题?或者描述一下您的研究背景和想法。`,
|
||||||
|
stage: currentStage,
|
||||||
|
stageName,
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
}]);
|
}]);
|
||||||
}
|
}
|
||||||
}, [conversationId, messages.length]);
|
}, [conversationId, messages.length, context]);
|
||||||
|
|
||||||
/**
|
// 处理流式响应完成
|
||||||
* 发送消息
|
useEffect(() => {
|
||||||
*/
|
if (streamStatus === 'complete' && streamContent) {
|
||||||
const handleSend = async () => {
|
// 解析 AI 响应中的 extracted_data(用于同步按钮)
|
||||||
if (!input.trim() || !conversationId) return;
|
const { cleanContent, extractedData } = parseExtractedData(streamContent);
|
||||||
|
|
||||||
const userMessage: Message = {
|
// 构建同步按钮数据
|
||||||
id: Date.now().toString(),
|
let syncButton: SyncButtonData | undefined;
|
||||||
role: 'user',
|
if (extractedData) {
|
||||||
content: input.trim(),
|
const stageCode = context?.currentStage || 'scientific_question';
|
||||||
timestamp: new Date(),
|
syncButton = {
|
||||||
|
stageCode,
|
||||||
|
extractedData,
|
||||||
|
label: `✅ 同步「${STAGE_NAMES[stageCode]}」到方案`,
|
||||||
|
disabled: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
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();
|
// 添加 AI 消息
|
||||||
if (result.success && result.data) {
|
|
||||||
const aiResponse: AgentResponse = result.data;
|
|
||||||
|
|
||||||
const aiMessage: Message = {
|
const aiMessage: Message = {
|
||||||
id: (Date.now() + 1).toString(),
|
id: Date.now().toString(),
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: aiResponse.content,
|
content: cleanContent,
|
||||||
thinkingContent: aiResponse.thinkingContent,
|
thinkingContent: streamThinking || undefined,
|
||||||
syncButton: aiResponse.syncButton,
|
stage: context?.currentStage,
|
||||||
actionCards: aiResponse.actionCards,
|
stageName: context?.stageName,
|
||||||
|
syncButton,
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
};
|
};
|
||||||
|
|
||||||
setMessages(prev => [...prev, aiMessage]);
|
setMessages(prev => [...prev, aiMessage]);
|
||||||
|
resetStream();
|
||||||
|
|
||||||
// 刷新上下文状态
|
// 刷新上下文状态
|
||||||
onContextUpdate();
|
onContextUpdate();
|
||||||
}
|
}
|
||||||
} catch (err) {
|
}, [streamStatus, streamContent, streamThinking, context, resetStream, onContextUpdate]);
|
||||||
console.error('[ChatArea] handleSend error:', err);
|
|
||||||
// 显示错误消息
|
// 处理流式错误
|
||||||
|
useEffect(() => {
|
||||||
|
if (streamStatus === 'error' && streamError) {
|
||||||
setMessages(prev => [...prev, {
|
setMessages(prev => [...prev, {
|
||||||
id: (Date.now() + 1).toString(),
|
id: Date.now().toString(),
|
||||||
role: 'system',
|
role: 'system',
|
||||||
content: '抱歉,消息发送失败。请稍后重试。',
|
content: `❌ 发送失败:${streamError}`,
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
}]);
|
}]);
|
||||||
} finally {
|
resetStream();
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
}, [streamStatus, streamError, resetStream]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理同步
|
* 发送消息(流式)
|
||||||
*/
|
*/
|
||||||
const handleSync = async (stageCode: string, data: Record<string, unknown>) => {
|
const handleSend = useCallback(async () => {
|
||||||
|
if (!input.trim() || !conversationId || isStreaming) return;
|
||||||
|
|
||||||
|
const userContent = input.trim();
|
||||||
|
|
||||||
|
// 添加用户消息
|
||||||
|
const userMessage: Message = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
role: 'user',
|
||||||
|
content: userContent,
|
||||||
|
timestamp: new Date(),
|
||||||
|
};
|
||||||
|
setMessages(prev => [...prev, userMessage]);
|
||||||
|
setInput('');
|
||||||
|
|
||||||
|
// 使用 useAIStream 发送消息(流式输出)
|
||||||
|
await sendStreamMessage(userContent, {
|
||||||
|
conversationId,
|
||||||
|
});
|
||||||
|
}, [input, conversationId, isStreaming, sendStreamMessage]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理同步到方案
|
||||||
|
*/
|
||||||
|
const handleSync = useCallback(async (stageCode: string, data: Record<string, unknown>) => {
|
||||||
if (!conversationId) return;
|
if (!conversationId) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const token = getAccessToken();
|
const token = getAccessToken();
|
||||||
const response = await fetch(`${API_BASE}/sync`, {
|
const response = await fetch('/api/v1/aia/protocol-agent/sync', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'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();
|
const result = await response.json();
|
||||||
if (result.success) {
|
|
||||||
// 添加系统消息
|
if (response.ok && result.success) {
|
||||||
const systemMsg: Message = {
|
// 添加成功提示
|
||||||
|
setMessages(prev => [...prev, {
|
||||||
id: Date.now().toString(),
|
id: Date.now().toString(),
|
||||||
role: 'system',
|
role: 'system',
|
||||||
content: result.data.message || '✅ 已同步到方案',
|
content: `✅ ${result.data?.message || `已同步「${STAGE_NAMES[stageCode] || stageCode}」到方案`}`,
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
};
|
}]);
|
||||||
setMessages(prev => [...prev, systemMsg]);
|
|
||||||
|
|
||||||
// 刷新上下文
|
// 刷新上下文状态
|
||||||
onContextUpdate();
|
onContextUpdate();
|
||||||
|
} else {
|
||||||
|
throw new Error(result.error || '同步失败');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[ChatArea] handleSync error:', 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]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理动作卡片点击
|
||||||
|
*/
|
||||||
|
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 发送
|
// 按 Enter 发送
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleSend();
|
handleSend();
|
||||||
}
|
}
|
||||||
};
|
}, [handleSend]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="chat-area">
|
<section className="chat-area">
|
||||||
@@ -207,7 +329,7 @@ export const ChatArea: React.FC<ChatAreaProps> = ({
|
|||||||
<User size={16} />
|
<User size={16} />
|
||||||
</div>
|
</div>
|
||||||
<div className="message-content">
|
<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">
|
<div className="chat-bubble user-bubble">
|
||||||
{msg.content}
|
{msg.content}
|
||||||
</div>
|
</div>
|
||||||
@@ -221,24 +343,44 @@ export const ChatArea: React.FC<ChatAreaProps> = ({
|
|||||||
<Sparkles size={16} />
|
<Sparkles size={16} />
|
||||||
</div>
|
</div>
|
||||||
<div className="message-content">
|
<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">
|
<div className="chat-bubble assistant-bubble">
|
||||||
{msg.content}
|
<MarkdownContent content={msg.content} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 同步按钮 */}
|
{/* 同步按钮 */}
|
||||||
{msg.syncButton && (
|
{msg.syncButton && !msg.syncButton.disabled && (
|
||||||
<SyncButton
|
<SyncButton
|
||||||
syncData={msg.syncButton}
|
syncData={msg.syncButton}
|
||||||
onSync={handleSync}
|
onSync={handleSync}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Action Cards */}
|
{/* 动作卡片 */}
|
||||||
{msg.actionCards && msg.actionCards.length > 0 && (
|
{msg.actionCards && msg.actionCards.length > 0 && (
|
||||||
<div className="action-cards">
|
<div className="action-cards">
|
||||||
{msg.actionCards.map(card => (
|
{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>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -256,17 +398,36 @@ export const ChatArea: React.FC<ChatAreaProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{loading && (
|
{/* 流式输出中的消息(打字机效果) */}
|
||||||
|
{(isStreaming || isThinking) && (
|
||||||
<div className="message-row assistant-row">
|
<div className="message-row assistant-row">
|
||||||
<div className="avatar assistant-avatar">
|
<div className="avatar assistant-avatar">
|
||||||
<Sparkles size={16} />
|
<Sparkles size={16} />
|
||||||
</div>
|
</div>
|
||||||
<div className="message-content">
|
<div className="message-content">
|
||||||
<div className="chat-bubble assistant-bubble thinking">
|
<div className="message-meta">
|
||||||
<span className="thinking-dot"></span>
|
Protocol Agent
|
||||||
<span className="thinking-dot"></span>
|
{context?.stageName && <span className="stage-tag">{context.stageName}</span>}
|
||||||
<span className="thinking-dot"></span>
|
<span className="timestamp">• 正在回复...</span>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -278,18 +439,18 @@ export const ChatArea: React.FC<ChatAreaProps> = ({
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="input-field"
|
className="input-field"
|
||||||
placeholder="输入您的指令..."
|
placeholder="输入您的问题或想法..."
|
||||||
value={input}
|
value={input}
|
||||||
onChange={(e) => setInput(e.target.value)}
|
onChange={(e) => setInput(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
disabled={loading || !conversationId}
|
disabled={isStreaming || !conversationId}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
className="send-btn"
|
className="send-btn"
|
||||||
onClick={handleSend}
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -306,5 +467,3 @@ function formatTime(date: Date): string {
|
|||||||
minute: '2-digit'
|
minute: '2-digit'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
/**
|
||||||
|
* MarkdownContent - 简单 Markdown 渲染组件
|
||||||
|
*
|
||||||
|
* 处理基本的 Markdown 格式:
|
||||||
|
* - **粗体**
|
||||||
|
* - *斜体*
|
||||||
|
* - 换行
|
||||||
|
* - 标题 (###)
|
||||||
|
* - 列表 (- / 1.)
|
||||||
|
* - 代码 `code`
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface MarkdownContentProps {
|
||||||
|
content: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 Markdown 文本转换为 React 元素
|
||||||
|
*/
|
||||||
|
function parseMarkdown(text: string): React.ReactNode[] {
|
||||||
|
const lines = text.split('\n');
|
||||||
|
const elements: React.ReactNode[] = [];
|
||||||
|
let listItems: string[] = [];
|
||||||
|
let listType: 'ul' | 'ol' | null = null;
|
||||||
|
let key = 0;
|
||||||
|
|
||||||
|
const flushList = () => {
|
||||||
|
if (listItems.length > 0) {
|
||||||
|
const ListTag = listType === 'ol' ? 'ol' : 'ul';
|
||||||
|
elements.push(
|
||||||
|
<ListTag key={key++}>
|
||||||
|
{listItems.map((item, i) => (
|
||||||
|
<li key={i}>{formatInline(item)}</li>
|
||||||
|
))}
|
||||||
|
</ListTag>
|
||||||
|
);
|
||||||
|
listItems = [];
|
||||||
|
listType = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
// 处理标题
|
||||||
|
if (line.startsWith('### ')) {
|
||||||
|
flushList();
|
||||||
|
elements.push(<h3 key={key++}>{formatInline(line.slice(4))}</h3>);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line.startsWith('## ')) {
|
||||||
|
flushList();
|
||||||
|
elements.push(<h2 key={key++}>{formatInline(line.slice(3))}</h2>);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line.startsWith('# ')) {
|
||||||
|
flushList();
|
||||||
|
elements.push(<h1 key={key++}>{formatInline(line.slice(2))}</h1>);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理无序列表
|
||||||
|
if (line.match(/^[\-\*]\s+/)) {
|
||||||
|
if (listType !== 'ul') {
|
||||||
|
flushList();
|
||||||
|
listType = 'ul';
|
||||||
|
}
|
||||||
|
listItems.push(line.replace(/^[\-\*]\s+/, ''));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理有序列表
|
||||||
|
if (line.match(/^\d+\.\s+/)) {
|
||||||
|
if (listType !== 'ol') {
|
||||||
|
flushList();
|
||||||
|
listType = 'ol';
|
||||||
|
}
|
||||||
|
listItems.push(line.replace(/^\d+\.\s+/, ''));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 非列表内容,先清空列表
|
||||||
|
flushList();
|
||||||
|
|
||||||
|
// 空行
|
||||||
|
if (line.trim() === '') {
|
||||||
|
elements.push(<br key={key++} />);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 普通段落
|
||||||
|
elements.push(<p key={key++}>{formatInline(line)}</p>);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理剩余列表
|
||||||
|
flushList();
|
||||||
|
|
||||||
|
return elements;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理行内格式(粗体、斜体、代码)
|
||||||
|
*/
|
||||||
|
function formatInline(text: string): React.ReactNode {
|
||||||
|
// 按顺序处理:代码块 > 粗体 > 斜体
|
||||||
|
const parts: React.ReactNode[] = [];
|
||||||
|
let remaining = text;
|
||||||
|
let key = 0;
|
||||||
|
|
||||||
|
// 处理行内代码 `code`
|
||||||
|
const codeRegex = /`([^`]+)`/g;
|
||||||
|
let lastIndex = 0;
|
||||||
|
let match;
|
||||||
|
|
||||||
|
const tempParts: Array<{ type: 'text' | 'code'; content: string }> = [];
|
||||||
|
|
||||||
|
while ((match = codeRegex.exec(remaining)) !== null) {
|
||||||
|
if (match.index > lastIndex) {
|
||||||
|
tempParts.push({ type: 'text', content: remaining.slice(lastIndex, match.index) });
|
||||||
|
}
|
||||||
|
tempParts.push({ type: 'code', content: match[1] });
|
||||||
|
lastIndex = match.index + match[0].length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastIndex < remaining.length) {
|
||||||
|
tempParts.push({ type: 'text', content: remaining.slice(lastIndex) });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理每个部分
|
||||||
|
for (const part of tempParts) {
|
||||||
|
if (part.type === 'code') {
|
||||||
|
parts.push(<code key={key++}>{part.content}</code>);
|
||||||
|
} else {
|
||||||
|
// 处理文本中的粗体和斜体
|
||||||
|
parts.push(...formatBoldItalic(part.content, key));
|
||||||
|
key += 10; // 预留足够的 key 空间
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.length === 1 ? parts[0] : <>{parts}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理粗体和斜体
|
||||||
|
*/
|
||||||
|
function formatBoldItalic(text: string, startKey: number): React.ReactNode[] {
|
||||||
|
const parts: React.ReactNode[] = [];
|
||||||
|
let key = startKey;
|
||||||
|
|
||||||
|
// 先处理粗体 **text**
|
||||||
|
const boldRegex = /\*\*([^*]+)\*\*/g;
|
||||||
|
let lastIndex = 0;
|
||||||
|
let match;
|
||||||
|
|
||||||
|
while ((match = boldRegex.exec(text)) !== null) {
|
||||||
|
if (match.index > lastIndex) {
|
||||||
|
// 处理前面的普通文本(可能包含斜体)
|
||||||
|
parts.push(...formatItalic(text.slice(lastIndex, match.index), key));
|
||||||
|
key += 5;
|
||||||
|
}
|
||||||
|
parts.push(<strong key={key++}>{match[1]}</strong>);
|
||||||
|
lastIndex = match.index + match[0].length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastIndex < text.length) {
|
||||||
|
parts.push(...formatItalic(text.slice(lastIndex), key));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts.length === 0) {
|
||||||
|
parts.push(...formatItalic(text, key));
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理斜体
|
||||||
|
*/
|
||||||
|
function formatItalic(text: string, startKey: number): React.ReactNode[] {
|
||||||
|
const parts: React.ReactNode[] = [];
|
||||||
|
let key = startKey;
|
||||||
|
|
||||||
|
// 处理斜体 *text*(但不是 **)
|
||||||
|
const italicRegex = /(?<!\*)\*([^*]+)\*(?!\*)/g;
|
||||||
|
let lastIndex = 0;
|
||||||
|
let match;
|
||||||
|
|
||||||
|
while ((match = italicRegex.exec(text)) !== null) {
|
||||||
|
if (match.index > lastIndex) {
|
||||||
|
parts.push(<span key={key++}>{text.slice(lastIndex, match.index)}</span>);
|
||||||
|
}
|
||||||
|
parts.push(<em key={key++}>{match[1]}</em>);
|
||||||
|
lastIndex = match.index + match[0].length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastIndex < text.length) {
|
||||||
|
parts.push(<span key={key++}>{text.slice(lastIndex)}</span>);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts.length === 0) {
|
||||||
|
parts.push(<span key={key++}>{text}</span>);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MarkdownContent: React.FC<MarkdownContentProps> = ({
|
||||||
|
content,
|
||||||
|
className = ''
|
||||||
|
}) => {
|
||||||
|
const elements = parseMarkdown(content);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`markdown-content ${className}`}>
|
||||||
|
{elements}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MarkdownContent;
|
||||||
|
|
||||||
@@ -18,6 +18,7 @@ import type {
|
|||||||
interface StageCardProps {
|
interface StageCardProps {
|
||||||
stage: StageInfo;
|
stage: StageInfo;
|
||||||
index: number;
|
index: number;
|
||||||
|
onEdit?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STAGE_TITLES: Record<string, string> = {
|
const STAGE_TITLES: Record<string, string> = {
|
||||||
@@ -28,7 +29,7 @@ const STAGE_TITLES: Record<string, string> = {
|
|||||||
endpoints: '观察指标',
|
endpoints: '观察指标',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const StageCard: React.FC<StageCardProps> = ({ stage, index }) => {
|
export const StageCard: React.FC<StageCardProps> = ({ stage, index, onEdit }) => {
|
||||||
const { stageCode, status, data } = stage;
|
const { stageCode, status, data } = stage;
|
||||||
const title = STAGE_TITLES[stageCode] || stage.stageName;
|
const title = STAGE_TITLES[stageCode] || stage.stageName;
|
||||||
const number = (index + 1).toString().padStart(2, '0');
|
const number = (index + 1).toString().padStart(2, '0');
|
||||||
@@ -45,9 +46,20 @@ export const StageCard: React.FC<StageCardProps> = ({ stage, index }) => {
|
|||||||
<div className={cardClasses}>
|
<div className={cardClasses}>
|
||||||
<div className="stage-header">
|
<div className="stage-header">
|
||||||
<h3 className="stage-number">{number} {title}</h3>
|
<h3 className="stage-number">{number} {title}</h3>
|
||||||
|
<div className="stage-actions">
|
||||||
|
{status === 'completed' && onEdit && (
|
||||||
|
<button
|
||||||
|
className="edit-btn"
|
||||||
|
onClick={onEdit}
|
||||||
|
title="编辑"
|
||||||
|
>
|
||||||
|
<Edit2 size={12} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{status === 'completed' && <Check size={14} className="check-icon" />}
|
{status === 'completed' && <Check size={14} className="check-icon" />}
|
||||||
{status === 'current' && <Loader2 size={14} className="loader-icon animate-spin" />}
|
{status === 'current' && <Loader2 size={14} className="loader-icon animate-spin" />}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{data && <StageDataRenderer stageCode={stageCode} data={data} />}
|
{data && <StageDataRenderer stageCode={stageCode} data={data} />}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,266 @@
|
|||||||
|
/**
|
||||||
|
* Stage Edit Modal - 阶段数据编辑弹窗
|
||||||
|
*
|
||||||
|
* 支持用户编辑已同步的阶段数据
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { X, Save } from 'lucide-react';
|
||||||
|
import type { StageInfo } from '../types';
|
||||||
|
|
||||||
|
interface StageEditModalProps {
|
||||||
|
stage: StageInfo;
|
||||||
|
onSave: (stageCode: string, data: Record<string, unknown>) => Promise<void>;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STAGE_TITLES: Record<string, string> = {
|
||||||
|
scientific_question: '科学问题',
|
||||||
|
pico: 'PICO要素',
|
||||||
|
study_design: '研究设计',
|
||||||
|
sample_size: '样本量',
|
||||||
|
endpoints: '观察指标',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const StageEditModal: React.FC<StageEditModalProps> = ({ stage, onSave, onClose }) => {
|
||||||
|
const [formData, setFormData] = useState<Record<string, any>>({});
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (stage.data) {
|
||||||
|
setFormData({ ...stage.data as Record<string, any> });
|
||||||
|
}
|
||||||
|
}, [stage]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await onSave(stage.stageCode, formData);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderFormFields = () => {
|
||||||
|
switch (stage.stageCode) {
|
||||||
|
case 'scientific_question':
|
||||||
|
return (
|
||||||
|
<div className="form-group">
|
||||||
|
<label>科学问题(一句话)</label>
|
||||||
|
<textarea
|
||||||
|
value={formData.content || ''}
|
||||||
|
onChange={(e) => setFormData({ ...formData, content: e.target.value })}
|
||||||
|
rows={3}
|
||||||
|
placeholder="请输入凝练后的科学问题..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'pico':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>P - 研究人群</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.population || ''}
|
||||||
|
onChange={(e) => setFormData({ ...formData, population: e.target.value })}
|
||||||
|
placeholder="研究人群..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>I - 干预措施</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.intervention || ''}
|
||||||
|
onChange={(e) => setFormData({ ...formData, intervention: e.target.value })}
|
||||||
|
placeholder="干预措施..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>C - 对照</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.comparison || ''}
|
||||||
|
onChange={(e) => setFormData({ ...formData, comparison: e.target.value })}
|
||||||
|
placeholder="对照组..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>O - 结局</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.outcome || ''}
|
||||||
|
onChange={(e) => setFormData({ ...formData, outcome: e.target.value })}
|
||||||
|
placeholder="结局指标..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'study_design':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>研究类型</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.studyType || ''}
|
||||||
|
onChange={(e) => setFormData({ ...formData, studyType: e.target.value })}
|
||||||
|
placeholder="如:随机对照试验、队列研究..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>设计特征(逗号分隔)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={Array.isArray(formData.design) ? formData.design.join(', ') : ''}
|
||||||
|
onChange={(e) => setFormData({
|
||||||
|
...formData,
|
||||||
|
design: e.target.value.split(',').map(s => s.trim()).filter(Boolean)
|
||||||
|
})}
|
||||||
|
placeholder="如:双盲, 平行对照..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'sample_size':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>总样本量</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={formData.sampleSize || ''}
|
||||||
|
onChange={(e) => setFormData({ ...formData, sampleSize: parseInt(e.target.value) || 0 })}
|
||||||
|
placeholder="样本量数字..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<div className="form-group half">
|
||||||
|
<label>α (显著性水平)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
value={formData.calculation?.alpha || ''}
|
||||||
|
onChange={(e) => setFormData({
|
||||||
|
...formData,
|
||||||
|
calculation: { ...formData.calculation, alpha: parseFloat(e.target.value) || 0.05 }
|
||||||
|
})}
|
||||||
|
placeholder="0.05"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group half">
|
||||||
|
<label>Power (检验效能)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
value={formData.calculation?.power || ''}
|
||||||
|
onChange={(e) => setFormData({
|
||||||
|
...formData,
|
||||||
|
calculation: { ...formData.calculation, power: parseFloat(e.target.value) || 0.8 }
|
||||||
|
})}
|
||||||
|
placeholder="0.80"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'endpoints':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>主要结局指标(逗号分隔)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.outcomes?.primary?.join(', ') || ''}
|
||||||
|
onChange={(e) => setFormData({
|
||||||
|
...formData,
|
||||||
|
outcomes: {
|
||||||
|
...formData.outcomes,
|
||||||
|
primary: e.target.value.split(',').map(s => s.trim()).filter(Boolean)
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
placeholder="如:死亡率, 住院时间..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>次要结局指标(逗号分隔)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.outcomes?.secondary?.join(', ') || ''}
|
||||||
|
onChange={(e) => setFormData({
|
||||||
|
...formData,
|
||||||
|
outcomes: {
|
||||||
|
...formData.outcomes,
|
||||||
|
secondary: e.target.value.split(',').map(s => s.trim()).filter(Boolean)
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
placeholder="如:生活质量, 并发症..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>混杂因素(逗号分隔)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.confounders?.join(', ') || ''}
|
||||||
|
onChange={(e) => setFormData({
|
||||||
|
...formData,
|
||||||
|
confounders: e.target.value.split(',').map(s => s.trim()).filter(Boolean)
|
||||||
|
})}
|
||||||
|
placeholder="如:年龄, 性别, 基础疾病..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<div className="form-group">
|
||||||
|
<label>数据 (JSON)</label>
|
||||||
|
<textarea
|
||||||
|
value={JSON.stringify(formData, null, 2)}
|
||||||
|
onChange={(e) => {
|
||||||
|
try {
|
||||||
|
setFormData(JSON.parse(e.target.value));
|
||||||
|
} catch {}
|
||||||
|
}}
|
||||||
|
rows={6}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="stage-edit-modal-overlay" onClick={onClose}>
|
||||||
|
<div className="stage-edit-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<h3>编辑 {STAGE_TITLES[stage.stageCode] || stage.stageName}</h3>
|
||||||
|
<button className="close-btn" onClick={onClose}>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="modal-body">
|
||||||
|
{renderFormFields()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button type="button" className="cancel-btn" onClick={onClose}>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="save-btn" disabled={saving}>
|
||||||
|
{saving ? '保存中...' : <><Save size={14} /> 保存</>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
@@ -2,18 +2,33 @@
|
|||||||
* State Panel - 方案状态面板
|
* State Panel - 方案状态面板
|
||||||
*
|
*
|
||||||
* 100%还原原型图右侧状态面板设计
|
* 100%还原原型图右侧状态面板设计
|
||||||
|
* 支持编辑已同步的数据
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import { FileText, Check, Loader2 } from 'lucide-react';
|
import { FileText, Check, Loader2 } from 'lucide-react';
|
||||||
import type { ProtocolContext, StageInfo } from '../types';
|
import type { ProtocolContext, StageInfo } from '../types';
|
||||||
import { StageCard } from './StageCard';
|
import { StageCard } from './StageCard';
|
||||||
|
import { StageEditModal } from './StageEditModal';
|
||||||
|
|
||||||
interface StatePanelProps {
|
interface StatePanelProps {
|
||||||
context: ProtocolContext | null;
|
context: ProtocolContext | null;
|
||||||
|
onStageUpdate?: (stageCode: string, data: Record<string, unknown>) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const StatePanel: React.FC<StatePanelProps> = ({ context }) => {
|
export const StatePanel: React.FC<StatePanelProps> = ({ context, onStageUpdate }) => {
|
||||||
|
const [editingStage, setEditingStage] = useState<StageInfo | null>(null);
|
||||||
|
|
||||||
|
const handleEdit = (stage: StageInfo) => {
|
||||||
|
setEditingStage(stage);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveEdit = async (stageCode: string, newData: Record<string, unknown>) => {
|
||||||
|
if (onStageUpdate) {
|
||||||
|
await onStageUpdate(stageCode, newData);
|
||||||
|
}
|
||||||
|
setEditingStage(null);
|
||||||
|
};
|
||||||
if (!context) {
|
if (!context) {
|
||||||
return (
|
return (
|
||||||
<aside className="state-panel">
|
<aside className="state-panel">
|
||||||
@@ -67,9 +82,19 @@ export const StatePanel: React.FC<StatePanelProps> = ({ context }) => {
|
|||||||
key={stage.stageCode}
|
key={stage.stageCode}
|
||||||
stage={stage}
|
stage={stage}
|
||||||
index={index}
|
index={index}
|
||||||
|
onEdit={stage.status === 'completed' ? () => handleEdit(stage) : undefined}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{/* 编辑弹窗 */}
|
||||||
|
{editingStage && (
|
||||||
|
<StageEditModal
|
||||||
|
stage={editingStage}
|
||||||
|
onSave={handleSaveEdit}
|
||||||
|
onClose={() => setEditingStage(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 一键生成按钮 */}
|
{/* 一键生成按钮 */}
|
||||||
{canGenerate && (
|
{canGenerate && (
|
||||||
<div className="generate-section">
|
<div className="generate-section">
|
||||||
|
|||||||
@@ -35,8 +35,20 @@ export function useProtocolContext(conversationId?: string) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (response.status === 404) {
|
if (response.status === 404) {
|
||||||
// 上下文不存在,返回空
|
// 上下文不存在,返回默认上下文(允许用户开始对话)
|
||||||
setContext(null);
|
setContext({
|
||||||
|
currentStage: 'scientific_question',
|
||||||
|
stageName: '科学问题梳理',
|
||||||
|
progress: 0,
|
||||||
|
stages: [
|
||||||
|
{ stageCode: 'scientific_question', stageName: '科学问题梳理', status: 'current', data: null },
|
||||||
|
{ stageCode: 'pico', stageName: 'PICO要素', status: 'pending', data: null },
|
||||||
|
{ stageCode: 'study_design', stageName: '研究设计', status: 'pending', data: null },
|
||||||
|
{ stageCode: 'sample_size', stageName: '样本量计算', status: 'pending', data: null },
|
||||||
|
{ stageCode: 'endpoints', stageName: '观察指标', status: 'pending', data: null },
|
||||||
|
],
|
||||||
|
canGenerate: false,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,10 +69,10 @@ export function useProtocolContext(conversationId?: string) {
|
|||||||
}, [conversationId]);
|
}, [conversationId]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 刷新上下文
|
* 刷新上下文(返回 Promise 以便等待完成)
|
||||||
*/
|
*/
|
||||||
const refreshContext = useCallback(() => {
|
const refreshContext = useCallback(async () => {
|
||||||
fetchContext();
|
await fetchContext();
|
||||||
}, [fetchContext]);
|
}, [fetchContext]);
|
||||||
|
|
||||||
// 初次加载和conversationId变化时获取上下文
|
// 初次加载和conversationId变化时获取上下文
|
||||||
|
|||||||
@@ -6,11 +6,16 @@
|
|||||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Noto+Sans+SC:wght@300;400;500;700&display=swap');
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Noto+Sans+SC:wght@300;400;500;700&display=swap');
|
||||||
|
|
||||||
/* ============================================ */
|
/* ============================================ */
|
||||||
/* 全局样式 */
|
/* 全局样式 - 全屏覆盖(与ChatWorkspace一致) */
|
||||||
/* ============================================ */
|
/* ============================================ */
|
||||||
.protocol-agent-page {
|
.protocol-agent-page {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 100;
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 100vh;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
font-family: 'Inter', 'Noto Sans SC', sans-serif;
|
font-family: 'Inter', 'Noto Sans SC', sans-serif;
|
||||||
background: #FFFFFF;
|
background: #FFFFFF;
|
||||||
@@ -445,8 +450,252 @@
|
|||||||
padding: 16px;
|
padding: 16px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.6;
|
line-height: 1.8;
|
||||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Markdown 格式支持 */
|
||||||
|
.chat-bubble h1,
|
||||||
|
.chat-bubble h2,
|
||||||
|
.chat-bubble h3 {
|
||||||
|
margin: 16px 0 8px 0;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-bubble h1 { font-size: 1.25em; }
|
||||||
|
.chat-bubble h2 { font-size: 1.15em; }
|
||||||
|
.chat-bubble h3 { font-size: 1.05em; }
|
||||||
|
|
||||||
|
.chat-bubble p {
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-bubble ul,
|
||||||
|
.chat-bubble ol {
|
||||||
|
margin: 8px 0;
|
||||||
|
padding-left: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-bubble li {
|
||||||
|
margin: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-bubble strong,
|
||||||
|
.chat-bubble b {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1F2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-bubble em,
|
||||||
|
.chat-bubble i {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-bubble code {
|
||||||
|
background: #F3F4F6;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: 'Monaco', 'Consolas', monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: #6366F1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-bubble pre {
|
||||||
|
background: #1F2937;
|
||||||
|
color: #F9FAFB;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-bubble pre code {
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-bubble blockquote {
|
||||||
|
border-left: 4px solid #6366F1;
|
||||||
|
margin: 12px 0;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: #F9FAFB;
|
||||||
|
color: #4B5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* MarkdownContent 组件样式 */
|
||||||
|
.markdown-content {
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content p {
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content p:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content h1,
|
||||||
|
.markdown-content h2,
|
||||||
|
.markdown-content h3 {
|
||||||
|
margin: 16px 0 8px 0;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content h1:first-child,
|
||||||
|
.markdown-content h2:first-child,
|
||||||
|
.markdown-content h3:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content ul,
|
||||||
|
.markdown-content ol {
|
||||||
|
margin: 8px 0 12px 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content li {
|
||||||
|
margin: 6px 0;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content li::marker {
|
||||||
|
color: #6366F1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content strong {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1F2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content em {
|
||||||
|
font-style: italic;
|
||||||
|
color: #4B5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content code {
|
||||||
|
background: rgba(99, 102, 241, 0.1);
|
||||||
|
color: #6366F1;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: 'Monaco', 'Consolas', 'Courier New', monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================ */
|
||||||
|
/* 阶段标签 */
|
||||||
|
/* ============================================ */
|
||||||
|
|
||||||
|
.message-meta .stage-tag {
|
||||||
|
display: inline-block;
|
||||||
|
background: linear-gradient(135deg, #6366F1, #8B5CF6);
|
||||||
|
color: white;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-left: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-meta .timestamp {
|
||||||
|
color: #9CA3AF;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================ */
|
||||||
|
/* 动作卡片 */
|
||||||
|
/* ============================================ */
|
||||||
|
|
||||||
|
.action-cards {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 14px 18px;
|
||||||
|
background: linear-gradient(135deg, #F8FAFC, #F1F5F9);
|
||||||
|
border: 1px solid #E2E8F0;
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
position: relative;
|
||||||
|
min-width: 200px;
|
||||||
|
max-width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-card:hover {
|
||||||
|
background: linear-gradient(135deg, #EEF2FF, #E0E7FF);
|
||||||
|
border-color: #6366F1;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-card-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1F2937;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-card-desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6B7280;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-card-icon {
|
||||||
|
position: absolute;
|
||||||
|
top: 14px;
|
||||||
|
right: 14px;
|
||||||
|
color: #9CA3AF;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-card:hover .action-card-icon {
|
||||||
|
color: #6366F1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================ */
|
||||||
|
/* 加载状态 */
|
||||||
|
/* ============================================ */
|
||||||
|
|
||||||
|
.chat-bubble.loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
color: #6B7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 流式输出光标 */
|
||||||
|
.streaming-cursor {
|
||||||
|
display: inline-block;
|
||||||
|
animation: blink 0.8s step-end infinite;
|
||||||
|
color: #6366F1;
|
||||||
|
margin-left: 2px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blink {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-bubble {
|
.assistant-bubble {
|
||||||
@@ -1190,6 +1439,201 @@
|
|||||||
background: #94A3B8;
|
background: #94A3B8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================ */
|
||||||
|
/* 阶段编辑弹窗 */
|
||||||
|
/* ============================================ */
|
||||||
|
.stage-edit-modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-edit-modal {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
width: 480px;
|
||||||
|
max-width: 90%;
|
||||||
|
max-height: 80vh;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-edit-modal .modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid #E5E7EB;
|
||||||
|
background: #F9FAFB;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-edit-modal .modal-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-edit-modal .close-btn {
|
||||||
|
padding: 4px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #6B7280;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-edit-modal .close-btn:hover {
|
||||||
|
background: #E5E7EB;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-edit-modal .modal-body {
|
||||||
|
padding: 20px;
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: 50vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-edit-modal .form-group {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-edit-modal .form-group label {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #374151;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-edit-modal .form-group input,
|
||||||
|
.stage-edit-modal .form-group textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid #D1D5DB;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #111827;
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-edit-modal .form-group input:focus,
|
||||||
|
.stage-edit-modal .form-group textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #3B82F6;
|
||||||
|
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-edit-modal .form-group textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-edit-modal .form-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-edit-modal .form-group.half {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-edit-modal .modal-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-top: 1px solid #E5E7EB;
|
||||||
|
background: #F9FAFB;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-edit-modal .cancel-btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #D1D5DB;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #374151;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-edit-modal .cancel-btn:hover {
|
||||||
|
background: #F3F4F6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-edit-modal .save-btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: #3B82F6;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-edit-modal .save-btn:hover:not(:disabled) {
|
||||||
|
background: #2563EB;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-edit-modal .save-btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 编辑按钮样式 */
|
||||||
|
.stage-header .stage-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-header .edit-btn {
|
||||||
|
padding: 4px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #9CA3AF;
|
||||||
|
transition: all 0.2s;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-card:hover .edit-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-header .edit-btn:hover {
|
||||||
|
background: #E5E7EB;
|
||||||
|
color: #3B82F6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 流式输出光标 */
|
||||||
|
.streaming-cursor {
|
||||||
|
display: inline-block;
|
||||||
|
animation: blink 1s step-end infinite;
|
||||||
|
color: #3B82F6;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blink {
|
||||||
|
0%, 50% { opacity: 1; }
|
||||||
|
51%, 100% { opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
/* ============================================ */
|
/* ============================================ */
|
||||||
/* 响应式 */
|
/* 响应式 */
|
||||||
/* ============================================ */
|
/* ============================================ */
|
||||||
|
|||||||
@@ -110,10 +110,10 @@ export interface ProtocolContext {
|
|||||||
* 同步按钮数据
|
* 同步按钮数据
|
||||||
*/
|
*/
|
||||||
export interface SyncButtonData {
|
export interface SyncButtonData {
|
||||||
stageCode: ProtocolStageCode;
|
stageCode: ProtocolStageCode | string;
|
||||||
extractedData: Record<string, unknown>;
|
extractedData: Record<string, unknown>;
|
||||||
label: string;
|
label: string;
|
||||||
disabled: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user