diff --git a/src/app/index.tsx b/src/app/index.tsx index 0ce24f9..159fe57 100644 --- a/src/app/index.tsx +++ b/src/app/index.tsx @@ -142,9 +142,10 @@ 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 +166,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 +270,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..46da425 100644 --- a/src/lib/ai.ts +++ b/src/lib/ai.ts @@ -1,7 +1,12 @@ -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 { createLLMClient, type LLMClient } from "./llm-client"; +import { + buildGroupingPrompt, + buildRefinePrompt, + buildSweepPrompt, + getSystemPrompt, +} from "./prompts"; // ── Zod schema for LLM response validation ── @@ -53,9 +58,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 +73,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 +110,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 +136,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 +166,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 +189,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 +220,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/grouping.ts b/src/lib/grouping.ts index 28cda9d..43e3a66 100644 --- a/src/lib/grouping.ts +++ b/src/lib/grouping.ts @@ -83,8 +83,75 @@ export function shouldRePrompt( /** * Post-LLM enforcement: ensure large groups have children at high specificity. - * Client-side domain clustering — no LLM call. + * Uses title keyword clustering — no LLM call. */ + +const STOPWORDS = new Set([ + "the", + "a", + "an", + "in", + "on", + "at", + "to", + "for", + "of", + "and", + "or", + "is", + "are", + "was", + "were", + "be", + "with", + "from", + "by", + "that", + "this", + "it", + "new", + "all", + "your", + "how", + "get", + "set", + "use", + "not", + "can", + "will", + "just", + "more", + "about", + "has", + "been", + "its", + "you", + "what", + "when", + "who", + "which", + "where", + "why", + "top", + "best", + "com", + "www", + "http", + "https", + "html", + "page", + "home", + "app", + "web", +]); + +function extractKeywords(title: string): string[] { + return title + .toLowerCase() + .split(/[\s|\-/:,.()[\]{}<>]+/) + .filter((w) => w.length >= 3 && !STOPWORDS.has(w)); +} + export function enforceSubgroups( result: LLMGroupingResult, specificity: number, @@ -96,85 +163,91 @@ export function enforceSubgroups( const tabMap = new Map(tabs.map((t) => [t.id, t])); const merged = structuredClone(result); - function getDomain(tabId: number): string { - const t = tabMap.get(tabId); - if (!t) return ""; - try { - return new URL(t.url).hostname.replace("www.", ""); - } catch { - return ""; + for (const group of merged.groups) { + if (group.tabIds.length < threshold) continue; + if (group.children && group.children.length > 0) continue; + + // Extract keywords from each tab's title + const tabKeywords = new Map(); + const keywordFreq = new Map(); + + for (const tabId of group.tabIds) { + const tab = tabMap.get(tabId); + if (!tab) continue; + const kws = extractKeywords(tab.title); + tabKeywords.set(tabId, kws); + for (const kw of new Set(kws)) { + keywordFreq.set(kw, (keywordFreq.get(kw) ?? 0) + 1); + } } - } - function labelFromDomain(domain: string): string { - // "docs.anthropic.com" → "Anthropic Docs", "github.com" → "GitHub" - const parts = domain.split("."); - if (parts.length >= 3) { - const sub = parts[0].charAt(0).toUpperCase() + parts[0].slice(1); - const base = - parts[parts.length - 2].charAt(0).toUpperCase() + parts[parts.length - 2].slice(1); - return `${base} ${sub}`; + // Pick top keywords that appear in 2+ tabs but not ALL tabs (otherwise not discriminating) + const tabCount = group.tabIds.length; + const candidates = [...keywordFreq.entries()] + .filter(([, count]) => count >= 2 && count < tabCount) + .sort((a, b) => b[1] - a[1]) + .slice(0, 5) + .map(([kw]) => kw); + + if (candidates.length < 2) { + // Can't find meaningful clusters — leave group flat + console.log( + `[GroupThink] enforceSubgroups: "${group.label}" — no viable keyword clusters, leaving flat`, + ); + continue; } - return parts[parts.length - 2] - ? parts[parts.length - 2].charAt(0).toUpperCase() + parts[parts.length - 2].slice(1) - : domain; - } - for (const group of merged.groups) { - if (group.tabIds.length < threshold) continue; - if (group.children && group.children.length > 0) continue; + // Assign each tab to its best-matching keyword cluster + const clusters = new Map(); + const assigned = new Set(); + + for (const keyword of candidates) { + clusters.set(keyword, []); + } - // Cluster by domain - const domainClusters = new Map(); for (const tabId of group.tabIds) { - const domain = getDomain(tabId); - const key = domain || "__unknown"; - if (!domainClusters.has(key)) domainClusters.set(key, []); - domainClusters.get(key)!.push(tabId); + const kws = tabKeywords.get(tabId) ?? []; + // Find the first (most frequent) candidate keyword this tab matches + const match = candidates.find((c) => kws.includes(c)); + if (match && !assigned.has(tabId)) { + clusters.get(match)!.push(tabId); + assigned.add(tabId); + } } - if (domainClusters.size >= 2) { - // Multiple domains → create children from clusters - const children: LLMGroupItem[] = []; - for (const [domain, tabIds] of domainClusters) { - if (tabIds.length === 0) continue; + // Unassigned tabs go into an "Other" cluster + const unassigned = group.tabIds.filter((id) => !assigned.has(id)); + + // Build children from clusters with 2+ tabs + const children: LLMGroupItem[] = []; + const overflow: number[] = [...unassigned]; + + for (const [keyword, tabIds] of clusters) { + if (tabIds.length >= 2) { children.push({ - label: domain === "__unknown" ? "Other" : labelFromDomain(domain), + label: keyword.charAt(0).toUpperCase() + keyword.slice(1), tabIds, }); + } else { + overflow.push(...tabIds); } - // Merge tiny children (1 tab) into the largest child - const sorted = children.sort((a, b) => b.tabIds.length - a.tabIds.length); - const kept: LLMGroupItem[] = []; - for (const child of sorted) { - if (child.tabIds.length <= 1 && kept.length > 0) { - kept[0].tabIds.push(...child.tabIds); - } else { - kept.push(child); - } - } - if (kept.length >= 2) { - group.children = kept; - group.tabIds = []; - console.log( - `[GroupThink] enforceSubgroups: decomposed "${group.label}" into ${kept.length} children`, - ); - } - } else if (group.tabIds.length >= 6) { - // Single domain, 6+ tabs → split into halves by title - const sortedIds = [...group.tabIds].sort((a, b) => { - const ta = tabMap.get(a)?.title ?? ""; - const tb = tabMap.get(b)?.title ?? ""; - return ta.localeCompare(tb); - }); - const mid = Math.ceil(sortedIds.length / 2); - group.children = [ - { label: `${group.label} A`, tabIds: sortedIds.slice(0, mid) }, - { label: `${group.label} B`, tabIds: sortedIds.slice(mid) }, - ]; + } + + // Merge overflow into the largest child + if (overflow.length > 0 && children.length > 0) { + children.sort((a, b) => b.tabIds.length - a.tabIds.length); + children[0].tabIds.push(...overflow); + } + + if (children.length >= 2) { + group.children = children; group.tabIds = []; console.log( - `[GroupThink] enforceSubgroups: split "${group.label}" into 2 halves (single domain)`, + `[GroupThink] enforceSubgroups: decomposed "${group.label}" into ${children.length} children by keyword`, + ); + } else { + console.log( + `[GroupThink] enforceSubgroups: "${group.label}" — only ${children.length} viable cluster(s), leaving flat`, ); } } diff --git a/src/lib/llm-client.ts b/src/lib/llm-client.ts new file mode 100644 index 0000000..46b5df1 --- /dev/null +++ b/src/lib/llm-client.ts @@ -0,0 +1,121 @@ +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/prompts.ts b/src/lib/prompts.ts index 36ebdcf..7a21b7b 100644 --- a/src/lib/prompts.ts +++ b/src/lib/prompts.ts @@ -1,3 +1,7 @@ +import type { LLMProvider } from "../types"; + +// ── System prompts ── + export const SYSTEM_PROMPT = `Tab organizer. Group by TOPIC/INTENT, never by website. Return valid JSON only. Rules: @@ -12,16 +16,43 @@ Rules: Schema: {"groups":[{"label":"str","sublabel?":"str","tabIds":[int],"children?":[{"label":"str","sublabel?":"str","tabIds":[int]}]}],"ungrouped":[int]}`; +const SYSTEM_PROMPT_LOCAL = `Tab organizer. Return valid JSON only. + +Group by TOPIC or ACTIVITY. NEVER name a group after a website (GitHub, AWS, YouTube, Figma, Google, etc). Tabs from different websites that share a purpose go in the same group. + +Rules: +1. Group by what the user is DOING, not where. An AWS billing page and a Stripe dashboard both belong in "Billing", not "AWS" and "Stripe". +2. Labels: 1–3 words, describe the activity or topic. FORBIDDEN labels: any domain name or brand. +3. Field name for tab IDs is "tabIds" (camelCase). No variants. +4. Categorize every tab. "ungrouped" must be empty — use a catchall if needed. +5. No duplicate tab IDs across groups. +6. When specificity requires children: move ALL tabIds into children, parent tabIds=[]. +7. Groups with ≤3 tabs: no children regardless of specificity. + +Schema: {"groups":[{"label":"str","sublabel?":"str","tabIds":[int],"children?":[{"label":"str","sublabel?":"str","tabIds":[int]}]}],"ungrouped":[int]}`; + +export function getSystemPrompt(provider?: LLMProvider): string { + return provider === "ollama" ? SYSTEM_PROMPT_LOCAL : SYSTEM_PROMPT; +} + +// ── Grouping prompt ── + export function buildGroupingPrompt( tabs: { id: number; title: string; url: string }[], specificity: number, contextHints?: string, + options?: { provider?: LLMProvider }, ): string { + const isLocal = options?.provider === "ollama"; + const tabList = tabs .map((t) => { try { const u = new URL(t.url); - return `${t.id}|${t.title}|${u.hostname}${u.pathname.slice(0, 50)}`; + // Local models: hostname only (reduce domain signal). Cloud: hostname + path. + return isLocal + ? `${t.id}|${t.title}|${u.hostname}` + : `${t.id}|${t.title}|${u.hostname}${u.pathname.slice(0, 50)}`; } catch { return `${t.id}|${t.title}|${t.url.slice(0, 60)}`; } @@ -35,13 +66,36 @@ export function buildGroupingPrompt( ? "Groups with 6+ tabs MUST have 2–4 children (min 2 tabs each)." : "Groups with 4+ tabs MUST have 2–5 children (min 2 tabs each)."; - let prompt = `Specificity: ${specificity}/10. ${childRule} + const parts: string[] = []; + + parts.push(`Specificity: ${specificity}/10. ${childRule}`); + + // Few-shot examples for local models + if (isLocal) { + parts.push(` +Example: +Tabs: 101|S3 bucket policies|aws.amazon.com 102|React useState deep dive|youtube.com 103|Deploy Next.js to AWS|dev.to 104|GitHub Actions CI/CD|github.com 105|Terraform AWS modules|registry.terraform.io +Good: {"groups":[{"label":"Cloud Infra","tabIds":[101,105]},{"label":"Frontend Dev","tabIds":[102,103]},{"label":"CI/CD","tabIds":[104]}],"ungrouped":[]} +Bad: {"groups":[{"label":"AWS","tabIds":[101,105]},{"label":"YouTube","tabIds":[102]},{"label":"Dev.to","tabIds":[103]},{"label":"GitHub","tabIds":[104]}],"ungrouped":[]} +Domain names are NEVER used as group labels.`); + + if (specificity >= 5) { + parts.push(`Example with children (specificity ${specificity}): +{"label":"Cloud Infra","tabIds":[],"children":[{"label":"Networking","tabIds":[201,202]},{"label":"Storage","tabIds":[203,204,205]}]} +Parent tabIds MUST be [] when children exist.`); + } + } + + parts.push(`Tabs (id|title|${isLocal ? "host" : "url"}):\n${tabList}`); -Tabs (id|title|url): -${tabList} + // Only request tabDescriptions/tabTags for cloud models + if (!isLocal) { + parts.push( + `Also return: "tabDescriptions":{"":"5-10 word summary"}, "tabTags":{"":["tag",...]}.\nTags: 1–3 lowercase topic words per tab.`, + ); + } -Also return: "tabDescriptions":{"":"5-10 word summary"}, "tabTags":{"":["tag",...]}. -Tags: 1–3 lowercase topic words per tab.`; + let prompt = parts.join("\n\n"); if (contextHints) { prompt += `\n\n${contextHints}`; @@ -50,6 +104,8 @@ Tags: 1–3 lowercase topic words per tab.`; return prompt; } +// ── Sweep prompt ── + export function buildSweepPrompt( existingGroups: { label: string; tabIds: number[] }[], ungroupedTabs: { id: number; title: string; url: string }[], @@ -74,6 +130,8 @@ Tabs (id|title|host): ${tabList}`; } +// ── Context hints ── + export function buildContextHints( context: import("./browser-context").BrowserContext, tabs: { id: number }[], @@ -127,6 +185,8 @@ export function buildContextHints( return sections.length > 0 ? sections.join("\n\n") : undefined; } +// ── Refine prompt ── + export function buildRefinePrompt(currentGroupingJson: string, userInstruction: string): string { return `Grouping: ${currentGroupingJson} 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..c281c60 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,41 @@ 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 +111,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}

} +
+ )} +