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) => {