diff options
| author | Adam Malczewski <[email protected]> | 2026-10-19 23:48:50 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-10-19 23:48:50 +0900 |
| commit | aa63d6b9f4cb14535fd0191ab358c27f0ee62ce8 (patch) | |
| tree | d089ad4db3522d5ba8338f6badd4f78ab24fbd7f /src | |
| parent | 6122a90cf0c9f938945e684e19e74153b38e7b4b (diff) | |
| download | dispatch-web-aa63d6b9f4cb14535fd0191ab358c27f0ee62ce8.tar.gz dispatch-web-aa63d6b9f4cb14535fd0191ab358c27f0ee62ce8.zip | |
feat(markdown): render local-path images inline via backend image route
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/views/ChatPane.svelte | 1 | ||||
| -rw-r--r-- | src/features/chat/ui.test.ts | 19 | ||||
| -rw-r--r-- | src/features/chat/ui/ChatView.svelte | 13 | ||||
| -rw-r--r-- | src/features/markdown/logic/markdown.test.ts | 93 | ||||
| -rw-r--r-- | src/features/markdown/logic/markdown.ts | 110 | ||||
| -rw-r--r-- | src/features/markdown/logic/streaming-markdown.ts | 61 | ||||
| -rw-r--r-- | src/features/markdown/ui/Markdown.svelte | 19 |
7 files changed, 295 insertions, 21 deletions
diff --git a/src/app/views/ChatPane.svelte b/src/app/views/ChatPane.svelte index 31e719e..d1630af 100644 --- a/src/app/views/ChatPane.svelte +++ b/src/app/views/ChatPane.svelte @@ -86,6 +86,7 @@ thinkingKeyBase={store.activeChat.thinkingKeyBase} providerRetry={store.activeChat.providerRetry} apiBaseUrl={store.httpBase} + cwd={store.cwd} {cacheTimer} /> {/key} diff --git a/src/features/chat/ui.test.ts b/src/features/chat/ui.test.ts index 10af76c..c557e4f 100644 --- a/src/features/chat/ui.test.ts +++ b/src/features/chat/ui.test.ts @@ -56,6 +56,25 @@ describe("ChatView", () => { expect(screen.getByText("Hello!")).toBeInTheDocument(); }); + it("renders local-path Markdown images through the backend image route", () => { + const chunks: RenderedChunk[] = [ + { + seq: 1, + role: "assistant", + chunk: { type: "text", text: "" }, + provisional: false, + }, + ]; + + const { container } = render(ChatView, { + props: { chunks, apiBaseUrl: "http://localhost:24203", cwd: "/w/foo" }, + }); + + expect(container.querySelector("img")?.getAttribute("src")).toBe( + "http://localhost:24203/local-image?path=%2Fhome%2Fu%2Fpic.png", + ); + }); + it("shows the show-earlier button only when earlier history is unloaded, and pages it in", async () => { const chunks: RenderedChunk[] = [ { seq: 26, role: "user", chunk: { type: "text", text: "later" }, provisional: false }, diff --git a/src/features/chat/ui/ChatView.svelte b/src/features/chat/ui/ChatView.svelte index 4fd0911..f34de9a 100644 --- a/src/features/chat/ui/ChatView.svelte +++ b/src/features/chat/ui/ChatView.svelte @@ -30,6 +30,7 @@ thinkingKeyBase = 0, providerRetry = null, apiBaseUrl = "", + cwd = null, cacheTimer = null, }: { chunks: readonly RenderedChunk[]; @@ -60,6 +61,12 @@ */ apiBaseUrl?: string; /** + * The active conversation's working directory (null when unset). Relative + * Markdown image srcs (``) resolve against it; local absolute + * and `file://` paths do not need it. Passed to `Markdown` with `apiBaseUrl`. + */ + cwd?: string | null; + /** * The cache timer (gaps between model requests): a running step gap shows * live at the bottom (under the executing tools), a running turn gap live * on that turn's metrics row; finished gaps are stamped on their step / @@ -187,7 +194,11 @@ <div class="chat chat-start [&>.chat-bubble]:max-w-5xl"> <div class="chat-bubble w-full bg-transparent"> {#if rendered.chunk.type === "text"} - <Markdown text={rendered.chunk.text} streaming={rendered.streaming ?? false} /> + <Markdown + text={rendered.chunk.text} + streaming={rendered.streaming ?? false} + image={{ apiBase: apiBaseUrl, cwd }} + /> {:else if rendered.chunk.type === "error"} <div class="text-error" role="alert"> {rendered.chunk.message} diff --git a/src/features/markdown/logic/markdown.test.ts b/src/features/markdown/logic/markdown.test.ts index 90f1938..61d0d0c 100644 --- a/src/features/markdown/logic/markdown.test.ts +++ b/src/features/markdown/logic/markdown.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it } from "vitest"; -import { renderMarkdown } from "./markdown"; +import { renderMarkdown, renderMarkdownTail } from "./markdown"; +import { advance, createStreamingMarkdown } from "./streaming-markdown"; + +/** The single rendered `<img>` src, or null. */ +function imageSrc(html: string): string | null { + const doc = new DOMParser().parseFromString(html, "text/html"); + return doc.querySelector("img")?.getAttribute("src") ?? null; +} describe("renderMarkdown", () => { it("renders GFM markdown (headings, emphasis)", () => { @@ -95,4 +102,88 @@ describe("renderMarkdown", () => { it("returns an empty string for empty input", () => { expect(renderMarkdown("")).toBe(""); }); + + describe("local-path images", () => { + const BASE = "http://localhost:24203"; + const image = { apiBase: BASE, cwd: "/w/foo" }; + + it("rewrites a file:// URL to the backend image route", () => { + expect(imageSrc(renderMarkdown("", { image }))).toBe( + `${BASE}/local-image?path=%2Fhome%2Fu%2Fpic.png`, + ); + }); + + it("rewrites an absolute path; an empty apiBase yields a root-relative src", () => { + expect(imageSrc(renderMarkdown("", { image: { apiBase: "" } }))).toBe( + "/local-image?path=%2Fhome%2Fu%2Fpic.png", + ); + expect(imageSrc(renderMarkdown("", { image }))).toBe( + `${BASE}/local-image?path=%2Fhome%2Fu%2Fpic.png`, + ); + }); + + it("resolves a relative path against the conversation cwd", () => { + expect(imageSrc(renderMarkdown("", { image }))).toBe( + `${BASE}/local-image?path=%2Fw%2Ffoo%2Fshot.png`, + ); + expect( + imageSrc(renderMarkdown("", { image: { apiBase: BASE, cwd: "/w/foo/" } })), + ).toBe(`${BASE}/local-image?path=%2Fw%2Ffoo%2Fshot.png`); + }); + + it("leaves a relative path untouched when no cwd is available", () => { + expect(imageSrc(renderMarkdown("", { image: { apiBase: BASE } }))).toBe( + "shot.png", + ); + expect(imageSrc(renderMarkdown("", { image }))).toBe( + `${BASE}/local-image?path=%2Fw%2Ffoo%2Fshot.png`, + ); + expect(imageSrc(renderMarkdown(""))).toBe("shot.png"); + }); + + it("leaves remote http(s) and data: images untouched", () => { + expect(imageSrc(renderMarkdown("", { image }))).toBe( + "https://example.com/a.png", + ); + expect(imageSrc(renderMarkdown("", { image }))).toBe( + "http://example.com/a.png", + ); + const data = "data:image/png;base64,iVBORw0KGgo="; + expect(imageSrc(renderMarkdown(``, { image }))).toBe(data); + }); + + it("does not rewrite image syntax inside fenced code", () => { + const html = renderMarkdown("```md\n\n```", { image }); + expect(html).toContain("/home/u/pic.png"); + expect(html).not.toContain("/local-image"); + }); + + it("rewrites the streaming tail, the settled prefix, and a seeded tail", () => { + const state = createStreamingMarkdown(); + const tail = advance(state, "", { image }); + expect(imageSrc(tail.tailHtml)).toBe(`${BASE}/local-image?path=%2Fhome%2Fu%2Fpic.png`); + + // The settled prefix must render with the same options too. + const settled = advance(state, "\n\nnext\n", { image }); + expect(imageSrc(settled.prefixHtml)).toBe(`${BASE}/local-image?path=%2Fhome%2Fu%2Fpic.png`); + + const seeded = renderMarkdownTail( + "", + { fences: 0, bold: 0, code: 0 }, + { + image, + }, + ); + expect(imageSrc(seeded)).toBe(`${BASE}/local-image?path=%2Fw%2Ffoo%2Fshot.png`); + }); + + it("invalidates the streaming cache when the image options change (async cwd)", () => { + const state = createStreamingMarkdown(); + const text = "\n\nnext\n"; + advance(state, text); + expect(imageSrc(state.prefixHtml)).toBe("shot.png"); // no cwd yet + const updated = advance(state, text, { image }); + expect(imageSrc(updated.prefixHtml)).toBe(`${BASE}/local-image?path=%2Fw%2Ffoo%2Fshot.png`); + }); + }); }); diff --git a/src/features/markdown/logic/markdown.ts b/src/features/markdown/logic/markdown.ts index fd6caf9..06e6a78 100644 --- a/src/features/markdown/logic/markdown.ts +++ b/src/features/markdown/logic/markdown.ts @@ -36,6 +36,7 @@ import xml from "highlight.js/lib/languages/xml"; import yaml from "highlight.js/lib/languages/yaml"; import { Marked } from "marked"; import { markedHighlight } from "marked-highlight"; +import { resolveImageUrl } from "../../../core/chunks"; // Hot set: registered eagerly so common code blocks highlight on first paint. const HOT_LANGUAGES: Record<string, LanguageFn> = { @@ -205,12 +206,106 @@ function sanitize(html: string): string { return purify.sanitize(html); } +/** + * Per-conversation context for rendering local-path images inline. `apiBase` + * is the backend HTTP base (may be `""` → a root-relative `/local-image?…`); + * `cwd` resolves RELATIVE Markdown image srcs against the conversation's + * working directory (the same machine the backend serves files from). + */ +export interface MarkdownImageOptions { + readonly apiBase: string; + readonly cwd?: string | null; +} + +/** Render options shared by all three render paths. */ +export interface MarkdownRenderOptions { + /** Balance dangling delimiters while the message is still generating. */ + readonly streaming?: boolean; + /** Rewrite local-path image srcs through the backend's `/local-image` route. */ + readonly image?: MarkdownImageOptions; +} + +/** Decode a URL-ish path, tolerating malformed `%` escapes. */ +function safeDecode(path: string): string { + try { + return decodeURIComponent(path); + } catch { + return path; + } +} + +/** + * Rewrite one image `src` for the local-image feature. Pure: `input → output`. + * + * - `file:///path` → local absolute path (scheme stripped). + * - `/absolute/path` → local absolute path. + * - a relative path (no scheme, no leading `/`) → joined to the conversation + * `cwd` (no `cwd` → left untouched). + * - `http(s)://…`, `data:`, and any other scheme → left untouched. + * + * Local paths become `${apiBase}/local-image?path=<encoded>`, reusing + * `resolveImageUrl`'s join-without-double-slash semantics (an empty `apiBase` + * yields a root-relative path). + */ +function rewriteImageSrc(src: string, image: MarkdownImageOptions): string { + const trimmed = src.trim(); + if (trimmed.length === 0) return src; + // Remote images and inline data URLs already work — never touch them. + if (/^(?:data:|https?:\/\/)/i.test(trimmed)) return src; + + let path: string; + + if (/^file:\/\//i.test(trimmed)) { + // file:///abs/path (and file://localhost/abs/path) → /abs/path. + const match = /^file:\/\/([^/]*)(\/.*)?$/i.exec(trimmed); + if (match === null) return src; + const host = match[1] ?? ""; + const filePath = match[2] ?? "/"; + path = host === "" || host.toLowerCase() === "localhost" ? filePath : `/${host}${filePath}`; + path = safeDecode(path); + } else if (trimmed.startsWith("/")) { + path = safeDecode(trimmed); + } else if (trimmed.startsWith("~/")) { + // The backend expands a leading `~`; pass it through un-joined. + path = trimmed; + } else if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(trimmed)) { + // Some other scheme (mailto:, ftp:, …) — leave it alone. + return src; + } else { + const cwd = image.cwd ?? null; + if (cwd === null || cwd.length === 0) return src; + const base = cwd.replace(/\/+$/, ""); + path = `${base}/${safeDecode(trimmed.replace(/^\.\//, ""))}`; + } + + return resolveImageUrl(`/local-image?path=${encodeURIComponent(path)}`, image.apiBase); +} + +/** + * Parse with marked, optionally rewriting image token `href`s just before + * rendering. Doing it on the token stream (not the Markdown source) means + * fenced/inline code is never touched, and reference-style images resolve + * through marked's normal link resolution. When `image` is absent the parse is + * byte-identical to today's. + */ +function parseMarkdown(src: string, image?: MarkdownImageOptions): string { + if (image === undefined) return md.parse(src) as string; + + const renderer = new md.Renderer(); + const imageRenderer = renderer.image; + renderer.image = (token) => + imageRenderer.call(renderer, { + ...token, + href: rewriteImageSrc(token.href, image), + }); + return md.parse(src, { renderer }) as string; +} + /** Render Markdown to sanitized HTML. Returns `""` if parsing ever throws. */ -export function renderMarkdown(text: string, opts?: { streaming?: boolean }): string { +export function renderMarkdown(text: string, opts?: MarkdownRenderOptions): string { const src = opts?.streaming === true ? closeOpenDelimiters(text) : text; try { - const raw = md.parse(src) as string; - return sanitize(addCopyButtons(raw)); + return sanitize(addCopyButtons(parseMarkdown(src, opts?.image))); } catch { return ""; } @@ -225,11 +320,14 @@ export function renderMarkdown(text: string, opts?: { streaming?: boolean }): st * degenerate case (nothing settled yet) is exactly today's behavior. * Returns `""` if parsing ever throws. */ -export function renderMarkdownTail(text: string, seed: DelimiterCounts = ZERO_COUNTS): string { +export function renderMarkdownTail( + text: string, + seed: DelimiterCounts = ZERO_COUNTS, + opts?: MarkdownRenderOptions, +): string { const src = closeOpenDelimiters(text, seed); try { - const raw = md.parse(src) as string; - return sanitize(addCopyButtons(raw)); + return sanitize(addCopyButtons(parseMarkdown(src, opts?.image))); } catch { return ""; } diff --git a/src/features/markdown/logic/streaming-markdown.ts b/src/features/markdown/logic/streaming-markdown.ts index e273c66..b530cd4 100644 --- a/src/features/markdown/logic/streaming-markdown.ts +++ b/src/features/markdown/logic/streaming-markdown.ts @@ -31,6 +31,8 @@ import { countDelimiters, type DelimiterCounts, + type MarkdownImageOptions, + type MarkdownRenderOptions, renderMarkdown, renderMarkdownTail, renderOpenFence, @@ -90,6 +92,13 @@ export interface StreamingMarkdownState { blankRunStart: number | null; /** INTERNAL — the best settle candidate found by the current scan pass. */ pendingSettle: number; + /** + * INTERNAL — identity of the image-render options (apiBase + cwd) the cache + * was rendered with. The options can arrive asynchronously (the cwd loads + * after mount), so a change invalidates the settled cache: image srcs in the + * prefix would otherwise keep the stale resolution. + */ + imageKey: string; } /** Fresh state for one streaming message. */ @@ -109,9 +118,22 @@ export function createStreamingMarkdown(): StreamingMarkdownState { linkRefSeen: false, blankRunStart: null, pendingSettle: -1, + imageKey: "", }; } +/** Identity of the image options, so an async cwd appearing invalidates the cache. */ +function imageKeyOf(image: MarkdownImageOptions | undefined): string { + return image === undefined ? "" : JSON.stringify([image.apiBase, image.cwd ?? null]); +} + +/** Build per-render options without setting `image: undefined` (exactOptionalPropertyTypes). */ +function renderOptionsOf( + image: MarkdownImageOptions | undefined, +): MarkdownRenderOptions | undefined { + return image === undefined ? undefined : { image }; +} + // ── Scanner helpers (CommonMark block starts, mirroring what marked does) ── /** A fenced-code-block opening line: ≤3 spaces indent, ≥3 backticks/tildes + info string. */ @@ -382,9 +404,13 @@ function scan(s: StreamingMarkdownState): void { } /** Render the newly-settled span once and fold it into the prefix cache. */ -function commitSettle(s: StreamingMarkdownState, end: number): void { +function commitSettle( + s: StreamingMarkdownState, + end: number, + image: MarkdownImageOptions | undefined, +): void { const chunk = s.source.slice(s.settledOffset, end); - s.prefixHtml += renderMarkdown(chunk); + s.prefixHtml += renderMarkdown(chunk, renderOptionsOf(image)); s.seed = addCounts(s.seed, countDelimiters(chunk)); s.settledOffset = end; } @@ -395,13 +421,24 @@ function commitSettle(s: StreamingMarkdownState, end: number): void { * string identity is what lets Svelte skip settled DOM). Resets itself when * the text shrinks or is otherwise not an append-extension. */ -export function advance(state: StreamingMarkdownState, textSoFar: string): StreamingMarkdownResult { - if (textSoFar === state.source && state.last !== null) return state.last; - if (!textSoFar.startsWith(state.source)) { +export function advance( + state: StreamingMarkdownState, + textSoFar: string, + opts?: MarkdownRenderOptions, +): StreamingMarkdownResult { + const image = opts?.image; + const imageKey = imageKeyOf(image); + if (textSoFar === state.source && state.last !== null && state.imageKey === imageKey) { + return state.last; + } + // A changed render option (e.g. the cwd loading after mount) invalidates the + // settled cache — its image srcs were resolved with the old options. + if (state.imageKey !== imageKey || !textSoFar.startsWith(state.source)) { resetTo(state, textSoFar); } else { state.source = textSoFar; } + state.imageKey = imageKey; // A PARTIAL trailing line that starts a `[…]:` definition already changes // how the whole-text parse resolves shortrefs (marked parses the truncated @@ -418,10 +455,10 @@ export function advance(state: StreamingMarkdownState, textSoFar: string): Strea scan(state); if (!state.linkRefSeen && state.pendingSettle > state.settledOffset) { - commitSettle(state, state.pendingSettle); + commitSettle(state, state.pendingSettle, image); } - const result = renderTail(state); + const result = renderTail(state, image); state.last = result; return result; } @@ -434,7 +471,10 @@ export function advance(state: StreamingMarkdownState, textSoFar: string): Strea * between the marker's arrival and the line's completion); * 3. otherwise prose → today's pipeline, seeded with the settled parity. */ -function renderTail(s: StreamingMarkdownState): StreamingMarkdownResult { +function renderTail( + s: StreamingMarkdownState, + image: MarkdownImageOptions | undefined, +): StreamingMarkdownResult { // 1. Open top-level fence: everything from its opening line is code content. if (s.fence !== null && s.fence.start >= s.settledOffset) { let code = s.source.slice(s.fence.contentStart); @@ -467,7 +507,8 @@ function renderTail(s: StreamingMarkdownState): StreamingMarkdownResult { const prose = s.source.slice(s.settledOffset, s.scanOffset); return { prefixHtml: s.prefixHtml, - tailHtml: renderMarkdownTail(prose, s.seed) + renderOpenFence(info, ""), + tailHtml: + renderMarkdownTail(prose, s.seed, renderOptionsOf(image)) + renderOpenFence(info, ""), }; } } @@ -475,6 +516,6 @@ function renderTail(s: StreamingMarkdownState): StreamingMarkdownResult { // 3. Prose tail. return { prefixHtml: s.prefixHtml, - tailHtml: renderMarkdownTail(s.source.slice(s.settledOffset), s.seed), + tailHtml: renderMarkdownTail(s.source.slice(s.settledOffset), s.seed, renderOptionsOf(image)), }; } diff --git a/src/features/markdown/ui/Markdown.svelte b/src/features/markdown/ui/Markdown.svelte index da2c9ba..0adbbea 100644 --- a/src/features/markdown/ui/Markdown.svelte +++ b/src/features/markdown/ui/Markdown.svelte @@ -1,16 +1,29 @@ <script lang="ts"> - import { renderMarkdown } from "../logic/markdown"; + import { type MarkdownImageOptions, renderMarkdown } from "../logic/markdown"; import { advance, createStreamingMarkdown } from "../logic/streaming-markdown"; let { text, streaming = false, + image = undefined, }: { text: string; /** Balance dangling delimiters while the message is still generating. */ streaming?: boolean; + /** + * Per-conversation local-image context: the backend HTTP base and the + * conversation cwd. Markdown image srcs that are local paths are rewritten + * through `${apiBase}/local-image?path=…` (relative paths resolve against + * `cwd`); remote/data URLs are untouched. Omit it to render srcs as-is. + */ + image?: MarkdownImageOptions; } = $props(); + // Keep the optional `image` out of the options object when absent + // (exactOptionalPropertyTypes). Identity is irrelevant: the streaming cache + // keys on apiBase/cwd values. + const renderOpts = $derived(image === undefined ? undefined : { image }); + // Checkpoint streaming state: one per component instance (per streaming // message). It caches the settled blocks' HTML so each delta re-renders only // the live tail — never the settled prefix (see logic/streaming-markdown). @@ -22,8 +35,8 @@ // chunk): ONE full authoritative render — today's exact behavior, so the // final/persisted output is byte-identical by construction. const rendered = $derived.by(() => { - if (!streaming) return { prefixHtml: "", tailHtml: renderMarkdown(text) }; - return advance(streamingState, text); + if (!streaming) return { prefixHtml: "", tailHtml: renderMarkdown(text, renderOpts) }; + return advance(streamingState, text, renderOpts); }); let container: HTMLElement; |
