Add secret, injection, and message controls to the workbench #43

Merged
weichen merged 6 commits from feature/sillytavern-review into main 2026-10-04 19:44:56 +00:00
14 changed files with 246 additions and 18 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",
+14
View File
@@ -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<string, unknown>) => 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 }[]>,
}
+104
View File
@@ -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<Secret[]>([])
const [entries, setEntries] = useState<Entry[]>([])
const [cmds, setCmds] = useState<Cmd[]>([])
const [service, setService] = useState<string>(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 (
<>
<SettingsGroup title="密钥">
<SettingRow label="新增密钥" desc="每个服务同时只有一把生效。列表只显示掩码。">
<select value={service} onChange={e => setService(e.target.value)}>
{services.map(([id, name]) => <option key={id} value={id}>{name}</option>)}
</select>
</SettingRow>
<SettingRow label="标签与密钥" desc="保存后立即生效,并停用同服务的其他密钥。">
<input className="search-input" value={label} placeholder="标签" onChange={e => setLabel(e.target.value)} />
<input className="search-input" type="password" autoComplete="new-password" value={value} placeholder="密钥" onChange={e => setValue(e.target.value)} />
<Btn variant="primary" onClick={async () => {
if (!value.trim()) return
await api.createSecret(service, label || '未命名', value.trim())
setValue(''); setLabel(''); reload()
}}>添加</Btn>
</SettingRow>
{secrets.map(s => (
<SettingRow key={s.id} label={s.label} desc={`${s.service} · ${s.mask}${s.active ? ' · 生效' : ''}`}>
{!s.active && <Btn onClick={async () => { await api.activateSecret(s.id); reload() }}>启用</Btn>}
<Btn onClick={async () => { await api.deleteSecret(s.id); reload() }}>删除</Btn>
</SettingRow>
))}
</SettingsGroup>
<SettingsGroup title="条件注入">
<SettingRow label="关键词" desc="逗号分隔。用 /正则/ 表示正则。勾选后变为常驻。">
<input className="search-input" value={keys} placeholder="关键词" onChange={e => setKeys(e.target.value)} />
<label><input type="checkbox" checked={constant} onChange={e => setConstant(e.target.checked)} /> 常驻</label>
</SettingRow>
<SettingRow label="注入正文" desc="命中后附在上下文末尾,不改系统前缀。">
<textarea rows={3} value={content} onChange={e => setContent(e.target.value)} />
<Btn variant="primary" onClick={async () => {
if (!content.trim()) return
await api.saveContextEntry({
keys: keys.split(/[,,]/).map(s => s.trim()).filter(Boolean),
content: content.trim(), constant, logic: 'and', enabled: true, scanDepth: 4, budgetChars: 2000,
})
setContent(''); setKeys(''); reload()
}}>添加</Btn>
</SettingRow>
{entries.map(e => (
<SettingRow key={e.id} label={e.constant ? '常驻' : (e.keys.join(' / ') || '未命名')} desc={e.content}>
<Btn onClick={async () => { await api.deleteContextEntry(e.id); reload() }}>删除</Btn>
</SettingRow>
))}
</SettingsGroup>
<SettingsGroup title="用户命令">
<SettingRow label="命令" desc="发送 /名称 时展开成提示词。不会自动触发。{{input}} 换成命令后面的文字。">
<input className="search-input" value={cmdName} onChange={e => setCmdName(e.target.value)} />
<input className="search-input" value={cmdTitle} onChange={e => setCmdTitle(e.target.value)} />
</SettingRow>
<SettingRow label="模板" desc="名称需为小写字母、数字或连字符,例如 /summary。">
<textarea rows={3} value={cmdBody} onChange={e => setCmdBody(e.target.value)} />
<Btn variant="primary" onClick={async () => {
await api.savePromptCommand({ name: cmdName, title: cmdTitle, template: cmdBody })
reload()
}}>添加</Btn>
</SettingRow>
{cmds.map(c => (
<SettingRow key={c.id} label={c.name} desc={c.title}>
<Btn onClick={async () => { await api.deletePromptCommand(c.id); reload() }}>删除</Btn>
</SettingRow>
))}
</SettingsGroup>
</>
)
}
+50 -11
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 渲染。)
@@ -198,7 +197,7 @@ function LiveBar({ live }: { live: LiveState }) {
// 用户在运行中手动折过的 run:结束后沿用这次选择,不强制再收起。
const touchedProcess = new Set<string>()
function AssistantMessage({ msg, live }: { msg: Message; live?: LiveState | null }) {
function AssistantMessage({ msg, live, convId }: { msg: Message; live?: LiveState | null; convId?: string }) {
const autoCollapse = useStore(s => s.ui.autoCollapseProcess)
const ctxObs = useStore(s => (msg.runId ? s.contextObs[msg.runId] : undefined))
const run = useStore(s => (msg.runId ? s.runs.find(r => r.id === msg.runId) : undefined))
@@ -237,7 +236,7 @@ function AssistantMessage({ msg, live }: { msg: Message; live?: LiveState | null
setOpen(next)
}
return (
<div className="msg assistant" data-msg-id={msg.id}>
<div className={`msg assistant${msg.excluded ? ' is-excluded' : ''}`} data-msg-id={msg.id}>
<div className="msg-role">
<span className="role-mark role-assistant"><BrandMark size={16} /></span>
<span>助手</span>
@@ -267,6 +266,38 @@ function AssistantMessage({ msg, live }: { msg: Message; live?: LiveState | null
))}
{live && <LiveAgentNotes live={live} />}
{!live && ctxObs && <ContextObsView obs={ctxObs} />}
{convId && <MessageTools msg={msg} convId={convId} />}
</div>
)
}
function SessionNoteField({ convId, note }: { convId: string; note: string }) {
const [value, setValue] = useState(note)
useEffect(() => { setValue(note) }, [note, convId])
return (
<input className="search-input session-note" value={value} placeholder="本会话要求"
title="附在上下文末尾,不改系统前缀"
onChange={e => setValue(e.target.value)}
onBlur={() => { if (value !== note) void api.setSessionNote(convId, value).then(() => store.openConversation(convId)) }} />
)
}
function MessageTools({ msg, convId }: { msg: Message; convId: string }) {
const text = msg.parts.filter(p => p.type === 'text').map(p => p.text ?? '').join('\n')
const refresh = () => { void store.openConversation(convId) }
return (
<div className="msg-tools">
<button className="btn ghost" type="button" onClick={() => { void navigator.clipboard.writeText(text) }}>复制</button>
<button className="btn ghost" type="button" onClick={() => { void api.excludeMessage(msg.id, !msg.excluded).then(refresh) }}>
{msg.excluded ? '纳入上下文' : '排除出上下文'}
</button>
{msg.role === 'assistant' && (
<button className="btn ghost" type="button" onClick={() => { void api.regenerate(convId, msg.id).then(refresh) }}>重新生成</button>
)}
{msg.variantGroup && msg.excluded && (
<button className="btn ghost" type="button" onClick={() => { void api.selectVariant(msg.id).then(refresh) }}>使用此候选</button>
)}
{msg.variantGroup && <span className="process-collapse-meta">候选 {(msg.variantIndex ?? 0) + 1}</span>}
</div>
)
}
@@ -279,12 +310,18 @@ function ContextObsView({ obs }: { obs: ContextObs }) {
trigger={<>
<span className="process-collapse-chev">▸</span>
<span>🧩 上下文构成</span>
<span className="process-collapse-meta">{obs.items.join(' · ')}(约 {obs.estChars} 字符)</span>
<span className="process-collapse-meta">{obs.items.join(' · ')}(约 {obs.estChars} token)</span>
</>}>
<div className="body">
<ul className="ui-list">
{obs.items.map((it, i) => <li key={i}>{it}</li>)}
</ul>
{obs.sections && obs.sections.length > 0 && (
<div className="process-collapse-meta">
{obs.sections.map(s => `${s.name} ${s.estTokens}`).join(' · ')}
{obs.prefixHash ? ` · 前缀 ${obs.prefixHash}` : ''}
</div>
)}
{obs.truncations && obs.truncations.length > 0 && (
<div style={{ color: 'var(--warn)', fontSize: 'var(--fs-ui-sm)', marginTop: 6 }}>
{obs.truncations.map((t, i) => <div key={i}>⚠ {t}</div>)}
@@ -509,6 +546,7 @@ export function Conversation() {
onClick={() => currentConvId && store.setCrossFlags(currentConvId, { allowCrossRead: conversation?.allowCrossRead === false })}>
<Icon name="users" size={13} />跨读{conversation?.allowCrossRead === false ? '·关' : '·开'}
</button>
<SessionNoteField convId={currentConvId} note={conversation?.sessionNote ?? ''} />
<span title={connected ? '事件流已连接' : '重连中'} className={`conn-dot ${connected ? 'on' : 'off'}`} />
{!connected && <span className="conn-label">重连中</span>}
{showStop && <button className="btn ghost" onClick={() => store.cancelActive()}>停止</button>}
@@ -535,11 +573,12 @@ export function Conversation() {
// 内联确认卡(T3):锚定本消息的危险命令确认,双现于对话流与收件箱。
const anchored = cards.filter(c => c.anchorMessageId === m.id && c.kind === 'confirm')
return (
<div className="msg user" key={m.id} data-msg-id={m.id}>
<div className={`msg user${m.excluded ? ' is-excluded' : ''}`} key={m.id} data-msg-id={m.id}>
<div className="msg-role">
<span className="role-mark"><Avatar name={myName} size="sm" /></span>你
</div>
<div className="bubble">{m.parts.filter(p => p.type === 'text').map(p => p.text).join('\n')}</div>
{currentConvId && <MessageTools msg={m} convId={currentConvId} />}
{currentConvId && <RewindControl convId={currentConvId} messageId={m.id} />}
{anchored.map(c => (
<InlineConfirmCard key={c.id} card={c} callbacks={{
@@ -551,7 +590,7 @@ export function Conversation() {
)
}
if (m.role === 'system') return <NoticeMessage key={m.id} msg={m} />
return <AssistantMessage key={m.id} msg={m} live={live?.runId === m.runId ? live : null} />
return <AssistantMessage key={m.id} msg={m} convId={currentConvId ?? undefined} live={live?.runId === m.runId ? live : null} />
})}
{showLiveBubble(live?.runId ?? null, messages.filter(m => m.role === 'assistant' && m.runId).map(m => m.runId as string)) && live &&
<AssistantMessage key={`live-${live.runId}`} msg={liveToMessage(live)} live={live} />}
+2
View File
@@ -8,6 +8,7 @@ import { store, useStore } from '../store'
import type { ThemeChoice } from '../theme'
import type { SettingDesc } from '../contracts'
import { SettingsShell, SettingRow, SettingsGroup, Btn } from '@ui'
import { BorrowPanels } from './BorrowPanels'
type Section = 'settings'
@@ -94,6 +95,7 @@ export function SettingsModal() {
</SettingsGroup>
)
})}
{section === 'settings' && <BorrowPanels />}
</SettingsShell>
)
}
+11
View File
@@ -17,6 +17,7 @@ export interface Conversation {
pinned?: boolean; archived?: boolean;
allowCrossRead?: boolean; crossPrivate?: boolean;
permissionMode?: 'default' | 'plan' | 'auto_edit' | '';
sessionNote?: string;
createdAt: string; updatedAt: string;
}
@@ -44,6 +45,9 @@ export interface Part {
export interface Message {
id: ID; conversationId: ID; runId?: ID; role: Role;
parts: Part[]; createdAt: string;
excluded?: boolean;
variantGroup?: string;
variantIndex?: number;
}
export type RunKind = 'conversation' | 'subagent' | 'tool';
@@ -110,10 +114,17 @@ export interface RunCompletedPayload {
context?: ContextObservation
}
interface ContextSection {
name: string
estTokens: number
}
export interface ContextObservation {
items: string[]
truncations?: string[]
estChars: number
sections?: ContextSection[]
prefixHash?: string
}
export interface RunCheckPayload { passed: boolean; reason?: string }
export interface RunFailedPayload { code: string; message: string }
+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() {
+2
View File
@@ -17,6 +17,8 @@ export interface ContextObs {
items: string[]
truncations?: string[]
estChars: number
sections?: { name: string; estTokens: number }[]
prefixHash?: string
}
export interface LiveState {
+3
View File
@@ -560,5 +560,8 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
font-size: var(--fs-ui-sm);
}
.rewind-panel ul { margin: 6px 0; padding-left: 1.2em; }
.msg.is-excluded { opacity: 0.55; }
.msg-tools { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; margin-top: 4px; }
.session-note { width: 12rem; }
.rewind-actions { display: flex; gap: 8px; }
.rewind-error { color: var(--err); font-size: var(--fs-ui-sm); }
+30
View File
@@ -0,0 +1,30 @@
// 棘轮:宿主侧的 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`)
// 空白放进前瞻里,避免 \s* 回退到空格前面、把 innerHTML = '' 误判成写入。
if (/innerHTML\s*=(?!\s*['"]['"])/.test(text)) hits.push(`${rel}: innerHTML`)
}
expect(hits).toEqual([])
})
})
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/App.tsx","./src/api.ts","./src/auth.ts","./src/contracts.ts","./src/live.ts","./src/main.tsx","./src/messageparts.ts","./src/outbox.ts","./src/passwords.ts","./src/pluginHost.ts","./src/previewsetup.ts","./src/previewtabs.ts","./src/projectmeta.ts","./src/scrollsync.ts","./src/store.ts","./src/tasklist.ts","./src/theme.ts","./src/toolkind.ts","./src/workspaces.ts","./src/components/AdminUsersModal.tsx","./src/components/AdminZone.tsx","./src/components/AgentTracker.tsx","./src/components/ArtifactsPanel.tsx","./src/components/Conversation.tsx","./src/components/DocViewer.tsx","./src/components/LoginPage.tsx","./src/components/PreviewPane.tsx","./src/components/ProcessTimeline.tsx","./src/components/ProjectSettingsModal.tsx","./src/components/SettingsModal.tsx","./src/components/TaskSidebar.tsx","./src/components/TopBar.tsx","./src/components/Workbench.tsx","./src/components/Workspaces.tsx","./src/components/adminSections.tsx","./test/auth.test.ts","./test/contracts.test.ts","./test/design-guard.test.ts","./test/live.test.ts","./test/messageparts.test.ts","./test/notice.test.ts","./test/outbox.test.ts","./test/previewtabs.test.ts","./test/scrollsync.test.ts","./test/tasklist.test.ts","./test/theme.test.ts","./test/toolkind.test.ts","./test/workspaces.test.ts"],"version":"5.9.3"}
{"root":["./src/App.tsx","./src/api.ts","./src/auth.ts","./src/contracts.ts","./src/live.ts","./src/main.tsx","./src/messageparts.ts","./src/outbox.ts","./src/passwords.ts","./src/pluginHost.ts","./src/previewsetup.ts","./src/previewtabs.ts","./src/projectmeta.ts","./src/runstate.test.ts","./src/runstate.ts","./src/scrollsync.ts","./src/store.ts","./src/tasklist.ts","./src/theme.ts","./src/toolkind.ts","./src/workspaces.ts","./src/components/AdminUsersModal.tsx","./src/components/AdminZone.tsx","./src/components/AgentTracker.tsx","./src/components/ArtifactsPanel.tsx","./src/components/BorrowPanels.tsx","./src/components/Conversation.tsx","./src/components/DocViewer.tsx","./src/components/LoginPage.tsx","./src/components/PreviewPane.tsx","./src/components/ProcessTimeline.tsx","./src/components/ProjectSettingsModal.tsx","./src/components/SettingsModal.tsx","./src/components/TaskSidebar.tsx","./src/components/TopBar.tsx","./src/components/Workbench.tsx","./src/components/Workspaces.tsx","./src/components/adminSections.tsx","./test/auth.test.ts","./test/contracts.test.ts","./test/design-guard.test.ts","./test/html-sinks.test.ts","./test/live.test.ts","./test/messageparts.test.ts","./test/notice.test.ts","./test/outbox.test.ts","./test/previewtabs.test.ts","./test/scrollsync.test.ts","./test/send.test.ts","./test/tasklist.test.ts","./test/theme.test.ts","./test/toolkind.test.ts","./test/workspaces.test.ts"],"version":"5.9.3"}
+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,