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:
@@ -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
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 } });
|
||||
}
|
||||
609
components/vibn-chat/chat-panel.tsx
Normal file
609
components/vibn-chat/chat-panel.tsx
Normal 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, "&").replace(/</g, "<").replace(/>/g, ">")
|
||||
.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
192
lib/ai/gemini-chat.ts
Normal 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
132
lib/ai/vibn-tools.ts
Normal 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) });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user