feat: Ollama models command, with load/pull/delete and unload (#16)

This commit was merged in pull request #16.
This commit is contained in:
2026-10-05 09:43:00 +00:00
parent 05fd9ff13b
commit 26e05dea5b
22 changed files with 1948 additions and 65 deletions
+85 -6
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_SYSTEM_PROMPT, RECENCY_HINT } from "../lib/constants";
import { buildMessages, composeSystemPrompt } from "../lib/history";
import { buildMessages, composeSystemPrompt, describeRuntime, type RuntimeContext } from "../lib/history";
import type { ChatTurn } from "../lib/types";
function turn(prompt: string, response: string): ChatTurn {
@@ -52,18 +52,97 @@ describe("buildMessages", () => {
});
});
/** The setup a test is composing for: the user's own machine, which is the case that exposed the gaps. */
function context(overrides: Partial<RuntimeContext> = {}): RuntimeContext {
return {
model: "ornith-1.5:9b",
host: "http://localhost:11434",
selfHosted: true,
webTools: true,
vision: "yes",
...overrides,
};
}
describe("composeSystemPrompt", () => {
it("uses the default prompt when the user has not set one", () => {
expect(composeSystemPrompt("", false)).toBe(DEFAULT_SYSTEM_PROMPT);
expect(composeSystemPrompt("", context())).toContain(DEFAULT_SYSTEM_PROMPT);
});
it("prefers the user's prompt when set", () => {
expect(composeSystemPrompt("Be terse.", false)).toBe("Be terse.");
it("prefers the user's prompt when set, and still says what the model is", () => {
// The runtime facts are not a matter of style, so a custom prompt must not
// drop them. This is the case that would otherwise silently lose them.
const composed = composeSystemPrompt("Be terse.", context());
expect(composed).toContain("Be terse.");
expect(composed).not.toContain(DEFAULT_SYSTEM_PROMPT);
expect(composed).toContain("You are Lam");
});
it("appends the recency hint only when web tools are on", () => {
expect(composeSystemPrompt("Be terse.", true)).toBe(`Be terse.\n\n${RECENCY_HINT}`);
expect(composeSystemPrompt("Be terse.", false)).not.toContain(RECENCY_HINT);
expect(composeSystemPrompt("Be terse.", context({ webTools: true }))).toContain(RECENCY_HINT);
expect(composeSystemPrompt("Be terse.", context({ webTools: false }))).not.toContain(RECENCY_HINT);
});
});
describe("describeRuntime", () => {
it("names the model and the server for a local setup", () => {
const text = describeRuntime(context());
expect(text).toContain("`ornith-1.5:9b`");
expect(text).toContain("http://localhost:11434");
expect(text).toContain("reachable on this machine");
});
it("says Ollama Cloud when that is the host", () => {
const text = describeRuntime(
context({ host: "https://ollama.com", selfHosted: false, model: "gpt-oss:120b" }),
);
expect(text).toContain("Ollama Cloud");
expect(text).not.toContain("reachable on this machine");
});
it("explains the :cloud convention only where it applies", () => {
// A local daemon proxying a cloud model is the case where a model that
// assumed it was local would tell the user their conversation stays put.
expect(describeRuntime(context({ selfHosted: true }))).toContain("`:cloud`");
expect(describeRuntime(context({ selfHosted: false }))).not.toContain("leaves this machine");
});
it("tells it to say so when there are no web tools, rather than offering", () => {
expect(describeRuntime(context({ webTools: true }))).toContain("You have `web_search`");
expect(describeRuntime(context({ webTools: false }))).toContain("no web tools in this session");
});
it("uses the confirmed answer about images when there is one", () => {
expect(describeRuntime(context({ vision: "yes" }))).toContain("You can read images");
expect(describeRuntime(context({ vision: "no" }))).toContain("You cannot read images");
});
it("tells a model whose eyes are unconfirmed to describe what it sees rather than reason about it", () => {
// The reported failure: handed a screenshot and an unconfirmed answer, the
// model wrote two paragraphs about whether it could see instead of looking.
const text = describeRuntime(context({ vision: "unknown" }));
expect(text).toContain("has not been confirmed");
expect(text).toContain("describe what you see");
});
it("always denies access it does not have", () => {
const text = describeRuntime(context());
expect(text).toContain("You see only what the user types or attaches");
expect(text).toContain("cannot read their screen, files, clipboard");
});
it("never claims the model is Vicinae, which was the actual confusion", () => {
// The reported answer: "Vicinae is a desktop launcher for Ollama". Vicinae is
// the launcher; Ollama is what serves the model, and they are not the same.
const text = describeRuntime(context());
expect(text).toContain("running inside the Vicinae launcher");
expect(text).not.toMatch(/You are Vicinae/);
});
});
+450 -1
View File
@@ -1,5 +1,19 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { modelCapabilities, normalizeHost, parseNdjsonStream, supportsVision } from "../lib/ollama";
import {
deleteModel,
describePullProgress,
isSelfHosted,
listModels,
listRunningModels,
modelCapabilities,
normalizeHost,
parseNdjsonStream,
pullModel,
supportsVision,
unloadModel,
webFetch,
webSearch,
} from "../lib/ollama";
/**
* The NDJSON reader is the part of the client most likely to break in a way
@@ -151,3 +165,438 @@ describe("modelCapabilities", () => {
expect(fetchMock.mock.calls[0][0]).toBe("http://localhost:11434/api/show");
});
});
describe("isSelfHosted", () => {
it("is true for a server the user runs", () => {
expect(isSelfHosted("http://localhost:11434")).toBe(true);
expect(isSelfHosted("https://box.local:11434")).toBe(true);
expect(isSelfHosted("http://192.168.1.10:11434/")).toBe(true);
});
it("is false for Ollama Cloud, which has nothing to pull or delete", () => {
expect(isSelfHosted("https://ollama.com")).toBe(false);
expect(isSelfHosted("https://ollama.com/")).toBe(false);
expect(isSelfHosted("https://api.ollama.com")).toBe(false);
expect(isSelfHosted(undefined)).toBe(false);
});
it("treats an unparseable host as local, because every request fails on its own terms anyway", () => {
expect(isSelfHosted("not a url")).toBe(true);
});
it("goes by hostname, so a local server behind https still counts", () => {
expect(isSelfHosted("https://gpu-box.internal:443")).toBe(true);
});
});
describe("listModels", () => {
const fetchMock = vi.fn();
beforeEach(() => {
fetchMock.mockReset();
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
/** Trimmed from a real `GET /api/tags` on Ollama 0.35.1, mixed local and proxied. */
const realPayload = {
models: [
{
name: "ornith-1.5:9b",
model: "ornith-1.5:9b",
size: 6550813657,
details: { parameter_size: "9.0B" },
capabilities: ["tools", "thinking", "completion", "vision"],
},
{
name: "deepseek-v4.1-flash:cloud",
model: "deepseek-v4.1-flash:cloud",
remote_model: "deepseek-v4.1-flash",
remote_host: "https://ollama.com",
size: 326,
details: { parameter_size: "763B" },
capabilities: ["completion", "thinking", "tools", "vision"],
},
],
};
it("reads the size, capabilities and remote host off each entry", async () => {
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify(realPayload), { status: 200 }));
const models = await listModels({ host: "http://localhost:11434", apiKey: "k" });
expect(models).toEqual([
{
name: "deepseek-v4.1-flash:cloud",
parameterSize: "763B",
size: 326,
capabilities: ["completion", "thinking", "tools", "vision"],
remoteHost: "https://ollama.com",
},
{
name: "ornith-1.5:9b",
parameterSize: "9.0B",
size: 6550813657,
capabilities: ["tools", "thinking", "completion", "vision"],
remoteHost: undefined,
},
]);
});
it("leaves capabilities absent rather than empty when the server does not send them", async () => {
// An absent list means "unknown", and a row that read it as [] would claim a
// model cannot see images when the server simply said nothing.
fetchMock.mockResolvedValueOnce(
new Response(JSON.stringify({ models: [{ name: "old-model:1b" }] }), { status: 200 }),
);
const [model] = await listModels({ host: "http://localhost:11434", apiKey: "k" });
expect(model.capabilities).toBeUndefined();
expect(model.size).toBeUndefined();
expect(model.remoteHost).toBeUndefined();
});
it("falls back to the model field and drops nameless entries", async () => {
fetchMock.mockResolvedValueOnce(
new Response(JSON.stringify({ models: [{ model: "from-model-field" }, { details: {} }] }), {
status: 200,
}),
);
await expect(listModels({ host: "http://localhost:11434", apiKey: "k" })).resolves.toEqual([
{ name: "from-model-field", parameterSize: undefined, size: undefined, capabilities: undefined, remoteHost: undefined },
]);
});
it("surfaces a rejected key instead of an empty list", async () => {
fetchMock.mockResolvedValueOnce(new Response("unauthorized", { status: 401 }));
await expect(listModels({ host: "https://ollama.com", apiKey: "k" })).rejects.toThrow(
/rejected the API key/,
);
});
});
describe("deleteModel", () => {
const fetchMock = vi.fn();
beforeEach(() => {
fetchMock.mockReset();
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("sends DELETE, because Ollama answers the POST form of this route with 405", async () => {
fetchMock.mockResolvedValueOnce(new Response("", { status: 200 }));
await deleteModel({ host: "http://localhost:11434", apiKey: "k", model: "llama3.2:3b" });
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe("http://localhost:11434/api/delete");
expect(init.method).toBe("DELETE");
expect(JSON.parse(init.body)).toEqual({ model: "llama3.2:3b" });
});
it("says the model is not there rather than swallowing a 404 into a success", async () => {
// Reported as a failure on purpose: for a delete, "no model called that"
// usually means something else removed it, or the host is not the one the
// user thinks it is.
fetchMock.mockResolvedValueOnce(
new Response(JSON.stringify({ error: "model 'x:1' not found" }), { status: 404 }),
);
await expect(
deleteModel({ host: "http://localhost:11434", apiKey: "k", model: "x:1" }),
).rejects.toThrow(/has no model called x:1/);
});
});
describe("pullModel", () => {
const fetchMock = vi.fn();
beforeEach(() => {
fetchMock.mockReset();
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
function ndjson(events: unknown[]): Response {
const body = `${events.map((event) => JSON.stringify(event)).join("\n")}\n`;
return new Response(body, { status: 200, headers: { "Content-Type": "application/x-ndjson" } });
}
async function collectPull(): Promise<{ status: string; total?: number; completed?: number }[]> {
const out: { status: string; total?: number; completed?: number }[] = [];
for await (const progress of pullModel({ host: "http://localhost:11434", apiKey: "k", model: "qwen3:8b" })) {
out.push(progress);
}
return out;
}
it("streams progress as it arrives", async () => {
fetchMock.mockResolvedValueOnce(
ndjson([
{ status: "pulling manifest" },
{ status: "downloading", total: 1000, completed: 250 },
{ status: "downloading", total: 1000, completed: 1000 },
{ status: "success" },
]),
);
await expect(collectPull()).resolves.toEqual([
{ status: "pulling manifest", total: undefined, completed: undefined },
{ status: "downloading", total: 1000, completed: 250 },
{ status: "downloading", total: 1000, completed: 1000 },
{ status: "success", total: undefined, completed: undefined },
]);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe("http://localhost:11434/api/pull");
expect(JSON.parse(init.body)).toEqual({ model: "qwen3:8b", stream: true });
});
it("throws on the error field, which arrives with HTTP 200 rather than a status", async () => {
// Measured against 0.35.1: a bad model name answers 200 and puts the failure
// in the body, so a check of the status line alone reports a clean pull.
fetchMock.mockResolvedValueOnce(
ndjson([{ status: "pulling manifest" }, { error: "pull model manifest: file does not exist" }]),
);
await expect(collectPull()).rejects.toThrow(/file does not exist/);
});
it("throws a plain message when the server refuses outright", async () => {
fetchMock.mockResolvedValueOnce(new Response("nope", { status: 500 }));
await expect(collectPull()).rejects.toThrow(/HTTP 500/);
});
});
describe("describePullProgress", () => {
it("passes a status through when there is nothing to measure yet", () => {
expect(describePullProgress({ status: "pulling manifest" })).toBe("pulling manifest");
expect(describePullProgress({ status: "verifying sha256 digest" })).toBe("verifying sha256 digest");
});
it("adds a percentage and the transferred size once a total is known", () => {
expect(describePullProgress({ status: "downloading", total: 1_000, completed: 420 })).toBe(
"downloading · 42% · 420 B / 1.0 kB",
);
});
it("does not divide by a total of zero", () => {
// A tick can arrive with the total not yet filled in, and printing
// "Infinity%" at the user is the kind of thing that only shows up in the wild.
expect(describePullProgress({ status: "downloading", total: 0, completed: 10 })).toBe("downloading");
expect(describePullProgress({ status: "downloading", total: 100, completed: Number.NaN })).toBe(
"downloading",
);
});
it("never reports more than complete", () => {
expect(describePullProgress({ status: "downloading", total: 100, completed: 140 })).toContain("100%");
});
});
describe("web tool paths", () => {
const fetchMock = vi.fn();
beforeEach(() => {
fetchMock.mockReset();
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
function json(payload: unknown, status = 200): Response {
return new Response(JSON.stringify(payload), { status });
}
it("uses the plain path on Ollama Cloud", async () => {
fetchMock.mockResolvedValueOnce(json({ results: [] }));
await webSearch({ host: "https://ollama.com", apiKey: "k", query: "q" });
expect(fetchMock.mock.calls[0][0]).toBe("https://ollama.com/api/web_search");
});
it("uses the experimental path on a local server, which is the one it serves", async () => {
// The reported bug: this used to post to /api/web_search on every host, so
// pointing the extension at a local server made every web call a 404 and the
// model reported that its web tools were not configured.
fetchMock.mockResolvedValueOnce(json({ results: [] }));
await webSearch({ host: "http://localhost:11434", apiKey: "k", query: "q" });
expect(fetchMock.mock.calls[0][0]).toBe("http://localhost:11434/api/experimental/web_search");
});
it("does the same for web_fetch", async () => {
fetchMock.mockResolvedValueOnce(json({ title: "T", content: "C", links: null }));
await expect(
webFetch({ host: "http://localhost:11434", apiKey: "k", url: "https://example.com" }),
).resolves.toEqual({ title: "T", content: "C", links: [] });
expect(fetchMock.mock.calls[0][0]).toBe("http://localhost:11434/api/experimental/web_fetch");
});
it("falls back to the other path when the preferred one is missing", async () => {
// A server built the other way round, or a future rename: a 404 means the
// route is absent, so trying the other one cannot double-send anything.
fetchMock
.mockResolvedValueOnce(new Response("404 page not found", { status: 404 }))
.mockResolvedValueOnce(json({ results: [{ title: "t", url: "u", content: "c" }] }));
await expect(webSearch({ host: "http://localhost:11434", apiKey: "k", query: "q" })).resolves.toHaveLength(
1,
);
expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([
"http://localhost:11434/api/experimental/web_search",
"http://localhost:11434/api/web_search",
]);
});
it("does not fall back on anything other than a 404", async () => {
// A 401 or a 500 is the server answering, and retrying the other path would
// just send the same refused request twice.
fetchMock.mockResolvedValueOnce(new Response("unauthorized", { status: 401 }));
await expect(webSearch({ host: "https://ollama.com", apiKey: "k", query: "q" })).rejects.toThrow(
/rejected the API key/,
);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("reports the failure from the path it actually tried last", async () => {
fetchMock
.mockResolvedValueOnce(new Response("404 page not found", { status: 404 }))
.mockResolvedValueOnce(new Response("404 page not found", { status: 404 }));
await expect(webSearch({ host: "http://localhost:11434", apiKey: "k", query: "q" })).rejects.toThrow(
/404/,
);
});
});
describe("listRunningModels", () => {
const fetchMock = vi.fn();
beforeEach(() => {
fetchMock.mockReset();
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
/** Trimmed from a real `GET /api/ps` on Ollama 0.35.1 with one model in VRAM. */
const realPayload = {
models: [
{
name: "ornith-1.5:9b",
model: "ornith-1.5:9b",
size: 10413733314,
size_vram: 10413733314,
expires_at: "2026-10-05T17:44:11.125356+08:00",
context_length: 131072,
details: { parameter_size: "9.0B" },
},
],
};
it("reads what is in memory, including when it will go on its own", async () => {
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify(realPayload), { status: 200 }));
const running = await listRunningModels({ host: "http://localhost:11434", apiKey: "k" });
expect(running).toEqual([
{
name: "ornith-1.5:9b",
parameterSize: "9.0B",
sizeVram: 10413733314,
expiresAt: Date.parse("2026-10-05T17:44:11.125356+08:00"),
},
]);
expect(fetchMock.mock.calls[0][0]).toBe("http://localhost:11434/api/ps");
});
it("is empty, not an error, when the server does not serve /api/ps", async () => {
// Older builds answer 404. Taking the whole model list down because the
// *running* list is unavailable would be a bad trade.
fetchMock.mockResolvedValueOnce(new Response("404 page not found", { status: 404 }));
await expect(listRunningModels({ host: "http://localhost:11434", apiKey: "k" })).resolves.toEqual([]);
});
it("is empty when the request fails outright", async () => {
fetchMock.mockRejectedValueOnce(new Error("connection refused"));
await expect(listRunningModels({ host: "http://localhost:11434", apiKey: "k" })).resolves.toEqual([]);
});
it("copes with a model holding no VRAM figure, as a CPU-offloaded one would", async () => {
fetchMock.mockResolvedValueOnce(
new Response(JSON.stringify({ models: [{ name: "cpu-model:1b", size_vram: 0 }] }), { status: 200 }),
);
await expect(listRunningModels({ host: "http://localhost:11434", apiKey: "k" })).resolves.toEqual([
{ name: "cpu-model:1b", parameterSize: undefined, sizeVram: 0, expiresAt: undefined },
]);
});
});
describe("unloadModel", () => {
const fetchMock = vi.fn();
beforeEach(() => {
fetchMock.mockReset();
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("posts keep_alive 0 with no prompt, which is how Ollama is told to release a model", async () => {
fetchMock.mockResolvedValueOnce(
new Response(JSON.stringify({ done: true, done_reason: "unload" }), { status: 200 }),
);
await unloadModel({ host: "http://localhost:11434", apiKey: "k", model: "ornith-1.5:9b" });
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe("http://localhost:11434/api/generate");
expect(init.method).toBe("POST");
// No `prompt` key at all, so this can never generate a completion.
expect(JSON.parse(init.body)).toEqual({
model: "ornith-1.5:9b",
keep_alive: 0,
stream: false,
});
});
it("surfaces a refusal rather than pretending the memory came back", async () => {
fetchMock.mockResolvedValueOnce(new Response("nope", { status: 500 }));
await expect(
unloadModel({ host: "http://localhost:11434", apiKey: "k", model: "x:1" }),
).rejects.toThrow(/HTTP 500/);
});
});
+94 -1
View File
@@ -1,13 +1,18 @@
import { describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { __resetLocalStorage, __setPreferenceValues } from "../__mocks__/@vicinae/api";
import { DEFAULT_HOST, DEFAULT_MAX_TOOL_ROUNDS, DEFAULT_MODEL } from "../lib/constants";
import {
clearModelSelection,
describeAutoDelete,
describeMaxToolRounds,
describeResumeTimeout,
resolveAutoDelete,
resolveMaxToolRounds,
resolvePreferences,
loadPreferences,
readModelSelection,
resolveResumeTimeout,
selectModel,
} from "../lib/preferences";
const MINUTE = 60_000;
@@ -203,3 +208,91 @@ describe("describeMaxToolRounds", () => {
expect(describeMaxToolRounds(1)).toBe("1 round per answer");
});
});
describe("the model selection", () => {
beforeEach(() => {
__resetLocalStorage();
__setPreferenceValues({ apiKey: "k", model: "from-preference:1b" });
});
afterEach(() => {
__setPreferenceValues({});
});
it("uses the preference when nothing has been picked", async () => {
const resolved = await loadPreferences();
expect(resolved.model).toBe("from-preference:1b");
expect(resolved.modelSource).toBe("preference");
expect(resolved.preferenceModel).toBe("from-preference:1b");
});
it("prefers a picked model over the preference, and says so", async () => {
// The whole reason this exists: an extension cannot write its own
// preferences, so the pick has to live somewhere else and win.
await selectModel("picked:9b");
const resolved = await loadPreferences();
expect(resolved.model).toBe("picked:9b");
expect(resolved.modelSource).toBe("selection");
// Both values are carried, so a screen can answer "what would a reset give
// me back".
expect(resolved.preferenceModel).toBe("from-preference:1b");
});
it("round-trips a pick through storage", async () => {
await selectModel("first:1b");
await expect(readModelSelection()).resolves.toBe("first:1b");
await selectModel("second:2b");
await expect(readModelSelection()).resolves.toBe("second:2b");
});
it("hands the model back to the preference when the selection is cleared", async () => {
await selectModel("picked:9b");
await clearModelSelection();
const resolved = await loadPreferences();
expect(resolved.model).toBe("from-preference:1b");
expect(resolved.modelSource).toBe("preference");
});
it("ignores a stored selection that is empty or whitespace", async () => {
// A blank value is not a model name, and treating it as one would leave the
// chat asking for "" on every turn.
await selectModel(" ");
await expect(readModelSelection()).resolves.toBeUndefined();
await expect(loadPreferences()).resolves.toMatchObject({ model: "from-preference:1b" });
});
it("trims a stored selection", async () => {
await selectModel(" spaced:1b ");
await expect(readModelSelection()).resolves.toBe("spaced:1b");
});
it("falls back to the preference when storage cannot be read", async () => {
const { LocalStorage } = await import("../__mocks__/@vicinae/api");
const original = LocalStorage.getItem;
LocalStorage.getItem = async () => {
throw new Error("storage is unavailable");
};
try {
await expect(loadPreferences()).resolves.toMatchObject({ model: "from-preference:1b" });
} finally {
LocalStorage.getItem = original;
}
});
it("leaves the model source as the preference when resolving without storage", () => {
expect(resolvePreferences({ model: "plain:1b" })).toMatchObject({
model: "plain:1b",
modelSource: "preference",
preferenceModel: "plain:1b",
});
});
});
+33
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
errorMessage,
formatBytes,
formatRelativeTime,
recencyGroup,
titleFromPrompt,
@@ -98,3 +99,35 @@ describe("errorMessage", () => {
expect(typeof errorMessage(circular)).toBe("string");
});
});
describe("formatBytes", () => {
it("matches what `ollama list` prints for the same bytes", () => {
// Captured from this machine: `ollama list` shows ornith-1.5:9b as 6.6 GB,
// and a size that disagreed with the terminal would read as our bug.
expect(formatBytes(6_550_813_657)).toBe("6.6 GB");
});
it("keeps small counts in bytes", () => {
expect(formatBytes(0)).toBe("0 B");
expect(formatBytes(326)).toBe("326 B");
expect(formatBytes(999)).toBe("999 B");
});
it("steps up one unit at a time", () => {
expect(formatBytes(1_500)).toBe("1.5 kB");
expect(formatBytes(2_500_000)).toBe("2.5 MB");
expect(formatBytes(3_000_000_000_000)).toBe("3.0 TB");
});
it("stops at terabytes rather than inventing a unit", () => {
expect(formatBytes(9_000_000_000_000_000)).toBe("9000.0 TB");
});
it("has no answer for a size that is absent or nonsense", () => {
// undefined rather than "NaN B", so a caller leaves the field out.
expect(formatBytes(undefined)).toBeUndefined();
expect(formatBytes(-1)).toBeUndefined();
expect(formatBytes(Number.NaN)).toBeUndefined();
expect(formatBytes(Number.POSITIVE_INFINITY)).toBeUndefined();
});
});
+40 -7
View File
@@ -3,9 +3,10 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { readAttachment } from "../attachments";
import { runAutoDelete } from "../cleanup";
import { useChat, type ToolActivity } from "../hooks/useChat";
import { usePreferences } from "../hooks/usePreferences";
import { useVisionSupport } from "../hooks/useVision";
import { buildThreadMarkdown, renderAttachments, type TranscriptOptions } from "../markdown";
import { getLamPreferences } from "../preferences";
import type { ResolvedPreferences } from "../preferences";
import { deleteSession, listSessions, loadSession, summarizeSession } from "../sessions";
import { resolveStartup } from "../startup";
import { buildThread } from "../thread";
@@ -94,17 +95,49 @@ interface SessionRow {
isActive: boolean;
}
export function ChatView({
/** Shown while the conversation and the preferences are still being read. */
function LoadingChat() {
return <List isLoading navigationTitle="Lam" searchBarPlaceholder="Loading conversations…" />;
}
/**
* The chat surface, once its preferences are known.
*
* Split in two so that {@link ChatView} can wait for the model before the state
* machine starts. `useChat` is created with the preferences it will use, so
* handing it preferences that are still loading would pin the conversation to
* whatever the preference said before the selection arrived.
*
* The outer half does nothing but that wait, and the loading state costs one
* LocalStorage read, which is a few milliseconds while the user is still moving
* their hands towards the keyboard.
*/
export function ChatView(props: ChatViewProps) {
const { preferences } = usePreferences();
if (!preferences) return <LoadingChat />;
return <ChatSession {...props} preferences={preferences} />;
}
interface ChatSessionProps extends ChatViewProps {
preferences: ResolvedPreferences;
}
function ChatSession({
preferences,
initialSession,
initialPrompt,
initialSessionId,
initialAttachmentPath,
launchNote,
}: ChatViewProps) {
const preferences = useMemo(() => getLamPreferences(), []);
const chat = useChat({ preferences, initialSession });
}: ChatSessionProps) {
// The capability check is resolved before the chat exists, because its answer
// is part of the system prompt: a model that knows it can read images describes
// the one it is sent, and one that does not know reasons about itself instead.
const vision = useVisionSupport(preferences);
const chat = useChat({ preferences, initialSession, vision: vision.support });
const { replaceSession } = chat;
const { canSeeImages } = useVisionSupport(preferences);
const { canSeeImages } = vision;
const { activity } = chat;
const [input, setInput] = useState(initialPrompt ?? "");
@@ -396,7 +429,7 @@ export function ChatView({
};
if (startup === "loading" || summaries === undefined) {
return <List isLoading navigationTitle="Lam" searchBarPlaceholder="Loading conversations…" />;
return <LoadingChat />;
}
const placeholder = chat.status ? `${chat.status}. Press ⏎ to stop` : "Lam is thinking. Press ⏎ to stop";
+19
View File
@@ -3,10 +3,29 @@
export const DEFAULT_HOST = "https://ollama.com";
export const DEFAULT_MODEL = "gpt-oss:120b";
/**
* Hosts that run the models themselves rather than handing them to a server.
*
* Only these can be managed: `pull` and `delete` are operations on a machine the
* user controls, and against Ollama Cloud there is nothing to pull or remove.
*/
export const CLOUD_HOSTNAMES = new Set(["ollama.com", "api.ollama.com"]);
/** LocalStorage keys. The index is small; each session body gets its own key so saving one never rewrites the others. */
export const STORAGE_INDEX_KEY = "lam.sessions.index";
export const STORAGE_SESSION_PREFIX = "lam.session.";
/**
* The model picked in the Models command.
*
* Separate from the `model` preference because an extension cannot write its own
* preferences: Vicinae reads them once at startup and holds them in memory, so
* writing the config file has no effect until it restarts (verified against
* 0.29.1). A selection in LocalStorage takes effect immediately instead, which is
* the whole point of picking a model from a list.
*/
export const STORAGE_MODEL_KEY = "lam.model.selected";
/**
* How many rounds of tool calls one turn may spend before the model is told to
* answer with what it has.
+78 -4
View File
@@ -1,5 +1,5 @@
import { DEFAULT_SYSTEM_PROMPT, RECENCY_HINT } from "./constants";
import type { ApiMessage, ChatTurn } from "./types";
import type { ApiMessage, ChatTurn, VisionSupport } from "./types";
/**
* Turns stored turns into the message array sent to the model.
@@ -34,9 +34,83 @@ export function buildMessages(
return messages;
}
/** Uses the user's prompt if they set one, otherwise Lam's default. */
export function composeSystemPrompt(customPrompt: string, webEnabled: boolean): string {
/**
* What Lam knows about the setup it is running in.
*
* Gathered from preferences and the capability check rather than asked for, so
* the model can answer questions about itself instead of guessing. The guesses
* were the problem: with none of this in the prompt, a local model described
* Vicinae as "a desktop launcher for Ollama", reported its own web tools as not
* configured, and reasoned out loud about whether it could see an image it had
* just been sent.
*/
export interface RuntimeContext {
model: string;
host: string;
/** True when the host is a server the user runs rather than ollama.com. */
selfHosted: boolean;
webTools: boolean;
vision: VisionSupport;
}
/**
* The paragraph that tells the model what it is and what it can do.
*
* Kept factual and short. Every claim here is one the extension can stand
* behind, because a model that is told something wrong about itself will state
* it confidently, and the user has no way to tell that apart from an answer.
*
* The `:cloud` convention is stated as a convention rather than as a lookup. The
* authoritative field is `remote_host` on `GET /api/tags`, which the Models
* command reads and shows as a `cloud` tag; fetching it here would mean a second
* request on every model change for one sentence. The convention is Ollama's own
* and holds on this machine, where both `:cloud` entries report
* `remote_host: https://ollama.com` and the local model reports nothing.
*/
export function describeRuntime(context: RuntimeContext): string {
const served = context.selfHosted
? `the Ollama server at ${context.host}, which is reachable on this machine`
: "Ollama Cloud";
const lines = [
"What you are, for questions about your own setup. Do not speculate beyond this:",
`- You are Lam, running inside the Vicinae launcher on the user's desktop, and answering as \`${context.model}\` served by ${served}.`,
"- You see only what the user types or attaches. You cannot read their screen, files, clipboard, calendar or anything else on the machine, and you cannot act on it.",
];
if (context.selfHosted) {
lines.push(
"- A model name ending in `:cloud` is proxied by that server to ollama.com, so the conversation leaves this machine when it is used. Anything else is running locally.",
);
}
lines.push(
context.webTools
? "- You have `web_search` and `web_fetch`. Use them rather than guessing about anything recent, and say so if they fail."
: "- You have no web tools in this session. Say that plainly rather than offering to look something up.",
);
lines.push(
context.vision === "yes"
? "- You can read images. A screenshot attached to a question is yours to describe, so describe it."
: context.vision === "no"
? "- You cannot read images. If a question depends on one, say so plainly."
: "- Whether you can read images has not been confirmed. If one is attached with a question, describe what you see rather than reasoning about whether you are able to.",
);
return lines.join("\n");
}
/**
* Uses the user's prompt if they set one, otherwise Lam's default.
*
* The runtime paragraph is appended either way. It is not a matter of style, so
* replacing the default prompt should not silently remove it, and a user writing
* their own prompt has even less reason to expect the model to know what it is.
*/
export function composeSystemPrompt(customPrompt: string, context: RuntimeContext): string {
const parts = [customPrompt.trim() || DEFAULT_SYSTEM_PROMPT];
if (webEnabled) parts.push(RECENCY_HINT);
if (context.webTools) parts.push(RECENCY_HINT);
parts.push(describeRuntime(context));
return parts.join("\n\n");
}
+25 -2
View File
@@ -3,11 +3,12 @@ import { runAgent } from "../agent";
import { toStoredAttachment } from "../attachments";
import { ipPlace } from "../geo";
import { buildMessages, composeSystemPrompt } from "../history";
import { isSelfHosted } from "../ollama";
import { OllamaError } from "../ollama";
import type { ResolvedPreferences } from "../preferences";
import { createSession, createTurn, saveSession, withTitleFromFirstTurn } from "../sessions";
import { toolsFor } from "../tools";
import type { PendingAttachment, Session } from "../types";
import type { PendingAttachment, Session, VisionSupport } from "../types";
import { errorMessage, isAbortError } from "../utils";
/**
@@ -33,6 +34,14 @@ export interface UseChatOptions {
preferences: ResolvedPreferences;
/** Restores an existing conversation, e.g. when reopening from the sessions command. */
initialSession?: Session;
/**
* Whether the model can read images, for the system prompt.
*
* Passed in rather than checked here because the answer is already known to the
* view: the snip action is gated on the same value, and asking twice would mean
* two capability requests per model change.
*/
vision?: VisionSupport;
}
/** Which tool is running right now, and how stale it is getting. */
@@ -85,6 +94,11 @@ export function useChat(options: UseChatOptions): UseChat {
const sessionRef = useRef(session);
const preferencesRef = useRef(options.preferences);
/**
* Read through a ref for the same reason as the preferences: `ask` is a stable
* callback, and the capability check lands after the first render.
*/
const visionRef = useRef<VisionSupport>(options.vision ?? "unknown");
const abortRef = useRef<AbortController | null>(null);
const liveRef = useRef(false);
const responseRef = useRef("");
@@ -93,6 +107,9 @@ export function useChat(options: UseChatOptions): UseChat {
const flushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
preferencesRef.current = options.preferences;
// Only ever forwards. The check starts as "unknown", and a value arriving is
// news; going back to "unknown" would be the view telling us less than before.
if (options.vision) visionRef.current = options.vision;
/** Updates state and the ref together, so `ask` never reads a stale session. */
const commit = useCallback((next: Session) => {
@@ -181,7 +198,13 @@ export function useChat(options: UseChatOptions): UseChat {
abortRef.current = controller;
const messages = buildMessages(
composeSystemPrompt(preferences.systemPrompt, preferences.webEnabled),
composeSystemPrompt(preferences.systemPrompt, {
model: preferences.model,
host: preferences.host,
selfHosted: isSelfHosted(preferences.host),
webTools: preferences.webEnabled,
vision: visionRef.current,
}),
base.turns,
prompt,
attachment ? [attachment.base64] : undefined,
+42
View File
@@ -0,0 +1,42 @@
import { useCallback, useEffect, useState } from "react";
import { loadPreferences, type ResolvedPreferences } from "../preferences";
/**
* The preferences a view should use, or `undefined` until they have loaded.
*
* Every reader goes through `loadPreferences`, which layers the model chosen in
* the Models command on top of what Vicinae holds, and that read is asynchronous
* because the selection lives in LocalStorage. A hook rather than a synchronous
* call so that the one reader is also the only way to get preferences into a
* component, and a view that forgets to handle the loading case fails to compile
* rather than quietly using a stale model.
*
* `loadPreferences` never rejects (an unreadable selection means no selection),
* so there is no error state to render here.
*/
export interface PreferencesState {
preferences?: ResolvedPreferences;
/** Re-reads them, for a view that has just changed the selection itself. */
reload: () => void;
}
export function usePreferences(): PreferencesState {
const [preferences, setPreferences] = useState<ResolvedPreferences | undefined>(undefined);
const [attempt, setAttempt] = useState(0);
useEffect(() => {
let cancelled = false;
void loadPreferences().then((loaded) => {
if (!cancelled) setPreferences(loaded);
});
return () => {
cancelled = true;
};
}, [attempt]);
const reload = useCallback(() => setAttempt((count) => count + 1), []);
return { preferences, reload };
}
+16 -3
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from "react";
import type { ResolvedPreferences } from "../preferences";
import type { VisionSupport } from "../types";
import { visionSupport } from "../vision";
/**
@@ -17,12 +18,22 @@ import { visionSupport } from "../vision";
export interface VisionState {
/** True only once Ollama has confirmed the model reports the `vision` capability. */
canSeeImages: boolean;
/**
* The same answer without the fail-closed collapse.
*
* Carried because the system prompt needs the difference: telling a model it
* cannot see when the check merely failed is a false claim about the user's
* setup, and telling it nothing is what made it reason out loud about its own
* capability instead of describing the image.
*/
support: VisionSupport;
/** True while an answer for this model is still outstanding. */
isChecking: boolean;
}
export function useVisionSupport(preferences: ResolvedPreferences): VisionState {
const [canSeeImages, setCanSeeImages] = useState(false);
const [support, setSupport] = useState<VisionSupport>("unknown");
const [isChecking, setIsChecking] = useState(true);
useEffect(() => {
@@ -37,8 +48,10 @@ export function useVisionSupport(preferences: ResolvedPreferences): VisionState
model: preferences.model,
signal: controller.signal,
})
.then((support) => {
if (!cancelled) setCanSeeImages(support === "yes");
.then((answer) => {
if (cancelled) return;
setSupport(answer);
setCanSeeImages(answer === "yes");
})
.finally(() => {
if (!cancelled) setIsChecking(false);
@@ -50,5 +63,5 @@ export function useVisionSupport(preferences: ResolvedPreferences): VisionState
};
}, [preferences.host, preferences.apiKey, preferences.model]);
return { canSeeImages, isChecking };
return { canSeeImages, support, isChecking };
}
+328 -18
View File
@@ -1,4 +1,5 @@
import { DEFAULT_HOST } from "./constants";
import { CLOUD_HOSTNAMES, DEFAULT_HOST } from "./constants";
import { formatBytes } from "./utils";
import type { ApiMessage, ModelInfo, ToolCall, ToolDefinition } from "./types";
/**
@@ -33,6 +34,23 @@ function authHeaders(apiKey: string): Record<string, string> {
return key ? { Authorization: `Bearer ${key}` } : {};
}
/**
* True when the host is a server whose models can be pulled and deleted.
*
* Decided from the hostname rather than the scheme or the port: a local server
* reached over https is still local, and Ollama Cloud is the one host that
* cannot be managed this way. A host that will not parse is treated as local,
* because every request against it fails on its own terms anyway, and the caller
* hides nothing it might have needed.
*/
export function isSelfHosted(host: string | undefined): boolean {
try {
return !CLOUD_HOSTNAMES.has(new URL(normalizeHost(host)).hostname);
} catch {
return true;
}
}
async function safeText(response: Response): Promise<string> {
try {
return await response.text();
@@ -54,7 +72,7 @@ function describeHttpError(status: number, bodyText: string, host: string): Olla
case 404:
return new OllamaError(`Ollama returned 404 from ${host}.`, {
status,
hint: "The model name may be wrong, or this host may not expose that endpoint. Local Ollama servers do not provide the web tools.",
hint: "The model name may be wrong, or this host may not expose that endpoint.",
});
case 429:
return new OllamaError("Ollama is rate limiting this key.", {
@@ -193,7 +211,14 @@ export interface ListModelsOptions {
signal?: AbortSignal;
}
/** Lists the models this API key can reach. On Ollama Cloud this reflects the account's entitlements. */
/**
* Lists the models this host can reach.
*
* On Ollama Cloud this reflects the account's entitlements. On a local server it
* reflects what is installed, which mixes real weights with `:cloud` entries the
* daemon proxies to ollama.com, so the two are told apart by `remoteHost` rather
* than by anything in the name.
*/
export async function listModels(options: ListModelsOptions): Promise<ModelInfo[]> {
const host = normalizeHost(options.host);
@@ -207,18 +232,255 @@ export async function listModels(options: ListModelsOptions): Promise<ModelInfo[
}
const payload = (await response.json()) as {
models?: Array<{ name?: string; model?: string; details?: { parameter_size?: string } }>;
models?: Array<{
name?: string;
model?: string;
size?: number;
remote_host?: string;
capabilities?: string[];
details?: { parameter_size?: string };
}>;
};
return (payload.models ?? [])
.map((model) => ({
name: model.name ?? model.model ?? "",
parameterSize: model.details?.parameter_size,
size: typeof model.size === "number" ? model.size : undefined,
// Every one of these is optional because older servers simply do not send
// it, and a field that is missing must not be guessed at.
capabilities: Array.isArray(model.capabilities) ? model.capabilities : undefined,
remoteHost: typeof model.remote_host === "string" && model.remote_host ? model.remote_host : undefined,
}))
.filter((model) => model.name.length > 0)
.sort((a, b) => a.name.localeCompare(b.name));
}
export interface DeleteModelOptions {
host: string;
apiKey: string;
model: string;
signal?: AbortSignal;
}
/**
* A model the server has in memory right now, as reported by `GET /api/ps`.
*
* Distinct from {@link ModelInfo}, which is what is on disk. A model can be
* installed without being loaded, and loaded models are the ones holding VRAM.
*/
export interface RunningModel {
name: string;
parameterSize?: string;
/**
* Bytes held in memory. Equal to the on-disk size when the model is fully
* offloaded to the GPU, and less when part of it is running on the CPU.
*/
sizeVram?: number;
/** When the server will drop it on its own, as a Unix timestamp. */
expiresAt?: number;
}
export interface ListRunningModelsOptions {
host: string;
apiKey: string;
signal?: AbortSignal;
}
/**
* The models holding memory on this server.
*
* Resolves to an empty list rather than throwing. Nothing here is worth failing
* over: a server that does not serve `/api/ps` (older builds answer 404) simply
* has nothing known to be loaded, and taking the whole model list down because
* the *running* list is unavailable would be a bad trade.
*/
export async function listRunningModels(options: ListRunningModelsOptions): Promise<RunningModel[]> {
const host = normalizeHost(options.host);
try {
const response = await fetch(`${host}/api/ps`, {
headers: authHeaders(options.apiKey),
signal: options.signal,
});
if (!response.ok) return [];
const payload = (await response.json()) as {
models?: Array<{
name?: string;
model?: string;
size_vram?: number;
expires_at?: string;
details?: { parameter_size?: string };
}>;
};
return (payload.models ?? [])
.map((model) => ({
name: model.name ?? model.model ?? "",
parameterSize: model.details?.parameter_size,
sizeVram: typeof model.size_vram === "number" ? model.size_vram : undefined,
expiresAt: model.expires_at ? Date.parse(model.expires_at) : undefined,
}))
.filter((model) => model.name.length > 0);
} catch {
return [];
}
}
export interface UnloadModelOptions {
host: string;
apiKey: string;
model: string;
signal?: AbortSignal;
}
/**
* Drops a model out of memory, freeing the VRAM it was holding.
*
* `POST /api/generate` with `keep_alive: 0` and no prompt is how Ollama says
* "release this now"; its own `ollama stop` does the same thing. Probed against
* 0.35.1: the reply is `{"done":true,"done_reason":"unload"}` and `/api/ps`
* comes back empty. The `DELETE` form of the same route answers 405, so there is
* no delete-shaped way to do this.
*
* Nothing is deleted, so a mistake costs a reload. That is why this needs no
* confirmation while `deleteModel` does.
*/
export async function unloadModel(options: UnloadModelOptions): Promise<void> {
const host = normalizeHost(options.host);
const response = await fetch(`${host}/api/generate`, {
method: "POST",
headers: { "Content-Type": "application/json", ...authHeaders(options.apiKey) },
// No `prompt`, so this never generates anything, and `stream: false` because
// there is exactly one reply to read.
body: JSON.stringify({ model: options.model, keep_alive: 0, stream: false }),
signal: options.signal,
});
if (!response.ok) {
throw describeHttpError(response.status, await safeText(response), host);
}
}
/**
* Removes a model from a server the user runs.
*
* `DELETE`, not `POST`: Ollama 0.35.1 answers the POST form of this route with
* `405 method not allowed`, which is the kind of change that is invisible until a
* user tries it.
*
* There is no undo and no trash, so the caller confirms first. For the same
* reason a 404 is not swallowed into a success: "no model called that" is worth
* saying out loud, because it usually means something else already removed it, or
* that the host is not what the user thinks it is.
*/
export async function deleteModel(options: DeleteModelOptions): Promise<void> {
const host = normalizeHost(options.host);
const response = await fetch(`${host}/api/delete`, {
method: "DELETE",
headers: { "Content-Type": "application/json", ...authHeaders(options.apiKey) },
body: JSON.stringify({ model: options.model }),
signal: options.signal,
});
if (response.status === 404) {
throw new OllamaError(`${host} has no model called ${options.model}.`, {
status: 404,
hint: "It may have been removed already, or by another machine sharing this server.",
});
}
if (!response.ok) {
throw describeHttpError(response.status, await safeText(response), host);
}
}
/**
* One line of pull progress, exactly as Ollama reports it.
*
* `total` and `completed` are only present once a download starts, so the caller
* has to render a status with no numbers in it for the first few ticks.
*/
export interface PullProgress {
status: string;
total?: number;
completed?: number;
}
export interface PullModelOptions {
host: string;
apiKey: string;
model: string;
signal?: AbortSignal;
}
/**
* Pulls a model onto a server the user runs, yielding progress as it arrives.
*
* Streaming rather than a blocking request, because a download is minutes long
* and a request that returns nothing until it is finished leaves the window with
* no way to tell a slow pull from a hung one.
*
* A bad model name comes back as **HTTP 200 with an `error` field in the body**
* (verified against 0.35.1), so the status line is not enough to decide whether
* this worked: the stream has to be read and checked, exactly as the chat stream
* already does.
*/
export async function* pullModel(options: PullModelOptions): AsyncGenerator<PullProgress> {
const host = normalizeHost(options.host);
const response = await fetch(`${host}/api/pull`, {
method: "POST",
headers: { "Content-Type": "application/json", ...authHeaders(options.apiKey) },
body: JSON.stringify({ model: options.model, stream: true }),
signal: options.signal,
});
if (!response.ok) {
throw describeHttpError(response.status, await safeText(response), host);
}
if (!response.body) {
throw new OllamaError("Ollama returned an empty response body.");
}
for await (const event of parseNdjsonStream<{
status?: string;
total?: number;
completed?: number;
error?: string;
}>(response.body)) {
if (event.error) throw new OllamaError(event.error);
if (!event.status) continue;
yield { status: event.status, total: event.total, completed: event.completed };
}
}
/**
* One line describing a pull, for the toast that stays up while it runs.
*
* Lives here rather than in the view because it is a reading of Ollama's own
* payload rather than a layout decision, and because the arithmetic is the part
* of a progress bar that goes wrong silently: a total of zero, or a `completed`
* that arrives before the `total` it is measured against, would otherwise print
* `NaN%` or `Infinity%` at the user.
*/
export function describePullProgress(progress: PullProgress): string {
const { status, total, completed } = progress;
if (!total || total <= 0 || completed === undefined || !Number.isFinite(completed)) {
return status;
}
const percent = Math.min(100, Math.max(0, Math.round((completed / total) * 100)));
return `${status} · ${percent}% · ${formatBytes(completed)} / ${formatBytes(total)}`;
}
export interface ModelCapabilitiesOptions {
host: string;
apiKey: string;
@@ -257,6 +519,53 @@ export function supportsVision(capabilities: string[]): boolean {
return capabilities.includes("vision");
}
/**
* Posts to a web tool, at the path this kind of host actually serves it from.
*
* The two kinds of host expose the same two operations at different paths, and
* there is no path that works for both. Measured, not looked up:
*
* - `https://ollama.com/api/web_search` answers 401 without a key, so the route
* exists, and its `/api/experimental/` alias is a 404.
* - A local 0.35.1 daemon answers 404 to that path, and serves
* `/api/experimental/web_search` with real results.
*
* This was a live bug: pointing the extension at a local server made every web
* tool call a 404, and the model, handed a failure and no explanation, told the
* user its web tools simply were not configured.
*
* The other path is tried when the first answers 404, because that is a server
* built the other way round rather than a request being refused. A 404 body is
* drained before the retry so the connection is not left hanging.
*/
async function postWebTool(options: {
host: string;
apiKey: string;
kind: "search" | "fetch";
body: Record<string, unknown>;
signal?: AbortSignal;
}): Promise<Response> {
const host = normalizeHost(options.host);
const [preferred, fallback] = isSelfHosted(host)
? [`/api/experimental/web_${options.kind}`, `/api/web_${options.kind}`]
: [`/api/web_${options.kind}`, `/api/experimental/web_${options.kind}`];
const post = (path: string) =>
fetch(`${host}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json", ...authHeaders(options.apiKey) },
body: JSON.stringify(options.body),
signal: options.signal,
});
const response = await post(preferred);
if (response.status !== 404) return response;
await response.text().catch(() => "");
return post(fallback);
}
export interface WebSearchResult {
title: string;
url: string;
@@ -272,20 +581,19 @@ export interface WebSearchOptions {
}
export async function webSearch(options: WebSearchOptions): Promise<WebSearchResult[]> {
const host = normalizeHost(options.host);
const body: Record<string, unknown> = { query: options.query };
if (typeof options.maxResults === "number") body.max_results = options.maxResults;
const response = await fetch(`${host}/api/web_search`, {
method: "POST",
headers: { "Content-Type": "application/json", ...authHeaders(options.apiKey) },
body: JSON.stringify(body),
const response = await postWebTool({
host: options.host,
apiKey: options.apiKey,
kind: "search",
body,
signal: options.signal,
});
if (!response.ok) {
throw describeHttpError(response.status, await safeText(response), host);
throw describeHttpError(response.status, await safeText(response), normalizeHost(options.host));
}
const payload = (await response.json()) as { results?: WebSearchResult[] };
@@ -306,19 +614,21 @@ export interface WebFetchOptions {
}
export async function webFetch(options: WebFetchOptions): Promise<WebFetchResult> {
const host = normalizeHost(options.host);
const response = await fetch(`${host}/api/web_fetch`, {
method: "POST",
headers: { "Content-Type": "application/json", ...authHeaders(options.apiKey) },
body: JSON.stringify({ url: options.url }),
const response = await postWebTool({
host: options.host,
apiKey: options.apiKey,
kind: "fetch",
body: { url: options.url },
signal: options.signal,
});
if (!response.ok) {
throw describeHttpError(response.status, await safeText(response), host);
throw describeHttpError(response.status, await safeText(response), normalizeHost(options.host));
}
// `links` comes back as null from a local daemon rather than as an empty list,
// which `??` covers. (Probed directly: a local `web_fetch` answered
// `{"title":...,"content":...,"links":null}`.)
const payload = (await response.json()) as Partial<WebFetchResult>;
return {
title: payload.title ?? options.url,
+75 -4
View File
@@ -1,5 +1,5 @@
import { getPreferenceValues } from "@vicinae/api";
import { DEFAULT_HOST, DEFAULT_MAX_TOOL_ROUNDS, DEFAULT_MODEL } from "./constants";
import { getPreferenceValues, LocalStorage } from "@vicinae/api";
import { DEFAULT_HOST, DEFAULT_MAX_TOOL_ROUNDS, DEFAULT_MODEL, STORAGE_MODEL_KEY } from "./constants";
/**
* Preferences, read and normalised.
@@ -26,10 +26,29 @@ export interface LamPreferences {
maxToolRounds?: string;
}
/** Where the model in use came from, so the UI can explain a divergence from the preference. */
export type ModelSource = "preference" | "selection";
/** Preferences with every optional value filled in, so callers never branch on undefined. */
export interface ResolvedPreferences {
apiKey: string;
model: string;
/**
* Whether {@link ResolvedPreferences.model} came from the preference or from a
* pick in the Models command.
*
* Carried rather than inferred, because the two disagree silently otherwise:
* the setup screen would show one model while the chat used another, and the
* user would have no way to tell which one to change.
*/
modelSource: ModelSource;
/**
* What the `Model` preference says, whatever is in use.
*
* Kept alongside `model` so a screen can answer the question a selection
* raises: what would happen if I reset it?
*/
preferenceModel: string;
host: string;
systemPrompt: string;
/**
@@ -166,10 +185,15 @@ function describeSpan(milliseconds: number): string {
export function resolvePreferences(raw: LamPreferences): ResolvedPreferences {
const thinking = (raw.thinking ?? "").trim();
const model = (raw.model ?? "").trim() || DEFAULT_MODEL;
return {
apiKey: (raw.apiKey ?? "").trim(),
model: (raw.model ?? "").trim() || DEFAULT_MODEL,
model,
// Anything read straight from Vicinae is the preference by definition. The
// selection is layered on top in `loadPreferences`.
modelSource: "preference",
preferenceModel: model,
host: (raw.ollamaHost ?? "").trim() || DEFAULT_HOST,
systemPrompt: (raw.systemPrompt ?? "").trim(),
place: (raw.location ?? "").trim(),
@@ -190,10 +214,57 @@ export function resolvePreferences(raw: LamPreferences): ResolvedPreferences {
};
}
export function getLamPreferences(): ResolvedPreferences {
/**
* Vicinae's own preferences, resolved and normalised. Not exported: everything
* outside this module should go through {@link loadPreferences}, which also
* applies the model chosen in the Models command.
*/
function getLamPreferences(): ResolvedPreferences {
return resolvePreferences(getPreferenceValues<LamPreferences>());
}
/**
* The model chosen in the Models command, if there is one.
*
* Never throws. Storage that cannot be read means no selection rather than a
* broken command, because the fallback (the preference) is always a valid
* answer.
*/
export async function readModelSelection(): Promise<string | undefined> {
const stored = await LocalStorage.getItem<string>(STORAGE_MODEL_KEY).catch(() => undefined);
const trimmed = (stored ?? "").trim();
return trimmed.length > 0 ? trimmed : undefined;
}
/** Remembers a model as the one to use, taking precedence over the preference. */
export async function selectModel(model: string): Promise<void> {
await LocalStorage.setItem(STORAGE_MODEL_KEY, model.trim());
}
/** Drops the selection, handing the model back to the `Model` preference. */
export async function clearModelSelection(): Promise<void> {
await LocalStorage.removeItem(STORAGE_MODEL_KEY).catch(() => undefined);
}
/**
* The preferences the rest of the extension should actually use.
*
* The only reader anyone else needs: it applies the model chosen in the Models
* command on top of the preferences Vicinae holds. A second, synchronous reader
* would be a trap, because it looks right and silently ignores the selection,
* which is why `getLamPreferences` above is not exported.
*
* The model is the only thing a selection can override. Everything else here has
* a single home in Vicinae's own settings, and adding a shadow copy of those
* would make "where do I change this" a question with two answers.
*/
export async function loadPreferences(): Promise<ResolvedPreferences> {
const base = getLamPreferences();
const selected = await readModelSelection();
return selected ? { ...base, model: selected, modelSource: "selection" } : base;
}
/** True when the extension cannot work until the user supplies a key. */
export function needsSetup(preferences: ResolvedPreferences): boolean {
return preferences.apiKey.length === 0;
+1 -1
View File
@@ -169,7 +169,7 @@ export async function executeTool(
}
} catch (error) {
const suffix = isWebTool(name)
? " The web tools require an Ollama Cloud host and an API key with web search enabled."
? " Web search needs Ollama Cloud, or a local Ollama server new enough to serve it (0.35 and later)."
: "";
return { content: `${errorMessage(error)}${suffix}`, isError: true };
}
+32
View File
@@ -148,10 +148,42 @@ export interface SessionSummary {
attachmentPaths?: string[];
}
/**
* Whether the model in use can read images, as far as the capability check knows.
*
* `"unknown"` is deliberately distinct from `"no"`: a failed request is not
* evidence that a model cannot see, and reporting it as one would be a false
* claim about the user's setup. Lives here rather than in `vision.ts` so that
* modules which must stay free of Vicinae imports can name it.
*/
export type VisionSupport = "yes" | "no" | "unknown";
/** A model as reported by `GET /api/tags`. */
export interface ModelInfo {
name: string;
parameterSize?: string;
/**
* How much disk the model takes, in bytes.
*
* Meaningless for a cloud model proxied through a local daemon: that entry is
* a few hundred bytes of manifest, so the caller shows nothing rather than
* "326 B", which is what `ollama list` does too.
*/
size?: number;
/**
* What the model says it can do, e.g. `["tools", "vision"]`.
*
* Only newer servers send this. Absent means unknown, never "no": reporting a
* missing field as a lack of vision would be a false claim about the user's
* setup.
*/
capabilities?: string[];
/**
* Set when this entry is a proxy rather than weights on disk, naming the server
* that actually runs it (`https://ollama.com`). Deleting one of these drops a
* reference; it does not free the gigabytes the size column implies.
*/
remoteHost?: string;
}
/**
+27
View File
@@ -50,6 +50,33 @@ export function recencyGroup(timestamp: number, now: number = Date.now()): Recen
return "Older";
}
/**
* A byte count for a person to read: "6.6 GB", "326 B".
*
* Decimal units, not the 1024-based ones a memory figure would use, because
* that is what every other tool in this ecosystem prints: `ollama list` shows
* 6550813657 bytes as 6.6 GB, and a number that disagreed with the terminal the
* user just looked at would read as a bug in this extension.
*
* `undefined` for a size that is absent or nonsense, so a caller can leave the
* field out rather than print "NaN B".
*/
export function formatBytes(bytes: number | undefined): string | undefined {
if (bytes === undefined || !Number.isFinite(bytes) || bytes < 0) return undefined;
if (bytes < 1000) return `${Math.round(bytes)} B`;
const units = ["kB", "MB", "GB", "TB"];
let value = bytes / 1000;
let unit = 0;
while (value >= 1000 && unit < units.length - 1) {
value /= 1000;
unit += 1;
}
return `${value.toFixed(1)} ${units[unit]}`;
}
/** Pulls a human-readable message out of anything that can be thrown. */
export function errorMessage(error: unknown): string {
if (error instanceof Error) return error.message;
+6 -1
View File
@@ -1,5 +1,6 @@
import { LocalStorage } from "@vicinae/api";
import { modelCapabilities, supportsVision } from "./ollama";
import type { VisionSupport } from "./types";
/**
* The cached answer to "can this model see images?".
@@ -17,7 +18,11 @@ import { modelCapabilities, supportsVision } from "./ollama";
/** One key per host and model, so switching model invalidates by construction. */
const VISION_CACHE_PREFIX = "lam.vision.";
export type VisionSupport = "yes" | "no" | "unknown";
// Re-exported so callers can keep naming the type from here, where it belongs
// conceptually. The definition moved to `types.ts` so that `history.ts`, which
// must not import a Vicinae module, can describe the same tri-state in the
// system prompt.
export type { VisionSupport };
export interface VisionCheck {
host: string;
+438
View File
@@ -0,0 +1,438 @@
import {
Action,
ActionPanel,
Alert,
Form,
Icon,
List,
Toast,
confirmAlert,
openExtensionPreferences,
showToast,
} from "@vicinae/api";
import { useCallback, useEffect, useState } from "react";
import { usePreferences } from "./lib/hooks/usePreferences";
import {
deleteModel,
describePullProgress,
isSelfHosted,
listModels,
listRunningModels,
pullModel,
unloadModel,
type RunningModel,
} from "./lib/ollama";
import { clearModelSelection, selectModel, type ResolvedPreferences } from "./lib/preferences";
import type { ModelInfo } from "./lib/types";
import { errorMessage, formatBytes } from "./lib/utils";
/**
* Browse, pick, and manage the models the configured Ollama host can reach.
*
* Four jobs in one command, because they are four answers to the same question
* ("what models do I have?"): pick one, unload one, delete one, pull a new one.
* Unloading is the odd one out and the reason a row needs two states rather than
* one: a model can be installed without being loaded, and only the loaded ones
* are holding memory. Delete and pull only appear on a host the user runs, since
* there is nothing to pull onto Ollama Cloud and nothing to delete from it.
*
* ## Where a pick is stored
*
* Not in the `Model` preference, because an extension cannot write its own
* preferences. Vicinae reads them once at startup and holds them in memory, so
* even writing its config file directly does nothing until it restarts, which was
* measured rather than assumed (`providers["@adrianbonpin/lam-agent"].preferences`
* in `~/.config/vicinae/settings.json`, edited while a command was then launched:
* the requests still went to the old host).
*
* So a pick goes to LocalStorage and `loadPreferences` layers it over the
* preference. The cost of that arrangement is one thing this screen has to say
* out loud, because it is invisible otherwise: while a pick is active, editing
* the `Model` preference has no effect until the pick is reset. Which is why the
* setup screen shows both values and offers the reset.
*
* ## Why the pull keeps the window open
*
* The download runs inside this command's worker, so closing the launcher cancels
* it. That is a real constraint and the command description says so.
*/
/** Which surface the command is showing. The form is a view rather than a dialog, which Vicinae has no equivalent of. */
type View = "list" | "pull";
export default function ModelsCommand() {
const { preferences, reload } = usePreferences();
// Nothing renders until the model in use is known, because the whole list is
// marked up relative to it: which row is current, and whether picking one
// would change anything.
if (!preferences) {
return <List isLoading navigationTitle="Ollama Models" searchBarPlaceholder="Loading models…" />;
}
return <ModelsScreen preferences={preferences} reloadPreferences={reload} />;
}
interface ModelsScreenProps {
preferences: ResolvedPreferences;
/** Re-reads preferences after this screen changes the model selection. */
reloadPreferences: () => void;
}
function ModelsScreen({ preferences, reloadPreferences }: ModelsScreenProps) {
const [models, setModels] = useState<ModelInfo[] | undefined>(undefined);
/** By name, so a row can ask whether it is the one holding memory. */
const [running, setRunning] = useState<Map<string, RunningModel>>(new Map());
const [error, setError] = useState<string | undefined>(undefined);
const [isLoading, setIsLoading] = useState(true);
const [view, setView] = useState<View>("list");
/** Whether this host's models can be managed at all. */
const selfHosted = isSelfHosted(preferences.host);
const load = useCallback(async () => {
setIsLoading(true);
try {
// Both at once: what is installed and what is loaded are different
// questions, and the second must not hold up the first.
const [installed, loaded] = await Promise.all([
listModels({ host: preferences.host, apiKey: preferences.apiKey }),
listRunningModels({ host: preferences.host, apiKey: preferences.apiKey }),
]);
setModels(installed);
setRunning(new Map(loaded.map((model) => [model.name, model])));
setError(undefined);
} catch (thrown) {
setModels([]);
setRunning(new Map());
setError(errorMessage(thrown));
} finally {
setIsLoading(false);
}
}, [preferences.host, preferences.apiKey]);
useEffect(() => {
void load();
}, [load]);
/**
* Releases a model from memory.
*
* No confirmation, unlike deleting: nothing is removed from disk, and the
* worst case is that the next question pays to load it again. The toast reports
* what was freed, because that is the whole reason to do this.
*/
const unload = useCallback(
async (model: ModelInfo, loaded: RunningModel) => {
try {
await unloadModel({ host: preferences.host, apiKey: preferences.apiKey, model: model.name });
} catch (thrown) {
await showToast({
style: Toast.Style.Failure,
title: `Could not unload ${model.name}`,
message: errorMessage(thrown),
});
return;
}
const freed = formatBytes(loaded.sizeVram);
await showToast({
style: Toast.Style.Success,
title: `Unloaded ${model.name}`,
message: freed ? `${freed} of memory freed` : undefined,
});
void load();
},
[load, preferences.host, preferences.apiKey],
);
const choose = useCallback(
async (name: string) => {
if (name === preferences.model) {
await showToast({ style: Toast.Style.Success, title: `${name} is already in use` });
return;
}
await selectModel(name);
reloadPreferences();
await showToast({
style: Toast.Style.Success,
title: `Lam will use ${name}`,
// Said here because it is the one surprising part of this screen: the
// model is chosen in this command, not in Vicinae's settings.
message: "Chosen here. The Model preference is left alone.",
});
},
[preferences.model, reloadPreferences],
);
const remove = useCallback(
async (model: ModelInfo) => {
const confirmed = await confirmAlert({
title: `Delete ${model.name}?`,
message: model.remoteHost
? `This removes the local reference. The model itself lives at ${model.remoteHost} and is not deleted there.`
: "The weights are removed from this machine, and pulling it again means downloading all of it.",
primaryAction: { title: "Delete", style: Alert.ActionStyle.Destructive },
});
if (!confirmed) return;
try {
await deleteModel({ host: preferences.host, apiKey: preferences.apiKey, model: model.name });
} catch (thrown) {
await showToast({
style: Toast.Style.Failure,
title: `Could not delete ${model.name}`,
message: errorMessage(thrown),
});
return;
}
// The model that just went may be the one in use, in which case the next
// question asked would fail with a 404. A selection can be cleared here;
// a preference cannot, so that case is reported instead of silently
// leaving a dangling name behind.
const wasInUse = model.name === preferences.model;
const selected = preferences.modelSource === "selection";
if (wasInUse && selected) {
await clearModelSelection();
reloadPreferences();
}
await showToast({
style: Toast.Style.Success,
title: `Deleted ${model.name}`,
message: wasInUse
? selected
? "It was the model in use, so that choice has been cleared."
: "It is still what the Model preference points at. Change it in preferences."
: undefined,
});
void load();
},
[load, preferences.host, preferences.apiKey, preferences.model, preferences.modelSource, reloadPreferences],
);
if (view === "pull") {
return (
<PullModelForm
host={preferences.host}
apiKey={preferences.apiKey}
onDone={() => {
setView("list");
void load();
}}
onCancel={() => setView("list")}
/>
);
}
const pullAction = selfHosted ? (
<Action title="Pull a Model…" icon={Icon.Download} onAction={() => setView("pull")} />
) : null;
const listActions = (
<ActionPanel>
<Action title="Refresh" icon={Icon.ArrowClockwise} onAction={() => void load()} />
{pullAction ? <ActionPanel.Section>{pullAction}</ActionPanel.Section> : null}
<ActionPanel.Section>
<Action
title="Open Extension Preferences"
icon={Icon.BulletPoints}
onAction={() => void openExtensionPreferences()}
/>
</ActionPanel.Section>
</ActionPanel>
);
const loaded = models ?? [];
return (
<List
isLoading={isLoading}
navigationTitle="Ollama Models"
searchBarPlaceholder="Search models…"
actions={listActions}
>
{error ? (
<List.EmptyView
icon={Icon.QuestionMarkCircle}
title="Could not reach Ollama"
description={error}
actions={
<ActionPanel>
<Action title="Try Again" icon={Icon.ArrowClockwise} onAction={() => void load()} />
<Action
title="Open Extension Preferences"
icon={Icon.BulletPoints}
onAction={() => void openExtensionPreferences()}
/>
</ActionPanel>
}
/>
) : models !== undefined && loaded.length === 0 ? (
<List.EmptyView
icon={Icon.Box}
title={selfHosted ? "No models on this server" : "No models in this account"}
description={
selfHosted
? "Pull one from the action panel, or with `ollama pull` in a terminal."
: "This host has nothing installed, and models are managed at ollama.com."
}
actions={listActions}
/>
) : null}
{loaded.map((model) => {
const isCurrent = model.name === preferences.model;
const size = model.remoteHost ? undefined : formatBytes(model.size);
const inMemory = running.get(model.name);
return (
<List.Item
key={model.name}
id={model.name}
icon={isCurrent ? Icon.CheckCircle : Icon.Box}
title={model.name}
subtitle={[model.parameterSize, size].filter(Boolean).join(" · ") || undefined}
accessories={[
...(inMemory ? [{ tag: { value: "loaded", color: "green" as const } }] : []),
...(isCurrent ? [{ tag: { value: "current", color: "blue" as const } }] : []),
...(model.capabilities?.includes("vision") ? [{ tag: "vision" }] : []),
{ tag: model.remoteHost ? "cloud" : selfHosted ? "local" : "cloud" },
]}
actions={
<ActionPanel>
<Action
title={isCurrent ? "Current Model" : "Use This Model"}
icon={isCurrent ? Icon.CheckCircle : Icon.Stars}
onAction={() => void choose(model.name)}
/>
{inMemory ? (
<Action
title="Unload Model"
icon={Icon.Eject}
onAction={() => void unload(model, inMemory)}
/>
) : null}
<Action.CopyToClipboard title="Copy Model Name" content={model.name} />
{selfHosted ? (
<Action
title="Delete Model"
icon={Icon.Trash}
style="destructive"
onAction={() => void remove(model)}
/>
) : null}
<ActionPanel.Section>
<Action title="Refresh" icon={Icon.ArrowClockwise} onAction={() => void load()} />
{pullAction}
<Action
title="Open Extension Preferences"
icon={Icon.BulletPoints}
onAction={() => void openExtensionPreferences()}
/>
</ActionPanel.Section>
</ActionPanel>
}
/>
);
})}
</List>
);
}
interface PullModelFormProps {
host: string;
apiKey: string;
onDone: () => void;
onCancel: () => void;
}
/**
* Pull a model by name.
*
* A `Form` rather than a `List`, because the model being pulled is usually not
* one of the models already installed, which is the whole reason for pulling it.
* A picker over the library would be nicer and needs an endpoint this API does
* not expose, so the name is typed and the description carries a few examples.
*/
function PullModelForm({ host, apiKey, onDone, onCancel }: PullModelFormProps) {
const [isPulling, setIsPulling] = useState(false);
const pull = useCallback(
async (values: Form.Values) => {
const name = String(values.model ?? "").trim();
if (!name) {
await showToast({ style: Toast.Style.Failure, title: "Give the model a name" });
return;
}
setIsPulling(true);
const toast = await showToast({ style: Toast.Style.Animated, title: `Pulling ${name}` });
try {
let last = "";
for await (const progress of pullModel({ host, apiKey, model: name })) {
// Ollama sends a tick per chunk, which for a large model is far more
// often than the toast needs updating. Only write when the line
// actually changed, so the percentage is what limits the traffic.
const line = describePullProgress(progress);
if (line !== last) {
last = line;
toast.message = line;
}
}
toast.style = Toast.Style.Success;
toast.title = `Pulled ${name}`;
toast.message = "It is in the list now";
onDone();
} catch (thrown) {
toast.style = Toast.Style.Failure;
toast.title = `Could not pull ${name}`;
toast.message = errorMessage(thrown);
} finally {
setIsPulling(false);
}
},
[apiKey, host, onDone],
);
return (
<Form
isLoading={isPulling}
navigationTitle="Pull a Model"
actions={
<ActionPanel>
<Action.SubmitForm title="Pull Model" icon={Icon.Download} onSubmit={pull} />
<Action title="Cancel" icon={Icon.Xmark} onAction={onCancel} />
</ActionPanel>
}
>
<Form.TextField
id="model"
title="Model"
placeholder="qwen3:8b"
autoFocus
info="The name as it appears in the Ollama library, including the tag. Leave the tag off to get the default one."
/>
<Form.Description
title="Examples"
text={["qwen3:8b", "llama3.2:3b", "gemma3:12b", "deepseek-r1:14b"].join("\n")}
/>
<Form.Description
title="While it pulls"
text="Leave the launcher open. The download runs in this window, so closing it cancels the pull."
/>
</Form>
);
}
+6 -4
View File
@@ -1,9 +1,9 @@
import { Action, ActionPanel, Clipboard, Icon, List, useNavigation } from "@vicinae/api";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { runAutoDelete } from "./lib/cleanup";
import { ChatView } from "./lib/components/ChatView";
import { sessionToPlainText } from "./lib/markdown";
import { getLamPreferences } from "./lib/preferences";
import { loadPreferences } from "./lib/preferences";
import { clearSessions, deleteSession, listSessions, loadSession } from "./lib/sessions";
import type { SessionSummary } from "./lib/types";
import { formatRelativeTime, truncate } from "./lib/utils";
@@ -18,7 +18,6 @@ import { formatRelativeTime, truncate } from "./lib/utils";
export default function SessionsCommand() {
const { push } = useNavigation();
const [sessions, setSessions] = useState<SessionSummary[] | undefined>(undefined);
const preferences = useMemo(() => getLamPreferences(), []);
const refresh = useCallback(() => {
void listSessions()
@@ -34,6 +33,9 @@ export default function SessionsCommand() {
let cancelled = false;
void (async () => {
// Read here rather than at render time: preferences are asynchronous now
// that the model selection has to be layered on top of them.
const preferences = await loadPreferences();
await runAutoDelete({ maxAgeMs: preferences.autoDeleteMs });
if (!cancelled) refresh();
})();
@@ -41,7 +43,7 @@ export default function SessionsCommand() {
return () => {
cancelled = true;
};
}, [preferences.autoDeleteMs, refresh]);
}, [refresh]);
const openSession = useCallback(
(id: string) => {
+77 -6
View File
@@ -1,12 +1,14 @@
import { Action, ActionPanel, Alert, Icon, List, confirmAlert, openExtensionPreferences } from "@vicinae/api";
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
import { Action, ActionPanel, Alert, Icon, List, Toast, confirmAlert, openExtensionPreferences, showToast } from "@vicinae/api";
import { useCallback, useEffect, useState, type ReactNode } from "react";
import { clearAllChats, clearOrphanedSnips, runAutoDelete } from "./lib/cleanup";
import { usePreferences } from "./lib/hooks/usePreferences";
import {
clearModelSelection,
describeAutoDelete,
describeMaxToolRounds,
describeResumeTimeout,
getLamPreferences,
needsSetup,
type ResolvedPreferences,
} from "./lib/preferences";
import { listModels } from "./lib/ollama";
import { forgetGeoPlace } from "./lib/geo";
@@ -40,7 +42,29 @@ function maskKey(apiKey: string): string {
}
export default function SetupCommand() {
const preferences = useMemo(() => getLamPreferences(), []);
const { preferences, reload } = usePreferences();
// The screen does nothing until the model is known, because half of what it
// reports is about that model: whether it can see images, and whether it is the
// one from preferences or from the Models command. Rendering it with the
// preference value and correcting it a moment later would flash a wrong answer
// at the user.
if (!preferences) {
return (
<List isLoading navigationTitle="Lam Agent Setup" searchBarPlaceholder="Loading preferences…" />
);
}
return <SetupScreen preferences={preferences} reloadPreferences={reload} />;
}
interface SetupScreenProps {
preferences: ResolvedPreferences;
/** Re-reads preferences after this screen changes the model selection. */
reloadPreferences: () => void;
}
function SetupScreen({ preferences, reloadPreferences }: SetupScreenProps) {
const [models, setModels] = useState<ModelInfo[]>([]);
const [error, setError] = useState<string | undefined>(undefined);
const [isLoading, setIsLoading] = useState(false);
@@ -98,6 +122,25 @@ export default function SetupCommand() {
void openExtensionPreferences();
}, []);
/**
* Hands the model back to the `Model` preference.
*
* The escape hatch for the one cost of keeping a selection: while one is set,
* editing the preference has no effect, and the only way to notice is this
* screen. Confirmed rather than immediate because it is a change the user
* cannot see the result of until the next question they ask.
*/
const resetModel = useCallback(async () => {
await clearModelSelection();
reloadPreferences();
await showToast({
style: Toast.Style.Success,
title: "Model reset to the preference",
message: preferences.preferenceModel,
});
}, [reloadPreferences, preferences.preferenceModel]);
/**
* Confirms, then runs a destructive tidy-up.
*
@@ -191,8 +234,36 @@ export default function SetupCommand() {
<List.Item
icon={Icon.Stars}
title="Model"
subtitle={preferences.model}
actions={settingsActions}
subtitle={
preferences.modelSource === "selection"
? `${preferences.model} (chosen in Models; the preference says ${preferences.preferenceModel})`
: preferences.model
}
actions={
preferences.modelSource === "selection" ? (
<ActionPanel>
<Action
title="Reset to the Preference"
icon={Icon.ArrowCounterClockwise}
onAction={() => void resetModel()}
/>
<ActionPanel.Section>
<Action
title="Open Extension Preferences"
icon={Icon.BulletPoints}
onAction={openPreferences}
/>
<Action
title="Test Connection"
icon={Icon.ArrowClockwise}
onAction={() => void testConnection()}
/>
</ActionPanel.Section>
</ActionPanel>
) : (
settingsActions
)
}
/>
<List.Item
icon={Icon.BulletPoints}
+5 -2
View File
@@ -1,6 +1,6 @@
import { LaunchType, closeMainWindow, launchCommand, showHUD, type LaunchProps } from "@vicinae/api";
import { captureRegion } from "./lib/capture";
import { getLamPreferences } from "./lib/preferences";
import { loadPreferences } from "./lib/preferences";
import { describeCaptureFailure, readSnipContext, snipDirectory } from "./lib/snip";
import { visionSupport } from "./lib/vision";
@@ -34,7 +34,10 @@ function settleWindow(): Promise<void> {
* Bind it to a global shortcut for one-key "snip and ask".
*/
export default async function SnipCommand(props: LaunchProps) {
const preferences = getLamPreferences();
// Awaited because the model can come from a pick in the Models command, which
// lives in storage. The vision check below is about whichever model is in use,
// so it has to be the resolved one.
const preferences = await loadPreferences();
const context = readSnipContext(props.launchContext);
// Before anything else. Everything below is pointless if the selector is