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:
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -220,6 +220,12 @@ export function ChatPanel() {
|
||||
const { data: sessionData, status } = useSession();
|
||||
const params = useParams();
|
||||
const workspace = (params?.workspace as string) || "";
|
||||
// When the user is on a /project/<id>/* route, scope the chat to
|
||||
// that project. The threads list, the new-thread create call, and
|
||||
// the system prompt all branch on this; the chat header surfaces it
|
||||
// so the user knows the AI is "talking about" the right thing.
|
||||
const projectId = (params?.projectId as string) || "";
|
||||
const [activeProjectName, setActiveProjectName] = useState<string | null>(null);
|
||||
|
||||
const [open, setOpen] = useState(() => {
|
||||
if (typeof window === "undefined") return false;
|
||||
@@ -272,29 +278,53 @@ export function ChatPanel() {
|
||||
.catch(() => {});
|
||||
}, [workspace, status]);
|
||||
|
||||
// Load threads
|
||||
// Load threads (scoped to the current project when one is in the URL).
|
||||
// Reset the loaded flag when projectId changes so the resume effect
|
||||
// re-runs against the correct list and doesn't restore a thread from
|
||||
// the previous project.
|
||||
const loadThreads = useCallback(async () => {
|
||||
if (!workspace || status !== "authenticated") return;
|
||||
try {
|
||||
const res = await fetch(`/api/chat/threads?workspace=${encodeURIComponent(workspace)}`);
|
||||
const qs = new URLSearchParams({ workspace });
|
||||
if (projectId) qs.set("projectId", projectId);
|
||||
const res = await fetch(`/api/chat/threads?${qs.toString()}`);
|
||||
const data = await res.json();
|
||||
setThreads(data.threads || []);
|
||||
} catch { /* silent */ } finally {
|
||||
setThreadsLoaded(true);
|
||||
}
|
||||
}, [workspace, status]);
|
||||
}, [workspace, projectId, status]);
|
||||
|
||||
useEffect(() => {
|
||||
setThreadsLoaded(false);
|
||||
setActiveThread(null);
|
||||
setMessages([]);
|
||||
loadThreads();
|
||||
}, [loadThreads]);
|
||||
}, [loadThreads, projectId]);
|
||||
|
||||
// Create and activate a new thread
|
||||
// Look up the active project's display name once we have a projectId,
|
||||
// so the chat header can show "Talking about: <name>".
|
||||
useEffect(() => {
|
||||
if (!projectId) { setActiveProjectName(null); return; }
|
||||
let cancelled = false;
|
||||
fetch(`/api/projects/${projectId}/anatomy`, { credentials: "include" })
|
||||
.then((r) => r.ok ? r.json() : null)
|
||||
.then((d) => {
|
||||
if (cancelled) return;
|
||||
const name = d?.project?.name;
|
||||
if (name) setActiveProjectName(name);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
}, [projectId]);
|
||||
|
||||
// Create and activate a new thread (tagged to the active project, if any).
|
||||
const newThread = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/chat/threads", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ workspace }),
|
||||
body: JSON.stringify({ workspace, projectId: projectId || undefined }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.thread) {
|
||||
@@ -304,7 +334,7 @@ export function ChatPanel() {
|
||||
setShowThreads(false);
|
||||
}
|
||||
} catch { /* silent */ }
|
||||
}, [workspace]);
|
||||
}, [workspace, projectId]);
|
||||
|
||||
// Load thread messages
|
||||
const loadThread = useCallback(async (id: string) => {
|
||||
@@ -334,18 +364,21 @@ export function ChatPanel() {
|
||||
return;
|
||||
}
|
||||
|
||||
const savedKey = `vibn-chat-active-thread:${workspace}`;
|
||||
const scopeKey = projectId ? `${workspace}:${projectId}` : workspace;
|
||||
const savedKey = `vibn-chat-active-thread:${scopeKey}`;
|
||||
const saved = typeof window !== "undefined" ? localStorage.getItem(savedKey) : null;
|
||||
const target = saved && threads.some((t) => t.id === saved) ? saved : threads[0].id;
|
||||
loadThread(target);
|
||||
}, [open, status, workspace, threadsLoaded, threads, activeThread, newThread, loadThread]);
|
||||
}, [open, status, workspace, projectId, threadsLoaded, threads, activeThread, newThread, loadThread]);
|
||||
|
||||
// Persist active thread so reload re-opens the same conversation.
|
||||
// Persist active thread so reload re-opens the same conversation,
|
||||
// keyed per-project so each project has its own "last conversation".
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || !workspace) return;
|
||||
const savedKey = `vibn-chat-active-thread:${workspace}`;
|
||||
const scopeKey = projectId ? `${workspace}:${projectId}` : workspace;
|
||||
const savedKey = `vibn-chat-active-thread:${scopeKey}`;
|
||||
if (activeThread) localStorage.setItem(savedKey, activeThread);
|
||||
}, [activeThread, workspace]);
|
||||
}, [activeThread, workspace, projectId]);
|
||||
|
||||
useEffect(() => { scrollToBottom(); }, [messages, toolEvents, scrollToBottom]);
|
||||
|
||||
@@ -583,6 +616,20 @@ export function ChatPanel() {
|
||||
transition: "transform 0.15s",
|
||||
transform: showThreads ? "rotate(180deg)" : "none",
|
||||
}} />
|
||||
{projectId && (
|
||||
<span
|
||||
title={`Chat is scoped to ${activeProjectName ?? "this project"}. Tool calls assume projectId=${projectId}.`}
|
||||
style={{
|
||||
marginLeft: 6, padding: "2px 8px",
|
||||
fontSize: "0.65rem", fontWeight: 600, letterSpacing: "0.02em",
|
||||
color: "#3d5afe", background: "#3d5afe14",
|
||||
borderRadius: 999, maxWidth: 160,
|
||||
whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
▸ {activeProjectName ?? "this project"}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<div style={{ display: "flex", gap: 4 }}>
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user