feat(desktop): 工业化升级 D —— 内容可信(Markdown 渲染 + 健康五灯全真)
- components/Markdown.tsx:零依赖、行级 Markdown 渲染(# 标题 / **粗** *斜* `码` / - 与 1. 列表 / > 引用 / --- 分隔 / 段落),流式安全(每 token 重渲染容忍残缺)。 报告正文与运行输出从裸 <pre> 换成真排版,瞬间像份报告。 - 健康聚合:mcp-go 加 rag.Status() + health 工具(milvus/neo4j/embedding 就绪); gateway GET /api/v1/health 聚合 gateway/nats/db/redis(本地) + milvus/neo4j(经 mcp-go); health.ts 轮询 /health,TopBar 五盏灯(Gateway/DB/NATS/Milvus/Neo4j)从"灰=未知"变真实绿/红。 验证:浏览器(Preview)跑报告——正文以标题/有序列表/引用/分隔线/二级标题排版呈现; 五盏灯全绿(/health 返回 db/gateway/milvus/nats/neo4j/redis 全 true)。tsc + vite build + 后端 build 通过。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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(<strong key={key} className="font-semibold text-slate-100">{m[2]}</strong>);
|
||||
else if (m[3] !== undefined) out.push(<code key={key} className="rounded bg-ink-800 px-1 py-0.5 font-mono text-[0.85em] text-accent-400">{m[3]}</code>);
|
||||
else if (m[4] !== undefined) out.push(<em key={key} className="italic text-slate-300">{m[4]}</em>);
|
||||
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(
|
||||
<p key={`p${k++}`} className="my-2 leading-relaxed text-slate-300">
|
||||
{inline(para.join(" "), `p${k}`)}
|
||||
</p>,
|
||||
);
|
||||
para = [];
|
||||
}
|
||||
};
|
||||
const flushList = () => {
|
||||
if (list) {
|
||||
const items = list.items.map((it, idx) => (
|
||||
<li key={idx} className="leading-relaxed text-slate-300">
|
||||
{inline(it, `li${k}-${idx}`)}
|
||||
</li>
|
||||
));
|
||||
blocks.push(
|
||||
list.ordered ? (
|
||||
<ol key={`l${k++}`} className="my-2 ml-5 list-decimal space-y-1 marker:text-slate-500">{items}</ol>
|
||||
) : (
|
||||
<ul key={`l${k++}`} className="my-2 ml-5 list-disc space-y-1 marker:text-slate-600">{items}</ul>
|
||||
),
|
||||
);
|
||||
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(<hr key={`hr${k++}`} className="my-3 border-line" />);
|
||||
} 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 ? <h1 key={`h${k++}`} className={cls}>{content}</h1> : lvl === 2 ? <h2 key={`h${k++}`} className={cls}>{content}</h2> : <h3 key={`h${k++}`} className={cls}>{content}</h3>);
|
||||
} else if (line.startsWith(">")) {
|
||||
flushPara();
|
||||
flushList();
|
||||
blocks.push(
|
||||
<blockquote key={`q${k++}`} className="my-2 border-l-2 border-brand/50 pl-3 text-sm text-slate-400">
|
||||
{inline(line.replace(/^>\s?/, ""), `q${k}`)}
|
||||
</blockquote>,
|
||||
);
|
||||
} 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 <div className={className}>{blocks}</div>;
|
||||
}
|
||||
@@ -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<Health>({ gateway: false, persisted: false });
|
||||
const [h, setH] = useState<Health>(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<Health>;
|
||||
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();
|
||||
|
||||
@@ -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 (
|
||||
<span className="flex items-center gap-1.5 text-[11px] text-slate-500" title={unknown ? `${label}(状态未透出)` : label}>
|
||||
<span className="flex items-center gap-1.5 text-[11px] text-slate-500" title={`${label} ${on ? "在线" : "离线"}`}>
|
||||
<span className={cn("h-1.5 w-1.5 rounded-full", dot)} />
|
||||
{label}
|
||||
</span>
|
||||
@@ -46,10 +46,10 @@ export function TopBar({ identity, setIdentity }: { identity: Identity; setIdent
|
||||
</div>
|
||||
<div className="ml-2 flex items-center gap-3">
|
||||
<Light on={h.gateway} label="Gateway" />
|
||||
<Light on={h.persisted} label="DB" />
|
||||
<Light unknown label="NATS" />
|
||||
<Light unknown label="Milvus" />
|
||||
<Light unknown label="Neo4j" />
|
||||
<Light on={h.db} label="DB" />
|
||||
<Light on={h.nats} label="NATS" />
|
||||
<Light on={h.milvus} label="Milvus" />
|
||||
<Light on={h.neo4j} label="Neo4j" />
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2" style={NODRAG}>
|
||||
<div className="relative">
|
||||
|
||||
@@ -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 ? (
|
||||
<pre className="whitespace-pre-wrap break-words font-sans text-sm leading-relaxed text-slate-300">{out}</pre>
|
||||
<Markdown text={out} className="text-sm" />
|
||||
) : (
|
||||
<EmptyState icon={FileText} title="尚未生成报告" desc="输入主题并点击「生成报告」,这里将实时显示规划与撰写过程。" />
|
||||
)}
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
<Panel title="模型输出" icon={FileText}>
|
||||
{run.output ? (
|
||||
<pre className="whitespace-pre-wrap break-words font-sans text-sm leading-relaxed text-slate-300">{run.output}</pre>
|
||||
<Markdown text={run.output} className="text-sm" />
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={Activity}
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
@@ -91,6 +93,30 @@ func (h *Handler) StreamTask(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// Health: GET /api/v1/health —— 聚合各依赖子系统健康,供桌面端顶栏五盏灯实时点亮。
|
||||
// gateway/db/redis/nats 网关本地可判;milvus/neo4j 经 mcp-go health 工具取(不可用则置否)。
|
||||
func (h *Handler) Health(c *gin.Context) {
|
||||
status := gin.H{
|
||||
"gateway": true, // 能应答即在线
|
||||
"nats": true, // 网关启动即连上 NATS(连不上会 fatal)
|
||||
"db": h.db.Enabled(), // Postgres
|
||||
"redis": h.cache.Enabled(), // Redis
|
||||
"milvus": false,
|
||||
"neo4j": false,
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(c.Request.Context(), 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 {
|
||||
status["milvus"] = sub["milvus"]
|
||||
status["neo4j"] = sub["neo4j"]
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, status)
|
||||
}
|
||||
|
||||
// StreamExec: 订阅 sundynix.exec.<task_id>,以 SSE 把执行轨迹事件推给客户端(运行·观测)。
|
||||
// 与 StreamTask(token 流)并行:前端同时连两路,token 走输出、exec 走轨迹/工具面板。
|
||||
func (h *Handler) StreamExec(c *gin.Context) {
|
||||
|
||||
@@ -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 模型配置(独立运维控制台调用)。
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user