From 1344bf98a057f107e455b3904ce3ec88d78f4759 Mon Sep 17 00:00:00 2001 From: Blizzard Date: Fri, 26 Jun 2026 16:13:54 +0800 Subject: [PATCH] =?UTF-8?q?feat(desktop):=20=E5=B7=A5=E4=BD=9C=E5=8F=B0?= =?UTF-8?q?=E9=87=8D=E5=81=9A=E4=B8=BA=E5=AE=9E=E6=97=B6=E4=BB=AA=E8=A1=A8?= =?UTF-8?q?=E7=9B=98=20+=20/stats/overview=20=E8=81=9A=E5=90=88=E7=AB=AF?= =?UTF-8?q?=E7=82=B9=EF=BC=88Tier1=20UI=20=E5=8D=87=E7=BA=A7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 桌面端首屏原是静态宣传页(stat 全硬编码、唯一活数据是网关在线),"太单调"。 重做为活的驾驶舱: 后端 GET /api/v1/stats/overview(聚合,几条轻量查询): - 任务今日/累计、7 日趋势、近 7 天终态分布(实例级,Task 无 owner) - 评测均分 + 忠实度均值 + 计数;知识库文档/库数(owner 级) - token 今日 + 7 日趋势(Redis 日计数)、近期运行 feed、服务健康(复用 health 口径) - store.StatsOverview + RecentTasks。 前端 Home 重写为仪表盘:4 指标卡(今日任务/Token/评测均分/知识库)带 SVG 火花线、 近期运行 feed(状态点+相对时间,点进运行观测)、7 日任务量柱图、服务健康灯带、能力入口、 快捷动作。5s 轮询刷新。配色沿用现有 ink 暗底 + brand/accent。 live(vite dev + 预览截图):登录后仪表盘渲染真实活数据(今日任务/Token/评测 1.00/近期运行/ 服务全绿),无控制台报错。tsc+vite 构建通过,gateway 全绿。 Co-Authored-By: Claude Opus 4.8 (1M context) --- sundynix-desktop/frontend/src/lib/api.ts | 26 +++ sundynix-desktop/frontend/src/views/Home.tsx | 203 +++++++++++++----- .../internal/handler/task_handler.go | 47 ++++ sundynix-gateway/internal/router/router.go | 1 + sundynix-gateway/internal/store/pgsql.go | 70 ++++++ 5 files changed, 297 insertions(+), 50 deletions(-) diff --git a/sundynix-desktop/frontend/src/lib/api.ts b/sundynix-desktop/frontend/src/lib/api.ts index ba182ac..d7f9d7f 100644 --- a/sundynix-desktop/frontend/src/lib/api.ts +++ b/sundynix-desktop/frontend/src/lib/api.ts @@ -457,3 +457,29 @@ export async function deleteMemory(id: Identity, key: string): Promise { throw new Error(d.error ?? `delete memory failed: ${res.status}`); } } + +// ── 工作台仪表盘聚合(GET /stats/overview)── +export interface KeyCount { key: string; count: number } +export interface RecentRun { task_id: string; status: string; detail: string; at: string } +export interface Overview { + tasks_today: number; + tasks_total: number; + status_count: KeyCount[]; + task_trend: KeyCount[]; + token_trend: KeyCount[]; + eval_avg: number; + faithful_avg: number; + eval_count: number; + kb_docs: number; + kb_count: number; + tokens_today: number; + daily_budget: number; + recent_runs: RecentRun[]; + services: Record; +} + +export async function statsOverview(): Promise { + const res = guard401(await fetch(`${GATEWAY}/api/v1/stats/overview`, { headers: bearer() })); + if (!res.ok) throw new Error(`stats failed: ${res.status}`); + return res.json() as Promise; +} diff --git a/sundynix-desktop/frontend/src/views/Home.tsx b/sundynix-desktop/frontend/src/views/Home.tsx index 13b34a7..84d508c 100644 --- a/sundynix-desktop/frontend/src/views/Home.tsx +++ b/sundynix-desktop/frontend/src/views/Home.tsx @@ -1,84 +1,187 @@ -import { Workflow, Database, Bookmark, FileText, Plus, Upload, ArrowRight, type LucideIcon } from "lucide-react"; -import { useHealth } from "../lib/health"; +import { useEffect, useState } from "react"; +import { Workflow, Database, Bookmark, FileText, Plus, Upload, Activity, Coins, Gauge, ArrowRight, type LucideIcon } from "lucide-react"; +import { statsOverview, type Overview, type RecentRun } from "../lib/api"; import type { ViewKey } from "../shell/LeftNav"; import { Button, cn } from "../ui"; -function Stat({ label, value, sub, accent }: { label: string; value: string; sub: string; accent: string }) { +// 迷你火花线:把一串数值画成 SVG 折线(仪表盘趋势)。 +function Spark({ data, stroke }: { data: number[]; stroke: string }) { + if (data.length < 2) return null; + const max = Math.max(...data, 1); + const min = Math.min(...data, 0); + const span = max - min || 1; + const w = 60; + const h = 18; + const pts = data + .map((v, i) => `${(i / (data.length - 1)) * w},${h - ((v - min) / span) * (h - 2) - 1}`) + .join(" "); + return ( + + + + ); +} + +function Metric({ icon: Icon, label, value, sub, spark, sparkColor, accent }: { + icon: LucideIcon; label: string; value: string; sub: string; + spark?: number[]; sparkColor?: string; accent?: string; +}) { return (
-
{label}
-
{value}
-
{sub}
+
+ {label} + +
+
{value}
+
+ {sub} + {spark && spark.some((v) => v > 0) ? : null} +
); } const CAPS: { icon: LucideIcon; title: string; desc: string; to: ViewKey; color: string }[] = [ - { icon: Workflow, title: "Agent 编排", desc: "React Flow 画布 → Eino 动态图 → 流式执行", to: "studio", color: "text-brand-400" }, - { icon: Database, title: "RAG 知识库", desc: "多文件入库 + 向量/全文/图谱 三路混合检索", to: "kb", color: "text-accent-400" }, - { icon: FileText, title: "报告生成", desc: "规划大纲 → 各章并行检索撰写 → 渲染 Word", to: "report", color: "text-indigo-300" }, - { icon: Bookmark, title: "偏好记忆", desc: "画像 + 多轮历史,让模型“知道是你”", to: "memory", color: "text-success" }, + { icon: Workflow, title: "Agent 编排", desc: "画布 → Eino 动态图 → 流式执行", to: "studio", color: "text-brand-400" }, + { icon: Database, title: "RAG 知识库", desc: "向量 / 全文 / 图谱 三路混合检索", to: "kb", color: "text-accent-400" }, + { icon: FileText, title: "报告生成", desc: "规划 → 分章并行 → 渲染 Word", to: "report", color: "text-indigo-300" }, + { icon: Bookmark, title: "偏好记忆", desc: "画像 + 多轮历史", to: "memory", color: "text-success" }, ]; -// 工作台:平台概览 + 快捷入口(深色 AI 控制台首页)。 +const STATUS_DOT: Record = { + done: "bg-success", + failed: "bg-danger", timeout: "bg-danger", rejected: "bg-danger", + waiting: "bg-warn", + running: "bg-brand", submitted: "bg-brand", +}; + +const SERVICE_LABEL: Record = { + gateway: "网关", nats: "总线", db: "PG", redis: "Redis", milvus: "Milvus", neo4j: "Neo4j", +}; + +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)} 天前`; +} + +function RunRow({ r }: { r: RecentRun }) { + return ( +
+ + {r.task_id} + {r.status} + {relTime(r.at)} +
+ ); +} + +// 工作台:实时驾驶舱(指标 + 趋势 + 近期运行 + 服务健康 + 快捷动作)。 export function Home({ onSelect }: { onSelect: (v: ViewKey) => void }) { - const h = useHealth(); + const [ov, setOv] = useState(null); + useEffect(() => { + let alive = true; + const load = () => statsOverview().then((d) => alive && setOv(d)).catch(() => {}); + load(); + const id = setInterval(load, 5000); + return () => { alive = false; clearInterval(id); }; + }, []); + + const taskTrend = ov?.task_trend.map((d) => d.count) ?? []; + const tokenTrend = ov?.token_trend.map((d) => d.count) ?? []; + const maxTask = Math.max(...taskTrend, 1); + const fmtTokens = (n: number) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}K` : `${n}`); + return (
-
+
+ {/* 头部 */}
-
- S +
S
+
+

工作台

+

实时概览 · 编排 / 知识库 / 报告 / 记忆

-
-

sundynix-agentix

-

分层式 AI Agent 平台 · 编排 / 知识库 / 报告 / 记忆

+ + + {ov?.services.gateway ? "实时" : "连接中"} + +
+ + {/* 指标卡 */} +
+ + + + +
+ + {/* 近期运行 + 7 日趋势 */} +
+
+
+ 近期运行 + +
+ {ov && ov.recent_runs.length > 0 ? ( + ov.recent_runs.map((r) => ) + ) : ( +
暂无运行记录
+ )} +
+
+
7 日任务量
+
+ {(ov?.task_trend ?? []).map((d, i) => ( +
+
+ {d.key.slice(-2)} +
+ ))} + {!ov &&
加载中…
} +
-
- - - - + {/* 服务健康灯带 */} +
+ 服务 + {Object.entries(SERVICE_LABEL).map(([k, label]) => ( + + + {label} + + ))}
-
+ {/* 能力入口 */} +
{CAPS.map((c) => { const Icon = c.icon; return ( - ); })}
-
-
快速开始
-
- - - - -
+ {/* 快捷动作 */} +
+ + + +
diff --git a/sundynix-gateway/internal/handler/task_handler.go b/sundynix-gateway/internal/handler/task_handler.go index 718d702..504faf1 100644 --- a/sundynix-gateway/internal/handler/task_handler.go +++ b/sundynix-gateway/internal/handler/task_handler.go @@ -374,6 +374,53 @@ func (h *Handler) SetMemory(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok", "message": res.Content}) } +// StatsOverview: GET /api/v1/stats/overview —— 工作台仪表盘聚合数据 +// (任务/评测实例级 + 知识库 owner 级 + token 用量 7 日 + 服务健康 + 近期运行)。 +func (h *Handler) StatsOverview(c *gin.Context) { + ctx := c.Request.Context() + uid := userID(c) + ov := h.db.StatsOverview(ctx, uid) + + // token 用量:今日 + 近 7 日(按用户按天,来自 Redis 计数)。 + now := time.Now() + today := now.Format("20060102") + var tokenTrend []gin.H + for i := 6; i >= 0; i-- { + d := now.AddDate(0, 0, -i) + tokenTrend = append(tokenTrend, gin.H{ + "key": d.Format("01-02"), "count": h.cache.GetUsage(ctx, uid, d.Format("20060102")), + }) + } + + // 近期运行 feed。 + recent := make([]gin.H, 0, 8) + for _, t := range h.db.RecentTasks(ctx, 8) { + recent = append(recent, gin.H{ + "task_id": t.TaskID, "status": t.Status, "detail": t.Detail, "at": t.CreatedAt, + }) + } + + // 服务健康(与 Health 同口径:本地可判 + milvus/neo4j 经 mcp-go)。 + services := gin.H{"gateway": true, "nats": true, "db": h.db.Enabled(), "redis": h.cache.Enabled(), "milvus": false, "neo4j": false} + cctx, cancel := context.WithTimeout(ctx, 2*time.Second) + defer cancel() + if res, err := h.bus.CallTool(cctx, contract.ToolSubjectGo("health"), &contract.ToolCall{Tool: "health"}); err == nil && res != nil && res.OK { + var sub map[string]bool + if json.Unmarshal([]byte(res.Content), &sub) == nil { + services["milvus"], services["neo4j"] = sub["milvus"], sub["neo4j"] + } + } + + c.JSON(http.StatusOK, gin.H{ + "tasks_today": ov.TasksToday, "tasks_total": ov.TasksTotal, + "status_count": ov.StatusCount, "task_trend": ov.TaskTrend, + "eval_avg": ov.EvalAvg, "faithful_avg": ov.FaithfulAvg, "eval_count": ov.EvalCount, + "kb_docs": ov.KBDocs, "kb_count": ov.KBCount, + "tokens_today": h.cache.GetUsage(ctx, uid, today), "daily_budget": userDailyTokenBudget(), + "token_trend": tokenTrend, "recent_runs": recent, "services": services, + }) +} + // 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 496d3e1..4939994 100644 --- a/sundynix-gateway/internal/router/router.go +++ b/sundynix-gateway/internal/router/router.go @@ -72,6 +72,7 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob. p.DELETE("/agents", h.AgentDelete) // 删除编排 p.POST("/reports", h.GenerateReport) // 报告生成 p.GET("/billing", h.Billing) + p.GET("/stats/overview", h.StatsOverview) // 工作台仪表盘聚合 } // 运维控制面:LLM 模型配置(含 API 密钥管理)—— 必须管理员(RequireAdmin)。 diff --git a/sundynix-gateway/internal/store/pgsql.go b/sundynix-gateway/internal/store/pgsql.go index 0d15c59..e03c649 100644 --- a/sundynix-gateway/internal/store/pgsql.go +++ b/sundynix-gateway/internal/store/pgsql.go @@ -180,6 +180,76 @@ func (p *Postgres) CountTasks(ctx context.Context) (int64, error) { return n, err } +// DayCount 是「某天 / 某状态 → 计数」的一行(工作台趋势/分布用)。 +type DayCount struct { + Key string `json:"key"` + Count int64 `json:"count"` +} + +// Overview 是工作台概览的聚合数据。任务/评测为实例级(Task 无 owner,单租户部署即全量), +// 知识库为 owner 级。降级模式(db==nil)返回零值。 +type Overview struct { + TasksToday int64 `json:"tasks_today"` + TasksTotal int64 `json:"tasks_total"` + StatusCount []DayCount `json:"status_count"` // 近 7 天各终态分布 + TaskTrend []DayCount `json:"task_trend"` // 近 7 天每日任务数(MM-DD) + EvalAvg float64 `json:"eval_avg"` // 综合分均值 + FaithfulAvg float64 `json:"faithful_avg"` // 忠实度均值(仅有来源的) + EvalCount int64 `json:"eval_count"` + KBDocs int64 `json:"kb_docs"` // owner 文档数 + KBCount int64 `json:"kb_count"` // owner 知识库数 +} + +// StatsOverview 聚合工作台概览(几条轻量查询)。owner 用于知识库口径。 +func (p *Postgres) StatsOverview(ctx context.Context, owner string) *Overview { + o := &Overview{StatusCount: []DayCount{}, TaskTrend: []DayCount{}} + if p.db == nil { + return o + } + db := p.db.WithContext(ctx) + startOfDay := time.Now().Truncate(24 * time.Hour) + + db.Model(&Task{}).Count(&o.TasksTotal) + db.Model(&Task{}).Where("created_at >= ?", startOfDay).Count(&o.TasksToday) + + // 近 7 天每日任务数(按日期分组,缺的天补 0 在前端/此处处理)。 + db.Model(&Task{}). + Select("to_char(created_at, 'MM-DD') as key, count(*) as count"). + Where("created_at >= ?", time.Now().AddDate(0, 0, -6).Truncate(24*time.Hour)). + Group("key").Order("key").Scan(&o.TaskTrend) + + // 近 7 天终态分布。 + db.Model(&Task{}). + Select("status as key, count(*) as count"). + Where("created_at >= ?", time.Now().AddDate(0, 0, -6)). + Group("status").Scan(&o.StatusCount) + + // 评测均值(综合 + 忠实度仅算有来源的)。 + var ev struct { + Avg float64 + Faithful float64 + N int64 + } + db.Model(&Eval{}).Select("coalesce(avg(overall),0) as avg, coalesce(avg(nullif(faithful,0)),0) as faithful, count(*) as n").Scan(&ev) + o.EvalAvg, o.FaithfulAvg, o.EvalCount = ev.Avg, ev.Faithful, ev.N + + if owner != "" { + db.Model(&Doc{}).Where("owner = ?", owner).Count(&o.KBDocs) + db.Model(&KB{}).Where("owner = ?", owner).Count(&o.KBCount) + } + return o +} + +// RecentTasks 返回最近 n 条任务(工作台「近期运行」feed)。 +func (p *Postgres) RecentTasks(ctx context.Context, n int) []Task { + if p.db == nil { + return nil + } + var out []Task + p.db.WithContext(ctx).Order("created_at desc").Limit(n).Find(&out) + return out +} + // Close 释放底层连接。 func (p *Postgres) Close() { if p.db == nil {