Files
vibn-frontend/app/api/cursor/backfill/route.ts

230 lines
7.1 KiB
TypeScript

import { NextRequest, NextResponse } from 'next/server';
import { adminAuth, adminDb } from '@/lib/firebase/admin';
export async function POST(request: NextRequest) {
try {
// Verify authentication using API key
const authHeader = request.headers.get('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
return NextResponse.json(
{ error: 'Unauthorized - Missing API key' },
{ status: 401 }
);
}
const apiKey = authHeader.substring(7);
// Look up user by API key
const apiKeysSnapshot = await adminDb
.collection('apiKeys')
.where('key', '==', apiKey)
.where('isActive', '==', true)
.limit(1)
.get();
if (apiKeysSnapshot.empty) {
return NextResponse.json(
{ error: 'Invalid API key' },
{ status: 401 }
);
}
const apiKeyDoc = apiKeysSnapshot.docs[0];
const apiKeyData = apiKeyDoc.data();
const userId = apiKeyData.userId;
if (!userId) {
return NextResponse.json(
{ error: 'API key not associated with user' },
{ status: 401 }
);
}
// Parse request body
const body = await request.json();
const {
projectId,
workspacePath,
githubUrl,
conversations
} = body;
if (!projectId || !conversations) {
return NextResponse.json(
{ error: 'Missing required fields: projectId, conversations' },
{ status: 400 }
);
}
// Verify user has access to the project
const projectRef = adminDb.collection('projects').doc(projectId);
const projectDoc = await projectRef.get();
if (!projectDoc.exists) {
return NextResponse.json(
{ error: 'Project not found' },
{ status: 404 }
);
}
const projectData = projectDoc.data();
if (projectData?.userId !== userId) {
return NextResponse.json(
{ error: 'Access denied to this project' },
{ status: 403 }
);
}
// Process and store conversations
const { composers, workspaceFiles, totalGenerations } = conversations;
let conversationCount = 0;
let totalMessagesWritten = 0;
// Determine filtering keywords based on project context
// TODO: Make this configurable per project
const projectKeywords = ['vibn', 'project', 'extension', 'collector', 'cursor-monitor'];
const excludeKeywords = ['nhl', 'hockey', 'market', 'transaction'];
// Store each composer (chat session) as a separate document
for (const composer of composers || []) {
if (composer.type !== 'head') continue; // Only process head composers
const conversationId = `cursor-${composer.composerId}`;
const conversationRef = adminDb
.collection('projects')
.doc(projectId)
.collection('cursorConversations')
.doc(conversationId);
const name = composer.name || 'Untitled Conversation';
const nameLower = name.toLowerCase();
// Simple relevance scoring
let relevanceScore = 0;
// Check for project keywords in name
for (const keyword of projectKeywords) {
if (nameLower.includes(keyword)) {
relevanceScore += 2;
}
}
// Penalize for exclude keywords
for (const keyword of excludeKeywords) {
if (nameLower.includes(keyword)) {
relevanceScore -= 3;
}
}
// Check if name mentions files from this workspace
if (workspaceFiles && Array.isArray(workspaceFiles)) {
for (const file of workspaceFiles) {
if (nameLower.includes(file.toLowerCase())) {
relevanceScore += 1;
}
}
}
// Count messages
let messageCount = 0;
if (composer.bubbles && Array.isArray(composer.bubbles)) {
messageCount = composer.bubbles.length;
}
const conversationData = {
userId,
projectId,
conversationId,
composerId: composer.composerId,
name,
createdAt: new Date(composer.createdAt).toISOString(),
lastUpdatedAt: new Date(composer.lastUpdatedAt).toISOString(),
unifiedMode: composer.unifiedMode || false,
forceMode: composer.forceMode || false,
workspacePath,
githubUrl: githubUrl || null,
importedAt: new Date().toISOString(),
relevanceScore, // For filtering
messageCount,
metadata: {
source: 'cursor-monitor-extension',
composerType: composer.type,
}
};
// Write conversation document first
await conversationRef.set(conversationData);
// Store messages in chunks to avoid Firestore batch limit (500 operations)
if (composer.bubbles && Array.isArray(composer.bubbles)) {
const BATCH_SIZE = 400; // Leave room for overhead
for (let i = 0; i < composer.bubbles.length; i += BATCH_SIZE) {
const batch = adminDb.batch();
const chunk = composer.bubbles.slice(i, i + BATCH_SIZE);
for (const bubble of chunk) {
const messageRef = conversationRef
.collection('messages')
.doc(bubble.bubbleId);
batch.set(messageRef, {
bubbleId: bubble.bubbleId,
type: bubble.type, // 1 = user, 2 = AI
role: bubble.type === 1 ? 'user' : bubble.type === 2 ? 'assistant' : 'unknown',
text: bubble.text || '',
createdAt: bubble.createdAt,
requestId: bubble.requestId,
attachedFiles: bubble.attachedFiles || []
});
}
await batch.commit();
totalMessagesWritten += chunk.length;
console.log(`✅ Wrote ${chunk.length} messages (${i + chunk.length}/${composer.bubbles.length}) for ${name}`);
}
}
conversationCount++;
}
// Store workspace metadata for reference
const workspaceMetaRef = adminDb
.collection('projects')
.doc(projectId)
.collection('cursorData')
.doc('workspace-meta');
await workspaceMetaRef.set({
workspacePath,
githubUrl,
workspaceFiles: workspaceFiles || [],
totalGenerations: totalGenerations || 0,
importedAt: new Date().toISOString(),
lastBatchImportedAt: new Date().toISOString(),
}, { merge: true });
console.log(`✅ Imported ${conversationCount} conversations to project ${projectId}`);
const workspaceFilesCount = conversations.workspaceFiles?.length || workspaceFiles?.length || 0;
const generationsCount = conversations.totalGenerations || totalGenerations || 0;
return NextResponse.json({
success: true,
conversationCount,
totalMessages: totalMessagesWritten,
workspaceFilesCount,
totalGenerations: generationsCount,
message: `Successfully imported ${conversationCount} conversations with ${totalMessagesWritten} messages`
});
} catch (error) {
console.error('Error importing Cursor conversations:', error);
return NextResponse.json(
{ error: 'Failed to import conversations', details: error instanceof Error ? error.message : String(error) },
{ status: 500 }
);
}
}