Skip to content
Open
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
34 changes: 32 additions & 2 deletions app/api/analyze/images/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
}
3 changes: 3 additions & 0 deletions app/api/categories/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ export async function GET(): Promise<NextResponse> {
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)
Expand Down
3 changes: 2 additions & 1 deletion app/api/categorize/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ export async function GET(): Promise<NextResponse> {

export async function DELETE(): Promise<NextResponse> {
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
Expand Down Expand Up @@ -242,6 +242,7 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
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)
}
Expand Down
58 changes: 54 additions & 4 deletions app/api/settings/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<NextResponse> {
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' } }),
])
Expand All @@ -42,6 +50,9 @@ export async function GET(): Promise<NextResponse> {
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,
Expand All @@ -62,6 +73,8 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
provider?: string
openaiApiKey?: string
openaiModel?: string
xAIApiKey?: string
xAIModel?: string
xOAuthClientId?: string
xOAuthClientSecret?: string
} = {}
Expand All @@ -71,11 +84,11 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
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({
Expand Down Expand Up @@ -115,6 +128,20 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
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() === '') {
Expand Down Expand Up @@ -161,6 +188,29 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
}
}

// 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 }[] = [
Expand Down Expand Up @@ -198,7 +248,7 @@ export async function DELETE(request: NextRequest): Promise<NextResponse> {
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 })
}
Expand Down
30 changes: 30 additions & 0 deletions app/api/settings/test/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<NextResponse> {
let body: { provider?: string } = {}
Expand Down Expand Up @@ -76,5 +77,34 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
}
}

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 })
}
59 changes: 49 additions & 10 deletions app/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand All @@ -125,7 +131,7 @@ function ApiKeyField({
fetch('/api/settings')
.then((r) => r.json())
.then((d: Record<string, unknown>) => {
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)
Expand Down Expand Up @@ -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
}) {
Expand Down Expand Up @@ -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 (
<div className="flex items-center gap-1 p-1 rounded-xl bg-zinc-800 border border-zinc-700 mb-5">
<button
Expand All @@ -518,33 +524,43 @@ function ProviderToggle({ value, onChange }: { value: 'anthropic' | 'openai'; on
>
OpenAI (GPT)
</button>
<button
onClick={() => onChange('xAI')}
className={`flex-1 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
value === 'xAI'
? 'bg-emerald-600 text-white shadow-sm'
: 'text-zinc-400 hover:text-zinc-200'
}`}
>
xAI (Grok)
</button>
</div>
)
}

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' })
Expand Down Expand Up @@ -598,7 +614,7 @@ function ApiKeySection({ onToast }: { onToast: (t: Toast) => void }) {
</div>
</div>
</>
) : (
) : provider === 'openai' ? (
<>
<CodexCliStatusBox />
<div className="space-y-5">
Expand All @@ -622,6 +638,29 @@ function ApiKeySection({ onToast }: { onToast: (t: Toast) => void }) {
</div>
</div>
</>
) : (
<>
<div className="space-y-5">
<div>
<ApiKeyField
label="xAI"
placeholder="xai-..."
fieldKey="xAIApiKey"
hint="Used for AI categorization, search, and image analysis."
docHref="https://console.x.ai"
onToast={onToast}
testProvider="xai"
/>
<ModelSelector
models={xAI_MODELS}
settingKey="xAIModel"
defaultValue="grok-4-fast-reasoning"
onToast={onToast}
/>
<p className="text-xs text-zinc-500 mt-1.5">Applies to all AI operations — API key only</p>
</div>
</div>
</>
)}
<p className="text-xs text-zinc-600 mt-4">Keys are stored in plaintext in your local SQLite database (<code className="font-mono">prisma/dev.db</code>). Do not expose the database file.</p>
</Section>
Expand Down
Loading