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:
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user