Render chat and previews through the shared HTML cleaner

Why: The conversation and office fallbacks wrote marked output straight into the page, including converted docx HTML.
Evidence: html-sinks ratchet forbids new innerHTML writes outside @ui safeHtml.
Impact: Conversation keeps line-break rendering. Unsafe links are stripped after docx and pptx preview. dompurify is deduped with the UI package.
Refs: feature/sillytavern-review
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-04 17:28:46 +00:00
co-authored by Cursor
parent e26119db89
commit 1f42edf80a
7 changed files with 63 additions and 12 deletions
+1 -1
View File
@@ -10,4 +10,4 @@
- `npx tsc -b` — 类型检查;`npm run build` — 产物 dist/(Dev Makefile 拷贝至 backend web/dist 内嵌)。
- `npm test` — vitest:契约形状测试(Core 黄金样本驱动的判别联合/事件信封/HITL impact/工具三清单断言)。
- 开发:`npm run dev`(Vite 代理 /api → 127.0.0.1:8787)。
- 依赖刻意最小:react + marked;无 UI 框架(设计令牌手写,见 styles.css)。
- 依赖刻意最小:react + marked + dompurify(模型输出与文档预览先清洗再写入页面);无 UI 框架(设计令牌手写,见 styles.css)。
+17
View File
@@ -9,6 +9,7 @@
"version": "0.1.0",
"dependencies": {
"docx-preview": "^0.4.1",
"dompurify": "^3.4.16",
"jszip": "^3.10.2",
"mammoth": "^1.13.0",
"marked": "^15.0.7",
@@ -1725,6 +1726,13 @@
"@types/react": "^19.3.0"
}
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
},
"node_modules/@vitejs/plugin-react": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@@ -2094,6 +2102,15 @@
"jszip": ">=3.0.0"
}
},
"node_modules/dompurify": {
"version": "3.4.16",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.16.tgz",
"integrity": "sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/duck": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/duck/-/duck-0.1.12.tgz",
+1
View File
@@ -11,6 +11,7 @@
},
"dependencies": {
"docx-preview": "^0.4.1",
"dompurify": "^3.4.16",
"jszip": "^3.10.2",
"mammoth": "^1.13.0",
"marked": "^15.0.7",
+5 -6
View File
@@ -1,9 +1,8 @@
// 中栏:消息流(Part 渲染器注册表)+ 流式渲染 + 过程折叠 + HITL 卡片 + 输入区(4.4)。
// 重设计方案 阶段④:折叠区全部迁 @ui Collapsible(<details> 已禁);工具调用块走
// ToolBlock 契约(运行态扫光、完成边沿收起、展开态记忆);live 行扫光 + 已工作计时。
import { Icon, Avatar, BrandMark, Collapsible, getPersistedOpen, setPersistedOpen } from '@ui'
import { Icon, Avatar, BrandMark, Collapsible, getPersistedOpen, setPersistedOpen, SafeMarkdown } from '@ui'
import { useEffect, useMemo, useRef, useState } from 'react'
import { marked } from 'marked'
import { api } from '../api'
import { store, useStore, activeRun, type LiveState, type ContextObs } from '../store'
import { scrollRatio, scrollTopForRatio } from '../scrollsync'
@@ -19,15 +18,15 @@ function hasFiles(dt: DataTransfer | null): boolean {
return !!dt && Array.from(dt.types).includes('Files')
}
// marked 走标准 Markdown 语义(不开 breaks):模型输出按块级排版渲染,
// 行内换行交给段落折叠,避免与 CSS 块间距叠加出松散排版。
// 对话正文沿用 breaks:卡片渲染器曾经把全局 marked 设成 breaks,
// 去重后的同一份 marked 让对话也按行内换行显示。这里显式保持这个效果。
// Part 渲染器注册表:按类型查表;未知类型静默忽略(前向兼容)。
function PartView({ part, live }: { part: Part; live?: boolean | null }) {
switch (part.type) {
case 'text':
return <div className={live ? 'part-text cursor-blink' : 'part-text'}
dangerouslySetInnerHTML={{ __html: marked.parse(markMissingArtifacts(part.text ?? '', id => !!findArtifact(id))) as string }} />
return <SafeMarkdown className={live ? 'part-text cursor-blink' : 'part-text'} breaks
markdown={markMissingArtifacts(part.text ?? '', id => !!findArtifact(id))} />
case 'thinking':
// 流式期间展开可实时看到思考;run 终态后收进过程折叠区,默认收起。
//(此单件形态用于过程组之外散落的思考;组内思考由 ProcessParts 渲染。)
+9 -4
View File
@@ -1,8 +1,7 @@
// 预览能力装配(裁定 D1–D4):把重解析器经 configureFilePreviewLoaders 注入 @ui。
// 全部 dynamic import——对应格式首次预览时才下载 chunk,主 bundle 不含解析器。
// 链接预览 fetcher 走服务端 /api/url-preview(SSRF 防护在服务端执行端)。
import { configureFilePreviewLoaders, configureLinkPreviewFetcher } from '@ui'
import { marked } from 'marked'
import { configureFilePreviewLoaders, configureLinkPreviewFetcher, renderMarkdown, setSafeHTML, stripUnsafeLinks } from '@ui'
import { auth } from './auth'
import type { LinkPreviewData } from '@ui'
@@ -28,7 +27,7 @@ const renderTextFallback = (el: HTMLElement, buf: ArrayBuffer, name: string | un
note.textContent = `「${name ?? '该文件'}」不是有效的 ${kindLabel} 文件(缺少 ZIP 结构),已按文本内容展示;需要正式格式请让智能体重新生成。`
const body = document.createElement('div')
body.className = 'doc-viewer'
body.innerHTML = marked.parse(new TextDecoder().decode(buf)) as string
setSafeHTML(body, renderMarkdown(new TextDecoder().decode(buf), { breaks: true }))
el.innerHTML = ''
el.append(note, body)
}
@@ -43,13 +42,18 @@ const docxLoader = async (el: HTMLElement, source: { contentUrl?: string; name?:
const { renderAsync } = await import('docx-preview')
el.innerHTML = ''
await renderAsync(buf, el, el, { className: 'docx', inWrapper: true, breakPages: true })
stripUnsafeLinks(el)
return
} catch (err) {
console.error('[preview:docx] page render failed, falling back', source.name, err)
}
const mammoth = await import('mammoth')
const { value } = await mammoth.convertToHtml({ arrayBuffer: buf })
el.innerHTML = `<div class="doc-viewer">${value}</div>`
el.innerHTML = ''
const body = document.createElement('div')
body.className = 'doc-viewer'
setSafeHTML(body, value)
el.append(body)
}
const xlsxLoader = async (el: HTMLElement, source: { contentUrl?: string; name?: string }) => {
@@ -120,6 +124,7 @@ const pptxLoader = async (el: HTMLElement, source: { contentUrl?: string; name?:
el.append(stage)
const previewer = init(stage, { width: stage.clientWidth || 900 })
await previewer.preview(buf)
stripUnsafeLinks(el)
}
export function wirePreviewCapabilities() {
+29
View File
@@ -0,0 +1,29 @@
// 棘轮:宿主侧的 HTML 写入必须走 @ui safeHtml,不能在组件里直接塞字符串。
import { describe, expect, it } from 'vitest'
import { readFileSync, readdirSync, statSync } from 'node:fs'
import { join, relative, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const SRC = join(dirname(fileURLToPath(import.meta.url)), '..', 'src')
function walk(dir: string, out: string[] = []): string[] {
for (const name of readdirSync(dir)) {
const p = join(dir, name)
if (statSync(p).isDirectory()) walk(p, out)
else if (/\.(ts|tsx)$/.test(name)) out.push(p)
}
return out
}
describe('html sinks', () => {
it('frontend components do not write HTML themselves', () => {
const hits: string[] = []
for (const file of walk(SRC)) {
const text = readFileSync(file, 'utf8')
const rel = relative(SRC, file)
if (text.includes('dangerouslySetInnerHTML')) hits.push(`${rel}: dangerouslySetInnerHTML`)
if (/innerHTML\s*=\s*(?!['"]['"])/.test(text)) hits.push(`${rel}: innerHTML`)
}
expect(hits).toEqual([])
})
})
+1 -1
View File
@@ -10,7 +10,7 @@ export default defineConfig({
resolve: {
alias: [{ find: '@ui', replacement: uiSrc }],
// 源码组合下 ui 仓有自己的 node_modules;dedupe 强制最终 bundle 用 frontend 的单副本。
dedupe: ['react', 'react-dom', 'marked'],
dedupe: ['react', 'react-dom', 'marked', 'dompurify'],
},
server: {
port: 5173,