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

This commit was merged in pull request #16.
This commit is contained in:
2026-10-05 09:43:00 +00:00
parent 05fd9ff13b
commit 26e05dea5b
22 changed files with 1948 additions and 65 deletions
+64 -5
View File
@@ -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,53 @@ 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. |
| **Unload Model** | Only on a row that is loaded. No confirmation, because nothing is deleted. |
| **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. |
### Loaded models
Installed and loaded are different states, and the list shows both. `GET /api/ps` reports what is holding memory right now, and those rows get a green `loaded` tag, so "why is my GPU busy" has an answer in the same place as "what do I have".
**Unload** frees the memory without deleting anything, which is why it needs no confirmation: the worst case is that the next question pays to load the model again. It is `POST /api/generate` with `keep_alive: 0` and no prompt, which is how Ollama expresses "release this now", and what its own `ollama stop` does. The reply reports `done_reason: "unload"`, and the toast says how much was freed.
A host that does not serve `/api/ps` (older builds answer 404) simply shows no loaded tags. That check resolving to nothing is not an error, and it must not take the model list down with it.
### 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.
@@ -164,6 +213,10 @@ This extension does not use GPS. That would mean CoreLocation on macOS, which ha
Web search results and fetched pages can be large. Lam truncates them before they reach the model.
**The web tools use a different path per host, and there is no path that works for both.** Ollama Cloud serves `/api/web_search` and `/api/web_fetch`; a local daemon serves the same operations under `/api/experimental/`. Both were probed directly: the cloud route answers `401` without a key, which means it exists, and its experimental alias is a `404`; a local 0.35.1 does the exact opposite. This was a live bug, because the extension posted to the cloud path on every host, so pointing it at a local server turned every web call into a 404 and the model, handed a failure and no explanation, told the user its web tools were not configured. The other path is now tried when the first answers 404, since that means the route is absent rather than the request being refused.
**Lam is told what it is.** `describeRuntime` in `history.ts` appends a short paragraph naming the model, the server it is served by, whether a `:cloud` name means the conversation leaves the machine, which tools are actually available, and whether it can read images. It is appended even when the user has written their own system prompt, because none of it is a matter of style. Without it the model guessed, and the guesses were confident and wrong: a local model described Vicinae as "a desktop launcher for Ollama", reported its own web tools as unavailable, and answered a question about an attached screenshot by reasoning out loud about whether it was able to see, instead of describing it.
**A turn that runs out of tool calls still ends in an answer.** The loop stops after 8 rounds by default, and the first version of it stopped by throwing away every preamble the model had written and returning a notice about the loop: asked for the fish in every Valheim biome, it researched for five rounds and then said nothing useful at all. The budget is now spent on a *closing round* with the tools taken away and an instruction to answer from what was gathered and to name what it could not confirm. So the worst case is a partial answer that says what is missing, rather than an app-generated message about its own control flow. If the closing round produces nothing either, the last preamble is used, and only then a notice. The closing round is why the default cap is 8 rather than 5: the extra round is not more searching, it is the answer.
The cap is a preference rather than a constant, because how many rounds a question needs is a property of the question and of the model. `Unlimited` is there for research that genuinely needs a dozen pages.
@@ -211,6 +264,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 +284,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 +328,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.
@@ -289,6 +346,8 @@ The hand-off goes through `snip` specifically rather than straight to `chat` on
**An attached image is sent once and never replayed.** It is the largest thing a request can carry, so `buildMessages` attaches it to the question it belongs to and drops it afterwards, exactly as it already drops tool output. The cost is that a follow-up about a screenshot needs the image attached again; the alternative is re-sending every screenshot in the conversation on every turn. Only the path and mime type go into storage: `MAX_STORED_CHARS_PER_TURN` exists so one turn cannot dominate `LocalStorage`, and base64 would blow past it on a single 1080p capture.
**Whether the model can see is a tri-state, and the prompt gets the whole answer.** `canSeeImages` stays fail-closed, because offering the snip action to a model that cannot read would waste the selection gesture. But the system prompt needs `"unknown"` kept apart from `"no"`: telling a model it cannot see when the check merely failed is a false claim about the user's setup, and telling it nothing is what made it reason about its own capability instead of describing the image it had been sent. A model name that does not exist on the server is the common way to end up at `"unknown"`, which is one more reason the Models command picks names from the list rather than taking them typed.
## Development
```sh