Files
vibn-frontend/app/api/projects/[projectId]/advisor/route.ts
Mark Henderson 01848ba682 feat: add persistent COO/Assist chat as left-side primary AI interface
- New CooChat component: streaming Gemini-backed advisor chat, message
  bubbles, typing cursor animation, Shift+Enter for newlines
- New /api/projects/[projectId]/advisor streaming endpoint: builds a
  COO system prompt from project context (name, description, vision,
  repo), proxies Gemini SSE stream back to the client
- Restructured BuildHubInner layout:
    Left (340px): CooChat — persistent across all Build sections
    Inner nav (200px): Build pills + contextual items (apps, tree, surfaces)
    Main area: File viewer for Code, Layouts content, Infra content
- AgentMode removed from main view — execution surfaces via COO delegation

Made-with: Cursor
2026-03-09 15:34:41 -07:00

153 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 GOOGLE_API_KEY = process.env.GOOGLE_API_KEY ?? '';
const MODEL = process.env.GEMINI_MODEL ?? 'gemini-2.0-flash-exp';
const STREAM_URL = `https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:streamGenerateContent?key=${GOOGLE_API_KEY}&alt=sse`;
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 });
}
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' },
});
}