From 3cf3c0b070d26268aedc5aafb299adba421265ba Mon Sep 17 00:00:00 2001 From: Blizzard Date: Fri, 26 Jun 2026 16:34:07 +0800 Subject: [PATCH] =?UTF-8?q?feat(desktop):=20=E8=BF=90=E8=A1=8C=C2=B7?= =?UTF-8?q?=E8=A7=82=E6=B5=8B=20=E9=87=8D=E5=81=9A=E4=B8=BA=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E5=8E=86=E5=8F=B2=20+=20=E5=A4=8D=E7=9B=98=EF=BC=88Ti?= =?UTF-8?q?er2=EF=BC=8C=E7=94=A8=E4=B8=8A=20exec=20Redis=20=E5=9B=9E?= =?UTF-8?q?=E6=94=BE=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RunsView 原本只能看「最近一次」实时运行;现做成完整的运行历史复盘: 后端 GET /api/v1/runs?limit=(store.RecentRuns):任务 LEFT JOIN 评测,返回 task_id/status/time + eval level/overall,供历史列表。 前端 RunsView:左侧运行历史列表(状态点 + 相对时间 + 评测分级徽标),点选任一历史运行 → 经 streamExec/streamTokens 从 Redis 回放该次执行轨迹 + 模型输出(对已完成任务流即回放), 并取 /tasks/:id/eval 显示评测(分级/忠实度/纠偏/评语/flags)。「当前运行」固定置顶沿用实时订阅。 api 补 listRuns + taskEval。 这正是之前 exec 轨迹 Redis 回放的消费场景。live(vite + 预览):提一新任务 → 历史列表出现 → 点选 → 完整回放轨迹(2 节点/2518ms/含推理过程)+ 答案全文 + 评测 ok·1.00,无控制台报错。 tsc+vite 构建通过,gateway 全绿。 Co-Authored-By: Claude Opus 4.8 (1M context) --- sundynix-desktop/frontend/src/lib/api.ts | 36 +++++ .../frontend/src/views/RunsView.tsx | 147 +++++++++++++----- .../internal/handler/task_handler.go | 11 ++ sundynix-gateway/internal/router/router.go | 1 + sundynix-gateway/internal/store/pgsql.go | 25 +++ 5 files changed, 182 insertions(+), 38 deletions(-) diff --git a/sundynix-desktop/frontend/src/lib/api.ts b/sundynix-desktop/frontend/src/lib/api.ts index d7f9d7f..0441332 100644 --- a/sundynix-desktop/frontend/src/lib/api.ts +++ b/sundynix-desktop/frontend/src/lib/api.ts @@ -483,3 +483,39 @@ export async function statsOverview(): Promise { if (!res.ok) throw new Error(`stats failed: ${res.status}`); return res.json() as Promise; } + +// ── 运行历史 / 复盘 ── +export interface RunSummary { + task_id: string; + status: string; + detail: string; + at: string; + eval_level: string; + eval_overall: number; +} + +export async function listRuns(limit = 30): Promise { + const res = guard401(await fetch(`${GATEWAY}/api/v1/runs?limit=${limit}`, { headers: bearer() })); + if (!res.ok) throw new Error(`list runs failed: ${res.status}`); + const d = (await res.json()) as { runs?: RunSummary[] }; + return d.runs ?? []; +} + +export interface EvalResult { + overall: number; + rule: number; + llm: number; + faithful: number; + level: string; + flags: string[]; + reason: string; + sources: number; + corrected: boolean; +} + +// taskEval: 取一次任务的评测结果(无则返回 null)。 +export async function taskEval(taskId: string): Promise { + const res = await fetch(`${GATEWAY}/api/v1/tasks/${taskId}/eval`, { headers: bearer() }); + if (!res.ok) return null; + return res.json() as Promise; +} diff --git a/sundynix-desktop/frontend/src/views/RunsView.tsx b/sundynix-desktop/frontend/src/views/RunsView.tsx index 3f1aee8..c32026f 100644 --- a/sundynix-desktop/frontend/src/views/RunsView.tsx +++ b/sundynix-desktop/frontend/src/views/RunsView.tsx @@ -1,57 +1,128 @@ -import { Activity, FileText } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { Activity, FileText, History } from "lucide-react"; import { ExecTrace } from "../components/ExecTrace"; import { Markdown } from "../components/Markdown"; -import { deriveNodes, type RunState } from "../lib/run"; -import { Panel, Dot, EmptyState } from "../ui"; +import { deriveNodes, emptyRun, type RunState } from "../lib/run"; +import { listRuns, taskEval, streamExec, streamTokens, type RunSummary, type EvalResult } from "../lib/api"; +import { Panel, Dot, Badge, EmptyState, cn } from "../ui"; -// 运行·观测:把最近一次运行的执行轨迹实时可视化(节点逐个点亮 + 工具入参/产出 + 耗时), -// 右侧并列模型输出。数据来自 Studio 运行时订阅的 sundynix.exec. 事件流。 +const STATUS_DOT: Record = { + done: "success", failed: "danger", timeout: "danger", rejected: "danger", + waiting: "warn", running: "running", submitted: "running", +}; +const LEVEL_TONE: Record = { ok: "success", warn: "warn", poor: "danger" }; + +function relTime(iso: string): string { + const d = Date.now() - new Date(iso).getTime(); + if (d < 60_000) return "刚刚"; + if (d < 3_600_000) return `${Math.floor(d / 60_000)} 分钟前`; + if (d < 86_400_000) return `${Math.floor(d / 3_600_000)} 小时前`; + return `${Math.floor(d / 86_400_000)} 天前`; +} + +// 运行 · 观测:左侧运行历史,选中后从 Redis 回放该次执行轨迹 + 输出 + 评测(复盘)。 +// 「当前运行」固定在顶部,沿用实时订阅;历史项点击即复现(exec/token 流对完成任务回放)。 export function RunsView({ run }: { run: RunState }) { - const nodes = deriveNodes(run.exec); + const [runs, setRuns] = useState([]); + const [sel, setSel] = useState(null); // null = 当前实时运行 + const [replay, setReplay] = useState(emptyRun); + const [evalRes, setEvalRes] = useState(null); + const closeRef = useRef<(() => void) | null>(null); + + // 拉运行历史(轮询刷新,捕获新完成的运行)。 + useEffect(() => { + let alive = true; + const load = () => listRuns(40).then((r) => alive && setRuns(r)).catch(() => {}); + load(); + const id = setInterval(load, 5000); + return () => { alive = false; clearInterval(id); closeRef.current?.(); }; + }, []); + + // 选中历史运行:从 Redis 回放轨迹 + token + 取评测。 + const selectRun = useCallback((taskId: string) => { + closeRef.current?.(); + if (taskId === run.taskId) { setSel(null); setEvalRes(null); return; } // 即当前实时运行 + setSel(taskId); + setReplay({ phase: "streaming", taskId, output: "", events: [], exec: [] }); + setEvalRes(null); + const closeExec = streamExec(taskId, (ev) => setReplay((r) => ({ ...r, exec: [...r.exec, ev] })), () => setReplay((r) => ({ ...r, phase: "done" })), () => {}); + const closeTok = streamTokens(taskId, (tok) => setReplay((r) => ({ ...r, output: r.output + tok })), () => {}, () => {}); + closeRef.current = () => { closeExec(); closeTok(); }; + taskEval(taskId).then(setEvalRes).catch(() => {}); + }, [run.taskId]); + + const liveActive = run.taskId && run.phase !== "idle"; + const cur = sel ? replay : run; + const nodes = deriveNodes(cur.exec); const tools = nodes.filter((n) => n.kind === "tool"); - const phaseText = - run.phase === "streaming" ? "执行中" : run.phase === "done" ? "完成" : run.phase === "error" ? "出错" : run.phase === "submitting" ? "提交中" : "就绪"; - const tone = run.phase === "streaming" ? "running" : run.phase === "done" ? "success" : run.phase === "error" ? "danger" : "neutral"; return ( -
+
-
+

运行 · 观测

-

- 实时执行轨迹:每个节点(记忆/工具/提示词/模型,或报告的规划/分章/渲染)逐个点亮,附入参产出与耗时。 -

-
-
- - 任务 {run.taskId ?? "—"} - - - - {phaseText} - - - {nodes.length} 节点 · {tools.length} 次工具调用 - +

运行历史复盘:选一次运行,从持久化流回放节点轨迹、模型输出与评测。

+ {nodes.length} 节点 · {tools.length} 次工具调用
-
+
+ {/* 运行历史列表 */} +
+
+ 运行历史 +
+
+ {liveActive && ( + + )} + {runs.map((r) => ( + + ))} + {runs.length === 0 &&
暂无运行记录
} +
+
+ + {/* 执行轨迹 */} - + - - {run.output ? ( - - ) : ( - + {/* 模型输出 + 评测 */} +
+ + {cur.output ? ( + + ) : ( + + )} + + {evalRes && ( +
+
+ 评测 + {evalRes.level} · {evalRes.overall.toFixed(2)} + {evalRes.corrected && 已纠偏} + {evalRes.sources > 0 && 忠实 {evalRes.faithful.toFixed(2)} · {evalRes.sources} 来源} +
+ {evalRes.reason &&

{evalRes.reason}

} + {evalRes.flags?.length > 0 &&

{evalRes.flags.join(";")}

} +
)} - +
); diff --git a/sundynix-gateway/internal/handler/task_handler.go b/sundynix-gateway/internal/handler/task_handler.go index 504faf1..d559e8b 100644 --- a/sundynix-gateway/internal/handler/task_handler.go +++ b/sundynix-gateway/internal/handler/task_handler.go @@ -421,6 +421,17 @@ func (h *Handler) StatsOverview(c *gin.Context) { }) } +// Runs: GET /api/v1/runs?limit= —— 运行历史列表(任务 + 评测分级),供「运行 · 观测」复盘。 +func (h *Handler) Runs(c *gin.Context) { + limit := 30 + if v := c.Query("limit"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 200 { + limit = n + } + } + c.JSON(http.StatusOK, gin.H{"runs": h.db.RecentRuns(c.Request.Context(), limit)}) +} + // ListMemory: GET /api/v1/memory —— 列出当前用户的全部偏好(结构化,供记忆面板)。 func (h *Handler) ListMemory(c *gin.Context) { res, err := h.bus.CallTool(c.Request.Context(), contract.ToolSubjectGo("memory_list"), diff --git a/sundynix-gateway/internal/router/router.go b/sundynix-gateway/internal/router/router.go index 4939994..db517d3 100644 --- a/sundynix-gateway/internal/router/router.go +++ b/sundynix-gateway/internal/router/router.go @@ -73,6 +73,7 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob. p.POST("/reports", h.GenerateReport) // 报告生成 p.GET("/billing", h.Billing) p.GET("/stats/overview", h.StatsOverview) // 工作台仪表盘聚合 + p.GET("/runs", h.Runs) // 运行历史(复盘) } // 运维控制面:LLM 模型配置(含 API 密钥管理)—— 必须管理员(RequireAdmin)。 diff --git a/sundynix-gateway/internal/store/pgsql.go b/sundynix-gateway/internal/store/pgsql.go index e03c649..5fda67e 100644 --- a/sundynix-gateway/internal/store/pgsql.go +++ b/sundynix-gateway/internal/store/pgsql.go @@ -250,6 +250,31 @@ func (p *Postgres) RecentTasks(ctx context.Context, n int) []Task { return out } +// RunRow 是「运行历史」一行:任务 + 其评测(LEFT JOIN,未评则 level 空)。 +type RunRow struct { + TaskID string `json:"task_id"` + Status string `json:"status"` + Detail string `json:"detail"` + At time.Time `json:"at"` + EvalLevel string `json:"eval_level"` + EvalOverall float64 `json:"eval_overall"` +} + +// RecentRuns 返回最近 n 条运行(含评测分级,供「运行历史」列表)。 +func (p *Postgres) RecentRuns(ctx context.Context, n int) []RunRow { + if p.db == nil { + return nil + } + var out []RunRow + p.db.WithContext(ctx).Table("sundynix_task as t"). + 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"). + Joins("left join sundynix_eval e on e.task_id = t.task_id"). + Where("t.deleted_at is null"). + Order("t.created_at desc").Limit(n).Scan(&out) + return out +} + // Close 释放底层连接。 func (p *Postgres) Close() { if p.db == nil {