diff options
| author | Adam Malczewski <[email protected]> | 2026-06-27 20:20:02 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-27 20:20:02 +0900 |
| commit | f5dc22f7c14d6c0dd4bcedee5a85b21ecd294aed (patch) | |
| tree | 8ba78efaefc25148dfb550b4332204822f5acf5d /src/features/chat | |
| parent | fa0bd9c0e433b1abddc814b48a358c94954c7d36 (diff) | |
| download | dispatch-web-f5dc22f7c14d6c0dd4bcedee5a85b21ecd294aed.tar.gz dispatch-web-f5dc22f7c14d6c0dd4bcedee5a85b21ecd294aed.zip | |
feat(vision): resolve persisted image URLs against the API base
Images are now stored on disk under tmp (not SQLite) and served via
GET /images/:conversationId/:imageId. Persisted ImageChunk.url is a compact
relative HTTP path (/images/<conv>/<uuid>.png) instead of a base64 data URL.
No wire/transport-contract type change (behavior only) — re-mirrored the
delta notes.
- New pure resolveImageUrl(url, apiBase) helper (core/chunks/image-url.ts,
+8 tests): data/absolute URLs pass through; relative paths are prepended
with the API base (no double slash; empty base -> root-relative). Exported
from core/chunks + re-exported from features/chat.
- ChatView: new apiBaseUrl prop; <img src> uses resolveImageUrl. The
optimistic echo's data URL passes through; persisted relative paths
resolve against the base. +3 tests.
- AppStore exposes httpBase (getter); App.svelte passes apiBaseUrl into
ChatView and the heartbeat RunModal (also renders image chunks).
Verification: svelte-check 0/0; vitest 959/959 (run twice, +11); biome
clean; vite build OK. See backend-handoff.md §2j-update-2.
Not merged or pushed.
Diffstat (limited to 'src/features/chat')
| -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 |
3 files changed, 68 insertions, 5 deletions
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" |
