diff --git a/sundynix-desktop/frontend/src/components/Markdown.tsx b/sundynix-desktop/frontend/src/components/Markdown.tsx new file mode 100644 index 0000000..ef842c7 --- /dev/null +++ b/sundynix-desktop/frontend/src/components/Markdown.tsx @@ -0,0 +1,112 @@ +import { type ReactNode } from "react"; + +// 轻量 Markdown 渲染 —— 零依赖、行级解析,覆盖报告正文用到的子集: +// # / ## / ### 标题、**粗** *斜* `码`、- 与 1. 列表、> 引用、--- 分隔、段落。 +// 流式安全:每个 token 重渲染,残缺语法也能容忍。 + +// 行内:把一段文本切成 **粗** / *斜* / `码` / 纯文本节点。 +function inline(text: string, keyPrefix: string): ReactNode[] { + const out: ReactNode[] = []; + const re = /(\*\*([^*]+)\*\*|`([^`]+)`|\*([^*]+)\*)/g; + let last = 0; + let m: RegExpExecArray | null; + let i = 0; + while ((m = re.exec(text)) !== null) { + if (m.index > last) out.push(text.slice(last, m.index)); + const key = `${keyPrefix}-${i++}`; + if (m[2] !== undefined) out.push({m[2]}); + else if (m[3] !== undefined) out.push({m[3]}); + else if (m[4] !== undefined) out.push({m[4]}); + last = re.lastIndex; + } + if (last < text.length) out.push(text.slice(last)); + return out; +} + +export function Markdown({ text, className }: { text: string; className?: string }) { + const lines = text.replace(/\r\n/g, "\n").split("\n"); + const blocks: ReactNode[] = []; + let para: string[] = []; + let list: { ordered: boolean; items: string[] } | null = null; + let k = 0; + + const flushPara = () => { + if (para.length) { + blocks.push( +

+ {inline(para.join(" "), `p${k}`)} +

, + ); + para = []; + } + }; + const flushList = () => { + if (list) { + const items = list.items.map((it, idx) => ( +
  • + {inline(it, `li${k}-${idx}`)} +
  • + )); + blocks.push( + list.ordered ? ( +
      {items}
    + ) : ( + + ), + ); + list = null; + } + }; + + for (const raw of lines) { + const line = raw.trimEnd(); + const h = /^(#{1,3})\s+(.*)$/.exec(line); + const ol = /^\d+\.\s+(.*)$/.exec(line); + const ul = /^[-*]\s+(.*)$/.exec(line); + + if (line.trim() === "") { + flushPara(); + flushList(); + } else if (/^(---|\*\*\*|___)\s*$/.test(line)) { + flushPara(); + flushList(); + blocks.push(
    ); + } else if (h) { + flushPara(); + flushList(); + const lvl = h[1].length; + const cls = lvl === 1 ? "mt-1 mb-3 text-xl font-bold text-slate-100" : lvl === 2 ? "mt-4 mb-2 text-base font-semibold text-slate-100" : "mt-3 mb-1 text-sm font-semibold text-slate-200"; + const content = inline(h[2], `h${k}`); + blocks.push(lvl === 1 ?

    {content}

    : lvl === 2 ?

    {content}

    :

    {content}

    ); + } else if (line.startsWith(">")) { + flushPara(); + flushList(); + blocks.push( +
    + {inline(line.replace(/^>\s?/, ""), `q${k}`)} +
    , + ); + } else if (ol) { + flushPara(); + if (!list || !list.ordered) { + flushList(); + list = { ordered: true, items: [] }; + } + list.items.push(ol[1]); + } else if (ul) { + flushPara(); + if (!list || list.ordered) { + flushList(); + list = { ordered: false, items: [] }; + } + list.items.push(ul[1]); + } else { + flushList(); + para.push(line); + } + } + flushPara(); + flushList(); + + return
    {blocks}
    ; +} diff --git a/sundynix-desktop/frontend/src/lib/health.ts b/sundynix-desktop/frontend/src/lib/health.ts index f4ca2bf..48af732 100644 --- a/sundynix-desktop/frontend/src/lib/health.ts +++ b/sundynix-desktop/frontend/src/lib/health.ts @@ -3,22 +3,38 @@ import { GATEWAY } from "./api"; export interface Health { gateway: boolean; - persisted: boolean; // Postgres 是否在线(billing.persisted) + persisted: boolean; // Postgres(兼容旧字段名) + db: boolean; + redis: boolean; + nats: boolean; + milvus: boolean; + neo4j: boolean; } -// useHealth 轮询 Gateway 健康(billing 端点同时回报持久化是否就绪)。 -// NATS/Milvus/Neo4j 暂未由网关透出,UI 以"未知"呈现(规划:加 /health 聚合)。 +const DOWN: Health = { gateway: false, persisted: false, db: false, redis: false, nats: false, milvus: false, neo4j: false }; + +// useHealth 轮询 Gateway /health 聚合端点,回报全部依赖子系统的实时状态。 +// gateway/nats/db/redis 由网关本地判定,milvus/neo4j 经 mcp-go health 工具取。 export function useHealth(intervalMs = 4000): Health { - const [h, setH] = useState({ gateway: false, persisted: false }); + const [h, setH] = useState(DOWN); useEffect(() => { let alive = true; const ping = async () => { try { - const res = await fetch(`${GATEWAY}/api/v1/billing`); - const data = (await res.json()) as { persisted?: boolean }; - if (alive) setH({ gateway: res.ok, persisted: Boolean(data.persisted) }); + const res = await fetch(`${GATEWAY}/api/v1/health`); + const d = (await res.json()) as Partial; + if (alive) + setH({ + gateway: res.ok && Boolean(d.gateway), + db: Boolean(d.db), + persisted: Boolean(d.db), + redis: Boolean(d.redis), + nats: Boolean(d.nats), + milvus: Boolean(d.milvus), + neo4j: Boolean(d.neo4j), + }); } catch { - if (alive) setH({ gateway: false, persisted: false }); + if (alive) setH(DOWN); } }; ping(); diff --git a/sundynix-desktop/frontend/src/shell/TopBar.tsx b/sundynix-desktop/frontend/src/shell/TopBar.tsx index 5e06abf..c45c914 100644 --- a/sundynix-desktop/frontend/src/shell/TopBar.tsx +++ b/sundynix-desktop/frontend/src/shell/TopBar.tsx @@ -9,10 +9,10 @@ import { cn } from "../ui"; const DRAG = { "--wails-draggable": "drag" } as CSSProperties; const NODRAG = { "--wails-draggable": "no-drag" } as CSSProperties; -function Light({ on, label, unknown }: { on?: boolean; label: string; unknown?: boolean }) { - const dot = unknown ? "bg-slate-600" : on ? "bg-success shadow-[0_0_8px_rgba(52,211,153,0.7)]" : "bg-danger"; +function Light({ on, label }: { on: boolean; label: string }) { + const dot = on ? "bg-success shadow-[0_0_8px_rgba(52,211,153,0.7)]" : "bg-danger"; return ( - + {label} @@ -46,10 +46,10 @@ export function TopBar({ identity, setIdentity }: { identity: Identity; setIdent
    - - - - + + + +
    diff --git a/sundynix-desktop/frontend/src/views/ReportView.tsx b/sundynix-desktop/frontend/src/views/ReportView.tsx index 3760758..7e09ec4 100644 --- a/sundynix-desktop/frontend/src/views/ReportView.tsx +++ b/sundynix-desktop/frontend/src/views/ReportView.tsx @@ -3,6 +3,7 @@ import { Play, Download, FileText, ExternalLink } from "lucide-react"; import { generateReport, streamTokens, streamExec, reportDownloadUrl, type Identity, type ExecEvent } from "../lib/api"; import { isDesktop, saveReportAs, openReport, notify } from "../lib/desktop"; import { ExecTrace } from "../components/ExecTrace"; +import { Markdown } from "../components/Markdown"; import { Button, Input, Field, Panel, Dot, EmptyState, useToast } from "../ui"; // 安全文件名:去掉路径不安全字符,限长。 @@ -130,7 +131,7 @@ export function ReportView({ identity }: { identity: Identity }) { } > {out ? ( -
    {out}
    + ) : ( )} diff --git a/sundynix-desktop/frontend/src/views/RunsView.tsx b/sundynix-desktop/frontend/src/views/RunsView.tsx index 3520ad3..3f1aee8 100644 --- a/sundynix-desktop/frontend/src/views/RunsView.tsx +++ b/sundynix-desktop/frontend/src/views/RunsView.tsx @@ -1,5 +1,6 @@ import { Activity, FileText } 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"; @@ -42,7 +43,7 @@ export function RunsView({ run }: { run: RunState }) { {run.output ? ( -
    {run.output}
    + ) : ( ,以 SSE 把执行轨迹事件推给客户端(运行·观测)。 // 与 StreamTask(token 流)并行:前端同时连两路,token 走输出、exec 走轨迹/工具面板。 func (h *Handler) StreamExec(c *gin.Context) { diff --git a/sundynix-gateway/internal/router/router.go b/sundynix-gateway/internal/router/router.go index 7ef4226..7ef0b28 100644 --- a/sundynix-gateway/internal/router/router.go +++ b/sundynix-gateway/internal/router/router.go @@ -32,6 +32,7 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus) *gin.Engine { api.POST("/reports", h.GenerateReport) // 报告生成(intent=report 任务 → Dispatcher 专用编排) api.GET("/reports/:id/download", h.DownloadReport) // 下载渲染好的 Word(.docx) + api.GET("/health", h.Health) // 依赖健康聚合(顶栏五盏灯) api.GET("/billing", h.Billing) // 运维控制面:LLM 模型配置(独立运维控制台调用)。 diff --git a/sundynix-mcp-go/internal/mcp/gateway.go b/sundynix-mcp-go/internal/mcp/gateway.go index 758a0ec..8be7903 100644 --- a/sundynix-mcp-go/internal/mcp/gateway.go +++ b/sundynix-mcp-go/internal/mcp/gateway.go @@ -60,6 +60,9 @@ func (g *Gateway) dispatch(ctx context.Context, call *contract.ToolCall) *contra return g.kbGraph(ctx, call) case "report_render": return g.reportRender(ctx, call) + case "health": + data, _ := json.Marshal(g.rag.Status()) + return &contract.ToolResult{OK: true, Content: string(data)} case "memory_get": return g.memoryGet(ctx, call) case "memory_upsert": diff --git a/sundynix-mcp-go/internal/rag/rag.go b/sundynix-mcp-go/internal/rag/rag.go index b61a0ce..cba8c0a 100644 --- a/sundynix-mcp-go/internal/rag/rag.go +++ b/sundynix-mcp-go/internal/rag/rag.go @@ -104,6 +104,15 @@ func (e *Engine) Triples(ctx context.Context, kb string, limit int) []Triple { // Ready 报告 RAG 是否可用(embedding + Milvus 均就绪)。 func (e *Engine) Ready() bool { return e.embed().ready() && e.mv != nil } +// Status 报告各依赖子系统的就绪情况(供 health 工具 → 控制台健康灯)。 +func (e *Engine) Status() map[string]bool { + return map[string]bool{ + "milvus": e.mv != nil, + "neo4j": e.graph.ready(), + "embedding": e.embed().ready(), + } +} + // Ingest 把一段文本切块 → 分批向量化 → 写 Milvus + Bleve,返回块数。 // onProgress 非空时逐阶段/逐批回调进度(用于实时入库监控)。 func (e *Engine) Ingest(ctx context.Context, kb, text string, onProgress func(contract.IngestEvent)) (int, error) {