feat(asl): Add DeepSearch smart literature retrieval MVP
Features: - Integrate unifuncs DeepSearch API (OpenAI compatible protocol) - SSE real-time streaming for AI thinking process display - Natural language input, auto-generate PubMed search strategy - Extract and display PubMed literature links - Database storage for task records (asl_research_tasks) Backend: - researchService.ts - Core business logic with SSE streaming - researchController.ts - SSE stream endpoint - researchWorker.ts - Async task worker (backup mode) - schema.prisma - AslResearchTask model Frontend: - ResearchSearch.tsx - Search page with unified content stream - ResearchSearch.css - Styling (unifuncs-inspired simple design) - ASLLayout.tsx - Enable menu item - api/index.ts - Add research API functions API Endpoints: - POST /api/v1/asl/research/stream - SSE streaming search - POST /api/v1/asl/research/tasks - Async task creation - GET /api/v1/asl/research/tasks/:taskId/status - Task status Documentation: - Development record for DeepSearch integration - Update ASL module status (v1.5) - Update system status (v3.7) Known limitations: - SSE mode, task interrupts when leaving page - Cost ~0.3 RMB per search (unifuncs API)
This commit is contained in:
@@ -436,6 +436,48 @@ export async function exportFulltextResults(
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
// ==================== 智能文献检索API (DeepSearch) ====================
|
||||
|
||||
/**
|
||||
* 创建智能文献检索任务
|
||||
*/
|
||||
export async function createResearchTask(data: {
|
||||
projectId: string;
|
||||
query: string;
|
||||
}): Promise<ApiResponse<{
|
||||
id: string;
|
||||
status: string;
|
||||
}>> {
|
||||
return request('/research/tasks', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取智能文献检索任务状态
|
||||
*/
|
||||
export async function getResearchTaskStatus(
|
||||
taskId: string
|
||||
): Promise<ApiResponse<{
|
||||
taskId: string;
|
||||
status: 'processing' | 'ready' | 'error';
|
||||
progress: number;
|
||||
query?: string;
|
||||
resultCount?: number;
|
||||
reasoningContent?: string;
|
||||
literatures?: Array<{
|
||||
pmid: string;
|
||||
title: string;
|
||||
authors: string;
|
||||
journal: string;
|
||||
year: number;
|
||||
}>;
|
||||
errorMessage?: string;
|
||||
}>> {
|
||||
return request(`/research/tasks/${taskId}/status`);
|
||||
}
|
||||
|
||||
// ==================== 统一导出API对象 ====================
|
||||
|
||||
/**
|
||||
@@ -482,4 +524,8 @@ export const aslApi = {
|
||||
|
||||
// 健康检查
|
||||
healthCheck,
|
||||
|
||||
// 智能文献检索 (DeepSearch)
|
||||
createResearchTask,
|
||||
getResearchTaskStatus,
|
||||
};
|
||||
|
||||
@@ -39,11 +39,9 @@ const ASLLayout = () => {
|
||||
title: '敬请期待'
|
||||
},
|
||||
{
|
||||
key: 'literature-search',
|
||||
key: '/literature/research/search',
|
||||
icon: <SearchOutlined />,
|
||||
label: '2. 智能文献检索',
|
||||
disabled: true,
|
||||
title: '敬请期待'
|
||||
},
|
||||
{
|
||||
key: 'literature-management',
|
||||
@@ -131,6 +129,9 @@ const ASLLayout = () => {
|
||||
};
|
||||
const openKeys = getOpenKeys();
|
||||
|
||||
// 智能文献检索页面使用全屏布局(无左侧导航栏装饰)
|
||||
const isResearchPage = currentPath.includes('/research/');
|
||||
|
||||
return (
|
||||
<Layout className="h-screen">
|
||||
{/* 左侧导航栏 */}
|
||||
@@ -162,7 +163,7 @@ const ASLLayout = () => {
|
||||
|
||||
{/* 右侧内容区 */}
|
||||
<Layout>
|
||||
<Content className="bg-white overflow-auto">
|
||||
<Content className={isResearchPage ? "overflow-auto" : "bg-white overflow-auto"}>
|
||||
<Outlet />
|
||||
</Content>
|
||||
</Layout>
|
||||
|
||||
@@ -19,6 +19,9 @@ const FulltextProgress = lazy(() => import('./pages/FulltextProgress'));
|
||||
const FulltextWorkbench = lazy(() => import('./pages/FulltextWorkbench'));
|
||||
const FulltextResults = lazy(() => import('./pages/FulltextResults'));
|
||||
|
||||
// 智能文献检索页面
|
||||
const ResearchSearch = lazy(() => import('./pages/ResearchSearch'));
|
||||
|
||||
const ASLModule = () => {
|
||||
return (
|
||||
<Suspense
|
||||
@@ -32,6 +35,9 @@ const ASLModule = () => {
|
||||
<Route path="" element={<ASLLayout />}>
|
||||
<Route index element={<Navigate to="screening/title/settings" replace />} />
|
||||
|
||||
{/* 智能文献检索 */}
|
||||
<Route path="research/search" element={<ResearchSearch />} />
|
||||
|
||||
{/* 标题摘要初筛 */}
|
||||
<Route path="screening/title">
|
||||
<Route index element={<Navigate to="settings" replace />} />
|
||||
|
||||
230
frontend-v2/src/modules/asl/pages/ResearchSearch.css
Normal file
230
frontend-v2/src/modules/asl/pages/ResearchSearch.css
Normal file
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* 智能文献检索页面样式
|
||||
*/
|
||||
|
||||
.research-page {
|
||||
min-height: 100%;
|
||||
background: #fafafa;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ==================== 搜索区域 ==================== */
|
||||
|
||||
.search-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
transition: all 0.3s ease;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.search-section-center {
|
||||
justify-content: center;
|
||||
min-height: calc(100vh - 100px);
|
||||
}
|
||||
|
||||
.search-section-top {
|
||||
padding-top: 40px;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.search-title {
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.search-section-top .search-title {
|
||||
font-size: 24px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
width: 100%;
|
||||
max-width: 680px;
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
border: 1px solid #e8e8e8;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
font-size: 16px !important;
|
||||
resize: none !important;
|
||||
padding: 8px 0 !important;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: #999 !important;
|
||||
}
|
||||
|
||||
.search-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.search-btn {
|
||||
height: 40px !important;
|
||||
padding: 0 24px !important;
|
||||
font-size: 15px !important;
|
||||
border-radius: 8px !important;
|
||||
}
|
||||
|
||||
/* ==================== 结果卡片 ==================== */
|
||||
|
||||
.result-card {
|
||||
max-width: 900px;
|
||||
margin: 0 auto 24px;
|
||||
width: calc(100% - 48px);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.result-card .ant-card-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* 状态栏 */
|
||||
.result-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
margin-bottom: 16px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.result-header .success-icon {
|
||||
color: #52c41a;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.result-header strong {
|
||||
color: #1890ff;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/* ==================== 统一内容流 ==================== */
|
||||
|
||||
.content-stream {
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
background: #f9f9f9;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.content-stream pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
.content-stream::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.content-stream::-webkit-scrollbar-track {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
.content-stream::-webkit-scrollbar-thumb {
|
||||
background: #ddd;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* ==================== PubMed 链接列表 ==================== */
|
||||
|
||||
.links-section {
|
||||
margin-top: 20px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.links-header {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.links-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.link-item {
|
||||
display: block;
|
||||
padding: 10px 12px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 6px;
|
||||
color: #1890ff;
|
||||
font-size: 13px;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.link-item:hover {
|
||||
background: #e6f7ff;
|
||||
color: #096dd9;
|
||||
}
|
||||
|
||||
/* ==================== 错误状态 ==================== */
|
||||
|
||||
.error-card {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
font-size: 48px;
|
||||
color: #ff4d4f;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: #666;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* ==================== 响应式 ==================== */
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.search-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.result-card {
|
||||
width: calc(100% - 32px);
|
||||
}
|
||||
|
||||
.content-stream {
|
||||
max-height: 400px;
|
||||
}
|
||||
}
|
||||
228
frontend-v2/src/modules/asl/pages/ResearchSearch.tsx
Normal file
228
frontend-v2/src/modules/asl/pages/ResearchSearch.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* 智能文献检索页面(DeepSearch)
|
||||
*
|
||||
* SSE 实时显示,统一文档流
|
||||
*/
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Input, Button, Card, message } from 'antd';
|
||||
import {
|
||||
SearchOutlined,
|
||||
LinkOutlined,
|
||||
CheckCircleFilled,
|
||||
CloseCircleFilled,
|
||||
LoadingOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { getAccessToken } from '../../../framework/auth/api';
|
||||
import './ResearchSearch.css';
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
const ResearchSearch = () => {
|
||||
const [query, setQuery] = useState('');
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [content, setContent] = useState(''); // 统一的内容流
|
||||
const [links, setLinks] = useState<string[]>([]); // PubMed 链接列表
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 自动滚动
|
||||
useEffect(() => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.scrollTop = contentRef.current.scrollHeight;
|
||||
}
|
||||
}, [content]);
|
||||
|
||||
// SSE 流式检索
|
||||
const handleSearch = async () => {
|
||||
if (!query.trim()) {
|
||||
message.warning('请输入检索问题');
|
||||
return;
|
||||
}
|
||||
|
||||
// 重置状态
|
||||
setIsSearching(true);
|
||||
setContent('');
|
||||
setLinks([]);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const token = getAccessToken();
|
||||
|
||||
const response = await fetch('/api/v1/asl/research/stream', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
projectId: 'default',
|
||||
query: query.trim(),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
throw new Error('无法读取响应流');
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
try {
|
||||
const data = JSON.parse(line.slice(6));
|
||||
|
||||
switch (data.type) {
|
||||
case 'reasoning':
|
||||
case 'content':
|
||||
// 统一追加到内容流
|
||||
setContent(prev => prev + data.content);
|
||||
break;
|
||||
case 'completed':
|
||||
setLinks(data.links || []);
|
||||
setIsSearching(false);
|
||||
message.success(`检索完成,找到 ${data.links?.length || 0} 个 PubMed 链接`);
|
||||
break;
|
||||
case 'error':
|
||||
setError(data.error);
|
||||
setIsSearching(false);
|
||||
message.error(data.error);
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略解析错误
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setIsSearching(false);
|
||||
|
||||
} catch (err: any) {
|
||||
setError(err.message || '检索失败');
|
||||
setIsSearching(false);
|
||||
message.error(err.message || '检索失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 重新检索
|
||||
const handleRetry = () => {
|
||||
setQuery('');
|
||||
setContent('');
|
||||
setLinks([]);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const hasContent = content.length > 0;
|
||||
const isCompleted = !isSearching && hasContent && !error;
|
||||
|
||||
return (
|
||||
<div className="research-page">
|
||||
{/* 搜索区域 */}
|
||||
<div className={`search-section ${hasContent ? 'search-section-top' : 'search-section-center'}`}>
|
||||
<h1 className="search-title">智能文献检索</h1>
|
||||
<div className="search-box">
|
||||
<TextArea
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="今天想检索点啥?"
|
||||
autoSize={{ minRows: 2, maxRows: 4 }}
|
||||
disabled={isSearching}
|
||||
className="search-input"
|
||||
onPressEnter={(e) => {
|
||||
if (!e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSearch();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="search-actions">
|
||||
<Button
|
||||
type="primary"
|
||||
icon={isSearching ? <LoadingOutlined /> : <SearchOutlined />}
|
||||
onClick={handleSearch}
|
||||
disabled={isSearching || !query.trim()}
|
||||
className="search-btn"
|
||||
>
|
||||
{isSearching ? '检索中' : 'Go'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 统一内容流 */}
|
||||
{hasContent && (
|
||||
<Card className="result-card">
|
||||
{/* 状态栏 */}
|
||||
<div className="result-header">
|
||||
{isSearching ? (
|
||||
<>
|
||||
<LoadingOutlined style={{ color: '#1890ff' }} />
|
||||
<span>AI 正在深度检索...</span>
|
||||
</>
|
||||
) : isCompleted ? (
|
||||
<>
|
||||
<CheckCircleFilled className="success-icon" />
|
||||
<span>检索完成,找到 <strong>{links.length}</strong> 个 PubMed 链接</span>
|
||||
<Button onClick={handleRetry} size="small" style={{ marginLeft: 'auto' }}>新建检索</Button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* 实时内容 */}
|
||||
<div className="content-stream" ref={contentRef}>
|
||||
<pre>{content}</pre>
|
||||
</div>
|
||||
|
||||
{/* PubMed 链接列表 */}
|
||||
{links.length > 0 && (
|
||||
<div className="links-section">
|
||||
<div className="links-header">
|
||||
<LinkOutlined /> PubMed 文献链接({links.length})
|
||||
</div>
|
||||
<div className="links-list">
|
||||
{links.map((link, index) => (
|
||||
<a
|
||||
key={index}
|
||||
href={link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="link-item"
|
||||
>
|
||||
{index + 1}. {link}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 错误状态 */}
|
||||
{error && !isSearching && (
|
||||
<Card className="result-card error-card">
|
||||
<CloseCircleFilled className="error-icon" />
|
||||
<div className="error-text">检索失败:{error}</div>
|
||||
<Button type="primary" onClick={handleRetry}>重新检索</Button>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResearchSearch;
|
||||
Reference in New Issue
Block a user