fix(ui): keep partial markdown readable while responses stream (#19403)
parent
771525270a
commit
d341499684
4
bun.lock
4
bun.lock
|
|
@ -516,6 +516,7 @@
|
||||||
"motion-dom": "12.34.3",
|
"motion-dom": "12.34.3",
|
||||||
"motion-utils": "12.29.2",
|
"motion-utils": "12.29.2",
|
||||||
"remeda": "catalog:",
|
"remeda": "catalog:",
|
||||||
|
"remend": "catalog:",
|
||||||
"shiki": "catalog:",
|
"shiki": "catalog:",
|
||||||
"solid-js": "catalog:",
|
"solid-js": "catalog:",
|
||||||
"solid-list": "catalog:",
|
"solid-list": "catalog:",
|
||||||
|
|
@ -631,6 +632,7 @@
|
||||||
"marked": "17.0.1",
|
"marked": "17.0.1",
|
||||||
"marked-shiki": "1.2.1",
|
"marked-shiki": "1.2.1",
|
||||||
"remeda": "2.26.0",
|
"remeda": "2.26.0",
|
||||||
|
"remend": "1.3.0",
|
||||||
"shiki": "3.20.0",
|
"shiki": "3.20.0",
|
||||||
"solid-js": "1.9.10",
|
"solid-js": "1.9.10",
|
||||||
"solid-list": "0.3.0",
|
"solid-list": "0.3.0",
|
||||||
|
|
@ -4107,6 +4109,8 @@
|
||||||
|
|
||||||
"remeda": ["remeda@2.26.0", "", { "dependencies": { "type-fest": "^4.41.0" } }, "sha512-lmNNwtaC6Co4m0WTTNoZ/JlpjEqAjPZO0+czC9YVRQUpkbS4x8Hmh+Mn9HPfJfiXqUQ5IXXgSXSOB2pBKAytdA=="],
|
"remeda": ["remeda@2.26.0", "", { "dependencies": { "type-fest": "^4.41.0" } }, "sha512-lmNNwtaC6Co4m0WTTNoZ/JlpjEqAjPZO0+czC9YVRQUpkbS4x8Hmh+Mn9HPfJfiXqUQ5IXXgSXSOB2pBKAytdA=="],
|
||||||
|
|
||||||
|
"remend": ["remend@1.3.0", "", {}, "sha512-iIhggPkhW3hFImKtB10w0dz4EZbs28mV/dmbcYVonWEJ6UGHHpP+bFZnTh6GNWJONg5m+U56JrL+8IxZRdgWjw=="],
|
||||||
|
|
||||||
"request-light": ["request-light@0.7.0", "", {}, "sha512-lMbBMrDoxgsyO+yB3sDcrDuX85yYt7sS8BfQd11jtbW/z5ZWgLZRcEGLsLoYw7I0WSUGQBs8CC8ScIxkTX1+6Q=="],
|
"request-light": ["request-light@0.7.0", "", {}, "sha512-lMbBMrDoxgsyO+yB3sDcrDuX85yYt7sS8BfQd11jtbW/z5ZWgLZRcEGLsLoYw7I0WSUGQBs8CC8ScIxkTX1+6Q=="],
|
||||||
|
|
||||||
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
|
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@
|
||||||
"luxon": "3.6.1",
|
"luxon": "3.6.1",
|
||||||
"marked": "17.0.1",
|
"marked": "17.0.1",
|
||||||
"marked-shiki": "1.2.1",
|
"marked-shiki": "1.2.1",
|
||||||
|
"remend": "1.3.0",
|
||||||
"@playwright/test": "1.51.0",
|
"@playwright/test": "1.51.0",
|
||||||
"typescript": "5.8.2",
|
"typescript": "5.8.2",
|
||||||
"@typescript/native-preview": "7.0.0-dev.20251207.1",
|
"@typescript/native-preview": "7.0.0-dev.20251207.1",
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,7 @@
|
||||||
"motion-dom": "12.34.3",
|
"motion-dom": "12.34.3",
|
||||||
"motion-utils": "12.29.2",
|
"motion-utils": "12.29.2",
|
||||||
"remeda": "catalog:",
|
"remeda": "catalog:",
|
||||||
|
"remend": "catalog:",
|
||||||
"shiki": "catalog:",
|
"shiki": "catalog:",
|
||||||
"solid-js": "catalog:",
|
"solid-js": "catalog:",
|
||||||
"solid-list": "catalog:",
|
"solid-list": "catalog:",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { stream } from "./markdown-stream"
|
||||||
|
|
||||||
|
describe("markdown stream", () => {
|
||||||
|
test("heals incomplete emphasis while streaming", () => {
|
||||||
|
expect(stream("hello **world", true)).toEqual([{ raw: "hello **world", src: "hello **world**", mode: "live" }])
|
||||||
|
expect(stream("say `code", true)).toEqual([{ raw: "say `code", src: "say `code`", mode: "live" }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps incomplete links non-clickable until they finish", () => {
|
||||||
|
expect(stream("see [docs](https://example.com/gu", true)).toEqual([
|
||||||
|
{ raw: "see [docs](https://example.com/gu", src: "see docs", mode: "live" },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("splits an unfinished trailing code fence from stable content", () => {
|
||||||
|
expect(stream("before\n\n```ts\nconst x = 1", true)).toEqual([
|
||||||
|
{ raw: "before\n\n", src: "before\n\n", mode: "live" },
|
||||||
|
{ raw: "```ts\nconst x = 1", src: "```ts\nconst x = 1", mode: "live" },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps reference-style markdown as one block", () => {
|
||||||
|
expect(stream("[docs][1]\n\n[1]: https://example.com", true)).toEqual([
|
||||||
|
{
|
||||||
|
raw: "[docs][1]\n\n[1]: https://example.com",
|
||||||
|
src: "[docs][1]\n\n[1]: https://example.com",
|
||||||
|
mode: "live",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { marked, type Tokens } from "marked"
|
||||||
|
import remend from "remend"
|
||||||
|
|
||||||
|
export type Block = {
|
||||||
|
raw: string
|
||||||
|
src: string
|
||||||
|
mode: "full" | "live"
|
||||||
|
}
|
||||||
|
|
||||||
|
function refs(text: string) {
|
||||||
|
return /^\[[^\]]+\]:\s+\S+/m.test(text) || /^\[\^[^\]]+\]:\s+/m.test(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
function open(raw: string) {
|
||||||
|
const match = raw.match(/^[ \t]{0,3}(`{3,}|~{3,})/)
|
||||||
|
if (!match) return false
|
||||||
|
const mark = match[1]
|
||||||
|
if (!mark) return false
|
||||||
|
const char = mark[0]
|
||||||
|
const size = mark.length
|
||||||
|
const last = raw.trimEnd().split("\n").at(-1)?.trim() ?? ""
|
||||||
|
return !new RegExp(`^[\\t ]{0,3}${char}{${size},}[\\t ]*$`).test(last)
|
||||||
|
}
|
||||||
|
|
||||||
|
function heal(text: string) {
|
||||||
|
return remend(text, { linkMode: "text-only" })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stream(text: string, live: boolean) {
|
||||||
|
if (!live) return [{ raw: text, src: text, mode: "full" }] satisfies Block[]
|
||||||
|
const src = heal(text)
|
||||||
|
if (refs(text)) return [{ raw: text, src, mode: "live" }] satisfies Block[]
|
||||||
|
const tokens = marked.lexer(text)
|
||||||
|
const tail = tokens.findLastIndex((token) => token.type !== "space")
|
||||||
|
if (tail < 0) return [{ raw: text, src, mode: "live" }] satisfies Block[]
|
||||||
|
const last = tokens[tail]
|
||||||
|
if (!last || last.type !== "code") return [{ raw: text, src, mode: "live" }] satisfies Block[]
|
||||||
|
const code = last as Tokens.Code
|
||||||
|
if (!open(code.raw)) return [{ raw: text, src, mode: "live" }] satisfies Block[]
|
||||||
|
const head = tokens
|
||||||
|
.slice(0, tail)
|
||||||
|
.map((token) => token.raw)
|
||||||
|
.join("")
|
||||||
|
if (!head) return [{ raw: code.raw, src: code.raw, mode: "live" }] satisfies Block[]
|
||||||
|
return [
|
||||||
|
{ raw: head, src: heal(head), mode: "live" },
|
||||||
|
{ raw: code.raw, src: code.raw, mode: "live" },
|
||||||
|
] satisfies Block[]
|
||||||
|
}
|
||||||
|
|
@ -2,10 +2,10 @@ import { useMarked } from "../context/marked"
|
||||||
import { useI18n } from "../context/i18n"
|
import { useI18n } from "../context/i18n"
|
||||||
import DOMPurify from "dompurify"
|
import DOMPurify from "dompurify"
|
||||||
import morphdom from "morphdom"
|
import morphdom from "morphdom"
|
||||||
import { marked, type Tokens } from "marked"
|
|
||||||
import { checksum } from "@opencode-ai/util/encode"
|
import { checksum } from "@opencode-ai/util/encode"
|
||||||
import { ComponentProps, createEffect, createResource, createSignal, onCleanup, splitProps } from "solid-js"
|
import { ComponentProps, createEffect, createResource, createSignal, onCleanup, splitProps } from "solid-js"
|
||||||
import { isServer } from "solid-js/web"
|
import { isServer } from "solid-js/web"
|
||||||
|
import { stream } from "./markdown-stream"
|
||||||
|
|
||||||
type Entry = {
|
type Entry = {
|
||||||
hash: string
|
hash: string
|
||||||
|
|
@ -58,47 +58,6 @@ function fallback(markdown: string) {
|
||||||
return escape(markdown).replace(/\r\n?/g, "\n").replace(/\n/g, "<br>")
|
return escape(markdown).replace(/\r\n?/g, "\n").replace(/\n/g, "<br>")
|
||||||
}
|
}
|
||||||
|
|
||||||
type Block = {
|
|
||||||
raw: string
|
|
||||||
mode: "full" | "live"
|
|
||||||
}
|
|
||||||
|
|
||||||
function references(markdown: string) {
|
|
||||||
return /^\[[^\]]+\]:\s+\S+/m.test(markdown) || /^\[\^[^\]]+\]:\s+/m.test(markdown)
|
|
||||||
}
|
|
||||||
|
|
||||||
function incomplete(raw: string) {
|
|
||||||
const open = raw.match(/^[ \t]{0,3}(`{3,}|~{3,})/)
|
|
||||||
if (!open) return false
|
|
||||||
const mark = open[1]
|
|
||||||
if (!mark) return false
|
|
||||||
const char = mark[0]
|
|
||||||
const size = mark.length
|
|
||||||
const last = raw.trimEnd().split("\n").at(-1)?.trim() ?? ""
|
|
||||||
return !new RegExp(`^[\\t ]{0,3}${char}{${size},}[\\t ]*$`).test(last)
|
|
||||||
}
|
|
||||||
|
|
||||||
function blocks(markdown: string, streaming: boolean) {
|
|
||||||
if (!streaming || references(markdown)) return [{ raw: markdown, mode: "full" }] satisfies Block[]
|
|
||||||
const tokens = marked.lexer(markdown)
|
|
||||||
const last = tokens.findLast((token) => token.type !== "space")
|
|
||||||
if (!last || last.type !== "code") return [{ raw: markdown, mode: "full" }] satisfies Block[]
|
|
||||||
const code = last as Tokens.Code
|
|
||||||
if (!incomplete(code.raw)) return [{ raw: markdown, mode: "full" }] satisfies Block[]
|
|
||||||
const head = tokens
|
|
||||||
.slice(
|
|
||||||
0,
|
|
||||||
tokens.findLastIndex((token) => token.type !== "space"),
|
|
||||||
)
|
|
||||||
.map((token) => token.raw)
|
|
||||||
.join("")
|
|
||||||
if (!head) return [{ raw: code.raw, mode: "live" }] satisfies Block[]
|
|
||||||
return [
|
|
||||||
{ raw: head, mode: "full" },
|
|
||||||
{ raw: code.raw, mode: "live" },
|
|
||||||
] satisfies Block[]
|
|
||||||
}
|
|
||||||
|
|
||||||
type CopyLabels = {
|
type CopyLabels = {
|
||||||
copy: string
|
copy: string
|
||||||
copied: string
|
copied: string
|
||||||
|
|
@ -251,8 +210,6 @@ function setupCodeCopy(root: HTMLDivElement, getLabels: () => CopyLabels) {
|
||||||
timeouts.set(button, timeout)
|
timeouts.set(button, timeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
decorate(root, getLabels())
|
|
||||||
|
|
||||||
const buttons = Array.from(root.querySelectorAll('[data-slot="markdown-copy-button"]'))
|
const buttons = Array.from(root.querySelectorAll('[data-slot="markdown-copy-button"]'))
|
||||||
for (const button of buttons) {
|
for (const button of buttons) {
|
||||||
if (button instanceof HTMLButtonElement) updateLabel(button)
|
if (button instanceof HTMLButtonElement) updateLabel(button)
|
||||||
|
|
@ -304,7 +261,7 @@ export function Markdown(
|
||||||
|
|
||||||
const base = src.key ?? checksum(src.text)
|
const base = src.key ?? checksum(src.text)
|
||||||
return Promise.all(
|
return Promise.all(
|
||||||
blocks(src.text, src.streaming).map(async (block, index) => {
|
stream(src.text, src.streaming).map(async (block, index) => {
|
||||||
const hash = checksum(block.raw)
|
const hash = checksum(block.raw)
|
||||||
const key = base ? `${base}:${index}:${block.mode}` : hash
|
const key = base ? `${base}:${index}:${block.mode}` : hash
|
||||||
|
|
||||||
|
|
@ -316,7 +273,7 @@ export function Markdown(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const next = await Promise.resolve(marked.parse(block.raw))
|
const next = await Promise.resolve(marked.parse(block.src))
|
||||||
const safe = sanitize(next)
|
const safe = sanitize(next)
|
||||||
if (key && hash) touch(key, { hash, html: safe })
|
if (key && hash) touch(key, { hash, html: safe })
|
||||||
return safe
|
return safe
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue