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:
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