feat(aia): Protocol Agent MVP complete with one-click generation and Word export

- Add one-click research protocol generation with streaming output

- Implement Word document export via Pandoc integration

- Add dynamic dual-panel layout with resizable split pane

- Implement collapsible content for StatePanel stages

- Add conversation history management with title auto-update

- Fix scroll behavior, markdown rendering, and UI layout issues

- Simplify conversation creation logic for reliability
This commit is contained in:
2026-01-25 19:16:36 +08:00
parent 4d7d97ca19
commit 303dd78c54
332 changed files with 6204 additions and 617 deletions

View File

@@ -0,0 +1,50 @@
# **UI 布局深度分析Chat vs. Document 比例问题**
**核心冲突:**
* **Chat**: 主要是控制台,指令短,但历史记录长。
* **Document**: 是交付物,内容宽,需要沉浸式阅读。
* **Context Panel**: 是辅助信息,卡片式,不需要太宽。
## **1\. 为什么“固定比例”不是最优解?**
如果强行统一比例,两头都不讨好:
* **如果统一为 70% (Chat) : 30% (Right)**
* *问题*: 右侧只能放 PICO 卡片。一旦开始生成文档A4 纸被挤成“长条”,用户必须横向滚动或者字变得极小,根本没法阅读。
* **如果统一为 40% (Chat) : 60% (Right)**
* *问题*: 在前期的 PICO 收集阶段,右侧面板(只有几个卡片)会留出大片空白,显得界面空旷、重心失衡。
## **2\. 推荐方案:动态自适应布局 (Dynamic Split View)**
我们要根据 **“用户当前的注意力焦点”** 自动调整比例。
### **阶段 A要素收集期 (Focus on Chat)**
* **状态**AI 在问,用户在答。右侧只是辅助展示“已提取的 PICO”。
* **比例****Chat 65% : Context 35%**
* **理由**:此时用户的视线主要在聊天流上,右侧只是个“仪表盘”。
### **阶段 B方案生成期 (Focus on Document)**
* **状态**AI 在写长文,用户在审阅。聊天框只用来发简单的修改指令。
* **比例****Chat 35% : Document 65%**
* **理由**:此时右侧的 A4 纸是主角。A4 纸的最佳阅读宽度通常需要 800px+,否则排版会乱(尤其是表格)。
### **阶段 C终极自由 (User Control)**
* **功能**:在两栏中间加一个 **Drag Handle (拖拽手柄)**,允许用户自己拖动宽度。
* **记忆**:记住用户的最后设置。
## **3\. 视觉优化细节**
1. **平滑过渡**:当从阶段 A 切换到阶段 B 时,使用 CSS transition 让分界线平滑移动,而不是突变。
2. **折叠按钮**:允许用户完全折叠左侧 Chat进入 **“全屏沉浸阅读模式”** (100% Doc)。
## **4\. 结论**
**不要统一。**
请采用 **“模式驱动的默认比例 \+ 手动拖拽”** 的策略。
* 默认:**PICO 模式 (60/40)** \-\> **生成模式 (35/65)**

View File

@@ -0,0 +1,277 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Protocol Agent V3.0 - 生成全流程</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/lucide@latest"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&family=Noto+Sans+SC:wght@300;400;500;700&family=Noto+Serif+SC:wght@400;700&display=swap');
body { font-family: 'Inter', 'Noto Sans SC', sans-serif; }
/* 聊天气泡 */
.chat-bubble-ai { background-color: #F3F4F6; border-radius: 12px 12px 12px 2px; }
.chat-bubble-user { background-color: #4F46E5; color: white; border-radius: 12px 12px 2px 12px; }
/* A4 纸张效果 (预览模式) */
.a4-paper {
width: 100%;
max-width: 210mm;
min-height: 297mm;
padding: 20mm;
background: white;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
margin: 0 auto;
font-family: 'Noto Serif SC', serif; /* 宋体风格 */
color: #1F2937;
line-height: 1.8;
font-size: 15px;
}
/* 打字机光标 */
.typing-cursor::after { content: '❘'; animation: blink 1s infinite; color: #4F46E5; }
@keyframes blink { 50% { opacity: 0; } }
/* 右侧面板切换动画 */
.panel-transition { transition: opacity 0.3s ease, transform 0.3s ease; }
.panel-hidden { opacity: 0; transform: translateX(20px); pointer-events: none; position: absolute; top:0; left:0; right:0; }
.panel-visible { opacity: 1; transform: translateX(0); position: relative; }
</style>
</head>
<body class="bg-gray-100 h-screen flex flex-col overflow-hidden">
<!-- Header -->
<header class="bg-white border-b border-gray-200 h-14 flex items-center justify-between px-4 shadow-sm z-20 shrink-0">
<div class="flex items-center gap-3">
<div class="w-8 h-8 bg-indigo-600 rounded-lg flex items-center justify-center text-white font-bold">
<i data-lucide="bot" class="w-5 h-5"></i>
</div>
<div>
<h1 class="font-bold text-gray-800 text-sm">Protocol Agent</h1>
<div class="flex items-center gap-1.5">
<span class="w-2 h-2 bg-green-500 rounded-full"></span>
<span class="text-[10px] text-gray-500">PICO Ready</span>
</div>
</div>
</div>
<!-- Tab Switcher (用于演示) -->
<div class="flex bg-gray-100 p-1 rounded-lg">
<button id="btn-view-context" class="px-3 py-1 text-xs font-medium bg-white shadow-sm rounded text-gray-700">视图: 关键要素</button>
<button id="btn-view-doc" class="px-3 py-1 text-xs font-medium text-gray-500 hover:text-gray-700">视图: 完整方案</button>
</div>
</header>
<main class="flex-1 flex overflow-hidden">
<!-- Left: Chat (35%) -->
<section class="w-[400px] flex flex-col bg-white border-r border-gray-200 z-10 shadow-lg">
<div id="chat-box" class="flex-1 overflow-y-auto p-4 space-y-6 bg-slate-50">
<!-- History -->
<div class="flex gap-3">
<div class="w-8 h-8 rounded-full bg-indigo-100 flex items-center justify-center shrink-0"><i data-lucide="bot" class="w-4 h-4 text-indigo-600"></i></div>
<div class="chat-bubble-ai p-3 text-sm text-gray-700 shadow-sm">
样本量计算已完成 (N=386)。至此,您的研究方案 <strong>5 个关键要素</strong> 已全部收集完毕。
</div>
</div>
<!-- 核心:生成按钮 Action Card -->
<div class="flex justify-center" id="action-area">
<div class="bg-white border-2 border-indigo-100 rounded-xl p-4 shadow-md w-full max-w-sm hover:border-indigo-300 transition-all cursor-default">
<div class="flex items-center gap-3 mb-3">
<div class="bg-indigo-600 text-white p-2 rounded-lg"><i data-lucide="sparkles" class="w-5 h-5"></i></div>
<div>
<h3 class="font-bold text-gray-800 text-sm">要素已就绪</h3>
<p class="text-xs text-gray-500">基于已确认的 PICO 与样本量</p>
</div>
</div>
<button onclick="startGeneration()" class="w-full bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-bold py-2.5 rounded-lg shadow-sm flex items-center justify-center gap-2 transition-transform active:scale-95">
开始撰写完整方案
<i data-lucide="arrow-right" class="w-4 h-4"></i>
</button>
</div>
</div>
<!-- 生成中的状态 (初始隐藏) -->
<div id="generating-msg" class="hidden flex gap-3 animate-fade-in">
<div class="w-8 h-8 rounded-full bg-indigo-100 flex items-center justify-center shrink-0"><i data-lucide="pen-tool" class="w-4 h-4 text-indigo-600 animate-pulse"></i></div>
<div class="chat-bubble-ai p-3 text-sm text-gray-700 shadow-sm w-full">
<p class="font-bold text-indigo-600 mb-2 text-xs">正在撰写...</p>
<div class="space-y-2">
<div class="flex items-center justify-between text-xs">
<span class="text-gray-600">1. 研究背景</span>
<i data-lucide="check-circle" class="w-3 h-3 text-green-500"></i>
</div>
<div class="flex items-center justify-between text-xs">
<span class="text-gray-600">2. 研究目的</span>
<i data-lucide="check-circle" class="w-3 h-3 text-green-500"></i>
</div>
<div class="flex items-center justify-between text-xs">
<span class="text-indigo-600 font-medium">3. 研究设计</span>
<i data-lucide="loader-2" class="w-3 h-3 text-indigo-500 animate-spin"></i>
</div>
</div>
</div>
</div>
</div>
<!-- Input -->
<div class="p-3 border-t border-gray-200 bg-white">
<div class="relative">
<input type="text" placeholder="对生成结果不满意?输入修改指令..." class="w-full pl-4 pr-10 py-3 bg-gray-50 border border-gray-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500">
<button class="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 text-indigo-600"><i data-lucide="send" class="w-4 h-4"></i></button>
</div>
</div>
</section>
<!-- Right: Dual Panel (Context vs Document) -->
<section class="flex-1 bg-slate-100 relative overflow-hidden">
<!-- Panel 1: Context View (结构化数据) -->
<div id="panel-context" class="absolute inset-0 p-8 overflow-y-auto panel-visible panel-transition">
<div class="max-w-3xl mx-auto">
<div class="flex items-center justify-between mb-6">
<h2 class="text-lg font-bold text-gray-800 flex items-center gap-2">
<i data-lucide="database" class="w-5 h-5 text-gray-500"></i>
关键要素 (Context Data)
</h2>
<span class="text-xs bg-white border border-gray-200 px-3 py-1 rounded-full text-gray-500">数据源: Postgres JSONB</span>
</div>
<div class="grid grid-cols-1 gap-4">
<!-- PICO Card -->
<div class="bg-white p-5 rounded-xl border border-gray-200 shadow-sm">
<div class="text-xs font-bold text-gray-400 uppercase mb-3">PICO 模型</div>
<div class="space-y-3">
<div class="flex"><span class="w-8 font-bold text-blue-600">P</span><span class="text-sm text-gray-700">≥65岁原发性高血压患者</span></div>
<div class="flex"><span class="w-8 font-bold text-indigo-600">I</span><span class="text-sm text-gray-700">阿司匹林肠溶片 100mg/d</span></div>
<div class="flex"><span class="w-8 font-bold text-orange-600">C</span><span class="text-sm text-gray-700">安慰剂</span></div>
<div class="flex"><span class="w-8 font-bold text-green-600">O</span><span class="text-sm text-gray-700">5年内缺血性脑卒中发生率</span></div>
</div>
</div>
<!-- Sample Size Card -->
<div class="bg-white p-5 rounded-xl border border-gray-200 shadow-sm flex items-center justify-between">
<div>
<div class="text-xs font-bold text-gray-400 uppercase mb-1">样本量估算</div>
<div class="text-sm text-gray-600">Alpha=0.05, Power=0.8</div>
</div>
<div class="text-2xl font-bold text-indigo-600 font-mono">N=386</div>
</div>
</div>
<div class="mt-8 text-center text-sm text-gray-400">
<i data-lucide="arrow-left" class="w-4 h-4 inline mr-1"></i>
点击左侧 "开始撰写" 将基于以上数据生成文档
</div>
</div>
</div>
<!-- Panel 2: Document View (A4 Preview) -->
<div id="panel-doc" class="absolute inset-0 p-8 overflow-y-auto panel-hidden panel-transition bg-gray-200/50">
<!-- Toolbar -->
<div class="absolute top-4 right-8 flex gap-2 z-10">
<button class="bg-white hover:bg-gray-50 text-gray-700 px-3 py-1.5 rounded border border-gray-300 text-xs font-medium shadow-sm flex items-center gap-1">
<i data-lucide="copy" class="w-3 h-3"></i> 复制
</button>
<button class="bg-indigo-600 hover:bg-indigo-700 text-white px-3 py-1.5 rounded border border-transparent text-xs font-medium shadow-sm flex items-center gap-1">
<i data-lucide="download" class="w-3 h-3"></i> 导出 Word
</button>
</div>
<!-- Paper -->
<div class="a4-paper">
<h1 class="text-2xl font-bold text-center mb-10 pt-4">阿司匹林预防老年高血压患者缺血性脑卒中的<br>前瞻性、随机、双盲对照研究方案</h1>
<div class="space-y-6">
<div>
<h2 class="text-lg font-bold mb-2 border-b border-gray-800 pb-1">1. 研究背景 (Background)</h2>
<p class="text-justify indent-8">
脑卒中是全球范围内导致死亡和长期残疾的主要原因之一。流行病学数据显示在65岁以上的老年人群中高血压是缺血性脑卒中最重要的独立危险因素...
</p>
</div>
<div>
<h2 class="text-lg font-bold mb-2 border-b border-gray-800 pb-1">2. 研究目的 (Objectives)</h2>
<p class="text-justify indent-8">
<strong>主要目的:</strong> 评价每日口服 100mg 阿司匹林肠溶片对比安慰剂,在降低 65 岁及以上原发性高血压患者 5 年内缺血性脑卒中发生率方面的有效性。
</p>
<p class="text-justify indent-8 mt-2">
<strong>次要目的:</strong> 评估阿司匹林组与安慰剂组在全因死亡率、出血性脑卒中发生率及主要出血事件(如消化道出血)方面的差异。
</p>
</div>
<div>
<h2 class="text-lg font-bold mb-2 border-b border-gray-800 pb-1">3. 研究设计 (Study Design)</h2>
<p class="text-justify indent-8 typing-cursor">
本研究采用多中心、随机、双盲、安慰剂对照设计 (RCT)。入组后的受试者将通过中央随机系统以 1:1 的比例分配至试验组(阿司匹林)或对照组(安慰剂)。
</p>
</div>
</div>
</div>
<div class="h-20"></div> <!-- Bottom Spacer -->
</div>
</section>
</main>
<script>
lucide.createIcons();
const panelContext = document.getElementById('panel-context');
const panelDoc = document.getElementById('panel-doc');
const btnViewContext = document.getElementById('btn-view-context');
const btnViewDoc = document.getElementById('btn-view-doc');
const actionArea = document.getElementById('action-area');
const generatingMsg = document.getElementById('generating-msg');
const chatBox = document.getElementById('chat-box');
function switchTab(view) {
if (view === 'context') {
panelContext.classList.remove('panel-hidden');
panelContext.classList.add('panel-visible');
panelDoc.classList.remove('panel-visible');
panelDoc.classList.add('panel-hidden');
btnViewContext.classList.add('bg-white', 'shadow-sm', 'text-gray-700');
btnViewContext.classList.remove('text-gray-500');
btnViewDoc.classList.remove('bg-white', 'shadow-sm', 'text-gray-700');
btnViewDoc.classList.add('text-gray-500');
} else {
panelContext.classList.remove('panel-visible');
panelContext.classList.add('panel-hidden');
panelDoc.classList.remove('panel-hidden');
panelDoc.classList.add('panel-visible');
btnViewDoc.classList.add('bg-white', 'shadow-sm', 'text-gray-700');
btnViewDoc.classList.remove('text-gray-500');
btnViewContext.classList.remove('bg-white', 'shadow-sm', 'text-gray-700');
btnViewContext.classList.add('text-gray-500');
}
}
function startGeneration() {
// 1. 切换到文档视图
switchTab('doc');
// 2. Chat UI 更新
actionArea.style.display = 'none'; // 隐藏按钮
generatingMsg.classList.remove('hidden'); // 显示进度条
// 滚动到底部
chatBox.scrollTo({ top: chatBox.scrollHeight, behavior: 'smooth' });
}
// 绑定 Tab 点击事件
btnViewContext.onclick = () => switchTab('context');
btnViewDoc.onclick = () => switchTab('doc');
</script>
</body>
</html>