diff --git a/README.md b/README.md index e0f89b71..a47cf808 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,8 @@ AI-powered research workflow: paper discovery → LLM analysis → scholar track Web Dashboard
+Current dashboard layout focused on the active research question, the workflow console, and decision-critical alerts. + ![Dashboard](asset/ui/dashboard.png) | Research Workspace | AgentSwarm Studio | diff --git a/asset/ui/dashboard.png b/asset/ui/dashboard.png index e3d3335d..752579e1 100644 Binary files a/asset/ui/dashboard.png and b/asset/ui/dashboard.png differ diff --git a/src/paperbot/api/routes/research.py b/src/paperbot/api/routes/research.py index 4b61c7b2..e1698d96 100644 --- a/src/paperbot/api/routes/research.py +++ b/src/paperbot/api/routes/research.py @@ -215,6 +215,36 @@ def _run() -> None: background_tasks.add_task(_run) +def _normalize_deadline_match_terms(values: List[Any]) -> Set[str]: + terms: Set[str] = set() + for value in values: + normalized = str(value or "").strip().lower() + if not normalized: + continue + terms.add(normalized) + for token in re.split(r"[^a-z0-9]+", normalized): + token = token.strip() + if len(token) >= 2: + terms.add(token) + return terms + + +def _collect_track_deadline_terms(track: Dict[str, Any]) -> Set[str]: + values: List[Any] = [] + for key in ("keywords", "methods", "venues"): + values.extend(track.get(key) or []) + return _normalize_deadline_match_terms(values) + + +def _collect_conference_deadline_terms(item: Dict[str, Any]) -> Set[str]: + values: List[Any] = list(item.get("keywords") or []) + values.append(item.get("field") or "") + name = re.sub(r"\b20\d{2}\b", "", str(item.get("name") or ""), flags=re.IGNORECASE).strip() + if name: + values.append(name) + return _normalize_deadline_match_terms(values) + + class TrackCreateRequest(BaseModel): user_id: str = "default" name: str = Field(..., min_length=1, max_length=128) @@ -303,9 +333,7 @@ def get_deadline_radar( track_id = int(track.get("id") or 0) if track_id <= 0: continue - tokens = { - str(term).strip().lower() for term in (track.get("keywords") or []) if str(term).strip() - } + tokens = _collect_track_deadline_terms(track) track_tokens[track_id] = tokens rows: List[Dict[str, Any]] = [] @@ -327,19 +355,21 @@ def get_deadline_radar( conf_keywords = { str(k).strip().lower() for k in (item.get("keywords") or []) if str(k).strip() } + conf_terms = _collect_conference_deadline_terms(item) matched_tracks: List[Dict[str, Any]] = [] for track in tracks: track_id = int(track.get("id") or 0) if track_id <= 0: continue - overlap = sorted(conf_keywords & track_tokens.get(track_id, set())) + overlap = sorted(conf_terms & track_tokens.get(track_id, set())) if overlap: matched_tracks.append( { "track_id": track_id, "track_name": str(track.get("name") or ""), "matched_keywords": overlap, + "matched_terms": overlap, } ) diff --git a/tests/unit/test_research_paper_registry_routes.py b/tests/unit/test_research_paper_registry_routes.py index c475dbcf..33734aff 100644 --- a/tests/unit/test_research_paper_registry_routes.py +++ b/tests/unit/test_research_paper_registry_routes.py @@ -186,6 +186,14 @@ def test_deadline_radar_route_returns_workflow_query_and_track_match(tmp_path, m keywords=["llm", "retrieval"], activate=True, ) + research_store.create_track( + user_id="u-deadline", + name="acl-track", + keywords=[], + venues=["ACL"], + methods=["retrieval"], + activate=False, + ) monkeypatch.setattr(research_route, "_research_store", research_store) @@ -205,3 +213,9 @@ def test_deadline_radar_route_returns_workflow_query_and_track_match(tmp_path, m matched_any = any(item.get("matched_tracks") for item in payload["items"]) assert matched_any + + acl_item = next(item for item in payload["items"] if item["name"] == "ACL 2026") + acl_match = next( + match for match in acl_item["matched_tracks"] if match["track_name"] == "acl-track" + ) + assert "acl" in acl_match["matched_terms"] diff --git a/web/src/app/dashboard/page.tsx b/web/src/app/dashboard/page.tsx index 65efb336..41810369 100644 --- a/web/src/app/dashboard/page.tsx +++ b/web/src/app/dashboard/page.tsx @@ -1,50 +1,823 @@ import Link from "next/link" -import { ArrowRight, CalendarDays, Sparkles, Zap } from "lucide-react" - -import { Badge } from "@/components/ui/badge" -import { Button } from "@/components/ui/button" -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" -import { ActivityFeed } from "@/components/dashboard/ActivityFeed" -import { DashboardCommandCenter } from "@/components/dashboard/DashboardCommandCenter" -import { DeadlineRadar } from "@/components/dashboard/DeadlineRadar" -import { LLMUsageChart } from "@/components/dashboard/LLMUsageChart" -import { PipelineStatus } from "@/components/dashboard/PipelineStatus" -import { ReadingQueue } from "@/components/dashboard/ReadingQueue" -import { ScholarSignalsPanel } from "@/components/dashboard/ScholarSignalsPanel" -import { TrackSpotlightSection } from "@/components/dashboard/TrackSpotlightSection" import { - fetchDeadlineRadar, - fetchLLMUsage, - fetchPipelineTasks, - fetchScholars, -} from "@/lib/api" + Activity, + ArrowRight, + Clock, + FileText, + FlaskConical, + Layers, + Settings, + Sparkles, + type LucideIcon, +} from "lucide-react" + +import TopicWorkflowDashboard from "@/components/research/TopicWorkflowDashboard" +import { fetchDeadlineRadar, fetchLLMUsage, fetchPapers, fetchPipelineTasks } from "@/lib/api" +import { + buildDashboardIntelligenceCards, + type DashboardIntelligenceCard, +} from "@/lib/dashboard-intelligence" import { - fetchDashboardActivities, - fetchDashboardAnchors, fetchDashboardReadingQueue, fetchDashboardTrackFeed, fetchDashboardTracks, + fetchIntelligenceFeed, } from "@/lib/dashboard-api" -import type { AnchorPreviewItem, TrackFeedItem } from "@/lib/types" - -function formatCompactNumber(value: number): string { - if (!Number.isFinite(value)) return "0" - if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M` - if (value >= 1_000) return `${(value / 1_000).toFixed(1)}k` - return `${Math.round(value)}` -} - -export default async function DashboardPage() { - const [tracksResult, tasksResult, readingQueueResult, llmUsageResult, deadlineResult, activitiesResult, scholarsResult] = - await Promise.allSettled([ - fetchDashboardTracks("default"), - fetchPipelineTasks(), - fetchDashboardReadingQueue("default", 8), - fetchLLMUsage(14), - fetchDeadlineRadar("default"), - fetchDashboardActivities("default"), - fetchScholars(), - ]) +import type { + DeadlineRadarItem, + LLMUsageSummary, + Paper, + PipelineTask, + ReadingQueueItem, + ResearchTrackSummary, + TrackFeedItem, +} from "@/lib/types" + +type DashboardPageProps = { + searchParams?: Promise> +} + +type PriorityLevel = "high" | "medium" | "low" +type Tone = "good" | "warn" | "bad" | "info" + +type QueuePreview = { + id: string + title: string + venue: string + tags: string[] + time: string + priority: PriorityLevel + href: string +} + +type BriefCardData = { + label: string + pill: string + tone: Tone + title: string + copy: string + metaLeft: string + metaRight: string +} + +type LaneItemData = { + title: string + copy: string + metaLeft: string + metaRight: string + tone: Tone + href?: string +} + +type DestinationCardData = { + title: string + description: string + metric: string + href: string + icon: LucideIcon +} + +const TONE_PILL_CLASSES: Record = { + good: "border-emerald-200 bg-emerald-50 text-emerald-700", + warn: "border-amber-200 bg-amber-50 text-amber-700", + bad: "border-rose-200 bg-rose-50 text-rose-700", + info: "border-indigo-200 bg-indigo-50 text-indigo-700", +} + +function getGreeting(): string { + const hour = new Date().getHours() + if (hour < 12) return "早上好" + if (hour < 18) return "下午好" + return "晚上好" +} + +function formatRelativeTime(value?: string | null): string { + if (!value) return "刚刚" + + const parsed = new Date(value) + if (Number.isNaN(parsed.getTime())) return value + + const diffMs = Date.now() - parsed.getTime() + const diffMinutes = Math.max(1, Math.floor(diffMs / 60_000)) + const diffHours = Math.floor(diffMinutes / 60) + const diffDays = Math.floor(diffHours / 24) + + if (diffMinutes < 60) return `${diffMinutes} 分钟前` + if (diffHours < 24) return `${diffHours} 小时前` + if (diffDays === 1) return "昨天" + if (diffDays < 7) return `${diffDays} 天前` + + return parsed.toLocaleDateString("zh-CN", { month: "numeric", day: "numeric" }) +} + +function getQueuePriority(item: ReadingQueueItem, index: number): PriorityLevel { + if (typeof item.priority === "number") { + if (item.priority <= 2) return "high" + if (item.priority <= 4) return "medium" + return "low" + } + + if (index < 2) return "high" + if (index < 4) return "medium" + return "low" +} + +function formatCurrency(value: number): string { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(value) +} + +function buildQueuePreviewItems( + readingQueue: ReadingQueueItem[], + paperMap: Map, + activeTrack: ResearchTrackSummary | null, +): QueuePreview[] { + const fallbackTags = (activeTrack?.keywords || []).slice(0, 2) + + return readingQueue.slice(0, 3).map((item, index) => { + const paper = paperMap.get(String(item.paper_id || item.id)) + const tags = ( + paper?.tags?.length + ? paper.tags + : fallbackTags.length + ? fallbackTags + : ["Reading Queue"] + ).slice(0, 2) + + return { + id: item.id, + title: item.title, + venue: + paper?.venue || + (item.authors + ? `${item.authors.split(",").slice(0, 2).join(", ")}` + : "Paper Library"), + tags, + time: formatRelativeTime(item.saved_at), + priority: getQueuePriority(item, index), + href: item.paper_id ? `/papers/${item.paper_id}` : "/papers", + } + }) +} + +function buildBriefCards(args: { + activeTrack: ResearchTrackSummary | null + activeTrackFeed: TrackFeedItem[] + activeTrackFeedTotal: number + tasks: PipelineTask[] + deadlines: DeadlineRadarItem[] + queueItems: QueuePreview[] +}): BriefCardData[] { + const { activeTrack, activeTrackFeed, activeTrackFeedTotal, tasks, deadlines, queueItems } = args + const failedTasks = tasks.filter((task) => task.status === "failed") + const runningTasks = tasks.filter((task) => task.status !== "success" && task.status !== "failed") + const latestTask = tasks[0] + const nearestDeadline = deadlines[0] + const highPriorityQueue = queueItems.filter((item) => item.priority === "high").length + const latestTrackPaper = activeTrackFeed[0]?.paper.title + + return [ + { + label: "Focus", + pill: activeTrack ? "当前焦点" : "待设置", + tone: activeTrack ? (activeTrackFeedTotal > 0 ? "good" : "info") : "warn", + title: activeTrack ? activeTrack.name : "还没有活跃的 Focus Track", + copy: activeTrack + ? activeTrackFeedTotal > 0 + ? latestTrackPaper + ? `最近 ${activeTrackFeedTotal} 条相关更新已经进入这个 Track,最新命中是《${latestTrackPaper}》。` + : `最近 ${activeTrackFeedTotal} 条相关更新已经进入这个 Track。` + : activeTrack.description || "这个 Track 暂时没有新的 feed,可以直接从工作台继续 Search。" + : "先去 Research 创建一个 Focus Track,再回到首页继续推进今天的主题。", + metaLeft: activeTrack ? `${activeTrackFeedTotal} 条更新` : "前往 Research", + metaRight: activeTrack ? "Research" : "未设置", + }, + { + label: "Pipeline", + pill: failedTasks.length > 0 ? "需要处理" : runningTasks.length > 0 ? "进行中" : "平稳", + tone: failedTasks.length > 0 ? "bad" : runningTasks.length > 0 ? "info" : "good", + title: + failedTasks.length > 0 + ? `${failedTasks.length} 个后台任务需要处理` + : runningTasks.length > 0 + ? `${runningTasks.length} 个后台任务正在运行` + : "后台任务当前平稳", + copy: + failedTasks.length > 0 + ? `最近失败任务:${failedTasks[0]?.paper_title || "unknown task"}。` + : latestTask + ? `最近任务:${latestTask.paper_title}。` + : "近期开启的抓取或预处理任务会显示在这里。", + metaLeft: latestTask ? `最近任务 ${latestTask.started_at}` : "暂无最近任务", + metaRight: + failedTasks.length > 0 + ? `${failedTasks.length} 失败` + : runningTasks.length > 0 + ? `${runningTasks.length} 运行中` + : "无阻塞", + }, + { + label: "Deadline", + pill: + nearestDeadline && nearestDeadline.days_left <= 14 + ? "临近" + : nearestDeadline + ? "已跟踪" + : "平静", + tone: + nearestDeadline && nearestDeadline.days_left <= 14 + ? "warn" + : nearestDeadline + ? "info" + : "good", + title: nearestDeadline + ? `${nearestDeadline.name} 还剩 ${nearestDeadline.days_left} 天` + : "近期没有紧迫截稿", + copy: nearestDeadline + ? `${nearestDeadline.field} 相关的提交窗口已经进入 Radar。` + : "Radar 中没有 14 天内需要优先处理的会议或 workshop 截止。", + metaLeft: nearestDeadline ? "Deadline Radar" : "保持当前节奏", + metaRight: nearestDeadline ? nearestDeadline.ccf_level || "Tracked" : "无紧迫项", + }, + { + label: "Queue", + pill: queueItems.length > 0 ? "待处理" : "空队列", + tone: + queueItems.length === 0 + ? "info" + : highPriorityQueue > 0 + ? "warn" + : "good", + title: + queueItems.length > 0 + ? `今天有 ${queueItems.length} 篇候选等待处理` + : "今天的待读队列还是空的", + copy: + queueItems[0] + ? `优先看看《${queueItems[0].title}》,决定是继续 Analyze 还是送入 Papers。` + : "可以从主工作台运行 Search 或 DailyPaper,先把候选池建立起来。", + metaLeft: `${highPriorityQueue} 篇高优`, + metaRight: queueItems[0] ? `最近 ${queueItems[0].time}` : "等待候选", + }, + ] +} + +function buildActionLanes(args: { + tasks: PipelineTask[] + deadlines: DeadlineRadarItem[] + queueItems: QueuePreview[] + usageSummary: LLMUsageSummary + signalCount: number + tracks: ResearchTrackSummary[] +}): { now: LaneItemData[]; later: LaneItemData[] } { + const { tasks, deadlines, queueItems, usageSummary, signalCount, tracks } = args + const failedTask = tasks.find((task) => task.status === "failed") + const urgentDeadline = deadlines.find((deadline) => deadline.days_left <= 14) + const highPriorityItem = queueItems.find((item) => item.priority === "high") + + const now: LaneItemData[] = [] + const later: LaneItemData[] = [] + + if (failedTask) { + now.push({ + title: `修复 ${failedTask.paper_title}`, + copy: "后台抓取或预处理失败会直接影响下午的候选与 digest 质量。", + metaLeft: failedTask.started_at, + metaRight: "阻塞主流程", + tone: "bad", + href: "#workflow", + }) + } + + if (urgentDeadline) { + now.push({ + title: `${urgentDeadline.name} 还有 ${urgentDeadline.days_left} 天`, + copy: "补齐材料和对照实验,避免 deadline 信息继续漂浮在首页边缘而没有真正进入行动队列。", + metaLeft: urgentDeadline.field, + metaRight: "高优先级", + tone: "warn", + href: urgentDeadline.matched_tracks[0] + ? `/research?track_id=${urgentDeadline.matched_tracks[0].track_id}` + : "/research", + }) + } + + if (highPriorityItem) { + now.push({ + title: `处理高优候选《${highPriorityItem.title}》`, + copy: "把最接近当前焦点的问题优先决策,避免候选堆进长列表里继续增加认知负担。", + metaLeft: highPriorityItem.venue, + metaRight: highPriorityItem.time, + tone: "warn", + href: highPriorityItem.href, + }) + } + + if (now.length === 0) { + now.push({ + title: "今天没有立即阻塞项", + copy: "可以直接回到主工作台跑 Search 或 DailyPaper,把注意力留给当前的 Focus Track。", + metaLeft: "Calm mode", + metaRight: "继续工作", + tone: "good", + href: "#workflow", + }) + } + + if (usageSummary.totals.calls > 0 || usageSummary.totals.total_cost_usd > 0) { + later.push({ + title: `近 ${usageSummary.window_days} 天完成 ${usageSummary.totals.calls} 次模型调用`, + copy: `累计成本 ${formatCurrency(usageSummary.totals.total_cost_usd)}。模型使用概览被收进次级页,只在它影响决策时再回到首页。`, + metaLeft: "Usage", + metaRight: "Settings", + tone: "info", + href: "/settings", + }) + } + + if (signalCount > 0) { + later.push({ + title: `${signalCount} 条社区信号已压缩进证据快照`, + copy: "首页只保留会影响当前判断的摘要,完整动态继续留在 Research 页面。", + metaLeft: "Signals", + metaRight: "Evidence", + tone: "info", + href: "#signals", + }) + } + + if (tracks.length > 1) { + later.push({ + title: `${tracks.length - 1} 个非焦点 Track 等待回顾`, + copy: "这些 Track 继续存在,但不会再在首页和当前焦点争夺同一层级的注意力。", + metaLeft: "Research", + metaRight: "稍后处理", + tone: "info", + href: "/research", + }) + } + + if (later.length === 0) { + later.push({ + title: "当前没有额外维护项", + copy: "今天可以把注意力完全集中在主工作台,不需要切换到别的页面清理配置或状态。", + metaLeft: "Workspace", + metaRight: "清爽", + tone: "info", + href: "/research", + }) + } + + return { + now: now.slice(0, 3), + later: later.slice(0, 3), + } +} + +function getLaneTone(items: LaneItemData[]): Tone { + if (items.some((item) => item.tone === "bad")) return "bad" + if (items.some((item) => item.tone === "warn")) return "warn" + if (items.some((item) => item.tone === "good")) return "good" + return "info" +} + +function getEvidenceStyles(source: string): { + badgeClassName: string + chipClassName: string +} { + switch (source) { + case "github": + return { + badgeClassName: "border-slate-200 bg-slate-50 text-slate-700", + chipClassName: "border-slate-200 bg-slate-50 text-slate-600", + } + case "reddit": + return { + badgeClassName: "border-amber-200 bg-amber-50 text-amber-700", + chipClassName: "border-amber-200 bg-amber-50 text-amber-700", + } + case "huggingface": + return { + badgeClassName: "border-sky-200 bg-sky-50 text-sky-700", + chipClassName: "border-sky-200 bg-sky-50 text-sky-700", + } + default: + return { + badgeClassName: "border-emerald-200 bg-emerald-50 text-emerald-700", + chipClassName: "border-slate-200 bg-slate-50 text-slate-600", + } + } +} + +function FocusDeadlinesPanel({ + track, + deadlines, +}: { + track: ResearchTrackSummary | null + deadlines: DeadlineRadarItem[] +}) { + const tone: Tone = + deadlines.length === 0 + ? track + ? "good" + : "info" + : deadlines[0].days_left <= 14 + ? "warn" + : "info" + + return ( +
+
+
+

Focus Deadlines

+

当前焦点的 Top 3 DDL

+

+ {track + ? "根据 Track 的 keywords、methods 和 venues 自动关联最近会议,让 deadline 直接进入当前工作面。" + : "先设置 Focus Track,系统才会开始自动关联相关会议 deadline。"} +

+
+ + {deadlines.length > 0 ? `${deadlines.length} 项` : track ? "暂无命中" : "等待焦点"} + +
+ + {deadlines.length > 0 ? ( +
+ {deadlines.map((deadline) => { + const matchedTrack = deadline.matched_tracks.find((item) => item.track_id === track?.id) + const matchedTerms = ( + matchedTrack?.matched_terms?.length + ? matchedTrack.matched_terms + : matchedTrack?.matched_keywords || [] + ).slice(0, 3) + + return ( + +
+ + {deadline.ccf_level || deadline.field} + + {deadline.days_left} 天 +
+
{deadline.name}
+

{deadline.field}

+ {matchedTerms.length > 0 ? ( +
+ {matchedTerms.map((term) => ( + + {term} + + ))} +
+ ) : null} +
+ 查看会议 + +
+ + ) + })} +
+ ) : ( +
+ {track + ? `当前还没有和 ${track.name} 自动关联的近期开会 DDL。可以补充 venues 或 methods,让匹配更稳定。` + : "先去 Research 设定 Focus Track,deadline 才会自动收敛到当前主题。"} +
+ )} +
+ ) +} + +function SectionIntro({ + eyebrow, + title, + copy, + actionHref, + actionLabel, +}: { + eyebrow: string + title: string + copy: string + actionHref?: string + actionLabel?: string +}) { + return ( +
+
+

{eyebrow}

+

+ {title} +

+

{copy}

+
+ + {actionHref && actionLabel ? ( + + {actionLabel} + + + ) : null} +
+ ) +} + +function TonePill({ tone, children }: { tone: Tone; children: React.ReactNode }) { + return ( + + {children} + + ) +} + +function FocusOverviewPanel({ items }: { items: BriefCardData[] }) { + return ( +
+
+
+

Overview

+

焦点摘要

+

把焦点、后台状态、deadline 和队列压成一个连续摘要,而不是四张分散卡片。

+
+ {items.length} 项 +
+ +
+ {items.map((item) => ( +
+
+
+

{item.label}

+ {item.pill} +
+ {item.metaRight} +
+
{item.title}
+

{item.copy}

+
{item.metaLeft}
+
+ ))} +
+
+ ) +} + +function EvidencePreviewCard({ + item, + compact = false, +}: { + item: DashboardIntelligenceCard + compact?: boolean +}) { + const styles = getEvidenceStyles(item.source) + + return ( +
+
+ + {item.sourceLabel} + + {formatRelativeTime(item.timestamp)} +
+ +

+ {item.title} +

+

{item.summary}

+ + {item.reasonChips.length > 0 ? ( +
+ {item.reasonChips.slice(0, compact ? 2 : 3).map((reason) => ( + + {reason} + + ))} +
+ ) : null} + +
+ {item.metricLabel} +
+ + {item.researchLabel} + + + {item.isExternal ? ( + + 原始来源 + + ) : null} +
+
+
+ ) +} + +function TodayRail({ + nowItems, + laterItems, + destinations, + queueItems, + highPriorityQueue, +}: { + nowItems: LaneItemData[] + laterItems: LaneItemData[] + destinations: DestinationCardData[] + queueItems: QueuePreview[] + highPriorityQueue: number +}) { + function renderLaneItem(item: LaneItemData, key: string) { + const content = ( +
+
+ + {item.tone === "bad" + ? "阻塞" + : item.tone === "warn" + ? "注意" + : item.tone === "good" + ? "清爽" + : "信息"} + + {item.metaRight} +
+

{item.title}

+

{item.copy}

+
+ {item.metaLeft} + {item.href ? ( + + 打开 + + + ) : null} +
+
+ ) + + return item.href ? ( + + {content} + + ) : ( +
{content}
+ ) + } + + return ( + + ) +} + +export default async function DashboardPage({ searchParams }: DashboardPageProps) { + const params = searchParams ? await searchParams : {} + const queryValue = Array.isArray(params?.query) ? params.query[0] : params?.query + const initialQueries = typeof queryValue === "string" + ? queryValue + .split(",") + .map((value) => value.trim()) + .filter(Boolean) + : undefined + + const [ + tracksResult, + tasksResult, + readingQueueResult, + llmUsageResult, + deadlineResult, + intelligenceResult, + papersResult, + ] = await Promise.allSettled([ + fetchDashboardTracks("default"), + fetchPipelineTasks(), + fetchDashboardReadingQueue("default", 6), + fetchLLMUsage(14), + fetchDeadlineRadar("default"), + fetchIntelligenceFeed("default", 6, { sortBy: "delta", sortOrder: "desc" }), + fetchPapers(), + ]) const tracks = tracksResult.status === "fulfilled" ? tracksResult.value : [] const tasks = tasksResult.status === "fulfilled" ? tasksResult.value : [] @@ -57,145 +830,293 @@ export default async function DashboardPage() { provider_models: [], totals: { calls: 0, total_tokens: 0, total_cost_usd: 0 }, } - const deadlines = deadlineResult.status === "fulfilled" ? deadlineResult.value : [] - const activities = activitiesResult.status === "fulfilled" ? activitiesResult.value : [] - const scholars = scholarsResult.status === "fulfilled" ? scholarsResult.value : [] + const deadlinesRaw = deadlineResult.status === "fulfilled" ? deadlineResult.value : [] + const intelligenceFeed = intelligenceResult.status === "fulfilled" + ? intelligenceResult.value + : { items: [], refreshed_at: null, refresh_scheduled: false, keywords: [], watch_repos: [], subreddits: [] } + const papers = papersResult.status === "fulfilled" ? papersResult.value : [] + const deadlines = [...deadlinesRaw].sort((left, right) => left.days_left - right.days_left) const activeTrack = tracks.find((track) => track.is_active) || tracks[0] || null + const activeTrackFeedResult = activeTrack + ? await fetchDashboardTrackFeed(activeTrack.id, "default", 4).catch(() => ({ items: [], total: 0 })) + : { items: [], total: 0 } + const activeTrackFeed = activeTrackFeedResult.items || [] + const activeTrackFeedTotal = activeTrackFeedResult.total || 0 + const intelligenceCards = buildDashboardIntelligenceCards(intelligenceFeed.items) + const focusDeadlines = activeTrack + ? deadlines.filter((deadline) => + deadline.matched_tracks.some((matchedTrack) => matchedTrack.track_id === activeTrack.id), + ).slice(0, 3) + : [] + const queueItems = buildQueuePreviewItems( + readingQueue, + new Map(papers.map((paper) => [String(paper.id), paper])), + activeTrack, + ) - let feedItems: TrackFeedItem[] = [] - let feedTotal = 0 - let anchors: AnchorPreviewItem[] = [] - if (activeTrack) { - const [feedResult, anchorsResult] = await Promise.allSettled([ - fetchDashboardTrackFeed(activeTrack.id, "default", 6), - fetchDashboardAnchors(activeTrack.id, "default", 4), - ]) - if (feedResult.status === "fulfilled") { - feedItems = feedResult.value.items - feedTotal = feedResult.value.total - } - if (anchorsResult.status === "fulfilled") { - anchors = anchorsResult.value - } - } + const focusKeywords = (activeTrack?.keywords || []).slice(0, 3) + const highPriorityQueue = queueItems.filter((item) => item.priority === "high").length + const failedTasks = tasks.filter((task) => task.status === "failed") + const urgentDeadlines = deadlines.filter((deadline) => deadline.days_left <= 14) + const signalCount = intelligenceCards.length + const alertCount = failedTasks.length + urgentDeadlines.length + (highPriorityQueue > 0 ? 1 : 0) + const greeting = getGreeting() + const libraryCount = papers.length - const runningPipelines = tasks.filter((task) => !["success", "failed"].includes(task.status)).length - const deadlineSoon = deadlines.filter((item) => item.days_left <= 30).length - const todayLabel = new Intl.DateTimeFormat("en-US", { - weekday: "long", - month: "short", - day: "numeric", - }).format(new Date()) + const focusSummary = activeTrack + ? activeTrackFeedTotal > 0 + ? activeTrackFeed[0]?.paper.title + ? `当前焦点 Track「${activeTrack.name}」最近捕获了 ${activeTrackFeedTotal} 条相关更新,最新命中是《${activeTrackFeed[0].paper.title}》。` + : `当前焦点 Track「${activeTrack.name}」最近捕获了 ${activeTrackFeedTotal} 条相关更新。` + : activeTrack.description || `当前焦点 Track「${activeTrack.name}」暂时没有新的 feed,可以直接从工作台继续 Search。` + : "目前还没有活跃的 Focus Track。先去 Research 设定问题域,再回到首页继续推进今天的工作。" + + const briefCards = buildBriefCards({ + activeTrack, + activeTrackFeed, + activeTrackFeedTotal, + tasks, + deadlines, + queueItems, + }) + const lanes = buildActionLanes({ + tasks, + deadlines, + queueItems, + usageSummary, + signalCount, + tracks, + }) + const destinationCards: DestinationCardData[] = [ + { + title: "Research Workspace", + description: activeTrack + ? `继续在 ${activeTrack.name} 里查看完整 Track feed、anchors 和深度研究上下文。` + : "在 Research 里建立 Track、整理上下文并继续深度探索。", + metric: activeTrack ? `${activeTrackFeedTotal} 条更新` : "创建焦点", + href: activeTrack ? `/research?track_id=${activeTrack.id}` : "/research", + icon: FlaskConical, + }, + { + title: "Papers Library", + description: "保存库、导出和 BibTeX 继续留在 Papers,不再和首页主路径抢同一层级。", + metric: `${libraryCount} 篇文献`, + href: "/papers", + icon: FileText, + }, + { + title: "Settings & Delivery", + description: "Provider、投递渠道和模型使用概览放回配置页,首页只保留会影响决策的摘要。", + metric: `${usageSummary.totals.calls} calls · ${formatCurrency(usageSummary.totals.total_cost_usd)}`, + href: "/settings", + icon: Settings, + }, + ] return ( -
- - -
-
- - - {todayLabel} - -

Research Dashboard

-

- One place to monitor track feed quality, workflow health, LLM spend, and delivery deadlines. +

+
+
+
+
+

Dashboard

+

+ {greeting},把今天最重要的问题先推进到可决策状态。 +

+

+ 首页收敛到焦点、主工作台、提醒和证据。深层研究、文献管理和配置继续留在各自页面。

+
- - - + + Workspace / default + + + {activeTrack ? activeTrack.name : "Focus 未设置"} + + + {alertCount} 项提醒 +
-
+ -
-
-

Active Tracks

-

{tracks.length}

-

{activeTrack?.name || "No active track"}

-
-
-

Tracked Scholars

-

{scholars.length}

-

- {scholars.filter((item) => item.status === "active").length} active now -

-
-
-

Saved Queue

-

{readingQueue.length}

-

Linked to Papers library

-
-
-

LLM Tokens ({usageSummary.window_days}d)

-

{formatCompactNumber(usageSummary.totals.total_tokens)}

-

- - {usageSummary.totals.calls} model calls -

-
-
-

Urgent Deadlines

-

{deadlineSoon}

-

- - {deadlines.length} deadlines tracked -

+
+
+
+
+
+

Workspace

+

+ 当前焦点工作台 +

+

+ 把焦点摘要、运行概览和执行入口收进同一块区域,减少上下跳转和空白断层。 +

+
+ + + 打开完整 Research + + +
+ +
+
+
+ + 当前焦点 +
+

+ {activeTrack?.name || "等待设置当前焦点"} +

+

{focusSummary}

+ +
+
+
+

Track 更新

+

{activeTrackFeedTotal}

+
+
+

候选队列

+

{readingQueue.length}

+
+
+

提醒

+

{alertCount}

+
+
+

证据信号

+

{signalCount}

+
+
+
+ +
+ + + {activeTrack ? activeTrack.name : "Focus 未设置"} + + + + {signalCount} 条证据已压缩 + + + + {highPriorityQueue} 篇高优候选 + +
+ +
+ {focusKeywords.length > 0 ? ( + focusKeywords.map((keyword) => ( + + {keyword} + + )) + ) : ( + + 去 Research 添加关键词 + + )} + {activeTrackFeedTotal > 0 ? ( + + {activeTrackFeedTotal} 条 Track feed + + ) : null} +
+ + + +
+ + 查看证据快照 + + + 打开 Papers + +
+
+ + +
+
+ +
-
- - - -
-
- - - - - - - Activity Stream - - - - - -
-
- - - - - -
-
+ + + +
+
+
+
+ + +
+ + {signalCount} 条信号 + + + {intelligenceCards.filter((item) => item.source === "github").length} 条 GitHub + + + 最近刷新 {formatRelativeTime(intelligenceFeed.refreshed_at)} + +
+
+ +
+ {intelligenceCards.length > 0 ? ( + intelligenceCards.map((item) => ) + ) : ( +
+ 当前没有需要上浮到首页的社区信号。可以直接在主工作台继续推进当前问题。 +
+ )} +
+
+
+
+
+
) } diff --git a/web/src/components/research/TopicWorkflowDashboard.tsx b/web/src/components/research/TopicWorkflowDashboard.tsx index 93e60af4..eac1b05f 100644 --- a/web/src/components/research/TopicWorkflowDashboard.tsx +++ b/web/src/components/research/TopicWorkflowDashboard.tsx @@ -1,23 +1,28 @@ "use client" +import Link from "next/link" import { useCallback, useEffect, useMemo, useRef, useState } from "react" import Markdown from "react-markdown" import remarkGfm from "remark-gfm" import { + ArrowUpRightIcon, BookOpenIcon, ChevronDownIcon, ChevronRightIcon, + CompassIcon, DownloadIcon, FilterIcon, Loader2Icon, MailIcon, PlusIcon, PlayIcon, + SearchIcon, SettingsIcon, SparklesIcon, StarIcon, Trash2Icon, TrendingUpIcon, + WorkflowIcon, XIcon, ZapIcon, } from "lucide-react" @@ -40,6 +45,7 @@ import { Label } from "@/components/ui/label" import { Progress } from "@/components/ui/progress" import { ScrollArea } from "@/components/ui/scroll-area" import { Separator } from "@/components/ui/separator" +import { Switch } from "@/components/ui/switch" import { Sheet, SheetContent, @@ -117,6 +123,75 @@ const REC_LABELS: Record = { skip: "Skip", } +const SOURCE_LABELS: Record = { + papers_cool: "papers.cool", + arxiv_api: "arXiv API", + hf_daily: "HF Daily", +} + +const BRANCH_LABELS: Record = { + arxiv: "arXiv", + venue: "Venue", +} + +const PHASE_COPY: Record = { + idle: "未开始", + searching: "检索中", + searched: "候选已就绪", + reporting: "生成中", + reported: "报告已就绪", + error: "需要处理", +} + +type WorkflowWorkspaceTab = "candidates" | "insights" | "judge" | "report" | "delivery" | "log" + +type WorkflowDashboardContext = { + activeTrackName?: string | null + activeTrackHref?: string + readingQueueCount?: number + urgentDeadlineCount?: number + signalCount?: number +} + +const WORKFLOW_REFERENCE_LINKS: Array<{ + label: string + description: string + href: string + external?: boolean +}> = [ + { + label: "Open Papers Library", + description: "Move from the dashboard run into saved papers and reference exports once the shortlist is stable.", + href: "/papers", + }, + { + label: "Tune Providers & Delivery", + description: "Route models and configure push channels before turning this workflow into a production ritual.", + href: "/settings", + }, +] + +function getNextWorkflowAction(args: { + hasSearchData: boolean + hasReportData: boolean + hasJudgeContent: boolean + hasLLMContent: boolean + isLoading: boolean +}) { + if (args.isLoading) return "当前有任务在运行。先观察进度,再决定下一步交接。" + if (!args.hasSearchData) return "先运行 Search,基于当前主题建立候选池。" + if (!args.hasReportData) return "接着运行 DailyPaper,把候选整理成可读报告和产物。" + if (!args.hasJudgeContent && !args.hasLLMContent) return "然后运行 Analyze,补齐 Judge 评分、趋势和洞察。" + return "回看最强候选,必要时补仓仓库信息,然后送入 Research 或 Wiki。" +} + +function formatTimestamp(value?: string | null) { + if (!value) return "尚未运行" + const parsed = new Date(value) + if (Number.isNaN(parsed.getTime())) return value + return parsed.toLocaleString() +} + function ScoreBar({ value, max = 5, label }: { value: number; max?: number; label: string }) { const pct = Math.round((value / max) * 100) const color = value >= 4 ? "bg-green-500" : value >= 3 ? "bg-blue-500" : value >= 2 ? "bg-yellow-500" : "bg-red-400" @@ -131,18 +206,249 @@ function ScoreBar({ value, max = 5, label }: { value: number; max?: number; labe ) } -function StatCard({ label, value, icon }: { label: string; value: string | number; icon: React.ReactNode }) { +function StatCard({ + label, + value, + icon, + compact = false, +}: { + label: string + value: string | number + icon: React.ReactNode + compact?: boolean +}) { return ( -
-
{icon}
+
+
+ {icon} +
-
{value}
-
{label}
+
{value}
+
{label}
) } +function getStageState(args: { + isRunning: boolean + isDone: boolean + hasError: boolean +}): StepStatus { + if (args.hasError) return "error" + if (args.isRunning) return "running" + if (args.isDone) return "done" + return "pending" +} + +function getStagePresentation(status: StepStatus): { + label: string + cardClassName: string + badgeClassName: string + dotClassName: string +} { + switch (status) { + case "done": + return { + label: "Ready", + cardClassName: "border-emerald-100 bg-emerald-50/70", + badgeClassName: "border-emerald-200 bg-emerald-50 text-emerald-700", + dotClassName: "bg-emerald-500", + } + case "running": + return { + label: "Running", + cardClassName: "border-indigo-100 bg-indigo-50/70", + badgeClassName: "border-indigo-200 bg-indigo-50 text-indigo-700", + dotClassName: "bg-indigo-500 animate-pulse", + } + case "error": + return { + label: "Blocked", + cardClassName: "border-rose-100 bg-rose-50/70", + badgeClassName: "border-rose-200 bg-rose-50 text-rose-700", + dotClassName: "bg-rose-500", + } + case "skipped": + return { + label: "Skipped", + cardClassName: "border-slate-200 bg-slate-50/70", + badgeClassName: "border-slate-200 bg-white text-slate-500", + dotClassName: "bg-slate-300", + } + default: + return { + label: "Pending", + cardClassName: "border-slate-200 bg-white", + badgeClassName: "border-slate-200 bg-white text-slate-600", + dotClassName: "bg-slate-300", + } + } +} + +function WorkflowStageCard({ + eyebrow, + title, + description, + metric, + status, + icon, +}: { + eyebrow: string + title: string + description: string + metric: string + status: StepStatus + icon: React.ReactNode +}) { + const presentation = getStagePresentation(status) + + return ( +
+
+
+

{eyebrow}

+
+ + {icon} + +
+

{title}

+
+
+
+ + + {presentation.label} + +
+

{description}

+

{metric}

+
+ ) +} + +function WorkflowChip({ + label, + active, + onToggle, +}: { + label: string + active: boolean + onToggle: () => void +}) { + return ( + + ) +} + +function WorkflowTogglePanel({ + title, + description, + checked, + onCheckedChange, + icon, + compact = false, +}: { + title: string + description: string + checked: boolean + onCheckedChange: (value: boolean) => void + icon: React.ReactNode + compact?: boolean +}) { + if (compact) { + return ( +
+
+
+
+ {icon} + {title} +
+

{description}

+
+ +
+
+ ) + } + + return ( +
+
+
+
+ {icon} + {title} +
+

{description}

+
+ +
+
+ ) +} + +function WorkflowReferenceCard({ + title, + description, + href, + external = false, +}: { + title: string + description: string + href: string + external?: boolean +}) { + const content = ( + <> +
+

{title}

+

{description}

+
+ + + ) + + if (external) { + return ( + + {content} + + ) + } + + return ( + + {content} + + ) +} + function buildDagStatuses(args: { phase: WorkflowPhase hasError: boolean @@ -274,14 +580,14 @@ function StreamProgressCard({ : 0 return ( - +
-
+
{PHASE_LABELS[streamPhase] || streamPhase}
-
+
{streamProgress.total > 0 && ( {streamProgress.done}/{streamProgress.total} )} @@ -300,11 +606,11 @@ function StreamProgressCard({ status === "done" ? "bg-green-500" : status === "active" - ? "bg-blue-500 animate-pulse" + ? "bg-indigo-500 animate-pulse" : "bg-slate-200" }`} /> - + {PHASE_LABELS[p]}
@@ -313,7 +619,7 @@ function StreamProgressCard({
{streamLog.length > 0 && ( -
+
{streamLog.slice(-20).map((line, idx) => (
{line}
))} @@ -454,7 +760,6 @@ function PaperDetailDialog({ item, open, onClose }: { item: SearchItem | null; o /* ── Config Sheet ─────────────────────────────────────── */ function ConfigSheetBody(props: { - queryItems: string[]; setQueryItems: (v: string[]) => void topK: number; setTopK: (v: number) => void topN: number; setTopN: (v: number) => void showPerBranch: number; setShowPerBranch: (v: number) => void @@ -479,7 +784,7 @@ function ConfigSheetBody(props: { resendEnabled: boolean; setResendEnabled: (v: boolean) => void }) { const { - queryItems, setQueryItems, topK, setTopK, topN, setTopN, + topK, setTopK, topN, setTopN, showPerBranch, setShowPerBranch, saveDaily, setSaveDaily, outputDir, setOutputDir, useArxiv, setUseArxiv, useVenue, setUseVenue, usePapersCool, setUsePapersCool, useArxivApi, setUseArxivApi, useHFDaily, setUseHFDaily, enableLLM, setEnableLLM, @@ -491,44 +796,9 @@ function ConfigSheetBody(props: { resendEnabled, setResendEnabled, } = props - const updateQuery = (idx: number, value: string) => { - const next = [...queryItems] - next[idx] = value - setQueryItems(next) - } - const removeQuery = (idx: number) => { - if (queryItems.length <= 1) return - setQueryItems(queryItems.filter((_, i) => i !== idx)) - } - const addQuery = () => setQueryItems([...queryItems, ""]) - return (
- -
- {queryItems.map((q, idx) => ( -
- updateQuery(idx, e.target.value)} - placeholder="Enter a topic..." - className="h-8 text-sm" - /> - {queryItems.length > 1 && ( - - )} -
- ))} -
- -
- -
@@ -708,9 +978,15 @@ function NewsletterSubscribeWidget() { type TopicWorkflowDashboardProps = { initialQueries?: string[] + dashboardContext?: WorkflowDashboardContext + compact?: boolean } -export default function TopicWorkflowDashboard({ initialQueries }: TopicWorkflowDashboardProps = {}) { +export default function TopicWorkflowDashboard({ + initialQueries, + dashboardContext, + compact = false, +}: TopicWorkflowDashboardProps = {}) { /* Config state (local — queries only) */ const [queryItems, setQueryItems] = useState([ ...((initialQueries && initialQueries.length ? initialQueries : DEFAULT_QUERIES) || DEFAULT_QUERIES), @@ -785,8 +1061,11 @@ export default function TopicWorkflowDashboard({ initialQueries }: TopicWorkflow /* UI state */ const [dagOpen, setDagOpen] = useState(false) + const [compactOptionsOpen, setCompactOptionsOpen] = useState(false) + const [settingsOpen, setSettingsOpen] = useState(false) const [selectedPaper, setSelectedPaper] = useState(null) const [sortBy, setSortBy] = useState<"score" | "judge">("score") + const [workspaceTab, setWorkspaceTab] = useState("candidates") const queries = useMemo(() => queryItems.map((q) => q.trim()).filter(Boolean), [queryItems]) const branches = useMemo(() => [useArxiv ? "arxiv" : "", useVenue ? "venue" : ""].filter(Boolean), [useArxiv, useVenue]) @@ -893,8 +1172,28 @@ export default function TopicWorkflowDashboard({ initialQueries }: TopicWorkflow })) }, [dailyResult]) + function updateQuery(index: number, value: string) { + setQueryItems((prev) => { + const next = [...prev] + next[index] = value + return next + }) + } + + function removeQuery(index: number) { + setQueryItems((prev) => { + if (prev.length <= 1) return prev + return prev.filter((_, itemIndex) => itemIndex !== index) + }) + } + + function addQuery() { + setQueryItems((prev) => [...prev, ""]) + } + /* Actions */ async function runTopicSearch() { + setWorkspaceTab("candidates") setLoadingSearch(true); setError(null); store.setPhase("searching") store.setDailyResult(null); store.clearAnalyzeLog() try { @@ -910,6 +1209,7 @@ export default function TopicWorkflowDashboard({ initialQueries }: TopicWorkflow } async function runDailyPaperStream() { + setWorkspaceTab("report") streamAbortRef.current?.abort() const controller = new AbortController() streamAbortRef.current = controller @@ -1209,6 +1509,7 @@ export default function TopicWorkflowDashboard({ initialQueries }: TopicWorkflow streamAbortRef.current?.abort() const controller = new AbortController() streamAbortRef.current = controller + setWorkspaceTab(runJudge ? "judge" : "insights") setLoadingAnalyze(true); setError(null); store.clearAnalyzeLog(); setAnalyzeProgress({ done: 0, total: 0 }); store.setPhase("reporting") setStreamPhase("idle"); setStreamLog([]); setStreamProgress({ done: 0, total: 0 }) streamStartRef.current = Date.now() @@ -1440,6 +1741,7 @@ export default function TopicWorkflowDashboard({ initialQueries }: TopicWorkflow return } + setWorkspaceTab("delivery") setLoadingRepos(true) setRepoError(null) try { @@ -1463,6 +1765,7 @@ export default function TopicWorkflowDashboard({ initialQueries }: TopicWorkflow } const isLoading = loadingSearch || loadingDaily || loadingAnalyze + const hasWorkspaceOutput = isLoading || hasSearchData || hasReportData || hasJudgeContent || hasLLMContent const canSearch = queries.length > 0 && branches.length > 0 && sources.length > 0 const loadingLabel = loadingSearch ? "Searching sources..." @@ -1472,62 +1775,687 @@ export default function TopicWorkflowDashboard({ initialQueries }: TopicWorkflow : loadingSearch ? "Multi-query retrieval in progress" : "Waiting for LLM events" + const activeSourceLabels = sources.map((source) => SOURCE_LABELS[source] || source) + const activeBranchLabels = branches.map((branch) => BRANCH_LABELS[branch] || branch) + const quickSummaryBadges = [ + `${queries.length} topic${queries.length === 1 ? "" : "s"}`, + activeSourceLabels.length ? activeSourceLabels.join(" · ") : "No sources", + activeBranchLabels.length ? activeBranchLabels.join(" · ") : "No branches", + enableLLM ? "LLM on" : "LLM off", + enableJudge ? "Judge on" : "Judge off", + ] + const searchStageState = getStageState({ + isRunning: loadingSearch, + isDone: hasSearchData, + hasError: Boolean(error) && !hasSearchData, + }) + const dailyStageState = getStageState({ + isRunning: loadingDaily, + isDone: hasReportData, + hasError: Boolean(error) && hasSearchData && !hasReportData, + }) + const analysisStageState = getStageState({ + isRunning: loadingAnalyze, + isDone: hasJudgeContent || hasLLMContent || schedulerDone, + hasError: Boolean(error) && hasReportData && !hasJudgeContent && !hasLLMContent, + }) + const phaseLabel = PHASE_COPY[phase] + const nextStepLabel = getNextWorkflowAction({ + hasSearchData, + hasReportData, + hasJudgeContent, + hasLLMContent, + isLoading, + }) + const deliveryChannels = [notifyEnabled ? "Email" : null, resendEnabled ? "Resend" : null].filter(Boolean) as string[] + const compactRetrievalSummary = [ + activeSourceLabels.length ? activeSourceLabels.join(" · ") : "未选择检索来源", + activeBranchLabels.length ? activeBranchLabels.join(" + ") : "未选择结果分支", + ] + const compactAnalysisSummary = [ + enableLLM ? "LLM 开" : "LLM 关", + enableJudge ? "Judge 开" : "Judge 关", + saveDaily ? "保存产物 开" : "保存产物 关", + ].join(" · ") + const compactDeliverySummary = deliveryChannels.length + ? `${deliveryChannels.join(" + ")} 已开启` + : "仅手动复核" + const showCompactOptions = compact && (compactOptionsOpen || !canSearch || notifyEnabled) + const controlStateHint = canSearch + ? compact + ? "当前配置已经可以直接运行 Search。" + : "Ready to search across the selected topic set." + : compact + ? "至少保留一个主题、来源和分支后才能执行。" + : "At least one topic, source, and branch must stay enabled before execution." + const emailOverrideField = notifyEnabled ? ( +
+
+
+

+ {compact ? "邮件接收地址" : "Direct email override"} +

+

+ {compact + ? "本次运行可覆盖默认后端收件人。" + : "This address overrides the default backend recipient for the next digest run."} +

+
+
+ store.setNotifyEmail(event.target.value)} + placeholder="you@example.com" + className="h-9 rounded-xl border-slate-200 bg-white" + /> +
+
+
+ ) : null + const overallProgressValue = Math.round( + [searchStageState, dailyStageState, analysisStageState].reduce((sum, status) => { + if (status === "done") return sum + 1 + if (status === "running") return sum + 0.5 + return sum + }, 0) / 3 * 100, + ) return ( -
+
setSelectedPaper(null)} /> - {/* Header */} -
-
-

Topic Workflow

-

- Search, analyze, and judge research papers - {store.lastUpdated && (last: {new Date(store.lastUpdated).toLocaleString()})} -

-
-
- - - - - - - - - - - - Workflow Configuration - Topics, sources, LLM and Judge settings - -
- + +
+ {!compact ? ( + <> +
+
+
+ + Research Workflow +
+

+ Turn topics into ranked digests, judge queues, and research handoffs. +

+

+ Search, DailyPaper, Analyze, and delivery now share one control surface inside Dashboard. Topics stay editable here; advanced knobs live in the side sheet instead of being hidden behind a detached page. +

+
+ {quickSummaryBadges.map((badge) => ( + + {badge} + + ))} + {dashboardContext?.activeTrackName ? ( + + Focus Track: {dashboardContext.activeTrackName} + + ) : null} +
+
+ +
+
+

Current phase

+

{phaseLabel}

+

{formatTimestamp(store.lastUpdated)}

+
+
+

Candidates

+

{allPapers.length}

+

{paperDataSource === "dailypaper" ? "From DailyPaper" : paperDataSource === "search" ? "From search" : "No data yet"}

+
+
+

Research handoff

+

{dashboardContext?.activeTrackName || "Global"}

+

+ {dashboardContext?.readingQueueCount ?? 0} queued · {(dashboardContext?.urgentDeadlineCount ?? 0) + (dashboardContext?.signalCount ?? 0)} alerts +

+
+
- - + +
+ } + /> + } + /> + } + /> +
+ + ) : null} + +
+
+
+
+

+ {compact ? "快速配置" : "Control Deck"} +

+

+ {compact ? "组织一次工作流运行" : "Compose a workflow run"} +

+

+ {compact + ? "把主题、来源、增强开关和执行动作收在一起,避免来回跳页。" + : "Put the driving questions, source mix, enrichment switches, and launch actions in one place."} +

+
+ + + +
+ +
+
+
+
+

{compact ? "主题" : "Topics"}

+

{compact ? "直接在这里维护本轮研究主题。" : "Edit the research questions directly inside the dashboard."}

+
+ +
+
+ {queryItems.map((query, index) => ( +
+ + {index + 1} + + updateQuery(index, event.target.value)} + placeholder={compact ? "输入主题、问题或线索" : "Enter a topic, problem, or question"} + className="h-9 border-none bg-transparent px-0 text-sm shadow-none focus-visible:ring-0" + /> + {queryItems.length > 1 ? ( + + ) : null} +
+ ))} +
+
+ + {compact ? ( +
+
+
+

运行选项摘要

+

+ 默认只显示本轮检索、分析和投递摘要,需要调整时再展开详细配置。 +

+
+ +
+ +
+
+
+
检索范围
+
+
{compactRetrievalSummary[0]}
+
{compactRetrievalSummary[1]}
+
+
+
+
分析增强
+
+
{compactAnalysisSummary}
+
需要更细的 LLM feature、judge budget 或输出策略时,再展开详细配置。
+
+
+
+
投递方式
+
+
{compactDeliverySummary}
+
+ {notifyEnabled && notifyEmail.trim() ? notifyEmail.trim() : "Dashboard 提醒默认保留在当前页面内。"} +
+
+
+
+
+ + {showCompactOptions ? ( +
+
+
+

检索范围

+

先确认来源和分支,再发起本轮搜索。

+
+
+
+

来源

+

选择本轮检索来源。

+
+ setUsePapersCool(!usePapersCool)} /> + setUseArxivApi(!useArxivApi)} /> + setUseHFDaily(!useHFDaily)} /> +
+
+
+

分支

+

控制结果更贴近 arXiv、Venue 或两者结合。

+
+ setUseArxiv(!useArxiv)} /> + setUseVenue(!useVenue)} /> +
+
+
+
+ +
+
+

分析增强

+

只保留最常用的高层开关,重型参数继续放在高级设置里。

+
+
+ } + compact + /> + } + compact + /> + } + compact + /> +
+
+ +
+
+

投递方式

+

Dashboard 提醒默认保留,外部推送按需开启。

+
+
+ } + compact + /> + } + compact + /> +
+ {emailOverrideField} +
+
+ ) : null} +
+ ) : ( + <> +
+
+

Sources

+

+ Mix APIs and curated feeds without leaving the console. +

+
+ setUsePapersCool(!usePapersCool)} /> + setUseArxivApi(!useArxivApi)} /> + setUseHFDaily(!useHFDaily)} /> +
+
+
+

Branches

+

+ Choose whether ranking stays closer to arXiv, venues, or both. +

+
+ setUseArxiv(!useArxiv)} /> + setUseVenue(!useVenue)} /> +
+
+
+ +
+ } + /> + } + /> + } + /> + } + /> + } + /> +
+ + {emailOverrideField} + + )} + +
+

{controlStateHint}

+
+ + + + +
+
+ + {compact ? ( +
+
+
+

Run Status

+

当前运行状态

+

{nextStepLabel}

+
+ + {phaseLabel} + +
+ +
+
+

上次更新

+

{formatTimestamp(store.lastUpdated)}

+
+
+

产物模式

+

{saveDaily ? "持久化产物" : "临时预览"}

+
+
+

Judge 覆盖

+

{judgedPapersCount} / {allPapers.length || 0} papers

+
+
+

交付方式

+

{deliveryChannels.length ? deliveryChannels.join(" + ") : "仅手动复核"}

+
+
+ +
+
+ 阶段进度 + {overallProgressValue}% +
+ +
+
+ ) : null} +
+
+ + {!compact ? ( +
+
+
+
+

+ {compact ? "运行状态" : "Run Snapshot"} +

+

+ {compact ? "当前工作流状态" : "Current workflow state"} +

+
+ + {phaseLabel} + +
+
+
+

+ {compact ? "上次更新" : "Last updated"} +

+

{formatTimestamp(store.lastUpdated)}

+
+
+

+ {compact ? "产物模式" : "Output mode"} +

+

+ {saveDaily ? (compact ? "持久化产物" : "Persistent artifacts") : compact ? "临时预览" : "Ephemeral preview"} +

+
+
+

+ {compact ? "Judge 覆盖" : "Judge coverage"} +

+

{judgedPapersCount} / {allPapers.length || 0} papers

+
+
+

+ {compact ? "交付方式" : "Delivery"} +

+

+ {deliveryChannels.length ? deliveryChannels.join(" + ") : compact ? "仅手动复核" : "Manual review only"} +

+
+
+
+
+ {compact ? "阶段进度" : "Stage progress"} + {overallProgressValue}% +
+ +

{nextStepLabel}

+
+
+ +
+

Delivery & Handoff

+

Keep the chain connected

+
+ + {saveDaily ? `Output: ${outputDir}` : "No artifact path"} + + + {deliveryChannels.length ? `${deliveryChannels.length} delivery channel(s)` : "Delivery disabled"} + + {dailyResult?.markdown_path ? ( + + MD ready + + ) : null} +
+
+ + +
+
+ +
+

Workflow Playbook

+

Attach docs and wiki to the operator loop

+

+ A reasonable chain is: topic selection inside Dashboard, concept grounding in Wiki, evidence review in Research, and then delivery from the same run snapshot. +

+
+ + + {WORKFLOW_REFERENCE_LINKS.map((link) => ( + + ))} +
+
+
+ ) : null} +
-
+ + + + Workflow Configuration + Advanced controls for retrieval depth, judge budget, enrichment, and delivery. + +
+ +
+
+
{error &&
{error}
} @@ -1573,50 +2501,119 @@ export default function TopicWorkflowDashboard({ initialQueries }: TopicWorkflow )} - {/* Stats Row */} -
- } /> - } /> - } /> - } /> -
- - {/* DAG (collapsible) */} - - setDagOpen(!dagOpen)}> -
- Workflow DAG -
-
- {Object.entries(dagStatuses).map(([key, status]) => ( -
- ))} + {compact && !hasWorkspaceOutput ? ( + + +
+
+ 结果区 +

先完成检索,再逐步生成报告和分析结果。

+
+
+ + {queries.length} 个主题 + + + {activeSourceLabels.length} 个来源 + + + {activeBranchLabels.length} 个分支 +
- {dagOpen ? : }
-
- - {dagOpen && ( - - + + +
+
+

01 Search

+

先拉起候选池

+

基于当前主题、来源和分支完成首轮检索。

+
+
+

02 DailyPaper

+

整理成日报

+

把候选变成可读报告和可保存产物。

+
+
+

03 Analyze

+

补齐 Judge 与洞察

+

在结果值得保留时再运行更重的分析步骤。

+
+
+ +
+ {nextStepLabel} +
- )} - + + ) : ( + <> + {/* Stats Row */} +
+ } + /> + } + /> + } + /> + } + /> +
- {/* Result Tabs */} - - - Papers - Insights - Judge - Report - + {/* DAG (collapsible) */} + + setDagOpen(!dagOpen)}> +
+ {compact ? "流程图" : "Workflow DAG"} +
+
+ {Object.entries(dagStatuses).map(([key, status]) => ( +
+ ))} +
+ {dagOpen ? : } +
+
+ + {dagOpen && ( + + + + )} + + + {/* Result Tabs */} + setWorkspaceTab(value as WorkflowWorkspaceTab)} className="w-full"> + + {compact ? "候选" : "Candidates"} + {compact ? "洞察" : "Insights"} + {compact ? "Judge" : "Judge"} + {compact ? "报告" : "Report"} + {compact ? "交付" : "Delivery"} + {compact ? "日志" : "Logs"} + {/* Papers */} - +
-

{allPapers.length} papers

+

{allPapers.length} {compact ? "篇结果" : "papers"}

{paperDataSource && ( {paperDataSource === "dailypaper" ? "DailyPaper" : "Search"} @@ -1624,10 +2621,10 @@ export default function TopicWorkflowDashboard({ initialQueries }: TopicWorkflow )}
- +
@@ -1650,7 +2647,9 @@ export default function TopicWorkflowDashboard({ initialQueries }: TopicWorkflow )) ) : (
- Run Search to find papers, then DailyPaper to rank and compose a report, then Analyze to run Judge/Trends. + {compact + ? "先运行 Search 建立候选池,再用 DailyPaper 生成报告,最后用 Analyze 补齐 Judge 和洞察。" + : "Run Search to find papers, then DailyPaper to rank and compose a report, then Analyze to run Judge/Trends."}
)}
@@ -1773,7 +2772,7 @@ export default function TopicWorkflowDashboard({ initialQueries }: TopicWorkflow {/* Report (Structured) */} - + {dailyResult?.report ? ( <> @@ -1886,51 +2885,6 @@ export default function TopicWorkflowDashboard({ initialQueries }: TopicWorkflow
- - -
- Repository Enrichment - -
-
- - {repoError &&
{repoError}
} - {repoRows.length > 0 ? ( - - - - - - - - - - - - {repoRows.map((row, idx) => ( - - - - - - - ))} - -
TitleRepositoryStarsLanguage
- {row.paper_url ? {row.title} : row.title} - - {row.repo_url} - {row.github?.stars ?? "-"}{row.github?.language || "-"}
-
- ) : ( -
Click "Find Repos" to enrich papers with code repositories.
- )} -
-
- ) : isLoading ? ( @@ -1944,7 +2898,129 @@ export default function TopicWorkflowDashboard({ initialQueries }: TopicWorkflow
Generate a DailyPaper to see the rendered report.
)} + + + + + Delivery Readiness + + +
+
+

Artifacts

+

{saveDaily ? outputDir : "Not persisted"}

+
+
+

Channels

+

{deliveryChannels.length ? deliveryChannels.join(" + ") : "Manual review"}

+
+
+

Research Handoff

+

{dashboardContext?.activeTrackName || "Global queue"}

+
+
+
+ {dailyResult?.markdown_path ? MD: {dailyResult.markdown_path} : null} + {dailyResult?.json_path ? JSON: {dailyResult.json_path} : null} + {notifyEnabled && notifyEmail.trim() ? Email override: {notifyEmail.trim()} : null} +
+
+ + +
+
+
+ + + +
+ Repository Enrichment + +
+
+ + {repoError &&
{repoError}
} + {repoRows.length > 0 ? ( + + + + + + + + + + + + {repoRows.map((row, idx) => ( + + + + + + + ))} + +
TitleRepositoryStarsLanguage
+ {row.paper_url ? {row.title} : row.title} + + {row.repo_url} + {row.github?.stars ?? "-"}{row.github?.language || "-"}
+
+ ) : ( +
Click "Find Repos" to enrich papers with code repositories.
+ )} +
+
+
+ + + + + Analyze Stream Log + + + +
+ {analyzeLog.length > 0 ? analyzeLog.map((line, index) =>
{line}
) : ( +
No analyze log yet. Run Analyze to stream judge and insight events.
+ )} +
+
+
+
+ + + + DailyPaper Stream Log + + + +
+ {streamLog.length > 0 ? streamLog.map((line, index) =>
{line}
) : ( +
No DailyPaper stream log yet. Generate a report to inspect the stream phases.
+ )} +
+
+
+
+
+ + )}
) } diff --git a/web/src/lib/dashboard-api.ts b/web/src/lib/dashboard-api.ts index f37571ba..7054cb84 100644 --- a/web/src/lib/dashboard-api.ts +++ b/web/src/lib/dashboard-api.ts @@ -1,11 +1,21 @@ import type { Activity, AnchorPreviewItem, + IntelligenceFeedResponse, ReadingQueueItem, ResearchTrackSummary, TrackFeedItem, } from "./types" +export interface IntelligenceFeedFilters { + source?: string + keyword?: string + repo?: string + sortBy?: string + sortOrder?: string + trackId?: number +} + const API_BASE_URL = (process.env.PAPERBOT_API_BASE_URL || "http://127.0.0.1:8000") + "/api" function formatDateLabel(value?: string | null): string { @@ -102,6 +112,33 @@ export async function fetchDashboardReadingQueue( }) } +export async function fetchIntelligenceFeed( + userId: string = "default", + limit: number = 6, + filters?: IntelligenceFeedFilters, +): Promise { + const qs = new URLSearchParams({ + user_id: userId, + limit: String(limit), + }) + if (filters?.source) qs.set("source", filters.source) + if (filters?.keyword) qs.set("keyword", filters.keyword) + if (filters?.repo) qs.set("repo", filters.repo) + if (filters?.sortBy) qs.set("sort_by", filters.sortBy) + if (filters?.sortOrder) qs.set("sort_order", filters.sortOrder) + if (filters?.trackId) qs.set("track_id", String(filters.trackId)) + + const payload = await fetchJsonOrNull(`/intelligence/feed?${qs.toString()}`) + return payload || { + items: [], + refreshed_at: null, + refresh_scheduled: false, + keywords: [], + watch_repos: [], + subreddits: [], + } +} + export async function fetchDashboardActivities(userId: string = "default"): Promise { const [runsPayload, savedPayload] = await Promise.all([ fetchJsonOrNull<{ diff --git a/web/src/lib/dashboard-intelligence.test.ts b/web/src/lib/dashboard-intelligence.test.ts new file mode 100644 index 00000000..5729e87b --- /dev/null +++ b/web/src/lib/dashboard-intelligence.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest" + +import { buildDashboardIntelligenceCards } from "./dashboard-intelligence" +import type { IntelligenceFeedItem } from "./types" + +describe("dashboard-intelligence", () => { + it("maps external radar signals into dashboard cards", () => { + const items: IntelligenceFeedItem[] = [ + { + id: "reddit:keyword:rag", + source: "reddit", + source_label: "Reddit Search", + kind: "keyword_spike", + title: "Reddit spike: rag", + summary: "24h mentions: 12 across r/MachineLearning. Top post: RAG agents are back.", + url: "https://reddit.example/rag", + keyword_hits: ["rag"], + author_matches: ["Alice Zhang"], + repo_matches: ["org/rag-agent"], + match_reasons: ["keyword: rag", "delta: +5", "author: Alice Zhang", "repo: org/rag-agent"], + score: 92, + metric: { + name: "mentions/24h", + value: 12, + delta: 5, + }, + published_at: "2026-03-10T10:00:00+00:00", + detected_at: "2026-03-10T11:00:00+00:00", + matched_tracks: [ + { + track_id: 7, + track_name: "RAG Agents", + matched_keywords: ["rag"], + }, + ], + research_query: "rag, org/rag-agent", + payload: {}, + }, + ] + + const [card] = buildDashboardIntelligenceCards(items) + + expect(card.title).toBe("Reddit spike: rag") + expect(card.metricLabel).toBe("mentions/24h 12 (+5)") + expect(card.reasonChips).toEqual([ + "keyword: rag", + "delta: +5", + "author: Alice Zhang", + "repo: org/rag-agent", + ]) + expect(card.href).toBe("https://reddit.example/rag") + expect(card.isExternal).toBe(true) + expect(card.researchHref).toBe("/research?track_id=7&query=rag%2C+org%2Frag-agent") + expect(card.matchedTrackNames).toEqual(["RAG Agents"]) + }) + + it("falls back to synthesized reason chips and empty state copy", () => { + const items: IntelligenceFeedItem[] = [ + { + id: "hf:1", + source: "huggingface", + source_label: "HF Daily Papers", + kind: "paper_buzz", + title: "A paper", + summary: "Signal summary", + keyword_hits: ["agents"], + author_matches: ["Jane Doe"], + repo_matches: ["paperbot/paperbot"], + match_reasons: [], + score: 70, + metric: { + name: "upvotes", + value: 9, + delta: 0, + }, + matched_tracks: [], + research_query: "", + payload: {}, + }, + ] + + const [card] = buildDashboardIntelligenceCards(items) + const [emptyCard] = buildDashboardIntelligenceCards([]) + + expect(card.reasonChips).toEqual([ + "keyword: agents", + "author: Jane Doe", + "repo: paperbot/paperbot", + ]) + expect(emptyCard.title).toBe("No urgent community signals") + expect(emptyCard.metricLabel).toBe("stable") + expect(emptyCard.researchHref).toBe("/research") + }) +}) \ No newline at end of file diff --git a/web/src/lib/dashboard-intelligence.ts b/web/src/lib/dashboard-intelligence.ts new file mode 100644 index 00000000..c9fe9121 --- /dev/null +++ b/web/src/lib/dashboard-intelligence.ts @@ -0,0 +1,121 @@ +import type { IntelligenceFeedItem } from "./types" + +export interface DashboardIntelligenceCard { + id: string + source: string + sourceLabel: string + title: string + summary: string + href: string + isExternal: boolean + researchHref: string + researchLabel: string + metricLabel: string + reasonChips: string[] + matchedTrackNames: string[] + timestamp?: string | null +} + +function buildMetricLabel(item: IntelligenceFeedItem): string { + const metricName = String(item.metric?.name || "").trim() + const metricValue = Number(item.metric?.value || 0) + const metricDelta = Number(item.metric?.delta || 0) + + if (!metricName) { + return `score ${Math.round(Number(item.score || 0))}` + } + + const deltaSuffix = metricDelta > 0 ? ` (+${metricDelta})` : metricDelta < 0 ? ` (${metricDelta})` : "" + return `${metricName} ${metricValue}${deltaSuffix}` +} + +function buildReasonChips(item: IntelligenceFeedItem): string[] { + const directReasons = (item.match_reasons || []).map((reason) => String(reason).trim()).filter(Boolean) + if (directReasons.length > 0) { + return directReasons.slice(0, 4) + } + + const fallbackReasons = [ + ...(item.keyword_hits || []).map((keyword) => `keyword: ${keyword}`), + ...(Number(item.metric?.delta || 0) > 0 ? [`delta: +${Number(item.metric?.delta || 0)}`] : []), + ...(item.author_matches || []).map((author) => `author: ${author}`), + ...(item.repo_matches || []).map((repo) => `repo: ${repo}`), + ] + + return fallbackReasons.slice(0, 4) +} + +function buildSignalHref(item: IntelligenceFeedItem): string { + const url = String(item.url || "").trim() + if (url) { + return url + } + + const repo = String(item.repo_full_name || "").trim() + if (repo) { + return `https://github.com/${repo}` + } + + return "/research" +} + +function buildResearchHref(item: IntelligenceFeedItem): string { + const params = new URLSearchParams() + const firstTrack = (item.matched_tracks || [])[0] + if (firstTrack?.track_id) { + params.set("track_id", String(firstTrack.track_id)) + } + + const query = String(item.research_query || "").trim() + if (query) { + params.set("query", query) + } + + const search = params.toString() + return search ? `/research?${search}` : "/research" +} + +export function buildDashboardIntelligenceCards( + items: IntelligenceFeedItem[], +): DashboardIntelligenceCard[] { + if (items.length === 0) { + return [ + { + id: "empty-intelligence", + source: "signal", + sourceLabel: "Community Radar", + title: "No urgent community signals", + summary: + "Keyword hits, trend deltas, and author or repository linkages are calm across the watched third-party sources.", + href: "/research", + isExternal: false, + researchHref: "/research", + researchLabel: "进入 Research", + metricLabel: "stable", + reasonChips: [], + matchedTrackNames: [], + timestamp: null, + }, + ] + } + + return items.slice(0, 3).map((item) => { + const href = buildSignalHref(item) + const firstTrack = (item.matched_tracks || [])[0] + return { + id: item.id, + source: String(item.source || "signal"), + sourceLabel: String(item.source_label || "Community Radar"), + title: String(item.title || item.source_label || "Community signal"), + summary: String(item.summary || "Third-party source reported a new community signal."), + href, + isExternal: /^https?:\/\//.test(href), + researchHref: buildResearchHref(item), + researchLabel: firstTrack?.track_name ? `转到 ${firstTrack.track_name}` : "进入 Research", + metricLabel: buildMetricLabel(item), + reasonChips: buildReasonChips(item), + matchedTrackNames: (item.matched_tracks || []).map((track) => String(track.track_name || "")).filter(Boolean), + timestamp: item.detected_at || item.published_at || null, + } + }) +} \ No newline at end of file diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 97bb9697..b31b1208 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -167,6 +167,48 @@ export interface Activity { } } +export interface IntelligenceMatchedTrack { + track_id: number + track_name: string + matched_keywords: string[] +} + +export interface IntelligenceFeedItem { + id: string + source: "reddit" | "github" | "huggingface" | "twitter_x" | string + source_label: string + kind: string + title: string + summary: string + url?: string + repo_full_name?: string + author_name?: string + keyword_hits: string[] + author_matches: string[] + repo_matches: string[] + match_reasons: string[] + score: number + metric: { + name: string + value: number + delta: number + } + published_at?: string | null + detected_at?: string | null + matched_tracks: IntelligenceMatchedTrack[] + research_query?: string + payload?: Record +} + +export interface IntelligenceFeedResponse { + items: IntelligenceFeedItem[] + refreshed_at?: string | null + refresh_scheduled?: boolean + keywords: string[] + watch_repos: string[] + subreddits: string[] +} + export interface PipelineTask { id: string paper_title: string @@ -199,6 +241,7 @@ export interface DeadlineRadarItem { track_id: number track_name: string matched_keywords: string[] + matched_terms?: string[] }> }