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:
Blizzard
2026-06-12 17:34:54 +08:00
parent 4d9d1ac615
commit d5ae2f71d4
9 changed files with 186 additions and 17 deletions
@@ -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>;
}