diff --git a/docker-compose.yml b/docker-compose.yml index afdd696..aea8895 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,6 @@ version: '3.8' services: app: - platform: linux/arm64/v8 build: context: . target: development @@ -39,8 +38,7 @@ services: - redis db: - platform: linux/arm64/v8 - image: postgres:16 + image: postgres:latest ports: - "5432:5432" environment: @@ -60,8 +58,7 @@ services: retries: 5 redis: - platform: linux/arm64/v8 - image: redis:7 + image: redis:latest ports: - "6379:6379" volumes: @@ -79,7 +76,6 @@ services: redis-insight: image: redislabs/redisinsight:latest - platform: linux/arm64/v8 # For M1/M2 Macs ports: - "8001:8001" depends_on: diff --git a/package.json b/package.json index bc887ef..67bfd95 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-icons": "^1.3.0", + "@radix-ui/react-progress": "^1.0.3", "@radix-ui/react-slot": "^1.0.2", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", @@ -25,6 +26,7 @@ "googleapis": "^144.0.0", "ioredis": "^5.4.2", "lucide-react": "^0.309.0", + "nanoid": "^5.1.0", "next": "14.1.0", "next-auth": "^4.24.11", "next-themes": "^0.2.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0fe93c3..600cf6b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: '@radix-ui/react-icons': specifier: ^1.3.0 version: 1.3.2(react@18.3.1) + '@radix-ui/react-progress': + specifier: ^1.0.3 + version: 1.1.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-slot': specifier: ^1.0.2 version: 1.1.1(@types/react@18.3.18)(react@18.3.1) @@ -53,6 +56,9 @@ importers: lucide-react: specifier: ^0.309.0 version: 0.309.0(react@18.3.1) + nanoid: + specifier: ^5.1.0 + version: 5.1.0 next: specifier: 14.1.0 version: 14.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -575,6 +581,32 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-primitive@2.0.2': + resolution: {integrity: sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-progress@1.1.2': + resolution: {integrity: sha512-u1IgJFQ4zNAUTjGdDL5dcl/U8ntOR6jsnhxKb5RKp5Ozwl88xKR9EqRZOe/Mk8tnx0x5tNUe2F+MzsyjqMg0MA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-roving-focus@1.1.1': resolution: {integrity: sha512-QE1RoxPGJ/Nm8Qmk0PxP8ojmoaS67i0s7hVssS7KuI2FQoc/uzVlZsqKfQvxPE6D8hICCPHJ4D88zNhT3OOmkw==} peerDependencies: @@ -597,6 +629,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-slot@1.1.2': + resolution: {integrity: sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-callback-ref@1.1.0': resolution: {integrity: sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==} peerDependencies: @@ -1971,6 +2012,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@5.1.0: + resolution: {integrity: sha512-zDAl/llz8Ue/EblwSYwdxGBYfj46IM1dhjVi8dyp9LQffoIGxJEAHj2oeZ4uNcgycSRcQ83CnfcZqEJzVDLcDw==} + engines: {node: ^18 || >=20} + hasBin: true + natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} @@ -3264,6 +3310,25 @@ snapshots: '@types/react': 18.3.18 '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-primitive@2.0.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-slot': 1.1.2(@types/react@18.3.18)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + '@types/react-dom': 18.3.5(@types/react@18.3.18) + + '@radix-ui/react-progress@1.1.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-context': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-roving-focus@1.1.1(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.1 @@ -3288,6 +3353,13 @@ snapshots: optionalDependencies: '@types/react': 18.3.18 + '@radix-ui/react-slot@1.1.2(@types/react@18.3.18)(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.1(@types/react@18.3.18)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.18 + '@radix-ui/react-use-callback-ref@1.1.0(@types/react@18.3.18)(react@18.3.1)': dependencies: react: 18.3.1 @@ -5001,6 +5073,8 @@ snapshots: nanoid@3.3.8: {} + nanoid@5.1.0: {} + natural-compare@1.4.0: {} next-auth@4.24.11(next@14.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1): diff --git a/prisma/migrations/20250219004029_add_usage_model/migration.sql b/prisma/migrations/20250219004029_add_usage_model/migration.sql new file mode 100644 index 0000000..1b45902 --- /dev/null +++ b/prisma/migrations/20250219004029_add_usage_model/migration.sql @@ -0,0 +1,70 @@ +-- CreateEnum +CREATE TYPE "UsageType" AS ENUM ('AUDIO_GENERATION', 'DOCUMENT_PROCESSING', 'CONTEXT_TOKENS'); + +-- AlterTable +ALTER TABLE "User" ADD COLUMN "credits" INTEGER NOT NULL DEFAULT 100, +ADD COLUMN "isGuest" BOOLEAN NOT NULL DEFAULT false, +ALTER COLUMN "clerkId" DROP NOT NULL; + +-- AlterTable +ALTER TABLE "_BookmarkedNotebooks" ADD CONSTRAINT "_BookmarkedNotebooks_AB_pkey" PRIMARY KEY ("A", "B"); + +-- DropIndex +DROP INDEX "_BookmarkedNotebooks_AB_unique"; + +-- AlterTable +ALTER TABLE "_NoteToTag" ADD CONSTRAINT "_NoteToTag_AB_pkey" PRIMARY KEY ("A", "B"); + +-- DropIndex +DROP INDEX "_NoteToTag_AB_unique"; + +-- AlterTable +ALTER TABLE "_NotebookToTag" ADD CONSTRAINT "_NotebookToTag_AB_pkey" PRIMARY KEY ("A", "B"); + +-- DropIndex +DROP INDEX "_NotebookToTag_AB_unique"; + +-- AlterTable +ALTER TABLE "_SharedNotebooks" ADD CONSTRAINT "_SharedNotebooks_AB_pkey" PRIMARY KEY ("A", "B"); + +-- DropIndex +DROP INDEX "_SharedNotebooks_AB_unique"; + +-- CreateTable +CREATE TABLE "CreditHistory" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "amount" INTEGER NOT NULL, + "operation" TEXT NOT NULL, + "description" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "CreditHistory_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Usage" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "type" "UsageType" NOT NULL, + "amount" INTEGER NOT NULL, + "notebookId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Usage_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "CreditHistory_userId_idx" ON "CreditHistory"("userId"); + +-- CreateIndex +CREATE INDEX "Usage_userId_idx" ON "Usage"("userId"); + +-- CreateIndex +CREATE INDEX "Usage_type_idx" ON "Usage"("type"); + +-- AddForeignKey +ALTER TABLE "CreditHistory" ADD CONSTRAINT "CreditHistory_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Usage" ADD CONSTRAINT "Usage_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml index fbffa92..648c57f 100644 --- a/prisma/migrations/migration_lock.toml +++ b/prisma/migrations/migration_lock.toml @@ -1,3 +1,3 @@ # Please do not edit this file manually -# It should be added in your version-control system (i.e. Git) +# It should be added in your version-control system (e.g., Git) provider = "postgresql" \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6cd8d3b..59dfcf9 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1,9 +1,3 @@ -// This is your Prisma schema file, -// learn more about it in the docs: https://pris.ly/d/prisma-schema - -// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions? -// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init - generator client { provider = "prisma-client-js" } @@ -14,127 +8,154 @@ datasource db { } model User { - id String @id @default(cuid()) - clerkId String @unique - email String @unique - name String? - notebooks Notebook[] - bookmarks Notebook[] @relation("BookmarkedNotebooks") - sharedWithMe Notebook[] @relation("SharedNotebooks") - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + clerkId String? @unique + email String @unique + name String? + isGuest Boolean @default(false) + credits Int @default(100) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + notebooks Notebook[] + bookmarks Notebook[] @relation("BookmarkedNotebooks") + sharedWithMe Notebook[] @relation("SharedNotebooks") + creditHistory CreditHistory[] + usages Usage[] } -model Notebook { - id String @id @default(cuid()) - title String - description String? - content String? @db.Text - user User @relation(fields: [userId], references: [id], onDelete: Cascade) +model CreditHistory { + id String @id @default(cuid()) userId String - bookmarkedBy User[] @relation("BookmarkedNotebooks") - sharedWith User[] @relation("SharedNotebooks") - isPublic Boolean @default(false) - sources Source[] - notes Note[] - chats Chat[] - tags Tag[] - isArchived Boolean @default(false) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + amount Int + operation String // "ADD", "SUBTRACT" + description String + createdAt DateTime @default(now()) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) +} + +model Notebook { + id String @id @default(cuid()) + title String + description String? + userId String + isArchived Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + content String? + isPublic Boolean @default(false) + chats Chat[] + notes Note[] + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + sources Source[] + bookmarkedBy User[] @relation("BookmarkedNotebooks") + tags Tag[] @relation("NotebookToTag") + sharedWith User[] @relation("SharedNotebooks") @@index([userId]) } model Source { - id String @id @default(cuid()) - title String - content String @db.Text - type SourceType - url String? - notebook Notebook @relation(fields: [notebookId], references: [id], onDelete: Cascade) - notebookId String - chunks Chunk[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + title String + content String + type SourceType + url String? + notebookId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + chunks Chunk[] + notebook Notebook @relation(fields: [notebookId], references: [id], onDelete: Cascade) @@index([notebookId]) } model Chunk { - id String @id @default(cuid()) - content String @db.Text - embedding Json? // Vector embedding for semantic search - source Source @relation(fields: [sourceId], references: [id], onDelete: Cascade) - sourceId String - startIndex Int - endIndex Int - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + content String + embedding Json? + sourceId String + startIndex Int + endIndex Int + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + source Source @relation(fields: [sourceId], references: [id], onDelete: Cascade) @@index([sourceId]) } model Chat { - id String @id @default(cuid()) - title String? - notebook Notebook @relation(fields: [notebookId], references: [id], onDelete: Cascade) - notebookId String - messages Message[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + title String? + notebookId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + notebook Notebook @relation(fields: [notebookId], references: [id], onDelete: Cascade) + messages Message[] @@index([notebookId]) } model Message { - id String @id @default(cuid()) - content String @db.Text - role Role - chat Chat @relation(fields: [chatId], references: [id], onDelete: Cascade) - chatId String - citations Citation[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + content String + role Role + chatId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + citations Citation[] + chat Chat @relation(fields: [chatId], references: [id], onDelete: Cascade) @@index([chatId]) } model Citation { - id String @id @default(cuid()) - message Message @relation(fields: [messageId], references: [id], onDelete: Cascade) - messageId String - chunkId String - startIndex Int - endIndex Int - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + messageId String + chunkId String + startIndex Int + endIndex Int + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + message Message @relation(fields: [messageId], references: [id], onDelete: Cascade) @@index([messageId]) } model Note { - id String @id @default(cuid()) - title String - content String @db.Text - notebook Notebook @relation(fields: [notebookId], references: [id], onDelete: Cascade) - notebookId String - tags Tag[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + title String + content String + notebookId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + notebook Notebook @relation(fields: [notebookId], references: [id], onDelete: Cascade) + tags Tag[] @relation("NoteToTag") @@index([notebookId]) } model Tag { - id String @id @default(cuid()) - name String - notebooks Notebook[] - notes Note[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + name String @unique + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + notes Note[] @relation("NoteToTag") + notebooks Notebook[] @relation("NotebookToTag") +} + +model Usage { + id String @id @default(cuid()) + userId String + type UsageType + amount Int + notebookId String? + createdAt DateTime @default(now()) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) - @@unique([name]) + @@index([userId]) + @@index([type]) } enum SourceType { @@ -150,3 +171,9 @@ enum Role { ASSISTANT SYSTEM } + +enum UsageType { + AUDIO_GENERATION + DOCUMENT_PROCESSING + CONTEXT_TOKENS +} diff --git a/scripts/dev-setup.sh b/scripts/dev-setup.sh new file mode 100755 index 0000000..350bafd --- /dev/null +++ b/scripts/dev-setup.sh @@ -0,0 +1,47 @@ +#!/bin/bash + +# Create .env file if it doesn't exist +if [ ! -f .env ]; then + echo "Creating .env file..." + cat > .env << EOL +DATABASE_URL="postgresql://postgres:postgres@localhost:5432/openbooklm" +REDIS_URL="redis://localhost:6379" +# Add your Clerk keys here +NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY="" +CLERK_SECRET_KEY="" +# Add your AI API keys here +CEREBRAS_API_KEY="" +LLAMA_API_KEY="" +OPENAI_API_KEY="" +EOL + echo "Created .env file. Please fill in your API keys." +fi + +# Start PostgreSQL and Redis containers +echo "Starting PostgreSQL and Redis..." +docker-compose up -d db redis + +# Wait for PostgreSQL to be ready +echo "Waiting for PostgreSQL to be ready..." +until docker-compose exec db pg_isready -U postgres; do + echo "PostgreSQL is unavailable - sleeping" + sleep 1 +done + +# Run database migrations +echo "Running database migrations..." +pnpm prisma migrate dev + +# Install dependencies if node_modules doesn't exist +if [ ! -d "node_modules" ]; then + echo "Installing dependencies..." + pnpm install +fi + +echo "Development environment is ready!" +echo "You can now run 'pnpm dev' to start the development server." +echo "" +echo "Database connection: postgresql://postgres:postgres@localhost:5432/openbooklm" +echo "Redis connection: redis://localhost:6379" +echo "" +echo "Don't forget to add your API keys to the .env file!" diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index ca067c0..564d007 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -5,6 +5,9 @@ import Cerebras from "@cerebras/cerebras_cloud_sdk"; import { setChatHistory } from "@/lib/redis-utils"; import { prisma } from "@/lib/db"; import { DEFAULT_MODEL, MODEL_SETTINGS } from "@/lib/ai-config"; +import { getOrCreateUser } from "@/lib/auth"; +import { CreditManager } from "@/lib/credit-manager"; +import { UsageType } from "@prisma/client"; const getClient = () => { if (!process.env.CEREBRAS_API_KEY) { @@ -26,8 +29,8 @@ interface ResponseChunk { export async function POST(req: Request) { try { - const { userId } = await auth(); - if (!userId) { + const user = await getOrCreateUser(); + if (!user) { return new NextResponse("Unauthorized", { status: 401 }); } @@ -42,6 +45,23 @@ export async function POST(req: Request) { ); } + // Calculate token usage (approximate) + const totalTokens = messages.reduce((sum, msg) => sum + msg.content.length / 4, 0); + + // Check credit availability + const hasCredits = await CreditManager.checkCredits( + user.id, + UsageType.CONTEXT_TOKENS, + totalTokens + ); + + if (!hasCredits) { + return NextResponse.json( + { error: "Insufficient credits" }, + { status: 402 } + ); + } + // Save to database first const chat = await prisma.chat.create({ data: { @@ -55,8 +75,16 @@ export async function POST(req: Request) { }, }); + // Use credits + await CreditManager.useCredits( + user.id, + UsageType.CONTEXT_TOKENS, + totalTokens, + notebookId + ); + // Then store in Redis - await setChatHistory(userId, notebookId, messages); + await setChatHistory(user.id, notebookId, messages); const client = getClient(); console.log('Sending request with messages:', messages); diff --git a/src/app/api/credits/usage/route.ts b/src/app/api/credits/usage/route.ts new file mode 100644 index 0000000..eabce33 --- /dev/null +++ b/src/app/api/credits/usage/route.ts @@ -0,0 +1,24 @@ +import { getOrCreateUser } from "@/lib/auth"; +import { CreditManager } from "@/lib/credit-manager"; +import { NextResponse } from "next/server"; + +export async function GET() { + try { + const user = await getOrCreateUser(); + if (!user) { + return NextResponse.json( + { error: "Unauthorized" }, + { status: 401 } + ); + } + + const usageSummary = await CreditManager.getUsageSummary(user.id); + return NextResponse.json(usageSummary); + } catch (error) { + console.error("Error fetching credit usage:", error); + return NextResponse.json( + { error: "Failed to fetch credit usage" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/notebooks/[id]/audio/route.ts b/src/app/api/notebooks/[id]/audio/route.ts index d71ec44..54e69aa 100644 --- a/src/app/api/notebooks/[id]/audio/route.ts +++ b/src/app/api/notebooks/[id]/audio/route.ts @@ -1,6 +1,8 @@ import { getOrCreateUser } from "@/lib/auth"; import { prisma } from "@/lib/db"; import { NextResponse } from "next/server"; +import { CreditManager } from "@/lib/credit-manager"; +import { UsageType } from "@prisma/client"; export async function POST( req: Request, @@ -33,6 +35,20 @@ export async function POST( // Get the conversation data from the request const { conversation } = await req.json(); + // Check credit availability for audio generation + const hasCredits = await CreditManager.checkCredits( + user.id, + UsageType.AUDIO_GENERATION, + 1 // Each generation counts as 1 credit + ); + + if (!hasCredits) { + return NextResponse.json( + { error: "Insufficient credits for audio generation" }, + { status: 402 } + ); + } + // Call your Python backend to generate audio const response = await fetch( "http://170.187.161.93:8000/api/generate_audio", @@ -49,17 +65,21 @@ export async function POST( ); if (!response.ok) { - const error = await response.json(); - return NextResponse.json( - { error: error.message || "Failed to generate audio" }, - { status: response.status } - ); + throw new Error("Failed to generate audio"); } + // Use the credit after successful generation + await CreditManager.useCredits( + user.id, + UsageType.AUDIO_GENERATION, + 1, + params.id + ); + const data = await response.json(); return NextResponse.json(data); } catch (error) { - console.error("[AUDIO_GENERATION]", error); + console.error("Error generating audio:", error); return NextResponse.json( { error: "Failed to generate audio" }, { status: 500 } diff --git a/src/app/api/user/guest/route.ts b/src/app/api/user/guest/route.ts new file mode 100644 index 0000000..7d3b2ea --- /dev/null +++ b/src/app/api/user/guest/route.ts @@ -0,0 +1,15 @@ +import { NextResponse } from "next/server"; +import { getOrCreateUser } from "@/lib/auth"; + +export async function POST() { + try { + const user = await getOrCreateUser(); + return NextResponse.json(user); + } catch (error) { + console.error("Error creating guest user:", error); + return NextResponse.json( + { error: "Failed to create guest user" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/user/route.ts b/src/app/api/user/route.ts new file mode 100644 index 0000000..0bf0a12 --- /dev/null +++ b/src/app/api/user/route.ts @@ -0,0 +1,28 @@ +import { getOrCreateUser } from "@/lib/auth"; +import { NextResponse } from "next/server"; + +export async function GET() { + try { + const user = await getOrCreateUser(); + if (!user) { + return NextResponse.json( + { error: "Unauthorized" }, + { status: 401 } + ); + } + + // Only return safe user data + return NextResponse.json({ + id: user.id, + name: user.name, + isGuest: user.isGuest, + createdAt: user.createdAt, + }); + } catch (error) { + console.error("Error fetching user:", error); + return NextResponse.json( + { error: "Failed to fetch user data" }, + { status: 500 } + ); + } +} diff --git a/src/components/credit-status.tsx b/src/components/credit-status.tsx new file mode 100644 index 0000000..a2bb2cc --- /dev/null +++ b/src/components/credit-status.tsx @@ -0,0 +1,149 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { UsageType } from "@prisma/client"; +import { Progress } from "@/components/ui/progress"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { AlertCircle, Coins, ChevronDown } from "lucide-react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; + +interface UsageSummary { + type: UsageType; + used: number; + limit: number; +} + +export function CreditStatus() { + const [usageSummary, setUsageSummary] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchUsage = async () => { + try { + const response = await fetch("/api/credits/usage"); + if (!response.ok) { + throw new Error("Failed to fetch usage data"); + } + const data = await response.json(); + if (!Array.isArray(data)) { + throw new Error("Invalid usage data format"); + } + setUsageSummary(data); + } catch (err) { + setError("Failed to load credit usage"); + console.error(err); + } finally { + setLoading(false); + } + }; + + fetchUsage(); + const interval = setInterval(fetchUsage, 60000); + return () => clearInterval(interval); + }, []); + + const getUsageColor = (used: number, limit: number) => { + const percentage = (used / limit) * 100; + if (percentage >= 90) return "bg-destructive"; + if (percentage >= 70) return "bg-warning"; + return "bg-primary"; + }; + + const formatUsageType = (type: UsageType) => { + return type + .split("_") + .map((word) => word.charAt(0) + word.slice(1).toLowerCase()) + .join(" "); + }; + + const getAvailableCredits = () => { + if (!usageSummary.length) return 0; + + // Only count non-token credits + const relevantTypes = usageSummary.filter( + (usage) => usage.type !== "CONTEXT_TOKENS" + ); + + return relevantTypes.reduce((total, usage) => { + return total + (usage.limit - usage.used); + }, 0); + }; + + if (loading) { + return ( + + ); + } + + if (error) { + return ( + + ); + } + + const availableCredits = getAvailableCredits(); + + return ( + + + + + + + Credit Usage Details + +
+ {usageSummary.map((usage) => ( + + + + {formatUsageType(usage.type)} + + + +
+
+ Used: {usage.used} + Available: {usage.limit - usage.used} +
+ + {usage.used >= usage.limit * 0.9 && ( +

+ + Approaching limit +

+ )} +
+
+
+ ))} +
+ Total Credits Available: {availableCredits} +
+
+
+
+ ); +} diff --git a/src/components/github-stats.tsx b/src/components/github-stats.tsx index 2335011..453da90 100644 --- a/src/components/github-stats.tsx +++ b/src/components/github-stats.tsx @@ -2,12 +2,16 @@ import { useEffect, useState } from "react"; import { Star, GitFork } from "lucide-react"; +import Link from "next/link"; interface GitHubStats { stars: number; forks: number; + fullName: string; } +const REPO_URL = "https://github.com/open-biz/OpenBookLM"; + export function GitHubStats() { const [stats, setStats] = useState(null); const [error, setError] = useState(null); @@ -23,6 +27,7 @@ export function GitHubStats() { setStats({ stars: data.stargazers_count, forks: data.forks_count, + fullName: data.full_name, }); } catch (err) { setError("Failed to load stats"); @@ -38,14 +43,27 @@ export function GitHubStats() { return (
-
+ {stats.fullName} + {stats.stars} -
-
+ + {stats.forks} -
+
); } diff --git a/src/components/guest-mode-indicator.tsx b/src/components/guest-mode-indicator.tsx new file mode 100644 index 0000000..8c228e8 --- /dev/null +++ b/src/components/guest-mode-indicator.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { SignInButton } from "@clerk/nextjs"; +import { User } from "@prisma/client"; + +export function GuestModeIndicator() { + const [user, setUser] = useState(null); + + useEffect(() => { + const fetchUser = async () => { + try { + const response = await fetch("/api/user"); + if (!response.ok) { + throw new Error("Failed to fetch user"); + } + const data = await response.json(); + setUser(data); + } catch (error) { + console.error("Error fetching user:", error); + } + }; + + fetchUser(); + }, []); + + if (!user?.isGuest) { + return null; + } + + return ( + + Guest Mode + +

+ You are currently using OpenBookLM in guest mode. This means you have + limited access to features and credits: +

+
    +
  • Audio Generation: 10 credits
  • +
  • Document Processing: 20 credits
  • +
  • Context Window: 4,000 tokens
  • +
  • 7-day history retention
  • +
+
+ + + +
+
+
+ ); +} diff --git a/src/components/root-layout.tsx b/src/components/root-layout.tsx index a11fb74..3c8d5fe 100644 --- a/src/components/root-layout.tsx +++ b/src/components/root-layout.tsx @@ -7,77 +7,74 @@ import { CreateNotebookDialog } from "@/components/create-notebook-dialog"; import { SignInButton, useAuth, UserButton } from "@clerk/nextjs"; import { GitHubStats } from "@/components/github-stats"; import Image from "next/image"; +import { CreditStatus } from "@/components/credit-status"; +import { GuestModeIndicator } from "@/components/guest-mode-indicator"; interface RootLayoutProps { children: React.ReactNode; } export function RootLayout({ children }: RootLayoutProps) { - const { isSignedIn } = useAuth(); + const { userId, isSignedIn } = useAuth(); return ( -
- {/* Global Header */} -
-
- - OpenBookLM Logo -

OpenBookLM

- -
-
- - - open-biz/OpenBookLM - - - {isSignedIn ? ( - <> - - - - ) : ( - - - - )} +
+ +
+ + + + {isSignedIn ? ( + <> + + + ) : ( + + + + )} + +
- - {/* Main Content */} -
{children}
+
+
+ {userId && } + {children} +
+
); } diff --git a/src/components/ui/progress.tsx b/src/components/ui/progress.tsx new file mode 100644 index 0000000..4c05524 --- /dev/null +++ b/src/components/ui/progress.tsx @@ -0,0 +1,27 @@ +"use client"; + +import * as React from "react"; +import * as ProgressPrimitive from "@radix-ui/react-progress"; +import { cn } from "@/lib/utils"; + +const Progress = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, value, ...props }, ref) => ( + + + +)); +Progress.displayName = ProgressPrimitive.Root.displayName; + +export { Progress }; diff --git a/src/lib/auth.ts b/src/lib/auth.ts index b1e5541..541ffa9 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,28 +1,56 @@ import { auth, currentUser } from "@clerk/nextjs/server"; import { prisma } from "./db"; +import { CreditManager } from "./credit-manager"; +import { nanoid } from "nanoid"; export async function getOrCreateUser() { const { userId } = await auth(); - if (!userId) { - return null; + + // Handle authenticated users + if (userId) { + let user = await prisma.user.findUnique({ + where: { clerkId: userId }, + }); + + if (!user) { + const clerkUser = await currentUser(); + user = await prisma.user.create({ + data: { + clerkId: userId, + email: clerkUser?.emailAddresses[0]?.emailAddress || "placeholder@example.com", + name: clerkUser?.firstName || "New User", + isGuest: false, + }, + }); + } + + return user; } - let user = await prisma.user.findUnique({ - where: { clerkId: userId }, + // Handle guest users + const guestId = nanoid(); + const guestUser = await prisma.user.create({ + data: { + email: `guest_${guestId}@openbooklm.com`, + name: "Guest User", + isGuest: true, + }, }); - if (!user) { - const clerkUser = await currentUser(); - user = await prisma.user.create({ - data: { - clerkId: userId, - email: - clerkUser?.emailAddresses[0]?.emailAddress || - "placeholder@example.com", - name: clerkUser?.firstName || "New User", - }, + // Initialize guest credits + await CreditManager.initializeGuestCredits(guestUser.id); + + return guestUser; +} + +export async function getCurrentUser() { + const { userId } = await auth(); + + if (userId) { + return prisma.user.findUnique({ + where: { clerkId: userId }, }); } - - return user; + + return null; } diff --git a/src/lib/credit-manager.ts b/src/lib/credit-manager.ts new file mode 100644 index 0000000..d49e387 --- /dev/null +++ b/src/lib/credit-manager.ts @@ -0,0 +1,153 @@ +import { prisma } from "./db"; +import { UsageType } from "@prisma/client"; + +const CREDIT_LIMITS = { + GUEST: { + AUDIO_GENERATION: 10, + DOCUMENT_PROCESSING: 20, + CONTEXT_TOKENS: 4000, + }, + USER: { + AUDIO_GENERATION: 50, + DOCUMENT_PROCESSING: 100, + CONTEXT_TOKENS: 16000, + }, +}; + +const GUEST_INITIAL_CREDITS = 100; +const GUEST_EXPIRY_DAYS = 7; + +const HISTORY_RETENTION_DAYS = { + GUEST: 7, + USER: 30, +}; + +export class CreditManager { + static async initializeGuestCredits(userId: string) { + await prisma.user.update({ + where: { id: userId }, + data: { + credits: GUEST_INITIAL_CREDITS, + creditHistory: { + create: { + amount: GUEST_INITIAL_CREDITS, + operation: "ADD", + description: "Initial guest credits", + }, + }, + }, + }); + } + + static async checkCredits( + userId: string, + usageType: UsageType, + amount: number + ): Promise { + const user = await prisma.user.findUnique({ + where: { id: userId }, + }); + + if (!user) return false; + + // Get monthly usage + const startOfMonth = new Date(); + startOfMonth.setDate(1); + startOfMonth.setHours(0, 0, 0, 0); + + const monthlyUsage = await prisma.usage.aggregate({ + where: { + userId, + type: usageType, + createdAt: { gte: startOfMonth }, + }, + _sum: { amount: true }, + }); + + const currentUsage = monthlyUsage._sum.amount || 0; + const limit = user.isGuest + ? CREDIT_LIMITS.GUEST[usageType] + : CREDIT_LIMITS.USER[usageType]; + + if (currentUsage + amount > limit) { + return false; + } + + return user.credits >= amount; + } + + static async useCredits( + userId: string, + usageType: UsageType, + amount: number, + notebookId?: string + ): Promise { + if (!(await this.checkCredits(userId, usageType, amount))) { + return false; + } + + await prisma.$transaction(async (tx) => { + // Record usage + await tx.usage.create({ + data: { + userId, + type: usageType, + amount, + notebookId, + }, + }); + + // Deduct credits + await tx.user.update({ + where: { id: userId }, + data: { credits: { decrement: amount } }, + }); + + // Record credit history + await tx.creditHistory.create({ + data: { + userId, + amount, + operation: "SUBTRACT", + description: `Used ${amount} credits for ${usageType}`, + }, + }); + }); + + return true; + } + + static async getUsageSummary(userId: string) { + const startOfMonth = new Date(); + startOfMonth.setDate(1); + startOfMonth.setHours(0, 0, 0, 0); + + const usage = await prisma.usage.groupBy({ + by: ["type"], + where: { + userId, + createdAt: { gte: startOfMonth }, + }, + _sum: { amount: true }, + }); + + const user = await prisma.user.findUnique({ + where: { id: userId }, + }); + + if (!user) { + throw new Error("User not found"); + } + + const limits = user.isGuest ? CREDIT_LIMITS.GUEST : CREDIT_LIMITS.USER; + + return Object.values(UsageType).map((type) => { + const typeUsage = usage.find((u) => u.type === type); + return { + type, + used: typeUsage?._sum.amount || 0, + limit: limits[type], + }; + }); + } +} diff --git a/src/middleware.ts b/src/middleware.ts index 302583c..1a4d658 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,9 +1,50 @@ -// Temporarily disabled Clerk authentication -import { clerkMiddleware } from "@clerk/nextjs/server"; +import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server"; +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; -export default clerkMiddleware({}); +// Define protected routes using createRouteMatcher +const protectedRoutes = createRouteMatcher([ + '/api/chat(.*)', + '/api/notebooks(.*)', + '/api/credits(.*)', + '/api/user(.*)', +]); +// Define public paths that don't require authentication +const publicPaths = [ + "/", + "/sign-in*", + "/sign-up*", + "/api/webhooks*", + "/api/trpc*", +]; + +const isPublic = (path: string) => { + return publicPaths.find((x) => + path.match(new RegExp(`^${x}$`.replace("*$", "($|/)"))) + ); +}; + +export default clerkMiddleware(async (auth, req) => { + const path = req.nextUrl.pathname; + + if (isPublic(path)) { + return NextResponse.next(); + } + + if (protectedRoutes(req)) { + await auth.protect(); + } + + return NextResponse.next(); +}); + +// Stop Middleware running on static files export const config = { - matcher: ["/((?!.+\\.[\\w]+$|_next).*)", "/", "/(api|trpc)(.*)"], - runtime: 'nodejs' // Explicitly use Node.js runtime + matcher: [ + // Skip Next.js internals and all static files + '/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)', + // Always run for API routes + '/(api|trpc)(.*)', + ], };