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:
2026-03-09 15:34:41 -07:00
parent 86f8960aa3
commit 01848ba682
2 changed files with 435 additions and 117 deletions

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { Suspense, useState, useEffect, useCallback } from "react"; import { Suspense, useState, useEffect, useCallback, useRef } from "react";
import { useParams, useSearchParams, useRouter } from "next/navigation"; import { useParams, useSearchParams, useRouter } from "next/navigation";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import Link from "next/link"; import Link from "next/link";
@@ -812,6 +812,181 @@ function TerminalPanel({ appName }: { appName: string }) {
); );
} }
// ── COO / Assist chat — persistent left-side advisor ─────────────────────────
interface CooMessage {
id: string;
role: "user" | "assistant";
content: string;
streaming?: boolean;
}
const WELCOME: CooMessage = {
id: "welcome",
role: "assistant",
content: "Hi. I'm your product COO I know your codebase, your goals, and what's been built. What do you need?",
};
function CooChat({ projectId, projectName }: { projectId: string; projectName: string }) {
const [messages, setMessages] = useState<CooMessage[]>([WELCOME]);
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
const bottomRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
const send = async () => {
const text = input.trim();
if (!text || loading) return;
setInput("");
const userMsg: CooMessage = { id: Date.now().toString(), role: "user", content: text };
const assistantId = (Date.now() + 1).toString();
const assistantMsg: CooMessage = { id: assistantId, role: "assistant", content: "", streaming: true };
setMessages(prev => [...prev, userMsg, assistantMsg]);
setLoading(true);
// Build history for the API (exclude welcome message, exclude the new blank assistant placeholder)
const history = messages
.filter(m => m.id !== "welcome" && m.content)
.map(m => ({ role: m.role === "assistant" ? "model" as const : "user" as const, content: m.content }));
try {
const res = await fetch(`/api/projects/${projectId}/advisor`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: text, history }),
});
if (!res.ok || !res.body) {
setMessages(prev => prev.map(m => m.id === assistantId
? { ...m, content: "Something went wrong. Please try again.", streaming: false }
: m));
return;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
setMessages(prev => prev.map(m => m.id === assistantId
? { ...m, content: m.content + chunk }
: m));
}
setMessages(prev => prev.map(m => m.id === assistantId ? { ...m, streaming: false } : m));
} catch {
setMessages(prev => prev.map(m => m.id === assistantId
? { ...m, content: "Connection error. Please try again.", streaming: false }
: m));
} finally {
setLoading(false);
textareaRef.current?.focus();
}
};
return (
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
{/* Messages */}
<div style={{ flex: 1, overflow: "auto", padding: "16px 16px 8px", display: "flex", flexDirection: "column", gap: 14 }}>
{messages.map(msg => (
<div key={msg.id} style={{
display: "flex",
flexDirection: msg.role === "user" ? "row-reverse" : "row",
alignItems: "flex-end", gap: 8,
}}>
{msg.role === "assistant" && (
<span style={{ width: 22, height: 22, borderRadius: 6, background: "#1a1a1a", display: "flex", alignItems: "center", justifyContent: "center", fontSize: "0.58rem", color: "#fff", flexShrink: 0 }}>◈</span>
)}
<div style={{
maxWidth: "82%",
padding: msg.role === "user" ? "8px 12px" : "0",
background: msg.role === "user" ? "#f0ece4" : "transparent",
borderRadius: msg.role === "user" ? 12 : 0,
fontSize: "0.8rem",
color: "#1a1a1a",
fontFamily: "Outfit, sans-serif",
lineHeight: 1.6,
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}>
{msg.content || (msg.streaming ? "" : "")}
{msg.streaming && msg.content === "" && (
<span style={{ display: "inline-flex", gap: 3, alignItems: "center" }}>
{[0, 1, 2].map(i => (
<span key={i} style={{
width: 4, height: 4, borderRadius: "50%", background: "#b5b0a6", display: "inline-block",
animation: `bounce 1.2s ${i * 0.2}s ease-in-out infinite`,
}} />
))}
</span>
)}
{msg.streaming && msg.content !== "" && (
<span style={{ display: "inline-block", width: 2, height: "0.9em", background: "#1a1a1a", marginLeft: 1, verticalAlign: "text-bottom", animation: "blink 1s step-end infinite" }} />
)}
</div>
</div>
))}
<div ref={bottomRef} />
</div>
{/* Input */}
<div style={{ flexShrink: 0, borderTop: "1px solid #e8e4dc", padding: "10px 12px", background: "#fff" }}>
<div style={{ display: "flex", gap: 8, alignItems: "flex-end" }}>
<textarea
ref={textareaRef}
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send(); } }}
placeholder={loading ? "Thinking" : "Ask anything"}
disabled={loading}
rows={2}
style={{
flex: 1, resize: "none", border: "1px solid #e8e4dc", borderRadius: 10,
padding: "8px 11px", fontSize: "0.8rem", fontFamily: "Outfit, sans-serif",
color: "#1a1a1a", outline: "none", background: loading ? "#faf8f5" : "#faf8f5",
lineHeight: 1.5,
}}
/>
<button
onClick={send}
disabled={!input.trim() || loading}
style={{
width: 34, height: 34, flexShrink: 0, border: "none", borderRadius: 9,
background: input.trim() && !loading ? "#1a1a1a" : "#e8e4dc",
color: input.trim() && !loading ? "#fff" : "#b5b0a6",
cursor: input.trim() && !loading ? "pointer" : "default",
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: "0.85rem", transition: "background 0.15s",
}}
>↑</button>
</div>
<div style={{ fontSize: "0.62rem", color: "#b5b0a6", marginTop: 5, fontFamily: "Outfit, sans-serif" }}>
↵ to send · Shift+↵ new line
</div>
</div>
<style>{`
@keyframes bounce {
0%, 60%, 100% { transform: translateY(0); }
30% { transform: translateY(-4px); }
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
`}</style>
</div>
);
}
// ── File tree (lives in sidebar B) ─────────────────────────────────────────── // ── File tree (lives in sidebar B) ───────────────────────────────────────────
function FileTree({ projectId, rootPath, selectedPath, onSelectFile }: { function FileTree({ projectId, rootPath, selectedPath, onSelectFile }: {
@@ -937,26 +1112,27 @@ function BuildHubInner() {
const [activeSurfaceId, setActiveSurfaceId] = useState<string>(activeSurfaceParam); const [activeSurfaceId, setActiveSurfaceId] = useState<string>(activeSurfaceParam);
const [projectName, setProjectName] = useState<string>(""); const [projectName, setProjectName] = useState<string>("");
// File viewer state — lifted so FileTree (B) and FileViewer (right) share it // File viewer state — shared between inner nav (tree) and viewer panel
const [selectedFilePath, setSelectedFilePath] = useState<string | null>(null); const [selectedFilePath, setSelectedFilePath] = useState<string | null>(null);
const [fileContent, setFileContent] = useState<string | null>(null); const [fileContent, setFileContent] = useState<string | null>(null);
const [fileLoading, setFileLoading] = useState(false); const [fileLoading, setFileLoading] = useState(false);
const [fileName, setFileName] = useState<string | null>(null); const [fileName, setFileName] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
fetch(`/api/projects/${projectId}/apps`).then(r => r.json()).then(d => setApps(d.apps ?? [])).catch(() => {}); fetch(`/api/projects/${projectId}/apps`)
.then(r => r.json())
.then(d => {
setApps(d.apps ?? []);
if (d.projectName) setProjectName(d.projectName);
}).catch(() => {});
fetch(`/api/projects/${projectId}/design-surfaces`).then(r => r.json()).then(d => { fetch(`/api/projects/${projectId}/design-surfaces`).then(r => r.json()).then(d => {
const ids: string[] = d.surfaces ?? []; const ids: string[] = d.surfaces ?? [];
const themes: Record<string, string> = d.surfaceThemes ?? {}; const themes: Record<string, string> = d.surfaceThemes ?? {};
setSurfaces(ids.map(id => ({ id, label: SURFACE_LABELS[id] ?? id, lockedTheme: themes[id] }))); setSurfaces(ids.map(id => ({ id, label: SURFACE_LABELS[id] ?? id, lockedTheme: themes[id] })));
if (!activeSurfaceId && ids.length > 0) setActiveSurfaceId(ids[0]); if (!activeSurfaceId && ids.length > 0) setActiveSurfaceId(ids[0]);
}).catch(() => {}); }).catch(() => {});
// Best-effort project name fetch
fetch(`/api/projects/${projectId}/apps`).then(r => r.json())
.then(d => { if (d.projectName) setProjectName(d.projectName); }).catch(() => {});
}, [projectId]); // eslint-disable-line react-hooks/exhaustive-deps }, [projectId]); // eslint-disable-line react-hooks/exhaustive-deps
// Clear file selection when app changes
useEffect(() => { useEffect(() => {
setSelectedFilePath(null); setSelectedFilePath(null);
setFileContent(null); setFileContent(null);
@@ -986,31 +1162,46 @@ function BuildHubInner() {
return ( return (
<div style={{ display: "flex", height: "100%", fontFamily: "Outfit, sans-serif", overflow: "hidden" }}> <div style={{ display: "flex", height: "100%", fontFamily: "Outfit, sans-serif", overflow: "hidden" }}>
{/* ── B: Sidebar — project header + build pills + nav + file tree ── */} {/* ── Left: COO / Assist persistent chat ── */}
<div style={{ width: 260, flexShrink: 0, borderRight: "1px solid #e8e4dc", background: "#faf8f5", display: "flex", flexDirection: "column", overflow: "hidden" }}> <div style={{ width: 340, flexShrink: 0, borderRight: "1px solid #e8e4dc", background: "#fff", display: "flex", flexDirection: "column", overflow: "hidden" }}>
{/* Project header */} {/* Advisor header */}
<div style={{ height: 48, flexShrink: 0, display: "flex", alignItems: "center", padding: "0 14px", borderBottom: "1px solid #e8e4dc", gap: 9 }}> <div style={{ height: 48, flexShrink: 0, display: "flex", alignItems: "center", padding: "0 14px", borderBottom: "1px solid #e8e4dc", gap: 9, background: "#faf8f5" }}>
<span style={{ width: 26, height: 26, borderRadius: 7, background: "#1a1a1a", display: "flex", alignItems: "center", justifyContent: "center", fontSize: "0.68rem", color: "#fff", flexShrink: 0, fontFamily: "Outfit, sans-serif" }}></span> <span style={{ width: 26, height: 26, borderRadius: 7, background: "#1a1a1a", display: "flex", alignItems: "center", justifyContent: "center", fontSize: "0.68rem", color: "#fff", flexShrink: 0 }}></span>
<span style={{ fontSize: "0.82rem", fontWeight: 600, color: "#1a1a1a", fontFamily: "Outfit, sans-serif", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", flex: 1 }}> <div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: "0.78rem", fontWeight: 600, color: "#1a1a1a", fontFamily: "Outfit, sans-serif", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{projectName || workspace} {projectName || workspace}
</span> </div>
<div style={{ fontSize: "0.6rem", color: "#a09a90", fontFamily: "Outfit, sans-serif", letterSpacing: "0.04em" }}>Assist · Your product COO</div>
</div>
</div> </div>
{/* Build section pills: Code | Layouts | Infra */} <CooChat projectId={projectId} projectName={projectName || workspace} />
</div>
{/* ── Right: Build content ── */}
<div style={{ flex: 1, display: "flex", overflow: "hidden", minWidth: 0 }}>
{/* Inner nav — Build section switcher + contextual items */}
<div style={{ width: 200, flexShrink: 0, borderRight: "1px solid #e8e4dc", background: "#faf8f5", display: "flex", flexDirection: "column", overflow: "hidden" }}>
{/* Build pills */}
<div style={{ padding: "10px 12px 9px", flexShrink: 0, borderBottom: "1px solid #f0ece4" }}> <div style={{ padding: "10px 12px 9px", flexShrink: 0, borderBottom: "1px solid #f0ece4" }}>
<div style={{ fontSize: "0.57rem", fontWeight: 700, color: "#b5b0a6", letterSpacing: "0.1em", textTransform: "uppercase", marginBottom: 7, fontFamily: "Outfit, sans-serif" }}>Build</div> <div style={{ fontSize: "0.57rem", fontWeight: 700, color: "#b5b0a6", letterSpacing: "0.1em", textTransform: "uppercase", marginBottom: 7, fontFamily: "Outfit, sans-serif" }}>Build</div>
<div style={{ display: "flex", gap: 5 }}> <div style={{ display: "flex", flexDirection: "column", gap: 3 }}>
{(["code", "layouts", "infrastructure"] as const).map(s => ( {(["code", "layouts", "infrastructure"] as const).map(s => (
<button key={s} onClick={() => setSection(s)} style={{ <button key={s} onClick={() => setSection(s)} style={{
padding: "4px 10px", border: "1px solid", display: "flex", alignItems: "center", gap: 8,
borderRadius: 20, fontSize: "0.68rem", fontWeight: section === s ? 600 : 440, padding: "6px 10px", border: "none", borderRadius: 7,
cursor: "pointer", fontFamily: "Outfit, sans-serif", fontSize: "0.76rem", fontWeight: section === s ? 600 : 440,
background: section === s ? "#1a1a1a" : "transparent", cursor: "pointer", fontFamily: "Outfit, sans-serif", textAlign: "left",
color: section === s ? "#fff" : "#5a5550", background: section === s ? "#f0ece4" : "transparent",
borderColor: section === s ? "#1a1a1a" : "#e0dcd5", color: section === s ? "#1a1a1a" : "#5a5550",
}}> }}>
{s === "code" ? "Code" : s === "layouts" ? "Layouts" : "Infra"} <span style={{ fontSize: "0.65rem", opacity: 0.6 }}>
{s === "code" ? "<>" : s === "layouts" ? "▢" : "⬡"}
</span>
{s === "code" ? "Code" : s === "layouts" ? "Layouts" : "Infrastructure"}
</button> </button>
))} ))}
</div> </div>
@@ -1019,7 +1210,6 @@ function BuildHubInner() {
{/* Code: app list + file tree */} {/* Code: app list + file tree */}
{section === "code" && ( {section === "code" && (
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}> <div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
{/* App list */}
<div style={{ flexShrink: 0 }}> <div style={{ flexShrink: 0 }}>
<div style={NAV_GROUP_LABEL}>Apps</div> <div style={NAV_GROUP_LABEL}>Apps</div>
{apps.length > 0 ? apps.map(app => ( {apps.length > 0 ? apps.map(app => (
@@ -1031,20 +1221,13 @@ function BuildHubInner() {
<div style={{ padding: "8px 22px", fontSize: "0.74rem", color: "#b5b0a6", fontFamily: "Outfit, sans-serif" }}>No apps yet</div> <div style={{ padding: "8px 22px", fontSize: "0.74rem", color: "#b5b0a6", fontFamily: "Outfit, sans-serif" }}>No apps yet</div>
)} )}
</div> </div>
{/* File tree — appears below app list when an app is selected */}
{activeApp && activeRoot && ( {activeApp && activeRoot && (
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden", borderTop: "1px solid #e8e4dc", marginTop: 6 }}> <div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden", borderTop: "1px solid #e8e4dc", marginTop: 6 }}>
<div style={{ padding: "7px 12px 4px", flexShrink: 0, display: "flex", alignItems: "center", gap: 6 }}> <div style={{ padding: "7px 12px 4px", flexShrink: 0, display: "flex", alignItems: "center", gap: 6 }}>
<span style={{ fontSize: "0.57rem", fontWeight: 700, color: "#b5b0a6", letterSpacing: "0.1em", textTransform: "uppercase", fontFamily: "Outfit, sans-serif" }}>Files</span> <span style={{ fontSize: "0.57rem", fontWeight: 700, color: "#b5b0a6", letterSpacing: "0.1em", textTransform: "uppercase", fontFamily: "Outfit, sans-serif" }}>Files</span>
<span style={{ fontSize: "0.65rem", color: "#a09a90", fontFamily: "IBM Plex Mono, monospace", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{activeApp}</span> <span style={{ fontSize: "0.62rem", color: "#a09a90", fontFamily: "IBM Plex Mono, monospace", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{activeApp}</span>
</div> </div>
<FileTree <FileTree projectId={projectId} rootPath={activeRoot} selectedPath={selectedFilePath} onSelectFile={handleSelectFile} />
projectId={projectId}
rootPath={activeRoot}
selectedPath={selectedFilePath}
onSelectFile={handleSelectFile}
/>
</div> </div>
)} )}
</div> </div>
@@ -1079,21 +1262,9 @@ function BuildHubInner() {
)} )}
</div> </div>
{/* ── Content ── */} {/* Main content panel */}
<div style={{ flex: 1, display: "flex", overflow: "hidden", minWidth: 0 }}>
{/* Code: D (agent chat) + Right (file viewer) + F (terminal) */}
{section === "code" && ( {section === "code" && (
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}> <div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
<div style={{ flex: 1, display: "flex", overflow: "hidden" }}>
{/* D: Agent chat — main content */}
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden", borderRight: "1px solid #e8e4dc", minWidth: 0 }}>
<AgentMode projectId={projectId} appName={activeApp} appPath={activeRoot} />
</div>
{/* Right: File viewer — code changes stream here */}
<div style={{ width: 460, flexShrink: 0, display: "flex", flexDirection: "column", overflow: "hidden" }}>
<FileViewer <FileViewer
selectedPath={selectedFilePath} selectedPath={selectedFilePath}
fileContent={fileContent} fileContent={fileContent}
@@ -1101,14 +1272,9 @@ function BuildHubInner() {
fileName={fileName} fileName={fileName}
rootPath={activeRoot} rootPath={activeRoot}
/> />
</div>
</div>
{/* F: Terminal strip */}
<TerminalPanel appName={activeApp} /> <TerminalPanel appName={activeApp} />
</div> </div>
)} )}
{section === "layouts" && ( {section === "layouts" && (
<LayoutsContent surfaces={surfaces} projectId={projectId} workspace={workspace} activeSurfaceId={activeSurfaceId} onSelectSurface={id => { setActiveSurfaceId(id); navigate({ section: "layouts", surface: id }); }} /> <LayoutsContent surfaces={surfaces} projectId={projectId} workspace={workspace} activeSurfaceId={activeSurfaceId} onSelectSurface={id => { setActiveSurfaceId(id); navigate({ section: "layouts", surface: id }); }} />
)} )}

View 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' },
});
}