feat(desktop): in-stream inline md/html artifact preview (md-mini renderer + sandboxed iframe)

This commit is contained in:
ZiyaZhang
2026-07-20 02:57:56 -07:00
parent 64397f5b39
commit d5e2115aba
12 files changed
+1609 -9

No files matched your search

@@ -165,6 +165,14 @@
}
function renderTile(entry, openArtifact) {
// Board reuse decision (lane-artifact-inline, 2026-07-20): the List row
// grew an expandable inline md/html preview, but the Board tile does NOT
// reuse it. A tile is a single <button> whose whole click target opens
// the artifact in the browser; the inline preview needs interactive
// links (<a>) and an expand toggle, and nesting those inside a <button>
// is invalid HTML and would hijack the tile's open-on-click. The tile
// keeps its cheap first-line thumbnail (thumbnailPreview) instead; deep
// reading happens on the List row or in the browser.
const tile = document.createElement('button')
tile.type = 'button'
tile.className = 'artifact-tile'
+213 -2
View File
@@ -53,7 +53,17 @@
function ensureBucket(sid) {
let b = bySession.get(sid)
if (!b) {
b = { cards: new Map(), history: new Map(), panelEl: null, currentView: 'list' }
b = {
cards: new Map(),
history: new Map(),
panelEl: null,
currentView: 'list',
// Per-card inline-preview open state, keyed by artifactId. Cards
// persist in the transcript DOM so their expanded/collapsed state
// survives naturally, but tracking it here lets a rebuilt card
// (and the unit tests) restore the last state deterministically.
previewOpen: new Set(),
}
bySession.set(sid, b)
}
return b
@@ -285,8 +295,201 @@
return out
}
// Classify an artifact for inline preview. `.md` gets the mini markdown
// renderer; `.html` gets a sandboxed iframe pointed at the artifact
// server. Everything else has no inline preview (open-in-browser only).
// Kind is taken from the event's `kind` when present, else sniffed off
// the path extension so fixture events that only carry a path still work.
function previewKind(entry) {
const kind = String(entry.kind || '').toLowerCase()
if (kind === 'md' || kind === 'markdown') return 'md'
if (kind === 'html') return 'html'
const p = String(entry.path || entry.artifactId || '').toLowerCase()
if (/\.(md|markdown)$/.test(p)) return 'md'
if (/\.html?$/.test(p)) return 'html'
return null
}
// Latest captured blob for an artifact, if any. The real ArtifactServer
// does not ship content on the wire, but fixtures (and the debug seed)
// do; recordHistory keeps it. Used as the md preview source so the
// fixture path renders real content without a network round-trip.
function latestBlob(entry) {
if (typeof entry.blob === 'string') return entry.blob
const arr = history.get(entry.artifactId)
if (!arr || !arr.length) return null
const latest = arr.reduce((a, b) => (a.version >= b.version ? a : b))
return typeof latest.blob === 'string' ? latest.blob : null
}
// Build the artifact server URL for an .html artifact. Prefer the URL the
// event already carries (real ArtifactServer stamps `url`); otherwise ask
// the preload bridge for the base and compose it the same way the server
// does (encodeURIComponent, but keep `/` path separators). Returns null
// when no server is up so callers fall back to open-in-browser.
async function resolveHtmlUrl(entry) {
if (typeof entry.url === 'string' && entry.url) return entry.url
if (!(window.dsh && typeof window.dsh.getArtifactBase === 'function')) return null
try {
const base = await window.dsh.getArtifactBase()
if (!base || !base.url) return null
const id = encodeURIComponent(entry.artifactId).replace(/%2F/gi, '/')
return base.url.replace(/\/$/, '') + '/a/' + id + '/'
} catch (err) {
console.error('getArtifactBase failed', err)
return null
}
}
// Inline preview strip: a <details>-free expandable region (we can't nest
// a <details> inside the card's <details> and get independent toggle
// state, so this is a button + region pair). Collapsed by default;
// content builds lazily on first expand. Open state is mirrored into the
// session bucket's previewOpen set so it's restorable.
function buildPreview(entry) {
const kind = previewKind(entry)
if (kind !== 'md' && kind !== 'html') return null
const wrap = document.createElement('div')
wrap.className = 'artifact-preview'
wrap.dataset.previewKind = kind
const toggle = document.createElement('button')
toggle.type = 'button'
toggle.className = 'artifact-preview-toggle'
toggle.setAttribute('aria-expanded', 'false')
const caret = document.createElement('span')
caret.className = 'artifact-preview-caret'
caret.setAttribute('aria-hidden', 'true')
caret.textContent = '▸'
const label = document.createElement('span')
label.className = 'artifact-preview-label'
label.textContent = kind === 'md' ? 'preview markdown' : 'preview page'
toggle.append(caret, label)
const region = document.createElement('div')
region.className = 'artifact-preview-region'
region.hidden = true
let built = false
const expand = () => {
wrap.classList.add('is-open')
toggle.setAttribute('aria-expanded', 'true')
caret.textContent = '▾'
region.hidden = false
bucket().previewOpen.add(entry.artifactId)
if (!built) {
built = true
if (kind === 'md') buildMdPreview(region, entry)
else buildHtmlPreview(region, entry)
}
}
const collapse = () => {
wrap.classList.remove('is-open')
toggle.setAttribute('aria-expanded', 'false')
caret.textContent = '▸'
region.hidden = true
bucket().previewOpen.delete(entry.artifactId)
}
toggle.addEventListener('click', (e) => {
e.preventDefault()
e.stopPropagation()
if (region.hidden) expand()
else collapse()
})
wrap.append(toggle, region)
// Restore prior open state (rebuilt card / test-driven restore).
// Opening the preview also means opening the parent <details> so the
// region is visible (a closed <details> hides non-summary children).
// Deferred to a microtask so the card is appended and `.closest` can
// find the parent.
if (bucket().previewOpen.has(entry.artifactId)) {
Promise.resolve().then(() => {
const parentCard = wrap.closest ? wrap.closest('.artifact-card') : null
if (parentCard) parentCard.open = true
expand()
})
}
return wrap
}
function buildMdPreview(region, entry) {
const md = window.__dshMdMini
const blob = latestBlob(entry)
if (!md) {
const note = document.createElement('div')
note.className = 'artifact-preview-note muted small'
note.textContent = 'markdown renderer unavailable'
region.appendChild(note)
return
}
if (typeof blob !== 'string') {
// No content on the wire (real ArtifactServer path). Be honest and
// point at the browser rather than fake a render.
const note = document.createElement('div')
note.className = 'artifact-preview-note muted small'
note.textContent = '内容未随事件传入 · 用「在浏览器打开」查看'
region.appendChild(note)
return
}
const onLink = (href) => {
if (window.dsh && typeof window.dsh.openExternalUrl === 'function') {
window.dsh.openExternalUrl(href)
}
}
const rendered = md.render(blob, { document, onLink })
rendered.classList.add('artifact-preview-md')
region.appendChild(rendered)
}
function buildHtmlPreview(region, entry) {
// Show a placeholder while we resolve whether a server is up; swap in
// the sandboxed iframe or the fallback once known.
const pending = document.createElement('div')
pending.className = 'artifact-preview-note muted small'
pending.textContent = 'loading preview…'
region.appendChild(pending)
resolveHtmlUrl(entry).then((url) => {
pending.remove()
if (!url) {
// Server not up (e.g. stdio profile without artifacts). Offer the
// existing open-in-browser action rather than a broken frame.
const note = document.createElement('div')
note.className = 'artifact-preview-note muted small'
note.textContent = 'artifact 服务未启动 · '
const btn = document.createElement('button')
btn.type = 'button'
btn.className = 'artifact-open ghost small'
btn.textContent = 'Open in browser'
btn.addEventListener('click', (e) => {
e.stopPropagation()
if (btn.getAttribute('aria-disabled') === 'true') return
invokeOpen(entry, btn, 'Open in browser')
})
note.appendChild(btn)
region.appendChild(note)
return
}
const frame = document.createElement('iframe')
frame.className = 'artifact-preview-frame'
// Sandbox: allow the page's own scripts to run (many artifacts are
// interactive) but withhold allow-same-origin so the framed doc can't
// reach back into the 127.0.0.1 origin's storage/cookies, and grant
// nothing else (no top-nav, popups, forms, downloads).
frame.setAttribute('sandbox', 'allow-scripts')
frame.setAttribute('loading', 'lazy')
frame.setAttribute('referrerpolicy', 'no-referrer')
frame.setAttribute('title', 'Artifact preview: ' + entry.artifactId)
frame.src = url
region.appendChild(frame)
})
}
function invokeOpen(entry, actionEl, restoreLabel) {
if (!actionEl) return
actionEl.setAttribute('aria-disabled', 'true')
actionEl.classList.add('is-busy')
const done = (label) => {
@@ -404,6 +607,14 @@
body.append(pathRow, actionRow)
// ---- inline preview (md / html) -----------------------------------
// A low-key expandable strip under the actions. Collapsed by default so
// the card stays a single quiet row; lazy — content only builds on
// first expand. Only .md and .html artifacts get it; other kinds
// (svg/binary/etc) keep the open-in-browser affordance alone.
const preview = buildPreview(entry)
if (preview) body.append(preview)
el.append(summary, body)
// Kick a fresh-flash so the arrival is noticeable.
flash(el)
+5
View File
@@ -1474,6 +1474,11 @@
reach the Board/Timeline/Evolution views without waiting for a
real run to emit fs artifacts. -->
<script src="./artifact-board-seed.js"></script>
<!-- Minimal Markdown → DOM renderer (window.__dshMdMini) for in-stream
inline .md artifact previews. MUST load before artifacts.js, which
calls it when expanding an .md card's preview. Dependency-free and
innerHTML-free: model text reaches the DOM only as text nodes. -->
<script src="./md-mini.js"></script>
<script src="./artifacts.js"></script>
<script src="./plugin-runtime-fold.js"></script>
<script src="./mcp-tool-name.js"></script>
+332
View File
@@ -0,0 +1,332 @@
// Minimal, dependency-free Markdown → DOM renderer for in-stream artifact
// previews (张子雅's "把 md 文档在流里展示出来" request). Deliberately NOT a
// full CommonMark implementation — it covers the constructs that show up in
// agent-authored .md artifacts (headings, emphasis, inline code, fenced code,
// lists, blockquotes, links, paragraphs, horizontal rules) and nothing else.
//
// SECURITY CONTRACT (the whole reason this file exists instead of `marked`):
// The renderer NEVER interprets HTML. Every scrap of model-authored text
// reaches the DOM through document.createTextNode / .textContent only — so a
// literal `<script>` or `<img onerror=…>` inside the markdown renders as the
// visible characters "<script>…", never as a node. There is no innerHTML
// path anywhere below. Links are the one interactive affordance: an <a> is
// created ONLY for http(s)/mailto hrefs, its navigation is cancelled
// (preventDefault) and handed to the caller's onLink() — which routes through
// the shell:openExternal whitelist. Any other scheme (javascript:, data:, …)
// degrades to inert text.
//
// Public API (window.__dshMdMini / module.exports):
// parseBlocks(src) → { blocks, truncated } (pure, testable)
// parseInline(text) → token[] (pure, testable)
// render(src, opts) → HTMLElement (opts.document, opts.onLink)
// MAX_LINES → number (preview length cap)
'use strict'
;(function () {
// Preview cap: agent artifacts can be thousands of lines; the in-stream card
// is a peek, not a reader. Past this we stop and show an "open full" note.
const MAX_LINES = 200
// ---- block-level parse ---------------------------------------------------
const RE_FENCE = /^(`{3,}|~{3,})(.*)$/
const RE_FENCE_CLOSE = /^(`{3,}|~{3,})\s*$/
const RE_HEADING = /^(#{1,6})\s+(.*)$/
const RE_LIST = /^\s*([-*+]|\d+[.)])\s+/
const RE_LIST_ORDERED = /^\s*\d+[.)]\s+/
const RE_QUOTE = /^\s*>\s?/
const RE_HR = /^\s*([-*_])\1{2,}\s*$/
const RE_BLANK = /^\s*$/
function isBlockStart(line) {
return (
RE_BLANK.test(line) ||
RE_FENCE.test(line) ||
RE_HEADING.test(line) ||
RE_LIST.test(line) ||
RE_QUOTE.test(line) ||
RE_HR.test(line)
)
}
function parseBlocks(src) {
const allLines = String(src == null ? '' : src).replace(/\r\n?/g, '\n').split('\n')
const truncated = allLines.length > MAX_LINES
const lines = truncated ? allLines.slice(0, MAX_LINES) : allLines
const blocks = []
let i = 0
while (i < lines.length) {
const line = lines[i]
if (RE_BLANK.test(line)) {
i++
continue
}
// Fenced code block — content is captured verbatim (no inline parse).
const fence = RE_FENCE.exec(line)
if (fence) {
const fenceCh = fence[1][0]
const lang = fence[2].trim()
const code = []
i++
while (i < lines.length) {
const close = RE_FENCE_CLOSE.exec(lines[i])
if (close && close[1][0] === fenceCh) {
i++
break
}
code.push(lines[i])
i++
}
blocks.push({ type: 'code', lang, text: code.join('\n') })
continue
}
const heading = RE_HEADING.exec(line)
if (heading) {
blocks.push({
type: 'heading',
level: heading[1].length,
text: heading[2].replace(/\s+#+\s*$/, '').trim(),
})
i++
continue
}
// Horizontal rule (checked before list so `***`/`---` don't parse as a
// bullet item with empty content).
if (RE_HR.test(line)) {
blocks.push({ type: 'hr' })
i++
continue
}
// List — gather consecutive items of the SAME ordered/unordered flavour.
if (RE_LIST.test(line)) {
const ordered = RE_LIST_ORDERED.test(line)
const items = []
while (i < lines.length && RE_LIST.test(lines[i]) && !RE_HR.test(lines[i])) {
if (RE_LIST_ORDERED.test(lines[i]) !== ordered) break
items.push(lines[i].replace(RE_LIST, ''))
i++
}
blocks.push({ type: 'list', ordered, items })
continue
}
// Blockquote — one level, consecutive `>` lines merged into one text.
if (RE_QUOTE.test(line)) {
const buf = []
while (i < lines.length && RE_QUOTE.test(lines[i])) {
buf.push(lines[i].replace(RE_QUOTE, ''))
i++
}
blocks.push({ type: 'quote', text: buf.join(' ') })
continue
}
// Paragraph — run of non-blank, non-block-start lines joined by spaces.
const para = [line]
i++
while (i < lines.length && !isBlockStart(lines[i])) {
para.push(lines[i])
i++
}
blocks.push({ type: 'paragraph', text: para.join(' ') })
}
return { blocks, truncated }
}
// ---- inline parse --------------------------------------------------------
// Scanning tokenizer. At each position we try the inline constructs in
// precedence order; anything else accumulates into a plain-text token. The
// token list is flat, but strong/em/link inner text is re-parsed at build
// time so `**bold `code`**` nests correctly.
const RE_CODE = /^(`+)([\s\S]*?)\1/
const RE_LINK = /^\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/
function parseInline(text) {
const s = String(text == null ? '' : text)
const tokens = []
let buf = ''
const flush = () => {
if (buf) {
tokens.push({ type: 'text', text: buf })
buf = ''
}
}
let i = 0
while (i < s.length) {
const c = s[i]
const tail = s.slice(i)
// Inline code: backtick-delimited, verbatim, highest precedence so
// markup inside a code span stays literal.
if (c === '`') {
const m = RE_CODE.exec(tail)
if (m) {
flush()
tokens.push({ type: 'code', text: m[2] })
i += m[0].length
continue
}
}
// Link [text](href) — href stops at whitespace/`)`; optional "title" dropped.
if (c === '[') {
const m = RE_LINK.exec(tail)
if (m) {
flush()
tokens.push({ type: 'link', text: m[1], href: m[2] })
i += m[0].length
continue
}
}
// Strong (**/__) then emphasis (*/_).
if (c === '*' || c === '_') {
const pair = c + c
if (tail.slice(0, 2) === pair) {
const strongRe = new RegExp('^\\' + c + '\\' + c + '([\\s\\S]+?)\\' + c + '\\' + c)
const m = strongRe.exec(tail)
if (m) {
flush()
tokens.push({ type: 'strong', text: m[1] })
i += m[0].length
continue
}
}
// Emphasis: require a non-space, non-delimiter char right after the
// marker so `a * b` and bare `*` don't open a run.
const emRe = new RegExp('^\\' + c + '(?![\\s' + '\\' + c + '])([\\s\\S]*?)\\' + c)
const m = emRe.exec(tail)
if (m && m[1].trim()) {
flush()
tokens.push({ type: 'em', text: m[1] })
i += m[0].length
continue
}
}
buf += c
i++
}
flush()
return tokens
}
function isSafeHref(href) {
return /^(https?:|mailto:)/i.test(String(href).trim())
}
// ---- DOM build -----------------------------------------------------------
function buildInline(parent, tokens, doc, onLink) {
for (const t of tokens) {
if (t.type === 'text') {
parent.appendChild(doc.createTextNode(t.text))
} else if (t.type === 'code') {
const el = doc.createElement('code')
el.className = 'md-mini-code'
el.textContent = t.text
parent.appendChild(el)
} else if (t.type === 'strong') {
const el = doc.createElement('strong')
buildInline(el, parseInline(t.text), doc, onLink)
parent.appendChild(el)
} else if (t.type === 'em') {
const el = doc.createElement('em')
buildInline(el, parseInline(t.text), doc, onLink)
parent.appendChild(el)
} else if (t.type === 'link') {
if (isSafeHref(t.href)) {
const a = doc.createElement('a')
a.className = 'md-mini-link'
a.textContent = t.text || t.href
a.setAttribute('href', t.href)
a.setAttribute('rel', 'noreferrer noopener')
a.addEventListener('click', (ev) => {
if (ev && typeof ev.preventDefault === 'function') ev.preventDefault()
if (onLink) onLink(t.href)
})
parent.appendChild(a)
} else {
// Disallowed scheme (javascript:, data:, …): render the visible text
// inert. No node that could navigate is ever created.
parent.appendChild(doc.createTextNode(t.text || t.href))
}
}
}
}
function render(src, opts) {
opts = opts || {}
const doc = opts.document || (typeof document !== 'undefined' ? document : null)
if (!doc) throw new Error('md-mini.render: no document available')
const onLink = typeof opts.onLink === 'function' ? opts.onLink : null
const parsed = parseBlocks(src)
const root = doc.createElement('div')
root.className = 'md-mini'
for (const b of parsed.blocks) {
if (b.type === 'heading') {
const el = doc.createElement('h' + b.level)
el.className = 'md-mini-h md-mini-h' + b.level
buildInline(el, parseInline(b.text), doc, onLink)
root.appendChild(el)
} else if (b.type === 'paragraph') {
const el = doc.createElement('p')
el.className = 'md-mini-p'
buildInline(el, parseInline(b.text), doc, onLink)
root.appendChild(el)
} else if (b.type === 'code') {
const pre = doc.createElement('pre')
pre.className = 'md-mini-pre'
const code = doc.createElement('code')
if (b.lang) code.className = 'md-mini-lang-' + b.lang.replace(/[^\w-]/g, '')
code.textContent = b.text
pre.appendChild(code)
root.appendChild(pre)
} else if (b.type === 'list') {
const listEl = doc.createElement(b.ordered ? 'ol' : 'ul')
listEl.className = 'md-mini-list'
for (const item of b.items) {
const li = doc.createElement('li')
buildInline(li, parseInline(item), doc, onLink)
listEl.appendChild(li)
}
root.appendChild(listEl)
} else if (b.type === 'quote') {
const q = doc.createElement('blockquote')
q.className = 'md-mini-quote'
buildInline(q, parseInline(b.text), doc, onLink)
root.appendChild(q)
} else if (b.type === 'hr') {
const hr = doc.createElement('hr')
hr.className = 'md-mini-hr'
root.appendChild(hr)
}
}
if (parsed.truncated) {
const note = doc.createElement('div')
note.className = 'md-mini-truncated'
note.textContent = '仅预览前 ' + MAX_LINES + ' 行 · 用「在浏览器打开」查看全文'
root.appendChild(note)
}
return root
}
const API = { parseBlocks, parseInline, render, isSafeHref, MAX_LINES }
if (typeof module !== 'undefined' && module.exports) module.exports = API
if (typeof window !== 'undefined') window.__dshMdMini = API
})()
+74
View File
@@ -761,6 +761,80 @@ body.layout-monitor .stream {
.artifact-body-actions { display: flex; gap: 8px; }
.artifact-open { font-size: 12px; padding: 4px 10px; }
/* Inline preview (lane-artifact-inline): md / html artifacts get a low-key
* expandable strip under the L1 actions. Collapsed by default so the card
* stays a quiet row; the region only builds its content on first expand. */
.artifact-preview { display: flex; flex-direction: column; gap: 0; }
.artifact-preview-toggle {
display: inline-flex; align-items: center; gap: 6px;
align-self: flex-start;
background: none; border: none; cursor: pointer;
padding: 2px 4px; margin: 0;
font-size: 11px; color: var(--text-tertiary);
border-radius: 3px;
}
.artifact-preview-toggle:hover { color: var(--accent); background: var(--accent-soft); }
.artifact-preview-caret {
font-size: 9px; line-height: 1; display: inline-block;
transform: translateY(0.5px);
}
.artifact-preview-label { letter-spacing: 0.02em; }
.artifact-preview-region {
margin-top: 6px;
border: 1px solid var(--border); border-radius: 5px;
background: var(--bg-elev-1);
overflow: hidden;
}
.artifact-preview-note {
padding: 8px 10px; display: flex; align-items: center; gap: 6px;
}
/* Rendered markdown block tight, readable, capped visual scale so the
* inline peek doesn't try to be a full document reader. */
.artifact-preview-md {
padding: 4px 12px 10px; max-height: 360px; overflow: auto;
font-size: 12px; line-height: 1.55; color: var(--text);
}
.artifact-preview-md .md-mini-h { margin: 10px 0 4px; line-height: 1.3; font-weight: 600; }
.artifact-preview-md .md-mini-h1 { font-size: 16px; }
.artifact-preview-md .md-mini-h2 { font-size: 14px; }
.artifact-preview-md .md-mini-h3 { font-size: 13px; }
.artifact-preview-md .md-mini-h4,
.artifact-preview-md .md-mini-h5,
.artifact-preview-md .md-mini-h6 { font-size: 12px; color: var(--muted); }
.artifact-preview-md .md-mini-p { margin: 4px 0; }
.artifact-preview-md .md-mini-list { margin: 4px 0; padding-left: 20px; }
.artifact-preview-md .md-mini-list li { margin: 2px 0; }
.artifact-preview-md .md-mini-code {
font-family: var(--mono); font-size: 11px;
background: var(--bg-elev-2); padding: 1px 5px; border-radius: 3px;
}
.artifact-preview-md .md-mini-pre {
background: var(--bg-elev-2); border-radius: 4px;
padding: 8px 10px; margin: 6px 0; overflow: auto;
}
.artifact-preview-md .md-mini-pre code {
font-family: var(--mono); font-size: 11px; color: var(--text);
background: none; padding: 0;
}
.artifact-preview-md .md-mini-quote {
margin: 6px 0; padding: 2px 0 2px 10px;
border-left: 3px solid var(--border); color: var(--muted);
}
.artifact-preview-md .md-mini-hr {
border: none; border-top: 1px solid var(--border); margin: 10px 0;
}
.artifact-preview-md .md-mini-link { color: var(--accent); cursor: pointer; }
.artifact-preview-md .md-mini-link:hover { text-decoration: underline; }
.artifact-preview-md .md-mini-truncated {
margin-top: 8px; padding-top: 6px; border-top: 1px dashed var(--border);
font-size: 10px; color: var(--text-tertiary);
}
/* Sandboxed html preview iframe — fixed height, full width of the region. */
.artifact-preview-frame {
display: block; width: 100%; height: 360px; border: none;
background: #fff;
}
/* Auto-group: consecutive artifact cards get wrapped in an
* `.artifact-group` container by the renderer so the group owns its own
* zero-gap layout (the stream's 12px flex-gap would otherwise dominate).