fix(app): move markdown parsing to worker (#40356)
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { checksum } from "@opencode-ai/core/util/encode"
|
||||
import DOMPurify from "dompurify"
|
||||
import { project } from "./markdown-stream"
|
||||
import { parseMarkdown } from "./markdown-worker"
|
||||
|
||||
export type MarkdownCacheEntry = {
|
||||
raw: string
|
||||
@@ -52,27 +52,18 @@ export function touchCachedMarkdown(key: string, value: MarkdownCacheEntry) {
|
||||
cache.delete(first)
|
||||
}
|
||||
|
||||
export async function preloadMarkdown(
|
||||
text: string,
|
||||
cacheKey: string,
|
||||
parser: { parse(text: string): string | Promise<string> },
|
||||
) {
|
||||
await Promise.all(
|
||||
project(undefined, text, false).blocks.map(async (block, index) => {
|
||||
if (block.mode === "code") return
|
||||
const key = `${cacheKey}:${index}:${block.mode}`
|
||||
const cached = getCachedMarkdown(key)
|
||||
if (cached?.raw === block.raw) {
|
||||
touchCachedMarkdown(key, cached)
|
||||
return
|
||||
}
|
||||
const hash = checksum(block.raw)
|
||||
if (!hash) return
|
||||
touchCachedMarkdown(key, {
|
||||
raw: block.raw,
|
||||
hash,
|
||||
html: sanitizeMarkdown(await Promise.resolve(parser.parse(block.src))),
|
||||
})
|
||||
}),
|
||||
)
|
||||
export async function preloadMarkdown(text: string, cacheKey: string) {
|
||||
const key = `${cacheKey}:0:full`
|
||||
const cached = getCachedMarkdown(key)
|
||||
if (cached?.raw === text) {
|
||||
touchCachedMarkdown(key, cached)
|
||||
return
|
||||
}
|
||||
const hash = checksum(text)
|
||||
if (!hash) return
|
||||
touchCachedMarkdown(key, {
|
||||
raw: text,
|
||||
hash,
|
||||
html: sanitizeMarkdown(await parseMarkdown(text)),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { preloadMarkdown } from "./markdown-cache"
|
||||
|
||||
test("preloads completed markdown into the render cache", async () => {
|
||||
const parsed: string[] = []
|
||||
const parser = {
|
||||
parse(text: string) {
|
||||
parsed.push(text)
|
||||
return `<p>${text}</p>`
|
||||
},
|
||||
}
|
||||
const key = `markdown-preload-${crypto.randomUUID()}`
|
||||
|
||||
await preloadMarkdown("prepared response", key, parser)
|
||||
await preloadMarkdown("prepared response", key, parser)
|
||||
|
||||
expect(parsed).toEqual(["prepared response"])
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { Block, Projection } from "./markdown-stream"
|
||||
|
||||
export function completedProjection(text: string): Projection {
|
||||
return { text, blocks: [{ raw: text, src: text, mode: "full" }] }
|
||||
}
|
||||
|
||||
export function canReusePendingBlock(current: Pick<Block, "mode" | "raw"> | undefined, next: Block) {
|
||||
if (!current || current.mode !== next.mode) return false
|
||||
if (next.mode === "code" || next.mode === "live") return next.raw.startsWith(current.raw)
|
||||
return current.raw === next.raw
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { canReusePendingBlock, project, stream } from "./markdown-stream"
|
||||
import { canReusePendingBlock } from "./markdown-projection"
|
||||
import { project, stream } from "./markdown-stream"
|
||||
|
||||
describe("markdown stream", () => {
|
||||
test("heals incomplete emphasis while streaming", () => {
|
||||
@@ -129,6 +130,9 @@ describe("markdown stream", () => {
|
||||
expect(
|
||||
canReusePendingBlock({ mode: "code", raw: "```ts\none" }, { mode: "code", raw: "```ts\none two", src: "" }),
|
||||
).toBe(true)
|
||||
expect(canReusePendingBlock({ mode: "live", raw: "partial" }, { mode: "live", raw: "partial text", src: "" })).toBe(
|
||||
true,
|
||||
)
|
||||
expect(canReusePendingBlock({ mode: "code", raw: "```ts\none" }, { mode: "live", raw: "one", src: "" })).toBe(false)
|
||||
})
|
||||
|
||||
@@ -145,6 +149,39 @@ describe("markdown stream", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("finalizes only the live tail when streaming stops", () => {
|
||||
const live = project(undefined, "# Plan\n\nFinished paragraph.\n\n- final item", true)
|
||||
const final = project(live, live.text, false)
|
||||
|
||||
expect(final.blocks[0]).toBe(live.blocks[0])
|
||||
expect(final.blocks[1]).toBe(live.blocks[1])
|
||||
expect(final.blocks[2]).toEqual({ raw: "- final item", src: "- final item", mode: "full" })
|
||||
})
|
||||
|
||||
test("catches up paced text before finalizing", () => {
|
||||
const live = project(undefined, "# Plan\n\nFinished paragraph.\n\n- final", true)
|
||||
const final = project(live, `${live.text} item`, false)
|
||||
|
||||
expect(canReusePendingBlock(live.blocks[0], final.blocks[0]!)).toBe(true)
|
||||
expect(canReusePendingBlock(live.blocks[1], final.blocks[1]!)).toBe(true)
|
||||
expect(final.blocks[2]).toEqual({ raw: "- final item", src: "- final item", mode: "full" })
|
||||
})
|
||||
|
||||
test("completes an open code block when streaming stops", () => {
|
||||
const live = project(undefined, "```ts\nconst value = 1", true)
|
||||
const final = project(live, live.text, false)
|
||||
|
||||
expect(final.blocks).toEqual([
|
||||
{
|
||||
raw: "```ts\nconst value = 1",
|
||||
src: "const value = 1",
|
||||
mode: "code",
|
||||
language: "ts",
|
||||
complete: true,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("does not add a blank line before the first streamed code", () => {
|
||||
const previous = project(undefined, "```ts\n", true)
|
||||
const next = project(previous, `${previous.text}const x = 1`, true)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { marked, type Tokens } from "marked"
|
||||
import remend from "remend"
|
||||
import { completedProjection } from "./markdown-projection"
|
||||
|
||||
export type Block = {
|
||||
raw: string
|
||||
@@ -50,7 +51,7 @@ function heal(text: string) {
|
||||
}
|
||||
|
||||
export function stream(text: string, live: boolean): Block[] {
|
||||
if (!live) return [{ raw: text, src: text, mode: "full" }] satisfies Block[]
|
||||
if (!live) return completedProjection(text).blocks
|
||||
if (refs(text)) return [{ raw: text, src: heal(text), mode: "live" }] satisfies Block[]
|
||||
const tokens = marked.lexer(text)
|
||||
const tail = tokens.findLastIndex((token) => token.type !== "space")
|
||||
@@ -84,14 +85,25 @@ export function stream(text: string, live: boolean): Block[] {
|
||||
return [...result, { raw, src: openCode(code.raw), mode: "code", language: language(code.lang) }]
|
||||
}
|
||||
|
||||
export function canReusePendingBlock(current: Pick<Block, "mode" | "raw"> | undefined, next: Block) {
|
||||
if (!current || current.mode !== next.mode) return false
|
||||
if (next.mode === "code") return next.raw.startsWith(current.raw)
|
||||
return current.raw === next.raw
|
||||
}
|
||||
|
||||
export function project(previous: Projection | undefined, text: string, live: boolean): Projection {
|
||||
if (!live || !previous || !text.startsWith(previous.text)) return { text, blocks: stream(text, live) }
|
||||
if (!live) {
|
||||
const current =
|
||||
previous?.text === text
|
||||
? previous
|
||||
: previous && text.startsWith(previous.text)
|
||||
? project(previous, text, true)
|
||||
: undefined
|
||||
if (!current) return completedProjection(text)
|
||||
return {
|
||||
text,
|
||||
blocks: current.blocks.map((block) => {
|
||||
if (block.mode === "live") return { raw: block.raw, src: block.raw, mode: "full" }
|
||||
if (block.mode === "code" && !block.complete) return { ...block, complete: true }
|
||||
return block
|
||||
}),
|
||||
}
|
||||
}
|
||||
if (!previous || !text.startsWith(previous.text)) return { text, blocks: stream(text, live) }
|
||||
const tail = previous.blocks.at(-1)
|
||||
const suffix = text.slice(previous.text.length)
|
||||
if (!suffix || tail?.mode !== "code" || tail.complete || closesFence(tail.raw, suffix))
|
||||
|
||||
@@ -10,6 +10,7 @@ const response = (id: number, reset: boolean, stable: [string, string][], unstab
|
||||
type: "highlight" as const,
|
||||
id,
|
||||
key: "code",
|
||||
language: "typescript",
|
||||
reset,
|
||||
stable,
|
||||
unstable,
|
||||
@@ -20,6 +21,7 @@ test("accumulates stable worker tokens and replaces the unstable tail", () => {
|
||||
type: "highlight",
|
||||
id: 1,
|
||||
key: "code",
|
||||
language: "typescript",
|
||||
reset: true,
|
||||
stable: [token("one\n")],
|
||||
unstable: [token("tw")],
|
||||
@@ -28,6 +30,7 @@ test("accumulates stable worker tokens and replaces the unstable tail", () => {
|
||||
type: "highlight",
|
||||
id: 2,
|
||||
key: "code",
|
||||
language: "typescript",
|
||||
reset: false,
|
||||
stable: [token("two\n")],
|
||||
unstable: [token("three")],
|
||||
@@ -35,6 +38,7 @@ test("accumulates stable worker tokens and replaces the unstable tail", () => {
|
||||
|
||||
expect(second.stable.map((item) => item[0])).toEqual(["one\n", "two\n"])
|
||||
expect(second.unstable.map((item) => item[0])).toEqual(["three"])
|
||||
expect(second.language).toBe("typescript")
|
||||
})
|
||||
|
||||
test("increments generation only when the worker resets token identity", () => {
|
||||
@@ -45,12 +49,13 @@ test("increments generation only when the worker resets token identity", () => {
|
||||
})
|
||||
|
||||
test("ignores stale worker responses and resets replacement streams", () => {
|
||||
const current = { id: 2, generation: 1, stable: [token("current")], unstable: [] }
|
||||
const current = { id: 2, generation: 1, language: "typescript", stable: [token("current")], unstable: [] }
|
||||
expect(
|
||||
applyMarkdownWorkerResponse(current, {
|
||||
type: "highlight",
|
||||
id: 1,
|
||||
key: "code",
|
||||
language: "typescript",
|
||||
reset: false,
|
||||
stable: [token("stale")],
|
||||
unstable: [],
|
||||
@@ -62,6 +67,7 @@ test("ignores stale worker responses and resets replacement streams", () => {
|
||||
type: "highlight",
|
||||
id: 3,
|
||||
key: "code",
|
||||
language: "typescript",
|
||||
reset: true,
|
||||
stable: [token("replacement")],
|
||||
unstable: [],
|
||||
|
||||
@@ -1,27 +1,32 @@
|
||||
import type { ThemeRegistrationResolved } from "shiki"
|
||||
import type { Projection } from "./markdown-stream"
|
||||
|
||||
export type MarkdownToken = [content: string, style: string]
|
||||
|
||||
export type MarkdownWorkerRequest =
|
||||
| { type: "init"; theme: ThemeRegistrationResolved }
|
||||
| { type: "parse"; id: number; text: string }
|
||||
| { type: "project"; id: number; key: string; text: string; live: boolean }
|
||||
| { type: "highlight"; id: number; key: string; text: string; language: string; complete?: boolean }
|
||||
| { type: "dispose"; key: string }
|
||||
|
||||
export type MarkdownWorkerResponse =
|
||||
| { type: "parse"; id: number; html: string }
|
||||
| { type: "project"; id: number; key: string; projection: Projection }
|
||||
| {
|
||||
type: "highlight"
|
||||
id: number
|
||||
key: string
|
||||
language: string
|
||||
reset: boolean
|
||||
stable: MarkdownToken[]
|
||||
unstable: MarkdownToken[]
|
||||
}
|
||||
| { type: "error"; id: number; key: string; message: string }
|
||||
| { type: "error"; id: number; key?: string; message: string }
|
||||
| { type: "superseded"; id: number; key: string }
|
||||
|
||||
export type MarkdownWorkerState = {
|
||||
id: number
|
||||
generation: number
|
||||
language: string
|
||||
stable: MarkdownToken[]
|
||||
unstable: MarkdownToken[]
|
||||
}
|
||||
@@ -42,6 +47,7 @@ export function applyMarkdownWorkerResponse(
|
||||
return {
|
||||
id: response.id,
|
||||
generation: (state?.generation ?? 0) + (response.reset ? 1 : 0),
|
||||
language: response.language,
|
||||
stable: response.reset ? response.stable : [...(state?.stable ?? []), ...response.stable],
|
||||
unstable: response.unstable,
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import MarkdownShikiWorkerUrl from "./markdown-shiki.worker.ts?worker&url"
|
||||
import { OpenCodeTheme } from "@opencode-ai/ui/context/marked"
|
||||
import MarkdownWorkerUrl from "./markdown.worker.ts?worker&url"
|
||||
import {
|
||||
applyMarkdownWorkerResponse,
|
||||
shouldReleaseMarkdownWorkerState,
|
||||
@@ -8,18 +7,32 @@ import {
|
||||
type MarkdownWorkerState,
|
||||
} from "./markdown-worker-protocol"
|
||||
import { createWorkerTransport } from "./markdown-worker-transport"
|
||||
import type { Projection } from "./markdown-stream"
|
||||
|
||||
type Pending = {
|
||||
type HighlightPending = {
|
||||
key: string
|
||||
complete: boolean
|
||||
resolve: (state: MarkdownWorkerState) => void
|
||||
reject: (error: Error) => void
|
||||
}
|
||||
|
||||
type ProjectPending = {
|
||||
key: string
|
||||
resolve: (projection: Projection) => void
|
||||
reject: (error: Error) => void
|
||||
}
|
||||
|
||||
type ParsePending = {
|
||||
resolve: (html: string) => void
|
||||
reject: (error: Error) => void
|
||||
}
|
||||
|
||||
let worker: Worker | undefined
|
||||
let disabled: Error | undefined
|
||||
let nextID = 0
|
||||
const pending = new Map<number, Pending>()
|
||||
const pending = new Map<number, HighlightPending>()
|
||||
const projects = new Map<number, ProjectPending>()
|
||||
const parses = new Map<number, ParsePending>()
|
||||
const states = new Map<string, MarkdownWorkerState>()
|
||||
const keys = new Set<string>()
|
||||
const latest = new Map<string, number>()
|
||||
@@ -32,6 +45,43 @@ const transport = createWorkerTransport<Extract<MarkdownWorkerRequest, { type: "
|
||||
result.reject(new MarkdownWorkerSupersededError())
|
||||
},
|
||||
})
|
||||
const projectTransport = createWorkerTransport<Extract<MarkdownWorkerRequest, { type: "project" }>>({
|
||||
post: (request) => worker!.postMessage(request),
|
||||
supersede: (request) => {
|
||||
const result = projects.get(request.id)
|
||||
if (!result) return
|
||||
projects.delete(request.id)
|
||||
result.reject(new MarkdownWorkerSupersededError())
|
||||
},
|
||||
})
|
||||
|
||||
export function parseMarkdown(text: string) {
|
||||
const instance = getWorker()
|
||||
const id = ++nextID
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
parses.set(id, { resolve, reject })
|
||||
instance.postMessage({ type: "parse", id, text } satisfies MarkdownWorkerRequest)
|
||||
})
|
||||
}
|
||||
|
||||
export function projectMarkdown(key: string, text: string, live: boolean) {
|
||||
getWorker()
|
||||
const id = ++nextID
|
||||
return new Promise<Projection>((resolve, reject) => {
|
||||
projects.set(id, { key, resolve, reject })
|
||||
projectTransport.send({ type: "project", id, key, text, live })
|
||||
})
|
||||
}
|
||||
|
||||
export function disposeMarkdownProjection(key: string) {
|
||||
projectTransport.dispose(key)
|
||||
projects.forEach((request, id) => {
|
||||
if (request.key !== key) return
|
||||
projects.delete(id)
|
||||
request.reject(new MarkdownWorkerDisposedError())
|
||||
})
|
||||
worker?.postMessage({ type: "dispose", key } satisfies MarkdownWorkerRequest)
|
||||
}
|
||||
|
||||
export function highlightStreamingCode(key: string, text: string, language: string, complete = false) {
|
||||
const instance = getWorker()
|
||||
@@ -67,48 +117,97 @@ function getWorker() {
|
||||
if (worker) return worker
|
||||
if (disabled) throw new MarkdownWorkerUnavailableError(disabled.message)
|
||||
try {
|
||||
worker = new Worker(MarkdownShikiWorkerUrl, { type: "module" })
|
||||
worker = new Worker(MarkdownWorkerUrl, { type: "module" })
|
||||
} catch (error) {
|
||||
disabled = error instanceof Error ? error : new Error(String(error))
|
||||
throw new MarkdownWorkerUnavailableError(disabled.message)
|
||||
}
|
||||
worker.onmessage = (event: MessageEvent<MarkdownWorkerResponse>) => {
|
||||
if (event.data.type === "parse") {
|
||||
const result = parses.get(event.data.id)
|
||||
if (!result) return
|
||||
parses.delete(event.data.id)
|
||||
result.resolve(event.data.html)
|
||||
return
|
||||
}
|
||||
if (event.data.type === "project") {
|
||||
const result = projects.get(event.data.id)
|
||||
if (!result) {
|
||||
projectTransport.complete(event.data.key, event.data.id)
|
||||
return
|
||||
}
|
||||
projects.delete(event.data.id)
|
||||
result.resolve(event.data.projection)
|
||||
projectTransport.complete(event.data.key, event.data.id)
|
||||
return
|
||||
}
|
||||
if (event.data.type === "error") {
|
||||
const parsed = parses.get(event.data.id)
|
||||
if (parsed) {
|
||||
parses.delete(event.data.id)
|
||||
parsed.reject(new Error(event.data.message))
|
||||
return
|
||||
}
|
||||
const projected = projects.get(event.data.id)
|
||||
if (projected) {
|
||||
projects.delete(event.data.id)
|
||||
projected.reject(new Error(event.data.message))
|
||||
projectTransport.complete(projected.key, event.data.id)
|
||||
return
|
||||
}
|
||||
}
|
||||
if (event.data.type === "superseded") {
|
||||
const projected = projects.get(event.data.id)
|
||||
if (projected) {
|
||||
projects.delete(event.data.id)
|
||||
projected.reject(new MarkdownWorkerSupersededError())
|
||||
projectTransport.complete(projected.key, event.data.id)
|
||||
return
|
||||
}
|
||||
}
|
||||
const key = event.data.key
|
||||
if (!key) return
|
||||
const result = pending.get(event.data.id)
|
||||
if (!result) {
|
||||
transport.complete(event.data.key, event.data.id)
|
||||
transport.complete(key, event.data.id)
|
||||
return
|
||||
}
|
||||
pending.delete(event.data.id)
|
||||
if (!keys.has(event.data.key)) {
|
||||
if (!keys.has(key)) {
|
||||
result.reject(new MarkdownWorkerDisposedError())
|
||||
transport.complete(event.data.key, event.data.id)
|
||||
transport.complete(key, event.data.id)
|
||||
return
|
||||
}
|
||||
if (event.data.type === "superseded") {
|
||||
result.reject(new MarkdownWorkerSupersededError())
|
||||
transport.complete(event.data.key, event.data.id)
|
||||
transport.complete(key, event.data.id)
|
||||
return
|
||||
}
|
||||
if (event.data.type === "error") {
|
||||
result.reject(new Error(event.data.message))
|
||||
transport.complete(event.data.key, event.data.id)
|
||||
transport.complete(key, event.data.id)
|
||||
return
|
||||
}
|
||||
const state = applyMarkdownWorkerResponse(states.get(event.data.key), event.data)
|
||||
if (shouldReleaseMarkdownWorkerState(result.complete, latest.get(event.data.key), event.data.id)) {
|
||||
states.delete(event.data.key)
|
||||
keys.delete(event.data.key)
|
||||
latest.delete(event.data.key)
|
||||
} else states.set(event.data.key, state)
|
||||
const state = applyMarkdownWorkerResponse(states.get(key), event.data)
|
||||
if (shouldReleaseMarkdownWorkerState(result.complete, latest.get(key), event.data.id)) {
|
||||
states.delete(key)
|
||||
keys.delete(key)
|
||||
latest.delete(key)
|
||||
} else states.set(key, state)
|
||||
result.resolve(state)
|
||||
transport.complete(event.data.key, event.data.id)
|
||||
transport.complete(key, event.data.id)
|
||||
}
|
||||
const fail = (message: string) => {
|
||||
const error = new Error(message)
|
||||
disabled = error
|
||||
transport.reset()
|
||||
projectTransport.reset()
|
||||
pending.forEach((request) => request.reject(error))
|
||||
projects.forEach((request) => request.reject(error))
|
||||
parses.forEach((request) => request.reject(error))
|
||||
pending.clear()
|
||||
projects.clear()
|
||||
parses.clear()
|
||||
states.clear()
|
||||
keys.clear()
|
||||
latest.clear()
|
||||
@@ -117,6 +216,5 @@ function getWorker() {
|
||||
}
|
||||
worker.onerror = (event) => fail(event.message || "Markdown highlighting worker failed")
|
||||
worker.onmessageerror = () => fail("Markdown worker response failed")
|
||||
worker.postMessage({ type: "init", theme: OpenCodeTheme } satisfies MarkdownWorkerRequest)
|
||||
return worker
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useMarked } from "@opencode-ai/ui/context/marked"
|
||||
import { useI18n } from "@opencode-ai/ui/context/i18n"
|
||||
import morphdom from "morphdom"
|
||||
import { checksum } from "@opencode-ai/core/util/encode"
|
||||
@@ -6,7 +5,6 @@ import {
|
||||
type Accessor,
|
||||
type ComponentProps,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createResource,
|
||||
createSignal,
|
||||
createUniqueId,
|
||||
@@ -18,14 +16,17 @@ import { isServer, render } from "solid-js/web"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { bundledLanguages } from "shiki"
|
||||
import { canReusePendingBlock, project, type Block, type Projection } from "./markdown-stream"
|
||||
import { canReusePendingBlock, completedProjection } from "./markdown-projection"
|
||||
import type { Block, Projection } from "./markdown-stream"
|
||||
import {
|
||||
disposeMarkdownProjection,
|
||||
disposeStreamingCode,
|
||||
highlightStreamingCode,
|
||||
MarkdownWorkerDisposedError,
|
||||
MarkdownWorkerSupersededError,
|
||||
MarkdownWorkerUnavailableError,
|
||||
parseMarkdown,
|
||||
projectMarkdown,
|
||||
} from "./markdown-worker"
|
||||
import { markdownBlockKey, type MarkdownToken } from "./markdown-worker-protocol"
|
||||
import { shouldResetCodeTokens, type RenderedCodeState } from "./markdown-code-state"
|
||||
@@ -67,10 +68,14 @@ function fallback(markdown: string) {
|
||||
}
|
||||
|
||||
async function code(text: string, language: string | undefined, key: string, complete = false) {
|
||||
const name = language && language in bundledLanguages ? language : "text"
|
||||
try {
|
||||
const result = await highlightStreamingCode(key, text, name, complete)
|
||||
return { language: name, generation: result.generation, stable: result.stable, unstable: result.unstable }
|
||||
const result = await highlightStreamingCode(key, text, language ?? "text", complete)
|
||||
return {
|
||||
language: result.language,
|
||||
generation: result.generation,
|
||||
stable: result.stable,
|
||||
unstable: result.unstable,
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
!(error instanceof MarkdownWorkerDisposedError) &&
|
||||
@@ -78,7 +83,7 @@ async function code(text: string, language: string | undefined, key: string, com
|
||||
!(error instanceof MarkdownWorkerUnavailableError)
|
||||
)
|
||||
console.error("Markdown highlighting worker failed", error)
|
||||
return { language: name, generation: 0, stable: [], unstable: [[text, ""] as MarkdownToken] }
|
||||
return { language: language ?? "text", generation: 0, stable: [], unstable: [[text, ""] as MarkdownToken] }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -352,6 +357,10 @@ function initialResult(text: string, key: string | undefined, projection: Projec
|
||||
}
|
||||
}
|
||||
|
||||
function pendingProjection(text: string): Projection {
|
||||
return { text, blocks: text ? [{ raw: text, src: text, mode: "live" }] : [] }
|
||||
}
|
||||
|
||||
export function Markdown(
|
||||
props: ComponentProps<"div"> & {
|
||||
text: string
|
||||
@@ -362,21 +371,44 @@ export function Markdown(
|
||||
},
|
||||
) {
|
||||
const [local, others] = splitProps(props, ["text", "cacheKey", "streaming", "class", "classList"])
|
||||
const marked = useMarked()
|
||||
const i18n = useI18n()
|
||||
const [root, setRoot] = createSignal<HTMLDivElement>()
|
||||
const owner = createUniqueId()
|
||||
const activeCodeKeys = new Set<string>()
|
||||
const completedCode = new Map<string, Extract<RenderedBlock, { mode: "code" }>>()
|
||||
const projection = createMemo((previous: Projection | undefined) =>
|
||||
project(previous, local.text, local.streaming ?? false),
|
||||
let streamed = false
|
||||
const [projection] = createResource(
|
||||
() => {
|
||||
if (isServer) return
|
||||
const live = local.streaming ?? false
|
||||
if (live) streamed = true
|
||||
if (!live && !streamed) return
|
||||
return { key: owner, text: local.text, live }
|
||||
},
|
||||
(src) => projectMarkdown(src.key, src.text, src.live),
|
||||
{ initialValue: pendingProjection("") },
|
||||
)
|
||||
const currentProjection = () => {
|
||||
if (!(local.streaming ?? false) && !streamed) return completedProjection(local.text)
|
||||
const value = projection.latest
|
||||
if (value?.text === local.text) return value
|
||||
if (value?.text) return value
|
||||
return pendingProjection(local.text)
|
||||
}
|
||||
const [html] = createResource(
|
||||
() => {
|
||||
if (isServer)
|
||||
return {
|
||||
text: local.text,
|
||||
key: local.cacheKey,
|
||||
projection: pendingProjection(local.text),
|
||||
}
|
||||
const value = !(local.streaming ?? false) && !streamed ? completedProjection(local.text) : projection.latest
|
||||
if (!value || value.text !== local.text) return
|
||||
return {
|
||||
text: local.text,
|
||||
key: local.cacheKey,
|
||||
projection: projection(),
|
||||
projection: value,
|
||||
}
|
||||
},
|
||||
async (src) => {
|
||||
@@ -426,7 +458,7 @@ export function Markdown(
|
||||
}
|
||||
|
||||
const hash = checksum(block.raw)
|
||||
const safe = sanitizeMarkdown(await Promise.resolve(marked.parse(block.src)))
|
||||
const safe = sanitizeMarkdown(await parseMarkdown(block.src))
|
||||
if (key && hash) touchCachedMarkdown(key, { raw: block.raw, hash, html: safe })
|
||||
return { key: blockKey, mode: block.mode, raw: block.raw, hash: hash ?? "", html: safe }
|
||||
}),
|
||||
@@ -449,7 +481,12 @@ export function Markdown(
|
||||
)
|
||||
},
|
||||
{
|
||||
initialValue: initialResult(local.text, local.cacheKey, projection(), owner),
|
||||
initialValue: initialResult(
|
||||
local.text,
|
||||
local.cacheKey,
|
||||
local.streaming ? pendingProjection(local.text) : completedProjection(local.text),
|
||||
owner,
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -458,7 +495,7 @@ export function Markdown(
|
||||
createEffect(() => {
|
||||
const container = root()
|
||||
const result = html.latest ?? html()
|
||||
const projected = projection()
|
||||
const projected = currentProjection()
|
||||
const content = local.text ? pendingBlocks(result, projected, local.cacheKey, owner) : []
|
||||
if (!container) return
|
||||
if (isServer) return
|
||||
@@ -497,6 +534,7 @@ export function Markdown(
|
||||
|
||||
onCleanup(() => {
|
||||
if (copyCleanup) copyCleanup()
|
||||
disposeMarkdownProjection(owner)
|
||||
activeCodeKeys.forEach(disposeCode)
|
||||
completedCode.clear()
|
||||
})
|
||||
|
||||
+57
-8
@@ -1,6 +1,8 @@
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
import { ShikiStreamTokenizer } from "@shikijs/stream"
|
||||
import { createMarkdownParser } from "@opencode-ai/ui/context/marked-parser"
|
||||
import { OpenCodeTheme } from "@opencode-ai/ui/context/marked-theme"
|
||||
import {
|
||||
bundledLanguages,
|
||||
createHighlighter,
|
||||
@@ -11,6 +13,7 @@ import {
|
||||
} from "shiki"
|
||||
import type { MarkdownToken, MarkdownWorkerRequest, MarkdownWorkerResponse } from "./markdown-worker-protocol"
|
||||
import { createLatestWorkerQueue } from "./markdown-worker-queue"
|
||||
import { project, type Projection } from "./markdown-stream"
|
||||
|
||||
type Stream = {
|
||||
language: string
|
||||
@@ -19,30 +22,70 @@ type Stream = {
|
||||
}
|
||||
|
||||
const streams = new Map<string, Stream>()
|
||||
const projections = new Map<string, Projection>()
|
||||
let highlighter: ReturnType<typeof createHighlighter> | undefined
|
||||
const queue = createLatestWorkerQueue<Extract<MarkdownWorkerRequest, { type: "highlight" }>>({
|
||||
const highlightQueue = createLatestWorkerQueue<Extract<MarkdownWorkerRequest, { type: "highlight" }>>({
|
||||
run: highlight,
|
||||
supersede: (request) => post({ type: "superseded", id: request.id, key: request.key }),
|
||||
dispose: (key) => void streams.delete(key),
|
||||
})
|
||||
const projectQueue = createLatestWorkerQueue<Extract<MarkdownWorkerRequest, { type: "project" }>>({
|
||||
run: runProject,
|
||||
supersede: (request) => post({ type: "superseded", id: request.id, key: request.key }),
|
||||
dispose: (key) => void projections.delete(key),
|
||||
})
|
||||
const parser = createMarkdownParser(async (code, language) => {
|
||||
const instance = await getHighlighter()
|
||||
const name = language in bundledLanguages ? language : "text"
|
||||
if (!instance.getLoadedLanguages().includes(name))
|
||||
await instance.loadLanguage(bundledLanguages[name as BundledLanguage])
|
||||
return instance.codeToHtml(code, { lang: name as BundledLanguage, theme: "OpenCode", tabindex: false })
|
||||
})
|
||||
|
||||
self.onmessage = (event: MessageEvent<MarkdownWorkerRequest>) => {
|
||||
if (event.data.type === "init") {
|
||||
highlighter ??= createHighlighter({ themes: [event.data.theme], langs: [] })
|
||||
if (event.data.type === "dispose") {
|
||||
highlightQueue.dispose(event.data.key)
|
||||
projectQueue.dispose(event.data.key)
|
||||
return
|
||||
}
|
||||
if (event.data.type === "dispose") {
|
||||
queue.dispose(event.data.key)
|
||||
if (event.data.type === "parse") {
|
||||
void parse(event.data)
|
||||
return
|
||||
}
|
||||
if (event.data.type === "project") {
|
||||
projectQueue.highlight(event.data)
|
||||
return
|
||||
}
|
||||
|
||||
queue.highlight(event.data)
|
||||
highlightQueue.highlight(event.data)
|
||||
}
|
||||
|
||||
async function parse(request: Extract<MarkdownWorkerRequest, { type: "parse" }>) {
|
||||
try {
|
||||
post({ type: "parse", id: request.id, html: await parser.parse(request.text) })
|
||||
} catch (error) {
|
||||
post({ type: "error", id: request.id, message: error instanceof Error ? error.message : String(error) })
|
||||
}
|
||||
}
|
||||
|
||||
async function runProject(request: Extract<MarkdownWorkerRequest, { type: "project" }>) {
|
||||
try {
|
||||
const projection = project(projections.get(request.key), request.text, request.live)
|
||||
projections.set(request.key, projection)
|
||||
post({ type: "project", id: request.id, key: request.key, projection })
|
||||
} catch (error) {
|
||||
post({
|
||||
type: "error",
|
||||
id: request.id,
|
||||
key: request.key,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function highlight(request: Extract<MarkdownWorkerRequest, { type: "highlight" }>) {
|
||||
try {
|
||||
const instance = await highlighter
|
||||
if (!instance) throw new Error("Shiki worker is not initialized")
|
||||
const instance = await getHighlighter()
|
||||
const language = request.language in bundledLanguages ? request.language : "text"
|
||||
if (!instance.getLoadedLanguages().includes(language))
|
||||
await instance.loadLanguage(bundledLanguages[language as BundledLanguage])
|
||||
@@ -54,6 +97,7 @@ async function highlight(request: Extract<MarkdownWorkerRequest, { type: "highli
|
||||
type: "highlight",
|
||||
id: request.id,
|
||||
key: request.key,
|
||||
language,
|
||||
reset: true,
|
||||
stable: result.tokens
|
||||
.flatMap((line, index) =>
|
||||
@@ -81,6 +125,7 @@ async function highlight(request: Extract<MarkdownWorkerRequest, { type: "highli
|
||||
type: "highlight",
|
||||
id: request.id,
|
||||
key: request.key,
|
||||
language,
|
||||
reset,
|
||||
stable: result.stable.filter((token) => token.content.length > 0).map(token),
|
||||
unstable: result.unstable.filter((token) => token.content.length > 0).map(token),
|
||||
@@ -95,6 +140,10 @@ async function highlight(request: Extract<MarkdownWorkerRequest, { type: "highli
|
||||
}
|
||||
}
|
||||
|
||||
function getHighlighter() {
|
||||
return (highlighter ??= createHighlighter({ themes: [OpenCodeTheme], langs: [] }))
|
||||
}
|
||||
|
||||
function post(response: MarkdownWorkerResponse) {
|
||||
self.postMessage(response)
|
||||
}
|
||||
@@ -1739,9 +1739,7 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
|
||||
<Show when={text()}>
|
||||
<div data-component="text-part" data-timeline-part-id={part().id}>
|
||||
<div data-slot="text-part-body">
|
||||
<Show when={streaming()} fallback={<Markdown text={text()} cacheKey={part().id} streaming={false} />}>
|
||||
<PacedMarkdown text={text()} cacheKey={part().id} streaming={streaming()} />
|
||||
</Show>
|
||||
<PacedMarkdown text={text()} cacheKey={part().id} streaming={streaming()} />
|
||||
</div>
|
||||
<Show when={showCopy()}>
|
||||
<div data-slot="text-part-copy-wrapper" data-interrupted={interrupted() ? "" : undefined}>
|
||||
@@ -1776,9 +1774,7 @@ PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props) {
|
||||
return (
|
||||
<Show when={text()}>
|
||||
<div data-component="reasoning-part" data-timeline-part-id={part().id}>
|
||||
<Show when={streaming()} fallback={<Markdown text={text()} cacheKey={part().id} streaming={false} />}>
|
||||
<PacedMarkdown text={text()} cacheKey={part().id} streaming={streaming()} />
|
||||
</Show>
|
||||
<PacedMarkdown text={text()} cacheKey={part().id} streaming={streaming()} />
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { WorkerPoolManager } from "@pierre/diffs/worker"
|
||||
import ShikiWorkerUrl from "@pierre/diffs/worker/worker.js?worker&url"
|
||||
import { registerOpenCodeTheme } from "@opencode-ai/ui/context/marked-theme-register"
|
||||
|
||||
registerOpenCodeTheme()
|
||||
|
||||
export type WorkerPoolStyle = "unified" | "split"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user