Files
vibn-frontend/app/api/projects/[projectId]/advisor/route.ts

242 lines
8.1 KiB
TypeScript

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<string, unknown>;
saved_at: string;
}
interface AgentSession {
id: string;
task: string;
status: string;
created_at: string;
result?: string;
}
async function loadPhases(projectId: string): Promise<SavedPhase[]> {
try {
const rows = await query<SavedPhase>(
`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<AgentSession[]> {
try {
const rows = await query<AgentSession>(
`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<string, unknown>,
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<string, unknown> = {};
let phases: SavedPhase[] = [];
let sessions: AgentSession[] = [];
try {
const [projectRows, phasesData, sessionsData] = await Promise.all([
query<{ data: Record<string, unknown> }>(
`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' },
});
}