feat(desktop): in-stream inline md/html artifact preview (md-mini renderer + sandboxed iframe)
This commit is contained in:
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'
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
})()
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user