Files
vibn-frontend/app/api/chat/route.ts
Mark Henderson 8872ab606b Fix tool calling: use non-streaming generateContent for tool rounds
Gemini 3.1 Pro thinking model requires thought_signature to be echoed
in functionResponse. SSE stream doesn't reliably include it in individual
chunks. Switch tool-calling rounds to non-streaming generateContent which
always returns the complete response with thought_signature present.

Made-with: Cursor
2026-04-27 17:18:34 -07:00

218 lines
7.4 KiB
TypeScript

/**
* POST /api/chat
*
* Streaming chat endpoint. Accepts a thread_id + user message,
* loads history, calls Gemini 3.1 Pro, runs the tool loop,
* persists messages, and streams SSE back to the client.
*
* SSE event shapes:
* data: {"type":"text","text":"..."}
* data: {"type":"tool_start","name":"...","args":{}}
* data: {"type":"tool_result","name":"...","result":"..."}
* data: {"type":"done"}
* data: {"type":"error","error":"..."}
*/
import { NextResponse } from 'next/server';
import { authSession } from '@/lib/auth/session-server';
import { query } from '@/lib/db-postgres';
import { callGeminiChat, streamGeminiChat } from '@/lib/ai/gemini-chat';
import { VIBN_TOOL_DEFINITIONS, executeMcpTool } from '@/lib/ai/vibn-tools';
import type { ChatMessage, ToolCall } from '@/lib/ai/gemini-chat';
const MAX_TOOL_ROUNDS = 6;
function buildSystemPrompt(projects: any[], workspace: string): string {
const projectsText = projects.length
? projects
.map(
(p: any) =>
`- "${p.productName || p.name}" (id: ${p.id}, status: ${p.status || 'defining'})${p.productVision ? ': ' + p.productVision.slice(0, 120) : ''}`,
)
.join('\n')
: '(no projects yet)';
return `You are Vibn AI, an expert product and infrastructure assistant embedded in the Vibn platform.
You are talking to the owner of the "${workspace}" workspace.
## Current workspace projects
${projectsText}
## Your capabilities
You can take real actions by calling tools:
- List/inspect projects and deployed apps
- Deploy new applications from templates (n8n, WordPress, Twenty CRM, etc.) or Docker images
- Register and manage custom domains
- View application logs
- Search available app templates
## Guidelines
- Be concise and action-oriented. If the user wants to deploy something, do it — don't just describe how.
- When deploying, always confirm the app name and domain with the user first unless they've been explicit.
- After a tool call, summarize what happened in plain language.
- If a deployment takes time, let the user know it may take a few minutes.
- Format code, URLs, and technical values in backticks.
- Today's date: ${new Date().toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}.`;
}
export async function POST(request: Request) {
const session = await authSession();
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
let body: { thread_id: string; message: string; workspace: string; mcp_token?: string };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
}
const { thread_id, message, workspace, mcp_token } = body;
if (!thread_id || !message?.trim()) {
return NextResponse.json({ error: 'thread_id and message are required' }, { status: 400 });
}
const email = session.user.email;
// Verify thread belongs to user
const threads = await query<any>(
`SELECT id FROM fs_chat_threads WHERE id = $1 AND user_id = $2`,
[thread_id, email],
);
if (!threads.length) {
return NextResponse.json({ error: 'Thread not found' }, { status: 404 });
}
// Load message history (last 40 messages)
const rows = await query<any>(
`SELECT data FROM fs_chat_messages WHERE thread_id = $1 ORDER BY created_at DESC LIMIT 40`,
[thread_id],
);
const history: ChatMessage[] = rows.reverse().map((r: any) => r.data);
// Add user message
const userMsg: ChatMessage = { role: 'user', content: message.trim() };
history.push(userMsg);
await query(
`INSERT INTO fs_chat_messages (thread_id, user_id, data) VALUES ($1, $2, $3)`,
[thread_id, email, JSON.stringify(userMsg)],
);
// Update thread updatedAt
await query(
`UPDATE fs_chat_threads SET updated_at = NOW(), data = data || $2 WHERE id = $1`,
[thread_id, JSON.stringify({ updatedAt: new Date().toISOString() })],
);
// Load projects for system prompt context
const projectRows = await query<any>(
`SELECT p.data FROM fs_projects p
JOIN fs_users u ON u.id = p.user_id
WHERE u.data->>'email' = $1
ORDER BY (p.data->>'updatedAt') DESC NULLS LAST LIMIT 20`,
[email],
);
const projects = projectRows.map((r: any) => r.data);
const systemPrompt = buildSystemPrompt(projects, workspace);
// Base URL for internal MCP calls
const host = request.headers.get('host') || 'vibnai.com';
const proto = host.startsWith('localhost') ? 'http' : 'https';
const baseUrl = `${proto}://${host}`;
// Stream response
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
function emit(chunk: object) {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`));
}
let messages = [...history];
let round = 0;
let assistantText = '';
const assistantToolCalls: ToolCall[] = [];
try {
// Tool-calling loop: use non-streaming so thought_signature is
// always present in the complete response (required by thinking models).
while (round < MAX_TOOL_ROUNDS) {
round++;
const toolDefs = mcp_token ? VIBN_TOOL_DEFINITIONS : [];
const resp = await callGeminiChat({ systemPrompt, messages, tools: toolDefs, temperature: 0.7 });
if (resp.error) {
emit({ type: 'error', error: resp.error });
controller.close();
return;
}
// Stream text to client
if (resp.text) {
assistantText += resp.text;
emit({ type: 'text', text: resp.text });
}
// Announce tool calls
for (const tc of resp.toolCalls) {
assistantToolCalls.push(tc);
emit({ type: 'tool_start', name: tc.name, args: tc.args });
}
// Save assistant turn
messages.push({
role: 'assistant',
content: resp.text,
toolCalls: resp.toolCalls.length ? resp.toolCalls : undefined,
});
if (!resp.toolCalls.length) break;
// Execute tool calls and add results
for (const tc of resp.toolCalls) {
const result = mcp_token
? await executeMcpTool(tc.name, tc.args, mcp_token, baseUrl)
: JSON.stringify({ error: 'No MCP token — read-only mode.' });
emit({ type: 'tool_result', name: tc.name, result: result.slice(0, 500) });
messages.push({
role: 'tool',
content: result,
toolCallId: tc.id,
toolName: tc.name,
thoughtSignature: tc.thoughtSignature,
});
}
}
// Persist final assistant message
const finalMsg: ChatMessage = {
role: 'assistant',
content: assistantText,
toolCalls: assistantToolCalls.length ? assistantToolCalls : undefined,
};
await query(
`INSERT INTO fs_chat_messages (thread_id, user_id, data) VALUES ($1, $2, $3)`,
[thread_id, email, JSON.stringify(finalMsg)],
);
emit({ type: 'done' });
controller.close();
} catch (e) {
emit({ type: 'error', error: e instanceof Error ? e.message : String(e) });
controller.close();
}
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
});
}