From 8714d6bd22e130722d6d02ba0ab80fece2098b24 Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Tue, 24 Feb 2026 10:34:55 +0000 Subject: [PATCH] Phase 1 complete --- .devcontainer/devcontainer.json | 14 +- .../{post-create.sh => post-start.sh} | 0 .env.example | 6 + apps/backend/generate-openapi.ts | 42 + apps/backend/package.json | 8 + .../20260224085801_init/migration.sql | 267 +++ .../prisma/migrations/migration_lock.toml | 3 + apps/backend/prisma/schema.prisma | 1 + apps/backend/prisma/seed.ts | 1 - apps/backend/src/app.module.ts | 28 +- apps/backend/src/auth/auth.controller.ts | 125 ++ apps/backend/src/auth/auth.module.ts | 33 + apps/backend/src/auth/auth.service.ts | 112 ++ .../backend/src/auth/dto/auth-response.dto.ts | 26 + apps/backend/src/auth/dto/keycloak.dto.ts | 37 + apps/backend/src/auth/dto/login.dto.ts | 35 + .../backend/src/auth/guards/jwt-auth.guard.ts | 24 + .../src/auth/guards/keycloak-auth.guard.ts | 13 + .../src/auth/guards/local-auth.guard.ts | 5 + apps/backend/src/auth/keycloak.service.ts | 333 ++++ .../src/auth/strategies/jwt.strategy.ts | 37 + .../src/auth/strategies/keycloak.strategy.ts | 191 ++ .../src/auth/strategies/local.strategy.ts | 21 + .../src/character/character.controller.ts | 85 + .../backend/src/character/character.module.ts | 10 + .../src/character/character.service.ts | 74 + .../character/dto/character-response.dto.ts | 38 + .../src/character/dto/create-character.dto.ts | 68 + apps/backend/src/chat/chat.controller.ts | 92 + apps/backend/src/chat/chat.gateway.ts | 143 ++ apps/backend/src/chat/chat.module.ts | 16 + apps/backend/src/chat/chat.service.ts | 288 +++ apps/backend/src/chat/dto/chat.dto.ts | 25 + .../src/chat/dto/conversation-response.dto.ts | 72 + .../decorators/current-user.decorator.ts | 10 + .../src/common/decorators/public.decorator.ts | 4 + .../src/import/adapters/text-file.adapter.ts | 40 + apps/backend/src/import/import.controller.ts | 101 + apps/backend/src/import/import.module.ts | 12 + apps/backend/src/import/import.service.ts | 214 ++ .../interfaces/import-adapter.interface.ts | 14 + .../llm/interfaces/llm-provider.interface.ts | 40 + apps/backend/src/llm/llm.module.ts | 8 + apps/backend/src/llm/llm.service.ts | 38 + .../src/llm/providers/openrouter.provider.ts | 140 ++ apps/backend/src/main.ts | 31 + apps/backend/src/prisma/prisma.module.ts | 9 + apps/backend/src/prisma/prisma.service.ts | 20 + apps/backend/src/user/dto/update-user.dto.ts | 27 + apps/backend/src/user/user.controller.ts | 54 + apps/backend/src/user/user.module.ts | 10 + apps/backend/src/user/user.service.ts | 95 + apps/backend/src/vector/embedding.service.ts | 32 + .../embedding-provider.interface.ts | 5 + apps/backend/src/vector/memory.service.ts | 122 ++ .../providers/local-embedding.provider.ts | 48 + .../src/vector/vector-store.service.ts | 116 ++ apps/backend/src/vector/vector.module.ts | 10 + apps/frontend/openapitools.json | 7 + apps/frontend/orval.config.js | 24 + apps/frontend/package.json | 6 +- apps/frontend/src/App.tsx | 135 +- apps/frontend/src/api/generated/auth/auth.ts | 173 ++ .../api/generated/characters/characters.ts | 133 ++ .../generated/conversations/conversations.ts | 135 ++ .../src/api/generated/import/import.ts | 111 ++ .../authControllerKeycloakCallbackParams.ts | 26 + .../authControllerKeycloakLoginParams.ts | 14 + .../api/generated/model/authResponseDto.ts | 17 + .../generated/model/characterResponseDto.ts | 32 + .../model/characterResponseDtoAttributes.ts | 12 + .../model/characterResponseDtoConfig.ts | 12 + .../generated/model/characterSummaryDto.ts | 16 + .../model/conversationResponseDto.ts | 27 + .../conversationWithMessagesResponseDto.ts | 30 + .../api/generated/model/createCharacterDto.ts | 24 + .../model/createCharacterDtoAttributes.ts | 12 + .../model/createCharacterDtoConfig.ts | 12 + .../generated/model/createConversationDto.ts | 14 + .../model/importControllerUploadFileBody.ts | 12 + .../frontend/src/api/generated/model/index.ts | 37 + .../api/generated/model/keycloakConfigDto.ts | 18 + .../generated/model/keycloakLoginUrlDto.ts | 14 + .../src/api/generated/model/loginDto.ts | 14 + .../api/generated/model/messageResponseDto.ts | 23 + .../generated/model/messageResponseDtoRole.ts | 19 + .../api/generated/model/refreshTokenDto.ts | 12 + .../src/api/generated/model/sendMessageDto.ts | 12 + .../generated/model/sendMessageResponseDto.ts | 15 + .../api/generated/model/updateCharacterDto.ts | 24 + .../model/updateCharacterDtoAttributes.ts | 12 + .../model/updateCharacterDtoConfig.ts | 12 + .../api/generated/model/updatePasswordDto.ts | 14 + .../src/api/generated/model/updateUserDto.ts | 14 + .../src/api/generated/model/userDto.ts | 19 + .../src/api/generated/model/userDtoRole.ts | 18 + .../frontend/src/api/generated/users/users.ts | 108 + apps/frontend/src/api/mutator/custom-fetch.ts | 44 + apps/frontend/src/pages/CharacterForm.tsx | 197 ++ apps/frontend/src/pages/CharacterList.tsx | 134 ++ apps/frontend/src/pages/Chat.tsx | 230 +++ apps/frontend/src/pages/ConversationList.tsx | 130 ++ apps/frontend/src/pages/Login.tsx | 212 ++ apps/frontend/src/stores/authStore.ts | 131 ++ apps/frontend/src/stores/characterStore.ts | 120 ++ apps/frontend/src/stores/chatStore.ts | 144 ++ apps/frontend/src/types/index.ts | 55 + apps/frontend/src/vite-env.d.ts | 10 + apps/openapi/openapi.json | 1468 ++++++++++++++ justfile | 180 ++ openapi/openapi.json | 1468 ++++++++++++++ pnpm-lock.yaml | 1771 ++++++++++++++++- 112 files changed, 11063 insertions(+), 73 deletions(-) rename .devcontainer/{post-create.sh => post-start.sh} (100%) create mode 100644 apps/backend/generate-openapi.ts create mode 100644 apps/backend/prisma/migrations/20260224085801_init/migration.sql create mode 100644 apps/backend/prisma/migrations/migration_lock.toml create mode 100644 apps/backend/src/auth/auth.controller.ts create mode 100644 apps/backend/src/auth/auth.module.ts create mode 100644 apps/backend/src/auth/auth.service.ts create mode 100644 apps/backend/src/auth/dto/auth-response.dto.ts create mode 100644 apps/backend/src/auth/dto/keycloak.dto.ts create mode 100644 apps/backend/src/auth/dto/login.dto.ts create mode 100644 apps/backend/src/auth/guards/jwt-auth.guard.ts create mode 100644 apps/backend/src/auth/guards/keycloak-auth.guard.ts create mode 100644 apps/backend/src/auth/guards/local-auth.guard.ts create mode 100644 apps/backend/src/auth/keycloak.service.ts create mode 100644 apps/backend/src/auth/strategies/jwt.strategy.ts create mode 100644 apps/backend/src/auth/strategies/keycloak.strategy.ts create mode 100644 apps/backend/src/auth/strategies/local.strategy.ts create mode 100644 apps/backend/src/character/character.controller.ts create mode 100644 apps/backend/src/character/character.module.ts create mode 100644 apps/backend/src/character/character.service.ts create mode 100644 apps/backend/src/character/dto/character-response.dto.ts create mode 100644 apps/backend/src/character/dto/create-character.dto.ts create mode 100644 apps/backend/src/chat/chat.controller.ts create mode 100644 apps/backend/src/chat/chat.gateway.ts create mode 100644 apps/backend/src/chat/chat.module.ts create mode 100644 apps/backend/src/chat/chat.service.ts create mode 100644 apps/backend/src/chat/dto/chat.dto.ts create mode 100644 apps/backend/src/chat/dto/conversation-response.dto.ts create mode 100644 apps/backend/src/common/decorators/current-user.decorator.ts create mode 100644 apps/backend/src/common/decorators/public.decorator.ts create mode 100644 apps/backend/src/import/adapters/text-file.adapter.ts create mode 100644 apps/backend/src/import/import.controller.ts create mode 100644 apps/backend/src/import/import.module.ts create mode 100644 apps/backend/src/import/import.service.ts create mode 100644 apps/backend/src/import/interfaces/import-adapter.interface.ts create mode 100644 apps/backend/src/llm/interfaces/llm-provider.interface.ts create mode 100644 apps/backend/src/llm/llm.module.ts create mode 100644 apps/backend/src/llm/llm.service.ts create mode 100644 apps/backend/src/llm/providers/openrouter.provider.ts create mode 100644 apps/backend/src/prisma/prisma.module.ts create mode 100644 apps/backend/src/prisma/prisma.service.ts create mode 100644 apps/backend/src/user/dto/update-user.dto.ts create mode 100644 apps/backend/src/user/user.controller.ts create mode 100644 apps/backend/src/user/user.module.ts create mode 100644 apps/backend/src/user/user.service.ts create mode 100644 apps/backend/src/vector/embedding.service.ts create mode 100644 apps/backend/src/vector/interfaces/embedding-provider.interface.ts create mode 100644 apps/backend/src/vector/memory.service.ts create mode 100644 apps/backend/src/vector/providers/local-embedding.provider.ts create mode 100644 apps/backend/src/vector/vector-store.service.ts create mode 100644 apps/backend/src/vector/vector.module.ts create mode 100644 apps/frontend/openapitools.json create mode 100644 apps/frontend/orval.config.js create mode 100644 apps/frontend/src/api/generated/auth/auth.ts create mode 100644 apps/frontend/src/api/generated/characters/characters.ts create mode 100644 apps/frontend/src/api/generated/conversations/conversations.ts create mode 100644 apps/frontend/src/api/generated/import/import.ts create mode 100644 apps/frontend/src/api/generated/model/authControllerKeycloakCallbackParams.ts create mode 100644 apps/frontend/src/api/generated/model/authControllerKeycloakLoginParams.ts create mode 100644 apps/frontend/src/api/generated/model/authResponseDto.ts create mode 100644 apps/frontend/src/api/generated/model/characterResponseDto.ts create mode 100644 apps/frontend/src/api/generated/model/characterResponseDtoAttributes.ts create mode 100644 apps/frontend/src/api/generated/model/characterResponseDtoConfig.ts create mode 100644 apps/frontend/src/api/generated/model/characterSummaryDto.ts create mode 100644 apps/frontend/src/api/generated/model/conversationResponseDto.ts create mode 100644 apps/frontend/src/api/generated/model/conversationWithMessagesResponseDto.ts create mode 100644 apps/frontend/src/api/generated/model/createCharacterDto.ts create mode 100644 apps/frontend/src/api/generated/model/createCharacterDtoAttributes.ts create mode 100644 apps/frontend/src/api/generated/model/createCharacterDtoConfig.ts create mode 100644 apps/frontend/src/api/generated/model/createConversationDto.ts create mode 100644 apps/frontend/src/api/generated/model/importControllerUploadFileBody.ts create mode 100644 apps/frontend/src/api/generated/model/index.ts create mode 100644 apps/frontend/src/api/generated/model/keycloakConfigDto.ts create mode 100644 apps/frontend/src/api/generated/model/keycloakLoginUrlDto.ts create mode 100644 apps/frontend/src/api/generated/model/loginDto.ts create mode 100644 apps/frontend/src/api/generated/model/messageResponseDto.ts create mode 100644 apps/frontend/src/api/generated/model/messageResponseDtoRole.ts create mode 100644 apps/frontend/src/api/generated/model/refreshTokenDto.ts create mode 100644 apps/frontend/src/api/generated/model/sendMessageDto.ts create mode 100644 apps/frontend/src/api/generated/model/sendMessageResponseDto.ts create mode 100644 apps/frontend/src/api/generated/model/updateCharacterDto.ts create mode 100644 apps/frontend/src/api/generated/model/updateCharacterDtoAttributes.ts create mode 100644 apps/frontend/src/api/generated/model/updateCharacterDtoConfig.ts create mode 100644 apps/frontend/src/api/generated/model/updatePasswordDto.ts create mode 100644 apps/frontend/src/api/generated/model/updateUserDto.ts create mode 100644 apps/frontend/src/api/generated/model/userDto.ts create mode 100644 apps/frontend/src/api/generated/model/userDtoRole.ts create mode 100644 apps/frontend/src/api/generated/users/users.ts create mode 100644 apps/frontend/src/api/mutator/custom-fetch.ts create mode 100644 apps/frontend/src/pages/CharacterForm.tsx create mode 100644 apps/frontend/src/pages/CharacterList.tsx create mode 100644 apps/frontend/src/pages/Chat.tsx create mode 100644 apps/frontend/src/pages/ConversationList.tsx create mode 100644 apps/frontend/src/pages/Login.tsx create mode 100644 apps/frontend/src/stores/authStore.ts create mode 100644 apps/frontend/src/stores/characterStore.ts create mode 100644 apps/frontend/src/stores/chatStore.ts create mode 100644 apps/frontend/src/types/index.ts create mode 100644 apps/frontend/src/vite-env.d.ts create mode 100644 apps/openapi/openapi.json create mode 100644 justfile create mode 100644 openapi/openapi.json diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 967bd97..27ceed7 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -9,18 +9,12 @@ }, "ghcr.io/devcontainers/features/docker-in-docker:2": { "moby": false - } + }, + "ghcr.io/jsburckhardt/devcontainer-features/just": {} }, "customizations": { "vscode": { - "extensions": [ - "dbaeumer.vscode-eslint", - "esbenp.prettier-vscode", - "bradlc.vscode-tailwindcss", - "ms-vscode.vscode-typescript-next", - "nestjs.vscode-nestjs", - "prisma.prisma" - ], + "extensions": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode", "bradlc.vscode-tailwindcss", "prisma.prisma"], "settings": { "editor.formatOnSave": true, "editor.defaultFormatter": "esbenp.prettier-vscode", @@ -43,7 +37,7 @@ "onAutoForward": "silent" } }, - "postCreateCommand": "bash .devcontainer/post-create.sh", + "postStartCommand": "bash .devcontainer/post-start.sh", "remoteUser": "node", "mounts": [ "source=${localWorkspaceFolderBasename}-node_modules,target=${containerWorkspaceFolder}/node_modules,type=volume", diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-start.sh similarity index 100% rename from .devcontainer/post-create.sh rename to .devcontainer/post-start.sh diff --git a/.env.example b/.env.example index a97f613..a03df2b 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,9 @@ JWT_SECRET=your_jwt_secret_key_min_32_chars JWT_EXPIRES_IN=1h JWT_REFRESH_EXPIRES_IN=7d +# Frontend URL (for OAuth redirects) +FRONTEND_URL=http://localhost:5173 + # LLM Configuration LLM_PROVIDER=openrouter LLM_API_KEY=sk-or-v1-... @@ -28,6 +31,9 @@ KEYCLOAK_REALM=dreamchat KEYCLOAK_CLIENT_ID=dreamchat-backend KEYCLOAK_CLIENT_SECRET=your_keycloak_secret +# Keycloak OAuth redirect URI (must match Keycloak client configuration) +KEYCLOAK_REDIRECT_URI=http://localhost:3000/api/auth/keycloak/callback + # Keycloak Authorization Settings # Require specific group/role/attribute for access # Set at least one of these to enforce authorization checks diff --git a/apps/backend/generate-openapi.ts b/apps/backend/generate-openapi.ts new file mode 100644 index 0000000..4d555a5 --- /dev/null +++ b/apps/backend/generate-openapi.ts @@ -0,0 +1,42 @@ +import { NestFactory } from '@nestjs/core'; +import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; +import { AppModule } from './src/app.module'; +import * as fs from 'fs'; +import * as path from 'path'; + +async function generateOpenApi() { + // Create app without starting the server + const app = await NestFactory.create(AppModule, { + logger: false, // Suppress logs during generation + }); + + app.setGlobalPrefix('api'); + + const config = new DocumentBuilder() + .setTitle('DreamChat API') + .setDescription('The DreamChat API documentation') + .setVersion('1.0.0') + .addBearerAuth() + .build(); + + const document = SwaggerModule.createDocument(app, config); + + // Ensure the output directory exists + const outputDir = path.join(__dirname, '..', '..', 'openapi'); + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); + } + + // Write the spec file + const outputPath = path.join(outputDir, 'openapi.json'); + fs.writeFileSync(outputPath, JSON.stringify(document, null, 2)); + console.log(`📄 OpenAPI spec written to: ${outputPath}`); + + await app.close(); + process.exit(0); +} + +generateOpenApi().catch((err) => { + console.error('Failed to generate OpenAPI spec:', err); + process.exit(1); +}); diff --git a/apps/backend/package.json b/apps/backend/package.json index eeb972f..5b6a6bf 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -11,22 +11,29 @@ "db:migrate": "prisma migrate deploy", "db:generate": "prisma generate", "db:seed": "prisma db seed", + "openapi:generate": "node dist/generate-openapi.js", "clean": "rm -r dist" }, "dependencies": { "@dreamchat/shared": "workspace:*", "@nestjs/common": "^11.1.14", "@nestjs/core": "^11.1.14", + "@nestjs/jwt": "^11.0.0", + "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.1.14", "@nestjs/platform-socket.io": "^11.1.14", + "@nestjs/swagger": "^11.0.0", "@nestjs/websockets": "^11.1.14", + "@prisma/adapter-pg": "^7.4.1", "@prisma/client": "^7.4.1", + "@types/keycloak-connect": "^7.0.0", "@xenova/transformers": "^2.15.0", "bcrypt": "^6.0.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", "dotenv": "^17.3.1", "jsonwebtoken": "^9.0.0", + "keycloak-connect": "^26.1.1", "passport": "^0.7.0", "passport-jwt": "^4.0.0", "passport-local": "^1.0.0", @@ -40,6 +47,7 @@ "@nestjs/testing": "^11.1.14", "@types/bcrypt": "^6.0.0", "@types/jsonwebtoken": "^9.0.0", + "@types/multer": "^1.4.12", "@types/node": "^24.10.13", "@types/passport-jwt": "^4.0.0", "@types/passport-local": "^1.0.0", diff --git a/apps/backend/prisma/migrations/20260224085801_init/migration.sql b/apps/backend/prisma/migrations/20260224085801_init/migration.sql new file mode 100644 index 0000000..c9b7bed --- /dev/null +++ b/apps/backend/prisma/migrations/20260224085801_init/migration.sql @@ -0,0 +1,267 @@ +-- Enable pgvector extension +CREATE EXTENSION IF NOT EXISTS vector; + +-- CreateEnum +CREATE TYPE "ImportSourceType" AS ENUM ('file', 'url', 'manual'); + +-- CreateEnum +CREATE TYPE "ImportStatus" AS ENUM ('pending', 'processing', 'completed', 'failed'); + +-- CreateEnum +CREATE TYPE "MessageRole" AS ENUM ('user', 'assistant', 'system'); + +-- CreateEnum +CREATE TYPE "UserRole" AS ENUM ('USER', 'ADMIN'); + +-- CreateEnum +CREATE TYPE "MemoryType" AS ENUM ('conversation', 'character'); + +-- CreateTable +CREATE TABLE "Character" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "avatarUrl" TEXT, + "personalityPrompt" TEXT NOT NULL, + "attributes" JSONB NOT NULL DEFAULT '{}', + "config" JSONB NOT NULL DEFAULT '{}', + "isPublic" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "userId" TEXT NOT NULL, + + CONSTRAINT "Character_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "CharacterKnowledge" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "sourceType" "ImportSourceType" NOT NULL, + "sourceName" TEXT NOT NULL, + "mimeType" TEXT, + "fileSize" BIGINT, + "rawContent" TEXT, + "status" "ImportStatus" NOT NULL DEFAULT 'pending', + "processingInfo" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "characterId" TEXT NOT NULL, + + CONSTRAINT "CharacterKnowledge_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Conversation" ( + "id" TEXT NOT NULL, + "title" TEXT, + "messageCount" INTEGER NOT NULL DEFAULT 0, + "totalTokens" INTEGER NOT NULL DEFAULT 0, + "settings" JSONB NOT NULL DEFAULT '{}', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "userId" TEXT NOT NULL, + "characterId" TEXT NOT NULL, + + CONSTRAINT "Conversation_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ConversationParticipant" ( + "id" TEXT NOT NULL, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "autoRespond" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "conversationId" TEXT NOT NULL, + "characterId" TEXT NOT NULL, + + CONSTRAINT "ConversationParticipant_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ImportDocument" ( + "id" TEXT NOT NULL, + "sourceType" "ImportSourceType" NOT NULL, + "sourceName" TEXT NOT NULL, + "mimeType" TEXT, + "fileSize" BIGINT, + "content" TEXT, + "status" "ImportStatus" NOT NULL DEFAULT 'pending', + "errorMessage" TEXT, + "metadata" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "userId" TEXT NOT NULL, + + CONSTRAINT "ImportDocument_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Message" ( + "id" TEXT NOT NULL, + "role" "MessageRole" NOT NULL, + "content" TEXT NOT NULL, + "tokensUsed" INTEGER, + "model" TEXT, + "metadata" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "conversationId" TEXT NOT NULL, + + CONSTRAINT "Message_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "StoryBranch" ( + "id" TEXT NOT NULL, + "title" TEXT, + "content" TEXT NOT NULL, + "userDirection" TEXT NOT NULL, + "generationParams" JSONB, + "depth" INTEGER NOT NULL DEFAULT 0, + "branchOrder" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "conversationId" TEXT NOT NULL, + "parentId" TEXT, + + CONSTRAINT "StoryBranch_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL, + "email" TEXT NOT NULL, + "username" TEXT NOT NULL, + "passwordHash" TEXT, + "keycloakSub" TEXT, + "role" "UserRole" NOT NULL DEFAULT 'USER', + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "VectorMemory" ( + "id" TEXT NOT NULL, + "content" TEXT NOT NULL, + "embedding" vector, + "memoryType" "MemoryType" NOT NULL DEFAULT 'conversation', + "metadata" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "conversationId" TEXT, + "characterId" TEXT, + "knowledgeId" TEXT, + + CONSTRAINT "VectorMemory_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "Character_userId_idx" ON "Character"("userId"); + +-- CreateIndex +CREATE INDEX "Character_name_idx" ON "Character"("name"); + +-- CreateIndex +CREATE INDEX "CharacterKnowledge_characterId_idx" ON "CharacterKnowledge"("characterId"); + +-- CreateIndex +CREATE INDEX "CharacterKnowledge_status_idx" ON "CharacterKnowledge"("status"); + +-- CreateIndex +CREATE INDEX "Conversation_userId_idx" ON "Conversation"("userId"); + +-- CreateIndex +CREATE INDEX "Conversation_characterId_idx" ON "Conversation"("characterId"); + +-- CreateIndex +CREATE INDEX "Conversation_createdAt_idx" ON "Conversation"("createdAt"); + +-- CreateIndex +CREATE INDEX "ConversationParticipant_conversationId_idx" ON "ConversationParticipant"("conversationId"); + +-- CreateIndex +CREATE UNIQUE INDEX "ConversationParticipant_conversationId_characterId_key" ON "ConversationParticipant"("conversationId", "characterId"); + +-- CreateIndex +CREATE INDEX "ImportDocument_userId_idx" ON "ImportDocument"("userId"); + +-- CreateIndex +CREATE INDEX "ImportDocument_status_idx" ON "ImportDocument"("status"); + +-- CreateIndex +CREATE INDEX "Message_conversationId_idx" ON "Message"("conversationId"); + +-- CreateIndex +CREATE INDEX "Message_createdAt_idx" ON "Message"("createdAt"); + +-- CreateIndex +CREATE INDEX "Message_conversationId_createdAt_idx" ON "Message"("conversationId", "createdAt"); + +-- CreateIndex +CREATE INDEX "StoryBranch_conversationId_idx" ON "StoryBranch"("conversationId"); + +-- CreateIndex +CREATE INDEX "StoryBranch_parentId_idx" ON "StoryBranch"("parentId"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_username_key" ON "User"("username"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_keycloakSub_key" ON "User"("keycloakSub"); + +-- CreateIndex +CREATE INDEX "User_email_idx" ON "User"("email"); + +-- CreateIndex +CREATE INDEX "User_keycloakSub_idx" ON "User"("keycloakSub"); + +-- CreateIndex +CREATE INDEX "VectorMemory_conversationId_idx" ON "VectorMemory"("conversationId"); + +-- CreateIndex +CREATE INDEX "VectorMemory_characterId_idx" ON "VectorMemory"("characterId"); + +-- CreateIndex +CREATE INDEX "VectorMemory_knowledgeId_idx" ON "VectorMemory"("knowledgeId"); + +-- CreateIndex +CREATE INDEX "VectorMemory_memoryType_idx" ON "VectorMemory"("memoryType"); + +-- AddForeignKey +ALTER TABLE "Character" ADD CONSTRAINT "Character_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CharacterKnowledge" ADD CONSTRAINT "CharacterKnowledge_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "Character"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Conversation" ADD CONSTRAINT "Conversation_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Conversation" ADD CONSTRAINT "Conversation_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "Character"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ConversationParticipant" ADD CONSTRAINT "ConversationParticipant_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "Conversation"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ImportDocument" ADD CONSTRAINT "ImportDocument_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Message" ADD CONSTRAINT "Message_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "Conversation"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "StoryBranch" ADD CONSTRAINT "StoryBranch_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "Conversation"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "StoryBranch" ADD CONSTRAINT "StoryBranch_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "StoryBranch"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "VectorMemory" ADD CONSTRAINT "VectorMemory_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "Conversation"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "VectorMemory" ADD CONSTRAINT "VectorMemory_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "Character"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "VectorMemory" ADD CONSTRAINT "VectorMemory_knowledgeId_fkey" FOREIGN KEY ("knowledgeId") REFERENCES "CharacterKnowledge"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/backend/prisma/migrations/migration_lock.toml b/apps/backend/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..044d57c --- /dev/null +++ b/apps/backend/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 619ff1f..0315867 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -7,6 +7,7 @@ generator client { provider = "prisma-client-js" previewFeatures = ["strictUndefinedChecks"] + engineType = "binary" } datasource db { diff --git a/apps/backend/prisma/seed.ts b/apps/backend/prisma/seed.ts index 9c5bb79..2571d34 100644 --- a/apps/backend/prisma/seed.ts +++ b/apps/backend/prisma/seed.ts @@ -29,7 +29,6 @@ async function main() { id: '00000000-0000-0000-0000-000000000001', name: 'Alice', personalityPrompt: 'You are Alice, a curious and adventurous explorer who loves discovering new things. You are friendly, witty, and always eager to help.', - backstory: 'Alice grew up in a small village at the edge of a vast forest. From a young age, she was fascinated by the unknown and would often venture into the woods to explore.', attributes: { traits: ['curious', 'brave', 'witty', 'friendly'], age: 25, diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index ee5f2c9..21ca7db 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -1,8 +1,32 @@ import { Module } from '@nestjs/common'; +import { APP_GUARD } from '@nestjs/core'; +import { PrismaModule } from './prisma/prisma.module'; +import { AuthModule } from './auth/auth.module'; +import { UserModule } from './user/user.module'; +import { CharacterModule } from './character/character.module'; +import { LLMModule } from './llm/llm.module'; +import { VectorModule } from './vector/vector.module'; +import { ChatModule } from './chat/chat.module'; +import { ImportModule } from './import/import.module'; +import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; @Module({ - imports: [], + imports: [ + PrismaModule, + AuthModule, + UserModule, + CharacterModule, + LLMModule, + VectorModule, + ChatModule, + ImportModule, + ], controllers: [], - providers: [], + providers: [ + { + provide: APP_GUARD, + useClass: JwtAuthGuard, + }, + ], }) export class AppModule {} diff --git a/apps/backend/src/auth/auth.controller.ts b/apps/backend/src/auth/auth.controller.ts new file mode 100644 index 0000000..0fc1bde --- /dev/null +++ b/apps/backend/src/auth/auth.controller.ts @@ -0,0 +1,125 @@ +import { Controller, Post, Get, Body, Query, HttpCode, HttpStatus, UseGuards, Req, Res } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery } from '@nestjs/swagger'; +import { Request, Response } from 'express'; +import { AuthService } from './auth.service'; +import { KeycloakService } from './keycloak.service'; +import { LoginDto, RefreshTokenDto } from './dto/login.dto'; +import { AuthResponseDto } from './dto/auth-response.dto'; +import { KeycloakLoginUrlDto, KeycloakCallbackQueryDto, KeycloakConfigDto } from './dto/keycloak.dto'; +import { Public } from '../common/decorators/public.decorator'; +import { KeycloakAuthGuard } from './guards/keycloak-auth.guard'; + +@ApiTags('auth') +@Controller('auth') +export class AuthController { + constructor( + private authService: AuthService, + private keycloakService: KeycloakService, + ) {} + + @Public() + @Post('login') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Login with email and password' }) + @ApiResponse({ status: 200, description: 'Login successful', type: AuthResponseDto }) + @ApiResponse({ status: 401, description: 'Invalid credentials' }) + async login(@Body() loginDto: LoginDto): Promise { + return this.authService.login(loginDto); + } + + @Public() + @Post('refresh') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Refresh access token' }) + @ApiResponse({ status: 200, description: 'Token refreshed', type: AuthResponseDto }) + @ApiResponse({ status: 401, description: 'Invalid refresh token' }) + async refreshTokens(@Body() refreshTokenDto: RefreshTokenDto): Promise { + return this.authService.refreshTokens(refreshTokenDto.refreshToken); + } + + // ==================== KEYCLOAK OAUTH FLOW ==================== + + @Public() + @Get('keycloak/config') + @ApiOperation({ summary: 'Get Keycloak configuration for frontend' }) + @ApiResponse({ status: 200, description: 'Keycloak config', type: KeycloakConfigDto }) + getKeycloakConfig(): KeycloakConfigDto { + return this.keycloakService.getConfig(); + } + + @Public() + @Get('keycloak/login') + @ApiOperation({ summary: 'Get Keycloak login URL (initiates OAuth flow)' }) + @ApiQuery({ name: 'redirectTo', required: false, description: 'Frontend path to redirect after login' }) + @ApiResponse({ status: 200, description: 'Login URL generated', type: KeycloakLoginUrlDto }) + @ApiResponse({ status: 400, description: 'Keycloak not enabled' }) + keycloakLogin(@Query('redirectTo') redirectTo?: string): KeycloakLoginUrlDto { + return this.keycloakService.generateLoginUrl(redirectTo); + } + + @Public() + @Get('keycloak/callback') + @ApiOperation({ summary: 'Keycloak OAuth callback endpoint' }) + @ApiQuery({ name: 'code', required: true, description: 'Authorization code from Keycloak' }) + @ApiQuery({ name: 'state', required: true, description: 'State parameter for CSRF validation' }) + @ApiQuery({ name: 'error', required: false, description: 'Error message if authentication failed' }) + @ApiQuery({ name: 'error_description', required: false, description: 'Error description' }) + @ApiResponse({ status: 302, description: 'Redirect to frontend with tokens' }) + @ApiResponse({ status: 401, description: 'Authentication failed' }) + async keycloakCallback( + @Query() query: KeycloakCallbackQueryDto, + @Res() res: Response, + ): Promise { + // Handle errors from Keycloak + if (query.error) { + const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:5173'; + const errorMessage = encodeURIComponent(query.error_description || query.error); + return res.redirect(`${frontendUrl}/login?error=${errorMessage}`); + } + + if (!query.code) { + const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:5173'; + return res.redirect(`${frontendUrl}/login?error=Missing authorization code`); + } + + // Exchange code for tokens + const result = await this.keycloakService.handleCallback(query.code, query.state); + + // Redirect to frontend with tokens + const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:5173'; + const redirectPath = result.redirectTo || '/characters'; + + // Build redirect URL with tokens + const params = new URLSearchParams({ + accessToken: result.authResponse.accessToken, + refreshToken: result.authResponse.refreshToken, + }); + + return res.redirect(`${frontendUrl}${redirectPath}?${params.toString()}`); + } + + // ==================== KEYCLOAK BEARER TOKEN (Legacy/Alternative) ==================== + + @Public() + @Post('keycloak') + @HttpCode(HttpStatus.OK) + @UseGuards(KeycloakAuthGuard) + @ApiOperation({ summary: 'Login with Keycloak bearer token (Authorization: Bearer )' }) + @ApiBearerAuth() + @ApiResponse({ status: 200, description: 'Login successful', type: AuthResponseDto }) + @ApiResponse({ status: 401, description: 'Invalid Keycloak token' }) + async keycloakBearerLogin(@Req() req: Request): Promise { + // The Keycloak guard validates the token and attaches the user to req.user + const keycloakUser = req.user as { + userId: string; + email: string; + role: string; + }; + + return this.authService.generateTokensFromUser( + keycloakUser.userId, + keycloakUser.email, + keycloakUser.role, + ); + } +} diff --git a/apps/backend/src/auth/auth.module.ts b/apps/backend/src/auth/auth.module.ts new file mode 100644 index 0000000..e5063f4 --- /dev/null +++ b/apps/backend/src/auth/auth.module.ts @@ -0,0 +1,33 @@ +import { Module } from '@nestjs/common'; +import { JwtModule } from '@nestjs/jwt'; +import { PassportModule } from '@nestjs/passport'; +import { AuthService } from './auth.service'; +import { AuthController } from './auth.controller'; +import { KeycloakService } from './keycloak.service'; +import { JwtStrategy } from './strategies/jwt.strategy'; +import { LocalStrategy } from './strategies/local.strategy'; +import { KeycloakStrategy } from './strategies/keycloak.strategy'; +import { PrismaModule } from '../prisma/prisma.module'; + +@Module({ + imports: [ + PrismaModule, + PassportModule, + JwtModule.registerAsync({ + useFactory: () => ({ + secret: process.env.JWT_SECRET || 'dev-jwt-secret-change-in-production', + signOptions: { expiresIn: '1h' }, + }), + }), + ], + providers: [ + AuthService, + KeycloakService, + JwtStrategy, + LocalStrategy, + KeycloakStrategy, + ], + controllers: [AuthController], + exports: [AuthService, KeycloakService], +}) +export class AuthModule {} diff --git a/apps/backend/src/auth/auth.service.ts b/apps/backend/src/auth/auth.service.ts new file mode 100644 index 0000000..bc8111d --- /dev/null +++ b/apps/backend/src/auth/auth.service.ts @@ -0,0 +1,112 @@ +import { Injectable, UnauthorizedException, ConflictException } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { PrismaService } from '../prisma/prisma.service'; +import * as bcrypt from 'bcrypt'; +import { LoginDto, RegisterDto } from './dto/login.dto'; +import { AuthResponseDto } from './dto/auth-response.dto'; +import { User } from '@prisma/client'; + +@Injectable() +export class AuthService { + constructor( + private prisma: PrismaService, + private jwtService: JwtService, + ) {} + + async validateUser(email: string, password: string): Promise | null> { + const user = await this.prisma.user.findUnique({ + where: { email }, + }); + + if (user && user.passwordHash) { + const isMatch = await bcrypt.compare(password, user.passwordHash); + if (isMatch) { + const { passwordHash, ...result } = user; + return result; + } + } + return null; + } + + async login(loginDto: LoginDto): Promise { + const user = await this.validateUser(loginDto.email, loginDto.password); + + if (!user) { + throw new UnauthorizedException('Invalid credentials'); + } + + if (!user.isActive) { + throw new UnauthorizedException('Account is deactivated'); + } + + return this.generateTokens(user); + } + + async register(registerDto: RegisterDto): Promise { + const existingUser = await this.prisma.user.findFirst({ + where: { + OR: [ + { email: registerDto.email }, + { username: registerDto.username }, + ], + }, + }); + + if (existingUser) { + throw new ConflictException('Email or username already exists'); + } + + const hashedPassword = await bcrypt.hash(registerDto.password, 10); + + const user = await this.prisma.user.create({ + data: { + email: registerDto.email, + username: registerDto.username, + passwordHash: hashedPassword, + }, + }); + + const { passwordHash, ...userWithoutPassword } = user; + return this.generateTokens(userWithoutPassword); + } + + async refreshTokens(refreshToken: string): Promise { + try { + const payload = this.jwtService.verify(refreshToken, { + secret: process.env.JWT_SECRET, + }); + + const user = await this.prisma.user.findUnique({ + where: { id: payload.sub }, + }); + + if (!user || !user.isActive) { + throw new UnauthorizedException('Invalid refresh token'); + } + + const { passwordHash, ...userWithoutPassword } = user; + return this.generateTokens(userWithoutPassword); + } catch { + throw new UnauthorizedException('Invalid refresh token'); + } + } + + generateTokensFromUser(userId: string, email: string, role: string): AuthResponseDto { + const payload = { sub: userId, email, role }; + + return { + accessToken: this.jwtService.sign(payload), + refreshToken: this.jwtService.sign(payload, { expiresIn: '7d' }), + user: { + id: userId, + email, + username: email.split('@')[0], + role, + }, + }; + } + + private generateTokens(user: Omit): AuthResponseDto { + return this.generateTokensFromUser(user.id, user.email, user.role); + } +} diff --git a/apps/backend/src/auth/dto/auth-response.dto.ts b/apps/backend/src/auth/dto/auth-response.dto.ts new file mode 100644 index 0000000..141f54f --- /dev/null +++ b/apps/backend/src/auth/dto/auth-response.dto.ts @@ -0,0 +1,26 @@ +import { ApiProperty } from '@nestjs/swagger'; + +class UserDto { + @ApiProperty({ description: 'User ID', example: '550e8400-e29b-41d4-a716-446655440000' }) + id: string; + + @ApiProperty({ description: 'User email', example: 'admin@dreamchat.local' }) + email: string; + + @ApiProperty({ description: 'User username', example: 'admin' }) + username: string; + + @ApiProperty({ description: 'User role', example: 'USER', enum: ['USER', 'ADMIN'] }) + role: string; +} + +export class AuthResponseDto { + @ApiProperty({ description: 'JWT access token' }) + accessToken: string; + + @ApiProperty({ description: 'JWT refresh token' }) + refreshToken: string; + + @ApiProperty({ description: 'User information', type: UserDto }) + user: UserDto; +} diff --git a/apps/backend/src/auth/dto/keycloak.dto.ts b/apps/backend/src/auth/dto/keycloak.dto.ts new file mode 100644 index 0000000..3c6e167 --- /dev/null +++ b/apps/backend/src/auth/dto/keycloak.dto.ts @@ -0,0 +1,37 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class KeycloakConfigDto { + @ApiProperty({ description: 'Whether Keycloak authentication is enabled' }) + enabled: boolean; + + @ApiPropertyOptional({ description: 'Keycloak realm URL' }) + url?: string; + + @ApiPropertyOptional({ description: 'Keycloak realm name' }) + realm?: string; + + @ApiPropertyOptional({ description: 'Keycloak client ID' }) + clientId?: string; +} + +export class KeycloakLoginUrlDto { + @ApiProperty({ description: 'Keycloak login URL to redirect the user to' }) + loginUrl: string; + + @ApiProperty({ description: 'State parameter for CSRF protection' }) + state: string; +} + +export class KeycloakCallbackQueryDto { + @ApiPropertyOptional({ description: 'Authorization code from Keycloak' }) + code?: string; + + @ApiPropertyOptional({ description: 'Error message if authentication failed' }) + error?: string; + + @ApiPropertyOptional({ description: 'Error description' }) + error_description?: string; + + @ApiProperty({ description: 'State parameter for CSRF validation' }) + state: string; +} diff --git a/apps/backend/src/auth/dto/login.dto.ts b/apps/backend/src/auth/dto/login.dto.ts new file mode 100644 index 0000000..bba8c04 --- /dev/null +++ b/apps/backend/src/auth/dto/login.dto.ts @@ -0,0 +1,35 @@ +import { IsString, IsEmail, MinLength } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class LoginDto { + @ApiProperty({ description: 'User email address', example: 'admin@dreamchat.local' }) + @IsEmail() + email: string; + + @ApiProperty({ description: 'User password', example: 'password123' }) + @IsString() + @MinLength(6) + password: string; +} + +export class RegisterDto { + @ApiProperty({ description: 'User email address', example: 'user@example.com' }) + @IsEmail() + email: string; + + @ApiProperty({ description: 'Username', example: 'myusername' }) + @IsString() + @MinLength(3) + username: string; + + @ApiProperty({ description: 'User password', example: 'password123' }) + @IsString() + @MinLength(6) + password: string; +} + +export class RefreshTokenDto { + @ApiProperty({ description: 'Refresh token', example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...' }) + @IsString() + refreshToken: string; +} diff --git a/apps/backend/src/auth/guards/jwt-auth.guard.ts b/apps/backend/src/auth/guards/jwt-auth.guard.ts new file mode 100644 index 0000000..61f05ab --- /dev/null +++ b/apps/backend/src/auth/guards/jwt-auth.guard.ts @@ -0,0 +1,24 @@ +import { Injectable, ExecutionContext } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { Reflector } from '@nestjs/core'; +import { IS_PUBLIC_KEY } from '../../common/decorators/public.decorator'; + +@Injectable() +export class JwtAuthGuard extends AuthGuard('jwt') { + constructor(private reflector: Reflector) { + super(); + } + + canActivate(context: ExecutionContext) { + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + if (isPublic) { + return true; + } + + return super.canActivate(context); + } +} diff --git a/apps/backend/src/auth/guards/keycloak-auth.guard.ts b/apps/backend/src/auth/guards/keycloak-auth.guard.ts new file mode 100644 index 0000000..98c53d3 --- /dev/null +++ b/apps/backend/src/auth/guards/keycloak-auth.guard.ts @@ -0,0 +1,13 @@ +import { Injectable, ExecutionContext } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +@Injectable() +export class KeycloakAuthGuard extends AuthGuard('keycloak') { + canActivate(context: ExecutionContext) { + // Skip if Keycloak is not enabled + if (process.env.KEYCLOAK_ENABLED !== 'true') { + return false; + } + return super.canActivate(context); + } +} diff --git a/apps/backend/src/auth/guards/local-auth.guard.ts b/apps/backend/src/auth/guards/local-auth.guard.ts new file mode 100644 index 0000000..ccf962b --- /dev/null +++ b/apps/backend/src/auth/guards/local-auth.guard.ts @@ -0,0 +1,5 @@ +import { Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +@Injectable() +export class LocalAuthGuard extends AuthGuard('local') {} diff --git a/apps/backend/src/auth/keycloak.service.ts b/apps/backend/src/auth/keycloak.service.ts new file mode 100644 index 0000000..1a95b94 --- /dev/null +++ b/apps/backend/src/auth/keycloak.service.ts @@ -0,0 +1,333 @@ +import { Injectable, UnauthorizedException, BadRequestException } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { PrismaService } from '../prisma/prisma.service'; +import { UserRole } from '@prisma/client'; +import { AuthResponseDto } from './dto/auth-response.dto'; +import * as crypto from 'crypto'; + +interface KeycloakTokenResponse { + access_token: string; + expires_in: number; + refresh_expires_in: number; + refresh_token: string; + token_type: string; + id_token?: string; + session_state?: string; + scope: string; +} + +interface KeycloakUserInfo { + sub: string; + email?: string; + email_verified?: boolean; + name?: string; + preferred_username?: string; + given_name?: string; + family_name?: string; + groups?: string[]; + // Keycloak may also put groups in these locations + realm_access?: { + roles?: string[]; + }; + resource_access?: { + [key: string]: { + roles?: string[]; + }; + }; + [key: string]: any; +} + +@Injectable() +export class KeycloakService { + private readonly keycloakEnabled: boolean; + private readonly keycloakUrl: string; + private readonly keycloakRealm: string; + private readonly clientId: string; + private readonly clientSecret: string; + private readonly redirectUri: string; + private stateStore: Map = new Map(); + + constructor( + private prisma: PrismaService, + private jwtService: JwtService, + ) { + this.keycloakEnabled = process.env.KEYCLOAK_ENABLED === 'true'; + this.keycloakUrl = process.env.KEYCLOAK_URL || ''; + this.keycloakRealm = process.env.KEYCLOAK_REALM || ''; + this.clientId = process.env.KEYCLOAK_CLIENT_ID || ''; + this.clientSecret = process.env.KEYCLOAK_CLIENT_SECRET || ''; + this.redirectUri = process.env.KEYCLOAK_REDIRECT_URI || 'http://localhost:3000/api/auth/keycloak/callback'; + + // Clean up old state entries every 5 minutes + setInterval(() => this.cleanupState(), 5 * 60 * 1000); + } + + isEnabled(): boolean { + return this.keycloakEnabled; + } + + getConfig() { + return { + enabled: this.keycloakEnabled, + url: this.keycloakUrl || undefined, + realm: this.keycloakRealm || undefined, + clientId: this.clientId || undefined, + }; + } + + generateLoginUrl(redirectTo?: string): { loginUrl: string; state: string } { + if (!this.keycloakEnabled) { + throw new BadRequestException('Keycloak is not enabled'); + } + + const state = crypto.randomBytes(32).toString('hex'); + this.stateStore.set(state, { + createdAt: Date.now(), + redirectTo, + }); + + const baseUrl = `${this.keycloakUrl}/realms/${this.keycloakRealm}/protocol/openid-connect/auth`; + const params = new URLSearchParams({ + client_id: this.clientId, + redirect_uri: this.redirectUri, + response_type: 'code', + scope: 'openid email profile', + state, + }); + + return { + loginUrl: `${baseUrl}?${params.toString()}`, + state, + }; + } + + async handleCallback(code: string, state: string): Promise<{ + authResponse: AuthResponseDto; + redirectTo?: string; + }> { + if (!this.keycloakEnabled) { + throw new BadRequestException('Keycloak is not enabled'); + } + + // Validate state + const stateData = this.stateStore.get(state); + if (!stateData) { + throw new UnauthorizedException('Invalid or expired state parameter'); + } + this.stateStore.delete(state); + + // Exchange code for tokens + const tokens = await this.exchangeCodeForTokens(code); + + // Get user info from Keycloak + const userInfo = await this.getUserInfo(tokens.access_token); + + // DEBUG: Log the full userinfo to see what groups are actually returned + console.log('[Keycloak Debug] UserInfo:', JSON.stringify(userInfo, null, 2)); + + // Validate user info + if (!userInfo.sub) { + throw new UnauthorizedException('Invalid user info from Keycloak'); + } + + // Check authorization requirements + this.checkAuthorization(userInfo); + + // Find or create user + const user = await this.findOrCreateUser(userInfo); + + if (!user.isActive) { + throw new UnauthorizedException('Account is deactivated'); + } + + // Generate DreamChat tokens + const authResponse = this.generateTokens(user); + + return { + authResponse, + redirectTo: stateData.redirectTo, + }; + } + + private async exchangeCodeForTokens(code: string): Promise { + const tokenUrl = `${this.keycloakUrl}/realms/${this.keycloakRealm}/protocol/openid-connect/token`; + + const params = new URLSearchParams({ + grant_type: 'authorization_code', + client_id: this.clientId, + client_secret: this.clientSecret, + code, + redirect_uri: this.redirectUri, + }); + + const response = await fetch(tokenUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: params.toString(), + }); + + if (!response.ok) { + const error = await response.text(); + throw new UnauthorizedException(`Failed to exchange code for tokens: ${error}`); + } + + return response.json(); + } + + private async getUserInfo(accessToken: string): Promise { + const userInfoUrl = `${this.keycloakUrl}/realms/${this.keycloakRealm}/protocol/openid-connect/userinfo`; + + const response = await fetch(userInfoUrl, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + + if (!response.ok) { + throw new UnauthorizedException('Failed to get user info from Keycloak'); + } + + return response.json(); + } + + private async findOrCreateUser(userInfo: KeycloakUserInfo) { + const keycloakSub = userInfo.sub; + const email = userInfo.email; + const username = userInfo.preferred_username || email || keycloakSub; + + // Try to find user by keycloakSub first + let user = await this.prisma.user.findUnique({ + where: { keycloakSub }, + }); + + if (user) { + return user; + } + + // Try to find by email and link accounts + if (email) { + const existingUser = await this.prisma.user.findUnique({ + where: { email }, + }); + + if (existingUser) { + // Link existing user to Keycloak + return this.prisma.user.update({ + where: { id: existingUser.id }, + data: { keycloakSub }, + }); + } + } + + // Check if auto-create is enabled + const autoCreate = process.env.KEYCLOAK_AUTO_CREATE_USER !== 'false'; + if (!autoCreate) { + throw new UnauthorizedException('User not found and auto-creation is disabled'); + } + + // Create new user + const defaultRole = + process.env.KEYCLOAK_DEFAULT_USER_ROLE === 'ADMIN' + ? UserRole.ADMIN + : UserRole.USER; + + return this.prisma.user.create({ + data: { + email: email || `${keycloakSub}@keycloak.local`, + username: username, + keycloakSub, + role: defaultRole, + }, + }); + } + + private checkAuthorization(userInfo: KeycloakUserInfo): void { + const requiredGroup = process.env.KEYCLOAK_REQUIRED_GROUP; + const requiredRole = process.env.KEYCLOAK_REQUIRED_ROLE; + const requiredClientRole = process.env.KEYCLOAK_REQUIRED_CLIENT_ROLE; + const requiredAttribute = process.env.KEYCLOAK_REQUIRED_ATTRIBUTE; + + // Collect all possible sources of groups/roles + const groups = userInfo.groups || []; + const realmRoles = userInfo.realm_access?.roles || []; + const clientRoles = userInfo.resource_access?.[this.clientId]?.roles || []; + + console.log('[Keycloak Debug] Authorization Check:'); + console.log(' Required Group:', requiredGroup); + console.log(' User Groups:', groups); + console.log(' Realm Roles:', realmRoles); + console.log(' Client Roles:', clientRoles); + + // Check required group - try groups array, then realm roles, then client roles + if (requiredGroup) { + // Check in groups claim (most common location) + const hasGroup = groups.includes(requiredGroup); + // Also check in realm roles (sometimes groups are mapped as roles) + const hasGroupAsRole = realmRoles.includes(requiredGroup); + // Also check in client roles + const hasGroupAsClientRole = clientRoles.includes(requiredGroup); + + if (!hasGroup && !hasGroupAsRole && !hasGroupAsClientRole) { + throw new UnauthorizedException( + `Access denied: required group '${requiredGroup}' not found. Your groups: [${groups.join(', ')}], realm roles: [${realmRoles.join(', ')}], client roles: [${clientRoles.join(', ')}]`, + ); + } + } + + // Check required realm role + if (requiredRole) { + if (!realmRoles.includes(requiredRole)) { + throw new UnauthorizedException( + `Access denied: required role '${requiredRole}' not found. Your roles: [${realmRoles.join(', ')}]`, + ); + } + } + + // Check required client role + if (requiredClientRole) { + if (!clientRoles.includes(requiredClientRole)) { + throw new UnauthorizedException( + `Access denied: required client role '${requiredClientRole}' not found. Your client roles: [${clientRoles.join(', ')}]`, + ); + } + } + + // Check required attribute + if (requiredAttribute) { + const [attrKey, attrValue] = requiredAttribute.split(':'); + if (userInfo[attrKey] !== attrValue) { + throw new UnauthorizedException( + `Access denied: required attribute '${attrKey}=${attrValue}' not found`, + ); + } + } + } + + private generateTokens(user: { id: string; email: string; username: string; role: string }): AuthResponseDto { + const payload = { sub: user.id, email: user.email, role: user.role }; + + return { + accessToken: this.jwtService.sign(payload), + refreshToken: this.jwtService.sign(payload, { expiresIn: '7d' }), + user: { + id: user.id, + email: user.email, + username: user.username, + role: user.role, + }, + }; + } + + private cleanupState(): void { + const now = Date.now(); + const maxAge = 10 * 60 * 1000; // 10 minutes + + for (const [state, data] of this.stateStore.entries()) { + if (now - data.createdAt > maxAge) { + this.stateStore.delete(state); + } + } + } +} diff --git a/apps/backend/src/auth/strategies/jwt.strategy.ts b/apps/backend/src/auth/strategies/jwt.strategy.ts new file mode 100644 index 0000000..afda357 --- /dev/null +++ b/apps/backend/src/auth/strategies/jwt.strategy.ts @@ -0,0 +1,37 @@ +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { ExtractJwt, Strategy } from 'passport-jwt'; +import { PrismaService } from '../../prisma/prisma.service'; + +interface JwtPayload { + sub: string; + email: string; + role: string; +} + +@Injectable() +export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { + constructor(private prisma: PrismaService) { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + ignoreExpiration: false, + secretOrKey: process.env.JWT_SECRET || 'dev-jwt-secret-change-in-production', + }); + } + + async validate(payload: JwtPayload) { + const user = await this.prisma.user.findUnique({ + where: { id: payload.sub }, + }); + + if (!user || !user.isActive) { + throw new UnauthorizedException('User not found or inactive'); + } + + return { + userId: payload.sub, + email: payload.email, + role: payload.role, + }; + } +} diff --git a/apps/backend/src/auth/strategies/keycloak.strategy.ts b/apps/backend/src/auth/strategies/keycloak.strategy.ts new file mode 100644 index 0000000..e64ec8a --- /dev/null +++ b/apps/backend/src/auth/strategies/keycloak.strategy.ts @@ -0,0 +1,191 @@ +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { Strategy, ExtractJwt } from 'passport-jwt'; +import { PrismaService } from '../../prisma/prisma.service'; +import { UserRole } from '@prisma/client'; + +interface KeycloakJwtPayload { + sub: string; + email?: string; + preferred_username?: string; + realm_access?: { + roles?: string[]; + }; + resource_access?: { + [key: string]: { + roles?: string[]; + }; + }; + groups?: string[]; + [key: string]: any; +} + +@Injectable() +export class KeycloakStrategy extends PassportStrategy(Strategy, 'keycloak') { + private keycloakEnabled: boolean; + + constructor(private prisma: PrismaService) { + const keycloakEnabled = process.env.KEYCLOAK_ENABLED === 'true'; + const keycloakUrl = process.env.KEYCLOAK_URL || ''; + const keycloakRealm = process.env.KEYCLOAK_REALM || ''; + + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + ignoreExpiration: false, + secretOrKey: keycloakEnabled + ? undefined + : 'keycloak-not-enabled-placeholder', + secretOrKeyProvider: keycloakEnabled + ? async (request, rawJwtToken, done) => { + try { + // Fetch Keycloak realm public key + const response = await fetch( + `${keycloakUrl}/realms/${keycloakRealm}`, + ); + const realmInfo = await response.json(); + const publicKey = realmInfo.public_key; + if (!publicKey) { + throw new Error('No public key found in Keycloak realm'); + } + done( + null, + `-----BEGIN PUBLIC KEY-----\n${publicKey}\n-----END PUBLIC KEY-----`, + ); + } catch (error) { + done(error as Error, ''); + } + } + : undefined, + algorithms: ['RS256'], + }); + + this.keycloakEnabled = keycloakEnabled; + } + + async validate(payload: KeycloakJwtPayload): Promise<{ + userId: string; + email: string; + role: UserRole; + keycloakSub: string; + }> { + if (!this.keycloakEnabled) { + throw new UnauthorizedException('Keycloak is not enabled'); + } + + const keycloakSub = payload.sub; + const email = payload.email; + const username = payload.preferred_username || email; + + if (!keycloakSub) { + throw new UnauthorizedException('Invalid Keycloak token'); + } + + // Check authorization requirements + this.checkAuthorization(payload); + + // Find or create user + let user = await this.prisma.user.findUnique({ + where: { keycloakSub }, + }); + + if (!user) { + // Auto-create user if enabled + const autoCreate = process.env.KEYCLOAK_AUTO_CREATE_USER !== 'false'; + if (!autoCreate) { + throw new UnauthorizedException( + 'User not found and auto-creation is disabled', + ); + } + + // Check if email already exists + if (email) { + const existingUser = await this.prisma.user.findUnique({ + where: { email }, + }); + if (existingUser) { + // Link existing user to Keycloak + user = await this.prisma.user.update({ + where: { id: existingUser.id }, + data: { keycloakSub }, + }); + } + } + + if (!user) { + // Create new user + const defaultRole = + process.env.KEYCLOAK_DEFAULT_USER_ROLE === 'ADMIN' + ? UserRole.ADMIN + : UserRole.USER; + + user = await this.prisma.user.create({ + data: { + email: email || `${keycloakSub}@keycloak.local`, + username: username || keycloakSub, + keycloakSub, + role: defaultRole, + }, + }); + } + } + + if (!user.isActive) { + throw new UnauthorizedException('Account is deactivated'); + } + + return { + userId: user.id, + email: user.email, + role: user.role, + keycloakSub, + }; + } + + private checkAuthorization(payload: KeycloakJwtPayload): void { + const requiredGroup = process.env.KEYCLOAK_REQUIRED_GROUP; + const requiredRole = process.env.KEYCLOAK_REQUIRED_ROLE; + const requiredClientRole = process.env.KEYCLOAK_REQUIRED_CLIENT_ROLE; + const requiredAttribute = process.env.KEYCLOAK_REQUIRED_ATTRIBUTE; + + // Check required group + if (requiredGroup) { + const groups = payload.groups || []; + if (!groups.includes(requiredGroup)) { + throw new UnauthorizedException( + `Access denied: required group '${requiredGroup}' not found`, + ); + } + } + + // Check required realm role + if (requiredRole) { + const roles = payload.realm_access?.roles || []; + if (!roles.includes(requiredRole)) { + throw new UnauthorizedException( + `Access denied: required role '${requiredRole}' not found`, + ); + } + } + + // Check required client role + if (requiredClientRole) { + const clientId = process.env.KEYCLOAK_CLIENT_ID || ''; + const clientRoles = payload.resource_access?.[clientId]?.roles || []; + if (!clientRoles.includes(requiredClientRole)) { + throw new UnauthorizedException( + `Access denied: required client role '${requiredClientRole}' not found`, + ); + } + } + + // Check required attribute + if (requiredAttribute) { + const [attrKey, attrValue] = requiredAttribute.split(':'); + if (payload[attrKey] !== attrValue) { + throw new UnauthorizedException( + `Access denied: required attribute '${attrKey}=${attrValue}' not found`, + ); + } + } + } +} diff --git a/apps/backend/src/auth/strategies/local.strategy.ts b/apps/backend/src/auth/strategies/local.strategy.ts new file mode 100644 index 0000000..c8f4b98 --- /dev/null +++ b/apps/backend/src/auth/strategies/local.strategy.ts @@ -0,0 +1,21 @@ +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { Strategy } from 'passport-local'; +import { AuthService } from '../auth.service'; + +@Injectable() +export class LocalStrategy extends PassportStrategy(Strategy, 'local') { + constructor(private authService: AuthService) { + super({ + usernameField: 'email', + }); + } + + async validate(email: string, password: string): Promise { + const user = await this.authService.validateUser(email, password); + if (!user) { + throw new UnauthorizedException('Invalid credentials'); + } + return user; + } +} diff --git a/apps/backend/src/character/character.controller.ts b/apps/backend/src/character/character.controller.ts new file mode 100644 index 0000000..a556b3c --- /dev/null +++ b/apps/backend/src/character/character.controller.ts @@ -0,0 +1,85 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiParam } from '@nestjs/swagger'; +import { CharacterService } from './character.service'; +import { CreateCharacterDto, UpdateCharacterDto } from './dto/create-character.dto'; +import { CharacterResponseDto } from './dto/character-response.dto'; +import { CurrentUser } from '../common/decorators/current-user.decorator'; +import { Character } from '@prisma/client'; + +@ApiTags('characters') +@ApiBearerAuth() +@Controller('characters') +export class CharacterController { + constructor(private characterService: CharacterService) {} + + @Post() + @ApiOperation({ summary: 'Create a new character' }) + @ApiResponse({ status: 201, description: 'Character created', type: CharacterResponseDto }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + async create( + @CurrentUser('userId') userId: string, + @Body() createCharacterDto: CreateCharacterDto, + ): Promise { + return this.characterService.create(userId, createCharacterDto); + } + + @Get() + @ApiOperation({ summary: 'Get all characters for current user' }) + @ApiResponse({ status: 200, description: 'List of characters', type: [CharacterResponseDto] }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + async findAll(@CurrentUser('userId') userId: string): Promise { + return this.characterService.findAllByUser(userId); + } + + @Get(':id') + @ApiOperation({ summary: 'Get character by ID' }) + @ApiParam({ name: 'id', description: 'Character ID' }) + @ApiResponse({ status: 200, description: 'Character found', type: CharacterResponseDto }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Access denied' }) + @ApiResponse({ status: 404, description: 'Character not found' }) + async findOne( + @Param('id') id: string, + @CurrentUser('userId') userId: string, + ): Promise { + return this.characterService.findById(id, userId); + } + + @Put(':id') + @ApiOperation({ summary: 'Update character' }) + @ApiParam({ name: 'id', description: 'Character ID' }) + @ApiResponse({ status: 200, description: 'Character updated', type: CharacterResponseDto }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Access denied' }) + @ApiResponse({ status: 404, description: 'Character not found' }) + async update( + @Param('id') id: string, + @CurrentUser('userId') userId: string, + @Body() updateCharacterDto: UpdateCharacterDto, + ): Promise { + return this.characterService.update(id, userId, updateCharacterDto); + } + + @Delete(':id') + @ApiOperation({ summary: 'Delete character' }) + @ApiParam({ name: 'id', description: 'Character ID' }) + @ApiResponse({ status: 200, description: 'Character deleted' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Access denied' }) + @ApiResponse({ status: 404, description: 'Character not found' }) + async delete( + @Param('id') id: string, + @CurrentUser('userId') userId: string, + ): Promise<{ message: string }> { + await this.characterService.delete(id, userId); + return { message: 'Character deleted successfully' }; + } +} diff --git a/apps/backend/src/character/character.module.ts b/apps/backend/src/character/character.module.ts new file mode 100644 index 0000000..082262d --- /dev/null +++ b/apps/backend/src/character/character.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { CharacterService } from './character.service'; +import { CharacterController } from './character.controller'; + +@Module({ + providers: [CharacterService], + controllers: [CharacterController], + exports: [CharacterService], +}) +export class CharacterModule {} diff --git a/apps/backend/src/character/character.service.ts b/apps/backend/src/character/character.service.ts new file mode 100644 index 0000000..597e480 --- /dev/null +++ b/apps/backend/src/character/character.service.ts @@ -0,0 +1,74 @@ +import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { CreateCharacterDto, UpdateCharacterDto } from './dto/create-character.dto'; +import { Character } from '@prisma/client'; + +@Injectable() +export class CharacterService { + constructor(private prisma: PrismaService) {} + + async create(userId: string, createCharacterDto: CreateCharacterDto): Promise { + return this.prisma.character.create({ + data: { + ...createCharacterDto, + userId, + }, + }); + } + + async findAllByUser(userId: string): Promise { + return this.prisma.character.findMany({ + where: { userId }, + orderBy: { createdAt: 'desc' }, + }); + } + + async findById(id: string, userId?: string): Promise { + const character = await this.prisma.character.findUnique({ + where: { id }, + include: { + knowledgeSources: true, + }, + }); + + if (!character) { + throw new NotFoundException('Character not found'); + } + + // If userId is provided, check if user owns the character or if it's public + if (userId && character.userId !== userId && !character.isPublic) { + throw new ForbiddenException('You do not have access to this character'); + } + + return character; + } + + async update( + id: string, + userId: string, + updateCharacterDto: UpdateCharacterDto, + ): Promise { + const character = await this.findById(id, userId); + + if (character.userId !== userId) { + throw new ForbiddenException('You can only update your own characters'); + } + + return this.prisma.character.update({ + where: { id }, + data: updateCharacterDto, + }); + } + + async delete(id: string, userId: string): Promise { + const character = await this.findById(id, userId); + + if (character.userId !== userId) { + throw new ForbiddenException('You can only delete your own characters'); + } + + await this.prisma.character.delete({ + where: { id }, + }); + } +} diff --git a/apps/backend/src/character/dto/character-response.dto.ts b/apps/backend/src/character/dto/character-response.dto.ts new file mode 100644 index 0000000..e44104e --- /dev/null +++ b/apps/backend/src/character/dto/character-response.dto.ts @@ -0,0 +1,38 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class CharacterResponseDto { + @ApiProperty({ description: 'Character ID', example: '550e8400-e29b-41d4-a716-446655440000' }) + id: string; + + @ApiProperty({ description: 'Character name', example: 'Alice the Explorer' }) + name: string; + + @ApiPropertyOptional({ description: 'Avatar URL', example: 'https://example.com/avatar.jpg' }) + avatarUrl: string | null; + + @ApiProperty({ description: 'Personality prompt', example: 'You are Alice, a curious explorer...' }) + personalityPrompt: string; + + @ApiProperty({ description: 'Custom attributes', example: { age: 25, traits: ['curious'] } }) + attributes: Record; + + @ApiProperty({ description: 'Character configuration', example: {} }) + config: Record; + + @ApiProperty({ description: 'Whether character is public', example: false }) + isPublic: boolean; + + @ApiProperty({ description: 'Creation date' }) + createdAt: Date; + + @ApiProperty({ description: 'Last update date' }) + updatedAt: Date; + + @ApiProperty({ description: 'User ID', example: '550e8400-e29b-41d4-a716-446655440000' }) + userId: string; +} + +export class CharacterListResponseDto { + @ApiProperty({ description: 'List of characters', type: [CharacterResponseDto] }) + characters: CharacterResponseDto[]; +} diff --git a/apps/backend/src/character/dto/create-character.dto.ts b/apps/backend/src/character/dto/create-character.dto.ts new file mode 100644 index 0000000..584c0c2 --- /dev/null +++ b/apps/backend/src/character/dto/create-character.dto.ts @@ -0,0 +1,68 @@ +import { IsString, IsOptional, IsBoolean, IsObject, MinLength } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class CreateCharacterDto { + @ApiProperty({ description: 'Character name', example: 'Alice the Explorer' }) + @IsString() + @MinLength(1) + name: string; + + @ApiPropertyOptional({ description: 'Avatar URL', example: 'https://example.com/avatar.jpg' }) + @IsOptional() + @IsString() + avatarUrl?: string; + + @ApiProperty({ description: 'Personality prompt that guides AI responses', example: 'You are Alice, a curious and adventurous explorer...' }) + @IsString() + @MinLength(10) + personalityPrompt: string; + + @ApiPropertyOptional({ description: 'Custom attributes (JSON)', example: { age: 25, traits: ['curious', 'brave'] } }) + @IsOptional() + @IsObject() + attributes?: Record; + + @ApiPropertyOptional({ description: 'Character configuration (JSON)', example: { voice: 'friendly' } }) + @IsOptional() + @IsObject() + config?: Record; + + @ApiPropertyOptional({ description: 'Whether the character is publicly visible', example: false }) + @IsOptional() + @IsBoolean() + isPublic?: boolean; +} + +export class UpdateCharacterDto { + @ApiPropertyOptional({ description: 'Character name', example: 'Alice the Explorer' }) + @IsOptional() + @IsString() + @MinLength(1) + name?: string; + + @ApiPropertyOptional({ description: 'Avatar URL', example: 'https://example.com/avatar.jpg' }) + @IsOptional() + @IsString() + avatarUrl?: string; + + @ApiPropertyOptional({ description: 'Personality prompt', example: 'You are Alice...' }) + @IsOptional() + @IsString() + @MinLength(10) + personalityPrompt?: string; + + @ApiPropertyOptional({ description: 'Custom attributes (JSON)' }) + @IsOptional() + @IsObject() + attributes?: Record; + + @ApiPropertyOptional({ description: 'Character configuration (JSON)' }) + @IsOptional() + @IsObject() + config?: Record; + + @ApiPropertyOptional({ description: 'Whether the character is publicly visible' }) + @IsOptional() + @IsBoolean() + isPublic?: boolean; +} diff --git a/apps/backend/src/chat/chat.controller.ts b/apps/backend/src/chat/chat.controller.ts new file mode 100644 index 0000000..f27072a --- /dev/null +++ b/apps/backend/src/chat/chat.controller.ts @@ -0,0 +1,92 @@ +import { + Controller, + Get, + Post, + Delete, + Body, + Param, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiParam } from '@nestjs/swagger'; +import { ChatService } from './chat.service'; +import { CreateConversationDto, SendMessageDto } from './dto/chat.dto'; +import { + ConversationResponseDto, + ConversationWithMessagesResponseDto, + SendMessageResponseDto +} from './dto/conversation-response.dto'; +import { CurrentUser } from '../common/decorators/current-user.decorator'; +import { Conversation, Message } from '@prisma/client'; + +@ApiTags('conversations') +@ApiBearerAuth() +@Controller('conversations') +export class ChatController { + constructor(private chatService: ChatService) {} + + @Post() + @ApiOperation({ summary: 'Create a new conversation' }) + @ApiResponse({ status: 201, description: 'Conversation created', type: ConversationResponseDto }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 404, description: 'Character not found' }) + async createConversation( + @CurrentUser('userId') userId: string, + @Body() createConversationDto: CreateConversationDto, + ): Promise { + return this.chatService.createConversation(userId, createConversationDto); + } + + @Get() + @ApiOperation({ summary: 'Get all conversations for current user' }) + @ApiResponse({ status: 200, description: 'List of conversations', type: [ConversationResponseDto] }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + async getConversations(@CurrentUser('userId') userId: string): Promise { + return this.chatService.findConversationsByUser(userId); + } + + @Get(':id') + @ApiOperation({ summary: 'Get conversation by ID with messages' }) + @ApiParam({ name: 'id', description: 'Conversation ID' }) + @ApiResponse({ status: 200, description: 'Conversation found', type: ConversationWithMessagesResponseDto }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Access denied' }) + @ApiResponse({ status: 404, description: 'Conversation not found' }) + async getConversation( + @Param('id') id: string, + @CurrentUser('userId') userId: string, + ): Promise { + return this.chatService.findConversationById(id, userId); + } + + @Delete(':id') + @ApiOperation({ summary: 'Delete conversation' }) + @ApiParam({ name: 'id', description: 'Conversation ID' }) + @ApiResponse({ status: 200, description: 'Conversation deleted' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Access denied' }) + @ApiResponse({ status: 404, description: 'Conversation not found' }) + async deleteConversation( + @Param('id') id: string, + @CurrentUser('userId') userId: string, + ): Promise<{ message: string }> { + await this.chatService.deleteConversation(id, userId); + return { message: 'Conversation deleted successfully' }; + } + + @Post(':id/messages') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Send a message in a conversation' }) + @ApiParam({ name: 'id', description: 'Conversation ID' }) + @ApiResponse({ status: 200, description: 'Message sent', type: SendMessageResponseDto }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Access denied' }) + @ApiResponse({ status: 404, description: 'Conversation not found' }) + async sendMessage( + @Param('id') conversationId: string, + @CurrentUser('userId') userId: string, + @Body() sendMessageDto: SendMessageDto, + ): Promise { + return this.chatService.sendMessage(conversationId, userId, sendMessageDto); + } +} diff --git a/apps/backend/src/chat/chat.gateway.ts b/apps/backend/src/chat/chat.gateway.ts new file mode 100644 index 0000000..e006ad4 --- /dev/null +++ b/apps/backend/src/chat/chat.gateway.ts @@ -0,0 +1,143 @@ +import { + WebSocketGateway, + WebSocketServer, + SubscribeMessage, + MessageBody, + ConnectedSocket, + OnGatewayConnection, + OnGatewayDisconnect, +} from '@nestjs/websockets'; +import { Server, Socket } from 'socket.io'; +import { UseGuards } from '@nestjs/common'; +import { ChatService } from './chat.service'; +import { JwtService } from '@nestjs/jwt'; + +interface AuthenticatedSocket extends Socket { + userId?: string; +} + +@WebSocketGateway({ + cors: { + origin: ['http://localhost:5173'], + credentials: true, + }, + namespace: '/chat', +}) +export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect { + @WebSocketServer() + server: Server; + + constructor( + private chatService: ChatService, + private jwtService: JwtService, + ) {} + + async handleConnection(client: AuthenticatedSocket) { + try { + const token = client.handshake.auth.token as string; + if (!token) { + client.disconnect(); + return; + } + + const payload = this.jwtService.verify(token.replace('Bearer ', '')); + client.userId = payload.sub; + + console.log(`Client connected: ${client.id}, user: ${client.userId}`); + } catch { + client.disconnect(); + } + } + + handleDisconnect(client: AuthenticatedSocket) { + console.log(`Client disconnected: ${client.id}`); + } + + @SubscribeMessage('join_conversation') + async handleJoinConversation( + @MessageBody() data: { conversationId: string }, + @ConnectedSocket() client: AuthenticatedSocket, + ) { + if (!client.userId) return; + + const room = `conversation:${data.conversationId}`; + await client.join(room); + client.emit('joined', { conversationId: data.conversationId }); + } + + @SubscribeMessage('leave_conversation') + async handleLeaveConversation( + @MessageBody() data: { conversationId: string }, + @ConnectedSocket() client: AuthenticatedSocket, + ) { + const room = `conversation:${data.conversationId}`; + await client.leave(room); + client.emit('left', { conversationId: data.conversationId }); + } + + @SubscribeMessage('send_message') + async handleSendMessage( + @MessageBody() + data: { conversationId: string; content: string }, + @ConnectedSocket() client: AuthenticatedSocket, + ) { + if (!client.userId) return; + + const room = `conversation:${data.conversationId}`; + + try { + // Stream the response + const stream = this.chatService.streamMessage( + data.conversationId, + client.userId, + { content: data.content }, + ); + + let assistantMessage: any = null; + + for await (const event of stream) { + if (event.type === 'chunk') { + // Broadcast chunk to all clients in the room + this.server.to(room).emit('message_chunk', { + conversationId: data.conversationId, + chunk: event.data, + }); + } else if (event.type === 'message') { + if (event.data.assistantMessage) { + assistantMessage = event.data.assistantMessage; + } + // Broadcast the full message + this.server.to(room).emit('message', { + conversationId: data.conversationId, + message: event.data, + }); + } + } + + // Signal completion + this.server.to(room).emit('message_complete', { + conversationId: data.conversationId, + assistantMessage, + }); + } catch (error) { + client.emit('error', { + conversationId: data.conversationId, + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + } + + @SubscribeMessage('typing') + async handleTyping( + @MessageBody() data: { conversationId: string; isTyping: boolean }, + @ConnectedSocket() client: AuthenticatedSocket, + ) { + if (!client.userId) return; + + const room = `conversation:${data.conversationId}`; + client.to(room).emit('user_typing', { + conversationId: data.conversationId, + isTyping: data.isTyping, + }); + } +} diff --git a/apps/backend/src/chat/chat.module.ts b/apps/backend/src/chat/chat.module.ts new file mode 100644 index 0000000..c504682 --- /dev/null +++ b/apps/backend/src/chat/chat.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { JwtModule } from '@nestjs/jwt'; +import { ChatService } from './chat.service'; +import { ChatController } from './chat.controller'; +import { ChatGateway } from './chat.gateway'; +import { LLMModule } from '../llm/llm.module'; +import { VectorModule } from '../vector/vector.module'; +import { CharacterModule } from '../character/character.module'; + +@Module({ + imports: [LLMModule, VectorModule, CharacterModule, JwtModule], + providers: [ChatService, ChatGateway], + controllers: [ChatController], + exports: [ChatService], +}) +export class ChatModule {} diff --git a/apps/backend/src/chat/chat.service.ts b/apps/backend/src/chat/chat.service.ts new file mode 100644 index 0000000..59997ea --- /dev/null +++ b/apps/backend/src/chat/chat.service.ts @@ -0,0 +1,288 @@ +import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { LLMService } from '../llm/llm.service'; +import { MemoryService } from '../vector/memory.service'; +import { CharacterService } from '../character/character.service'; +import { CreateConversationDto, SendMessageDto } from './dto/chat.dto'; +import { Conversation, Message, MessageRole } from '@prisma/client'; + +@Injectable() +export class ChatService { + constructor( + private prisma: PrismaService, + private llmService: LLMService, + private memoryService: MemoryService, + private characterService: CharacterService, + ) {} + + async createConversation( + userId: string, + createConversationDto: CreateConversationDto, + ): Promise { + // Verify character exists and user has access + const character = await this.characterService.findById( + createConversationDto.characterId, + userId, + ); + + return this.prisma.conversation.create({ + data: { + userId, + characterId: createConversationDto.characterId, + title: createConversationDto.title || `Chat with ${character.name}`, + }, + }); + } + + async findConversationsByUser(userId: string): Promise { + return this.prisma.conversation.findMany({ + where: { userId }, + include: { + character: { + select: { + id: true, + name: true, + avatarUrl: true, + }, + }, + }, + orderBy: { updatedAt: 'desc' }, + }); + } + + async findConversationById( + id: string, + userId: string, + ): Promise { + const conversation = await this.prisma.conversation.findUnique({ + where: { id }, + include: { + messages: { + orderBy: { createdAt: 'asc' }, + }, + character: { + select: { + id: true, + name: true, + personalityPrompt: true, + }, + }, + }, + }); + + if (!conversation) { + throw new NotFoundException('Conversation not found'); + } + + if (conversation.userId !== userId) { + throw new ForbiddenException('You do not have access to this conversation'); + } + + return conversation; + } + + async deleteConversation(id: string, userId: string): Promise { + const conversation = await this.findConversationById(id, userId); + + if (conversation.userId !== userId) { + throw new ForbiddenException('You can only delete your own conversations'); + } + + // Delete related vector memories first + await this.memoryService['vectorStore'].deleteByConversation(id); + + await this.prisma.conversation.delete({ + where: { id }, + }); + } + + async sendMessage( + conversationId: string, + userId: string, + sendMessageDto: SendMessageDto, + ): Promise<{ userMessage: Message; assistantMessage: Message }> { + const conversation = await this.findConversationById(conversationId, userId); + + // Create user message + const userMessage = await this.prisma.message.create({ + data: { + conversationId, + role: 'user', + content: sendMessageDto.content, + }, + }); + + // Store user message in vector memory + await this.memoryService.storeConversationMessage( + `User: ${sendMessageDto.content}`, + conversationId, + { messageId: userMessage.id }, + ); + + // Generate context from memory + const memoryContext = await this.memoryService.buildContextForConversation( + conversationId, + sendMessageDto.content, + conversation.characterId, + ); + + // Build messages for LLM + const messages = this.buildLLMMessages( + conversation.character.personalityPrompt, + conversation.messages, + sendMessageDto.content, + memoryContext, + ); + + // Generate response + const response = await this.llmService.generateCompletion(messages, { + temperature: 0.7, + maxTokens: 2000, + }); + + // Create assistant message + const assistantMessage = await this.prisma.message.create({ + data: { + conversationId, + role: 'assistant', + content: response.content, + tokensUsed: response.tokensUsed, + model: response.model, + }, + }); + + // Update conversation stats + await this.prisma.conversation.update({ + where: { id: conversationId }, + data: { + messageCount: { increment: 2 }, + totalTokens: { increment: response.tokensUsed }, + }, + }); + + // Store assistant response in vector memory + await this.memoryService.storeConversationMessage( + `${conversation.character.name}: ${response.content}`, + conversationId, + { messageId: assistantMessage.id }, + ); + + return { userMessage, assistantMessage }; + } + + async *streamMessage( + conversationId: string, + userId: string, + sendMessageDto: SendMessageDto, + ): AsyncGenerator<{ type: 'chunk' | 'message'; data: any }> { + const conversation = await this.findConversationById(conversationId, userId); + + // Create user message + const userMessage = await this.prisma.message.create({ + data: { + conversationId, + role: 'user', + content: sendMessageDto.content, + }, + }); + + yield { type: 'message', data: { userMessage } }; + + // Store user message in vector memory + await this.memoryService.storeConversationMessage( + `User: ${sendMessageDto.content}`, + conversationId, + { messageId: userMessage.id }, + ); + + // Generate context from memory + const memoryContext = await this.memoryService.buildContextForConversation( + conversationId, + sendMessageDto.content, + conversation.characterId, + ); + + // Build messages for LLM + const messages = this.buildLLMMessages( + conversation.character.personalityPrompt, + conversation.messages, + sendMessageDto.content, + memoryContext, + ); + + // Generate streaming response + let fullContent = ''; + const stream = this.llmService.generateStream(messages, { + temperature: 0.7, + maxTokens: 2000, + }); + + for await (const chunk of stream) { + fullContent += chunk.content; + yield { type: 'chunk', data: chunk }; + } + + // Create assistant message + const assistantMessage = await this.prisma.message.create({ + data: { + conversationId, + role: 'assistant', + content: fullContent, + model: process.env.LLM_MODEL || 'openai/gpt-4o', + }, + }); + + // Update conversation stats + const tokensUsed = this.llmService.countTokens([ + ...messages, + { role: 'assistant', content: fullContent }, + ]); + + await this.prisma.conversation.update({ + where: { id: conversationId }, + data: { + messageCount: { increment: 2 }, + totalTokens: { increment: tokensUsed }, + }, + }); + + // Store assistant response in vector memory + await this.memoryService.storeConversationMessage( + `${conversation.character.name}: ${fullContent}`, + conversationId, + { messageId: assistantMessage.id }, + ); + + yield { type: 'message', data: { assistantMessage } }; + } + + private buildLLMMessages( + personalityPrompt: string | null, + history: Message[], + currentMessage: string, + memoryContext: string, + ): Array<{ role: 'system' | 'user' | 'assistant'; content: string }> { + const messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }> = []; + + // Add system message with personality and context + let systemContent = personalityPrompt; + if (memoryContext) { + systemContent += `\n\nUse the following context to inform your responses:\n${memoryContext}`; + } + messages.push({ role: 'system', content: systemContent }); + + // Add recent conversation history (last 10 messages) + const recentHistory = history.slice(-10); + for (const message of recentHistory) { + messages.push({ + role: message.role.toLowerCase() as 'user' | 'assistant', + content: message.content, + }); + } + + // Add current message + messages.push({ role: 'user', content: currentMessage }); + + return messages; + } +} diff --git a/apps/backend/src/chat/dto/chat.dto.ts b/apps/backend/src/chat/dto/chat.dto.ts new file mode 100644 index 0000000..0654e48 --- /dev/null +++ b/apps/backend/src/chat/dto/chat.dto.ts @@ -0,0 +1,25 @@ +import { IsString, IsOptional } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class CreateConversationDto { + @ApiProperty({ description: 'Character ID to chat with', example: '550e8400-e29b-41d4-a716-446655440000' }) + @IsString() + characterId: string; + + @ApiPropertyOptional({ description: 'Conversation title', example: 'My Adventure with Alice' }) + @IsOptional() + @IsString() + title?: string; +} + +export class SendMessageDto { + @ApiProperty({ description: 'Message content', example: 'Hello! Tell me about yourself.' }) + @IsString() + content: string; +} + +export class UpdateMessageDto { + @ApiProperty({ description: 'Updated message content' }) + @IsString() + content: string; +} diff --git a/apps/backend/src/chat/dto/conversation-response.dto.ts b/apps/backend/src/chat/dto/conversation-response.dto.ts new file mode 100644 index 0000000..9b7ae97 --- /dev/null +++ b/apps/backend/src/chat/dto/conversation-response.dto.ts @@ -0,0 +1,72 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { MessageRole } from '@prisma/client'; + +export class MessageResponseDto { + @ApiProperty({ description: 'Message ID' }) + id: string; + + @ApiProperty({ description: 'Message role', enum: MessageRole }) + role: MessageRole; + + @ApiProperty({ description: 'Message content' }) + content: string; + + @ApiPropertyOptional({ description: 'Tokens used' }) + tokensUsed: number | null; + + @ApiPropertyOptional({ description: 'Model used' }) + model: string | null; + + @ApiProperty({ description: 'Creation date' }) + createdAt: Date; +} + +export class CharacterSummaryDto { + @ApiProperty({ description: 'Character ID' }) + id: string; + + @ApiProperty({ description: 'Character name' }) + name: string; + + @ApiPropertyOptional({ description: 'Avatar URL' }) + avatarUrl: string | null; +} + +export class ConversationResponseDto { + @ApiProperty({ description: 'Conversation ID' }) + id: string; + + @ApiPropertyOptional({ description: 'Conversation title' }) + title: string | null; + + @ApiProperty({ description: 'Character ID' }) + characterId: string; + + @ApiProperty({ description: 'Number of messages' }) + messageCount: number; + + @ApiProperty({ description: 'Total tokens used' }) + totalTokens: number; + + @ApiProperty({ description: 'Creation date' }) + createdAt: Date; + + @ApiProperty({ description: 'Last update date' }) + updatedAt: Date; + + @ApiPropertyOptional({ description: 'Character info', type: CharacterSummaryDto }) + character?: CharacterSummaryDto; +} + +export class ConversationWithMessagesResponseDto extends ConversationResponseDto { + @ApiProperty({ description: 'Messages in conversation', type: [MessageResponseDto] }) + messages: MessageResponseDto[]; +} + +export class SendMessageResponseDto { + @ApiProperty({ description: 'User message', type: MessageResponseDto }) + userMessage: MessageResponseDto; + + @ApiProperty({ description: 'Assistant response', type: MessageResponseDto }) + assistantMessage: MessageResponseDto; +} diff --git a/apps/backend/src/common/decorators/current-user.decorator.ts b/apps/backend/src/common/decorators/current-user.decorator.ts new file mode 100644 index 0000000..9bb7b57 --- /dev/null +++ b/apps/backend/src/common/decorators/current-user.decorator.ts @@ -0,0 +1,10 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; + +export const CurrentUser = createParamDecorator( + (data: keyof any | undefined, ctx: ExecutionContext) => { + const request = ctx.switchToHttp().getRequest(); + const user = request.user; + + return data ? user?.[data] : user; + }, +); diff --git a/apps/backend/src/common/decorators/public.decorator.ts b/apps/backend/src/common/decorators/public.decorator.ts new file mode 100644 index 0000000..b3845e1 --- /dev/null +++ b/apps/backend/src/common/decorators/public.decorator.ts @@ -0,0 +1,4 @@ +import { SetMetadata } from '@nestjs/common'; + +export const IS_PUBLIC_KEY = 'isPublic'; +export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); diff --git a/apps/backend/src/import/adapters/text-file.adapter.ts b/apps/backend/src/import/adapters/text-file.adapter.ts new file mode 100644 index 0000000..9baf155 --- /dev/null +++ b/apps/backend/src/import/adapters/text-file.adapter.ts @@ -0,0 +1,40 @@ +import { IImportAdapter, ImportResult } from '../interfaces/import-adapter.interface'; + +export class TextFileAdapter implements IImportAdapter { + private readonly supportedMimeTypes = [ + 'text/plain', + 'text/markdown', + 'text/x-markdown', + 'application/octet-stream', + ]; + + private readonly supportedExtensions = ['.txt', '.md', '.markdown']; + + canHandle(file: Express.Multer.File): boolean { + // Check MIME type + if (this.supportedMimeTypes.includes(file.mimetype)) { + return true; + } + + // Check file extension + const ext = file.originalname.toLowerCase().slice(file.originalname.lastIndexOf('.')); + if (this.supportedExtensions.includes(ext)) { + return true; + } + + return false; + } + + async parse(file: Express.Multer.File): Promise { + const content = file.buffer.toString('utf-8'); + + return { + content, + metadata: { + sourceName: file.originalname, + mimeType: file.mimetype, + fileSize: file.size, + }, + }; + } +} diff --git a/apps/backend/src/import/import.controller.ts b/apps/backend/src/import/import.controller.ts new file mode 100644 index 0000000..88b26e8 --- /dev/null +++ b/apps/backend/src/import/import.controller.ts @@ -0,0 +1,101 @@ +import { + Controller, + Post, + Get, + Delete, + Param, + UploadedFile, + UseInterceptors, + BadRequestException, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiParam, ApiConsumes, ApiBody, ApiProperty } from '@nestjs/swagger'; +import { ImportService } from './import.service'; +import { CurrentUser } from '../common/decorators/current-user.decorator'; +import { CharacterKnowledge } from '@prisma/client'; + +class UploadResponseDto { + @ApiProperty({ description: 'Knowledge ID' }) + knowledgeId: string; + + @ApiProperty({ description: 'Status message' }) + message: string; +} + +@ApiTags('import') +@ApiBearerAuth() +@Controller('import') +export class ImportController { + constructor(private importService: ImportService) {} + + @Post('characters/:characterId/files') + @ApiOperation({ summary: 'Upload a file for character knowledge' }) + @ApiParam({ name: 'characterId', description: 'Character ID' }) + @ApiConsumes('multipart/form-data') + @ApiBody({ + schema: { + type: 'object', + properties: { + file: { + type: 'string', + format: 'binary', + description: 'File to upload (.txt, .md)', + }, + }, + }, + }) + @ApiResponse({ status: 201, description: 'File uploaded and processing' }) + @ApiResponse({ status: 400, description: 'Invalid file type' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @UseInterceptors(FileInterceptor('file')) + async uploadFile( + @Param('characterId') characterId: string, + @UploadedFile() file: Express.Multer.File, + @CurrentUser('userId') userId: string, + ): Promise { + if (!file) { + throw new BadRequestException('No file uploaded'); + } + + return this.importService.uploadFile(file, characterId, userId); + } + + @Get('knowledge/:knowledgeId/status') + @ApiOperation({ summary: 'Get knowledge processing status' }) + @ApiParam({ name: 'knowledgeId', description: 'Knowledge ID' }) + @ApiResponse({ status: 200, description: 'Knowledge status' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 404, description: 'Knowledge not found' }) + async getKnowledgeStatus( + @Param('knowledgeId') knowledgeId: string, + @CurrentUser('userId') userId: string, + ): Promise { + return this.importService.getKnowledgeStatus(knowledgeId, userId); + } + + @Get('characters/:characterId/knowledge') + @ApiOperation({ summary: 'Get all knowledge for a character' }) + @ApiParam({ name: 'characterId', description: 'Character ID' }) + @ApiResponse({ status: 200, description: 'List of knowledge' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + async getCharacterKnowledge( + @Param('characterId') characterId: string, + @CurrentUser('userId') userId: string, + ): Promise { + return this.importService.getCharacterKnowledge(characterId, userId); + } + + @Delete('knowledge/:knowledgeId') + @ApiOperation({ summary: 'Delete knowledge' }) + @ApiParam({ name: 'knowledgeId', description: 'Knowledge ID' }) + @ApiResponse({ status: 200, description: 'Knowledge deleted' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 404, description: 'Knowledge not found' }) + async deleteKnowledge( + @Param('knowledgeId') knowledgeId: string, + @CurrentUser('userId') userId: string, + ): Promise<{ message: string }> { + await this.importService.deleteKnowledge(knowledgeId, userId); + return { message: 'Knowledge deleted successfully' }; + } +} diff --git a/apps/backend/src/import/import.module.ts b/apps/backend/src/import/import.module.ts new file mode 100644 index 0000000..cb927dd --- /dev/null +++ b/apps/backend/src/import/import.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { ImportService } from './import.service'; +import { ImportController } from './import.controller'; +import { VectorModule } from '../vector/vector.module'; + +@Module({ + imports: [VectorModule], + providers: [ImportService], + controllers: [ImportController], + exports: [ImportService], +}) +export class ImportModule {} diff --git a/apps/backend/src/import/import.service.ts b/apps/backend/src/import/import.service.ts new file mode 100644 index 0000000..605243f --- /dev/null +++ b/apps/backend/src/import/import.service.ts @@ -0,0 +1,214 @@ +import { Injectable, BadRequestException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { MemoryService } from '../vector/memory.service'; +import { TextFileAdapter } from './adapters/text-file.adapter'; +import { IImportAdapter, ImportResult } from './interfaces/import-adapter.interface'; +import { ImportStatus } from '@prisma/client'; + +@Injectable() +export class ImportService { + private adapters: IImportAdapter[]; + + constructor( + private prisma: PrismaService, + private memoryService: MemoryService, + ) { + this.adapters = [new TextFileAdapter()]; + } + + async uploadFile( + file: Express.Multer.File, + characterId: string, + userId: string, + ): Promise<{ knowledgeId: string; message: string }> { + // Find appropriate adapter + const adapter = this.adapters.find((a) => a.canHandle(file)); + + if (!adapter) { + throw new BadRequestException( + `Unsupported file type: ${file.mimetype}. Supported types: .txt, .md`, + ); + } + + // Parse the file + const result = await adapter.parse(file); + + // Create knowledge entry + const knowledge = await this.prisma.characterKnowledge.create({ + data: { + name: file.originalname, + sourceType: 'file', + sourceName: file.originalname, + mimeType: file.mimetype, + fileSize: BigInt(file.size), + rawContent: result.content, + status: 'processing', + processingInfo: result.metadata, + characterId, + }, + }); + + // Process the content in the background + this.processContent(knowledge.id, characterId, result).catch((error) => { + console.error('Error processing import:', error); + this.prisma.characterKnowledge.update({ + where: { id: knowledge.id }, + data: { + status: 'failed', + processingInfo: { + ...result.metadata, + error: error instanceof Error ? error.message : 'Unknown error', + }, + }, + }); + }); + + return { + knowledgeId: knowledge.id, + message: 'File uploaded and is being processed', + }; + } + + async getKnowledgeStatus(knowledgeId: string, userId: string) { + const knowledge = await this.prisma.characterKnowledge.findFirst({ + where: { + id: knowledgeId, + character: { + userId, + }, + }, + include: { + character: { + select: { + id: true, + name: true, + }, + }, + }, + }); + + if (!knowledge) { + throw new BadRequestException('Knowledge not found'); + } + + return knowledge; + } + + async deleteKnowledge(knowledgeId: string, userId: string): Promise { + const knowledge = await this.prisma.characterKnowledge.findFirst({ + where: { + id: knowledgeId, + character: { + userId, + }, + }, + }); + + if (!knowledge) { + throw new BadRequestException('Knowledge not found'); + } + + // Delete associated vector memories + await this.memoryService['vectorStore'].deleteByKnowledge(knowledgeId); + + // Delete the knowledge entry + await this.prisma.characterKnowledge.delete({ + where: { id: knowledgeId }, + }); + } + + async getCharacterKnowledge(characterId: string, userId: string) { + // Verify user owns the character + const character = await this.prisma.character.findFirst({ + where: { + id: characterId, + userId, + }, + }); + + if (!character) { + throw new BadRequestException('Character not found'); + } + + return this.prisma.characterKnowledge.findMany({ + where: { characterId }, + orderBy: { createdAt: 'desc' }, + }); + } + + private async processContent( + knowledgeId: string, + characterId: string, + result: ImportResult, + ): Promise { + try { + // Chunk the content into smaller pieces + const chunks = this.chunkContent(result.content, 1000, 200); + + // Store each chunk in vector memory + for (let i = 0; i < chunks.length; i++) { + await this.memoryService.storeCharacterKnowledge( + chunks[i], + characterId, + knowledgeId, + { + ...result.metadata, + chunkIndex: i, + totalChunks: chunks.length, + }, + ); + } + + // Update status to completed + await this.prisma.characterKnowledge.update({ + where: { id: knowledgeId }, + data: { + status: 'completed', + processingInfo: { + ...result.metadata, + chunksProcessed: chunks.length, + }, + }, + }); + } catch (error) { + // Update status to failed + await this.prisma.characterKnowledge.update({ + where: { id: knowledgeId }, + data: { + status: 'failed', + processingInfo: { + ...result.metadata, + error: error instanceof Error ? error.message : 'Unknown error', + }, + }, + }); + throw error; + } + } + + private chunkContent(content: string, chunkSize: number, overlap: number): string[] { + const chunks: string[] = []; + let start = 0; + + while (start < content.length) { + const end = Math.min(start + chunkSize, content.length); + let chunk = content.slice(start, end); + + // Try to break at a sentence boundary + if (end < content.length) { + const lastPeriod = chunk.lastIndexOf('.'); + const lastNewline = chunk.lastIndexOf('\n'); + const breakPoint = Math.max(lastPeriod, lastNewline); + + if (breakPoint > chunkSize * 0.5) { + chunk = chunk.slice(0, breakPoint + 1); + } + } + + chunks.push(chunk.trim()); + start += chunk.length - overlap; + } + + return chunks; + } +} diff --git a/apps/backend/src/import/interfaces/import-adapter.interface.ts b/apps/backend/src/import/interfaces/import-adapter.interface.ts new file mode 100644 index 0000000..497a43b --- /dev/null +++ b/apps/backend/src/import/interfaces/import-adapter.interface.ts @@ -0,0 +1,14 @@ +export interface ImportResult { + content: string; + metadata: { + sourceName: string; + mimeType: string; + fileSize?: number; + [key: string]: any; + }; +} + +export interface IImportAdapter { + canHandle(file: Express.Multer.File): boolean; + parse(file: Express.Multer.File): Promise; +} diff --git a/apps/backend/src/llm/interfaces/llm-provider.interface.ts b/apps/backend/src/llm/interfaces/llm-provider.interface.ts new file mode 100644 index 0000000..8f92c4b --- /dev/null +++ b/apps/backend/src/llm/interfaces/llm-provider.interface.ts @@ -0,0 +1,40 @@ +export interface LLMMessage { + role: 'system' | 'user' | 'assistant'; + content: string; +} + +export interface LLMCompletionOptions { + model?: string; + temperature?: number; + maxTokens?: number; + topP?: number; + frequencyPenalty?: number; + presencePenalty?: number; + stop?: string[]; +} + +export interface LLMCompletionResponse { + content: string; + model: string; + tokensUsed: number; + finishReason: string; +} + +export interface LLMStreamChunk { + content: string; + isDone: boolean; +} + +export interface ILLMProvider { + generateCompletion( + messages: LLMMessage[], + options?: LLMCompletionOptions, + ): Promise; + + generateStream( + messages: LLMMessage[], + options?: LLMCompletionOptions, + ): AsyncGenerator; + + countTokens(messages: LLMMessage[]): number; +} diff --git a/apps/backend/src/llm/llm.module.ts b/apps/backend/src/llm/llm.module.ts new file mode 100644 index 0000000..0e9a14b --- /dev/null +++ b/apps/backend/src/llm/llm.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { LLMService } from './llm.service'; + +@Module({ + providers: [LLMService], + exports: [LLMService], +}) +export class LLMModule {} diff --git a/apps/backend/src/llm/llm.service.ts b/apps/backend/src/llm/llm.service.ts new file mode 100644 index 0000000..e603d54 --- /dev/null +++ b/apps/backend/src/llm/llm.service.ts @@ -0,0 +1,38 @@ +import { Injectable } from '@nestjs/common'; +import { + ILLMProvider, + LLMMessage, + LLMCompletionOptions, + LLMCompletionResponse, + LLMStreamChunk, +} from './interfaces/llm-provider.interface'; +import { OpenRouterProvider } from './providers/openrouter.provider'; + +@Injectable() +export class LLMService { + private provider: ILLMProvider; + + constructor() { + // For now, only OpenRouter is supported + // In the future, this could be configurable + this.provider = new OpenRouterProvider(); + } + + async generateCompletion( + messages: LLMMessage[], + options?: LLMCompletionOptions, + ): Promise { + return this.provider.generateCompletion(messages, options); + } + + async *generateStream( + messages: LLMMessage[], + options?: LLMCompletionOptions, + ): AsyncGenerator { + yield* this.provider.generateStream(messages, options); + } + + countTokens(messages: LLMMessage[]): number { + return this.provider.countTokens(messages); + } +} diff --git a/apps/backend/src/llm/providers/openrouter.provider.ts b/apps/backend/src/llm/providers/openrouter.provider.ts new file mode 100644 index 0000000..5048c80 --- /dev/null +++ b/apps/backend/src/llm/providers/openrouter.provider.ts @@ -0,0 +1,140 @@ +import { Injectable } from '@nestjs/common'; +import { + ILLMProvider, + LLMMessage, + LLMCompletionOptions, + LLMCompletionResponse, + LLMStreamChunk, +} from '../interfaces/llm-provider.interface'; + +@Injectable() +export class OpenRouterProvider implements ILLMProvider { + private readonly apiKey: string; + private readonly baseUrl = 'https://openrouter.ai/api/v1'; + private readonly defaultModel: string; + + constructor() { + this.apiKey = process.env.LLM_API_KEY || ''; + this.defaultModel = process.env.LLM_MODEL || 'openai/gpt-4o'; + + if (!this.apiKey) { + console.warn('LLM_API_KEY not set. OpenRouter provider will not work.'); + } + } + + async generateCompletion( + messages: LLMMessage[], + options?: LLMCompletionOptions, + ): Promise { + const response = await fetch(`${this.baseUrl}/chat/completions`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.apiKey}`, + 'HTTP-Referer': process.env.APP_URL || 'http://localhost:3000', + 'X-Title': 'DreamChat', + }, + body: JSON.stringify({ + model: options?.model || this.defaultModel, + messages, + temperature: options?.temperature ?? 0.7, + max_tokens: options?.maxTokens, + top_p: options?.topP, + frequency_penalty: options?.frequencyPenalty, + presence_penalty: options?.presencePenalty, + stop: options?.stop, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`OpenRouter API error: ${error}`); + } + + const data = await response.json(); + const choice = data.choices[0]; + + return { + content: choice.message.content, + model: data.model, + tokensUsed: data.usage?.total_tokens || 0, + finishReason: choice.finish_reason, + }; + } + + async *generateStream( + messages: LLMMessage[], + options?: LLMCompletionOptions, + ): AsyncGenerator { + const response = await fetch(`${this.baseUrl}/chat/completions`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.apiKey}`, + 'HTTP-Referer': process.env.APP_URL || 'http://localhost:3000', + 'X-Title': 'DreamChat', + }, + body: JSON.stringify({ + model: options?.model || this.defaultModel, + messages, + temperature: options?.temperature ?? 0.7, + max_tokens: options?.maxTokens, + top_p: options?.topP, + frequency_penalty: options?.frequencyPenalty, + presence_penalty: options?.presencePenalty, + stop: options?.stop, + stream: true, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`OpenRouter API error: ${error}`); + } + + const reader = response.body?.getReader(); + if (!reader) { + throw new Error('No response body'); + } + + const decoder = new TextDecoder(); + let buffer = ''; + + 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: ')) { + const data = line.slice(6); + if (data === '[DONE]') { + yield { content: '', isDone: true }; + return; + } + + try { + const parsed = JSON.parse(data); + const content = parsed.choices[0]?.delta?.content || ''; + const isDone = parsed.choices[0]?.finish_reason != null; + + yield { content, isDone }; + } catch { + // Skip invalid JSON + } + } + } + } + + yield { content: '', isDone: true }; + } + + countTokens(messages: LLMMessage[]): number { + // Simple estimation: ~4 characters per token on average + const totalChars = messages.reduce((sum, msg) => sum + msg.content.length, 0); + return Math.ceil(totalChars / 4); + } +} diff --git a/apps/backend/src/main.ts b/apps/backend/src/main.ts index 8bdaff5..65efce0 100644 --- a/apps/backend/src/main.ts +++ b/apps/backend/src/main.ts @@ -1,4 +1,6 @@ +import 'dotenv/config'; import { NestFactory } from '@nestjs/core'; +import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; import { AppModule } from './app.module'; async function bootstrap() { @@ -10,11 +12,40 @@ async function bootstrap() { }); app.setGlobalPrefix('api'); + + // Swagger/OpenAPI setup + const config = new DocumentBuilder() + .setTitle('DreamChat API') + .setDescription('The DreamChat API documentation') + .setVersion('1.0.0') + .addBearerAuth() + .build(); + const document = SwaggerModule.createDocument(app, config); + SwaggerModule.setup('api/docs', app, document); + + // Also export the OpenAPI spec as JSON + const fs = await import('fs'); + const path = await import('path'); + + // Ensure the output directory exists + const outputDir = path.join(process.cwd(), '..', '..', 'openapi'); + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); + } + + // Write the spec file + fs.writeFileSync( + path.join(outputDir, 'openapi.json'), + JSON.stringify(document, null, 2), + ); + console.log(`📄 OpenAPI spec written to: ${path.join(outputDir, 'openapi.json')}`); + const port = process.env.PORT || 3000; await app.listen(port); console.log(`🚀 Backend running on: http://localhost:${port}/api`); + console.log(`📚 API docs available at: http://localhost:${port}/api/docs`); } bootstrap(); diff --git a/apps/backend/src/prisma/prisma.module.ts b/apps/backend/src/prisma/prisma.module.ts new file mode 100644 index 0000000..23c626e --- /dev/null +++ b/apps/backend/src/prisma/prisma.module.ts @@ -0,0 +1,9 @@ +import { Module, Global } from '@nestjs/common'; +import { PrismaService } from './prisma.service'; + +@Global() +@Module({ + providers: [PrismaService], + exports: [PrismaService], +}) +export class PrismaModule {} diff --git a/apps/backend/src/prisma/prisma.service.ts b/apps/backend/src/prisma/prisma.service.ts new file mode 100644 index 0000000..3d346bc --- /dev/null +++ b/apps/backend/src/prisma/prisma.service.ts @@ -0,0 +1,20 @@ +import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { PrismaPg } from '@prisma/adapter-pg'; +import { PrismaClient } from '@prisma/client'; + +@Injectable() +export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { + constructor() { + const adapter = new PrismaPg({ + connectionString: process.env.DATABASE_URL!, + }); + super({ adapter: adapter }); + } + async onModuleInit() { + await this.$connect(); + } + + async onModuleDestroy() { + await this.$disconnect(); + } +} diff --git a/apps/backend/src/user/dto/update-user.dto.ts b/apps/backend/src/user/dto/update-user.dto.ts new file mode 100644 index 0000000..a2fe370 --- /dev/null +++ b/apps/backend/src/user/dto/update-user.dto.ts @@ -0,0 +1,27 @@ +import { IsString, IsEmail, MinLength, IsOptional } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; + +export class UpdateUserDto { + @ApiPropertyOptional({ description: 'New email address', example: 'newemail@example.com' }) + @IsOptional() + @IsEmail() + email?: string; + + @ApiPropertyOptional({ description: 'New username', example: 'newusername' }) + @IsOptional() + @IsString() + @MinLength(3) + username?: string; +} + +export class UpdatePasswordDto { + @ApiPropertyOptional({ description: 'Current password', example: 'oldpassword123' }) + @IsString() + @MinLength(6) + currentPassword: string; + + @ApiPropertyOptional({ description: 'New password', example: 'newpassword123' }) + @IsString() + @MinLength(6) + newPassword: string; +} diff --git a/apps/backend/src/user/user.controller.ts b/apps/backend/src/user/user.controller.ts new file mode 100644 index 0000000..af8a194 --- /dev/null +++ b/apps/backend/src/user/user.controller.ts @@ -0,0 +1,54 @@ +import { Controller, Get, Put, Delete, Body } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; +import { UserService } from './user.service'; +import { UpdateUserDto, UpdatePasswordDto } from './dto/update-user.dto'; +import { CurrentUser } from '../common/decorators/current-user.decorator'; +import { User } from '@prisma/client'; + +@ApiTags('users') +@ApiBearerAuth() +@Controller('users') +export class UserController { + constructor(private userService: UserService) {} + + @Get('me') + @ApiOperation({ summary: 'Get current user profile' }) + @ApiResponse({ status: 200, description: 'User profile retrieved' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + async getProfile(@CurrentUser('userId') userId: string): Promise> { + return this.userService.findById(userId); + } + + @Put('me') + @ApiOperation({ summary: 'Update current user profile' }) + @ApiResponse({ status: 200, description: 'Profile updated' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 409, description: 'Email or username already exists' }) + async updateProfile( + @CurrentUser('userId') userId: string, + @Body() updateUserDto: UpdateUserDto, + ): Promise> { + return this.userService.update(userId, updateUserDto); + } + + @Put('me/password') + @ApiOperation({ summary: 'Update user password' }) + @ApiResponse({ status: 200, description: 'Password updated successfully' }) + @ApiResponse({ status: 401, description: 'Current password incorrect' }) + async updatePassword( + @CurrentUser('userId') userId: string, + @Body() updatePasswordDto: UpdatePasswordDto, + ): Promise<{ message: string }> { + await this.userService.updatePassword(userId, updatePasswordDto); + return { message: 'Password updated successfully' }; + } + + @Delete('me') + @ApiOperation({ summary: 'Delete user account' }) + @ApiResponse({ status: 200, description: 'Account deleted' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + async deleteAccount(@CurrentUser('userId') userId: string): Promise<{ message: string }> { + await this.userService.delete(userId); + return { message: 'Account deleted successfully' }; + } +} diff --git a/apps/backend/src/user/user.module.ts b/apps/backend/src/user/user.module.ts new file mode 100644 index 0000000..240cbd0 --- /dev/null +++ b/apps/backend/src/user/user.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { UserService } from './user.service'; +import { UserController } from './user.controller'; + +@Module({ + providers: [UserService], + controllers: [UserController], + exports: [UserService], +}) +export class UserModule {} diff --git a/apps/backend/src/user/user.service.ts b/apps/backend/src/user/user.service.ts new file mode 100644 index 0000000..037df01 --- /dev/null +++ b/apps/backend/src/user/user.service.ts @@ -0,0 +1,95 @@ +import { Injectable, NotFoundException, ConflictException, UnauthorizedException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { UpdateUserDto, UpdatePasswordDto } from './dto/update-user.dto'; +import * as bcrypt from 'bcrypt'; +import { User } from '@prisma/client'; + +@Injectable() +export class UserService { + constructor(private prisma: PrismaService) {} + + async findById(id: string): Promise> { + const user = await this.prisma.user.findUnique({ + where: { id }, + }); + + if (!user) { + throw new NotFoundException('User not found'); + } + + const { passwordHash, ...userWithoutPassword } = user; + return userWithoutPassword; + } + + async update(id: string, updateUserDto: UpdateUserDto): Promise> { + const existingUser = await this.prisma.user.findUnique({ + where: { id }, + }); + + if (!existingUser) { + throw new NotFoundException('User not found'); + } + + if (updateUserDto.email && updateUserDto.email !== existingUser.email) { + const emailExists = await this.prisma.user.findUnique({ + where: { email: updateUserDto.email }, + }); + if (emailExists) { + throw new ConflictException('Email already in use'); + } + } + + if (updateUserDto.username && updateUserDto.username !== existingUser.username) { + const usernameExists = await this.prisma.user.findUnique({ + where: { username: updateUserDto.username }, + }); + if (usernameExists) { + throw new ConflictException('Username already in use'); + } + } + + const user = await this.prisma.user.update({ + where: { id }, + data: updateUserDto, + }); + + const { passwordHash, ...userWithoutPassword } = user; + return userWithoutPassword; + } + + async updatePassword(id: string, updatePasswordDto: UpdatePasswordDto): Promise { + const user = await this.prisma.user.findUnique({ + where: { id }, + }); + + if (!user || !user.passwordHash) { + throw new NotFoundException('User not found'); + } + + const isMatch = await bcrypt.compare(updatePasswordDto.currentPassword, user.passwordHash); + if (!isMatch) { + throw new UnauthorizedException('Current password is incorrect'); + } + + const hashedPassword = await bcrypt.hash(updatePasswordDto.newPassword, 10); + + await this.prisma.user.update({ + where: { id }, + data: { passwordHash: hashedPassword }, + }); + } + + async delete(id: string): Promise { + const user = await this.prisma.user.findUnique({ + where: { id }, + }); + + if (!user) { + throw new NotFoundException('User not found'); + } + + await this.prisma.user.delete({ + where: { id }, + }); + } +} diff --git a/apps/backend/src/vector/embedding.service.ts b/apps/backend/src/vector/embedding.service.ts new file mode 100644 index 0000000..98c905c --- /dev/null +++ b/apps/backend/src/vector/embedding.service.ts @@ -0,0 +1,32 @@ +import { Injectable } from '@nestjs/common'; +import { IEmbeddingProvider } from './interfaces/embedding-provider.interface'; +import { LocalEmbeddingProvider } from './providers/local-embedding.provider'; + +@Injectable() +export class EmbeddingService { + private provider: IEmbeddingProvider; + + constructor() { + const providerType = process.env.EMBEDDING_PROVIDER || 'local'; + + switch (providerType) { + case 'local': + this.provider = new LocalEmbeddingProvider(); + break; + default: + throw new Error(`Unknown embedding provider: ${providerType}`); + } + } + + async embed(text: string): Promise { + return this.provider.embed(text); + } + + async embedBatch(texts: string[]): Promise { + return this.provider.embedBatch(texts); + } + + getDimension(): number { + return this.provider.getDimension(); + } +} diff --git a/apps/backend/src/vector/interfaces/embedding-provider.interface.ts b/apps/backend/src/vector/interfaces/embedding-provider.interface.ts new file mode 100644 index 0000000..fcc13ff --- /dev/null +++ b/apps/backend/src/vector/interfaces/embedding-provider.interface.ts @@ -0,0 +1,5 @@ +export interface IEmbeddingProvider { + embed(text: string): Promise; + embedBatch(texts: string[]): Promise; + getDimension(): number; +} diff --git a/apps/backend/src/vector/memory.service.ts b/apps/backend/src/vector/memory.service.ts new file mode 100644 index 0000000..524e192 --- /dev/null +++ b/apps/backend/src/vector/memory.service.ts @@ -0,0 +1,122 @@ +import { Injectable } from '@nestjs/common'; +import { EmbeddingService } from './embedding.service'; +import { VectorStoreService, SearchResult } from './vector-store.service'; +import { MemoryType } from '@prisma/client'; + +export interface MemoryContext { + content: string; + metadata: any; + similarity: number; +} + +@Injectable() +export class MemoryService { + constructor( + private embeddingService: EmbeddingService, + private vectorStore: VectorStoreService, + ) {} + + async addMemory( + content: string, + memoryType: MemoryType, + options: { + conversationId?: string; + characterId?: string; + knowledgeId?: string; + metadata?: any; + }, + ): Promise { + const embedding = await this.embeddingService.embed(content); + await this.vectorStore.store(content, embedding, memoryType, options); + } + + async retrieveRelevantMemories( + query: string, + options: { + limit?: number; + threshold?: number; + conversationId?: string; + characterId?: string; + memoryType?: MemoryType; + }, + ): Promise { + const embedding = await this.embeddingService.embed(query); + const results = await this.vectorStore.searchSimilar(embedding, options); + + return results.map((result) => ({ + content: result.content, + metadata: result.metadata, + similarity: result.similarity, + })); + } + + async buildContextForConversation( + conversationId: string, + currentMessage: string, + characterId: string, + ): Promise { + // Retrieve recent conversation memories + const conversationMemories = await this.retrieveRelevantMemories( + currentMessage, + { + limit: 3, + threshold: 0.6, + conversationId, + memoryType: 'conversation', + }, + ); + + // Retrieve character knowledge + const characterMemories = await this.retrieveRelevantMemories( + currentMessage, + { + limit: 3, + threshold: 0.7, + characterId, + memoryType: 'character', + }, + ); + + const contextParts: string[] = []; + + if (characterMemories.length > 0) { + contextParts.push('Relevant character knowledge:'); + characterMemories.forEach((memory) => { + contextParts.push(`- ${memory.content}`); + }); + } + + if (conversationMemories.length > 0) { + contextParts.push('\nRelevant conversation history:'); + conversationMemories.forEach((memory) => { + contextParts.push(`- ${memory.content}`); + }); + } + + return contextParts.join('\n'); + } + + async storeConversationMessage( + content: string, + conversationId: string, + metadata?: any, + ): Promise { + await this.addMemory(content, 'conversation', { + conversationId, + metadata, + }); + } + + async storeCharacterKnowledge( + content: string, + characterId: string, + knowledgeId: string, + metadata?: any, + ): Promise { + await this.addMemory(content, 'character', { + characterId, + knowledgeId, + metadata, + }); + } +} diff --git a/apps/backend/src/vector/providers/local-embedding.provider.ts b/apps/backend/src/vector/providers/local-embedding.provider.ts new file mode 100644 index 0000000..b52b511 --- /dev/null +++ b/apps/backend/src/vector/providers/local-embedding.provider.ts @@ -0,0 +1,48 @@ +import { Injectable, OnModuleInit } from '@nestjs/common'; +import { IEmbeddingProvider } from '../interfaces/embedding-provider.interface'; +import { pipeline, FeatureExtractionPipeline } from '@xenova/transformers'; + +@Injectable() +export class LocalEmbeddingProvider implements IEmbeddingProvider, OnModuleInit { + private extractor: FeatureExtractionPipeline | null = null; + private readonly modelName: string; + private readonly dimension: number; + + constructor() { + this.modelName = process.env.EMBEDDING_MODEL || 'Xenova/all-MiniLM-L6-v2'; + this.dimension = parseInt(process.env.EMBEDDING_DIMENSION || '384', 10); + } + + async onModuleInit() { + // Lazy initialization - model will be loaded on first use + } + + private async getExtractor(): Promise { + if (!this.extractor) { + this.extractor = await pipeline('feature-extraction', this.modelName, { + quantized: false, // Use full precision for better quality + }); + } + return this.extractor; + } + + async embed(text: string): Promise { + const extractor = await this.getExtractor(); + const output = await extractor(text, { pooling: 'mean', normalize: true }); + return Array.from(output.data as Float32Array); + } + + async embedBatch(texts: string[]): Promise { + const extractor = await this.getExtractor(); + const outputs = await Promise.all( + texts.map((text) => + extractor(text, { pooling: 'mean', normalize: true }), + ), + ); + return outputs.map((output) => Array.from(output.data as Float32Array)); + } + + getDimension(): number { + return this.dimension; + } +} diff --git a/apps/backend/src/vector/vector-store.service.ts b/apps/backend/src/vector/vector-store.service.ts new file mode 100644 index 0000000..b070c0d --- /dev/null +++ b/apps/backend/src/vector/vector-store.service.ts @@ -0,0 +1,116 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { MemoryType, VectorMemory } from '@prisma/client'; + +export interface SearchResult { + id: string; + content: string; + memoryType: MemoryType; + metadata: any; + similarity: number; +} + +@Injectable() +export class VectorStoreService { + constructor(private prisma: PrismaService) {} + + async store( + content: string, + embedding: number[], + memoryType: MemoryType, + options: { + conversationId?: string; + characterId?: string; + knowledgeId?: string; + metadata?: any; + }, + ): Promise { + const vectorString = `[${embedding.join(',')}]`; + + return this.prisma.$queryRaw` + INSERT INTO "VectorMemory" (id, content, embedding, "memoryType", metadata, "conversationId", "characterId", "knowledgeId", "createdAt") + VALUES ( + gen_random_uuid(), + ${content}, + ${vectorString}::vector, + ${memoryType}, + ${options.metadata ? JSON.stringify(options.metadata) : null}::jsonb, + ${options.conversationId || null}, + ${options.characterId || null}, + ${options.knowledgeId || null}, + NOW() + ) + RETURNING * + `.then((results) => results[0]); + } + + async searchSimilar( + embedding: number[], + options: { + limit?: number; + threshold?: number; + conversationId?: string; + characterId?: string; + memoryType?: MemoryType; + }, + ): Promise { + const { limit = 5, threshold = 0.7 } = options; + const vectorString = `[${embedding.join(',')}]`; + + let whereClause = ''; + const params: any[] = [vectorString, threshold, limit]; + let paramIndex = 4; + + if (options.conversationId) { + whereClause += ` AND "conversationId" = $${paramIndex}`; + params.push(options.conversationId); + paramIndex++; + } + + if (options.characterId) { + whereClause += ` AND "characterId" = $${paramIndex}`; + params.push(options.characterId); + paramIndex++; + } + + if (options.memoryType) { + whereClause += ` AND "memoryType" = $${paramIndex}`; + params.push(options.memoryType); + paramIndex++; + } + + const query = ` + SELECT + id, + content, + "memoryType", + metadata, + 1 - (embedding <=> $1::vector) as similarity + FROM "VectorMemory" + WHERE 1 - (embedding <=> $1::vector) >= $2 + ${whereClause} + ORDER BY embedding <=> $1::vector + LIMIT $3 + `; + + return this.prisma.$queryRawUnsafe(query, ...params); + } + + async deleteByConversation(conversationId: string): Promise { + await this.prisma.vectorMemory.deleteMany({ + where: { conversationId }, + }); + } + + async deleteByCharacter(characterId: string): Promise { + await this.prisma.vectorMemory.deleteMany({ + where: { characterId }, + }); + } + + async deleteByKnowledge(knowledgeId: string): Promise { + await this.prisma.vectorMemory.deleteMany({ + where: { knowledgeId }, + }); + } +} diff --git a/apps/backend/src/vector/vector.module.ts b/apps/backend/src/vector/vector.module.ts new file mode 100644 index 0000000..869965e --- /dev/null +++ b/apps/backend/src/vector/vector.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { EmbeddingService } from './embedding.service'; +import { VectorStoreService } from './vector-store.service'; +import { MemoryService } from './memory.service'; + +@Module({ + providers: [EmbeddingService, VectorStoreService, MemoryService], + exports: [EmbeddingService, VectorStoreService, MemoryService], +}) +export class VectorModule {} diff --git a/apps/frontend/openapitools.json b/apps/frontend/openapitools.json new file mode 100644 index 0000000..c121433 --- /dev/null +++ b/apps/frontend/openapitools.json @@ -0,0 +1,7 @@ +{ + "$schema": "./node_modules/@openapitools/openapi-generator-cli/config.schema.json", + "spaces": 2, + "generator-cli": { + "version": "7.20.0" + } +} diff --git a/apps/frontend/orval.config.js b/apps/frontend/orval.config.js new file mode 100644 index 0000000..0b358b6 --- /dev/null +++ b/apps/frontend/orval.config.js @@ -0,0 +1,24 @@ +module.exports = { + dreamchat: { + input: { + target: '../openapi/openapi.json', + }, + output: { + mode: 'tags-split', + target: './src/api/generated', + schemas: './src/api/generated/model', + client: 'fetch', + baseUrl: 'http://localhost:3000', + mock: false, + override: { + fetch: { + includeHttpResponseReturnType: false, + }, + mutator: { + path: './src/api/mutator/custom-fetch.ts', + name: 'customFetch', + }, + }, + }, + }, +}; diff --git a/apps/frontend/package.json b/apps/frontend/package.json index fe44430..ca22502 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -8,7 +8,8 @@ "preview": "vite preview", "test": "vitest", "lint": "eslint . --ext ts,tsx", - "clean": "rm -rf dist" + "clean": "rm -rf dist", + "api:generate": "orval" }, "dependencies": { "@dreamchat/shared": "workspace:*", @@ -20,10 +21,13 @@ "zustand": "^4.5.0" }, "devDependencies": { + "@openapitools/openapi-generator-cli": "^2.29.0", "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", "@vitejs/plugin-react": "^4.2.0", "autoprefixer": "^10.4.0", + "openapi-typescript": "^7.13.0", + "orval": "^8.4.2", "postcss": "^8.4.0", "tailwindcss": "^3.4.0", "typescript": "^5.3.0", diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx index f07479c..8dd33d0 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -1,13 +1,132 @@ +import { useEffect } from 'react'; +import { BrowserRouter, Routes, Route, Navigate, useSearchParams, useNavigate } from 'react-router-dom'; +import { useAuthStore } from './stores/authStore'; +import { Login } from './pages/Login'; +import { CharacterList } from './pages/CharacterList'; +import { CharacterForm } from './pages/CharacterForm'; +import { ConversationList } from './pages/ConversationList'; +import { Chat } from './pages/Chat'; + +// OAuth Callback Handler - processes tokens from URL before routing +function OAuthCallbackHandler({ children }: { children: React.ReactNode }) { + const [searchParams, setSearchParams] = useSearchParams(); + const navigate = useNavigate(); + const { setTokens } = useAuthStore(); + + useEffect(() => { + const accessToken = searchParams.get('accessToken'); + const refreshToken = searchParams.get('refreshToken'); + const errorMsg = searchParams.get('error'); + + if (errorMsg) { + // Redirect to login with error + const decodedError = decodeURIComponent(errorMsg); + setSearchParams({}, { replace: true }); + navigate(`/login?error=${encodeURIComponent(decodedError)}`, { replace: true }); + return; + } + + if (accessToken && refreshToken) { + // Store tokens + setTokens(accessToken, refreshToken); + // Clear tokens from URL but keep the path + setSearchParams({}, { replace: true }); + } + }, [searchParams, setSearchParams, navigate, setTokens]); + + return <>{children}; +} + +function PrivateRoute({ children }: { children: React.ReactNode }) { + const { isAuthenticated } = useAuthStore(); + + return isAuthenticated ? <>{children} : ; +} + +function PublicRoute({ children }: { children: React.ReactNode }) { + const { isAuthenticated } = useAuthStore(); + + return !isAuthenticated ? <>{children} : ; +} + function App() { + const { init } = useAuthStore(); + + useEffect(() => { + init(); + }, [init]); + return ( -
-
-

DreamChat

-

- Character simulation and interactive storytelling platform -

-
-
+ + + + } /> + + + + + } + /> + + + + + } + /> + + + + + } + /> + + + + + } + /> + + + + + } + /> + + + + + } + /> + + + + + } + /> + + + ); } diff --git a/apps/frontend/src/api/generated/auth/auth.ts b/apps/frontend/src/api/generated/auth/auth.ts new file mode 100644 index 0000000..b61e249 --- /dev/null +++ b/apps/frontend/src/api/generated/auth/auth.ts @@ -0,0 +1,173 @@ +/** + * Generated by orval v8.4.2 🍺 + * Do not edit manually. + * DreamChat API + * The DreamChat API documentation + * OpenAPI spec version: 1.0.0 + */ +import type { + AuthControllerKeycloakCallbackParams, + AuthControllerKeycloakLoginParams, + AuthResponseDto, + KeycloakConfigDto, + KeycloakLoginUrlDto, + LoginDto, + RefreshTokenDto +} from '.././model'; + +import { customFetch } from '../../mutator/custom-fetch'; + +/** + * @summary Login with email and password + */ +export const getAuthControllerLoginUrl = () => { + + + + + return `http://localhost:3000/api/auth/login` +} + +export const authControllerLogin = async (loginDto: LoginDto, options?: RequestInit): Promise => { + + return customFetch(getAuthControllerLoginUrl(), + { + ...options, + method: 'POST', + headers: { 'Content-Type': 'application/json', ...options?.headers }, + body: JSON.stringify( + loginDto,) + } +);} + + +/** + * @summary Refresh access token + */ +export const getAuthControllerRefreshTokensUrl = () => { + + + + + return `http://localhost:3000/api/auth/refresh` +} + +export const authControllerRefreshTokens = async (refreshTokenDto: RefreshTokenDto, options?: RequestInit): Promise => { + + return customFetch(getAuthControllerRefreshTokensUrl(), + { + ...options, + method: 'POST', + headers: { 'Content-Type': 'application/json', ...options?.headers }, + body: JSON.stringify( + refreshTokenDto,) + } +);} + + +/** + * @summary Get Keycloak configuration for frontend + */ +export const getAuthControllerGetKeycloakConfigUrl = () => { + + + + + return `http://localhost:3000/api/auth/keycloak/config` +} + +export const authControllerGetKeycloakConfig = async ( options?: RequestInit): Promise => { + + return customFetch(getAuthControllerGetKeycloakConfigUrl(), + { + ...options, + method: 'GET' + + + } +);} + + +/** + * @summary Get Keycloak login URL (initiates OAuth flow) + */ +export const getAuthControllerKeycloakLoginUrl = (params?: AuthControllerKeycloakLoginParams,) => { + const normalizedParams = new URLSearchParams(); + + Object.entries(params || {}).forEach(([key, value]) => { + + if (value !== undefined) { + normalizedParams.append(key, value === null ? 'null' : value.toString()) + } + }); + + const stringifiedParams = normalizedParams.toString(); + + return stringifiedParams.length > 0 ? `http://localhost:3000/api/auth/keycloak/login?${stringifiedParams}` : `http://localhost:3000/api/auth/keycloak/login` +} + +export const authControllerKeycloakLogin = async (params?: AuthControllerKeycloakLoginParams, options?: RequestInit): Promise => { + + return customFetch(getAuthControllerKeycloakLoginUrl(params), + { + ...options, + method: 'GET' + + + } +);} + + +/** + * @summary Keycloak OAuth callback endpoint + */ +export const getAuthControllerKeycloakCallbackUrl = (params: AuthControllerKeycloakCallbackParams,) => { + const normalizedParams = new URLSearchParams(); + + Object.entries(params || {}).forEach(([key, value]) => { + + if (value !== undefined) { + normalizedParams.append(key, value === null ? 'null' : value.toString()) + } + }); + + const stringifiedParams = normalizedParams.toString(); + + return stringifiedParams.length > 0 ? `http://localhost:3000/api/auth/keycloak/callback?${stringifiedParams}` : `http://localhost:3000/api/auth/keycloak/callback` +} + +export const authControllerKeycloakCallback = async (params: AuthControllerKeycloakCallbackParams, options?: RequestInit): Promise => { + + return customFetch(getAuthControllerKeycloakCallbackUrl(params), + { + ...options, + method: 'GET' + + + } +);} + + +/** + * @summary Login with Keycloak bearer token (Authorization: Bearer ) + */ +export const getAuthControllerKeycloakBearerLoginUrl = () => { + + + + + return `http://localhost:3000/api/auth/keycloak` +} + +export const authControllerKeycloakBearerLogin = async ( options?: RequestInit): Promise => { + + return customFetch(getAuthControllerKeycloakBearerLoginUrl(), + { + ...options, + method: 'POST' + + + } +);} + + diff --git a/apps/frontend/src/api/generated/characters/characters.ts b/apps/frontend/src/api/generated/characters/characters.ts new file mode 100644 index 0000000..3a6a112 --- /dev/null +++ b/apps/frontend/src/api/generated/characters/characters.ts @@ -0,0 +1,133 @@ +/** + * Generated by orval v8.4.2 🍺 + * Do not edit manually. + * DreamChat API + * The DreamChat API documentation + * OpenAPI spec version: 1.0.0 + */ +import type { + CharacterResponseDto, + CreateCharacterDto, + UpdateCharacterDto +} from '.././model'; + +import { customFetch } from '../../mutator/custom-fetch'; + +/** + * @summary Create a new character + */ +export const getCharacterControllerCreateUrl = () => { + + + + + return `http://localhost:3000/api/characters` +} + +export const characterControllerCreate = async (createCharacterDto: CreateCharacterDto, options?: RequestInit): Promise => { + + return customFetch(getCharacterControllerCreateUrl(), + { + ...options, + method: 'POST', + headers: { 'Content-Type': 'application/json', ...options?.headers }, + body: JSON.stringify( + createCharacterDto,) + } +);} + + +/** + * @summary Get all characters for current user + */ +export const getCharacterControllerFindAllUrl = () => { + + + + + return `http://localhost:3000/api/characters` +} + +export const characterControllerFindAll = async ( options?: RequestInit): Promise => { + + return customFetch(getCharacterControllerFindAllUrl(), + { + ...options, + method: 'GET' + + + } +);} + + +/** + * @summary Get character by ID + */ +export const getCharacterControllerFindOneUrl = (id: string,) => { + + + + + return `http://localhost:3000/api/characters/${id}` +} + +export const characterControllerFindOne = async (id: string, options?: RequestInit): Promise => { + + return customFetch(getCharacterControllerFindOneUrl(id), + { + ...options, + method: 'GET' + + + } +);} + + +/** + * @summary Update character + */ +export const getCharacterControllerUpdateUrl = (id: string,) => { + + + + + return `http://localhost:3000/api/characters/${id}` +} + +export const characterControllerUpdate = async (id: string, + updateCharacterDto: UpdateCharacterDto, options?: RequestInit): Promise => { + + return customFetch(getCharacterControllerUpdateUrl(id), + { + ...options, + method: 'PUT', + headers: { 'Content-Type': 'application/json', ...options?.headers }, + body: JSON.stringify( + updateCharacterDto,) + } +);} + + +/** + * @summary Delete character + */ +export const getCharacterControllerDeleteUrl = (id: string,) => { + + + + + return `http://localhost:3000/api/characters/${id}` +} + +export const characterControllerDelete = async (id: string, options?: RequestInit): Promise => { + + return customFetch(getCharacterControllerDeleteUrl(id), + { + ...options, + method: 'DELETE' + + + } +);} + + diff --git a/apps/frontend/src/api/generated/conversations/conversations.ts b/apps/frontend/src/api/generated/conversations/conversations.ts new file mode 100644 index 0000000..dcdcab0 --- /dev/null +++ b/apps/frontend/src/api/generated/conversations/conversations.ts @@ -0,0 +1,135 @@ +/** + * Generated by orval v8.4.2 🍺 + * Do not edit manually. + * DreamChat API + * The DreamChat API documentation + * OpenAPI spec version: 1.0.0 + */ +import type { + ConversationResponseDto, + ConversationWithMessagesResponseDto, + CreateConversationDto, + SendMessageDto, + SendMessageResponseDto +} from '.././model'; + +import { customFetch } from '../../mutator/custom-fetch'; + +/** + * @summary Create a new conversation + */ +export const getChatControllerCreateConversationUrl = () => { + + + + + return `http://localhost:3000/api/conversations` +} + +export const chatControllerCreateConversation = async (createConversationDto: CreateConversationDto, options?: RequestInit): Promise => { + + return customFetch(getChatControllerCreateConversationUrl(), + { + ...options, + method: 'POST', + headers: { 'Content-Type': 'application/json', ...options?.headers }, + body: JSON.stringify( + createConversationDto,) + } +);} + + +/** + * @summary Get all conversations for current user + */ +export const getChatControllerGetConversationsUrl = () => { + + + + + return `http://localhost:3000/api/conversations` +} + +export const chatControllerGetConversations = async ( options?: RequestInit): Promise => { + + return customFetch(getChatControllerGetConversationsUrl(), + { + ...options, + method: 'GET' + + + } +);} + + +/** + * @summary Get conversation by ID with messages + */ +export const getChatControllerGetConversationUrl = (id: string,) => { + + + + + return `http://localhost:3000/api/conversations/${id}` +} + +export const chatControllerGetConversation = async (id: string, options?: RequestInit): Promise => { + + return customFetch(getChatControllerGetConversationUrl(id), + { + ...options, + method: 'GET' + + + } +);} + + +/** + * @summary Delete conversation + */ +export const getChatControllerDeleteConversationUrl = (id: string,) => { + + + + + return `http://localhost:3000/api/conversations/${id}` +} + +export const chatControllerDeleteConversation = async (id: string, options?: RequestInit): Promise => { + + return customFetch(getChatControllerDeleteConversationUrl(id), + { + ...options, + method: 'DELETE' + + + } +);} + + +/** + * @summary Send a message in a conversation + */ +export const getChatControllerSendMessageUrl = (id: string,) => { + + + + + return `http://localhost:3000/api/conversations/${id}/messages` +} + +export const chatControllerSendMessage = async (id: string, + sendMessageDto: SendMessageDto, options?: RequestInit): Promise => { + + return customFetch(getChatControllerSendMessageUrl(id), + { + ...options, + method: 'POST', + headers: { 'Content-Type': 'application/json', ...options?.headers }, + body: JSON.stringify( + sendMessageDto,) + } +);} + + diff --git a/apps/frontend/src/api/generated/import/import.ts b/apps/frontend/src/api/generated/import/import.ts new file mode 100644 index 0000000..9b137a7 --- /dev/null +++ b/apps/frontend/src/api/generated/import/import.ts @@ -0,0 +1,111 @@ +/** + * Generated by orval v8.4.2 🍺 + * Do not edit manually. + * DreamChat API + * The DreamChat API documentation + * OpenAPI spec version: 1.0.0 + */ +import type { + ImportControllerUploadFileBody +} from '.././model'; + +import { customFetch } from '../../mutator/custom-fetch'; + +/** + * @summary Upload a file for character knowledge + */ +export const getImportControllerUploadFileUrl = (characterId: string,) => { + + + + + return `http://localhost:3000/api/import/characters/${characterId}/files` +} + +export const importControllerUploadFile = async (characterId: string, + importControllerUploadFileBody: ImportControllerUploadFileBody, options?: RequestInit): Promise => { + const formData = new FormData(); +if(importControllerUploadFileBody.file !== undefined) { + formData.append(`file`, importControllerUploadFileBody.file); + } + + return customFetch(getImportControllerUploadFileUrl(characterId), + { + ...options, + method: 'POST' + , + body: + formData, + } +);} + + +/** + * @summary Get knowledge processing status + */ +export const getImportControllerGetKnowledgeStatusUrl = (knowledgeId: string,) => { + + + + + return `http://localhost:3000/api/import/knowledge/${knowledgeId}/status` +} + +export const importControllerGetKnowledgeStatus = async (knowledgeId: string, options?: RequestInit): Promise => { + + return customFetch(getImportControllerGetKnowledgeStatusUrl(knowledgeId), + { + ...options, + method: 'GET' + + + } +);} + + +/** + * @summary Get all knowledge for a character + */ +export const getImportControllerGetCharacterKnowledgeUrl = (characterId: string,) => { + + + + + return `http://localhost:3000/api/import/characters/${characterId}/knowledge` +} + +export const importControllerGetCharacterKnowledge = async (characterId: string, options?: RequestInit): Promise => { + + return customFetch(getImportControllerGetCharacterKnowledgeUrl(characterId), + { + ...options, + method: 'GET' + + + } +);} + + +/** + * @summary Delete knowledge + */ +export const getImportControllerDeleteKnowledgeUrl = (knowledgeId: string,) => { + + + + + return `http://localhost:3000/api/import/knowledge/${knowledgeId}` +} + +export const importControllerDeleteKnowledge = async (knowledgeId: string, options?: RequestInit): Promise => { + + return customFetch(getImportControllerDeleteKnowledgeUrl(knowledgeId), + { + ...options, + method: 'DELETE' + + + } +);} + + diff --git a/apps/frontend/src/api/generated/model/authControllerKeycloakCallbackParams.ts b/apps/frontend/src/api/generated/model/authControllerKeycloakCallbackParams.ts new file mode 100644 index 0000000..d6fbe8c --- /dev/null +++ b/apps/frontend/src/api/generated/model/authControllerKeycloakCallbackParams.ts @@ -0,0 +1,26 @@ +/** + * Generated by orval v8.4.2 🍺 + * Do not edit manually. + * DreamChat API + * The DreamChat API documentation + * OpenAPI spec version: 1.0.0 + */ + +export type AuthControllerKeycloakCallbackParams = { +/** + * Authorization code from Keycloak + */ +code: string; +/** + * Error message if authentication failed + */ +error?: string; +/** + * Error description + */ +error_description?: string; +/** + * State parameter for CSRF validation + */ +state: string; +}; diff --git a/apps/frontend/src/api/generated/model/authControllerKeycloakLoginParams.ts b/apps/frontend/src/api/generated/model/authControllerKeycloakLoginParams.ts new file mode 100644 index 0000000..7d0003a --- /dev/null +++ b/apps/frontend/src/api/generated/model/authControllerKeycloakLoginParams.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v8.4.2 🍺 + * Do not edit manually. + * DreamChat API + * The DreamChat API documentation + * OpenAPI spec version: 1.0.0 + */ + +export type AuthControllerKeycloakLoginParams = { +/** + * Frontend path to redirect after login + */ +redirectTo?: string; +}; diff --git a/apps/frontend/src/api/generated/model/authResponseDto.ts b/apps/frontend/src/api/generated/model/authResponseDto.ts new file mode 100644 index 0000000..1c91218 --- /dev/null +++ b/apps/frontend/src/api/generated/model/authResponseDto.ts @@ -0,0 +1,17 @@ +/** + * Generated by orval v8.4.2 🍺 + * Do not edit manually. + * DreamChat API + * The DreamChat API documentation + * OpenAPI spec version: 1.0.0 + */ +import type { UserDto } from './userDto'; + +export interface AuthResponseDto { + /** JWT access token */ + accessToken: string; + /** JWT refresh token */ + refreshToken: string; + /** User information */ + user: UserDto; +} diff --git a/apps/frontend/src/api/generated/model/characterResponseDto.ts b/apps/frontend/src/api/generated/model/characterResponseDto.ts new file mode 100644 index 0000000..6685984 --- /dev/null +++ b/apps/frontend/src/api/generated/model/characterResponseDto.ts @@ -0,0 +1,32 @@ +/** + * Generated by orval v8.4.2 🍺 + * Do not edit manually. + * DreamChat API + * The DreamChat API documentation + * OpenAPI spec version: 1.0.0 + */ +import type { CharacterResponseDtoAttributes } from './characterResponseDtoAttributes'; +import type { CharacterResponseDtoConfig } from './characterResponseDtoConfig'; + +export interface CharacterResponseDto { + /** Character ID */ + id: string; + /** Character name */ + name: string; + /** Avatar URL */ + avatarUrl?: string; + /** Personality prompt */ + personalityPrompt: string; + /** Custom attributes */ + attributes: CharacterResponseDtoAttributes; + /** Character configuration */ + config: CharacterResponseDtoConfig; + /** Whether character is public */ + isPublic: boolean; + /** Creation date */ + createdAt: string; + /** Last update date */ + updatedAt: string; + /** User ID */ + userId: string; +} diff --git a/apps/frontend/src/api/generated/model/characterResponseDtoAttributes.ts b/apps/frontend/src/api/generated/model/characterResponseDtoAttributes.ts new file mode 100644 index 0000000..174f8b7 --- /dev/null +++ b/apps/frontend/src/api/generated/model/characterResponseDtoAttributes.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval v8.4.2 🍺 + * Do not edit manually. + * DreamChat API + * The DreamChat API documentation + * OpenAPI spec version: 1.0.0 + */ + +/** + * Custom attributes + */ +export type CharacterResponseDtoAttributes = { [key: string]: unknown }; diff --git a/apps/frontend/src/api/generated/model/characterResponseDtoConfig.ts b/apps/frontend/src/api/generated/model/characterResponseDtoConfig.ts new file mode 100644 index 0000000..80413f7 --- /dev/null +++ b/apps/frontend/src/api/generated/model/characterResponseDtoConfig.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval v8.4.2 🍺 + * Do not edit manually. + * DreamChat API + * The DreamChat API documentation + * OpenAPI spec version: 1.0.0 + */ + +/** + * Character configuration + */ +export type CharacterResponseDtoConfig = { [key: string]: unknown }; diff --git a/apps/frontend/src/api/generated/model/characterSummaryDto.ts b/apps/frontend/src/api/generated/model/characterSummaryDto.ts new file mode 100644 index 0000000..eefbe3b --- /dev/null +++ b/apps/frontend/src/api/generated/model/characterSummaryDto.ts @@ -0,0 +1,16 @@ +/** + * Generated by orval v8.4.2 🍺 + * Do not edit manually. + * DreamChat API + * The DreamChat API documentation + * OpenAPI spec version: 1.0.0 + */ + +export interface CharacterSummaryDto { + /** Character ID */ + id: string; + /** Character name */ + name: string; + /** Avatar URL */ + avatarUrl?: string; +} diff --git a/apps/frontend/src/api/generated/model/conversationResponseDto.ts b/apps/frontend/src/api/generated/model/conversationResponseDto.ts new file mode 100644 index 0000000..25918e7 --- /dev/null +++ b/apps/frontend/src/api/generated/model/conversationResponseDto.ts @@ -0,0 +1,27 @@ +/** + * Generated by orval v8.4.2 🍺 + * Do not edit manually. + * DreamChat API + * The DreamChat API documentation + * OpenAPI spec version: 1.0.0 + */ +import type { CharacterSummaryDto } from './characterSummaryDto'; + +export interface ConversationResponseDto { + /** Conversation ID */ + id: string; + /** Conversation title */ + title?: string; + /** Character ID */ + characterId: string; + /** Number of messages */ + messageCount: number; + /** Total tokens used */ + totalTokens: number; + /** Creation date */ + createdAt: string; + /** Last update date */ + updatedAt: string; + /** Character info */ + character?: CharacterSummaryDto; +} diff --git a/apps/frontend/src/api/generated/model/conversationWithMessagesResponseDto.ts b/apps/frontend/src/api/generated/model/conversationWithMessagesResponseDto.ts new file mode 100644 index 0000000..871107e --- /dev/null +++ b/apps/frontend/src/api/generated/model/conversationWithMessagesResponseDto.ts @@ -0,0 +1,30 @@ +/** + * Generated by orval v8.4.2 🍺 + * Do not edit manually. + * DreamChat API + * The DreamChat API documentation + * OpenAPI spec version: 1.0.0 + */ +import type { CharacterSummaryDto } from './characterSummaryDto'; +import type { MessageResponseDto } from './messageResponseDto'; + +export interface ConversationWithMessagesResponseDto { + /** Conversation ID */ + id: string; + /** Conversation title */ + title?: string; + /** Character ID */ + characterId: string; + /** Number of messages */ + messageCount: number; + /** Total tokens used */ + totalTokens: number; + /** Creation date */ + createdAt: string; + /** Last update date */ + updatedAt: string; + /** Character info */ + character?: CharacterSummaryDto; + /** Messages in conversation */ + messages: MessageResponseDto[]; +} diff --git a/apps/frontend/src/api/generated/model/createCharacterDto.ts b/apps/frontend/src/api/generated/model/createCharacterDto.ts new file mode 100644 index 0000000..ed29ba0 --- /dev/null +++ b/apps/frontend/src/api/generated/model/createCharacterDto.ts @@ -0,0 +1,24 @@ +/** + * Generated by orval v8.4.2 🍺 + * Do not edit manually. + * DreamChat API + * The DreamChat API documentation + * OpenAPI spec version: 1.0.0 + */ +import type { CreateCharacterDtoAttributes } from './createCharacterDtoAttributes'; +import type { CreateCharacterDtoConfig } from './createCharacterDtoConfig'; + +export interface CreateCharacterDto { + /** Character name */ + name: string; + /** Avatar URL */ + avatarUrl?: string; + /** Personality prompt that guides AI responses */ + personalityPrompt: string; + /** Custom attributes (JSON) */ + attributes?: CreateCharacterDtoAttributes; + /** Character configuration (JSON) */ + config?: CreateCharacterDtoConfig; + /** Whether the character is publicly visible */ + isPublic?: boolean; +} diff --git a/apps/frontend/src/api/generated/model/createCharacterDtoAttributes.ts b/apps/frontend/src/api/generated/model/createCharacterDtoAttributes.ts new file mode 100644 index 0000000..0465b77 --- /dev/null +++ b/apps/frontend/src/api/generated/model/createCharacterDtoAttributes.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval v8.4.2 🍺 + * Do not edit manually. + * DreamChat API + * The DreamChat API documentation + * OpenAPI spec version: 1.0.0 + */ + +/** + * Custom attributes (JSON) + */ +export type CreateCharacterDtoAttributes = { [key: string]: unknown }; diff --git a/apps/frontend/src/api/generated/model/createCharacterDtoConfig.ts b/apps/frontend/src/api/generated/model/createCharacterDtoConfig.ts new file mode 100644 index 0000000..0eb895b --- /dev/null +++ b/apps/frontend/src/api/generated/model/createCharacterDtoConfig.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval v8.4.2 🍺 + * Do not edit manually. + * DreamChat API + * The DreamChat API documentation + * OpenAPI spec version: 1.0.0 + */ + +/** + * Character configuration (JSON) + */ +export type CreateCharacterDtoConfig = { [key: string]: unknown }; diff --git a/apps/frontend/src/api/generated/model/createConversationDto.ts b/apps/frontend/src/api/generated/model/createConversationDto.ts new file mode 100644 index 0000000..eaf05c3 --- /dev/null +++ b/apps/frontend/src/api/generated/model/createConversationDto.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v8.4.2 🍺 + * Do not edit manually. + * DreamChat API + * The DreamChat API documentation + * OpenAPI spec version: 1.0.0 + */ + +export interface CreateConversationDto { + /** Character ID to chat with */ + characterId: string; + /** Conversation title */ + title?: string; +} diff --git a/apps/frontend/src/api/generated/model/importControllerUploadFileBody.ts b/apps/frontend/src/api/generated/model/importControllerUploadFileBody.ts new file mode 100644 index 0000000..d4c7536 --- /dev/null +++ b/apps/frontend/src/api/generated/model/importControllerUploadFileBody.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval v8.4.2 🍺 + * Do not edit manually. + * DreamChat API + * The DreamChat API documentation + * OpenAPI spec version: 1.0.0 + */ + +export type ImportControllerUploadFileBody = { + /** File to upload (.txt, .md) */ + file?: Blob; +}; diff --git a/apps/frontend/src/api/generated/model/index.ts b/apps/frontend/src/api/generated/model/index.ts new file mode 100644 index 0000000..43553dd --- /dev/null +++ b/apps/frontend/src/api/generated/model/index.ts @@ -0,0 +1,37 @@ +/** + * Generated by orval v8.4.2 🍺 + * Do not edit manually. + * DreamChat API + * The DreamChat API documentation + * OpenAPI spec version: 1.0.0 + */ + +export * from './authControllerKeycloakCallbackParams'; +export * from './authControllerKeycloakLoginParams'; +export * from './authResponseDto'; +export * from './characterResponseDto'; +export * from './characterResponseDtoAttributes'; +export * from './characterResponseDtoConfig'; +export * from './characterSummaryDto'; +export * from './conversationResponseDto'; +export * from './conversationWithMessagesResponseDto'; +export * from './createCharacterDto'; +export * from './createCharacterDtoAttributes'; +export * from './createCharacterDtoConfig'; +export * from './createConversationDto'; +export * from './importControllerUploadFileBody'; +export * from './keycloakConfigDto'; +export * from './keycloakLoginUrlDto'; +export * from './loginDto'; +export * from './messageResponseDto'; +export * from './messageResponseDtoRole'; +export * from './refreshTokenDto'; +export * from './sendMessageDto'; +export * from './sendMessageResponseDto'; +export * from './updateCharacterDto'; +export * from './updateCharacterDtoAttributes'; +export * from './updateCharacterDtoConfig'; +export * from './updatePasswordDto'; +export * from './updateUserDto'; +export * from './userDto'; +export * from './userDtoRole'; \ No newline at end of file diff --git a/apps/frontend/src/api/generated/model/keycloakConfigDto.ts b/apps/frontend/src/api/generated/model/keycloakConfigDto.ts new file mode 100644 index 0000000..a1de5a6 --- /dev/null +++ b/apps/frontend/src/api/generated/model/keycloakConfigDto.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v8.4.2 🍺 + * Do not edit manually. + * DreamChat API + * The DreamChat API documentation + * OpenAPI spec version: 1.0.0 + */ + +export interface KeycloakConfigDto { + /** Whether Keycloak authentication is enabled */ + enabled: boolean; + /** Keycloak realm URL */ + url?: string; + /** Keycloak realm name */ + realm?: string; + /** Keycloak client ID */ + clientId?: string; +} diff --git a/apps/frontend/src/api/generated/model/keycloakLoginUrlDto.ts b/apps/frontend/src/api/generated/model/keycloakLoginUrlDto.ts new file mode 100644 index 0000000..cbf1902 --- /dev/null +++ b/apps/frontend/src/api/generated/model/keycloakLoginUrlDto.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v8.4.2 🍺 + * Do not edit manually. + * DreamChat API + * The DreamChat API documentation + * OpenAPI spec version: 1.0.0 + */ + +export interface KeycloakLoginUrlDto { + /** Keycloak login URL to redirect the user to */ + loginUrl: string; + /** State parameter for CSRF protection */ + state: string; +} diff --git a/apps/frontend/src/api/generated/model/loginDto.ts b/apps/frontend/src/api/generated/model/loginDto.ts new file mode 100644 index 0000000..b1a6444 --- /dev/null +++ b/apps/frontend/src/api/generated/model/loginDto.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v8.4.2 🍺 + * Do not edit manually. + * DreamChat API + * The DreamChat API documentation + * OpenAPI spec version: 1.0.0 + */ + +export interface LoginDto { + /** User email address */ + email: string; + /** User password */ + password: string; +} diff --git a/apps/frontend/src/api/generated/model/messageResponseDto.ts b/apps/frontend/src/api/generated/model/messageResponseDto.ts new file mode 100644 index 0000000..633ba73 --- /dev/null +++ b/apps/frontend/src/api/generated/model/messageResponseDto.ts @@ -0,0 +1,23 @@ +/** + * Generated by orval v8.4.2 🍺 + * Do not edit manually. + * DreamChat API + * The DreamChat API documentation + * OpenAPI spec version: 1.0.0 + */ +import type { MessageResponseDtoRole } from './messageResponseDtoRole'; + +export interface MessageResponseDto { + /** Message ID */ + id: string; + /** Message role */ + role: MessageResponseDtoRole; + /** Message content */ + content: string; + /** Tokens used */ + tokensUsed?: number; + /** Model used */ + model?: string; + /** Creation date */ + createdAt: string; +} diff --git a/apps/frontend/src/api/generated/model/messageResponseDtoRole.ts b/apps/frontend/src/api/generated/model/messageResponseDtoRole.ts new file mode 100644 index 0000000..e205b96 --- /dev/null +++ b/apps/frontend/src/api/generated/model/messageResponseDtoRole.ts @@ -0,0 +1,19 @@ +/** + * Generated by orval v8.4.2 🍺 + * Do not edit manually. + * DreamChat API + * The DreamChat API documentation + * OpenAPI spec version: 1.0.0 + */ + +/** + * Message role + */ +export type MessageResponseDtoRole = typeof MessageResponseDtoRole[keyof typeof MessageResponseDtoRole]; + + +export const MessageResponseDtoRole = { + user: 'user', + assistant: 'assistant', + system: 'system', +} as const; diff --git a/apps/frontend/src/api/generated/model/refreshTokenDto.ts b/apps/frontend/src/api/generated/model/refreshTokenDto.ts new file mode 100644 index 0000000..0f03837 --- /dev/null +++ b/apps/frontend/src/api/generated/model/refreshTokenDto.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval v8.4.2 🍺 + * Do not edit manually. + * DreamChat API + * The DreamChat API documentation + * OpenAPI spec version: 1.0.0 + */ + +export interface RefreshTokenDto { + /** Refresh token */ + refreshToken: string; +} diff --git a/apps/frontend/src/api/generated/model/sendMessageDto.ts b/apps/frontend/src/api/generated/model/sendMessageDto.ts new file mode 100644 index 0000000..e30a289 --- /dev/null +++ b/apps/frontend/src/api/generated/model/sendMessageDto.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval v8.4.2 🍺 + * Do not edit manually. + * DreamChat API + * The DreamChat API documentation + * OpenAPI spec version: 1.0.0 + */ + +export interface SendMessageDto { + /** Message content */ + content: string; +} diff --git a/apps/frontend/src/api/generated/model/sendMessageResponseDto.ts b/apps/frontend/src/api/generated/model/sendMessageResponseDto.ts new file mode 100644 index 0000000..780dce1 --- /dev/null +++ b/apps/frontend/src/api/generated/model/sendMessageResponseDto.ts @@ -0,0 +1,15 @@ +/** + * Generated by orval v8.4.2 🍺 + * Do not edit manually. + * DreamChat API + * The DreamChat API documentation + * OpenAPI spec version: 1.0.0 + */ +import type { MessageResponseDto } from './messageResponseDto'; + +export interface SendMessageResponseDto { + /** User message */ + userMessage: MessageResponseDto; + /** Assistant response */ + assistantMessage: MessageResponseDto; +} diff --git a/apps/frontend/src/api/generated/model/updateCharacterDto.ts b/apps/frontend/src/api/generated/model/updateCharacterDto.ts new file mode 100644 index 0000000..e47925f --- /dev/null +++ b/apps/frontend/src/api/generated/model/updateCharacterDto.ts @@ -0,0 +1,24 @@ +/** + * Generated by orval v8.4.2 🍺 + * Do not edit manually. + * DreamChat API + * The DreamChat API documentation + * OpenAPI spec version: 1.0.0 + */ +import type { UpdateCharacterDtoAttributes } from './updateCharacterDtoAttributes'; +import type { UpdateCharacterDtoConfig } from './updateCharacterDtoConfig'; + +export interface UpdateCharacterDto { + /** Character name */ + name?: string; + /** Avatar URL */ + avatarUrl?: string; + /** Personality prompt */ + personalityPrompt?: string; + /** Custom attributes (JSON) */ + attributes?: UpdateCharacterDtoAttributes; + /** Character configuration (JSON) */ + config?: UpdateCharacterDtoConfig; + /** Whether the character is publicly visible */ + isPublic?: boolean; +} diff --git a/apps/frontend/src/api/generated/model/updateCharacterDtoAttributes.ts b/apps/frontend/src/api/generated/model/updateCharacterDtoAttributes.ts new file mode 100644 index 0000000..453a5c5 --- /dev/null +++ b/apps/frontend/src/api/generated/model/updateCharacterDtoAttributes.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval v8.4.2 🍺 + * Do not edit manually. + * DreamChat API + * The DreamChat API documentation + * OpenAPI spec version: 1.0.0 + */ + +/** + * Custom attributes (JSON) + */ +export type UpdateCharacterDtoAttributes = { [key: string]: unknown }; diff --git a/apps/frontend/src/api/generated/model/updateCharacterDtoConfig.ts b/apps/frontend/src/api/generated/model/updateCharacterDtoConfig.ts new file mode 100644 index 0000000..cf02d0f --- /dev/null +++ b/apps/frontend/src/api/generated/model/updateCharacterDtoConfig.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval v8.4.2 🍺 + * Do not edit manually. + * DreamChat API + * The DreamChat API documentation + * OpenAPI spec version: 1.0.0 + */ + +/** + * Character configuration (JSON) + */ +export type UpdateCharacterDtoConfig = { [key: string]: unknown }; diff --git a/apps/frontend/src/api/generated/model/updatePasswordDto.ts b/apps/frontend/src/api/generated/model/updatePasswordDto.ts new file mode 100644 index 0000000..cb4dec5 --- /dev/null +++ b/apps/frontend/src/api/generated/model/updatePasswordDto.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v8.4.2 🍺 + * Do not edit manually. + * DreamChat API + * The DreamChat API documentation + * OpenAPI spec version: 1.0.0 + */ + +export interface UpdatePasswordDto { + /** Current password */ + currentPassword?: string; + /** New password */ + newPassword?: string; +} diff --git a/apps/frontend/src/api/generated/model/updateUserDto.ts b/apps/frontend/src/api/generated/model/updateUserDto.ts new file mode 100644 index 0000000..73b7cd3 --- /dev/null +++ b/apps/frontend/src/api/generated/model/updateUserDto.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v8.4.2 🍺 + * Do not edit manually. + * DreamChat API + * The DreamChat API documentation + * OpenAPI spec version: 1.0.0 + */ + +export interface UpdateUserDto { + /** New email address */ + email?: string; + /** New username */ + username?: string; +} diff --git a/apps/frontend/src/api/generated/model/userDto.ts b/apps/frontend/src/api/generated/model/userDto.ts new file mode 100644 index 0000000..c8fc16f --- /dev/null +++ b/apps/frontend/src/api/generated/model/userDto.ts @@ -0,0 +1,19 @@ +/** + * Generated by orval v8.4.2 🍺 + * Do not edit manually. + * DreamChat API + * The DreamChat API documentation + * OpenAPI spec version: 1.0.0 + */ +import type { UserDtoRole } from './userDtoRole'; + +export interface UserDto { + /** User ID */ + id: string; + /** User email */ + email: string; + /** User username */ + username: string; + /** User role */ + role: UserDtoRole; +} diff --git a/apps/frontend/src/api/generated/model/userDtoRole.ts b/apps/frontend/src/api/generated/model/userDtoRole.ts new file mode 100644 index 0000000..b2a6a3d --- /dev/null +++ b/apps/frontend/src/api/generated/model/userDtoRole.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v8.4.2 🍺 + * Do not edit manually. + * DreamChat API + * The DreamChat API documentation + * OpenAPI spec version: 1.0.0 + */ + +/** + * User role + */ +export type UserDtoRole = typeof UserDtoRole[keyof typeof UserDtoRole]; + + +export const UserDtoRole = { + USER: 'USER', + ADMIN: 'ADMIN', +} as const; diff --git a/apps/frontend/src/api/generated/users/users.ts b/apps/frontend/src/api/generated/users/users.ts new file mode 100644 index 0000000..e62bf2f --- /dev/null +++ b/apps/frontend/src/api/generated/users/users.ts @@ -0,0 +1,108 @@ +/** + * Generated by orval v8.4.2 🍺 + * Do not edit manually. + * DreamChat API + * The DreamChat API documentation + * OpenAPI spec version: 1.0.0 + */ +import type { + UpdatePasswordDto, + UpdateUserDto +} from '.././model'; + +import { customFetch } from '../../mutator/custom-fetch'; + +/** + * @summary Get current user profile + */ +export const getUserControllerGetProfileUrl = () => { + + + + + return `http://localhost:3000/api/users/me` +} + +export const userControllerGetProfile = async ( options?: RequestInit): Promise => { + + return customFetch(getUserControllerGetProfileUrl(), + { + ...options, + method: 'GET' + + + } +);} + + +/** + * @summary Update current user profile + */ +export const getUserControllerUpdateProfileUrl = () => { + + + + + return `http://localhost:3000/api/users/me` +} + +export const userControllerUpdateProfile = async (updateUserDto: UpdateUserDto, options?: RequestInit): Promise => { + + return customFetch(getUserControllerUpdateProfileUrl(), + { + ...options, + method: 'PUT', + headers: { 'Content-Type': 'application/json', ...options?.headers }, + body: JSON.stringify( + updateUserDto,) + } +);} + + +/** + * @summary Delete user account + */ +export const getUserControllerDeleteAccountUrl = () => { + + + + + return `http://localhost:3000/api/users/me` +} + +export const userControllerDeleteAccount = async ( options?: RequestInit): Promise => { + + return customFetch(getUserControllerDeleteAccountUrl(), + { + ...options, + method: 'DELETE' + + + } +);} + + +/** + * @summary Update user password + */ +export const getUserControllerUpdatePasswordUrl = () => { + + + + + return `http://localhost:3000/api/users/me/password` +} + +export const userControllerUpdatePassword = async (updatePasswordDto: UpdatePasswordDto, options?: RequestInit): Promise => { + + return customFetch(getUserControllerUpdatePasswordUrl(), + { + ...options, + method: 'PUT', + headers: { 'Content-Type': 'application/json', ...options?.headers }, + body: JSON.stringify( + updatePasswordDto,) + } +);} + + diff --git a/apps/frontend/src/api/mutator/custom-fetch.ts b/apps/frontend/src/api/mutator/custom-fetch.ts new file mode 100644 index 0000000..658b654 --- /dev/null +++ b/apps/frontend/src/api/mutator/custom-fetch.ts @@ -0,0 +1,44 @@ +const getBaseUrl = () => { + return (import.meta.env as unknown as ImportMetaEnv).VITE_API_URL || 'http://localhost:3000/api'; +}; + +const getToken = (): string | null => { + return localStorage.getItem('accessToken'); +}; + +export const customFetch = async ( + url: string, + options: RequestInit = {} +): Promise => { + // If URL is already absolute (starts with http), use it as-is + // Otherwise, prepend the base URL + const fullUrl = url.startsWith('http') ? url : `${getBaseUrl()}${url}`; + + const token = getToken(); + + const headers: Record = { + 'Content-Type': 'application/json', + ...(options.headers as Record || {}), + }; + + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + const response = await fetch(fullUrl, { + ...options, + headers, + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ message: 'Unknown error' })); + throw new Error(error.message || `HTTP ${response.status}`); + } + + // Handle empty responses + if (response.status === 204) { + return undefined as T; + } + + return response.json(); +}; diff --git a/apps/frontend/src/pages/CharacterForm.tsx b/apps/frontend/src/pages/CharacterForm.tsx new file mode 100644 index 0000000..854b213 --- /dev/null +++ b/apps/frontend/src/pages/CharacterForm.tsx @@ -0,0 +1,197 @@ +import { useEffect, useState } from 'react'; +import { useNavigate, useParams, Link } from 'react-router-dom'; +import { useCharacterStore } from '../stores/characterStore'; + +export function CharacterForm() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const isEditing = id !== 'new'; + + const { + currentCharacter, + isLoading, + error, + getCharacter, + createCharacter, + updateCharacter, + clearError, + setCurrentCharacter + } = useCharacterStore(); + + const [name, setName] = useState(''); + const [personalityPrompt, setPersonalityPrompt] = useState(''); + const [avatarUrl, setAvatarUrl] = useState(''); + const [isPublic, setIsPublic] = useState(false); + const [attributes, setAttributes] = useState('{}'); + + useEffect(() => { + if (isEditing && id) { + getCharacter(id); + } + return () => { + setCurrentCharacter(null); + }; + }, [isEditing, id, getCharacter, setCurrentCharacter]); + + useEffect(() => { + if (currentCharacter && isEditing) { + setName(currentCharacter.name); + setPersonalityPrompt(currentCharacter.personalityPrompt); + setAvatarUrl(currentCharacter.avatarUrl || ''); + setIsPublic(currentCharacter.isPublic); + setAttributes(JSON.stringify(currentCharacter.attributes, null, 2)); + } + }, [currentCharacter, isEditing]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + clearError(); + + let parsedAttributes = {}; + try { + parsedAttributes = JSON.parse(attributes); + } catch { + alert('Invalid JSON in attributes field'); + return; + } + + const data = { + name, + personalityPrompt, + avatarUrl: avatarUrl || undefined, + isPublic, + attributes: parsedAttributes, + }; + + try { + if (isEditing && id) { + await updateCharacter(id, data); + } else { + await createCharacter(data); + } + navigate('/characters'); + } catch { + // Error is handled by the store + } + }; + + return ( +
+
+
+ + ← Back to Characters + +
+
+ +
+
+

+ {isEditing ? 'Edit Character' : 'Create New Character'} +

+ + {error && ( +
+ {error} +
+ )} + +
+
+ + setName(e.target.value)} + className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500" + placeholder="e.g., Alice the Explorer" + /> +
+ +
+ + setAvatarUrl(e.target.value)} + className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500" + placeholder="https://example.com/avatar.jpg" + /> +
+ +
+ +