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)} ) : ( {m[0]} ), ); } else out.push({renderInline(m[8] ?? m[9] ?? "", key)}); last = at + m[0].length; } if (last < text.length) out.push(...withBreaks(text.slice(last), `${keyBase}-t${n}`)); return out; } function withBreaks(s: string, key: string): ReactNode[] { const parts = s.split("\n"); return parts.flatMap((p, k) => (k === 0 ? [p] : [
, 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 (
{blocks.map((b, k) => { const key = `b${k}`; switch (b.type) { case "heading": { const Tag = `h${Math.min(6, b.level + 2)}` as "h3"; return ( {renderInline(b.text, key)} ); } case "paragraph": return

{renderInline(b.text, key)}

; case "quote": return (
{renderInline(b.text, key)}
); case "hr": return
; case "code": return (
                {b.text}
              
); case "list": { const items = b.items.map((it, j) => (
  • {renderInline(it, `${key}-${j}`)}
  • )); return b.ordered ? (
      {items}
    ) : ( ); } case "table": return (
    {b.head.map((h, j) => ( ))} {b.rows.map((r, j) => ( {r.map((c, x) => ( ))} ))}
    {renderInline(h, `${key}-h${j}`)}
    {renderInline(c, `${key}-r${j}-${x}`)}
    ); default: return null; } })}
    ); }