diff options
Diffstat (limited to 'src/features/vision/ui')
| -rw-r--r-- | src/features/vision/ui/VisionSettingsView.svelte | 192 | ||||
| -rw-r--r-- | src/features/vision/ui/VisionSettingsView.test.ts | 241 |
2 files changed, 433 insertions, 0 deletions
diff --git a/src/features/vision/ui/VisionSettingsView.svelte b/src/features/vision/ui/VisionSettingsView.svelte new file mode 100644 index 0000000..2b4ebba --- /dev/null +++ b/src/features/vision/ui/VisionSettingsView.svelte @@ -0,0 +1,192 @@ +<script lang="ts"> + import type { ModelMetadata } from "@dispatch/transport-contract"; + import { + compactionModelChanged, + compactionModelFromValue, + compactionModelOptions, + DEFAULT_IMAGE_LIMIT, + imageLimitChanged, + imageLimitLabel, + parseImageLimit, + selectedCompactionValue, + type LoadVisionSettings, + type SaveVisionSettings, + type VisionSettings, + } from "../logic/view-model"; + + let { + models, + modelInfo = {}, + load, + save, + }: { + /** The model catalog (`GET /models` `models`) — for the compaction-model dropdown. */ + models: readonly string[]; + /** Per-model metadata — to filter the dropdown to vision-capable models. */ + modelInfo?: Readonly<Record<string, ModelMetadata>>; + /** Load the global vision settings (`GET /settings/vision`). */ + load: LoadVisionSettings; + /** Save a partial vision-settings update (`PUT /settings/vision`). */ + save: SaveVisionSettings; + } = $props(); + + let settings = $state<VisionSettings | null>(null); + let loadError = $state<string | null>(null); + + // imageLimit input state. + let imageLimitInput = $state(""); + let savingImageLimit = $state(false); + let imageLimitError = $state<string | null>(null); + let imageLimitSaved = $state(false); + + // compactionModel select state. + let compactionSaving = $state(false); + let compactionError = $state<string | null>(null); + let compactionSaved = $state(false); + + // Load on mount. + $effect(() => { + void refresh(); + }); + + async function refresh(): Promise<void> { + const result = await load(); + if (result.ok) { + settings = result.settings; + imageLimitInput = String(result.settings.imageLimit); + loadError = null; + imageLimitError = null; + imageLimitSaved = false; + compactionError = null; + compactionSaved = false; + } else { + loadError = result.error; + } + } + + const options = $derived(compactionModelOptions(models, modelInfo)); + const selectedCompaction = $derived( + settings ? selectedCompactionValue(settings.compactionModel) : selectedCompactionValue(null), + ); + const limitLabel = $derived(imageLimitLabel(settings?.imageLimit ?? null)); + + const canSaveImageLimit = $derived( + settings !== null && imageLimitChanged(imageLimitInput, settings.imageLimit), + ); + + async function handleSaveImageLimit(): Promise<void> { + if (settings === null || savingImageLimit) return; + const parsed = parseImageLimit(imageLimitInput); + if (!parsed.ok) { + imageLimitError = parsed.error; + imageLimitSaved = false; + return; + } + savingImageLimit = true; + imageLimitError = null; + imageLimitSaved = false; + const result = await save({ imageLimit: parsed.value }); + savingImageLimit = false; + if (result.ok) { + settings = result.settings; + imageLimitInput = String(result.settings.imageLimit); + imageLimitSaved = true; + } else { + imageLimitError = result.error; + } + } + + async function handleCompactionChange(e: Event): Promise<void> { + if (settings === null || compactionSaving) return; + const value = (e.currentTarget as HTMLSelectElement).value; + if (!compactionModelChanged(value, settings.compactionModel)) return; + compactionSaving = true; + compactionError = null; + compactionSaved = false; + const result = await save({ compactionModel: compactionModelFromValue(value) }); + compactionSaving = false; + if (result.ok) { + settings = result.settings; + compactionSaved = true; + } else { + compactionError = result.error; + } + } +</script> + +<div class="flex flex-col gap-3"> + {#if loadError} + <p class="text-xs text-error">{loadError}</p> + {/if} + + {#if settings === null && !loadError} + <p class="text-xs opacity-60">Loading vision settings…</p> + {:else if settings !== null} + <!-- imageLimit --> + <section class="flex flex-col gap-1"> + <span class="text-xs font-semibold uppercase opacity-60">Image limit</span> + <div class="flex items-center gap-2"> + <input + type="text" + inputmode="numeric" + class="input input-bordered input-sm w-24" + placeholder={String(DEFAULT_IMAGE_LIMIT)} + bind:value={imageLimitInput} + disabled={savingImageLimit} + aria-label="Image limit (max native images per turn)" + /> + <button + type="button" + class="btn btn-sm btn-outline" + disabled={!canSaveImageLimit || savingImageLimit} + onclick={handleSaveImageLimit} + > + {#if savingImageLimit} + <span class="loading loading-spinner loading-xs"></span> + Saving… + {:else} + Save + {/if} + </button> + </div> + <p class="text-xs opacity-50"> + Current: {limitLabel} + <br /> + Max native images per turn before the oldest are transcribed to text. + 0 disables compaction. Default is {DEFAULT_IMAGE_LIMIT}. + </p> + {#if imageLimitError} + <p class="text-xs text-error">{imageLimitError}</p> + {:else if imageLimitSaved} + <p class="text-xs text-success">Saved.</p> + {/if} + </section> + + <!-- compactionModel --> + <section class="flex flex-col gap-1"> + <span class="text-xs font-semibold uppercase opacity-60">Compaction model</span> + <select + class="select select-bordered select-sm w-full" + value={selectedCompaction} + disabled={compactionSaving} + onchange={handleCompactionChange} + aria-label="Compaction model (which vision model transcribes old images)" + > + {#each options as opt (opt.value)} + <option value={opt.value}>{opt.label}</option> + {/each} + </select> + {#if compactionSaving} + <p class="text-xs opacity-60">Saving…</p> + {/if} + <p class="text-xs opacity-50"> + The vision-capable model that transcribes old images to text. "Auto" lets the server choose. + </p> + {#if compactionError} + <p class="text-xs text-error">{compactionError}</p> + {:else if compactionSaved} + <p class="text-xs text-success">Saved.</p> + {/if} + </section> + {/if} +</div> diff --git a/src/features/vision/ui/VisionSettingsView.test.ts b/src/features/vision/ui/VisionSettingsView.test.ts new file mode 100644 index 0000000..48afc71 --- /dev/null +++ b/src/features/vision/ui/VisionSettingsView.test.ts @@ -0,0 +1,241 @@ +import { render, screen } from "@testing-library/svelte"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import type { + LoadVisionSettingsResult, + SaveVisionSettingsResult, + VisionSettings, +} from "../logic/view-model"; +import VisionSettingsView from "./VisionSettingsView.svelte"; + +const SETTINGS: VisionSettings = { imageLimit: 10, compactionModel: null }; + +function fakeLoad(settings: VisionSettings = SETTINGS): { + calls: number; + impl: () => Promise<LoadVisionSettingsResult>; +} { + let calls = 0; + return { + get calls() { + return calls; + }, + impl: async () => { + calls += 1; + return { ok: true, settings }; + }, + }; +} + +function fakeSaveOk(): { + patches: object[]; + impl: (patch: object) => Promise<SaveVisionSettingsResult>; +} { + const patches: object[] = []; + return { + get patches() { + return patches; + }, + impl: async (patch) => { + patches.push(patch); + // Merge into the current settings to simulate the server echo. + const next: VisionSettings = { + imageLimit: + "imageLimit" in patch ? (patch as VisionSettings).imageLimit : SETTINGS.imageLimit, + compactionModel: + "compactionModel" in patch + ? (patch as VisionSettings).compactionModel + : SETTINGS.compactionModel, + }; + return { ok: true, settings: next }; + }, + }; +} + +describe("VisionSettingsView", () => { + it("loads settings on mount and seeds the imageLimit input", async () => { + const load = fakeLoad({ imageLimit: 7, compactionModel: "kimi/k2" }); + render(VisionSettingsView, { + props: { + models: ["kimi/k2"], + modelInfo: { "kimi/k2": { vision: true } }, + load: load.impl, + save: fakeSaveOk().impl, + }, + }); + + await vi.waitFor(() => { + expect(screen.getByLabelText(/Image limit/)).toHaveValue("7"); + }); + // "Auto" is selected (compactionModel was kimi/k2 here actually) + expect(screen.getByLabelText(/Compaction model/)).toHaveValue("kimi/k2"); + }); + + it("disables Save until the imageLimit input differs", async () => { + const load = fakeLoad(); + const save = fakeSaveOk(); + const user = userEvent.setup(); + render(VisionSettingsView, { + props: { models: [], modelInfo: {}, load: load.impl, save: save.impl }, + }); + + await vi.waitFor(() => { + expect(screen.getByRole("button", { name: "Save" })).toBeDisabled(); + }); + + const input = screen.getByLabelText(/Image limit/); + await user.clear(input); + await user.type(input, "5"); + expect(screen.getByRole("button", { name: "Save" })).toBeEnabled(); + }); + + it("saves the imageLimit on click and confirms", async () => { + const load = fakeLoad(); + const save = fakeSaveOk(); + const user = userEvent.setup(); + render(VisionSettingsView, { + props: { models: [], modelInfo: {}, load: load.impl, save: save.impl }, + }); + + await vi.waitFor(() => { + expect(screen.getByLabelText(/Image limit/)).toHaveValue("10"); + }); + + const input = screen.getByLabelText(/Image limit/); + await user.clear(input); + await user.type(input, "3"); + await user.click(screen.getByRole("button", { name: "Save" })); + + await vi.waitFor(() => { + expect(save.patches).toEqual([{ imageLimit: 3 }]); + }); + expect(screen.getByText(/Saved/i)).toBeInTheDocument(); + }); + + it("shows an error for a non-numeric imageLimit on save", async () => { + const load = fakeLoad(); + const save = fakeSaveOk(); + const user = userEvent.setup(); + render(VisionSettingsView, { + props: { models: [], modelInfo: {}, load: load.impl, save: save.impl }, + }); + + await vi.waitFor(() => { + expect(screen.getByLabelText(/Image limit/)).toHaveValue("10"); + }); + + const input = screen.getByLabelText(/Image limit/); + await user.clear(input); + await user.type(input, "abc"); + // Save is disabled for invalid input, so no save fires; the error surfaces + // only on a submit attempt — but the button is disabled, so just assert that. + expect(screen.getByRole("button", { name: "Save" })).toBeDisabled(); + expect(save.patches).toEqual([]); + }); + + it("renders the compaction-model dropdown with Auto + vision-capable models", async () => { + const load = fakeLoad(); + render(VisionSettingsView, { + props: { + models: ["kimi/k2", "umans/glm-5.2", "kimi/k1.5"], + modelInfo: { + "kimi/k2": { vision: true }, + "kimi/k1.5": { vision: true }, + "umans/glm-5.2": { vision: false }, + }, + load: load.impl, + save: fakeSaveOk().impl, + }, + }); + + await vi.waitFor(() => { + expect(screen.getByLabelText(/Compaction model/)).toBeInTheDocument(); + }); + const select = screen.getByLabelText(/Compaction model/) as HTMLSelectElement; + const optionTexts = Array.from(select.options).map((o) => o.textContent ?? ""); + expect(optionTexts).toEqual(["Auto (server-selected)", "kimi/k2", "kimi/k1.5"]); + // Non-vision glm-5.2 is excluded. + expect(optionTexts.some((t) => t.includes("glm-5.2"))).toBe(false); + }); + + it("saves the compactionModel on change (Auto → a vision model)", async () => { + const load = fakeLoad({ imageLimit: 10, compactionModel: null }); + const save = fakeSaveOk(); + const user = userEvent.setup(); + render(VisionSettingsView, { + props: { + models: ["kimi/k2"], + modelInfo: { "kimi/k2": { vision: true } }, + load: load.impl, + save: save.impl, + }, + }); + + await vi.waitFor(() => { + expect(screen.getByLabelText(/Compaction model/)).toBeInTheDocument(); + }); + + await user.selectOptions(screen.getByLabelText(/Compaction model/), "kimi/k2"); + + await vi.waitFor(() => { + expect(save.patches).toEqual([{ compactionModel: "kimi/k2" }]); + }); + expect(screen.getByText(/Saved/i)).toBeInTheDocument(); + }); + + it("saves null (Auto) when the auto option is chosen", async () => { + const load = fakeLoad({ imageLimit: 10, compactionModel: "kimi/k2" }); + const save = fakeSaveOk(); + const user = userEvent.setup(); + render(VisionSettingsView, { + props: { + models: ["kimi/k2"], + modelInfo: { "kimi/k2": { vision: true } }, + load: load.impl, + save: save.impl, + }, + }); + + await vi.waitFor(() => { + expect(screen.getByLabelText(/Compaction model/)).toHaveValue("kimi/k2"); + }); + + await user.selectOptions(screen.getByLabelText(/Compaction model/), "__auto__"); + + await vi.waitFor(() => { + expect(save.patches).toEqual([{ compactionModel: null }]); + }); + }); + + it("surfaces a load error", async () => { + const load = vi.fn(async () => ({ ok: false, error: "vision unavailable" }) as const); + render(VisionSettingsView, { + props: { models: [], modelInfo: {}, load, save: fakeSaveOk().impl }, + }); + + await vi.waitFor(() => { + expect(screen.getByText("vision unavailable")).toBeInTheDocument(); + }); + }); + + it("surfaces a save error", async () => { + const load = fakeLoad(); + const save = vi.fn(async () => ({ ok: false, error: "boom" }) as const); + const user = userEvent.setup(); + render(VisionSettingsView, { + props: { models: [], modelInfo: {}, load: load.impl, save }, + }); + + await vi.waitFor(() => { + expect(screen.getByLabelText(/Image limit/)).toHaveValue("10"); + }); + + const input = screen.getByLabelText(/Image limit/); + await user.clear(input); + await user.type(input, "3"); + await user.click(screen.getByRole("button", { name: "Save" })); + + await vi.waitFor(() => { + expect(screen.getByText("boom")).toBeInTheDocument(); + }); + }); +}); |
