diff --git a/apps/web/app/components/agent/agent-conversation.tsx b/apps/web/app/components/agent/agent-conversation.tsx index f117740f..c58ea56e 100644 --- a/apps/web/app/components/agent/agent-conversation.tsx +++ b/apps/web/app/components/agent/agent-conversation.tsx @@ -36,6 +36,7 @@ import { ApprovalCard, type ApprovalCardStatus } from "~/components/agentui/appr import { type CitationItem, CitationList } from "~/components/agentui/citations.tsx"; import { CHEVRON_CLASS, Disclosure } from "~/components/agentui/disclosure.tsx"; import { FileDiff } from "~/components/agentui/file-diff.tsx"; +import { Markdown } from "~/components/agentui/markdown.tsx"; import { Message, MessageAvatar, @@ -427,7 +428,8 @@ export function AgentConversation({ announce={false} showActions={!active && idx === lastText} > - {it.text} + {/* 流式中按原文显示(半截 Markdown 会闪),结束后按安全子集渲染 */} + {streaming ? it.text : } ); @@ -724,7 +726,7 @@ export function FinalReport({ 模型说的(未经核查)

- {said || "(没有文字汇报)"} + {said ? : "(没有文字汇报)"}

仅供参考,不作为完成依据。

diff --git a/apps/web/app/components/agentui/markdown.test.ts b/apps/web/app/components/agentui/markdown.test.ts new file mode 100644 index 00000000..402ca90a --- /dev/null +++ b/apps/web/app/components/agentui/markdown.test.ts @@ -0,0 +1,74 @@ +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { Markdown, parseMarkdown, safeHref } from "./markdown.tsx"; + +const html = (text: string) => renderToStaticMarkup(createElement(Markdown, { text })); + +describe("Markdown 安全子集", () => { + it("块级:标题、列表、表格、代码块、引用、分隔线", () => { + const src = [ + "## 结论", + "", + "**当前订阅 2 个**,来源 `inventory.query`:", + "", + "| # | 标题 | 年份 |", + "|---|:---|---:|", + "| 1 | 沙丘2 | 2024 |", + "| 2 | 沙丘:预言 | 2024 |", + "", + "- 缺 1080p", + "- 缺 4K", + "", + "1. 先搜", + "2. 再提交", + "", + "> 仅供参考", + "", + "---", + "```json", + '{"a":1}', + "```", + ].join("\n"); + const blocks = parseMarkdown(src); + expect(blocks.map((b) => b.type)).toEqual(["heading", "paragraph", "table", "list", "list", "quote", "hr", "code"]); + const out = html(src); + expect(out).toContain("", () => { + const out = html( + " [点我](javascript:alert(1)) [文档](https://example.com/a)", + ); + expect(out).not.toContain(" { + const out = html("用 `a*b*c` 查询\n第二行 *强调*"); + expect(out).toContain(""); + expect(out).toContain("强调"); + }); + + it("空文本不渲染", () => { + expect(html(" \n\n")).toBe(""); + }); +}); diff --git a/apps/web/app/components/agentui/markdown.tsx b/apps/web/app/components/agentui/markdown.tsx new file mode 100644 index 00000000..2a5d1734 --- /dev/null +++ b/apps/web/app/components/agentui/markdown.tsx @@ -0,0 +1,292 @@ +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} +
    + ) : ( +
      + {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; + } + })} +
    + ); +}