- 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
154 lines
5.4 KiB
TypeScript
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' },
|
|
});
|
|
}
|