diff --git a/app/api/categories/suggest/route.ts b/app/api/categories/suggest/route.ts new file mode 100644 index 0000000..fa50bc3 --- /dev/null +++ b/app/api/categories/suggest/route.ts @@ -0,0 +1,57 @@ +import { NextRequest, NextResponse } from 'next/server' +import { + generateCategorySuggestions, + createCategoryFromSuggestion, + CategorySuggestion, +} from '@/lib/category-suggester' + +export async function GET(): Promise { + try { + const suggestions = await generateCategorySuggestions() + return NextResponse.json({ suggestions }) + } catch (err) { + console.error('Category suggestion error:', err) + return NextResponse.json( + { error: err instanceof Error ? err.message : 'Failed to generate suggestions' }, + { status: 500 } + ) + } +} + +export async function POST(request: NextRequest): Promise { + let body: { suggestions?: CategorySuggestion[] } = {} + try { + body = await request.json() + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }) + } + + const { suggestions } = body + if (!suggestions || !Array.isArray(suggestions) || suggestions.length === 0) { + return NextResponse.json( + { error: 'Missing required field: suggestions' }, + { status: 400 } + ) + } + + const results = { created: 0, failed: 0, errors: [] as string[] } + + for (const suggestion of suggestions) { + try { + await createCategoryFromSuggestion(suggestion) + results.created++ + } catch (err) { + results.failed++ + results.errors.push( + `${suggestion.name}: ${err instanceof Error ? err.message : String(err)}` + ) + } + } + + return NextResponse.json({ + success: results.failed === 0, + created: results.created, + failed: results.failed, + errors: results.errors, + }) +} diff --git a/app/categories/page.tsx b/app/categories/page.tsx index 7dff5ff..1605356 100644 --- a/app/categories/page.tsx +++ b/app/categories/page.tsx @@ -1,7 +1,7 @@ 'use client' import { useState, useEffect } from 'react' -import { Plus, Tag, X, ArrowRight, Folder, Bookmark } from 'lucide-react' +import { Plus, Tag, X, ArrowRight, Folder, Bookmark, Sparkles, Loader2, Check, Trash2 } from 'lucide-react' import * as Dialog from '@radix-ui/react-dialog' import Link from 'next/link' import type { Category } from '@/lib/types' @@ -17,6 +17,20 @@ const PRESET_COLORS = [ '#ec4899', // pink ] +interface CategorySuggestion { + name: string + slug: string + description: string + color: string + bookmarkCount: number + confidence: number + exampleBookmarks: Array<{ + tweetId: string + text: string + authorHandle: string + }> +} + interface AddCategoryModalProps { open: boolean onClose: () => void @@ -167,6 +181,256 @@ function AddCategoryModal({ open, onClose, onAdd }: AddCategoryModalProps) { ) } +interface AIAssistantModalProps { + open: boolean + onClose: () => void + onCategoriesCreated: (categories: Category[]) => void +} + +function AIAssistantModal({ open, onClose, onCategoriesCreated }: AIAssistantModalProps) { + const [suggestions, setSuggestions] = useState([]) + const [selectedSuggestions, setSelectedSuggestions] = useState>(new Set()) + const [loading, setLoading] = useState(false) + const [creating, setCreating] = useState(false) + const [error, setError] = useState('') + + useEffect(() => { + if (open && suggestions.length === 0) { + fetchSuggestions() + } + }, [open]) + + async function fetchSuggestions() { + setLoading(true) + setError('') + try { + const res = await fetch('/api/categories/suggest') + const data = await res.json() + if (!res.ok) throw new Error(data.error ?? 'Failed to generate suggestions') + setSuggestions(data.suggestions || []) + // Auto-select all by default + setSelectedSuggestions(new Set(data.suggestions?.map((s: CategorySuggestion) => s.slug) || [])) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to generate suggestions') + } finally { + setLoading(false) + } + } + + function toggleSelection(slug: string) { + setSelectedSuggestions((prev) => { + const next = new Set(prev) + if (next.has(slug)) { + next.delete(slug) + } else { + next.add(slug) + } + return next + }) + } + + async function handleCreateSelected() { + const selected = suggestions.filter((s) => selectedSuggestions.has(s.slug)) + if (selected.length === 0) return + + setCreating(true) + setError('') + try { + const res = await fetch('/api/categories/suggest', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ suggestions: selected }), + }) + const data = await res.json() + if (!res.ok) throw new Error(data.error ?? 'Failed to create categories') + + // Refresh categories list + const catsRes = await fetch('/api/categories') + const catsData = await catsRes.json() + if (catsData.categories) { + onCategoriesCreated(catsData.categories) + } + + // Close modal + onClose() + setSuggestions([]) + setSelectedSuggestions(new Set()) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to create categories') + } finally { + setCreating(false) + } + } + + function handleClose() { + if (!creating) { + onClose() + setError('') + } + } + + return ( + !v && handleClose()}> + + + +
+
+
+ + + AI Category Assistant + + + Analyze your bookmarks and discover natural topic clusters + +
+ +
+
+ +
+ {loading && ( +
+ +

Analyzing your bookmarks...

+

This may take a moment

+
+ )} + + {!loading && error && ( +
+

{error}

+ +
+ )} + + {!loading && !error && suggestions.length === 0 && ( +
+

No suggestions available.

+

Make sure you have at least 10 bookmarks imported.

+
+ )} + + {!loading && suggestions.length > 0 && ( +
+

+ Found {suggestions.length} potential categories. Select the ones you want to create: +

+ + {suggestions.map((suggestion) => ( +
toggleSelection(suggestion.slug)} + className={`relative border rounded-xl p-4 cursor-pointer transition-all duration-200 ${ + selectedSuggestions.has(suggestion.slug) + ? 'border-indigo-500 bg-indigo-500/5' + : 'border-zinc-800 hover:border-zinc-700' + }`} + > +
+
+ {selectedSuggestions.has(suggestion.slug) && ( + + )} +
+ +
+
+ +

{suggestion.name}

+ + {suggestion.bookmarkCount} bookmarks + + + {(suggestion.confidence * 100).toFixed(0)}% confidence + +
+ +

{suggestion.description}

+ + {suggestion.exampleBookmarks.length > 0 && ( +
+

Example bookmarks:

+ {suggestion.exampleBookmarks.map((bm) => ( +
+ @{bm.authorHandle}: {bm.text} +
+ ))} +
+ )} +
+
+
+ ))} +
+ )} +
+ + {suggestions.length > 0 && ( +
+
+

+ {selectedSuggestions.size} of {suggestions.length} selected +

+
+ + +
+
+
+ )} +
+
+
+ ) +} + interface CategoryDisplayCardProps { category: Category } @@ -234,6 +498,7 @@ export default function CategoriesPage() { const [totalBookmarks, setTotalBookmarks] = useState(0) const [loading, setLoading] = useState(true) const [modalOpen, setModalOpen] = useState(false) + const [aiModalOpen, setAiModalOpen] = useState(false) useEffect(() => { Promise.all([ @@ -252,9 +517,12 @@ export default function CategoriesPage() { setCategories((prev) => [...prev, category]) } + function handleCategoriesCreated(newCategories: Category[]) { + setCategories(newCategories) + } + return (
- {/* Page Header */}
@@ -275,13 +543,22 @@ export default function CategoriesPage() { : 'Organize your bookmarks by topic'}

- +
+ + +
{/* Loading State */} @@ -341,6 +618,12 @@ export default function CategoriesPage() { onClose={() => setModalOpen(false)} onAdd={handleAdd} /> + + setAiModalOpen(false)} + onCategoriesCreated={handleCategoriesCreated} + />
) } diff --git a/lib/category-suggester.ts b/lib/category-suggester.ts new file mode 100644 index 0000000..8a76740 --- /dev/null +++ b/lib/category-suggester.ts @@ -0,0 +1,334 @@ +import prisma from '@/lib/db' +import { getActiveModel, getProvider } from '@/lib/settings' +import { AIClient, resolveAIClient } from '@/lib/ai-client' +import { getCliAvailability, claudePrompt, modelNameToCliAlias } from '@/lib/claude-cli-auth' +import { getCodexCliAvailability, codexPrompt } from '@/lib/codex-cli' + +export interface CategorySuggestion { + name: string + slug: string + description: string + color: string + bookmarkCount: number + confidence: number + exampleBookmarks: Array<{ + tweetId: string + text: string + authorHandle: string + }> +} + +interface BookmarkSample { + id: string + tweetId: string + text: string + authorHandle: string + semanticTags?: string[] + hashtags?: string[] + tools?: string[] +} + +const CATEGORY_COLORS = [ + '#8b5cf6', // violet + '#f59e0b', // amber + '#06b6d4', // cyan + '#10b981', // green + '#f97316', // orange + '#6366f1', // indigo + '#ec4899', // pink + '#14b8a6', // teal + '#ef4444', // red + '#3b82f6', // blue + '#a855f7', // purple + '#eab308', // yellow + '#64748b', // slate + '#84cc16', // lime + '#06b6d4', // cyan +] + +/** + * Sample bookmarks for analysis (stratified sampling for diversity) + */ +async function getBookmarkSamples(limit: number = 100): Promise { + // Get bookmarks with enrichment data first + const bookmarks = await prisma.bookmark.findMany({ + where: { + OR: [ + { semanticTags: { not: null } }, + { entities: { not: null } }, + ], + }, + take: limit, + orderBy: { importedAt: 'desc' }, + select: { + id: true, + tweetId: true, + text: true, + authorHandle: true, + semanticTags: true, + entities: true, + }, + }) + + // If not enough enriched bookmarks, get regular ones too + if (bookmarks.length < limit) { + const remaining = limit - bookmarks.length + const additional = await prisma.bookmark.findMany({ + where: { + semanticTags: null, + entities: null, + }, + take: remaining, + orderBy: { importedAt: 'desc' }, + select: { + id: true, + tweetId: true, + text: true, + authorHandle: true, + semanticTags: true, + entities: true, + }, + }) + bookmarks.push(...additional) + } + + return bookmarks.map((b) => { + let entities: { hashtags?: string[]; tools?: string[] } = {} + try { + if (b.entities) { + entities = JSON.parse(b.entities) + } + } catch { + // ignore parse errors + } + + let semanticTags: string[] = [] + try { + if (b.semanticTags) { + semanticTags = JSON.parse(b.semanticTags) + } + } catch { + // ignore parse errors + } + + return { + id: b.id, + tweetId: b.tweetId, + text: b.text.slice(0, 280), // Truncate long tweets + authorHandle: b.authorHandle, + semanticTags, + hashtags: entities.hashtags || [], + tools: entities.tools || [], + } + }) +} + +function buildCategorySuggestionPrompt(bookmarks: BookmarkSample[]): string { + const bookmarkTexts = bookmarks + .map( + (b, i) => + `${i + 1}. @${b.authorHandle}: ${b.text}${b.semanticTags?.length ? ` [Tags: ${b.semanticTags.join(', ')}]` : ''}${b.hashtags?.length ? ` [Hashtags: ${b.hashtags.join(', ')}]` : ''}${b.tools?.length ? ` [Tools: ${b.tools.join(', ')}]` : ''}` + ) + .join('\n\n') + + return `Analyze these bookmarked tweets and identify natural topic clusters. Suggest 3-8 custom categories that would help organize these bookmarks. + +TWEETS TO ANALYZE: +${bookmarkTexts} + +TASK: +1. Identify 3-8 distinct topic clusters/themes from these tweets +2. For each cluster, provide: + - A clear, concise category name (2-4 words) + - A detailed description that explains what content belongs (1-2 sentences) + - The number of tweets that fit this category + - 2-3 example tweet IDs that best represent this category + +GUIDELINES: +- Categories should be specific enough to be useful (e.g., "Rust Programming" not "Programming") +- Avoid overly broad categories like "General" or "Misc" +- Categories should be mutually exclusive where possible +- Consider both the tweet content and any semantic tags/hashtags/tools +- Focus on recurring themes, not one-off topics + +RESPOND WITH VALID JSON ONLY (no markdown, no explanation): +{ + "suggestions": [ + { + "name": "Category Name", + "description": "Detailed description of what content belongs here...", + "bookmarkCount": 15, + "confidence": 0.85, + "exampleTweetIds": ["123456", "789012", "345678"] + } + ] +}` +} + +async function suggestCategoriesViaCLI(bookmarks: BookmarkSample[]): Promise { + const provider = await getProvider() + const prompt = buildCategorySuggestionPrompt(bookmarks) + + if (provider === 'openai') { + if (await getCodexCliAvailability()) { + const result = await codexPrompt(prompt, { timeoutMs: 120_000 }) + if (!result.success || !result.data) { + throw new Error('CLI categorization failed: ' + (result.error || 'No result')) + } + return parseCategorySuggestions(result.data, bookmarks) + } + } else { + if (await getCliAvailability()) { + const model = await getActiveModel() + const cliModel = modelNameToCliAlias(model) + const result = await claudePrompt(prompt, { model: cliModel, timeoutMs: 120_000 }) + if (!result.success || !result.data) { + throw new Error('CLI categorization failed: ' + (result.error || 'No result')) + } + return parseCategorySuggestions(result.data, bookmarks) + } + } + + throw new Error('No CLI available for categorization') +} + +async function suggestCategoriesViaSDK( + bookmarks: BookmarkSample[], + client: AIClient +): Promise { + const prompt = buildCategorySuggestionPrompt(bookmarks) + const model = await getActiveModel() + + const response = await client.createMessage({ + model, + max_tokens: 4000, + messages: [{ role: 'user', content: prompt }], + }) + + return parseCategorySuggestions(response.text, bookmarks) +} + +function parseCategorySuggestions( + responseText: string, + bookmarks: BookmarkSample[] +): CategorySuggestion[] { + // Extract JSON from response + const jsonMatch = responseText.match(/\{[\s\S]*\}/) + if (!jsonMatch) { + throw new Error('No JSON found in response') + } + + let parsed: { suggestions?: Array & { exampleTweetIds?: string[] }> } + try { + parsed = JSON.parse(jsonMatch[0]) + } catch (err) { + throw new Error('Failed to parse JSON: ' + (err instanceof Error ? err.message : String(err))) + } + + if (!parsed.suggestions || !Array.isArray(parsed.suggestions)) { + throw new Error('Invalid response format: missing suggestions array') + } + + // Generate slugs and assign colors + const usedSlugs = new Set() + + return parsed.suggestions.map((suggestion, index) => { + const baseSlug = suggestion.name + ?.toLowerCase() + .trim() + .replace(/[^a-z0-9\s-]/g, '') + .replace(/\s+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') || `category-${index}` + + // Ensure unique slug + let slug = baseSlug + let counter = 1 + while (usedSlugs.has(slug)) { + slug = `${baseSlug}-${counter}` + counter++ + } + usedSlugs.add(slug) + + // Find example bookmarks + const exampleBookmarks = bookmarks + .filter((b) => suggestion.exampleTweetIds?.includes(b.tweetId)) + .slice(0, 3) + .map((b) => ({ + tweetId: b.tweetId, + text: b.text.slice(0, 100) + (b.text.length > 100 ? '...' : ''), + authorHandle: b.authorHandle, + })) + + return { + name: suggestion.name || 'Unnamed Category', + slug, + description: suggestion.description || '', + color: CATEGORY_COLORS[index % CATEGORY_COLORS.length], + bookmarkCount: suggestion.bookmarkCount || 0, + confidence: suggestion.confidence || 0.5, + exampleBookmarks, + } + }) +} + +/** + * Generate AI-powered category suggestions based on bookmark analysis + */ +export async function generateCategorySuggestions(): Promise { + // Get sample of bookmarks to analyze + const bookmarks = await getBookmarkSamples(100) + + if (bookmarks.length < 10) { + throw new Error('Not enough bookmarks to analyze. Need at least 10 bookmarks.') + } + + const provider = await getProvider() + + // Try CLI first (preferred for OAuth tokens) + try { + if (provider === 'openai') { + if (await getCodexCliAvailability()) { + return await suggestCategoriesViaCLI(bookmarks) + } + } else { + if (await getCliAvailability()) { + return await suggestCategoriesViaCLI(bookmarks) + } + } + } catch (err) { + console.warn('CLI categorization failed, falling back to SDK:', err) + } + + // Fallback to SDK + try { + const client = await resolveAIClient({}) + return await suggestCategoriesViaSDK(bookmarks, client) + } catch (err) { + console.error('SDK categorization failed:', err) + throw new Error('Failed to generate category suggestions: ' + (err instanceof Error ? err.message : String(err))) + } +} + +/** + * Create a category from a suggestion + */ +export async function createCategoryFromSuggestion(suggestion: CategorySuggestion): Promise { + const existing = await prisma.category.findFirst({ + where: { OR: [{ name: suggestion.name }, { slug: suggestion.slug }] }, + }) + + if (existing) { + throw new Error(`Category "${suggestion.name}" already exists`) + } + + await prisma.category.create({ + data: { + name: suggestion.name, + slug: suggestion.slug, + description: suggestion.description, + color: suggestion.color, + isAiGenerated: true, + }, + }) +}