import { Fragment, type ReactNode } from "react";
import { cn } from "~/lib/utils.ts";
// 模型汇报的 Markdown 渲染(AG-3):只支持安全子集——标题、段落、列表、引用、分隔线、代码块、表格,
// 行内粗体 / 斜体 / 删除线 / 行内代码 / 链接。直接生成 React 元素,不用 innerHTML:原文里的 HTML 标签
// 按普通文字显示;链接只放行 http(s) 与站内相对路径,其余协议(javascript:、data: 等)按文字显示。
export type MdBlock =
| { type: "heading"; level: number; text: string }
| { type: "paragraph"; text: string }
| { type: "list"; ordered: boolean; start: number; items: string[] }
| { type: "quote"; text: string }
| { type: "code"; lang: string; text: string }
| { type: "table"; head: string[]; align: ("left" | "center" | "right" | null)[]; rows: string[][] }
| { type: "hr" };
const FENCE = /^\s*(```|~~~)\s*([\w+-]*)\s*$/;
const HEADING = /^\s{0,3}(#{1,6})\s+(.*?)\s*#*\s*$/;
const HR = /^\s{0,3}([-*_])(\s*\1){2,}\s*$/;
const UL = /^\s{0,3}[-*+]\s+(.*)$/;
const OL = /^\s{0,3}(\d{1,9})[.)]\s+(.*)$/;
const QUOTE = /^\s{0,3}>\s?(.*)$/;
const TABLE_SEP = /^\s*\|?\s*:?-{1,}:?\s*(\|\s*:?-{1,}:?\s*)*\|?\s*$/;
function splitRow(line: string): string[] {
let s = line.trim();
if (s.startsWith("|")) s = s.slice(1);
if (s.endsWith("|") && !s.endsWith("\\|")) s = s.slice(0, -1);
const out: string[] = [];
let cur = "";
for (let i = 0; i < s.length; i++) {
const ch = s[i];
if (ch === "\\" && s[i + 1] === "|") {
cur += "|";
i++;
} else if (ch === "|") {
out.push(cur.trim());
cur = "";
} else cur += ch;
}
out.push(cur.trim());
return out;
}
/** 块级解析(纯函数,便于测试) */
export function parseMarkdown(src: string): MdBlock[] {
const lines = src.replace(/\r\n?/g, "\n").split("\n");
const blocks: MdBlock[] = [];
let i = 0;
const isBlockStart = (l: string, next?: string) =>
FENCE.test(l) ||
HEADING.test(l) ||
HR.test(l) ||
UL.test(l) ||
OL.test(l) ||
QUOTE.test(l) ||
(l.includes("|") && next !== undefined && TABLE_SEP.test(next) && next.includes("-"));
while (i < lines.length) {
const line = lines[i] ?? "";
if (!line.trim()) {
i++;
continue;
}
const fence = FENCE.exec(line);
if (fence) {
const body: string[] = [];
i++;
while (i < lines.length && !(lines[i] ?? "").trim().startsWith(fence[1] ?? "```")) body.push(lines[i++] ?? "");
i++; // 结束围栏(缺失时读到文末)
blocks.push({ type: "code", lang: fence[2] ?? "", text: body.join("\n") });
continue;
}
const h = HEADING.exec(line);
if (h) {
blocks.push({ type: "heading", level: h[1]?.length ?? 1, text: h[2] ?? "" });
i++;
continue;
}
if (HR.test(line)) {
blocks.push({ type: "hr" });
i++;
continue;
}
const next = lines[i + 1];
if (line.includes("|") && next !== undefined && TABLE_SEP.test(next) && next.includes("-")) {
const head = splitRow(line);
const align = splitRow(next).map((c) =>
c.startsWith(":") && c.endsWith(":") ? "center" : c.endsWith(":") ? "right" : c.startsWith(":") ? "left" : null,
);
const rows: string[][] = [];
i += 2;
while (i < lines.length && (lines[i] ?? "").includes("|") && (lines[i] ?? "").trim()) {
const cells = splitRow(lines[i] ?? "");
rows.push(head.map((_, k) => cells[k] ?? ""));
i++;
}
blocks.push({ type: "table", head, align, rows });
continue;
}
if (QUOTE.test(line)) {
const body: string[] = [];
while (i < lines.length && QUOTE.test(lines[i] ?? "")) body.push(QUOTE.exec(lines[i++] ?? "")?.[1] ?? "");
blocks.push({ type: "quote", text: body.join("\n") });
continue;
}
const ul = UL.exec(line);
const ol = OL.exec(line);
if (ul || ol) {
const ordered = !ul;
const re = ordered ? OL : UL;
const items: string[] = [];
while (i < lines.length) {
const l = lines[i] ?? "";
const m = re.exec(l);
if (m) {
items.push((ordered ? m[2] : m[1]) ?? "");
i++;
} else if (l.trim() && /^\s{2,}\S/.test(l) && items.length) {
// 缩进的续行 / 子项:并入上一项
items[items.length - 1] += `\n${l.trim()}`;
i++;
} else break;
}
blocks.push({ type: "list", ordered, start: ordered ? Number(ol?.[1] ?? 1) : 1, items });
continue;
}
const para: string[] = [];
while (i < lines.length && (lines[i] ?? "").trim() && !(para.length && isBlockStart(lines[i] ?? "", lines[i + 1])))
para.push(lines[i++] ?? "");
blocks.push({ type: "paragraph", text: para.join("\n") });
}
return blocks;
}
/** 链接只放行 http(s) 与站内相对路径 */
export function safeHref(href: string): string | null {
const h = href.trim();
if (/^https?:\/\//i.test(h)) return h;
if (/^\/(?!\/)/.test(h)) return h;
return null;
}
// 行内:`code`、**粗体**、*斜体* / _斜体_、~~删除线~~、[文字](链接)
const INLINE =
/(`+)([\s\S]*?[^`])\1(?!`)|\*\*([\s\S]+?)\*\*|__([\s\S]+?)__|~~([\s\S]+?)~~|\[([^\]]+)\]\(([^)\s]+)\)|\*([^*\s][^*]*?)\*|(? last) out.push(...withBreaks(text.slice(last, at), `${keyBase}-t${n}`));
const key = `${keyBase}-${n++}`;
if (m[1])
out.push(
{m[2]}
,
);
else if (m[3] !== undefined || m[4] !== undefined)
out.push(
{renderInline(m[3] ?? m[4] ?? "", key)}
,
);
else if (m[5] !== undefined) out.push({renderInline(m[5], key)});
else if (m[6] !== undefined) {
const href = safeHref(m[7] ?? "");
out.push(
href ? (
{renderInline(m[6], key)}
) : (
, p]));
}
const H_CLASS = ["text-lead font-semibold", "text-body font-semibold", "text-caption font-semibold"];
/** 渲染安全子集 Markdown;空文本返回 null */
export function Markdown({ text, className }: { text: string; className?: string }) {
const blocks = parseMarkdown(text);
if (blocks.length === 0) return null;
return (
{renderInline(b.text, key)}
; case "quote": return ({renderInline(b.text, key)}); case "hr": return
{b.text}
);
case "list": {
const items = b.items.map((it, j) => (
| {renderInline(h, `${key}-h${j}`)} | ))}
|---|
| {renderInline(c, `${key}-r${j}-${x}`)} | ))}