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:
@@ -12,11 +12,10 @@ import { ReportView } from "./views/ReportView";
|
|||||||
import { RunsView } from "./views/RunsView";
|
import { RunsView } from "./views/RunsView";
|
||||||
import { UsageView } from "./views/UsageView";
|
import { UsageView } from "./views/UsageView";
|
||||||
import { Home } from "./views/Home";
|
import { Home } from "./views/Home";
|
||||||
import { Placeholder } from "./views/Placeholder";
|
|
||||||
import { CommandPalette, type Command } from "./components/CommandPalette";
|
import { CommandPalette, type Command } from "./components/CommandPalette";
|
||||||
import { UpdateBanner } from "./components/UpdateBanner";
|
import { UpdateBanner } from "./components/UpdateBanner";
|
||||||
import { Login } from "./views/Login";
|
import { Login } from "./views/Login";
|
||||||
import { submitTask, streamTokens, streamExec, taskStatus, listRuns, authMe, logout, tenantCurrent, myTenants, switchTenant, spaceCurrent, mySpaces, switchSpace, createSpace, type Identity, type AuthUser, type TenantCtx, type MyTenant, type SpaceCtx, type MySpace } from "./lib/api";
|
import { submitTask, generateReport, streamTokens, streamExec, taskStatus, listRuns, authMe, logout, tenantCurrent, myTenants, switchTenant, spaceCurrent, mySpaces, switchSpace, createSpace, type Identity, type AuthUser, type TenantCtx, type MyTenant, type SpaceCtx, type MySpace } from "./lib/api";
|
||||||
import type { TaskDsl } from "./lib/dsl";
|
import type { TaskDsl } from "./lib/dsl";
|
||||||
import { emptyRun, type RunState } from "./lib/run";
|
import { emptyRun, type RunState } from "./lib/run";
|
||||||
import { ToastProvider } from "./ui";
|
import { ToastProvider } from "./ui";
|
||||||
@@ -31,16 +30,11 @@ function getSessionId(): string {
|
|||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PLACEHOLDERS: Partial<Record<ViewKey, { title: string; desc: string }>> = {
|
|
||||||
home: { title: "工作台", desc: "概览:知识库 / 文档 / 近期运行 / 待办报告 / 配额计费 + 快捷入口。" },
|
|
||||||
kb: { title: "知识库 (RAG)", desc: "入库流水线监控 · 检索调试台(带来源徽标) · 文档/块浏览 · 知识图谱 · 检索评测。依赖 embedding + 入库 worker + 真实混合检索。" },
|
|
||||||
report: { title: "报告生成", desc: "模板库 · 大纲编辑 · 章节并行生成进度 · 实时预览(含引用) · 导出 docx/pdf。依赖 RAG 核心链 + UniOffice。" },
|
|
||||||
runs: { title: "运行 · 观测", desc: "实时执行 · 节点轨迹 · 工具调用 · 运行历史复盘。当前运行结果见底部抽屉。" },
|
|
||||||
market: { title: "市场 · Packs(规划中)", desc: "垂直包(法律/医疗/金融) · Agent 模板 · 开通向导(建租户→入库→注册模板→应用配置)。依赖多租户 + Pack 格式。" },
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [view, setView] = useState<ViewKey>("home");
|
const [view, setView] = useState<ViewKey>("home");
|
||||||
|
// 从工作台「最近任务」点进来时要定位到具体那条运行,而不是只把页面切过去。
|
||||||
|
const [focusRun, setFocusRun] = useState<string | null>(null);
|
||||||
|
const goto = (v: ViewKey, taskId?: string) => { setView(v); setFocusRun(taskId ?? null); };
|
||||||
const [user, setUser] = useState<AuthUser | null>(null);
|
const [user, setUser] = useState<AuthUser | null>(null);
|
||||||
const [tenant, setTenant] = useState<TenantCtx | null>(null);
|
const [tenant, setTenant] = useState<TenantCtx | null>(null);
|
||||||
const [tenants, setTenants] = useState<MyTenant[]>([]);
|
const [tenants, setTenants] = useState<MyTenant[]>([]);
|
||||||
@@ -252,6 +246,34 @@ export default function App() {
|
|||||||
[identity, attachRun],
|
[identity, attachRun],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 报告生成走和编排任务同一条路:灌进全局运行态 + 跳「运行 · 观测」。
|
||||||
|
// 此前报告页自己维护一套 SSE/输出/轨迹本地 state,于是运行页看不到它、
|
||||||
|
// 切个页面这套 state 就没了 —— 报告明明在后端跑完了却永远找不回。
|
||||||
|
const onRunReport = useCallback(
|
||||||
|
async (topic: string, kb?: string) => {
|
||||||
|
closeRef.current?.();
|
||||||
|
execCloseRef.current?.();
|
||||||
|
stopPoll();
|
||||||
|
const t0 = Date.now();
|
||||||
|
setRun({ phase: "submitting", output: "", events: [{ t: 0, label: "提交报告任务" }], exec: [] });
|
||||||
|
setFocusRun(null);
|
||||||
|
setView("runs");
|
||||||
|
try {
|
||||||
|
const taskId = await generateReport(identity, topic, kb);
|
||||||
|
setRun((r) => ({
|
||||||
|
...r,
|
||||||
|
phase: "streaming",
|
||||||
|
taskId,
|
||||||
|
events: [...r.events, { t: Date.now() - t0, label: `已发布 ${taskId}` }],
|
||||||
|
}));
|
||||||
|
attachRun(taskId, t0);
|
||||||
|
} catch (e) {
|
||||||
|
setRun((r) => ({ ...r, phase: "error", error: (e as Error).message }));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[identity, attachRun],
|
||||||
|
);
|
||||||
|
|
||||||
// 恢复在途待审任务:登录后若存在 waiting 任务且当前无 live run,挂回它 → 全局审批条重现,
|
// 恢复在途待审任务:登录后若存在 waiting 任务且当前无 live run,挂回它 → 全局审批条重现,
|
||||||
// 用户刷新页面/重开 app 也能继续批准(HITL 持久化中断后审批可跨重启、可等数小时)。
|
// 用户刷新页面/重开 app 也能继续批准(HITL 持久化中断后审批可跨重启、可等数小时)。
|
||||||
const restoredRef = useRef(false);
|
const restoredRef = useRef(false);
|
||||||
@@ -303,22 +325,20 @@ export default function App() {
|
|||||||
<LeftNav active={view} onSelect={setView} />
|
<LeftNav active={view} onSelect={setView} />
|
||||||
<main className="min-w-0 flex-1 overflow-hidden">
|
<main className="min-w-0 flex-1 overflow-hidden">
|
||||||
{view === "home" ? (
|
{view === "home" ? (
|
||||||
<Home onSelect={setView} userName={user.name || user.email} spaceName={space?.space?.name} />
|
<Home onSelect={goto} userName={user.name || user.email} spaceName={space?.space?.name} />
|
||||||
) : view === "studio" ? (
|
) : view === "studio" ? (
|
||||||
<StudioView onRun={onRun} phase={run.phase} identity={identity} readOnly={tenant?.role === "viewer"} spaceId={space?.space?.id ?? ""} spaceReadOnly={space?.role === "viewer"} />
|
<StudioView onRun={onRun} phase={run.phase} identity={identity} readOnly={tenant?.role === "viewer"} spaceId={space?.space?.id ?? ""} spaceReadOnly={space?.role === "viewer"} />
|
||||||
) : view === "kb" ? (
|
) : view === "kb" ? (
|
||||||
<KbView identity={identity} spaceId={space?.space?.id ?? ""} spaceReadOnly={space?.role === "viewer"} />
|
<KbView identity={identity} spaceId={space?.space?.id ?? ""} spaceReadOnly={space?.role === "viewer"} />
|
||||||
) : view === "report" ? (
|
) : view === "report" ? (
|
||||||
<ReportView identity={identity} />
|
<ReportView onRunReport={onRunReport} running={run.phase === "submitting" || run.phase === "streaming"} />
|
||||||
) : view === "runs" ? (
|
) : view === "runs" ? (
|
||||||
<RunsView run={run} />
|
<RunsView run={run} focusTaskId={focusRun} />
|
||||||
) : view === "memory" ? (
|
) : view === "memory" ? (
|
||||||
<MemoryView identity={identity} />
|
<MemoryView identity={identity} />
|
||||||
) : view === "usage" ? (
|
) : view === "usage" ? (
|
||||||
<UsageView />
|
<UsageView />
|
||||||
) : (
|
) : null}
|
||||||
<Placeholder {...(PLACEHOLDERS[view] ?? { title: "模块", desc: "规划中。" })} />
|
|
||||||
)}
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<CommandPalette open={cmdOpen} onClose={() => setCmdOpen(false)} commands={commands} />
|
<CommandPalette open={cmdOpen} onClose={() => setCmdOpen(false)} commands={commands} />
|
||||||
|
|||||||
@@ -648,7 +648,6 @@ export async function deleteMemory(id: Identity, key: string): Promise<void> {
|
|||||||
|
|
||||||
// ── 工作台仪表盘聚合(GET /stats/overview)──
|
// ── 工作台仪表盘聚合(GET /stats/overview)──
|
||||||
export interface KeyCount { key: string; count: number }
|
export interface KeyCount { key: string; count: number }
|
||||||
export interface RecentRun { task_id: string; status: string; detail: string; at: string }
|
|
||||||
export interface Overview {
|
export interface Overview {
|
||||||
tasks_today: number;
|
tasks_today: number;
|
||||||
tasks_total: number;
|
tasks_total: number;
|
||||||
@@ -662,7 +661,7 @@ export interface Overview {
|
|||||||
kb_count: number;
|
kb_count: number;
|
||||||
tokens_today: number;
|
tokens_today: number;
|
||||||
daily_budget: number;
|
daily_budget: number;
|
||||||
recent_runs: RecentRun[];
|
recent_runs: RunSummary[]; // 与「运行」页共用类型,后端也共用 RecentRuns
|
||||||
services: Record<string, boolean>;
|
services: Record<string, boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "../ui";
|
import { cn } from "../ui";
|
||||||
|
|
||||||
export type ViewKey = "home" | "studio" | "kb" | "report" | "runs" | "memory" | "usage" | "market";
|
export type ViewKey = "home" | "studio" | "kb" | "report" | "runs" | "memory" | "usage";
|
||||||
|
|
||||||
interface Item {
|
interface Item {
|
||||||
key: ViewKey;
|
key: ViewKey;
|
||||||
@@ -25,11 +25,11 @@ const ITEMS: Item[] = [
|
|||||||
{ key: "home", label: "工作台", icon: LayoutDashboard, ready: true },
|
{ key: "home", label: "工作台", icon: LayoutDashboard, ready: true },
|
||||||
{ key: "studio", label: "编排", icon: Workflow, group: "BUILD", ready: true },
|
{ key: "studio", label: "编排", icon: Workflow, group: "BUILD", ready: true },
|
||||||
{ key: "kb", label: "知识库", icon: Database, group: "BUILD", ready: true },
|
{ key: "kb", label: "知识库", icon: Database, group: "BUILD", ready: true },
|
||||||
{ key: "report", label: "报告", icon: FileText, group: "BUILD", ready: true },
|
|
||||||
{ key: "runs", label: "运行", icon: Activity, group: "RUN", ready: true },
|
{ key: "runs", label: "运行", icon: Activity, group: "RUN", ready: true },
|
||||||
|
// 报告是「执行」不是「构建」:它归 RUN。放 BUILD 里跟编排/知识库并列会让人以为它是个要搭的东西。
|
||||||
|
{ key: "report", label: "报告", icon: FileText, group: "RUN", ready: true },
|
||||||
{ key: "memory", label: "记忆", icon: Bookmark, group: "MANAGE", ready: true },
|
{ key: "memory", label: "记忆", icon: Bookmark, group: "MANAGE", ready: true },
|
||||||
{ key: "usage", label: "用量", icon: Coins, group: "MANAGE", ready: true },
|
{ key: "usage", label: "用量", icon: Coins, group: "MANAGE", ready: true },
|
||||||
{ key: "market", label: "市场", icon: Boxes, group: "MANAGE", ready: false },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// 左导航:深色,激活态紫色高亮 + 左侧光条,描线图标。
|
// 左导航:深色,激活态紫色高亮 + 左侧光条,描线图标。
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Workflow, Database, Bookmark, FileText, Plus, Upload, Activity, Coins, Gauge, ArrowRight, ArrowUpRight, type LucideIcon } from "lucide-react";
|
import { Workflow, Database, Bookmark, FileText, Plus, Activity, Coins, Gauge, ArrowRight, ArrowUpRight, type LucideIcon } from "lucide-react";
|
||||||
import { statsOverview, type Overview, type RecentRun } from "../lib/api";
|
import { statsOverview, type Overview, type RunSummary } from "../lib/api";
|
||||||
import type { ViewKey } from "../shell/LeftNav";
|
import type { ViewKey } from "../shell/LeftNav";
|
||||||
import { Button, cn } from "../ui";
|
import { Button, cn } from "../ui";
|
||||||
|
|
||||||
@@ -61,15 +61,19 @@ function relTime(iso: string): string {
|
|||||||
return `${Math.floor(d / 86_400_000)} 天前`;
|
return `${Math.floor(d / 86_400_000)} 天前`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function RunRow({ r }: { r: RecentRun }) {
|
// 一条最近运行。点击直达「运行」页对应那条 —— 光切页面还得让用户自己再找一遍。
|
||||||
|
// 有主题(报告类)就显示主题;否则退回 task_id:report_<hex> 这种 id 对用户没有任何意义。
|
||||||
|
function RunRow({ r, onOpen }: { r: RunSummary; onOpen: () => void }) {
|
||||||
const s = STATUS_LABEL[r.status] ?? { text: r.status, cls: "text-slate-500" };
|
const s = STATUS_LABEL[r.status] ?? { text: r.status, cls: "text-slate-500" };
|
||||||
return (
|
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={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={cn("text-[11px]", s.cls)}>{s.text}</span>
|
||||||
<span className="w-16 text-right text-[11px] text-slate-600">{relTime(r.at)}</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);
|
const [ov, setOv] = useState<Overview | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
@@ -132,7 +136,7 @@ export function Home({ onSelect, userName, spaceName }: { onSelect: (v: ViewKey)
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{hasRuns ? (
|
{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="flex flex-col items-center gap-3 py-10 text-center">
|
||||||
<div className="text-sm text-slate-400">还没有运行记录</div>
|
<div className="text-sm text-slate-400">还没有运行记录</div>
|
||||||
@@ -175,13 +179,8 @@ export function Home({ onSelect, userName, spaceName }: { onSelect: (v: ViewKey)
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</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 { useState } from "react";
|
||||||
import { Play, FileText, FileType2, Printer, FileCode } from "lucide-react";
|
import { FileText, Play } from "lucide-react";
|
||||||
import { generateReport, streamTokens, streamExec, reportExportUrl, reportFilename, type Identity, type ExecEvent } from "../lib/api";
|
import { Button, Panel } from "../ui";
|
||||||
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";
|
|
||||||
|
|
||||||
type Phase = "idle" | "running" | "done" | "error";
|
// 报告生成 —— 只做启动器:输入主题 → 提交 → 自动跳「运行 · 观测」实时看。
|
||||||
|
//
|
||||||
// 报告生成:输入主题(+可选知识库) → 触发后端专用编排
|
// 这里曾经自己维护一整套:SSE 订阅、token 流、执行轨迹、导出按钮、PDF 打印,
|
||||||
// (规划大纲 → 各章并行检索+撰写 → 渲染 Word),实时看进度与正文,完成后下载 .docx。
|
// 页面上还挂着「执行轨迹」和「报告正文」两个面板 —— 和运行页完全重复。
|
||||||
export function ReportView({ identity }: { identity: Identity }) {
|
// 而且那套 state 全是组件本地 useState:切个页面就灰飞烟灭,报告明明在后端
|
||||||
const toast = useToast();
|
// 好好地跑完了,用户却永远找不回它。
|
||||||
|
// 现在轨迹/正文/导出统一由运行页负责(那份是持久的、能复盘、能导出)。
|
||||||
|
export function ReportView({ onRunReport, running }: { onRunReport: (topic: string, kb?: string) => void; running: boolean }) {
|
||||||
const [topic, setTopic] = useState("");
|
const [topic, setTopic] = useState("");
|
||||||
const [kb, setKb] = 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";
|
const submit = () => {
|
||||||
|
|
||||||
// 导出 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 () => {
|
|
||||||
if (!topic.trim() || running) return;
|
if (!topic.trim() || running) return;
|
||||||
closeRef.current?.();
|
onRunReport(topic.trim(), kb.trim() || undefined);
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const tracePhase = running ? "streaming" : phase === "done" ? "done" : phase === "error" ? "error" : "idle";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-0 flex-col gap-4 overflow-hidden p-6">
|
<div className="h-full overflow-auto p-6">
|
||||||
<header>
|
<div className="mx-auto max-w-2xl">
|
||||||
<h1 className="text-lg font-semibold text-slate-100">报告生成</h1>
|
<h1 className="text-lg font-semibold text-slate-100">报告生成</h1>
|
||||||
<p className="mt-1 text-xs text-slate-500">
|
<p className="mt-1 text-xs text-slate-500">
|
||||||
规划大纲 → 各章并行(知识库检索 + LLM 撰写)→ 汇聚 → 渲染真实 Word(.docx)。依赖已配置对话模型;挂知识库则引用其资料。
|
规划大纲 → 各章并行(知识库检索 + LLM 撰写)→ 汇聚 → 渲染真实 Word(.docx)。依赖已配置对话模型;挂知识库则引用其资料。
|
||||||
</p>
|
</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">
|
<Panel title="新建报告" icon={FileText} className="mt-5">
|
||||||
<Field label="报告主题">
|
<div className="space-y-4 p-1">
|
||||||
<Input
|
<div>
|
||||||
value={topic}
|
<label className="mb-1.5 block text-xs text-slate-400">报告主题</label>
|
||||||
onChange={(e) => setTopic(e.target.value)}
|
<input
|
||||||
onKeyDown={(e) => e.key === "Enter" && onGenerate()}
|
value={topic}
|
||||||
placeholder="如:2026 年国产大模型产业现状与趋势分析"
|
onChange={(e) => setTopic(e.target.value)}
|
||||||
/>
|
onKeyDown={(e) => e.key === "Enter" && submit()}
|
||||||
</Field>
|
placeholder="如:2026 年国产大模型产业现状与趋势分析"
|
||||||
<Field label="知识库(可选)">
|
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"
|
||||||
<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" />
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
<div>
|
||||||
<EmptyState icon={FileText} title="尚未生成报告" desc="输入主题并点击「生成报告」,这里将实时显示规划与撰写过程。" />
|
<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>
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ function relTime(iso: string): string {
|
|||||||
|
|
||||||
// 运行 · 观测:左=运行历史,中=tab 切换(轨迹/工具/评测)+状态,右=模型输出(Markdown)。
|
// 运行 · 观测:左=运行历史,中=tab 切换(轨迹/工具/评测)+状态,右=模型输出(Markdown)。
|
||||||
// 选中历史运行从 Redis 回放;「当前运行」置顶沿用实时订阅。
|
// 选中历史运行从 Redis 回放;「当前运行」置顶沿用实时订阅。
|
||||||
export function RunsView({ run }: { run: RunState }) {
|
export function RunsView({ run, focusTaskId }: { run: RunState; focusTaskId?: string | null }) {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const [runs, setRuns] = useState<RunSummary[]>([]);
|
const [runs, setRuns] = useState<RunSummary[]>([]);
|
||||||
const [sel, setSel] = useState<string | null>(null); // null = 当前实时运行
|
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"); }
|
if (run.taskId) { setSel(null); setEvalRes(null); setTab("trace"); }
|
||||||
}, [run.taskId]);
|
}, [run.taskId]);
|
||||||
|
|
||||||
|
// 从工作台「最近任务」点进来:直接定位到那一条,别让用户切过来再自己找一遍。
|
||||||
|
useEffect(() => {
|
||||||
|
if (focusTaskId) selectRun(focusTaskId);
|
||||||
|
}, [focusTaskId, selectRun]);
|
||||||
|
|
||||||
// 当前实时运行的评测:完成后拉一次。
|
// 当前实时运行的评测:完成后拉一次。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sel && run.taskId && run.phase === "done") taskEval(run.taskId).then(setEvalRes).catch(() => {});
|
if (!sel && run.taskId && run.phase === "done") taskEval(run.taskId).then(setEvalRes).catch(() => {});
|
||||||
|
|||||||
@@ -453,13 +453,10 @@ func (h *Handler) StatsOverview(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 近期运行 feed。
|
// 近期运行 feed —— 与「运行」页的运行历史共用 RecentRuns,同一份数据不能有两个查法。
|
||||||
recent := make([]gin.H, 0, 8)
|
// 此前这里走 RecentTasks(),于是运行历史加了 topic 字段、工作台完全没跟上,
|
||||||
for _, t := range h.db.RecentTasks(ctx, uid, 8) {
|
// 还在显示 report_<hex>;而且 RecentTasks 没有租户过滤,口径也不一致。
|
||||||
recent = append(recent, gin.H{
|
recent := h.db.RecentRuns(ctx, uid, 8)
|
||||||
"task_id": t.TaskID, "status": t.Status, "detail": t.Detail, "at": t.CreatedAt,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 服务健康(与 Health 同口径:本地可判 + milvus/neo4j 经 mcp-go)。
|
// 服务健康(与 Health 同口径:本地可判 + milvus/neo4j 经 mcp-go)。
|
||||||
services := gin.H{"gateway": true, "nats": true, "db": h.db.Enabled(), "redis": h.cache.Enabled(), "milvus": false, "neo4j": false}
|
services := gin.H{"gateway": true, "nats": true, "db": h.db.Enabled(), "redis": h.cache.Enabled(), "milvus": false, "neo4j": false}
|
||||||
|
|||||||
@@ -275,19 +275,8 @@ func (p *Postgres) StatsOverview(ctx context.Context, owner string) *Overview {
|
|||||||
return o
|
return o
|
||||||
}
|
}
|
||||||
|
|
||||||
// RecentTasks 返回最近 n 条任务(工作台「近期运行」feed)。
|
|
||||||
// RecentTasks 返回某用户最近 n 条任务(个人工作台「近期运行」feed)。
|
|
||||||
// owner 过滤"我的运行";tenant 由插件自动叠加(双保险:跨用户/跨租户都隔离)。
|
|
||||||
func (p *Postgres) RecentTasks(ctx context.Context, owner string, n int) []Task {
|
|
||||||
if p.db == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var out []Task
|
|
||||||
p.db.WithContext(ctx).Where("owner = ?", owner).Order("created_at desc").Limit(n).Find(&out)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// RunRow 是「运行历史」一行:任务 + 其评测(LEFT JOIN,未评则 level 空)。
|
// RunRow 是「运行历史」一行:任务 + 其评测(LEFT JOIN,未评则 level 空)。
|
||||||
|
// 工作台「最近任务」与「运行」页共用它 —— 同一份数据只能有一个查法。
|
||||||
type RunRow struct {
|
type RunRow struct {
|
||||||
TaskID string `json:"task_id"`
|
TaskID string `json:"task_id"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
@@ -308,8 +297,8 @@ func (p *Postgres) RecentRuns(ctx context.Context, owner string, n int) []RunRow
|
|||||||
}
|
}
|
||||||
var out []RunRow
|
var out []RunRow
|
||||||
q := p.db.WithContext(ctx).Table("sundynix_task as t").
|
q := p.db.WithContext(ctx).Table("sundynix_task as t").
|
||||||
Select("t.task_id, t.status, t.detail, t.created_at as at, " +
|
Select("t.task_id, t.status, t.detail, t.created_at as at, "+
|
||||||
"coalesce(e.level,'') as eval_level, coalesce(e.overall,0) as eval_overall, " +
|
"coalesce(e.level,'') as eval_level, coalesce(e.overall,0) as eval_overall, "+
|
||||||
"coalesce(t.graph->>'topic','') as topic").
|
"coalesce(t.graph->>'topic','') as topic").
|
||||||
Joins("left join sundynix_eval e on e.task_id = t.task_id").
|
Joins("left join sundynix_eval e on e.task_id = t.task_id").
|
||||||
Where("t.deleted_at is null AND t.owner = ?", owner)
|
Where("t.deleted_at is null AND t.owner = ?", owner)
|
||||||
|
|||||||
Reference in New Issue
Block a user