summaryrefslogtreecommitdiffhomepage
path: root/src/features
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-27 20:20:02 +0900
committerAdam Malczewski <[email protected]>2026-06-27 20:20:02 +0900
commitf5dc22f7c14d6c0dd4bcedee5a85b21ecd294aed (patch)
tree8ba78efaefc25148dfb550b4332204822f5acf5d /src/features
parentfa0bd9c0e433b1abddc814b48a358c94954c7d36 (diff)
downloaddispatch-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')
-rw-r--r--src/features/chat/index.ts2
-rw-r--r--src/features/chat/ui.test.ts52
-rw-r--r--src/features/chat/ui/ChatView.svelte19
-rw-r--r--src/features/heartbeat/ui/RunModal.svelte7
4 files changed, 75 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"
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>