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 {