feat: snip a region into chat, and make links clickable (#14)

This commit was merged in pull request #14.
This commit is contained in:
2026-10-05 08:38:50 +00:00
parent f5d1ced4d6
commit 576c5946f7
44 changed files with 3890 additions and 120 deletions
+21 -5
View File
@@ -1,11 +1,13 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { runAgent } from "../agent";
import { toStoredAttachment } from "../attachments";
import { ipPlace } from "../geo";
import { buildMessages, composeSystemPrompt } from "../history";
import { OllamaError } from "../ollama";
import type { ResolvedPreferences } from "../preferences";
import { createSession, createTurn, saveSession, withTitleFromFirstTurn } from "../sessions";
import { toolsFor } from "../tools";
import type { Session } from "../types";
import type { PendingAttachment, Session } from "../types";
import { errorMessage, isAbortError } from "../utils";
/**
@@ -41,7 +43,7 @@ export interface UseChat {
/** Short activity line such as "Searching the web", shown while a turn is in flight. */
status?: string;
error?: string;
ask: (prompt: string) => void;
ask: (prompt: string, attachment?: PendingAttachment) => void;
cancel: () => void;
replaceSession: (session: Session) => void;
newSession: () => void;
@@ -119,16 +121,22 @@ export function useChat(options: UseChatOptions): UseChat {
}, []);
const ask = useCallback(
(rawPrompt: string) => {
(rawPrompt: string, attachment?: PendingAttachment) => {
const prompt = rawPrompt.trim();
if (!prompt || liveRef.current) return;
const preferences = preferencesRef.current;
const base = sessionRef.current;
// Only the path, mime type and dimensions are kept on the turn. The base64
// payload is used for this one request and then dropped, so it never
// reaches storage. The dimensions have to survive because the transcript
// lays the preview out long after the payload is gone.
const attachments = attachment ? [toStoredAttachment(attachment)] : [];
// The turn is inserted immediately with an empty response so the UI shows
// the question while the answer streams in beneath it.
const turn = createTurn(prompt, preferences.model);
const turn = createTurn(prompt, preferences.model, Date.now(), attachments);
const started = withTitleFromFirstTurn({
...base,
model: preferences.model,
@@ -152,6 +160,7 @@ export function useChat(options: UseChatOptions): UseChat {
composeSystemPrompt(preferences.systemPrompt, preferences.webEnabled),
base.turns,
prompt,
attachment ? [attachment.base64] : undefined,
);
void (async () => {
@@ -166,7 +175,14 @@ export function useChat(options: UseChatOptions): UseChat {
think: preferences.think,
messages,
tools: toolsFor({ webEnabled: preferences.webEnabled }),
toolContext: { host: preferences.host, apiKey: preferences.apiKey },
toolContext: {
host: preferences.host,
apiKey: preferences.apiKey,
place: preferences.place,
// Only wired up when the user switched it on, so an off setting
// cannot reach the network for a location at all.
ipPlace: preferences.ipGeolocation ? ipPlace : undefined,
},
signal: controller.signal,
events: {
onContentDelta: (delta) => {
+54
View File
@@ -0,0 +1,54 @@
import { useEffect, useState } from "react";
import type { ResolvedPreferences } from "../preferences";
import { visionSupport } from "../vision";
/**
* Whether the current model accepts images, for gating the snip action.
*
* Fails closed: only a confirmed `"yes"` turns the feature on. A model that
* cannot see would otherwise accept a screenshot and answer as though it had,
* which is worse than the action not being offered.
*
* The wording for a *failed* check lives in the snip command rather than here,
* because that is the only place with somewhere to say it (a HUD). This hook
* only decides whether to show an action.
*/
export interface VisionState {
/** True only once Ollama has confirmed the model reports the `vision` capability. */
canSeeImages: boolean;
/** True while an answer for this model is still outstanding. */
isChecking: boolean;
}
export function useVisionSupport(preferences: ResolvedPreferences): VisionState {
const [canSeeImages, setCanSeeImages] = useState(false);
const [isChecking, setIsChecking] = useState(true);
useEffect(() => {
let cancelled = false;
const controller = new AbortController();
setIsChecking(true);
void visionSupport({
host: preferences.host,
apiKey: preferences.apiKey,
model: preferences.model,
signal: controller.signal,
})
.then((support) => {
if (!cancelled) setCanSeeImages(support === "yes");
})
.finally(() => {
if (!cancelled) setIsChecking(false);
});
return () => {
cancelled = true;
controller.abort();
};
}, [preferences.host, preferences.apiKey, preferences.model]);
return { canSeeImages, isChecking };
}