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