feat: list, pick and manage Ollama models from a command
This commit is contained in:
@@ -18,6 +18,7 @@ Hit a global shortcut, type a question, get a streamed answer without leaving wh
|
||||
- **Real markdown.** Tables, bulleted and numbered lists, headings, blockquotes, bold, italics and links all render natively, and the default prompt asks for the shape that fits the answer rather than flattening everything into prose.
|
||||
- **Tools.** Lam decides when to call them: current time, location context, web search, and page fetching. A turn that uses up its tool budget still ends in an answer, written from what it gathered, rather than a notice that it stopped.
|
||||
- **Tool calls you can watch and stop.** What Lam is doing right now stays on screen, including when it asks the same question twice, the round limit is a preference, Force Stop is always one keystroke away, and a call that repeats itself with identical arguments ends the turn instead of running forever.
|
||||
- **Models, from a list.** Browse every model your host can reach, pick one and start using it immediately, and on a server you run, pull new ones or delete old ones. Tag names are picked, never typed.
|
||||
- **Optional auto-deletion.** Set a retention window and Lam clears out the conversations you have stopped using the next time you open a command, taking their screenshots with them and collecting any capture you never sent. Off by default, and nothing at all is read or written while it is off. The setup screen also has both tidy-ups as explicit actions, confirmed first, for when you would rather press the button yourself.
|
||||
- **Screenshots, two ways.** Snip a region into the conversation you are already in from the action panel, or run **Snip to Lam** to start a new chat about it. The launcher window gets out of the way first, so it never ends up in the shot, and the capture appears in the transcript as a thumbnail with Open Screenshot for the full image. Neither is offered unless the current model can actually see images.
|
||||
- **Chat only.** Lam answers questions. It does not touch your files or run commands. The one exception is capture, and it only ever happens because you pressed the key for it.
|
||||
@@ -64,12 +65,12 @@ npm run build
|
||||
Setup is two prompts on first launch, then one command you can revisit any time.
|
||||
|
||||
1. **The API key.** Vicinae prompts for required preferences before a command runs, so the very first launch asks for your Ollama API key.
|
||||
2. **The model.** The next prompt asks which model to use. Type it exactly, e.g. `deepseek-v4.1-flash:cloud` or `gpt-oss:120b`.
|
||||
3. **`Set Up Lam Agent`** verifies the connection, lists every model your key can actually reach, and copies a model name to the clipboard so you can paste it into preferences.
|
||||
2. **The model.** The next prompt asks which model to use. Type it exactly, e.g. `deepseek-v4.1-flash:cloud` or `gpt-oss:120b`. Typing it by hand is the fragile part, so run **Ollama Models** afterwards and pick the real one from the list.
|
||||
3. **`Set Up Lam Agent`** verifies the connection, lists every model your key can actually reach, and reports which one is in use and where that came from.
|
||||
|
||||
To change the model later, either edit the **Model** preference directly, or run **Set Up Lam Agent** and copy the name of a model you would rather use.
|
||||
To change the model later, run **Ollama Models** and pick one, or edit the **Model** preference directly.
|
||||
|
||||
> An extension can read preferences but not write them, which is why setup hands you the exact name to paste rather than saving it for you. It also means the model list can never drift out of sync with your account.
|
||||
> An extension can read preferences but not write them, which is why a pick made in **Ollama Models** is stored by the extension and layered over the preference rather than saved into it. See [Models](#models) for how the two are reconciled, and for what a reset does.
|
||||
|
||||
## Keyboard shortcuts
|
||||
|
||||
@@ -115,6 +116,7 @@ vicinae 'vicinae://launch/lam-agent/chat?fallbackText=summarise%20this'
|
||||
| **Chat with Lam** | The chat surface. Bind this to your global shortcut. |
|
||||
| **Snip to Lam** | Capture a region and start a new chat about it. A `no-view` command, so the launcher window closes before the selector appears. Bind this to a second shortcut for one-key "snip and ask". |
|
||||
| **Chat Sessions** | Browse, reopen, and delete past conversations. |
|
||||
| **Ollama Models** | Browse the models the configured host can reach, pick the one Lam uses, and pull or delete models on a server you run. See [Models](#models). |
|
||||
| **Set Up Lam Agent** | Verify the connection, list available models, check whether the selected model can see images, and clear out chats or unattached photos. |
|
||||
|
||||
## Configuration
|
||||
@@ -122,7 +124,7 @@ vicinae 'vicinae://launch/lam-agent/chat?fallbackText=summarise%20this'
|
||||
| Preference | Default | Notes |
|
||||
| --- | --- | --- |
|
||||
| **Ollama API Key** | None | Required. Stored by Vicinae as a password preference. |
|
||||
| **Model** | None | Required. Any model your key can reach. |
|
||||
| **Model** | None | Required. Any model your key can reach. **Ollama Models** can pick one for you, and a pick takes precedence over this value until you reset it from the setup screen. |
|
||||
| **Ollama Host** | `https://ollama.com` | Set to `http://localhost:11434` for a local server. |
|
||||
| **Location** | None | The place Lam should treat as yours, for example `Cebu City, Philippines`. Optional. The only exact location available, and the only one that stops anything being inferred. |
|
||||
| **IP Geolocation** | off | Off by default. When on, and only when **Location** is empty, your IP is sent to ipwho.is to resolve a rough area. |
|
||||
@@ -135,6 +137,44 @@ vicinae 'vicinae://launch/lam-agent/chat?fallbackText=summarise%20this'
|
||||
| **Delete Old Chats After** | Disabled | Permanently deletes a conversation once you have not opened it for this long, along with any screenshots it captured. Nothing is ever deleted while this is off. See [Auto-deletion](#auto-deletion). |
|
||||
| **Tool Call Limit** | 8 rounds | How many rounds of tool calls Lam may make before it has to answer with what it has. `Unlimited` removes the cap. A call that repeats itself with identical arguments ends the turn either way. |
|
||||
|
||||
## Models
|
||||
|
||||
**Ollama Models** lists everything the configured host can reach, filtered by the search bar. Each row shows the parameter count and how much disk it takes, with tags for the model in use and for anything that reports image input. Picking one writes it to LocalStorage and `loadPreferences` layers it over the `Model` preference, so it takes effect on the next question with no restart.
|
||||
|
||||
| Action | Notes |
|
||||
| --- | --- |
|
||||
| **Use This Model** | The primary action. Immediate, and survives restarts. |
|
||||
| **Copy Model Name** | For pasting into the `Model` preference. |
|
||||
| **Delete Model** | Confirmed first. A server you run only. |
|
||||
| **Pull a Model…** | Confirmed by the download itself. A server you run only. |
|
||||
|
||||
### Why a pick is not written to the preference
|
||||
|
||||
It cannot be. `@vicinae/api` exposes `getPreferenceValues` and nothing that sets one, so the only way to change the preference would be to write Vicinae's own config file. That does not work: preferences are read once at startup and held in memory, so an edit to `~/.config/vicinae/settings.json` is invisible until Vicinae restarts. This was measured rather than assumed. Pointing `ollamaHost` at a local listener that logged every request, then launching a command, showed the requests still going to the old host.
|
||||
|
||||
Writing that file would also be a lost update, because Vicinae owns it and rewrites it (its own header warns as much), and it is undocumented, so it would break on any release that changes the layout.
|
||||
|
||||
So the pick lives in LocalStorage and the preference is left alone. The cost is stated rather than hidden: **while a pick is active, editing the `Model` preference has no effect.** That is why the setup screen shows both values, `ornith-1.5:9b (chosen in Models; the preference says gpt-oss:120b)`, and offers **Reset to the Preference**.
|
||||
|
||||
### Local servers versus Ollama Cloud
|
||||
|
||||
Pull and delete only appear when the host is not `ollama.com`, because they are operations on a machine you control.
|
||||
|
||||
A local daemon's list is often mixed, and the two kinds are worth telling apart:
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| **local** | Real weights on disk. Deleting frees the gigabytes the size column shows. |
|
||||
| **cloud** | A `:cloud` entry the daemon proxies to ollama.com (`remote_model`/`remote_host` in the API). Only a few hundred bytes are stored, so there is no size to show, and deleting drops the reference rather than any weights. |
|
||||
|
||||
Deleting is confirmed with wording that matches the kind, since "delete this model" means something different in each case. Deleting the model that is currently in use clears the pick, so the next question does not fail on a name that no longer exists. If the preference is what pointed at it, that is said instead: a preference cannot be edited from here.
|
||||
|
||||
### Pull
|
||||
|
||||
`POST /api/pull` streams its progress, and the toast follows it: the manifest status first, then `downloading · 42% · 1.2 GB / 2.9 GB`, then success. A bad name comes back as **HTTP 200 with the error in the body**, so the stream is checked and not just the status, which is the same trap the chat stream already had.
|
||||
|
||||
The download runs inside this command's worker, so **closing the launcher cancels a pull.** The command description says so, and the form says so again next to the field.
|
||||
|
||||
## Tools
|
||||
|
||||
Lam calls these on its own when they would improve an answer. All four are read-only, and all four are the entire list of things Lam is allowed to do without being asked twice. Screen capture is deliberately not among them.
|
||||
@@ -211,6 +251,7 @@ When a conversation cannot be read there is no way to know which captures are st
|
||||
src/
|
||||
chat.tsx # "chat" command entrypoint, thin wrapper
|
||||
snip.tsx # "snip" command entrypoint, no-view: captures then hands over
|
||||
models.tsx # "models" command: browse, pick, pull, delete
|
||||
sessions.tsx # browse, reopen, delete conversations
|
||||
setup.tsx # connection check + live model list
|
||||
lib/
|
||||
@@ -230,6 +271,7 @@ src/
|
||||
code.ts # code fences and links, repaired
|
||||
preferences.ts # preferences, normalised
|
||||
hooks/useChat.ts # the chat state machine
|
||||
hooks/usePreferences.ts # preferences + the model pick, loaded once
|
||||
hooks/useVision.ts # capability check, for gating the snip action
|
||||
snip.ts # the snip -> chat handoff contract
|
||||
components/ChatView.tsx # the chat surface
|
||||
@@ -273,6 +315,8 @@ The rendered height stays capped at 200px whatever is asked for (`Math.min(_rawH
|
||||
|
||||
**The logic layer has no Vicinae imports.** `agent.ts`, `ollama.ts`, `tools.ts` and friends are plain TypeScript, which is why they unit test without a runtime. A test enforces this.
|
||||
|
||||
**Preferences have exactly one reader.** `loadPreferences` applies the model chosen in **Ollama Models** on top of what Vicinae holds, and it is asynchronous because that pick lives in LocalStorage. `getLamPreferences` is deliberately not exported, because a second, synchronous reader would look correct and silently ignore the pick. Views take preferences through `hooks/usePreferences`, and the chat view is split in two so that `useChat` is only ever created once the model is known, rather than being pinned to whatever the preference said a moment earlier.
|
||||
|
||||
**Sessions are capped twice over**, once per turn and once per session, because `LocalStorage` has no documented ceiling.
|
||||
|
||||
**Screen capture is owned, not borrowed.** There is no screenshot call anywhere in `@vicinae/api`, and `Clipboard` moves text, html, urls and a file path and nothing binary, so a bitmap can never be read back out of the clipboard either. The only way to get a region of the screen is to shell out, which is fair game because extensions run under Node. The useful consequence is that the extension picks the output path and reads the file it wrote, rather than guessing what some other application left on the clipboard.
|
||||
|
||||
@@ -28,6 +28,13 @@
|
||||
"description": "Browse, reopen, and clean up your past conversations with Lam.",
|
||||
"mode": "view"
|
||||
},
|
||||
{
|
||||
"name": "models",
|
||||
"title": "Ollama Models",
|
||||
"subtitle": "Lam Agent",
|
||||
"description": "Browse the models your Ollama host can reach, pick the one Lam should use, and pull or delete models on a server you run. A pick takes effect immediately; a pull keeps downloading until it finishes, so leave the launcher open.",
|
||||
"mode": "view"
|
||||
},
|
||||
{
|
||||
"name": "setup",
|
||||
"title": "Set Up Lam Agent",
|
||||
|
||||
@@ -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