Firebase was not configured so every chat request crashed with 'Firebase Admin credentials not configured'. - chat-mode-resolver.ts: read project phase from fs_projects (Postgres) - chat-context.ts: load project data from fs_projects instead of Firestore - /api/ai/conversation: store/retrieve conversations in chat_conversations Postgres table (created automatically on first use) - /api/ai/chat: replace all Firestore reads/writes with Postgres queries - v_ai_chat/page.tsx: replace Firebase client auth with useSession from next-auth/react; remove Firestore listeners, use REST API for project data Co-authored-by: Cursor <cursoragent@cursor.com>
404 lines
12 KiB
TypeScript
404 lines
12 KiB
TypeScript
/**
|
|
* Project Context Builder for Chat
|
|
*
|
|
* Loads project state from Firestore and AlloyDB vector memory,
|
|
* building a compact context object for LLM consumption.
|
|
*/
|
|
|
|
import { query } from '@/lib/db-postgres';
|
|
import { retrieveRelevantChunks } from '@/lib/server/vector-memory';
|
|
import { embedText } from '@/lib/ai/embeddings';
|
|
import {
|
|
summarizeKnowledgeItems,
|
|
summarizeExtractions,
|
|
} from '@/lib/server/chat-mode-resolver';
|
|
import type { ChatMode } from '@/lib/ai/chat-modes';
|
|
import type { ProjectPhaseData, ProjectPhaseScores } from '@/lib/types/project-artifacts';
|
|
import type { PhaseHandoff } from '@/lib/types/phase-handoff';
|
|
|
|
/**
|
|
* Compact project context for LLM
|
|
*/
|
|
export interface ProjectChatContext {
|
|
/** Basic project info */
|
|
project: {
|
|
id: string;
|
|
name: string;
|
|
currentPhase: string;
|
|
phaseStatus: string;
|
|
githubRepo?: string | null;
|
|
githubRepoUrl?: string | null;
|
|
extensionLinked?: boolean;
|
|
visionAnswers?: {
|
|
q1?: string;
|
|
q2?: string;
|
|
q3?: string;
|
|
updatedAt?: string;
|
|
};
|
|
};
|
|
|
|
/** Phase-specific artifacts */
|
|
phaseData: {
|
|
canonicalProductModel?: any;
|
|
mvpPlan?: any;
|
|
marketingPlan?: any;
|
|
};
|
|
|
|
/** Phase scores and progress */
|
|
phaseScores: ProjectPhaseScores;
|
|
|
|
/** Phase handoffs for smart transitions */
|
|
phaseHandoffs: Partial<Record<'collector' | 'extraction' | 'vision' | 'mvp' | 'marketing', PhaseHandoff>>;
|
|
|
|
/** Knowledge summary (counts, types) */
|
|
knowledgeSummary: {
|
|
totalCount: number;
|
|
bySourceType: Record<string, number>;
|
|
recentTitles: string[];
|
|
};
|
|
|
|
/** Extraction summary */
|
|
extractionSummary: {
|
|
totalCount: number;
|
|
avgConfidence: number;
|
|
avgCompletion: number;
|
|
};
|
|
|
|
/** Relevant chunks from vector search */
|
|
retrievedChunks: {
|
|
content: string;
|
|
sourceType?: string | null;
|
|
importance?: string | null;
|
|
similarity: number;
|
|
}[];
|
|
|
|
/** Repository analysis (if GitHub connected) */
|
|
repositoryAnalysis?: {
|
|
repoFullName: string;
|
|
totalFiles: number;
|
|
directories: string[];
|
|
keyFiles: string[];
|
|
techStack: string[];
|
|
readme: string | null;
|
|
summary: string;
|
|
} | null;
|
|
|
|
/** Session history from linked Cursor sessions */
|
|
sessionHistory: {
|
|
totalSessions: number;
|
|
messages: Array<{
|
|
role: string;
|
|
content: string;
|
|
timestamp: string;
|
|
sessionId?: string;
|
|
}>;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Build project context for a chat interaction
|
|
*
|
|
* @param projectId - Firestore project ID
|
|
* @param mode - Current chat mode
|
|
* @param userMessage - User's message (used for vector retrieval)
|
|
* @param options - Context building options
|
|
* @returns Compact context object
|
|
*/
|
|
export async function buildProjectContextForChat(
|
|
projectId: string,
|
|
mode: ChatMode,
|
|
userMessage: string,
|
|
options: {
|
|
retrievalLimit?: number;
|
|
includeVectorSearch?: boolean;
|
|
includeGitHubAnalysis?: boolean;
|
|
} = {}
|
|
): Promise<ProjectChatContext> {
|
|
const {
|
|
retrievalLimit = 10,
|
|
includeVectorSearch = true,
|
|
includeGitHubAnalysis = true,
|
|
} = options;
|
|
|
|
try {
|
|
// Load project from Postgres
|
|
const projectRows = await query<{ data: any }>(
|
|
`SELECT data FROM fs_projects WHERE id = $1 LIMIT 1`,
|
|
[projectId]
|
|
);
|
|
if (projectRows.length === 0) {
|
|
throw new Error(`Project ${projectId} not found`);
|
|
}
|
|
|
|
const projectData = projectRows[0].data ?? {};
|
|
|
|
// Load summaries in parallel
|
|
const [knowledgeSummary, extractionSummary] = await Promise.all([
|
|
summarizeKnowledgeItems(projectId),
|
|
summarizeExtractions(projectId),
|
|
]);
|
|
|
|
// Vector retrieval
|
|
let retrievedChunks: ProjectChatContext['retrievedChunks'] = [];
|
|
|
|
// extraction_review_mode does NOT load documents - it reviews extraction results
|
|
// Normal vector search for modes that need it
|
|
if (includeVectorSearch && mode !== 'collector_mode' && mode !== 'extraction_review_mode' && userMessage.trim().length > 0) {
|
|
try {
|
|
const queryEmbedding = await embedText(userMessage);
|
|
const chunks = await retrieveRelevantChunks(projectId, queryEmbedding, {
|
|
limit: retrievalLimit,
|
|
minSimilarity: 0.7, // Only include reasonably relevant chunks
|
|
});
|
|
|
|
retrievedChunks = chunks.map((chunk) => ({
|
|
content: chunk.content,
|
|
sourceType: chunk.sourceType,
|
|
importance: chunk.importance,
|
|
similarity: chunk.similarity,
|
|
}));
|
|
|
|
console.log(
|
|
`[Chat Context] Retrieved ${retrievedChunks.length} chunks for project ${projectId}`
|
|
);
|
|
} catch (vectorError) {
|
|
console.error('[Chat Context] Vector retrieval failed:', vectorError);
|
|
// Continue without vector results
|
|
}
|
|
}
|
|
|
|
// GitHub repository analysis
|
|
let repositoryAnalysis = null;
|
|
if (includeGitHubAnalysis && projectData.githubRepo && projectData.userId) {
|
|
try {
|
|
const { analyzeGitHubRepository } = await import('@/lib/server/github-analyzer');
|
|
repositoryAnalysis = await analyzeGitHubRepository(
|
|
projectData.userId,
|
|
projectData.githubRepo,
|
|
projectData.githubDefaultBranch || 'main'
|
|
);
|
|
} catch (githubError) {
|
|
console.error('[Chat Context] GitHub analysis failed:', githubError);
|
|
// Continue without GitHub analysis
|
|
}
|
|
}
|
|
|
|
// Fetch linked Cursor session history
|
|
let sessionHistory = {
|
|
totalSessions: 0,
|
|
messages: [] as Array<{
|
|
role: string;
|
|
content: string;
|
|
timestamp: string;
|
|
sessionId?: string;
|
|
}>,
|
|
};
|
|
|
|
try {
|
|
// Query sessions linked to this project
|
|
const sessionsSnapshot = await adminDb
|
|
.collection('sessions')
|
|
.where('projectId', '==', projectId)
|
|
.orderBy('startTime', 'asc')
|
|
.get();
|
|
|
|
if (!sessionsSnapshot.empty) {
|
|
sessionHistory.totalSessions = sessionsSnapshot.size;
|
|
|
|
// Extract all messages from all sessions in chronological order
|
|
const allMessages: Array<{
|
|
role: string;
|
|
content: string;
|
|
timestamp: string;
|
|
sessionId: string;
|
|
}> = [];
|
|
|
|
for (const sessionDoc of sessionsSnapshot.docs) {
|
|
const sessionData = sessionDoc.data();
|
|
const conversation = sessionData.conversation || [];
|
|
|
|
// Add messages from this session
|
|
for (const msg of conversation) {
|
|
if (msg.content && msg.content.trim()) {
|
|
allMessages.push({
|
|
role: msg.role || 'unknown',
|
|
content: msg.content,
|
|
timestamp: msg.timestamp instanceof Date
|
|
? msg.timestamp.toISOString()
|
|
: (typeof msg.timestamp === 'string' ? msg.timestamp : new Date().toISOString()),
|
|
sessionId: sessionDoc.id,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sort all messages by timestamp (chronological order)
|
|
allMessages.sort((a, b) =>
|
|
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
|
|
);
|
|
|
|
sessionHistory.messages = allMessages;
|
|
|
|
console.log(
|
|
`[Chat Context] Loaded ${sessionHistory.totalSessions} sessions with ${allMessages.length} total messages for project ${projectId}`
|
|
);
|
|
} else {
|
|
console.log(`[Chat Context] No linked sessions found for project ${projectId}`);
|
|
}
|
|
} catch (sessionError) {
|
|
console.error('[Chat Context] Session history fetch failed:', sessionError);
|
|
// Continue without session history
|
|
}
|
|
|
|
// Build context object
|
|
const context: ProjectChatContext = {
|
|
project: {
|
|
id: projectId,
|
|
name: projectData.name ?? 'Unnamed Project',
|
|
currentPhase: projectData.currentPhase ?? 'collector',
|
|
phaseStatus: projectData.phaseStatus ?? 'not_started',
|
|
githubRepo: projectData.githubRepo ?? null,
|
|
githubRepoUrl: projectData.githubRepoUrl ?? null,
|
|
extensionLinked: projectData.extensionLinked ?? false,
|
|
visionAnswers: projectData.visionAnswers ?? {},
|
|
},
|
|
phaseData: {
|
|
canonicalProductModel: projectData.phaseData?.canonicalProductModel ?? null,
|
|
mvpPlan: projectData.phaseData?.mvpPlan ?? null,
|
|
marketingPlan: projectData.phaseData?.marketingPlan ?? null,
|
|
},
|
|
phaseScores: projectData.phaseScores ?? {},
|
|
phaseHandoffs: projectData.phaseData?.phaseHandoffs ?? {},
|
|
knowledgeSummary,
|
|
extractionSummary,
|
|
retrievedChunks,
|
|
repositoryAnalysis: repositoryAnalysis as any,
|
|
sessionHistory, // ✅ Include session history in context
|
|
};
|
|
|
|
return context;
|
|
} catch (error) {
|
|
console.error('[Chat Context] Failed to build context:', error);
|
|
throw new Error(
|
|
`Failed to build chat context: ${error instanceof Error ? error.message : String(error)}`
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Determine which artifacts were used in building the context
|
|
*
|
|
* This helps the UI show what sources the AI is drawing from.
|
|
*/
|
|
export function determineArtifactsUsed(context: ProjectChatContext): string[] {
|
|
const artifacts: string[] = [];
|
|
|
|
if (context.phaseData.canonicalProductModel) {
|
|
artifacts.push('Product Model');
|
|
}
|
|
|
|
if (context.phaseData.mvpPlan) {
|
|
artifacts.push('MVP Plan');
|
|
}
|
|
|
|
if (context.phaseData.marketingPlan) {
|
|
artifacts.push('Marketing Plan');
|
|
}
|
|
|
|
if (context.retrievedChunks.length > 0) {
|
|
artifacts.push(`${context.retrievedChunks.length} Vector Chunks`);
|
|
}
|
|
|
|
if (context.repositoryAnalysis) {
|
|
artifacts.push('GitHub Repo Analysis');
|
|
}
|
|
|
|
if (context.knowledgeSummary.totalCount > 0) {
|
|
artifacts.push(`${context.knowledgeSummary.totalCount} Knowledge Items`);
|
|
}
|
|
|
|
if (context.extractionSummary.totalCount > 0) {
|
|
artifacts.push(`${context.extractionSummary.totalCount} Extractions`);
|
|
}
|
|
|
|
if (context.sessionHistory.totalSessions > 0) {
|
|
artifacts.push(`${context.sessionHistory.totalSessions} Cursor Sessions (${context.sessionHistory.messages.length} messages)`);
|
|
}
|
|
|
|
return artifacts;
|
|
}
|
|
|
|
/**
|
|
* Format project context as a string for LLM system prompt
|
|
*
|
|
* Provides a human-readable summary of the context.
|
|
*/
|
|
export function formatContextForPrompt(context: ProjectChatContext): string {
|
|
const sections: string[] = [];
|
|
|
|
// Project info
|
|
sections.push(`Project: ${context.project.name} (ID: ${context.project.id})`);
|
|
sections.push(
|
|
`Phase: ${context.project.currentPhase} (${context.project.phaseStatus})`
|
|
);
|
|
|
|
// Knowledge summary
|
|
if (context.knowledgeSummary.totalCount > 0) {
|
|
sections.push(`\nKnowledge Items: ${context.knowledgeSummary.totalCount} total`);
|
|
if (Object.keys(context.knowledgeSummary.bySourceType).length > 0) {
|
|
sections.push(
|
|
` By type: ${JSON.stringify(context.knowledgeSummary.bySourceType)}`
|
|
);
|
|
}
|
|
}
|
|
|
|
// Extraction summary
|
|
if (context.extractionSummary.totalCount > 0) {
|
|
sections.push(
|
|
`\nExtractions: ${context.extractionSummary.totalCount} analyzed (avg confidence: ${(context.extractionSummary.avgConfidence * 100).toFixed(1)}%)`
|
|
);
|
|
}
|
|
|
|
// Retrieved chunks
|
|
if (context.retrievedChunks.length > 0) {
|
|
sections.push(`\nRelevant Context (vector search):`);
|
|
context.retrievedChunks.slice(0, 3).forEach((chunk, i) => {
|
|
sections.push(
|
|
` ${i + 1}. [${chunk.sourceType ?? 'unknown'}] (similarity: ${(chunk.similarity * 100).toFixed(1)}%)`
|
|
);
|
|
sections.push(` ${chunk.content.substring(0, 150)}...`);
|
|
});
|
|
}
|
|
|
|
// GitHub repo
|
|
if (context.repositoryAnalysis) {
|
|
sections.push(`\nGitHub Repository: ${context.repositoryAnalysis.repoFullName}`);
|
|
sections.push(` Files: ${context.repositoryAnalysis.totalFiles}`);
|
|
sections.push(` Tech: ${context.repositoryAnalysis.techStack.join(', ')}`);
|
|
}
|
|
|
|
// Phase handoffs
|
|
const handoffs = Object.keys(context.phaseHandoffs);
|
|
if (handoffs.length > 0) {
|
|
sections.push(`\nPhase Handoffs: ${handoffs.join(', ')}`);
|
|
}
|
|
|
|
// Session history
|
|
if (context.sessionHistory.totalSessions > 0) {
|
|
sections.push(`\n## Cursor Session History (${context.sessionHistory.totalSessions} sessions, ${context.sessionHistory.messages.length} messages)`);
|
|
sections.push(`This is your complete conversation history with the user from Cursor IDE, in chronological order.`);
|
|
sections.push(`Use this to understand what has been built, discussed, and decided so far.\n`);
|
|
|
|
// Include all messages chronologically
|
|
context.sessionHistory.messages.forEach((msg, i) => {
|
|
const timestamp = new Date(msg.timestamp).toLocaleString();
|
|
sections.push(`[${timestamp}] ${msg.role}:`);
|
|
sections.push(msg.content);
|
|
sections.push(''); // Empty line between messages
|
|
});
|
|
}
|
|
|
|
return sections.join('\n');
|
|
}
|
|
|