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
353 lines
12 KiB
TypeScript
353 lines
12 KiB
TypeScript
/**
|
||
* 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);
|
||
|
||
// 轮询 hook(phase >= 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;
|