fix(ui): reduce markdown jank while responses stream (#19304)
This commit is contained in:
@@ -37,7 +37,6 @@ import { LayoutProvider } from "@/context/layout"
|
|||||||
import { ModelsProvider } from "@/context/models"
|
import { ModelsProvider } from "@/context/models"
|
||||||
import { NotificationProvider } from "@/context/notification"
|
import { NotificationProvider } from "@/context/notification"
|
||||||
import { PermissionProvider } from "@/context/permission"
|
import { PermissionProvider } from "@/context/permission"
|
||||||
import { usePlatform } from "@/context/platform"
|
|
||||||
import { PromptProvider } from "@/context/prompt"
|
import { PromptProvider } from "@/context/prompt"
|
||||||
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
|
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
|
||||||
import { SettingsProvider } from "@/context/settings"
|
import { SettingsProvider } from "@/context/settings"
|
||||||
@@ -77,11 +76,6 @@ declare global {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function MarkedProviderWithNativeParser(props: ParentProps) {
|
|
||||||
const platform = usePlatform()
|
|
||||||
return <MarkedProvider nativeParser={platform.parseMarkdown}>{props.children}</MarkedProvider>
|
|
||||||
}
|
|
||||||
|
|
||||||
function QueryProvider(props: ParentProps) {
|
function QueryProvider(props: ParentProps) {
|
||||||
const client = new QueryClient()
|
const client = new QueryClient()
|
||||||
return <QueryClientProvider client={client}>{props.children}</QueryClientProvider>
|
return <QueryClientProvider client={client}>{props.children}</QueryClientProvider>
|
||||||
@@ -144,9 +138,9 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
|
|||||||
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
|
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
|
||||||
<QueryProvider>
|
<QueryProvider>
|
||||||
<DialogProvider>
|
<DialogProvider>
|
||||||
<MarkedProviderWithNativeParser>
|
<MarkedProvider>
|
||||||
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
|
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
|
||||||
</MarkedProviderWithNativeParser>
|
</MarkedProvider>
|
||||||
</DialogProvider>
|
</DialogProvider>
|
||||||
</QueryProvider>
|
</QueryProvider>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
|
|||||||
@@ -943,7 +943,10 @@ export function MessageTimeline(props: {
|
|||||||
"min-w-0 w-full max-w-full": true,
|
"min-w-0 w-full max-w-full": true,
|
||||||
"md:max-w-200 2xl:max-w-[1000px]": props.centered,
|
"md:max-w-200 2xl:max-w-[1000px]": props.centered,
|
||||||
}}
|
}}
|
||||||
style={{ "content-visibility": "auto", "contain-intrinsic-size": "auto 500px" }}
|
style={{
|
||||||
|
"content-visibility": active() ? undefined : "auto",
|
||||||
|
"contain-intrinsic-size": active() ? undefined : "auto 500px",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Show when={commentCount() > 0}>
|
<Show when={commentCount() > 0}>
|
||||||
<div class="w-full px-4 md:px-5 pb-2">
|
<div class="w-full px-4 md:px-5 pb-2">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ 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"
|
||||||
@@ -57,6 +58,47 @@ 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
|
||||||
@@ -180,10 +222,11 @@ function decorate(root: HTMLDivElement, labels: CopyLabels) {
|
|||||||
markCodeLinks(root)
|
markCodeLinks(root)
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupCodeCopy(root: HTMLDivElement, labels: CopyLabels) {
|
function setupCodeCopy(root: HTMLDivElement, getLabels: () => CopyLabels) {
|
||||||
const timeouts = new Map<HTMLButtonElement, ReturnType<typeof setTimeout>>()
|
const timeouts = new Map<HTMLButtonElement, ReturnType<typeof setTimeout>>()
|
||||||
|
|
||||||
const updateLabel = (button: HTMLButtonElement) => {
|
const updateLabel = (button: HTMLButtonElement) => {
|
||||||
|
const labels = getLabels()
|
||||||
const copied = button.getAttribute("data-copied") === "true"
|
const copied = button.getAttribute("data-copied") === "true"
|
||||||
setCopyState(button, labels, copied)
|
setCopyState(button, labels, copied)
|
||||||
}
|
}
|
||||||
@@ -200,6 +243,7 @@ function setupCodeCopy(root: HTMLDivElement, labels: CopyLabels) {
|
|||||||
const clipboard = navigator?.clipboard
|
const clipboard = navigator?.clipboard
|
||||||
if (!clipboard) return
|
if (!clipboard) return
|
||||||
await clipboard.writeText(content)
|
await clipboard.writeText(content)
|
||||||
|
const labels = getLabels()
|
||||||
setCopyState(button, labels, true)
|
setCopyState(button, labels, true)
|
||||||
const existing = timeouts.get(button)
|
const existing = timeouts.get(button)
|
||||||
if (existing) clearTimeout(existing)
|
if (existing) clearTimeout(existing)
|
||||||
@@ -207,7 +251,7 @@ function setupCodeCopy(root: HTMLDivElement, labels: CopyLabels) {
|
|||||||
timeouts.set(button, timeout)
|
timeouts.set(button, timeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
decorate(root, labels)
|
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) {
|
||||||
@@ -239,44 +283,56 @@ export function Markdown(
|
|||||||
props: ComponentProps<"div"> & {
|
props: ComponentProps<"div"> & {
|
||||||
text: string
|
text: string
|
||||||
cacheKey?: string
|
cacheKey?: string
|
||||||
|
streaming?: boolean
|
||||||
class?: string
|
class?: string
|
||||||
classList?: Record<string, boolean>
|
classList?: Record<string, boolean>
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const [local, others] = splitProps(props, ["text", "cacheKey", "class", "classList"])
|
const [local, others] = splitProps(props, ["text", "cacheKey", "streaming", "class", "classList"])
|
||||||
const marked = useMarked()
|
const marked = useMarked()
|
||||||
const i18n = useI18n()
|
const i18n = useI18n()
|
||||||
const [root, setRoot] = createSignal<HTMLDivElement>()
|
const [root, setRoot] = createSignal<HTMLDivElement>()
|
||||||
const [html] = createResource(
|
const [html] = createResource(
|
||||||
() => local.text,
|
() => ({
|
||||||
async (markdown) => {
|
text: local.text,
|
||||||
if (isServer) return fallback(markdown)
|
key: local.cacheKey,
|
||||||
|
streaming: local.streaming ?? false,
|
||||||
|
}),
|
||||||
|
async (src) => {
|
||||||
|
if (isServer) return fallback(src.text)
|
||||||
|
if (!src.text) return ""
|
||||||
|
|
||||||
const hash = checksum(markdown)
|
const base = src.key ?? checksum(src.text)
|
||||||
const key = local.cacheKey ?? hash
|
return Promise.all(
|
||||||
|
blocks(src.text, src.streaming).map(async (block, index) => {
|
||||||
|
const hash = checksum(block.raw)
|
||||||
|
const key = base ? `${base}:${index}:${block.mode}` : hash
|
||||||
|
|
||||||
if (key && hash) {
|
if (key && hash) {
|
||||||
const cached = cache.get(key)
|
const cached = cache.get(key)
|
||||||
if (cached && cached.hash === hash) {
|
if (cached && cached.hash === hash) {
|
||||||
touch(key, cached)
|
touch(key, cached)
|
||||||
return cached.html
|
return cached.html
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const next = await marked.parse(markdown)
|
const next = await Promise.resolve(marked.parse(block.raw))
|
||||||
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
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.then((list) => list.join(""))
|
||||||
|
.catch(() => fallback(src.text))
|
||||||
},
|
},
|
||||||
{ initialValue: isServer ? fallback(local.text) : "" },
|
{ initialValue: fallback(local.text) },
|
||||||
)
|
)
|
||||||
|
|
||||||
let copySetupTimer: ReturnType<typeof setTimeout> | undefined
|
|
||||||
let copyCleanup: (() => void) | undefined
|
let copyCleanup: (() => void) | undefined
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const container = root()
|
const container = root()
|
||||||
const content = html()
|
const content = local.text ? (html.latest ?? html() ?? "") : ""
|
||||||
if (!container) return
|
if (!container) return
|
||||||
if (isServer) return
|
if (isServer) return
|
||||||
|
|
||||||
@@ -285,33 +341,39 @@ export function Markdown(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const temp = document.createElement("div")
|
const labels = {
|
||||||
temp.innerHTML = content
|
|
||||||
decorate(temp, {
|
|
||||||
copy: i18n.t("ui.message.copy"),
|
copy: i18n.t("ui.message.copy"),
|
||||||
copied: i18n.t("ui.message.copied"),
|
copied: i18n.t("ui.message.copied"),
|
||||||
})
|
}
|
||||||
|
const temp = document.createElement("div")
|
||||||
|
temp.innerHTML = content
|
||||||
|
decorate(temp, labels)
|
||||||
|
|
||||||
morphdom(container, temp, {
|
morphdom(container, temp, {
|
||||||
childrenOnly: true,
|
childrenOnly: true,
|
||||||
onBeforeElUpdated: (fromEl, toEl) => {
|
onBeforeElUpdated: (fromEl, toEl) => {
|
||||||
|
if (
|
||||||
|
fromEl instanceof HTMLButtonElement &&
|
||||||
|
toEl instanceof HTMLButtonElement &&
|
||||||
|
fromEl.getAttribute("data-slot") === "markdown-copy-button" &&
|
||||||
|
toEl.getAttribute("data-slot") === "markdown-copy-button" &&
|
||||||
|
fromEl.getAttribute("data-copied") === "true"
|
||||||
|
) {
|
||||||
|
setCopyState(toEl, labels, true)
|
||||||
|
}
|
||||||
if (fromEl.isEqualNode(toEl)) return false
|
if (fromEl.isEqualNode(toEl)) return false
|
||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
if (copySetupTimer) clearTimeout(copySetupTimer)
|
if (!copyCleanup)
|
||||||
copySetupTimer = setTimeout(() => {
|
copyCleanup = setupCodeCopy(container, () => ({
|
||||||
if (copyCleanup) copyCleanup()
|
|
||||||
copyCleanup = setupCodeCopy(container, {
|
|
||||||
copy: i18n.t("ui.message.copy"),
|
copy: i18n.t("ui.message.copy"),
|
||||||
copied: i18n.t("ui.message.copied"),
|
copied: i18n.t("ui.message.copied"),
|
||||||
})
|
}))
|
||||||
}, 150)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
if (copySetupTimer) clearTimeout(copySetupTimer)
|
|
||||||
if (copyCleanup) copyCleanup()
|
if (copyCleanup) copyCleanup()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1334,6 +1334,9 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
|
|||||||
|
|
||||||
const displayText = () => (part().text ?? "").trim()
|
const displayText = () => (part().text ?? "").trim()
|
||||||
const throttledText = createThrottledValue(displayText)
|
const throttledText = createThrottledValue(displayText)
|
||||||
|
const streaming = createMemo(
|
||||||
|
() => props.message.role === "assistant" && typeof (props.message as AssistantMessage).time.completed !== "number",
|
||||||
|
)
|
||||||
const isLastTextPart = createMemo(() => {
|
const isLastTextPart = createMemo(() => {
|
||||||
const last = (data.store.part?.[props.message.id] ?? [])
|
const last = (data.store.part?.[props.message.id] ?? [])
|
||||||
.filter((item): item is TextPart => item?.type === "text" && !!item.text?.trim())
|
.filter((item): item is TextPart => item?.type === "text" && !!item.text?.trim())
|
||||||
@@ -1360,7 +1363,7 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
|
|||||||
<Show when={throttledText()}>
|
<Show when={throttledText()}>
|
||||||
<div data-component="text-part">
|
<div data-component="text-part">
|
||||||
<div data-slot="text-part-body">
|
<div data-slot="text-part-body">
|
||||||
<Markdown text={throttledText()} cacheKey={part().id} />
|
<Markdown text={throttledText()} cacheKey={part().id} streaming={streaming()} />
|
||||||
</div>
|
</div>
|
||||||
<Show when={showCopy()}>
|
<Show when={showCopy()}>
|
||||||
<div data-slot="text-part-copy-wrapper" data-interrupted={interrupted() ? "" : undefined}>
|
<div data-slot="text-part-copy-wrapper" data-interrupted={interrupted() ? "" : undefined}>
|
||||||
@@ -1394,11 +1397,14 @@ PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props) {
|
|||||||
const part = () => props.part as ReasoningPart
|
const part = () => props.part as ReasoningPart
|
||||||
const text = () => part().text.trim()
|
const text = () => part().text.trim()
|
||||||
const throttledText = createThrottledValue(text)
|
const throttledText = createThrottledValue(text)
|
||||||
|
const streaming = createMemo(
|
||||||
|
() => props.message.role === "assistant" && typeof (props.message as AssistantMessage).time.completed !== "number",
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show when={throttledText()}>
|
<Show when={throttledText()}>
|
||||||
<div data-component="reasoning-part">
|
<div data-component="reasoning-part">
|
||||||
<Markdown text={throttledText()} cacheKey={part().id} />
|
<Markdown text={throttledText()} cacheKey={part().id} streaming={streaming()} />
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user