diff --git a/app/api/analyze/images/route.ts b/app/api/analyze/images/route.ts index 4366026..379e10d 100644 --- a/app/api/analyze/images/route.ts +++ b/app/api/analyze/images/route.ts @@ -49,11 +49,41 @@ async function runAnalysis(client: AIClient | null, batchSize: number): Promise< return NextResponse.json({ analyzed: 0, remaining: 0, message: 'All images already analyzed.' }) } - const analyzed = await analyzeBatch(untagged, client) + let analyzed = 0 + const errors: string[] = [] + + // Analyze each image individually to handle failures gracefully + for (const item of untagged) { + try { + // Download and validate the image before analysis + const response = await fetch(item.url, { method: 'HEAD' }) // Use HEAD to check content-type without downloading full image + if (!response.ok) { + throw new Error(`Failed to fetch image: ${response.status}`) + } + const contentType = response.headers.get('content-type') + if (!contentType || !contentType.startsWith('image/') || !['image/jpeg', 'image/png', 'image/webp', 'image/gif'].includes(contentType)) { + throw new Error(`Invalid content-type: ${contentType}`) + } + + // If valid, analyze (assuming analyzeBatch can handle single items or we call analyzeItem) + // Note: Since analyzeBatch is used, we may need to modify it to skip invalid items, but for now, wrap in try-catch + await analyzeBatch([item], client) // Assuming it can handle a batch of one + analyzed++ + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err) + console.warn(`[vision] analysis failed for ${item.id}: ${errorMsg}`) + errors.push(`Failed to analyze ${item.id}: ${errorMsg}`) + // Continue to next image instead of failing the whole batch + } + } const remaining = await prisma.mediaItem.count({ where: { imageTags: null, type: { in: ['photo', 'gif'] } }, }) - return NextResponse.json({ analyzed, remaining }) + return NextResponse.json({ + analyzed, + remaining, + errors: errors.length > 0 ? errors : undefined, + }) } diff --git a/app/api/categories/route.ts b/app/api/categories/route.ts index bf30e1f..f6ea452 100644 --- a/app/api/categories/route.ts +++ b/app/api/categories/route.ts @@ -38,6 +38,9 @@ export async function GET(): Promise { bookmarkCount: cat._count.bookmarks, })) + // Log for debugging zero counts + console.log('[categories] Fetched categories with counts:', formatted.map(c => ({ name: c.name, count: c.bookmarkCount }))) + return NextResponse.json({ categories: formatted }) } catch (err) { console.error('Categories fetch error:', err) diff --git a/app/api/categorize/route.ts b/app/api/categorize/route.ts index 71f6017..944e9db 100644 --- a/app/api/categorize/route.ts +++ b/app/api/categorize/route.ts @@ -83,7 +83,7 @@ export async function GET(): Promise { export async function DELETE(): Promise { const state = getState() - if (state.status !== 'running') { + if (state.status !== 'running' && state.status !== 'stopping') { return NextResponse.json({ error: 'No pipeline running' }, { status: 409 }) } globalState.categorizationAbort = true @@ -242,6 +242,7 @@ export async function POST(request: NextRequest): Promise { await writeCategoryResults(results) counts.categorized += ids.length setState({ stageCounts: { ...counts } }) + console.log(`[categorize] Processed batch of ${ids.length} bookmarks, total categorized: ${counts.categorized}`) } catch (catErr) { console.error('[parallel] categorize batch error:', catErr) } diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts index f06373e..9c20ff7 100644 --- a/app/api/settings/route.ts +++ b/app/api/settings/route.ts @@ -22,14 +22,22 @@ const ALLOWED_OPENAI_MODELS = [ 'o3', ] as const +const ALLOWED_XAI_MODELS = [ + 'grok-4-fast-reasoning', + 'grok-4.20-0309-reasoning', + 'grok-code-fast-1', +] as const + export async function GET(): Promise { try { - const [anthropic, anthropicModel, provider, openai, openaiModel, xClientId, xClientSecret] = await Promise.all([ + const [anthropic, anthropicModel, provider, openai, openaiModel, xai, xaiModel, xClientId, xClientSecret] = await Promise.all([ prisma.setting.findUnique({ where: { key: 'anthropicApiKey' } }), prisma.setting.findUnique({ where: { key: 'anthropicModel' } }), prisma.setting.findUnique({ where: { key: 'aiProvider' } }), prisma.setting.findUnique({ where: { key: 'openaiApiKey' } }), prisma.setting.findUnique({ where: { key: 'openaiModel' } }), + prisma.setting.findUnique({ where: { key: 'xaiApiKey' } }), + prisma.setting.findUnique({ where: { key: 'xaiModel' } }), prisma.setting.findUnique({ where: { key: 'x_oauth_client_id' } }), prisma.setting.findUnique({ where: { key: 'x_oauth_client_secret' } }), ]) @@ -42,6 +50,9 @@ export async function GET(): Promise { openaiApiKey: maskKey(openai?.value ?? null), hasOpenaiKey: openai !== null, openaiModel: openaiModel?.value ?? 'gpt-4.1-mini', + xAIApiKey: maskKey(xai?.value ?? null), + hasXAIKey: xai !== null, + xAIModel: xaiModel?.value ?? 'grok-4-fast-reasoning', xOAuthClientId: maskKey(xClientId?.value ?? null), xOAuthClientSecret: maskKey(xClientSecret?.value ?? null), hasXOAuth: !!xClientId?.value, @@ -62,6 +73,8 @@ export async function POST(request: NextRequest): Promise { provider?: string openaiApiKey?: string openaiModel?: string + xAIApiKey?: string + xAIModel?: string xOAuthClientId?: string xOAuthClientSecret?: string } = {} @@ -71,11 +84,11 @@ export async function POST(request: NextRequest): Promise { return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }) } - const { anthropicApiKey, anthropicModel, provider, openaiApiKey, openaiModel } = body + const { anthropicApiKey, anthropicModel, provider, openaiApiKey, openaiModel, xAIApiKey, xAIModel } = body // Save provider if provided if (provider !== undefined) { - if (provider !== 'anthropic' && provider !== 'openai') { + if (provider !== 'anthropic' && provider !== 'openai' && provider !== 'xai') { return NextResponse.json({ error: 'Invalid provider' }, { status: 400 }) } await prisma.setting.upsert({ @@ -115,6 +128,20 @@ export async function POST(request: NextRequest): Promise { return NextResponse.json({ saved: true }) } + // Save xAI model if provided + if (xAIModel !== undefined) { + if (!(ALLOWED_XAI_MODELS as readonly string[]).includes(xAIModel)) { + return NextResponse.json({ error: 'Invalid xAI model' }, { status: 400 }) + } + await prisma.setting.upsert({ + where: { key: 'xaiModel' }, + update: { value: xAIModel }, + create: { key: 'xaiModel', value: xAIModel }, + }) + invalidateSettingsCache() + return NextResponse.json({ saved: true }) + } + // Save Anthropic key if provided if (anthropicApiKey !== undefined) { if (typeof anthropicApiKey !== 'string' || anthropicApiKey.trim() === '') { @@ -161,6 +188,29 @@ export async function POST(request: NextRequest): Promise { } } + // Save xAI key if provided + if (xAIApiKey !== undefined) { + if (typeof xAIApiKey !== 'string' || xAIApiKey.trim() === '') { + return NextResponse.json({ error: 'Invalid xAIApiKey value' }, { status: 400 }) + } + const trimmed = xAIApiKey.trim() + try { + await prisma.setting.upsert({ + where: { key: 'xaiApiKey' }, + update: { value: trimmed }, + create: { key: 'xaiApiKey', value: trimmed }, + }) + invalidateSettingsCache() + return NextResponse.json({ saved: true }) + } catch (err) { + console.error('Settings POST (xai) error:', err) + return NextResponse.json( + { error: `Failed to save: ${err instanceof Error ? err.message : String(err)}` }, + { status: 500 } + ) + } + } + // Save X OAuth credentials if provided const { xOAuthClientId, xOAuthClientSecret } = body const xKeys: { key: string; value: string | undefined }[] = [ @@ -198,7 +248,7 @@ export async function DELETE(request: NextRequest): Promise { return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }) } - const allowed = ['anthropicApiKey', 'openaiApiKey', 'x_oauth_client_id', 'x_oauth_client_secret'] + const allowed = ['anthropicApiKey', 'openaiApiKey', 'xAIApiKey', 'x_oauth_client_id', 'x_oauth_client_secret'] if (!body.key || !allowed.includes(body.key)) { return NextResponse.json({ error: 'Invalid key' }, { status: 400 }) } diff --git a/app/api/settings/test/route.ts b/app/api/settings/test/route.ts index 84c12af..b873f6f 100644 --- a/app/api/settings/test/route.ts +++ b/app/api/settings/test/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server' import prisma from '@/lib/db' import { resolveAnthropicClient, getCliAuthStatus } from '@/lib/claude-cli-auth' import { resolveOpenAIClient } from '@/lib/openai-auth' +import { resolveXAIClient } from '@/lib/xai-auth' export async function POST(request: NextRequest): Promise { let body: { provider?: string } = {} @@ -76,5 +77,34 @@ export async function POST(request: NextRequest): Promise { } } + if (provider === 'xai') { + const setting = await prisma.setting.findUnique({ where: { key: 'xaiApiKey' } }) + const dbKey = setting?.value?.trim() + + let client + try { + client = resolveXAIClient({ dbKey }) + } catch { + return NextResponse.json({ working: false, error: 'No xAI API key found. Add one in Settings.' }) + } + + try { + await client.chat.completions.create({ + model: 'grok-4-fast-reasoning', + max_tokens: 5, + messages: [{ role: 'user', content: 'hi' }], + }) + return NextResponse.json({ working: true }) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + const friendly = msg.includes('401') || msg.includes('invalid_api_key') + ? 'Invalid API key' + : msg.includes('403') + ? 'Key does not have permission' + : msg.slice(0, 120) + return NextResponse.json({ working: false, error: friendly }) + } + } + return NextResponse.json({ error: 'Unknown provider' }, { status: 400 }) } diff --git a/app/settings/page.tsx b/app/settings/page.tsx index f022624..e0f0419 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -36,6 +36,12 @@ const OPENAI_MODELS = [ { value: 'o3', label: 'o3', description: 'Reasoning' }, ] +const xAI_MODELS = [ + { value: 'grok-4-fast-reasoning', label: 'grok-4-fast-reasoning', description: 'Fast & Cheap' }, + { value: 'grok-4.20-0309-reasoning', label: 'grok-4.20-0309-reasoning', description: 'Smart & Balanced' }, + { value: 'grok-code-fast-1', label: 'grok-code-fast-1', description: 'Most Capable' }, +] + interface Toast { type: 'success' | 'error' @@ -106,7 +112,7 @@ function ApiKeyField({ }: { label: string placeholder: string - fieldKey: 'anthropicApiKey' | 'openaiApiKey' + fieldKey: 'anthropicApiKey' | 'openaiApiKey' | 'xAIApiKey' hint: string docHref: string onToast: (t: Toast) => void @@ -125,7 +131,7 @@ function ApiKeyField({ fetch('/api/settings') .then((r) => r.json()) .then((d: Record) => { - const hasKeyField = fieldKey === 'openaiApiKey' ? 'hasOpenaiKey' : 'hasAnthropicKey' + const hasKeyField = fieldKey === 'openaiApiKey' ? 'hasOpenaiKey' : fieldKey === 'xAIApiKey' ? 'hasXAIKey' : 'hasAnthropicKey' const hasKey = d[hasKeyField] const masked = d[fieldKey] as string | null if (hasKey && masked) setSavedMasked(masked) @@ -305,7 +311,7 @@ function ModelSelector({ onToast, }: { models: { value: string; label: string; description: string }[] - settingKey: 'anthropicModel' | 'openaiModel' + settingKey: 'anthropicModel' | 'openaiModel' | 'xAIModel' defaultValue: string onToast: (t: Toast) => void }) { @@ -495,7 +501,7 @@ function CodexCliStatusBox() { ) } -function ProviderToggle({ value, onChange }: { value: 'anthropic' | 'openai'; onChange: (v: 'anthropic' | 'openai') => void }) { +function ProviderToggle({ value, onChange }: { value: 'anthropic' | 'openai' | 'xAI'; onChange: (v: 'anthropic' | 'openai' | 'xAI') => void }) { return (
+
) } function ApiKeySection({ onToast }: { onToast: (t: Toast) => void }) { - const [provider, setProvider] = useState<'anthropic' | 'openai' | null>(null) + const [provider, setProvider] = useState<'anthropic' | 'openai' | 'xAI' | null>(null) useEffect(() => { fetch('/api/settings') .then((r) => r.json()) .then((d: { provider?: string }) => { - setProvider(d.provider === 'openai' ? 'openai' : 'anthropic') + setProvider(d.provider === 'openai' ? 'openai' : d.provider === 'xai' ? 'xAI' : 'anthropic') }) .catch(() => setProvider('anthropic')) }, []) - async function handleProviderChange(newProvider: 'anthropic' | 'openai') { + async function handleProviderChange(newProvider: 'anthropic' | 'openai' | 'xAI') { const prev = provider setProvider(newProvider) try { const res = await fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ provider: newProvider }), + body: JSON.stringify({ provider: newProvider === 'xAI' ? 'xai' : newProvider }), }) if (!res.ok) throw new Error('Failed to save provider') - onToast({ type: 'success', message: `Switched to ${newProvider === 'openai' ? 'OpenAI' : 'Anthropic'}` }) + onToast({ type: 'success', message: `Switched to ${newProvider === 'openai' ? 'OpenAI' : newProvider === 'xAI' ? 'xAI' : 'Anthropic'}` }) } catch { setProvider(prev) // revert on failure onToast({ type: 'error', message: 'Failed to save provider preference' }) @@ -598,7 +614,7 @@ function ApiKeySection({ onToast }: { onToast: (t: Toast) => void }) { - ) : ( + ) : provider === 'openai' ? ( <>
@@ -622,6 +638,29 @@ function ApiKeySection({ onToast }: { onToast: (t: Toast) => void }) {
+ ) : ( + <> +
+
+ + +

Applies to all AI operations — API key only

+
+
+ )}

Keys are stored in plaintext in your local SQLite database (prisma/dev.db). Do not expose the database file.

diff --git a/lib/ai-client.ts b/lib/ai-client.ts index ca8135b..c8db7e2 100644 --- a/lib/ai-client.ts +++ b/lib/ai-client.ts @@ -2,13 +2,10 @@ import Anthropic from '@anthropic-ai/sdk' import OpenAI from 'openai' import { resolveAnthropicClient } from './claude-cli-auth' import { resolveOpenAIClient } from './openai-auth' +import { resolveXAIClient } from './xai-auth' import { getProvider } from './settings' -export interface AIContentBlock { - type: 'text' | 'image' - text?: string - source?: { type: 'base64'; media_type: string; data: string } -} +export type AIContentBlock = { type: 'text'; text: string } | { type: 'image'; source: { type: 'base64'; media_type: string; data: string } } export interface AIMessage { role: 'user' | 'assistant' @@ -20,7 +17,7 @@ export interface AIResponse { } export interface AIClient { - provider: 'anthropic' | 'openai' + provider: 'anthropic' | 'openai' | 'xai' createMessage(params: { model: string max_tokens: number @@ -49,7 +46,7 @@ export class AnthropicAIClient implements AIClient { }, } } - return { type: 'text' as const, text: b.text ?? '' } + return { type: 'text' as const, text: (b as { type: 'text'; text: string }).text } }) return { role: m.role as 'user' | 'assistant', content: blocks } }) @@ -83,7 +80,41 @@ export class OpenAIAIClient implements AIClient { image_url: { url: `data:${b.source.media_type};base64,${b.source.data}` }, } } - return { type: 'text' as const, text: b.text ?? '' } + return { type: 'text' as const, text: (b as { type: 'text'; text: string }).text } + }) + if (m.role === 'assistant') return { role: 'assistant' as const, content: parts.map(p => p.type === 'text' ? p : p).filter((p): p is OpenAI.ChatCompletionContentPartText => p.type === 'text') } + return { role: 'user' as const, content: parts } + }) + + const completion = await this.sdk.chat.completions.create({ + model: params.model, + max_tokens: params.max_tokens, + messages, + }) + + return { text: completion.choices[0]?.message?.content ?? '' } + } +} + +// Wrap xAI SDK (assuming xAI API is OpenAI-compatible; adjust if not) +export class XAIAIClient implements AIClient { + provider = 'xai' as const + constructor(private sdk: OpenAI) {} // Reuse OpenAI SDK if xAI is compatible + + async createMessage(params: { model: string; max_tokens: number; messages: AIMessage[] }): Promise { + const messages: OpenAI.ChatCompletionMessageParam[] = params.messages.map((m): OpenAI.ChatCompletionMessageParam => { + if (typeof m.content === 'string') { + if (m.role === 'assistant') return { role: 'assistant' as const, content: m.content } + return { role: 'user' as const, content: m.content } + } + const parts: OpenAI.ChatCompletionContentPart[] = m.content.map(b => { + if (b.type === 'image' && b.source) { + return { + type: 'image_url' as const, + image_url: { url: `data:${b.source.media_type};base64,${b.source.data}` }, + } + } + return { type: 'text' as const, text: (b as { type: 'text'; text: string }).text } }) if (m.role === 'assistant') return { role: 'assistant' as const, content: parts.map(p => p.type === 'text' ? p : p).filter((p): p is OpenAI.ChatCompletionContentPartText => p.type === 'text') } return { role: 'user' as const, content: parts } @@ -110,6 +141,11 @@ export async function resolveAIClient(options: { return new OpenAIAIClient(client) } + if (provider === 'xai') { + const client = resolveXAIClient(options) + return new XAIAIClient(client) + } + const client = resolveAnthropicClient(options) return new AnthropicAIClient(client) } diff --git a/lib/settings.ts b/lib/settings.ts index f1d7810..37192e4 100644 --- a/lib/settings.ts +++ b/lib/settings.ts @@ -4,12 +4,15 @@ import prisma from '@/lib/db' let _cachedModel: string | null = null let _modelCacheExpiry = 0 -let _cachedProvider: 'anthropic' | 'openai' | null = null +let _cachedProvider: 'anthropic' | 'openai' | 'xai' | null = null let _providerCacheExpiry = 0 let _cachedOpenAIModel: string | null = null let _openAIModelCacheExpiry = 0 +let _cachedXAIModel: string | null = null +let _xaiModelCacheExpiry = 0 + const CACHE_TTL = 5 * 60 * 1000 /** @@ -26,10 +29,10 @@ export async function getAnthropicModel(): Promise { /** * Get the active AI provider (cached for 5 minutes). */ -export async function getProvider(): Promise<'anthropic' | 'openai'> { +export async function getProvider(): Promise<'anthropic' | 'openai' | 'xai'> { if (_cachedProvider && Date.now() < _providerCacheExpiry) return _cachedProvider const setting = await prisma.setting.findUnique({ where: { key: 'aiProvider' } }) - _cachedProvider = setting?.value === 'openai' ? 'openai' : 'anthropic' + _cachedProvider = setting?.value === 'openai' ? 'openai' : setting?.value === 'xai' ? 'xai' : 'anthropic' _providerCacheExpiry = Date.now() + CACHE_TTL return _cachedProvider } @@ -45,12 +48,25 @@ export async function getOpenAIModel(): Promise { return _cachedOpenAIModel } +/** + * Get the configured xAI model from settings (cached for 5 minutes). + */ +export async function getXAIModel(): Promise { + if (_cachedXAIModel && Date.now() < _xaiModelCacheExpiry) return _cachedXAIModel + const setting = await prisma.setting.findUnique({ where: { key: 'xaiModel' } }) + _cachedXAIModel = setting?.value ?? 'grok-4-fast-reasoning' + _xaiModelCacheExpiry = Date.now() + CACHE_TTL + return _cachedXAIModel +} + /** * Get the model for the currently active provider. */ export async function getActiveModel(): Promise { const provider = await getProvider() - return provider === 'openai' ? getOpenAIModel() : getAnthropicModel() + if (provider === 'openai') return getOpenAIModel() + if (provider === 'xai') return getXAIModel() + return getAnthropicModel() } /** @@ -63,4 +79,6 @@ export function invalidateSettingsCache(): void { _providerCacheExpiry = 0 _cachedOpenAIModel = null _openAIModelCacheExpiry = 0 + _cachedXAIModel = null + _xaiModelCacheExpiry = 0 } diff --git a/lib/vision-analyzer.ts b/lib/vision-analyzer.ts index 5368c6f..67ad7bc 100644 --- a/lib/vision-analyzer.ts +++ b/lib/vision-analyzer.ts @@ -122,8 +122,15 @@ async function analyzeImageWithRetry( { role: 'user', content: [ - { type: 'image', source: { type: 'base64', media_type: img.mediaType, data: img.data } }, { type: 'text', text: ANALYSIS_PROMPT }, + { + type: 'image', + source: { + type: 'base64', + media_type: img.mediaType, + data: img.data, + }, + }, ], }, ], @@ -327,7 +334,9 @@ export interface BookmarkForEnrichment { urls?: string[] mentions?: string[] tools?: string[] - tweetType?: string + tweetType: 'thread' | 'reply' | 'quote' | 'original' + hasMedia: boolean + mediaTypes: string[] } } diff --git a/lib/xai-auth.ts b/lib/xai-auth.ts new file mode 100644 index 0000000..4e6dcd2 --- /dev/null +++ b/lib/xai-auth.ts @@ -0,0 +1,22 @@ +import OpenAI from 'openai' + +/** + * Resolve an xAI client, preferring override key, then DB key. + * Assumes xAI API is OpenAI-compatible (adjust if not). + */ +export function resolveXAIClient(options: { + overrideKey?: string + dbKey?: string +} = {}): OpenAI { + const key = options.overrideKey || options.dbKey || process.env.XAI_API_KEY + + if (!key) { + throw new Error('No xAI API key found. Set XAI_API_KEY env var or save in settings.') + } + + // Assuming xAI uses OpenAI-compatible API; replace with xAI SDK if available + return new OpenAI({ + apiKey: key, + baseURL: 'https://api.x.ai/v1', // Example; confirm xAI's base URL + }) +}