@@ -587,7 +593,7 @@ const ChatBlock = memo(function ChatBlock({
}) {
switch (item.type) {
case "user":
- return
;
+ return
;
case "assistant":
return
;
case "step_group":
@@ -627,223 +633,6 @@ const ChatBlock = memo(function ChatBlock({
}
});
-/**
- * 用户消息 - Claude 风格:无头像,右对齐浅色气泡
- */
-const UserMessage = memo(function UserMessage({ content }: { content: string }) {
- return (
-
- );
-});
-
-/**
- * Assistant 消息 - Claude 风格:无头像,无气泡背景,纯文字流
- */
-const AssistantMessage = memo(function AssistantMessage({
- content,
- streaming,
-}: {
- content: string;
- streaming: boolean;
-}) {
- const [copied, setCopied] = useState(false);
-
- const handleCopy = useCallback(() => {
- navigator.clipboard.writeText(content).then(() => {
- setCopied(true);
- setTimeout(() => setCopied(false), 1500);
- });
- }, [content]);
-
- return (
-
- {streaming ? (
-
- {content}
-
-
- ) : (
- <>
-
- }>
- {content}
-
-
-
-
-
- >
- )}
-
- );
-});
-
-/* ========== 步骤组 ========== */
-
-const StepGroupCard = memo(function StepGroupCard({ steps }: { steps: StepItem[] }) {
- return (
-
-
-
-
-
执行步骤
-
- {steps.filter((s) => s.status === "done").length}/{steps.length}
-
-
-
- {steps.map((step, idx) => (
-
- ))}
-
-
-
- );
-});
-
-function StepRow({ step }: { step: StepItem }) {
- const isIngest = step.toolName === "ingest_arxiv";
- const autoExpand = isIngest && step.status === "running";
- const [expanded, setExpanded] = useState(false);
- const meta = getToolMeta(step.toolName);
- const Icon = meta.icon;
- const hasData = step.data && Object.keys(step.data).length > 0;
- const hasProgress = step.status === "running" && step.progressTotal && step.progressTotal > 0;
- const progressPct = hasProgress
- ? Math.round(((step.progressCurrent || 0) / step.progressTotal!) * 100)
- : 0;
- const showExpanded = expanded || autoExpand;
-
- const statusIcon =
- step.status === "running" ? (
-
- ) : step.status === "done" ? (
-
- ) : (
-
- );
-
- return (
-
-
-
- {/* 入库进度面板 - 独立的可视化区域 */}
- {isIngest && hasProgress && (
-
-
-
-
-
- {progressPct}%
-
-
-
-
{step.progressMessage}
-
- {step.progressCurrent ?? 0} / {step.progressTotal ?? 0} 篇
-
-
-
-
-
-
- )}
-
- {/* 非入库工具的简单进度条 */}
- {!isIngest && hasProgress && (
-
- )}
-
- {showExpanded && step.data && (
-
-
-
- )}
-
- );
-}
-
/**
* 论文列表卡片(search_papers / search_arxiv 共用)
*/
@@ -1004,636 +793,6 @@ const IngestResultView = memo(function IngestResultView({
);
});
-/* ========== arXiv 候选论文选择器 ========== */
-
-const QUERY_TO_CATEGORIES: Record
= {
- graphics: ["cs.GR"],
- rendering: ["cs.GR", "cs.CV"],
- vision: ["cs.CV"],
- nlp: ["cs.CL"],
- language: ["cs.CL"],
- robot: ["cs.RO"],
- learning: ["cs.LG", "cs.AI"],
- neural: ["cs.LG", "cs.CV", "cs.AI"],
- "3d": ["cs.GR", "cs.CV"],
- image: ["cs.CV"],
- audio: ["cs.SD", "eess.AS"],
- speech: ["cs.CL", "cs.SD"],
- security: ["cs.CR"],
- network: ["cs.NI"],
- database: ["cs.DB"],
- attention: ["cs.LG", "cs.CL"],
- transformer: ["cs.LG", "cs.CL"],
- diffusion: ["cs.CV", "cs.LG"],
- gaussian: ["cs.GR", "cs.CV"],
- nerf: ["cs.GR", "cs.CV"],
- reconstruction: ["cs.GR", "cs.CV"],
- detection: ["cs.CV"],
- segmentation: ["cs.CV"],
- generation: ["cs.CV", "cs.LG"],
- llm: ["cs.CL", "cs.AI"],
- agent: ["cs.AI", "cs.CL"],
- rl: ["cs.LG", "cs.AI"],
- reinforcement: ["cs.LG", "cs.AI"],
- optimization: ["math.OC", "cs.LG"],
-};
-
-function inferRelevantCategories(query: string): Set {
- const qLower = query.toLowerCase();
- const cats = new Set();
- for (const [kw, kwCats] of Object.entries(QUERY_TO_CATEGORIES)) {
- if (qLower.includes(kw)) kwCats.forEach((c) => cats.add(c));
- }
- return cats;
-}
-
-function isRelevantCandidate(cats: string[], relevantCats: Set): boolean {
- if (relevantCats.size === 0) return true;
- return cats.some((c) => relevantCats.has(c));
-}
-
-function ArxivCandidateSelector({
- candidates,
- query,
-}: {
- candidates: Array>;
- query: string;
-}) {
- const { sendMessage, loading } = useAgentSession();
- const relevantCats = inferRelevantCategories(query);
-
- const [selected, setSelected] = useState>(() => {
- if (relevantCats.size === 0) return new Set(candidates.map((c) => String(c.arxiv_id ?? "")));
- const relevant = new Set();
- for (const c of candidates) {
- const cats = Array.isArray(c.categories) ? (c.categories as string[]) : [];
- if (isRelevantCandidate(cats, relevantCats)) relevant.add(String(c.arxiv_id ?? ""));
- }
- return relevant.size > 0 ? relevant : new Set(candidates.map((c) => String(c.arxiv_id ?? "")));
- });
- const [submitted, setSubmitted] = useState(false);
- const allSelected = selected.size === candidates.length;
- const relevantCount =
- relevantCats.size > 0
- ? candidates.filter((c) =>
- isRelevantCandidate(
- Array.isArray(c.categories) ? (c.categories as string[]) : [],
- relevantCats
- )
- ).length
- : candidates.length;
-
- const toggle = (id: string) => {
- setSelected((prev) => {
- const next = new Set(prev);
- if (next.has(id)) next.delete(id);
- else next.add(id);
- return next;
- });
- };
-
- const selectRelevant = () => {
- const relevant = new Set();
- for (const c of candidates) {
- const cats = Array.isArray(c.categories) ? (c.categories as string[]) : [];
- if (isRelevantCandidate(cats, relevantCats)) relevant.add(String(c.arxiv_id ?? ""));
- }
- setSelected(relevant);
- };
-
- const handleSubmit = () => {
- if (selected.size === 0 || submitted) return;
- setSubmitted(true);
- const ids = Array.from(selected).join(", ");
- sendMessage(`请将以下论文入库:${ids}`).catch(() => {
- setSubmitted(false);
- });
- };
-
- return (
-
-
-
- {candidates.length} 篇候选论文
- {relevantCats.size > 0 && relevantCount < candidates.length && (
- ({relevantCount} 篇高相关)
- )}
-
-
- {relevantCats.size > 0 && relevantCount < candidates.length && (
-
- )}
-
-
- 已选 {selected.size}/{candidates.length}
-
-
-
-
- {candidates.map((p, i) => {
- const aid = String(p.arxiv_id ?? "");
- const isChecked = selected.has(aid);
- const cats = Array.isArray(p.categories) ? (p.categories as string[]) : [];
- const isRelevant = isRelevantCandidate(cats, relevantCats);
- return (
-
- );
- })}
-
- {!submitted ? (
-
- ) : (
-
-
- 已发送请求,等待确认后开始入库…
-
- )}
-
- );
-}
-
-const StepDataView = memo(function StepDataView({
- data,
- toolName,
-}: {
- data: Record;
- toolName: string;
-}) {
- const navigate = useNavigate();
-
- if (toolName === "search_papers" && Array.isArray(data.papers)) {
- return (
- >}
- label={`找到 ${(data.papers as unknown[]).length} 篇论文`}
- />
- );
- }
- if (toolName === "search_arxiv" && Array.isArray(data.candidates)) {
- return (
- >}
- query={String(data.query ?? "")}
- />
- );
- }
- if (toolName === "ingest_arxiv" && data.total !== undefined) {
- return ;
- }
- if (toolName === "get_system_status") {
- return (
-
- {[
- { label: "论文", value: data.paper_count, color: "text-primary" },
- { label: "已向量化", value: data.embedded_count, color: "text-success" },
- { label: "主题", value: data.topic_count, color: "text-blue-600 dark:text-blue-400" },
- ].map((s) => (
-
- {String(s.value ?? 0)}
- {s.label}
-
- ))}
-
- );
- }
- /* ask_knowledge_base — Markdown 答案 + 引用论文 */
- if (toolName === "ask_knowledge_base" && data.markdown) {
- const evidence = Array.isArray(data.evidence)
- ? (data.evidence as Array>)
- : [];
- const rounds = data.rounds as number | undefined;
- return (
-
- {rounds && rounds > 1 && (
-
-
- 经过 {rounds} 轮迭代检索优化
-
- )}
-
- }>
- {String(data.markdown)}
-
-
- {evidence.length > 0 && (
-
-
- 引用 {evidence.length} 篇论文
-
-
- {evidence.slice(0, 8).map((e, i) => (
-
- ))}
-
-
- )}
-
- );
- }
- /* list_topics — 主题列表 */
- if (toolName === "list_topics" && Array.isArray(data.topics)) {
- const topics = data.topics as Array>;
- return (
-
- {topics.map((t, i) => (
-
-
- {String(t.name ?? "")}
- {t.paper_count !== undefined && (
- {String(t.paper_count)} 篇
- )}
- {t.enabled !== undefined && (
-
- {t.enabled ? "已订阅" : "未订阅"}
-
- )}
-
- ))}
-
- );
- }
- /* get_timeline — 时间线 */
- if (toolName === "get_timeline" && Array.isArray(data.timeline)) {
- const items = data.timeline as Array>;
- return (
-
- {items.map((p, i) => (
-
- ))}
-
- );
- }
- /* get_similar_papers — 相似论文 */
- if (toolName === "get_similar_papers") {
- const items = Array.isArray(data.items) ? (data.items as Array>) : [];
- const ids = Array.isArray(data.similar_ids) ? (data.similar_ids as string[]) : [];
- if (items.length > 0) {
- return (
-
- {items.map((p, i) => (
-
- ))}
-
- );
- }
- if (ids.length > 0) {
- return 找到 {ids.length} 篇相似论文
;
- }
- }
- /* get_citation_tree — 引用树统计 */
- if (toolName === "get_citation_tree" && data.nodes) {
- const nodes = Array.isArray(data.nodes) ? data.nodes.length : 0;
- const edges = Array.isArray(data.edges) ? data.edges.length : 0;
- return (
-
- {nodes} 个节点
- {edges} 条引用关系
-
- );
- }
- /* suggest_keywords — 关键词建议 */
- if (toolName === "suggest_keywords" && Array.isArray(data.suggestions)) {
- const suggestions = data.suggestions as Array>;
- return (
-
- {suggestions.map((s, i) => (
-
-
{String(s.name ?? "")}
-
{String(s.query ?? "")}
- {s.reason !== undefined && (
-
{String(s.reason)}
- )}
-
- ))}
-
- );
- }
- /* skim_paper / deep_read_paper — 报告摘要 */
- if ((toolName === "skim_paper" || toolName === "deep_read_paper") && data.one_liner) {
- return (
-
-
{String(data.one_liner)}
- {data.novelty !== undefined && (
-
- 创新点: {String(data.novelty)}
-
- )}
- {data.methodology !== undefined && (
-
- 方法: {String(data.methodology)}
-
- )}
-
- );
- }
- /* reasoning_analysis — 推理链 */
- if (toolName === "reasoning_analysis" && data.reasoning_steps) {
- const steps = Array.isArray(data.reasoning_steps)
- ? (data.reasoning_steps as Array>)
- : [];
- return (
-
- {steps.slice(0, 6).map((s, i) => (
-
-
- {String(s.step_name ?? s.claim ?? `步骤 ${i + 1}`)}
-
- {s.evidence !== undefined && (
-
{String(s.evidence)}
- )}
-
- ))}
-
- );
- }
- /* analyze_figures — 图表列表 */
- if (toolName === "analyze_figures" && Array.isArray(data.figures)) {
- const figs = data.figures as Array>;
- return (
-
- {figs.map((f, i) => (
-
-
- {String(f.figure_type ?? "图表")} — p.{String(f.page ?? "?")}
-
-
- {String(f.description ?? f.analysis ?? "")}
-
-
- ))}
-
- );
- }
- /* identify_research_gaps — 研究空白 */
- if (toolName === "identify_research_gaps" && data.analysis) {
- const analysis = data.analysis as Record;
- const gaps = Array.isArray(analysis.research_gaps)
- ? (analysis.research_gaps as Array>)
- : [];
- return (
-
- {gaps.slice(0, 5).map((g, i) => (
-
-
- {String(g.gap_title ?? g.title ?? `空白 ${i + 1}`)}
-
-
- {String(g.description ?? g.evidence ?? "")}
-
-
- ))}
-
- );
- }
- /* get_paper_detail — 论文详情卡片 */
- if (toolName === "get_paper_detail" && data.title) {
- return (
-
-
- {data.abstract_zh !== undefined && (
-
{String(data.abstract_zh)}
- )}
-
- {data.arxiv_id ? {String(data.arxiv_id)} : null}
- {data.read_status ? {String(data.read_status)} : null}
-
-
- );
- }
- /* writing_assist — 写作助手结果 */
- if (toolName === "writing_assist" && data.content) {
- return (
-
- }>
- {String(data.content)}
-
-
- );
- }
- /* 兜底:原始 JSON */
- return (
-
- {JSON.stringify(data, null, 2)}
-
- );
-});
-
-/* ========== 确认卡片 ========== */
-
-const ActionConfirmCard = memo(function ActionConfirmCard({
- actionId,
- description,
- tool,
- args,
- isPending,
- isConfirming,
- onConfirm,
- onReject,
-}: {
- actionId: string;
- description: string;
- tool: string;
- args?: Record;
- isPending: boolean;
- isConfirming: boolean;
- onConfirm: (id: string) => void;
- onReject: (id: string) => void;
-}) {
- const meta = getToolMeta(tool);
- const Icon = meta.icon;
- return (
-
-
-
-
-
- {isPending ? "⚠️ 需要你的确认" : "已处理"}
-
-
-
-
-
-
-
-
-
{description}
- {args && Object.keys(args).length > 0 && (
-
- {Object.entries(args).map(([k, v]) => (
-
- {k}:
-
- {typeof v === "string" ? v : JSON.stringify(v)}
-
-
- ))}
-
- )}
-
-
- {isPending && (
-
-
-
-
- )}
- {!isPending && (
-
-
- 已处理
-
- )}
-
-
-
- );
-});
-
const ErrorCard = memo(function ErrorCard({
content,
onRetry,
diff --git a/frontend/src/pages/AgentMessages.tsx b/frontend/src/pages/AgentMessages.tsx
new file mode 100644
index 0000000..e1464ce
--- /dev/null
+++ b/frontend/src/pages/AgentMessages.tsx
@@ -0,0 +1,231 @@
+import { memo, useState, useCallback, useRef, useEffect, useMemo } from "react";
+import { useNavigate } from "react-router-dom";
+import { cn } from "@/lib/utils";
+import { CheckCircle2, XCircle, Loader2, Play, ChevronDown, ChevronRight } from "lucide-react";
+import ReactMarkdown from "react-markdown";
+import remarkGfm from "remark-gfm";
+import remarkMath from "remark-math";
+import rehypeKatex from "rehype-katex";
+import "katex/dist/katex.min.css";
+import { type StepItem } from "@/contexts/AgentSessionContext";
+import { getToolMeta, StepDataView } from "./AgentSteps";
+
+const UserMessage = memo(function UserMessage({
+ content,
+ messageId,
+}: {
+ content: string;
+ messageId?: string;
+}) {
+ return (
+
+ );
+});
+
+const AssistantMessage = memo(function AssistantMessage({
+ content,
+ streaming,
+}: {
+ content: string;
+ streaming: boolean;
+}) {
+ const [copied, setCopied] = useState(false);
+ const contentRef = useRef(content);
+
+ useEffect(() => {
+ contentRef.current = content;
+ }, [content]);
+
+ const handleCopy = useCallback(() => {
+ navigator.clipboard.writeText(contentRef.current).then(() => {
+ setCopied(true);
+ setTimeout(() => setCopied(false), 1500);
+ });
+ }, []);
+
+ const markdownKey = streaming ? content : `static_${content}`;
+ const markdownContent = useMemo(() => content, [markdownKey]);
+
+ return (
+
+
+
+ {markdownContent}
+
+
+ {streaming && (
+
+ )}
+ {!streaming && (
+
+
+
+ )}
+
+ );
+});
+
+const StepGroupCard = memo(function StepGroupCard({ steps }: { steps: StepItem[] }) {
+ return (
+
+
+
+
+
执行步骤
+
+ {steps.filter((s) => s.status === "done").length}/{steps.length}
+
+
+
+ {steps.map((step, idx) => (
+
+ ))}
+
+
+
+ );
+});
+
+function StepRow({ step }: { step: StepItem }) {
+ const isIngest = step.toolName === "ingest_arxiv";
+ const autoExpand = isIngest && step.status === "running";
+ const [expanded, setExpanded] = useState(false);
+ const meta = getToolMeta(step.toolName);
+ const Icon = meta.icon;
+ const hasData = step.data && Object.keys(step.data).length > 0;
+ const hasProgress = step.status === "running" && step.progressTotal && step.progressTotal > 0;
+ const progressPct = hasProgress
+ ? Math.round(((step.progressCurrent || 0) / step.progressTotal!) * 100)
+ : 0;
+ const showExpanded = expanded || autoExpand;
+
+ const statusIcon =
+ step.status === "running" ? (
+
+ ) : step.status === "done" ? (
+
+ ) : (
+
+ );
+
+ return (
+
+
+
+ {isIngest && hasProgress && (
+
+
+
+
+
+ {progressPct}%
+
+
+
+
{step.progressMessage}
+
+ {step.progressCurrent ?? 0} / {step.progressTotal ?? 0} 篇
+
+
+
+
+
+
+ )}
+
+ {!isIngest && hasProgress && (
+
+ )}
+
+ {showExpanded && step.data && (
+
+
+
+ )}
+
+ );
+}
+
+export { UserMessage, AssistantMessage, StepGroupCard, StepRow };
diff --git a/frontend/src/pages/AgentSteps.tsx b/frontend/src/pages/AgentSteps.tsx
new file mode 100644
index 0000000..3b33b85
--- /dev/null
+++ b/frontend/src/pages/AgentSteps.tsx
@@ -0,0 +1,594 @@
+import { memo, useState, Suspense } from "react";
+import { useNavigate } from "react-router-dom";
+import { cn } from "@/lib/utils";
+import {
+ CheckCircle2,
+ XCircle,
+ Loader2,
+ Download,
+ Search,
+ BookOpen,
+ FileText,
+ Brain,
+ Newspaper,
+ Star,
+ Hash,
+ TrendingUp,
+ AlertTriangle,
+} from "lucide-react";
+import { useAgentSession, type StepItem } from "@/contexts/AgentSessionContext";
+import { lazy } from "react";
+const Markdown = lazy(() => import("@/components/Markdown"));
+
+interface ToolMeta {
+ icon: typeof Search;
+ label: string;
+}
+
+function getToolMeta(name: string): ToolMeta {
+ const META: Record = {
+ search_arxiv: { icon: Search, label: "搜索 arXiv" },
+ search_papers: { icon: Search, label: "搜索论文库" },
+ ingest_arxiv: { icon: Download, label: "下载入库" },
+ get_system_status: { icon: Brain, label: "系统状态" },
+ list_topics: { icon: Hash, label: "主题列表" },
+ get_timeline: { icon: TrendingUp, label: "时间线" },
+ get_similar_papers: { icon: Star, label: "相似论文" },
+ get_citation_tree: { icon: BookOpen, label: "引用树" },
+ suggest_keywords: { icon: Hash, label: "关键词建议" },
+ ask_knowledge_base: { icon: Brain, label: "知识库问答" },
+ skim_paper: { icon: FileText, label: "粗读论文" },
+ deep_read_paper: { icon: BookOpen, label: "精读论文" },
+ reasoning_analysis: { icon: Brain, label: "推理链分析" },
+ analyze_figures: { icon: FileText, label: "图表解读" },
+ identify_research_gaps: { icon: Search, label: "研究空白" },
+ get_paper_detail: { icon: FileText, label: "论文详情" },
+ writing_assist: { icon: Newspaper, label: "写作助手" },
+ };
+ return META[name] || { icon: FileText, label: name };
+}
+
+function PaperListView({ papers, label }: { papers: Array>; label: string }) {
+ const navigate = useNavigate();
+ return (
+
+
{label}
+ {papers.slice(0, 5).map((p, i) => (
+
+ ))}
+
+ );
+}
+
+function IngestResultView({ data }: { data: Record }) {
+ const navigate = useNavigate();
+ return (
+
+
+ 已入库 {String(data.total ?? 0)} 篇
+ {data.skipped !== undefined && ({String(data.skipped)} 篇跳过)}
+
+ {Array.isArray(data.papers) && (data.papers as Array
>).length > 0 && (
+
+ {(data.papers as Array>).slice(0, 3).map((p, i) => (
+
+ ))}
+
+ )}
+
+ );
+}
+
+function inferRelevantCategories(query: string): Set {
+ const cats: string[] = [];
+ const LOWER = query.toLowerCase();
+ const ALIASES: Record = {
+ "cs.AI": ["artificial intelligence", "ai", "machine learning", "ml", "deep learning", "neural", "gpt", "llm", "transformer", "reinforcement learning", "nlp", "natural language"],
+ "cs.CV": ["computer vision", "cv", "image", "video", "object detection", "segmentation", "recognition", "gan", "diffusion", "stable diffusion", "dino"],
+ "cs.LG": ["machine learning", "ml", "deep learning", "neural network", "representation", "self-supervised", "contrastive"],
+ "cs.CL": ["computational linguistics", "nlp", "natural language", "text", "language model", "translation", "parsing", "word2vec", "bert", "embedding"],
+ "cs.IR": ["information retrieval", "search", "ranking", "recommender", "recommendation"],
+ "cs.KR": ["knowledge representation", "reasoning", "logic", "knowledge graph"],
+ "cs.Robotics": ["robotics", "robot", "autonomous", "manipulation", "navigation"],
+ "cs.NE": ["neural evolution", "evolutionary", "genetic algorithm"],
+ "cs.SE": ["software engineering", "program synthesis", "code generation"],
+ "cs.CR": ["cryptography", "security", "privacy"],
+ "cs.CY": ["cybersecurity", "security", "privacy", "attack", "defense"],
+ "cs.DC": ["distributed computing", "parallel", "grid", "cluster"],
+ "cs.DS": ["data science", "analytics", "data mining", "big data"],
+ "cs.DB": ["database", "sql", "nosql", "query"],
+ "cs.PL": ["programming language", "compiler", "type system", "parser"],
+ "cs.HCI": ["human computer interaction", "ux", "ui", "interface", "vr", "ar", "virtual reality", "augmented reality"],
+ "cs.GR": ["graphics", "rendering", "3d", "geometry", "animation"],
+ "cs.MM": ["multimedia", "video", "audio", "speech"],
+ "cs.SD": ["sound", "audio", "speech", "music"],
+ "cs.LO": ["logic", "formal", "theorem proving", "coq", "lean"],
+ "cs.MA": ["mathematics", "algebra", "geometry", "calculus", "optimization"],
+ "cs.RO": ["optimization", "operations research", "linear programming", "integer programming"],
+ "cs.ET": ["emerging technology", "blockchain", "web3", "metaverse"],
+ "cs.CG": ["computational geometry", "geometry", "mesh", "voronoi"],
+ "cs.AP": ["applied computing", "e-commerce", "finance", "health", "medicine", "biology", "bioinformatics"],
+ };
+ for (const [cat, kwList] of Object.entries(ALIASES)) {
+ if (kwList.some((kw) => LOWER.includes(kw))) {
+ cats.push(cat);
+ }
+ }
+ return new Set(cats);
+}
+
+function isRelevantCandidate(cats: string[], relevantCats: Set): boolean {
+ if (relevantCats.size === 0) return true;
+ return cats.some((c) => relevantCats.has(c));
+}
+
+const ArxivCandidateSelector = memo(function ArxivCandidateSelector({
+ candidates,
+ query,
+}: {
+ candidates: Array>;
+ query: string;
+}) {
+ const { sendMessage, loading } = useAgentSession();
+ const relevantCats = inferRelevantCategories(query);
+
+ const [selected, setSelected] = useState>(() => {
+ if (relevantCats.size === 0) return new Set(candidates.map((c) => String(c.arxiv_id ?? "")));
+ const relevant = new Set();
+ for (const c of candidates) {
+ const cats = Array.isArray(c.categories) ? (c.categories as string[]) : [];
+ if (isRelevantCandidate(cats, relevantCats)) relevant.add(String(c.arxiv_id ?? ""));
+ }
+ return relevant.size > 0 ? relevant : new Set(candidates.map((c) => String(c.arxiv_id ?? "")));
+ });
+ const [submitted, setSubmitted] = useState(false);
+ const allSelected = selected.size === candidates.length;
+ const relevantCount =
+ relevantCats.size > 0
+ ? candidates.filter((c) => isRelevantCandidate(Array.isArray(c.categories) ? (c.categories as string[]) : [], relevantCats)).length
+ : candidates.length;
+
+ const toggle = (id: string) => {
+ setSelected((prev) => {
+ const next = new Set(prev);
+ if (next.has(id)) next.delete(id);
+ else next.add(id);
+ return next;
+ });
+ };
+
+ const selectRelevant = () => {
+ const relevant = new Set();
+ for (const c of candidates) {
+ const cats = Array.isArray(c.categories) ? (c.categories as string[]) : [];
+ if (isRelevantCandidate(cats, relevantCats)) relevant.add(String(c.arxiv_id ?? ""));
+ }
+ setSelected(relevant);
+ };
+
+ const handleSubmit = () => {
+ if (selected.size === 0 || submitted) return;
+ setSubmitted(true);
+ const ids = Array.from(selected).join(", ");
+ sendMessage(`请将以下论文入库:${ids}`).catch(() => {
+ setSubmitted(false);
+ });
+ };
+
+ return (
+
+
+
+ {candidates.length} 篇候选论文
+ {relevantCats.size > 0 && relevantCount < candidates.length && (
+ ({relevantCount} 篇高相关)
+ )}
+
+
+ {relevantCats.size > 0 && relevantCount < candidates.length && (
+
+ )}
+
+ 已选 {selected.size}/{candidates.length}
+
+
+
+ {candidates.map((p, i) => {
+ const aid = String(p.arxiv_id ?? "");
+ const isChecked = selected.has(aid);
+ const cats = Array.isArray(p.categories) ? (p.categories as string[]) : [];
+ const isRelevant = isRelevantCandidate(cats, relevantCats);
+ return (
+
+ );
+ })}
+
+ {!submitted ? (
+
+ ) : (
+
+
+ 已发送请求,等待确认后开始入库…
+
+ )}
+
+ );
+});
+
+const StepDataView = memo(function StepDataView({ data, toolName }: { data: Record; toolName: string }) {
+ const navigate = useNavigate();
+
+ if (toolName === "search_papers" && Array.isArray(data.papers)) {
+ return >} label={`找到 ${(data.papers as unknown[]).length} 篇论文`} />;
+ }
+ if (toolName === "search_arxiv" && Array.isArray(data.candidates)) {
+ return >} query={String(data.query ?? "")} />;
+ }
+ if (toolName === "ingest_arxiv" && data.total !== undefined) {
+ return ;
+ }
+ if (toolName === "get_system_status") {
+ return (
+
+ {[
+ { label: "论文", value: data.paper_count, color: "text-primary" },
+ { label: "已向量化", value: data.embedded_count, color: "text-success" },
+ { label: "主题", value: data.topic_count, color: "text-blue-600 dark:text-blue-400" },
+ ].map((s) => (
+
+ {String(s.value ?? 0)}
+ {s.label}
+
+ ))}
+
+ );
+ }
+ if (toolName === "ask_knowledge_base" && data.markdown) {
+ const evidence = Array.isArray(data.evidence) ? (data.evidence as Array>) : [];
+ const rounds = data.rounds as number | undefined;
+ return (
+
+ {rounds && rounds > 1 && (
+
+
+ 经过 {rounds} 轮迭代检索优化
+
+ )}
+
+ }>{String(data.markdown)}
+
+ {evidence.length > 0 && (
+
+
引用 {evidence.length} 篇论文
+
+ {evidence.slice(0, 8).map((e, i) => (
+
+ ))}
+
+
+ )}
+
+ );
+ }
+ if (toolName === "list_topics" && Array.isArray(data.topics)) {
+ const topics = data.topics as Array>;
+ return (
+
+ {topics.map((t, i) => (
+
+
+ {String(t.name ?? "")}
+ {t.paper_count !== undefined && {String(t.paper_count)} 篇}
+ {t.enabled !== undefined && (
+
+ {t.enabled ? "已订阅" : "未订阅"}
+
+ )}
+
+ ))}
+
+ );
+ }
+ if (toolName === "get_timeline" && Array.isArray(data.timeline)) {
+ const items = data.timeline as Array>;
+ return (
+
+ {items.map((p, i) => (
+
+ ))}
+
+ );
+ }
+ if (toolName === "get_similar_papers") {
+ const items = Array.isArray(data.items) ? (data.items as Array>) : [];
+ const ids = Array.isArray(data.similar_ids) ? (data.similar_ids as string[]) : [];
+ if (items.length > 0) {
+ return (
+
+ {items.map((p, i) => (
+
+ ))}
+
+ );
+ }
+ if (ids.length > 0) return 找到 {ids.length} 篇相似论文
;
+ }
+ if (toolName === "get_citation_tree" && data.nodes) {
+ const nodes = Array.isArray(data.nodes) ? data.nodes.length : 0;
+ const edges = Array.isArray(data.edges) ? data.edges.length : 0;
+ return (
+
+ {nodes} 个节点
+ {edges} 条引用关系
+
+ );
+ }
+ if (toolName === "suggest_keywords" && Array.isArray(data.suggestions)) {
+ const suggestions = data.suggestions as Array>;
+ return (
+
+ {suggestions.map((s, i) => (
+
+
{String(s.name ?? "")}
+
{String(s.query ?? "")}
+ {s.reason !== undefined &&
{String(s.reason)}
}
+
+ ))}
+
+ );
+ }
+ if ((toolName === "skim_paper" || toolName === "deep_read_paper") && data.one_liner) {
+ return (
+
+
{String(data.one_liner)}
+ {data.novelty !== undefined &&
创新点: {String(data.novelty)}
}
+ {data.methodology !== undefined &&
方法: {String(data.methodology)}
}
+
+ );
+ }
+ if (toolName === "reasoning_analysis" && data.reasoning_steps) {
+ const steps = Array.isArray(data.reasoning_steps) ? (data.reasoning_steps as Array>) : [];
+ return (
+
+ {steps.slice(0, 6).map((s, i) => (
+
+
{String(s.step_name ?? s.claim ?? `步骤 ${i + 1}`)}
+ {s.evidence !== undefined &&
{String(s.evidence)}
}
+
+ ))}
+
+ );
+ }
+ if (toolName === "analyze_figures" && Array.isArray(data.figures)) {
+ const figs = data.figures as Array>;
+ return (
+
+ {figs.map((f, i) => (
+
+
{String(f.figure_type ?? "图表")} — p.{String(f.page ?? "?")}
+
{String(f.description ?? f.analysis ?? "")}
+
+ ))}
+
+ );
+ }
+ if (toolName === "identify_research_gaps" && data.analysis) {
+ const analysis = data.analysis as Record;
+ const gaps = Array.isArray(analysis.research_gaps) ? (analysis.research_gaps as Array>) : [];
+ return (
+
+ {gaps.slice(0, 5).map((g, i) => (
+
+
{String(g.gap_title ?? g.title ?? `空白 ${i + 1}`)}
+
{String(g.description ?? g.evidence ?? "")}
+
+ ))}
+
+ );
+ }
+ if (toolName === "get_paper_detail" && data.title) {
+ return (
+
+
+ {data.abstract_zh !== undefined &&
{String(data.abstract_zh)}
}
+
+ {data.arxiv_id ? {String(data.arxiv_id)} : null}
+ {data.read_status ? {String(data.read_status)} : null}
+
+
+ );
+ }
+ if (toolName === "writing_assist" && data.content) {
+ return (
+
+ }>{String(data.content)}
+
+ );
+ }
+ return {JSON.stringify(data, null, 2)};
+});
+
+export { getToolMeta, ArxivCandidateSelector, StepDataView, ActionConfirmCard };
+
+const ActionConfirmCard = memo(function ActionConfirmCard({
+ actionId,
+ description,
+ tool,
+ args,
+ isPending,
+ isConfirming,
+ onConfirm,
+ onReject,
+}: {
+ actionId: string;
+ description: string;
+ tool: string;
+ args?: Record;
+ isPending: boolean;
+ isConfirming: boolean;
+ onConfirm: (id: string) => void;
+ onReject: (id: string) => void;
+}) {
+ const meta = getToolMeta(tool);
+ const Icon = meta.icon;
+ return (
+
+
+
+
+
+ {isPending ? "⚠️ 需要你的确认" : "已处理"}
+
+
+
+
+
+
+
+
+
{description}
+ {args && Object.keys(args).length > 0 && (
+
+ {Object.entries(args).map(([k, v]) => (
+
+ {k}:
+
+ {typeof v === "string" ? v : JSON.stringify(v)}
+
+
+ ))}
+
+ )}
+
+
+ {isPending && (
+
+
+
+
+ )}
+ {!isPending && (
+
+
+ 已处理
+
+ )}
+
+
+
+ );
+});
diff --git a/frontend/src/pages/Chat.tsx b/frontend/src/pages/Chat.tsx
deleted file mode 100644
index 8b4cbc9..0000000
--- a/frontend/src/pages/Chat.tsx
+++ /dev/null
@@ -1,254 +0,0 @@
-/**
- * AI Chat - RAG 问答 (Claude 对话风格)
- * 覆盖 API: POST /rag/ask
- * @author Color2333
- */
-import { useState, useRef, useEffect } from "react";
-import { Card, Button } from "@/components/ui";
-import { ragApi } from "@/services/api";
-import type { ChatMessage } from "@/types";
-import { uid } from "@/lib/utils";
-import { Send, Sparkles, User, BookOpen, Trash2 } from "lucide-react";
-
-export default function Chat() {
- const [messages, setMessages] = useState([]);
- const [input, setInput] = useState("");
- const [loading, setLoading] = useState(false);
- const scrollRef = useRef(null);
- const inputRef = useRef(null);
-
- useEffect(() => {
- if (scrollRef.current) {
- scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
- }
- }, [messages]);
-
- const handleSend = async () => {
- const question = input.trim();
- if (!question || loading) return;
-
- const userMsg: ChatMessage = {
- id: uid(),
- role: "user",
- content: question,
- timestamp: new Date(),
- };
- setMessages((prev) => [...prev, userMsg]);
- setInput("");
- setLoading(true);
-
- try {
- const res = await ragApi.ask({ question, top_k: 5 });
- const botMsg: ChatMessage = {
- id: uid(),
- role: "assistant",
- content: res.answer,
- cited_paper_ids: res.cited_paper_ids,
- evidence: res.evidence,
- timestamp: new Date(),
- };
- setMessages((prev) => [...prev, botMsg]);
- } catch (err) {
- const errorMsg: ChatMessage = {
- id: uid(),
- role: "assistant",
- content: `抱歉,查询时出现错误: ${err instanceof Error ? err.message : "未知错误"}`,
- timestamp: new Date(),
- };
- setMessages((prev) => [...prev, errorMsg]);
- } finally {
- setLoading(false);
- inputRef.current?.focus();
- }
- };
-
- const handleKeyDown = (e: React.KeyboardEvent) => {
- if (e.key === "Enter" && !e.shiftKey) {
- e.preventDefault();
- handleSend();
- }
- };
-
- const clearChat = () => {
- setMessages([]);
- };
-
- return (
-
- {/* 标题栏 */}
-
-
-
AI Chat
-
基于 RAG 的跨论文智能问答
-
- {messages.length > 0 && (
-
}
- onClick={clearChat}
- >
- 清空对话
-
- )}
-
-
- {/* 消息区域 */}
-
- {messages.length === 0 ? (
-
-
-
-
-
PaperMind AI
-
- 基于你收录的论文进行智能问答。 支持跨文档检索,自动引用来源论文。
-
-
- {[
- "这些论文中关于 Transformer 的主要创新是什么?",
- "有哪些论文讨论了模型压缩的方法?",
- "总结一下近期在多模态学习方面的进展",
- ].map((q) => (
-
- ))}
-
-
- ) : (
- messages.map((msg) => (
-
- {msg.role === "assistant" && (
-
-
-
- )}
-
- {msg.role === "user" ? (
-
{msg.content}
- ) : (
- <>
-
- {/* 引用论文 */}
- {msg.cited_paper_ids && msg.cited_paper_ids.length > 0 && (
-
- {msg.cited_paper_ids.map((cid) => (
-
-
- {cid.slice(0, 8)}...
-
- ))}
-
- )}
- {/* Evidence */}
- {msg.evidence && msg.evidence.length > 0 && (
-
-
- 查看 {msg.evidence.length} 条证据
-
-
- {msg.evidence.map((ev, i) => (
-
- {JSON.stringify(ev, null, 2)}
-
- ))}
-
-
- )}
- >
- )}
-
- {msg.role === "user" && (
-
-
-
- )}
-
- ))
- )}
-
- {/* 加载中提示 */}
- {loading && (
-
- )}
-
-
- {/* 输入区域 */}
-
-
-
-
- 基于 RAG 检索增强生成,回答可能不完全准确,请以原始论文为准
-
-
-
- );
-}
diff --git a/frontend/src/pages/ChatNavBar.tsx b/frontend/src/pages/ChatNavBar.tsx
new file mode 100644
index 0000000..56102c8
--- /dev/null
+++ b/frontend/src/pages/ChatNavBar.tsx
@@ -0,0 +1,136 @@
+import { memo, useState, useCallback, useRef, useEffect } from "react";
+import { cn } from "@/lib/utils";
+import type { ChatItem } from "@/contexts/AgentSessionContext";
+
+interface ChatNavBarProps {
+ items: ChatItem[];
+ scrollAreaRef: React.RefObject;
+}
+
+interface UserMessageMarker {
+ id: string;
+ content: string;
+ top: number;
+ height: number;
+}
+
+const ChatNavBar = memo(function ChatNavBar({ items, scrollAreaRef }: ChatNavBarProps) {
+ const [hoveredId, setHoveredId] = useState(null);
+ const [markers, setMarkers] = useState([]);
+ const rafRef = useRef(null);
+
+ const updateMarkers = useCallback(() => {
+ const scrollEl = scrollAreaRef.current;
+ if (!scrollEl) return;
+
+ const userMessages = items.filter((item) => item.type === "user");
+ if (userMessages.length === 0) {
+ setMarkers([]);
+ return;
+ }
+
+ const scrollRect = scrollEl.getBoundingClientRect();
+
+ const newMarkers: UserMessageMarker[] = [];
+ for (const item of userMessages) {
+ const msgEl = scrollEl.querySelector(`[data-message-id="${item.id}"]`);
+ if (msgEl) {
+ const rect = msgEl.getBoundingClientRect();
+ newMarkers.push({
+ id: item.id,
+ content: item.content.slice(0, 300) + (item.content.length > 300 ? "..." : ""),
+ top: rect.top - scrollRect.top,
+ height: rect.height,
+ });
+ }
+ }
+ setMarkers(newMarkers);
+ }, [items, scrollAreaRef]);
+
+ useEffect(() => {
+ const scrollEl = scrollAreaRef.current;
+ if (!scrollEl) return;
+
+ const handleScroll = () => {
+ if (rafRef.current) return;
+ rafRef.current = requestAnimationFrame(() => {
+ rafRef.current = null;
+ updateMarkers();
+ });
+ };
+
+ updateMarkers();
+ scrollEl.addEventListener("scroll", handleScroll, { passive: true });
+ return () => scrollEl.removeEventListener("scroll", handleScroll);
+ }, [items, scrollAreaRef, updateMarkers]);
+
+ const jumpToMessage = useCallback(
+ (msgId: string) => {
+ const scrollEl = scrollAreaRef.current;
+ if (!scrollEl) return;
+ const msgEl = scrollEl.querySelector(`[data-message-id="${msgId}"]`);
+ if (msgEl) {
+ msgEl.scrollIntoView({ behavior: "smooth", block: "start" });
+ }
+ },
+ [scrollAreaRef]
+ );
+
+ if (markers.length === 0) return null;
+
+ return (
+ setHoveredId(null)}
+ >
+
+
+ {markers.map((marker) => {
+ return (
+
+
+ );
+ })}
+
+
+ );
+});
+
+export { ChatNavBar };
diff --git a/logs/frontend.log b/logs/frontend.log
index 3746382..60440af 100644
--- a/logs/frontend.log
+++ b/logs/frontend.log
@@ -612,7 +612,7 @@
/Users/haojiang/Documents/2026/PaperMind/frontend/src/components/SettingsDialog.tsx:1218:13: ERROR: The character "}" is not valid inside a JSX element
Plugin: vite:esbuild
File: /Users/haojiang/Documents/2026/PaperMind/frontend/src/components/SettingsDialog.tsx:1199:0
-
+
The character "}" is not valid inside a JSX element
1216|
1217| >
@@ -620,7 +620,7 @@
| ^
1219|