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}
}
+
+ )}
+
-