summaryrefslogtreecommitdiffhomepage
path: root/src/app
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-27 19:15:04 +0900
committerAdam Malczewski <[email protected]>2026-06-27 19:15:04 +0900
commitfa0bd9c0e433b1abddc814b48a358c94954c7d36 (patch)
treededf7adf827de6908b5c7cbb4b4153aba38c1882 /src/app
parent3566a20ebbded754070fce66af48690d1a904879 (diff)
downloaddispatch-web-fa0bd9c0e433b1abddc814b48a358c94954c7d36.tar.gz
dispatch-web-fa0bd9c0e433b1abddc814b48a358c94954c7d36.zip
feat(vision): consult_vision tool + vision settings API
Backend vision update (additive to [email protected] / [email protected], 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.
Diffstat (limited to 'src/app')
-rw-r--r--src/app/App.svelte38
-rw-r--r--src/app/store.svelte.ts77
-rw-r--r--src/app/store.test.ts124
3 files changed, 239 insertions, 0 deletions
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<LoadVisionSettingsResult> {
+ // 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<SaveVisionSettingsResult> {
+ 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"}
+ <!-- Global vision settings (image compaction). Not conversation-scoped (no {#key}). -->
+ <VisionSettingsView
+ models={store.models}
+ modelInfo={store.modelInfo}
+ load={loadVisionSettings}
+ save={saveVisionSettings}
+ />
{:else if kind === "system-prompt"}
<!-- Global system prompt template. Opens a full-page modal editor (half
template / half variable palette). Not conversation-scoped (no {#key}). -->
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 }
@@ -269,6 +280,23 @@ export interface AppStore {
*/
setCompactPercent(percent: number): Promise<CompactPercentResult | null>;
/**
+ * 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<void>;
+ /**
+ * Save a PARTIAL vision-settings update (`PUT /settings/vision`). Either
+ * field may be omitted. Returns the merged settings on success.
+ */
+ setVisionSettings(patch: VisionSettingsPatch): Promise<VisionSettingsResult | null>;
+ /**
* 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<number | null>(null);
+ // The GLOBAL vision settings (shared across all conversations). Seeded on
+ // boot; null = not yet fetched.
+ let visionSettings = $state<VisionSettings | null>(null);
+
+ /** Refetch the global vision settings (`GET /settings/vision`). */
+ async function refreshVisionSettings(): Promise<void> {
+ 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<void> {
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<void> {
+ await refreshVisionSettings();
+ },
get chatLimit(): number {
return chatLimit;
},
@@ -1500,6 +1551,32 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
}
},
+ async setVisionSettings(patch: VisionSettingsPatch): Promise<VisionSettingsResult | null> {
+ 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<ChatLimitResult> {
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<Response> => {
+ 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();
+ });
+});