From 5e07bbf39d56a7fe03fe70500235beeecd14c275 Mon Sep 17 00:00:00 2001
From: Mark Henderson
Date: Mon, 27 Apr 2026 15:40:32 -0700
Subject: [PATCH] Add Vibn AI chat panel powered by Gemini 3.1 Pro
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 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
---
app/[workspace]/layout.tsx | 15 +-
app/api/chat/route.ts | 218 ++++++++++
app/api/chat/threads/[id]/route.ts | 56 +++
app/api/chat/threads/route.ts | 55 +++
components/vibn-chat/chat-panel.tsx | 609 ++++++++++++++++++++++++++++
lib/ai/gemini-chat.ts | 192 +++++++++
lib/ai/vibn-tools.ts | 132 ++++++
7 files changed, 1275 insertions(+), 2 deletions(-)
create mode 100644 app/api/chat/route.ts
create mode 100644 app/api/chat/threads/[id]/route.ts
create mode 100644 app/api/chat/threads/route.ts
create mode 100644 components/vibn-chat/chat-panel.tsx
create mode 100644 lib/ai/gemini-chat.ts
create mode 100644 lib/ai/vibn-tools.ts
diff --git a/app/[workspace]/layout.tsx b/app/[workspace]/layout.tsx
index b2fe40c6..629c5584 100644
--- a/app/[workspace]/layout.tsx
+++ b/app/[workspace]/layout.tsx
@@ -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 */}
+
+ {children}
+
+
+ >
+ );
}
-
diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts
new file mode 100644
index 00000000..3dfbbeba
--- /dev/null
+++ b/app/api/chat/route.ts
@@ -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(
+ `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(
+ `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(
+ `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',
+ },
+ });
+}
diff --git a/app/api/chat/threads/[id]/route.ts b/app/api/chat/threads/[id]/route.ts
new file mode 100644
index 00000000..ce71fae4
--- /dev/null
+++ b/app/api/chat/threads/[id]/route.ts
@@ -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(
+ `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(
+ `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 });
+}
diff --git a/app/api/chat/threads/route.ts b/app/api/chat/threads/route.ts
new file mode 100644
index 00000000..bfd5a769
--- /dev/null
+++ b/app/api/chat/threads/route.ts
@@ -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(
+ `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(
+ `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 } });
+}
diff --git a/components/vibn-chat/chat-panel.tsx b/components/vibn-chat/chat-panel.tsx
new file mode 100644
index 00000000..a463c637
--- /dev/null
+++ b/components/vibn-chat/chat-panel.tsx
@@ -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 }[];
+ 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, "&").replace(//g, ">")
+ .replace(/\*\*(.+?)\*\*/g, "$1")
+ .replace(/`([^`]+)`/g, '$1')
+ .replace(/^### (.+)$/gm, '$1
')
+ .replace(/^## (.+)$/gm, '$1
')
+ .replace(/^- (.+)$/gm, '$1')
+ .replace(/(]*>.*<\/li>\n?)+/g, (m) => ``)
+ .replace(/\n\n/g, '
')
+ .replace(/\n/g, "
");
+}
+
+// ── Message bubble ────────────────────────────────────────────────────────────
+
+function MessageBubble({ msg }: { msg: Message }) {
+ const isUser = msg.role === "user";
+ return (
+
+ {!isUser && (
+
+ V
+
+ )}
+
+ {isUser ? (
+ {msg.content}
+ ) : (
+
+ )}
+
+
+ );
+}
+
+function ToolBubble({ event }: { event: ToolEvent }) {
+ return (
+
+ {event.status === "running" ? (
+
+ ) : (
+
+ )}
+ {friendlyToolName(event.name)}{event.status === "running" ? "…" : " ✓"}
+
+ );
+}
+
+// ── 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([]);
+ const [activeThread, setActiveThread] = useState(null);
+ const [messages, setMessages] = useState([]);
+ const [toolEvents, setToolEvents] = useState([]);
+ const [input, setInput] = useState("");
+ const [sending, setSending] = useState(false);
+ const [showThreads, setShowThreads] = useState(false);
+ const [mcpToken, setMcpToken] = useState(null);
+ const messagesEndRef = useRef(null);
+ const inputRef = useRef(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) => {
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault();
+ sendMessage();
+ }
+ };
+
+ if (status !== "authenticated") return null;
+
+ // ── Collapsed tab ──────────────────────────────────────────────────────────
+ if (!open) {
+ return (
+
+ );
+ }
+
+ // ── Open panel ─────────────────────────────────────────────────────────────
+ return (
+
+ {/* Header */}
+
+
+
+
+
+
+
+
+ {/* Thread list dropdown */}
+ {showThreads && (
+
+ {threads.length === 0 && (
+
+ No conversations yet
+
+ )}
+ {threads.map((t) => (
+
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"; }}
+ >
+
+
+ {t.title}
+
+
{timeAgo(t.updatedAt)}
+
+
+
+ ))}
+
+ )}
+
+ {/* Messages */}
+
+ {messages.length === 0 && !sending && (
+
+
+ V
+
+
+ Vibn AI
+
+
+ Ask me to deploy an app, register a domain,
or help plan your next product.
+
+
+ )}
+
+ {messages.map((msg, i) => (
+
+ ))}
+
+ {toolEvents.map((ev, i) => (
+
+ ))}
+
+ {sending && messages[messages.length - 1]?.role !== "assistant" && (
+
+
+ V
+
+
+ {[0, 1, 2].map((i) => (
+
+ ))}
+
+
+ )}
+
+
+
+
+ {/* Input */}
+
+ {!mcpToken && (
+
+ Read-only mode — add your MCP token in Settings to enable actions.
+
+ )}
+
+
+
+ Powered by Gemini 3.1 Pro
+
+
+
+
+
+ );
+}
diff --git a/lib/ai/gemini-chat.ts b/lib/ai/gemini-chat.ts
new file mode 100644
index 00000000..aa0d3817
--- /dev/null
+++ b/lib/ai/gemini-chat.ts
@@ -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;
+}
+
+export interface ToolDefinition {
+ name: string;
+ description: string;
+ parameters: Record;
+}
+
+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 {
+ 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' };
+}
diff --git a/lib/ai/vibn-tools.ts b/lib/ai/vibn-tools.ts
new file mode 100644
index 00000000..6d62070d
--- /dev/null
+++ b/lib/ai/vibn-tools.ts
@@ -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,
+ mcpToken: string,
+ baseUrl: string,
+): Promise {
+ // Map tool names (underscored) back to MCP action names (dotted)
+ const actionMap: Record = {
+ 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) });
+ }
+}