From 3566a20ebbded754070fce66af48690d1a904879 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Sat, 27 Jun 2026 04:18:59 +0900 Subject: feat(vision): image paste + transcript image rendering + vision badge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vision & vision-handoff frontend (consumes the backend's additive wire@0.12.0 / transport-contract@0.22.0 image types — no version bump). Contracts mirrored: - .dispatch/wire.reference.md: ImageChunk added to the Chunk union + ImageChunk/ImageInput interfaces. - .dispatch/transport-contract.reference.md: ChatRequest.images, ModelMetadata.vision, + ImageChunk/ImageInput re-exports. Core (core/chunks): - conformance: assertChunkExhaustive handles the new 'image' variant (the guard caught it — its purpose). - appendUserMessage(state, text, images?) echoes a [text, image, ...] user run; the user-message event dedup scans the trailing user run (not just the last chunk) so an image-bearing echo doesn't duplicate the text; applyHistory's during-gen dedup matches a multi-chunk echo by content equality (chunkContentEquals + trailingRun helpers). UI: - ChatView renders user 'image' chunks as lazy bubbles; a non-vision model's persisted [image, analysis-text] both render. read_image tool renders generically (no special-casing). - Composer: clipboard paste / file picker / drag-drop of images -> base64 data URLs, thumbnail previews with remove, forwarded on chat.send (omitted when none). Image-only sends allowed; steering (chat.queue) never forwards images. - ModelSelector: vision badge (isVisionModel) marks vision-capable models; indicator shows native-vision vs vision-handoff hint. Store wiring: ChatStore.send + AppStore.send + App.svelte handleSend thread images through; chat.send still omits cwd (only images added). Verification: svelte-check 0/0; vitest 901/901 (run twice, +34 new); biome clean; vite build OK. See backend-handoff.md §2j. Not merged or pushed. --- src/app/store.svelte.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) (limited to 'src/app/store.svelte.ts') diff --git a/src/app/store.svelte.ts b/src/app/store.svelte.ts index 78f6ede..41f3a5d 100644 --- a/src/app/store.svelte.ts +++ b/src/app/store.svelte.ts @@ -35,7 +35,7 @@ import type { WarmResponse, } from "@dispatch/transport-contract"; import type { SubscribeMessage, SurfaceServerMessage, SurfaceSpec } from "@dispatch/ui-contract"; -import type { ComputerEntry, ConversationStatus } from "@dispatch/wire"; +import type { ComputerEntry, ConversationStatus, ImageInput } from "@dispatch/wire"; import { untrack } from "svelte"; import { createIdbChunkStore } from "../adapters/idb"; import { createLocalStore } from "../adapters/local-storage"; @@ -160,7 +160,14 @@ export interface AppStore { readonly storage: Storage | undefined; /** The current spec for one surface by id (discovery-by-id), or null if absent. */ surface(surfaceId: string): SurfaceSpec | null; - send(text: string): void; + /** + * Send a user message (start a turn). Forwards any staged `images` + * (`ImageInput[]` — base64 data URLs / https URLs) on the `chat.send` op; + * the server passes them to a vision-capable model natively or transcribes + * them via vision handoff for a non-vision model. Omitted on the wire when + * none are staged. On a draft, promotes to a tab first. + */ + send(text: string, images?: readonly ImageInput[]): void; /** * Enqueue a steering message onto the focused conversation's queue * (`chat.queue` WS op). While a turn is generating, the message is delivered @@ -1149,7 +1156,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { return getSurfaceSpec(protocol, surfaceId); }, - send(text: string): void { + send(text: string, images?: readonly ImageInput[]): void { if (tabsStore.activeConversationId === null) { // Draft: promote to tab on first send const conversationId = draftConversationId; @@ -1177,9 +1184,9 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { void refreshReasoningEffort(); void refreshCompactPercent(); // Now send on the promoted store - chatStores.get(conversationId)?.send(text); + chatStores.get(conversationId)?.send(text, images); } else { - activeChat.send(text); + activeChat.send(text, images); } }, -- cgit v1.2.3 From fa0bd9c0e433b1abddc814b48a358c94954c7d36 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Sat, 27 Jun 2026 19:15:04 +0900 Subject: feat(vision): consult_vision tool + vision settings API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend vision update (additive to wire@0.12.0 / transport-contract@0.22.0, no version bump). Contracts mirrored (.dispatch/transport-contract.reference.md): - VisionSettingsResponse + SetVisionSettingsRequest (GET/PUT /settings/vision). - Delta note: read_image -> consult_vision; numbered placeholders; compaction. Tool rendering (ChatView): - read_image test -> consult_vision (rendering is generic by toolName). - +2 tests: numbered-placeholder text chunk + [Compacted image] text chunk (both regular text chunks, render as-is — no special handling). New vision feature library (src/features/vision/): - logic/view-model.ts (32 tests): VisionSettings/VisionSettingsPatch types (consumer-defines-port), LoadVisionSettings/SaveVisionSettings ports + results, normalizeVisionSettings (network-seam coercion), parseImageLimit/ imageLimitChanged, compactionModelOptions (vision-capable models via chat's public isVisionModel + Auto sentinel), round-trip helpers, imageLimitLabel. - ui/VisionSettingsView.svelte (9 tests): imageLimit input + Save, compactionModel dropdown (Auto + vision-capable models), load-on-mount, save-on-change, error/saved feedback. - index.ts. Cross-unit seam: isVisionModel added to features/chat public index.ts (additive); imported through the public surface, not internals. Store wiring (src/app/store.svelte.ts): - visionSettings state + refreshVisionSettings (GET /settings/vision, normalized at the seam) + setVisionSettings (PUT, partial, returns merged) + VisionSettingsResult; seeded on boot; exposed on AppStore. +4 store tests. Mounted in App.svelte: new "Vision" sidebar view kind + VisionSettingsView in viewContent (not conversation-scoped); load/save adapters; visionManifest. Verification: svelte-check 0/0; vitest 948/948 (run twice, +47 since the prior vision commit); biome clean; vite build OK. See backend-handoff.md §2j. Not merged or pushed. --- .dispatch/transport-contract.reference.md | 37 +++- GLOSSARY.md | 5 +- backend-handoff.md | 73 ++++++- src/app/App.svelte | 38 ++++ src/app/store.svelte.ts | 77 +++++++ src/app/store.test.ts | 124 +++++++++++ src/features/chat/index.ts | 1 + src/features/chat/ui.test.ts | 57 ++++- src/features/vision/index.ts | 30 +++ src/features/vision/logic/view-model.test.ts | 198 ++++++++++++++++++ src/features/vision/logic/view-model.ts | 189 +++++++++++++++++ src/features/vision/ui/VisionSettingsView.svelte | 192 +++++++++++++++++ src/features/vision/ui/VisionSettingsView.test.ts | 241 ++++++++++++++++++++++ 13 files changed, 1242 insertions(+), 20 deletions(-) create mode 100644 src/features/vision/index.ts create mode 100644 src/features/vision/logic/view-model.test.ts create mode 100644 src/features/vision/logic/view-model.ts create mode 100644 src/features/vision/ui/VisionSettingsView.svelte create mode 100644 src/features/vision/ui/VisionSettingsView.test.ts (limited to 'src/app/store.svelte.ts') diff --git a/.dispatch/transport-contract.reference.md b/.dispatch/transport-contract.reference.md index 152a1b0..047f050 100644 --- a/.dispatch/transport-contract.reference.md +++ b/.dispatch/transport-contract.reference.md @@ -13,9 +13,19 @@ > (each entry: `{ url, mimeType? }` — a base64 data URL or `http(s)://` URL; validated non-array/no-url/ > empty-url → 400, empty array treated as absent). `ModelMetadata` gains `vision?: boolean` (true when the > model natively accepts images; absent → the server's vision handoff transcribes images to text before the -> model sees them). `ImageChunk`/`ImageInput` are `@dispatch/wire` types (re-exported here). A non-vision -> model's persisted user message keeps the original `image` chunk AND adds a `text` transcription chunk -> (`[Image analysis (via )]: …`) in the SAME message — render both. See `backend-handoff.md` §2j. +> model sees them). `ImageChunk`/`ImageInput` are `@dispatch/wire` types (re-exported here). +> +> **2026-06-26 update (consult_vision + vision settings — ADDITIVE, NO version bump):** the `read_image` +> tool is REPLACED by `consult_vision` (`{ question: string, imageIds?: number[], path?: string }`) — it +> opens a NEW conversation tab with a vision-capable model, attaches the image + question, and returns the +> vision model's answer (rendered like any tool call/result). Non-vision models now get NUMBERED +> PLACEHOLDERS (`[Image N attached — call consult_vision with imageIds=[N] and a specific question to +> analyze it]`) instead of auto-transcriptions — these are regular `text` chunks (render as-is). Image +> compaction transcribes the oldest images past `imageLimit` to `[Compacted image]: ` text +> chunks (also regular `text` — render as-is; the persisted `image` chunk stays for rendering). NEW global +> vision settings API: `GET /settings/vision` → `VisionSettingsResponse` (`{ imageLimit, compactionModel }`), +> `PUT /settings/vision` ← `SetVisionSettingsRequest` (partial: `imageLimit?` non-negative int, 0 = disable +> compaction; `compactionModel?` `/` or null = auto). See `backend-handoff.md` §2j. > > **2026-06-25 delta (SSH handoff #2 — ADDITIVE to `transport-contract@0.22.0`, NO version bump):** adds the > computer HTTP API types: `ComputerListResponse` (`GET /computers`), `ComputerResponse` (`GET /computers/:alias`), @@ -459,6 +469,27 @@ export interface SystemPromptVariablesResponse { readonly variables: readonly SystemPromptVariable[]; } +// ─── Vision settings (global) ─────────────────────────────────────────────── + +/** + * Response of `GET /settings/vision` — the global vision configuration shared + * across all conversations and vision models. + */ +export interface VisionSettingsResponse { + /** Max native images per turn (default 10); 0 disables image compaction. */ + readonly imageLimit: number; + /** Which model transcribes old images (null = auto-select a vision model). */ + readonly compactionModel: string | null; +} + +/** Body of `PUT /settings/vision` — a partial update. */ +export interface SetVisionSettingsRequest { + /** Non-negative integer (0 = disable compaction). */ + readonly imageLimit?: number; + /** A model name (`/`) or null (auto). */ + readonly compactionModel?: string | null; +} + // ─── Message queue (steering) ───────────────────────────────────────────────── /** diff --git a/GLOSSARY.md b/GLOSSARY.md index 03fd848..3973fe1 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -27,8 +27,9 @@ | **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 (`wire@0.12.0`, 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 (`wire@0.12.0`, 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 | -| **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** transcribes each image to a text description before the model sees it. 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: the orchestrator transcribes the image to a text description (via a vision-capable model) and feeds that text instead, so a text-only model never sees the image directly. The persisted user message keeps the original `image` chunk AND a `text` transcription chunk (`[Image analysis (via )]: …`) in the SAME message — the description is reused on every later turn (no re-transcription). The FE renders both; it sends the image and lets the server decide. Distinct from a vision-capable model, which receives the image natively. | image transcription, vision relay | +| **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]: ` 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 | ## Frontend-specific | Term | Meaning | Aliases to avoid | diff --git a/backend-handoff.md b/backend-handoff.md index 741eb25..9338a5a 100644 --- a/backend-handoff.md +++ b/backend-handoff.md @@ -5,11 +5,12 @@ > **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 ADDED — Vision & vision handoff: image paste in the composer, `image` chunks -rendered in the transcript, and a vision badge in the model picker. Additive to `wire@0.12.0` / -`transport-contract@0.22.0` (NO version bump): new `ImageChunk` in the `Chunk` union + `ImageInput`; -`ChatRequest.images` (`ChatSendMessage` carries it; `ChatQueueMessage` does NOT — steering is text-only); -`ModelMetadata.vision`. typecheck 0/0, 901 tests green (+34), biome clean, build OK. §2i unchanged.)_ +_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.)_ **FE is current on `ui-contract@0.2.0` / `transport-contract@0.22.0` / `wire@0.12.0`.** Open asks: **CR-9** (`system:os` should detect WSL + include Linux distro — backend behavior change, no contract bump). The SSH-divergence (§2d) is RESOLVED. @@ -792,8 +793,66 @@ transcript render; `GET /models` `vision` → badge; history `image` chunk → r component + store tests. To confirm end-to-end: start the backend, paste an image into the composer with a vision model selected (e.g. any `kimi/*`), send, confirm the image renders + the model responds to it; then switch to a non-vision model (e.g. `umans/glm-5.2`), paste an image, send, confirm the image renders -AND a `[Image analysis (via …)]` text bubble appears (the handoff transcription); confirm the vision -badge shows/hides per model in the picker. +AND a numbered placeholder `[Image N attached — call consult_vision…]` text bubble appears (the non-vision +handoff — see the update below; the old `[Image analysis (via …)]` auto-transcription is GONE); confirm +the vision badge shows/hides per model in the picker. + +### 2j-update. consult_vision tool + vision settings API → **CONSUMED ✅ (backend updated; FE built + verified)** + +A follow-up to §2j. The backend revised the vision surface: the `read_image` tool is REPLACED by +`consult_vision`, non-vision models get NUMBERED PLACEHOLDERS (not auto-transcriptions), and there is a +NEW global vision-settings API. Additive to `wire@0.12.0` / `transport-contract@0.22.0` (NO version bump); +re-mirrored `.dispatch/transport-contract.reference.md` (added `VisionSettingsResponse` / +`SetVisionSettingsRequest` + a delta note). + +**What changed (backend):** +- **`read_image` → `consult_vision`** (`{ question: string (req), imageIds?: number[], path?: string }`): + opens a NEW conversation tab with a vision-capable model (Kimi), attaches the image + question, returns + the conversation id + the vision model's answer (suggests the dispatch CLI for follow-ups). Rendered like + any tool call/result (generic `toolName` dispatch — no special-casing). +- **Non-vision models get NUMBERED PLACEHOLDERS** instead of auto-transcriptions: a pasted image on a + non-vision model persists an `image` chunk + a `text` chunk `[Image N attached — call consult_vision + with imageIds=[N] and a specific question to analyze it]`. (The old `[Image analysis (via )]: …` + auto-transcription is GONE.) +- **Image compaction** (transparent): when a vision model has > `imageLimit` images in history, the oldest + are transcribed to `[Compacted image]: ` `text` chunks (the persisted `image` chunk stays + for rendering). Both placeholder + compacted chunks are REGULAR `text` chunks — render as-is (no special + handling). +- **NEW global vision settings API:** `GET /settings/vision` → `VisionSettingsResponse` + (`{ imageLimit: number (default 10), compactionModel: string|null (null = auto) }`); `PUT /settings/vision` + ← `SetVisionSettingsRequest` (partial: `imageLimit?` non-negative int, 0 = disable compaction; + `compactionModel?` `/` or null). + +**FE (DONE + verified):** +- **Tool rendering:** the ChatView `read_image` test → `consult_vision` (rendering is generic — renders by + `toolName`, so no component change; only the test name/input updated). +2 ChatView tests for the + placeholder text chunk + the compacted-image text chunk (both render as regular text). +- **New `vision` feature library** (`src/features/vision/`): pure `logic/view-model.ts` (32 tests) — + `VisionSettings`/`VisionSettingsPatch` (owned locally, consumer-defines-port; the shapes are a plain REST + surface, mirroring heartbeat/mcp), `LoadVisionSettings`/`SaveVisionSettings` ports + result types, + `normalizeVisionSettings` (network-seam coercion — a malformed body can't crash the renderer), + `parseImageLimit`/`imageLimitChanged` (dirty-check), `compactionModelOptions` (filters `GET /models` to + vision-capable models via the chat feature's public `isVisionModel` export + an "Auto" sentinel), + `selectedCompactionValue`/`compactionModelFromValue` round-trip, `imageLimitLabel`; `ui/VisionSettingsView.svelte` + (imageLimit text input + Save, compactionModel dropdown with Auto + vision-capable models, load-on-mount, + save-on-change, error/saved feedback; 9 component tests); `index.ts`. +- **Cross-unit seam:** `isVisionModel` was added to `features/chat`'s public `index.ts` (additive — the + vision feature imports it through the public surface, not the chat internals). +- **Store wiring** (`src/app/store.svelte.ts`): `visionSettings` reactive state + `refreshVisionSettings()` + (`GET /settings/vision`, normalized at the seam) + `setVisionSettings(patch)` (`PUT /settings/vision`, + returns merged settings) + `VisionSettingsResult`; seeded on boot; exposed on `AppStore`. +4 store tests. +- **Mounted in `App.svelte`:** a new "Vision" sidebar view kind (`viewKinds`) + `VisionSettingsView` in the + `viewContent` snippet (not conversation-scoped — no `{#key}`); `loadVisionSettings`/`saveVisionSettings` + adapters wrap the store; `visionManifest` in `loadedModules`. + +**Verification:** `svelte-check` 0/0; vitest **948/948** (run TWICE — no cross-test pollution; +47 new +since the §2j baseline: 32 vision view-model, 9 VisionSettingsView, 4 store, +2 ChatView +placeholder/compacted, and the read_image→consult_vision test update), biome clean, `vite build` succeeds. +**Live probe NOT run** (backend not reachable headless). To confirm end-to-end: open the Vision sidebar +view, confirm the imageLimit + compactionModel load; change imageLimit → Save → confirm it persists on +reload; set compactionModel to a vision model → confirm the dropdown reflects it; paste an image with a +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. --- diff --git a/src/app/App.svelte b/src/app/App.svelte index f240a06..f41d7ba 100644 --- a/src/app/App.svelte +++ b/src/app/App.svelte @@ -77,6 +77,13 @@ type SaveSystemPrompt as SaveSystemPromptAlias, manifest as systemPromptManifest, } from "../features/system-prompt"; + import { + VisionSettingsView, + manifest as visionManifest, + type LoadVisionSettingsResult, + type SaveVisionSettingsResult, + type VisionSettingsPatch, + } from "../features/vision"; import type { AppStore } from "./store.svelte"; import ErrorModal from "./ErrorModal.svelte"; import { createLocalStore } from "../adapters/local-storage"; @@ -106,6 +113,7 @@ { id: "cache-warming", label: "Cache Warming" }, { id: "tasks", label: "Tasks" }, { id: "compaction", label: "Compaction" }, + { id: "vision", label: "Vision" }, { id: "heartbeat", label: "Heartbeat" }, { id: "system-prompt", label: "System Prompt" }, { id: "settings", label: "Settings" }, @@ -143,6 +151,7 @@ settingsManifest, systemPromptManifest, heartbeatManifest, + visionManifest, ].map((m) => [m.name, m.description] as const); // Smart-scroll: keep the transcript pinned to the bottom while it streams, @@ -304,6 +313,27 @@ : { ok: false, error: result.error }; } + // Adapt the store's global vision-settings API to the vision feature's ports. + async function loadVisionSettings(): Promise { + // The store seeds `visionSettings` on boot; a refresh keeps it current. + await store.refreshVisionSettings(); + const settings = store.visionSettings; + if (settings === null) { + return { ok: false, error: "Vision settings not available." }; + } + return { ok: true, settings }; + } + + async function saveVisionSettings( + patch: VisionSettingsPatch, + ): Promise { + const result = await store.setVisionSettings(patch); + if (result === null) return { ok: false, error: "Vision settings not available." }; + return result.ok + ? { ok: true, settings: result.settings } + : { ok: false, error: result.error }; + } + // Adapt the store's chat-limit result to the settings feature's port. On a // raise the active chat refills (prepends older history); preserve the // reader's viewport over the prepend (the manual analogue of CSS scroll @@ -660,6 +690,14 @@ savePercent={saveCompactPercent} /> {/key} + {:else if kind === "vision"} + + {:else if kind === "system-prompt"} diff --git a/src/app/store.svelte.ts b/src/app/store.svelte.ts index 41f3a5d..554d5e0 100644 --- a/src/app/store.svelte.ts +++ b/src/app/store.svelte.ts @@ -27,6 +27,7 @@ import type { SetReasoningEffortRequest, SetSystemPromptTemplateRequest, SetTitleRequest, + SetVisionSettingsRequest, SystemPromptTemplateResponse, SystemPromptVariable, SystemPromptVariablesResponse, @@ -67,6 +68,11 @@ import type { import { normalizeHeartbeatConfig, normalizeHeartbeatRuns } from "../features/heartbeat"; import type { Tab, TabsState } from "../features/tabs"; import { createTabsStore, deriveTitle, type TabsStore } from "../features/tabs"; +import { + normalizeVisionSettings, + type VisionSettings, + type VisionSettingsPatch, +} from "../features/vision"; import { resolveHttpUrl } from "./resolve-http-url"; import { resolveWsUrl } from "./resolve-ws-url"; import { randomId } from "./uuid"; @@ -123,6 +129,11 @@ export type CompactPercentResult = | { readonly ok: true; readonly percent: number } | { readonly ok: false; readonly error: string }; +/** Outcome of `PUT /settings/vision` (global vision-settings save). */ +export type VisionSettingsResult = + | { readonly ok: true; readonly settings: VisionSettings } + | { readonly ok: false; readonly error: string }; + /** Outcome of `GET /system-prompt` (global template load). */ export type SystemPromptLoadResult = | { readonly ok: true; readonly template: string } @@ -268,6 +279,23 @@ export interface AppStore { * number enables. Works for a draft too (its id survives promotion). */ setCompactPercent(percent: number): Promise; + /** + * The GLOBAL vision settings (`GET /settings/vision`): `imageLimit` (max + * native images per turn before compaction; 0 = disabled) + `compactionModel` + * (which vision model transcribes old images; null = auto). Shared across all + * conversations. Seeded on boot; `null` = not yet fetched. + */ + readonly visionSettings: VisionSettings | null; + /** + * Refetch the global vision settings (`GET /settings/vision`). Called by the + * vision-settings view on mount; also seeded on boot. + */ + refreshVisionSettings(): Promise; + /** + * Save a PARTIAL vision-settings update (`PUT /settings/vision`). Either + * field may be omitted. Returns the merged settings on success. + */ + setVisionSettings(patch: VisionSettingsPatch): Promise; /** * Fetch the workspace conversation's language-server status (`GET /conversations/:id/lsp`). * The backend lazily spawns servers, so this may take a moment on the first call for a cwd. @@ -626,6 +654,22 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { // backend on focus change; null = not yet fetched. 0 = disabled. let compactPercent = $state(null); + // The GLOBAL vision settings (shared across all conversations). Seeded on + // boot; null = not yet fetched. + let visionSettings = $state(null); + + /** Refetch the global vision settings (`GET /settings/vision`). */ + async function refreshVisionSettings(): Promise { + try { + const res = await fetchImpl(`${httpBase}/settings/vision`); + if (!res.ok) return; + const data = normalizeVisionSettings(await res.json()); + visionSettings = data; + } catch (err) { + reportError("Failed to load vision settings", err); + } + } + /** Refetch the workspace conversation's compact percent (works for a draft too). */ async function refreshCompactPercent(): Promise { const id = workspaceConversationId(); @@ -1066,6 +1110,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { void refreshModel(); void refreshReasoningEffort(); void refreshCompactPercent(); + void refreshVisionSettings(); // Fetch the authoritative open-conversation list from the backend (cross- // device tab sync). Merges with the localStorage-restored tabs: opens new @@ -1142,6 +1187,12 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { get compactPercent(): number | null { return compactPercent; }, + get visionSettings(): VisionSettings | null { + return visionSettings; + }, + async refreshVisionSettings(): Promise { + await refreshVisionSettings(); + }, get chatLimit(): number { return chatLimit; }, @@ -1500,6 +1551,32 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { } }, + async setVisionSettings(patch: VisionSettingsPatch): Promise { + const body: SetVisionSettingsRequest = patch; + try { + const res = await fetchImpl(`${httpBase}/settings/vision`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const errBody = (await res.json().catch(() => null)) as { error?: string } | null; + return { + ok: false, + error: errBody?.error ?? `Set vision settings failed (HTTP ${res.status})`, + }; + } + const data = normalizeVisionSettings(await res.json()); + visionSettings = data; + return { ok: true, settings: data }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Set vision settings request failed", + }; + } + }, + async setChatLimit(limit: number): Promise { const next = normalizeChatLimit(limit); chatLimitStore.save(next); diff --git a/src/app/store.test.ts b/src/app/store.test.ts index 7130c4d..1534402 100644 --- a/src/app/store.test.ts +++ b/src/app/store.test.ts @@ -1404,3 +1404,127 @@ describe("createAppStore", () => { store.dispose(); }); }); + +describe("createAppStore — vision settings (global)", () => { + function visionFetch(initial: { imageLimit: number; compactionModel: string | null }): { + fetchImpl: typeof fetch; + puts: { imageLimit?: number; compactionModel?: string | null }[]; + } { + let current = initial; + const puts: { imageLimit?: number; compactionModel?: string | null }[] = []; + return { + puts, + fetchImpl: async (input: string | URL | Request, init?: RequestInit): Promise => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (url.endsWith("/models")) { + return new Response( + JSON.stringify({ + models: ["kimi/k2", "umans/glm-5.2"], + modelInfo: { "kimi/k2": { vision: true } }, + }), + { status: 200 }, + ); + } + if (url.endsWith("/settings/vision")) { + if (init?.method === "PUT") { + const text = typeof init.body === "string" ? init.body : ""; + const body = text ? (JSON.parse(text) as object) : {}; + puts.push(body as { imageLimit?: number; compactionModel?: string | null }); + current = { ...current, ...(body as object) } as { + imageLimit: number; + compactionModel: string | null; + }; + } + return new Response(JSON.stringify(current), { status: 200 }); + } + // Default: empty history + no cwd for the other endpoints. + return new Response(JSON.stringify({ chunks: [], latestSeq: 0 }), { status: 200 }); + }, + }; + } + + it("loads vision settings on boot (GET /settings/vision)", async () => { + const { fetchImpl } = visionFetch({ imageLimit: 7, compactionModel: "kimi/k2" }); + const store = createAppStore({ + socketFactory: () => fakeSocket(), + fetchImpl, + localStorage: createFakeStorage(), + }); + fakeSocket().resolveOpen(); // not strictly needed for HTTP + + await vi.waitFor(() => { + expect(store.visionSettings).toEqual({ imageLimit: 7, compactionModel: "kimi/k2" }); + }); + store.dispose(); + }); + + it("setVisionSettings PUTs a partial update and reflects the merged settings", async () => { + const ctx = visionFetch({ imageLimit: 10, compactionModel: null }); + const store = createAppStore({ + socketFactory: () => fakeSocket(), + fetchImpl: ctx.fetchImpl, + localStorage: createFakeStorage(), + }); + + await vi.waitFor(() => { + expect(store.visionSettings?.imageLimit).toBe(10); + }); + + const result = await store.setVisionSettings({ imageLimit: 3 }); + expect(result?.ok).toBe(true); + if (result?.ok) { + expect(result.settings.imageLimit).toBe(3); + expect(result.settings.compactionModel).toBeNull(); + } + expect(ctx.puts).toEqual([{ imageLimit: 3 }]); + expect(store.visionSettings?.imageLimit).toBe(3); + + // A second save updates compactionModel only. + const result2 = await store.setVisionSettings({ compactionModel: "kimi/k2" }); + expect(result2?.ok).toBe(true); + expect(ctx.puts).toEqual([{ imageLimit: 3 }, { compactionModel: "kimi/k2" }]); + expect(store.visionSettings?.compactionModel).toBe("kimi/k2"); + store.dispose(); + }); + + it("refreshVisionSettings refetches (load adapter)", async () => { + const { fetchImpl } = visionFetch({ imageLimit: 5, compactionModel: null }); + const store = createAppStore({ + socketFactory: () => fakeSocket(), + fetchImpl, + localStorage: createFakeStorage(), + }); + + await store.refreshVisionSettings(); + expect(store.visionSettings).toEqual({ imageLimit: 5, compactionModel: null }); + store.dispose(); + }); + + it("surfaces a PUT error", async () => { + const fetchImpl: typeof fetch = async (input, init) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (url.endsWith("/settings/vision")) { + if (init?.method === "PUT") { + return new Response(JSON.stringify({ error: "invalid imageLimit" }), { status: 400 }); + } + return new Response(JSON.stringify({ imageLimit: 10, compactionModel: null }), { + status: 200, + }); + } + return new Response(JSON.stringify({ models: [] }), { status: 200 }); + }; + const store = createAppStore({ + socketFactory: () => fakeSocket(), + fetchImpl, + localStorage: createFakeStorage(), + }); + + const result = await store.setVisionSettings({ imageLimit: -1 }); + expect(result?.ok).toBe(false); + if (result !== null && !result.ok) { + expect(result.error).toContain("invalid imageLimit"); + } + store.dispose(); + }); +}); diff --git a/src/features/chat/index.ts b/src/features/chat/index.ts index ddb094d..8694691 100644 --- a/src/features/chat/index.ts +++ b/src/features/chat/index.ts @@ -6,6 +6,7 @@ export type { } from "../../core/chunks"; export { groupRenderedChunks, viewProviderRetry } from "../../core/chunks"; export type { TurnMetricsEntry } from "../../core/metrics"; +export { isVisionModel } from "./model-select"; export type { ChatTransport, HistorySync, HistoryWindow, MetricsSync } from "./ports"; export type { EffortOption, diff --git a/src/features/chat/ui.test.ts b/src/features/chat/ui.test.ts index af32f72..b0aa6f0 100644 --- a/src/features/chat/ui.test.ts +++ b/src/features/chat/ui.test.ts @@ -655,8 +655,10 @@ describe("ChatView", () => { expect(container.querySelector("img")?.getAttribute("src")).toBe(url); }); - it("renders a read_image tool call/result like any other tool", () => { - // The read_image tool is available to all models; it is a normal tool call. + it("renders a consult_vision tool call/result like any other tool", () => { + // read_image is GONE — replaced by consult_vision (opens a vision-model + // conversation, attaches the image + question, returns the answer). It is + // a normal tool call: rendered generically by toolName. const chunks: RenderedChunk[] = [ { seq: 1, @@ -664,8 +666,8 @@ describe("ChatView", () => { chunk: { type: "tool-call", toolCallId: "tc1", - toolName: "read_image", - input: { path: "/tmp/screenshot.png" }, + toolName: "consult_vision", + input: { question: "what is in this image?", imageIds: [1] }, }, provisional: false, }, @@ -675,8 +677,8 @@ describe("ChatView", () => { chunk: { type: "tool-result", toolCallId: "tc1", - toolName: "read_image", - content: "a screenshot of the desktop", + toolName: "consult_vision", + content: "a red square on a white background", isError: false, }, provisional: false, @@ -685,8 +687,47 @@ describe("ChatView", () => { render(ChatView, { props: { chunks } }); - expect(screen.getAllByText("read_image").length).toBeGreaterThan(0); - expect(screen.getByText("a screenshot of the desktop")).toBeInTheDocument(); + expect(screen.getAllByText("consult_vision").length).toBeGreaterThan(0); + expect(screen.getByText("a red square on a white background")).toBeInTheDocument(); + }); + + it("renders a non-vision placeholder text chunk as-is", () => { + // A non-vision model gets a numbered placeholder (a regular text chunk) + // instead of an auto-transcription. Renders like any text chunk. + const chunks: RenderedChunk[] = [ + { + seq: 1, + role: "user", + chunk: { + type: "text", + text: "[Image 1 attached — call consult_vision with imageIds=[1] and a specific question to analyze it]", + }, + provisional: false, + }, + ]; + + render(ChatView, { props: { chunks } }); + + expect(screen.getByText(/\[Image 1 attached/)).toBeInTheDocument(); + expect(screen.getByText(/consult_vision with imageIds/)).toBeInTheDocument(); + }); + + it("renders a compacted-image text chunk as-is", () => { + // Image compaction transcribes old images to [Compacted image]: . + // Regular text chunk — render as-is. + const chunks: RenderedChunk[] = [ + { + seq: 1, + role: "user", + chunk: { type: "text", text: "[Compacted image]: a chart showing rising sales" }, + provisional: false, + }, + ]; + + render(ChatView, { props: { chunks } }); + + expect(screen.getByText(/\[Compacted image\]/)).toBeInTheDocument(); + expect(screen.getByText(/rising sales/)).toBeInTheDocument(); }); }); diff --git a/src/features/vision/index.ts b/src/features/vision/index.ts new file mode 100644 index 0000000..6956d75 --- /dev/null +++ b/src/features/vision/index.ts @@ -0,0 +1,30 @@ +export type { + CompactionModelOption, + ImageLimitParse, + LoadVisionSettings, + LoadVisionSettingsResult, + SaveVisionSettings, + SaveVisionSettingsResult, + VisionSettings, + VisionSettingsPatch, +} from "./logic/view-model"; +export { + AUTO_COMPACTION_MODEL, + compactionModelChanged, + compactionModelFromValue, + compactionModelOptions, + DEFAULT_IMAGE_LIMIT, + imageLimitChanged, + imageLimitLabel, + MAX_IMAGE_LIMIT, + normalizeVisionSettings, + parseImageLimit, + selectedCompactionValue, +} from "./logic/view-model"; +export { default as VisionSettingsView } from "./ui/VisionSettingsView.svelte"; + +/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ +export const manifest = { + name: "vision", + description: "Global vision settings (image compaction limit + model)", +} as const; diff --git a/src/features/vision/logic/view-model.test.ts b/src/features/vision/logic/view-model.test.ts new file mode 100644 index 0000000..b1db822 --- /dev/null +++ b/src/features/vision/logic/view-model.test.ts @@ -0,0 +1,198 @@ +import type { ModelMetadata } from "@dispatch/transport-contract"; +import { describe, expect, it } from "vitest"; +import { + AUTO_COMPACTION_MODEL, + compactionModelChanged, + compactionModelFromValue, + compactionModelOptions, + DEFAULT_IMAGE_LIMIT, + imageLimitChanged, + imageLimitLabel, + MAX_IMAGE_LIMIT, + normalizeVisionSettings, + parseImageLimit, + selectedCompactionValue, +} from "./view-model"; + +describe("normalizeVisionSettings", () => { + it("returns the value as-is for a well-formed body", () => { + expect(normalizeVisionSettings({ imageLimit: 5, compactionModel: "kimi/k2" })).toEqual({ + imageLimit: 5, + compactionModel: "kimi/k2", + }); + }); + + it("coerces a null compactionModel to null", () => { + expect(normalizeVisionSettings({ imageLimit: 10, compactionModel: null })).toEqual({ + imageLimit: 10, + compactionModel: null, + }); + }); + + it("defaults imageLimit when absent or non-numeric", () => { + expect(normalizeVisionSettings({ compactionModel: "kimi/k2" })).toEqual({ + imageLimit: DEFAULT_IMAGE_LIMIT, + compactionModel: "kimi/k2", + }); + expect(normalizeVisionSettings({ imageLimit: "oops", compactionModel: null })).toEqual({ + imageLimit: DEFAULT_IMAGE_LIMIT, + compactionModel: null, + }); + }); + + it("floors a fractional imageLimit", () => { + expect(normalizeVisionSettings({ imageLimit: 7.9, compactionModel: null }).imageLimit).toBe(7); + }); + + it("defaults for a null/non-object body (never crashes)", () => { + expect(normalizeVisionSettings(null)).toEqual({ + imageLimit: DEFAULT_IMAGE_LIMIT, + compactionModel: null, + }); + expect(normalizeVisionSettings("nope")).toEqual({ + imageLimit: DEFAULT_IMAGE_LIMIT, + compactionModel: null, + }); + }); + + it("treats a negative imageLimit as the default", () => { + expect(normalizeVisionSettings({ imageLimit: -3, compactionModel: null }).imageLimit).toBe( + DEFAULT_IMAGE_LIMIT, + ); + }); + + it("treats an empty compactionModel string as null", () => { + expect( + normalizeVisionSettings({ imageLimit: 10, compactionModel: "" }).compactionModel, + ).toBeNull(); + }); +}); + +describe("parseImageLimit", () => { + it("parses a non-negative integer", () => { + expect(parseImageLimit("5")).toEqual({ ok: true, value: 5 }); + expect(parseImageLimit("0")).toEqual({ ok: true, value: 0 }); + }); + + it("floors a fractional value", () => { + expect(parseImageLimit("7.9")).toEqual({ ok: true, value: 7 }); + }); + + it("trims whitespace", () => { + expect(parseImageLimit(" 12 ")).toEqual({ ok: true, value: 12 }); + }); + + it("errors on empty input", () => { + expect(parseImageLimit("")).toEqual({ ok: false, error: "Enter a number." }); + }); + + it("errors on non-numeric input", () => { + expect(parseImageLimit("lots").ok).toBe(false); + }); + + it("errors on a negative value", () => { + expect(parseImageLimit("-1").ok).toBe(false); + }); + + it("errors when above the max", () => { + expect(parseImageLimit(String(MAX_IMAGE_LIMIT + 1)).ok).toBe(false); + }); + + it("accepts the max", () => { + expect(parseImageLimit(String(MAX_IMAGE_LIMIT))).toEqual({ ok: true, value: MAX_IMAGE_LIMIT }); + }); +}); + +describe("imageLimitChanged", () => { + it("is true when the typed value differs after normalization", () => { + expect(imageLimitChanged("5", 10)).toBe(true); + }); + + it("is false when equal", () => { + expect(imageLimitChanged("10", 10)).toBe(false); + }); + + it("is false for invalid input", () => { + expect(imageLimitChanged("abc", 10)).toBe(false); + expect(imageLimitChanged("", 10)).toBe(false); + }); + + it("compares after flooring", () => { + expect(imageLimitChanged("7.9", 7)).toBe(false); + }); +}); + +describe("compactionModelOptions", () => { + const modelInfo: Record = { + "kimi/k2": { vision: true }, + "kimi/k1.5": { vision: true }, + "umans/glm-5.2": { vision: false }, + "openai/gpt-4": { contextWindow: 128000 }, + }; + + it("includes the Auto option first", () => { + const opts = compactionModelOptions([], {}); + expect(opts).toHaveLength(1); + expect(opts[0]?.auto).toBe(true); + expect(opts[0]?.value).toBe(AUTO_COMPACTION_MODEL); + }); + + it("includes only vision-capable models, in catalog order", () => { + const models = ["umans/glm-5.2", "kimi/k2", "openai/gpt-4", "kimi/k1.5"]; + const opts = compactionModelOptions(models, modelInfo); + expect(opts.map((o) => o.label)).toEqual(["Auto (server-selected)", "kimi/k2", "kimi/k1.5"]); + }); + + it("excludes non-vision models even with metadata present", () => { + const opts = compactionModelOptions(["umans/glm-5.2"], modelInfo); + expect(opts).toHaveLength(1); // only Auto + }); +}); + +describe("compactionModel value round-trip", () => { + it("selectedCompactionValue maps null to the auto sentinel", () => { + expect(selectedCompactionValue(null)).toBe(AUTO_COMPACTION_MODEL); + }); + + it("selectedCompactionValue maps a model name to itself", () => { + expect(selectedCompactionValue("kimi/k2")).toBe("kimi/k2"); + }); + + it("compactionModelFromValue maps the auto sentinel back to null", () => { + expect(compactionModelFromValue(AUTO_COMPACTION_MODEL)).toBeNull(); + }); + + it("compactionModelFromValue maps a model name to itself", () => { + expect(compactionModelFromValue("kimi/k2")).toBe("kimi/k2"); + }); +}); + +describe("compactionModelChanged", () => { + it("is true when the value maps to a different model", () => { + expect(compactionModelChanged("kimi/k2", null)).toBe(true); + expect(compactionModelChanged(AUTO_COMPACTION_MODEL, "kimi/k2")).toBe(true); + }); + + it("is false when equal (null vs auto, or same model)", () => { + expect(compactionModelChanged(AUTO_COMPACTION_MODEL, null)).toBe(false); + expect(compactionModelChanged("kimi/k2", "kimi/k2")).toBe(false); + }); +}); + +describe("imageLimitLabel", () => { + it("labels null as loading", () => { + expect(imageLimitLabel(null)).toBe("Loading…"); + }); + + it("labels 0 as disabled", () => { + expect(imageLimitLabel(0)).toBe("0 (compaction disabled)"); + }); + + it("labels the default with (default)", () => { + expect(imageLimitLabel(DEFAULT_IMAGE_LIMIT)).toBe(`${DEFAULT_IMAGE_LIMIT} (default)`); + }); + + it("labels other values plainly", () => { + expect(imageLimitLabel(7)).toBe("7"); + }); +}); diff --git a/src/features/vision/logic/view-model.ts b/src/features/vision/logic/view-model.ts new file mode 100644 index 0000000..97a7093 --- /dev/null +++ b/src/features/vision/logic/view-model.ts @@ -0,0 +1,189 @@ +import type { ModelMetadata } from "@dispatch/transport-contract"; +import { isVisionModel } from "../../chat"; + +/** + * Pure core for the vision settings feature — zero DOM, zero effects, zero Svelte. + * + * The global vision configuration (`GET`/`PUT /settings/vision`) controls image + * compaction: how many native images a vision model keeps per turn before the + * oldest are transcribed to text (`imageLimit`), and which vision model does the + * transcribing (`compactionModel`, null = auto). This module is the view-model + * seam: typed settings, parse/validate, dirty-check, network normalization, and + * the vision-capable model option list. The composition root adapts the store's + * HTTP calls to the injected ports. + */ + +// ── Types (owned locally — consumer-defines-port; the contract shapes are a +// plain REST surface not in a shared contract package version bump, mirroring +// the heartbeat/mcp pattern). If the backend promotes these to a shared +// package, swap the local types for the imports + re-mirror. ────────────── + +/** The global vision settings (mirrors `VisionSettingsResponse`). */ +export interface VisionSettings { + /** Max native images per turn (default 10); 0 disables compaction. */ + readonly imageLimit: number; + /** Which model transcribes old images (`/`), or null = auto. */ + readonly compactionModel: string | null; +} + +/** A partial update (mirrors `SetVisionSettingsRequest`). */ +export interface VisionSettingsPatch { + readonly imageLimit?: number; + readonly compactionModel?: string | null; +} + +// ── Injected ports (consumer-defines-port). ─────────────────────────────────── + +/** Outcome of loading the vision settings. */ +export type LoadVisionSettingsResult = + | { readonly ok: true; readonly settings: VisionSettings } + | { readonly ok: false; readonly error: string }; + +/** Outcome of saving a partial vision-settings update. */ +export type SaveVisionSettingsResult = + | { readonly ok: true; readonly settings: VisionSettings } + | { readonly ok: false; readonly error: string }; + +export type LoadVisionSettings = () => Promise; +export type SaveVisionSettings = (patch: VisionSettingsPatch) => Promise; + +// ── Constants ──────────────────────────────────────────────────────────────── + +/** The backend's default image limit when none is persisted. */ +export const DEFAULT_IMAGE_LIMIT = 10; +/** Upper bound for the image limit input (defensive — the backend owns real clamping). */ +export const MAX_IMAGE_LIMIT = 1000; + +// ── Network normalization (pure; coerces untyped JSON at the seam). ────────── + +/** + * Coerce an untyped JSON body (from `GET /settings/vision`) into a valid + * `VisionSettings`. A malformed/partial response can never crash the renderer: + * `imageLimit` falls back to the default; `compactionModel` coerces to null. + */ +export function normalizeVisionSettings(body: unknown): VisionSettings { + if (body === null || typeof body !== "object") { + return { imageLimit: DEFAULT_IMAGE_LIMIT, compactionModel: null }; + } + const raw = body as Record; + const limitRaw = raw.imageLimit; + const imageLimit = + typeof limitRaw === "number" && Number.isFinite(limitRaw) && limitRaw >= 0 + ? Math.floor(limitRaw) + : DEFAULT_IMAGE_LIMIT; + const modelRaw = raw.compactionModel; + const compactionModel = typeof modelRaw === "string" && modelRaw.length > 0 ? modelRaw : null; + return { imageLimit, compactionModel }; +} + +// ── imageLimit parse / validate ─────────────────────────────────────────────── + +/** Result of parsing a typed image-limit string. */ +export type ImageLimitParse = + | { readonly ok: true; readonly value: number } + | { readonly ok: false; readonly error: string }; + +/** + * Parse a typed image-limit string into a non-negative integer (floored, + * clamped to [0, MAX_IMAGE_LIMIT]). Empty or non-numeric input is an ERROR + * (so the UI can disable submit + message), NOT a silent default. + */ +export function parseImageLimit(raw: string): ImageLimitParse { + const trimmed = raw.trim(); + if (trimmed.length === 0) { + return { ok: false, error: "Enter a number." }; + } + const n = Number(trimmed); + if (!Number.isFinite(n) || n < 0) { + return { ok: false, error: "Must be 0 or a positive number." }; + } + const floored = Math.floor(n); + if (floored > MAX_IMAGE_LIMIT) { + return { ok: false, error: `Must be at most ${MAX_IMAGE_LIMIT}.` }; + } + return { ok: true, value: floored }; +} + +/** + * Whether saving `typed` would change the `current` image limit. A no-op save + * (empty/invalid, or equal) should be disabled. This is the dirty-check for the + * input — it must NOT mutate or clamp, only compare. + */ +export function imageLimitChanged(typed: string, current: number): boolean { + const parsed = parseImageLimit(typed); + if (!parsed.ok) return false; + return parsed.value !== current; +} + +// ── compactionModel option list ─────────────────────────────────────────────── + +/** + * The sentinel value for the "Auto" option (null compactionModel — the server + * auto-selects a vision model). Used as the `