refactor(desktop,gateway): 用户端去重 —— 一个功能一个入口,一份数据一个查法

用户反馈「UI 太乱」。数出来的:report/kb/memory 各 4 个入口、studio 5 个;
运行历史两套渲染。乱的机制是同一件事写了两遍,改一处另一处必然掉队。

**一份数据一个查法**
- 工作台「最近任务」和运行页「运行历史」是同一批数据,却各查各的:
  前者走 store.RecentTasks(),后者走 RecentRuns()。于是我给运行历史加的
  topic 字段工作台完全没跟上,还在 mono 显示 report_<hex>;而且 RecentTasks
  没有租户过滤,口径也不一致。现在统一走 RecentRuns,RecentTasks 删掉
  (它只有那一个调用点)。前端 recent_runs 也改用 RunSummary 同一个类型。

**一个功能一个入口**
- 工作台删掉「入库知识/生成报告/管理记忆」按钮排 —— 跳的目标和上面四张
  能力卡片完全重合,同一页给两个入口。
- 「最近任务」现在点击直达运行页对应那条(App 加 goto(view, taskId) →
  RunsView focusTaskId),而不是只把页面切过去让用户自己再找一遍。

**报告归 RUN,只做启动器**
- 报告是「执行」不是「构建」,从 BUILD 组移到 RUN。
- 报告页砍掉「执行轨迹」「报告正文」两个面板 —— 和运行页完全重复,而且
  那套是组件本地 state,切页面就没。现在它只有主题输入框。
- 报告改走 App 的全局运行态(新增 onRunReport,与 onRun 同构、复用 attachRun):
  提交 → 跳「运行 · 观测」→ 实时看轨迹/正文/导出。App 里 onRun 早就写着
  「发起即跳运行页,观测统一收敛在此页」——这条方针一直在,只有报告没遵守。

**顺带清死代码**
- 市场(规划中)从导航下架,ViewKey 里的 "market" 和 Boxes 图标一并删。
- PLACEHOLDERS 整块死代码:它列的 home/kb/report/runs 全都早已真实实现,
  market 是唯一还可能用到的、下架后那个 Placeholder 分支永远走不到
  (tsc 报 "Spread types" 就是因为 PLACEHOLDERS[view] 已成 never)。
  连同 views/Placeholder.tsx 一起删。

live 验证(真账号,桌面端实机):报告页输入主题 → 提交 → 自动跳运行页 →
顶部「当前运行 · live」→ 轨迹实时跑 → 报告正文流式出 + 导出按钮在位 →
report_7a0110a10b534f90 落库带 topic「归口测试」,POST /api/v1/reports → 202。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-16 14:08:16 +08:00
parent fd99ba6226
commit 759483bf92
9 changed files with 110 additions and 212 deletions
+14 -15
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { Workflow, Database, Bookmark, FileText, Plus, Upload, Activity, Coins, Gauge, ArrowRight, ArrowUpRight, type LucideIcon } from "lucide-react";
import { statsOverview, type Overview, type RecentRun } from "../lib/api";
import { Workflow, Database, Bookmark, FileText, Plus, Activity, Coins, Gauge, ArrowRight, ArrowUpRight, type LucideIcon } from "lucide-react";
import { statsOverview, type Overview, type RunSummary } from "../lib/api";
import type { ViewKey } from "../shell/LeftNav";
import { Button, cn } from "../ui";
@@ -61,15 +61,19 @@ function relTime(iso: string): string {
return `${Math.floor(d / 86_400_000)} 天前`;
}
function RunRow({ r }: { r: RecentRun }) {
// 一条最近运行。点击直达「运行」页对应那条 —— 光切页面还得让用户自己再找一遍。
// 有主题(报告类)就显示主题;否则退回 task_idreport_<hex> 这种 id 对用户没有任何意义。
function RunRow({ r, onOpen }: { r: RunSummary; onOpen: () => void }) {
const s = STATUS_LABEL[r.status] ?? { text: r.status, cls: "text-slate-500" };
return (
<div className="flex items-center gap-3 rounded-lg px-2 py-2 transition-colors hover:bg-ink-850">
<button onClick={onOpen} className="flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left transition-colors hover:bg-ink-850">
<span className={cn("h-1.5 w-1.5 flex-none rounded-full", s.cls.replace("text-", "bg-"))} />
<span className="flex-1 truncate font-mono text-xs text-slate-400">{r.task_id}</span>
<span className={cn("flex-1 truncate text-xs", r.topic ? "text-slate-300" : "font-mono text-slate-400")}>
{r.topic || r.task_id}
</span>
<span className={cn("text-[11px]", s.cls)}>{s.text}</span>
<span className="w-16 text-right text-[11px] text-slate-600">{relTime(r.at)}</span>
</div>
</button>
);
}
@@ -79,7 +83,7 @@ function greet(): string {
}
// 工作台:商业级门面 —— 问候 + 主行动 + 关键指标 + 最近任务 + 快速开始(去基建、去黑话)。
export function Home({ onSelect, userName, spaceName }: { onSelect: (v: ViewKey) => void; userName?: string; spaceName?: string }) {
export function Home({ onSelect, userName, spaceName }: { onSelect: (v: ViewKey, taskId?: string) => void; userName?: string; spaceName?: string }) {
const [ov, setOv] = useState<Overview | null>(null);
useEffect(() => {
let alive = true;
@@ -132,7 +136,7 @@ export function Home({ onSelect, userName, spaceName }: { onSelect: (v: ViewKey)
)}
</div>
{hasRuns ? (
<div className="mt-1">{ov!.recent_runs.map((r) => <RunRow key={r.task_id} r={r} />)}</div>
<div className="mt-1">{ov!.recent_runs.map((r) => <RunRow key={r.task_id} r={r} onOpen={() => onSelect("runs", r.task_id)} />)}</div>
) : (
<div className="flex flex-col items-center gap-3 py-10 text-center">
<div className="text-sm text-slate-400"></div>
@@ -175,13 +179,8 @@ export function Home({ onSelect, userName, spaceName }: { onSelect: (v: ViewKey)
})}
</div>
</div>
{/* 次要动作 */}
<div className="mt-4 flex flex-wrap gap-2">
<Button size="sm" icon={Upload} onClick={() => onSelect("kb")}></Button>
<Button size="sm" icon={FileText} onClick={() => onSelect("report")}></Button>
<Button size="sm" icon={Bookmark} onClick={() => onSelect("memory")}></Button>
</div>
{/* 原本这里还有一排「入库知识/生成报告/管理记忆」按钮,跳的目标和上面四张卡片
完全重合 —— 同一页里同一个功能给两个入口,纯冗余,已删。 */}
</div>
</div>
);
@@ -1,20 +0,0 @@
import { Hammer } from "lucide-react";
import { Badge } from "../ui";
// 规划中模块占位 —— 深色,露出信息架构与定位。
export function Placeholder({ title, desc }: { title: string; desc: string }) {
return (
<div className="flex h-full items-center justify-center p-8">
<div className="max-w-md rounded-lg border border-dashed border-line bg-ink-900 p-8 text-center">
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-xl border border-line bg-ink-850 text-slate-500">
<Hammer className="h-6 w-6" strokeWidth={1.5} />
</div>
<div className="mb-2 text-base font-medium text-slate-200">{title}</div>
<p className="text-sm leading-relaxed text-slate-500">{desc}</p>
<div className="mt-4">
<Badge tone="warn"></Badge>
</div>
</div>
</div>
);
}
@@ -1,151 +1,60 @@
import { useRef, useState } from "react";
import { Play, FileText, FileType2, Printer, FileCode } from "lucide-react";
import { generateReport, streamTokens, streamExec, reportExportUrl, reportFilename, type Identity, type ExecEvent } from "../lib/api";
import { saveReportAs, printReportHtml, notify } from "../lib/desktop";
import { ExecTrace } from "../components/ExecTrace";
import { Markdown } from "../components/Markdown";
import { Button, Input, Field, Panel, Dot, EmptyState, useToast } from "../ui";
import { useState } from "react";
import { FileText, Play } from "lucide-react";
import { Button, Panel } from "../ui";
type Phase = "idle" | "running" | "done" | "error";
// 报告生成:输入主题(+可选知识库) → 触发后端专用编排
// (规划大纲 → 各章并行检索+撰写 → 渲染 Word),实时看进度与正文,完成后下载 .docx
export function ReportView({ identity }: { identity: Identity }) {
const toast = useToast();
// 报告生成 —— 只做启动器:输入主题 → 提交 → 自动跳「运行 · 观测」实时看。
//
// 这里曾经自己维护一整套:SSE 订阅、token 流、执行轨迹、导出按钮、PDF 打印,
// 页面上还挂着「执行轨迹」和「报告正文」两个面板 —— 和运行页完全重复
// 而且那套 state 全是组件本地 useState:切个页面就灰飞烟灭,报告明明在后端
// 好好地跑完了,用户却永远找不回它。
// 现在轨迹/正文/导出统一由运行页负责(那份是持久的、能复盘、能导出)。
export function ReportView({ onRunReport, running }: { onRunReport: (topic: string, kb?: string) => void; running: boolean }) {
const [topic, setTopic] = useState("");
const [kb, setKb] = useState("");
const [phase, setPhase] = useState<Phase>("idle");
const [out, setOut] = useState("");
const [exec, setExec] = useState<ExecEvent[]>([]);
const [taskId, setTaskId] = useState("");
const closeRef = useRef<(() => void) | null>(null);
const execCloseRef = useRef<(() => void) | null>(null);
const previewRef = useRef<HTMLDivElement>(null);
const running = phase === "running";
// 导出 Word / Markdown:后端按需现渲染(导出时再处理),经原生"另存为"或浏览器下载。
const exportFile = async (format: "docx" | "md") => {
try {
const p = await saveReportAs(reportExportUrl(taskId, format), reportFilename(topic, taskId, format));
if (p) toast.push("success", "已保存到 " + p);
} catch (e) {
toast.push("error", (e as Error).message);
}
};
// 导出 PDF:把预览到的 Markdown(已渲染 HTML)送进打印视图出 PDF(CJK 零字体依赖)。
const exportPdf = () => {
const html = previewRef.current?.innerHTML;
if (!html) {
toast.push("error", "暂无报告正文可导出");
return;
}
if (!printReportHtml(topic.trim() || taskId, html)) {
toast.push("error", "打印窗口被拦截,请允许弹出窗口后重试");
}
};
const onGenerate = async () => {
const submit = () => {
if (!topic.trim() || running) return;
closeRef.current?.();
execCloseRef.current?.();
setPhase("running");
setOut("");
setExec([]);
setTaskId("");
try {
const id = await generateReport(identity, topic.trim(), kb.trim() || undefined);
setTaskId(id);
execCloseRef.current = streamExec(
id,
(ev) => setExec((xs) => [...xs, ev]),
() => {},
() => {},
);
closeRef.current = streamTokens(
id,
(tok) => setOut((o) => o + tok),
() => {
setPhase("done");
toast.push("success", "报告已生成,可保存 Word");
notify("报告已生成", topic.trim());
},
() => {
setPhase("error");
toast.push("error", "报告流连接中断");
},
);
} catch (e) {
setPhase("error");
toast.push("error", (e as Error).message);
}
onRunReport(topic.trim(), kb.trim() || undefined);
};
const tracePhase = running ? "streaming" : phase === "done" ? "done" : phase === "error" ? "error" : "idle";
return (
<div className="flex h-full min-h-0 flex-col gap-4 overflow-hidden p-6">
<header>
<div className="h-full overflow-auto p-6">
<div className="mx-auto max-w-2xl">
<h1 className="text-lg font-semibold text-slate-100"></h1>
<p className="mt-1 text-xs text-slate-500">
+ LLM Word(.docx)
</p>
</header>
<div className="grid grid-cols-[1fr_220px_auto_auto] items-end gap-3 rounded-lg border border-line bg-ink-900 p-4 shadow-card">
<Field label="报告主题">
<Input
value={topic}
onChange={(e) => setTopic(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && onGenerate()}
placeholder="如:2026 年国产大模型产业现状与趋势分析"
/>
</Field>
<Field label="知识库(可选)">
<Input value={kb} onChange={(e) => setKb(e.target.value)} placeholder="如 docs,留空则不挂检索" />
</Field>
<Button variant="primary" icon={Play} onClick={onGenerate} disabled={running || !topic.trim()}>
{running ? "生成中…" : "生成报告"}
</Button>
{phase === "done" && taskId ? (
<div className="flex items-center gap-2">
<span className="text-[11px] text-slate-500"></span>
<Button icon={FileType2} onClick={() => exportFile("docx")}>
Word
</Button>
<Button icon={Printer} onClick={exportPdf}>
PDF
</Button>
<Button variant="ghost" icon={FileCode} onClick={() => exportFile("md")}>
Markdown
</Button>
</div>
) : (
<span className="h-9" />
)}
</div>
<div className="grid min-h-0 flex-1 grid-cols-[340px_1fr] gap-4">
<Panel title="执行轨迹" icon={FileText}>
<ExecTrace events={exec} phase={tracePhase} />
</Panel>
<Panel
title={
<span className="flex items-center gap-2">
<Dot tone={running ? "running" : phase === "done" ? "success" : "neutral"} pulse={running} />
· {taskId || "未开始"}
</span>
}
>
{out ? (
<div ref={previewRef}>
<Markdown text={out} className="text-sm" />
<Panel title="新建报告" icon={FileText} className="mt-5">
<div className="space-y-4 p-1">
<div>
<label className="mb-1.5 block text-xs text-slate-400"></label>
<input
value={topic}
onChange={(e) => setTopic(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && submit()}
placeholder="如:2026 年国产大模型产业现状与趋势分析"
className="w-full rounded-md border border-line bg-ink-850 px-3 py-2 text-sm text-slate-100 outline-none placeholder:text-slate-600 focus:border-brand/50"
/>
</div>
) : (
<EmptyState icon={FileText} title="尚未生成报告" desc="输入主题并点击「生成报告」,这里将实时显示规划与撰写过程。" />
)}
<div>
<label className="mb-1.5 block text-xs text-slate-400"></label>
<input
value={kb}
onChange={(e) => setKb(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && submit()}
placeholder="如 docs,留空则不挂检索"
className="w-full rounded-md border border-line bg-ink-850 px-3 py-2 text-sm text-slate-100 outline-none placeholder:text-slate-600 focus:border-brand/50"
/>
</div>
<div className="flex items-center gap-3 pt-1">
<Button variant="primary" icon={Play} onClick={submit} disabled={running || !topic.trim()}>
{running ? "生成中…" : "生成报告"}
</Button>
<span className="text-[11px] text-slate-600"> Word</span>
</div>
</div>
</Panel>
</div>
</div>
@@ -29,7 +29,7 @@ function relTime(iso: string): string {
// 运行 · 观测:左=运行历史,中=tab 切换(轨迹/工具/评测)+状态,右=模型输出(Markdown)。
// 选中历史运行从 Redis 回放;「当前运行」置顶沿用实时订阅。
export function RunsView({ run }: { run: RunState }) {
export function RunsView({ run, focusTaskId }: { run: RunState; focusTaskId?: string | null }) {
const toast = useToast();
const [runs, setRuns] = useState<RunSummary[]>([]);
const [sel, setSel] = useState<string | null>(null); // null = 当前实时运行
@@ -63,6 +63,11 @@ export function RunsView({ run }: { run: RunState }) {
if (run.taskId) { setSel(null); setEvalRes(null); setTab("trace"); }
}, [run.taskId]);
// 从工作台「最近任务」点进来:直接定位到那一条,别让用户切过来再自己找一遍。
useEffect(() => {
if (focusTaskId) selectRun(focusTaskId);
}, [focusTaskId, selectRun]);
// 当前实时运行的评测:完成后拉一次。
useEffect(() => {
if (!sel && run.taskId && run.phase === "done") taskEval(run.taskId).then(setEvalRes).catch(() => {});