Files
AIclinicalresearch/frontend-v2/src/modules/asl/pages/DeepResearchPage.tsx
HaHafeng 87655ea7e6 feat(rvw,asl): RVW V3.0 smart review + ASL deep research history + stability
RVW module (V3.0 Smart Review Enhancement):
- Add LLM data validation via PromptService (RVW_DATA_VALIDATION)
- Add ClinicalAssessmentSkill with FINER-based evaluation (RVW_CLINICAL)
- Remove all numeric scores from UI (editorial, methodology, overall)
- Implement partial_completed status with Promise.allSettled
- Add error_details JSON field to ReviewTask for granular failure info
- Fix overallStatus logic: warning status now counts as success
- Restructure ForensicsReport: per-table LLM results, remove top-level block
- Refactor ClinicalReport: structured collapsible sections
- Increase all skill timeouts to 300s for long manuscripts (20+ pages)
- Increase DataForensics LLM timeout to 180s, pg-boss to 15min
- Executor default fallback timeout 30s -> 60s

ASL module:
- Add deep research history with sidebar accordion UI
- Implement waterfall flow for historical task display
- Upgrade Unifuncs DeepSearch API from S2 to S3 with fallback
- Add ASL_SR module seed for admin configurability
- Fix new search button inconsistency

Docs:
- Update RVW module status to V3.0
- Update deployment changelist
- Add 0305 deployment summary

DB Migration:
- Add error_details JSONB column to rvw_schema.review_tasks

Tested: All 4 review modules verified, partial completion working
Made-with: Cursor
2026-03-07 19:24:21 +08:00

353 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Deep Research V2.0 主页面 — 瀑布流布局
*
* Phase 0: Landing全屏居中搜索
* Phase 1+: 配置 -> 策略 -> 执行 -> 结果,依次累积展示
*
* 支持从 URL 参数 :taskId 恢复历史任务。
*/
import { useState, useCallback, useRef, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Card, Button, Typography, message } from 'antd';
import { CheckCircleFilled, DownloadOutlined, PlusOutlined, ClockCircleOutlined, CloseCircleFilled } from '@ant-design/icons';
import { getAccessToken } from '../../../framework/auth/api';
import { aslApi } from '../api';
import { useDeepResearchTask } from '../hooks/useDeepResearchTask';
import { useASLLayout } from '../components/ASLLayout';
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 { taskId: urlTaskId } = useParams<{ taskId?: string }>();
const navigate = useNavigate();
const { refreshHistory } = useASLLayout();
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 [restoredFromUrl, setRestoredFromUrl] = useState(false);
const strategyRef = useRef<HTMLDivElement>(null);
const terminalRef = useRef<HTMLDivElement>(null);
const resultsRef = useRef<HTMLDivElement>(null);
// 轮询 hookphase >= 3 或从 URL 恢复的任务)
const shouldPoll = phase >= 3 || (restoredFromUrl && !!taskId);
const { task, isRunning, isCompleted, isFailed } = useDeepResearchTask({
taskId,
enabled: shouldPoll,
});
// 从 URL :taskId 恢复历史任务,或 URL 失去 taskId 时重置
useEffect(() => {
if (urlTaskId && urlTaskId !== taskId) {
setTaskId(urlTaskId);
setRestoredFromUrl(true);
} else if (!urlTaskId && taskId) {
setPhase(0);
setTaskId(null);
setQuery('');
setGeneratedRequirement('');
setIntentSummary(null);
setRestoredFromUrl(false);
}
}, [urlTaskId]); // eslint-disable-line react-hooks/exhaustive-deps
// 恢复的任务加载完成后设置正确的 phase
useEffect(() => {
if (!restoredFromUrl || !task) return;
setQuery(task.query || '');
if (task.confirmedRequirement) {
setGeneratedRequirement(task.confirmedRequirement);
}
if (task.aiIntentSummary) {
setIntentSummary(task.aiIntentSummary);
}
if (task.status === 'completed') {
setPhase(4);
} else if (task.status === 'running' || task.status === 'pending') {
setPhase(3);
} else if (task.status === 'failed') {
setPhase(4);
} else if (task.status === 'draft') {
setPhase(2);
}
}, [restoredFromUrl, task]);
// 正常流程中:执行完成自动进入结果页
useEffect(() => {
if (isCompleted && phase === 3 && !restoredFromUrl) {
setPhase(4);
setTimeout(() => resultsRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 150);
}
}, [isCompleted, phase, restoredFromUrl]);
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);
setRestoredFromUrl(false);
}, []);
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 已启动');
// 任务进入 pending 后刷新侧边栏历史列表
navigate(`/literature/research/deep/${taskId}`, { replace: true });
refreshHistory();
} catch (err: any) {
message.error(err.message || '启动失败');
}
}, [taskId, navigate, refreshHistory]);
const handleNewSearch = useCallback(() => {
setPhase(0);
setQuery('');
setTaskId(null);
setGeneratedRequirement('');
setIntentSummary(null);
setRestoredFromUrl(false);
navigate('/literature/research/deep', { replace: true });
window.scrollTo({ top: 0, behavior: 'smooth' });
}, [navigate]);
// ── 从 URL 恢复的任务:直接展示结果或执行中状态 ──
if (restoredFromUrl && task) {
const isFinished = task.status === 'completed' || task.status === 'failed';
return (
<div className="h-full overflow-auto bg-gray-50 pb-16">
<div className="max-w-5xl mx-auto px-6 pt-6">
{/* 顶部横幅:完成/失败状态 + 导出按钮 */}
{isFinished && (
<HistoryBanner task={task} onNewSearch={handleNewSearch} />
)}
{!isFinished && (
<Card size="small" className="mb-4 !bg-blue-50 !border-blue-200">
<div className="flex items-center gap-3">
<ClockCircleOutlined className="text-lg text-blue-500" />
<Typography.Text strong>{task.query} ...</Typography.Text>
</div>
</Card>
)}
{/* 检索需求书PICOS + 完整指令) */}
{task.confirmedRequirement && task.aiIntentSummary && (
<div className="mt-4">
<StrategyConfirm
generatedRequirement={task.confirmedRequirement}
intentSummary={task.aiIntentSummary}
onConfirm={() => {}}
readOnly
/>
</div>
)}
{/* 执行日志 */}
<div className="mt-4">
<AgentTerminal task={task} isRunning={!isFinished && isRunning} isFailed={isFailed} />
</div>
{/* 结果区(报告 + 文献表格),横幅已在顶部展示 */}
{isFinished && (
<div className="mt-4">
<ResultsView task={task} onNewSearch={handleNewSearch} hideBanner />
</div>
)}
</div>
</div>
);
}
// 恢复中但 task 还未加载 — loading
if (restoredFromUrl && !task) {
return (
<div className="h-full flex items-center justify-center bg-gray-50">
<div className="text-slate-400 text-sm">...</div>
</div>
);
}
// ── 正常新建流程 ──
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>
);
};
// ── 历史记录顶部横幅(完成/失败 + 导出) ──
const { Text } = Typography;
function HistoryBanner({ task, onNewSearch }: { task: any; onNewSearch: () => void }) {
const [exporting, setExporting] = useState(false);
const isCompleted = task.status === 'completed';
const handleExportWord = async () => {
setExporting(true);
try {
const token = getAccessToken();
const res = await fetch(`/api/v1/asl/research/tasks/${task.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);
}
};
if (!isCompleted) {
return (
<Card size="small" className="mb-4 !bg-red-50 !border-red-200">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<CloseCircleFilled className="text-lg text-red-500" />
<div>
<Text strong></Text>
<Text type="secondary" className="text-sm ml-2">{task.query}</Text>
</div>
</div>
<Button icon={<PlusOutlined />} onClick={onNewSearch}></Button>
</div>
</Card>
);
}
return (
<Card size="small" className="mb-4 !bg-green-50 !border-green-200">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<CheckCircleFilled className="text-xl text-green-500" />
<div>
<Text strong>Deep Research </Text>
<Text type="secondary" className="text-sm ml-2">
{task.query} {task.resultCount || 0}
{task.completedAt && ` · ${new Date(task.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>
);
}
export default DeepResearchPage;