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
This commit is contained in:
2026-03-07 19:24:21 +08:00
parent 91ae80888e
commit 87655ea7e6
46 changed files with 2929 additions and 511 deletions

View File

@@ -485,6 +485,7 @@ import type {
GenerateRequirementRequest,
GenerateRequirementResponse,
DeepResearchTask,
DeepResearchTaskSummary,
} from '../types/deepResearch';
/**
@@ -519,6 +520,20 @@ export async function executeDeepResearchTask(
});
}
/**
* 获取 V2.0 任务历史列表(最近 100 条,不含 draft
*/
export async function listDeepResearchTasks(): Promise<ApiResponse<DeepResearchTaskSummary[]>> {
return request('/research/v2/tasks');
}
/**
* 删除 V2.0 检索任务
*/
export async function deleteDeepResearchTask(taskId: string): Promise<ApiResponse<void>> {
return request(`/research/tasks/${taskId}`, { method: 'DELETE' });
}
/**
* 获取 V2.0 任务详情(状态 + 日志 + 结果)
*/
@@ -714,6 +729,8 @@ export const aslApi = {
getDeepResearchDataSources,
generateRequirement,
executeDeepResearchTask,
listDeepResearchTasks,
deleteDeepResearchTask,
getDeepResearchTask,
// 工具 3全文智能提取

View File

@@ -1,160 +1,288 @@
/**
* ASL模块布局组件
*
* 左侧导航栏 + 右侧内容区
* 参考原型AI智能文献-标题摘要初筛原型.html
* ASL 模块布局 — 互斥手风琴侧边栏
*
* 参考 v6.html 设计 + Protocol Agent (AIA) 模块 UI 风格
* - 面板 A智能文献检索历史列表 + 新建检索)
* - 面板 B系统综述项目需要 ASL_SR 模块权限)
* - 两个面板 Header 始终可见,内容区互斥展开
*/
import { Layout, Menu } from 'antd';
import { useState, useCallback, useMemo, createContext, useContext } from 'react';
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Popconfirm, message } from 'antd';
import {
SearchOutlined,
FilterOutlined,
DatabaseOutlined,
BarChartOutlined,
SettingOutlined,
CheckSquareOutlined,
UnorderedListOutlined,
ApartmentOutlined,
} from '@ant-design/icons';
import type { MenuProps } from 'antd';
Sparkles,
ChevronDown,
Plus,
History,
MessageSquare,
FolderKanban,
Trash2,
ListFilter,
FileSearch,
FileText,
BarChart3,
TrendingUp,
Lock,
} from 'lucide-react';
import { useAuth } from '../../../framework/auth/AuthContext';
import { aslApi } from '../api';
import type { DeepResearchTaskSummary } from '../types/deepResearch';
import '../styles/asl-sidebar.css';
const { Sider, Content } = Layout;
// ─── Context ────────────────────────────────────
type MenuItem = Required<MenuProps>['items'][number];
interface ASLLayoutContextValue {
refreshHistory: () => void;
}
const ASLLayoutContext = createContext<ASLLayoutContextValue>({
refreshHistory: () => {},
});
export function useASLLayout() {
return useContext(ASLLayoutContext);
}
// ─── 日期分组 ───────────────────────────────────
function groupByDate(tasks: DeepResearchTaskSummary[]) {
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterday = new Date(today.getTime() - 86400000);
const weekAgo = new Date(today.getTime() - 7 * 86400000);
const groups: { label: string; items: DeepResearchTaskSummary[] }[] = [
{ label: '今天', items: [] },
{ label: '昨天', items: [] },
{ label: '最近 7 天', items: [] },
{ label: '更早', items: [] },
];
for (const task of tasks) {
const d = new Date(task.createdAt);
if (d >= today) groups[0].items.push(task);
else if (d >= yesterday) groups[1].items.push(task);
else if (d >= weekAgo) groups[2].items.push(task);
else groups[3].items.push(task);
}
return groups.filter((g) => g.items.length > 0);
}
// ─── 主组件 ─────────────────────────────────────
type PanelType = 'SEARCH' | 'PROJECT';
const SR_NAV_ITEMS = [
{ label: '标题摘要初筛', path: '/literature/screening/title/settings', icon: ListFilter, matchPrefix: '/literature/screening/title' },
{ label: '全文复筛', path: '/literature/screening/fulltext/settings', icon: FileSearch, matchPrefix: '/literature/screening/fulltext' },
{ label: '全文智能提取', path: '/literature/extraction/setup', icon: FileText, matchPrefix: '/literature/extraction' },
{ label: 'SR 图表生成器', path: '/literature/charting', icon: BarChart3, matchPrefix: '/literature/charting' },
{ label: 'Meta 分析引擎', path: '/literature/meta-analysis', icon: TrendingUp, matchPrefix: '/literature/meta-analysis' },
];
const ASLLayout = () => {
const navigate = useNavigate();
const location = useLocation();
const queryClient = useQueryClient();
const { hasModule } = useAuth();
const hasSR = hasModule('ASL_SR');
// 菜单项配置
const menuItems: MenuItem[] = [
{
key: '/literature/research/deep',
icon: <SearchOutlined />,
label: '1. 智能文献检索',
},
{
key: 'title-screening',
icon: <FilterOutlined />,
label: '2. 标题摘要初筛',
children: [
{
key: '/literature/screening/title/settings',
icon: <SettingOutlined />,
label: '设置与启动',
},
{
key: '/literature/screening/title/workbench',
icon: <CheckSquareOutlined />,
label: '审核工作台',
},
{
key: '/literature/screening/title/results',
icon: <UnorderedListOutlined />,
label: '初筛结果',
},
],
},
{
key: 'extraction',
icon: <DatabaseOutlined />,
label: '3. 全文智能提取',
children: [
{
key: '/literature/extraction/setup',
icon: <SettingOutlined />,
label: '配置与启动',
},
{
key: '/literature/extraction/workbench',
icon: <CheckSquareOutlined />,
label: '审核工作台',
},
],
},
{
key: '/literature/charting',
icon: <ApartmentOutlined />,
label: '4. SR 图表生成器',
},
{
key: '/literature/meta-analysis',
icon: <BarChartOutlined />,
label: '5. Meta 分析引擎',
},
];
const [expandedPanel, setExpandedPanel] = useState<PanelType>('SEARCH');
const [deletingId, setDeletingId] = useState<string | null>(null);
// 处理菜单点击
const handleMenuClick: MenuProps['onClick'] = ({ key }) => {
if (key.startsWith('/')) {
navigate(key);
}
};
const currentTaskId = useMemo(() => {
const match = location.pathname.match(/\/research\/deep\/([^/]+)/);
return match ? match[1] : null;
}, [location.pathname]);
// 获取当前选中的菜单项和展开的子菜单
const currentPath = location.pathname;
const selectedKeys = [currentPath];
// 根据当前路径确定展开的菜单
const getOpenKeys = () => {
if (currentPath.includes('screening/title')) return ['title-screening'];
if (currentPath.includes('/extraction')) return ['extraction'];
if (currentPath.includes('/charting')) return [];
if (currentPath.includes('/meta-analysis')) return [];
return [];
};
const openKeys = getOpenKeys();
const { data: historyResp } = useQuery({
queryKey: ['deep-research-history'],
queryFn: () => aslApi.listDeepResearchTasks(),
staleTime: 30_000,
});
const historyTasks = historyResp?.data ?? [];
const groupedHistory = useMemo(() => groupByDate(historyTasks), [historyTasks]);
// 智能文献检索页面使用全屏布局(无左侧导航栏装饰)
const isResearchPage = currentPath.includes('/research/');
const refreshHistory = useCallback(() => {
queryClient.invalidateQueries({ queryKey: ['deep-research-history'] });
}, [queryClient]);
const togglePanel = useCallback((panel: PanelType) => {
setExpandedPanel((prev) =>
prev === panel
? panel === 'SEARCH' ? 'PROJECT' : 'SEARCH'
: panel
);
}, []);
const selectTask = useCallback(
(taskId: string) => navigate(`/literature/research/deep/${taskId}`),
[navigate]
);
const handleNewSearch = useCallback(() => {
navigate('/literature/research/deep');
}, [navigate]);
const handleDelete = useCallback(
async (taskId: string) => {
setDeletingId(taskId);
try {
await aslApi.deleteDeepResearchTask(taskId);
message.success('已删除');
refreshHistory();
if (currentTaskId === taskId) {
navigate('/literature/research/deep');
}
} catch (err: any) {
message.error(err.message || '删除失败');
} finally {
setDeletingId(null);
}
},
[currentTaskId, navigate, refreshHistory]
);
const isSearchExpanded = expandedPanel === 'SEARCH';
const contextValue = useMemo(() => ({ refreshHistory }), [refreshHistory]);
return (
<Layout className="h-screen">
{/* 左侧导航栏 */}
<Sider
width={250}
className="bg-gray-50 border-r border-gray-200"
theme="light"
>
<div className="p-4 border-b border-gray-200">
<h2 className="text-lg font-bold text-gray-800">
<FilterOutlined className="mr-2" />
AI智能文献
</h2>
<p className="text-xs text-gray-500 mt-1">
MVP
</p>
</div>
<Menu
mode="inline"
selectedKeys={selectedKeys}
defaultOpenKeys={openKeys}
items={menuItems}
onClick={handleMenuClick}
className="border-none"
style={{ height: 'calc(100vh - 80px)', overflowY: 'auto' }}
/>
</Sider>
<ASLLayoutContext.Provider value={contextValue}>
<div style={{ height: '100vh', display: 'flex', overflow: 'hidden' }}>
{/* ── 侧边栏 ── */}
<div className="asl-sidebar">
{/* 右侧内容区 */}
<Layout>
<Content className={isResearchPage ? "overflow-auto" : "bg-white overflow-auto"}>
{/* ── 面板 A Header ── */}
<div
className="asl-panel-header"
onClick={() => togglePanel('SEARCH')}
>
<div className={`asl-panel-header-left ${isSearchExpanded ? 'active' : ''}`}>
<Sparkles size={16} className="header-icon" />
<span></span>
</div>
<ChevronDown
size={16}
className={`asl-panel-chevron ${isSearchExpanded ? 'expanded' : ''}`}
/>
</div>
{/* ── 面板 A Content ── */}
<div className={`asl-panel-content ${isSearchExpanded ? '' : 'collapsed'}`}>
<button className="asl-new-btn" onClick={handleNewSearch}>
<Plus size={16} />
</button>
<div className="asl-list-section-label">
<span></span>
<History size={14} />
</div>
<div className="asl-history-list">
{historyTasks.length === 0 ? (
<div className="asl-empty-state"></div>
) : (
groupedHistory.map((group) => (
<div key={group.label}>
<div className="asl-date-group-label">{group.label}</div>
{group.items.map((task) => {
const isActive = currentTaskId === task.id;
return (
<div
key={task.id}
className={`asl-conv-item ${isActive ? 'active' : ''}`}
onClick={() => selectTask(task.id)}
>
<MessageSquare size={16} className="conv-icon" />
<span
className={`asl-conv-status ${task.status}`}
/>
<span className="asl-conv-title">{task.query}</span>
<Popconfirm
title="确认删除此检索记录?"
onConfirm={(e) => {
e?.stopPropagation();
handleDelete(task.id);
}}
onCancel={(e) => e?.stopPropagation()}
okText="删除"
cancelText="取消"
okButtonProps={{ danger: true }}
>
<button
className="asl-conv-delete"
onClick={(e) => e.stopPropagation()}
disabled={deletingId === task.id}
>
<Trash2 size={14} />
</button>
</Popconfirm>
</div>
);
})}
</div>
))
)}
</div>
</div>
{/* ── 分割线 ── */}
<div className="asl-divider" />
{/* ── 面板 B Header ── */}
<div
className="asl-panel-header"
onClick={() => togglePanel('PROJECT')}
>
<div className={`asl-panel-header-left ${!isSearchExpanded ? 'active' : ''}`}>
<FolderKanban size={16} className="header-icon" />
<span> (SR)</span>
</div>
<ChevronDown
size={16}
className={`asl-panel-chevron ${!isSearchExpanded ? 'expanded' : ''}`}
/>
</div>
{/* ── 面板 B Content ── */}
<div className={`asl-panel-content ${!isSearchExpanded ? '' : 'collapsed'}`}>
{hasSR ? (
<div className="asl-nav-list">
{SR_NAV_ITEMS.map((item) => {
const isActive = location.pathname.startsWith(item.matchPrefix);
const Icon = item.icon;
return (
<div
key={item.path}
className={`asl-nav-item ${isActive ? 'active' : ''}`}
onClick={() => navigate(item.path)}
>
<Icon size={16} className="nav-icon" />
<span className="asl-nav-item-label">{item.label}</span>
</div>
);
})}
</div>
) : (
<div className="asl-sr-locked">
<Lock size={32} className="asl-sr-locked-icon" />
<p className="asl-sr-locked-text"></p>
</div>
)}
</div>
</div>
{/* ── 右侧内容区 ── */}
<div style={{ flex: 1, overflow: 'hidden', background: '#F9FAFB' }}>
<Outlet />
</Content>
</Layout>
</Layout>
</div>
</div>
</ASLLayoutContext.Provider>
);
};
export default ASLLayout;

View File

@@ -24,9 +24,10 @@ const { Text } = Typography;
interface ResultsViewProps {
task: DeepResearchTask;
onNewSearch: () => void;
hideBanner?: boolean;
}
const ResultsView: React.FC<ResultsViewProps> = ({ task, onNewSearch }) => {
const ResultsView: React.FC<ResultsViewProps> = ({ task, onNewSearch, hideBanner }) => {
const { synthesisReport, resultList, resultCount, query, completedAt, taskId } = task;
const [exporting, setExporting] = useState(false);
@@ -129,28 +130,30 @@ const ResultsView: React.FC<ResultsViewProps> = ({ task, onNewSearch }) => {
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>
{!hideBanner && (
<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>
<div className="flex gap-2">
<Button icon={<DownloadOutlined />} loading={exporting} onClick={handleExportWord}>
Word
</Button>
<Button icon={<PlusOutlined />} onClick={onNewSearch}>
</Button>
</div>
</div>
</Card>
</Card>
)}
{/* AI 综合报告 — Markdown 渲染 */}
{synthesisReport && (

View File

@@ -24,6 +24,7 @@ interface StrategyConfirmProps {
intentSummary: IntentSummary | null;
onConfirm: (confirmedRequirement: string) => void;
collapsed?: boolean;
readOnly?: boolean;
}
const StrategyConfirm: React.FC<StrategyConfirmProps> = ({
@@ -31,11 +32,13 @@ const StrategyConfirm: React.FC<StrategyConfirmProps> = ({
intentSummary,
onConfirm,
collapsed,
readOnly,
}) => {
const [editedRequirement, setEditedRequirement] = useState(generatedRequirement);
const [saved, setSaved] = useState(false);
const [expanded, setExpanded] = useState(false);
if (collapsed) {
if (collapsed && !readOnly) {
return (
<Card size="small" className="!bg-white">
<div className="flex items-center gap-3">
@@ -46,6 +49,64 @@ const StrategyConfirm: React.FC<StrategyConfirmProps> = ({
);
}
if (readOnly) {
return (
<Card
size="small"
className="!bg-white"
title={
<div
className="flex items-center justify-between cursor-pointer select-none"
onClick={() => setExpanded(prev => !prev)}
>
<div className="flex items-center gap-2">
<CheckCircleOutlined className="text-green-500" />
<span></span>
</div>
<Text type="secondary" className="text-xs font-normal">
{expanded ? '收起' : '展开详情'}
</Text>
</div>
}
>
{intentSummary && (
<div className="mb-3">
<div className="flex items-center gap-2 mb-2">
<AimOutlined className="text-blue-500" />
<Text className="text-sm">{intentSummary.objective}</Text>
</div>
<div className="flex flex-wrap gap-x-6 gap-y-1 text-xs">
<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-2">
<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>
)}
</div>
)}
{expanded && generatedRequirement && (
<div className="mt-2 p-3 bg-gray-50 rounded-lg text-sm leading-relaxed whitespace-pre-wrap text-gray-700 max-h-[400px] overflow-y-auto">
{generatedRequirement}
</div>
)}
</Card>
);
}
const handleSave = () => {
setSaved(true);
message.success('检索指令已保存');

View File

@@ -71,6 +71,7 @@ const ASLModule = () => {
{/* Deep Research V2.0 */}
<Route path="research/deep" element={<DeepResearchPage />} />
<Route path="research/deep/:taskId" element={<DeepResearchPage />} />
{/* 标题摘要初筛 */}
<Route path="screening/title">

View File

@@ -2,13 +2,19 @@
* Deep Research V2.0 主页面 — 瀑布流布局
*
* Phase 0: Landing全屏居中搜索
* Phase 1+: 配置 策略 执行 结果,依次累积展示
* Phase 1+: 配置 -> 策略 -> 执行 -> 结果,依次累积展示
*
* 支持从 URL 参数 :taskId 恢复历史任务。
*/
import { useState, useCallback, useRef, useEffect } from 'react';
import { message } from 'antd';
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';
@@ -19,28 +25,74 @@ import type { IntentSummary, GenerateRequirementResponse } from '../types/deepRe
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: phase >= 3,
enabled: shouldPoll,
});
// 从 URL :taskId 恢复历史任务,或 URL 失去 taskId 时重置
useEffect(() => {
if (isCompleted && phase === 3) {
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]);
}, [isCompleted, phase, restoredFromUrl]);
const scrollTo = (ref: React.RefObject<HTMLDivElement | null>) => {
setTimeout(() => ref.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 150);
@@ -52,6 +104,7 @@ const DeepResearchPage = () => {
setGeneratedRequirement('');
setIntentSummary(null);
setTaskId(null);
setRestoredFromUrl(false);
}, []);
const handleSetupSubmit = useCallback(async (
@@ -86,10 +139,13 @@ const DeepResearchPage = () => {
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]);
}, [taskId, navigate, refreshHistory]);
const handleNewSearch = useCallback(() => {
setPhase(0);
@@ -97,9 +153,69 @@ const DeepResearchPage = () => {
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">
@@ -158,4 +274,79 @@ const DeepResearchPage = () => {
);
};
// ── 历史记录顶部横幅(完成/失败 + 导出) ──
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;

View File

@@ -0,0 +1,373 @@
/**
* ASL 互斥手风琴侧边栏样式
* 对齐 Protocol Agent (AIA) 模块风格
*/
/* ── 侧边栏容器 ── */
.asl-sidebar {
width: 280px;
min-width: 280px;
display: flex;
flex-direction: column;
background: #F9FAFB;
border-right: 1px solid #E5E7EB;
overflow: hidden;
}
/* ── 面板 Header ── */
.asl-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
cursor: pointer;
transition: background 0.2s;
user-select: none;
flex-shrink: 0;
border-bottom: 1px solid #E5E7EB;
}
.asl-panel-header:hover {
background: #F3F4F6;
}
.asl-panel-header-left {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 600;
color: #374151;
transition: color 0.2s;
}
.asl-panel-header-left.active {
color: #6366F1;
}
.asl-panel-header-left .header-icon {
color: #9CA3AF;
transition: color 0.2s;
}
.asl-panel-header-left.active .header-icon {
color: #6366F1;
}
.asl-panel-chevron {
color: #9CA3AF;
transition: transform 0.3s ease;
}
.asl-panel-chevron.expanded {
transform: rotate(180deg);
}
/* ── 面板内容区(可展开/折叠) ── */
.asl-panel-content {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.asl-panel-content.collapsed {
flex: 0;
min-height: 0;
max-height: 0;
overflow: hidden;
}
/* ── 新建按钮 ── */
.asl-new-btn {
margin: 8px 12px;
padding: 8px 12px;
background: #6366F1;
color: white;
border: none;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
cursor: pointer;
font-size: 13px;
font-weight: 600;
transition: all 0.2s;
flex-shrink: 0;
}
.asl-new-btn:hover {
background: #4F46E5;
}
/* ── 列表区域 ── */
.asl-list-section-label {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 16px;
font-size: 11px;
font-weight: 600;
color: #9CA3AF;
text-transform: uppercase;
letter-spacing: 0.05em;
flex-shrink: 0;
}
.asl-history-list {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 4px 8px;
scrollbar-width: thin;
scrollbar-color: #CBD5E1 transparent;
}
.asl-history-list::-webkit-scrollbar {
width: 6px;
}
.asl-history-list::-webkit-scrollbar-track {
background: transparent;
}
.asl-history-list::-webkit-scrollbar-thumb {
background: #CBD5E1;
border-radius: 3px;
}
.asl-history-list::-webkit-scrollbar-thumb:hover {
background: #94A3B8;
}
/* ── 日期分组标签 ── */
.asl-date-group-label {
padding: 8px 12px 4px;
font-size: 11px;
font-weight: 500;
color: #9CA3AF;
}
/* ── 历史列表项(对齐 Protocol Agent .conv-item ── */
.asl-conv-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
color: #6B7280;
font-size: 13px;
position: relative;
}
.asl-conv-item:hover {
background: #F3F4F6;
}
.asl-conv-item.active {
background: #EEF2FF;
color: #6366F1;
}
.asl-conv-item .conv-icon {
flex-shrink: 0;
color: #9CA3AF;
}
.asl-conv-item.active .conv-icon {
color: #6366F1;
}
.asl-conv-title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.asl-conv-status {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.asl-conv-status.completed {
background: #10B981;
}
.asl-conv-status.running {
background: #F59E0B;
animation: pulse-dot 2s ease-in-out infinite;
}
.asl-conv-status.failed {
background: #EF4444;
}
@keyframes pulse-dot {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
/* ── 删除按钮(对齐 Protocol Agent .conv-delete ── */
.asl-conv-delete {
opacity: 0;
background: none;
border: none;
padding: 4px;
border-radius: 4px;
cursor: pointer;
color: #EF4444;
transition: all 0.2s;
flex-shrink: 0;
}
.asl-conv-item:hover .asl-conv-delete {
opacity: 1;
}
.asl-conv-delete:hover {
background: #FEE2E2;
}
/* ── 空状态 ── */
.asl-empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 32px 16px;
text-align: center;
color: #9CA3AF;
font-size: 13px;
flex: 1;
}
/* ── 面板 B 占位 ── */
.asl-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 32px 16px;
text-align: center;
flex: 1;
}
.asl-placeholder-icon {
color: #D1D5DB;
margin-bottom: 12px;
}
.asl-placeholder-title {
font-size: 14px;
font-weight: 500;
color: #6B7280;
margin: 0 0 4px;
}
.asl-placeholder-desc {
font-size: 12px;
color: #9CA3AF;
margin: 0;
}
.asl-disabled-btn {
margin: 0 16px 16px;
padding: 8px;
border: 1px dashed #D1D5DB;
background: white;
color: #9CA3AF;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
font-size: 13px;
cursor: not-allowed;
flex-shrink: 0;
}
/* ── 分割线 ── */
.asl-divider {
height: 1px;
background: #E5E7EB;
flex-shrink: 0;
}
/* ── SR 工具导航项 ── */
.asl-nav-list {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 4px 8px;
scrollbar-width: thin;
scrollbar-color: #CBD5E1 transparent;
}
.asl-nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
color: #6B7280;
font-size: 13px;
text-decoration: none;
}
.asl-nav-item:hover {
background: #F3F4F6;
color: #374151;
}
.asl-nav-item.active {
background: #EEF2FF;
color: #6366F1;
}
.asl-nav-item .nav-icon {
flex-shrink: 0;
color: #9CA3AF;
transition: color 0.2s;
}
.asl-nav-item.active .nav-icon {
color: #6366F1;
}
.asl-nav-item-label {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ── SR 未开通提示 ── */
.asl-sr-locked {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 32px 16px;
text-align: center;
flex: 1;
}
.asl-sr-locked-icon {
color: #D1D5DB;
margin-bottom: 12px;
}
.asl-sr-locked-text {
font-size: 13px;
color: #9CA3AF;
margin: 0;
}

View File

@@ -74,3 +74,12 @@ export interface GenerateRequirementResponse {
}
export type DeepResearchStep = 'landing' | 'setup' | 'strategy' | 'executing' | 'results';
export interface DeepResearchTaskSummary {
id: string;
query: string;
status: 'pending' | 'running' | 'completed' | 'failed';
resultCount: number | null;
createdAt: string;
completedAt: string | null;
}