Each Vibn project now gets its OWN Coolify project named
vibn-{workspace-slug}-{project-slug}. All apps/databases/services
deployed for the project land inside that Coolify project, giving
us clean grouping, cascading delete, and per-project domain
namespaces.
Changes:
- New lib/projects.ts: ensureProjectCoolifyProject (idempotent
create/lookup), getProjectCoolifyUuid, getOwnedCoolifyProjectUuids
- /api/projects/create: pre-insert row, mint per-project Coolify
project, then complete the row with productData (preserves the
coolifyProjectUuid that was just set)
- apps.list (MCP): without projectId, aggregates across ALL
workspace-owned Coolify projects; with projectId, scopes to
that project's Coolify project. Returns coolifyProjectUuid
on each result so the AI knows where things live.
- apps.create (MCP): accepts projectId; auto-mints the Vibn
project's Coolify project on first deploy if missing
- apps_list/apps_create tool defs: projectId param surfaced
- System prompt: Project as first-class — planning + live as
facets of ONE thing, never as separate worlds. AI told to
always pass projectId on apps_create.
Stage 2 (next): set-aware ensureResourceInProject across all
single-resource MCP tools (apps.get/delete/exec/etc.) and
cascading delete via projects.delete.
Made-with: Cursor
263 lines
10 KiB
TypeScript
263 lines
10 KiB
TypeScript
/**
|
|
* 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 { callGeminiChat, 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;
|
|
|
|
let chatTablesReady = false;
|
|
async function ensureChatTables() {
|
|
if (chatTablesReady) return;
|
|
await query(`
|
|
CREATE TABLE IF NOT EXISTS fs_chat_threads (
|
|
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
|
user_id TEXT NOT NULL,
|
|
workspace TEXT NOT NULL DEFAULT '',
|
|
data JSONB NOT NULL DEFAULT '{}',
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
CREATE INDEX IF NOT EXISTS fs_chat_threads_user_ws_idx
|
|
ON fs_chat_threads (user_id, workspace, updated_at DESC);
|
|
|
|
CREATE TABLE IF NOT EXISTS fs_chat_messages (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
thread_id TEXT NOT NULL REFERENCES fs_chat_threads(id) ON DELETE CASCADE,
|
|
user_id TEXT NOT NULL,
|
|
data JSONB NOT NULL DEFAULT '{}',
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
CREATE INDEX IF NOT EXISTS fs_chat_messages_thread_idx
|
|
ON fs_chat_messages (thread_id, created_at ASC);
|
|
`, []);
|
|
chatTablesReady = true;
|
|
}
|
|
|
|
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.
|
|
|
|
## How Vibn is structured
|
|
- **Workspace** ("${workspace}") — the tenant boundary. One per user. Owns the Gitea org and a fleet of Coolify projects.
|
|
- **Project** — an initiative the user is building (e.g. "Twenty CRM", "My Blog"). Each project has its OWN isolated Coolify project so all its apps + databases + services are grouped together. A project has both:
|
|
- Planning side: name, vision/objectives, requirements (from \`projects_get\`)
|
|
- Live side: deployed apps + services (in \`projects_get → possibleDeployments[]\` and \`apps_list { projectId }\`)
|
|
These are facets of ONE thing — never describe them as separate.
|
|
|
|
When the user asks about a project, call \`projects_get\` first — it returns both the planning details and the linked deployments. Use \`apps_list { projectId }\` to drill into running services for a specific project, or \`apps_list\` (no args) to see everything in the workspace.
|
|
|
|
When deploying with \`apps_create\`, ALWAYS pass \`projectId\` so the new app/service lands inside the right project's isolated Coolify namespace. If the user hasn't specified a project, ask them which one.
|
|
|
|
## Current workspace projects
|
|
${projectsText}
|
|
|
|
## Your capabilities
|
|
You have full access to the Vibn platform. You can:
|
|
- **Apps**: list, create, update, delete, deploy, get logs, exec commands, manage domains, manage env vars, manage volumes, repair broken deploys
|
|
- **Databases**: provision Postgres/MySQL/Redis/MongoDB and 5 other flavors, get connection URLs
|
|
- **Auth providers**: deploy Pocketbase, Authentik, Keycloak, Logto, SuperTokens, and others
|
|
- **Domains**: search availability, register, attach to apps with full DNS wiring
|
|
- **Storage**: provision GCS buckets, inject S3-compatible credentials into apps
|
|
- **Templates**: search and deploy from 320+ one-click app templates (n8n, Ghost, Supabase, etc.)
|
|
- **GitHub**: search open-source repos, read any file from a public repo
|
|
- **Web**: fetch any public URL or documentation page
|
|
|
|
## Key rules
|
|
- Call \`apps_list\` (not \`projects_list\`) when the user asks what is running or what has a domain.
|
|
- Always call \`apps_templates_search\` before \`apps_create\` for any popular third-party app.
|
|
- Be concise and action-oriented — if the user wants to deploy something, do it, don't just describe how.
|
|
- Confirm app name and domain before deploying unless the user has been explicit.
|
|
- After tool calls, summarize in plain language what happened.
|
|
- 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) {
|
|
await ensureChatTables();
|
|
|
|
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 {
|
|
// Tool-calling loop: use non-streaming so thought_signature is
|
|
// always present in the complete response (required by thinking models).
|
|
while (round < MAX_TOOL_ROUNDS) {
|
|
round++;
|
|
|
|
const toolDefs = mcp_token ? VIBN_TOOL_DEFINITIONS : [];
|
|
const resp = await callGeminiChat({ systemPrompt, messages, tools: toolDefs, temperature: 0.7 });
|
|
|
|
if (resp.error) {
|
|
emit({ type: 'error', error: resp.error });
|
|
controller.close();
|
|
return;
|
|
}
|
|
|
|
// Stream text to client
|
|
if (resp.text) {
|
|
assistantText += resp.text;
|
|
emit({ type: 'text', text: resp.text });
|
|
}
|
|
|
|
// Announce tool calls
|
|
for (const tc of resp.toolCalls) {
|
|
assistantToolCalls.push(tc);
|
|
emit({ type: 'tool_start', name: tc.name, args: tc.args });
|
|
}
|
|
|
|
// Save assistant turn
|
|
messages.push({
|
|
role: 'assistant',
|
|
content: resp.text,
|
|
toolCalls: resp.toolCalls.length ? resp.toolCalls : undefined,
|
|
});
|
|
|
|
if (!resp.toolCalls.length) break;
|
|
|
|
// Execute tool calls and add results
|
|
for (const tc of resp.toolCalls) {
|
|
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) });
|
|
|
|
messages.push({
|
|
role: 'tool',
|
|
content: result,
|
|
toolCallId: tc.id,
|
|
toolName: tc.name,
|
|
thoughtSignature: tc.thoughtSignature,
|
|
});
|
|
}
|
|
}
|
|
|
|
// 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',
|
|
},
|
|
});
|
|
}
|