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:
2026-04-27 15:40:32 -07:00
parent 89eaff113c
commit 5e07bbf39d
7 changed files with 1275 additions and 2 deletions

View File

@@ -1,8 +1,19 @@
"use client";
import { ChatPanel } from "@/components/vibn-chat/chat-panel";
export default function WorkspaceLayout({
children,
}: {
children: React.ReactNode;
}) {
return children;
return (
<>
{/* Main content shifts left to make room for the open chat panel */}
<div id="workspace-content" style={{ marginRight: "var(--chat-panel-width, 0px)", transition: "margin-right 0.2s ease" }}>
{children}
</div>
<ChatPanel />
</>
);
}

218
app/api/chat/route.ts Normal file
View 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',
},
});
}

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

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

View File

@@ -0,0 +1,609 @@
"use client";
import { useEffect, useRef, useState, useCallback } from "react";
import { useSession } from "next-auth/react";
import { useParams } from "next/navigation";
import {
MessageSquare,
X,
ChevronRight,
Send,
Plus,
Loader2,
Wrench,
ChevronDown,
Trash2,
} from "lucide-react";
// ── Types ─────────────────────────────────────────────────────────────────────
interface Thread {
id: string;
title: string;
updatedAt: string;
}
interface Message {
id?: string;
role: "user" | "assistant" | "tool";
content: string;
toolCalls?: { id: string; name: string; args: Record<string, unknown> }[];
toolName?: string;
createdAt?: string;
}
interface ToolEvent {
name: string;
status: "running" | "done";
result?: string;
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function timeAgo(dateStr?: string): string {
if (!dateStr) return "";
const diff = (Date.now() - new Date(dateStr).getTime()) / 1000;
if (diff < 60) return "just now";
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
return `${Math.floor(diff / 86400)}d ago`;
}
function friendlyToolName(name: string): string {
return name
.replace(/_/g, ".")
.replace("projects.list", "listing projects")
.replace("apps.list", "listing apps")
.replace("apps.create", "deploying app")
.replace("apps.templates.list", "listing templates")
.replace("apps.templates.search", "searching templates")
.replace("domains.register", "registering domain")
.replace("domains.list", "listing domains")
.replace("apps.logs", "fetching logs");
}
// ── Markdown-lite renderer ────────────────────────────────────────────────────
function renderMarkdown(text: string): string {
return text
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
.replace(/`([^`]+)`/g, '<code style="background:#f0ede8;padding:1px 5px;border-radius:3px;font-family:var(--font-ibm-plex-mono),monospace;font-size:0.85em">$1</code>')
.replace(/^### (.+)$/gm, '<h3 style="font-weight:600;margin:12px 0 4px;font-size:0.88rem">$1</h3>')
.replace(/^## (.+)$/gm, '<h2 style="font-weight:600;margin:14px 0 4px;font-size:0.9rem">$1</h2>')
.replace(/^- (.+)$/gm, '<li style="margin-left:16px;list-style:disc">$1</li>')
.replace(/(<li[^>]*>.*<\/li>\n?)+/g, (m) => `<ul style="margin:6px 0">${m}</ul>`)
.replace(/\n\n/g, '</p><p style="margin:0 0 8px">')
.replace(/\n/g, "<br>");
}
// ── Message bubble ────────────────────────────────────────────────────────────
function MessageBubble({ msg }: { msg: Message }) {
const isUser = msg.role === "user";
return (
<div style={{ display: "flex", justifyContent: isUser ? "flex-end" : "flex-start", marginBottom: 12 }}>
{!isUser && (
<div style={{
width: 24, height: 24, borderRadius: "50%", background: "#1a1a1a",
display: "flex", alignItems: "center", justifyContent: "center",
marginRight: 8, flexShrink: 0, marginTop: 2,
}}>
<span style={{ color: "#fff", fontSize: "0.6rem", fontWeight: 700, fontFamily: "var(--font-lora),serif" }}>V</span>
</div>
)}
<div style={{
maxWidth: "82%",
padding: isUser ? "9px 14px" : "10px 14px",
borderRadius: isUser ? "14px 14px 4px 14px" : "4px 14px 14px 14px",
background: isUser ? "#1a1a1a" : "#f7f4ef",
color: isUser ? "#fff" : "#1a1a1a",
fontSize: "0.84rem",
lineHeight: 1.6,
fontFamily: "var(--font-inter),ui-sans-serif,sans-serif",
}}>
{isUser ? (
<span style={{ whiteSpace: "pre-wrap" }}>{msg.content}</span>
) : (
<span dangerouslySetInnerHTML={{ __html: renderMarkdown(msg.content) }} />
)}
</div>
</div>
);
}
function ToolBubble({ event }: { event: ToolEvent }) {
return (
<div style={{
display: "flex", alignItems: "center", gap: 8,
padding: "6px 12px", margin: "4px 0",
background: "#f0ede8", borderRadius: 8,
fontSize: "0.75rem", color: "#6b6560",
fontFamily: "var(--font-inter),ui-sans-serif,sans-serif",
}}>
{event.status === "running" ? (
<Loader2 style={{ width: 12, height: 12 }} className="animate-spin" />
) : (
<Wrench style={{ width: 12, height: 12, color: "#2e7d32" }} />
)}
<span>{friendlyToolName(event.name)}{event.status === "running" ? "…" : " ✓"}</span>
</div>
);
}
// ── Main panel ────────────────────────────────────────────────────────────────
export function ChatPanel() {
const { data: sessionData, status } = useSession();
const params = useParams();
const workspace = (params?.workspace as string) || "";
const [open, setOpen] = useState(() => {
if (typeof window === "undefined") return false;
return localStorage.getItem("vibn-chat-open") !== "false";
});
const [threads, setThreads] = useState<Thread[]>([]);
const [activeThread, setActiveThread] = useState<string | null>(null);
const [messages, setMessages] = useState<Message[]>([]);
const [toolEvents, setToolEvents] = useState<ToolEvent[]>([]);
const [input, setInput] = useState("");
const [sending, setSending] = useState(false);
const [showThreads, setShowThreads] = useState(false);
const [mcpToken, setMcpToken] = useState<string | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const scrollToBottom = useCallback(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, []);
// Persist open state + adjust main content margin
useEffect(() => {
localStorage.setItem("vibn-chat-open", String(open));
document.documentElement.style.setProperty("--chat-panel-width", open ? "380px" : "0px");
}, [open]);
// Load MCP token from localStorage (set at /settings)
useEffect(() => {
const t = localStorage.getItem(`vibn-mcp-token-${workspace}`);
if (t) setMcpToken(t);
}, [workspace]);
// Load threads
const loadThreads = useCallback(async () => {
if (!workspace || status !== "authenticated") return;
try {
const res = await fetch(`/api/chat/threads?workspace=${encodeURIComponent(workspace)}`);
const data = await res.json();
setThreads(data.threads || []);
} catch { /* silent */ }
}, [workspace, status]);
useEffect(() => {
loadThreads();
}, [loadThreads]);
// Create and activate a new thread
const newThread = useCallback(async () => {
try {
const res = await fetch("/api/chat/threads", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ workspace }),
});
const data = await res.json();
if (data.thread) {
setThreads((prev) => [data.thread, ...prev]);
setActiveThread(data.thread.id);
setMessages([]);
setShowThreads(false);
}
} catch { /* silent */ }
}, [workspace]);
// Load thread messages
const loadThread = useCallback(async (id: string) => {
setActiveThread(id);
setShowThreads(false);
setMessages([]);
try {
const res = await fetch(`/api/chat/threads/${id}`);
const data = await res.json();
setMessages(data.messages || []);
} catch { /* silent */ }
}, []);
// Auto-create first thread
useEffect(() => {
if (open && status === "authenticated" && workspace && threads.length === 0 && !activeThread) {
newThread();
} else if (open && threads.length > 0 && !activeThread) {
loadThread(threads[0].id);
}
}, [open, status, workspace, threads.length, activeThread, newThread, loadThread]);
useEffect(() => { scrollToBottom(); }, [messages, toolEvents, scrollToBottom]);
const deleteThread = useCallback(async (id: string, e: React.MouseEvent) => {
e.stopPropagation();
await fetch(`/api/chat/threads/${id}`, { method: "DELETE" });
setThreads((prev) => prev.filter((t) => t.id !== id));
if (activeThread === id) {
setActiveThread(null);
setMessages([]);
}
loadThreads();
}, [activeThread, loadThreads]);
const sendMessage = useCallback(async () => {
if (!input.trim() || sending || !activeThread) return;
const text = input.trim();
setInput("");
setSending(true);
setToolEvents([]);
const userMsg: Message = { role: "user", content: text };
setMessages((prev) => [...prev, userMsg]);
let assistantContent = "";
const assistantMsg: Message = { role: "assistant", content: "" };
let msgIndex = -1;
try {
const res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ thread_id: activeThread, message: text, workspace, mcp_token: mcpToken }),
});
if (!res.ok || !res.body) throw new Error("Stream failed");
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "";
setMessages((prev) => {
msgIndex = prev.length;
return [...prev, { ...assistantMsg }];
});
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;
let ev: any;
try { ev = JSON.parse(line.slice(6)); } catch { continue; }
if (ev.type === "text" && ev.text) {
assistantContent += ev.text;
setMessages((prev) => {
const next = [...prev];
if (msgIndex >= 0 && next[msgIndex]) {
next[msgIndex] = { ...next[msgIndex], content: assistantContent };
}
return next;
});
} else if (ev.type === "tool_start") {
setToolEvents((prev) => [...prev, { name: ev.name, status: "running" }]);
} else if (ev.type === "tool_result") {
setToolEvents((prev) =>
prev.map((t) => t.name === ev.name && t.status === "running"
? { ...t, status: "done", result: ev.result }
: t
)
);
} else if (ev.type === "error") {
assistantContent += `\n\n⚠ ${ev.error}`;
setMessages((prev) => {
const next = [...prev];
if (msgIndex >= 0 && next[msgIndex]) {
next[msgIndex] = { ...next[msgIndex], content: assistantContent };
}
return next;
});
}
}
}
// Auto-title thread from first message
const thisThread = threads.find((t) => t.id === activeThread);
if (thisThread?.title === "New conversation") {
const title = text.slice(0, 50);
await fetch(`/api/chat/threads/${activeThread}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title }),
});
setThreads((prev) => prev.map((t) => t.id === activeThread ? { ...t, title } : t));
}
loadThreads();
} catch (e) {
setMessages((prev) => {
const next = [...prev];
if (msgIndex >= 0 && next[msgIndex]) {
next[msgIndex] = { ...next[msgIndex], content: "⚠️ Failed to get response. Please try again." };
}
return next;
});
} finally {
setSending(false);
}
}, [input, sending, activeThread, workspace, mcpToken, threads, loadThreads]);
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
};
if (status !== "authenticated") return null;
// ── Collapsed tab ──────────────────────────────────────────────────────────
if (!open) {
return (
<button
onClick={() => setOpen(true)}
style={{
position: "fixed", right: 0, top: "50%", transform: "translateY(-50%)",
zIndex: 1000,
background: "#1a1a1a", color: "#fff",
border: "none", borderRadius: "8px 0 0 8px",
padding: "14px 10px", cursor: "pointer",
display: "flex", flexDirection: "column", alignItems: "center", gap: 6,
boxShadow: "-2px 0 12px #1a1a1a14",
}}
title="Open Vibn AI"
>
<MessageSquare style={{ width: 16, height: 16 }} />
<span style={{
writingMode: "vertical-rl", textOrientation: "mixed",
fontSize: "0.65rem", fontWeight: 600, letterSpacing: "0.08em",
fontFamily: "var(--font-inter),ui-sans-serif,sans-serif",
transform: "rotate(180deg)",
}}>
VIBN AI
</span>
</button>
);
}
// ── Open panel ─────────────────────────────────────────────────────────────
return (
<div style={{
position: "fixed", right: 0, top: 0, bottom: 0, zIndex: 999,
width: 380,
background: "#fff",
borderLeft: "1px solid #e8e4dc",
display: "flex", flexDirection: "column",
boxShadow: "-4px 0 24px #1a1a1a08",
fontFamily: "var(--font-inter),ui-sans-serif,sans-serif",
}}>
{/* Header */}
<div style={{
display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "14px 16px", borderBottom: "1px solid #e8e4dc",
background: "#faf8f5", flexShrink: 0,
}}>
<button
onClick={() => setShowThreads((v) => !v)}
style={{
display: "flex", alignItems: "center", gap: 6,
background: "none", border: "none", cursor: "pointer",
padding: "4px 6px", borderRadius: 6,
}}
>
<span style={{
fontFamily: "var(--font-lora),serif", fontSize: "0.95rem",
fontWeight: 400, color: "#1a1a1a", letterSpacing: "-0.01em",
}}>
Vibn AI
</span>
<ChevronDown style={{
width: 13, height: 13, color: "#a09a90",
transition: "transform 0.15s",
transform: showThreads ? "rotate(180deg)" : "none",
}} />
</button>
<div style={{ display: "flex", gap: 4 }}>
<button
onClick={newThread}
style={{
background: "none", border: "none", cursor: "pointer",
padding: "5px 6px", borderRadius: 6, color: "#6b6560",
display: "flex", alignItems: "center",
}}
title="New conversation"
>
<Plus style={{ width: 15, height: 15 }} />
</button>
<button
onClick={() => setOpen(false)}
style={{
background: "none", border: "none", cursor: "pointer",
padding: "5px 6px", borderRadius: 6, color: "#6b6560",
display: "flex", alignItems: "center",
}}
title="Close"
>
<ChevronRight style={{ width: 15, height: 15 }} />
</button>
</div>
</div>
{/* Thread list dropdown */}
{showThreads && (
<div style={{
borderBottom: "1px solid #e8e4dc",
background: "#faf8f5",
maxHeight: 200,
overflowY: "auto",
flexShrink: 0,
}}>
{threads.length === 0 && (
<div style={{ padding: "12px 16px", fontSize: "0.78rem", color: "#a09a90" }}>
No conversations yet
</div>
)}
{threads.map((t) => (
<div
key={t.id}
onClick={() => loadThread(t.id)}
style={{
display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "9px 16px",
background: activeThread === t.id ? "#f0ede8" : "transparent",
cursor: "pointer",
borderBottom: "1px solid #f0ede8",
}}
onMouseEnter={(e) => { if (activeThread !== t.id) e.currentTarget.style.background = "#f7f4ef"; }}
onMouseLeave={(e) => { if (activeThread !== t.id) e.currentTarget.style.background = "transparent"; }}
>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: "0.8rem", fontWeight: 500, color: "#1a1a1a", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{t.title}
</div>
<div style={{ fontSize: "0.7rem", color: "#a09a90" }}>{timeAgo(t.updatedAt)}</div>
</div>
<button
onClick={(e) => deleteThread(t.id, e)}
style={{ background: "none", border: "none", cursor: "pointer", padding: "2px 4px", color: "#c0bab2", flexShrink: 0 }}
>
<Trash2 style={{ width: 12, height: 12 }} />
</button>
</div>
))}
</div>
)}
{/* Messages */}
<div style={{ flex: 1, overflowY: "auto", padding: "16px 14px" }}>
{messages.length === 0 && !sending && (
<div style={{ textAlign: "center", paddingTop: 40 }}>
<div style={{
width: 40, height: 40, borderRadius: "50%", background: "#1a1a1a",
display: "flex", alignItems: "center", justifyContent: "center",
margin: "0 auto 12px",
}}>
<span style={{ color: "#fff", fontSize: "1rem", fontFamily: "var(--font-lora),serif" }}>V</span>
</div>
<p style={{ fontSize: "0.88rem", fontWeight: 500, color: "#1a1a1a", marginBottom: 4 }}>
Vibn AI
</p>
<p style={{ fontSize: "0.78rem", color: "#a09a90", lineHeight: 1.5 }}>
Ask me to deploy an app, register a domain,<br />or help plan your next product.
</p>
</div>
)}
{messages.map((msg, i) => (
<MessageBubble key={msg.id || i} msg={msg} />
))}
{toolEvents.map((ev, i) => (
<ToolBubble key={i} event={ev} />
))}
{sending && messages[messages.length - 1]?.role !== "assistant" && (
<div style={{ display: "flex", alignItems: "center", gap: 8, padding: "6px 0" }}>
<div style={{
width: 24, height: 24, borderRadius: "50%", background: "#1a1a1a",
display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0,
}}>
<span style={{ color: "#fff", fontSize: "0.6rem", fontWeight: 700, fontFamily: "var(--font-lora),serif" }}>V</span>
</div>
<div style={{ display: "flex", gap: 4 }}>
{[0, 1, 2].map((i) => (
<span key={i} style={{
width: 6, height: 6, borderRadius: "50%", background: "#c0bab2",
animation: `vibn-bounce 1.2s ease infinite ${i * 0.2}s`,
display: "inline-block",
}} />
))}
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div style={{
padding: "12px 14px", borderTop: "1px solid #e8e4dc",
background: "#faf8f5", flexShrink: 0,
}}>
{!mcpToken && (
<div style={{
fontSize: "0.7rem", color: "#9a7b3a", background: "#d4a04a12",
border: "1px solid #d4a04a30", borderRadius: 6,
padding: "5px 10px", marginBottom: 8, lineHeight: 1.4,
}}>
Read-only mode add your MCP token in Settings to enable actions.
</div>
)}
<div style={{
display: "flex", gap: 8, alignItems: "flex-end",
background: "#fff", borderRadius: 10,
border: "1px solid #e8e4dc",
padding: "8px 10px",
boxShadow: "0 1px 3px #1a1a1a05",
}}>
<textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ask Vibn AI anything…"
rows={1}
disabled={sending || !activeThread}
style={{
flex: 1, border: "none", outline: "none", background: "transparent",
fontSize: "0.84rem", lineHeight: 1.5, resize: "none",
fontFamily: "var(--font-inter),ui-sans-serif,sans-serif",
color: "#1a1a1a", maxHeight: 120, overflowY: "auto",
}}
onInput={(e) => {
const el = e.currentTarget;
el.style.height = "auto";
el.style.height = Math.min(el.scrollHeight, 120) + "px";
}}
/>
<button
onClick={sendMessage}
disabled={sending || !input.trim() || !activeThread}
style={{
background: input.trim() && !sending ? "#1a1a1a" : "#e8e4dc",
color: input.trim() && !sending ? "#fff" : "#a09a90",
border: "none", borderRadius: 7, padding: "6px 8px",
cursor: input.trim() && !sending ? "pointer" : "default",
display: "flex", alignItems: "center", justifyContent: "center",
transition: "all 0.15s", flexShrink: 0,
}}
>
{sending
? <Loader2 style={{ width: 15, height: 15 }} className="animate-spin" />
: <Send style={{ width: 15, height: 15 }} />
}
</button>
</div>
<div style={{ fontSize: "0.65rem", color: "#c0bab2", textAlign: "center", marginTop: 6 }}>
Powered by Gemini 3.1 Pro
</div>
</div>
<style>{`
@keyframes vibn-bounce {
0%, 60%, 100% { transform: translateY(0); }
30% { transform: translateY(-4px); }
}
`}</style>
</div>
);
}

192
lib/ai/gemini-chat.ts Normal file
View File

@@ -0,0 +1,192 @@
/**
* Gemini 3.1 Pro streaming chat client with tool-calling support.
*
* Uses the Gemini API (generativelanguage.googleapis.com) with the
* existing GOOGLE_API_KEY. Drop-in upgrade to Vertex AI when needed
* by swapping GEMINI_BASE_URL.
*/
const GEMINI_API_KEY = process.env.GOOGLE_API_KEY || '';
const GEMINI_MODEL = process.env.VIBN_CHAT_MODEL || 'gemini-3.1-pro-preview';
const GEMINI_BASE_URL = 'https://generativelanguage.googleapis.com/v1beta';
export interface ChatMessage {
role: 'user' | 'assistant' | 'tool';
content: string;
/** Populated when role === 'assistant' and model made tool calls */
toolCalls?: ToolCall[];
/** Populated when role === 'tool' */
toolCallId?: string;
toolName?: string;
}
export interface ToolCall {
id: string;
name: string;
args: Record<string, unknown>;
}
export interface ToolDefinition {
name: string;
description: string;
parameters: Record<string, unknown>;
}
export interface ChatChunk {
type: 'text' | 'tool_call' | 'done' | 'error';
text?: string;
toolCall?: ToolCall;
error?: string;
}
/** Convert our flat ChatMessage[] to Gemini's contents[] format */
function toGeminiContents(messages: ChatMessage[]) {
const contents: any[] = [];
for (const msg of messages) {
if (msg.role === 'user') {
contents.push({ role: 'user', parts: [{ text: msg.content }] });
} else if (msg.role === 'assistant') {
const parts: any[] = [];
if (msg.content) parts.push({ text: msg.content });
if (msg.toolCalls?.length) {
for (const tc of msg.toolCalls) {
parts.push({ functionCall: { name: tc.name, args: tc.args, id: tc.id } });
}
}
if (parts.length) contents.push({ role: 'model', parts });
} else if (msg.role === 'tool') {
// Tool results must be bundled after the model turn that requested them
const last = contents[contents.length - 1];
const part = {
functionResponse: {
name: msg.toolName || 'unknown',
id: msg.toolCallId,
response: { content: msg.content },
},
};
if (last?.role === 'user') {
last.parts.push(part);
} else {
contents.push({ role: 'user', parts: [part] });
}
}
}
return contents;
}
/** Convert our ToolDefinition[] to Gemini functionDeclarations */
function toGeminiFunctions(tools: ToolDefinition[]) {
if (!tools.length) return undefined;
return [
{
functionDeclarations: tools.map((t) => ({
name: t.name,
description: t.description,
parameters: t.parameters,
})),
},
];
}
/**
* Stream a Gemini response with optional tool-calling.
* Yields ChatChunk objects: text deltas, tool_call requests, and a final done.
*/
export async function* streamGeminiChat(opts: {
systemPrompt: string;
messages: ChatMessage[];
tools?: ToolDefinition[];
temperature?: number;
}): AsyncGenerator<ChatChunk> {
const { systemPrompt, messages, tools = [], temperature = 0.7 } = opts;
const body: any = {
contents: toGeminiContents(messages),
systemInstruction: { parts: [{ text: systemPrompt }] },
generationConfig: {
temperature,
maxOutputTokens: 8192,
},
};
const fns = toGeminiFunctions(tools);
if (fns) body.tools = fns;
const url = `${GEMINI_BASE_URL}/models/${GEMINI_MODEL}:streamGenerateContent?key=${GEMINI_API_KEY}&alt=sse`;
let res: Response;
try {
res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
} catch (e) {
yield { type: 'error', error: `Network error: ${e instanceof Error ? e.message : String(e)}` };
return;
}
if (!res.ok) {
const errText = await res.text().catch(() => '');
yield { type: 'error', error: `Gemini API error ${res.status}: ${errText.slice(0, 300)}` };
return;
}
const reader = res.body?.getReader();
if (!reader) {
yield { type: 'error', error: 'No response body' };
return;
}
const decoder = new TextDecoder();
let buffer = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const data = line.slice(6).trim();
if (!data || data === '[DONE]') continue;
let chunk: any;
try {
chunk = JSON.parse(data);
} catch {
continue;
}
const candidate = chunk?.candidates?.[0];
if (!candidate) continue;
const parts = candidate?.content?.parts ?? [];
for (const part of parts) {
if (part.text) {
yield { type: 'text', text: part.text };
}
if (part.functionCall) {
yield {
type: 'tool_call',
toolCall: {
id: part.functionCall.id || `tc-${Date.now()}`,
name: part.functionCall.name,
args: part.functionCall.args ?? {},
},
};
}
}
}
}
} finally {
reader.releaseLock();
}
yield { type: 'done' };
}

132
lib/ai/vibn-tools.ts Normal file
View File

@@ -0,0 +1,132 @@
/**
* Vibn MCP tool definitions for the chat assistant.
* These are surfaced to Gemini as function declarations so the model
* can take actions (deploy apps, list projects, etc.) on behalf of the user.
*/
import type { ToolDefinition } from './gemini-chat';
export const VIBN_TOOL_DEFINITIONS: ToolDefinition[] = [
{
name: 'projects_list',
description: 'List all projects in the user\'s workspace with their status, last activity, and session counts.',
parameters: { type: 'OBJECT', properties: {}, required: [] },
},
{
name: 'projects_get',
description: 'Get detailed information about a specific project by its ID.',
parameters: {
type: 'OBJECT',
properties: {
projectId: { type: 'STRING', description: 'The project ID to retrieve.' },
},
required: ['projectId'],
},
},
{
name: 'apps_list',
description: 'List all deployed applications in the Coolify workspace.',
parameters: { type: 'OBJECT', properties: {}, required: [] },
},
{
name: 'apps_create',
description: 'Create and deploy a new application. Can deploy from a template (e.g. n8n, wordpress, twenty) or a Docker image.',
parameters: {
type: 'OBJECT',
properties: {
name: { type: 'STRING', description: 'App name (slug-friendly, e.g. "my-app").' },
domain: { type: 'STRING', description: 'Custom domain (e.g. "myapp.mark.vibnai.com").' },
template: { type: 'STRING', description: 'Coolify service template name (e.g. "n8n", "wordpress", "twenty").' },
dockerImage: { type: 'STRING', description: 'Docker image to deploy (e.g. "nginx:latest"). Use instead of template.' },
},
required: ['name', 'domain'],
},
},
{
name: 'apps_templates_list',
description: 'List available one-click application templates (n8n, wordpress, twenty, etc.).',
parameters: { type: 'OBJECT', properties: {}, required: [] },
},
{
name: 'apps_templates_search',
description: 'Search for a specific application template by name or keyword.',
parameters: {
type: 'OBJECT',
properties: {
query: { type: 'STRING', description: 'Search query (e.g. "crm", "blog", "automation").' },
},
required: ['query'],
},
},
{
name: 'domains_register',
description: 'Register a new domain name via OpenSRS.',
parameters: {
type: 'OBJECT',
properties: {
domain: { type: 'STRING', description: 'Domain name to register (e.g. "myapp.com").' },
},
required: ['domain'],
},
},
{
name: 'domains_list',
description: 'List all domains registered or attached in the workspace.',
parameters: { type: 'OBJECT', properties: {}, required: [] },
},
{
name: 'apps_logs',
description: 'Get recent runtime logs from a deployed application.',
parameters: {
type: 'OBJECT',
properties: {
appUuid: { type: 'STRING', description: 'The Coolify application UUID.' },
lines: { type: 'NUMBER', description: 'Number of log lines to return (default 50).' },
},
required: ['appUuid'],
},
},
];
/**
* Execute a Vibn MCP tool call by forwarding to the internal MCP API.
* Called server-side from the /api/chat route, using the user's workspace API key.
*/
export async function executeMcpTool(
toolName: string,
args: Record<string, unknown>,
mcpToken: string,
baseUrl: string,
): Promise<string> {
// Map tool names (underscored) back to MCP action names (dotted)
const actionMap: Record<string, string> = {
projects_list: 'projects.list',
projects_get: 'projects.get',
apps_list: 'apps.list',
apps_create: 'apps.create',
apps_templates_list: 'apps.templates.list',
apps_templates_search: 'apps.templates.search',
domains_register: 'domains.register',
domains_list: 'domains.list',
apps_logs: 'apps.logs',
};
const action = actionMap[toolName];
if (!action) {
return JSON.stringify({ error: `Unknown tool: ${toolName}` });
}
try {
const res = await fetch(`${baseUrl}/api/mcp`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${mcpToken}`,
},
body: JSON.stringify({ action, params: args }),
});
const data = await res.json();
return JSON.stringify(data.result ?? data.error ?? data, null, 2).slice(0, 8000);
} catch (e) {
return JSON.stringify({ error: e instanceof Error ? e.message : String(e) });
}
}