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
This commit is contained in:
152
app/api/projects/[projectId]/advisor/route.ts
Normal file
152
app/api/projects/[projectId]/advisor/route.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
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' },
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user