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

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