mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-23 10:06:09 +00:00
Compare commits
1
Commits
v2
...
stream-markdown
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bca3e5dac5 |
@@ -717,7 +717,6 @@
|
||||
"luxon": "catalog:",
|
||||
"marked": "catalog:",
|
||||
"mermaid": "11.16.1",
|
||||
"morphdom": "2.7.8",
|
||||
"motion": "12.34.5",
|
||||
"remeda": "catalog:",
|
||||
"remend": "catalog:",
|
||||
@@ -727,6 +726,7 @@
|
||||
"strip-ansi": "7.1.2",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@happy-dom/global-registrator": "20.0.11",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/luxon": "catalog:",
|
||||
@@ -6652,8 +6652,6 @@
|
||||
|
||||
"openid-client/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
|
||||
|
||||
|
||||
|
||||
"oxc-parser/@oxc-project/types": ["@oxc-project/types@0.127.0", "", {}, "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ=="],
|
||||
|
||||
"p-locate/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
|
||||
@@ -7552,18 +7550,6 @@
|
||||
|
||||
"miniflare/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="],
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
"p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||
|
||||
"pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
|
||||
@@ -8398,7 +8384,6 @@
|
||||
|
||||
"js-beautify/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||
|
||||
|
||||
"pkg-dir/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
|
||||
|
||||
"pkg-up/find-up/locate-path/p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="],
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
"test": "bun test src --only-failures"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@happy-dom/global-registrator": "20.0.11",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/luxon": "catalog:",
|
||||
@@ -75,7 +76,6 @@
|
||||
"luxon": "catalog:",
|
||||
"marked": "catalog:",
|
||||
"mermaid": "11.16.1",
|
||||
"morphdom": "2.7.8",
|
||||
"motion": "12.34.5",
|
||||
"remeda": "catalog:",
|
||||
"remend": "catalog:",
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { GlobalRegistrator } from "@happy-dom/global-registrator"
|
||||
import { afterAll, beforeAll, expect, test } from "bun:test"
|
||||
import { parseMarkdownNodes } from "./markdown-solid"
|
||||
|
||||
beforeAll(() => GlobalRegistrator.register())
|
||||
afterAll(() => GlobalRegistrator.unregister())
|
||||
|
||||
test("assigns stable paths to elements and individual words", () => {
|
||||
expect(parseMarkdownNodes('<p class="lead">Hello <strong>streaming</strong> world</p>', true)).toEqual([
|
||||
{
|
||||
key: "0",
|
||||
type: "element",
|
||||
tag: "p",
|
||||
attributes: { class: "lead" },
|
||||
children: [
|
||||
{ key: "0.0:0", type: "word", text: "Hello" },
|
||||
{ key: "0.0:1", type: "text", text: " " },
|
||||
{
|
||||
key: "0.1",
|
||||
type: "element",
|
||||
tag: "strong",
|
||||
attributes: {},
|
||||
children: [{ key: "0.1.0:0", type: "word", text: "streaming" }],
|
||||
},
|
||||
{ key: "0.2:1", type: "text", text: " " },
|
||||
{ key: "0.2:2", type: "word", text: "world" },
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("keeps completed block text compact", () => {
|
||||
expect(parseMarkdownNodes("<p>Hello world</p>", false)).toEqual([
|
||||
{
|
||||
key: "0",
|
||||
type: "element",
|
||||
tag: "p",
|
||||
attributes: {},
|
||||
children: [{ key: "0.0", type: "text", text: "Hello world" }],
|
||||
},
|
||||
])
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
import { For, onMount } from "solid-js"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { Dynamic, render } from "solid-js/web"
|
||||
|
||||
type MarkdownNode =
|
||||
| { key: string; type: "element"; tag: string; attributes: Record<string, string>; children: MarkdownNode[] }
|
||||
| { key: string; type: "text"; text: string }
|
||||
| { key: string; type: "word"; text: string }
|
||||
|
||||
export function createMarkdownRenderer(root: HTMLDivElement, html: string, words: boolean) {
|
||||
const [nodes, setNodes] = createStore(parseMarkdownNodes(html, words))
|
||||
let ready = false
|
||||
const dispose = render(
|
||||
() => <For each={nodes}>{(node) => <MarkdownDomNode node={node} animate={() => ready} />}</For>,
|
||||
root,
|
||||
)
|
||||
ready = true
|
||||
|
||||
return {
|
||||
update(next: string, nextWords: boolean) {
|
||||
setNodes(reconcile(parseMarkdownNodes(next, nextWords), { key: "key" }))
|
||||
},
|
||||
dispose,
|
||||
}
|
||||
}
|
||||
|
||||
function MarkdownDomNode(props: { node: MarkdownNode; animate: () => boolean }) {
|
||||
const node = props.node
|
||||
if (node.type === "text") return node.text
|
||||
if (node.type === "word") {
|
||||
let ref: HTMLSpanElement | undefined
|
||||
onMount(() => {
|
||||
if (props.animate()) ref?.setAttribute("data-markdown-enter", "")
|
||||
})
|
||||
return (
|
||||
<span ref={ref} data-markdown-word="">
|
||||
{node.text}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Dynamic component={node.tag} {...node.attributes}>
|
||||
<For each={node.children}>{(node) => <MarkdownDomNode node={node} animate={props.animate} />}</For>
|
||||
</Dynamic>
|
||||
)
|
||||
}
|
||||
|
||||
export function parseMarkdownNodes(html: string, words: boolean) {
|
||||
const template = document.createElement("template")
|
||||
template.innerHTML = html
|
||||
return Array.from(template.content.childNodes).flatMap((node, index) => parseNode(node, `${index}`, words))
|
||||
}
|
||||
|
||||
function parseNode(node: Node, key: string, words: boolean): MarkdownNode[] {
|
||||
if (node instanceof Text) {
|
||||
if (!words) return [{ key, type: "text", text: node.data }]
|
||||
return node.data.split(/(\s+)/).flatMap((text, index): MarkdownNode[] => {
|
||||
if (!text) return []
|
||||
if (/^\s+$/.test(text)) return [{ key: `${key}:${index}`, type: "text", text }]
|
||||
return [{ key: `${key}:${index}`, type: "word", text }]
|
||||
})
|
||||
}
|
||||
if (!(node instanceof Element)) return []
|
||||
return [
|
||||
{
|
||||
key,
|
||||
type: "element",
|
||||
tag: node.tagName.toLowerCase(),
|
||||
attributes: Object.fromEntries(Array.from(node.attributes).map((attribute) => [attribute.name, attribute.value])),
|
||||
children: Array.from(node.childNodes).flatMap((child, index) => parseNode(child, `${key}.${index}`, words)),
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -16,6 +16,14 @@
|
||||
font-size: var(--font-size-base); /* 14px */
|
||||
line-height: 160%;
|
||||
|
||||
[data-markdown-word] {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
[data-markdown-word][data-markdown-enter] {
|
||||
animation: markdown-word-enter 180ms ease-out both;
|
||||
}
|
||||
|
||||
/* Spacing for flow */
|
||||
> *:first-child {
|
||||
margin-top: 0;
|
||||
@@ -329,6 +337,21 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes markdown-word-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-component="markdown"] [data-markdown-word][data-markdown-enter] {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="markdown"] > * {
|
||||
unicode-bidi: plaintext;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { markdown } from "@opencode-ai/ui/storybook/fixtures"
|
||||
import { createSignal, onCleanup } from "solid-js"
|
||||
import { Markdown } from "./markdown"
|
||||
|
||||
export default {
|
||||
@@ -32,3 +33,22 @@ export const CompactResult = {
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
const streamed =
|
||||
"Streaming Markdown now keeps existing elements alive while each newly arriving word fades into the response."
|
||||
|
||||
function StreamingMarkdown() {
|
||||
const words = streamed.match(/\S+\s*/g) ?? []
|
||||
const [count, setCount] = createSignal(1)
|
||||
const timer = setInterval(() => setCount((value) => Math.min(value + 1, words.length)), 180)
|
||||
onCleanup(() => clearInterval(timer))
|
||||
return <Markdown text={words.slice(0, count()).join("")} streaming={count() < words.length} />
|
||||
}
|
||||
|
||||
export const StreamingResponse = {
|
||||
render: () => (
|
||||
<div class="mx-auto max-w-[760px] rounded-lg border border-border-weak-base bg-background-base px-5 py-4">
|
||||
<StreamingMarkdown />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useI18n } from "@opencode-ai/ui/context/i18n"
|
||||
import morphdom from "morphdom"
|
||||
import { checksum } from "@opencode-ai/util/encode"
|
||||
import {
|
||||
type ComponentProps,
|
||||
@@ -32,6 +31,7 @@ import { shouldResetCodeTokens, type RenderedCodeState } from "./markdown-code-s
|
||||
import { getCachedMarkdown, sanitizeMarkdown, touchCachedMarkdown, type MarkdownCacheEntry } from "./markdown-cache"
|
||||
import { inlineCodeKind } from "./markdown-inline-code-kind"
|
||||
import { renderMermaidSvg } from "./markdown-mermaid"
|
||||
import { createMarkdownRenderer } from "./markdown-solid"
|
||||
|
||||
type RenderedBlock =
|
||||
| (MarkdownCacheEntry & { key: string; mode: Exclude<Block["mode"], "code"> })
|
||||
@@ -54,6 +54,7 @@ type RenderResult = {
|
||||
}
|
||||
|
||||
const renderedCodeTokens = new WeakMap<HTMLDivElement, RenderedCodeState>()
|
||||
const renderedMarkdown = new WeakMap<HTMLDivElement, ReturnType<typeof createMarkdownRenderer>>()
|
||||
|
||||
function escape(text: string) {
|
||||
return text
|
||||
@@ -177,6 +178,17 @@ function disposeCopyButtons(root: Element) {
|
||||
hosts.forEach(disposeCopyButton)
|
||||
}
|
||||
|
||||
function disposeRenderedMarkdown(root: Element) {
|
||||
const blocks = [
|
||||
...(root instanceof HTMLDivElement && root.hasAttribute("data-markdown-block") ? [root] : []),
|
||||
...Array.from(root.querySelectorAll<HTMLDivElement>("[data-markdown-block]")),
|
||||
]
|
||||
blocks.forEach((block) => {
|
||||
renderedMarkdown.get(block)?.dispose()
|
||||
renderedMarkdown.delete(block)
|
||||
})
|
||||
}
|
||||
|
||||
const shellLanguages = new Set(["bash", "sh", "shell", "zsh", "fish", "console", "terminal"])
|
||||
|
||||
function codeKind(language: string | undefined) {
|
||||
@@ -185,12 +197,6 @@ function codeKind(language: string | undefined) {
|
||||
if (shellLanguages.has(value)) return "shell"
|
||||
}
|
||||
|
||||
function codeLanguage(block: HTMLPreElement) {
|
||||
const code = block.querySelector("code")
|
||||
if (!(code instanceof HTMLElement)) return
|
||||
return code.className.match(/(?:^|\s)language-([^\s]+)/)?.[1]
|
||||
}
|
||||
|
||||
function applyCodeMetadata(wrapper: HTMLElement, language: string | undefined) {
|
||||
if (language) wrapper.dataset.language = language
|
||||
else delete wrapper.dataset.language
|
||||
@@ -200,37 +206,6 @@ function applyCodeMetadata(wrapper: HTMLElement, language: string | undefined) {
|
||||
else delete wrapper.dataset.codeKind
|
||||
}
|
||||
|
||||
function ensureCodeWrapper(block: HTMLPreElement, labels: CopyLabels) {
|
||||
const parent = block.parentElement
|
||||
if (!parent) return
|
||||
const wrapped = parent.getAttribute("data-component") === "markdown-code"
|
||||
if (!wrapped) {
|
||||
const wrapper = document.createElement("div")
|
||||
wrapper.setAttribute("data-component", "markdown-code")
|
||||
applyCodeMetadata(wrapper, codeLanguage(block))
|
||||
parent.replaceChild(wrapper, block)
|
||||
wrapper.appendChild(block)
|
||||
wrapper.appendChild(createCopyButton(labels))
|
||||
return
|
||||
}
|
||||
|
||||
applyCodeMetadata(parent, codeLanguage(block))
|
||||
|
||||
const buttons = Array.from(parent.querySelectorAll('[data-slot="markdown-copy-button"]')).filter(
|
||||
(el): el is HTMLButtonElement => el instanceof HTMLButtonElement,
|
||||
)
|
||||
|
||||
if (buttons.length === 0) {
|
||||
parent.appendChild(createCopyButton(labels))
|
||||
return
|
||||
}
|
||||
|
||||
for (const button of buttons.slice(1)) {
|
||||
disposeCopyButton(button)
|
||||
button.remove()
|
||||
}
|
||||
}
|
||||
|
||||
function decorateMermaid(wrapper: HTMLElement, code: HTMLElement, complete: boolean) {
|
||||
if (!code.classList.contains("language-mermaid")) {
|
||||
clearMermaid(wrapper)
|
||||
@@ -311,18 +286,6 @@ function markInlineCode(root: HTMLDivElement) {
|
||||
}
|
||||
}
|
||||
|
||||
function decorate(root: HTMLDivElement, labels: CopyLabels, complete: boolean) {
|
||||
const blocks = Array.from(root.querySelectorAll("pre"))
|
||||
for (const block of blocks) {
|
||||
ensureCodeWrapper(block, labels)
|
||||
const wrapper = block.parentElement
|
||||
const code = block.querySelector("code")
|
||||
if (wrapper instanceof HTMLElement && code instanceof HTMLElement) decorateMermaid(wrapper, code, complete)
|
||||
}
|
||||
markInlineCode(root)
|
||||
markCodeLinks(root)
|
||||
}
|
||||
|
||||
function setupCodeCopy(root: HTMLDivElement, getLabels: () => CopyLabels) {
|
||||
const timeouts = new Map<HTMLElement, ReturnType<typeof setTimeout>>()
|
||||
|
||||
@@ -552,6 +515,7 @@ export function Markdown(
|
||||
delete container.dataset.markdownReady
|
||||
if (content.length === 0) {
|
||||
disposeCopyButtons(container)
|
||||
disposeRenderedMarkdown(container)
|
||||
container.innerHTML = ""
|
||||
if (result?.ready && result.text === local.text) container.dataset.markdownReady = ""
|
||||
return
|
||||
@@ -572,6 +536,7 @@ export function Markdown(
|
||||
const child = container.lastElementChild
|
||||
if (!child) break
|
||||
disposeCopyButtons(child)
|
||||
disposeRenderedMarkdown(child)
|
||||
child.remove()
|
||||
}
|
||||
container
|
||||
@@ -587,6 +552,8 @@ export function Markdown(
|
||||
|
||||
onCleanup(() => {
|
||||
if (copyCleanup) copyCleanup()
|
||||
const container = root()
|
||||
if (container) disposeRenderedMarkdown(container)
|
||||
disposeMarkdownProjection(owner)
|
||||
activeCodeKeys.forEach(disposeCode)
|
||||
completedCode.clear()
|
||||
@@ -647,44 +614,39 @@ function updateBlock(container: HTMLDivElement, index: number, block: RenderedBl
|
||||
updateCodeBlock(container, current, block, labels)
|
||||
return
|
||||
}
|
||||
if (
|
||||
current instanceof HTMLDivElement &&
|
||||
current.dataset.markdownKey === block.key &&
|
||||
current.dataset.markdownHash === block.hash
|
||||
)
|
||||
return
|
||||
const existing = current instanceof HTMLDivElement && current.dataset.markdownKey === block.key ? current : undefined
|
||||
if (existing?.dataset.markdownHash === block.hash) return
|
||||
|
||||
const next = document.createElement("div")
|
||||
const next = existing ?? document.createElement("div")
|
||||
next.dataset.markdownBlock = ""
|
||||
next.dataset.markdownKey = block.key
|
||||
next.dataset.markdownHash = block.hash
|
||||
next.style.display = "contents"
|
||||
next.innerHTML = block.html
|
||||
decorate(next, labels, block.mode === "full")
|
||||
const source = document.createElement("div")
|
||||
source.innerHTML = block.html
|
||||
markInlineCode(source)
|
||||
markCodeLinks(source)
|
||||
const html = source.innerHTML
|
||||
|
||||
if (!(current instanceof HTMLDivElement)) {
|
||||
container.appendChild(next)
|
||||
if (existing) {
|
||||
const renderer = renderedMarkdown.get(existing)
|
||||
if (renderer) {
|
||||
renderer.update(html, block.mode === "live")
|
||||
return
|
||||
}
|
||||
existing.innerHTML = ""
|
||||
renderedMarkdown.set(existing, createMarkdownRenderer(existing, html, block.mode === "live"))
|
||||
return
|
||||
}
|
||||
|
||||
morphdom(current, next, {
|
||||
onBeforeElUpdated: (fromEl, toEl) => {
|
||||
if (
|
||||
fromEl instanceof HTMLElement &&
|
||||
toEl instanceof HTMLElement &&
|
||||
fromEl.getAttribute("data-slot") === "markdown-copy-button" &&
|
||||
toEl.getAttribute("data-slot") === "markdown-copy-button"
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (fromEl.isEqualNode(toEl)) return false
|
||||
return true
|
||||
},
|
||||
onBeforeNodeDiscarded: (node) => {
|
||||
if (node instanceof Element) disposeCopyButtons(node)
|
||||
return true
|
||||
},
|
||||
})
|
||||
renderedMarkdown.set(next, createMarkdownRenderer(next, html, block.mode === "live"))
|
||||
if (!current) {
|
||||
container.appendChild(next)
|
||||
return
|
||||
}
|
||||
disposeCopyButtons(current)
|
||||
disposeRenderedMarkdown(current)
|
||||
current.replaceWith(next)
|
||||
}
|
||||
|
||||
function updateCodeBlock(
|
||||
@@ -756,6 +718,7 @@ function updateCodeBlock(
|
||||
})
|
||||
if (current) {
|
||||
disposeCopyButtons(current)
|
||||
disposeRenderedMarkdown(current)
|
||||
current.replaceWith(next)
|
||||
return
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user