Files
vibn-frontend/app/api/projects/[projectId]/advisor/route.ts
Mark Henderson 853e41705f feat: split top navbar to align with chat/content panels, fix Gemini API key
- Top bar left section (320px) = logo + project name, aligns with chat panel
- Top bar right section = Build|Market|Assist pills + tool icons (Preview, Tasks, Code, Design, Backend) + avatar
- Read GOOGLE_API_KEY inside POST handler (not top-level) to ensure env is resolved at request time

Made-with: Cursor
2026-03-09 16:17:31 -07:00

154 lines
5.4 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.0-flash';
function buildSystemPrompt(projectData: Record<string, unknown>): 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) ?? '';
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();
}
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 ?? '';
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 { 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 project data for context
let projectData: Record<string, unknown> = {};
try {
const rows = await 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]
);
if (rows.length > 0) projectData = rows[0].data ?? {};
} catch { /* best-effort, proceed without context */ }
const systemPrompt = buildSystemPrompt(projectData);
// Build Gemini conversation history
const contents = [
...history.map(h => ({
role: h.role,
parts: [{ text: h.content }],
})),
{ role: 'user' as const, parts: [{ text: message }] },
];
// Call Gemini streaming
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.7,
maxOutputTokens: 1024,
},
}),
});
if (!geminiRes.ok) {
const err = await geminiRes.text();
return new Response(`Gemini error: ${err}`, { status: 502 });
}
// Proxy the SSE stream, extracting just the text deltas
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 chunk */ }
}
}
} finally {
controller.close();
}
},
});
return new Response(stream, {
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
});
}