Files
vibn-frontend/app/api/chat/route.ts
Mark Henderson c8dec7c656 feat(mcp): add gitea_* tools so the AI can write code, not just deploy it
Closes the AI's self-reported gap: "I cannot directly commit or push code".

New MCP capabilities (8) — all scoped to the workspace's Gitea org via
requireGiteaOrg + ensureRepoOwnerInOrg:

- gitea.repos.list           — discover existing repos
- gitea.repo.get             — metadata (default branch, clone URL)
- gitea.repo.create          — mint a new private repo with auto-init
- gitea.file.read            — read a file (or list a directory)
- gitea.file.write           — create/update one file in one commit
- gitea.file.delete          — delete a file (auto-resolves sha)
- gitea.branches.list        — list branches with head sha
- gitea.branch.create        — branch off an existing branch

Wired through:
- lib/gitea.ts: giteaReadFile, giteaListContents, giteaListBranches,
  giteaCreateBranch, giteaListOrgRepos, giteaDeleteFile.
- lib/ai/vibn-tools.ts: 8 new Gemini tool declarations (53 total).
- app/api/chat/route.ts: system prompt now teaches the end-to-end
  scaffold-then-deploy recipe so the AI stops deferring to the user.

MCP capability descriptor bumped to version 2.5.0.

Made-with: Cursor
2026-04-28 11:52:16 -07:00

297 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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. You can ONLY see and touch resources in this workspace.
- **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 two facets that are part of ONE thing — never describe them as separate:
- Planning side: name, vision/objectives, requirements (from \`projects_get\`)
- Live side: deployed apps + services (from \`projects_get → possibleDeployments[]\` and \`apps_list { projectId }\`)
## How to answer questions
- "What is project X?" → \`projects_get { id }\`. The result includes both planning details and the linked deployments.
- "What's running / what has a domain?" → \`apps_list\` (no args) for everything in the workspace, or \`apps_list { projectId }\` for one project.
- "Show me logs / containers / env" → resolve the app uuid first via \`apps_list\`, then call \`apps_logs\` / \`apps_containers_list\` / \`apps_envs_list\`.
- "Find an open source X" → \`github_search\` (always include \`license:mit\` unless the user says otherwise), then \`github_file\` to read READMEs / docker-compose.yml / design system entry points before recommending.
- "What's our docs say about Y?" → \`http_fetch\` against the relevant URL.
## How to deploy
**Third-party app (Twenty CRM, n8n, Ghost, Supabase, Pocketbase, etc.)**
1. \`apps_templates_search { query }\` — find the official one-click template.
2. \`apps_create { projectId, name, template, domain }\` — deploy from template into the right project's Coolify namespace.
3. Watch \`apps_get { uuid }\` for status; surface the live URL once \`fqdn\` is set.
**Custom Docker image**
1. \`apps_create { projectId, name, dockerImage, domain, envsJson }\`.
2. \`apps_deploy { uuid }\` if it doesn't auto-deploy.
**Database**
1. \`databases_create { projectId, name, type }\` (type: postgres, mysql, redis, mongodb, mariadb, dragonfly, clickhouse, keydb).
2. \`databases_get { uuid }\` returns the internal connection URL — inject it into the app via \`apps_envs_set\`.
**Domain**
1. \`domains_search { query }\` to check availability + price.
2. \`domains_register { domain }\` to buy it (uses workspace billing).
3. \`apps_domains_set { uuid, domains }\` to attach. DNS + Traefik are wired automatically.
## Writing & shipping code (Gitea)
You CAN write code directly — don't tell the user "I can only generate code, you push it." Use these tools to scaffold and edit a project's repo end-to-end:
- \`gitea_repo_create { name }\` — mint a new private repo in the workspace org.
- \`gitea_file_write { repo, path, content, message }\` — commit one file at a time. Call repeatedly to scaffold a project.
- \`gitea_file_read { repo, path }\` — inspect existing code (returns directory listings if path is a folder).
- \`gitea_branches_list\` / \`gitea_branch_create\` — branch for risky edits.
- \`gitea_repos_list\` — discover what already exists before creating anything new.
End-to-end recipe for "build me X":
1. \`gitea_repo_create { name: 'x' }\`.
2. \`gitea_file_write\` × N — package.json, Dockerfile, src/index.ts, etc.
3. \`apps_create { projectId, repo: 'x', ports, domain }\` — Pathway 1 deploys from the Gitea repo. Coolify auto-redeploys on subsequent file writes.
## Troubleshooting
- Deploy stuck or "exited (1)" → \`apps_logs { uuid }\` and \`apps_containers_list { uuid }\`. Common causes: missing env var, wrong port, image pull failure.
- 502 / "no available server" → app probably has no public domain yet. Check \`apps_get\`; if \`fqdn\` is empty, attach a domain.
- "tenant" / "does not belong to" errors → the uuid you passed isn't in this workspace. Re-list with \`apps_list\` to grab a valid one.
- Compose stack acting weird → \`apps_repair { uuid }\` to re-apply post-deploy fixes (Traefik labels, port forwarding).
- Need to nuke and re-deploy → \`apps_delete { uuid, confirm }\` (confirm must equal the app's exact name; fetch via \`apps_get\` first), then re-create.
## Hard rules
- ALWAYS pass \`projectId\` to \`apps_create\` and \`databases_create\`. If the user didn't say which project, ask once, then proceed.
- ALWAYS call \`apps_templates_search\` BEFORE \`apps_create\` when the user names a known third-party app — don't hand-roll a Docker image when a maintained template exists.
- Destructive ops (\`*_delete\`, \`*_volumes_wipe\`) require \`confirm\` equal to the resource's exact name. Always fetch the name first with a \`*_get\` call.
- Long-running ops (deploys, DNS provisioning, db provisioning) take 15 min. Tell the user up front so they don't think you're stuck.
- Be concise and action-oriented. If the user says "deploy X", do it — don't write a tutorial.
- After every tool call, summarize the result in 12 sentences. Don't dump raw JSON unless asked.
- Format app names, URLs, env keys, UUIDs, and file paths in backticks.
- If a tool errors and you don't understand why, say so honestly and suggest the next diagnostic call.
## Current workspace projects
${projectsText}
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',
},
});
}