From 1f42edf80a0b0d3f2dba1ea2f86681fee9e7b5ce Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 4 Oct 2026 17:28:46 +0000 Subject: [PATCH 1/5] 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 --- PROJECT.md | 2 +- package-lock.json | 17 +++++++++++++++++ package.json | 1 + src/components/Conversation.tsx | 11 +++++------ src/previewsetup.ts | 13 +++++++++---- test/html-sinks.test.ts | 29 +++++++++++++++++++++++++++++ vite.config.ts | 2 +- 7 files changed, 63 insertions(+), 12 deletions(-) create mode 100644 test/html-sinks.test.ts diff --git a/PROJECT.md b/PROJECT.md index 1635319..158e15d 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -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)。 diff --git a/package-lock.json b/package-lock.json index 982c1fb..630fced 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index e1e48bf..3943559 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/Conversation.tsx b/src/components/Conversation.tsx index 7e33872..3d42a84 100644 --- a/src/components/Conversation.tsx +++ b/src/components/Conversation.tsx @@ -1,9 +1,8 @@ // 中栏:消息流(Part 渲染器注册表)+ 流式渲染 + 过程折叠 + HITL 卡片 + 输入区(4.4)。 // 重设计方案 阶段④:折叠区全部迁 @ui Collapsible(
已禁);工具调用块走 // 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
!!findArtifact(id))) as string }} /> + return !!findArtifact(id))} /> case 'thinking': // 流式期间展开可实时看到思考;run 终态后收进过程折叠区,默认收起。 //(此单件形态用于过程组之外散落的思考;组内思考由 ProcessParts 渲染。) diff --git a/src/previewsetup.ts b/src/previewsetup.ts index 4c94b17..bf2e268 100644 --- a/src/previewsetup.ts +++ b/src/previewsetup.ts @@ -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 = `
${value}
` + 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() { diff --git a/test/html-sinks.test.ts b/test/html-sinks.test.ts new file mode 100644 index 0000000..b34e1b5 --- /dev/null +++ b/test/html-sinks.test.ts @@ -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([]) + }) +}) \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts index 36cb30d..bc3b2a0 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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, From 36b92ede88ad6a72db1a56269709f9e51b2c3742 Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 4 Oct 2026 18:20:10 +0000 Subject: [PATCH 2/5] Add secret, injection, and message controls to the workbench Why: Keys must be added without writing masks back, and a turn needs copy, exclude, and regenerate without deleting history. Evidence: npx tsc -b. Session notes and candidate switches call the new conversation and message routes. Impact: Settings gains key, lore, and slash-template panels. Excluded messages stay visible and are dimmed. Refs: docs/records/2026-10-04-sillytavern-review.md --- src/api.ts | 14 +++++ src/components/BorrowPanels.tsx | 104 +++++++++++++++++++++++++++++++ src/components/Conversation.tsx | 50 +++++++++++++-- src/components/SettingsModal.tsx | 2 + src/contracts.ts | 11 ++++ src/store.ts | 2 + src/styles.css | 3 + 7 files changed, 181 insertions(+), 5 deletions(-) create mode 100644 src/components/BorrowPanels.tsx diff --git a/src/api.ts b/src/api.ts index 5fe8429..1b5bb77 100644 --- a/src/api.ts +++ b/src/api.ts @@ -110,6 +110,20 @@ export const api = { listPlugins: () => auth.fetch(`${BASE}/api/plugins`).then(r => r.json()), reloadPlugins: () => auth.fetch(`${BASE}/api/plugins/reload`, { method: 'POST' }).then(r => r.json()), listCommands: () => auth.fetch(`${BASE}/api/commands`).then(r => r.json()) as Promise<{ name?: string; id?: string; title?: string; description?: string; danger?: string }[]>, + setSessionNote: (id: string, sessionNote: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionNote }) }).then(r => { if (!r.ok) throw new Error(`${r.status}`) }), + excludeMessage: (id: string, excluded: boolean) => auth.fetch(`${BASE}/api/messages/${id}/exclude`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ excluded }) }).then(r => { if (!r.ok) throw new Error(`${r.status}`) }), + selectVariant: (id: string) => auth.fetch(`${BASE}/api/messages/${id}/variant`, { method: 'POST' }).then(r => { if (!r.ok) throw new Error(`${r.status}`) }), + regenerate: (convId: string, messageId: string) => auth.fetch(`${BASE}/api/conversations/${convId}/regenerate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ messageId }) }).then(r => { if (!r.ok) throw new Error(`${r.status}: ${r.statusText}`) }), + listSecrets: () => auth.fetch(`${BASE}/api/secrets`).then(r => json<{ id: string; service: string; label: string; active: boolean; mask: string }[]>(r)), + createSecret: (service: string, label: string, value: string) => auth.fetch(`${BASE}/api/secrets`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ service, label, value }) }).then(r => json(r)), + activateSecret: (id: string) => auth.fetch(`${BASE}/api/secrets/${id}/activate`, { method: 'POST' }), + deleteSecret: (id: string) => auth.fetch(`${BASE}/api/secrets/${id}`, { method: 'DELETE' }), + listContextEntries: () => auth.fetch(`${BASE}/api/context-entries`).then(r => json<{ id: string; keys: string[]; secondary?: string; logic: string; content: string; constant: boolean; scanDepth: number; budgetChars: number; enabled: boolean }[]>(r)), + saveContextEntry: (body: Record) => auth.fetch(`${BASE}/api/context-entries`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).then(r => json(r)), + deleteContextEntry: (id: string) => auth.fetch(`${BASE}/api/context-entries/${id}`, { method: 'DELETE' }), + listPromptCommands: () => auth.fetch(`${BASE}/api/prompt-commands`).then(r => json<{ id: string; name: string; title: string; template: string }[]>(r)), + savePromptCommand: (body: { name: string; title: string; template: string }) => auth.fetch(`${BASE}/api/prompt-commands`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).then(r => json(r)), + deletePromptCommand: (id: string) => auth.fetch(`${BASE}/api/prompt-commands/${id}`, { method: 'DELETE' }), listWorkspaces: () => auth.fetch(`${BASE}/api/workspaces`).then(r => r.json()) as Promise<{ id: string; title: string; icon?: string; shortcut?: string; badgeTool?: string; body?: any[]; refreshTool?: string }[]>, } diff --git a/src/components/BorrowPanels.tsx b/src/components/BorrowPanels.tsx new file mode 100644 index 0000000..d8f8bca --- /dev/null +++ b/src/components/BorrowPanels.tsx @@ -0,0 +1,104 @@ +import { useEffect, useState } from 'react' +import { Btn, SettingRow, SettingsGroup } from '@ui' +import { api } from '../api' + +const services = [ + ['models.chat', '主模型'], + ['models.lightweight', '轻量模型'], + ['models.embedding', 'Embedding'], + ['models.rerank', 'Reranker'], + ['tools.tavily', 'Tavily'], +] as const + +type Secret = { id: string; service: string; label: string; active: boolean; mask: string } +type Entry = { id: string; keys: string[]; content: string; constant: boolean; logic: string } +type Cmd = { id: string; name: string; title: string; template: string } + +export function BorrowPanels() { + const [secrets, setSecrets] = useState([]) + const [entries, setEntries] = useState([]) + const [cmds, setCmds] = useState([]) + const [service, setService] = useState(services[0][0]) + const [label, setLabel] = useState('') + const [value, setValue] = useState('') + const [keys, setKeys] = useState('') + const [content, setContent] = useState('') + const [constant, setConstant] = useState(false) + const [cmdName, setCmdName] = useState('/summary') + const [cmdTitle, setCmdTitle] = useState('总结') + const [cmdBody, setCmdBody] = useState('请总结以上内容。\n{{input}}') + + const reload = () => { + api.listSecrets().then(setSecrets).catch(() => undefined) + api.listContextEntries().then(setEntries).catch(() => undefined) + api.listPromptCommands().then(setCmds).catch(() => undefined) + } + useEffect(() => { reload() }, []) + + return ( + <> + + + + + + setLabel(e.target.value)} /> + setValue(e.target.value)} /> + { + if (!value.trim()) return + await api.createSecret(service, label || '未命名', value.trim()) + setValue(''); setLabel(''); reload() + }}>添加 + + {secrets.map(s => ( + + {!s.active && { await api.activateSecret(s.id); reload() }}>启用} + { await api.deleteSecret(s.id); reload() }}>删除 + + ))} + + + + setKeys(e.target.value)} /> + + + +