feat(chat): scope AI conversations to the active project

The chat panel now reads projectId from the URL and tags every thread
to it, so:
- Threads listed in the side panel are filtered to the project the user
  is currently viewing (workspace-level chats still work from /projects).
- New conversations started from a project page are persisted with that
  project_id, surviving page reloads.
- The system prompt prepends an ACTIVE PROJECT block so tool calls
  (apps_create, devcontainer_ensure, etc.) use the right projectId
  without the user having to name it.
- A small chip in the chat header shows which project the AI is
  currently talking about.

Schema migrates idempotently on first request (project_id column +
composite index on fs_chat_threads).

Made-with: Cursor
This commit is contained in:
2026-04-29 17:41:45 -07:00
parent 85db68636b
commit b706fa0e89
3 changed files with 174 additions and 33 deletions

View File

@@ -56,7 +56,11 @@ async function ensureChatTables() {
chatTablesReady = true;
}
export function buildSystemPrompt(projects: any[], workspace: string): string {
export function buildSystemPrompt(
projects: any[],
workspace: string,
activeProject?: any,
): string {
const projectsText = projects.length
? projects
.map(
@@ -66,6 +70,24 @@ export function buildSystemPrompt(projects: any[], workspace: string): string {
.join('\n')
: '(no projects yet)';
// When this thread is scoped to a project, surface a STRONG header
// at the top so the model treats `projectId` as resolved without the
// user having to name it. Falls through to the workspace-level mode
// (browse all projects) when activeProject is undefined.
const activeBlock = activeProject
? `\n## ACTIVE PROJECT — assume this for every tool call unless the user explicitly says otherwise
The user is currently looking at:
- Name: "${activeProject.productName || activeProject.name}"
- projectId: \`${activeProject.id}\`
- Slug: \`${activeProject.slug ?? '(none)'}\`
- Audience: ${activeProject.audience ?? 'unspecified'}
- Vision: ${activeProject.productVision ? activeProject.productVision.slice(0, 240) : '(not yet captured)'}
${activeProject.kickoff ? `- Created via: ${activeProject.kickoff.mode} (${JSON.stringify(activeProject.kickoff.sourceData).slice(0, 200)})` : ''}
When you call tools that take a \`projectId\`, USE this id (\`${activeProject.id}\`) without asking. When the user says "this project" / "the app" / "deploy it" — they mean THIS project. Switch to a different project only if the user names one explicitly.\n`
: '';
return `You are Vibn AI — the technical co-founder of every Vibn user. You ship code, deploy infra, and treat their projects like they're your own.
You're talking to the owner of the "${workspace}" workspace. They have admin access to their Gitea org, a fleet of Coolify projects, and a persistent dev container per project. You can read and write any of it.
@@ -175,7 +197,7 @@ For all file editing inside an existing repo, ALWAYS use \`fs_*\` against the de
- After a \`ship\` or \`apps.deploy\`, the result is authoritative. Don't call gitea_*, shell_exec, or apps_* to "verify" — read the response and report.
- Don't loop blindly on tool errors. If \`shell_exec\` returns non-zero, READ THE STDERR, form a hypothesis, then act. If you can't diagnose in two attempts, surface what you tried and ask the user.
## Current workspace projects
${activeBlock}## Current workspace projects
${projectsText}
Today's date: ${new Date().toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}.`;
@@ -203,14 +225,15 @@ export async function POST(request: Request) {
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`,
// Verify thread belongs to user, and capture its project scope (if any).
const threads = await query<{ id: string; project_id: string | null }>(
`SELECT id, project_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 });
}
const threadProjectId = threads[0].project_id;
// Load message history (last 40 messages)
const rows = await query<any>(
@@ -242,7 +265,26 @@ export async function POST(request: Request) {
[email],
);
const projects = projectRows.map((r: any) => r.data);
const systemPrompt = buildSystemPrompt(projects, workspace);
// If the thread is project-scoped, pull the active project's data
// (preferring fs_projects since the projects array is capped at 20).
let activeProject: any = null;
if (threadProjectId) {
const found = projects.find((p: any) => p.id === threadProjectId);
if (found) {
activeProject = found;
} else {
const r = await query<{ data: any }>(
`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`,
[threadProjectId, email],
);
if (r[0]?.data) activeProject = r[0].data;
}
}
const systemPrompt = buildSystemPrompt(projects, workspace, activeProject);
// Base URL for internal MCP calls
const host = request.headers.get('host') || 'vibnai.com';

View File

@@ -1,6 +1,15 @@
/**
* GET /api/chat/threads — list user's threads
* POST /api/chat/threads — create a new thread
* GET /api/chat/threads — list user's threads
* GET /api/chat/threads?projectId=… — list user's threads for one project
* POST /api/chat/threads — create a new thread (optionally scoped to a project)
*
* Threads can be either:
* - workspace-level (project_id NULL) — created from /projects, etc.
* - project-scoped (project_id set) — created from a project page so
* the AI can pin the right project context in its system prompt.
*
* The schema is migrated idempotently the first time the route is hit
* after deploy (no manual migration needed).
*/
import { NextResponse } from 'next/server';
import { authSession } from '@/lib/auth/session-server';
@@ -31,6 +40,15 @@ async function ensureChatTables() {
CREATE INDEX IF NOT EXISTS fs_chat_messages_thread_idx
ON fs_chat_messages (thread_id, created_at ASC);
`, []);
// Idempotent migration: add project_id + composite index for fast
// per-project listing. No-op on subsequent boots.
await query(`
ALTER TABLE fs_chat_threads ADD COLUMN IF NOT EXISTS project_id TEXT;
CREATE INDEX IF NOT EXISTS fs_chat_threads_user_proj_idx
ON fs_chat_threads (user_id, project_id, updated_at DESC);
`, []);
chatTablesReady = true;
}
@@ -41,19 +59,30 @@ export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const workspace = searchParams.get('workspace') || '';
const projectId = searchParams.get('projectId') || null;
const rows = await query<any>(
`SELECT id, data, created_at, updated_at
FROM fs_chat_threads
WHERE user_id = $1 AND workspace = $2
ORDER BY updated_at DESC
LIMIT 50`,
[session.user.email, workspace],
);
// When projectId is supplied, narrow to that project. When omitted,
// return only WORKSPACE-level threads (project_id IS NULL) so the
// workspace chat UI doesn't get spammed with every project's history.
const sql = projectId
? `SELECT id, project_id, data, created_at, updated_at
FROM fs_chat_threads
WHERE user_id = $1 AND workspace = $2 AND project_id = $3
ORDER BY updated_at DESC LIMIT 50`
: `SELECT id, project_id, data, created_at, updated_at
FROM fs_chat_threads
WHERE user_id = $1 AND workspace = $2 AND project_id IS NULL
ORDER BY updated_at DESC LIMIT 50`;
const args = projectId
? [session.user.email, workspace, projectId]
: [session.user.email, workspace];
const rows = await query<any>(sql, args);
return NextResponse.json({
threads: rows.map((r: any) => ({
id: r.id,
projectId: r.project_id ?? null,
title: r.data?.title || 'New conversation',
updatedAt: r.updated_at,
createdAt: r.created_at,
@@ -66,20 +95,43 @@ export async function POST(request: Request) {
const session = await authSession();
if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { workspace, title } = await request.json().catch(() => ({}));
const { workspace, title, projectId } = await request.json().catch(() => ({}));
if (!workspace) return NextResponse.json({ error: 'workspace required' }, { status: 400 });
// Verify the project belongs to the requesting user before tagging
// a thread to it. Silently drop the projectId if the check fails so
// a misbehaving client can't tag threads onto someone else's project.
let safeProjectId: string | null = null;
if (projectId) {
const owned = await query<{ id: string }>(
`SELECT p.id 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, session.user.email],
);
if (owned.length > 0) safeProjectId = projectId;
}
const rows = await query<any>(
`INSERT INTO fs_chat_threads (user_id, workspace, data)
VALUES ($1, $2, $3)
RETURNING id, data, created_at, updated_at`,
`INSERT INTO fs_chat_threads (user_id, workspace, project_id, data)
VALUES ($1, $2, $3, $4)
RETURNING id, project_id, data, created_at, updated_at`,
[
session.user.email,
workspace,
safeProjectId,
JSON.stringify({ title: title || 'New conversation', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }),
],
);
const r = rows[0];
return NextResponse.json({ thread: { id: r.id, title: r.data?.title || 'New conversation', createdAt: r.created_at, updatedAt: r.updated_at } });
return NextResponse.json({
thread: {
id: r.id,
projectId: r.project_id ?? null,
title: r.data?.title || 'New conversation',
createdAt: r.created_at,
updatedAt: r.updated_at,
},
});
}