Files
deerflow-code/frontend-web/src/pages/chats-history/ResearchHistoryTab.tsx
T
董宏凯andCursor 4ac7bc21cd feat: 优化对话历史与会商引导体验
列表引导改为手动触发且不跳转创建页;历史 Tab 展示会商步骤/链条与深度研究模板;Step2 增加暂停与内容高度跟随滚动,并修复旧草稿恢复兜底。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 20:25:21 +08:00

210 lines
6.9 KiB
TypeScript

"use client";
/**
* 「对话历史」页 —— 深度研究 Tab。
*
* 数据来自 deep-research 会话列表(useResearchSessions)。点击跳转深度研究页并
* 带 ``?session=<id>``,由 DeepResearchWorkbench 挂载时读取该参数直接选中并
* 打开这条研究记录。
*/
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Search } from "lucide-react";
import { toast } from "sonner";
import {
useResearchActions,
useResearchSessions,
type DeepResearchSession,
} from "@/strategy-components/api/deep-research";
import { useReportStructures } from "@/strategy-components/api/report-structures";
import { formatRelative } from "@/open-canvas/components/ai-writing/AIWritingHistoryDropdown";
import { desensitize } from "@/lib/desensitize";
import {
DeleteIcon,
HistoryRenameDialog,
HistoryRow,
HistoryRowIconButton,
HistoryStatusPill,
HistoryTypeBadge,
RenameIcon,
} from "./history-tab-shared";
const DEEP_RESEARCH_PATH = "/page/workspace/deep-research";
/** 状态文案与配色对齐 DeepResearchWorkbench 的研究记录侧栏。 */
const RESEARCH_STATUS: Record<
DeepResearchSession["status"],
{ label: string; className: string }
> = {
draft: { label: "待开始", className: "text-muted-foreground" },
collecting: { label: "收集中", className: "text-primary" },
awaiting_report: { label: "待撰写", className: "text-amber-500" },
running: { label: "进行中", className: "text-primary" },
awaiting_input: { label: "等待确认", className: "text-amber-500" },
completed: { label: "已完成", className: "text-emerald-600" },
failed: { label: "失败", className: "text-rose-500" },
cancelled: { label: "已取消", className: "text-muted-foreground" },
};
/**
* 深度研究会话把选中的报告模板快照为 custom_outline;用结构库里的内容反查标题。
* 内容不再存在或用户输入了自定义大纲时,明确展示为「自定义报告结构」,避免冒充模板名。
*/
function reportTemplateLabel(
session: DeepResearchSession,
structures: { title: string; content: string }[],
): string {
const config = session.config ?? {};
const outline = typeof config.custom_outline === "string"
? config.custom_outline.trim()
: typeof config.customOutline === "string"
? config.customOutline.trim()
: "";
if (!outline) return "默认报告结构";
return structures.find((structure) => structure.content.trim() === outline)?.title ?? "自定义报告结构";
}
export function ResearchHistoryTab({
query,
onTotalChange,
}: {
query: string;
onTotalChange?: (total: number) => void;
}) {
const navigate = useNavigate();
const { data, isLoading } = useResearchSessions(100);
const structuresQuery = useReportStructures();
const actions = useResearchActions();
const [renameId, setRenameId] = useState<string | null>(null);
const [renameTitle, setRenameTitle] = useState("");
const sessions = data?.items ?? [];
const keyword = query.trim().toLowerCase();
const filtered = keyword
? sessions.filter((session) =>
(session.title || session.query).toLowerCase().includes(keyword),
)
: sessions;
useEffect(() => {
onTotalChange?.(filtered.length);
}, [filtered.length, onTotalChange]);
const openSession = (session: DeepResearchSession) => {
navigate(`${DEEP_RESEARCH_PATH}?session=${encodeURIComponent(session.id)}`);
};
const handleRename = async (id: string, title: string) => {
try {
await actions.updateTitle.mutateAsync({ id, title });
toast.success("已重命名");
} catch {
toast.error("重命名失败,请重试");
} finally {
setRenameId(null);
}
};
const handleDelete = async (session: DeepResearchSession) => {
if (!window.confirm("确定删除这条研究记录吗?此操作不可恢复。")) return;
try {
await actions.delete.mutateAsync(session.id);
toast.success("已删除研究记录");
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除失败,请重试");
}
};
if (isLoading && sessions.length === 0) {
return (
<div className="text-muted-foreground py-16 text-center text-sm">
加载中…
</div>
);
}
if (filtered.length === 0) {
return (
<div className="text-muted-foreground py-16 text-center text-sm">
{keyword ? "没有匹配的研究记录" : "暂无研究记录"}
</div>
);
}
return (
<>
<div>
{filtered.map((session) => {
const status = RESEARCH_STATUS[session.status] ?? {
label: session.status,
className: "text-muted-foreground",
};
const timestamp = session.updatedAt ?? session.createdAt ?? null;
const template = reportTemplateLabel(session, structuresQuery.data ?? []);
return (
<HistoryRow
key={session.id}
badge={<HistoryTypeBadge icon={Search} label="深度研究" />}
title={desensitize(session.title || session.query || "未命名研究")}
meta={
<>
<HistoryStatusPill
label={status.label}
className={status.className}
/>
{session.sourceCount > 0 && (
<>
<span className="text-muted-foreground/60">·</span>
<span>{session.sourceCount} 条来源</span>
</>
)}
<span className="text-muted-foreground/60">·</span>
<span className="max-w-56 truncate" title={`模板:${template}`}>
模板:{template}
</span>
{timestamp && (
<>
<span className="text-muted-foreground/60">·</span>
<span>{formatRelative(timestamp)}</span>
</>
)}
</>
}
onOpen={() => openSession(session)}
>
<HistoryRowIconButton
title="重命名"
onClick={() => {
setRenameId(session.id);
setRenameTitle(session.title || session.query || "");
}}
>
<RenameIcon />
</HistoryRowIconButton>
<HistoryRowIconButton
title="删除"
onClick={() => void handleDelete(session)}
>
<DeleteIcon />
</HistoryRowIconButton>
</HistoryRow>
);
})}
</div>
<HistoryRenameDialog
open={renameId !== null}
initialTitle={renameTitle}
dialogTitle="重命名研究"
onCancel={() => setRenameId(null)}
onConfirm={(title) => renameId && void handleRename(renameId, title)}
/>
</>
);
}