summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--PLAN.md85
-rw-r--r--backend-handoff.md24
-rw-r--r--src/app/views/ChatPane.svelte1
-rw-r--r--src/features/chat/ui.test.ts19
-rw-r--r--src/features/chat/ui/ChatView.svelte13
-rw-r--r--src/features/markdown/logic/markdown.test.ts93
-rw-r--r--src/features/markdown/logic/markdown.ts110
-rw-r--r--src/features/markdown/logic/streaming-markdown.ts61
-rw-r--r--src/features/markdown/ui/Markdown.svelte19
9 files changed, 404 insertions, 21 deletions
diff --git a/PLAN.md b/PLAN.md
new file mode 100644
index 0000000..c45994d
--- /dev/null
+++ b/PLAN.md
@@ -0,0 +1,85 @@
+# Feature: local-image-markdown
+
+**Goal:** Agents can show the user images in chat purely via Markdown — `![alt](/absolute/path/to/image.png)` or `file:///…` — with no tool call. The chat UI renders them inline.
+
+**Repos:** backend AND frontend (separate worktrees of the same feature branch `feature/local-image-markdown`; the API contract below is FIXED — do not change it unilaterally).
+
+**Out of scope / decisions already made:**
+- **Raster images only** — png, jpg, jpeg, webp, gif, bmp. **SVG is intentionally excluded** (XSS vector via embedded scripts).
+- No auth changes on the route (the API is unauthenticated today; the extension whitelist + magic-byte sniff bounds the exposure to actual image files).
+- Stored custom system-prompt templates are NOT migrated — only `DEFAULT_TEMPLATE` changes. If a stored template exists it overrides the default; just note this in the summary, do not attempt migration.
+
+---
+
+## Backend (packages: `transport-http`, `system-prompt`)
+
+### 1. New route `GET /local-image` in `packages/transport-http/src/app.ts`
+
+Place it next to the existing tmp-image route (`app.get("/images/:conversationId/:imageId", …)`), mirroring its style (dynamic `node:path` / `node:fs/promises` imports, same response shape).
+
+**Contract (pinned):**
+- Query param: `path` — a URL-encoded absolute filesystem path to an image.
+ - Expand a leading `~` to the OS home dir (agents often write `~/…`).
+ - Reject with `400 { error: … }` if the decoded path is not absolute (after `~` expansion) or is empty.
+- Extension whitelist (lowercase, after decode): `.png .jpg .jpeg .webp .gif .bmp` → MIME map (`image/png`, `image/jpeg`, `image/webp`, `image/gif`, `image/bmp`). Any other extension → `400`.
+- **Magic-byte sniff** before serving (cheap header check per format, e.g. PNG `89 50 4E 47`, JPEG `FF D8 FF`, GIF `GIF8`, WEBP `RIFF…WEBP`, BMP `BM`). Mismatch → `400`. This guarantees the endpoint can never serve a non-image file regardless of extension.
+- Read failure (missing/unreadable) → `404 { error: "Image not found" }` (match the existing route's shape).
+- Success: `Response(buf, { headers: { "Content-Type": <mime>, "Cache-Control": "no-cache", "X-Content-Type-Options": "nosniff" } })`.
+- The endpoint intentionally serves ANY absolute image path on the machine — that is the feature (the agent already has shell/read access to the same machine). Do not add a root allowlist.
+
+**Tests:** follow the existing transport-http app test conventions — 200 + correct MIME for a real temp image file per format, 400 for non-image extension, 400 for magic-byte mismatch (e.g. PNG bytes named `.png` vs text bytes named `.png`), 400 for relative path, 404 for missing file, `~` expansion.
+
+### 2. System-prompt default template — `packages/system-prompt/src/service.ts`
+
+Add to `DEFAULT_TEMPLATE` (after the cwd line) a paragraph telling agents the capability, e.g.:
+
+```
+You can show the user images directly in chat by embedding them in your Markdown
+replies, e.g. ![description](/absolute/path/to/image.png). Local absolute paths
+(and file:// URLs) are rendered inline by the chat UI; relative paths resolve
+against the current working directory. Supported formats: png, jpg, jpeg, webp,
+gif, bmp. No tool call is needed — just the Markdown image syntax.
+```
+
+(Exact wording may be polished; the capability, the syntax, and the format list must be present.) Update `service.test.ts` structure assertions if needed.
+
+---
+
+## Frontend (feature: `src/features/markdown`, wiring in `src/features/chat`)
+
+### 3. Local-path image rewriting in `src/features/markdown/logic/markdown.ts`
+
+Today `![x](file:///…)` and `![x](/home/…)` render broken: DOMPurify strips `file:` srcs, and a root-relative `/home/…` src 404s against the frontend origin. Remote `http(s)` images already work and must keep working.
+
+**Required behavior — `renderMarkdown`, `renderMarkdownTail`, and `renderOpenFence` must all rewrite image srcs consistently (all three funnel through `sanitize()`):**
+
+For each `<img>` produced by Markdown image syntax `![alt](src)`:
+- `file:///path` → strip the scheme → local absolute path.
+- `/absolute/path` → local absolute path.
+- Relative path (no scheme, no leading `/`, not `data:`/`http(s)`) → resolve against the **conversation cwd** (threaded in as a render option; empty/absent cwd → leave as-is / no rewrite).
+- `http(s)://…`, `data:` → leave untouched.
+
+Rewritten src = `` `${apiBase}/local-image?path=${encodeURIComponent(absPath)}` `` — reuse the join-without-double-slash semantics of `src/core/chunks/image-url.ts` (`apiBase` may be empty → root-relative `/local-image?…`). `apiBase` and `cwd` are per-conversation render options, not module constants — thread them through as an opts parameter (e.g. `{ image?: { apiBase: string; cwd?: string | null } }`) from the render entry points. Whether you implement this via a DOMPurify `afterSanitizeAttributes` IMG hook (the module already uses this pattern for link `target=_blank`) or a pre-parse rewrite of the Markdown source is your call — cover Markdown image syntax at minimum; raw-HTML `<img>` tags in Markdown are best-effort.
+
+### 4. Component threading — `Markdown.svelte` + `ChatView.svelte`
+
+- `Markdown.svelte` (`src/features/markdown/ui/Markdown.svelte`): new optional prop carrying `{ apiBase: string; cwd?: string | null }`, forwarded into all render calls (including the streaming path via `advance`/`createStreamingMarkdown` — check `logic/streaming-markdown.ts` for how render opts flow).
+- `ChatView.svelte` (renders `<Markdown text={…} streaming={…} />` around line 190): pass `apiBase` (the store already exposes the HTTP base, see how `resolveImageUrl` callers get it) and the conversation `cwd` (already reactive state in the app store — `$state cwd`, fetched via `GET /conversations/:id/cwd`).
+
+### 5. Tests
+
+Extend `src/features/markdown/logic/markdown.test.ts` (and streaming tests if the tail path is affected):
+- `file:///home/u/pic.png` + apiBase `http://localhost:24203` → src `http://localhost:24203/local-image?path=%2Fhome%2Fu%2Fpic.png`.
+- `/home/u/pic.png` with empty apiBase → root-relative `/local-image?path=…`.
+- Relative `shot.png` with cwd `/w/foo` → `…/local-image?path=%2Fw%2Ffoo%2Fshot.png`; without cwd → untouched.
+- `https://example.com/a.png` and `data:` URLs → untouched.
+- Streaming tail render rewrites too.
+
+---
+
+## Definition of done (both repos)
+
+- Backend: `bun install` then the repo's verification commands from AGENTS.md (typecheck / test / check / build) all green; route + template changes committed to `feature/local-image-markdown`.
+- Frontend: same — all green, committed to `feature/local-image-markdown`.
+- Manual smoke (report only, don't block): with the stack running, an agent message containing `![x](/abs/path.png)` shows the image inline.
+- Do NOT merge or push. Commit to the feature branch only.
diff --git a/backend-handoff.md b/backend-handoff.md
index ab79bf2..1986e94 100644
--- a/backend-handoff.md
+++ b/backend-handoff.md
@@ -18,6 +18,30 @@
> ?archived=`) was made in the same branch and the FE consumes it via the `file:` deps (dists
> rebuilt). Recorded below.
+## Milestone: Local-path images in Markdown (`feature/local-image-markdown`, 2026-10-19)
+
+**Slice status:** COMPLETE (frontend half; the backend route is implemented in parallel in
+`../backend` from the same pinned contract).
+
+- `features/markdown/logic/markdown.ts`: `renderMarkdown` / `renderMarkdownTail` accept per-conversation
+ `image: { apiBase, cwd? }`. A custom marked renderer rewrites Markdown image token hrefs:
+ `file://…` → decoded absolute path, `/abs/path` → itself, relative → joined to `cwd`, `~/…` passed
+ through for backend expansion; `http(s)://`, `data:`, and other schemes are untouched. Local paths
+ become `${apiBase}/local-image?path=<encodeURIComponent>` via `resolveImageUrl` (empty `apiBase` →
+ root-relative). Rewriting happens on the token stream, so fenced/inline code is never touched.
+- `logic/streaming-markdown.ts`: `advance(state, text, opts)` threads the same options through the
+ settled, tail, and open-fence pipelines. The cache is keyed on `apiBase`/`cwd` so an async cwd load
+ invalidates and re-renders settled blocks; string identity is preserved while the options are stable.
+- `ui/Markdown.svelte`: new optional `image` prop. `ChatView` gains `cwd` and passes
+ `image={{ apiBase: apiBaseUrl, cwd }}`; `ChatPane` supplies `store.httpBase` + `store.cwd`.
+- Tests: `file://`, absolute, relative-with/without-cwd, empty-`apiBase`, http(s), `data:`, fenced-code
+ exemption, streaming tail + settled prefix + options-change invalidation, and ChatView threading.
+- Backend contract: expects `GET /local-image?path=` exactly as pinned in `PLAN.md` (absolute path,
+ `~` expansion, raster whitelist, magic-byte sniff). No FE contract/type changes; **no backend asks**.
+- Verification: typecheck **0 errors / 0 warnings**; tests **2,070 passed** (114 files);
+ `bun run check` clean; build succeeds (existing bundle-size warning only). No wire/browser-effect
+ adapter changes → no live probe required for this slice; manual inline-image smoke remains user-run.
+
## Milestone: Agent tab grid + subagent groups (`feature/agent-tab-grid`, 2026-10-15)
- FE: the tabs view is now a 2-column grid of identical fixed-height cards (`TabCard`); a tab with
visible subagents is double-width and wrapped with them in a darker, nestable box
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: "![pic](/home/u/pic.png)" },
+ 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 (`![x](shot.png)`) 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("![pic](file:///home/u/pic.png)", { 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("![pic](/home/u/pic.png)", { image: { apiBase: "" } }))).toBe(
+ "/local-image?path=%2Fhome%2Fu%2Fpic.png",
+ );
+ expect(imageSrc(renderMarkdown("![pic](/home/u/pic.png)", { image }))).toBe(
+ `${BASE}/local-image?path=%2Fhome%2Fu%2Fpic.png`,
+ );
+ });
+
+ it("resolves a relative path against the conversation cwd", () => {
+ expect(imageSrc(renderMarkdown("![shot](shot.png)", { image }))).toBe(
+ `${BASE}/local-image?path=%2Fw%2Ffoo%2Fshot.png`,
+ );
+ expect(
+ imageSrc(renderMarkdown("![shot](shot.png)", { 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("![shot](shot.png)", { image: { apiBase: BASE } }))).toBe(
+ "shot.png",
+ );
+ expect(imageSrc(renderMarkdown("![shot](shot.png)", { image }))).toBe(
+ `${BASE}/local-image?path=%2Fw%2Ffoo%2Fshot.png`,
+ );
+ expect(imageSrc(renderMarkdown("![shot](shot.png)"))).toBe("shot.png");
+ });
+
+ it("leaves remote http(s) and data: images untouched", () => {
+ expect(imageSrc(renderMarkdown("![remote](https://example.com/a.png)", { image }))).toBe(
+ "https://example.com/a.png",
+ );
+ expect(imageSrc(renderMarkdown("![remote](http://example.com/a.png)", { image }))).toBe(
+ "http://example.com/a.png",
+ );
+ const data = "data:image/png;base64,iVBORw0KGgo=";
+ expect(imageSrc(renderMarkdown(`![inline](${data})`, { image }))).toBe(data);
+ });
+
+ it("does not rewrite image syntax inside fenced code", () => {
+ const html = renderMarkdown("```md\n![pic](/home/u/pic.png)\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, "![pic](/home/u/pic.png)", { 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, "![pic](/home/u/pic.png)\n\nnext\n", { image });
+ expect(imageSrc(settled.prefixHtml)).toBe(`${BASE}/local-image?path=%2Fhome%2Fu%2Fpic.png`);
+
+ const seeded = renderMarkdownTail(
+ "![shot](shot.png)",
+ { 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 = "![shot](shot.png)\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;