feat(asl): Complete Deep Research V2.0 core development

Backend:
- Add SSE streaming client (unifuncsSseClient) replacing async polling
- Add paragraph-based reasoning parser with mergeConsecutiveThinking
- Add requirement expansion service (DeepSeek-V3 PICOS+MeSH)
- Add Word export service with Pandoc, inline hyperlinks, reference link expansion
- Add deep research V2 worker with 2s log flush and Chinese source prompt
- Add 5 curated data sources config (PubMed/ClinicalTrials/Cochrane/CNKI/MedJournals)
- Add 4 API endpoints (generate-requirement/tasks/task-status/export-word)
- Update Prisma schema with 6 new V2.0 fields on AslResearchTask
- Add DB migration for V2.0 fields
- Simplify ASL_DEEP_RESEARCH_EXPANSION prompt (remove strategy section)

Frontend:
- Add waterfall-flow DeepResearchPage (phase 0-4 progressive reveal)
- Add LandingView, SetupPanel, StrategyConfirm, AgentTerminal, ResultsView
- Add react-markdown + remark-gfm for report rendering
- Add custom link component showing visible URLs after references
- Add useDeepResearchTask polling hook
- Add deep research TypeScript types

Tests:
- Add E2E test, smoke test, and Chinese data source test scripts

Docs:
- Update ASL module status (v2.0 - core features complete)
- Update system status (v6.1 - ASL V2.0 milestone)
- Update Unifuncs DeepSearch API guide (v2.0 - SSE mode + Chinese source results)
- Update module auth specification (test script guidelines)
- Update V2.0 development plan

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-02-23 13:21:52 +08:00
parent b06daecacd
commit 8f06d4f929
39 changed files with 5605 additions and 417 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -38,7 +38,9 @@
"prismjs": "^1.30.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.9.5",
"remark-gfm": "^4.0.1",
"xlsx": "^0.18.5",
"zustand": "^5.0.8"
},

View File

@@ -991,9 +991,7 @@
.message-bubble .markdown-content ol {
margin: 8px 0 12px 0;
padding-left: 24px;
}
.message-bubble .markdown-content ul {
}.message-bubble .markdown-content ul {
list-style-type: disc;
}.message-bubble .markdown-content ol {
list-style-type: decimal;
@@ -1020,4 +1018,4 @@
border-radius: 4px;
font-family: 'Monaco', 'Consolas', 'Courier New', monospace;
font-size: 0.9em;
}
}

View File

@@ -478,6 +478,56 @@ export async function getResearchTaskStatus(
return request(`/research/tasks/${taskId}/status`);
}
// ==================== Deep Research V2.0 API ====================
import type {
DataSourceConfig,
GenerateRequirementRequest,
GenerateRequirementResponse,
DeepResearchTask,
} from '../types/deepResearch';
/**
* 获取数据源配置列表
*/
export async function getDeepResearchDataSources(): Promise<ApiResponse<DataSourceConfig[]>> {
return request('/research/data-sources');
}
/**
* 需求扩写PICOS + MeSH
*/
export async function generateRequirement(
data: GenerateRequirementRequest
): Promise<ApiResponse<GenerateRequirementResponse>> {
return request('/research/generate-requirement', {
method: 'POST',
body: JSON.stringify(data),
});
}
/**
* 启动异步执行
*/
export async function executeDeepResearchTask(
taskId: string,
confirmedRequirement: string
): Promise<ApiResponse<void>> {
return request(`/research/tasks/${taskId}/execute`, {
method: 'PUT',
body: JSON.stringify({ confirmedRequirement }),
});
}
/**
* 获取 V2.0 任务详情(状态 + 日志 + 结果)
*/
export async function getDeepResearchTask(
taskId: string
): Promise<ApiResponse<DeepResearchTask>> {
return request(`/research/tasks/${taskId}`);
}
// ==================== 统一导出API对象 ====================
/**
@@ -525,7 +575,13 @@ export const aslApi = {
// 健康检查
healthCheck,
// 智能文献检索 (DeepSearch)
// 智能文献检索 (DeepSearch V1.x)
createResearchTask,
getResearchTaskStatus,
// Deep Research V2.0
getDeepResearchDataSources,
generateRequirement,
executeDeepResearchTask,
getDeepResearchTask,
};

View File

@@ -39,7 +39,7 @@ const ASLLayout = () => {
title: '敬请期待'
},
{
key: '/literature/research/search',
key: '/literature/research/deep',
icon: <SearchOutlined />,
label: '2. 智能文献检索',
},

View File

@@ -0,0 +1,165 @@
/**
* Deep Research V2.0 — Agent Terminal
*
* 暗色终端风格展示 AI 思考与执行过程:
* - 每条日志展示完整思考内容
* - 自动过滤无意义内容(任务 ID 等)
* - 条件 auto-scroll
*/
import { useRef, useEffect, useCallback, useState } from 'react';
import { Spin, Typography, Alert } from 'antd';
import { LoadingOutlined } from '@ant-design/icons';
import type { DeepResearchTask, ExecutionLogEntry } from '../../types/deepResearch';
const { Text } = Typography;
interface AgentTerminalProps {
task: DeepResearchTask | null;
isRunning: boolean;
isFailed: boolean;
}
const LOG_COLORS: Record<string, string> = {
thinking: '#8b949e',
searching: '#58a6ff',
reading: '#d2a8ff',
analyzing: '#7ee787',
summary: '#ffa657',
info: '#79c0ff',
};
const LOG_PREFIXES: Record<string, string> = {
thinking: '🤔 思考',
searching: '🔍 搜索',
reading: '📖 阅读',
analyzing: '🧪 分析',
summary: '📊 总结',
info: ' 信息',
};
const AgentTerminal: React.FC<AgentTerminalProps> = ({ task, isRunning, isFailed }) => {
const containerRef = useRef<HTMLDivElement>(null);
const [userScrolled, setUserScrolled] = useState(false);
const prevLogCountRef = useRef(0);
const logs = (task?.executionLogs || []).filter(
(log: ExecutionLogEntry) => !log.text.includes('Unifuncs 任务 ID')
);
const handleScroll = useCallback(() => {
const el = containerRef.current;
if (!el) return;
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
setUserScrolled(distanceFromBottom > 50);
}, []);
useEffect(() => {
if (!userScrolled && containerRef.current && logs.length > prevLogCountRef.current) {
containerRef.current.scrollTop = containerRef.current.scrollHeight;
}
prevLogCountRef.current = logs.length;
}, [logs.length, userScrolled]);
return (
<div>
<div className="flex items-center gap-3 mb-4">
{isRunning && <Spin indicator={<LoadingOutlined spin />} size="small" />}
<Text strong className="text-lg">
{isRunning ? 'Deep Research 执行中...' : isFailed ? '执行失败' : '执行完成'}
</Text>
</div>
{isFailed && task?.errorMessage && (
<Alert
type="error"
message="执行失败"
description={task.errorMessage}
className="mb-4"
showIcon
/>
)}
<div
ref={containerRef}
onScroll={handleScroll}
className="rounded-xl overflow-hidden"
style={{
background: '#0d1117',
color: '#c9d1d9',
fontFamily: "'JetBrains Mono', 'Fira Code', 'Consolas', monospace",
fontSize: 13,
lineHeight: 1.8,
padding: '16px 20px',
maxHeight: 500,
minHeight: 200,
overflowY: 'auto',
}}
>
{/* Header */}
<div className="flex items-center gap-2 mb-4 pb-3" style={{ borderBottom: '1px solid #21262d' }}>
<span style={{ width: 12, height: 12, borderRadius: '50%', background: '#ff5f56', display: 'inline-block' }} />
<span style={{ width: 12, height: 12, borderRadius: '50%', background: '#ffbd2e', display: 'inline-block' }} />
<span style={{ width: 12, height: 12, borderRadius: '50%', background: '#27c93f', display: 'inline-block' }} />
<span className="ml-3" style={{ color: '#8b949e' }}>Deep Research Agent</span>
</div>
{/* Log entries */}
{logs.map((log: ExecutionLogEntry, i: number) => (
<div key={i} className="mb-3 pb-2" style={{ borderBottom: '1px solid #161b22' }}>
<div className="flex items-center gap-2 mb-1">
<span style={{ color: LOG_COLORS[log.type] || '#c9d1d9', fontWeight: 600, fontSize: 12 }}>
{LOG_PREFIXES[log.type] || log.title}
</span>
<span style={{ color: '#484f58', fontSize: 11 }}>
{new Date(log.ts).toLocaleTimeString()}
</span>
</div>
<div style={{ color: '#c9d1d9', paddingLeft: 4, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{log.text}
</div>
</div>
))}
{/* Blinking cursor */}
{isRunning && (
<span className="inline-block mt-1" style={{
width: 8,
height: 16,
background: '#58a6ff',
animation: 'blink 1s step-end infinite',
}} />
)}
{logs.length === 0 && (
<div style={{ color: '#484f58' }}>...</div>
)}
</div>
{userScrolled && isRunning && (
<div className="text-center mt-2">
<Text
type="secondary"
className="text-xs cursor-pointer hover:text-blue-400 transition-colors"
onClick={() => {
setUserScrolled(false);
if (containerRef.current) {
containerRef.current.scrollTop = containerRef.current.scrollHeight;
}
}}
>
</Text>
</div>
)}
<style>{`
@keyframes blink {
50% { opacity: 0; }
}
`}</style>
</div>
);
};
export default AgentTerminal;

View File

@@ -0,0 +1,92 @@
/**
* Deep Research V2.0 — Landing 视图
*
* 大搜索框 + 推荐预置词,居中展示。
*/
import { useState } from 'react';
import { Input, Button, Tag } from 'antd';
import { SearchOutlined, ExperimentOutlined } from '@ant-design/icons';
const { TextArea } = Input;
interface LandingViewProps {
onSubmit: (query: string) => void;
}
const PRESET_QUERIES = [
'他汀类药物在心血管疾病一级预防中的最新RCT证据',
'PD-1/PD-L1抑制剂联合化疗治疗非小细胞肺癌的Meta分析',
'糖尿病肾病患者SGLT2抑制剂的肾脏保护作用',
'阿尔茨海默病早期诊断生物标志物研究进展',
];
const LandingView: React.FC<LandingViewProps> = ({ onSubmit }) => {
const [query, setQuery] = useState('');
const handleSubmit = () => {
const trimmed = query.trim();
if (!trimmed) return;
onSubmit(trimmed);
};
return (
<div className="flex flex-col items-center justify-center" style={{ minHeight: 'calc(100vh - 64px)' }}>
<div className="text-center mb-8">
<ExperimentOutlined className="text-5xl text-blue-500 mb-4" />
<h1 className="text-3xl font-bold text-gray-800 mb-2">Deep Research</h1>
<p className="text-gray-500 text-base">AI </p>
</div>
<div
className="w-full bg-white rounded-2xl shadow-md border border-gray-200 p-5 flex flex-col gap-3"
style={{ maxWidth: 700 }}
>
<TextArea
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="输入您的研究想法,例如:他汀类药物预防心血管疾病的系统评价..."
autoSize={{ minRows: 3, maxRows: 6 }}
className="!border-none !shadow-none !text-base !resize-none"
onPressEnter={(e) => {
if (!e.shiftKey) {
e.preventDefault();
handleSubmit();
}
}}
/>
<div className="flex justify-end">
<Button
type="primary"
icon={<SearchOutlined />}
size="large"
onClick={handleSubmit}
disabled={!query.trim()}
className="!rounded-lg !px-6"
>
</Button>
</div>
</div>
<div className="mt-8" style={{ maxWidth: 700, width: '100%' }}>
<p className="text-gray-400 text-sm mb-3"></p>
<div className="flex flex-wrap gap-2">
{PRESET_QUERIES.map((q, i) => (
<Tag
key={i}
className="cursor-pointer !text-sm !py-1 !px-3 !rounded-full hover:!bg-blue-50 hover:!border-blue-300 transition-colors"
onClick={() => {
setQuery(q);
}}
>
{q}
</Tag>
))}
</div>
</div>
</div>
);
};
export default LandingView;

View File

@@ -0,0 +1,225 @@
/**
* Deep Research V2.0 — 结果展示
*
* 完成横幅 + AI 综合报告Markdown 正式渲染)+ 文献清单表格
* 降级展示:如果 resultList 为 null仅展示报告
*/
import { useState } from 'react';
import { Card, Table, Button, Typography, Tag, Divider, Empty, message } from 'antd';
import {
CheckCircleFilled,
FileTextOutlined,
TableOutlined,
PlusOutlined,
DownloadOutlined,
} from '@ant-design/icons';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { getAccessToken } from '../../../../framework/auth/api';
import type { DeepResearchTask, LiteratureItem } from '../../types/deepResearch';
const { Text } = Typography;
interface ResultsViewProps {
task: DeepResearchTask;
onNewSearch: () => void;
}
const ResultsView: React.FC<ResultsViewProps> = ({ task, onNewSearch }) => {
const { synthesisReport, resultList, resultCount, query, completedAt, taskId } = task;
const [exporting, setExporting] = useState(false);
const handleExportWord = async () => {
setExporting(true);
try {
const token = getAccessToken();
const res = await fetch(`/api/v1/asl/research/tasks/${taskId}/export-word`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const blob = await res.blob();
const disposition = res.headers.get('Content-Disposition') || '';
const filenameMatch = disposition.match(/filename\*?=(?:UTF-8'')?([^;]+)/);
const filename = filenameMatch ? decodeURIComponent(filenameMatch[1]) : 'DeepResearch.docx';
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
message.success('导出成功');
} catch (err: any) {
message.error(err.message || '导出失败');
} finally {
setExporting(false);
}
};
const columns = [
{
title: '#',
key: 'index',
width: 50,
render: (_: any, __: any, idx: number) => idx + 1,
},
{
title: '标题',
dataIndex: 'title',
key: 'title',
ellipsis: true,
render: (text: string, record: LiteratureItem) => (
record.url ? (
<a href={record.url} target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">
{text || record.url}
</a>
) : (
<span>{text || '—'}</span>
)
),
},
{
title: '作者',
dataIndex: 'authors',
key: 'authors',
width: 160,
ellipsis: true,
render: (t: string) => t || '—',
},
{
title: '期刊',
dataIndex: 'journal',
key: 'journal',
width: 140,
ellipsis: true,
render: (t: string) => t || '—',
},
{
title: '年份',
dataIndex: 'year',
key: 'year',
width: 70,
render: (t: number | string) => t || '—',
},
{
title: '研究类型',
dataIndex: 'studyType',
key: 'studyType',
width: 100,
render: (t: string) => t ? <Tag color="blue">{t}</Tag> : '—',
},
{
title: 'PMID',
dataIndex: 'pmid',
key: 'pmid',
width: 100,
render: (t: string) => t ? (
<a
href={`https://pubmed.ncbi.nlm.nih.gov/${t}/`}
target="_blank"
rel="noopener noreferrer"
className="text-blue-500"
>
{t}
</a>
) : '—',
},
];
return (
<div>
{/* 完成横幅 */}
<Card className="mb-6 !bg-green-50 !border-green-200">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<CheckCircleFilled className="text-2xl text-green-500" />
<div>
<Text strong className="text-lg block">Deep Research </Text>
<Text type="secondary" className="text-sm">
{query} {resultCount || 0}
{completedAt && ` · ${new Date(completedAt).toLocaleString()}`}
</Text>
</div>
</div>
<div className="flex gap-2">
<Button icon={<DownloadOutlined />} loading={exporting} onClick={handleExportWord}>
Word
</Button>
<Button icon={<PlusOutlined />} onClick={onNewSearch}>
</Button>
</div>
</div>
</Card>
{/* AI 综合报告 — Markdown 渲染 */}
{synthesisReport && (
<Card
className="mb-6"
title={<><FileTextOutlined className="mr-2" />AI </>}
>
<div className="prose prose-sm max-w-none prose-headings:text-base prose-headings:font-semibold prose-a:text-blue-600 prose-a:no-underline hover:prose-a:underline">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
a: ({ href, children }) => (
<>
<a href={href} target="_blank" rel="noopener noreferrer">
{children}
</a>
{href && (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="!text-gray-400 !text-xs !ml-1 hover:!text-blue-500 !no-underline"
title="点击打开链接"
>
{href}
</a>
)}
</>
),
}}
>
{synthesisReport}
</ReactMarkdown>
</div>
</Card>
)}
<Divider />
{/* 文献清单表格 */}
{resultList && resultList.length > 0 ? (
<Card
title={<><TableOutlined className="mr-2" />{resultList.length} </>}
>
<Table
dataSource={resultList}
columns={columns}
rowKey={(_, idx) => String(idx)}
size="small"
pagination={{ pageSize: 20, showSizeChanger: true }}
scroll={{ x: 900 }}
/>
</Card>
) : (
synthesisReport ? (
<Card>
<Empty
description="AI 未能结构化提取文献列表,请查阅上方综合报告中的文献引用"
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
</Card>
) : (
<Card>
<Empty description="无结果数据" />
</Card>
)
)}
</div>
);
};
export default ResultsView;

View File

@@ -0,0 +1,223 @@
/**
* Deep Research V2.0 — 配置面板
*
* 数据源 Checkbox + 高级筛选 + 生成需求书按钮
* 支持 collapsed 模式显示为摘要卡片
*/
import { useState, useEffect } from 'react';
import { Card, Checkbox, Button, Input, Select, Spin, Divider, Typography, Tag } from 'antd';
import {
ArrowLeftOutlined,
ThunderboltOutlined,
GlobalOutlined,
EditOutlined,
CheckCircleOutlined,
} from '@ant-design/icons';
import { aslApi } from '../../api';
import type { DataSourceConfig } from '../../types/deepResearch';
const { Text } = Typography;
interface SetupPanelProps {
initialQuery: string;
onSubmit: (
query: string,
selectedSources: string[],
filters: { yearRange?: string; targetCount?: string }
) => void;
onBack: () => void;
loading: boolean;
collapsed?: boolean;
onExpand?: () => void;
}
const LOADING_TEXTS = [
'AI 正在理解您的研究意图...',
'正在进行 PICOS 结构化拆解...',
'正在匹配 MeSH 医学术语...',
'正在生成检索指令书...',
];
const SetupPanel: React.FC<SetupPanelProps> = ({
initialQuery, onSubmit, onBack, loading, collapsed, onExpand,
}) => {
const [query, setQuery] = useState(initialQuery);
const [dataSources, setDataSources] = useState<DataSourceConfig[]>([]);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [yearRange, setYearRange] = useState<string>('近5年');
const [targetCount, setTargetCount] = useState<string>('全面检索');
const [loadingSources, setLoadingSources] = useState(true);
const [loadingTextIdx, setLoadingTextIdx] = useState(0);
useEffect(() => {
aslApi.getDeepResearchDataSources().then(res => {
const sources = res.data || [];
setDataSources(sources);
setSelectedIds(sources.filter((s: DataSourceConfig) => s.defaultChecked).map((s: DataSourceConfig) => s.id));
}).catch(() => {
setDataSources([]);
}).finally(() => {
setLoadingSources(false);
});
}, []);
useEffect(() => {
if (!loading) { setLoadingTextIdx(0); return; }
const timer = setInterval(() => {
setLoadingTextIdx(prev => (prev + 1) % LOADING_TEXTS.length);
}, 3000);
return () => clearInterval(timer);
}, [loading]);
const handleToggle = (id: string) => {
setSelectedIds(prev =>
prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]
);
};
const handleSubmit = () => {
const domains = dataSources
.filter(s => selectedIds.includes(s.id))
.map(s => s.domainScope);
onSubmit(query, domains, { yearRange, targetCount });
};
const selectedNames = dataSources.filter(s => selectedIds.includes(s.id)).map(s => s.label);
if (collapsed) {
return (
<Card size="small" className="!bg-white">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 flex-1 min-w-0">
<CheckCircleOutlined className="text-green-500 text-lg flex-shrink-0" />
<div className="min-w-0">
<Text strong className="block truncate">{query}</Text>
<div className="flex items-center gap-2 mt-1 flex-wrap">
{selectedNames.map(name => (
<Tag key={name} className="!text-xs !m-0">{name}</Tag>
))}
<Text type="secondary" className="text-xs">{yearRange} · {targetCount}</Text>
</div>
</div>
</div>
{onExpand && (
<Button type="link" icon={<EditOutlined />} onClick={onExpand} className="flex-shrink-0">
</Button>
)}
</div>
</Card>
);
}
const englishSources = dataSources.filter(s => s.category === 'english');
const chineseSources = dataSources.filter(s => s.category === 'chinese');
return (
<div>
<div className="flex items-center gap-3 mb-4">
<Button type="text" icon={<ArrowLeftOutlined />} onClick={onBack} size="small">
</Button>
<Text strong className="text-lg"></Text>
</div>
<Card className="mb-4" size="small">
<Text type="secondary" className="block mb-2 text-xs"></Text>
<Input.TextArea
value={query}
onChange={(e) => setQuery(e.target.value)}
autoSize={{ minRows: 2, maxRows: 4 }}
className="!text-base"
/>
</Card>
<Card className="mb-4" size="small" title={<><GlobalOutlined className="mr-2" /></>}>
{loadingSources ? (
<Spin size="small" />
) : (
<>
<Text type="secondary" className="block mb-3 text-xs"></Text>
<div className="flex flex-col gap-2 mb-4">
{englishSources.map(ds => (
<Checkbox
key={ds.id}
checked={selectedIds.includes(ds.id)}
onChange={() => handleToggle(ds.id)}
>
<span className="font-medium">{ds.label}</span>
<span className="text-gray-400 text-xs ml-2">{ds.domainScope}</span>
</Checkbox>
))}
</div>
<Divider className="!my-3" />
<Text type="secondary" className="block mb-3 text-xs"></Text>
<div className="flex flex-col gap-2">
{chineseSources.map(ds => (
<Checkbox
key={ds.id}
checked={selectedIds.includes(ds.id)}
onChange={() => handleToggle(ds.id)}
>
<span className="font-medium">{ds.label}</span>
<span className="text-gray-400 text-xs ml-2">{ds.domainScope}</span>
</Checkbox>
))}
</div>
</>
)}
</Card>
<Card className="mb-6" size="small" title="高级筛选">
<div className="flex gap-4">
<div className="flex-1">
<Text type="secondary" className="block mb-1 text-xs"></Text>
<Select
value={yearRange}
onChange={setYearRange}
className="w-full"
options={[
{ value: '不限', label: '不限' },
{ value: '近1年', label: '近1年' },
{ value: '近3年', label: '近3年' },
{ value: '近5年', label: '近5年' },
{ value: '近10年', label: '近10年' },
]}
/>
</div>
<div className="flex-1">
<Text type="secondary" className="block mb-1 text-xs"></Text>
<Select
value={targetCount}
onChange={setTargetCount}
className="w-full"
options={[
{ value: '全面检索', label: '全面检索' },
{ value: '约20篇', label: '约20篇' },
{ value: '约50篇', label: '约50篇' },
{ value: '约100篇', label: '约100篇' },
]}
/>
</div>
</div>
</Card>
<Button
type="primary"
icon={<ThunderboltOutlined />}
size="large"
block
onClick={handleSubmit}
loading={loading}
disabled={!query.trim() || selectedIds.length === 0}
>
{loading ? LOADING_TEXTS[loadingTextIdx] : '生成检索需求书'}
</Button>
</div>
);
};
export default SetupPanel;

View File

@@ -0,0 +1,122 @@
/**
* Deep Research V2.0 — 检索指令确认
*
* 单列布局PICOS 内联摘要 + 可编辑检索指令书 + 保存/启动按钮
* 支持 collapsed 模式显示为已确认卡片
*/
import { useState } from 'react';
import { Card, Button, Input, Tag, Typography, Divider, message } from 'antd';
import {
RocketOutlined,
SaveOutlined,
CheckCircleOutlined,
AimOutlined,
TagsOutlined,
} from '@ant-design/icons';
import type { IntentSummary } from '../../types/deepResearch';
const { Text } = Typography;
const { TextArea } = Input;
interface StrategyConfirmProps {
generatedRequirement: string;
intentSummary: IntentSummary | null;
onConfirm: (confirmedRequirement: string) => void;
collapsed?: boolean;
}
const StrategyConfirm: React.FC<StrategyConfirmProps> = ({
generatedRequirement,
intentSummary,
onConfirm,
collapsed,
}) => {
const [editedRequirement, setEditedRequirement] = useState(generatedRequirement);
const [saved, setSaved] = useState(false);
if (collapsed) {
return (
<Card size="small" className="!bg-white">
<div className="flex items-center gap-3">
<CheckCircleOutlined className="text-green-500 text-lg flex-shrink-0" />
<Text strong></Text>
</div>
</Card>
);
}
const handleSave = () => {
setSaved(true);
message.success('检索指令已保存');
setTimeout(() => setSaved(false), 2000);
};
return (
<div>
<Text strong className="text-lg block mb-4"></Text>
{intentSummary && (
<Card size="small" className="mb-4 !bg-blue-50/50">
<div className="flex items-center gap-2 mb-2">
<AimOutlined className="text-blue-500" />
<Text className="text-sm">{intentSummary.objective}</Text>
</div>
<Divider className="!my-2" />
<div className="flex flex-wrap gap-x-6 gap-y-1 text-xs mb-2">
<span><Text type="secondary">P </Text> <Text className="ml-1">{intentSummary.population || '—'}</Text></span>
<span><Text type="secondary">I </Text> <Text className="ml-1">{intentSummary.intervention || '—'}</Text></span>
<span><Text type="secondary">C </Text> <Text className="ml-1">{intentSummary.comparison || '—'}</Text></span>
<span><Text type="secondary">O </Text> <Text className="ml-1">{intentSummary.outcome || '—'}</Text></span>
</div>
{intentSummary.meshTerms && intentSummary.meshTerms.length > 0 && (
<div className="flex items-center gap-1 flex-wrap mt-1">
<TagsOutlined className="text-green-500 text-xs" />
{intentSummary.meshTerms.map((term, i) => (
<Tag key={i} color="green" className="!text-xs !m-0">{term}</Tag>
))}
</div>
)}
{intentSummary.studyDesign && intentSummary.studyDesign.length > 0 && (
<div className="flex items-center gap-1 flex-wrap mt-1">
{intentSummary.studyDesign.map((s, i) => (
<Tag key={i} color="blue" className="!text-xs !m-0">{s}</Tag>
))}
</div>
)}
</Card>
)}
<Card size="small" className="mb-4">
<TextArea
value={editedRequirement}
onChange={(e) => { setEditedRequirement(e.target.value); setSaved(false); }}
autoSize={{ minRows: 8, maxRows: 20 }}
className="!text-sm !leading-relaxed"
/>
</Card>
<div className="flex gap-3">
<Button
icon={<SaveOutlined />}
onClick={handleSave}
disabled={saved}
>
{saved ? '已保存' : '保存修改'}
</Button>
<Button
type="primary"
icon={<RocketOutlined />}
size="large"
className="flex-1"
onClick={() => onConfirm(editedRequirement)}
disabled={!editedRequirement.trim()}
>
Deep Research
</Button>
</div>
</div>
);
};
export default StrategyConfirm;

View File

@@ -0,0 +1,48 @@
/**
* Deep Research V2.0 任务轮询 Hook
*
* running 时 3s 轮询completed/failed 停止。
*/
import { useQuery } from '@tanstack/react-query';
import { aslApi } from '../api';
interface UseDeepResearchTaskOptions {
taskId: string | null;
enabled?: boolean;
pollingInterval?: number;
}
export function useDeepResearchTask({
taskId,
enabled = true,
pollingInterval = 3000,
}: UseDeepResearchTaskOptions) {
const { data, isLoading, error, refetch } = useQuery({
queryKey: ['deep-research-task', taskId],
queryFn: () => aslApi.getDeepResearchTask(taskId!),
enabled: enabled && !!taskId,
refetchInterval: (query) => {
const task = query.state.data?.data;
if (!task) return pollingInterval;
if (task.status === 'completed' || task.status === 'failed') {
return false;
}
return pollingInterval;
},
staleTime: 0,
});
const task = data?.data || null;
return {
task,
isLoading,
error,
refetch,
isRunning: task?.status === 'running' || task?.status === 'pending',
isCompleted: task?.status === 'completed',
isFailed: task?.status === 'failed',
isDraft: task?.status === 'draft',
};
}

View File

@@ -22,6 +22,9 @@ const FulltextResults = lazy(() => import('./pages/FulltextResults'));
// 智能文献检索页面
const ResearchSearch = lazy(() => import('./pages/ResearchSearch'));
// Deep Research V2.0
const DeepResearchPage = lazy(() => import('./pages/DeepResearchPage'));
const ASLModule = () => {
return (
<Suspense
@@ -35,8 +38,11 @@ const ASLModule = () => {
<Route path="" element={<ASLLayout />}>
<Route index element={<Navigate to="screening/title/settings" replace />} />
{/* 智能文献检索 */}
{/* 智能文献检索 V1.x保留兼容 */}
<Route path="research/search" element={<ResearchSearch />} />
{/* Deep Research V2.0 */}
<Route path="research/deep" element={<DeepResearchPage />} />
{/* 标题摘要初筛 */}
<Route path="screening/title">

View File

@@ -0,0 +1,161 @@
/**
* Deep Research V2.0 主页面 — 瀑布流布局
*
* Phase 0: Landing全屏居中搜索
* Phase 1+: 配置 → 策略 → 执行 → 结果,依次累积展示
*/
import { useState, useCallback, useRef, useEffect } from 'react';
import { message } from 'antd';
import { aslApi } from '../api';
import { useDeepResearchTask } from '../hooks/useDeepResearchTask';
import LandingView from '../components/deep-research/LandingView';
import SetupPanel from '../components/deep-research/SetupPanel';
import StrategyConfirm from '../components/deep-research/StrategyConfirm';
import AgentTerminal from '../components/deep-research/AgentTerminal';
import ResultsView from '../components/deep-research/ResultsView';
import type { IntentSummary, GenerateRequirementResponse } from '../types/deepResearch';
type Phase = 0 | 1 | 2 | 3 | 4;
const DeepResearchPage = () => {
const [phase, setPhase] = useState<Phase>(0);
const [query, setQuery] = useState('');
const [taskId, setTaskId] = useState<string | null>(null);
const [generatedRequirement, setGeneratedRequirement] = useState('');
const [intentSummary, setIntentSummary] = useState<IntentSummary | null>(null);
const [generating, setGenerating] = useState(false);
const strategyRef = useRef<HTMLDivElement>(null);
const terminalRef = useRef<HTMLDivElement>(null);
const resultsRef = useRef<HTMLDivElement>(null);
const { task, isRunning, isCompleted, isFailed } = useDeepResearchTask({
taskId,
enabled: phase >= 3,
});
useEffect(() => {
if (isCompleted && phase === 3) {
setPhase(4);
setTimeout(() => resultsRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 150);
}
}, [isCompleted, phase]);
const scrollTo = (ref: React.RefObject<HTMLDivElement | null>) => {
setTimeout(() => ref.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 150);
};
const handleLandingSubmit = useCallback((q: string) => {
setQuery(q);
setPhase(1);
setGeneratedRequirement('');
setIntentSummary(null);
setTaskId(null);
}, []);
const handleSetupSubmit = useCallback(async (
originalQuery: string,
selectedSources: string[],
filters: { yearRange?: string; targetCount?: string }
) => {
setGenerating(true);
try {
const res = await aslApi.generateRequirement({
originalQuery,
targetSources: selectedSources,
filters,
});
const data = res.data as GenerateRequirementResponse;
setTaskId(data.taskId);
setGeneratedRequirement(data.generatedRequirement);
setIntentSummary(data.intentSummary);
setPhase(2);
scrollTo(strategyRef);
} catch (err: any) {
message.error(err.message || '需求扩写失败');
} finally {
setGenerating(false);
}
}, []);
const handleStrategyConfirm = useCallback(async (confirmedReq: string) => {
if (!taskId) return;
try {
await aslApi.executeDeepResearchTask(taskId, confirmedReq);
setPhase(3);
scrollTo(terminalRef);
message.success('Deep Research 已启动');
} catch (err: any) {
message.error(err.message || '启动失败');
}
}, [taskId]);
const handleNewSearch = useCallback(() => {
setPhase(0);
setQuery('');
setTaskId(null);
setGeneratedRequirement('');
setIntentSummary(null);
window.scrollTo({ top: 0, behavior: 'smooth' });
}, []);
if (phase === 0) {
return (
<div className="h-full overflow-auto bg-gray-50">
<LandingView onSubmit={handleLandingSubmit} />
</div>
);
}
return (
<div className="h-full overflow-auto bg-gray-50 pb-16">
<div className="max-w-5xl mx-auto px-6 pt-6">
{/* Section 1: Setup */}
<SetupPanel
initialQuery={query}
onSubmit={handleSetupSubmit}
onBack={() => setPhase(0)}
loading={generating}
collapsed={phase >= 2}
onExpand={phase === 2 ? () => setPhase(1) : undefined}
/>
{/* Section 2: Strategy */}
{phase >= 2 && (
<div ref={strategyRef} className="mt-6">
<StrategyConfirm
generatedRequirement={generatedRequirement}
intentSummary={intentSummary}
onConfirm={handleStrategyConfirm}
collapsed={phase >= 3}
/>
</div>
)}
{/* Section 3: Executing */}
{phase >= 3 && (
<div ref={terminalRef} className="mt-6">
<AgentTerminal
task={task}
isRunning={isRunning}
isFailed={isFailed}
/>
</div>
)}
{/* Section 4: Results */}
{phase >= 4 && task && (
<div ref={resultsRef} className="mt-6">
<ResultsView
task={task}
onNewSearch={handleNewSearch}
/>
</div>
)}
</div>
</div>
);
};
export default DeepResearchPage;

View File

@@ -0,0 +1,76 @@
/**
* Deep Research V2.0 类型定义
*/
export interface DataSourceConfig {
id: string;
label: string;
labelEn: string;
domainScope: string;
category: 'english' | 'chinese';
defaultChecked: boolean;
note?: string;
}
export interface IntentSummary {
objective: string;
population: string;
intervention: string;
comparison: string;
outcome: string;
studyDesign: string[];
meshTerms: string[];
condition: string;
}
export interface ExecutionLogEntry {
type: 'thinking' | 'searching' | 'reading' | 'analyzing' | 'summary' | 'info';
title: string;
text: string;
ts: string;
}
export interface LiteratureItem {
title: string;
authors?: string;
journal?: string;
year?: number | string;
doi?: string;
pmid?: string;
url?: string;
abstract?: string;
studyType?: string;
}
export interface DeepResearchTask {
taskId: string;
status: 'draft' | 'pending' | 'running' | 'completed' | 'failed';
query: string;
targetSources: string[] | null;
confirmedRequirement: string | null;
aiIntentSummary: IntentSummary | null;
executionLogs: ExecutionLogEntry[];
synthesisReport: string | null;
resultList: LiteratureItem[] | null;
resultCount: number | null;
errorMessage: string | null;
createdAt: string;
completedAt: string | null;
}
export interface GenerateRequirementRequest {
originalQuery: string;
targetSources?: string[];
filters?: {
yearRange?: string;
targetCount?: string;
};
}
export interface GenerateRequirementResponse {
taskId: string;
generatedRequirement: string;
intentSummary: IntentSummary;
}
export type DeepResearchStep = 'landing' | 'setup' | 'strategy' | 'executing' | 'results';