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. --- 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 ++++++++++++++++++++++ 10 files changed, 1139 insertions(+), 8 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') 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 `