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

This commit was merged in pull request #16.
This commit is contained in:
2026-10-05 09:43:00 +00:00
parent 05fd9ff13b
commit 26e05dea5b
22 changed files with 1948 additions and 65 deletions
+25 -2
View File
@@ -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,
+42
View File
@@ -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 };
}
+16 -3
View File
@@ -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 };
}