Files
vibn-frontend/app/api/projects/[projectId]/atlas-chat/route.ts
Mark Henderson 651ddf1e11 Rip out Theia, ship P5.1 attach E2E + Justine UI work-in-progress
Theia rip-out:
- Delete app/api/theia-auth/route.ts (Traefik ForwardAuth shim)
- Delete app/api/projects/[projectId]/workspace/route.ts and
  app/api/projects/prewarm/route.ts (Cloud Run Theia provisioning)
- Delete lib/cloud-run-workspace.ts and lib/coolify-workspace.ts
- Strip provisionTheiaWorkspace + theiaWorkspaceUrl/theiaAppUuid/
  theiaError from app/api/projects/create/route.ts response
- Remove Theia callbackUrl branch in app/auth/page.tsx
- Drop "Open in Theia" button + xterm/Theia PTY copy in build/page.tsx
- Drop theiaWorkspaceUrl from deployment/page.tsx Project type
- Strip Theia IDE line + theia-code-os from advisor + agent-chat
  context strings
- Scrub Theia mention from lib/auth/workspace-auth.ts comment

P5.1 (custom apex domains + DNS):
- lib/coolify.ts + lib/opensrs.ts: nameserver normalization, OpenSRS
  XML auth, Cloud DNS plumbing
- scripts/smoke-attach-e2e.ts: full prod GCP + sandbox OpenSRS +
  prod Coolify smoke covering register/zone/A/NS/PATCH/cleanup

In-progress (Justine onboarding/build, MVP setup, agent telemetry):
- New (justine)/stories, project (home) layouts, mvp-setup, run, tasks
  routes + supporting components
- Project shell + sidebar + nav refactor for the Stackless palette
- Agent session API hardening (sessions, events, stream, approve,
  retry, stop) + atlas-chat, advisor, design-surfaces refresh
- New scripts/sync-db-url-from-coolify.mjs +
  scripts/prisma-db-push.mjs + docker-compose.local-db.yml for
  local Prisma workflows
- lib/dev-bypass.ts, lib/chat-context-refs.ts, lib/prd-sections.ts
- Misc: stories CSS, debug/prisma route, modal-theme, BuildLivePlanPanel

Made-with: Cursor
2026-04-22 18:05:01 -07:00

288 lines
9.9 KiB
TypeScript

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";
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<any[]> {
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<void> {
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<void> {
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
);
// __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 });
}
const runnerMessage = isInit
? 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."
: 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 });
}