feat(web): render agent reports as safe-subset Markdown
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
b7cbc06567
commit
41e3647f0a
3 files changed
+370
-2
No files matched your search
@@ -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 : <Markdown text={it.text} />}
|
||||
</StreamingResponse>
|
||||
</AssistantRow>
|
||||
);
|
||||
@@ -724,7 +726,7 @@ export function FinalReport({
|
||||
模型说的(未经核查)
|
||||
</p>
|
||||
<StreamingResponse status="complete" copyText={said || undefined} contentClassName="max-h-60 overflow-auto">
|
||||
{said || "(没有文字汇报)"}
|
||||
{said ? <Markdown text={said} /> : "(没有文字汇报)"}
|
||||
</StreamingResponse>
|
||||
<p className="mt-2 text-meta text-muted-foreground">仅供参考,不作为完成依据。</p>
|
||||
</div>
|
||||
|
||||
@@ -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("<h4");
|
||||
expect(out).toContain("<strong");
|
||||
expect(out).toContain("<table");
|
||||
expect(out).toContain("<td");
|
||||
expect(out).toContain("沙丘:预言");
|
||||
expect(out).toContain('<ol start="1"');
|
||||
expect(out).toContain("<ul");
|
||||
expect(out).toContain("<blockquote");
|
||||
expect(out).toContain("<pre");
|
||||
expect(out).toContain("text-align:right");
|
||||
});
|
||||
|
||||
it("原文里的 HTML 按文字显示;危险链接不生成 <a>", () => {
|
||||
const out = html(
|
||||
"<script>alert(1)</script> <img src=x onerror=alert(1)> [点我](javascript:alert(1)) [文档](https://example.com/a)",
|
||||
);
|
||||
expect(out).not.toContain("<script");
|
||||
expect(out).not.toContain("<img");
|
||||
expect(out).toContain("<script>");
|
||||
expect(out).not.toContain('href="javascript');
|
||||
expect(out).toContain('href="https://example.com/a"');
|
||||
expect(out).toContain('rel="noopener noreferrer nofollow"');
|
||||
expect(safeHref("/console/tasks")).toBe("/console/tasks");
|
||||
expect(safeHref("//evil.example")).toBeNull();
|
||||
expect(safeHref("data:text/html,x")).toBeNull();
|
||||
});
|
||||
|
||||
it("行内:代码里的星号不当强调;单换行保留为换行", () => {
|
||||
const out = html("用 `a*b*c` 查询\n第二行 *强调*");
|
||||
expect(out).toContain("<code");
|
||||
expect(out).toContain("a*b*c");
|
||||
expect(out).toContain("<br/>");
|
||||
expect(out).toContain("<em>强调</em>");
|
||||
});
|
||||
|
||||
it("空文本不渲染", () => {
|
||||
expect(html(" \n\n")).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -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][^*]*?)\*|(?<![\w])_([^_\s][^_]*?)_(?![\w])/g;
|
||||
|
||||
export function renderInline(text: string, keyBase = "i"): ReactNode[] {
|
||||
const out: ReactNode[] = [];
|
||||
let last = 0;
|
||||
let n = 0;
|
||||
for (const m of text.matchAll(INLINE)) {
|
||||
const at = m.index ?? 0;
|
||||
if (at > last) out.push(...withBreaks(text.slice(last, at), `${keyBase}-t${n}`));
|
||||
const key = `${keyBase}-${n++}`;
|
||||
if (m[1])
|
||||
out.push(
|
||||
<code key={key} className="rounded-tight bg-foreground/[0.07] px-1 py-px font-mono text-meta">
|
||||
{m[2]}
|
||||
</code>,
|
||||
);
|
||||
else if (m[3] !== undefined || m[4] !== undefined)
|
||||
out.push(
|
||||
<strong key={key} className="font-semibold text-foreground">
|
||||
{renderInline(m[3] ?? m[4] ?? "", key)}
|
||||
</strong>,
|
||||
);
|
||||
else if (m[5] !== undefined) out.push(<del key={key}>{renderInline(m[5], key)}</del>);
|
||||
else if (m[6] !== undefined) {
|
||||
const href = safeHref(m[7] ?? "");
|
||||
out.push(
|
||||
href ? (
|
||||
<a
|
||||
key={key}
|
||||
href={href}
|
||||
target={href.startsWith("/") ? undefined : "_blank"}
|
||||
rel="noopener noreferrer nofollow"
|
||||
className="text-primary underline underline-offset-2"
|
||||
>
|
||||
{renderInline(m[6], key)}
|
||||
</a>
|
||||
) : (
|
||||
<Fragment key={key}>{m[0]}</Fragment>
|
||||
),
|
||||
);
|
||||
} else out.push(<em key={key}>{renderInline(m[8] ?? m[9] ?? "", key)}</em>);
|
||||
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] : [<br key={`${key}-${k}`} />, 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 (
|
||||
<div data-slot="markdown" className={cn("space-y-2 whitespace-normal", className)}>
|
||||
{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 (
|
||||
<Tag key={key} className={cn("text-foreground", H_CLASS[Math.min(2, b.level - 1)])}>
|
||||
{renderInline(b.text, key)}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
case "paragraph":
|
||||
return <p key={key}>{renderInline(b.text, key)}</p>;
|
||||
case "quote":
|
||||
return (
|
||||
<blockquote key={key} className="border-l-2 border-border pl-3 text-muted-foreground">
|
||||
{renderInline(b.text, key)}
|
||||
</blockquote>
|
||||
);
|
||||
case "hr":
|
||||
return <hr key={key} className="border-border/70" />;
|
||||
case "code":
|
||||
return (
|
||||
<pre
|
||||
key={key}
|
||||
className="overflow-x-auto rounded-lg bg-foreground/[0.05] p-2 font-mono text-meta leading-5"
|
||||
>
|
||||
<code>{b.text}</code>
|
||||
</pre>
|
||||
);
|
||||
case "list": {
|
||||
const items = b.items.map((it, j) => (
|
||||
<li key={`${key}-${j}`} className="pl-0.5">
|
||||
{renderInline(it, `${key}-${j}`)}
|
||||
</li>
|
||||
));
|
||||
return b.ordered ? (
|
||||
<ol key={key} start={b.start} className="list-decimal space-y-0.5 pl-5">
|
||||
{items}
|
||||
</ol>
|
||||
) : (
|
||||
<ul key={key} className="list-disc space-y-0.5 pl-5">
|
||||
{items}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
case "table":
|
||||
return (
|
||||
<div key={key} className="overflow-x-auto">
|
||||
<table className="w-full border-collapse text-meta">
|
||||
<thead>
|
||||
<tr>
|
||||
{b.head.map((h, j) => (
|
||||
<th
|
||||
key={`${key}-h${j}`}
|
||||
style={{ textAlign: b.align[j] ?? "left" }}
|
||||
className="border-b border-border px-2 py-1 font-semibold text-foreground"
|
||||
>
|
||||
{renderInline(h, `${key}-h${j}`)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{b.rows.map((r, j) => (
|
||||
<tr key={`${key}-r${j}`} className="border-b border-border/50">
|
||||
{r.map((c, x) => (
|
||||
<td
|
||||
key={`${key}-r${j}-${x}`}
|
||||
style={{ textAlign: b.align[x] ?? "left" }}
|
||||
className="px-2 py-1 align-top"
|
||||
>
|
||||
{renderInline(c, `${key}-r${j}-${x}`)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user