diff options
| -rw-r--r-- | .dispatch/transport-contract.reference.md | 8 | ||||
| -rw-r--r-- | .dispatch/wire.reference.md | 28 | ||||
| -rw-r--r-- | GLOSSARY.md | 2 | ||||
| -rw-r--r-- | backend-handoff.md | 53 | ||||
| -rw-r--r-- | src/app/App.svelte | 2 | ||||
| -rw-r--r-- | src/app/store.svelte.ts | 9 | ||||
| -rw-r--r-- | src/core/chunks/image-url.test.ts | 46 | ||||
| -rw-r--r-- | src/core/chunks/image-url.ts | 35 | ||||
| -rw-r--r-- | src/core/chunks/index.ts | 1 | ||||
| -rw-r--r-- | src/features/chat/index.ts | 2 | ||||
| -rw-r--r-- | src/features/chat/ui.test.ts | 52 | ||||
| -rw-r--r-- | src/features/chat/ui/ChatView.svelte | 19 | ||||
| -rw-r--r-- | src/features/heartbeat/ui/RunModal.svelte | 7 |
13 files changed, 245 insertions, 19 deletions
diff --git a/.dispatch/transport-contract.reference.md b/.dispatch/transport-contract.reference.md index 047f050..182279a 100644 --- a/.dispatch/transport-contract.reference.md +++ b/.dispatch/transport-contract.reference.md @@ -27,6 +27,14 @@ > `PUT /settings/vision` ← `SetVisionSettingsRequest` (partial: `imageLimit?` non-negative int, 0 = disable > compaction; `compactionModel?` `<key>/<model>` or null = auto). See `backend-handoff.md` §2j. > +> **2026-06-26 update (image storage — NO type change, behavior only):** persisted `ImageChunk.url`s are now +> compact relative HTTP paths (`/images/<conversationId>/<uuid>.png`) served by the new +> `GET /images/:conversationId/:imageId` endpoint (raw image bytes + correct Content-Type) — NOT base64 data +> URLs (images are stored on disk under tmp, not in the SQLite store). `ChatRequest.images` (`ImageInput.url`) +> is UNCHANGED — clients still send data URLs; the backend saves them to tmp and returns compact paths in +> the persisted chunks. A client resolves a relative `url` against its API base (`resolveImageUrl`); the +> optimistic echo's data URL and any absolute URL pass through. See `backend-handoff.md` §2j. +> > **2026-06-25 delta (SSH handoff #2 — ADDITIVE to `[email protected]`, NO version bump):** adds the > computer HTTP API types: `ComputerListResponse` (`GET /computers`), `ComputerResponse` (`GET /computers/:alias`), > `ComputerStatusResponse` (`GET /computers/:alias/status`), `TestComputerResponse` (`POST /computers/:alias/test`), diff --git a/.dispatch/wire.reference.md b/.dispatch/wire.reference.md index c40fe81..75adac5 100644 --- a/.dispatch/wire.reference.md +++ b/.dispatch/wire.reference.md @@ -14,6 +14,15 @@ > — the orchestrator's vision handoff transcribes each to a text description (persisted as a separate > `text` chunk in the SAME user message). See `backend-handoff.md` §2j. > +> **2026-06-26 update (image storage — NO type change, behavior only):** `ImageChunk.url` for PERSISTED +> chunks is now a compact relative HTTP path (`/images/<conversationId>/<uuid>.png`) served by the backend's +> new `GET /images/:conversationId/:imageId` endpoint (raw bytes + correct Content-Type), NOT a base64 data +> URL — images are stored on disk under tmp, not in the SQLite conversation store (keeps payloads small). +> `ImageInput.url` (what a client SENDS on `ChatRequest.images`) is UNCHANGED — still a data URL or +> `http(s)://` URL; the backend saves it to tmp and returns the compact path in the persisted chunk. A client +> resolves a relative `url` against its API base (`resolveImageUrl`); a data URL (the optimistic echo) or an +> absolute URL passes through unchanged. See `backend-handoff.md` §2j. +> > **2026-06-23 delta (workspaces handoff — package bumped `0.11.0` → `0.12.0`, ADDITIVE):** adds > `Workspace` + `WorkspaceEntry` (a list entry with a conversation count) and a required > `workspaceId: string` on `ConversationMeta` (`"default"` for legacy/unspecified conversations). A @@ -156,12 +165,17 @@ export interface SystemChunk { /** * An image attached to a message (e.g. a user-pasted screenshot or pasted * photo). Carries a `url` that is EITHER a base64 data URL - * (`data:image/png;base64,…`) OR an `http(s)://` URL. Vision-capable models - * receive it natively (the provider serializes it to its image-content - * format); non-vision models never see it directly — the orchestrator's - * **vision handoff** transcribes it to a text description (via a - * vision-capable model) and feeds that text instead, so a text-only model can - * still reason about the image's contents. + * (`data:image/png;base64,…`) OR an `http(s)://` URL OR — for PERSISTED chunks + * (history/replay) — a compact relative HTTP path (`/images/<conversationId>/ + * <uuid>.png`) served by the backend's `GET /images/:conversationId/:imageId` + * endpoint (images are stored on disk under tmp, NOT in the conversation store, + * to keep SQLite payloads small). A client resolves a relative path against its + * API base URL; a data URL (the optimistic echo / a pasted image) or an + * absolute URL is rendered as-is. Vision-capable models receive it natively + * (the provider serializes it to its image-content format); non-vision models + * never see it directly — the orchestrator's **vision handoff** transcribes it + * to a text description (via a vision-capable model) and feeds that text + * instead, so a text-only model can still reason about the image's contents. * * When a transcription was performed, it is persisted as a separate `text` * chunk alongside the `image` chunk in the SAME user message, so the @@ -170,7 +184,7 @@ export interface SystemChunk { */ export interface ImageChunk { readonly type: "image"; - /** Image source: a base64 data URL (`data:image/…;base64,…`) or an `http(s)://` URL. */ + /** Image source: a base64 data URL (`data:image/…;base64,…`), an `http(s)://` URL, or a compact relative path (`/images/<conv>/<uuid>.png`) for persisted chunks. */ readonly url: string; /** * Optional MIME type of the image (e.g. `"image/png"`). Inferred from the diff --git a/GLOSSARY.md b/GLOSSARY.md index 3973fe1..14b81d1 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -26,7 +26,7 @@ | **steering** | A user message injected into an in-flight turn at the tool-result boundary (drawn from the **message queue**): the model sees it alongside the tool results and may adjust course. Emitted on the chat stream as a `steering` `AgentEvent` (`TurnSteeringEvent`); the queue surface clears on drain (move, don't duplicate). If the turn ends with a non-empty queue (no tool call fired), the queue carries into a NEW turn as its opening prompt (no `steering` event). `[email protected]`. | mid-turn injection, course correction, interruption | | **computer** | A remote SSH target discovered from the system's `~/.ssh/config` — a read-only VIEW, NOT an editable entity (no CRUD store; to add one the user edits `~/.ssh/config`). Backend-canonical (`[email protected]`, additive). On the wire as `Computer` (`{ alias, hostName, port, user, identityFile, knownHost }`) + `ComputerEntry extends Computer` (adds `usageCount`, for `GET /computers`). `alias` IS the **computerId** — the string persisted per-conversation / per-workspace (the computer analog of `cwd`). Resolution is SERVER-owned (never re-implement): per-conversation `computerId` → `workspace.defaultComputerId` → `null`/local. USER-facing only: a tool-execution target forwarded to tools, NEVER part of the model prompt (does not affect prompt caching); the agent never sees it. HTTP API (`GET /computers`, `GET`/`PUT /conversations/:id/computer`, `PUT /workspaces/:id/default-computer`, `GET /computers/:alias/status`, `POST /computers/:alias/test`) consumed in handoff #2. | ssh host, remote host, server, connection target | | **computerId** | The string id of a **computer** — an SSH config `Host` alias users select. Persisted per-conversation and per-workspace (the computer analog of `cwd`/`workspaceId`). `null` means local (no SSH; today's behavior). On `Workspace` as the REQUIRED `defaultComputerId: string \| null` (null = local / no SSH; the computer analog of `defaultCwd`); per-conversation persistence via `GET`/`PUT`/`DELETE /conversations/:id/computer`. `chat.send` need not send it (resolved server-side from the persisted per-conversation value in the MVP). | ssh alias, host id, remote id | -| **image chunk** | An `ImageChunk` (`{ type: "image", url, mimeType? }`) — a NEW `Chunk` variant for an image attached to a message (a user-pasted screenshot/photo). `url` is a base64 data URL (`data:image/…;base64,…`) OR an `http(s)://` URL. Backend-canonical (`[email protected]`, additive). A user message may be multi-chunk (`[text, image, image, …]` in order). On the wire as `ImageChunk` (persisted) + `ImageInput` (what `ChatRequest.images` carries; the orchestrator converts each into an `ImageChunk`). | picture, photo attachment, screenshot chunk | +| **image chunk** | An `ImageChunk` (`{ type: "image", url, mimeType? }`) — a `Chunk` variant for an image attached to a message (a user-pasted screenshot/photo). `url` is a base64 data URL (`data:image/…;base64,…`), an `http(s)://` URL, or — for PERSISTED chunks — a compact relative HTTP path (`/images/<conversationId>/<uuid>.png`) served by `GET /images/:conversationId/:imageId` (images are stored on disk under tmp, not in the SQLite store). Backend-canonical (`[email protected]`, additive). A user message may be multi-chunk (`[text, image, image, …]` in order). On the wire as `ImageChunk` (persisted) + `ImageInput` (what `ChatRequest.images` carries — still a data URL; the orchestrator saves it to tmp and returns the compact path). A client resolves a relative `url` against its API base (`resolveImageUrl`); a data URL (the optimistic echo) or absolute URL passes through. | picture, photo attachment, screenshot chunk | | **vision** (capability) | Whether a model can natively accept image input (multimodal). On the wire as `ModelMetadata.vision?: boolean` (`GET /models` `modelInfo[name].vision`). `true` (e.g. any `kimi/*` model) → image chunks are passed through to the provider natively. Absent/`false` (e.g. `umans/glm-5.2`) → the server's **vision handoff** gives the model a numbered placeholder + the `consult_vision` tool. The FE shows a vision badge in the model picker; it does NOT decide handoff (server-owned). | multimodal, image support | | **vision handoff** | The server-owned mechanism by which a NON-vision model still reasons about a pasted image. A non-vision model gets a NUMBERED PLACEHOLDER text chunk alongside the persisted `image` chunk, then calls the `consult_vision` tool (which opens a NEW conversation tab with a vision-capable model, attaches the image + question, and returns the vision model's answer). The persisted user message keeps the original `image` chunk (the FE renders it) AND the placeholder text. When a vision-capable model has more than `imageLimit` images in history, image **compaction** transcribes the oldest to `[Compacted image]: <description>` text chunks (the `image` chunk stays for rendering). All these are regular `text` chunks — the FE renders them as-is. Distinct from a vision-capable model, which receives the image natively. | image transcription, vision relay | | **consult_vision** | A tool available to all models that defers image analysis to a vision-capable model: it opens a NEW conversation tab (with a vision model, e.g. Kimi), attaches the image (by `imageIds` from a pasted placeholder, or by `path` from disk) + a `question`, and returns the conversation id + the vision model's answer (suggesting the dispatch CLI for follow-ups). Replaces the former `read_image` tool. Rendered generically like any tool call/result (by `toolName`). | read_image (former), vision tool | diff --git a/backend-handoff.md b/backend-handoff.md index 9338a5a..5a476be 100644 --- a/backend-handoff.md +++ b/backend-handoff.md @@ -5,12 +5,11 @@ > **From:** dispatch-web orchestrator · **To:** `../backend` orchestrator · **Courier:** the user. > `lsp` does NOT span the repos (AGENTS.md § Backend seam) — every cross-repo ask flows through here. -_Last updated: 2026-06-26 (§2j UPDATED — consult_vision tool + vision settings API: `read_image` is REPLACED by -`consult_vision` (non-vision models now get numbered placeholder text chunks, not auto-transcriptions); NEW global -`GET`/`PUT /settings/vision` (`VisionSettingsResponse`/`SetVisionSettingsRequest` — `imageLimit` + `compactionModel`); -image compaction transcribes old images to `[Compacted image]: …` text chunks (all regular text — render as-is). New -`vision` feature library + "Vision" sidebar view (imageLimit input + compactionModel dropdown of vision-capable models + -"Auto"). typecheck 0/0, 948 tests green (+47), biome clean, build OK. §2i unchanged.)_ +_Last updated: 2026-06-26 (§2j UPDATED — Image storage: persisted `ImageChunk.url`s are now compact relative HTTP +paths (`/images/<conv>/<uuid>.png`) served by `GET /images/:conversationId/:imageId` (images stored on disk under tmp, +not SQLite). FE resolves relative urls against the API base via a new pure `resolveImageUrl` helper; the optimistic +echo's data URL passes through unchanged; `ChatRequest.images` (send) is unchanged. typecheck 0/0, 959 tests green +(+11), biome clean, build OK. §2i unchanged.)_ **FE is current on `[email protected]` / `[email protected]` / `[email protected]`.** Open asks: **CR-9** (`system:os` should detect WSL + include Linux distro — backend behavior change, no contract bump). The SSH-divergence (§2d) is RESOLVED. @@ -854,6 +853,48 @@ reload; set compactionModel to a vision model → confirm the dropdown reflects non-vision model → confirm the `[Image N attached — call consult_vision…]` placeholder renders (not an auto-transcription); trigger a `consult_vision` tool call → confirm it renders like a tool. +### 2j-update-2. Image storage (tmp, not SQLite) → **CONSUMED ✅ (backend updated; FE built + verified)** + +A follow-up to §2j. The backend no longer persists images as base64 data URLs in the conversation store — +they are saved to a tmp directory and served via HTTP. The `ImageChunk.url` field's FORMAT changed (the +TYPE is unchanged — still `string`); `GET /images/:conversationId/:imageId` is a new endpoint serving raw +bytes + the correct Content-Type. **NO wire/transport-contract type change** (behavior only); re-mirrored +the delta notes in `.dispatch/wire.reference.md` + `.dispatch/transport-contract.reference.md`. + +**What changed (backend):** +- **BEFORE:** `ImageChunk.url` was a base64 data URL (`data:image/png;base64,…`). +- **NOW:** `ImageChunk.url` for PERSISTED chunks (history/replay) is a compact relative HTTP path + (`/images/<conversationId>/<uuid>.png`), served by `GET /images/:conversationId/:imageId` (raw image + bytes + Content-Type). Images live on disk under tmp, NOT in the SQLite conversation store (keeps + payloads small). +- **`ChatRequest.images` (`ImageInput.url`) is UNCHANGED** — the FE still sends data URLs; the backend + saves them to tmp and returns compact paths in the persisted chunks. +- **Optimistic echo:** the FE's provisional echo still uses the data URL it sent (immediate render); + when the persisted chunk arrives (via `loadSince`/`syncTail`/event stream), it carries the compact path + and the FE switches to rendering via the HTTP endpoint. +- The vision settings API, `consult_vision`, and image compaction are UNCHANGED (compaction resolves + compact URLs internally). + +**FE (DONE + verified):** +- **New pure helper `resolveImageUrl(url, apiBase)`** (`core/chunks/image-url.ts`, +8 tests, exported from + `core/chunks` + re-exported from `features/chat`): a `data:` URL or an `http(s)://` URL passes through + unchanged; a relative path (`/images/…`) is prepended with the API base (no double slash; an empty base + yields a root-relative path a browser resolves against its origin). Pure — zero DOM/Svelte. +- **`ChatView.svelte`:** new `apiBaseUrl` prop (default `""`); the `<img src>` now uses + `resolveImageUrl(rendered.chunk.url, apiBaseUrl)`. The optimistic echo's data URL + any absolute URL + pass through; persisted relative paths resolve against the base. +3 ChatView tests (relative-path + resolution, data-URL pass-through with a base set, root-relative when no base). +- **Store + wiring:** `httpBase` (the resolved HTTP API base) is now exposed as a getter on `AppStore`; + `App.svelte` passes `apiBaseUrl={store.httpBase}` to `ChatView` AND to the heartbeat `RunModal` (its + `ChatView` also renders image chunks — added an `apiBaseUrl` prop there, threaded from `App.svelte`). + +**Verification:** `svelte-check` 0/0; vitest **959/959** (run TWICE — no cross-test pollution; +11 since the +prior commit: 8 `resolveImageUrl`, 3 ChatView resolution), biome clean, `vite build` succeeds. +**Live probe NOT run** (backend not reachable headless). To confirm end-to-end: paste an image with a +vision model, send, confirm the image renders immediately (data-URL echo) AND continues to render after +the turn seals (the persisted compact-path `/images/…` resolved against `httpBase`); reload the +conversation → confirm the persisted image renders from the `/images/…` endpoint (not a data URL). + --- ## 3. Likely NEXT backend asks (heads-up, not yet requested) diff --git a/src/app/App.svelte b/src/app/App.svelte index f41d7ba..7d70f67 100644 --- a/src/app/App.svelte +++ b/src/app/App.svelte @@ -516,6 +516,7 @@ onShowEarlier={handleShowEarlier} thinkingKeyBase={store.activeChat.thinkingKeyBase} providerRetry={store.activeChat.providerRetry} + apiBaseUrl={store.httpBase} /> {/key} </div> @@ -610,6 +611,7 @@ closeChat={closeRunChat} stopRun={stopHeartbeatRun} onClose={() => (heartbeatRun = null)} + apiBaseUrl={store.httpBase} /> {/key} {/if} diff --git a/src/app/store.svelte.ts b/src/app/store.svelte.ts index 554d5e0..8353820 100644 --- a/src/app/store.svelte.ts +++ b/src/app/store.svelte.ts @@ -157,6 +157,12 @@ export interface AppStore { readonly activeConversationId: string | null; /** The workspace currently in view (URL slug); tabs are filtered to it. */ readonly activeWorkspaceId: string; + /** + * The resolved HTTP API base URL (e.g. `http://localhost:24203`). Used to + * resolve relative image URLs served by the backend (`/images/…`) into + * absolute URLs for `<img src>`. + */ + readonly httpBase: string; readonly activeChat: ChatStore; readonly models: readonly string[]; /** Per-model metadata (contextWindow, etc.) from `GET /models`. */ @@ -1127,6 +1133,9 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { get activeWorkspaceId(): string { return activeWorkspaceId; }, + get httpBase(): string { + return httpBase; + }, setActiveWorkspace(workspaceId: string): void { activeWorkspaceId = workspaceId; // Reset to a fresh draft scoped to the new workspace so a new chat is diff --git a/src/core/chunks/image-url.test.ts b/src/core/chunks/image-url.test.ts new file mode 100644 index 0000000..8a79c09 --- /dev/null +++ b/src/core/chunks/image-url.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { resolveImageUrl } from "./image-url"; + +const BASE = "http://localhost:24203"; + +describe("resolveImageUrl", () => { + it("returns a data URL as-is (the optimistic echo / a pasted image)", () => { + const dataUrl = "data:image/png;base64,iVBORw0KGgo="; + expect(resolveImageUrl(dataUrl, BASE)).toBe(dataUrl); + }); + + it("returns an absolute http URL as-is", () => { + const abs = "https://example.com/img.png"; + expect(resolveImageUrl(abs, BASE)).toBe(abs); + }); + + it("prepends the api base to a relative /images/ path", () => { + expect(resolveImageUrl("/images/conv-123/abc-456.png", BASE)).toBe( + "http://localhost:24203/images/conv-123/abc-456.png", + ); + }); + + it("does not double the slash when the base has a trailing slash", () => { + expect(resolveImageUrl("/images/c/x.png", "http://localhost:24203/")).toBe( + "http://localhost:24203/images/c/x.png", + ); + }); + + it("adds a leading slash to a path-relative url without one", () => { + expect(resolveImageUrl("images/c/x.png", BASE)).toBe("http://localhost:24203/images/c/x.png"); + }); + + it("returns the relative path as-is when apiBase is empty (root-relative)", () => { + // A browser resolves a root-relative `/images/…` against the document origin. + expect(resolveImageUrl("/images/c/x.png", "")).toBe("/images/c/x.png"); + }); + + it("handles a relative path with an empty apiBase (path-relative without slash)", () => { + expect(resolveImageUrl("images/c/x.png", "")).toBe("/images/c/x.png"); + }); + + it("returns a data URL as-is even with an empty apiBase", () => { + const dataUrl = "data:image/jpeg;base64,AAAA"; + expect(resolveImageUrl(dataUrl, "")).toBe(dataUrl); + }); +}); diff --git a/src/core/chunks/image-url.ts b/src/core/chunks/image-url.ts new file mode 100644 index 0000000..e5ec756 --- /dev/null +++ b/src/core/chunks/image-url.ts @@ -0,0 +1,35 @@ +/** + * Resolve an `ImageChunk.url` into a renderable `<img src>` value. + * + * Persisted image chunks now carry a COMPACT HTTP path + * (`/images/<conversationId>/<uuid>.png`) served by the backend — NOT a base64 + * data URL (images are stored on disk under tmp, not in the conversation store, + * to keep SQLite payloads small). The optimistic echo (what the FE just sent in + * `ChatRequest.images`) still carries a data URL, and a chunk could also carry + * an absolute `http(s)://` URL, so the resolution is format-aware: + * + * - `data:` URL → returned as-is (the optimistic echo / a pasted data URL). + * - `http(s)://` → returned as-is (an absolute URL already). + * - anything else (a relative path like `/images/…`) → `apiBase` is prepended + * (with no double slash). An empty `apiBase` leaves a root-relative path, + * which a browser resolves against the document origin. + * + * Pure: input → output, zero DOM, zero Svelte. + * + * @param url The chunk's `url` (data URL, absolute, or relative path). + * @param apiBase The HTTP API base URL (e.g. `http://localhost:24203`). + */ +export function resolveImageUrl(url: string, apiBase: string): string { + if (url.startsWith("data:") || url.startsWith("http://") || url.startsWith("https://")) { + return url; + } + // A relative path (e.g. `/images/…`) — normalize to a leading slash and + // prepend the api base. With an empty base this yields a root-relative path + // (a browser resolves `/images/…` against the document origin). + const path = url.startsWith("/") ? url : `/${url}`; + if (apiBase.length === 0) return path; + // Join without a double slash: strip a trailing slash from the base, then + // append the (leading-slash) path verbatim. + const base = apiBase.endsWith("/") ? apiBase.slice(0, -1) : apiBase; + return `${base}${path}`; +} diff --git a/src/core/chunks/index.ts b/src/core/chunks/index.ts index eea2303..bdd6ce4 100644 --- a/src/core/chunks/index.ts +++ b/src/core/chunks/index.ts @@ -1,5 +1,6 @@ export type { RenderGroup, ToolBatchEntry } from "./groups"; export { groupRenderedChunks } from "./groups"; +export { resolveImageUrl } from "./image-url"; export { appendUserMessage, applyHistory, diff --git a/src/features/chat/index.ts b/src/features/chat/index.ts index 8694691..773cb91 100644 --- a/src/features/chat/index.ts +++ b/src/features/chat/index.ts @@ -4,7 +4,7 @@ export type { RenderGroup, ToolBatchEntry, } from "../../core/chunks"; -export { groupRenderedChunks, viewProviderRetry } from "../../core/chunks"; +export { groupRenderedChunks, resolveImageUrl, viewProviderRetry } from "../../core/chunks"; export type { TurnMetricsEntry } from "../../core/metrics"; export { isVisionModel } from "./model-select"; export type { ChatTransport, HistorySync, HistoryWindow, MetricsSync } from "./ports"; diff --git a/src/features/chat/ui.test.ts b/src/features/chat/ui.test.ts index b0aa6f0..5f8067d 100644 --- a/src/features/chat/ui.test.ts +++ b/src/features/chat/ui.test.ts @@ -628,6 +628,58 @@ describe("ChatView", () => { expect(img?.getAttribute("loading")).toBe("lazy"); }); + it("resolves a persisted image chunk's relative url against apiBaseUrl", () => { + // Persisted image chunks now carry a compact relative path (`/images/…`) + // served by the backend — prepend the API base to render them. + const chunks: RenderedChunk[] = [ + { + seq: 1, + role: "user", + chunk: { type: "image", url: "/images/conv-123/abc-456.png", mimeType: "image/png" }, + provisional: false, + }, + ]; + + const { container } = render(ChatView, { + props: { chunks, apiBaseUrl: "http://localhost:24203" }, + }); + + expect(container.querySelector("img")?.getAttribute("src")).toBe( + "http://localhost:24203/images/conv-123/abc-456.png", + ); + }); + + it("passes a data URL through unchanged even with apiBaseUrl set (optimistic echo)", () => { + // The optimistic echo (what the FE just sent) is still a data URL; it must + // NOT be mangled by the base-URL prepend. + const dataUrl = "data:image/png;base64,iVBOR="; + const chunks: RenderedChunk[] = [ + { seq: null, role: "user", chunk: { type: "image", url: dataUrl }, provisional: true }, + ]; + + const { container } = render(ChatView, { + props: { chunks, apiBaseUrl: "http://localhost:24203" }, + }); + + expect(container.querySelector("img")?.getAttribute("src")).toBe(dataUrl); + }); + + it("leaves a relative image url root-relative when apiBaseUrl is absent", () => { + // No apiBaseUrl → a browser resolves `/images/…` against the document origin. + const chunks: RenderedChunk[] = [ + { + seq: 1, + role: "user", + chunk: { type: "image", url: "/images/conv-1/x.png" }, + provisional: false, + }, + ]; + + const { container } = render(ChatView, { props: { chunks } }); + + expect(container.querySelector("img")?.getAttribute("src")).toBe("/images/conv-1/x.png"); + }); + it("renders a multi-chunk user message [text, image] and a transcription text", () => { // A non-vision model: the server persists the original image chunk AND a // transcription text chunk in the SAME user message — render both. diff --git a/src/features/chat/ui/ChatView.svelte b/src/features/chat/ui/ChatView.svelte index 8081951..cd69071 100644 --- a/src/features/chat/ui/ChatView.svelte +++ b/src/features/chat/ui/ChatView.svelte @@ -1,6 +1,6 @@ <script lang="ts"> import type { TurnProviderRetryEvent } from "@dispatch/wire"; - import { groupRenderedChunks, type RenderedChunk, viewProviderRetry } from "../index"; + import { groupRenderedChunks, resolveImageUrl, type RenderedChunk, viewProviderRetry } from "../index"; import { interleaveTurnMetrics, viewCacheRate, @@ -24,6 +24,7 @@ onShowEarlier, thinkingKeyBase = 0, providerRetry = null, + apiBaseUrl = "", }: { chunks: readonly RenderedChunk[]; turnMetrics?: readonly TurnMetricsEntry[]; @@ -44,6 +45,14 @@ * newest attempt + delay, and is cleared when content resumes / turn ends. */ providerRetry?: TurnProviderRetryEvent | null; + /** + * The HTTP API base URL (e.g. `http://localhost:24203`). Persisted image + * chunks carry a compact relative path (`/images/<conv>/<uuid>.png`); this + * base is prepended to render them. The optimistic echo's data URL and any + * absolute URL pass through unchanged (see `resolveImageUrl`). Defaults to + * "" (root-relative — a browser resolves `/images/…` against its origin). + */ + apiBaseUrl?: string; } = $props(); // True while a show-earlier page-in is awaited (disables the button). @@ -96,15 +105,17 @@ {#snippet chunkRow(rendered: RenderedChunk)} {#if rendered.role === "user"} <!-- User: a speech bubble, left-aligned. A user message may be multi-chunk - ([text, image, image, …]); each chunk renders in its own bubble (the - image's url is a base64 data URL or an https URL — render it directly). --> + ([text, image, image, …]); each chunk renders in its own bubble. A + persisted image chunk's url is a compact relative path (`/images/…`) + served by the backend — resolve it against the API base. The + optimistic echo's data URL (and any absolute URL) passes through. --> <div class="chat chat-start"> <div class="chat-bubble chat-bubble-primary"> {#if rendered.chunk.type === "text"} <p>{rendered.chunk.text}</p> {:else if rendered.chunk.type === "image"} <img - src={rendered.chunk.url} + src={resolveImageUrl(rendered.chunk.url, apiBaseUrl)} alt={rendered.chunk.mimeType ?? "pasted image"} loading="lazy" decoding="async" diff --git a/src/features/heartbeat/ui/RunModal.svelte b/src/features/heartbeat/ui/RunModal.svelte index a4ed356..92068ae 100644 --- a/src/features/heartbeat/ui/RunModal.svelte +++ b/src/features/heartbeat/ui/RunModal.svelte @@ -11,6 +11,7 @@ closeChat, stopRun, onClose, + apiBaseUrl = "", }: { /** The run to display (its conversation's chat is shown live). */ run: HeartbeatRunView; @@ -26,6 +27,11 @@ /** Stop the heartbeat run (`POST .../runs/:runId/stop`). */ stopRun: StopHeartbeatRun; onClose: () => void; + /** + * The HTTP API base URL, to resolve persisted image chunk URLs + * (`/images/…`) in the run's transcript. Defaults to "" (root-relative). + */ + apiBaseUrl?: string; } = $props(); // Open the live watch ONCE on mount (the modal is keyed per run.id, so a run @@ -153,6 +159,7 @@ onShowEarlier={chat.showEarlier} thinkingKeyBase={chat.thinkingKeyBase} providerRetry={chat.providerRetry} + apiBaseUrl={apiBaseUrl} /> {/if} </div> |
