diff --git a/vibn-frontend/app/api/ROUTES.md b/vibn-frontend/app/api/ROUTES.md index 6391dfe..02c0201 100644 --- a/vibn-frontend/app/api/ROUTES.md +++ b/vibn-frontend/app/api/ROUTES.md @@ -37,7 +37,6 @@ | POST | `/api/projects/delete` | session | project | Delete project | | GET/PATCH | `/api/projects/[projectId]` | session | project | Get / update project | | GET | `/api/projects/[projectId]/activity` | session | project | Activity feed | -| POST | `/api/projects/[projectId]/advisor` | session | project | AI advisor | | GET/POST | `/api/projects/[projectId]/anatomy` | session | project | Anatomy read/update | | GET/POST | `/api/projects/[projectId]/apps` | session | project | App list / create | | GET/POST | `/api/projects/[projectId]/design-kit` | session | project | Design kit CRUD | diff --git a/vibn-frontend/app/api/projects/[projectId]/advisor/route.ts b/vibn-frontend/app/api/projects/[projectId]/advisor/route.ts deleted file mode 100644 index 4853cf6..0000000 --- a/vibn-frontend/app/api/projects/[projectId]/advisor/route.ts +++ /dev/null @@ -1,201 +0,0 @@ -/** - * Assist COO — proxies to the agent runner's Orchestrator. - * - * The Orchestrator (Claude Sonnet 4.6, Tier B) has full tool access: - * Gitea — read repos, files, issues, commits - * Coolify — app status, deploy logs, trigger deploys - * Web search, memory, agent spawning - * - * This route loads project-specific context (PRD, phases, apps, sessions) - * and injects it as knowledge_context into the orchestrator's system prompt. - */ -import { NextRequest } from 'next/server'; -import { authSession } from "@/lib/auth/session-server"; -import { query } from '@/lib/db-postgres'; - -const AGENT_RUNNER_URL = process.env.AGENT_RUNNER_URL ?? 'https://agents.vibnai.com'; - -// --------------------------------------------------------------------------- -// Context loader — everything the COO needs to know about the project -// --------------------------------------------------------------------------- - -async function buildKnowledgeContext(projectId: string, email: string): Promise { - const [projectRows, phaseRows, sessionRows] = await Promise.all([ - query<{ data: Record }>( - `SELECT p.data FROM fs_projects p - JOIN fs_users u ON u.id = p.user_id - WHERE p.id = $1 AND u.data->>'email' = $2 LIMIT 1`, - [projectId, email] - ).catch(() => [] as { data: Record }[]), - query<{ phase: string; title: string; summary: string }>( - `SELECT phase, title, summary FROM atlas_phases - WHERE project_id = $1 ORDER BY saved_at ASC`, - [projectId] - ).catch(() => [] as { phase: string; title: string; summary: string }[]), - query<{ task: string; status: string }>( - `SELECT data->>'task' as task, data->>'status' as status - FROM fs_sessions WHERE data->>'projectId' = $1 - ORDER BY created_at DESC LIMIT 8`, - [projectId] - ).catch(() => [] as { task: string; status: string }[]), - ]); - - const d = projectRows[0]?.data ?? {}; - const name = (d.name as string) ?? 'Unknown Project'; - const vision = (d.productVision as string) ?? (d.vision as string) ?? ''; - const giteaRepo = (d.giteaRepo as string) ?? ''; - const prd = (d.prd as string) ?? ''; - const architecture = d.architecture as Record | null ?? null; - const apps = (d.apps as Array<{ name: string; domain?: string; coolifyServiceUuid?: string }>) ?? []; - const coolifyProjectUuid = (d.coolifyProjectUuid as string) ?? ''; - - const lines: string[] = []; - - // COO persona — injected so the orchestrator knows its role for this session - lines.push(`## Your role for this conversation -You are the personal AI COO for "${name}" — a trusted executive partner to the founder. -The founder talks to you. You figure out what needs to happen and get it done. -You delegate to specialist agents (Coder, PM, Marketing) when work is needed. - -Operating principles: -- Use your tools proactively. Don't guess — check Gitea for what's been built, check Coolify for app status. -- Before delegating any work: state the scope in plain English and confirm with the founder. -- Be brief. No preamble, no "Great question!". -- You decide the technical approach — never ask the founder to choose. -- Be honest when you're uncertain or when data isn't available. -- Do NOT spawn agents on the protected platform repos (vibn-frontend, vibn-agent-runner, vibn-api, master-ai).`); - - // Project identity - lines.push(`\n## Project: ${name}`); - if (vision) lines.push(`Vision: ${vision}`); - if (giteaRepo) lines.push(`Gitea repo: ${giteaRepo} — use read_repo_file and list_repos to explore it`); - if (coolifyProjectUuid) lines.push(`Coolify project UUID: ${coolifyProjectUuid} — use coolify_list_applications to find its apps`); - - // Architecture document - if (architecture) { - const archApps = (architecture.apps as Array<{ name: string; type: string; description: string }> ?? []) - .map(a => ` - ${a.name} (${a.type}): ${a.description}`).join('\n'); - const archInfra = (architecture.infrastructure as Array<{ name: string; reason: string }> ?? []) - .map(i => ` - ${i.name}: ${i.reason}`).join('\n'); - lines.push(`\n## Technical Architecture\nSummary: ${architecture.summary ?? ''}\n\nApps:\n${archApps}\n\nInfrastructure:\n${archInfra}`); - } - - // PRD or discovery phases - if (prd) { - // Claude Sonnet has a 200k token context — pass the full PRD, no truncation needed - lines.push(`\n## Product Requirements Document\n${prd}`); - } else if (phaseRows.length > 0) { - lines.push(`\n## Discovery phases completed (${phaseRows.length})`); - for (const p of phaseRows) { - lines.push(`- ${p.title}: ${p.summary}`); - } - lines.push(`(PRD not yet finalized — Vibn discovery is in progress)`); - } else { - lines.push(`\n## Product discovery: not yet started`); - } - - // Deployed apps - if (apps.length > 0) { - lines.push(`\n## Deployed apps`); - for (const a of apps) { - const url = a.domain ? `https://${a.domain}` : '(no domain yet)'; - const uuid = a.coolifyServiceUuid ? ` [uuid: ${a.coolifyServiceUuid}]` : ''; - lines.push(`- ${a.name} → ${url}${uuid}`); - } - } - - // Recent agent work - const validSessions = sessionRows.filter(s => s.task); - if (validSessions.length > 0) { - lines.push(`\n## Recent agent sessions (what's been worked on)`); - for (const s of validSessions) { - lines.push(`- [${s.status ?? 'unknown'}] ${s.task}`); - } - } - - return lines.join('\n'); -} - -// --------------------------------------------------------------------------- -// POST handler -// --------------------------------------------------------------------------- - -export async function POST( - req: NextRequest, - { params }: { params: Promise<{ projectId: string }> } -) { - const { projectId } = await params; - - const session = await authSession(); - if (!session?.user?.email) { - return new Response('Unauthorized', { status: 401 }); - } - - const { message, history = [] } = await req.json() as { - message: string; - history: Array<{ role: 'user' | 'model'; content: string }>; - }; - - if (!message?.trim()) { - return new Response('Message required', { status: 400 }); - } - - // Load project context (best-effort) - let knowledgeContext = ''; - try { - knowledgeContext = await buildKnowledgeContext(projectId, session.user.email); - } catch { /* proceed without — orchestrator still works */ } - - // Convert history: frontend uses "model", orchestrator uses "assistant" - const llmHistory = history - .filter(h => h.content?.trim()) - .map(h => ({ - role: (h.role === 'model' ? 'assistant' : 'user') as 'assistant' | 'user', - content: h.content, - })); - - // Call the orchestrator on the agent runner - let orchRes: Response; - try { - orchRes = await fetch(`${AGENT_RUNNER_URL}/orchestrator/chat`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - message, - // Scoped session per project so in-memory context persists within a browser session - session_id: `coo_${projectId}_${session.user.email.split('@')[0]}`, - history: llmHistory, - knowledge_context: knowledgeContext, - }), - }); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - return new Response(`Agent runner unreachable: ${msg}`, { status: 502 }); - } - - if (!orchRes.ok) { - const err = await orchRes.text(); - return new Response(`Orchestrator error: ${err}`, { status: 502 }); - } - - const result = await orchRes.json() as { reply?: string; error?: string }; - - if (result.error) { - return new Response(result.error, { status: 500 }); - } - - const reply = result.reply ?? '(no response)'; - - // Return as a streaming response — single chunk (orchestrator is non-streaming) - const encoder = new TextEncoder(); - const stream = new ReadableStream({ - start(controller) { - controller.enqueue(encoder.encode(reply)); - controller.close(); - }, - }); - - return new Response(stream, { - headers: { 'Content-Type': 'text/plain; charset=utf-8' }, - }); -} diff --git a/vibn-frontend/app/api/projects/[projectId]/atlas-chat/route.ts b/vibn-frontend/app/api/projects/[projectId]/atlas-chat/route.ts deleted file mode 100644 index 92ea5b9..0000000 --- a/vibn-frontend/app/api/projects/[projectId]/atlas-chat/route.ts +++ /dev/null @@ -1,350 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { authSession } from "@/lib/auth/session-server"; -import { query } from "@/lib/db-postgres"; -import { augmentAtlasMessage, parseContextRefs } from "@/lib/chat-context-refs"; -import { formatCreationKickoffForPrompt } from "@/lib/server/creation-kickoff-prompt"; - -const AGENT_RUNNER_URL = - process.env.AGENT_RUNNER_URL ?? "http://localhost:3333"; - -const ALLOWED_SCOPES = new Set(["overview", "build"]); - -function normalizeScope(raw: string | null | undefined): "overview" | "build" { - const s = (raw ?? "overview").trim(); - return ALLOWED_SCOPES.has(s) ? (s as "overview" | "build") : "overview"; -} - -function runnerSessionId( - projectId: string, - scope: "overview" | "build", -): string { - return scope === "overview" - ? `atlas_${projectId}` - : `atlas_${projectId}__build`; -} - -// --------------------------------------------------------------------------- -// DB — atlas_chat_threads (project_id + scope); legacy atlas_conversations → overview -// --------------------------------------------------------------------------- - -let threadsTableReady = false; -let legacyTableChecked = false; - -async function ensureThreadsTable() { - if (threadsTableReady) return; - await query(` - CREATE TABLE IF NOT EXISTS atlas_chat_threads ( - project_id TEXT NOT NULL, - scope TEXT NOT NULL, - messages JSONB NOT NULL DEFAULT '[]'::jsonb, - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - PRIMARY KEY (project_id, scope) - ) - `); - threadsTableReady = true; -} - -async function ensureLegacyConversationsTable() { - if (legacyTableChecked) return; - await query(` - CREATE TABLE IF NOT EXISTS atlas_conversations ( - project_id TEXT PRIMARY KEY, - messages JSONB NOT NULL DEFAULT '[]'::jsonb, - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - ) - `); - legacyTableChecked = true; -} - -async function loadAtlasHistory( - projectId: string, - scope: "overview" | "build", -): Promise { - try { - await ensureThreadsTable(); - const rows = await query<{ messages: any[] }>( - `SELECT messages FROM atlas_chat_threads WHERE project_id = $1 AND scope = $2`, - [projectId, scope], - ); - if (rows.length > 0) { - const fromThreads = rows[0]?.messages; - return Array.isArray(fromThreads) ? fromThreads : []; - } - if (scope === "overview") { - await ensureLegacyConversationsTable(); - const leg = await query<{ messages: any[] }>( - `SELECT messages FROM atlas_conversations WHERE project_id = $1`, - [projectId], - ); - const legacyMsgs = leg[0]?.messages ?? []; - if (Array.isArray(legacyMsgs) && legacyMsgs.length > 0) { - await saveAtlasHistory(projectId, scope, legacyMsgs); - return legacyMsgs; - } - } - return []; - } catch { - return []; - } -} - -async function saveAtlasHistory( - projectId: string, - scope: "overview" | "build", - messages: any[], -): Promise { - try { - await ensureThreadsTable(); - await query( - `INSERT INTO atlas_chat_threads (project_id, scope, messages, updated_at) - VALUES ($1, $2, $3::jsonb, NOW()) - ON CONFLICT (project_id, scope) DO UPDATE - SET messages = $3::jsonb, updated_at = NOW()`, - [projectId, scope, JSON.stringify(messages)], - ); - } catch (e) { - console.error("[atlas-chat] Failed to save history:", e); - } -} - -async function savePrd(projectId: string, prdContent: string): Promise { - try { - await query( - `UPDATE fs_projects - SET data = data || jsonb_build_object('prd', $2::text, 'stage', 'architecture'), - updated_at = NOW() - WHERE id = $1`, - [projectId, prdContent], - ); - console.log(`[atlas-chat] PRD saved for project ${projectId}`); - } catch (e) { - console.error("[atlas-chat] Failed to save PRD:", e); - } -} - -/** Replace the latest user message content so DB/UI never show the internal ref prefix. */ -function scrubLastUserMessageContent( - history: unknown[], - cleanText: string, -): unknown[] { - if (!Array.isArray(history) || history.length === 0) return history; - const h = history.map((m) => - m && typeof m === "object" ? { ...(m as object) } : m, - ); - for (let i = h.length - 1; i >= 0; i--) { - const m = h[i] as { role?: string; content?: string }; - if (m?.role === "user" && typeof m.content === "string") { - h[i] = { ...m, content: cleanText }; - break; - } - } - return h; -} - -// --------------------------------------------------------------------------- -// GET — load stored conversation messages for display -// --------------------------------------------------------------------------- - -export async function GET( - req: NextRequest, - { params }: { params: Promise<{ projectId: string }> }, -) { - const session = await authSession(); - if (!session?.user?.email) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const { projectId } = await params; - const scope = normalizeScope(req.nextUrl.searchParams.get("scope")); - const history = await loadAtlasHistory(projectId, scope); - - // Filter to only user/assistant messages (no system prompts) for display - const messages = history - .filter((m: any) => m.role === "user" || m.role === "assistant") - .map((m: any) => ({ - role: m.role as "user" | "assistant", - content: m.content as string, - })); - - return NextResponse.json({ messages }); -} - -// --------------------------------------------------------------------------- -// POST — send message to Atlas -// --------------------------------------------------------------------------- - -export async function POST( - req: NextRequest, - { params }: { params: Promise<{ projectId: string }> }, -) { - const session = await authSession(); - if (!session?.user?.email) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const { projectId } = await params; - const body = await req.json(); - const message = body?.message as string | undefined; - const contextRefs = parseContextRefs(body?.contextRefs); - if (!message?.trim()) { - return NextResponse.json({ error: "message is required" }, { status: 400 }); - } - - const scope = normalizeScope(body?.scope as string | undefined); - const sessionId = runnerSessionId(projectId, scope); - const cleanUserText = message.trim(); - - // Load conversation history from DB to persist across agent runner restarts. - // Strip tool_call / tool_response messages — replaying them across sessions - // causes Gemini to reject the request with a turn-ordering error. - const rawHistory = await loadAtlasHistory(projectId, scope); - const history = rawHistory - .filter( - (m: any) => (m.role === "user" || m.role === "assistant") && m.content, - ) - .map((m: any) => { - if (typeof m.content === "string") { - m.content = m.content - .replace(/[\s\S]*?<\/tool_calls>/g, "") - .replace(/[\s\S]*?<\/think>/g, "") - .trim(); - } - return m; - }); - - // __init__ is a special internal trigger used only when there is no existing history. - // If history already exists, ignore the init request (conversation already started). - const isInit = cleanUserText === "__atlas_init__"; - if (isInit && history.length > 0) { - return NextResponse.json({ reply: null, alreadyStarted: true }); - } - - let runnerMessage: string; - if (isInit) { - let kickoffPrefix = ""; - try { - const rows = await query<{ data: Record }>( - `SELECT data FROM fs_projects WHERE id = $1 LIMIT 1`, - [projectId], - ); - const kb = - rows[0]?.data != null - ? formatCreationKickoffForPrompt( - rows[0].data as Record, - ) - : null; - if (kb) { - kickoffPrefix = `[Project kickoff from creation wizard]\n${kb}\n\n`; - } - } catch { - /* non-fatal */ - } - const base = - scope === "build" - ? "Begin as Vibn in build mode. The user is working in their monorepo. Ask what they want to ship or fix next, and offer concrete implementation guidance. Do not acknowledge this as an internal trigger." - : "Begin the conversation. Introduce yourself as Vibn and ask what the user is building. Do not acknowledge this as an internal trigger."; - runnerMessage = kickoffPrefix + base; - } else { - runnerMessage = augmentAtlasMessage(cleanUserText, contextRefs); - } - - try { - const res = await fetch(`${AGENT_RUNNER_URL}/atlas/chat`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - message: runnerMessage, - session_id: sessionId, - history, - is_init: isInit, - }), - signal: AbortSignal.timeout(120_000), - }); - - if (!res.ok) { - const text = await res.text(); - console.error("[atlas-chat] Agent runner error:", text); - return NextResponse.json( - { error: "Vibn is unavailable. Please try again." }, - { status: 502 }, - ); - } - - const data = await res.json(); - - let historyOut = data.history ?? []; - // Store the user's line without the internal reference block (UI shows clean text). - if (!isInit && cleanUserText !== "__atlas_init__") { - historyOut = scrubLastUserMessageContent(historyOut, cleanUserText); - } - - await saveAtlasHistory(projectId, scope, historyOut); - - // If Atlas finalized the PRD, save it to the project (discovery / overview) - if (data.prdContent && scope === "overview") { - await savePrd(projectId, data.prdContent); - } - - return NextResponse.json({ - reply: data.reply, - sessionId, - prdContent: data.prdContent ?? null, - model: data.model ?? null, - }); - } catch (err) { - console.error("[atlas-chat] Error:", err); - return NextResponse.json( - { error: "Request timed out or failed. Please try again." }, - { status: 500 }, - ); - } -} - -// --------------------------------------------------------------------------- -// DELETE — clear Atlas conversation for this project -// --------------------------------------------------------------------------- - -export async function DELETE( - req: NextRequest, - { params }: { params: Promise<{ projectId: string }> }, -) { - const session = await authSession(); - if (!session?.user?.email) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const { projectId } = await params; - const scope = normalizeScope(req.nextUrl.searchParams.get("scope")); - const sessionId = runnerSessionId(projectId, scope); - - try { - await fetch( - `${AGENT_RUNNER_URL}/atlas/sessions/${encodeURIComponent(sessionId)}`, - { method: "DELETE" }, - ); - } catch { - /* runner may be down */ - } - - try { - await ensureThreadsTable(); - await query( - `DELETE FROM atlas_chat_threads WHERE project_id = $1 AND scope = $2`, - [projectId, scope], - ); - } catch { - /* table may not exist yet */ - } - - if (scope === "overview") { - try { - await query(`DELETE FROM atlas_conversations WHERE project_id = $1`, [ - projectId, - ]); - } catch { - /* legacy */ - } - } - - return NextResponse.json({ cleared: true }); -}