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,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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user