Add Vibn AI chat panel powered by Gemini 3.1 Pro
- Right-docked chat panel on all workspace pages ([workspace]/layout.tsx) - Streaming SSE responses with Gemini 3.1 Pro preview via generativelanguage API - Full tool-calling loop (up to 6 rounds): deploys apps, lists projects, registers domains, fetches logs — all via existing MCP dispatcher - Persistent conversation history: fs_chat_threads + fs_chat_messages tables (Postgres) - Thread management: create, list, rename (auto-title from first message), delete - Panel collapses to a tab; open state persisted to localStorage - Read-only mode hint when no MCP token is present - Graceful content margin shift when panel is open Made-with: Cursor
This commit is contained in:
218
app/api/chat/route.ts
Normal file
218
app/api/chat/route.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* 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 { 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 {
|
||||
while (round < MAX_TOOL_ROUNDS) {
|
||||
round++;
|
||||
let pendingToolCalls: ToolCall[] = [];
|
||||
let roundText = '';
|
||||
|
||||
for await (const chunk of streamGeminiChat({
|
||||
systemPrompt,
|
||||
messages,
|
||||
tools: mcp_token ? VIBN_TOOL_DEFINITIONS : [],
|
||||
temperature: 0.7,
|
||||
})) {
|
||||
if (chunk.type === 'text' && chunk.text) {
|
||||
roundText += chunk.text;
|
||||
assistantText += chunk.text;
|
||||
emit({ type: 'text', text: chunk.text });
|
||||
} else if (chunk.type === 'tool_call' && chunk.toolCall) {
|
||||
pendingToolCalls.push(chunk.toolCall);
|
||||
assistantToolCalls.push(chunk.toolCall);
|
||||
emit({ type: 'tool_start', name: chunk.toolCall.name, args: chunk.toolCall.args });
|
||||
} else if (chunk.type === 'error') {
|
||||
emit({ type: 'error', error: chunk.error });
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Save assistant turn
|
||||
const assistantMsg: ChatMessage = {
|
||||
role: 'assistant',
|
||||
content: roundText,
|
||||
toolCalls: pendingToolCalls.length ? pendingToolCalls : undefined,
|
||||
};
|
||||
messages.push(assistantMsg);
|
||||
|
||||
if (!pendingToolCalls.length) break;
|
||||
|
||||
// Execute tool calls
|
||||
for (const tc of pendingToolCalls) {
|
||||
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) });
|
||||
|
||||
const toolMsg: ChatMessage = {
|
||||
role: 'tool',
|
||||
content: result,
|
||||
toolCallId: tc.id,
|
||||
toolName: tc.name,
|
||||
};
|
||||
messages.push(toolMsg);
|
||||
}
|
||||
}
|
||||
|
||||
// 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',
|
||||
},
|
||||
});
|
||||
}
|
||||
56
app/api/chat/threads/[id]/route.ts
Normal file
56
app/api/chat/threads/[id]/route.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* GET /api/chat/threads/[id] — load a thread + its messages
|
||||
* PATCH /api/chat/threads/[id] — rename a thread
|
||||
* DELETE /api/chat/threads/[id] — delete a thread
|
||||
*/
|
||||
import { NextResponse } from 'next/server';
|
||||
import { authSession } from '@/lib/auth/session-server';
|
||||
import { query } from '@/lib/db-postgres';
|
||||
|
||||
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const session = await authSession();
|
||||
if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const threads = await query<any>(
|
||||
`SELECT id, data, created_at, updated_at FROM fs_chat_threads WHERE id = $1 AND user_id = $2`,
|
||||
[id, session.user.email],
|
||||
);
|
||||
if (!threads.length) return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
|
||||
const messages = await query<any>(
|
||||
`SELECT id, data, created_at FROM fs_chat_messages WHERE thread_id = $1 ORDER BY created_at ASC`,
|
||||
[id],
|
||||
);
|
||||
|
||||
const t = threads[0];
|
||||
return NextResponse.json({
|
||||
thread: { id: t.id, title: t.data?.title || 'New conversation', createdAt: t.created_at, updatedAt: t.updated_at },
|
||||
messages: messages.map((m: any) => ({ id: m.id, ...m.data, createdAt: m.created_at })),
|
||||
});
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const session = await authSession();
|
||||
if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const { id } = await params;
|
||||
const { title } = await request.json().catch(() => ({}));
|
||||
if (!title) return NextResponse.json({ error: 'title required' }, { status: 400 });
|
||||
|
||||
await query(
|
||||
`UPDATE fs_chat_threads SET data = data || $3, updated_at = NOW() WHERE id = $1 AND user_id = $2`,
|
||||
[id, session.user.email, JSON.stringify({ title })],
|
||||
);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const session = await authSession();
|
||||
if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const { id } = await params;
|
||||
await query(`DELETE FROM fs_chat_threads WHERE id = $1 AND user_id = $2`, [id, session.user.email]);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
55
app/api/chat/threads/route.ts
Normal file
55
app/api/chat/threads/route.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* GET /api/chat/threads — list user's threads
|
||||
* POST /api/chat/threads — create a new thread
|
||||
*/
|
||||
import { NextResponse } from 'next/server';
|
||||
import { authSession } from '@/lib/auth/session-server';
|
||||
import { query } from '@/lib/db-postgres';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const session = await authSession();
|
||||
if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const workspace = searchParams.get('workspace') || '';
|
||||
|
||||
const rows = await query<any>(
|
||||
`SELECT id, data, created_at, updated_at
|
||||
FROM fs_chat_threads
|
||||
WHERE user_id = $1 AND workspace = $2
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 50`,
|
||||
[session.user.email, workspace],
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
threads: rows.map((r: any) => ({
|
||||
id: r.id,
|
||||
title: r.data?.title || 'New conversation',
|
||||
updatedAt: r.updated_at,
|
||||
createdAt: r.created_at,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const session = await authSession();
|
||||
if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const { workspace, title } = await request.json().catch(() => ({}));
|
||||
if (!workspace) return NextResponse.json({ error: 'workspace required' }, { status: 400 });
|
||||
|
||||
const rows = await query<any>(
|
||||
`INSERT INTO fs_chat_threads (user_id, workspace, data)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, data, created_at, updated_at`,
|
||||
[
|
||||
session.user.email,
|
||||
workspace,
|
||||
JSON.stringify({ title: title || 'New conversation', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }),
|
||||
],
|
||||
);
|
||||
|
||||
const r = rows[0];
|
||||
return NextResponse.json({ thread: { id: r.id, title: r.data?.title || 'New conversation', createdAt: r.created_at, updatedAt: r.updated_at } });
|
||||
}
|
||||
Reference in New Issue
Block a user