Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions src/app/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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) => {
Expand Down Expand Up @@ -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 (
<div className="gt-app">
<div className="gt-setup">
Expand Down
8 changes: 4 additions & 4 deletions src/background/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
}

Expand All @@ -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) ──
Expand Down Expand Up @@ -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.");
}

Expand All @@ -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 }));
Expand Down
86 changes: 38 additions & 48 deletions src/lib/ai.ts
Original file line number Diff line number Diff line change
@@ -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 ──

Expand Down Expand Up @@ -53,9 +58,7 @@ function normalizeGroup(group: Record<string, unknown>): Record<string, unknown>
}
}

// 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) &&
Expand All @@ -70,7 +73,6 @@ function normalizeGroup(group: Record<string, unknown>): Record<string, unknown>
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(", ")}`,
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -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, "")
Expand All @@ -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<string, unknown>, i: number) => {
console.log(
Expand Down
Loading
Loading