From d6cebfdbb5b493ad2651399de018e91fe5f2e581 Mon Sep 17 00:00:00 2001 From: ojfbot <151410806+ojfbot@users.noreply.github.com> Date: Thu, 26 Mar 2026 17:36:15 -0500 Subject: [PATCH 1/3] feat: add Ollama local model support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Ollama as an LLM provider alongside Anthropic, enabling free local inference on Apple Silicon. Users select provider in settings — Ollama requires no API key, just a running local server. - Add LLMProvider type and provider-aware config (types, storage migration) - Create LLMClient abstraction with Anthropic and Ollama implementations - Refactor GroupThinkAI to accept config object, use provider-agnostic client - Update background handlers to pass full config, relax API key guard for Ollama - Add provider selector, per-provider model lists, and connection test to settings UI - Gate app init on provider readiness (not just API key presence) Co-Authored-By: Claude Opus 4.6 --- src/app/index.tsx | 12 ++-- src/background/index.ts | 8 +-- src/lib/ai.ts | 81 ++++++++++-------------- src/lib/llm-client.ts | 124 ++++++++++++++++++++++++++++++++++++ src/lib/storage.ts | 11 +++- src/options/index.html | 27 ++++++++ src/options/index.tsx | 136 ++++++++++++++++++++++++++++++++++------ src/types/index.ts | 8 ++- 8 files changed, 330 insertions(+), 77 deletions(-) create mode 100644 src/lib/llm-client.ts diff --git a/src/app/index.tsx b/src/app/index.tsx index 0ce24f9..0d8d6a3 100644 --- a/src/app/index.tsx +++ b/src/app/index.tsx @@ -142,9 +142,11 @@ function App() { [clearTimers, countTabs], ); - // Initial grouping — runs once when API key becomes available + // Initial grouping — runs once when config is ready + const configReady = + config && (config.provider === "ollama" || !!config.anthropicApiKey); useEffect(() => { - if (!config?.anthropicApiKey || didInitRef.current) return; + if (!configReady || didInitRef.current) return; didInitRef.current = true; (async () => { @@ -165,7 +167,7 @@ function App() { doGrouping(specificity); })(); // eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally runs once - }, [config?.anthropicApiKey]); + }, [configReady]); const handleSpecificityChange = useCallback( (newSpec: number) => { @@ -269,8 +271,8 @@ function App() { (loadingPhase === "idle" && displayGrouping && !treemapReady); const chaosPhase: "chaos" | "coalescing" = loadingPhase === "chaos" ? "chaos" : "coalescing"; - // ── No API key state ── - if (config && !config.anthropicApiKey) { + // ── No API key state (only applies to Anthropic provider) ── + if (config && config.provider === "anthropic" && !config.anthropicApiKey) { return (
diff --git a/src/background/index.ts b/src/background/index.ts index c4577f2..abcba54 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -191,7 +191,7 @@ async function handleMessage(message: { type: string; [key: string]: unknown }): case "group-tabs": { const config = await Storage.getConfig(); - if (!config.anthropicApiKey) { + if (config.provider === "anthropic" && !config.anthropicApiKey) { throw new Error("No API key configured. Open GroupThink settings to add one."); } @@ -207,7 +207,7 @@ async function handleMessage(message: { type: string; [key: string]: unknown }): } satisfies GroupingResponse; } - const ai = new GroupThinkAI(config.anthropicApiKey, config.model); + const ai = new GroupThinkAI(config); const specificity = (message.specificity as number) ?? config.specificity; // ── Gather browser context (if enabled) ── @@ -281,7 +281,7 @@ async function handleMessage(message: { type: string; [key: string]: unknown }): case "refine-grouping": { const config = await Storage.getConfig(); - if (!config.anthropicApiKey) { + if (config.provider === "anthropic" && !config.anthropicApiKey) { throw new Error("No API key configured."); } @@ -304,7 +304,7 @@ async function handleMessage(message: { type: string; [key: string]: unknown }): ungrouped: currentGrouping.ungrouped.map((t) => t.id), }); - const ai = new GroupThinkAI(config.anthropicApiKey, config.model); + const ai = new GroupThinkAI(config); const chatHistory = history .filter((m) => m.role === "user" || m.role === "assistant") .map((m) => ({ role: m.role as "user" | "assistant", content: m.content })); diff --git a/src/lib/ai.ts b/src/lib/ai.ts index 05ba4a8..23ca56c 100644 --- a/src/lib/ai.ts +++ b/src/lib/ai.ts @@ -1,7 +1,7 @@ -import Anthropic from "@anthropic-ai/sdk"; import { z } from "zod"; -import type { LLMGroupingResult, TabInfo } from "../types"; -import { buildGroupingPrompt, buildRefinePrompt, buildSweepPrompt, SYSTEM_PROMPT } from "./prompts"; +import type { GroupThinkConfig, LLMGroupingResult, LLMProvider, TabInfo } from "../types"; +import { type LLMClient, createLLMClient } from "./llm-client"; +import { buildGroupingPrompt, buildRefinePrompt, buildSweepPrompt, getSystemPrompt } from "./prompts"; // ── Zod schema for LLM response validation ── @@ -53,9 +53,7 @@ function normalizeGroup(group: Record): Record } } - // Safety: if no tabIds found at all, look harder if (!("tabIds" in out)) { - // Check for any array of numbers as a fallback for (const [key, value] of Object.entries(group)) { if ( Array.isArray(value) && @@ -70,7 +68,6 @@ function normalizeGroup(group: Record): Record break; } } - // Last resort: empty array if (!("tabIds" in out)) { console.warn( `[GroupThink] normalize: no tab IDs found in group "${group.label ?? "?"}", keys: ${Object.keys(group).join(", ")}`, @@ -108,21 +105,16 @@ function normalizeResponse(parsed: unknown): unknown { obj.ungrouped = []; } - // Pass through tabDescriptions as-is (Zod validates it) - return obj; } export class GroupThinkAI { - private client: Anthropic; - private model: string; + private client: LLMClient; + private provider: LLMProvider; - constructor(apiKey: string, model: string) { - this.client = new Anthropic({ - apiKey, - dangerouslyAllowBrowser: true, - }); - this.model = model; + constructor(config: GroupThinkConfig) { + this.client = createLLMClient(config); + this.provider = config.provider; } async groupTabs( @@ -139,21 +131,25 @@ export class GroupThinkAI { })); const t0 = performance.now(); - const response = await this.client.messages.create({ - model: this.model, - max_tokens: 4096, - system: SYSTEM_PROMPT, + const response = await this.client.complete({ + system: getSystemPrompt(this.provider), messages: [ - { role: "user", content: buildGroupingPrompt(tabSummaries, specificity, contextHints) }, + { + role: "user", + content: buildGroupingPrompt(tabSummaries, specificity, contextHints, { + provider: this.provider, + }), + }, ], + maxTokens: this.provider === "ollama" ? 2048 : 4096, }); const elapsed = Math.round(performance.now() - t0); console.log( - `[GroupThink] LLM response in ${elapsed}ms, usage: input=${response.usage.input_tokens} output=${response.usage.output_tokens}`, + `[GroupThink] LLM response in ${elapsed}ms, usage: input=${response.inputTokens ?? "?"} output=${response.outputTokens ?? "?"}`, ); - return this.parseResponse(response); + return this.parseResponse(response.text); } async sweepUncategorized( @@ -165,19 +161,18 @@ export class GroupThinkAI { ); const t0 = performance.now(); - const response = await this.client.messages.create({ - model: this.model, - max_tokens: 1024, - system: SYSTEM_PROMPT, + const response = await this.client.complete({ + system: getSystemPrompt(this.provider), messages: [{ role: "user", content: buildSweepPrompt(existingGroups, ungroupedTabs) }], + maxTokens: 1024, }); const elapsed = Math.round(performance.now() - t0); console.log( - `[GroupThink] LLM sweep response in ${elapsed}ms, usage: input=${response.usage.input_tokens} output=${response.usage.output_tokens}`, + `[GroupThink] LLM sweep response in ${elapsed}ms, usage: input=${response.inputTokens ?? "?"} output=${response.outputTokens ?? "?"}`, ); - return this.parseResponse(response); + return this.parseResponse(response.text); } async refineGrouping( @@ -189,36 +184,27 @@ export class GroupThinkAI { `[GroupThink] refineGrouping: "${userInstruction}", ${history.length} history msgs`, ); - const messages: Anthropic.MessageParam[] = [ - ...history.map((m) => ({ - role: m.role as "user" | "assistant", - content: m.content, - })), - { role: "user", content: buildRefinePrompt(currentGroupingJson, userInstruction) }, + const messages = [ + ...history, + { role: "user" as const, content: buildRefinePrompt(currentGroupingJson, userInstruction) }, ]; const t0 = performance.now(); - const response = await this.client.messages.create({ - model: this.model, - max_tokens: 2048, - system: SYSTEM_PROMPT, + const response = await this.client.complete({ + system: getSystemPrompt(this.provider), messages, + maxTokens: 2048, }); const elapsed = Math.round(performance.now() - t0); console.log( - `[GroupThink] LLM refine response in ${elapsed}ms, usage: input=${response.usage.input_tokens} output=${response.usage.output_tokens}`, + `[GroupThink] LLM refine response in ${elapsed}ms, usage: input=${response.inputTokens ?? "?"} output=${response.outputTokens ?? "?"}`, ); - return this.parseResponse(response); + return this.parseResponse(response.text); } - private parseResponse(response: Anthropic.Message): LLMGroupingResult { - const text = response.content - .filter((block): block is Anthropic.TextBlock => block.type === "text") - .map((block) => block.text) - .join(""); - + private parseResponse(text: string): LLMGroupingResult { // Strip markdown fences if present const cleaned = text .replace(/^```(?:json)?\s*/m, "") @@ -229,7 +215,6 @@ export class GroupThinkAI { const parsed = JSON.parse(cleaned); - // Log raw field names per group before normalization if (Array.isArray(parsed.groups)) { parsed.groups.forEach((g: Record, i: number) => { console.log( diff --git a/src/lib/llm-client.ts b/src/lib/llm-client.ts new file mode 100644 index 0000000..d6665ef --- /dev/null +++ b/src/lib/llm-client.ts @@ -0,0 +1,124 @@ +import Anthropic from "@anthropic-ai/sdk"; +import type { GroupThinkConfig } from "../types"; + +// ── Provider-agnostic interface ── + +export interface LLMRequest { + system: string; + messages: Array<{ role: "user" | "assistant"; content: string }>; + maxTokens: number; +} + +export interface LLMResponse { + text: string; + inputTokens?: number; + outputTokens?: number; +} + +export interface LLMClient { + complete(request: LLMRequest): Promise; +} + +// ── Anthropic ── + +class AnthropicClient implements LLMClient { + private client: Anthropic; + private model: string; + + constructor(apiKey: string, model: string) { + this.client = new Anthropic({ apiKey, dangerouslyAllowBrowser: true }); + this.model = model; + } + + async complete(request: LLMRequest): Promise { + const response = await this.client.messages.create({ + model: this.model, + max_tokens: request.maxTokens, + system: request.system, + messages: request.messages.map((m) => ({ + role: m.role as "user" | "assistant", + content: m.content, + })), + }); + + const text = response.content + .filter((block): block is Anthropic.TextBlock => block.type === "text") + .map((block) => block.text) + .join(""); + + return { + text, + inputTokens: response.usage.input_tokens, + outputTokens: response.usage.output_tokens, + }; + } +} + +// ── Ollama (OpenAI-compatible API) ── + +class OllamaClient implements LLMClient { + private baseUrl: string; + private model: string; + private temperature: number; + + constructor(baseUrl: string, model: string, temperature = 0.15) { + this.baseUrl = baseUrl.replace(/\/$/, ""); + this.model = model; + this.temperature = temperature; + } + + async complete(request: LLMRequest): Promise { + const messages = [ + { role: "system" as const, content: request.system }, + ...request.messages, + ]; + + const res = await fetch(`${this.baseUrl}/v1/chat/completions`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: this.model, + messages, + max_tokens: request.maxTokens, + temperature: this.temperature, + }), + }); + + if (!res.ok) { + const body = await res.text().catch(() => ""); + throw new Error(`Ollama request failed (${res.status}): ${body.slice(0, 200)}`); + } + + const data = (await res.json()) as { + choices: Array<{ message: { content: string } }>; + usage?: { prompt_tokens?: number; completion_tokens?: number }; + }; + + const text = data.choices?.[0]?.message?.content ?? ""; + + return { + text, + inputTokens: data.usage?.prompt_tokens, + outputTokens: data.usage?.completion_tokens, + }; + } +} + +// ── Factory ── + +export function createLLMClient(config: GroupThinkConfig): LLMClient { + switch (config.provider) { + case "anthropic": { + if (!config.anthropicApiKey) { + throw new Error("No Anthropic API key configured. Open GroupThink settings to add one."); + } + return new AnthropicClient(config.anthropicApiKey, config.model); + } + case "ollama": { + const baseUrl = config.ollamaBaseUrl || "http://localhost:11434"; + return new OllamaClient(baseUrl, config.model); + } + default: + throw new Error(`Unknown provider: ${config.provider}`); + } +} diff --git a/src/lib/storage.ts b/src/lib/storage.ts index 350a414..a9e9d10 100644 --- a/src/lib/storage.ts +++ b/src/lib/storage.ts @@ -12,7 +12,16 @@ export class Storage { static async getConfig(): Promise { const result = await chrome.storage.local.get(KEYS.CONFIG); - return result[KEYS.CONFIG] ?? { ...DEFAULT_CONFIG }; + const stored = result[KEYS.CONFIG]; + if (!stored) return { ...DEFAULT_CONFIG }; + + // Migration: existing installs without provider field + if (!stored.provider) { + stored.provider = stored.anthropicApiKey ? "anthropic" : "ollama"; + if (!stored.ollamaBaseUrl) stored.ollamaBaseUrl = "http://localhost:11434"; + } + + return { ...DEFAULT_CONFIG, ...stored }; } static async setConfig(config: Partial): Promise { diff --git a/src/options/index.html b/src/options/index.html index 721b8d8..681b4b3 100644 --- a/src/options/index.html +++ b/src/options/index.html @@ -85,6 +85,33 @@ color: var(--gt-text-secondary); } .btn-ghost:hover { background: var(--gt-surface-hover); } + .field-row { + display: flex; + gap: 8px; + align-items: center; + } + .field-row input { flex: 1; } + .btn-test { + flex-shrink: 0; + background: var(--gt-surface-1); + color: var(--gt-text-secondary); + border: 1px solid var(--gt-border); + white-space: nowrap; + } + .btn-test:hover { background: var(--gt-surface-hover); } + .btn-test--ok { + color: var(--gt-success); + border-color: var(--gt-success); + } + .btn-test:disabled { opacity: 0.6; cursor: default; } + .hint--error { color: var(--gt-error, #f44); } + code { + font-family: var(--gt-font-mono, monospace); + font-size: 12px; + background: var(--gt-surface-1); + padding: 1px 5px; + border-radius: 3px; + } .saved { font-size: 13px; color: var(--gt-success); diff --git a/src/options/index.tsx b/src/options/index.tsx index c068580..a0a8cad 100644 --- a/src/options/index.tsx +++ b/src/options/index.tsx @@ -1,14 +1,21 @@ import { useEffect, useState } from "react"; import { createRoot } from "react-dom/client"; import { ThemeManager } from "../lib/theme"; -import type { GroupThinkConfig } from "../types"; +import type { GroupThinkConfig, LLMProvider } from "../types"; -const MODELS = [ +const ANTHROPIC_MODELS = [ { value: "claude-sonnet-4-20250514", label: "Claude Sonnet 4 (recommended)" }, { value: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5 (cheapest)" }, { value: "claude-opus-4-20250514", label: "Claude Opus 4" }, ]; +const OLLAMA_MODELS = [ + { value: "qwen2.5:7b", label: "Qwen 2.5 7B (recommended)" }, + { value: "llama3.1:8b", label: "Llama 3.1 8B" }, + { value: "mistral:7b", label: "Mistral 7B" }, + { value: "gemma2:9b", label: "Gemma 2 9B" }, +]; + type EnrichmentLevel = "off" | "basic" | "full"; const ENRICHMENT_PERMISSIONS: Record = { @@ -18,22 +25,36 @@ const ENRICHMENT_PERMISSIONS: Record = { }; function Options() { + const [provider, setProvider] = useState("ollama"); const [apiKey, setApiKey] = useState(""); - const [model, setModel] = useState("claude-sonnet-4-20250514"); + const [ollamaBaseUrl, setOllamaBaseUrl] = useState("http://localhost:11434"); + const [model, setModel] = useState("qwen2.5:7b"); const [theme, setTheme] = useState<"light" | "dark" | "auto">("auto"); const [enrichment, setEnrichment] = useState("off"); const [saved, setSaved] = useState(false); + const [ollamaStatus, setOllamaStatus] = useState<"idle" | "testing" | "ok" | "error">("idle"); + const [ollamaError, setOllamaError] = useState(""); useEffect(() => { ThemeManager.apply(); chrome.runtime.sendMessage({ type: "get-config" }).then((config: GroupThinkConfig) => { + if (config.provider) setProvider(config.provider); if (config.anthropicApiKey) setApiKey(config.anthropicApiKey); + if (config.ollamaBaseUrl) setOllamaBaseUrl(config.ollamaBaseUrl); if (config.model) setModel(config.model); if (config.theme) setTheme(config.theme); if (config.contextEnrichment) setEnrichment(config.contextEnrichment); }); }, []); + const handleProviderChange = (newProvider: LLMProvider) => { + setProvider(newProvider); + // Switch to default model for the new provider + const models = newProvider === "ollama" ? OLLAMA_MODELS : ANTHROPIC_MODELS; + setModel(models[0].value); + setOllamaStatus("idle"); + }; + const handleEnrichmentChange = async (level: EnrichmentLevel) => { if (level === "off") { setEnrichment(level); @@ -45,17 +66,43 @@ function Options() { if (granted) { setEnrichment(level); } - // If denied, keep current level } catch { // Permission request failed — keep current level } }; + const testOllamaConnection = async () => { + setOllamaStatus("testing"); + setOllamaError(""); + try { + const url = ollamaBaseUrl.replace(/\/$/, ""); + const res = await fetch(`${url}/api/tags`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = (await res.json()) as { models?: Array<{ name: string }> }; + const modelNames = data.models?.map((m) => m.name) ?? []; + if (modelNames.length === 0) { + setOllamaStatus("error"); + setOllamaError("Ollama is running but has no models. Run: ollama pull qwen2.5:7b"); + } else { + setOllamaStatus("ok"); + } + } catch (err) { + setOllamaStatus("error"); + setOllamaError( + err instanceof Error + ? `Cannot reach Ollama: ${err.message}` + : "Cannot reach Ollama server", + ); + } + }; + const handleSave = async () => { await chrome.runtime.sendMessage({ type: "set-config", config: { + provider, anthropicApiKey: apiKey, + ollamaBaseUrl, model, theme, contextEnrichment: enrichment, @@ -66,30 +113,83 @@ function Options() { setTimeout(() => setSaved(false), 2000); }; + const models = provider === "ollama" ? OLLAMA_MODELS : ANTHROPIC_MODELS; + return ( <>

GroupThink

Settings

- - setApiKey(e.target.value)} - placeholder="sk-ant-..." - autoComplete="off" - /> -

- Stored locally in your browser. Never sent anywhere except Anthropic's API. -

+ + + {provider === "ollama" && ( +

+ Runs against a local model on your machine. Install Ollama, pull a model, and run{" "} + ollama serve. +

+ )}
+ {provider === "anthropic" && ( +
+ + setApiKey(e.target.value)} + placeholder="sk-ant-..." + autoComplete="off" + /> +

+ Stored locally in your browser. Never sent anywhere except Anthropic's API. +

+
+ )} + + {provider === "ollama" && ( +
+ +
+ { + setOllamaBaseUrl(e.target.value); + setOllamaStatus("idle"); + }} + placeholder="http://localhost:11434" + /> + +
+ {ollamaStatus === "error" &&

{ollamaError}

} +
+ )} +