feat: Ollama models command, with load/pull/delete and unload (#16)
This commit was merged in pull request #16.
This commit is contained in:
@@ -3,9 +3,10 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { readAttachment } from "../attachments";
|
||||
import { runAutoDelete } from "../cleanup";
|
||||
import { useChat, type ToolActivity } from "../hooks/useChat";
|
||||
import { usePreferences } from "../hooks/usePreferences";
|
||||
import { useVisionSupport } from "../hooks/useVision";
|
||||
import { buildThreadMarkdown, renderAttachments, type TranscriptOptions } from "../markdown";
|
||||
import { getLamPreferences } from "../preferences";
|
||||
import type { ResolvedPreferences } from "../preferences";
|
||||
import { deleteSession, listSessions, loadSession, summarizeSession } from "../sessions";
|
||||
import { resolveStartup } from "../startup";
|
||||
import { buildThread } from "../thread";
|
||||
@@ -94,17 +95,49 @@ interface SessionRow {
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export function ChatView({
|
||||
/** Shown while the conversation and the preferences are still being read. */
|
||||
function LoadingChat() {
|
||||
return <List isLoading navigationTitle="Lam" searchBarPlaceholder="Loading conversations…" />;
|
||||
}
|
||||
|
||||
/**
|
||||
* The chat surface, once its preferences are known.
|
||||
*
|
||||
* Split in two so that {@link ChatView} can wait for the model before the state
|
||||
* machine starts. `useChat` is created with the preferences it will use, so
|
||||
* handing it preferences that are still loading would pin the conversation to
|
||||
* whatever the preference said before the selection arrived.
|
||||
*
|
||||
* The outer half does nothing but that wait, and the loading state costs one
|
||||
* LocalStorage read, which is a few milliseconds while the user is still moving
|
||||
* their hands towards the keyboard.
|
||||
*/
|
||||
export function ChatView(props: ChatViewProps) {
|
||||
const { preferences } = usePreferences();
|
||||
|
||||
if (!preferences) return <LoadingChat />;
|
||||
return <ChatSession {...props} preferences={preferences} />;
|
||||
}
|
||||
|
||||
interface ChatSessionProps extends ChatViewProps {
|
||||
preferences: ResolvedPreferences;
|
||||
}
|
||||
|
||||
function ChatSession({
|
||||
preferences,
|
||||
initialSession,
|
||||
initialPrompt,
|
||||
initialSessionId,
|
||||
initialAttachmentPath,
|
||||
launchNote,
|
||||
}: ChatViewProps) {
|
||||
const preferences = useMemo(() => getLamPreferences(), []);
|
||||
const chat = useChat({ preferences, initialSession });
|
||||
}: ChatSessionProps) {
|
||||
// The capability check is resolved before the chat exists, because its answer
|
||||
// is part of the system prompt: a model that knows it can read images describes
|
||||
// the one it is sent, and one that does not know reasons about itself instead.
|
||||
const vision = useVisionSupport(preferences);
|
||||
const chat = useChat({ preferences, initialSession, vision: vision.support });
|
||||
const { replaceSession } = chat;
|
||||
const { canSeeImages } = useVisionSupport(preferences);
|
||||
const { canSeeImages } = vision;
|
||||
const { activity } = chat;
|
||||
|
||||
const [input, setInput] = useState(initialPrompt ?? "");
|
||||
@@ -396,7 +429,7 @@ export function ChatView({
|
||||
};
|
||||
|
||||
if (startup === "loading" || summaries === undefined) {
|
||||
return <List isLoading navigationTitle="Lam" searchBarPlaceholder="Loading conversations…" />;
|
||||
return <LoadingChat />;
|
||||
}
|
||||
|
||||
const placeholder = chat.status ? `${chat.status}. Press ⏎ to stop` : "Lam is thinking. Press ⏎ to stop";
|
||||
|
||||
@@ -3,10 +3,29 @@
|
||||
export const DEFAULT_HOST = "https://ollama.com";
|
||||
export const DEFAULT_MODEL = "gpt-oss:120b";
|
||||
|
||||
/**
|
||||
* Hosts that run the models themselves rather than handing them to a server.
|
||||
*
|
||||
* Only these can be managed: `pull` and `delete` are operations on a machine the
|
||||
* user controls, and against Ollama Cloud there is nothing to pull or remove.
|
||||
*/
|
||||
export const CLOUD_HOSTNAMES = new Set(["ollama.com", "api.ollama.com"]);
|
||||
|
||||
/** LocalStorage keys. The index is small; each session body gets its own key so saving one never rewrites the others. */
|
||||
export const STORAGE_INDEX_KEY = "lam.sessions.index";
|
||||
export const STORAGE_SESSION_PREFIX = "lam.session.";
|
||||
|
||||
/**
|
||||
* The model picked in the Models command.
|
||||
*
|
||||
* Separate from the `model` preference because an extension cannot write its own
|
||||
* preferences: Vicinae reads them once at startup and holds them in memory, so
|
||||
* writing the config file has no effect until it restarts (verified against
|
||||
* 0.29.1). A selection in LocalStorage takes effect immediately instead, which is
|
||||
* the whole point of picking a model from a list.
|
||||
*/
|
||||
export const STORAGE_MODEL_KEY = "lam.model.selected";
|
||||
|
||||
/**
|
||||
* How many rounds of tool calls one turn may spend before the model is told to
|
||||
* answer with what it has.
|
||||
|
||||
+78
-4
@@ -1,5 +1,5 @@
|
||||
import { DEFAULT_SYSTEM_PROMPT, RECENCY_HINT } from "./constants";
|
||||
import type { ApiMessage, ChatTurn } from "./types";
|
||||
import type { ApiMessage, ChatTurn, VisionSupport } from "./types";
|
||||
|
||||
/**
|
||||
* Turns stored turns into the message array sent to the model.
|
||||
@@ -34,9 +34,83 @@ export function buildMessages(
|
||||
return messages;
|
||||
}
|
||||
|
||||
/** Uses the user's prompt if they set one, otherwise Lam's default. */
|
||||
export function composeSystemPrompt(customPrompt: string, webEnabled: boolean): string {
|
||||
/**
|
||||
* What Lam knows about the setup it is running in.
|
||||
*
|
||||
* Gathered from preferences and the capability check rather than asked for, so
|
||||
* the model can answer questions about itself instead of guessing. The guesses
|
||||
* were the problem: with none of this in the prompt, a local model described
|
||||
* Vicinae as "a desktop launcher for Ollama", reported its own web tools as not
|
||||
* configured, and reasoned out loud about whether it could see an image it had
|
||||
* just been sent.
|
||||
*/
|
||||
export interface RuntimeContext {
|
||||
model: string;
|
||||
host: string;
|
||||
/** True when the host is a server the user runs rather than ollama.com. */
|
||||
selfHosted: boolean;
|
||||
webTools: boolean;
|
||||
vision: VisionSupport;
|
||||
}
|
||||
|
||||
/**
|
||||
* The paragraph that tells the model what it is and what it can do.
|
||||
*
|
||||
* Kept factual and short. Every claim here is one the extension can stand
|
||||
* behind, because a model that is told something wrong about itself will state
|
||||
* it confidently, and the user has no way to tell that apart from an answer.
|
||||
*
|
||||
* The `:cloud` convention is stated as a convention rather than as a lookup. The
|
||||
* authoritative field is `remote_host` on `GET /api/tags`, which the Models
|
||||
* command reads and shows as a `cloud` tag; fetching it here would mean a second
|
||||
* request on every model change for one sentence. The convention is Ollama's own
|
||||
* and holds on this machine, where both `:cloud` entries report
|
||||
* `remote_host: https://ollama.com` and the local model reports nothing.
|
||||
*/
|
||||
export function describeRuntime(context: RuntimeContext): string {
|
||||
const served = context.selfHosted
|
||||
? `the Ollama server at ${context.host}, which is reachable on this machine`
|
||||
: "Ollama Cloud";
|
||||
|
||||
const lines = [
|
||||
"What you are, for questions about your own setup. Do not speculate beyond this:",
|
||||
`- You are Lam, running inside the Vicinae launcher on the user's desktop, and answering as \`${context.model}\` served by ${served}.`,
|
||||
"- You see only what the user types or attaches. You cannot read their screen, files, clipboard, calendar or anything else on the machine, and you cannot act on it.",
|
||||
];
|
||||
|
||||
if (context.selfHosted) {
|
||||
lines.push(
|
||||
"- A model name ending in `:cloud` is proxied by that server to ollama.com, so the conversation leaves this machine when it is used. Anything else is running locally.",
|
||||
);
|
||||
}
|
||||
|
||||
lines.push(
|
||||
context.webTools
|
||||
? "- You have `web_search` and `web_fetch`. Use them rather than guessing about anything recent, and say so if they fail."
|
||||
: "- You have no web tools in this session. Say that plainly rather than offering to look something up.",
|
||||
);
|
||||
|
||||
lines.push(
|
||||
context.vision === "yes"
|
||||
? "- You can read images. A screenshot attached to a question is yours to describe, so describe it."
|
||||
: context.vision === "no"
|
||||
? "- You cannot read images. If a question depends on one, say so plainly."
|
||||
: "- Whether you can read images has not been confirmed. If one is attached with a question, describe what you see rather than reasoning about whether you are able to.",
|
||||
);
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Uses the user's prompt if they set one, otherwise Lam's default.
|
||||
*
|
||||
* The runtime paragraph is appended either way. It is not a matter of style, so
|
||||
* replacing the default prompt should not silently remove it, and a user writing
|
||||
* their own prompt has even less reason to expect the model to know what it is.
|
||||
*/
|
||||
export function composeSystemPrompt(customPrompt: string, context: RuntimeContext): string {
|
||||
const parts = [customPrompt.trim() || DEFAULT_SYSTEM_PROMPT];
|
||||
if (webEnabled) parts.push(RECENCY_HINT);
|
||||
if (context.webTools) parts.push(RECENCY_HINT);
|
||||
parts.push(describeRuntime(context));
|
||||
return parts.join("\n\n");
|
||||
}
|
||||
|
||||
@@ -3,11 +3,12 @@ import { runAgent } from "../agent";
|
||||
import { toStoredAttachment } from "../attachments";
|
||||
import { ipPlace } from "../geo";
|
||||
import { buildMessages, composeSystemPrompt } from "../history";
|
||||
import { isSelfHosted } from "../ollama";
|
||||
import { OllamaError } from "../ollama";
|
||||
import type { ResolvedPreferences } from "../preferences";
|
||||
import { createSession, createTurn, saveSession, withTitleFromFirstTurn } from "../sessions";
|
||||
import { toolsFor } from "../tools";
|
||||
import type { PendingAttachment, Session } from "../types";
|
||||
import type { PendingAttachment, Session, VisionSupport } from "../types";
|
||||
import { errorMessage, isAbortError } from "../utils";
|
||||
|
||||
/**
|
||||
@@ -33,6 +34,14 @@ export interface UseChatOptions {
|
||||
preferences: ResolvedPreferences;
|
||||
/** Restores an existing conversation, e.g. when reopening from the sessions command. */
|
||||
initialSession?: Session;
|
||||
/**
|
||||
* Whether the model can read images, for the system prompt.
|
||||
*
|
||||
* Passed in rather than checked here because the answer is already known to the
|
||||
* view: the snip action is gated on the same value, and asking twice would mean
|
||||
* two capability requests per model change.
|
||||
*/
|
||||
vision?: VisionSupport;
|
||||
}
|
||||
|
||||
/** Which tool is running right now, and how stale it is getting. */
|
||||
@@ -85,6 +94,11 @@ export function useChat(options: UseChatOptions): UseChat {
|
||||
|
||||
const sessionRef = useRef(session);
|
||||
const preferencesRef = useRef(options.preferences);
|
||||
/**
|
||||
* Read through a ref for the same reason as the preferences: `ask` is a stable
|
||||
* callback, and the capability check lands after the first render.
|
||||
*/
|
||||
const visionRef = useRef<VisionSupport>(options.vision ?? "unknown");
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const liveRef = useRef(false);
|
||||
const responseRef = useRef("");
|
||||
@@ -93,6 +107,9 @@ export function useChat(options: UseChatOptions): UseChat {
|
||||
const flushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
preferencesRef.current = options.preferences;
|
||||
// Only ever forwards. The check starts as "unknown", and a value arriving is
|
||||
// news; going back to "unknown" would be the view telling us less than before.
|
||||
if (options.vision) visionRef.current = options.vision;
|
||||
|
||||
/** Updates state and the ref together, so `ask` never reads a stale session. */
|
||||
const commit = useCallback((next: Session) => {
|
||||
@@ -181,7 +198,13 @@ export function useChat(options: UseChatOptions): UseChat {
|
||||
abortRef.current = controller;
|
||||
|
||||
const messages = buildMessages(
|
||||
composeSystemPrompt(preferences.systemPrompt, preferences.webEnabled),
|
||||
composeSystemPrompt(preferences.systemPrompt, {
|
||||
model: preferences.model,
|
||||
host: preferences.host,
|
||||
selfHosted: isSelfHosted(preferences.host),
|
||||
webTools: preferences.webEnabled,
|
||||
vision: visionRef.current,
|
||||
}),
|
||||
base.turns,
|
||||
prompt,
|
||||
attachment ? [attachment.base64] : undefined,
|
||||
|
||||
@@ -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 type { ResolvedPreferences } from "../preferences";
|
||||
import type { VisionSupport } from "../types";
|
||||
import { visionSupport } from "../vision";
|
||||
|
||||
/**
|
||||
@@ -17,12 +18,22 @@ import { visionSupport } from "../vision";
|
||||
export interface VisionState {
|
||||
/** True only once Ollama has confirmed the model reports the `vision` capability. */
|
||||
canSeeImages: boolean;
|
||||
/**
|
||||
* The same answer without the fail-closed collapse.
|
||||
*
|
||||
* Carried because the system prompt needs the difference: telling a model it
|
||||
* cannot see when the check merely failed is a false claim about the user's
|
||||
* setup, and telling it nothing is what made it reason out loud about its own
|
||||
* capability instead of describing the image.
|
||||
*/
|
||||
support: VisionSupport;
|
||||
/** True while an answer for this model is still outstanding. */
|
||||
isChecking: boolean;
|
||||
}
|
||||
|
||||
export function useVisionSupport(preferences: ResolvedPreferences): VisionState {
|
||||
const [canSeeImages, setCanSeeImages] = useState(false);
|
||||
const [support, setSupport] = useState<VisionSupport>("unknown");
|
||||
const [isChecking, setIsChecking] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -37,8 +48,10 @@ export function useVisionSupport(preferences: ResolvedPreferences): VisionState
|
||||
model: preferences.model,
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then((support) => {
|
||||
if (!cancelled) setCanSeeImages(support === "yes");
|
||||
.then((answer) => {
|
||||
if (cancelled) return;
|
||||
setSupport(answer);
|
||||
setCanSeeImages(answer === "yes");
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setIsChecking(false);
|
||||
@@ -50,5 +63,5 @@ export function useVisionSupport(preferences: ResolvedPreferences): VisionState
|
||||
};
|
||||
}, [preferences.host, preferences.apiKey, preferences.model]);
|
||||
|
||||
return { canSeeImages, isChecking };
|
||||
return { canSeeImages, support, isChecking };
|
||||
}
|
||||
|
||||
+328
-18
@@ -1,4 +1,5 @@
|
||||
import { DEFAULT_HOST } from "./constants";
|
||||
import { CLOUD_HOSTNAMES, DEFAULT_HOST } from "./constants";
|
||||
import { formatBytes } from "./utils";
|
||||
import type { ApiMessage, ModelInfo, ToolCall, ToolDefinition } from "./types";
|
||||
|
||||
/**
|
||||
@@ -33,6 +34,23 @@ function authHeaders(apiKey: string): Record<string, string> {
|
||||
return key ? { Authorization: `Bearer ${key}` } : {};
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the host is a server whose models can be pulled and deleted.
|
||||
*
|
||||
* Decided from the hostname rather than the scheme or the port: a local server
|
||||
* reached over https is still local, and Ollama Cloud is the one host that
|
||||
* cannot be managed this way. A host that will not parse is treated as local,
|
||||
* because every request against it fails on its own terms anyway, and the caller
|
||||
* hides nothing it might have needed.
|
||||
*/
|
||||
export function isSelfHosted(host: string | undefined): boolean {
|
||||
try {
|
||||
return !CLOUD_HOSTNAMES.has(new URL(normalizeHost(host)).hostname);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
async function safeText(response: Response): Promise<string> {
|
||||
try {
|
||||
return await response.text();
|
||||
@@ -54,7 +72,7 @@ function describeHttpError(status: number, bodyText: string, host: string): Olla
|
||||
case 404:
|
||||
return new OllamaError(`Ollama returned 404 from ${host}.`, {
|
||||
status,
|
||||
hint: "The model name may be wrong, or this host may not expose that endpoint. Local Ollama servers do not provide the web tools.",
|
||||
hint: "The model name may be wrong, or this host may not expose that endpoint.",
|
||||
});
|
||||
case 429:
|
||||
return new OllamaError("Ollama is rate limiting this key.", {
|
||||
@@ -193,7 +211,14 @@ export interface ListModelsOptions {
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/** Lists the models this API key can reach. On Ollama Cloud this reflects the account's entitlements. */
|
||||
/**
|
||||
* Lists the models this host can reach.
|
||||
*
|
||||
* On Ollama Cloud this reflects the account's entitlements. On a local server it
|
||||
* reflects what is installed, which mixes real weights with `:cloud` entries the
|
||||
* daemon proxies to ollama.com, so the two are told apart by `remoteHost` rather
|
||||
* than by anything in the name.
|
||||
*/
|
||||
export async function listModels(options: ListModelsOptions): Promise<ModelInfo[]> {
|
||||
const host = normalizeHost(options.host);
|
||||
|
||||
@@ -207,18 +232,255 @@ export async function listModels(options: ListModelsOptions): Promise<ModelInfo[
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as {
|
||||
models?: Array<{ name?: string; model?: string; details?: { parameter_size?: string } }>;
|
||||
models?: Array<{
|
||||
name?: string;
|
||||
model?: string;
|
||||
size?: number;
|
||||
remote_host?: string;
|
||||
capabilities?: string[];
|
||||
details?: { parameter_size?: string };
|
||||
}>;
|
||||
};
|
||||
|
||||
return (payload.models ?? [])
|
||||
.map((model) => ({
|
||||
name: model.name ?? model.model ?? "",
|
||||
parameterSize: model.details?.parameter_size,
|
||||
size: typeof model.size === "number" ? model.size : undefined,
|
||||
// Every one of these is optional because older servers simply do not send
|
||||
// it, and a field that is missing must not be guessed at.
|
||||
capabilities: Array.isArray(model.capabilities) ? model.capabilities : undefined,
|
||||
remoteHost: typeof model.remote_host === "string" && model.remote_host ? model.remote_host : undefined,
|
||||
}))
|
||||
.filter((model) => model.name.length > 0)
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
export interface DeleteModelOptions {
|
||||
host: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/**
|
||||
* A model the server has in memory right now, as reported by `GET /api/ps`.
|
||||
*
|
||||
* Distinct from {@link ModelInfo}, which is what is on disk. A model can be
|
||||
* installed without being loaded, and loaded models are the ones holding VRAM.
|
||||
*/
|
||||
export interface RunningModel {
|
||||
name: string;
|
||||
parameterSize?: string;
|
||||
/**
|
||||
* Bytes held in memory. Equal to the on-disk size when the model is fully
|
||||
* offloaded to the GPU, and less when part of it is running on the CPU.
|
||||
*/
|
||||
sizeVram?: number;
|
||||
/** When the server will drop it on its own, as a Unix timestamp. */
|
||||
expiresAt?: number;
|
||||
}
|
||||
|
||||
export interface ListRunningModelsOptions {
|
||||
host: string;
|
||||
apiKey: string;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/**
|
||||
* The models holding memory on this server.
|
||||
*
|
||||
* Resolves to an empty list rather than throwing. Nothing here is worth failing
|
||||
* over: a server that does not serve `/api/ps` (older builds answer 404) simply
|
||||
* has nothing known to be loaded, and taking the whole model list down because
|
||||
* the *running* list is unavailable would be a bad trade.
|
||||
*/
|
||||
export async function listRunningModels(options: ListRunningModelsOptions): Promise<RunningModel[]> {
|
||||
const host = normalizeHost(options.host);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${host}/api/ps`, {
|
||||
headers: authHeaders(options.apiKey),
|
||||
signal: options.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) return [];
|
||||
|
||||
const payload = (await response.json()) as {
|
||||
models?: Array<{
|
||||
name?: string;
|
||||
model?: string;
|
||||
size_vram?: number;
|
||||
expires_at?: string;
|
||||
details?: { parameter_size?: string };
|
||||
}>;
|
||||
};
|
||||
|
||||
return (payload.models ?? [])
|
||||
.map((model) => ({
|
||||
name: model.name ?? model.model ?? "",
|
||||
parameterSize: model.details?.parameter_size,
|
||||
sizeVram: typeof model.size_vram === "number" ? model.size_vram : undefined,
|
||||
expiresAt: model.expires_at ? Date.parse(model.expires_at) : undefined,
|
||||
}))
|
||||
.filter((model) => model.name.length > 0);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export interface UnloadModelOptions {
|
||||
host: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops a model out of memory, freeing the VRAM it was holding.
|
||||
*
|
||||
* `POST /api/generate` with `keep_alive: 0` and no prompt is how Ollama says
|
||||
* "release this now"; its own `ollama stop` does the same thing. Probed against
|
||||
* 0.35.1: the reply is `{"done":true,"done_reason":"unload"}` and `/api/ps`
|
||||
* comes back empty. The `DELETE` form of the same route answers 405, so there is
|
||||
* no delete-shaped way to do this.
|
||||
*
|
||||
* Nothing is deleted, so a mistake costs a reload. That is why this needs no
|
||||
* confirmation while `deleteModel` does.
|
||||
*/
|
||||
export async function unloadModel(options: UnloadModelOptions): Promise<void> {
|
||||
const host = normalizeHost(options.host);
|
||||
|
||||
const response = await fetch(`${host}/api/generate`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...authHeaders(options.apiKey) },
|
||||
// No `prompt`, so this never generates anything, and `stream: false` because
|
||||
// there is exactly one reply to read.
|
||||
body: JSON.stringify({ model: options.model, keep_alive: 0, stream: false }),
|
||||
signal: options.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw describeHttpError(response.status, await safeText(response), host);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a model from a server the user runs.
|
||||
*
|
||||
* `DELETE`, not `POST`: Ollama 0.35.1 answers the POST form of this route with
|
||||
* `405 method not allowed`, which is the kind of change that is invisible until a
|
||||
* user tries it.
|
||||
*
|
||||
* There is no undo and no trash, so the caller confirms first. For the same
|
||||
* reason a 404 is not swallowed into a success: "no model called that" is worth
|
||||
* saying out loud, because it usually means something else already removed it, or
|
||||
* that the host is not what the user thinks it is.
|
||||
*/
|
||||
export async function deleteModel(options: DeleteModelOptions): Promise<void> {
|
||||
const host = normalizeHost(options.host);
|
||||
|
||||
const response = await fetch(`${host}/api/delete`, {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json", ...authHeaders(options.apiKey) },
|
||||
body: JSON.stringify({ model: options.model }),
|
||||
signal: options.signal,
|
||||
});
|
||||
|
||||
if (response.status === 404) {
|
||||
throw new OllamaError(`${host} has no model called ${options.model}.`, {
|
||||
status: 404,
|
||||
hint: "It may have been removed already, or by another machine sharing this server.",
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw describeHttpError(response.status, await safeText(response), host);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One line of pull progress, exactly as Ollama reports it.
|
||||
*
|
||||
* `total` and `completed` are only present once a download starts, so the caller
|
||||
* has to render a status with no numbers in it for the first few ticks.
|
||||
*/
|
||||
export interface PullProgress {
|
||||
status: string;
|
||||
total?: number;
|
||||
completed?: number;
|
||||
}
|
||||
|
||||
export interface PullModelOptions {
|
||||
host: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pulls a model onto a server the user runs, yielding progress as it arrives.
|
||||
*
|
||||
* Streaming rather than a blocking request, because a download is minutes long
|
||||
* and a request that returns nothing until it is finished leaves the window with
|
||||
* no way to tell a slow pull from a hung one.
|
||||
*
|
||||
* A bad model name comes back as **HTTP 200 with an `error` field in the body**
|
||||
* (verified against 0.35.1), so the status line is not enough to decide whether
|
||||
* this worked: the stream has to be read and checked, exactly as the chat stream
|
||||
* already does.
|
||||
*/
|
||||
export async function* pullModel(options: PullModelOptions): AsyncGenerator<PullProgress> {
|
||||
const host = normalizeHost(options.host);
|
||||
|
||||
const response = await fetch(`${host}/api/pull`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...authHeaders(options.apiKey) },
|
||||
body: JSON.stringify({ model: options.model, stream: true }),
|
||||
signal: options.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw describeHttpError(response.status, await safeText(response), host);
|
||||
}
|
||||
if (!response.body) {
|
||||
throw new OllamaError("Ollama returned an empty response body.");
|
||||
}
|
||||
|
||||
for await (const event of parseNdjsonStream<{
|
||||
status?: string;
|
||||
total?: number;
|
||||
completed?: number;
|
||||
error?: string;
|
||||
}>(response.body)) {
|
||||
if (event.error) throw new OllamaError(event.error);
|
||||
if (!event.status) continue;
|
||||
|
||||
yield { status: event.status, total: event.total, completed: event.completed };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One line describing a pull, for the toast that stays up while it runs.
|
||||
*
|
||||
* Lives here rather than in the view because it is a reading of Ollama's own
|
||||
* payload rather than a layout decision, and because the arithmetic is the part
|
||||
* of a progress bar that goes wrong silently: a total of zero, or a `completed`
|
||||
* that arrives before the `total` it is measured against, would otherwise print
|
||||
* `NaN%` or `Infinity%` at the user.
|
||||
*/
|
||||
export function describePullProgress(progress: PullProgress): string {
|
||||
const { status, total, completed } = progress;
|
||||
|
||||
if (!total || total <= 0 || completed === undefined || !Number.isFinite(completed)) {
|
||||
return status;
|
||||
}
|
||||
|
||||
const percent = Math.min(100, Math.max(0, Math.round((completed / total) * 100)));
|
||||
|
||||
return `${status} · ${percent}% · ${formatBytes(completed)} / ${formatBytes(total)}`;
|
||||
}
|
||||
|
||||
export interface ModelCapabilitiesOptions {
|
||||
host: string;
|
||||
apiKey: string;
|
||||
@@ -257,6 +519,53 @@ export function supportsVision(capabilities: string[]): boolean {
|
||||
return capabilities.includes("vision");
|
||||
}
|
||||
|
||||
/**
|
||||
* Posts to a web tool, at the path this kind of host actually serves it from.
|
||||
*
|
||||
* The two kinds of host expose the same two operations at different paths, and
|
||||
* there is no path that works for both. Measured, not looked up:
|
||||
*
|
||||
* - `https://ollama.com/api/web_search` answers 401 without a key, so the route
|
||||
* exists, and its `/api/experimental/` alias is a 404.
|
||||
* - A local 0.35.1 daemon answers 404 to that path, and serves
|
||||
* `/api/experimental/web_search` with real results.
|
||||
*
|
||||
* This was a live bug: pointing the extension at a local server made every web
|
||||
* tool call a 404, and the model, handed a failure and no explanation, told the
|
||||
* user its web tools simply were not configured.
|
||||
*
|
||||
* The other path is tried when the first answers 404, because that is a server
|
||||
* built the other way round rather than a request being refused. A 404 body is
|
||||
* drained before the retry so the connection is not left hanging.
|
||||
*/
|
||||
async function postWebTool(options: {
|
||||
host: string;
|
||||
apiKey: string;
|
||||
kind: "search" | "fetch";
|
||||
body: Record<string, unknown>;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<Response> {
|
||||
const host = normalizeHost(options.host);
|
||||
|
||||
const [preferred, fallback] = isSelfHosted(host)
|
||||
? [`/api/experimental/web_${options.kind}`, `/api/web_${options.kind}`]
|
||||
: [`/api/web_${options.kind}`, `/api/experimental/web_${options.kind}`];
|
||||
|
||||
const post = (path: string) =>
|
||||
fetch(`${host}${path}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...authHeaders(options.apiKey) },
|
||||
body: JSON.stringify(options.body),
|
||||
signal: options.signal,
|
||||
});
|
||||
|
||||
const response = await post(preferred);
|
||||
if (response.status !== 404) return response;
|
||||
|
||||
await response.text().catch(() => "");
|
||||
return post(fallback);
|
||||
}
|
||||
|
||||
export interface WebSearchResult {
|
||||
title: string;
|
||||
url: string;
|
||||
@@ -272,20 +581,19 @@ export interface WebSearchOptions {
|
||||
}
|
||||
|
||||
export async function webSearch(options: WebSearchOptions): Promise<WebSearchResult[]> {
|
||||
const host = normalizeHost(options.host);
|
||||
|
||||
const body: Record<string, unknown> = { query: options.query };
|
||||
if (typeof options.maxResults === "number") body.max_results = options.maxResults;
|
||||
|
||||
const response = await fetch(`${host}/api/web_search`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...authHeaders(options.apiKey) },
|
||||
body: JSON.stringify(body),
|
||||
const response = await postWebTool({
|
||||
host: options.host,
|
||||
apiKey: options.apiKey,
|
||||
kind: "search",
|
||||
body,
|
||||
signal: options.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw describeHttpError(response.status, await safeText(response), host);
|
||||
throw describeHttpError(response.status, await safeText(response), normalizeHost(options.host));
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as { results?: WebSearchResult[] };
|
||||
@@ -306,19 +614,21 @@ export interface WebFetchOptions {
|
||||
}
|
||||
|
||||
export async function webFetch(options: WebFetchOptions): Promise<WebFetchResult> {
|
||||
const host = normalizeHost(options.host);
|
||||
|
||||
const response = await fetch(`${host}/api/web_fetch`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...authHeaders(options.apiKey) },
|
||||
body: JSON.stringify({ url: options.url }),
|
||||
const response = await postWebTool({
|
||||
host: options.host,
|
||||
apiKey: options.apiKey,
|
||||
kind: "fetch",
|
||||
body: { url: options.url },
|
||||
signal: options.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw describeHttpError(response.status, await safeText(response), host);
|
||||
throw describeHttpError(response.status, await safeText(response), normalizeHost(options.host));
|
||||
}
|
||||
|
||||
// `links` comes back as null from a local daemon rather than as an empty list,
|
||||
// which `??` covers. (Probed directly: a local `web_fetch` answered
|
||||
// `{"title":...,"content":...,"links":null}`.)
|
||||
const payload = (await response.json()) as Partial<WebFetchResult>;
|
||||
return {
|
||||
title: payload.title ?? options.url,
|
||||
|
||||
+75
-4
@@ -1,5 +1,5 @@
|
||||
import { getPreferenceValues } from "@vicinae/api";
|
||||
import { DEFAULT_HOST, DEFAULT_MAX_TOOL_ROUNDS, DEFAULT_MODEL } from "./constants";
|
||||
import { getPreferenceValues, LocalStorage } from "@vicinae/api";
|
||||
import { DEFAULT_HOST, DEFAULT_MAX_TOOL_ROUNDS, DEFAULT_MODEL, STORAGE_MODEL_KEY } from "./constants";
|
||||
|
||||
/**
|
||||
* Preferences, read and normalised.
|
||||
@@ -26,10 +26,29 @@ export interface LamPreferences {
|
||||
maxToolRounds?: string;
|
||||
}
|
||||
|
||||
/** Where the model in use came from, so the UI can explain a divergence from the preference. */
|
||||
export type ModelSource = "preference" | "selection";
|
||||
|
||||
/** Preferences with every optional value filled in, so callers never branch on undefined. */
|
||||
export interface ResolvedPreferences {
|
||||
apiKey: string;
|
||||
model: string;
|
||||
/**
|
||||
* Whether {@link ResolvedPreferences.model} came from the preference or from a
|
||||
* pick in the Models command.
|
||||
*
|
||||
* Carried rather than inferred, because the two disagree silently otherwise:
|
||||
* the setup screen would show one model while the chat used another, and the
|
||||
* user would have no way to tell which one to change.
|
||||
*/
|
||||
modelSource: ModelSource;
|
||||
/**
|
||||
* What the `Model` preference says, whatever is in use.
|
||||
*
|
||||
* Kept alongside `model` so a screen can answer the question a selection
|
||||
* raises: what would happen if I reset it?
|
||||
*/
|
||||
preferenceModel: string;
|
||||
host: string;
|
||||
systemPrompt: string;
|
||||
/**
|
||||
@@ -166,10 +185,15 @@ function describeSpan(milliseconds: number): string {
|
||||
|
||||
export function resolvePreferences(raw: LamPreferences): ResolvedPreferences {
|
||||
const thinking = (raw.thinking ?? "").trim();
|
||||
const model = (raw.model ?? "").trim() || DEFAULT_MODEL;
|
||||
|
||||
return {
|
||||
apiKey: (raw.apiKey ?? "").trim(),
|
||||
model: (raw.model ?? "").trim() || DEFAULT_MODEL,
|
||||
model,
|
||||
// Anything read straight from Vicinae is the preference by definition. The
|
||||
// selection is layered on top in `loadPreferences`.
|
||||
modelSource: "preference",
|
||||
preferenceModel: model,
|
||||
host: (raw.ollamaHost ?? "").trim() || DEFAULT_HOST,
|
||||
systemPrompt: (raw.systemPrompt ?? "").trim(),
|
||||
place: (raw.location ?? "").trim(),
|
||||
@@ -190,10 +214,57 @@ export function resolvePreferences(raw: LamPreferences): ResolvedPreferences {
|
||||
};
|
||||
}
|
||||
|
||||
export function getLamPreferences(): ResolvedPreferences {
|
||||
/**
|
||||
* Vicinae's own preferences, resolved and normalised. Not exported: everything
|
||||
* outside this module should go through {@link loadPreferences}, which also
|
||||
* applies the model chosen in the Models command.
|
||||
*/
|
||||
function getLamPreferences(): ResolvedPreferences {
|
||||
return resolvePreferences(getPreferenceValues<LamPreferences>());
|
||||
}
|
||||
|
||||
/**
|
||||
* The model chosen in the Models command, if there is one.
|
||||
*
|
||||
* Never throws. Storage that cannot be read means no selection rather than a
|
||||
* broken command, because the fallback (the preference) is always a valid
|
||||
* answer.
|
||||
*/
|
||||
export async function readModelSelection(): Promise<string | undefined> {
|
||||
const stored = await LocalStorage.getItem<string>(STORAGE_MODEL_KEY).catch(() => undefined);
|
||||
const trimmed = (stored ?? "").trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
/** Remembers a model as the one to use, taking precedence over the preference. */
|
||||
export async function selectModel(model: string): Promise<void> {
|
||||
await LocalStorage.setItem(STORAGE_MODEL_KEY, model.trim());
|
||||
}
|
||||
|
||||
/** Drops the selection, handing the model back to the `Model` preference. */
|
||||
export async function clearModelSelection(): Promise<void> {
|
||||
await LocalStorage.removeItem(STORAGE_MODEL_KEY).catch(() => undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* The preferences the rest of the extension should actually use.
|
||||
*
|
||||
* The only reader anyone else needs: it applies the model chosen in the Models
|
||||
* command on top of the preferences Vicinae holds. A second, synchronous reader
|
||||
* would be a trap, because it looks right and silently ignores the selection,
|
||||
* which is why `getLamPreferences` above is not exported.
|
||||
*
|
||||
* The model is the only thing a selection can override. Everything else here has
|
||||
* a single home in Vicinae's own settings, and adding a shadow copy of those
|
||||
* would make "where do I change this" a question with two answers.
|
||||
*/
|
||||
export async function loadPreferences(): Promise<ResolvedPreferences> {
|
||||
const base = getLamPreferences();
|
||||
const selected = await readModelSelection();
|
||||
|
||||
return selected ? { ...base, model: selected, modelSource: "selection" } : base;
|
||||
}
|
||||
|
||||
/** True when the extension cannot work until the user supplies a key. */
|
||||
export function needsSetup(preferences: ResolvedPreferences): boolean {
|
||||
return preferences.apiKey.length === 0;
|
||||
|
||||
+1
-1
@@ -169,7 +169,7 @@ export async function executeTool(
|
||||
}
|
||||
} catch (error) {
|
||||
const suffix = isWebTool(name)
|
||||
? " The web tools require an Ollama Cloud host and an API key with web search enabled."
|
||||
? " Web search needs Ollama Cloud, or a local Ollama server new enough to serve it (0.35 and later)."
|
||||
: "";
|
||||
return { content: `${errorMessage(error)}${suffix}`, isError: true };
|
||||
}
|
||||
|
||||
@@ -148,10 +148,42 @@ export interface SessionSummary {
|
||||
attachmentPaths?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the model in use can read images, as far as the capability check knows.
|
||||
*
|
||||
* `"unknown"` is deliberately distinct from `"no"`: a failed request is not
|
||||
* evidence that a model cannot see, and reporting it as one would be a false
|
||||
* claim about the user's setup. Lives here rather than in `vision.ts` so that
|
||||
* modules which must stay free of Vicinae imports can name it.
|
||||
*/
|
||||
export type VisionSupport = "yes" | "no" | "unknown";
|
||||
|
||||
/** A model as reported by `GET /api/tags`. */
|
||||
export interface ModelInfo {
|
||||
name: string;
|
||||
parameterSize?: string;
|
||||
/**
|
||||
* How much disk the model takes, in bytes.
|
||||
*
|
||||
* Meaningless for a cloud model proxied through a local daemon: that entry is
|
||||
* a few hundred bytes of manifest, so the caller shows nothing rather than
|
||||
* "326 B", which is what `ollama list` does too.
|
||||
*/
|
||||
size?: number;
|
||||
/**
|
||||
* What the model says it can do, e.g. `["tools", "vision"]`.
|
||||
*
|
||||
* Only newer servers send this. Absent means unknown, never "no": reporting a
|
||||
* missing field as a lack of vision would be a false claim about the user's
|
||||
* setup.
|
||||
*/
|
||||
capabilities?: string[];
|
||||
/**
|
||||
* Set when this entry is a proxy rather than weights on disk, naming the server
|
||||
* that actually runs it (`https://ollama.com`). Deleting one of these drops a
|
||||
* reference; it does not free the gigabytes the size column implies.
|
||||
*/
|
||||
remoteHost?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -50,6 +50,33 @@ export function recencyGroup(timestamp: number, now: number = Date.now()): Recen
|
||||
return "Older";
|
||||
}
|
||||
|
||||
/**
|
||||
* A byte count for a person to read: "6.6 GB", "326 B".
|
||||
*
|
||||
* Decimal units, not the 1024-based ones a memory figure would use, because
|
||||
* that is what every other tool in this ecosystem prints: `ollama list` shows
|
||||
* 6550813657 bytes as 6.6 GB, and a number that disagreed with the terminal the
|
||||
* user just looked at would read as a bug in this extension.
|
||||
*
|
||||
* `undefined` for a size that is absent or nonsense, so a caller can leave the
|
||||
* field out rather than print "NaN B".
|
||||
*/
|
||||
export function formatBytes(bytes: number | undefined): string | undefined {
|
||||
if (bytes === undefined || !Number.isFinite(bytes) || bytes < 0) return undefined;
|
||||
if (bytes < 1000) return `${Math.round(bytes)} B`;
|
||||
|
||||
const units = ["kB", "MB", "GB", "TB"];
|
||||
let value = bytes / 1000;
|
||||
let unit = 0;
|
||||
|
||||
while (value >= 1000 && unit < units.length - 1) {
|
||||
value /= 1000;
|
||||
unit += 1;
|
||||
}
|
||||
|
||||
return `${value.toFixed(1)} ${units[unit]}`;
|
||||
}
|
||||
|
||||
/** Pulls a human-readable message out of anything that can be thrown. */
|
||||
export function errorMessage(error: unknown): string {
|
||||
if (error instanceof Error) return error.message;
|
||||
|
||||
+6
-1
@@ -1,5 +1,6 @@
|
||||
import { LocalStorage } from "@vicinae/api";
|
||||
import { modelCapabilities, supportsVision } from "./ollama";
|
||||
import type { VisionSupport } from "./types";
|
||||
|
||||
/**
|
||||
* The cached answer to "can this model see images?".
|
||||
@@ -17,7 +18,11 @@ import { modelCapabilities, supportsVision } from "./ollama";
|
||||
/** One key per host and model, so switching model invalidates by construction. */
|
||||
const VISION_CACHE_PREFIX = "lam.vision.";
|
||||
|
||||
export type VisionSupport = "yes" | "no" | "unknown";
|
||||
// Re-exported so callers can keep naming the type from here, where it belongs
|
||||
// conceptually. The definition moved to `types.ts` so that `history.ts`, which
|
||||
// must not import a Vicinae module, can describe the same tri-state in the
|
||||
// system prompt.
|
||||
export type { VisionSupport };
|
||||
|
||||
export interface VisionCheck {
|
||||
host: string;
|
||||
|
||||
Reference in New Issue
Block a user