diff --git a/app/api/projects/[projectId]/advisor/route.ts b/app/api/projects/[projectId]/advisor/route.ts index 08f7e34..93bed82 100644 --- a/app/api/projects/[projectId]/advisor/route.ts +++ b/app/api/projects/[projectId]/advisor/route.ts @@ -3,47 +3,131 @@ 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.0-flash'; +const MODEL = process.env.GEMINI_MODEL ?? 'gemini-2.5-flash'; -function buildSystemPrompt(projectData: Record): string { - const name = (projectData.name as string) ?? 'this project'; - const description = (projectData.description as string) ?? ''; - const vision = (projectData.vision as string) ?? ''; - const giteaRepo = (projectData.giteaRepo as string) ?? ''; +// --------------------------------------------------------------------------- +// Context loaders — all best-effort, never block the response +// --------------------------------------------------------------------------- - return `You are the personal AI COO for "${name}" — a product assistant that thinks like a seasoned Chief Operating Officer. - -Your role is to be the founder's single AI interface. The founder talks to you. You figure out what needs to happen and make it clear before anything is done. - -## About this project -${description ? `Description: ${description}` : ''} -${vision ? `Vision: ${vision}` : ''} -${giteaRepo ? `Codebase: ${giteaRepo}` : ''} - -## Your principles -- You are NOT a chatbot. You are an executive partner. -- You reason across the entire business: product, code, growth, analytics, operations. -- You surface insights the founder hasn't asked about when they're relevant. -- You speak in plain language. No jargon without explanation. -- Before any work is delegated, you surface a clear plan: "Here's what I'd do: [plain language]. Want me to start?" -- You are brief and direct. Founders are busy. -- When something needs to be built, you describe the scope before acting. -- When something needs analysis, you explain what you found and what it means. -- You never ask the founder to pick a module, agent, or technical approach — you figure that out. - -## What you have access to (context expands over time) -- The project codebase and commit history -- Agent session outcomes (what's been built) -- Analytics and deployment status -- Market and competitor knowledge (via web search) -- Persistent memory of decisions, preferences, patterns - -## Tone -Confident, brief, thoughtful. Like a trusted advisor who has seen this before. Not overly formal, not casual. Never sycophantic — skip "Great question!" entirely. - -Start each conversation ready to help. If this is the user's first message, greet them briefly and ask what's on their mind.`.trim(); +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 }> } @@ -56,12 +140,12 @@ export async function POST( } const GOOGLE_API_KEY = process.env.GOOGLE_API_KEY ?? ''; - const STREAM_URL = `https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:streamGenerateContent?key=${GOOGLE_API_KEY}&alt=sse`; - 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 }>; @@ -71,21 +155,29 @@ export async function POST( return new Response('Message required', { status: 400 }); } - // Load project data for context + // Load all context in parallel — never block on failure let projectData: Record = {}; + let phases: SavedPhase[] = []; + let sessions: AgentSession[] = []; + try { - const rows = await 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] - ); - if (rows.length > 0) projectData = rows[0].data ?? {}; - } catch { /* best-effort, proceed without context */ } + 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); + const systemPrompt = buildSystemPrompt(projectData, phases, sessions); - // Build Gemini conversation history const contents = [ ...history.map(h => ({ role: h.role, @@ -94,7 +186,6 @@ export async function POST( { role: 'user' as const, parts: [{ text: message }] }, ]; - // Call Gemini streaming const geminiRes = await fetch(STREAM_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -102,34 +193,31 @@ export async function POST( system_instruction: { parts: [{ text: systemPrompt }] }, contents, generationConfig: { - temperature: 0.7, - maxOutputTokens: 1024, + temperature: 0.6, + maxOutputTokens: 1500, }, }), }); if (!geminiRes.ok) { const err = await geminiRes.text(); - return new Response(`Gemini error: ${err}`, { status: 502 }); + return new Response(`Gemini error: ${err}`, { status: 500 }); } - // Proxy the SSE stream, extracting just the text deltas + // 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(); @@ -138,7 +226,7 @@ export async function POST( const chunk = JSON.parse(json); const text = chunk.candidates?.[0]?.content?.parts?.[0]?.text; if (text) controller.enqueue(encoder.encode(text)); - } catch { /* skip malformed chunk */ } + } catch { /* skip malformed */ } } } } finally {