Add secret, injection, and message controls to the workbench #43
+1
-1
@@ -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)。
|
||||
|
||||
Generated
+17
@@ -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",
|
||||
|
||||
@@ -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
@@ -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 }[]>,
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
@@ -17,6 +17,8 @@ export interface ContextObs {
|
||||
items: string[]
|
||||
truncations?: string[]
|
||||
estChars: number
|
||||
sections?: { name: string; estTokens: number }[]
|
||||
prefixHash?: string
|
||||
}
|
||||
|
||||
export interface LiveState {
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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 @@
|
||||
{"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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user