From 105120f22f3327b08c41ee12e6931e8f3990b31e Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Fri, 18 Sep 2026 12:51:55 +0800 Subject: [PATCH] =?UTF-8?q?=E6=88=90=E6=9E=9C=E5=8C=BA=E6=B8=B2=E6=9F=93?= =?UTF-8?q?=20Markdown=EF=BC=9A=E3=80=8C1+1=20=E7=AD=89=E4=BA=8E=20**2**?= =?UTF-8?q?=E3=80=8D=E4=B8=8D=E5=86=8D=E5=8E=9F=E6=A0=B7=E5=B8=A6=E7=9D=80?= =?UTF-8?q?=E6=98=9F=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 模型几乎总是用 Markdown 组织回答,而成果区是 whitespace-pre-wrap 的纯文本, 于是它的结构全丢了:标题、列表、表格、粗体一律变成满屏符号。 **手写而不是装库。** 依赖只有七个、全是必需的,react-markdown 那一串 (unified / mdast / micromark)比这个文件大一个数量级。而要渲染的东西是 有限的——统计了库里 252 段真实输出,出现过的构造只有: 有序列表 40% · 无序列表 15% · 粗体 13% · 标题 3% · 行内代码 3% 表格 3% · 分隔线 2% · 代码块 1% · 引用 1% · 斜体 1% · 链接 0.8% 所以只做这些,**不声称支持完整 CommonMark**。认不出的语法原样当文本显示 ——那正是现在的行为,下限不会比今天差。包大小 +37KB(gzip +12KB)。 安全:全程构造 React 元素,一处 dangerouslySetInnerHTML 都没有。模型输出 是不可信内容,走 innerHTML 等于给自己开个 XSS 口子。链接只放行 http/https/mailto,`javascript:` 退回纯文本。 一个只有拿真实输出才会撞上的 bug:模型很爱写 **`table_name`**(粗体套 代码),而粗体在扫描顺序上先命中,把那对反引号当普通文本吃掉了——渲染出来 是加粗的 `v_demo_table` 带着反引号。强调内部要再解析一层(深度卡在 三层,每层文本严格更短)。另外把模块级正则的 g 标志去掉了:lastIndex 有 状态,递归调用时会从上一层留下的位置接着扫。 审批卡里的待审草稿也走同一个渲染——要人家判断"这条能不能发",却让他对着 一堆星号看,是在给审批添难度。 check-stream 补 12 条,测例是从库里导出的真实输出 + 一段构造的恶意输入 (真实输出不会自带攻击,但渲染层要么天生免疫,要么就是个口子):残留标记、 粗体/代码/列表/表格、粗体套代码、javascript: 链接不可点、正常链接可点、 原始 HTML 不变成真标签且当文本显示、注入脚本没执行。 --- frontend/src/dev/preview.tsx | 34 ++ frontend/src/run/AssistantStream.tsx | 20 +- frontend/src/run/Markdown.tsx | 294 ++++++++++++++++++ frontend/src/run/RunPanel.tsx | 7 +- .../src/run/__tests__/markdown-samples.json | 6 + scripts/check-stream.mjs | 50 +++ 6 files changed, 404 insertions(+), 7 deletions(-) create mode 100644 frontend/src/run/Markdown.tsx create mode 100644 frontend/src/run/__tests__/markdown-samples.json diff --git a/frontend/src/dev/preview.tsx b/frontend/src/dev/preview.tsx index 225b2668..021f6d98 100644 --- a/frontend/src/dev/preview.tsx +++ b/frontend/src/dev/preview.tsx @@ -2,9 +2,11 @@ import { useState } from 'react' import { createRoot } from 'react-dom/client' import { Database, MessageSquare } from 'lucide-react' import { AssistantStream, StreamEmpty, type StreamTurn } from '../run/AssistantStream' +import { Markdown } from '../run/Markdown' import { decodeRun } from '../run/decode' import { ToastHost } from '../components/ui' import fixtures from '../run/__tests__/fixtures.json' +import mdSamples from '../run/__tests__/markdown-samples.json' import '../index.css' /** @@ -52,11 +54,41 @@ function buildTurns(): StreamTurn[] { }) } +/** + * 一段恶意输入。这类测例必须是构造的——真实输出不会自带攻击,但模型输出 + * 是不可信内容,渲染层要么天生免疫,要么就是个 XSS 口子。 + */ +const HOSTILE = [ + '正常文本 **加粗**。', + '', + '', + '', + '[点我](javascript:window.__pwned=1)', + '[正常链接](https://example.com)', + '', + '``', +].join('\n') + +/** Markdown 渲染用真实输出验,不用编的样本 */ +function MarkdownCases() { + return ( +
+ {[...(mdSamples as string[]), HOSTILE].map((t, i) => ( +
+
真实输出 #{i + 1}
+ +
+ ))} +
+ ) +} + function Preview() { // ?case=db 只看一个用例,?theme=light 直接出浅色——都是为了截图可复现, // 不用靠点按钮 const params = new URLSearchParams(location.search) const only = params.get('case') + const md = params.get('md') === '1' if (params.get('theme') === 'light') document.documentElement.dataset.theme = 'light' const [all] = useState(buildTurns) const turns = only ? all.filter((t) => t.id === only) : all @@ -71,6 +103,8 @@ function Preview() { /> ) + if (md) return
+ return (
diff --git a/frontend/src/run/AssistantStream.tsx b/frontend/src/run/AssistantStream.tsx index d5b656b8..9c0a3f9c 100644 --- a/frontend/src/run/AssistantStream.tsx +++ b/frontend/src/run/AssistantStream.tsx @@ -8,6 +8,7 @@ import { Spinner } from '../components/ui' import { parseQueryResult, type ResultTable as Table, type Step, type StepKind, } from './decode' +import { Markdown } from './Markdown' /** * 助手流:一轮轮「你问什么 → 它做了什么 → 结果」。 @@ -364,14 +365,23 @@ function OutputValue({ value, dense }: { value: unknown; dense: boolean }) { // 不设上限的话,一次没走查询工具、直接把几千行塞进 output 的运行会 // 把整条流卡住——列表本来就没做虚拟化 const long = text.length > TEXT_CAP + const shown = long && !full ? text.slice(0, TEXT_CAP) : text + return ( -
- {long && !full ? text.slice(0, TEXT_CAP) : text} +
+ {/* 模型几乎总是用 Markdown 组织回答。当纯文本显示的话,「1+1 等于 **2**」 + 就原样带着星号——它的结构全丢了。只有字符串才渲染:JSON.stringify + 出来的东西按 Markdown 解会被 * 和 _ 搅乱 */} + {typeof value === 'string' + ? + : ( +
{shown}
+ )} {long && ( - )}
diff --git a/frontend/src/run/Markdown.tsx b/frontend/src/run/Markdown.tsx new file mode 100644 index 00000000..a4ec5b12 --- /dev/null +++ b/frontend/src/run/Markdown.tsx @@ -0,0 +1,294 @@ +import { Fragment, type ReactNode } from 'react' +import clsx from 'clsx' + +/** + * 模型输出的 Markdown 渲染。 + * + * 在此之前成果区是 whitespace-pre-wrap 的纯文本,于是「1+1 等于 **2**」就 + * 原样带着星号显示——模型几乎总是用 Markdown 组织回答,不渲染等于把它的 + * 结构全丢了。 + * + * **手写而不是装库。** 依赖只有七个,全是必需的;react-markdown 那一串 + * (unified / mdast / micromark)比这个文件大一个数量级,而实际要渲染的 + * 东西是有限的——统计了库里 252 段真实输出,出现过的构造只有下面这些: + * + * 有序列表 40% · 无序列表 15% · 粗体 13% · 标题 3% · 行内代码 3% + * 表格 3% · 分隔线 2% · 代码块 1% · 引用 1% · 斜体 1% · 链接 0.8% + * + * 所以这里只做这些,**不声称支持完整 CommonMark**。认不出来的语法原样当 + * 文本显示——那正是现在的行为,所以下限不会比今天差;能认出来的就渲染对。 + * + * 全程构造 React 元素,一处 dangerouslySetInnerHTML 都没有。模型输出是不 + * 可信内容,走 innerHTML 就等于给自己开了个 XSS 口子。 + */ +export function Markdown({ text, dense }: { text: string; dense?: boolean }) { + if (!text?.trim()) return null + return ( +
+ {parseBlocks(text).map((b, i) => )} +
+ ) +} + +// ------------------------------------------------------------------------- +// 块级 +// ------------------------------------------------------------------------- + +type Block = + | { kind: 'p'; text: string } + | { kind: 'h'; level: number; text: string } + | { kind: 'code'; lang: string; code: string } + | { kind: 'quote'; text: string } + | { kind: 'hr' } + | { kind: 'list'; ordered: boolean; items: { text: string; depth: number }[] } + | { kind: 'table'; head: string[]; rows: string[][] } + +const FENCE = /^\s*```(\w*)\s*$/ +const HEADING = /^(#{1,6})\s+(.*)$/ +const HR = /^\s*(-{3,}|\*{3,}|_{3,})\s*$/ +const BULLET = /^(\s*)[-*+]\s+(.*)$/ +const ORDERED = /^(\s*)\d+[.)]\s+(.*)$/ +const QUOTE = /^\s*>\s?(.*)$/ +const TABLE_ROW = /^\s*\|(.+)\|\s*$/ +const TABLE_SEP = /^\s*\|[\s:|-]+\|\s*$/ + +function parseBlocks(text: string): Block[] { + const lines = text.replace(/\r\n?/g, '\n').split('\n') + const out: Block[] = [] + let i = 0 + + while (i < lines.length) { + const line = lines[i] + + if (!line.trim()) { i += 1; continue } + + // 代码块优先:里面的一切都不当语法看 + const fence = line.match(FENCE) + if (fence) { + const body: string[] = [] + i += 1 + while (i < lines.length && !FENCE.test(lines[i])) { body.push(lines[i]); i += 1 } + i += 1 // 吃掉收尾的 ``` + out.push({ kind: 'code', lang: fence[1] || '', code: body.join('\n') }) + continue + } + + // 分隔线要在列表之前判:--- 也能匹配 BULLET + if (HR.test(line)) { out.push({ kind: 'hr' }); i += 1; continue } + + const heading = line.match(HEADING) + if (heading) { + out.push({ kind: 'h', level: heading[1].length, text: heading[2] }) + i += 1 + continue + } + + // 表格:一行 | ... | 后面紧跟一行分隔行,两者都有才算 + if (TABLE_ROW.test(line) && i + 1 < lines.length && TABLE_SEP.test(lines[i + 1])) { + const head = splitRow(line) + const rows: string[][] = [] + i += 2 + while (i < lines.length && TABLE_ROW.test(lines[i])) { rows.push(splitRow(lines[i])); i += 1 } + out.push({ kind: 'table', head, rows }) + continue + } + + const quote = line.match(QUOTE) + if (quote) { + const body = [quote[1]] + i += 1 + while (i < lines.length && QUOTE.test(lines[i])) { + body.push(lines[i].match(QUOTE)![1]); i += 1 + } + out.push({ kind: 'quote', text: body.join('\n') }) + continue + } + + const bullet = line.match(BULLET) + const ordered = line.match(ORDERED) + if (bullet || ordered) { + // 一段连续的列表算一块。有序和无序混排时按第一条定性——模型不常这么写, + // 真遇上了也比拆成两块好看 + const isOrdered = !!ordered + const items: { text: string; depth: number }[] = [] + while (i < lines.length) { + const m = lines[i].match(BULLET) || lines[i].match(ORDERED) + if (m) { + items.push({ text: m[2], depth: Math.floor(m[1].replace(/\t/g, ' ').length / 2) }) + i += 1 + } else if (lines[i].trim() && !isBlockStart(lines[i]) && items.length) { + // 悬挂缩进的续行接到上一条上,不要另起一段 + items[items.length - 1].text += '\n' + lines[i].trim() + i += 1 + } else break + } + out.push({ kind: 'list', ordered: isOrdered, items }) + continue + } + + // 普通段落:一直吃到空行或下一个块开头 + const para = [line] + i += 1 + while (i < lines.length && lines[i].trim() && !isBlockStart(lines[i])) { + para.push(lines[i]); i += 1 + } + out.push({ kind: 'p', text: para.join('\n') }) + } + return out +} + +function isBlockStart(line: string): boolean { + return FENCE.test(line) || HR.test(line) || HEADING.test(line) + || BULLET.test(line) || ORDERED.test(line) || QUOTE.test(line) || TABLE_ROW.test(line) +} + +function splitRow(line: string): string[] { + return line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map((c) => c.trim()) +} + +function Block({ block, dense }: { block: Block; dense?: boolean }) { + switch (block.kind) { + case 'h': { + // 侧栏只有 360px 宽,一级标题按 h1 的字号会大得不像话。 + // 按层级递减但整体压扁,保住层次感就够了 + const size = ['text-[14px]', 'text-[13px]', 'text-[12.5px]'][Math.min(block.level, 3) - 1] + return ( +
+ +
+ ) + } + case 'hr': + return
+ case 'code': + return ( +
+          {block.code}
+        
+ ) + case 'quote': + return ( +
+ +
+ ) + case 'list': { + const Tag = block.ordered ? 'ol' : 'ul' + return ( + + {block.items.map((it, i) => ( +
  • + +
  • + ))} +
    + ) + } + case 'table': + return ( +
    + + + + {block.head.map((h, i) => ( + + ))} + + + + {block.rows.map((row, i) => ( + + {/* 用表头的列数对齐:模型偶尔会少写一格,少的补空, + 多的截掉——否则整张表会错位 */} + {block.head.map((_, j) => ( + + ))} + + ))} + +
    + +
    + +
    +
    + ) + default: + return

    + } +} + +// ------------------------------------------------------------------------- +// 行内 +// ------------------------------------------------------------------------- + +// 顺序重要:行内代码最先,它里面的一切都不再当语法看;然后链接、粗体、 +// 斜体。斜体放最后且要求两侧不是 *,否则 **粗体** 会被它先拆掉。 +// 不带 g:每次用的时候按 source 新建一个带 g 的,避免 lastIndex 被共享 +const INLINE = new RegExp([ + '(`+)([^`]+?)\\1', // `code` + '\\[([^\\]]+)\\]\\(([^)\\s]+)[^)]*\\)', // [text](url) + '\\*\\*([^*]+?)\\*\\*', // **bold** + '__([^_]+?)__', // __bold__ + '(? last) out.push(text.slice(last, m.index)) + const [, , code, linkText, href, bold1, bold2, italic, strike] = m + + // 强调的内容要再解析一层。模型很爱写 **`table_name`**(粗体里套代码), + // 而粗体在扫描顺序上先命中,不递归的话那对反引号就原样显示出来了。 + // 深度有限:每层的文本都严格更短,且这里也卡了三层 + const sub = (t: string) => + depth < 3 ? : t + + if (code !== undefined) { + // 代码内部不再解析——这正是代码的含义 + out.push( + {code}, + ) + } else if (linkText !== undefined) { + out.push() + } else if (bold1 !== undefined || bold2 !== undefined) { + out.push({sub(bold1 ?? bold2)}) + } else if (italic !== undefined) { + out.push({sub(italic)}) + } else if (strike !== undefined) { + out.push({sub(strike)}) + } + last = m.index + m[0].length + } + if (last < text.length) out.push(text.slice(last)) + return <>{out.map((n, i) => {n})} +} + +/** + * 链接。 + * + * 只放行 http/https/mailto。模型输出是不可信内容,`javascript:` 开头的 + * href 点一下就是脚本执行——这里不认的协议就退回纯文本显示,宁可少一个 + * 可点链接,也不留这个口子。 + */ +function Link({ href, text }: { href: string; text: string }) { + const safe = /^(https?:|mailto:)/i.test(href.trim()) + if (!safe) return <>{text} + return ( + + {text} + + ) +} diff --git a/frontend/src/run/RunPanel.tsx b/frontend/src/run/RunPanel.tsx index a7474609..0ce5f98c 100644 --- a/frontend/src/run/RunPanel.tsx +++ b/frontend/src/run/RunPanel.tsx @@ -5,6 +5,7 @@ import { useStudio } from '../store/studio' import { useCatalog } from '../store/catalog' import { NODE_DEFS } from '../canvas/nodeDefs' import { StatusDot, useToast } from '../components/ui' +import { Markdown } from './Markdown' // 发起运行的表单搬去了 run/RunControl.tsx 的工具栏控件里。它原先常驻在 // 助手栏顶部,和 Copilot 输入框两个"主要动作"互相压着——而它们是两种不同的 @@ -69,8 +70,10 @@ export function ApprovalCard({ approval, onResolved }: {
    {payload.message && ( -
    - {payload.message} + // 待审的往往是一整段模型写的文案,同样是 Markdown。要人家判断"能不能 + // 发",却让他对着一堆星号看,是在给审批这件事添难度 +
    +
    )} diff --git a/frontend/src/run/__tests__/markdown-samples.json b/frontend/src/run/__tests__/markdown-samples.json new file mode 100644 index 00000000..7ea1b622 --- /dev/null +++ b/frontend/src/run/__tests__/markdown-samples.json @@ -0,0 +1,6 @@ +[ + "已完成查询。下面是结论。\n\n## 一、这个库里大致有哪些类型的数据\n\n这个公共自行车数仓(ANALYTICS schema,共 41 张表/视图)按业务域大致可分为以下几类:\n\n1. **骑行事实类**(`v_trip_fact`、`v_trip_fact_hist`、`v_trip_anomaly`)——单次骑行的起讫站点、时长、里程、计费与异常标记。\n2. **站点与车辆主数据**(`v_station_info`、`v_station_capacity`、`v_bike_master`、`v_lock_status`)——站点坐标、桩位数、车辆型号与锁具状态。\n3. **调度运维类**(`v_dispatch_task`、`v_dispatch_exec`、`v_maint_order`)——调度工单、执行记录、维修派单。\n4. **会员与计费类**(`v_member_profile`、`v_card_bind`、`v_billing_detail`)——会员档案、卡绑定、扣费明细。\n5. **其他基础/运维类**(`etl_audit`、`v_dict_region`、`dq_check_log` 等)。\n\n## 二、最大的一张表:`ANALYTICS.v_trip_fact`\n\n通过逐张 `COUNT(*)` 实测,已确认的最大表是骑行事实表:\n\n- **行数**:约 **1,031,592 行**(103 万+),明显超过第二大的 `v_billing_detail`(约 81 万行)和 `v_dispatch_exec`(约 50 万行)。\n- **视图含义**:单次骑行的明细事实,一行即一次完整骑行。\n\n### 字段结构\n\n| 字段 | 类型 | 说明 |\n|------|------|------|\n| trip_id | VARCHAR(32) | 骑行流水号(非空) |\n| bike_code | VARCHAR(20) | 车辆编号 |\n| start_station | VARCHAR(100) | 起点站点编码(非空) |\n| end_station | VARCHAR(100) | 终点站点编码 |\n| start_time | DATE | 开锁时间(非空) |\n| duration_min | INTEGER | 骑行时长(分钟) |\n| fee | NUMBER | 计费金额 |\n| load_time | DATE | 加载时间 |\n\n### 这是一个典型的事实表(Fact Table)\n\n- **维度**:车辆、起点站、终点站、时间——四维组合。\n- **度量**:`duration_min`、`fee`。\n- **粒度**:一条记录 = 一次骑行,103 万行正是这些维度组合展开后的明细量级。\n\n### 能支持什么分析\n\n- 骑行量按**站点、时段、车型**多维度的趋势分析;\n- 潮汐分析:起讫站点配对,识别早晚高峰的净流入/净流出站点;\n- 按站点或区域做调度需求测算,支撑车辆投放与调度计划;\n- 与站点主数据(`v_station_info`、`v_station_capacity`)关联,补充坐标与桩位属性做更细的分析。\n\n> 补充说明:`v_billing_detail`(计费明细)的 `COUNT(*)` 查询因超过 30 秒被中断,未能拿到精确行数,从业务上看它也可能很大;但在已成功实测的对象中,**`v_trip_fact`(约 103 万行)是行数最大的表**。", + "我看了你提供的“多智能体研究结果”,发现它并不是实际的研究数据或整合后的观点,而是一段**任务接收与流程说明**(包括检索方式、整合方法、输出框架,以及“请把问题发我”的结尾)。\n\n因此,我暂时无法基于这段内容生成一个实质性的、面向用户的最终答案——因为里面没有可供提炼的事实、观点、共识或分歧。\n\n### 我需要你补充以下任一信息,才能继续:\n\n1. **实际研究结果** \n 例如多智能体检索到的关键事实、数据、来源链接、不同观点摘要、争议点等。 \n 把这些内容粘贴给我,我会按你要求的框架整理成最终答案。\n\n2. **明确要回答的问题** \n 如果你希望我直接回答类似「大模型推理成本为何下降这么快?」「2025年哪些行业最适合AI落地?」「如何评估Agent框架的选型?」这类问题,请指定一个或多个,我可以基于已有知识按框架输出,但需要说明这不是基于你提供的“研究结果”。\n\n3. **确认是否粘贴有误** \n 如果这段文字就是你希望我处理的全部内容,可能你原本想粘贴的研究材料没有成功发送。请重新复制实际结果后再发一次。\n\n---\n\n只要你补齐材料或明确需求,我会立刻按「问题拆解 → 核心结论 → 分论点论证 → 争议/不确定点 → 待补充信息」的结构交付完整答案。", + "**改写后:**\n\n「今天骑到哪了」今日上线。\n\n有些记录很短,却足以标记一段路程。\n你骑过的街,我们记着;你想回看,随时可查。\n\n在这里,留下你的轨迹,让每一程都有去处。\n\n主要调整:去掉\"运动伙伴\"\"智能陪伴\"等偏功能化、口语化的表达,换用更克制、留白的说法,整体节奏更短,语感更安静。", + "这个库里调度运维相关的数据视图(共 12 张,另有对应的 `hist_` 历史视图):\n\n| 视图 | 说明 |\n|---|---|\n| `v_dispatch_plan` | 班次调度计划表 |\n| `v_dispatch_task` | 调度工单 |\n| `v_dispatch_exec` | 调度执行记录 |\n| `v_dispatch_crew` | 调度人员工时明细 |\n| `v_maint_order` | 维修派单 |\n| `v_maint_fault` | 故障明细 |\n| `v_lock_status` | 锁具状态快照 |\n| `v_bike_health` | 车辆健康评分 |\n| `v_region_quota` | 区域投放配额 |\n| `v_dispatch_hist_*` | 上述各表的历史版本 |\n\n## 挑一个有数据的视图:`ANALYTICS.v_maint_fault`(故障明细表)\n\n- **行数:365,633 行**\n- **数据加载时间**:最近一次 `load_time` 为 **2026-09-17 07:23:54**\n- **业务发生时间**:表内最新的故障上报时间 `report_time` 为 **2026-09-17 06:53**\n\n也就是说,这份数据不仅量大(36.5 万+行),而且很新——最新一条故障上报发生在 2026-09-17 上午 06:53,几乎就是数据集成的时点附近。\n\n(对比:另外两张测过的 `v_dispatch_crew` 只有 246 行、`v_dispatch_plan` 只有 43 行,明显数据量少,所以选了 `v_maint_fault` 作为\"有数据\"的代表。)" +] diff --git a/scripts/check-stream.mjs b/scripts/check-stream.mjs index 457aba45..d11753c5 100644 --- a/scripts/check-stream.mjs +++ b/scripts/check-stream.mjs @@ -65,6 +65,56 @@ console.log('\n=== 展开交互 ===') await page.close() } +console.log('\n=== Markdown 渲染 ===') +{ + // 用库里导出的**真实输出**,不是编的样本:模型写什么才是要渲染的, + // 按 CommonMark 规范挑测例只会测到用不上的角落 + const page = await browser.newPage({ viewport: { width: 1100, height: 900 } }) + const errors = [] + page.on('pageerror', (e) => errors.push(e.message)) + await page.goto(`${WEB}/preview.html?md=1`, { waitUntil: 'networkidle' }) + await page.waitForTimeout(600) + check('没有运行时报错', errors.length === 0, errors.join(' | ')) + + const text = await page.locator('body').innerText() + // 「1+1 等于 **2**」原样带着星号显示——这就是改之前的样子 + const stray = [...text.matchAll(/\*\*[^*\n]{1,40}\*\*|(? m[0]) + check('没有残留的 markdown 标记', stray.length === 0, stray.slice(0, 4).join(' ')) + + const el = await page.evaluate(() => ({ + strong: document.querySelectorAll('strong').length, + code: document.querySelectorAll('code').length, + li: document.querySelectorAll('li').length, + table: document.querySelectorAll('table').length, + quote: document.querySelectorAll('blockquote').length, + // 模型很爱写 **`table_name`**。粗体在扫描顺序上先命中,强调内部不再 + // 解析的话,那对反引号会原样显示出来 + codeInStrong: document.querySelectorAll('strong code').length, + // 模型输出不可信,链接必须挡住 javascript: + badHref: [...document.querySelectorAll('a')] + .filter((a) => !/^(https?:|mailto:)/i.test(a.getAttribute('href') || '')).length, + // 只数 markdown 渲染出来的,不数整页——dev server 自己就注入 HMR 脚本 + rawHtml: [...document.querySelectorAll('main, .space-y-2')] + .reduce((n, el) => n + el.querySelectorAll('script,iframe,object,embed,img').length, 0), + goodHref: [...document.querySelectorAll('a')] + .filter((a) => /^https?:/i.test(a.getAttribute('href') || '')).length, + pwned: window.__pwned === 1, + })) + check('粗体渲染出来了', el.strong > 0, `${el.strong} 处`) + check('行内代码渲染出来了', el.code > 0, `${el.code} 处`) + check('列表渲染出来了', el.li > 0, `${el.li} 条`) + check('表格渲染出来了', el.table > 0, `${el.table} 张`) + check('粗体里的代码也解析', el.codeInStrong > 0, `${el.codeInStrong} 处`) + // 样本里混了一段构造的恶意输入(真实输出不会自带攻击,但模型输出是不可信 + // 内容,渲染层要么天生免疫,要么就是个 XSS 口子) + check('javascript: 链接没被渲染成可点的', el.badHref === 0, `${el.badHref} 个`) + check('正常链接照常可点', el.goodHref > 0, `${el.goodHref} 个`) + check('原始 HTML 没有变成真标签', el.rawHtml === 0, `${el.rawHtml} 个`) + check('注入的脚本没有执行', el.pwned === false) + check('原始 HTML 当文本显示出来了', text.includes('