import { NextRequest } from 'next/server'; import { getServerSession } from 'next-auth'; import { authOptions } from '@/lib/auth/authOptions'; import { query } from '@/lib/db-postgres'; const MODEL = process.env.GEMINI_MODEL ?? 'gemini-2.5-flash'; // --------------------------------------------------------------------------- // Context loaders — all best-effort, never block the response // --------------------------------------------------------------------------- interface SavedPhase { phase: string; title: string; summary: string; data: Record; saved_at: string; } interface AgentSession { id: string; task: string; status: string; created_at: string; result?: string; } async function loadPhases(projectId: string): Promise { try { const rows = await query( `SELECT phase, title, summary, data, saved_at FROM atlas_phases WHERE project_id = $1 ORDER BY saved_at ASC`, [projectId] ); return rows; } catch { return []; } } async function loadRecentSessions(projectId: string): Promise { try { const rows = await query( `SELECT id, data->>'task' as task, data->>'status' as status, data->>'createdAt' as created_at, data->>'result' as result FROM fs_sessions WHERE data->>'projectId' = $1 ORDER BY created_at DESC LIMIT 10`, [projectId] ); return rows.filter(r => r.task); } catch { return []; } } // --------------------------------------------------------------------------- // System prompt — only states what it actually has // --------------------------------------------------------------------------- function buildSystemPrompt( projectData: Record, phases: SavedPhase[], sessions: AgentSession[], ): string { const name = (projectData.name as string) ?? 'this project'; const vision = (projectData.productVision as string) ?? (projectData.vision as string) ?? ''; const repo = (projectData.giteaRepo as string) ?? ''; const prd = (projectData.prd as string) ?? ''; const apps = (projectData.apps as Array<{ name: string; domain?: string }>) ?? []; const theiaUrl = (projectData.theiaWorkspaceUrl as string) ?? ''; // PRD / discovery phases let prdSection = ''; if (prd) { prdSection = `\n## Product Requirements Document (PRD)\n${prd.slice(0, 3000)}${prd.length > 3000 ? '\n[…truncated]' : ''}`; } else if (phases.length > 0) { const phaseLines = phases.map(p => `### ${p.title} (${p.phase})\n${p.summary}\n${ Object.entries(p.data) .filter(([, v]) => v !== null && v !== '' && !Array.isArray(v)) .slice(0, 6) .map(([k, v]) => `- ${k.replace(/_/g, ' ')}: ${v}`) .join('\n') }` ).join('\n\n'); prdSection = `\n## Discovery phases completed (${phases.length})\n${phaseLines}`; } // Apps / deployment let deploySection = ''; if (apps.length > 0) { const appLines = apps.map(a => `- ${a.name}${a.domain ? ` → https://${a.domain}` : ''}`).join('\n'); deploySection = `\n## Deployed apps\n${appLines}`; } // Recent agent work let sessionsSection = ''; if (sessions.length > 0) { const lines = sessions .slice(0, 6) .map(s => `- [${s.status ?? 'unknown'}] ${s.task}`) .join('\n'); sessionsSection = `\n## Recent agent sessions (what's been worked on)\n${lines}`; } return `You are the AI COO for "${name}" — an executive-level product partner, not a chatbot. Your role: the founder talks to you first. You reason across product, code, growth, and operations. You surface what matters, propose clear plans, and delegate to specialist agents when needed. ## What you know about this project ${vision ? `Vision: ${vision}` : ''} ${repo ? `Codebase: ${repo}` : ''} ${theiaUrl ? `IDE workspace: ${theiaUrl}` : ''} ${prdSection} ${deploySection} ${sessionsSection} ## Your operating principles - You are an executive partner, not an assistant. You have opinions. - You reason across the full business — product, tech, growth, ops — not just the question asked. - You surface what the founder hasn't asked about when it's relevant. - Before any work starts: state the scope in plain language, then ask "Want me to proceed?" - Be brief. Founders are busy. No preamble, no "Great question!". - Never ask the founder to pick a technical approach — you decide and explain your reasoning. - When you don't have data (e.g. analytics, recent commits), say so directly rather than guessing. - Be honest about the limits of your current context.`.trim(); } // --------------------------------------------------------------------------- // POST handler // --------------------------------------------------------------------------- export async function POST( req: NextRequest, { params }: { params: Promise<{ projectId: string }> } ) { const { projectId } = await params; const session = await getServerSession(authOptions); if (!session?.user?.email) { return new Response('Unauthorized', { status: 401 }); } const GOOGLE_API_KEY = process.env.GOOGLE_API_KEY ?? ''; if (!GOOGLE_API_KEY) { return new Response('GOOGLE_API_KEY not configured', { status: 500 }); } const STREAM_URL = `https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:streamGenerateContent?key=${GOOGLE_API_KEY}&alt=sse`; const { message, history = [] } = await req.json() as { message: string; history: Array<{ role: 'user' | 'model'; content: string }>; }; if (!message?.trim()) { return new Response('Message required', { status: 400 }); } // Load all context in parallel — never block on failure let projectData: Record = {}; let phases: SavedPhase[] = []; let sessions: AgentSession[] = []; try { const [projectRows, phasesData, sessionsData] = await Promise.all([ query<{ data: Record }>( `SELECT p.data FROM fs_projects p JOIN fs_users u ON u.id = p.user_id WHERE p.id = $1 AND u.data->>'email' = $2 LIMIT 1`, [projectId, session.user.email] ), loadPhases(projectId), loadRecentSessions(projectId), ]); if (projectRows.length > 0) projectData = projectRows[0].data ?? {}; phases = phasesData; sessions = sessionsData; } catch { /* proceed with whatever loaded */ } const systemPrompt = buildSystemPrompt(projectData, phases, sessions); const contents = [ ...history.map(h => ({ role: h.role, parts: [{ text: h.content }], })), { role: 'user' as const, parts: [{ text: message }] }, ]; const geminiRes = await fetch(STREAM_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ system_instruction: { parts: [{ text: systemPrompt }] }, contents, generationConfig: { temperature: 0.6, maxOutputTokens: 1500, }, }), }); if (!geminiRes.ok) { const err = await geminiRes.text(); return new Response(`Gemini error: ${err}`, { status: 500 }); } // Proxy SSE stream — extract text deltas only const encoder = new TextEncoder(); const stream = new ReadableStream({ async start(controller) { const reader = geminiRes.body!.getReader(); const decoder = new TextDecoder(); let buf = ''; try { while (true) { const { done, value } = await reader.read(); if (done) break; buf += decoder.decode(value, { stream: true }); const lines = buf.split('\n'); buf = lines.pop() ?? ''; for (const line of lines) { if (!line.startsWith('data: ')) continue; const json = line.slice(6).trim(); if (!json || json === '[DONE]') continue; try { const chunk = JSON.parse(json); const text = chunk.candidates?.[0]?.content?.parts?.[0]?.text; if (text) controller.enqueue(encoder.encode(text)); } catch { /* skip malformed */ } } } } finally { controller.close(); } }, }); return new Response(stream, { headers: { 'Content-Type': 'text/plain; charset=utf-8' }, }); }