-
{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 (
+
+ {label}
+
+ )
+}
+
+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 (
- Topics
-
- {queryItems.map((q, idx) => (
-
- updateQuery(idx, e.target.value)}
- placeholder="Enter a topic..."
- className="h-8 text-sm"
- />
- {queryItems.length > 1 && (
- removeQuery(idx)}>
-
-
- )}
-
- ))}
-
-
- Add Topic
-
-
-
-
Sources & Branches
setUsePapersCool(Boolean(v))} /> papers.cool
@@ -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()}) }
-
-
-
-
- {loadingSearch ? : } Search
-
-
- {loadingDaily ? : } DailyPaper
-
-
- {loadingAnalyze ? : } Analyze
-
-
-
{ store.clearAll(); setError(null) }}>
-
-
-
-
-
-
-
-
- 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 ? "高级设置" : "Advanced settings"}
+
+
+
+
+
+
+
+
+
{compact ? "主题" : "Topics"}
+
{compact ? "直接在这里维护本轮研究主题。" : "Edit the research questions directly inside the dashboard."}
+
+
+
+ {compact ? "添加主题" : "Add topic"}
+
+
+
+ {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 ? (
+ removeQuery(index)}
+ >
+
+
+ ) : null}
+
+ ))}
+
+
+
+ {compact ? (
+
+
+
+
运行选项摘要
+
+ 默认只显示本轮检索、分析和投递摘要,需要调整时再展开详细配置。
+
+
+
setCompactOptionsOpen((prev) => !prev)}
+ >
+ {showCompactOptions ? : }
+ {showCompactOptions ? "收起选项" : "编辑选项"}
+
+
+
+
+
+
+
检索范围
+
+
{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}
+
+
+ {loadingSearch ? : }
+ Search
+
+
+ {loadingDaily ? : }
+ DailyPaper
+
+
+ {loadingAnalyze ? : }
+ Analyze
+
+
{ store.clearAll(); setError(null) }}
+ >
+
+ {compact ? "清空结果" : null}
+
+
+
+
+ {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
)}
- Sort:
+ {compact ? "排序" : "Sort:"}
setSortBy(e.target.value as "score" | "judge")}>
- Search Score
- Judge Score
+ {compact ? "检索分" : "Search Score"}
+ {compact ? "Judge 分" : "Judge Score"}
@@ -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
-
- {loadingRepos ? : null}
- {loadingRepos ? "Enriching..." : "Find Repos"}
-
-
-
-
- {repoError && {repoError}
}
- {repoRows.length > 0 ? (
-
-
-
-
- Title
- Repository
- Stars
- Language
-
-
-
- {repoRows.map((row, idx) => (
-
-
- {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}
+
+
+
+
+ Open Research
+
+
+
+
+
+ Open Wiki
+
+
+
+
+
+
+
+
+
+
+ Repository Enrichment
+
+ {loadingRepos ? : null}
+ {loadingRepos ? "Enriching..." : "Find Repos"}
+
+
+
+
+ {repoError && {repoError}
}
+ {repoRows.length > 0 ? (
+
+
+
+
+ Title
+ Repository
+ Stars
+ Language
+
+
+
+ {repoRows.map((row, idx) => (
+
+
+ {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[]
}>
}