feat(aia): Protocol Agent streaming + editable state panel + protocol generation plan

Day 2 Development (2026-01-24):

Backend Enhancements:
- Implement SSE streaming in ProtocolAgentController using createStreamingService
- Add data condensation via LLM in ProtocolOrchestrator.handleProtocolSync
- Support stage editing without resetting progress
- Add explicit JSON output format for each stage in system prompt
- Create independent seed script for Protocol Agent (seed-protocol-agent.ts)

Frontend Improvements:
- Integrate useAIStream hook for typewriter effect in ChatArea
- Add MarkdownContent component for basic Markdown rendering
- Implement StageEditModal for editing stage data (scientific question, PICO, etc.)
- Add edit button to StageCard (visible on hover)
- Fix routing paths from /aia to /ai-qa
- Enhance CSS with full-screen layout and Markdown styles

New Documentation:
- One-click protocol generation development plan (v1.1)
- Editor selection evaluation (Novel vs BlockNote vs Tiptap)
- Novel fork strategy for AI-native editing

Technical Decisions:
- Choose Novel (Fork) as protocol editor for AI-first design
- Two-stage progressive generation: summary in chat, full protocol in editor
- 10-day development plan for protocol generation feature

Code Stats:
- Backend: 3 files modified, 1 new file
- Frontend: 9 files modified, 2 new files
- Docs: 3 new files

Status: Streaming and editable features working, protocol generation pending
This commit is contained in:
2026-01-24 23:06:33 +08:00
parent 596f2dfc02
commit 4d7d97ca19
18 changed files with 2708 additions and 192 deletions

View File

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

View File

@@ -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. 创建租户模块订阅

View File

@@ -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,46 +41,95 @@ 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> {
const { conversationId, content } = request.body;
const userId = (request as any).user?.userId;
if (!userId) {
reply.code(401).send({ error: 'Unauthorized' });
return;
}
if (!conversationId || !content) {
reply.code(400).send({ error: 'Missing required fields: conversationId, content' });
return;
}
try { try {
const { conversationId, content, messageId } = request.body; // 1. 确保上下文存在
const userId = (request as any).user?.userId; const contextService = this.orchestrator.getContextService();
const context = await contextService.getOrCreateContext(conversationId, userId);
// 2. 构建包含上下文的消息
const messages = await this.buildMessagesWithContext(conversationId, content, context);
// 3. 保存用户消息到数据库
await this.prisma.message.create({
data: {
conversationId,
role: 'user',
content,
},
});
if (!userId) { // 4. 使用通用 StreamingService 流式输出
reply.code(401).send({ error: 'Unauthorized' }); const streamingService = createStreamingService(reply, {
return; model: 'deepseek-v3',
} temperature: 0.7,
maxTokens: 4096,
if (!conversationId || !content) { enableDeepThinking: false,
reply.code(400).send({ error: 'Missing required fields: conversationId, content' });
return;
}
const response = await this.orchestrator.handleMessage({
conversationId,
userId, userId,
content, conversationId,
messageId,
}); });
reply.send({ await streamingService.streamGenerate(messages, {
success: true, onComplete: async (fullContent, thinkingContent) => {
data: response, // 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

View File

@@ -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;
const currentIndex = STAGE_ORDER.indexOf(stage); let nextStage: ProtocolStageCode | undefined;
const nextStage = currentIndex < STAGE_ORDER.length - 1
? STAGE_ORDER[currentIndex + 1]
: undefined;
// 标记当前阶段完成,更新到下一阶段 if (isAlreadyCompleted) {
const context = await this.contextService.completeStage( // 已完成的阶段:只更新数据,不改变当前阶段
conversationId, context = await this.contextService.getContext(conversationId) as ProtocolContextData;
stage, } else {
nextStage // 首次完成:标记完成并进入下一阶段
); const currentIndex = STAGE_ORDER.indexOf(stage);
nextStage = currentIndex < STAGE_ORDER.length - 1
? STAGE_ORDER[currentIndex + 1]
: undefined;
context = await this.contextService.completeStage(
conversationId,
stage,
nextStage
);
}
// 检查是否所有阶段都已完成 // 检查是否所有阶段都已完成
const allCompleted = this.contextService.isAllStagesCompleted(context); const allCompleted = this.contextService.isAllStagesCompleted(context);
@@ -168,14 +192,104 @@ export class ProtocolOrchestrator extends BaseAgentOrchestrator {
success: true, success: true,
context, context,
nextStage, nextStage,
message: allCompleted condensedData: finalData,
? '🎉 所有核心要素已完成!您可以点击「一键生成研究方案」生成完整方案。' message: isAlreadyCompleted
: nextStage ? `✅ 已更新「${STAGE_NAMES[stage]}`
? `已同步${STAGE_NAMES[stage]},进入${STAGE_NAMES[nextStage]}阶段` : allCompleted
: `已同步${STAGE_NAMES[stage]}`, ? '🎉 所有核心要素已完成!您可以点击「一键生成研究方案」生成完整方案。'
: nextStage
? `已同步${STAGE_NAMES[stage]},进入${STAGE_NAMES[nextStage]}阶段`
: `已同步${STAGE_NAMES[stage]}`,
}; };
} }
/**
* 使用 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上下文服务
*/ */

View File

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

View 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 1Fork 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)**,更新开发计划

View 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) 是完全兼容的,用户数据不会丢。

View 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\>
);
}

View File

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

View File

@@ -5,7 +5,7 @@
* 三栏布局:可折叠侧边栏 + 聊天区 + 状态面板 * 三栏布局:可折叠侧边栏 + 聊天区 + 状态面板
*/ */
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { 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>

View File

@@ -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) {
// 解析 AI 响应中的 extracted_data用于同步按钮
const { cleanContent, extractedData } = parseExtractedData(streamContent);
// 构建同步按钮数据
let syncButton: SyncButtonData | undefined;
if (extractedData) {
const stageCode = context?.currentStage || 'scientific_question';
syncButton = {
stageCode,
extractedData,
label: `✅ 同步「${STAGE_NAMES[stageCode]}」到方案`,
disabled: false,
};
}
// 添加 AI 消息
const aiMessage: Message = {
id: Date.now().toString(),
role: 'assistant',
content: cleanContent,
thinkingContent: streamThinking || undefined,
stage: context?.currentStage,
stageName: context?.stageName,
syncButton,
timestamp: new Date(),
};
setMessages(prev => [...prev, aiMessage]);
resetStream();
// 刷新上下文状态
onContextUpdate();
}
}, [streamStatus, streamContent, streamThinking, context, resetStream, onContextUpdate]);
// 处理流式错误
useEffect(() => {
if (streamStatus === 'error' && streamError) {
setMessages(prev => [...prev, {
id: Date.now().toString(),
role: 'system',
content: `❌ 发送失败:${streamError}`,
timestamp: new Date(),
}]);
resetStream();
}
}, [streamStatus, streamError, resetStream]);
/** /**
* 发送消息 * 发送消息(流式)
*/ */
const handleSend = async () => { const handleSend = useCallback(async () => {
if (!input.trim() || !conversationId) return; if (!input.trim() || !conversationId || isStreaming) return;
const userContent = input.trim();
// 添加用户消息
const userMessage: Message = { const userMessage: Message = {
id: Date.now().toString(), id: Date.now().toString(),
role: 'user', role: 'user',
content: input.trim(), content: userContent,
timestamp: new Date(), timestamp: new Date(),
}; };
setMessages(prev => [...prev, userMessage]); setMessages(prev => [...prev, userMessage]);
setInput(''); setInput('');
setLoading(true);
try { // 使用 useAIStream 发送消息(流式输出)
const token = getAccessToken(); await sendStreamMessage(userContent, {
const response = await fetch(`${API_BASE}/message`, { conversationId,
method: 'POST', });
headers: { }, [input, conversationId, isStreaming, sendStreamMessage]);
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
conversationId,
content: userMessage.content,
}),
});
if (!response.ok) {
throw new Error('Failed to send message');
}
const result = await response.json();
if (result.success && result.data) {
const aiResponse: AgentResponse = result.data;
const aiMessage: Message = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: aiResponse.content,
thinkingContent: aiResponse.thinkingContent,
syncButton: aiResponse.syncButton,
actionCards: aiResponse.actionCards,
timestamp: new Date(),
};
setMessages(prev => [...prev, aiMessage]);
// 刷新上下文状态
onContextUpdate();
}
} catch (err) {
console.error('[ChatArea] handleSend error:', err);
// 显示错误消息
setMessages(prev => [...prev, {
id: (Date.now() + 1).toString(),
role: 'system',
content: '抱歉,消息发送失败。请稍后重试。',
timestamp: new Date(),
}]);
} finally {
setLoading(false);
}
};
/** /**
* 处理同步 * 处理同步到方案
*/ */
const handleSync = async (stageCode: string, data: Record<string, unknown>) => { 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]);
// 按Enter发送 /**
const handleKeyDown = (e: React.KeyboardEvent) => { * 处理动作卡片点击
*/
const handleActionCard = useCallback((card: ActionCard) => {
console.log('[ChatArea] Action card clicked:', card);
if (card.actionUrl) {
// 对于 API 调用类型的动作卡片
if (card.actionUrl.startsWith('/api/')) {
// TODO: 实现 API 调用(如一键生成)
setMessages(prev => [...prev, {
id: Date.now().toString(),
role: 'system',
content: `⏳ 正在执行:${card.title}...`,
timestamp: new Date(),
}]);
} else {
// 跳转到工具页面
window.open(card.actionUrl, '_blank');
}
}
}, []);
// 按 Enter 发送
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) { 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'
}); });
} }

View File

@@ -0,0 +1,222 @@
/**
* MarkdownContent - 简单 Markdown 渲染组件
*
* 处理基本的 Markdown 格式:
* - **粗体**
* - *斜体*
* - 换行
* - 标题 (###)
* - 列表 (- / 1.)
* - 代码 `code`
*/
import React from 'react';
interface MarkdownContentProps {
content: string;
className?: string;
}
/**
* 将 Markdown 文本转换为 React 元素
*/
function parseMarkdown(text: string): React.ReactNode[] {
const lines = text.split('\n');
const elements: React.ReactNode[] = [];
let listItems: string[] = [];
let listType: 'ul' | 'ol' | null = null;
let key = 0;
const flushList = () => {
if (listItems.length > 0) {
const ListTag = listType === 'ol' ? 'ol' : 'ul';
elements.push(
<ListTag key={key++}>
{listItems.map((item, i) => (
<li key={i}>{formatInline(item)}</li>
))}
</ListTag>
);
listItems = [];
listType = null;
}
};
for (const line of lines) {
// 处理标题
if (line.startsWith('### ')) {
flushList();
elements.push(<h3 key={key++}>{formatInline(line.slice(4))}</h3>);
continue;
}
if (line.startsWith('## ')) {
flushList();
elements.push(<h2 key={key++}>{formatInline(line.slice(3))}</h2>);
continue;
}
if (line.startsWith('# ')) {
flushList();
elements.push(<h1 key={key++}>{formatInline(line.slice(2))}</h1>);
continue;
}
// 处理无序列表
if (line.match(/^[\-\*]\s+/)) {
if (listType !== 'ul') {
flushList();
listType = 'ul';
}
listItems.push(line.replace(/^[\-\*]\s+/, ''));
continue;
}
// 处理有序列表
if (line.match(/^\d+\.\s+/)) {
if (listType !== 'ol') {
flushList();
listType = 'ol';
}
listItems.push(line.replace(/^\d+\.\s+/, ''));
continue;
}
// 非列表内容,先清空列表
flushList();
// 空行
if (line.trim() === '') {
elements.push(<br key={key++} />);
continue;
}
// 普通段落
elements.push(<p key={key++}>{formatInline(line)}</p>);
}
// 处理剩余列表
flushList();
return elements;
}
/**
* 处理行内格式(粗体、斜体、代码)
*/
function formatInline(text: string): React.ReactNode {
// 按顺序处理:代码块 > 粗体 > 斜体
const parts: React.ReactNode[] = [];
let remaining = text;
let key = 0;
// 处理行内代码 `code`
const codeRegex = /`([^`]+)`/g;
let lastIndex = 0;
let match;
const tempParts: Array<{ type: 'text' | 'code'; content: string }> = [];
while ((match = codeRegex.exec(remaining)) !== null) {
if (match.index > lastIndex) {
tempParts.push({ type: 'text', content: remaining.slice(lastIndex, match.index) });
}
tempParts.push({ type: 'code', content: match[1] });
lastIndex = match.index + match[0].length;
}
if (lastIndex < remaining.length) {
tempParts.push({ type: 'text', content: remaining.slice(lastIndex) });
}
// 处理每个部分
for (const part of tempParts) {
if (part.type === 'code') {
parts.push(<code key={key++}>{part.content}</code>);
} else {
// 处理文本中的粗体和斜体
parts.push(...formatBoldItalic(part.content, key));
key += 10; // 预留足够的 key 空间
}
}
return parts.length === 1 ? parts[0] : <>{parts}</>;
}
/**
* 处理粗体和斜体
*/
function formatBoldItalic(text: string, startKey: number): React.ReactNode[] {
const parts: React.ReactNode[] = [];
let key = startKey;
// 先处理粗体 **text**
const boldRegex = /\*\*([^*]+)\*\*/g;
let lastIndex = 0;
let match;
while ((match = boldRegex.exec(text)) !== null) {
if (match.index > lastIndex) {
// 处理前面的普通文本(可能包含斜体)
parts.push(...formatItalic(text.slice(lastIndex, match.index), key));
key += 5;
}
parts.push(<strong key={key++}>{match[1]}</strong>);
lastIndex = match.index + match[0].length;
}
if (lastIndex < text.length) {
parts.push(...formatItalic(text.slice(lastIndex), key));
}
if (parts.length === 0) {
parts.push(...formatItalic(text, key));
}
return parts;
}
/**
* 处理斜体
*/
function formatItalic(text: string, startKey: number): React.ReactNode[] {
const parts: React.ReactNode[] = [];
let key = startKey;
// 处理斜体 *text*(但不是 **
const italicRegex = /(?<!\*)\*([^*]+)\*(?!\*)/g;
let lastIndex = 0;
let match;
while ((match = italicRegex.exec(text)) !== null) {
if (match.index > lastIndex) {
parts.push(<span key={key++}>{text.slice(lastIndex, match.index)}</span>);
}
parts.push(<em key={key++}>{match[1]}</em>);
lastIndex = match.index + match[0].length;
}
if (lastIndex < text.length) {
parts.push(<span key={key++}>{text.slice(lastIndex)}</span>);
}
if (parts.length === 0) {
parts.push(<span key={key++}>{text}</span>);
}
return parts;
}
export const MarkdownContent: React.FC<MarkdownContentProps> = ({
content,
className = ''
}) => {
const elements = parseMarkdown(content);
return (
<div className={`markdown-content ${className}`}>
{elements}
</div>
);
};
export default MarkdownContent;

View File

@@ -18,6 +18,7 @@ import type {
interface StageCardProps { 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,8 +46,19 @@ 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>
{status === 'completed' && <Check size={14} className="check-icon" />} <div className="stage-actions">
{status === 'current' && <Loader2 size={14} className="loader-icon animate-spin" />} {status === 'completed' && onEdit && (
<button
className="edit-btn"
onClick={onEdit}
title="编辑"
>
<Edit2 size={12} />
</button>
)}
{status === 'completed' && <Check size={14} className="check-icon" />}
{status === 'current' && <Loader2 size={14} className="loader-icon animate-spin" />}
</div>
</div> </div>
{data && <StageDataRenderer stageCode={stageCode} data={data} />} {data && <StageDataRenderer stageCode={stageCode} data={data} />}

View File

@@ -0,0 +1,266 @@
/**
* Stage Edit Modal - 阶段数据编辑弹窗
*
* 支持用户编辑已同步的阶段数据
*/
import React, { useState, useEffect } from 'react';
import { X, Save } from 'lucide-react';
import type { StageInfo } from '../types';
interface StageEditModalProps {
stage: StageInfo;
onSave: (stageCode: string, data: Record<string, unknown>) => Promise<void>;
onClose: () => void;
}
const STAGE_TITLES: Record<string, string> = {
scientific_question: '科学问题',
pico: 'PICO要素',
study_design: '研究设计',
sample_size: '样本量',
endpoints: '观察指标',
};
export const StageEditModal: React.FC<StageEditModalProps> = ({ stage, onSave, onClose }) => {
const [formData, setFormData] = useState<Record<string, any>>({});
const [saving, setSaving] = useState(false);
useEffect(() => {
if (stage.data) {
setFormData({ ...stage.data as Record<string, any> });
}
}, [stage]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
try {
await onSave(stage.stageCode, formData);
} finally {
setSaving(false);
}
};
const renderFormFields = () => {
switch (stage.stageCode) {
case 'scientific_question':
return (
<div className="form-group">
<label></label>
<textarea
value={formData.content || ''}
onChange={(e) => setFormData({ ...formData, content: e.target.value })}
rows={3}
placeholder="请输入凝练后的科学问题..."
/>
</div>
);
case 'pico':
return (
<>
<div className="form-group">
<label>P - </label>
<input
type="text"
value={formData.population || ''}
onChange={(e) => setFormData({ ...formData, population: e.target.value })}
placeholder="研究人群..."
/>
</div>
<div className="form-group">
<label>I - </label>
<input
type="text"
value={formData.intervention || ''}
onChange={(e) => setFormData({ ...formData, intervention: e.target.value })}
placeholder="干预措施..."
/>
</div>
<div className="form-group">
<label>C - </label>
<input
type="text"
value={formData.comparison || ''}
onChange={(e) => setFormData({ ...formData, comparison: e.target.value })}
placeholder="对照组..."
/>
</div>
<div className="form-group">
<label>O - </label>
<input
type="text"
value={formData.outcome || ''}
onChange={(e) => setFormData({ ...formData, outcome: e.target.value })}
placeholder="结局指标..."
/>
</div>
</>
);
case 'study_design':
return (
<>
<div className="form-group">
<label></label>
<input
type="text"
value={formData.studyType || ''}
onChange={(e) => setFormData({ ...formData, studyType: e.target.value })}
placeholder="如:随机对照试验、队列研究..."
/>
</div>
<div className="form-group">
<label></label>
<input
type="text"
value={Array.isArray(formData.design) ? formData.design.join(', ') : ''}
onChange={(e) => setFormData({
...formData,
design: e.target.value.split(',').map(s => s.trim()).filter(Boolean)
})}
placeholder="如:双盲, 平行对照..."
/>
</div>
</>
);
case 'sample_size':
return (
<>
<div className="form-group">
<label></label>
<input
type="number"
value={formData.sampleSize || ''}
onChange={(e) => setFormData({ ...formData, sampleSize: parseInt(e.target.value) || 0 })}
placeholder="样本量数字..."
/>
</div>
<div className="form-row">
<div className="form-group half">
<label>α ()</label>
<input
type="number"
step="0.01"
value={formData.calculation?.alpha || ''}
onChange={(e) => setFormData({
...formData,
calculation: { ...formData.calculation, alpha: parseFloat(e.target.value) || 0.05 }
})}
placeholder="0.05"
/>
</div>
<div className="form-group half">
<label>Power ()</label>
<input
type="number"
step="0.01"
value={formData.calculation?.power || ''}
onChange={(e) => setFormData({
...formData,
calculation: { ...formData.calculation, power: parseFloat(e.target.value) || 0.8 }
})}
placeholder="0.80"
/>
</div>
</div>
</>
);
case 'endpoints':
return (
<>
<div className="form-group">
<label></label>
<input
type="text"
value={formData.outcomes?.primary?.join(', ') || ''}
onChange={(e) => setFormData({
...formData,
outcomes: {
...formData.outcomes,
primary: e.target.value.split(',').map(s => s.trim()).filter(Boolean)
}
})}
placeholder="如:死亡率, 住院时间..."
/>
</div>
<div className="form-group">
<label></label>
<input
type="text"
value={formData.outcomes?.secondary?.join(', ') || ''}
onChange={(e) => setFormData({
...formData,
outcomes: {
...formData.outcomes,
secondary: e.target.value.split(',').map(s => s.trim()).filter(Boolean)
}
})}
placeholder="如:生活质量, 并发症..."
/>
</div>
<div className="form-group">
<label></label>
<input
type="text"
value={formData.confounders?.join(', ') || ''}
onChange={(e) => setFormData({
...formData,
confounders: e.target.value.split(',').map(s => s.trim()).filter(Boolean)
})}
placeholder="如:年龄, 性别, 基础疾病..."
/>
</div>
</>
);
default:
return (
<div className="form-group">
<label> (JSON)</label>
<textarea
value={JSON.stringify(formData, null, 2)}
onChange={(e) => {
try {
setFormData(JSON.parse(e.target.value));
} catch {}
}}
rows={6}
/>
</div>
);
}
};
return (
<div className="stage-edit-modal-overlay" onClick={onClose}>
<div className="stage-edit-modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h3> {STAGE_TITLES[stage.stageCode] || stage.stageName}</h3>
<button className="close-btn" onClick={onClose}>
<X size={18} />
</button>
</div>
<form onSubmit={handleSubmit}>
<div className="modal-body">
{renderFormFields()}
</div>
<div className="modal-footer">
<button type="button" className="cancel-btn" onClick={onClose}>
</button>
<button type="submit" className="save-btn" disabled={saving}>
{saving ? '保存中...' : <><Save size={14} /> </>}
</button>
</div>
</form>
</div>
</div>
);
};

View File

@@ -2,18 +2,33 @@
* State Panel - 方案状态面板 * 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">

View File

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

View File

@@ -6,11 +6,16 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Noto+Sans+SC:wght@300;400;500;700&display=swap'); @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; }
}
/* ============================================ */ /* ============================================ */
/* 响应式 */ /* 响应式 */
/* ============================================ */ /* ============================================ */

View File

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