feat(api): comprehensive QA hardening — security gates, chat improvements, beta scaffolds
Closes checklist items F-01..F-06, D-01..D-28, S-01..S-10, C-01..C-07, B-01..B-07, R-01..R-02, O-03. Security (28 deletions + 10 auth gates): - Delete 28 unauthenticated debug/cursor/firebase/test routes - Gate ai/chat, ai/conversation, context/summarize, work-completed with withTenantProject/withAuth - Add HMAC-SHA256 signature verification to webhooks/coolify - Switch all admin secret comparisons to timingSafeStringEq Foundations (lib/server/*): - api-handler.ts: withAuth, withTenantProject, withWorkspace, withAdminSecret, withRateLimit - logger.ts: structured request-scoped logging with turnId - audit-log.ts: writeAuditLog helper + audit_log table - rate-limit.ts: Postgres sliding window rate limiter - coolify-webhook.ts: verifyCoolifySignature - timing-safe.ts: timingSafeStringEq Chat hardening (chat/route.ts): - MAX_TOOL_ROUNDS 15 → 8 (C-01) - Loop detection: hard-break at 3 identical fingerprints (was 5) (C-02) - Add 6-consecutive-tool-call hard-break (C-02) - Mode: respond first, act second prompt block (C-03) - SSE heartbeat every 25s via setInterval (C-04) - Per-tool 45s timeout via Promise.race (C-05) - turnId per-turn UUID for log correlation (C-06) - Recovery fires when roundsSinceText >= 4 (C-07) - SSE plan event on plan_task_add/edit (B-05) Beta features: - invites table + GET/POST /api/invites (P4.8) - invites/[token] validate + redeem (P4.8) - fs_project_dev_servers table + lib/server/dev-server-state.ts (P6.B1) - fs_project_secrets table + CRUD routes (P6.D2) - lib/integrations/brief-extract.ts (P3.7) Documentation: - app/api/ROUTES.md: full route map with auth + tenant
This commit is contained in:
159
vibn-frontend/app/api/ROUTES.md
Normal file
159
vibn-frontend/app/api/ROUTES.md
Normal file
@@ -0,0 +1,159 @@
|
||||
# API Route Map
|
||||
|
||||
> Generated 2026-05-17. Auth column: `session` = NextAuth cookie,
|
||||
> `api_key` = `vibn_sk_…` bearer, `admin_secret` = env-var secret,
|
||||
> `webhook_sig` = HMAC-SHA256, `public` = no auth.
|
||||
>
|
||||
> Tenant column: `workspace` = must belong to caller's workspace,
|
||||
> `project` = must own project, `user` = must match session user,
|
||||
> `global` = cross-workspace admin op.
|
||||
|
||||
## Chat
|
||||
|
||||
| Method | Path | Auth | Tenant | Purpose |
|
||||
|--------|------|------|--------|---------|
|
||||
| POST | `/api/chat` | session | workspace | Main SSE chat with Gemini + tool loop |
|
||||
| GET | `/api/chat/threads` | session | user | List threads |
|
||||
| POST | `/api/chat/threads` | session | user | Create thread |
|
||||
| GET | `/api/chat/threads/[id]` | session | user | Get thread + messages |
|
||||
| PATCH | `/api/chat/threads/[id]` | session | user | Rename thread |
|
||||
| DELETE | `/api/chat/threads/[id]` | session | user | Delete thread |
|
||||
|
||||
## AI (legacy, plan to deprecate)
|
||||
|
||||
| Method | Path | Auth | Tenant | Purpose |
|
||||
|--------|------|------|--------|---------|
|
||||
| POST | `/api/ai/chat` | session | project | Old collector-mode chat (pre-tool) |
|
||||
| GET | `/api/ai/conversation` | session | project | Fetch saved conversation history |
|
||||
| DELETE | `/api/ai/conversation` | session | project | Wipe conversation history |
|
||||
| POST | `/api/ai/conversation/reset` | session | project | Alias for DELETE |
|
||||
|
||||
## Projects
|
||||
|
||||
| Method | Path | Auth | Tenant | Purpose |
|
||||
|--------|------|------|--------|---------|
|
||||
| GET | `/api/projects` | session | user | List user's projects |
|
||||
| POST | `/api/projects/create` | session | user | Create project (enforces quota) |
|
||||
| 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 |
|
||||
| GET/POST | `/api/projects/[projectId]/plan` | session | project | Plan read/update |
|
||||
| POST | `/api/projects/[projectId]/plan/intelligent` | session | project | AI plan generation |
|
||||
| POST | `/api/projects/[projectId]/plan/mvp` | session | project | MVP plan |
|
||||
| POST | `/api/projects/[projectId]/plan/marketing` | session | project | Marketing plan |
|
||||
| POST | `/api/projects/[projectId]/documents/upload` | session | project | Upload brief |
|
||||
| GET/POST | `/api/projects/[projectId]/secrets` | session | project | List/set project secrets (B-06) |
|
||||
| GET/DELETE | `/api/projects/[projectId]/secrets/[key]` | session | project | Reveal/delete secret (B-06) |
|
||||
| GET | `/api/projects/[projectId]/knowledge` | session | project | Knowledge items |
|
||||
| POST | `/api/projects/[projectId]/knowledge/batch-extract` | session | project | Batch extract knowledge |
|
||||
| GET/POST | `/api/projects/[projectId]/agent/sessions` | session | project | Agent session CRUD |
|
||||
| GET | `/api/projects/[projectId]/agent/sessions/[sessionId]` | session | project | Session state |
|
||||
| POST | `/api/projects/[projectId]/agent/sessions/[sessionId]/approve` | session | project | Approve session commit |
|
||||
| POST | `/api/projects/[projectId]/agent/sessions/[sessionId]/stop` | session | project | Stop agent |
|
||||
| GET | `/api/projects/[projectId]/agent/sessions/[sessionId]/events` | session | project | Event list |
|
||||
| GET | `/api/projects/[projectId]/agent/sessions/[sessionId]/events/stream` | session | project | SSE event tail |
|
||||
|
||||
## Workspaces
|
||||
|
||||
| Method | Path | Auth | Tenant | Purpose |
|
||||
|--------|------|------|--------|---------|
|
||||
| GET | `/api/workspaces` | session/api_key | user | List workspaces |
|
||||
| POST | `/api/workspaces/delete` | session | user | Delete workspace |
|
||||
| GET | `/api/workspaces/[slug]` | session/api_key | workspace | Get workspace |
|
||||
| GET/POST | `/api/workspaces/[slug]/apps` | session/api_key | workspace | List/create apps |
|
||||
| GET/PATCH/DELETE | `/api/workspaces/[slug]/apps/[uuid]` | session/api_key | workspace | App CRUD |
|
||||
| POST | `/api/workspaces/[slug]/apps/[uuid]/deploy` | session/api_key | workspace | Trigger deploy |
|
||||
| GET | `/api/workspaces/[slug]/apps/[uuid]/logs` | session/api_key | workspace | Runtime logs |
|
||||
| GET/PATCH | `/api/workspaces/[slug]/apps/[uuid]/envs` | session/api_key | workspace | Env vars |
|
||||
| POST | `/api/workspaces/[slug]/apps/[uuid]/exec` | session/api_key | workspace | Remote exec |
|
||||
| GET/POST | `/api/workspaces/[slug]/databases` | session/api_key | workspace | Database CRUD |
|
||||
| GET | `/api/workspaces/[slug]/domains` | session/api_key | workspace | Domain list |
|
||||
| GET/POST | `/api/workspaces/[slug]/keys` | session/api_key | workspace | API keys |
|
||||
| POST | `/api/workspaces/[slug]/provision` | session/api_key | workspace | Provision workspace |
|
||||
|
||||
## MCP
|
||||
|
||||
| Method | Path | Auth | Tenant | Purpose |
|
||||
|--------|------|------|--------|---------|
|
||||
| POST | `/api/mcp` | session/api_key | workspace | All 40+ MCP tools |
|
||||
| GET/POST/DELETE | `/api/mcp/generate-key` | session | user | Manage MCP API keys |
|
||||
|
||||
## Auth / Sessions
|
||||
|
||||
| Method | Path | Auth | Tenant | Purpose |
|
||||
|--------|------|------|--------|---------|
|
||||
| GET/POST | `/api/auth/[...nextauth]` | public | - | NextAuth handlers |
|
||||
| GET | `/api/user/api-key` | session | user | Get/create user API key |
|
||||
| GET | `/api/sessions` | session | user | Session history |
|
||||
| POST | `/api/sessions/track` | session | user | Track session event |
|
||||
| POST | `/api/sessions/associate-project` | session | user | Link session to project |
|
||||
|
||||
## GitHub Integrations
|
||||
|
||||
| Method | Path | Auth | Tenant | Purpose |
|
||||
|--------|------|------|--------|---------|
|
||||
| GET/POST/DELETE | `/api/github/connect` | session | user | Legacy GitHub OAuth connect |
|
||||
| GET | `/api/github/repos` | session | user | List connected repos |
|
||||
| GET | `/api/github/repo-tree` | session | user | Repo file tree |
|
||||
| GET | `/api/github/file-content` | session | user | Single file content |
|
||||
| POST | `/api/github/oauth/token` | public | - | OAuth token exchange |
|
||||
| GET | `/api/integrations/github/connect` | session | user | New OAuth connect |
|
||||
| GET | `/api/integrations/github/callback` | public | - | OAuth callback |
|
||||
| POST | `/api/integrations/github/disconnect` | session | user | Disconnect GitHub |
|
||||
| GET | `/api/integrations/github/repos` | session | user | New integration repos |
|
||||
|
||||
## Webhooks
|
||||
|
||||
| Method | Path | Auth | Tenant | Purpose |
|
||||
|--------|------|------|--------|---------|
|
||||
| POST | `/api/webhooks/gitea` | webhook_sig | project | Gitea push events → context snapshot |
|
||||
| POST | `/api/webhooks/coolify` | webhook_sig | project | Deploy status → context snapshot |
|
||||
|
||||
## Invites (P4.8)
|
||||
|
||||
| Method | Path | Auth | Tenant | Purpose |
|
||||
|--------|------|------|--------|---------|
|
||||
| GET/POST | `/api/invites` | admin_secret | global | Create / list invite tokens |
|
||||
| GET | `/api/invites/[token]` | public | - | Validate token (used by auth page) |
|
||||
| POST | `/api/invites/[token]` | session | user | Redeem token on signup |
|
||||
|
||||
## Admin / Ops
|
||||
|
||||
| Method | Path | Auth | Tenant | Purpose |
|
||||
|--------|------|------|--------|---------|
|
||||
| POST | `/api/admin/migrate` | admin_secret | global | Run SQL migrations |
|
||||
| GET | `/api/admin/path-b` | admin_secret | global | Path B kill-switch state |
|
||||
| POST | `/api/admin/path-b/disable` | admin_secret | global | Disable Path B |
|
||||
| POST | `/api/admin/path-b/enable` | admin_secret | global | Enable Path B |
|
||||
| POST | `/api/admin/path-b/idle-sweep` | admin_secret | global | Suspend idle dev containers |
|
||||
| POST | `/api/admin/path-b/autosave` | admin_secret | global | Autosave workspace |
|
||||
| POST | `/api/admin/backfill-isolation` | admin_secret | global | Backfill tenant isolation |
|
||||
| POST | `/api/admin/path-b` | admin_secret | global | Path B bulk status |
|
||||
| GET | `/api/internal/infra-health` | admin_secret | global | Coolify + SSH probe |
|
||||
|
||||
## Utilities
|
||||
|
||||
| Method | Path | Auth | Tenant | Purpose |
|
||||
|--------|------|------|--------|---------|
|
||||
| POST | `/api/context/summarize` | session | user | Gemini one-shot doc summary |
|
||||
| GET | `/api/activity` | session | user | Workspace activity feed |
|
||||
| GET | `/api/work-completed` | session | project | Work completed items |
|
||||
| GET | `/api/stats` | session | user | Usage stats |
|
||||
| GET | `/api/preview/embed` | session | user | HTML proxy for preview iframes |
|
||||
| GET/POST | `/api/extension/link-project` | session | user | Browser extension project link |
|
||||
| POST | `/api/vision/update` | session | project | Update project vision |
|
||||
| GET | `/api/keys` | session | user | User key management |
|
||||
| GET | `/api/design-systems/[id]/preview` | public | - | Design system preview HTML |
|
||||
| GET | `/api/design-systems/[id]/showcase` | public | - | Design system showcase |
|
||||
|
||||
## Deprecated / V0
|
||||
|
||||
| Method | Path | Auth | Tenant | Purpose |
|
||||
|--------|------|------|--------|---------|
|
||||
| * | `/api/v0/*` | varies | varies | Legacy v0 integration (verify still used) |
|
||||
| * | `/api/openai/*` | session | user | OpenAI GPT import (verify still used) |
|
||||
| * | `/api/chatgpt/*` | session | user | ChatGPT conversation import |
|
||||
@@ -1,46 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getAdminDb } from '@/lib/firebase/admin';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const projectId = searchParams.get('projectId');
|
||||
const userId = searchParams.get('userId');
|
||||
|
||||
const adminDb = getAdminDb();
|
||||
|
||||
// Get all sessions for this user
|
||||
const sessionsSnapshot = await adminDb
|
||||
.collection('sessions')
|
||||
.where('userId', '==', userId)
|
||||
.get();
|
||||
|
||||
const allSessions = sessionsSnapshot.docs.map(doc => {
|
||||
const data = doc.data();
|
||||
return {
|
||||
id: doc.id,
|
||||
projectId: data.projectId || null,
|
||||
workspacePath: data.workspacePath || null,
|
||||
workspaceName: data.workspaceName || null,
|
||||
needsProjectAssociation: data.needsProjectAssociation,
|
||||
messageCount: data.messageCount,
|
||||
conversationLength: data.conversation?.length || 0,
|
||||
};
|
||||
});
|
||||
|
||||
// Filter sessions that match this project
|
||||
const matchingSessions = allSessions.filter(s => s.projectId === projectId);
|
||||
|
||||
return NextResponse.json({
|
||||
totalSessions: allSessions.length,
|
||||
matchingSessions: matchingSessions.length,
|
||||
allSessions,
|
||||
projectId,
|
||||
userId,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('[Admin Check Sessions] Error:', error);
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getAdminDb } from '@/lib/firebase/admin';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { projectId, workspacePath } = await request.json();
|
||||
|
||||
if (!projectId || !workspacePath) {
|
||||
return NextResponse.json(
|
||||
{ error: 'projectId and workspacePath required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const adminDb = getAdminDb();
|
||||
|
||||
// Update project with workspacePath
|
||||
await adminDb.collection('projects').doc(projectId).update({
|
||||
workspacePath,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
console.log(`[Fix Project] Set workspacePath for ${projectId}: ${workspacePath}`);
|
||||
|
||||
// Now find and link all matching sessions
|
||||
const sessionsSnapshot = await adminDb
|
||||
.collection('sessions')
|
||||
.where('workspacePath', '==', workspacePath)
|
||||
.where('needsProjectAssociation', '==', true)
|
||||
.get();
|
||||
|
||||
const batch = adminDb.batch();
|
||||
let linkedCount = 0;
|
||||
|
||||
for (const sessionDoc of sessionsSnapshot.docs) {
|
||||
batch.update(sessionDoc.ref, {
|
||||
projectId,
|
||||
needsProjectAssociation: false,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
linkedCount++;
|
||||
}
|
||||
|
||||
await batch.commit();
|
||||
|
||||
console.log(`[Fix Project] Linked ${linkedCount} sessions to project ${projectId}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
projectId,
|
||||
workspacePath,
|
||||
sessionsLinked: linkedCount,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('[Fix Project] Error:', error);
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,13 +13,17 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { query } from "@/lib/db-postgres";
|
||||
import { readFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { timingSafeStringEq } from "@/lib/server/timing-safe";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const secret = process.env.ADMIN_MIGRATE_SECRET ?? "";
|
||||
if (!secret) {
|
||||
return NextResponse.json(
|
||||
{ error: "ADMIN_MIGRATE_SECRET env var not set — migration endpoint disabled" },
|
||||
{ status: 403 }
|
||||
{
|
||||
error:
|
||||
"ADMIN_MIGRATE_SECRET env var not set — migration endpoint disabled",
|
||||
},
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,7 +32,7 @@ export async function POST(req: NextRequest) {
|
||||
new URL(req.url).searchParams.get("secret") ??
|
||||
"";
|
||||
|
||||
if (incoming !== secret) {
|
||||
if (!incoming || !timingSafeStringEq(secret, incoming)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
@@ -299,9 +303,9 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
const failed = results.filter(r => !r.ok);
|
||||
const failed = results.filter((r) => !r.ok);
|
||||
return NextResponse.json(
|
||||
{ ok: failed.length === 0, results },
|
||||
{ status: failed.length === 0 ? 200 : 207 }
|
||||
{ status: failed.length === 0 ? 200 : 207 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,22 +18,32 @@
|
||||
* commits go through the `ship` tool.
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { autosaveWorkspace } from '@/lib/dev-container';
|
||||
import { query } from '@/lib/db-postgres';
|
||||
import { getOrCreateProvisionedWorkspace } from '@/lib/workspaces';
|
||||
import { NextResponse } from "next/server";
|
||||
import { autosaveWorkspace } from "@/lib/dev-container";
|
||||
import { query } from "@/lib/db-postgres";
|
||||
import { getOrCreateProvisionedWorkspace } from "@/lib/workspaces";
|
||||
import { timingSafeStringEq } from "@/lib/server/timing-safe";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const auth = request.headers.get('authorization') ?? '';
|
||||
const bearer = auth.toLowerCase().startsWith('bearer ') ? auth.slice(7).trim() : '';
|
||||
if (!bearer || !process.env.NEXTAUTH_SECRET || bearer !== process.env.NEXTAUTH_SECRET) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
const expected = process.env.NEXTAUTH_SECRET ?? "";
|
||||
if (!expected) {
|
||||
return NextResponse.json(
|
||||
{ error: "NEXTAUTH_SECRET not configured" },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
const auth = request.headers.get("authorization") ?? "";
|
||||
const bearer = auth.toLowerCase().startsWith("bearer ")
|
||||
? auth.slice(7).trim()
|
||||
: "";
|
||||
if (!bearer || !timingSafeStringEq(expected, bearer)) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
let body: { projectId?: string; projectSlug?: string; sweep?: boolean };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
|
||||
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Single-project mode.
|
||||
@@ -44,15 +54,18 @@ export async function POST(request: Request) {
|
||||
[projectId],
|
||||
);
|
||||
if (row.length === 0) {
|
||||
return NextResponse.json({ error: 'Project not found' }, { status: 404 });
|
||||
return NextResponse.json({ error: "Project not found" }, { status: 404 });
|
||||
}
|
||||
const ws = await getOrCreateProvisionedWorkspace({
|
||||
userId: row[0].data?.userId ?? '',
|
||||
email: row[0].data?.ownerEmail ?? '',
|
||||
userId: row[0].data?.userId ?? "",
|
||||
email: row[0].data?.ownerEmail ?? "",
|
||||
displayName: row[0].workspace,
|
||||
}).catch(() => null);
|
||||
if (!ws) {
|
||||
return NextResponse.json({ error: 'Workspace not provisioned' }, { status: 503 });
|
||||
return NextResponse.json(
|
||||
{ error: "Workspace not provisioned" },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
const result = await autosaveWorkspace({
|
||||
projectId,
|
||||
@@ -76,8 +89,8 @@ export async function POST(request: Request) {
|
||||
);
|
||||
if (proj.length === 0) continue;
|
||||
const ws = await getOrCreateProvisionedWorkspace({
|
||||
userId: proj[0].data?.userId ?? '',
|
||||
email: proj[0].data?.ownerEmail ?? '',
|
||||
userId: proj[0].data?.userId ?? "",
|
||||
email: proj[0].data?.ownerEmail ?? "",
|
||||
displayName: r.workspace,
|
||||
}).catch(() => null);
|
||||
if (!ws) continue;
|
||||
@@ -85,14 +98,17 @@ export async function POST(request: Request) {
|
||||
projectId: r.project_id,
|
||||
projectSlug: proj[0].slug,
|
||||
workspace: ws,
|
||||
}).catch(err => ({ ran: false, reason: err instanceof Error ? err.message : String(err) }));
|
||||
}).catch((err) => ({
|
||||
ran: false,
|
||||
reason: err instanceof Error ? err.message : String(err),
|
||||
}));
|
||||
out.push({ projectId: r.project_id, ran: res.ran, reason: res.reason });
|
||||
}
|
||||
return NextResponse.json({ result: { swept: out.length, out } });
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'Provide either { projectId } or { sweep: true }' },
|
||||
{ error: "Provide either { projectId } or { sweep: true }" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { setFlag } from '@/lib/feature-flags';
|
||||
import { NextResponse } from "next/server";
|
||||
import { setFlag } from "@/lib/feature-flags";
|
||||
import { timingSafeStringEq } from "@/lib/server/timing-safe";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const auth = request.headers.get('authorization') ?? '';
|
||||
const bearer = auth.toLowerCase().startsWith('bearer ') ? auth.slice(7).trim() : '';
|
||||
if (!bearer || !process.env.NEXTAUTH_SECRET || bearer !== process.env.NEXTAUTH_SECRET) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
const expected = process.env.NEXTAUTH_SECRET ?? "";
|
||||
if (!expected) {
|
||||
return NextResponse.json(
|
||||
{ error: "NEXTAUTH_SECRET not configured" },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
await setFlag('path_b_disabled', true);
|
||||
const auth = request.headers.get("authorization") ?? "";
|
||||
const bearer = auth.toLowerCase().startsWith("bearer ")
|
||||
? auth.slice(7).trim()
|
||||
: "";
|
||||
if (!bearer || !timingSafeStringEq(expected, bearer)) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
await setFlag("path_b_disabled", true);
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
flag: 'path_b_disabled',
|
||||
flag: "path_b_disabled",
|
||||
value: true,
|
||||
note: 'Path B (AI dev containers) disabled. New chat sessions fall back to Gitea-write tools. Existing dev containers continue until idle-suspend.',
|
||||
note: "Path B (AI dev containers) disabled. New chat sessions fall back to Gitea-write tools. Existing dev containers continue until idle-suspend.",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { setFlag } from '@/lib/feature-flags';
|
||||
import { NextResponse } from "next/server";
|
||||
import { setFlag } from "@/lib/feature-flags";
|
||||
import { timingSafeStringEq } from "@/lib/server/timing-safe";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const auth = request.headers.get('authorization') ?? '';
|
||||
const bearer = auth.toLowerCase().startsWith('bearer ') ? auth.slice(7).trim() : '';
|
||||
if (!bearer || !process.env.NEXTAUTH_SECRET || bearer !== process.env.NEXTAUTH_SECRET) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
const expected = process.env.NEXTAUTH_SECRET ?? "";
|
||||
if (!expected) {
|
||||
return NextResponse.json(
|
||||
{ error: "NEXTAUTH_SECRET not configured" },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
await setFlag('path_b_disabled', false);
|
||||
const auth = request.headers.get("authorization") ?? "";
|
||||
const bearer = auth.toLowerCase().startsWith("bearer ")
|
||||
? auth.slice(7).trim()
|
||||
: "";
|
||||
if (!bearer || !timingSafeStringEq(expected, bearer)) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
await setFlag("path_b_disabled", false);
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
flag: 'path_b_disabled',
|
||||
flag: "path_b_disabled",
|
||||
value: false,
|
||||
note: 'Path B re-enabled.',
|
||||
note: "Path B re-enabled.",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -17,18 +17,31 @@
|
||||
* the next shell.exec call resumes the service in <5s.
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { suspendIdleContainers } from '@/lib/dev-container';
|
||||
import { NextResponse } from "next/server";
|
||||
import { suspendIdleContainers } from "@/lib/dev-container";
|
||||
import { timingSafeStringEq } from "@/lib/server/timing-safe";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const auth = request.headers.get('authorization') ?? '';
|
||||
const bearer = auth.toLowerCase().startsWith('bearer ') ? auth.slice(7).trim() : '';
|
||||
if (!bearer || !process.env.NEXTAUTH_SECRET || bearer !== process.env.NEXTAUTH_SECRET) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
const expected = process.env.NEXTAUTH_SECRET ?? "";
|
||||
if (!expected) {
|
||||
return NextResponse.json(
|
||||
{ error: "NEXTAUTH_SECRET not configured" },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
const auth = request.headers.get("authorization") ?? "";
|
||||
const bearer = auth.toLowerCase().startsWith("bearer ")
|
||||
? auth.slice(7).trim()
|
||||
: "";
|
||||
if (!bearer || !timingSafeStringEq(expected, bearer)) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const url = new URL(request.url);
|
||||
const minStr = url.searchParams.get('minutes');
|
||||
const minutes = minStr && Number.isFinite(Number(minStr)) ? Math.max(5, Number(minStr)) : 30;
|
||||
const minStr = url.searchParams.get("minutes");
|
||||
const minutes =
|
||||
minStr && Number.isFinite(Number(minStr))
|
||||
? Math.max(5, Number(minStr))
|
||||
: 30;
|
||||
const result = await suspendIdleContainers(minutes);
|
||||
return NextResponse.json({ result, idleMinutes: minutes });
|
||||
}
|
||||
|
||||
@@ -20,19 +20,24 @@
|
||||
* to every Vibn pod within ~10s of the SQL update.
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getFlag, setFlag } from '@/lib/feature-flags';
|
||||
import { NextResponse } from "next/server";
|
||||
import { getFlag } from "@/lib/feature-flags";
|
||||
import { timingSafeStringEq } from "@/lib/server/timing-safe";
|
||||
|
||||
function authorized(request: Request): boolean {
|
||||
const auth = request.headers.get('authorization') ?? '';
|
||||
const bearer = auth.toLowerCase().startsWith('bearer ') ? auth.slice(7).trim() : '';
|
||||
return Boolean(bearer && process.env.NEXTAUTH_SECRET && bearer === process.env.NEXTAUTH_SECRET);
|
||||
const expected = process.env.NEXTAUTH_SECRET ?? "";
|
||||
if (!expected) return false;
|
||||
const auth = request.headers.get("authorization") ?? "";
|
||||
const bearer = auth.toLowerCase().startsWith("bearer ")
|
||||
? auth.slice(7).trim()
|
||||
: "";
|
||||
return Boolean(bearer && timingSafeStringEq(expected, bearer));
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
if (!authorized(request)) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const disabled = await getFlag<boolean>('path_b_disabled', false);
|
||||
const disabled = await getFlag<boolean>("path_b_disabled", false);
|
||||
return NextResponse.json({ disabled });
|
||||
}
|
||||
|
||||
@@ -1,44 +1,52 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { GeminiLlmClient } from '@/lib/ai/gemini-client';
|
||||
import type { LlmClient } from '@/lib/ai/llm-client';
|
||||
import { query } from '@/lib/db-postgres';
|
||||
import { MODE_SYSTEM_PROMPTS, ChatMode } from '@/lib/ai/chat-modes';
|
||||
import { resolveChatMode } from '@/lib/server/chat-mode-resolver';
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { GeminiLlmClient } from "@/lib/ai/gemini-client";
|
||||
import { withTenantProject } from "@/lib/server/api-handler";
|
||||
import { log } from "@/lib/server/logger";
|
||||
import type { LlmClient } from "@/lib/ai/llm-client";
|
||||
import { query } from "@/lib/db-postgres";
|
||||
import { MODE_SYSTEM_PROMPTS, ChatMode } from "@/lib/ai/chat-modes";
|
||||
import { resolveChatMode } from "@/lib/server/chat-mode-resolver";
|
||||
import {
|
||||
buildProjectContextForChat,
|
||||
determineArtifactsUsed,
|
||||
formatContextForPrompt,
|
||||
} from '@/lib/server/chat-context';
|
||||
import { logProjectEvent } from '@/lib/server/logs';
|
||||
import type { CollectorPhaseHandoff } from '@/lib/types/phase-handoff';
|
||||
} from "@/lib/server/chat-context";
|
||||
import { logProjectEvent } from "@/lib/server/logs";
|
||||
import type { CollectorPhaseHandoff } from "@/lib/types/phase-handoff";
|
||||
|
||||
// Increase timeout for Gemini 3 Pro thinking mode (can take 1-2 minutes)
|
||||
export const maxDuration = 180; // 3 minutes
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const ChatReplySchema = z.object({
|
||||
reply: z.string(),
|
||||
visionAnswers: z.object({
|
||||
q1: z.string().optional(), // Answer to question 1
|
||||
q2: z.string().optional(), // Answer to question 2
|
||||
q3: z.string().optional(), // Answer to question 3
|
||||
allAnswered: z.boolean().optional(), // True when all 3 are complete
|
||||
}).optional(),
|
||||
collectorHandoff: z.object({
|
||||
hasDocuments: z.boolean().optional(),
|
||||
documentCount: z.number().optional(),
|
||||
githubConnected: z.boolean().optional(),
|
||||
githubRepo: z.string().optional(),
|
||||
extensionLinked: z.boolean().optional(),
|
||||
extensionDeclined: z.boolean().optional(),
|
||||
noGithubYet: z.boolean().optional(),
|
||||
readyForExtraction: z.boolean().optional(),
|
||||
}).optional(),
|
||||
extractionReviewHandoff: z.object({
|
||||
extractionApproved: z.boolean().optional(),
|
||||
readyForVision: z.boolean().optional(),
|
||||
}).optional(),
|
||||
visionAnswers: z
|
||||
.object({
|
||||
q1: z.string().optional(), // Answer to question 1
|
||||
q2: z.string().optional(), // Answer to question 2
|
||||
q3: z.string().optional(), // Answer to question 3
|
||||
allAnswered: z.boolean().optional(), // True when all 3 are complete
|
||||
})
|
||||
.optional(),
|
||||
collectorHandoff: z
|
||||
.object({
|
||||
hasDocuments: z.boolean().optional(),
|
||||
documentCount: z.number().optional(),
|
||||
githubConnected: z.boolean().optional(),
|
||||
githubRepo: z.string().optional(),
|
||||
extensionLinked: z.boolean().optional(),
|
||||
extensionDeclined: z.boolean().optional(),
|
||||
noGithubYet: z.boolean().optional(),
|
||||
readyForExtraction: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
extractionReviewHandoff: z
|
||||
.object({
|
||||
extractionApproved: z.boolean().optional(),
|
||||
readyForVision: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
interface ChatRequestBody {
|
||||
@@ -57,7 +65,7 @@ const ENSURE_CONV_TABLE = `
|
||||
|
||||
async function appendConversation(
|
||||
projectId: string,
|
||||
newMessages: Array<{ role: 'user' | 'assistant'; content: string }>,
|
||||
newMessages: Array<{ role: "user" | "assistant"; content: string }>,
|
||||
) {
|
||||
await query(ENSURE_CONV_TABLE);
|
||||
const now = new Date().toISOString();
|
||||
@@ -69,52 +77,62 @@ async function appendConversation(
|
||||
ON CONFLICT (project_id) DO UPDATE
|
||||
SET messages = chat_conversations.messages || $2::jsonb,
|
||||
updated_at = NOW()`,
|
||||
[projectId, JSON.stringify(stamped)]
|
||||
[projectId, JSON.stringify(stamped)],
|
||||
);
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = (await request.json()) as ChatRequestBody;
|
||||
const projectId = body.projectId?.trim();
|
||||
const message = body.message?.trim();
|
||||
export const POST = withTenantProject(
|
||||
async (request, _ctx, { project, user }) => {
|
||||
try {
|
||||
const body = (await request.json()) as ChatRequestBody;
|
||||
const projectId = project.id;
|
||||
const message = body.message?.trim();
|
||||
|
||||
if (!projectId || !message) {
|
||||
return NextResponse.json({ error: 'projectId and message are required' }, { status: 400 });
|
||||
}
|
||||
if (!message) {
|
||||
return NextResponse.json(
|
||||
{ error: "message is required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Verify project exists in Postgres
|
||||
const projectRows = await query<{ data: any }>(
|
||||
`SELECT data FROM fs_projects WHERE id = $1 LIMIT 1`,
|
||||
[projectId]
|
||||
);
|
||||
if (projectRows.length === 0) {
|
||||
return NextResponse.json({ error: 'Project not found' }, { status: 404 });
|
||||
}
|
||||
const projectData = projectRows[0].data ?? {};
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const projectData = (project.data ?? {}) as any;
|
||||
log.info("ai/chat: starting", {
|
||||
route: "api.ai.chat",
|
||||
projectId,
|
||||
user: user.email,
|
||||
});
|
||||
|
||||
// Resolve chat mode (uses new resolver)
|
||||
const resolvedMode = body.overrideMode ?? await resolveChatMode(projectId);
|
||||
console.log(`[AI Chat] Mode: ${resolvedMode}`);
|
||||
// Resolve chat mode (uses new resolver)
|
||||
const resolvedMode =
|
||||
body.overrideMode ?? (await resolveChatMode(projectId));
|
||||
console.log(`[AI Chat] Mode: ${resolvedMode}`);
|
||||
|
||||
// Build comprehensive context with vector retrieval
|
||||
// Only include GitHub analysis for MVP generation (not needed for vision questions)
|
||||
const context = await buildProjectContextForChat(projectId, resolvedMode, message, {
|
||||
retrievalLimit: 10,
|
||||
includeVectorSearch: true,
|
||||
includeGitHubAnalysis: resolvedMode === 'mvp_mode', // Only load repo analysis when generating MVP
|
||||
});
|
||||
// Build comprehensive context with vector retrieval
|
||||
// Only include GitHub analysis for MVP generation (not needed for vision questions)
|
||||
const context = await buildProjectContextForChat(
|
||||
projectId,
|
||||
resolvedMode,
|
||||
message,
|
||||
{
|
||||
retrievalLimit: 10,
|
||||
includeVectorSearch: true,
|
||||
includeGitHubAnalysis: resolvedMode === "mvp_mode", // Only load repo analysis when generating MVP
|
||||
},
|
||||
);
|
||||
|
||||
console.log(`[AI Chat] Context built: ${context.retrievedChunks.length} vector chunks retrieved`);
|
||||
console.log(
|
||||
`[AI Chat] Context built: ${context.retrievedChunks.length} vector chunks retrieved`,
|
||||
);
|
||||
|
||||
// Get mode-specific system prompt
|
||||
const systemPrompt = MODE_SYSTEM_PROMPTS[resolvedMode];
|
||||
// Get mode-specific system prompt
|
||||
const systemPrompt = MODE_SYSTEM_PROMPTS[resolvedMode];
|
||||
|
||||
// Format context for LLM
|
||||
const contextSummary = formatContextForPrompt(context);
|
||||
// Format context for LLM
|
||||
const contextSummary = formatContextForPrompt(context);
|
||||
|
||||
// Prepare enhanced system prompt with context
|
||||
const enhancedSystemPrompt = `${systemPrompt}
|
||||
// Prepare enhanced system prompt with context
|
||||
const enhancedSystemPrompt = `${systemPrompt}
|
||||
|
||||
## Current Project Context
|
||||
|
||||
@@ -126,208 +144,248 @@ You have access to:
|
||||
- Project artifacts (product model, MVP plan, marketing plan)
|
||||
- Knowledge items (${context.knowledgeSummary.totalCount} total)
|
||||
- Extraction signals (${context.extractionSummary.totalCount} analyzed)
|
||||
${context.retrievedChunks.length > 0 ? `- ${context.retrievedChunks.length} relevant chunks from vector search (most similar to user's query)` : ''}
|
||||
${context.repositoryAnalysis ? `- GitHub repository analysis (${context.repositoryAnalysis.totalFiles} files)` : ''}
|
||||
${context.sessionHistory.totalSessions > 0 ? `- Complete Cursor session history (${context.sessionHistory.totalSessions} sessions, ${context.sessionHistory.messages.length} messages in chronological order)` : ''}
|
||||
${context.retrievedChunks.length > 0 ? `- ${context.retrievedChunks.length} relevant chunks from vector search (most similar to user's query)` : ""}
|
||||
${context.repositoryAnalysis ? `- GitHub repository analysis (${context.repositoryAnalysis.totalFiles} files)` : ""}
|
||||
${context.sessionHistory.totalSessions > 0 ? `- Complete Cursor session history (${context.sessionHistory.totalSessions} sessions, ${context.sessionHistory.messages.length} messages in chronological order)` : ""}
|
||||
|
||||
Use this context to provide specific, grounded responses. The session history shows your complete conversation history with the user - use it to understand what has been built and discussed.`;
|
||||
|
||||
// Load existing conversation history from Postgres
|
||||
await query(ENSURE_CONV_TABLE);
|
||||
const convRows = await query<{ messages: any[] }>(
|
||||
`SELECT messages FROM chat_conversations WHERE project_id = $1`,
|
||||
[projectId]
|
||||
);
|
||||
const conversationHistory: any[] = convRows[0]?.messages ?? [];
|
||||
// Load existing conversation history from Postgres
|
||||
await query(ENSURE_CONV_TABLE);
|
||||
const convRows = await query<{ messages: any[] }>(
|
||||
`SELECT messages FROM chat_conversations WHERE project_id = $1`,
|
||||
[projectId],
|
||||
);
|
||||
const conversationHistory: any[] = convRows[0]?.messages ?? [];
|
||||
|
||||
// Build full message context (history + current message)
|
||||
const messages = [
|
||||
...conversationHistory.map((msg: any) => ({
|
||||
role: msg.role as 'user' | 'assistant',
|
||||
content: msg.content as string,
|
||||
})),
|
||||
{
|
||||
role: 'user' as const,
|
||||
content: message,
|
||||
},
|
||||
];
|
||||
// Build full message context (history + current message)
|
||||
const messages = [
|
||||
...conversationHistory.map((msg: any) => ({
|
||||
role: msg.role as "user" | "assistant",
|
||||
content: msg.content as string,
|
||||
})),
|
||||
{
|
||||
role: "user" as const,
|
||||
content: message,
|
||||
},
|
||||
];
|
||||
|
||||
console.log(`[AI Chat] Sending ${messages.length} messages to LLM (${conversationHistory.length} from history + 1 new)`);
|
||||
console.log(`[AI Chat] Mode: ${resolvedMode}, Phase: ${projectData.currentPhase}, Has extraction: ${!!context.phaseHandoffs?.extraction}`);
|
||||
|
||||
// Log system prompt length
|
||||
console.log(`[AI Chat] System prompt length: ${enhancedSystemPrompt.length} chars (~${Math.ceil(enhancedSystemPrompt.length / 4)} tokens)`);
|
||||
|
||||
// Log each message length
|
||||
messages.forEach((msg, i) => {
|
||||
console.log(`[AI Chat] Message ${i + 1} (${msg.role}): ${msg.content.length} chars (~${Math.ceil(msg.content.length / 4)} tokens)`);
|
||||
});
|
||||
|
||||
const totalInputChars = enhancedSystemPrompt.length + messages.reduce((sum, msg) => sum + msg.content.length, 0);
|
||||
console.log(`[AI Chat] Total input: ${totalInputChars} chars (~${Math.ceil(totalInputChars / 4)} tokens)`);
|
||||
|
||||
// Log system prompt preview (first 500 chars)
|
||||
console.log(`[AI Chat] System prompt preview: ${enhancedSystemPrompt.substring(0, 500)}...`);
|
||||
|
||||
// Log last user message
|
||||
const lastUserMsg = messages[messages.length - 1];
|
||||
console.log(`[AI Chat] User message: ${lastUserMsg.content}`);
|
||||
|
||||
// Safety check: extraction_review_mode requires extraction results
|
||||
if (resolvedMode === 'extraction_review_mode' && !context.phaseHandoffs?.extraction) {
|
||||
console.warn(`[AI Chat] WARNING: extraction_review_mode active but no extraction results found for project ${projectId}`);
|
||||
}
|
||||
console.log(
|
||||
`[AI Chat] Sending ${messages.length} messages to LLM (${conversationHistory.length} from history + 1 new)`,
|
||||
);
|
||||
console.log(
|
||||
`[AI Chat] Mode: ${resolvedMode}, Phase: ${projectData.currentPhase}, Has extraction: ${!!context.phaseHandoffs?.extraction}`,
|
||||
);
|
||||
|
||||
const llm: LlmClient = new GeminiLlmClient();
|
||||
// Log system prompt length
|
||||
console.log(
|
||||
`[AI Chat] System prompt length: ${enhancedSystemPrompt.length} chars (~${Math.ceil(enhancedSystemPrompt.length / 4)} tokens)`,
|
||||
);
|
||||
|
||||
// Configure thinking mode based on task complexity
|
||||
// Simple modes (collector, extraction_review) don't need deep thinking
|
||||
// Complex modes (mvp, vision) benefit from extended reasoning
|
||||
const needsThinking = resolvedMode === 'mvp_mode' || resolvedMode === 'vision_mode';
|
||||
// Log each message length
|
||||
messages.forEach((msg, i) => {
|
||||
console.log(
|
||||
`[AI Chat] Message ${i + 1} (${msg.role}): ${msg.content.length} chars (~${Math.ceil(msg.content.length / 4)} tokens)`,
|
||||
);
|
||||
});
|
||||
|
||||
const reply = await llm.structuredCall<{
|
||||
reply: string;
|
||||
visionAnswers?: {
|
||||
q1?: string;
|
||||
q2?: string;
|
||||
q3?: string;
|
||||
allAnswered?: boolean;
|
||||
};
|
||||
collectorHandoff?: {
|
||||
hasDocuments?: boolean;
|
||||
documentCount?: number;
|
||||
githubConnected?: boolean;
|
||||
githubRepo?: string;
|
||||
extensionLinked?: boolean;
|
||||
extensionDeclined?: boolean;
|
||||
noGithubYet?: boolean;
|
||||
readyForExtraction?: boolean;
|
||||
};
|
||||
extractionReviewHandoff?: {
|
||||
extractionApproved?: boolean;
|
||||
readyForVision?: boolean;
|
||||
};
|
||||
}>({
|
||||
model: 'gemini',
|
||||
systemPrompt: enhancedSystemPrompt,
|
||||
messages: messages, // Full conversation history!
|
||||
schema: ChatReplySchema,
|
||||
temperature: 0.4,
|
||||
thinking_config: needsThinking ? {
|
||||
thinking_level: 'high',
|
||||
include_thoughts: false,
|
||||
} : undefined,
|
||||
});
|
||||
const totalInputChars =
|
||||
enhancedSystemPrompt.length +
|
||||
messages.reduce((sum, msg) => sum + msg.content.length, 0);
|
||||
console.log(
|
||||
`[AI Chat] Total input: ${totalInputChars} chars (~${Math.ceil(totalInputChars / 4)} tokens)`,
|
||||
);
|
||||
|
||||
// Store all vision answers when provided
|
||||
if (reply.visionAnswers) {
|
||||
const updates: any = {};
|
||||
|
||||
if (reply.visionAnswers.q1) {
|
||||
updates['visionAnswers.q1'] = reply.visionAnswers.q1;
|
||||
console.log('[AI Chat] Storing vision answer Q1');
|
||||
// Log system prompt preview (first 500 chars)
|
||||
console.log(
|
||||
`[AI Chat] System prompt preview: ${enhancedSystemPrompt.substring(0, 500)}...`,
|
||||
);
|
||||
|
||||
// Log last user message
|
||||
const lastUserMsg = messages[messages.length - 1];
|
||||
console.log(`[AI Chat] User message: ${lastUserMsg.content}`);
|
||||
|
||||
// Safety check: extraction_review_mode requires extraction results
|
||||
if (
|
||||
resolvedMode === "extraction_review_mode" &&
|
||||
!context.phaseHandoffs?.extraction
|
||||
) {
|
||||
console.warn(
|
||||
`[AI Chat] WARNING: extraction_review_mode active but no extraction results found for project ${projectId}`,
|
||||
);
|
||||
}
|
||||
if (reply.visionAnswers.q2) {
|
||||
updates['visionAnswers.q2'] = reply.visionAnswers.q2;
|
||||
console.log('[AI Chat] Storing vision answer Q2');
|
||||
}
|
||||
if (reply.visionAnswers.q3) {
|
||||
updates['visionAnswers.q3'] = reply.visionAnswers.q3;
|
||||
console.log('[AI Chat] Storing vision answer Q3');
|
||||
}
|
||||
|
||||
// If all answers are complete, trigger MVP generation
|
||||
if (reply.visionAnswers.allAnswered) {
|
||||
updates['visionAnswers.allAnswered'] = true;
|
||||
updates['readyForMVP'] = true;
|
||||
console.log('[AI Chat] ✅ All 3 vision answers complete - ready for MVP generation');
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
updates['visionAnswers.updatedAt'] = new Date().toISOString();
|
||||
|
||||
await query(
|
||||
`UPDATE fs_projects
|
||||
|
||||
const llm: LlmClient = new GeminiLlmClient();
|
||||
|
||||
// Configure thinking mode based on task complexity
|
||||
// Simple modes (collector, extraction_review) don't need deep thinking
|
||||
// Complex modes (mvp, vision) benefit from extended reasoning
|
||||
const needsThinking =
|
||||
resolvedMode === "mvp_mode" || resolvedMode === "vision_mode";
|
||||
|
||||
const reply = await llm.structuredCall<{
|
||||
reply: string;
|
||||
visionAnswers?: {
|
||||
q1?: string;
|
||||
q2?: string;
|
||||
q3?: string;
|
||||
allAnswered?: boolean;
|
||||
};
|
||||
collectorHandoff?: {
|
||||
hasDocuments?: boolean;
|
||||
documentCount?: number;
|
||||
githubConnected?: boolean;
|
||||
githubRepo?: string;
|
||||
extensionLinked?: boolean;
|
||||
extensionDeclined?: boolean;
|
||||
noGithubYet?: boolean;
|
||||
readyForExtraction?: boolean;
|
||||
};
|
||||
extractionReviewHandoff?: {
|
||||
extractionApproved?: boolean;
|
||||
readyForVision?: boolean;
|
||||
};
|
||||
}>({
|
||||
model: "gemini",
|
||||
systemPrompt: enhancedSystemPrompt,
|
||||
messages: messages, // Full conversation history!
|
||||
schema: ChatReplySchema,
|
||||
temperature: 0.4,
|
||||
thinking_config: needsThinking
|
||||
? {
|
||||
thinking_level: "high",
|
||||
include_thoughts: false,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
// Store all vision answers when provided
|
||||
if (reply.visionAnswers) {
|
||||
const updates: any = {};
|
||||
|
||||
if (reply.visionAnswers.q1) {
|
||||
updates["visionAnswers.q1"] = reply.visionAnswers.q1;
|
||||
console.log("[AI Chat] Storing vision answer Q1");
|
||||
}
|
||||
if (reply.visionAnswers.q2) {
|
||||
updates["visionAnswers.q2"] = reply.visionAnswers.q2;
|
||||
console.log("[AI Chat] Storing vision answer Q2");
|
||||
}
|
||||
if (reply.visionAnswers.q3) {
|
||||
updates["visionAnswers.q3"] = reply.visionAnswers.q3;
|
||||
console.log("[AI Chat] Storing vision answer Q3");
|
||||
}
|
||||
|
||||
// If all answers are complete, trigger MVP generation
|
||||
if (reply.visionAnswers.allAnswered) {
|
||||
updates["visionAnswers.allAnswered"] = true;
|
||||
updates["readyForMVP"] = true;
|
||||
console.log(
|
||||
"[AI Chat] ✅ All 3 vision answers complete - ready for MVP generation",
|
||||
);
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
updates["visionAnswers.updatedAt"] = new Date().toISOString();
|
||||
|
||||
await query(
|
||||
`UPDATE fs_projects
|
||||
SET data = data || $1::jsonb
|
||||
WHERE id = $2`,
|
||||
[JSON.stringify({ visionAnswers: updates }), projectId]
|
||||
).catch((error) => {
|
||||
console.error('[ai/chat] Failed to store vision answers', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Best-effort: append this turn to the persisted conversation history
|
||||
appendConversation(projectId, [
|
||||
{ role: 'user', content: message },
|
||||
{ role: 'assistant', content: reply.reply },
|
||||
]).catch((error) => {
|
||||
console.error('[ai/chat] Failed to append conversation history', error);
|
||||
});
|
||||
|
||||
// If in collector mode, always update handoff state based on actual project context
|
||||
// This ensures the checklist updates even if AI doesn't return collectorHandoff
|
||||
if (resolvedMode === 'collector_mode') {
|
||||
// Derive handoff state from actual project context
|
||||
const hasDocuments = (context.knowledgeSummary.bySourceType['imported_document'] ?? 0) > 0;
|
||||
const documentCount = context.knowledgeSummary.bySourceType['imported_document'] ?? 0;
|
||||
const githubConnected = !!context.project.githubRepo;
|
||||
const extensionLinked = context.project.extensionLinked ?? false;
|
||||
|
||||
// Check if AI indicated readiness (from reply if provided, otherwise check reply text)
|
||||
let readyForExtraction = reply.collectorHandoff?.readyForExtraction ?? false;
|
||||
|
||||
// Fallback: If AI says certain phrases, assume user confirmed readiness
|
||||
// IMPORTANT: These phrases must be SPECIFIC to avoid false positives
|
||||
if (!readyForExtraction && reply.reply) {
|
||||
const replyLower = reply.reply.toLowerCase();
|
||||
|
||||
// Check for explicit analysis/digging phrases (not just "perfect!")
|
||||
const analysisKeywords = ['analyze', 'analyzing', 'digging', 'extraction', 'processing'];
|
||||
const hasAnalysisKeyword = analysisKeywords.some(keyword => replyLower.includes(keyword));
|
||||
|
||||
// Only trigger if AI mentions BOTH readiness AND analysis action
|
||||
if (hasAnalysisKeyword) {
|
||||
const confirmPhrases = [
|
||||
'let me analyze what you',
|
||||
'i\'ll start digging into',
|
||||
'i\'m starting the analysis',
|
||||
'running the extraction',
|
||||
'processing what you\'ve shared',
|
||||
];
|
||||
readyForExtraction = confirmPhrases.some(phrase => replyLower.includes(phrase));
|
||||
|
||||
if (readyForExtraction) {
|
||||
console.log(`[AI Chat] Detected readiness from AI reply text: "${reply.reply.substring(0, 100)}"`);
|
||||
}
|
||||
[JSON.stringify({ visionAnswers: updates }), projectId],
|
||||
).catch((error) => {
|
||||
console.error("[ai/chat] Failed to store vision answers", error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const handoff: CollectorPhaseHandoff = {
|
||||
phase: 'collector',
|
||||
readyForNextPhase: readyForExtraction,
|
||||
confidence: readyForExtraction ? 0.9 : 0.5,
|
||||
confirmed: {
|
||||
hasDocuments,
|
||||
documentCount,
|
||||
githubConnected,
|
||||
githubRepo: context.project.githubRepo ?? undefined,
|
||||
extensionLinked,
|
||||
},
|
||||
uncertain: {
|
||||
extensionDeclined: reply.collectorHandoff?.extensionDeclined ?? false,
|
||||
noGithubYet: reply.collectorHandoff?.noGithubYet ?? false,
|
||||
},
|
||||
missing: [],
|
||||
questionsForUser: [],
|
||||
sourceEvidence: [],
|
||||
version: '1.0',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
// Best-effort: append this turn to the persisted conversation history
|
||||
appendConversation(projectId, [
|
||||
{ role: "user", content: message },
|
||||
{ role: "assistant", content: reply.reply },
|
||||
]).catch((error) => {
|
||||
console.error("[ai/chat] Failed to append conversation history", error);
|
||||
});
|
||||
|
||||
// Persist to project phaseData in Postgres
|
||||
await query(
|
||||
`UPDATE fs_projects
|
||||
// If in collector mode, always update handoff state based on actual project context
|
||||
// This ensures the checklist updates even if AI doesn't return collectorHandoff
|
||||
if (resolvedMode === "collector_mode") {
|
||||
// Derive handoff state from actual project context
|
||||
const hasDocuments =
|
||||
(context.knowledgeSummary.bySourceType["imported_document"] ?? 0) > 0;
|
||||
const documentCount =
|
||||
context.knowledgeSummary.bySourceType["imported_document"] ?? 0;
|
||||
const githubConnected = !!context.project.githubRepo;
|
||||
const extensionLinked = context.project.extensionLinked ?? false;
|
||||
|
||||
// Check if AI indicated readiness (from reply if provided, otherwise check reply text)
|
||||
let readyForExtraction =
|
||||
reply.collectorHandoff?.readyForExtraction ?? false;
|
||||
|
||||
// Fallback: If AI says certain phrases, assume user confirmed readiness
|
||||
// IMPORTANT: These phrases must be SPECIFIC to avoid false positives
|
||||
if (!readyForExtraction && reply.reply) {
|
||||
const replyLower = reply.reply.toLowerCase();
|
||||
|
||||
// Check for explicit analysis/digging phrases (not just "perfect!")
|
||||
const analysisKeywords = [
|
||||
"analyze",
|
||||
"analyzing",
|
||||
"digging",
|
||||
"extraction",
|
||||
"processing",
|
||||
];
|
||||
const hasAnalysisKeyword = analysisKeywords.some((keyword) =>
|
||||
replyLower.includes(keyword),
|
||||
);
|
||||
|
||||
// Only trigger if AI mentions BOTH readiness AND analysis action
|
||||
if (hasAnalysisKeyword) {
|
||||
const confirmPhrases = [
|
||||
"let me analyze what you",
|
||||
"i'll start digging into",
|
||||
"i'm starting the analysis",
|
||||
"running the extraction",
|
||||
"processing what you've shared",
|
||||
];
|
||||
readyForExtraction = confirmPhrases.some((phrase) =>
|
||||
replyLower.includes(phrase),
|
||||
);
|
||||
|
||||
if (readyForExtraction) {
|
||||
console.log(
|
||||
`[AI Chat] Detected readiness from AI reply text: "${reply.reply.substring(0, 100)}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handoff: CollectorPhaseHandoff = {
|
||||
phase: "collector",
|
||||
readyForNextPhase: readyForExtraction,
|
||||
confidence: readyForExtraction ? 0.9 : 0.5,
|
||||
confirmed: {
|
||||
hasDocuments,
|
||||
documentCount,
|
||||
githubConnected,
|
||||
githubRepo: context.project.githubRepo ?? undefined,
|
||||
extensionLinked,
|
||||
},
|
||||
uncertain: {
|
||||
extensionDeclined:
|
||||
reply.collectorHandoff?.extensionDeclined ?? false,
|
||||
noGithubYet: reply.collectorHandoff?.noGithubYet ?? false,
|
||||
},
|
||||
missing: [],
|
||||
questionsForUser: [],
|
||||
sourceEvidence: [],
|
||||
version: "1.0",
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Persist to project phaseData in Postgres
|
||||
await query(
|
||||
`UPDATE fs_projects
|
||||
SET data = jsonb_set(
|
||||
data,
|
||||
'{phaseData,phaseHandoffs,collector}',
|
||||
@@ -335,77 +393,93 @@ Use this context to provide specific, grounded responses. The session history sh
|
||||
true
|
||||
)
|
||||
WHERE id = $2`,
|
||||
[JSON.stringify(handoff), projectId]
|
||||
).catch((error) => {
|
||||
console.error('[ai/chat] Failed to persist collector handoff', error);
|
||||
});
|
||||
[JSON.stringify(handoff), projectId],
|
||||
).catch((error) => {
|
||||
console.error("[ai/chat] Failed to persist collector handoff", error);
|
||||
});
|
||||
|
||||
console.log(`[AI Chat] Collector handoff persisted:`, {
|
||||
hasDocuments: handoff.confirmed.hasDocuments,
|
||||
githubConnected: handoff.confirmed.githubConnected,
|
||||
extensionLinked: handoff.confirmed.extensionLinked,
|
||||
readyForExtraction: handoff.readyForNextPhase,
|
||||
});
|
||||
console.log(`[AI Chat] Collector handoff persisted:`, {
|
||||
hasDocuments: handoff.confirmed.hasDocuments,
|
||||
githubConnected: handoff.confirmed.githubConnected,
|
||||
extensionLinked: handoff.confirmed.extensionLinked,
|
||||
readyForExtraction: handoff.readyForNextPhase,
|
||||
});
|
||||
|
||||
// Auto-transition to extraction phase if ready
|
||||
if (handoff.readyForNextPhase) {
|
||||
console.log(`[AI Chat] Collector complete - triggering backend extraction`);
|
||||
|
||||
// Mark collector as complete
|
||||
await query(
|
||||
`UPDATE fs_projects
|
||||
// Auto-transition to extraction phase if ready
|
||||
if (handoff.readyForNextPhase) {
|
||||
console.log(
|
||||
`[AI Chat] Collector complete - triggering backend extraction`,
|
||||
);
|
||||
|
||||
// Mark collector as complete
|
||||
await query(
|
||||
`UPDATE fs_projects
|
||||
SET data = jsonb_set(data, '{phaseData,collectorCompletedAt}', $1::jsonb, true)
|
||||
WHERE id = $2`,
|
||||
[JSON.stringify(new Date().toISOString()), projectId]
|
||||
).catch((error) => {
|
||||
console.error('[ai/chat] Failed to mark collector complete', error);
|
||||
});
|
||||
|
||||
// Trigger backend extraction (async - don't await)
|
||||
import('@/lib/server/backend-extractor').then(({ runBackendExtractionForProject }) => {
|
||||
runBackendExtractionForProject(projectId).catch((error) => {
|
||||
console.error(`[AI Chat] Backend extraction failed for project ${projectId}:`, error);
|
||||
[JSON.stringify(new Date().toISOString()), projectId],
|
||||
).catch((error) => {
|
||||
console.error("[ai/chat] Failed to mark collector complete", error);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Handle extraction review → vision phase transition
|
||||
if (resolvedMode === 'extraction_review_mode') {
|
||||
// Check if AI indicated extraction is approved and ready for vision
|
||||
let readyForVision = reply.extractionReviewHandoff?.readyForVision ?? false;
|
||||
|
||||
// Fallback: Check reply text for approval phrases
|
||||
if (!readyForVision && reply.reply) {
|
||||
const replyLower = reply.reply.toLowerCase();
|
||||
|
||||
// Check for vision transition phrases
|
||||
const visionKeywords = ['vision', 'mvp', 'roadmap', 'plan'];
|
||||
const hasVisionKeyword = visionKeywords.some(keyword => replyLower.includes(keyword));
|
||||
|
||||
if (hasVisionKeyword) {
|
||||
const confirmPhrases = [
|
||||
'ready to move to',
|
||||
'ready for vision',
|
||||
'let\'s move to vision',
|
||||
'moving to vision',
|
||||
'great! let\'s define',
|
||||
'perfect! now let\'s',
|
||||
];
|
||||
readyForVision = confirmPhrases.some(phrase => replyLower.includes(phrase));
|
||||
|
||||
if (readyForVision) {
|
||||
console.log(`[AI Chat] Detected vision readiness from AI reply text: "${reply.reply.substring(0, 100)}"`);
|
||||
}
|
||||
// Trigger backend extraction (async - don't await)
|
||||
import("@/lib/server/backend-extractor").then(
|
||||
({ runBackendExtractionForProject }) => {
|
||||
runBackendExtractionForProject(projectId).catch((error) => {
|
||||
console.error(
|
||||
`[AI Chat] Backend extraction failed for project ${projectId}:`,
|
||||
error,
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (readyForVision) {
|
||||
console.log(`[AI Chat] Extraction review complete - transitioning to vision phase`);
|
||||
|
||||
// Mark extraction review as complete and transition to vision
|
||||
await query(
|
||||
`UPDATE fs_projects
|
||||
|
||||
// Handle extraction review → vision phase transition
|
||||
if (resolvedMode === "extraction_review_mode") {
|
||||
// Check if AI indicated extraction is approved and ready for vision
|
||||
let readyForVision =
|
||||
reply.extractionReviewHandoff?.readyForVision ?? false;
|
||||
|
||||
// Fallback: Check reply text for approval phrases
|
||||
if (!readyForVision && reply.reply) {
|
||||
const replyLower = reply.reply.toLowerCase();
|
||||
|
||||
// Check for vision transition phrases
|
||||
const visionKeywords = ["vision", "mvp", "roadmap", "plan"];
|
||||
const hasVisionKeyword = visionKeywords.some((keyword) =>
|
||||
replyLower.includes(keyword),
|
||||
);
|
||||
|
||||
if (hasVisionKeyword) {
|
||||
const confirmPhrases = [
|
||||
"ready to move to",
|
||||
"ready for vision",
|
||||
"let's move to vision",
|
||||
"moving to vision",
|
||||
"great! let's define",
|
||||
"perfect! now let's",
|
||||
];
|
||||
readyForVision = confirmPhrases.some((phrase) =>
|
||||
replyLower.includes(phrase),
|
||||
);
|
||||
|
||||
if (readyForVision) {
|
||||
console.log(
|
||||
`[AI Chat] Detected vision readiness from AI reply text: "${reply.reply.substring(0, 100)}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (readyForVision) {
|
||||
console.log(
|
||||
`[AI Chat] Extraction review complete - transitioning to vision phase`,
|
||||
);
|
||||
|
||||
// Mark extraction review as complete and transition to vision
|
||||
await query(
|
||||
`UPDATE fs_projects
|
||||
SET data = data
|
||||
|| '{"currentPhase":"vision","phaseStatus":"in_progress"}'::jsonb
|
||||
|| jsonb_build_object('phaseData',
|
||||
@@ -414,86 +488,99 @@ Use this context to provide specific, grounded responses. The session history sh
|
||||
)
|
||||
)
|
||||
WHERE id = $2`,
|
||||
[new Date().toISOString(), projectId]
|
||||
).catch((error) => {
|
||||
console.error('[ai/chat] Failed to transition to vision phase', error);
|
||||
});
|
||||
[new Date().toISOString(), projectId],
|
||||
).catch((error) => {
|
||||
console.error(
|
||||
"[ai/chat] Failed to transition to vision phase",
|
||||
error,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save conversation history to Postgres
|
||||
await appendConversation(projectId, [
|
||||
{ role: 'user', content: message },
|
||||
{ role: 'assistant', content: reply.reply },
|
||||
]).catch((error) => {
|
||||
console.error('[ai/chat] Failed to save conversation history', error);
|
||||
});
|
||||
// Save conversation history to Postgres
|
||||
await appendConversation(projectId, [
|
||||
{ role: "user", content: message },
|
||||
{ role: "assistant", content: reply.reply },
|
||||
]).catch((error) => {
|
||||
console.error("[ai/chat] Failed to save conversation history", error);
|
||||
});
|
||||
|
||||
console.log(`[AI Chat] Conversation history saved (+2 messages)`);
|
||||
console.log(`[AI Chat] Conversation history saved (+2 messages)`);
|
||||
|
||||
// Determine which artifacts were used
|
||||
const artifactsUsed = determineArtifactsUsed(context);
|
||||
// Determine which artifacts were used
|
||||
const artifactsUsed = determineArtifactsUsed(context);
|
||||
|
||||
// Log successful interaction
|
||||
logProjectEvent({
|
||||
projectId,
|
||||
userId: projectData.userId ?? null,
|
||||
eventType: 'chat_interaction',
|
||||
mode: resolvedMode,
|
||||
phase: projectData.currentPhase ?? null,
|
||||
artifactsUsed,
|
||||
usedVectorSearch: context.retrievedChunks.length > 0,
|
||||
vectorChunkCount: context.retrievedChunks.length,
|
||||
promptVersion: '2.0', // Updated with vector search
|
||||
modelUsed: process.env.VERTEX_AI_MODEL || 'gemini-3-pro-preview',
|
||||
success: true,
|
||||
errorMessage: null,
|
||||
metadata: {
|
||||
knowledgeCount: context.knowledgeSummary.totalCount,
|
||||
extractionCount: context.extractionSummary.totalCount,
|
||||
hasGithubRepo: !!context.repositoryAnalysis,
|
||||
},
|
||||
}).catch((err) => console.error('[ai/chat] Failed to log event:', err));
|
||||
|
||||
return NextResponse.json({
|
||||
reply: reply.reply,
|
||||
mode: resolvedMode,
|
||||
projectPhase: projectData.currentPhase ?? null,
|
||||
artifactsUsed,
|
||||
usedVectorSearch: context.retrievedChunks.length > 0,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[ai/chat] Error handling chat request', error);
|
||||
|
||||
// Log error (best-effort) - extract projectId from request body if available
|
||||
const errorProjectId = typeof (error as { projectId?: string })?.projectId === 'string'
|
||||
? (error as { projectId: string }).projectId
|
||||
: null;
|
||||
|
||||
if (errorProjectId) {
|
||||
// Log successful interaction
|
||||
logProjectEvent({
|
||||
projectId: errorProjectId,
|
||||
userId: null,
|
||||
eventType: 'error',
|
||||
mode: null,
|
||||
phase: null,
|
||||
artifactsUsed: [],
|
||||
usedVectorSearch: false,
|
||||
promptVersion: '2.0',
|
||||
modelUsed: process.env.VERTEX_AI_MODEL || 'gemini-3-pro-preview',
|
||||
success: false,
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
}).catch((err) => console.error('[ai/chat] Failed to log error:', err));
|
||||
projectId,
|
||||
userId: projectData.userId ?? null,
|
||||
eventType: "chat_interaction",
|
||||
mode: resolvedMode,
|
||||
phase: projectData.currentPhase ?? null,
|
||||
artifactsUsed,
|
||||
usedVectorSearch: context.retrievedChunks.length > 0,
|
||||
vectorChunkCount: context.retrievedChunks.length,
|
||||
promptVersion: "2.0", // Updated with vector search
|
||||
modelUsed: process.env.VERTEX_AI_MODEL || "gemini-3-pro-preview",
|
||||
success: true,
|
||||
errorMessage: null,
|
||||
metadata: {
|
||||
knowledgeCount: context.knowledgeSummary.totalCount,
|
||||
extractionCount: context.extractionSummary.totalCount,
|
||||
hasGithubRepo: !!context.repositoryAnalysis,
|
||||
},
|
||||
}).catch((err) => console.error("[ai/chat] Failed to log event:", err));
|
||||
|
||||
return NextResponse.json({
|
||||
reply: reply.reply,
|
||||
mode: resolvedMode,
|
||||
projectPhase: projectData.currentPhase ?? null,
|
||||
artifactsUsed,
|
||||
usedVectorSearch: context.retrievedChunks.length > 0,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[ai/chat] Error handling chat request", error);
|
||||
|
||||
// Log error (best-effort) - extract projectId from request body if available
|
||||
const errorProjectId =
|
||||
typeof (error as { projectId?: string })?.projectId === "string"
|
||||
? (error as { projectId: string }).projectId
|
||||
: null;
|
||||
|
||||
if (errorProjectId) {
|
||||
logProjectEvent({
|
||||
projectId: errorProjectId,
|
||||
userId: null,
|
||||
eventType: "error",
|
||||
mode: null,
|
||||
phase: null,
|
||||
artifactsUsed: [],
|
||||
usedVectorSearch: false,
|
||||
promptVersion: "2.0",
|
||||
modelUsed: process.env.VERTEX_AI_MODEL || "gemini-3-pro-preview",
|
||||
success: false,
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
}).catch((err) =>
|
||||
log.error("ai/chat log failed", {
|
||||
route: "api.ai.chat",
|
||||
err: err instanceof Error ? err.message : String(err),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
log.error("ai/chat error", {
|
||||
route: "api.ai.chat",
|
||||
err: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Failed to process chat message",
|
||||
details: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to process chat message',
|
||||
details: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
},
|
||||
{ source: "body", paramName: "projectId" },
|
||||
);
|
||||
|
||||
@@ -1,37 +1,38 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getAdminDb } from '@/lib/firebase/admin';
|
||||
/**
|
||||
* POST /api/ai/conversation/reset
|
||||
* Body OR query: { projectId }
|
||||
*
|
||||
* Deletes the conversation history for a project the caller owns.
|
||||
*
|
||||
* Closes S-03. Also migrated off the legacy Firebase admin path onto Postgres
|
||||
* to match `/api/ai/conversation`.
|
||||
*/
|
||||
import { NextResponse } from "next/server";
|
||||
import { query } from "@/lib/db-postgres";
|
||||
import { withTenantProject } from "@/lib/server/api-handler";
|
||||
import { log } from "@/lib/server/logger";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const body = await request
|
||||
.json()
|
||||
.catch(() => ({ projectId: url.searchParams.get('projectId') }));
|
||||
|
||||
const projectId = (body?.projectId ?? url.searchParams.get('projectId') ?? '').trim();
|
||||
|
||||
if (!projectId) {
|
||||
export const POST = withTenantProject(
|
||||
async (_request, _ctx, { project }) => {
|
||||
try {
|
||||
await query(`DELETE FROM chat_conversations WHERE project_id = $1`, [
|
||||
project.id,
|
||||
]);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (err) {
|
||||
log.error("ai/conversation/reset failed", {
|
||||
route: "api.ai.conversation.reset",
|
||||
projectId: project.id,
|
||||
err: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: 'projectId is required' },
|
||||
{ status: 400 },
|
||||
{
|
||||
error: "Failed to reset conversation",
|
||||
details: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
const adminDb = getAdminDb();
|
||||
const docRef = adminDb.collection('chat_conversations').doc(projectId);
|
||||
await docRef.delete();
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('[ai/conversation/reset] Failed to reset conversation', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to reset conversation',
|
||||
details: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
},
|
||||
{ source: "body", paramName: "projectId" },
|
||||
);
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { query } from '@/lib/db-postgres';
|
||||
/**
|
||||
* GET /api/ai/conversation?projectId=… — fetch saved conversation
|
||||
* DELETE /api/ai/conversation?projectId=… — wipe saved conversation
|
||||
*
|
||||
* Closes S-02: was completely unauthenticated and accepted any projectId.
|
||||
*/
|
||||
import { NextResponse } from "next/server";
|
||||
import { query } from "@/lib/db-postgres";
|
||||
import { withTenantProject } from "@/lib/server/api-handler";
|
||||
import { log } from "@/lib/server/logger";
|
||||
|
||||
const ENSURE_TABLE = `
|
||||
CREATE TABLE IF NOT EXISTS chat_conversations (
|
||||
@@ -9,7 +17,7 @@ const ENSURE_TABLE = `
|
||||
)
|
||||
`;
|
||||
|
||||
type StoredMessageRole = 'user' | 'assistant';
|
||||
type StoredMessageRole = "user" | "assistant";
|
||||
|
||||
type ConversationMessage = {
|
||||
role: StoredMessageRole;
|
||||
@@ -21,49 +29,48 @@ type ConversationResponse = {
|
||||
messages: ConversationMessage[];
|
||||
};
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const projectId = (url.searchParams.get('projectId') ?? '').trim();
|
||||
|
||||
if (!projectId) {
|
||||
return NextResponse.json({ error: 'projectId is required' }, { status: 400 });
|
||||
export const GET = withTenantProject(
|
||||
async (request, _ctx, { project }) => {
|
||||
try {
|
||||
await query(ENSURE_TABLE);
|
||||
const rows = await query<{ messages: ConversationMessage[] }>(
|
||||
`SELECT messages FROM chat_conversations WHERE project_id = $1`,
|
||||
[project.id],
|
||||
);
|
||||
const messages: ConversationMessage[] = rows[0]?.messages ?? [];
|
||||
const response: ConversationResponse = { messages };
|
||||
return NextResponse.json(response);
|
||||
} catch (err) {
|
||||
log.error("ai/conversation GET failed", {
|
||||
route: "api.ai.conversation",
|
||||
projectId: project.id,
|
||||
err: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
return NextResponse.json({ messages: [] });
|
||||
}
|
||||
},
|
||||
{ source: "search", paramName: "projectId" },
|
||||
);
|
||||
|
||||
await query(ENSURE_TABLE);
|
||||
|
||||
const rows = await query<{ messages: ConversationMessage[] }>(
|
||||
`SELECT messages FROM chat_conversations WHERE project_id = $1`,
|
||||
[projectId]
|
||||
);
|
||||
|
||||
const messages: ConversationMessage[] = rows[0]?.messages ?? [];
|
||||
const response: ConversationResponse = { messages };
|
||||
return NextResponse.json(response);
|
||||
} catch (error) {
|
||||
console.error('[GET /api/ai/conversation] Error:', error);
|
||||
return NextResponse.json({ messages: [] });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const projectId = (url.searchParams.get('projectId') ?? '').trim();
|
||||
|
||||
if (!projectId) {
|
||||
return NextResponse.json({ error: 'projectId is required' }, { status: 400 });
|
||||
export const DELETE = withTenantProject(
|
||||
async (_request, _ctx, { project }) => {
|
||||
try {
|
||||
await query(ENSURE_TABLE);
|
||||
await query(`DELETE FROM chat_conversations WHERE project_id = $1`, [
|
||||
project.id,
|
||||
]);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (err) {
|
||||
log.error("ai/conversation DELETE failed", {
|
||||
route: "api.ai.conversation",
|
||||
projectId: project.id,
|
||||
err: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to reset conversation" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
await query(ENSURE_TABLE);
|
||||
await query(
|
||||
`DELETE FROM chat_conversations WHERE project_id = $1`,
|
||||
[projectId]
|
||||
);
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
console.error('[DELETE /api/ai/conversation] Error:', error);
|
||||
return NextResponse.json({ error: 'Failed to reset conversation' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
},
|
||||
{ source: "search", paramName: "projectId" },
|
||||
);
|
||||
|
||||
@@ -33,11 +33,10 @@ import { buildDesignKitPromptSection } from "@/lib/design-kits/for-ai";
|
||||
import { buildCodebaseSummary } from "@/lib/ai/project-context/codebase-summary";
|
||||
import type { ChatMessage, ToolCall } from "@/lib/ai/gemini-chat";
|
||||
|
||||
// Path B chains routinely fire 7-10 tool calls in one user turn. 18
|
||||
// gives enough headroom for complex workflows (scaffold → install →
|
||||
// configure → start) while still capping runaway loops. When the cap
|
||||
// IS hit, we emit a recovery summary instead of silent tool pills.
|
||||
const MAX_TOOL_ROUNDS = 15;
|
||||
// C-01: Lowered from 15 → 8. Real workflows (scaffold → install →
|
||||
// configure → start) rarely need more than 8 rounds when done correctly.
|
||||
// If the cap IS hit the model gets a recovery summary, not silence.
|
||||
const MAX_TOOL_ROUNDS = 8;
|
||||
|
||||
let chatTablesReady = false;
|
||||
async function ensureChatTables() {
|
||||
@@ -151,6 +150,20 @@ After every assistant turn, the harness automatically runs \`git add -A && git c
|
||||
|
||||
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.
|
||||
|
||||
## Mode: respond first, act second
|
||||
Before calling any tool, decide: is the user asking a question, or telling you to do something?
|
||||
|
||||
**CONVERSATIONAL inputs — respond with text only, no tools:**
|
||||
- One-word or greeting messages: "test", "hi", "ok", "thanks"
|
||||
- Questions ending in "?": "are you able to…?", "what does X mean?", "how would you…?"
|
||||
- Status checks: "is it deployed?", "what's running?" (one read-only tool MAX, then respond)
|
||||
|
||||
**ACTION inputs — tools allowed:**
|
||||
- Imperatives: "deploy it", "build me X", "fix the navbar", "ship"
|
||||
- Specific tasks with clear deliverables: "add Stripe to the pricing page"
|
||||
|
||||
If you are unsure which mode the user is in, **default to CONVERSATIONAL** and ask one clarifying sentence before acting. "Want me to actually deploy this to prod now, or were you just checking?" is always cheaper than a silent 16-tool spiral.
|
||||
|
||||
## Identity
|
||||
You are a high-agency product engineer. You own the outcome. Continue until the user's goal is actually resolved unless you're blocked on missing info, proceeding would be unsafe, or the user changes direction. You are not answering questions; you are building with the user. Translate engineering complexity into product momentum.
|
||||
|
||||
@@ -530,6 +543,9 @@ export async function POST(request: Request) {
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
let streamClosed = false;
|
||||
// C-06: Per-turn correlation ID so prod logs are greppable.
|
||||
const turnId = crypto.randomUUID();
|
||||
|
||||
function emit(chunk: object) {
|
||||
if (streamClosed) return;
|
||||
try {
|
||||
@@ -544,11 +560,21 @@ export async function POST(request: Request) {
|
||||
function safeClose() {
|
||||
if (streamClosed) return;
|
||||
streamClosed = true;
|
||||
clearInterval(heartbeat);
|
||||
try {
|
||||
controller.close();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// C-04: SSE heartbeat every 25s keeps Cloudflare / proxies from
|
||||
// dropping the connection during long Gemini thinking phases.
|
||||
const heartbeat = setInterval(() => {
|
||||
emit({ type: "ping", turnId });
|
||||
}, 25_000);
|
||||
|
||||
// Emit turnId immediately so the client can log/correlate.
|
||||
emit({ type: "turn_start", turnId });
|
||||
|
||||
let messages = [...history];
|
||||
let round = 0;
|
||||
let assistantText = "";
|
||||
@@ -616,6 +642,29 @@ export async function POST(request: Request) {
|
||||
return `${tc.name}:${argSig}`;
|
||||
}
|
||||
|
||||
// ── Server-side conversational guard (C-03 enforcement) ───────────
|
||||
// If the user's message looks conversational we withhold tools for
|
||||
// round 1. The model MUST respond in text first. If its reply then
|
||||
// expresses clear intent to act, tools become available from round 2.
|
||||
// This is more reliable than a prompt rule against a "do-er" model.
|
||||
function isConversational(msg: string): boolean {
|
||||
const m = msg.trim();
|
||||
if (m.length < 3) return true; // single word / emoji
|
||||
if (m.endsWith("?")) return true; // explicit question
|
||||
// Short phrases that are status checks or greetings
|
||||
const conversationalPatterns = [
|
||||
/^(hi|hey|hello|sup|test|ok|okay|thanks|ty|thx|lgtm|nice|cool|great|wow)\b/i,
|
||||
/^(what|how|why|when|where|who|which|is |are |can |could |would |do |does |did |has |have |had |was |were )\S+.{0,60}$/i,
|
||||
/^(are you able to|can you|could you|would you|is it possible)/i,
|
||||
/^(what'?s |whats )(running|live|deployed|happening|wrong|broken|up)/i,
|
||||
/^(is it|is that|is this|is there|is the)/i,
|
||||
];
|
||||
return conversationalPatterns.some((re) => re.test(m));
|
||||
}
|
||||
const firstMessageIsConversational =
|
||||
mcp_token !== undefined && // tools available
|
||||
isConversational(message.trim());
|
||||
|
||||
try {
|
||||
// Tool-calling loop: use non-streaming so thought_signature is
|
||||
// always present in the complete response (required by thinking models).
|
||||
@@ -623,7 +672,12 @@ export async function POST(request: Request) {
|
||||
if (aborted) break;
|
||||
round++;
|
||||
|
||||
const toolDefs = mcp_token ? VIBN_TOOL_DEFINITIONS : [];
|
||||
// On round 1, withhold tools if the message looks conversational.
|
||||
// The model must answer in text first; tools unlock from round 2.
|
||||
const toolDefs =
|
||||
mcp_token && !(round === 1 && firstMessageIsConversational)
|
||||
? VIBN_TOOL_DEFINITIONS
|
||||
: [];
|
||||
|
||||
// Every 2 silent rounds or 5 tool calls, nudge the model to surface a one-liner
|
||||
// status before continuing. This is the user's only signal of
|
||||
@@ -637,6 +691,16 @@ export async function POST(request: Request) {
|
||||
"on and why. The user is staring at silent tool pills."
|
||||
: "";
|
||||
|
||||
// When withholding tools on round 1 (conversational guard), add a
|
||||
// mandatory instruction so the model doesn't return empty text.
|
||||
if (round === 1 && firstMessageIsConversational) {
|
||||
extraSystem +=
|
||||
"\n\n[MANDATORY] The user's message is a question or conversational input, " +
|
||||
"not a command. You have NO tools available on this turn. " +
|
||||
"Respond with PLAIN TEXT ONLY in 1-3 sentences answering their question. " +
|
||||
"If they want you to take action, confirm intent and wait for a clear directive.";
|
||||
}
|
||||
|
||||
if (MAX_TOOL_ROUNDS - round <= 3) {
|
||||
extraSystem += `\n\n[WARNING] You only have ${MAX_TOOL_ROUNDS - round} tool calls left before you are forcefully terminated. Stop exploring, make your final edits, and write your final response to the user NOW.`;
|
||||
}
|
||||
@@ -713,14 +777,17 @@ export async function POST(request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Stage 1: Warning at 3 repeats
|
||||
if (maxRepeats === 3) {
|
||||
extraSystem += `\n\n[WARNING] You have called ${repeatedCmd} 3 times recently. Please wrap up this approach or try a completely different tool.`;
|
||||
// C-02: Tightened. Hard-break at 3 identical fingerprints (was 5).
|
||||
if (maxRepeats === 2) {
|
||||
extraSystem += `\n\n[WARNING] You have called ${repeatedCmd} twice in a row. Try a different approach or surface what's blocking you to the user.`;
|
||||
}
|
||||
if (maxRepeats >= 3) {
|
||||
loopBreakReason = `Repeated ${repeatedCmd} ${maxRepeats}× in last 10 calls`;
|
||||
}
|
||||
|
||||
// Stage 2: Hard Break at 5 repeats
|
||||
if (maxRepeats >= 5) {
|
||||
loopBreakReason = `Repeated ${repeatedCmd} ${maxRepeats}× in last 10 calls`;
|
||||
// C-02: Also hard-break after 6 consecutive tool calls with no text.
|
||||
if (!loopBreakReason && toolCallsSinceText >= 6) {
|
||||
loopBreakReason = `${toolCallsSinceText} consecutive tool calls with no assistant text`;
|
||||
}
|
||||
|
||||
// Execute tool calls and add results. OpenAI-compatible APIs
|
||||
@@ -730,15 +797,32 @@ export async function POST(request: Request) {
|
||||
const recoveryLines: string[] = [];
|
||||
for (const tc of resp.toolCalls) {
|
||||
if (aborted) break;
|
||||
const result = mcp_token
|
||||
? await executeMcpTool(
|
||||
// C-05: Per-tool timeout. A hung MCP call would freeze the whole turn.
|
||||
const TOOL_TIMEOUT_MS = 45_000;
|
||||
const toolTimeout = new Promise<string>((resolve) =>
|
||||
setTimeout(
|
||||
() =>
|
||||
resolve(
|
||||
JSON.stringify({
|
||||
ok: false,
|
||||
error: `Tool ${tc.name} timed out after ${TOOL_TIMEOUT_MS / 1000}s`,
|
||||
}),
|
||||
),
|
||||
TOOL_TIMEOUT_MS,
|
||||
),
|
||||
);
|
||||
const toolExec = mcp_token
|
||||
? executeMcpTool(
|
||||
tc.name,
|
||||
tc.args,
|
||||
mcp_token,
|
||||
baseUrl,
|
||||
activeProject?.id,
|
||||
)
|
||||
: JSON.stringify({ error: "No MCP token — read-only mode." });
|
||||
: Promise.resolve(
|
||||
JSON.stringify({ error: "No MCP token — read-only mode." }),
|
||||
);
|
||||
const result = await Promise.race([toolExec, toolTimeout]);
|
||||
|
||||
emit({
|
||||
type: "tool_result",
|
||||
@@ -756,6 +840,25 @@ export async function POST(request: Request) {
|
||||
|
||||
const recovery = detectKnownError(result);
|
||||
if (recovery) recoveryLines.push(formatRecoveryMessage(recovery));
|
||||
|
||||
// B-05: SSE plan event — stream task state changes to the client
|
||||
// so the Plan tab updates in real-time during a chat turn.
|
||||
if (tc.name === "plan_task_add" || tc.name === "plan_task_edit") {
|
||||
try {
|
||||
const parsed = JSON.parse(result);
|
||||
const task = parsed?.result?.task ?? parsed?.task;
|
||||
if (task?.id) {
|
||||
emit({
|
||||
type: "plan",
|
||||
taskId: task.id,
|
||||
text: task.text ?? task.title ?? "",
|
||||
status: task.status ?? "open",
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// non-JSON result — skip
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const line of recoveryLines) {
|
||||
messages.push({ role: "user", content: line });
|
||||
@@ -787,12 +890,15 @@ export async function POST(request: Request) {
|
||||
// 20 toolCalls, user had to re-prompt to get any answer.
|
||||
const lastTurnHadTools =
|
||||
messages.length > 0 && messages[messages.length - 1].role === "tool";
|
||||
// C-07: Also recover when the model has been running tools without
|
||||
// any text for >=4 rounds — the user is staring at silence.
|
||||
const needsRecovery =
|
||||
!aborted &&
|
||||
lastTurnHadTools &&
|
||||
(round >= MAX_TOOL_ROUNDS ||
|
||||
!!loopBreakReason ||
|
||||
assistantText.trim().length === 0 ||
|
||||
roundsSinceText >= 4 ||
|
||||
lastToolResultsHadFailure(messages));
|
||||
|
||||
if (needsRecovery) {
|
||||
@@ -1072,9 +1178,9 @@ export async function POST(request: Request) {
|
||||
}
|
||||
},
|
||||
cancel() {
|
||||
// Browser disconnected (tab closed, navigated away). Nothing to
|
||||
// do — the abort handler above already flipped the flag and the
|
||||
// loop will bail at the next checkpoint.
|
||||
// Browser disconnected (tab closed, navigated away). Clear the
|
||||
// heartbeat so we stop writing to a closed stream.
|
||||
// The abort handler above already flipped the flag so the loop bails.
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
25
vibn-frontend/app/api/chat/route.ts.sed
Normal file
25
vibn-frontend/app/api/chat/route.ts.sed
Normal file
@@ -0,0 +1,25 @@
|
||||
// Two-stage Loop detection (Fix 4 update)
|
||||
// Sliding window of 10
|
||||
const window = toolFingerprints.slice(-10);
|
||||
const counts = new Map<string, number>();
|
||||
for (const fp of window) counts.set(fp, (counts.get(fp) ?? 0) + 1);
|
||||
|
||||
// Find highest repeating tool call
|
||||
let maxRepeats = 0;
|
||||
let repeatedCmd = "";
|
||||
for (const [fp, n] of counts.entries()) {
|
||||
if (n > maxRepeats) {
|
||||
maxRepeats = n;
|
||||
repeatedCmd = fp.split("|")[0];
|
||||
}
|
||||
}
|
||||
|
||||
// Stage 1: Warning at 3 repeats
|
||||
if (maxRepeats === 3) {
|
||||
extraSystem += `\n\n[WARNING] You have called ${repeatedCmd} 3 times recently. Please wrap up this approach or try a completely different tool.`;
|
||||
}
|
||||
|
||||
// Stage 2: Hard Break at 5 repeats
|
||||
if (maxRepeats >= 5) {
|
||||
loopBreakReason = `Repeated ${repeatedCmd} ${maxRepeats}× in last 10 calls`;
|
||||
}
|
||||
@@ -1,52 +1,99 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
/**
|
||||
* POST /api/context/summarize
|
||||
* Body: { content: string, title?: string }
|
||||
*
|
||||
* Generates a short summary via Gemini. Closes S-04: now requires a
|
||||
* signed-in user (rate-limit per user, not per-IP) so we don't burn Gemini
|
||||
* quota on anonymous traffic.
|
||||
*/
|
||||
import { NextResponse } from "next/server";
|
||||
import { withAuth, withRateLimit } from "@/lib/server/api-handler";
|
||||
import { log } from "@/lib/server/logger";
|
||||
|
||||
const MODEL = process.env.GEMINI_MODEL || 'gemini-2.0-flash-exp';
|
||||
const API_KEY = process.env.GOOGLE_API_KEY || '';
|
||||
const MODEL = process.env.GEMINI_MODEL || "gemini-3.1-pro-preview";
|
||||
const API_KEY = process.env.GOOGLE_API_KEY || "";
|
||||
const GEMINI_URL = `https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent`;
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { content, title } = await request.json();
|
||||
export const POST = withRateLimit(
|
||||
withAuth(async (request, _ctx, { user }) => {
|
||||
try {
|
||||
const { content, title } = (await request.json()) as {
|
||||
content?: string;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
if (!content) {
|
||||
return NextResponse.json({ error: "Content is required" }, { status: 400 });
|
||||
}
|
||||
if (!content || typeof content !== "string") {
|
||||
return NextResponse.json(
|
||||
{ error: "content is required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const maxContentLength = 30000;
|
||||
const truncatedContent = content.length > maxContentLength
|
||||
? content.substring(0, maxContentLength) + "..."
|
||||
: content;
|
||||
const maxContentLength = 30000;
|
||||
const truncatedContent =
|
||||
content.length > maxContentLength
|
||||
? content.substring(0, maxContentLength) + "..."
|
||||
: content;
|
||||
|
||||
const prompt = `Read this document titled "${title}" and provide a concise 1-2 sentence summary that captures the main topic and key points. Be specific and actionable.
|
||||
const prompt = `Read this document titled "${title ?? "(untitled)"}" and provide a concise 1-2 sentence summary that captures the main topic and key points. Be specific and actionable.
|
||||
|
||||
Document content:
|
||||
${truncatedContent}
|
||||
|
||||
Summary:`;
|
||||
|
||||
const response = await fetch(`${GEMINI_URL}?key=${API_KEY}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
contents: [{ role: 'user', parts: [{ text: prompt }] }],
|
||||
generationConfig: { temperature: 0.3 },
|
||||
}),
|
||||
});
|
||||
const response = await fetch(`${GEMINI_URL}?key=${API_KEY}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
contents: [{ role: "user", parts: [{ text: prompt }] }],
|
||||
generationConfig: { temperature: 0.3 },
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Gemini API error (${response.status}): ${await response.text()}`);
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
log.warn("context/summarize gemini error", {
|
||||
route: "api.context.summarize",
|
||||
user: user.email,
|
||||
status: response.status,
|
||||
body: text.slice(0, 500),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Gemini API error (${response.status})`,
|
||||
details: text.slice(0, 500),
|
||||
},
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
const summary =
|
||||
result.candidates?.[0]?.content?.parts?.[0]?.text?.trim() ||
|
||||
"Summary unavailable";
|
||||
return NextResponse.json({ summary });
|
||||
} catch (err) {
|
||||
log.error("context/summarize failed", {
|
||||
route: "api.context.summarize",
|
||||
err: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Failed to generate summary",
|
||||
details: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
const summary = result.candidates?.[0]?.content?.parts?.[0]?.text?.trim() || 'Summary unavailable';
|
||||
|
||||
return NextResponse.json({ summary });
|
||||
} catch (error) {
|
||||
console.error("Error generating summary:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to generate summary", details: error instanceof Error ? error.message : String(error) },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
}),
|
||||
{
|
||||
// 20 summaries / min / user — much higher than chat because they're cheap.
|
||||
limit: 20,
|
||||
windowMs: 60_000,
|
||||
keyFn: (_req, extra) => {
|
||||
const userEmail = (extra as { user?: { email?: string } })?.user?.email;
|
||||
return `context-summarize:${userEmail ?? "anon"}`;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,229 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { adminAuth, adminDb } from '@/lib/firebase/admin';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Verify authentication using API key
|
||||
const authHeader = request.headers.get('Authorization');
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized - Missing API key' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const apiKey = authHeader.substring(7);
|
||||
|
||||
// Look up user by API key
|
||||
const apiKeysSnapshot = await adminDb
|
||||
.collection('apiKeys')
|
||||
.where('key', '==', apiKey)
|
||||
.where('isActive', '==', true)
|
||||
.limit(1)
|
||||
.get();
|
||||
|
||||
if (apiKeysSnapshot.empty) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid API key' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const apiKeyDoc = apiKeysSnapshot.docs[0];
|
||||
const apiKeyData = apiKeyDoc.data();
|
||||
const userId = apiKeyData.userId;
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'API key not associated with user' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Parse request body
|
||||
const body = await request.json();
|
||||
const {
|
||||
projectId,
|
||||
workspacePath,
|
||||
githubUrl,
|
||||
conversations
|
||||
} = body;
|
||||
|
||||
if (!projectId || !conversations) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required fields: projectId, conversations' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Verify user has access to the project
|
||||
const projectRef = adminDb.collection('projects').doc(projectId);
|
||||
const projectDoc = await projectRef.get();
|
||||
|
||||
if (!projectDoc.exists) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Project not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const projectData = projectDoc.data();
|
||||
if (projectData?.userId !== userId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Access denied to this project' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Process and store conversations
|
||||
const { composers, workspaceFiles, totalGenerations } = conversations;
|
||||
let conversationCount = 0;
|
||||
let totalMessagesWritten = 0;
|
||||
|
||||
// Determine filtering keywords based on project context
|
||||
// TODO: Make this configurable per project
|
||||
const projectKeywords = ['vibn', 'project', 'extension', 'collector', 'cursor-monitor'];
|
||||
const excludeKeywords = ['nhl', 'hockey', 'market', 'transaction'];
|
||||
|
||||
// Store each composer (chat session) as a separate document
|
||||
for (const composer of composers || []) {
|
||||
if (composer.type !== 'head') continue; // Only process head composers
|
||||
|
||||
const conversationId = `cursor-${composer.composerId}`;
|
||||
const conversationRef = adminDb
|
||||
.collection('projects')
|
||||
.doc(projectId)
|
||||
.collection('cursorConversations')
|
||||
.doc(conversationId);
|
||||
|
||||
const name = composer.name || 'Untitled Conversation';
|
||||
const nameLower = name.toLowerCase();
|
||||
|
||||
// Simple relevance scoring
|
||||
let relevanceScore = 0;
|
||||
|
||||
// Check for project keywords in name
|
||||
for (const keyword of projectKeywords) {
|
||||
if (nameLower.includes(keyword)) {
|
||||
relevanceScore += 2;
|
||||
}
|
||||
}
|
||||
|
||||
// Penalize for exclude keywords
|
||||
for (const keyword of excludeKeywords) {
|
||||
if (nameLower.includes(keyword)) {
|
||||
relevanceScore -= 3;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if name mentions files from this workspace
|
||||
if (workspaceFiles && Array.isArray(workspaceFiles)) {
|
||||
for (const file of workspaceFiles) {
|
||||
if (nameLower.includes(file.toLowerCase())) {
|
||||
relevanceScore += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Count messages
|
||||
let messageCount = 0;
|
||||
if (composer.bubbles && Array.isArray(composer.bubbles)) {
|
||||
messageCount = composer.bubbles.length;
|
||||
}
|
||||
|
||||
const conversationData = {
|
||||
userId,
|
||||
projectId,
|
||||
conversationId,
|
||||
composerId: composer.composerId,
|
||||
name,
|
||||
createdAt: new Date(composer.createdAt).toISOString(),
|
||||
lastUpdatedAt: new Date(composer.lastUpdatedAt).toISOString(),
|
||||
unifiedMode: composer.unifiedMode || false,
|
||||
forceMode: composer.forceMode || false,
|
||||
workspacePath,
|
||||
githubUrl: githubUrl || null,
|
||||
importedAt: new Date().toISOString(),
|
||||
relevanceScore, // For filtering
|
||||
messageCount,
|
||||
metadata: {
|
||||
source: 'cursor-monitor-extension',
|
||||
composerType: composer.type,
|
||||
}
|
||||
};
|
||||
|
||||
// Write conversation document first
|
||||
await conversationRef.set(conversationData);
|
||||
|
||||
// Store messages in chunks to avoid Firestore batch limit (500 operations)
|
||||
if (composer.bubbles && Array.isArray(composer.bubbles)) {
|
||||
const BATCH_SIZE = 400; // Leave room for overhead
|
||||
|
||||
for (let i = 0; i < composer.bubbles.length; i += BATCH_SIZE) {
|
||||
const batch = adminDb.batch();
|
||||
const chunk = composer.bubbles.slice(i, i + BATCH_SIZE);
|
||||
|
||||
for (const bubble of chunk) {
|
||||
const messageRef = conversationRef
|
||||
.collection('messages')
|
||||
.doc(bubble.bubbleId);
|
||||
|
||||
batch.set(messageRef, {
|
||||
bubbleId: bubble.bubbleId,
|
||||
type: bubble.type, // 1 = user, 2 = AI
|
||||
role: bubble.type === 1 ? 'user' : bubble.type === 2 ? 'assistant' : 'unknown',
|
||||
text: bubble.text || '',
|
||||
createdAt: bubble.createdAt,
|
||||
requestId: bubble.requestId,
|
||||
attachedFiles: bubble.attachedFiles || []
|
||||
});
|
||||
}
|
||||
|
||||
await batch.commit();
|
||||
totalMessagesWritten += chunk.length;
|
||||
console.log(`✅ Wrote ${chunk.length} messages (${i + chunk.length}/${composer.bubbles.length}) for ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
conversationCount++;
|
||||
}
|
||||
|
||||
// Store workspace metadata for reference
|
||||
const workspaceMetaRef = adminDb
|
||||
.collection('projects')
|
||||
.doc(projectId)
|
||||
.collection('cursorData')
|
||||
.doc('workspace-meta');
|
||||
|
||||
await workspaceMetaRef.set({
|
||||
workspacePath,
|
||||
githubUrl,
|
||||
workspaceFiles: workspaceFiles || [],
|
||||
totalGenerations: totalGenerations || 0,
|
||||
importedAt: new Date().toISOString(),
|
||||
lastBatchImportedAt: new Date().toISOString(),
|
||||
}, { merge: true });
|
||||
|
||||
console.log(`✅ Imported ${conversationCount} conversations to project ${projectId}`);
|
||||
|
||||
const workspaceFilesCount = conversations.workspaceFiles?.length || workspaceFiles?.length || 0;
|
||||
const generationsCount = conversations.totalGenerations || totalGenerations || 0;
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
conversationCount,
|
||||
totalMessages: totalMessagesWritten,
|
||||
workspaceFilesCount,
|
||||
totalGenerations: generationsCount,
|
||||
message: `Successfully imported ${conversationCount} conversations with ${totalMessagesWritten} messages`
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error importing Cursor conversations:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to import conversations', details: error instanceof Error ? error.message : String(error) },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { adminDb } from '@/lib/firebase/admin';
|
||||
|
||||
// TEMPORARY: For debugging/testing only - no auth required
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const projectId = request.nextUrl.searchParams.get('projectId');
|
||||
|
||||
if (!projectId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing projectId' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Delete all cursor conversations for this project
|
||||
const conversationsSnapshot = await adminDb
|
||||
.collection('projects')
|
||||
.doc(projectId)
|
||||
.collection('cursorConversations')
|
||||
.get();
|
||||
|
||||
const batch = adminDb.batch();
|
||||
|
||||
conversationsSnapshot.docs.forEach((doc: FirebaseFirestore.QueryDocumentSnapshot) => {
|
||||
batch.delete(doc.ref);
|
||||
});
|
||||
|
||||
// Also delete the messages data document
|
||||
const messagesRef = adminDb
|
||||
.collection('projects')
|
||||
.doc(projectId)
|
||||
.collection('cursorData')
|
||||
.doc('messages');
|
||||
|
||||
batch.delete(messagesRef);
|
||||
|
||||
await batch.commit();
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
deletedCount: conversationsSnapshot.size,
|
||||
message: 'All cursor conversations cleared'
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error clearing cursor conversations:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to clear conversations', details: error instanceof Error ? error.message : String(error) },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,192 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { adminDb } from '@/lib/firebase/admin';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const projectId = request.nextUrl.searchParams.get('projectId');
|
||||
const sessionGapMinutes = parseInt(request.nextUrl.searchParams.get('gap') || '30'); // 30 min default
|
||||
|
||||
if (!projectId) {
|
||||
return NextResponse.json({ error: 'Missing projectId' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get all conversations sorted by time
|
||||
const conversationsSnapshot = await adminDb
|
||||
.collection('projects')
|
||||
.doc(projectId)
|
||||
.collection('cursorConversations')
|
||||
.orderBy('createdAt', 'asc')
|
||||
.get();
|
||||
|
||||
const conversations = conversationsSnapshot.docs.map((doc: FirebaseFirestore.QueryDocumentSnapshot) => {
|
||||
const data = doc.data();
|
||||
return {
|
||||
id: doc.id,
|
||||
ref: doc.ref,
|
||||
name: data.name,
|
||||
createdAt: new Date(data.createdAt),
|
||||
relevanceScore: data.relevanceScore || 0
|
||||
};
|
||||
});
|
||||
|
||||
// Step 1: Group by date
|
||||
const conversationsByDate: Record<string, typeof conversations> = {};
|
||||
|
||||
for (const conv of conversations) {
|
||||
const dateKey = conv.createdAt.toISOString().split('T')[0]; // YYYY-MM-DD
|
||||
if (!conversationsByDate[dateKey]) {
|
||||
conversationsByDate[dateKey] = [];
|
||||
}
|
||||
conversationsByDate[dateKey].push(conv);
|
||||
}
|
||||
|
||||
// Step 2: Within each date, create sessions based on time gaps
|
||||
const sessions: any[] = [];
|
||||
let sessionId = 0;
|
||||
|
||||
for (const [date, dayConversations] of Object.entries(conversationsByDate)) {
|
||||
let currentSession: any = null;
|
||||
|
||||
for (const conv of dayConversations) {
|
||||
if (!currentSession) {
|
||||
// Start first session of the day
|
||||
sessionId++;
|
||||
currentSession = {
|
||||
sessionId,
|
||||
date,
|
||||
startTime: conv.createdAt,
|
||||
endTime: conv.createdAt,
|
||||
conversations: [conv]
|
||||
};
|
||||
} else {
|
||||
// Check time gap from last conversation
|
||||
const gapMs = conv.createdAt.getTime() - currentSession.endTime.getTime();
|
||||
const gapMinutes = gapMs / (1000 * 60);
|
||||
|
||||
if (gapMinutes <= sessionGapMinutes) {
|
||||
// Same session
|
||||
currentSession.conversations.push(conv);
|
||||
currentSession.endTime = conv.createdAt;
|
||||
} else {
|
||||
// New session - close current and start new
|
||||
sessions.push(currentSession);
|
||||
sessionId++;
|
||||
currentSession = {
|
||||
sessionId,
|
||||
date,
|
||||
startTime: conv.createdAt,
|
||||
endTime: conv.createdAt,
|
||||
conversations: [conv]
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add last session of the day
|
||||
if (currentSession) {
|
||||
sessions.push(currentSession);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Analyze each session and determine project
|
||||
const projectKeywords = ['vibn', 'extension', 'collector', 'cursor-monitor'];
|
||||
const excludeKeywords = ['nhl', 'hockey', 'market', 'transaction'];
|
||||
|
||||
const analyzedSessions = sessions.map(session => {
|
||||
const allNames = session.conversations.map((c: any) => c.name.toLowerCase()).join(' ');
|
||||
|
||||
let projectTag = 'unknown';
|
||||
let confidence = 'low';
|
||||
|
||||
// Check for strong exclude signals
|
||||
for (const keyword of excludeKeywords) {
|
||||
if (allNames.includes(keyword)) {
|
||||
projectTag = 'other';
|
||||
confidence = 'high';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If not excluded, check for vibn signals
|
||||
if (projectTag === 'unknown') {
|
||||
for (const keyword of projectKeywords) {
|
||||
if (allNames.includes(keyword)) {
|
||||
projectTag = 'vibn';
|
||||
confidence = 'high';
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If still unknown, check for generic "project" keyword
|
||||
if (projectTag === 'unknown' && allNames.includes('project')) {
|
||||
projectTag = 'vibn';
|
||||
confidence = 'medium';
|
||||
}
|
||||
|
||||
return {
|
||||
...session,
|
||||
projectTag,
|
||||
confidence,
|
||||
conversationCount: session.conversations.length
|
||||
};
|
||||
});
|
||||
|
||||
// Step 4: Update Firestore with session tags
|
||||
const batch = adminDb.batch();
|
||||
let updateCount = 0;
|
||||
|
||||
for (const session of analyzedSessions) {
|
||||
for (const conv of session.conversations) {
|
||||
batch.update(conv.ref, {
|
||||
sessionId: session.sessionId,
|
||||
sessionDate: session.date,
|
||||
sessionProject: session.projectTag,
|
||||
sessionConfidence: session.confidence
|
||||
});
|
||||
updateCount++;
|
||||
}
|
||||
}
|
||||
|
||||
await batch.commit();
|
||||
|
||||
// Return summary
|
||||
const summary = {
|
||||
totalConversations: conversations.length,
|
||||
totalSessions: sessions.length,
|
||||
sessionGapMinutes,
|
||||
projectBreakdown: {
|
||||
vibn: analyzedSessions.filter(s => s.projectTag === 'vibn').length,
|
||||
other: analyzedSessions.filter(s => s.projectTag === 'other').length,
|
||||
unknown: analyzedSessions.filter(s => s.projectTag === 'unknown').length
|
||||
},
|
||||
conversationBreakdown: {
|
||||
vibn: analyzedSessions.filter(s => s.projectTag === 'vibn').reduce((sum, s) => sum + s.conversationCount, 0),
|
||||
other: analyzedSessions.filter(s => s.projectTag === 'other').reduce((sum, s) => sum + s.conversationCount, 0),
|
||||
unknown: analyzedSessions.filter(s => s.projectTag === 'unknown').reduce((sum, s) => sum + s.conversationCount, 0)
|
||||
},
|
||||
sampleSessions: analyzedSessions.slice(0, 10).map(s => ({
|
||||
sessionId: s.sessionId,
|
||||
date: s.date,
|
||||
conversationCount: s.conversationCount,
|
||||
projectTag: s.projectTag,
|
||||
confidence: s.confidence,
|
||||
conversationNames: s.conversations.slice(0, 3).map((c: any) => c.name)
|
||||
}))
|
||||
};
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
updatedConversations: updateCount,
|
||||
...summary
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error tagging sessions:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to tag sessions', details: error instanceof Error ? error.message : String(error) },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getAdminDb } from '@/lib/firebase/admin';
|
||||
import { FieldValue } from 'firebase-admin/firestore';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const projectId = (body.projectId ?? '').trim();
|
||||
|
||||
if (!projectId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'projectId is required' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const adminDb = getAdminDb();
|
||||
const docRef = adminDb.collection('chat_conversations').doc(projectId);
|
||||
|
||||
await adminDb.runTransaction(async (tx) => {
|
||||
const snapshot = await tx.get(docRef);
|
||||
const existing = (snapshot.exists ? (snapshot.data()?.messages as unknown[]) : []) ?? [];
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const newMessages = [
|
||||
{
|
||||
role: 'user' as const,
|
||||
content: '[debug] test user message',
|
||||
createdAt: now,
|
||||
},
|
||||
{
|
||||
role: 'assistant' as const,
|
||||
content: '[debug] test assistant reply',
|
||||
createdAt: now,
|
||||
},
|
||||
];
|
||||
|
||||
tx.set(
|
||||
docRef,
|
||||
{
|
||||
projectId,
|
||||
messages: [...existing, ...newMessages],
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
},
|
||||
{ merge: true },
|
||||
);
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('[debug/append-conversation] Failed to append messages', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to append debug conversation messages',
|
||||
details: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
/**
|
||||
* Debug API to check session links
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getAdminAuth, getAdminDb } from '@/lib/firebase/admin';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const authHeader = request.headers.get('Authorization');
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const idToken = authHeader.split('Bearer ')[1];
|
||||
const adminAuth = getAdminAuth();
|
||||
const adminDb = getAdminDb();
|
||||
|
||||
let userId: string;
|
||||
try {
|
||||
const decodedToken = await adminAuth.verifyIdToken(idToken);
|
||||
userId = decodedToken.uid;
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: 'Invalid token' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Get all user's sessions
|
||||
const sessionsSnapshot = await adminDb
|
||||
.collection('sessions')
|
||||
.where('userId', '==', userId)
|
||||
.get();
|
||||
|
||||
const linked: any[] = [];
|
||||
const unlinked: any[] = [];
|
||||
|
||||
sessionsSnapshot.docs.forEach(doc => {
|
||||
const data = doc.data();
|
||||
const sessionInfo = {
|
||||
id: doc.id,
|
||||
workspaceName: data.workspaceName || 'Unknown',
|
||||
workspacePath: data.workspacePath,
|
||||
projectId: data.projectId,
|
||||
needsProjectAssociation: data.needsProjectAssociation,
|
||||
createdAt: data.createdAt?.toDate?.() || data.createdAt,
|
||||
};
|
||||
|
||||
if (data.projectId) {
|
||||
linked.push(sessionInfo);
|
||||
} else {
|
||||
unlinked.push(sessionInfo);
|
||||
}
|
||||
});
|
||||
|
||||
// Get all user's projects
|
||||
const projectsSnapshot = await adminDb
|
||||
.collection('projects')
|
||||
.where('userId', '==', userId)
|
||||
.get();
|
||||
|
||||
const projects = projectsSnapshot.docs.map(doc => ({
|
||||
id: doc.id,
|
||||
name: doc.data().productName || doc.data().name,
|
||||
workspacePath: doc.data().workspacePath,
|
||||
}));
|
||||
|
||||
return NextResponse.json({
|
||||
summary: {
|
||||
totalSessions: sessionsSnapshot.size,
|
||||
linkedSessions: linked.length,
|
||||
unlinkedSessions: unlinked.length,
|
||||
totalProjects: projects.length,
|
||||
},
|
||||
linked,
|
||||
unlinked,
|
||||
projects,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Debug check error:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to check links',
|
||||
details: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { adminDb } from '@/lib/firebase/admin';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const projectId = request.nextUrl.searchParams.get('projectId');
|
||||
|
||||
if (!projectId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing projectId parameter' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if project exists
|
||||
const projectDoc = await adminDb.collection('projects').doc(projectId).get();
|
||||
|
||||
if (!projectDoc.exists) {
|
||||
// List all projects to help debug
|
||||
const allProjectsSnapshot = await adminDb.collection('projects').limit(20).get();
|
||||
const allProjects = allProjectsSnapshot.docs.map(doc => ({
|
||||
id: doc.id,
|
||||
name: doc.data().name,
|
||||
userId: doc.data().userId,
|
||||
createdAt: doc.data().createdAt
|
||||
}));
|
||||
|
||||
return NextResponse.json({
|
||||
exists: false,
|
||||
projectId,
|
||||
message: 'Project not found',
|
||||
availableProjects: allProjects
|
||||
});
|
||||
}
|
||||
|
||||
const projectData = projectDoc.data();
|
||||
|
||||
return NextResponse.json({
|
||||
exists: true,
|
||||
projectId,
|
||||
project: {
|
||||
name: projectData?.name,
|
||||
userId: projectData?.userId,
|
||||
createdAt: projectData?.createdAt,
|
||||
githubRepo: projectData?.githubRepo
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error checking project:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to check project', details: error instanceof Error ? error.message : String(error) },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getAdminDb } from '@/lib/firebase/admin';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const projectId = url.searchParams.get('projectId');
|
||||
|
||||
if (!projectId) {
|
||||
return NextResponse.json({ error: 'Missing projectId' }, { status: 400 });
|
||||
}
|
||||
|
||||
const adminDb = getAdminDb();
|
||||
|
||||
// Get contextSources subcollection
|
||||
const contextSourcesRef = adminDb
|
||||
.collection('projects')
|
||||
.doc(projectId)
|
||||
.collection('contextSources');
|
||||
|
||||
const snapshot = await contextSourcesRef.get();
|
||||
|
||||
const sources = snapshot.docs.map(doc => ({
|
||||
id: doc.id,
|
||||
...doc.data(),
|
||||
}));
|
||||
|
||||
return NextResponse.json({
|
||||
projectId,
|
||||
count: sources.length,
|
||||
sources,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[debug/context-sources] Error:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to fetch context sources',
|
||||
details: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { adminDb } from '@/lib/firebase/admin';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const projectId = request.nextUrl.searchParams.get('projectId');
|
||||
|
||||
if (!projectId) {
|
||||
return NextResponse.json({ error: 'Missing projectId' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get workspace metadata
|
||||
const workspaceMetaDoc = await adminDb
|
||||
.collection('projects')
|
||||
.doc(projectId)
|
||||
.collection('cursorData')
|
||||
.doc('workspace-meta')
|
||||
.get();
|
||||
|
||||
const workspaceMeta = workspaceMetaDoc.exists ? workspaceMetaDoc.data() : null;
|
||||
|
||||
// Get all conversations
|
||||
const conversationsSnapshot = await adminDb
|
||||
.collection('projects')
|
||||
.doc(projectId)
|
||||
.collection('cursorConversations')
|
||||
.get();
|
||||
|
||||
// Analyze by score
|
||||
const negative = conversationsSnapshot.docs
|
||||
.map(doc => ({ name: doc.data().name, score: doc.data().relevanceScore || 0 }))
|
||||
.filter(c => c.score < 0)
|
||||
.sort((a, b) => a.score - b.score);
|
||||
|
||||
const positive = conversationsSnapshot.docs
|
||||
.map(doc => ({ name: doc.data().name, score: doc.data().relevanceScore || 0 }))
|
||||
.filter(c => c.score > 0)
|
||||
.sort((a, b) => b.score - a.score);
|
||||
|
||||
const neutral = conversationsSnapshot.docs
|
||||
.filter(doc => (doc.data().relevanceScore || 0) === 0)
|
||||
.length;
|
||||
|
||||
return NextResponse.json({
|
||||
workspacePath: workspaceMeta?.workspacePath,
|
||||
githubUrl: workspaceMeta?.githubUrl,
|
||||
workspaceFilesCount: workspaceMeta?.workspaceFiles?.length || 0,
|
||||
workspaceFilesSample: (workspaceMeta?.workspaceFiles || []).slice(0, 20),
|
||||
totalGenerations: workspaceMeta?.totalGenerations || 0,
|
||||
totalConversations: conversationsSnapshot.size,
|
||||
scoreBreakdown: {
|
||||
negative: negative.length,
|
||||
neutral,
|
||||
positive: positive.length
|
||||
},
|
||||
negativeScoreConversations: negative,
|
||||
topPositiveConversations: positive.slice(0, 10),
|
||||
sampleNeutralConversations: conversationsSnapshot.docs
|
||||
.filter(doc => (doc.data().relevanceScore || 0) === 0)
|
||||
.slice(0, 10)
|
||||
.map(doc => doc.data().name)
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error analyzing conversations:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to analyze', details: error instanceof Error ? error.message : String(error) },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { adminDb } from '@/lib/firebase/admin';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const projectId = request.nextUrl.searchParams.get('projectId');
|
||||
|
||||
if (!projectId) {
|
||||
return NextResponse.json({ error: 'Missing projectId' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get 5 random conversations with content
|
||||
const conversationsSnapshot = await adminDb
|
||||
.collection('projects')
|
||||
.doc(projectId)
|
||||
.collection('cursorConversations')
|
||||
.limit(5)
|
||||
.get();
|
||||
|
||||
const samples = conversationsSnapshot.docs.map(doc => {
|
||||
const data = doc.data();
|
||||
return {
|
||||
name: data.name,
|
||||
messageCount: data.messageCount || 0,
|
||||
promptCount: data.prompts?.length || 0,
|
||||
generationCount: data.generations?.length || 0,
|
||||
filesCount: data.files?.length || 0,
|
||||
sampleFiles: (data.files || []).slice(0, 3),
|
||||
samplePrompt: data.prompts?.[0]?.text?.substring(0, 100) || 'none',
|
||||
hasContent: !!(data.prompts?.length || data.generations?.length)
|
||||
};
|
||||
});
|
||||
|
||||
// Get overall stats
|
||||
const allConversationsSnapshot = await adminDb
|
||||
.collection('projects')
|
||||
.doc(projectId)
|
||||
.collection('cursorConversations')
|
||||
.get();
|
||||
|
||||
let totalWithContent = 0;
|
||||
let totalWithFiles = 0;
|
||||
let totalMessages = 0;
|
||||
|
||||
allConversationsSnapshot.docs.forEach(doc => {
|
||||
const data = doc.data();
|
||||
if (data.prompts?.length || data.generations?.length) {
|
||||
totalWithContent++;
|
||||
}
|
||||
if (data.files?.length) {
|
||||
totalWithFiles++;
|
||||
}
|
||||
totalMessages += data.messageCount || 0;
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
totalConversations: allConversationsSnapshot.size,
|
||||
totalWithContent,
|
||||
totalWithFiles,
|
||||
totalMessages,
|
||||
samples
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching content sample:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch sample', details: error instanceof Error ? error.message : String(error) },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { adminDb } from '@/lib/firebase/admin';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const projectId = request.nextUrl.searchParams.get('projectId');
|
||||
|
||||
if (!projectId) {
|
||||
return NextResponse.json({ error: 'Missing projectId' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get cursor conversations for this project
|
||||
const conversationsSnapshot = await adminDb
|
||||
.collection('projects')
|
||||
.doc(projectId)
|
||||
.collection('cursorConversations')
|
||||
.orderBy('createdAt', 'desc')
|
||||
.get();
|
||||
|
||||
const conversations = conversationsSnapshot.docs.map(doc => ({
|
||||
id: doc.id,
|
||||
...doc.data()
|
||||
}));
|
||||
|
||||
// Get the messages data
|
||||
const messagesDoc = await adminDb
|
||||
.collection('projects')
|
||||
.doc(projectId)
|
||||
.collection('cursorData')
|
||||
.doc('messages')
|
||||
.get();
|
||||
|
||||
const messagesData = messagesDoc.exists ? messagesDoc.data() : null;
|
||||
|
||||
return NextResponse.json({
|
||||
projectId,
|
||||
conversationCount: conversations.length,
|
||||
conversations,
|
||||
messagesData: messagesData ? {
|
||||
promptCount: messagesData.prompts?.length || 0,
|
||||
generationCount: messagesData.generations?.length || 0,
|
||||
importedAt: messagesData.importedAt
|
||||
} : null
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching cursor conversations:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch conversations', details: error instanceof Error ? error.message : String(error) },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { adminDb } from '@/lib/firebase/admin';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const projectId = request.nextUrl.searchParams.get('projectId');
|
||||
const minScore = parseInt(request.nextUrl.searchParams.get('minScore') || '0');
|
||||
|
||||
if (!projectId) {
|
||||
return NextResponse.json({ error: 'Missing projectId' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get all conversations
|
||||
const conversationsSnapshot = await adminDb
|
||||
.collection('projects')
|
||||
.doc(projectId)
|
||||
.collection('cursorConversations')
|
||||
.get();
|
||||
|
||||
const conversations = conversationsSnapshot.docs
|
||||
.map(doc => {
|
||||
const data = doc.data();
|
||||
return {
|
||||
name: data.name,
|
||||
relevanceScore: data.relevanceScore || 0,
|
||||
createdAt: data.createdAt,
|
||||
workspacePath: data.workspacePath
|
||||
};
|
||||
})
|
||||
.filter(c => c.relevanceScore >= minScore)
|
||||
.sort((a, b) => b.relevanceScore - a.relevanceScore);
|
||||
|
||||
// Group by score
|
||||
const scoreGroups: Record<number, number> = {};
|
||||
conversationsSnapshot.docs.forEach(doc => {
|
||||
const score = doc.data().relevanceScore || 0;
|
||||
scoreGroups[score] = (scoreGroups[score] || 0) + 1;
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
totalConversations: conversationsSnapshot.size,
|
||||
filteredConversations: conversations.length,
|
||||
minScore,
|
||||
scoreDistribution: scoreGroups,
|
||||
conversations: conversations.slice(0, 50) // First 50
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching relevant conversations:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch conversations', details: error instanceof Error ? error.message : String(error) },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { adminDb } from '@/lib/firebase/admin';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const projectId = request.nextUrl.searchParams.get('projectId');
|
||||
|
||||
if (!projectId) {
|
||||
return NextResponse.json({ error: 'Missing projectId' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get 10 conversations with their dates
|
||||
const conversationsSnapshot = await adminDb
|
||||
.collection('projects')
|
||||
.doc(projectId)
|
||||
.collection('cursorConversations')
|
||||
.orderBy('createdAt', 'desc')
|
||||
.limit(10)
|
||||
.get();
|
||||
|
||||
const samples = conversationsSnapshot.docs.map(doc => {
|
||||
const data = doc.data();
|
||||
return {
|
||||
name: data.name,
|
||||
createdAt: data.createdAt,
|
||||
workspacePath: data.workspacePath
|
||||
};
|
||||
});
|
||||
|
||||
return NextResponse.json({ samples });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching samples:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch samples', details: error instanceof Error ? error.message : String(error) },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { adminDb } from '@/lib/firebase/admin';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const projectId = request.nextUrl.searchParams.get('projectId');
|
||||
|
||||
if (!projectId) {
|
||||
return NextResponse.json({ error: 'Missing projectId' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get all conversations sorted by time
|
||||
const conversationsSnapshot = await adminDb
|
||||
.collection('projects')
|
||||
.doc(projectId)
|
||||
.collection('cursorConversations')
|
||||
.orderBy('createdAt', 'asc')
|
||||
.get();
|
||||
|
||||
const conversations = conversationsSnapshot.docs.map(doc => {
|
||||
const data = doc.data();
|
||||
return {
|
||||
name: data.name,
|
||||
createdAt: new Date(data.createdAt),
|
||||
score: data.relevanceScore || 0
|
||||
};
|
||||
});
|
||||
|
||||
// Find NHL work sessions (negative scores clustered in time)
|
||||
const nhlConversations = conversations.filter(c => c.score < 0);
|
||||
|
||||
return NextResponse.json({
|
||||
totalConversations: conversations.length,
|
||||
nhlConversations: nhlConversations.length,
|
||||
nhlDates: nhlConversations.map(c => ({
|
||||
date: c.createdAt.toISOString().split('T')[0],
|
||||
name: c.name
|
||||
})),
|
||||
// Find if NHL conversations cluster
|
||||
nhlDateCounts: nhlConversations.reduce((acc: any, c) => {
|
||||
const date = c.createdAt.toISOString().split('T')[0];
|
||||
acc[date] = (acc[date] || 0) + 1;
|
||||
return acc;
|
||||
}, {})
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error analyzing session summary:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to analyze', details: error instanceof Error ? error.message : String(error) },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { adminDb } from '@/lib/firebase/admin';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const projectId = request.nextUrl.searchParams.get('projectId');
|
||||
const sessionGapMinutes = parseInt(request.nextUrl.searchParams.get('gap') || '120'); // 2 hours default
|
||||
|
||||
if (!projectId) {
|
||||
return NextResponse.json({ error: 'Missing projectId' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get all conversations sorted by time
|
||||
const conversationsSnapshot = await adminDb
|
||||
.collection('projects')
|
||||
.doc(projectId)
|
||||
.collection('cursorConversations')
|
||||
.orderBy('createdAt', 'asc')
|
||||
.get();
|
||||
|
||||
const conversations = conversationsSnapshot.docs.map(doc => {
|
||||
const data = doc.data();
|
||||
return {
|
||||
id: doc.id,
|
||||
name: data.name,
|
||||
createdAt: new Date(data.createdAt),
|
||||
relevanceScore: data.relevanceScore || 0
|
||||
};
|
||||
});
|
||||
|
||||
// Group into sessions based on time gaps
|
||||
const sessions: any[] = [];
|
||||
let currentSession: any = null;
|
||||
|
||||
for (const conv of conversations) {
|
||||
if (!currentSession) {
|
||||
// Start first session
|
||||
currentSession = {
|
||||
startTime: conv.createdAt,
|
||||
endTime: conv.createdAt,
|
||||
conversations: [conv],
|
||||
relevanceScores: [conv.relevanceScore]
|
||||
};
|
||||
} else {
|
||||
// Check time gap from last conversation
|
||||
const gapMs = conv.createdAt.getTime() - currentSession.endTime.getTime();
|
||||
const gapMinutes = gapMs / (1000 * 60);
|
||||
|
||||
if (gapMinutes <= sessionGapMinutes) {
|
||||
// Same session
|
||||
currentSession.conversations.push(conv);
|
||||
currentSession.relevanceScores.push(conv.relevanceScore);
|
||||
currentSession.endTime = conv.createdAt;
|
||||
} else {
|
||||
// New session - close current and start new
|
||||
sessions.push(currentSession);
|
||||
currentSession = {
|
||||
startTime: conv.createdAt,
|
||||
endTime: conv.createdAt,
|
||||
conversations: [conv],
|
||||
relevanceScores: [conv.relevanceScore]
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add last session
|
||||
if (currentSession) {
|
||||
sessions.push(currentSession);
|
||||
}
|
||||
|
||||
// Analyze each session
|
||||
const analyzedSessions = sessions.map((session, idx) => {
|
||||
const durationMinutes = Math.round((session.endTime.getTime() - session.startTime.getTime()) / (1000 * 60));
|
||||
|
||||
// Calculate session relevance score (average of all conversations)
|
||||
const avgScore = session.relevanceScores.reduce((a: number, b: number) => a + b, 0) / session.relevanceScores.length;
|
||||
|
||||
// Count negative/positive conversations
|
||||
const negative = session.relevanceScores.filter((s: number) => s < 0).length;
|
||||
const positive = session.relevanceScores.filter((s: number) => s > 0).length;
|
||||
const neutral = session.relevanceScores.filter((s: number) => s === 0).length;
|
||||
|
||||
// Determine likely project based on majority
|
||||
let likelyProject = 'unknown';
|
||||
if (negative > positive && negative > neutral) {
|
||||
likelyProject = 'other (NHL/market)';
|
||||
} else if (positive > negative && positive > neutral) {
|
||||
likelyProject = 'vibn (likely)';
|
||||
} else if (positive > 0 || avgScore > 0) {
|
||||
likelyProject = 'vibn (mixed)';
|
||||
} else {
|
||||
likelyProject = 'unclear';
|
||||
}
|
||||
|
||||
return {
|
||||
sessionNumber: idx + 1,
|
||||
startTime: session.startTime.toISOString(),
|
||||
endTime: session.endTime.toISOString(),
|
||||
durationMinutes,
|
||||
conversationCount: session.conversations.length,
|
||||
avgRelevanceScore: Math.round(avgScore * 100) / 100,
|
||||
scoreBreakdown: { negative, neutral, positive },
|
||||
likelyProject,
|
||||
conversationNames: session.conversations.slice(0, 5).map((c: any) => c.name)
|
||||
};
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
totalConversations: conversations.length,
|
||||
totalSessions: sessions.length,
|
||||
sessionGapMinutes,
|
||||
sessions: analyzedSessions
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error analyzing sessions:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to analyze sessions', details: error instanceof Error ? error.message : String(error) },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { adminDb } from '@/lib/firebase/admin';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const projectId = request.nextUrl.searchParams.get('projectId');
|
||||
|
||||
if (!projectId) {
|
||||
return NextResponse.json({ error: 'Missing projectId' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get all conversations
|
||||
const conversationsSnapshot = await adminDb
|
||||
.collection('projects')
|
||||
.doc(projectId)
|
||||
.collection('cursorConversations')
|
||||
.get();
|
||||
|
||||
const conversations = conversationsSnapshot.docs.map(doc => doc.data());
|
||||
|
||||
// Find date range
|
||||
const dates = conversations
|
||||
.filter(c => c.createdAt)
|
||||
.map(c => new Date(c.createdAt))
|
||||
.sort((a, b) => a.getTime() - b.getTime());
|
||||
|
||||
if (dates.length === 0) {
|
||||
return NextResponse.json({ error: 'No conversations with dates found' });
|
||||
}
|
||||
|
||||
const earliest = dates[0];
|
||||
const latest = dates[dates.length - 1];
|
||||
const span = Math.floor((latest.getTime() - earliest.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
// Find the actual conversation names for earliest and latest
|
||||
const earliestConv = conversations.find(c =>
|
||||
new Date(c.createdAt).getTime() === earliest.getTime()
|
||||
);
|
||||
const latestConv = conversations.find(c =>
|
||||
new Date(c.createdAt).getTime() === latest.getTime()
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
totalConversations: conversations.length,
|
||||
dateRange: {
|
||||
earliest: earliest.toISOString(),
|
||||
latest: latest.toISOString(),
|
||||
spanDays: span
|
||||
},
|
||||
oldestConversation: {
|
||||
name: earliestConv?.name || 'Unknown',
|
||||
date: earliest.toISOString()
|
||||
},
|
||||
newestConversation: {
|
||||
name: latestConv?.name || 'Unknown',
|
||||
date: latest.toISOString()
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching cursor stats:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch stats', details: error instanceof Error ? error.message : String(error) },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { adminDb } from '@/lib/firebase/admin';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const projectId = request.nextUrl.searchParams.get('projectId');
|
||||
|
||||
if (!projectId) {
|
||||
return NextResponse.json({ error: 'Missing projectId' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get all unknown conversations grouped by session
|
||||
const conversationsSnapshot = await adminDb
|
||||
.collection('projects')
|
||||
.doc(projectId)
|
||||
.collection('cursorConversations')
|
||||
.where('sessionProject', '==', 'unknown')
|
||||
.get();
|
||||
|
||||
const sessionMap: Record<number, any[]> = {};
|
||||
|
||||
conversationsSnapshot.docs.forEach(doc => {
|
||||
const data = doc.data();
|
||||
const sessionId = data.sessionId;
|
||||
|
||||
if (!sessionMap[sessionId]) {
|
||||
sessionMap[sessionId] = [];
|
||||
}
|
||||
|
||||
sessionMap[sessionId].push({
|
||||
name: data.name,
|
||||
date: data.sessionDate,
|
||||
createdAt: data.createdAt
|
||||
});
|
||||
});
|
||||
|
||||
// Convert to array and take sample
|
||||
const sessions = Object.entries(sessionMap).map(([sessionId, conversations]) => ({
|
||||
sessionId: parseInt(sessionId),
|
||||
date: conversations[0].date,
|
||||
conversationCount: conversations.length,
|
||||
conversationNames: conversations.map(c => c.name)
|
||||
}));
|
||||
|
||||
return NextResponse.json({
|
||||
totalUnknownSessions: sessions.length,
|
||||
totalUnknownConversations: conversationsSnapshot.size,
|
||||
sample: sessions.slice(0, 30)
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching unknown sessions:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch', details: error instanceof Error ? error.message : String(error) },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { adminDb } from '@/lib/firebase/admin';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const projectId = request.nextUrl.searchParams.get('projectId');
|
||||
|
||||
if (!projectId) {
|
||||
return NextResponse.json({ error: 'Missing projectId' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get all conversations
|
||||
const conversationsSnapshot = await adminDb
|
||||
.collection('projects')
|
||||
.doc(projectId)
|
||||
.collection('cursorConversations')
|
||||
.get();
|
||||
|
||||
const conversations = conversationsSnapshot.docs.map(doc => doc.data());
|
||||
|
||||
// Group by workspace path
|
||||
const workspaceGroups: Record<string, number> = {};
|
||||
const githubGroups: Record<string, number> = {};
|
||||
|
||||
conversations.forEach(conv => {
|
||||
const workspace = conv.workspacePath || 'unknown';
|
||||
const github = conv.githubUrl || 'none';
|
||||
|
||||
workspaceGroups[workspace] = (workspaceGroups[workspace] || 0) + 1;
|
||||
githubGroups[github] = (githubGroups[github] || 0) + 1;
|
||||
});
|
||||
|
||||
// Sort by count
|
||||
const workspaceList = Object.entries(workspaceGroups)
|
||||
.map(([path, count]) => ({ path, count }))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
|
||||
const githubList = Object.entries(githubGroups)
|
||||
.map(([url, count]) => ({ url, count }))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
|
||||
return NextResponse.json({
|
||||
totalConversations: conversations.length,
|
||||
uniqueWorkspaces: workspaceList.length,
|
||||
uniqueRepos: githubList.length,
|
||||
workspaces: workspaceList,
|
||||
repos: githubList
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching workspace breakdown:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch breakdown', details: error instanceof Error ? error.message : String(error) },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
18
vibn-frontend/app/api/debug/env/route.ts
vendored
18
vibn-frontend/app/api/debug/env/route.ts
vendored
@@ -1,18 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({
|
||||
firebaseProjectId: process.env.FIREBASE_PROJECT_ID ? 'SET' : 'NOT SET',
|
||||
firebaseClientEmail: process.env.FIREBASE_CLIENT_EMAIL ? 'SET' : 'NOT SET',
|
||||
firebasePrivateKey: process.env.FIREBASE_PRIVATE_KEY ? 'SET (length: ' + process.env.FIREBASE_PRIVATE_KEY.length + ')' : 'NOT SET',
|
||||
|
||||
publicApiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY ? 'SET' : 'NOT SET',
|
||||
publicAuthDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN ? 'SET' : 'NOT SET',
|
||||
publicProjectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID ? 'SET' : 'NOT SET',
|
||||
|
||||
nodeEnv: process.env.NODE_ENV,
|
||||
|
||||
tip: 'If any Firebase vars show NOT SET, restart your dev server after updating .env.local'
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getAdminDb } from '@/lib/firebase/admin';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const adminDb = getAdminDb();
|
||||
const snapshot = await adminDb.collection('projects').limit(1).get();
|
||||
|
||||
if (snapshot.empty) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No projects found' },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
const doc = snapshot.docs[0];
|
||||
return NextResponse.json({
|
||||
id: doc.id,
|
||||
data: doc.data(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[debug/first-project] Failed to load project', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to load project',
|
||||
details: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getAdminDb } from '@/lib/firebase/admin';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const projectId = url.searchParams.get('projectId');
|
||||
|
||||
if (!projectId) {
|
||||
return NextResponse.json({ error: 'Missing projectId' }, { status: 400 });
|
||||
}
|
||||
|
||||
const adminDb = getAdminDb();
|
||||
|
||||
// Get knowledge_items collection
|
||||
const knowledgeItemsRef = adminDb.collection('knowledge_items');
|
||||
const snapshot = await knowledgeItemsRef
|
||||
.where('projectId', '==', projectId)
|
||||
.limit(20)
|
||||
.get();
|
||||
|
||||
const items = snapshot.docs.map(doc => ({
|
||||
id: doc.id,
|
||||
...doc.data(),
|
||||
}));
|
||||
|
||||
return NextResponse.json({
|
||||
projectId,
|
||||
count: items.length,
|
||||
items,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[debug/knowledge-items] Error:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to fetch knowledge items',
|
||||
details: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getAdminDb } from '@/lib/firebase/admin';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const projectId = (url.searchParams.get('projectId') ?? '').trim();
|
||||
|
||||
if (!projectId) {
|
||||
return NextResponse.json({ error: 'Missing projectId' }, { status: 400 });
|
||||
}
|
||||
|
||||
const adminDb = getAdminDb();
|
||||
|
||||
// Use a simple filter query without orderBy to avoid requiring a composite index.
|
||||
const snapshot = await adminDb
|
||||
.collection('knowledge_items')
|
||||
.where('projectId', '==', projectId)
|
||||
.get();
|
||||
|
||||
const items = snapshot.docs.map((doc) => doc.data());
|
||||
|
||||
return NextResponse.json({ count: items.length, items });
|
||||
} catch (error) {
|
||||
console.error('[debug/knowledge] Failed to list knowledge items', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to list knowledge items',
|
||||
details: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
/**
|
||||
* Dev-only: verifies Prisma can connect (NextAuth adapter needs this after Google redirects back).
|
||||
* Open GET /api/debug/prisma while running next dev.
|
||||
*/
|
||||
export async function GET() {
|
||||
if (process.env.NODE_ENV !== "development") {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const hasUrl = Boolean(process.env.DATABASE_URL?.trim() || process.env.POSTGRES_URL?.trim());
|
||||
const prisma = new PrismaClient();
|
||||
try {
|
||||
await prisma.$queryRaw`SELECT 1`;
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
databaseUrlConfigured: hasUrl,
|
||||
hint: "Prisma connects; if auth still fails, check Google client id/secret and terminal [next-auth] logs.",
|
||||
});
|
||||
} catch (e: unknown) {
|
||||
const message = e instanceof Error ? e.message : "Unknown error";
|
||||
const publicHost = /Can't reach database server at `([\d.]+):(\d+)`/.exec(message);
|
||||
const hint = publicHost
|
||||
? `No TCP route to Postgres at ${publicHost[1]}:${publicHost[2]} from this machine. In Coolify: confirm the DB service publishes that host port and Postgres listens on 0.0.0.0. On the cloud firewall (e.g. GCP), allow inbound TCP ${publicHost[2]} from your IP (or use VPN). Test: nc -zv ${publicHost[1]} ${publicHost[2]} or psql. Then npm run db:push from vibn-frontend.`
|
||||
: "If the URL uses a Coolify internal hostname, it only works inside Docker. Otherwise check DATABASE_URL, firewall, and run npm run db:push.";
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
databaseUrlConfigured: hasUrl,
|
||||
message,
|
||||
hint,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
} finally {
|
||||
await prisma.$disconnect().catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { adminAuth, adminDb } from '@/lib/firebase/admin';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const diagnostics: any = {
|
||||
timestamp: new Date().toISOString(),
|
||||
environment: {},
|
||||
firebase: {},
|
||||
token: {},
|
||||
};
|
||||
|
||||
try {
|
||||
// Check environment variables
|
||||
diagnostics.environment = {
|
||||
FIREBASE_PROJECT_ID: process.env.FIREBASE_PROJECT_ID ? 'SET' : 'NOT SET',
|
||||
FIREBASE_CLIENT_EMAIL: process.env.FIREBASE_CLIENT_EMAIL ? 'SET' : 'NOT SET',
|
||||
FIREBASE_PRIVATE_KEY: process.env.FIREBASE_PRIVATE_KEY ? `SET (${process.env.FIREBASE_PRIVATE_KEY.length} chars)` : 'NOT SET',
|
||||
NEXT_PUBLIC_FIREBASE_PROJECT_ID: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID || 'NOT SET',
|
||||
};
|
||||
|
||||
// Test Firebase Admin
|
||||
try {
|
||||
const testDoc = await adminDb.collection('test').doc('diagnostic').get();
|
||||
diagnostics.firebase.adminDb = 'OK - Can access Firestore';
|
||||
diagnostics.firebase.adminAuth = 'OK - Auth service initialized';
|
||||
} catch (error: any) {
|
||||
diagnostics.firebase.error = error.message;
|
||||
}
|
||||
|
||||
// Try to verify a token if provided
|
||||
const authHeader = request.headers.get('authorization');
|
||||
if (authHeader?.startsWith('Bearer ')) {
|
||||
const token = authHeader.substring(7);
|
||||
diagnostics.token.received = true;
|
||||
diagnostics.token.length = token.length;
|
||||
|
||||
try {
|
||||
const decodedToken = await adminAuth.verifyIdToken(token);
|
||||
diagnostics.token.verification = 'SUCCESS';
|
||||
diagnostics.token.uid = decodedToken.uid;
|
||||
diagnostics.token.email = decodedToken.email;
|
||||
} catch (error: any) {
|
||||
diagnostics.token.verification = 'FAILED';
|
||||
diagnostics.token.error = error.message;
|
||||
diagnostics.token.errorCode = error.code;
|
||||
}
|
||||
} else {
|
||||
diagnostics.token.received = false;
|
||||
diagnostics.token.note = 'No token provided - add Authorization: Bearer <token> header to test';
|
||||
}
|
||||
|
||||
return NextResponse.json(diagnostics, { status: 200 });
|
||||
} catch (error: any) {
|
||||
diagnostics.criticalError = {
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
};
|
||||
return NextResponse.json(diagnostics, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { adminDb, adminAuth } from '@/lib/firebase/admin';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// Test 1: Check if Firebase Admin is initialized
|
||||
if (!adminDb) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Firebase Admin not initialized' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// Test 2: Try to access Firestore (this will verify credentials)
|
||||
const testCollection = adminDb.collection('_healthcheck');
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
// Write a test document
|
||||
const docRef = await testCollection.add({
|
||||
message: 'Firebase connection test',
|
||||
timestamp: timestamp,
|
||||
});
|
||||
|
||||
// Read it back
|
||||
const doc = await docRef.get();
|
||||
const data = doc.data();
|
||||
|
||||
// Clean up
|
||||
await docRef.delete();
|
||||
|
||||
// Test 3: Check Auth is working
|
||||
const authCheck = adminAuth ? 'OK' : 'Failed';
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Firebase is connected successfully! 🎉',
|
||||
tests: {
|
||||
adminInit: 'OK',
|
||||
firestoreWrite: 'OK',
|
||||
firestoreRead: 'OK',
|
||||
authInit: authCheck,
|
||||
},
|
||||
projectId: process.env.FIREBASE_PROJECT_ID,
|
||||
testData: data,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Firebase test error:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Firebase connection failed',
|
||||
details: error instanceof Error ? error.message : String(error),
|
||||
tip: 'Check your .env.local file for correct FIREBASE_PROJECT_ID, FIREBASE_CLIENT_EMAIL, and FIREBASE_PRIVATE_KEY',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
isCoolifyInfraOperational,
|
||||
runCoolifyInfraHealthProbe,
|
||||
} from '@/lib/server/infra-coolify-health';
|
||||
} from "@/lib/server/infra-coolify-health";
|
||||
import { timingSafeStringEq } from "@/lib/server/timing-safe";
|
||||
|
||||
/**
|
||||
* Authenticated infrastructure probe for Coolify API + SSH→Docker.
|
||||
@@ -23,20 +24,22 @@ export async function GET(req: NextRequest) {
|
||||
{
|
||||
ok: false,
|
||||
error:
|
||||
'INFRA_HEALTH_SECRET is not set — configure it on vibn-frontend to enable this probe.',
|
||||
"INFRA_HEALTH_SECRET is not set — configure it on vibn-frontend to enable this probe.",
|
||||
},
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
const auth = req.headers.get('authorization');
|
||||
const bearer =
|
||||
auth?.startsWith('Bearer ') ? auth.slice(7).trim() : '';
|
||||
const headerSecret = req.headers.get('x-vibn-infra-secret')?.trim() ?? '';
|
||||
const auth = req.headers.get("authorization");
|
||||
const bearer = auth?.startsWith("Bearer ") ? auth.slice(7).trim() : "";
|
||||
const headerSecret = req.headers.get("x-vibn-infra-secret")?.trim() ?? "";
|
||||
const token = bearer || headerSecret;
|
||||
|
||||
if (token !== secret) {
|
||||
return NextResponse.json({ ok: false, error: 'Unauthorized' }, { status: 401 });
|
||||
if (!token || !timingSafeStringEq(secret, token)) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: "Unauthorized" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
85
vibn-frontend/app/api/invites/[token]/route.ts
Normal file
85
vibn-frontend/app/api/invites/[token]/route.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* GET /api/invites/[token] — validate (public, used by /auth page)
|
||||
* POST /api/invites/[token]/redeem — consume (called on sign-up completion)
|
||||
*/
|
||||
import { NextResponse } from "next/server";
|
||||
import { query, queryOne } from "@/lib/db-postgres";
|
||||
import { authSession } from "@/lib/auth/session-server";
|
||||
|
||||
interface InviteRow {
|
||||
token: string;
|
||||
email: string | null;
|
||||
max_uses: number;
|
||||
use_count: number;
|
||||
expires_at: string | null;
|
||||
redeemed_by: string[];
|
||||
}
|
||||
|
||||
async function validateInvite(
|
||||
token: string,
|
||||
): Promise<{ valid: boolean; reason?: string; row?: InviteRow }> {
|
||||
const row = await queryOne<InviteRow>(
|
||||
`SELECT token, email, max_uses, use_count, expires_at, redeemed_by
|
||||
FROM invites WHERE token = $1`,
|
||||
[token],
|
||||
);
|
||||
if (!row) return { valid: false, reason: "Token not found" };
|
||||
if (row.use_count >= row.max_uses)
|
||||
return { valid: false, reason: "Token already fully redeemed" };
|
||||
if (row.expires_at && new Date(row.expires_at) < new Date())
|
||||
return { valid: false, reason: "Token expired" };
|
||||
return { valid: true, row };
|
||||
}
|
||||
|
||||
/** GET /api/invites/[token] — check if a token is valid (used by auth page UI) */
|
||||
export async function GET(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ token: string }> },
|
||||
) {
|
||||
const { token } = await params;
|
||||
const { valid, reason, row } = await validateInvite(token);
|
||||
if (!valid) {
|
||||
return NextResponse.json({ valid: false, reason }, { status: 400 });
|
||||
}
|
||||
return NextResponse.json({
|
||||
valid: true,
|
||||
email: row!.email ?? null,
|
||||
usesRemaining: row!.max_uses - row!.use_count,
|
||||
});
|
||||
}
|
||||
|
||||
/** POST /api/invites/[token] — redeem (call after a user signs up) */
|
||||
export async function POST(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ token: string }> },
|
||||
) {
|
||||
const session = await authSession();
|
||||
if (!session?.user?.email) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const { token } = await params;
|
||||
const { valid, reason, row } = await validateInvite(token);
|
||||
if (!valid) {
|
||||
return NextResponse.json({ ok: false, reason }, { status: 400 });
|
||||
}
|
||||
const email = session.user.email;
|
||||
if (row!.email && row!.email !== email) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, reason: "Token is for a different email address" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
if (row!.redeemed_by.includes(email)) {
|
||||
return NextResponse.json(
|
||||
{ ok: true, alreadyRedeemed: true },
|
||||
);
|
||||
}
|
||||
await query(
|
||||
`UPDATE invites
|
||||
SET use_count = use_count + 1,
|
||||
redeemed_by = array_append(redeemed_by, $2)
|
||||
WHERE token = $1`,
|
||||
[token, email],
|
||||
);
|
||||
return NextResponse.json({ ok: true, redeemed: true });
|
||||
}
|
||||
90
vibn-frontend/app/api/invites/route.ts
Normal file
90
vibn-frontend/app/api/invites/route.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* POST /api/invites
|
||||
*
|
||||
* Admin-only. Creates an invite token and returns the invite URL.
|
||||
* Closes BETA_LAUNCH_PLAN P4.8.
|
||||
*
|
||||
* Body: { email?: string, note?: string, maxUses?: number }
|
||||
* Auth: Bearer ADMIN_MIGRATE_SECRET
|
||||
*
|
||||
* curl -X POST https://vibnai.com/api/invites \
|
||||
* -H "x-admin-secret: $ADMIN_MIGRATE_SECRET" \
|
||||
* -d '{"email":"friend@example.com","note":"beta tester"}'
|
||||
*/
|
||||
import { NextResponse } from "next/server";
|
||||
import { query } from "@/lib/db-postgres";
|
||||
import { withAdminSecret } from "@/lib/server/api-handler";
|
||||
import { randomBytes } from "crypto";
|
||||
|
||||
let tableReady = false;
|
||||
async function ensureTable() {
|
||||
if (tableReady) return;
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS invites (
|
||||
token TEXT PRIMARY KEY,
|
||||
email TEXT,
|
||||
note TEXT,
|
||||
max_uses INT NOT NULL DEFAULT 1,
|
||||
use_count INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ,
|
||||
redeemed_by TEXT[] NOT NULL DEFAULT '{}'
|
||||
)
|
||||
`);
|
||||
await query(`CREATE INDEX IF NOT EXISTS invites_email_idx ON invites (email) WHERE email IS NOT NULL`);
|
||||
tableReady = true;
|
||||
}
|
||||
|
||||
export const POST = withAdminSecret(
|
||||
async (request) => {
|
||||
await ensureTable();
|
||||
const body = await request.json().catch(() => ({})) as {
|
||||
email?: string;
|
||||
note?: string;
|
||||
maxUses?: number;
|
||||
expiresInDays?: number;
|
||||
};
|
||||
|
||||
const token = randomBytes(20).toString("hex");
|
||||
const maxUses = Math.max(1, Math.min(100, body.maxUses ?? 1));
|
||||
const expiresAt = body.expiresInDays
|
||||
? new Date(Date.now() + body.expiresInDays * 86_400_000).toISOString()
|
||||
: null;
|
||||
|
||||
await query(
|
||||
`INSERT INTO invites (token, email, note, max_uses, expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
[token, body.email ?? null, body.note ?? null, maxUses, expiresAt],
|
||||
);
|
||||
|
||||
const baseUrl =
|
||||
process.env.NEXT_PUBLIC_APP_URL ||
|
||||
process.env.NEXTAUTH_URL ||
|
||||
"https://vibnai.com";
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
token,
|
||||
inviteUrl: `${baseUrl}/auth?invite=${token}`,
|
||||
email: body.email ?? null,
|
||||
maxUses,
|
||||
expiresAt,
|
||||
});
|
||||
},
|
||||
{ secretEnvVar: "ADMIN_MIGRATE_SECRET", altHeader: "x-admin-secret" },
|
||||
);
|
||||
|
||||
export const GET = withAdminSecret(
|
||||
async (request) => {
|
||||
await ensureTable();
|
||||
const { searchParams } = new URL(request.url);
|
||||
const limit = Math.min(200, parseInt(searchParams.get("limit") ?? "50", 10));
|
||||
const rows = await query(
|
||||
`SELECT token, email, note, max_uses, use_count, created_at, expires_at, redeemed_by
|
||||
FROM invites ORDER BY created_at DESC LIMIT $1`,
|
||||
[limit],
|
||||
);
|
||||
return NextResponse.json({ invites: rows });
|
||||
},
|
||||
{ secretEnvVar: "ADMIN_MIGRATE_SECRET", altHeader: "x-admin-secret" },
|
||||
);
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* DELETE /api/projects/[projectId]/secrets/[key]
|
||||
* GET /api/projects/[projectId]/secrets/[key] — reveal (decrypted)
|
||||
*
|
||||
* Reveal is intentionally project-scoped (user must own the project).
|
||||
* Never log the plaintext value.
|
||||
*/
|
||||
import { NextResponse } from "next/server";
|
||||
import { query, queryOne } from "@/lib/db-postgres";
|
||||
import { withTenantProject } from "@/lib/server/api-handler";
|
||||
import { decryptSecret } from "@/lib/auth/secret-box";
|
||||
import { log } from "@/lib/server/logger";
|
||||
|
||||
export const DELETE = withTenantProject(
|
||||
async (_req, ctx, { project }) => {
|
||||
const { key } = await (ctx.params as Promise<{ projectId: string; key: string }>);
|
||||
await query(
|
||||
`DELETE FROM fs_project_secrets WHERE project_id = $1 AND key = $2`,
|
||||
[project.id, key],
|
||||
);
|
||||
log.info("project secret deleted", {
|
||||
route: "api.projects.secrets.delete",
|
||||
projectId: project.id,
|
||||
key,
|
||||
});
|
||||
return NextResponse.json({ ok: true });
|
||||
},
|
||||
);
|
||||
|
||||
export const GET = withTenantProject(
|
||||
async (_req, ctx, { project }) => {
|
||||
const { key } = await (ctx.params as Promise<{ projectId: string; key: string }>);
|
||||
const row = await queryOne<{ value_enc: string }>(
|
||||
`SELECT value_enc FROM fs_project_secrets WHERE project_id = $1 AND key = $2`,
|
||||
[project.id, key],
|
||||
);
|
||||
if (!row) {
|
||||
return NextResponse.json({ error: "Secret not found" }, { status: 404 });
|
||||
}
|
||||
try {
|
||||
const value = decryptSecret(row.value_enc);
|
||||
return NextResponse.json({ key, value });
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: "Decryption failed — VIBN_SECRETS_KEY may have rotated" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
91
vibn-frontend/app/api/projects/[projectId]/secrets/route.ts
Normal file
91
vibn-frontend/app/api/projects/[projectId]/secrets/route.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Project-level encrypted secret scratchpad.
|
||||
* Closes BETA_LAUNCH_PLAN P6.D2.
|
||||
*
|
||||
* GET /api/projects/[projectId]/secrets — list key names only (never values)
|
||||
* POST /api/projects/[projectId]/secrets — set/update a secret { key, value }
|
||||
*
|
||||
* Values are encrypted at-rest with AES-256-GCM via the existing
|
||||
* `lib/auth/secret-box.ts` (same envelope used for workspace API keys and
|
||||
* Gitea bot PATs). The plaintext value is NEVER returned by the list route.
|
||||
* Use a dedicated GET /secrets/[key] route (below) if you need to surface
|
||||
* it to the AI.
|
||||
*
|
||||
* Table created lazily on first write.
|
||||
*/
|
||||
import { NextResponse } from "next/server";
|
||||
import { query } from "@/lib/db-postgres";
|
||||
import { withTenantProject } from "@/lib/server/api-handler";
|
||||
import { encryptSecret } from "@/lib/auth/secret-box";
|
||||
import { log } from "@/lib/server/logger";
|
||||
|
||||
let tableReady = false;
|
||||
async function ensureTable() {
|
||||
if (tableReady) return;
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS fs_project_secrets (
|
||||
project_id TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
value_enc TEXT NOT NULL, -- AES-256-GCM encrypted, base64
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (project_id, key)
|
||||
)
|
||||
`);
|
||||
await query(
|
||||
`CREATE INDEX IF NOT EXISTS fs_project_secrets_project_idx ON fs_project_secrets (project_id)`,
|
||||
);
|
||||
tableReady = true;
|
||||
}
|
||||
|
||||
/** GET — returns key names only, never values. */
|
||||
export const GET = withTenantProject(
|
||||
async (_req, _ctx, { project }) => {
|
||||
await ensureTable();
|
||||
const rows = await query<{ key: string; updated_at: string }>(
|
||||
`SELECT key, updated_at FROM fs_project_secrets
|
||||
WHERE project_id = $1 ORDER BY key`,
|
||||
[project.id],
|
||||
);
|
||||
return NextResponse.json({ secrets: rows });
|
||||
},
|
||||
);
|
||||
|
||||
/** POST — upsert a secret. Body: { key: string, value: string } */
|
||||
export const POST = withTenantProject(
|
||||
async (req, _ctx, { project }) => {
|
||||
const body = await req.json().catch(() => ({})) as {
|
||||
key?: string;
|
||||
value?: string;
|
||||
};
|
||||
if (!body.key || typeof body.key !== "string") {
|
||||
return NextResponse.json({ error: "key is required" }, { status: 400 });
|
||||
}
|
||||
if (typeof body.value !== "string") {
|
||||
return NextResponse.json({ error: "value is required" }, { status: 400 });
|
||||
}
|
||||
if (body.key.length > 200) {
|
||||
return NextResponse.json(
|
||||
{ error: "key must be ≤200 chars" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
await ensureTable();
|
||||
const valueEnc = encryptSecret(body.value);
|
||||
await query(
|
||||
`INSERT INTO fs_project_secrets (project_id, key, value_enc)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (project_id, key) DO UPDATE SET
|
||||
value_enc = EXCLUDED.value_enc,
|
||||
updated_at = NOW()`,
|
||||
[project.id, body.key.trim(), valueEnc],
|
||||
);
|
||||
log.info("project secret upserted", {
|
||||
route: "api.projects.secrets.post",
|
||||
projectId: project.id,
|
||||
key: body.key,
|
||||
});
|
||||
return NextResponse.json({ ok: true, key: body.key });
|
||||
},
|
||||
);
|
||||
@@ -1,21 +0,0 @@
|
||||
/**
|
||||
* Companion endpoint to /sentry-example-page. Throws on every call
|
||||
* so the "Throw server error" button has something real to fail
|
||||
* against. The thrown error propagates to instrumentation.ts'
|
||||
* onRequestError hook and lands in Sentry as a server-side issue.
|
||||
*/
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
class SentryExampleApiError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "SentryExampleApiError";
|
||||
}
|
||||
}
|
||||
|
||||
export function GET() {
|
||||
throw new SentryExampleApiError(
|
||||
"Sentry test (API route) — vibn-ai",
|
||||
);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { auth } from '@/lib/firebase/config';
|
||||
import { adminAuth } from '@/lib/firebase/admin';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// Get current user from client-side auth
|
||||
const user = auth.currentUser;
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Get ID token
|
||||
const token = await user.getIdToken();
|
||||
|
||||
console.log('Token length:', token.length);
|
||||
console.log('User UID:', user.uid);
|
||||
|
||||
// Try to verify it with Admin SDK
|
||||
const decodedToken = await adminAuth.verifyIdToken(token);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
clientUid: user.uid,
|
||||
decodedUid: decodedToken.uid,
|
||||
match: user.uid === decodedToken.uid,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Token verification error:', error);
|
||||
return NextResponse.json({
|
||||
error: 'Token verification failed',
|
||||
details: error instanceof Error ? error.message : String(error),
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,43 +1,90 @@
|
||||
/**
|
||||
* POST /api/webhooks/coolify?projectId={projectId}
|
||||
*
|
||||
* Receives deployment status events from Coolify.
|
||||
* Updates the project's contextSnapshot.lastDeployment in Postgres.
|
||||
* Receives deployment status events from Coolify and updates the project's
|
||||
* contextSnapshot.lastDeployment in Postgres.
|
||||
*
|
||||
* Closes S-06: signature verification was missing. Coolify (≥4.0.0-beta.300)
|
||||
* signs every webhook with HMAC-SHA256 of the raw body using the per-app
|
||||
* `webhook_secret`. We now reject unsigned / mismatched requests.
|
||||
*
|
||||
* Configure: in Coolify, set the webhook target to
|
||||
* https://vibnai.com/api/webhooks/coolify?projectId={projectId}
|
||||
* AND set the per-app webhook secret to match `COOLIFY_WEBHOOK_SECRET`.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { query } from '@/lib/db-postgres';
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { query } from "@/lib/db-postgres";
|
||||
import { verifyCoolifySignature } from "@/lib/server/coolify-webhook";
|
||||
import { log } from "@/lib/server/logger";
|
||||
|
||||
const COOLIFY_WEBHOOK_SECRET = process.env.COOLIFY_WEBHOOK_SECRET ?? "";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const projectId = request.nextUrl.searchParams.get('projectId');
|
||||
const projectId = request.nextUrl.searchParams.get("projectId");
|
||||
if (!projectId) {
|
||||
return NextResponse.json({ error: 'Missing projectId' }, { status: 400 });
|
||||
return NextResponse.json({ error: "Missing projectId" }, { status: 400 });
|
||||
}
|
||||
|
||||
let payload: any;
|
||||
if (!COOLIFY_WEBHOOK_SECRET) {
|
||||
log.error("coolify webhook: COOLIFY_WEBHOOK_SECRET not configured", {
|
||||
route: "api.webhooks.coolify",
|
||||
projectId,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: "Webhook receiver not configured" },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
// We need the RAW body for HMAC verification — `.json()` would
|
||||
// re-serialize and could change byte order. Read text first, then parse.
|
||||
const rawBody = await request.text();
|
||||
const signature = request.headers.get("x-coolify-signature-256");
|
||||
|
||||
const ok = await verifyCoolifySignature(
|
||||
rawBody,
|
||||
signature,
|
||||
COOLIFY_WEBHOOK_SECRET,
|
||||
);
|
||||
if (!ok) {
|
||||
log.warn("coolify webhook: invalid signature", {
|
||||
route: "api.webhooks.coolify",
|
||||
projectId,
|
||||
hasHeader: !!signature,
|
||||
});
|
||||
return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
|
||||
}
|
||||
|
||||
let payload: Record<string, unknown>;
|
||||
try {
|
||||
payload = await request.json();
|
||||
payload = JSON.parse(rawBody) as Record<string, unknown>;
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
|
||||
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
|
||||
const rows = await query<{ id: string; data: any }>(
|
||||
const rows = await query<{ id: string; data: Record<string, unknown> }>(
|
||||
`SELECT id, data FROM fs_projects WHERE id = $1 LIMIT 1`,
|
||||
[projectId]
|
||||
[projectId],
|
||||
);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return NextResponse.json({ error: 'Project not found' }, { status: 404 });
|
||||
return NextResponse.json({ error: "Project not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const project = rows[0];
|
||||
const existingSnapshot = project.data?.contextSnapshot ?? {};
|
||||
const existingSnapshot =
|
||||
(project.data?.contextSnapshot as Record<string, unknown>) ?? {};
|
||||
|
||||
// Coolify sends status events like: queued, in_progress, finished, failed, cancelled
|
||||
const status = payload.status ?? payload.data?.status ?? 'unknown';
|
||||
const applicationUuid = payload.application_uuid ?? payload.data?.application_uuid;
|
||||
const deploymentUuid = payload.deployment_uuid ?? payload.data?.deployment_uuid;
|
||||
const url = payload.fqdn ?? payload.data?.fqdn ?? null;
|
||||
const data = (payload.data ?? {}) as Record<string, unknown>;
|
||||
const status = (payload.status ?? data.status ?? "unknown") as string;
|
||||
const applicationUuid = (payload.application_uuid ??
|
||||
data.application_uuid) as string | undefined;
|
||||
const deploymentUuid = (payload.deployment_uuid ?? data.deployment_uuid) as
|
||||
| string
|
||||
| undefined;
|
||||
const url = (payload.fqdn ?? data.fqdn ?? null) as string | null;
|
||||
|
||||
const newSnapshot = {
|
||||
...existingSnapshot,
|
||||
@@ -51,12 +98,18 @@ export async function POST(request: NextRequest) {
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await query(`
|
||||
UPDATE fs_projects
|
||||
SET data = jsonb_set(data, '{contextSnapshot}', $1::jsonb)
|
||||
WHERE id = $2
|
||||
`, [JSON.stringify(newSnapshot), projectId]);
|
||||
await query(
|
||||
`UPDATE fs_projects
|
||||
SET data = jsonb_set(data, '{contextSnapshot}', $1::jsonb)
|
||||
WHERE id = $2`,
|
||||
[JSON.stringify(newSnapshot), projectId],
|
||||
);
|
||||
|
||||
console.log(`[webhook/coolify] deploy ${status} for project ${projectId}`);
|
||||
log.info("coolify webhook received", {
|
||||
route: "api.webhooks.coolify",
|
||||
projectId,
|
||||
status,
|
||||
deploymentUuid,
|
||||
});
|
||||
return NextResponse.json({ ok: true, status, projectId });
|
||||
}
|
||||
|
||||
@@ -1,42 +1,65 @@
|
||||
/**
|
||||
* GET /api/work-completed?projectId=<uuid>&limit=<n>
|
||||
*
|
||||
* Returns the work_completed rows for a project the caller owns.
|
||||
*
|
||||
* Closes S-05: this route used to accept any projectId off the query string
|
||||
* with no auth and silently fell back to `projectId = 1` on missing input.
|
||||
*/
|
||||
import { NextResponse } from "next/server";
|
||||
import { query } from "@/lib/db-postgres";
|
||||
import type { WorkCompleted } from "@/lib/types";
|
||||
import { withTenantProject } from "@/lib/server/api-handler";
|
||||
import { log } from "@/lib/server/logger";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
export const GET = withTenantProject(
|
||||
async (request) => {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const projectId = searchParams.get("projectId");
|
||||
const limit = searchParams.get("limit") || "20";
|
||||
|
||||
const workItems = await query<WorkCompleted>(
|
||||
`SELECT
|
||||
wc.*,
|
||||
s.session_id,
|
||||
s.primary_ai_model,
|
||||
s.duration_minutes
|
||||
FROM work_completed wc
|
||||
LEFT JOIN sessions s ON wc.session_id = s.id
|
||||
WHERE wc.project_id = $1
|
||||
ORDER BY wc.completed_at DESC
|
||||
LIMIT $2`,
|
||||
[projectId || 1, limit],
|
||||
if (!projectId) {
|
||||
return NextResponse.json(
|
||||
{ error: "projectId is required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const limit = Math.min(
|
||||
200,
|
||||
Math.max(1, parseInt(searchParams.get("limit") ?? "20", 10) || 20),
|
||||
);
|
||||
|
||||
// Parse JSON fields
|
||||
const parsedWork = workItems.map((item) => ({
|
||||
...item,
|
||||
files_modified:
|
||||
typeof item.files_modified === "string"
|
||||
? JSON.parse(item.files_modified as any)
|
||||
: item.files_modified,
|
||||
}));
|
||||
|
||||
return NextResponse.json(parsedWork);
|
||||
} catch (error) {
|
||||
console.error("Error fetching work completed:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch work completed" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
try {
|
||||
const workItems = await query<WorkCompleted>(
|
||||
`SELECT
|
||||
wc.*,
|
||||
s.session_id,
|
||||
s.primary_ai_model,
|
||||
s.duration_minutes
|
||||
FROM work_completed wc
|
||||
LEFT JOIN sessions s ON wc.session_id = s.id
|
||||
WHERE wc.project_id = $1
|
||||
ORDER BY wc.completed_at DESC
|
||||
LIMIT $2`,
|
||||
[projectId, limit],
|
||||
);
|
||||
const parsedWork = workItems.map((item) => ({
|
||||
...item,
|
||||
files_modified:
|
||||
typeof item.files_modified === "string"
|
||||
? JSON.parse(item.files_modified)
|
||||
: item.files_modified,
|
||||
}));
|
||||
return NextResponse.json(parsedWork);
|
||||
} catch (err) {
|
||||
log.error("work-completed: db error", {
|
||||
route: "api.work-completed",
|
||||
projectId,
|
||||
err: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch work completed" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
},
|
||||
{ source: "search", paramName: "projectId" },
|
||||
);
|
||||
|
||||
@@ -30,24 +30,31 @@ interface ProjectStagePillProps {
|
||||
}
|
||||
|
||||
type PillState =
|
||||
| { kind: "build_failed"; reason: string }
|
||||
| { kind: "deploying"; reason: string }
|
||||
| { kind: "down"; reason: string }
|
||||
| { kind: "live"; reason: string }
|
||||
| { kind: "empty"; reason: string };
|
||||
| { kind: "build_failed"; reason: string }
|
||||
| { kind: "deploying"; reason: string }
|
||||
| { kind: "down"; reason: string }
|
||||
| { kind: "live"; reason: string }
|
||||
| { kind: "empty"; reason: string };
|
||||
|
||||
export function ProjectStagePill({ projectId, fallbackStage }: ProjectStagePillProps) {
|
||||
export function ProjectStagePill({
|
||||
projectId,
|
||||
fallbackStage,
|
||||
}: ProjectStagePillProps) {
|
||||
const [anatomyPollMs, setAnatomyPollMs] = useState(0);
|
||||
const { anatomy, loading } = useAnatomy(projectId, { pollMs: anatomyPollMs });
|
||||
|
||||
useEffect(() => {
|
||||
if (!anatomy) {
|
||||
setAnatomyPollMs(0);
|
||||
// Don't call setState here if not needed
|
||||
if (anatomyPollMs !== 0) setAnatomyPollMs(0);
|
||||
return;
|
||||
}
|
||||
const s = derivePillState(anatomy);
|
||||
setAnatomyPollMs(s.kind === "live" || s.kind === "empty" ? 0 : 8000);
|
||||
}, [anatomy]);
|
||||
const targetPollMs = s.kind === "live" || s.kind === "empty" ? 0 : 8000;
|
||||
if (anatomyPollMs !== targetPollMs) {
|
||||
setAnatomyPollMs(targetPollMs);
|
||||
}
|
||||
}, [anatomy, anatomyPollMs]);
|
||||
|
||||
const state = useMemo<PillState | null>(() => {
|
||||
if (!anatomy) return null;
|
||||
@@ -56,11 +63,25 @@ export function ProjectStagePill({ projectId, fallbackStage }: ProjectStagePillP
|
||||
|
||||
if (loading && !anatomy) {
|
||||
const f = FALLBACK_PRESETS[fallbackStage];
|
||||
return <Pill label={f.label} color={f.color} bg={f.bg} title="Loading project status…" />;
|
||||
return (
|
||||
<Pill
|
||||
label={f.label}
|
||||
color={f.color}
|
||||
bg={f.bg}
|
||||
title="Loading project status…"
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (!state) {
|
||||
const f = FALLBACK_PRESETS[fallbackStage];
|
||||
return <Pill label={f.label} color={f.color} bg={f.bg} title="Project status unavailable." />;
|
||||
return (
|
||||
<Pill
|
||||
label={f.label}
|
||||
color={f.color}
|
||||
bg={f.bg}
|
||||
title="Project status unavailable."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const visual = VISUALS[state.kind];
|
||||
@@ -110,9 +131,13 @@ export function ProjectStagePill({ projectId, fallbackStage }: ProjectStagePillP
|
||||
rel="noreferrer"
|
||||
title={`Open Coolify build logs in a new tab`}
|
||||
style={{
|
||||
display: "inline-flex", alignItems: "center", gap: 3,
|
||||
fontSize: "0.68rem", color: logsLinkColor,
|
||||
textDecoration: "none", opacity: 0.8,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 3,
|
||||
fontSize: "0.68rem",
|
||||
color: logsLinkColor,
|
||||
textDecoration: "none",
|
||||
opacity: 0.8,
|
||||
}}
|
||||
>
|
||||
Logs <ExternalLink size={9} />
|
||||
@@ -137,7 +162,12 @@ function classifyAppStatus(raw?: string): AppPhase {
|
||||
if (!s || s === "unknown") return "unknown";
|
||||
if (/^(running|healthy)/.test(s)) return "up";
|
||||
if (/healthy/.test(s) && !/unhealthy/.test(s)) return "up";
|
||||
if (/^(starting|restarting|created|paused|deploying|building|in_progress|queued)/.test(s)) return "transient";
|
||||
if (
|
||||
/^(starting|restarting|created|paused|deploying|building|in_progress|queued)/.test(
|
||||
s,
|
||||
)
|
||||
)
|
||||
return "transient";
|
||||
if (/^(exited|dead|failed|stopped|unhealthy|error)/.test(s)) return "down";
|
||||
// Default to transient for anything unrecognised — Coolify occasionally
|
||||
// emits novel phases during upgrades; better to wait than mis-flag red.
|
||||
@@ -149,7 +179,10 @@ function derivePillState(a: Anatomy): PillState {
|
||||
const live = a.hosting?.live ?? [];
|
||||
|
||||
if (live.length === 0) {
|
||||
return { kind: "empty", reason: "No apps deployed yet. Use the chat to spin one up." };
|
||||
return {
|
||||
kind: "empty",
|
||||
reason: "No apps deployed yet. Use the chat to spin one up.",
|
||||
};
|
||||
}
|
||||
|
||||
// 1. Active build in flight — highest priority signal.
|
||||
@@ -157,12 +190,17 @@ function derivePillState(a: Anatomy): PillState {
|
||||
if (deploying.length > 0) {
|
||||
const names = deploying.map((l) => l.name).join(", ");
|
||||
const stage = deploying[0].inFlightBuild?.status ?? "in progress";
|
||||
return { kind: "deploying", reason: `Deploying ${names}\nCoolify status: ${stage}` };
|
||||
return {
|
||||
kind: "deploying",
|
||||
reason: `Deploying ${names}\nCoolify status: ${stage}`,
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Container is currently booting (starting / restarting). Surface
|
||||
// as "Deploying" since to the user this is the same wait state.
|
||||
const transient = live.filter((l) => classifyAppStatus(l.status) === "transient");
|
||||
const transient = live.filter(
|
||||
(l) => classifyAppStatus(l.status) === "transient",
|
||||
);
|
||||
if (transient.length > 0) {
|
||||
const lines = transient.map((l) => `${l.name}: ${l.status}`);
|
||||
return {
|
||||
@@ -180,9 +218,14 @@ function derivePillState(a: Anatomy): PillState {
|
||||
const lines = failed.map(
|
||||
(l) =>
|
||||
`${l.name}: ${l.lastBuild?.status}` +
|
||||
(l.lastBuild?.finishedAt ? ` · ${relTime(l.lastBuild.finishedAt)}` : ""),
|
||||
(l.lastBuild?.finishedAt
|
||||
? ` · ${relTime(l.lastBuild.finishedAt)}`
|
||||
: ""),
|
||||
);
|
||||
return { kind: "build_failed", reason: `Last deploy failed:\n${lines.join("\n")}` };
|
||||
return {
|
||||
kind: "build_failed",
|
||||
reason: `Last deploy failed:\n${lines.join("\n")}`,
|
||||
};
|
||||
}
|
||||
|
||||
const phases = live.map((l) => classifyAppStatus(l.status));
|
||||
@@ -196,10 +239,16 @@ function derivePillState(a: Anatomy): PillState {
|
||||
};
|
||||
}
|
||||
if (upCount > 0) {
|
||||
return { kind: "live", reason: `${upCount}/${live.length} services running.` };
|
||||
return {
|
||||
kind: "live",
|
||||
reason: `${upCount}/${live.length} services running.`,
|
||||
};
|
||||
}
|
||||
if (downCount > 0) {
|
||||
const sample = live.slice(0, 3).map((l) => `${l.name}: ${l.status}`).join("\n");
|
||||
const sample = live
|
||||
.slice(0, 3)
|
||||
.map((l) => `${l.name}: ${l.status}`)
|
||||
.join("\n");
|
||||
return { kind: "down", reason: `Apps are not running.\n${sample}` };
|
||||
}
|
||||
|
||||
@@ -213,42 +262,69 @@ function derivePillState(a: Anatomy): PillState {
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
const VISUALS: Record<PillState["kind"], { label: string; color: string; bg: string }> = {
|
||||
const VISUALS: Record<
|
||||
PillState["kind"],
|
||||
{ label: string; color: string; bg: string }
|
||||
> = {
|
||||
build_failed: { label: "Build failed", color: "#c5392b", bg: "#c5392b14" },
|
||||
deploying: { label: "Deploying", color: "#3d5afe", bg: "#3d5afe10" },
|
||||
down: { label: "Down", color: "#c5392b", bg: "#c5392b14" },
|
||||
live: { label: "Live", color: "#2e7d32", bg: "#2e7d3210" },
|
||||
empty: { label: "Empty", color: "#7c7770", bg: "#a09a9014" },
|
||||
deploying: { label: "Deploying", color: "#3d5afe", bg: "#3d5afe10" },
|
||||
down: { label: "Down", color: "#c5392b", bg: "#c5392b14" },
|
||||
live: { label: "Live", color: "#2e7d32", bg: "#2e7d3210" },
|
||||
empty: { label: "Empty", color: "#7c7770", bg: "#a09a9014" },
|
||||
};
|
||||
|
||||
const FALLBACK_PRESETS: Record<
|
||||
"discovery" | "architecture" | "building" | "active",
|
||||
{ label: string; color: string; bg: string }
|
||||
> = {
|
||||
discovery: { label: "Defining", color: "#9a7b3a", bg: "#d4a04a14" },
|
||||
discovery: { label: "Defining", color: "#9a7b3a", bg: "#d4a04a14" },
|
||||
architecture: { label: "Planning", color: "#3d5afe", bg: "#3d5afe10" },
|
||||
building: { label: "Building", color: "#3d5afe", bg: "#3d5afe10" },
|
||||
active: { label: "Live", color: "#2e7d32", bg: "#2e7d3210" },
|
||||
building: { label: "Building", color: "#3d5afe", bg: "#3d5afe10" },
|
||||
active: { label: "Live", color: "#2e7d32", bg: "#2e7d3210" },
|
||||
};
|
||||
|
||||
function Pill({
|
||||
label, color, bg, title, spinning,
|
||||
}: { label: string; color: string; bg: string; title?: string; spinning?: boolean }) {
|
||||
label,
|
||||
color,
|
||||
bg,
|
||||
title,
|
||||
spinning,
|
||||
}: {
|
||||
label: string;
|
||||
color: string;
|
||||
bg: string;
|
||||
title?: string;
|
||||
spinning?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
title={title}
|
||||
style={{
|
||||
display: "inline-flex", alignItems: "center", gap: 6,
|
||||
padding: "4px 10px", borderRadius: 4,
|
||||
fontSize: "0.7rem", fontWeight: 600, letterSpacing: "0.02em",
|
||||
color, background: bg, whiteSpace: "nowrap",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
padding: "4px 10px",
|
||||
borderRadius: 4,
|
||||
fontSize: "0.7rem",
|
||||
fontWeight: 600,
|
||||
letterSpacing: "0.02em",
|
||||
color,
|
||||
background: bg,
|
||||
whiteSpace: "nowrap",
|
||||
cursor: title ? "help" : "default",
|
||||
}}
|
||||
>
|
||||
{spinning ? (
|
||||
<Loader2 size={9} className="animate-spin" style={{ color }} />
|
||||
) : (
|
||||
<span style={{ width: 7, height: 7, borderRadius: "50%", background: color }} />
|
||||
<span
|
||||
style={{
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: "50%",
|
||||
background: color,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{label}
|
||||
</span>
|
||||
|
||||
@@ -9,10 +9,20 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export interface Anatomy {
|
||||
project: { id: string; name: string; gitea?: string; coolifyProjectUuid?: string };
|
||||
project: {
|
||||
id: string;
|
||||
name: string;
|
||||
gitea?: string;
|
||||
coolifyProjectUuid?: string;
|
||||
};
|
||||
codebasesReason?: "no_repo" | "empty_repo";
|
||||
product: {
|
||||
codebases: Array<{ id: string; label: string; path: string; hint?: string }>;
|
||||
codebases: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
path: string;
|
||||
hint?: string;
|
||||
}>;
|
||||
images: Array<{
|
||||
uuid: string;
|
||||
name: string;
|
||||
@@ -104,7 +114,10 @@ export interface UseAnatomyOptions {
|
||||
pollMs?: number;
|
||||
}
|
||||
|
||||
export function useAnatomy(projectId: string, options: UseAnatomyOptions = {}): UseAnatomyResult {
|
||||
export function useAnatomy(
|
||||
projectId: string,
|
||||
options: UseAnatomyOptions = {},
|
||||
): UseAnatomyResult {
|
||||
const { pollMs } = options;
|
||||
const [anatomy, setAnatomy] = useState<Anatomy | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -131,22 +144,30 @@ export function useAnatomy(projectId: string, options: UseAnatomyOptions = {}):
|
||||
fetch(`/api/projects/${projectId}/anatomy`, {
|
||||
credentials: "include",
|
||||
signal: controller.signal,
|
||||
cache: "no-store",
|
||||
})
|
||||
.then(async r => {
|
||||
.then(async (r) => {
|
||||
let body: unknown = {};
|
||||
try { body = await r.json(); } catch { /* keep {} */ }
|
||||
try {
|
||||
body = await r.json();
|
||||
} catch {
|
||||
/* keep {} */
|
||||
}
|
||||
if (!r.ok) {
|
||||
const msg = (body as { error?: string }).error || `HTTP ${r.status} ${r.statusText}`.trim();
|
||||
const msg =
|
||||
(body as { error?: string }).error ||
|
||||
`HTTP ${r.status} ${r.statusText}`.trim();
|
||||
throw new Error(msg);
|
||||
}
|
||||
return body as Anatomy;
|
||||
})
|
||||
.then(data => {
|
||||
.then((data) => {
|
||||
if (!cancelled) setAnatomy(data);
|
||||
})
|
||||
.catch(err => {
|
||||
.catch((err) => {
|
||||
if (cancelled) return;
|
||||
if (err?.name === "AbortError") setError("Request timed out after 10s.");
|
||||
if (err?.name === "AbortError")
|
||||
setError("Request timed out after 10s.");
|
||||
else setError(err?.message || "Failed to load project anatomy");
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -161,5 +182,5 @@ export function useAnatomy(projectId: string, options: UseAnatomyOptions = {}):
|
||||
};
|
||||
}, [projectId, tick]);
|
||||
|
||||
return { anatomy, loading, error, reload: () => setTick(t => t + 1) };
|
||||
return { anatomy, loading, error, reload: () => setTick((t) => t + 1) };
|
||||
}
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
/**
|
||||
* Gemini 3.1 Pro chat client with tool-calling support.
|
||||
*
|
||||
* Architecture:
|
||||
* - Tool-calling rounds use generateContent (non-streaming) so we always
|
||||
* get the complete response including thought_signature. Thinking models
|
||||
* (2.5+, 3.x) require this field to be echoed back in functionResponse
|
||||
* and it is not reliably present in individual SSE chunks.
|
||||
* - Final text-only response uses streamGenerateContent for good UX.
|
||||
*/
|
||||
import { GoogleGenAI } from '@google/genai';
|
||||
|
||||
const GEMINI_API_KEY = process.env.GOOGLE_API_KEY || "";
|
||||
const GEMINI_MODEL = process.env.VIBN_CHAT_MODEL || "gemini-3.1-pro-preview";
|
||||
const GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta";
|
||||
|
||||
if (!GEMINI_API_KEY) {
|
||||
console.warn(`[GeminiChat] WARNING: GOOGLE_API_KEY is not set. Chat stream will fail with 403 Forbidden.`);
|
||||
}
|
||||
|
||||
const ai = new GoogleGenAI({ apiKey: GEMINI_API_KEY });
|
||||
|
||||
export interface ChatMessage {
|
||||
role: "user" | "assistant" | "tool";
|
||||
@@ -26,7 +22,6 @@ export interface ToolCall {
|
||||
id: string;
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
/** Must be echoed back in functionResponse for Gemini thinking models */
|
||||
thoughtSignature?: string;
|
||||
}
|
||||
|
||||
@@ -43,7 +38,6 @@ export interface ChatChunk {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Convert our ChatMessage[] to Gemini's contents[] format */
|
||||
function toGeminiContents(messages: ChatMessage[]) {
|
||||
const contents: any[] = [];
|
||||
|
||||
@@ -55,12 +49,12 @@ function toGeminiContents(messages: ChatMessage[]) {
|
||||
if (msg.content) parts.push({ text: msg.content });
|
||||
if (msg.toolCalls?.length) {
|
||||
for (const tc of msg.toolCalls) {
|
||||
// thoughtSignature is a SIBLING of functionCall in the part object,
|
||||
// not nested inside it. See: ai.google.dev/gemini-api/docs/thought-signatures
|
||||
const part: any = {
|
||||
functionCall: { name: tc.name, args: tc.args, id: tc.id },
|
||||
functionCall: { name: tc.name, args: tc.args },
|
||||
};
|
||||
if (tc.thoughtSignature) part.thoughtSignature = tc.thoughtSignature;
|
||||
if (tc.thoughtSignature) {
|
||||
part.thoughtSignature = tc.thoughtSignature;
|
||||
}
|
||||
parts.push(part);
|
||||
}
|
||||
}
|
||||
@@ -69,8 +63,7 @@ function toGeminiContents(messages: ChatMessage[]) {
|
||||
const part = {
|
||||
functionResponse: {
|
||||
name: msg.toolName || "unknown",
|
||||
id: msg.toolCallId,
|
||||
response: { content: msg.content },
|
||||
response: { name: msg.toolName || "unknown", content: msg.content },
|
||||
},
|
||||
};
|
||||
const last = contents[contents.length - 1];
|
||||
@@ -91,44 +84,12 @@ function toGeminiFunctions(tools: ToolDefinition[]) {
|
||||
functionDeclarations: tools.map((t) => ({
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
parameters: t.parameters,
|
||||
parameters: t.parameters as any,
|
||||
})),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function buildBody(opts: {
|
||||
systemPrompt: string;
|
||||
messages: ChatMessage[];
|
||||
tools?: ToolDefinition[];
|
||||
temperature?: number;
|
||||
/**
|
||||
* Ask Gemini to return its thought summaries as parts marked
|
||||
* `thought: true`. We pay for thinking tokens regardless; this just
|
||||
* makes them visible so the UI can show "Reading server.js…",
|
||||
* "Shipping to production…" between tool calls instead of leaving
|
||||
* the user staring at a silent tool tray. Defaults to true.
|
||||
*/
|
||||
includeThoughts?: boolean;
|
||||
}) {
|
||||
const body: any = {
|
||||
contents: toGeminiContents(opts.messages),
|
||||
systemInstruction: { parts: [{ text: opts.systemPrompt }] },
|
||||
generationConfig: {
|
||||
temperature: opts.temperature ?? 0.7,
|
||||
maxOutputTokens: 8192,
|
||||
thinkingConfig: { includeThoughts: opts.includeThoughts ?? true },
|
||||
},
|
||||
};
|
||||
const fns = toGeminiFunctions(opts.tools ?? []);
|
||||
if (fns) body.tools = fns;
|
||||
return body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-streaming call — used for tool-calling rounds.
|
||||
* Returns complete response with thought_signature guaranteed.
|
||||
*/
|
||||
export async function callGeminiChat(opts: {
|
||||
systemPrompt: string;
|
||||
messages: ChatMessage[];
|
||||
@@ -137,149 +98,115 @@ export async function callGeminiChat(opts: {
|
||||
includeThoughts?: boolean;
|
||||
}): Promise<{
|
||||
text: string;
|
||||
/** First-person reasoning narration; meant for a "thinking" UI panel, not the main bubble. */
|
||||
thoughts: string;
|
||||
toolCalls: ToolCall[];
|
||||
finishReason?: string;
|
||||
error?: string;
|
||||
}> {
|
||||
const url = `${GEMINI_BASE_URL}/models/${GEMINI_MODEL}:generateContent?key=${GEMINI_API_KEY}`;
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(buildBody(opts)),
|
||||
const config: any = {
|
||||
temperature: opts.temperature ?? 0.7,
|
||||
maxOutputTokens: 8192,
|
||||
};
|
||||
|
||||
if (opts.systemPrompt) {
|
||||
config.systemInstruction = opts.systemPrompt;
|
||||
}
|
||||
|
||||
if (opts.includeThoughts) {
|
||||
config.thinkingConfig = { thinkingBudgetTokens: 1024 };
|
||||
}
|
||||
|
||||
const fns = toGeminiFunctions(opts.tools ?? []);
|
||||
if (fns) config.tools = fns;
|
||||
|
||||
const response = await ai.models.generateContent({
|
||||
model: GEMINI_MODEL,
|
||||
contents: toGeminiContents(opts.messages),
|
||||
config
|
||||
});
|
||||
} catch (e) {
|
||||
|
||||
let text = "";
|
||||
let thoughts = "";
|
||||
const toolCalls: ToolCall[] = [];
|
||||
|
||||
const parts = response.candidates?.[0]?.content?.parts ?? [];
|
||||
|
||||
for (const part of parts) {
|
||||
if (part.text) {
|
||||
if ((part as any).thought) thoughts += part.text;
|
||||
else text += part.text;
|
||||
}
|
||||
if (part.functionCall) {
|
||||
toolCalls.push({
|
||||
id: `tc-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
name: part.functionCall.name,
|
||||
args: part.functionCall.args as Record<string, unknown> ?? {},
|
||||
thoughtSignature: (part as any).thoughtSignature,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
text,
|
||||
thoughts,
|
||||
toolCalls,
|
||||
finishReason: response.candidates?.[0]?.finishReason
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
return {
|
||||
text: "",
|
||||
thoughts: "",
|
||||
toolCalls: [],
|
||||
error: `Network error: ${e instanceof Error ? e.message : String(e)}`,
|
||||
error: `GoogleGenAI error: ${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
}
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
const msg = data?.error?.message || JSON.stringify(data).slice(0, 200);
|
||||
return {
|
||||
text: "",
|
||||
thoughts: "",
|
||||
toolCalls: [],
|
||||
error: `Gemini API error ${res.status}: ${msg}`,
|
||||
};
|
||||
}
|
||||
|
||||
const cand = data?.candidates?.[0];
|
||||
const parts: any[] = cand?.content?.parts ?? [];
|
||||
let text = "";
|
||||
let thoughts = "";
|
||||
const toolCalls: ToolCall[] = [];
|
||||
|
||||
for (const part of parts) {
|
||||
if (part.text) {
|
||||
// CRITICAL: Gemini tags reasoning parts with `thought: true`. If
|
||||
// we lump them into `text` they leak into the chat bubble as if
|
||||
// they were prose for the user — which is the opposite of what
|
||||
// the user wants. Keep them in their own bucket so the route
|
||||
// can stream them as a separate SSE event type.
|
||||
if (part.thought) thoughts += part.text;
|
||||
else text += part.text;
|
||||
}
|
||||
if (part.functionCall) {
|
||||
toolCalls.push({
|
||||
id:
|
||||
part.functionCall.id ||
|
||||
`tc-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
name: part.functionCall.name,
|
||||
args: part.functionCall.args ?? {},
|
||||
// thoughtSignature is a SIBLING of functionCall in the part, not inside it
|
||||
thoughtSignature: part.thoughtSignature,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { text, thoughts, toolCalls, finishReason: cand?.finishReason };
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming call — used for the final text-only response.
|
||||
* Yields ChatChunk objects.
|
||||
*/
|
||||
export async function* streamGeminiChat(opts: {
|
||||
systemPrompt: string;
|
||||
messages: ChatMessage[];
|
||||
tools?: ToolDefinition[];
|
||||
temperature?: number;
|
||||
}): AsyncGenerator<ChatChunk> {
|
||||
const url = `${GEMINI_BASE_URL}/models/${GEMINI_MODEL}:streamGenerateContent?key=${GEMINI_API_KEY}&alt=sse`;
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(buildBody(opts)),
|
||||
const config: any = {
|
||||
temperature: opts.temperature ?? 0.7,
|
||||
maxOutputTokens: 8192,
|
||||
thinkingConfig: { thinkingBudgetTokens: 1024 },
|
||||
};
|
||||
|
||||
if (opts.systemPrompt) {
|
||||
config.systemInstruction = opts.systemPrompt;
|
||||
}
|
||||
|
||||
const fns = toGeminiFunctions(opts.tools ?? []);
|
||||
if (fns) config.tools = fns;
|
||||
|
||||
const streamResult = await ai.models.generateContentStream({
|
||||
model: GEMINI_MODEL,
|
||||
contents: toGeminiContents(opts.messages),
|
||||
config
|
||||
});
|
||||
} catch (e) {
|
||||
yield {
|
||||
type: "error",
|
||||
error: `Network error: ${e instanceof Error ? e.message : String(e)}`,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const errText = await res.text().catch(() => "");
|
||||
yield {
|
||||
type: "error",
|
||||
error: `Gemini API error ${res.status}: ${errText.slice(0, 300)}`,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = res.body?.getReader();
|
||||
if (!reader) {
|
||||
yield { type: "error", error: "No response body" };
|
||||
return;
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() ?? "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith("data: ")) continue;
|
||||
const data = line.slice(6).trim();
|
||||
if (!data || data === "[DONE]") continue;
|
||||
let chunk: any;
|
||||
try {
|
||||
chunk = JSON.parse(data);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const parts = chunk?.candidates?.[0]?.content?.parts ?? [];
|
||||
for (const part of parts) {
|
||||
if (part.text) {
|
||||
yield part.thought
|
||||
? { type: "thinking", text: part.text }
|
||||
: { type: "text", text: part.text };
|
||||
}
|
||||
for await (const chunk of streamResult) {
|
||||
const parts = chunk.candidates?.[0]?.content?.parts ?? [];
|
||||
for (const part of parts) {
|
||||
if (part.text) {
|
||||
yield (part as any).thought
|
||||
? { type: "thinking", text: part.text }
|
||||
: { type: "text", text: part.text };
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
yield { type: "done" };
|
||||
yield { type: "done" };
|
||||
|
||||
} catch (error) {
|
||||
yield {
|
||||
type: "error",
|
||||
error: `GoogleGenAI error: ${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import type { LlmClient, StructuredCallArgs } from '@/lib/ai/llm-client';
|
||||
import { GoogleGenAI } from '@google/genai';
|
||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||
|
||||
// Use the new Google GenAI SDK (replacing the deprecated VertexAI SDK)
|
||||
// Since Vertex AI gemini-3.1-pro-preview threw a 404 in your region, we use the standard AI Studio endpoint natively.
|
||||
const DEFAULT_MODEL = process.env.GEMINI_MODEL || 'gemini-3.1-pro-preview';
|
||||
const GOOGLE_API_KEY = process.env.GOOGLE_API_KEY || '';
|
||||
|
||||
const GEMINI_BASE_URL = 'https://generativelanguage.googleapis.com/v1beta/models';
|
||||
if (!GOOGLE_API_KEY) {
|
||||
console.warn(`[GeminiLlmClient] WARNING: GOOGLE_API_KEY is not set. API calls will fail with a 403 Forbidden.`);
|
||||
}
|
||||
|
||||
const ai = new GoogleGenAI({ apiKey: GOOGLE_API_KEY });
|
||||
|
||||
class JsonValidationError extends Error {
|
||||
constructor(message: string, public readonly rawResponse: string) {
|
||||
@@ -20,67 +27,12 @@ function extractJsonPayload(raw: string): string {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
async function parseResponse<TOutput>(
|
||||
rawResponse: any,
|
||||
schema: StructuredCallArgs<TOutput>['schema'],
|
||||
): Promise<TOutput> {
|
||||
let text = '';
|
||||
|
||||
const finishReason = rawResponse?.candidates?.[0]?.finishReason;
|
||||
if (finishReason && finishReason !== 'STOP') {
|
||||
console.warn(`[Gemini] WARNING: Response may be incomplete. finishReason: ${finishReason}`);
|
||||
}
|
||||
|
||||
if (rawResponse?.candidates?.[0]?.content?.parts?.[0]?.text) {
|
||||
text = rawResponse.candidates[0].content.parts[0].text;
|
||||
} else if (rawResponse?.text) {
|
||||
text = rawResponse.text;
|
||||
} else if (typeof rawResponse === 'string') {
|
||||
text = rawResponse;
|
||||
}
|
||||
|
||||
if (text.trim().startsWith('<!DOCTYPE') || text.trim().startsWith('<html')) {
|
||||
console.error('[Gemini] Received HTML — likely an API auth error');
|
||||
throw new Error('Gemini API returned HTML. Check GOOGLE_API_KEY.');
|
||||
}
|
||||
|
||||
if (!text) {
|
||||
console.error('[Gemini] Empty response:', JSON.stringify(rawResponse)?.slice(0, 300));
|
||||
throw new Error('Empty response from Gemini API');
|
||||
}
|
||||
|
||||
console.log('[Gemini] Response preview:', text.slice(0, 200));
|
||||
|
||||
const cleaned = extractJsonPayload(text);
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(cleaned);
|
||||
} catch (error) {
|
||||
throw new JsonValidationError(
|
||||
`Failed to parse JSON: ${(error as Error).message}`,
|
||||
text,
|
||||
);
|
||||
}
|
||||
|
||||
const validation = schema.safeParse(parsed);
|
||||
if (!validation.success) {
|
||||
console.error('[Gemini] Schema validation failed:', validation.error.errors);
|
||||
throw new JsonValidationError(validation.error.message, text);
|
||||
}
|
||||
|
||||
return validation.data;
|
||||
}
|
||||
|
||||
export class GeminiLlmClient implements LlmClient {
|
||||
private readonly model: string;
|
||||
|
||||
constructor() {
|
||||
this.model = DEFAULT_MODEL;
|
||||
if (!GOOGLE_API_KEY) {
|
||||
console.warn('[Gemini] WARNING: GOOGLE_API_KEY is not set');
|
||||
}
|
||||
console.log(`[Gemini] Initialized — model: ${this.model}`);
|
||||
console.log(`[GoogleGenAI] Initialized — model: ${this.model}`);
|
||||
}
|
||||
|
||||
async structuredCall<TOutput>(args: StructuredCallArgs<TOutput>): Promise<TOutput> {
|
||||
@@ -88,7 +40,6 @@ export class GeminiLlmClient implements LlmClient {
|
||||
throw new Error(`GeminiLlmClient only supports model "gemini" (got ${args.model})`);
|
||||
}
|
||||
|
||||
// Convert Zod schema → Google schema format
|
||||
const rawJsonSchema = zodToJsonSchema(args.schema, 'responseSchema') as any;
|
||||
let actualSchema: any = rawJsonSchema;
|
||||
if (rawJsonSchema.$ref && rawJsonSchema.definitions) {
|
||||
@@ -115,18 +66,16 @@ export class GeminiLlmClient implements LlmClient {
|
||||
|
||||
const googleSchema = convertToGoogleSchema(actualSchema);
|
||||
|
||||
// Build request body
|
||||
const body: any = {
|
||||
contents: args.messages.map((m) => ({
|
||||
role: m.role === 'assistant' ? 'model' : 'user',
|
||||
parts: [{ text: m.content }],
|
||||
})),
|
||||
generationConfig: {
|
||||
temperature: args.temperature ?? 1.0,
|
||||
responseMimeType: 'application/json',
|
||||
responseSchema: googleSchema,
|
||||
maxOutputTokens: 32768,
|
||||
},
|
||||
const contents = args.messages.map((m) => ({
|
||||
role: m.role === 'assistant' ? 'model' : 'user',
|
||||
parts: [{ text: m.content }],
|
||||
}));
|
||||
|
||||
const config: any = {
|
||||
temperature: args.temperature ?? 1.0,
|
||||
responseMimeType: 'application/json',
|
||||
responseSchema: googleSchema,
|
||||
maxOutputTokens: 8192,
|
||||
};
|
||||
|
||||
if (args.systemPrompt) {
|
||||
@@ -134,48 +83,64 @@ export class GeminiLlmClient implements LlmClient {
|
||||
for (const key of Object.keys(googleSchema.properties || {})) {
|
||||
exampleJson[key] = key === 'reply' ? 'Your response here' : null;
|
||||
}
|
||||
body.systemInstruction = {
|
||||
parts: [{
|
||||
text: `${args.systemPrompt}\n\nIMPERATIVE: Respond ONLY with this exact JSON format:\n${JSON.stringify(exampleJson)}\n\nDo NOT add any other fields.`,
|
||||
}],
|
||||
};
|
||||
config.systemInstruction = `${args.systemPrompt}\n\nIMPERATIVE: Respond ONLY with this exact JSON format:\n${JSON.stringify(exampleJson)}\n\nDo NOT add any other fields.`;
|
||||
}
|
||||
|
||||
if (args.thinking_config) {
|
||||
body.generationConfig.thinkingConfig = {
|
||||
thinkingLevel: args.thinking_config.thinking_level?.toUpperCase() || 'HIGH',
|
||||
includeThoughts: args.thinking_config.include_thoughts || false,
|
||||
config.thinkingConfig = {
|
||||
thinkingBudgetTokens: 1024,
|
||||
};
|
||||
}
|
||||
|
||||
const url = `${GEMINI_BASE_URL}/${this.model}:generateContent?key=${GOOGLE_API_KEY}`;
|
||||
|
||||
const run = async () => {
|
||||
console.log(`[Gemini] POST ${GEMINI_BASE_URL}/${this.model}:generateContent`);
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(180_000),
|
||||
const run = async (currentContents: any[]) => {
|
||||
console.log(`[GoogleGenAI] generateContent with ${this.model}`);
|
||||
|
||||
const response = await ai.models.generateContent({
|
||||
model: this.model,
|
||||
contents: currentContents,
|
||||
config,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Gemini API error (${response.status}): ${errorText}`);
|
||||
const text = response.text || '';
|
||||
|
||||
if (text.trim().startsWith('<!DOCTYPE') || text.trim().startsWith('<html')) {
|
||||
throw new Error('GoogleGenAI returned HTML. Check API key permissions.');
|
||||
}
|
||||
if (!text) {
|
||||
throw new Error('Empty response from GoogleGenAI');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return parseResponse(result, args.schema);
|
||||
console.log('[GoogleGenAI] Response preview:', text.slice(0, 200));
|
||||
|
||||
const cleaned = extractJsonPayload(text);
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(cleaned);
|
||||
} catch (error) {
|
||||
throw new JsonValidationError(
|
||||
`Failed to parse JSON: ${(error as Error).message}`,
|
||||
text,
|
||||
);
|
||||
}
|
||||
|
||||
const validation = args.schema.safeParse(parsed);
|
||||
if (!validation.success) {
|
||||
console.error('[GoogleGenAI] Schema validation failed:', validation.error.errors);
|
||||
throw new JsonValidationError(validation.error.message, text);
|
||||
}
|
||||
|
||||
return validation.data;
|
||||
};
|
||||
|
||||
try {
|
||||
return await run();
|
||||
return await run(contents);
|
||||
} catch (error) {
|
||||
if (!(error instanceof JsonValidationError)) throw error;
|
||||
|
||||
// Retry once on JSON parse failure
|
||||
body.contents = [
|
||||
...body.contents,
|
||||
console.warn(`[GoogleGenAI] JSON Validation failed. Retrying...`);
|
||||
const retryContents = [
|
||||
...contents,
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{
|
||||
@@ -183,7 +148,7 @@ export class GeminiLlmClient implements LlmClient {
|
||||
}],
|
||||
},
|
||||
];
|
||||
return run();
|
||||
return run(retryContents);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,8 +14,11 @@
|
||||
* happened.
|
||||
*/
|
||||
|
||||
import { runOnCoolifyHost } from '@/lib/coolify-ssh';
|
||||
import { resolveAppTargetContainer, type ComposeContainer } from '@/lib/coolify-containers';
|
||||
import { runOnCoolifyHost } from "@/lib/coolify-ssh";
|
||||
import {
|
||||
resolveAppTargetContainer,
|
||||
type ComposeContainer,
|
||||
} from "@/lib/coolify-containers";
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 60_000;
|
||||
const MAX_TIMEOUT_MS = 600_000; // 10 min — enough for migrations / seeds
|
||||
@@ -47,7 +50,7 @@ export interface ExecInAppResult {
|
||||
truncated: boolean;
|
||||
durationMs: number;
|
||||
/** Container health at time of exec (parsed from `docker ps`). */
|
||||
containerHealth: ComposeContainer['health'];
|
||||
containerHealth: ComposeContainer["health"];
|
||||
/** The command as it was actually executed (post-escape, for logs). */
|
||||
executedCommand: string;
|
||||
}
|
||||
@@ -57,9 +60,11 @@ function sq(s: string): string {
|
||||
return `'${s.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
export async function execInCoolifyApp(opts: ExecInAppOptions): Promise<ExecInAppResult> {
|
||||
if (!opts.command || typeof opts.command !== 'string') {
|
||||
throw new Error('command is required');
|
||||
export async function execInCoolifyApp(
|
||||
opts: ExecInAppOptions,
|
||||
): Promise<ExecInAppResult> {
|
||||
if (!opts.command || typeof opts.command !== "string") {
|
||||
throw new Error("command is required");
|
||||
}
|
||||
const timeoutMs = Math.min(
|
||||
Math.max(opts.timeoutMs ?? DEFAULT_TIMEOUT_MS, 1_000),
|
||||
@@ -80,14 +85,27 @@ export async function execInCoolifyApp(opts: ExecInAppOptions): Promise<ExecInAp
|
||||
if (opts.user) flags.push(`--user ${sq(opts.user)}`);
|
||||
if (opts.workdir) flags.push(`--workdir ${sq(opts.workdir)}`);
|
||||
|
||||
const executedCommand = `docker exec ${flags.join(' ')} ${sq(container.name)} sh -lc ${sq(opts.command)}`.replace(/\s+/g, ' ').trim();
|
||||
// NOTE: do NOT collapse whitespace on the outer docker-exec invocation.
|
||||
// The command payload is already single-quoted via sq(), so only the
|
||||
// docker/flag portion needs normalising — and newlines in the payload
|
||||
// itself must be preserved so multi-line bash scripts work correctly.
|
||||
// (Collapsing \n → ' ' turns `if ... ; then\n cmd\nfi` into invalid sh.)
|
||||
// Keep the trailing space before the quoted command — dash (Ubuntu's sh)
|
||||
// requires a space between -lc and its argument. Trimming it causes
|
||||
// sh to mis-parse the quoted string as flags ("Illegal option -d").
|
||||
const dockerPrefix =
|
||||
`docker exec ${flags.join(" ")} ${sq(container.name)} sh -lc `.replace(
|
||||
/ +/g,
|
||||
" ",
|
||||
);
|
||||
const executedCommand = `${dockerPrefix}${sq(opts.command)}`;
|
||||
|
||||
const startedAt = Date.now();
|
||||
// Audit log: record the command + target, NOT the output (output
|
||||
// may contain secrets). Structured so downstream log shipping can
|
||||
// parse it.
|
||||
console.log(
|
||||
'[apps.exec]',
|
||||
"[apps.exec]",
|
||||
JSON.stringify({
|
||||
app_uuid: opts.appUuid,
|
||||
container: container.name,
|
||||
|
||||
185
vibn-frontend/lib/integrations/brief-extract.ts
Normal file
185
vibn-frontend/lib/integrations/brief-extract.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Project brief extraction.
|
||||
* Closes BETA_LAUNCH_PLAN P3.7.
|
||||
*
|
||||
* When a user uploads a PDF / .md / .docx / .txt brief file, we extract
|
||||
* the text here and store it on `fs_projects.data.plan.brief`. The
|
||||
* `buildSystemPrompt` function in `app/api/chat/route.ts` then surfaces
|
||||
* it in the [PROJECT BRIEF] block.
|
||||
*
|
||||
* Supports:
|
||||
* - .txt / .md — read as-is
|
||||
* - .pdf — extract text via pdf.js (no binary install required)
|
||||
* - .docx — extract via unzipper + xml text nodes
|
||||
* - .html / .htm — strip tags
|
||||
*
|
||||
* 5 MB max, 50 000 chars after extraction (truncated with a note).
|
||||
*/
|
||||
import { query } from "@/lib/db-postgres";
|
||||
import { log } from "@/lib/server/logger";
|
||||
|
||||
export const BRIEF_MAX_CHARS = 50_000;
|
||||
export const BRIEF_MAX_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
export type BriefExtractionResult =
|
||||
| { ok: true; text: string; truncated: boolean; chars: number }
|
||||
| { ok: false; error: string };
|
||||
|
||||
/**
|
||||
* Extract plain text from a File-like object.
|
||||
* Call from `POST /api/projects/[projectId]/documents/upload`.
|
||||
*/
|
||||
export async function extractBriefText(
|
||||
buffer: Buffer,
|
||||
mimeType: string,
|
||||
filename: string,
|
||||
): Promise<BriefExtractionResult> {
|
||||
if (buffer.byteLength > BRIEF_MAX_BYTES) {
|
||||
return { ok: false, error: `File is too large (max 5 MB)` };
|
||||
}
|
||||
|
||||
try {
|
||||
let text = "";
|
||||
const lower = filename.toLowerCase();
|
||||
|
||||
if (lower.endsWith(".pdf") || mimeType === "application/pdf") {
|
||||
text = await extractPdf(buffer);
|
||||
} else if (
|
||||
lower.endsWith(".docx") ||
|
||||
mimeType ===
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
) {
|
||||
text = await extractDocx(buffer);
|
||||
} else if (lower.endsWith(".html") || lower.endsWith(".htm")) {
|
||||
text = buffer.toString("utf8").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
||||
} else {
|
||||
// .txt, .md, plain text
|
||||
text = buffer.toString("utf8");
|
||||
}
|
||||
|
||||
text = text.trim();
|
||||
const truncated = text.length > BRIEF_MAX_CHARS;
|
||||
if (truncated) {
|
||||
text =
|
||||
text.slice(0, BRIEF_MAX_CHARS) +
|
||||
`\n\n[Brief truncated at ${BRIEF_MAX_CHARS} chars — upload a shorter document for full coverage]`;
|
||||
}
|
||||
|
||||
return { ok: true, text, truncated, chars: text.length };
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Extraction failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function extractPdf(buffer: Buffer): Promise<string> {
|
||||
// Dynamic import — pdf-parse is a large optional dep.
|
||||
// If not installed, fall back to an error message.
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const pdfParse = require("pdf-parse") as (
|
||||
b: Buffer,
|
||||
) => Promise<{ text: string }>;
|
||||
const result = await pdfParse(buffer);
|
||||
return result.text;
|
||||
} catch (e: unknown) {
|
||||
if (
|
||||
e instanceof Error &&
|
||||
e.message.includes("Cannot find module")
|
||||
) {
|
||||
throw new Error(
|
||||
"pdf-parse package not installed. Run `npm install pdf-parse` or upload a .txt/.md file instead.",
|
||||
);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async function extractDocx(buffer: Buffer): Promise<string> {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { DOMParser } = require("@xmldom/xmldom") as {
|
||||
DOMParser: new () => { parseFromString(xml: string, type: string): Document };
|
||||
};
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const unzipper = require("unzipper") as {
|
||||
Open: {
|
||||
buffer(b: Buffer): Promise<{ files: Array<{ path: string; buffer(): Promise<Buffer> }> }>;
|
||||
};
|
||||
};
|
||||
|
||||
const directory = await unzipper.Open.buffer(buffer);
|
||||
const wordDoc = directory.files.find(
|
||||
(f: { path: string }) => f.path === "word/document.xml",
|
||||
);
|
||||
if (!wordDoc) throw new Error("word/document.xml not found in docx");
|
||||
|
||||
const xmlBuf = await wordDoc.buffer();
|
||||
const xml = xmlBuf.toString("utf8");
|
||||
|
||||
const doc = new DOMParser().parseFromString(xml, "text/xml");
|
||||
const texts: string[] = [];
|
||||
|
||||
function extractText(node: Node) {
|
||||
if (node.nodeType === 3 /* TEXT_NODE */) {
|
||||
const t = (node as Text).textContent?.trim();
|
||||
if (t) texts.push(t);
|
||||
}
|
||||
node.childNodes?.forEach((child: Node) => extractText(child));
|
||||
}
|
||||
extractText(doc);
|
||||
|
||||
return texts.join(" ");
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error && e.message.includes("Cannot find module")) {
|
||||
throw new Error(
|
||||
"unzipper or @xmldom/xmldom not installed. Upload a .txt or .md file instead.",
|
||||
);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the extracted brief text to `fs_projects.data.plan.brief`.
|
||||
* Called by the upload route after extraction succeeds.
|
||||
*/
|
||||
export async function persistProjectBrief(
|
||||
projectId: string,
|
||||
text: string,
|
||||
meta: { filename: string; chars: number; truncated: boolean },
|
||||
): Promise<void> {
|
||||
try {
|
||||
await query(
|
||||
`UPDATE fs_projects
|
||||
SET data = jsonb_set(
|
||||
data,
|
||||
'{plan}',
|
||||
COALESCE(data->'plan', '{}'::jsonb)
|
||||
|| jsonb_build_object(
|
||||
'brief', $1::text,
|
||||
'briefMeta', $2::jsonb
|
||||
),
|
||||
true
|
||||
)
|
||||
WHERE id = $3`,
|
||||
[
|
||||
text,
|
||||
JSON.stringify({
|
||||
...meta,
|
||||
uploadedAt: new Date().toISOString(),
|
||||
}),
|
||||
projectId,
|
||||
],
|
||||
);
|
||||
log.info("project brief persisted", { projectId, chars: meta.chars });
|
||||
} catch (err) {
|
||||
log.error("brief persist failed", {
|
||||
projectId,
|
||||
err: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
233
vibn-frontend/lib/server/api-handler.ts
Normal file
233
vibn-frontend/lib/server/api-handler.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* API route wrappers.
|
||||
*
|
||||
* Replaces the 8-line auth + ownership boilerplate that every route file
|
||||
* was reimplementing (and getting subtly wrong in 18 of them — see the
|
||||
* 2026-05-17 QA pass).
|
||||
*
|
||||
* Usage:
|
||||
*
|
||||
* // Plain session-required handler.
|
||||
* export const GET = withAuth(async (req, ctx, { user }) => {
|
||||
* return NextResponse.json({ email: user.email });
|
||||
* });
|
||||
*
|
||||
* // Session + tenant-project ownership in one wrapper.
|
||||
* export const POST = withTenantProject(async (req, ctx, { user, project }) => {
|
||||
* // `project` is guaranteed to belong to `user`.
|
||||
* }, { paramName: 'projectId' });
|
||||
*
|
||||
* // Workspace-scoped (session OR vibn_sk_ api key OK).
|
||||
* export const POST = withWorkspace(async (req, ctx, { principal }) => {
|
||||
* // `principal.workspace` is guaranteed to be tenant-checked.
|
||||
* });
|
||||
*
|
||||
* // Admin secret (ops endpoint).
|
||||
* export const POST = withAdminSecret(async (req, ctx) => { … }, {
|
||||
* secretEnvVar: 'ADMIN_MIGRATE_SECRET',
|
||||
* });
|
||||
*/
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import type { Session } from "next-auth";
|
||||
import { authSession } from "@/lib/auth/session-server";
|
||||
import { queryOne } from "@/lib/db-postgres";
|
||||
import { requireWorkspacePrincipal } from "@/lib/auth/workspace-auth";
|
||||
import { timingSafeStringEq } from "@/lib/server/timing-safe";
|
||||
import { rateLimit, type RateLimitOpts } from "@/lib/server/rate-limit";
|
||||
|
||||
type WithAuthCtx = { user: NonNullable<Session["user"]> };
|
||||
type WithTenantProjectCtx = WithAuthCtx & { project: ProjectRow };
|
||||
type WithAdminCtx = { secret: string };
|
||||
|
||||
export interface ProjectRow {
|
||||
id: string;
|
||||
data: Record<string, unknown>;
|
||||
slug?: string;
|
||||
}
|
||||
|
||||
type RouteHandler<TCtx = unknown, TParams = unknown> = (
|
||||
req: Request,
|
||||
ctx: { params: Promise<TParams> },
|
||||
extra: TCtx,
|
||||
) => Promise<Response> | Response;
|
||||
|
||||
// ─── withAuth ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function withAuth<TParams = unknown>(
|
||||
handler: RouteHandler<WithAuthCtx, TParams>,
|
||||
) {
|
||||
return async (req: Request, ctx: { params: Promise<TParams> }) => {
|
||||
const session = await authSession();
|
||||
if (!session?.user?.email) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
return handler(req, ctx, { user: session.user });
|
||||
};
|
||||
}
|
||||
|
||||
// ─── withTenantProject ────────────────────────────────────────────────────
|
||||
|
||||
export interface WithTenantProjectOpts {
|
||||
/**
|
||||
* Where to find the project id. Default `'projectId'` in `params`.
|
||||
* - 'params:projectId' → ctx.params.projectId (default for `[projectId]` routes)
|
||||
* - 'search:projectId' → searchParams.projectId
|
||||
* - 'body:projectId' → body.projectId (consumes body via clone+json)
|
||||
*/
|
||||
source?: "params" | "search" | "body";
|
||||
paramName?: string;
|
||||
}
|
||||
|
||||
export function withTenantProject<TParams = Record<string, string>>(
|
||||
handler: RouteHandler<WithTenantProjectCtx, TParams>,
|
||||
opts: WithTenantProjectOpts = {},
|
||||
) {
|
||||
const source = opts.source ?? "params";
|
||||
const name = opts.paramName ?? "projectId";
|
||||
|
||||
return async (req: Request, ctx: { params: Promise<TParams> }) => {
|
||||
const session = await authSession();
|
||||
if (!session?.user?.email) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
let projectId: string | undefined;
|
||||
if (source === "params") {
|
||||
const p = (await ctx.params) as Record<string, string>;
|
||||
projectId = p?.[name];
|
||||
} else if (source === "search") {
|
||||
projectId = new URL(req.url).searchParams.get(name) ?? undefined;
|
||||
} else if (source === "body") {
|
||||
try {
|
||||
const body = await req.clone().json();
|
||||
projectId = body?.[name];
|
||||
} catch {
|
||||
// fallthrough; caller will get 400 below
|
||||
}
|
||||
}
|
||||
|
||||
if (!projectId) {
|
||||
return NextResponse.json(
|
||||
{ error: `${name} is required` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Ownership check: project must belong to the authenticated user.
|
||||
const row = await queryOne<ProjectRow>(
|
||||
`SELECT p.id::text AS id, p.data
|
||||
FROM fs_projects p
|
||||
JOIN fs_users u ON u.id = p.user_id
|
||||
WHERE p.id = $1::text AND u.data->>'email' = $2::text
|
||||
LIMIT 1`,
|
||||
[projectId, session.user.email],
|
||||
);
|
||||
|
||||
if (!row) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return handler(req, ctx, { user: session.user, project: row });
|
||||
};
|
||||
}
|
||||
|
||||
// ─── withWorkspace (re-export of existing helper with consistent shape) ──
|
||||
|
||||
type WorkspacePrincipal = Exclude<
|
||||
Awaited<ReturnType<typeof requireWorkspacePrincipal>>,
|
||||
NextResponse
|
||||
>;
|
||||
|
||||
export function withWorkspace<TParams = Record<string, string>>(
|
||||
handler: RouteHandler<{ principal: WorkspacePrincipal }, TParams>,
|
||||
opts: { paramName?: string } = {},
|
||||
) {
|
||||
const name = opts.paramName ?? "slug";
|
||||
return async (req: Request, ctx: { params: Promise<TParams> }) => {
|
||||
const params = (await ctx.params) as Record<string, string> | undefined;
|
||||
const targetSlug = params?.[name];
|
||||
const principal = await requireWorkspacePrincipal(req, { targetSlug });
|
||||
if (principal instanceof NextResponse) return principal;
|
||||
return handler(req, ctx, { principal });
|
||||
};
|
||||
}
|
||||
|
||||
// ─── withAdminSecret ──────────────────────────────────────────────────────
|
||||
|
||||
export interface WithAdminSecretOpts {
|
||||
/** env var that holds the expected secret. */
|
||||
secretEnvVar: string;
|
||||
/** Header to read. Default `authorization` (expects `Bearer <secret>`). */
|
||||
header?: string;
|
||||
/** Alternate header that may also carry the secret (e.g. `x-admin-secret`). */
|
||||
altHeader?: string;
|
||||
}
|
||||
|
||||
export function withAdminSecret<TParams = unknown>(
|
||||
handler: RouteHandler<WithAdminCtx, TParams>,
|
||||
opts: WithAdminSecretOpts,
|
||||
) {
|
||||
return async (req: Request, ctx: { params: Promise<TParams> }) => {
|
||||
const expected = process.env[opts.secretEnvVar]?.trim() ?? "";
|
||||
if (!expected) {
|
||||
return NextResponse.json(
|
||||
{ error: `${opts.secretEnvVar} not configured — endpoint disabled` },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
const header = (opts.header ?? "authorization").toLowerCase();
|
||||
const raw = req.headers.get(header) ?? "";
|
||||
const bearer = raw.toLowerCase().startsWith("bearer ")
|
||||
? raw.slice(7).trim()
|
||||
: "";
|
||||
const alt = opts.altHeader
|
||||
? (req.headers.get(opts.altHeader) ?? "").trim()
|
||||
: "";
|
||||
const incoming = bearer || alt;
|
||||
if (!incoming || !timingSafeStringEq(expected, incoming)) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
return handler(req, ctx, { secret: expected });
|
||||
};
|
||||
}
|
||||
|
||||
// ─── withRateLimit ────────────────────────────────────────────────────────
|
||||
|
||||
export interface WithRateLimitOpts extends Omit<RateLimitOpts, "key"> {
|
||||
/**
|
||||
* How to derive the per-call key. Receives the bound auth context if any.
|
||||
* Default: client IP.
|
||||
*/
|
||||
keyFn?: (req: Request, extra: unknown) => string | Promise<string>;
|
||||
}
|
||||
|
||||
/** Wrap any other wrapper's handler. Composes neatly with withAuth/withWorkspace. */
|
||||
export function withRateLimit<THandler extends (...args: any[]) => any>(
|
||||
handler: THandler,
|
||||
opts: WithRateLimitOpts,
|
||||
): THandler {
|
||||
return (async (req: Request, ...rest: unknown[]) => {
|
||||
const extra = rest[1] ?? {};
|
||||
const key =
|
||||
(opts.keyFn ? await opts.keyFn(req, extra) : null) ??
|
||||
`ip:${req.headers.get("x-forwarded-for") ?? "unknown"}`;
|
||||
const rl = await rateLimit({
|
||||
key,
|
||||
limit: opts.limit,
|
||||
windowMs: opts.windowMs,
|
||||
});
|
||||
if (!rl.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: "Rate limit exceeded", retryAfterMs: rl.retryAfterMs },
|
||||
{
|
||||
status: 429,
|
||||
headers: rl.retryAfterMs
|
||||
? { "Retry-After": String(Math.ceil(rl.retryAfterMs / 1000)) }
|
||||
: undefined,
|
||||
},
|
||||
);
|
||||
}
|
||||
return handler(req, ...rest);
|
||||
}) as THandler;
|
||||
}
|
||||
145
vibn-frontend/lib/server/audit-log.ts
Normal file
145
vibn-frontend/lib/server/audit-log.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Workspace-scoped audit log of mutating operations.
|
||||
*
|
||||
* Closes BETA_LAUNCH_PLAN P4.7: "Per-workspace audit log of mutating MCP calls".
|
||||
*
|
||||
* Schema:
|
||||
* audit_log
|
||||
* id BIGSERIAL PK
|
||||
* ts TIMESTAMPTZ DEFAULT NOW()
|
||||
* workspace TEXT NOT NULL -- workspace slug
|
||||
* user_email TEXT -- caller; null for runner/system
|
||||
* source TEXT NOT NULL -- 'session' | 'api_key' | 'system' | 'webhook'
|
||||
* action TEXT NOT NULL -- 'apps.create' | 'databases.delete' | …
|
||||
* resource_type TEXT -- 'application' | 'database' | 'project' | …
|
||||
* resource_id TEXT -- coolify uuid / project id / etc.
|
||||
* ok BOOLEAN NOT NULL
|
||||
* params JSONB -- redacted call params
|
||||
* error TEXT
|
||||
* turn_id TEXT -- correlation id (chat turn etc.)
|
||||
*
|
||||
* Read via `SELECT … WHERE workspace = $1 ORDER BY ts DESC LIMIT N`.
|
||||
*
|
||||
* SECRETS: never write raw credentials into `params`. The helper redacts
|
||||
* the standard secret-shaped keys (`api_key`, `password`, `token`, `secret`,
|
||||
* `private_key`, `credential`). Callers are still responsible for not
|
||||
* passing sensitive blobs through unfiltered.
|
||||
*/
|
||||
import { getPool, query } from "@/lib/db-postgres";
|
||||
import { log } from "@/lib/server/logger";
|
||||
|
||||
let tableReady = false;
|
||||
|
||||
async function ensureTable() {
|
||||
if (tableReady) return;
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
workspace TEXT NOT NULL,
|
||||
user_email TEXT,
|
||||
source TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
resource_type TEXT,
|
||||
resource_id TEXT,
|
||||
ok BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
params JSONB,
|
||||
error TEXT,
|
||||
turn_id TEXT
|
||||
)
|
||||
`);
|
||||
await query(`CREATE INDEX IF NOT EXISTS audit_log_workspace_ts_idx ON audit_log (workspace, ts DESC)`);
|
||||
await query(`CREATE INDEX IF NOT EXISTS audit_log_action_ts_idx ON audit_log (action, ts DESC)`);
|
||||
tableReady = true;
|
||||
}
|
||||
|
||||
const SECRET_KEYS = new Set([
|
||||
"api_key", "apiKey",
|
||||
"password",
|
||||
"token", "access_token", "refresh_token",
|
||||
"secret",
|
||||
"private_key", "privateKey",
|
||||
"credential", "credentials",
|
||||
"authorization",
|
||||
]);
|
||||
|
||||
function redact(obj: unknown, depth = 0): unknown {
|
||||
if (depth > 4) return "[deep]";
|
||||
if (obj == null) return obj;
|
||||
if (Array.isArray(obj)) return obj.map((x) => redact(x, depth + 1));
|
||||
if (typeof obj === "object") {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
|
||||
out[k] = SECRET_KEYS.has(k) ? "[redacted]" : redact(v, depth + 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
if (typeof obj === "string" && obj.length > 2048) return obj.slice(0, 2048) + "…";
|
||||
return obj;
|
||||
}
|
||||
|
||||
export interface AuditLogEntry {
|
||||
workspace: string;
|
||||
userEmail?: string | null;
|
||||
source: "session" | "api_key" | "system" | "webhook";
|
||||
action: string;
|
||||
resourceType?: string;
|
||||
resourceId?: string;
|
||||
ok: boolean;
|
||||
params?: Record<string, unknown>;
|
||||
error?: string;
|
||||
turnId?: string;
|
||||
}
|
||||
|
||||
/** Best-effort: never throw out of the audit path. */
|
||||
export async function writeAuditLog(entry: AuditLogEntry): Promise<void> {
|
||||
try {
|
||||
await ensureTable();
|
||||
await query(
|
||||
`INSERT INTO audit_log
|
||||
(workspace, user_email, source, action, resource_type, resource_id, ok, params, error, turn_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, $10)`,
|
||||
[
|
||||
entry.workspace,
|
||||
entry.userEmail ?? null,
|
||||
entry.source,
|
||||
entry.action,
|
||||
entry.resourceType ?? null,
|
||||
entry.resourceId ?? null,
|
||||
entry.ok,
|
||||
entry.params ? JSON.stringify(redact(entry.params)) : null,
|
||||
entry.error ?? null,
|
||||
entry.turnId ?? null,
|
||||
],
|
||||
);
|
||||
} catch (err) {
|
||||
log.warn("audit-log write failed (non-fatal)", {
|
||||
action: entry.action,
|
||||
err: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function listAuditLog(opts: {
|
||||
workspace: string;
|
||||
limit?: number;
|
||||
action?: string;
|
||||
}): Promise<unknown[]> {
|
||||
await ensureTable();
|
||||
const limit = Math.min(500, Math.max(1, opts.limit ?? 100));
|
||||
if (opts.action) {
|
||||
return query(
|
||||
`SELECT id, ts, user_email, source, action, resource_type, resource_id, ok, params, error, turn_id
|
||||
FROM audit_log WHERE workspace = $1 AND action = $2 ORDER BY ts DESC LIMIT $3`,
|
||||
[opts.workspace, opts.action, limit],
|
||||
);
|
||||
}
|
||||
return query(
|
||||
`SELECT id, ts, user_email, source, action, resource_type, resource_id, ok, params, error, turn_id
|
||||
FROM audit_log WHERE workspace = $1 ORDER BY ts DESC LIMIT $2`,
|
||||
[opts.workspace, limit],
|
||||
);
|
||||
}
|
||||
|
||||
// Re-export for tests / migration scripts
|
||||
export { getPool };
|
||||
40
vibn-frontend/lib/server/coolify-webhook.ts
Normal file
40
vibn-frontend/lib/server/coolify-webhook.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Coolify webhook signature verification.
|
||||
*
|
||||
* Coolify (≥ 4.0.0-beta.300) signs every webhook with HMAC-SHA256 of the
|
||||
* raw body using the per-app `webhook_secret`. The signature is sent in
|
||||
* the `X-Coolify-Signature-256` header as `sha256=<hex>`.
|
||||
*
|
||||
* If the per-app secret is not set, Coolify sends the body unsigned. In
|
||||
* that case we reject the call: every prod deploy MUST set a secret.
|
||||
*
|
||||
* Mirrors the pattern in `lib/gitea.ts:verifyWebhookSignature`.
|
||||
*/
|
||||
|
||||
import { timingSafeStringEq } from "@/lib/server/timing-safe";
|
||||
|
||||
export async function verifyCoolifySignature(
|
||||
body: string,
|
||||
signatureHeader: string | null,
|
||||
secret: string,
|
||||
): Promise<boolean> {
|
||||
if (!secret) return false;
|
||||
if (!signatureHeader?.startsWith("sha256=")) return false;
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const key = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
encoder.encode(secret),
|
||||
{ name: "HMAC", hash: "SHA-256" },
|
||||
false,
|
||||
["sign"],
|
||||
);
|
||||
const sigBytes = await crypto.subtle.sign("HMAC", key, encoder.encode(body));
|
||||
const expected =
|
||||
"sha256=" +
|
||||
Array.from(new Uint8Array(sigBytes))
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
|
||||
return timingSafeStringEq(expected, signatureHeader);
|
||||
}
|
||||
140
vibn-frontend/lib/server/dev-server-state.ts
Normal file
140
vibn-frontend/lib/server/dev-server-state.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Persistent dev-server configuration store.
|
||||
* Closes BETA_LAUNCH_PLAN P6.B1.
|
||||
*
|
||||
* When `dev_server_start` succeeds, the MCP tool should call
|
||||
* `upsertDevServerConfig` so the project page can auto-resume the
|
||||
* server on next mount without requiring the user to re-type the
|
||||
* command (see P6.B2 for the auto-resume hook).
|
||||
*
|
||||
* Schema:
|
||||
* fs_project_dev_servers
|
||||
* project_id UUID PK → fs_projects.id
|
||||
* command TEXT NOT NULL e.g. "cd myapp && npm run dev"
|
||||
* port INT NOT NULL e.g. 3000
|
||||
* framework TEXT e.g. "nextjs", "vite", "express"
|
||||
* preview_url TEXT last known *.preview.vibnai.com URL
|
||||
* last_started_at TIMESTAMPTZ
|
||||
* status TEXT CHECK IN ('running','stopped','crashed')
|
||||
* updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
*/
|
||||
|
||||
import { query } from "@/lib/db-postgres";
|
||||
import { log } from "@/lib/server/logger";
|
||||
|
||||
let tableReady = false;
|
||||
async function ensureTable() {
|
||||
if (tableReady) return;
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS fs_project_dev_servers (
|
||||
project_id TEXT PRIMARY KEY,
|
||||
command TEXT NOT NULL,
|
||||
port INT NOT NULL,
|
||||
framework TEXT,
|
||||
preview_url TEXT,
|
||||
last_started_at TIMESTAMPTZ,
|
||||
status TEXT NOT NULL DEFAULT 'stopped'
|
||||
CHECK (status IN ('running', 'stopped', 'crashed')),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
`);
|
||||
tableReady = true;
|
||||
}
|
||||
|
||||
export interface DevServerConfig {
|
||||
projectId: string;
|
||||
command: string;
|
||||
port: number;
|
||||
framework?: string;
|
||||
previewUrl?: string;
|
||||
status: "running" | "stopped" | "crashed";
|
||||
}
|
||||
|
||||
/** Called by the MCP dev_server_start handler after a successful start. */
|
||||
export async function upsertDevServerConfig(
|
||||
cfg: DevServerConfig,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await ensureTable();
|
||||
await query(
|
||||
`INSERT INTO fs_project_dev_servers
|
||||
(project_id, command, port, framework, preview_url, last_started_at, status, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, NOW(), $6, NOW())
|
||||
ON CONFLICT (project_id) DO UPDATE SET
|
||||
command = EXCLUDED.command,
|
||||
port = EXCLUDED.port,
|
||||
framework = COALESCE(EXCLUDED.framework, fs_project_dev_servers.framework),
|
||||
preview_url = COALESCE(EXCLUDED.preview_url, fs_project_dev_servers.preview_url),
|
||||
last_started_at = NOW(),
|
||||
status = EXCLUDED.status,
|
||||
updated_at = NOW()`,
|
||||
[
|
||||
cfg.projectId,
|
||||
cfg.command,
|
||||
cfg.port,
|
||||
cfg.framework ?? null,
|
||||
cfg.previewUrl ?? null,
|
||||
cfg.status,
|
||||
],
|
||||
);
|
||||
} catch (err) {
|
||||
log.warn("dev-server-state: upsert failed (non-fatal)", {
|
||||
projectId: cfg.projectId,
|
||||
err: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Update just the status (e.g. on stop / crash). */
|
||||
export async function setDevServerStatus(
|
||||
projectId: string,
|
||||
status: "running" | "stopped" | "crashed",
|
||||
): Promise<void> {
|
||||
try {
|
||||
await ensureTable();
|
||||
await query(
|
||||
`UPDATE fs_project_dev_servers
|
||||
SET status = $2, updated_at = NOW()
|
||||
WHERE project_id = $1`,
|
||||
[projectId, status],
|
||||
);
|
||||
} catch (err) {
|
||||
log.warn("dev-server-state: status update failed (non-fatal)", {
|
||||
projectId,
|
||||
err: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the last-known dev server config for a project, or null. */
|
||||
export async function getDevServerConfig(
|
||||
projectId: string,
|
||||
): Promise<DevServerConfig | null> {
|
||||
try {
|
||||
await ensureTable();
|
||||
const rows = await query<{
|
||||
project_id: string;
|
||||
command: string;
|
||||
port: number;
|
||||
framework: string | null;
|
||||
preview_url: string | null;
|
||||
status: string;
|
||||
}>(
|
||||
`SELECT project_id, command, port, framework, preview_url, status
|
||||
FROM fs_project_dev_servers WHERE project_id = $1`,
|
||||
[projectId],
|
||||
);
|
||||
if (!rows[0]) return null;
|
||||
const r = rows[0];
|
||||
return {
|
||||
projectId: r.project_id,
|
||||
command: r.command,
|
||||
port: r.port,
|
||||
framework: r.framework ?? undefined,
|
||||
previewUrl: r.preview_url ?? undefined,
|
||||
status: r.status as "running" | "stopped" | "crashed",
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
71
vibn-frontend/lib/server/logger.ts
Normal file
71
vibn-frontend/lib/server/logger.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Structured logger for API handlers.
|
||||
*
|
||||
* Why this exists: `console.log` everywhere in 159 route files is impossible
|
||||
* to grep through. With a request-scoped `turnId` (or any other correlation
|
||||
* id), prod incidents go from "search by guess" to "ripgrep one ID, get the
|
||||
* whole timeline."
|
||||
*
|
||||
* Output shape (single line, JSON, prod-friendly):
|
||||
* {"ts":"2026-05-17T20:00:00.000Z","level":"info","route":"api.chat",
|
||||
* "turnId":"…","projectId":"…","user":"mark@…","msg":"…","ctx":{…}}
|
||||
*
|
||||
* In dev, we prefix with a coloured tag and pretty-print `ctx` for eyeball-
|
||||
* ability. In prod, single-line JSON so a log shipper can parse it.
|
||||
*/
|
||||
|
||||
export type LogLevel = "debug" | "info" | "warn" | "error";
|
||||
|
||||
export interface LogContext {
|
||||
route?: string;
|
||||
turnId?: string;
|
||||
projectId?: string;
|
||||
workspaceSlug?: string;
|
||||
user?: string;
|
||||
userId?: string;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
const isDev = process.env.NODE_ENV !== "production";
|
||||
|
||||
function emit(level: LogLevel, msg: string, ctx: LogContext = {}) {
|
||||
const ts = new Date().toISOString();
|
||||
const { route, turnId, projectId, workspaceSlug, user, userId, ...rest } = ctx;
|
||||
const base = { ts, level, msg, route, turnId, projectId, workspaceSlug, user, userId };
|
||||
if (isDev) {
|
||||
const tag = level === "error" ? "✗" : level === "warn" ? "!" : level === "debug" ? "·" : "→";
|
||||
const idStr = turnId ? ` [${turnId.slice(0, 8)}]` : "";
|
||||
const routeStr = route ? ` ${route}` : "";
|
||||
const extra = Object.keys(rest).length ? ` ${JSON.stringify(rest)}` : "";
|
||||
// eslint-disable-next-line no-console
|
||||
console[level === "debug" ? "log" : level](`${tag}${routeStr}${idStr} ${msg}${extra}`);
|
||||
return;
|
||||
}
|
||||
const line = JSON.stringify({ ...base, ...rest });
|
||||
// eslint-disable-next-line no-console
|
||||
(console[level === "debug" ? "log" : level] as (m: string) => void)(line);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a logger pre-bound to a request context. Pass through `info()` /
|
||||
* `warn()` / `error()` / `debug()` and every line carries the same
|
||||
* `turnId` / `projectId` / `route`.
|
||||
*/
|
||||
export function makeLogger(base: LogContext) {
|
||||
return {
|
||||
debug: (msg: string, ctx: LogContext = {}) => emit("debug", msg, { ...base, ...ctx }),
|
||||
info: (msg: string, ctx: LogContext = {}) => emit("info", msg, { ...base, ...ctx }),
|
||||
warn: (msg: string, ctx: LogContext = {}) => emit("warn", msg, { ...base, ...ctx }),
|
||||
error: (msg: string, ctx: LogContext = {}) => emit("error", msg, { ...base, ...ctx }),
|
||||
child(extra: LogContext) {
|
||||
return makeLogger({ ...base, ...extra });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const log = {
|
||||
debug: (msg: string, ctx: LogContext = {}) => emit("debug", msg, ctx),
|
||||
info: (msg: string, ctx: LogContext = {}) => emit("info", msg, ctx),
|
||||
warn: (msg: string, ctx: LogContext = {}) => emit("warn", msg, ctx),
|
||||
error: (msg: string, ctx: LogContext = {}) => emit("error", msg, ctx),
|
||||
};
|
||||
91
vibn-frontend/lib/server/rate-limit.ts
Normal file
91
vibn-frontend/lib/server/rate-limit.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Postgres-backed sliding-window rate limiter.
|
||||
*
|
||||
* Designed for "small N, simple shape": a few thousand keys/min across the
|
||||
* platform, single primary, no Redis dependency to keep beta infra tight.
|
||||
* If we outgrow this, swap the storage backend without changing call sites.
|
||||
*
|
||||
* Schema (auto-created):
|
||||
* rate_limit_log (key TEXT, ts TIMESTAMPTZ DEFAULT NOW())
|
||||
* index on (key, ts DESC)
|
||||
*
|
||||
* Algorithm:
|
||||
* 1. Cleanup older rows for this key (best-effort, capped).
|
||||
* 2. Count remaining rows in window.
|
||||
* 3. If under limit, INSERT a row and return {ok: true, remaining}.
|
||||
* 4. Else return {ok: false, retryAfterMs}.
|
||||
*
|
||||
* NOT race-free across nodes — that's deliberate for cost. If you need
|
||||
* hard quotas (e.g. billing-tier caps), use `lib/quotas.ts` instead.
|
||||
*/
|
||||
|
||||
import { query } from "@/lib/db-postgres";
|
||||
import { log } from "@/lib/server/logger";
|
||||
|
||||
let tableReady = false;
|
||||
async function ensureTable() {
|
||||
if (tableReady) return;
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS rate_limit_log (
|
||||
key TEXT NOT NULL,
|
||||
ts TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
`);
|
||||
await query(`CREATE INDEX IF NOT EXISTS rate_limit_log_key_ts_idx ON rate_limit_log (key, ts DESC)`);
|
||||
tableReady = true;
|
||||
}
|
||||
|
||||
export interface RateLimitOpts {
|
||||
/** Identity key — e.g. `chat:user@x.com`, `mcp:ws=mark:tool=apps_create`. Required. */
|
||||
key: string;
|
||||
/** Max calls inside the window. Default 60. */
|
||||
limit?: number;
|
||||
/** Window in ms. Default 60_000 (1 min). */
|
||||
windowMs?: number;
|
||||
}
|
||||
|
||||
export interface RateLimitResult {
|
||||
ok: boolean;
|
||||
remaining: number;
|
||||
retryAfterMs?: number;
|
||||
}
|
||||
|
||||
export async function rateLimit(opts: RateLimitOpts): Promise<RateLimitResult> {
|
||||
const limit = opts.limit ?? 60;
|
||||
const windowMs = opts.windowMs ?? 60_000;
|
||||
try {
|
||||
await ensureTable();
|
||||
// 1. Sweep stale rows for this key (cheap; index is `(key, ts DESC)`).
|
||||
await query(
|
||||
`DELETE FROM rate_limit_log WHERE key = $1 AND ts < NOW() - $2::interval`,
|
||||
[opts.key, `${Math.ceil(windowMs / 1000)} seconds`],
|
||||
);
|
||||
// 2. Count remaining.
|
||||
const rows = await query<{ n: string }>(
|
||||
`SELECT COUNT(*)::text AS n FROM rate_limit_log WHERE key = $1`,
|
||||
[opts.key],
|
||||
);
|
||||
const used = Number(rows[0]?.n ?? "0");
|
||||
if (used >= limit) {
|
||||
// Find oldest row in window to compute retry-after.
|
||||
const oldest = await query<{ ts: string }>(
|
||||
`SELECT ts FROM rate_limit_log WHERE key = $1 ORDER BY ts ASC LIMIT 1`,
|
||||
[opts.key],
|
||||
);
|
||||
const oldestMs = oldest[0]?.ts ? new Date(oldest[0].ts).getTime() : Date.now();
|
||||
const retryAfterMs = Math.max(0, oldestMs + windowMs - Date.now());
|
||||
return { ok: false, remaining: 0, retryAfterMs };
|
||||
}
|
||||
await query(`INSERT INTO rate_limit_log (key) VALUES ($1)`, [opts.key]);
|
||||
return { ok: true, remaining: Math.max(0, limit - used - 1) };
|
||||
} catch (err) {
|
||||
// Fail-open on DB problems — better than locking everyone out of chat
|
||||
// when Postgres has a hiccup. The downside (unbounded calls during the
|
||||
// outage) is acceptable for beta scale.
|
||||
log.warn("rate-limit DB unavailable, failing open", {
|
||||
key: opts.key,
|
||||
err: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
return { ok: true, remaining: limit };
|
||||
}
|
||||
}
|
||||
26
vibn-frontend/lib/server/timing-safe.ts
Normal file
26
vibn-frontend/lib/server/timing-safe.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Constant-time string comparison.
|
||||
*
|
||||
* Use this for every admin-secret / bearer-token / HMAC comparison. Naive
|
||||
* `a === b` short-circuits on the first byte mismatch, leaking length
|
||||
* information that an attacker can use to slow-search the secret.
|
||||
*
|
||||
* `crypto.timingSafeEqual` requires equal-length buffers and runs in
|
||||
* constant time. We normalise to UTF-8 buffers, pad shorter to longer
|
||||
* with zero bytes so length mismatch is also constant-time, and OR a
|
||||
* length-mismatch flag at the end so different lengths can't return true.
|
||||
*/
|
||||
import { timingSafeEqual } from "crypto";
|
||||
|
||||
export function timingSafeStringEq(a: string, b: string): boolean {
|
||||
const aBuf = Buffer.from(a, "utf8");
|
||||
const bBuf = Buffer.from(b, "utf8");
|
||||
const max = Math.max(aBuf.length, bBuf.length);
|
||||
const aPadded = Buffer.alloc(max);
|
||||
const bPadded = Buffer.alloc(max);
|
||||
aBuf.copy(aPadded);
|
||||
bBuf.copy(bPadded);
|
||||
const equal = timingSafeEqual(aPadded, bPadded);
|
||||
// Length mismatch defeats the compare even if padded prefixes happen to match.
|
||||
return equal && aBuf.length === bBuf.length;
|
||||
}
|
||||
@@ -9,7 +9,10 @@ const turbopackRoot = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// Google OAuth on localhost: NextAuth must build the same callback URL Google expects.
|
||||
// If NEXTAUTH_URL is unset in dev, default it (set explicitly if you use 127.0.0.1 or another port).
|
||||
if (process.env.NODE_ENV === "development" && !process.env.NEXTAUTH_URL?.trim()) {
|
||||
if (
|
||||
process.env.NODE_ENV === "development" &&
|
||||
!process.env.NEXTAUTH_URL?.trim()
|
||||
) {
|
||||
process.env.NEXTAUTH_URL = "http://localhost:3000";
|
||||
}
|
||||
|
||||
@@ -21,7 +24,19 @@ const nextConfig: NextConfig = {
|
||||
// ssh2 ships native .node binaries; turbopack can't bundle them
|
||||
// ("non-ecmascript placeable asset"). Externalize so they're loaded
|
||||
// at runtime via Node's require, the same way @prisma/client works.
|
||||
serverExternalPackages: ["@prisma/client", "prisma", "ssh2", "cpu-features", "pdf-parse"],
|
||||
serverExternalPackages: [
|
||||
"@prisma/client",
|
||||
"prisma",
|
||||
"ssh2",
|
||||
"cpu-features",
|
||||
"pdf-parse",
|
||||
// Prevents Turbopack from bundling these packages and hitting the
|
||||
// import-in-the-middle version mismatch warning on every request.
|
||||
// Both ship a nested @opentelemetry/instrumentation@0.212 that wants
|
||||
// iitm@2.x, but the project root has iitm@3.x.
|
||||
"@fastify/otel",
|
||||
"@prisma/instrumentation",
|
||||
],
|
||||
// react-markdown and its entire unified/remark/rehype ecosystem are
|
||||
// ESM-only (type:"module", no CJS fallback). Next.js webpack can't
|
||||
// resolve them without explicit transpilation — manifests as
|
||||
|
||||
Reference in New Issue
Block a user