feat: list, pick and manage Ollama models from a command
This commit is contained in:
@@ -1,5 +1,15 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { modelCapabilities, normalizeHost, parseNdjsonStream, supportsVision } from "../lib/ollama";
|
||||
import {
|
||||
deleteModel,
|
||||
describePullProgress,
|
||||
isSelfHosted,
|
||||
listModels,
|
||||
modelCapabilities,
|
||||
normalizeHost,
|
||||
parseNdjsonStream,
|
||||
pullModel,
|
||||
supportsVision,
|
||||
} from "../lib/ollama";
|
||||
|
||||
/**
|
||||
* The NDJSON reader is the part of the client most likely to break in a way
|
||||
@@ -151,3 +161,245 @@ 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%");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,14 +95,42 @@ 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(), []);
|
||||
}: ChatSessionProps) {
|
||||
const chat = useChat({ preferences, initialSession });
|
||||
const { replaceSession } = chat;
|
||||
const { canSeeImages } = useVisionSupport(preferences);
|
||||
@@ -396,7 +425,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";
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
+163
-3
@@ -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();
|
||||
@@ -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,153 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
+75
-4
@@ -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;
|
||||
|
||||
@@ -152,6 +152,28 @@ export interface SessionSummary {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
+375
@@ -0,0 +1,375 @@
|
||||
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, pullModel } 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.
|
||||
*
|
||||
* Three jobs in one command, because they are three answers to the same question
|
||||
* ("what models do I have?"): pick one, delete one, pull a new one. The last two
|
||||
* 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);
|
||||
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 {
|
||||
setModels(await listModels({ host: preferences.host, apiKey: preferences.apiKey }));
|
||||
setError(undefined);
|
||||
} catch (thrown) {
|
||||
setModels([]);
|
||||
setError(errorMessage(thrown));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [preferences.host, preferences.apiKey]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
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);
|
||||
|
||||
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={[
|
||||
...(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)}
|
||||
/>
|
||||
<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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user