diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 0000000..702319f --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,19 @@ +language: en-US +early_access: false +reviews: + profile: chill + request_changes_workflow: false + high_level_summary: true + poem: false + review_status: true + collapse_walkthrough: false + auto_review: + enabled: true + drafts: false + base_branches: + - main + - master + - develop +chat: + auto_reply: true + diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3693fc2 --- /dev/null +++ b/.env.example @@ -0,0 +1,102 @@ +# ============================================================================== +# REPLIK - Environment Variables Configuration +# ============================================================================== +# Copy this file to .env and fill in your actual values +# NEVER commit .env to git! (.gitignore already excludes it) +# ============================================================================== + +# ------------------------------------------------------------------------------ +# ๐Ÿ” SUPABASE CONFIGURATION (REQUIRED) +# ------------------------------------------------------------------------------ +# Get these from: https://supabase.com/dashboard/project/_/settings/api + +# Your Supabase project URL +NEXT_PUBLIC_SUPABASE_URL=https://your-project-ref.supabase.co + +# Supabase Anon/Public Key (safe for frontend - respects Row Level Security) +NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.your-anon-key + +# โš ๏ธ CRITICAL: Service Role Key (NEVER expose to frontend!) +# Only use in API routes server-side code +# This bypasses Row Level Security - handle with extreme care +SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.your-service-role-key + +# ------------------------------------------------------------------------------ +# ๐Ÿ—„๏ธ DATABASE CONFIGURATION (REQUIRED) +# ------------------------------------------------------------------------------ +# Get these from: https://supabase.com/dashboard/project/_/settings/database + +# Database connection URL (pooled - for general use) +DATABASE_URL=postgresql://postgres.your-project-ref:YOUR_PASSWORD@aws-1-us-east-2.pooler.supabase.com:6543/postgres?pgbouncer=true + +# Direct database URL (non-pooled - for migrations and special operations) +DIRECT_URL=postgresql://postgres:YOUR_PASSWORD@db.your-project-ref.supabase.co:5432/postgres + +# ------------------------------------------------------------------------------ +# ๐Ÿค– AI SERVICES (REQUIRED) +# ------------------------------------------------------------------------------ + +# Fish Audio API Key - For voice cloning and TTS +# Get your key from: https://fish.audio +# โš ๏ธ Costs: ~$0.15 per 1000 characters of speech +FISH_AUDIO_API_KEY=fa_your_fish_audio_key_here + +# Anthropic Claude API Key - For AI personality and conversations +# Get your key from: https://console.anthropic.com/ +# โš ๏ธ Costs: ~$0.015 per request (varies by model and token usage) +ANTHROPIC_API_KEY=sk-ant-your_anthropic_key_here + +# ------------------------------------------------------------------------------ +# ๐ŸŽฎ MINECRAFT INTEGRATION (OPTIONAL) +# ------------------------------------------------------------------------------ + +# API key for Minecraft mod to access /api/speak endpoint +# Generate a secure random string (e.g., use: openssl rand -hex 32) +# If not set, Minecraft mod users must be authenticated via Supabase +MINECRAFT_API_KEY=your_secure_random_api_key_for_minecraft_mod + +# ------------------------------------------------------------------------------ +# ๐Ÿง  VECTOR MEMORY (OPTIONAL - Chroma DB) +# ------------------------------------------------------------------------------ +# For semantic memory search using ChromaDB +# If not set, will use simple keyword-based memory search + +CHROMA_HOST=localhost +CHROMA_PORT=8000 + +# ------------------------------------------------------------------------------ +# ๐ŸŒ AUTONOMOUS AGENTS (OPTIONAL - Fetch.ai) +# ------------------------------------------------------------------------------ +# For AI agent marketplace integration (experimental feature) + +FETCH_AI_API_KEY=your_fetch_ai_key_here + +# ------------------------------------------------------------------------------ +# ๐Ÿš€ DEPLOYMENT (OPTIONAL) +# ------------------------------------------------------------------------------ + +# Base URL of your deployed application +# Used for OAuth redirects and external API calls +# Development: http://localhost:3000 +# Production: https://your-domain.com +NEXT_PUBLIC_BASE_URL=http://localhost:3000 + +# Railway Public Domain (auto-set by Railway during deployment) +# Don't manually set this - Railway will inject it automatically +# RAILWAY_PUBLIC_DOMAIN=your-app.railway.app + +# ------------------------------------------------------------------------------ +# ๐Ÿ“ NOTES & SECURITY WARNINGS +# ------------------------------------------------------------------------------ + +# 1. NEVER commit this file with real values to git +# 2. Service Role Key should NEVER be exposed to the frontend +# 3. All NEXT_PUBLIC_* variables are exposed to the browser (public) +# 4. API keys have rate limits and costs - monitor your usage +# 5. Rotate keys regularly for production deployments +# 6. Use different keys for development vs production + +# ๐Ÿ“š For more information: +# - Setup Guide: See README.md +# - Security: See SECURITY.md +# - Supabase Setup: See docs/SUPABASE_SETUP.md diff --git a/.gitignore b/.gitignore index 11cdc7a..72f969c 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,34 @@ next-env.d.ts # Test scripts with hardcoded API keys (NEVER commit these!) test_fish*.sh +# Gradle (Minecraft mod build artifacts) +minecraft-mod/.gradle/ +minecraft-mod/build/ +minecraft-mod/.gradle/build/ +minecraft-mod/*.jar +minecraft-mod/gradle/build/ +.gradle/ +.gradletasknamecache + +# Python cache (for scripts) +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python + +# Supabase local (if running locally) +.supabase/ + +# macOS additional files +.AppleDouble +.LSOverride +._* +Thumbs.db + +# Additional sensitive file types +*.key +*.p12 + +*.hprof +AGENTS.md diff --git a/LANDING_PAGE_SUMMARY.md b/LANDING_PAGE_SUMMARY.md deleted file mode 100644 index 64bfbea..0000000 --- a/LANDING_PAGE_SUMMARY.md +++ /dev/null @@ -1,167 +0,0 @@ -# Landing Page Implementation Summary - -## What Was Built - -### 1. Landing Page (`components/LandingPage.tsx`) -A modern, animated landing page with: -- **Hero Section** - Eye-catching headline with call-to-action -- **Features Section** - Explains how the AI clone works (Voice, Face, Personality) -- **Use Cases Section** - Shows why users would create a clone -- **CTA Section** - Final call-to-action to sign up -- **Auth Modal** - Login/signup form with smooth animations - -### 2. Temporary Auth System (`lib/hooks/useAuth.ts`) -Simple authentication using localStorage: -- `signup(email, password, name)` - Create new user -- `login(email, password)` - Authenticate existing user -- `logout()` - Clear session -- `user` - Current user object -- `isLoading` - Loading state - -**โš ๏ธ NOT SECURE** - Uses localStorage and plain text passwords. **Your friend should replace this with Supabase ASAP.** - -### 3. Auth Context Provider (`components/Providers.tsx`) -Wraps the entire app to provide auth state globally. - -### 4. Updated Main App (`app/page.tsx`) -- Shows **Landing Page** if not logged in -- Shows **Clone Creation Flow** (Record โ†’ Upload โ†’ Chat) if logged in -- Added **Logout button** and user info in top-right corner -- Split into `Home` component (routing logic) and `AuthenticatedApp` component (main app) - -### 5. Updated Layout (`app/layout.tsx`) -- Wraps app with `` for global auth state - -## User Flow - -### New User: -1. Lands on **Landing Page** -2. Clicks "Get Started" or "Create Your Clone" -3. **Signup modal** appears -4. Enters email, password, (optional) name -5. Automatically logged in -6. Redirected to **Clone Creation** (Record โ†’ Upload โ†’ Chat) - -### Returning User: -1. Lands on **Landing Page** -2. Clicks "Get Started" -3. **Login modal** appears (or clicks "Log in" link) -4. Enters credentials -5. Redirected to **Clone Creation Flow** - -### Logged-In User: -1. Sees main app immediately -2. Can logout from top-right corner -3. On logout, returns to Landing Page - -## Files Created/Modified - -### New Files: -- โœ… `components/LandingPage.tsx` - Main landing page -- โœ… `lib/hooks/useAuth.ts` - Temporary auth hook -- โœ… `components/Providers.tsx` - Auth context wrapper -- โœ… `SUPABASE_MIGRATION.md` - Guide for your friend to implement Supabase -- โœ… `LANDING_PAGE_SUMMARY.md` - This file - -### Modified Files: -- โœ… `app/page.tsx` - Added auth gating + logout button -- โœ… `app/layout.tsx` - Added Providers wrapper - -## Data Structure (For Supabase Migration) - -The auth system is designed to support the following data: - -```typescript -User { - id: string - email: string - name?: string - createdAt: string -} - -Clone { - userId: string - voiceModelId: string - audioUrl: string - faceContours: JSON - photoUrls: string[] - personalityData: JSON - contexts: { - stories: string - habits: string - reactions: string - } -} - -Conversation { - userId: string - role: 'user' | 'assistant' - content: string - audioUrl?: string - timestamp: string -} - -Memory { - userId: string - content: string - category: 'story' | 'habit' | 'reaction' - embedding: string -} -``` - -## Current Limitations (Temporary Auth) - -โš ๏ธ **Security Issues:** -- Passwords stored in plain text in localStorage -- No encryption -- No email verification -- No password reset -- No session expiration -- Client-side only (no server validation) - -โš ๏ธ **Data Persistence:** -- Data only stored in browser's localStorage -- Clearing browser data = losing all data -- No backup -- No cross-device sync - -**โ†’ Your friend MUST replace this with Supabase for production!** - -## What Your Friend Needs to Do - -1. **Read** `SUPABASE_MIGRATION.md` -2. **Create** Supabase project -3. **Run** SQL scripts to create tables -4. **Replace** `lib/hooks/useAuth.ts` with Supabase auth -5. **Update** API routes to use Supabase auth -6. **Migrate** file uploads to Supabase Storage -7. **Test** everything - -The current implementation is **intentionally simple** to make the Supabase migration straightforward. The auth hook interface can stay the same, just swap the implementation. - -## Testing the Landing Page - -1. Start the dev server: `npm run dev` -2. Visit `http://localhost:3000` -3. You should see the **Landing Page** -4. Click "Get Started" โ†’ Signup with any email/password -5. You'll be logged in and see the **Clone Creation** flow -6. Click "Logout" in top-right to return to Landing Page - -## Design Notes - -- **Color Scheme:** White/Black with subtle white glows (matches existing app) -- **Animations:** Framer Motion for smooth transitions -- **Responsive:** Works on mobile and desktop -- **Accessibility:** Proper semantic HTML and ARIA labels -- **Performance:** Lazy-loaded landing page, fast initial load - -## Questions for Your Friend - -When implementing Supabase, consider: -1. Should users verify email before creating clone? -2. Should clones be public/shareable or private only? -3. Should there be usage limits (free tier)? -4. Should chat history be summarized or full? -5. Should old conversations be deleted after X days? - diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e9e6872 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Replik Team + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/MINECRAFT_SETUP.md b/MINECRAFT_SETUP.md new file mode 100644 index 0000000..fb614f9 --- /dev/null +++ b/MINECRAFT_SETUP.md @@ -0,0 +1,419 @@ +# Minecraft Integration Guide + +## Overview + +The EchoSelf Minecraft mod brings your digital twins into Minecraft with **two modes**: + +1. **MVP Mode** - Command-based with voice playback (IMPLEMENTED) +2. **Advanced Mode** - GUI-based with custom entities (IN DEVELOPMENT) + +Both modes feature full voice playback using Fish Audio TTS. + +--- + +## Quick Start + +### Prerequisites + +- **Minecraft 1.20.1** +- **Fabric Loader 0.15.0+** - [Download](https://fabricmc.net/use/) +- **Fabric API** - [Download](https://modrinth.com/mod/fabric-api) +- **Fabric Language Kotlin** - [Download](https://modrinth.com/mod/fabric-language-kotlin) +- **Java 17+** + +### Installation + +1. **Build the mod:** + ```bash + cd minecraft-mod + ./gradlew build + ``` + +2. **Install:** + - Copy `build/libs/digitaltwins-1.0.0.jar` to `.minecraft/mods/` + - Launch Minecraft 1.20.1 with Fabric + +3. **Start your web app:** + ```bash + npm run dev + ``` + +--- + +## MVP Mode (Commands + Voice) + +### Commands + +#### `/twinimport ` +Import a digital twin from your web app. + +**Example:** +``` +/twinimport http://localhost:3000/api/minecraft/export/YOUR_USER_ID +``` + +**Result:** `โœ“ Loaded twin: Alex Chen` + +#### `/twinlist` +List all imported twins. + +**Output:** +``` +=== Imported Twins === +- Alex Chen (Spawned) +- Maya Rodriguez (Not spawned) +``` + +#### `/twinspawn ` +Spawn a twin NPC (armor stand). + +**Example:** +``` +/twinspawn Alex +``` + +**Result:** Armor stand appears with "Alex Chen" name tag + +#### `/twin ` +Chat with a twin and hear their voice! + +**Example:** +``` +/twin Alex What's your favorite food? +``` + +**What happens:** +1. Message sent to API (3-5 second wait) +2. Text response appears: `[Alex Chen] Oh man, spicy ramen for sure!` +3. **Voice plays through speakers!** + +#### `/twinremove ` +Despawn a twin NPC. + +--- + +## Advanced Mode (GUI + Custom Entities) + +### Features + +- **Custom Entities** - Real mobs (not armor stands) with AI +- **Spawn Eggs** - One egg per twin in creative menu +- **Custom GUI** - Minecraft-style chat interface +- **Player Model** - Twins look like players +- **AI Behavior** - Walks around, looks at you + +### How to Use + +1. **Get spawn egg:** + - Open creative inventory + - Search for "Alex Clone Spawn Egg" + +2. **Spawn twin:** + - Right-click ground with egg + - Custom NPC appears + +3. **Chat:** + - Right-click NPC + - GUI opens + - Type message, press Enter + - Voice response plays! + +--- + +## Voice Playback + +Both modes feature full voice synthesis: + +``` +You: "What's your favorite food?" + โ†“ +API Call โ†’ /api/speak + โ†“ +Claude generates response + โ†“ +Fish Audio generates voice + โ†“ +Response: { + "text": "Oh man, spicy ramen for sure!", + "audioUrl": "/uploads/.../response.mp3" +} + โ†“ +MP3 plays in Minecraft! +``` + +--- + +## Building from Source + +```bash +cd minecraft-mod + +# Clean build +./gradlew clean build + +# Run client (for testing) +./gradlew runClient + +# Output JAR +ls build/libs/digitaltwins-1.0.0.jar +``` + +--- + +## Troubleshooting + +### "Twin not found" +**Solution:** Run `/twinimport ` first + +### "Connection failed" +**Solution:** +- Check internet connection +- Verify web app is running: `npm run dev` +- Test API: `curl http://localhost:3000/api/minecraft/export/YOUR_USER_ID` + +### "Audio playback failed" +**Possible causes:** +- MP3 codec missing (should be bundled) +- Invalid audio URL +- No audio output device + +**Fix:** Check console logs in `.minecraft/logs/latest.log` + +### Mod doesn't load +**Solution:** +- Ensure Fabric Loader 0.15.0+ installed +- Install Fabric API +- Install Fabric Language Kotlin +- Check logs: `.minecraft/logs/latest.log` + +--- + +## API Endpoints + +### Export API (Web App) +**Download Clone JSON** - Two ways to export your clone data: +1. Visit the **Minecraft Integration page** (`/minecraft`) and click "Download Twin Data" button +2. Use the "Export JSON" button in the **Build Context** tab of the main app + +**Downloaded File:** `twin-username.json` or `username_clone.json` + +**Structure:** +```json +{ + "userId": "b9f8b510-a463-4232-9490-9679300453c1", + "exportDate": "2025-10-26T12:34:56.789Z", + + "context": { + "entries": [ + { + "category": "story", + "content": "I love spicy ramen and coding", + "timestamp": "2025-10-26T12:00:00.000Z" + } + ], + "totalEntries": 5, + "categories": ["story", "habit", "preference"] + }, + + "audioData": { + "audioUrl": null, + "voiceModelId": "d7dfbedf1d39421a948a302839a86ba9", + "voiceModelProvider": "fish-audio", + "usage": { + "description": "Use voiceModelId to make Fish Audio API calls", + "apiEndpoint": "https://api.fish.audio/v1/tts", + "requiredFields": ["text", "reference_id (voiceModelId)", "format"] + } + }, + + "faceData": { ... }, + + "metadata": { + "name": "Alex Chen", + "username": "alexc", + "email": "alex@example.com", + "createdAt": "2025-10-25T00:00:00.000Z", + "minecraftIntegration": { + "howToUse": "See MINECRAFT_INTEGRATION.md in the Replik repo", + "apiUrl": "https://your-domain.com/api/speak", + "requiresInternet": true + } + } +} +``` + +### Key Fields for Minecraft: + +1. **`userId`** - Use this for API calls to `/api/speak` +2. **`voiceModelId`** - Use this for direct Fish Audio TTS calls +3. **`context.entries`** - The clone's personality data +4. **`metadata.apiUrl`** - Where to send chat messages + +### Speak API (For Chat) +**POST** `/api/speak` + +**Request:** +```json +{ + "userId": "b9f8b510-a463-4232-9490-9679300453c1", + "message": "Hey, how are you?" +} +``` + +**Response:** +```json +{ + "text": "Pretty good! Working on a new project.", + "audioUrl": "https://ehxprwfkqnoxsvxljksz.supabase.co/storage/v1/object/public/audio-recordings/abc-123/response_456.mp3", + "success": true +} +``` + +**Important:** This endpoint uses the clone's personality + Fish Audio voice model automatically! + +--- + +## Fish Audio API Integration + +### Option 1: Use Replik's `/api/speak` Endpoint (Recommended) +This is the easiest way - just send the `userId` and the API handles everything: +- Fetches personality context +- Generates AI response with Claude +- Synthesizes voice with Fish Audio +- Returns text + audio URL + +**Example:** +```kotlin +val response = httpClient.post("https://your-domain.com/api/speak") { + contentType(ContentType.Application.Json) + setBody("""{"userId":"$userId","message":"$message"}""") +} +// Response includes audioUrl ready to play! +``` + +### Option 2: Direct Fish Audio API Calls (Advanced) +If you want to generate voice for custom text (not AI responses): + +**Endpoint:** `https://api.fish.audio/v1/tts` + +**Headers:** +``` +Authorization: Bearer YOUR_FISH_AUDIO_API_KEY +Content-Type: application/json +``` + +**Request Body:** +```json +{ + "text": "Hello from Minecraft!", + "reference_id": "d7dfbedf1d39421a948a302839a86ba9", + "format": "mp3", + "mp3_bitrate": 128, + "opus_bitrate": -1000, + "latency": "normal" +} +``` + +**Parameters:** +- `text` - The text to speak +- `reference_id` - The `voiceModelId` from your clone's JSON +- `format` - Audio format (`mp3`, `wav`, `opus`, `flac`) +- `latency` - `normal` or `balanced` (normal = better quality) + +**Response:** +Binary audio data (MP3 file) + +**Example (Kotlin):** +```kotlin +val voiceModelId = "d7dfbedf1d39421a948a302839a86ba9" // From clone JSON + +val response = httpClient.post("https://api.fish.audio/v1/tts") { + header("Authorization", "Bearer ${System.getenv("FISH_AUDIO_API_KEY")}") + contentType(ContentType.Application.Json) + setBody("""{ + "text": "Hello from Minecraft!", + "reference_id": "$voiceModelId", + "format": "mp3", + "latency": "normal" + }""") +} + +val audioBytes = response.readBytes() +// Save to file or play directly +``` + +### Where to Get Your Fish Audio API Key + +1. Go to [https://fish.audio](https://fish.audio) +2. Sign up / log in +3. Navigate to API section +4. Generate an API key +5. Add to your environment: `export FISH_AUDIO_API_KEY=your_key_here` + +### Which Option Should You Use? + +| Use Case | Recommended Approach | +|----------|---------------------| +| Chat with AI clone | `/api/speak` endpoint | +| Custom voice lines | Direct Fish Audio API | +| Testing voice quality | Direct Fish Audio API | +| Full clone experience | `/api/speak` endpoint | + +--- + +## Technical Details + +### Tech Stack + +| Component | Technology | +|-----------|-----------| +| Framework | Fabric 1.20.1 | +| Language | Kotlin 1.9.0 | +| HTTP Client | OkHttp 4.12.0 | +| JSON | Gson 2.10.1 | +| Async | Kotlin Coroutines 1.7.3 | +| Audio | MP3SPI 1.9.5.4 + JLayer 1.0.1.4 | + +### File Structure + +``` +minecraft-mod/ +โ”œโ”€โ”€ src/main/kotlin/com/digitaltwins/ +โ”‚ โ”œโ”€โ”€ DigitalTwinsMod.kt โ† Main entry point +โ”‚ โ”œโ”€โ”€ TwinAPI.kt โ† HTTP client +โ”‚ โ”œโ”€โ”€ TwinAudioPlayer.kt โ† Voice playback +โ”‚ โ”œโ”€โ”€ TwinCommands.kt โ† MVP commands +โ”‚ โ”œโ”€โ”€ TwinNPC.kt โ† MVP NPCs (armor stands) +โ”‚ โ”œโ”€โ”€ TwinStorage.kt โ† Local storage +โ”‚ โ””โ”€โ”€ advanced/ โ† Advanced mode (in development) +โ”‚ โ”œโ”€โ”€ entity/ โ† Custom entities +โ”‚ โ”œโ”€โ”€ item/ โ† Spawn eggs +โ”‚ โ”œโ”€โ”€ client/ โ† GUI + rendering +โ”‚ โ””โ”€โ”€ network/ โ† Packets +โ”œโ”€โ”€ src/main/resources/ +โ”‚ โ””โ”€โ”€ fabric.mod.json โ† Mod metadata +โ”œโ”€โ”€ build.gradle.kts โ† Build config +โ””โ”€โ”€ README.md โ† Full documentation +``` + +--- + + +## What's Next + +### Planned Features +- Voice playback - **DONE!** +- Custom entities - **In progress** +- GUI chat screen - **In progress** +- Spawn eggs - **In progress** +- Multiple players - **Planned** +- Offline caching - **Planned** + +--- + +## Credits + +- **Fabric** - Mod framework +- **Kotlin** - Programming language +- **Anthropic Claude** - AI personality +- **Fish Audio** - Voice cloning diff --git a/RAILWAY_DEPLOY.md b/RAILWAY_DEPLOY.md deleted file mode 100644 index e4118b2..0000000 --- a/RAILWAY_DEPLOY.md +++ /dev/null @@ -1,188 +0,0 @@ -# Railway Backend Deployment - -## Why Railway? - -Vercel is optimized for **static/serverless frontends**, but GhostJournal needs: -- **SQLite database** (or PostgreSQL) -- **File uploads** (user photos, audio) -- **Long-running processes** (voice cloning, face analysis) - -Railway provides a **full backend environment** for this. - -## Deployment Steps - -### 1. Sign Up for Railway -- Go to https://railway.app/ -- Sign in with GitHub - -### 2. Create New Project -- Click **"New Project"** -- Select **"Deploy from GitHub repo"** -- Choose your `ghostjournal` repository - -### 3. Configure Environment Variables -Click on your project โ†’ Variables โ†’ Add the following: - -```bash -# Database (Railway will auto-provide PostgreSQL, or use SQLite) -DATABASE_URL="file:./dev.db" - -# Anthropic (Claude) - REQUIRED -ANTHROPIC_API_KEY="sk-ant-..." - -# Fish Audio - REQUIRED for voice cloning -FISH_AUDIO_API_KEY="..." - -# App Configuration -NEXT_PUBLIC_BASE_URL="https://your-railway-app.up.railway.app" -PORT="3000" - -# ChromaDB (Optional - using mock for hackathon) -CHROMA_HOST="localhost" -CHROMA_PORT="8000" - -# Fetch.ai (Optional) -FETCH_AI_API_KEY="..." -``` - -### 4. Configure Build & Start Commands - -Railway should auto-detect Next.js, but verify: - -**Build Command:** -```bash -npm install && npm run build -``` - -**Start Command:** -```bash -npm start -``` - -### 5. Set Up PostgreSQL (Recommended for Production) - -For hackathon, SQLite (`file:./dev.db`) works fine. - -For production: -1. In Railway, click **"New"** โ†’ **"Database"** โ†’ **"PostgreSQL"** -2. Railway will auto-create a `DATABASE_URL` variable -3. Run migrations: - ```bash - npx prisma migrate deploy - ``` - -### 6. Configure Domain - -Railway provides a default domain: `your-app.up.railway.app` - -To use custom domain: -1. Go to Settings โ†’ Domains -2. Add your domain and configure DNS - -### 7. File Uploads Storage - -โš ๏ธ **Important:** Railway's filesystem is ephemeral (resets on deploy). - -For persistent uploads, integrate: -- **Cloudflare R2** (S3-compatible, free tier) -- **AWS S3** -- **Vercel Blob Storage** - -Quick fix for hackathon: -- Keep uploads in `/public/uploads/` (will persist during session) -- For production, migrate to cloud storage - -## Do You Need Vercel Too? - -**No!** Railway hosts your **entire Next.js app** in one place: - -``` -โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” -โ”‚ RAILWAY (Everything) โ”‚ -โ”‚ - Frontend (landing page, UI) โ”‚ -โ”‚ - API routes โ”‚ -โ”‚ - Database (PostgreSQL/SQLite) โ”‚ -โ”‚ - File uploads โ”‚ -โ”‚ - AI processing (Claude, Fish Audio) โ”‚ -โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ -``` - -**For hackathon: Just use Railway. One deployment, one URL, done.** โœ“ - -### Optional: Railway + Vercel Hybrid (for scale) - -Only if you need: -- Vercel's global CDN for faster page loads -- Separate frontend/backend scaling - -To implement: -1. Deploy full app to Railway (backend) -2. Deploy frontend to Vercel -3. Add proxy in `vercel.json`: - ```json - { - "rewrites": [ - { "source": "/api/:path*", "destination": "https://your-railway-app.up.railway.app/api/:path*" } - ] - } - ``` - -**But for your hackathon: Railway alone is perfect!** - -## Testing Deployment - -After deployment: - -1. Open your Railway URL -2. You should see: **"GhostJournal - Your AI Clone"** -3. Click **"Get Started"** โ†’ Create account -4. Test the full flow: - - Record voice โœ“ - - Upload photos โœ“ - - Add context โœ“ - - Chat with clone โœ“ - -## Common Issues - -### "Cannot find module @prisma/client" -**Fix:** Run `npx prisma generate` in Railway CLI or add to build script: -```json -"build": "npx prisma generate && next build" -``` - -### File uploads fail -**Fix:** Check write permissions or migrate to cloud storage (see above) - -### Environment variables not loading -**Fix:** Restart service after adding/updating variables - -### Database connection error -**Fix:** Make sure `DATABASE_URL` is set correctly - -## Monitoring - -Railway provides: -- **Logs** (click "View Logs") -- **Metrics** (CPU, RAM, Network) -- **Deployments** (rollback if needed) - -## Cost - -Railway pricing: -- **Free tier:** $5 credit/month (enough for hackathon) -- **Hobby:** $5/month for more resources -- **Team:** $20/month for production - -## Next Steps - -- [ ] Deploy to Railway -- [ ] Test full user flow -- [ ] (Optional) Set up custom domain -- [ ] (Optional) Add PostgreSQL for production -- [ ] (Optional) Migrate file uploads to cloud storage -- [ ] Update frontend to use Railway API URL - ---- - -**Quick start:** Just push your current code, Railway will handle the rest! ๐Ÿš€ - diff --git a/README.md b/README.md index 069bbc1..25422ce 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,6 @@ -# EchoSelf ๐ŸŽญ +# Replik -> **Your Interactive Voice + Visual AI Clone** -> Built for Cal Hacks 12.0 +> **Your Interactive Voice + Visual AI Clone** An immersive dark-mode web application that creates an animated AI clone of you using: - **Voice cloning** (Fish Audio API) @@ -10,28 +9,41 @@ An immersive dark-mode web application that creates an animated AI clone of you - **Long-term memory** (ChromaDB) - **Optional autonomous deployment** (Fetch.ai Agentverse) +**Try it live:** [replik.tech](https://replik.tech) + --- -## โœจ Features +## Features -### ๐ŸŽค Voice Cloning +### Voice Cloning Record 20 seconds of audio to create a personalized voice model using Fish Audio's state-of-the-art TTS technology. -### ๐Ÿ“ธ Visual Clone +### Visual Clone Capture 5 selfies (front, left, right, up, down) to generate a ghostly outline of your face rendered with live audio waveforms. -### ๐Ÿง  Personality Model +### Personality Model Share stories, habits, and reactions to build a personality profile powered by Claude that makes your clone authentically *you*. -### ๐Ÿ’พ Vector Memory +### Vector Memory All conversations and contexts are stored in ChromaDB, enabling your clone to recall past interactions and maintain context. -### ๐Ÿค– Autonomous Agent (Optional) +### Autonomous Agent (Optional) Deploy your clone to Fetch.ai's Agentverse where it can operate independently, interact with other agents, and persist beyond your session. +### Minecraft Integration +Bring your digital twins into Minecraft with full voice support! Import twins from the web app, spawn them as NPCs, and chat with them in-game. Supports both command-based (MVP) and GUI-based (Advanced) modes. + +**See [MINECRAFT_SETUP.md](./MINECRAFT_SETUP.md) for full guide.** + +--- + +## Documentation + +For detailed setup guides, architecture documentation, and implementation notes, see the [docs/](./docs/) folder. + --- -## ๐Ÿ› ๏ธ Tech Stack +## Tech Stack ### Frontend - **Next.js 14** (App Router) @@ -53,7 +65,7 @@ Deploy your clone to Fetch.ai's Agentverse where it can operate independently, i --- -## ๐Ÿš€ Quick Start +## Quick Start ### Prerequisites - Node.js 18+ and npm/yarn @@ -63,7 +75,7 @@ Deploy your clone to Fetch.ai's Agentverse where it can operate independently, i ```bash git clone -cd CalHacks +cd replik npm install ``` @@ -120,7 +132,7 @@ Open [http://localhost:3000](http://localhost:3000) in your browser. --- -## ๐Ÿ”‘ API Keys Setup +## API Keys Setup ### Fish Audio API 1. Sign up at [fish.audio](https://fish.audio) @@ -153,10 +165,10 @@ Open [http://localhost:3000](http://localhost:3000) in your browser. --- -## ๐Ÿ“ Project Structure +## Project Structure ``` -CalHacks/ +replik/ โ”œโ”€โ”€ app/ โ”‚ โ”œโ”€โ”€ api/ # API routes โ”‚ โ”‚ โ”œโ”€โ”€ upload/ # Media upload handler @@ -197,7 +209,7 @@ CalHacks/ --- -## ๐ŸŽจ Visual Design +## Visual Design ### Color Palette - **Background:** `#0a0a0f` (dark-bg) @@ -215,7 +227,7 @@ CalHacks/ --- -## ๐Ÿ”„ User Flow +## User Flow 1. **Record Voice** (20 seconds) - Visualize audio levels in real-time @@ -237,7 +249,7 @@ CalHacks/ --- -## ๐Ÿงช Development Notes +## Development Notes ### Mock Modes The app gracefully degrades if API keys are missing: @@ -258,7 +270,7 @@ Currently uses mock elliptical outlines. For production: --- -## ๐Ÿ“ฆ Deployment +## Deployment ### Vercel (Recommended) ```bash @@ -289,17 +301,7 @@ For production, deploy ChromaDB separately: --- -## ๐ŸŽฏ Hackathon Tracks - -This project qualifies for: -- **Fish Audio Track:** Voice cloning & TTS implementation -- **Anthropic Track:** Claude-powered personality modeling -- **Fetch.ai Track:** Agent deployment on Agentverse -- **Best Overall:** Innovative use of multiple AI technologies - ---- - -## ๐Ÿ› Troubleshooting +## Troubleshooting ### Audio Recording Issues - **Firefox:** May require HTTPS or localhost @@ -320,7 +322,7 @@ This project qualifies for: --- -## ๐Ÿ”ฎ Future Enhancements +## Future Enhancements - [ ] Multi-language voice cloning - [ ] 3D face model rendering @@ -333,43 +335,34 @@ This project qualifies for: --- -## ๐Ÿ“„ License +## License -MIT License - Built for Cal Hacks 12.0 +MIT License --- -## ๐Ÿค Contributing +## Contributing -This is a hackathon project, but contributions are welcome! +Contributions are welcome! 1. Fork the repository 2. Create a feature branch 3. Submit a pull request --- -## ๐Ÿ‘ฅ Team - -Built with โค๏ธ by [Your Team Name] - ---- - -## ๐Ÿ™ Acknowledgments +## Acknowledgments - **Fish Audio** - Voice cloning technology - **Anthropic** - Claude API - **Chroma** - Vector database - **Fetch.ai** - Agent infrastructure -- **Cal Hacks** - Amazing hackathon opportunity --- -## ๐Ÿ“ž Support +## Support For issues or questions: - Open a GitHub issue - Check API documentation - Review troubleshooting section -**Happy Hacking! ๐Ÿš€** - diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..a195cf6 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,330 @@ +# Security Policy + +## Supported Versions + +Currently supported versions for security updates: + +| Version | Supported | +| ------- | ------------------ | +| 1.x.x | :white_check_mark: | +| < 1.0 | :x: | + +## Reporting a Vulnerability + +We take the security of Replik seriously. If you discover a security vulnerability, please follow these steps: + +### 1. **Do NOT** disclose the vulnerability publicly + +Please do not open a public GitHub issue for security vulnerabilities. + +### 2. Report via Email + +Send a detailed report to: **shoadachi1101@gmail.com** + +Include: +- Description of the vulnerability +- Steps to reproduce +- Potential impact +- Suggested fix (if any) + +### 3. Response Timeline + +- **Initial Response**: Within 48 hours +- **Status Update**: Within 7 days +- **Fix Timeline**: Depends on severity (Critical: < 7 days, High: < 14 days, Medium: < 30 days) + +### 4. Coordinated Disclosure + +We follow responsible disclosure practices: +1. We'll work with you to understand and validate the issue +2. We'll develop and test a fix +3. We'll release a security update +4. After the fix is deployed, we'll publicly acknowledge your contribution (unless you prefer to remain anonymous) + +--- + +## Security Best Practices for Deployment + +### ๐Ÿ” Environment Variables + +**CRITICAL: Never commit `.env` files to git!** + +```bash +# Already in .gitignore: +.env +.env*.local +.env.production +``` + +### ๐Ÿšจ Service Role Key Protection + +The `SUPABASE_SERVICE_ROLE_KEY` is **extremely sensitive** and must be protected: + +โœ… **DO:** +- Only use in API routes (server-side) +- Store in environment variables +- Rotate regularly (every 90 days) +- Use different keys for dev/staging/production + +โŒ **DON'T:** +- Never expose to frontend code +- Never log in console +- Never commit to git +- Never share publicly + +### ๐Ÿ”‘ API Keys Security + +All API keys should be treated as secrets: + +| Variable | Sensitivity | Location | Notes | +|----------|-------------|----------|-------| +| `SUPABASE_SERVICE_ROLE_KEY` | **CRITICAL** | Server-only | Bypasses RLS | +| `ANTHROPIC_API_KEY` | High | Server-only | Claude API access | +| `FISH_AUDIO_API_KEY` | High | Server-only | Voice synthesis | +| `MINECRAFT_API_KEY` | Medium | Server-only | External integrations | +| `NEXT_PUBLIC_SUPABASE_URL` | Low | Public | URL only, no secret | +| `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY` | Low | Public | Respects RLS | + +### ๐Ÿ›ก๏ธ Authentication & Authorization + +**Web Users:** +- All authenticated via Supabase Auth +- Row Level Security (RLS) enforced on database +- Session tokens stored in HTTP-only cookies + +**Minecraft Mod:** +- Requires `MINECRAFT_API_KEY` in X-API-Key header +- Or user must be authenticated via Supabase session + +**Public Endpoints:** +- `/api/clones` - Public by design (only returns public profiles) +- `/api/minecraft/export/*` - Public by design (only returns public clones) + +### ๐Ÿšฆ Rate Limiting Recommendations + +**โš ๏ธ WARNING:** Currently, rate limiting is NOT implemented. For production deployments, we strongly recommend adding rate limiting to prevent abuse. + +**Recommended Implementation:** + +```typescript +// Using Upstash Redis Rate Limiting +import { Ratelimit } from "@upstash/ratelimit"; +import { Redis } from "@upstash/redis"; + +const ratelimit = new Ratelimit({ + redis: Redis.fromEnv(), + limiter: Ratelimit.slidingWindow(10, "10 s"), // 10 requests per 10 seconds +}); +``` + +**Endpoints that need rate limiting:** +1. `/api/speak` - Most expensive (AI + TTS costs) +2. `/api/clones` - Can be scraped +3. `/api/minecraft/export/*` - Public access +4. `/api/upload` - File uploads +5. `/api/upload-photo` - Image uploads + +### ๐Ÿ”’ CORS Configuration + +By default, Next.js API routes are accessible from any origin. For production: + +1. Add explicit CORS headers to public endpoints +2. Whitelist your frontend domain +3. Use proper preflight handling + +Example: +```typescript +export async function OPTIONS() { + return new Response(null, { + headers: { + 'Access-Control-Allow-Origin': process.env.ALLOWED_ORIGIN || '*', + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, X-API-Key', + }, + }); +} +``` + +### ๐Ÿ“Š Monitoring & Logging + +**What to monitor:** +- Failed authentication attempts +- Unusual API usage patterns +- API quota consumption (Anthropic, Fish Audio) +- Database query performance +- Error rates + +**What NOT to log:** +- Full API keys or tokens +- User passwords +- Service role keys +- Personal identification information (unless necessary and encrypted) + +### ๐Ÿ—„๏ธ Database Security + +**Supabase Row Level Security (RLS):** + +All tables should have RLS policies. Current RLS setup: + +```sql +-- Users can only see their own data +CREATE POLICY "Users can view own data" ON users + FOR SELECT USING (auth.uid() = id); + +-- Users can update their own profile +CREATE POLICY "Users can update own data" ON users + FOR UPDATE USING (auth.uid() = id); +``` + +**โš ๏ธ Service Role bypasses RLS** - Use with caution! + +### ๐ŸŒ Deployment Checklist + +Before deploying to production: + +- [ ] All API keys in environment variables (not hardcoded) +- [ ] `.env` files in `.gitignore` +- [ ] Service role key is NOT in frontend code +- [ ] HTTPS enabled (force SSL) +- [ ] Rate limiting implemented +- [ ] Error messages don't leak sensitive info +- [ ] Database backups configured +- [ ] Monitoring and alerting set up +- [ ] CORS properly configured +- [ ] Security headers set (CSP, HSTS, etc.) + +### ๐Ÿ”„ Key Rotation + +Rotate keys regularly: + +| Key Type | Rotation Frequency | +|----------|-------------------| +| Service Role Key | Every 90 days | +| API Keys | Every 180 days | +| Database Passwords | Every 180 days | +| Minecraft API Key | Every 365 days | + +### ๐Ÿ›ก๏ธ Dependency Security + +Keep dependencies updated: + +```bash +# Check for vulnerabilities +npm audit + +# Fix automatically (if possible) +npm audit fix + +# Review and update dependencies +npm outdated +``` + +Enable GitHub Dependabot alerts: +1. Go to repo Settings +2. Security & analysis +3. Enable "Dependabot alerts" + +--- + +## Known Limitations + +### Current Security Gaps + +1. **No Rate Limiting** - Endpoints can be abused + - **Mitigation**: Add Upstash or similar rate limiting + +2. **Public Minecraft Export** - Anyone can export public clones + - **Mitigation**: By design for Minecraft integration. Users must opt-in via `isPublic` flag + +3. **Voice Model IDs Exposed** - Fish Audio model IDs are visible + - **Mitigation**: IDs alone cannot be used without Fish Audio API key + +4. **No Request Signing** - API requests aren't cryptographically signed + - **Mitigation**: Use HTTPS + authentication tokens + +### Accepting These Risks + +These limitations are acceptable because: +- The app is designed for public AI clones (opt-in) +- Users control what data they make public +- Authentication prevents unauthorized modifications +- Costs are limited by API provider rate limits + +--- + +## Security Features Implemented + +โœ… **Authentication** +- Supabase Auth with JWT tokens +- Password + Google OAuth +- HTTP-only session cookies + +โœ… **Authorization** +- Row Level Security (RLS) on database +- User-specific data access +- Service role used only server-side + +โœ… **Data Protection** +- HTTPS enforced in production +- Secrets in environment variables +- No secrets in git history + +โœ… **Input Validation** +- Prisma ORM (prevents SQL injection) +- UUID validation on user IDs +- File type validation on uploads + +โœ… **API Security** +- Authentication required on sensitive endpoints +- API key support for external integrations +- Proper error handling (no info leakage) + +--- + +## Compliance & Privacy + +### Data Collection + +Replik collects: +- User account info (email, name, username) +- Voice recordings (for cloning) +- Conversation history +- Personality context (stories, habits, reactions) +- Photos (optional, for visual representation) + +### Data Storage + +- Voice recordings: Fish Audio (external service) +- Database: Supabase (PostgreSQL) +- Files: Supabase Storage +- Vector memories: ChromaDB (optional) + +### User Rights + +Users can: +- View all their data +- Delete their account (`/api/delete-account`) +- Export their clone data (JSON format) +- Control data visibility (`isPublic` flag) + +### GDPR Considerations + +If deploying in EU: +- Add cookie consent banner +- Provide data export functionality โœ… (already implemented) +- Implement data deletion โœ… (already implemented) +- Add privacy policy +- Document data processing + +--- + +## Contact + +For security concerns: **shoadachi1101@gmail.com** + +For general issues: [GitHub Issues](https://github.com/ShoAdachi01/replik/issues) + +--- + +**Last Updated:** 2025-10-29 + diff --git a/SECURITY_ALERT.md b/SECURITY_ALERT.md deleted file mode 100644 index 1440a30..0000000 --- a/SECURITY_ALERT.md +++ /dev/null @@ -1,122 +0,0 @@ -# ๐Ÿšจ SECURITY ALERT - API KEY EXPOSURE - -## What Happened? - -Your **Fish Audio API key** was **hardcoded** in test script files (`test_fish*.sh`) that were **committed and pushed to GitHub**. - -## โš ๏ธ IMMEDIATE ACTION REQUIRED - -### 1. **Regenerate Your Fish Audio API Key NOW** - -Your current key is **publicly visible** on GitHub. Anyone can see it in your repo's commit history. - -**Steps:** -1. Go to https://fish.audio/ -2. Navigate to **API Keys** or **Settings** -3. **Revoke/Delete** your current API key -4. **Generate a new API key** -5. Update your `.env` file with the new key: - ```bash - FISH_AUDIO_API_KEY="your_new_key_here" - ``` - -### 2. **Never Commit API Keys Again** - -โœ… **Correct:** Store in `.env` file -```bash -# .env (this file is in .gitignore - safe!) -FISH_AUDIO_API_KEY="sk-abc123..." -ANTHROPIC_API_KEY="sk-ant-abc123..." -``` - -โŒ **WRONG:** Hardcode in scripts -```bash -# test.sh - NEVER DO THIS! -API_KEY="sk-abc123..." # โŒ Will be pushed to GitHub! -``` - -## What I Fixed - -โœ… **Deleted all test scripts** with hardcoded keys: -- `test_fish.sh` -- `test_fish_create.sh` -- `test_fish_detailed.sh` -- `test_fish_models.sh` -- `test_fish_reference.sh` -- `test_fish_with_reference.sh` -- `test_fish_create_proper.sh` - -โœ… **Added to `.gitignore`**: -``` -test_fish*.sh -scripts/test_*.sh -``` - -โœ… **Pushed changes** to remove files from repo - -## โš ๏ธ Git History Still Contains Old Keys - -Even though the files are deleted now, they're still visible in **git commit history**. - -### Option 1: Quick Fix (Recommended for Hackathon) -Just regenerate your API key (step 1 above). Old key becomes useless. - -### Option 2: Clean Git History (Optional, Advanced) -If you want to remove the keys from git history entirely: - -```bash -# Use BFG Repo Cleaner -git clone --mirror git@github.com:your-repo.git -bfg --delete-files test_fish*.sh your-repo.git -cd your-repo.git -git reflog expire --expire=now --all && git gc --prune=now --aggressive -git push --force -``` - -**Warning:** This rewrites history and requires force push. - -## Best Practices Going Forward - -### For Testing APIs: - -Create a safe test script that reads from `.env`: - -```bash -#!/bin/bash -# test_api_safe.sh - -# Load environment variables -source .env - -# Now use $FISH_AUDIO_API_KEY safely -curl -X POST "https://api.fish.audio/v1/..." \ - -H "Authorization: Bearer $FISH_AUDIO_API_KEY" \ - -H "Content-Type: application/json" -``` - -### For Railway Deployment: - -Use environment variables in Railway dashboard (already documented in `RAILWAY_DEPLOY.md`). - -### For Local Development: - -Always use `.env` file (already in `.gitignore`). - -## Summary - -- [x] Test files with keys **deleted** -- [x] Files **removed from repo** -- [x] `.gitignore` **updated** -- [ ] **YOU NEED TO:** Regenerate Fish Audio API key -- [ ] **YOU NEED TO:** Update `.env` with new key - -## Questions? - -- How to check if `.env` is safe? Run: `git status` - if `.env` appears, it's NOT in `.gitignore` -- How to remove from staging? Run: `git reset .env` -- Need help with BFG? See: https://rtyley.github.io/bfg-repo-cleaner/ - ---- - -**Again: REGENERATE YOUR FISH AUDIO API KEY NOW!** ๐Ÿšจ - diff --git a/app/api/analyze-face/route.ts b/app/api/analyze-face/route.ts index d116678..e5bf1cc 100644 --- a/app/api/analyze-face/route.ts +++ b/app/api/analyze-face/route.ts @@ -5,6 +5,10 @@ import { readFile } from 'fs/promises' import { join } from 'path' import sharp from 'sharp' +// Force dynamic rendering for this route +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' + const prisma = new PrismaClient() /** @@ -209,16 +213,44 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'User ID required' }, { status: 400 }) } - console.log('๐ŸŽญ Analyzing face for user:', userId) + console.log('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•') + console.log('๐ŸŽญ /api/analyze-face - REQUEST RECEIVED') + console.log(' userId:', userId) + console.log(' Type:', typeof userId) + console.log(' Length:', userId.length) + console.log(' First 20 chars:', userId.substring(0, 20)) + console.log('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•') + + // Validate UUID format + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + if (!uuidRegex.test(userId)) { + console.error('โŒ INVALID UUID FORMAT received in analyze-face!') + console.error(' Received:', userId) + console.error(' Expected: UUID format (e.g., 12345678-1234-1234-1234-123456789012)') + return NextResponse.json({ + error: 'Invalid user ID format', + faceData: { contours: generateMockFaceContours() } + }, { status: 400 }) + } + console.log('โœ… UUID format valid, querying database...') const user = await prisma.user.findUnique({ where: { id: userId } }) + if (!user) { - console.warn('โš ๏ธ User not found, using mock data') + console.warn('โš ๏ธ User NOT FOUND in database!') + console.warn(' Searched for userId:', userId) + console.warn(' Returning mock data') return NextResponse.json({ faceData: { contours: generateMockFaceContours() } }) } + console.log('โœ… User FOUND in database!') + console.log(' Username:', user.username || '(none)') + console.log(' Email:', user.email?.substring(0, 20) || '(none)') + console.log(' Has faceData:', !!user.faceData) + console.log(' Has photoUrls:', !!user.photoUrls) + // CHECK FOR MEDIAPIPE DATA FIRST (new flow) if (user.faceData) { console.log('โœ… Found MediaPipe face data in database!') diff --git a/app/api/change-username/route.ts b/app/api/change-username/route.ts new file mode 100644 index 0000000..8e639eb --- /dev/null +++ b/app/api/change-username/route.ts @@ -0,0 +1,118 @@ +import { NextRequest, NextResponse } from 'next/server' +import { PrismaClient } from '@prisma/client' +import { createClient } from '@supabase/supabase-js' + +const prisma = new PrismaClient() + +export async function POST(request: NextRequest) { + console.log('๐Ÿ”„ Change Username API called') + + try { + const { userId, newUsername } = await request.json() + + if (!userId || !newUsername) { + console.error('โŒ Missing required fields') + return NextResponse.json( + { error: 'User ID and new username required' }, + { status: 400 } + ) + } + + // Validate username format + const usernameRegex = /^[a-zA-Z0-9_]{3,20}$/ + if (!usernameRegex.test(newUsername)) { + console.error('โŒ Invalid username format') + return NextResponse.json( + { error: 'Username must be 3-20 characters (letters, numbers, underscores only)' }, + { status: 400 } + ) + } + + console.log('๐Ÿ” Checking if username is available...') + console.log(' User ID:', userId) + console.log(' New username:', newUsername) + + // Check if username is already taken by another user + const existingUser = await prisma.user.findUnique({ + where: { username: newUsername } + }) + + if (existingUser && existingUser.id !== userId) { + console.error('โŒ Username already taken') + return NextResponse.json( + { error: 'Username already taken' }, + { status: 409 } + ) + } + + // Step 1: Update Prisma database + console.log('โœ๏ธ Updating username in database...') + const updatedUser = await prisma.user.update({ + where: { id: userId }, + data: { username: newUsername }, + select: { + id: true, + username: true, + name: true, + email: true, + isPublic: true, + voiceModelId: true, + faceData: true, + photoUrls: true + } + }) + console.log('โœ… Database updated') + + // Step 2: Update Supabase Auth metadata (so it persists on refresh) + try { + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL + const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY + + if (supabaseUrl && serviceRoleKey) { + console.log('๐Ÿ” Updating Supabase auth metadata...') + const supabaseAdmin = createClient(supabaseUrl, serviceRoleKey, { + auth: { + autoRefreshToken: false, + persistSession: false + } + }) + + const { error: authError } = await supabaseAdmin.auth.admin.updateUserById( + userId, + { + user_metadata: { + username: newUsername, + name: newUsername + } + } + ) + + if (authError) { + console.error('โš ๏ธ Failed to update Supabase auth metadata:', authError) + // Don't fail the request - database is already updated + } else { + console.log('โœ… Supabase auth metadata updated') + } + } else { + console.log('โš ๏ธ Supabase not configured - skipping auth metadata update') + } + } catch (authError) { + console.error('โš ๏ธ Error updating Supabase auth:', authError) + // Don't fail the request - database is already updated + } + + return NextResponse.json({ + success: true, + message: 'Username updated successfully', + user: updatedUser + }) + + } catch (error: any) { + console.error('โŒโŒโŒ Change username error:', error) + return NextResponse.json( + { error: 'Failed to change username', details: error.message }, + { status: 500 } + ) + } +} + diff --git a/app/api/clones/route.ts b/app/api/clones/route.ts new file mode 100644 index 0000000..1e7aa66 --- /dev/null +++ b/app/api/clones/route.ts @@ -0,0 +1,77 @@ +import { NextRequest, NextResponse } from 'next/server' +import { PrismaClient } from '@prisma/client' + +const prisma = new PrismaClient() + +/** + * GET /api/clones + * Fetch all public clone models for browsing + */ +export async function GET(request: NextRequest) { + try { + console.log('๐Ÿ” /api/clones - Fetching public clones...') + const { searchParams } = new URL(request.url) + const search = searchParams.get('search') || '' + console.log(' Search query:', search || '(none)') + + // Fetch public users + const users = await prisma.user.findMany({ + where: { + isPublic: true, + ...(search && { + OR: [ + { username: { contains: search, mode: 'insensitive' } }, + { name: { contains: search, mode: 'insensitive' } }, + { bio: { contains: search, mode: 'insensitive' } } + ] + }) + }, + select: { + id: true, + username: true, + name: true, + bio: true, + createdAt: true, + voiceModelId: true, // To show if voice is trained + photoUrls: true // Profile photo + }, + orderBy: { + createdAt: 'desc' + }, + take: 50 // Limit results + }) + + // Transform to Clone interface format + const clones = users.map(user => ({ + userId: user.id, + username: user.username || 'unknown', + name: user.name, + bio: user.bio, + createdAt: user.createdAt.toISOString(), + isPublic: true, + hasVoiceModel: !!user.voiceModelId, + photoUrls: user.photoUrls + })) + + console.log(`โœ… Returning ${clones.length} public clones`) + + // Debug: Log each clone's ID format + clones.forEach((clone, i) => { + console.log(` Clone ${i + 1}: @${clone.username}`) + console.log(` userId: ${clone.userId}`) + console.log(` type: ${typeof clone.userId} | length: ${clone.userId?.length}`) + }) + + return NextResponse.json({ clones }) + } catch (error: any) { + console.error('โŒ Error fetching clones:', error) + console.error(' Error message:', error.message) + console.error(' Error code:', error.code) + console.error(' Error stack:', error.stack) + return NextResponse.json( + { error: 'Failed to fetch clones', details: error.message, code: error.code }, + { status: 500 } + ) + } +} + diff --git a/app/api/create-user/route.ts b/app/api/create-user/route.ts index 3b46e09..93e48b7 100644 --- a/app/api/create-user/route.ts +++ b/app/api/create-user/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from 'next/server' import { PrismaClient } from '@prisma/client' -import { createServerSupabaseClient } from '@/lib/supabase-server' +import { createRouteHandlerClient } from '@/lib/supabase-server' import { uploadAudio } from '@/lib/storage' const prisma = new PrismaClient() @@ -13,10 +13,11 @@ const prisma = new PrismaClient() export async function POST(request: NextRequest) { try { // Get authenticated user from Supabase - const supabase = createServerSupabaseClient() + const { supabase } = createRouteHandlerClient(request) const { data: { user: authUser }, error: authError } = await supabase.auth.getUser() if (authError || !authUser) { + console.error('Auth error in create-user:', authError) return NextResponse.json( { error: 'Unauthorized - please log in' }, { status: 401 } @@ -39,18 +40,20 @@ export async function POST(request: NextRequest) { update: { email: authUser.email, name: authUser.user_metadata?.name || authUser.user_metadata?.full_name, + username: authUser.user_metadata?.username, }, create: { id: authUser.id, email: authUser.email, name: authUser.user_metadata?.name || authUser.user_metadata?.full_name, + username: authUser.user_metadata?.username, photoUrls: '[]', } }) console.log('โœ… User created/updated:', user.id) - // Upload audio to Supabase Storage + // Upload audio to Supabase Storage (uses service role key internally) console.log('๐Ÿ“ค Uploading audio to Supabase Storage...') const audioUrl = await uploadAudio(authUser.id, audio) console.log('โœ… Audio uploaded:', audioUrl) @@ -71,10 +74,21 @@ export async function POST(request: NextRequest) { message: 'User created with audio' }) - } catch (error) { - console.error('Create user error:', error) + } catch (error: any) { + console.error('โŒ Create user error:', error) + console.error(' Error details:', { + message: error.message, + code: error.code, + meta: error.meta, + name: error.name + }) return NextResponse.json( - { error: 'User creation failed' }, + { + error: 'User creation failed', + details: error.message, + code: error.code, + meta: error.meta + }, { status: 500 } ) } diff --git a/app/api/delete-account/route.ts b/app/api/delete-account/route.ts new file mode 100644 index 0000000..6b8d8d6 --- /dev/null +++ b/app/api/delete-account/route.ts @@ -0,0 +1,61 @@ +import { NextRequest, NextResponse } from 'next/server' +import { PrismaClient } from '@prisma/client' +import { createRouteHandlerClient } from '@/lib/supabase-server' + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' + +const prisma = new PrismaClient() + +/** + * Delete user account and all associated data + */ +export async function DELETE(request: NextRequest) { + try { + // Get authenticated user + const { supabase } = createRouteHandlerClient(request) + const { data: { user: authUser }, error: authError } = await supabase.auth.getUser() + + if (authError || !authUser) { + console.error('Auth error in delete-account:', authError) + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ) + } + + console.log('๐Ÿ—‘๏ธ Deleting account for user:', authUser.id) + + // Delete user from Prisma database (cascades to memories and conversations) + try { + await prisma.user.delete({ + where: { id: authUser.id } + }) + console.log('โœ… User data deleted from database') + } catch (dbError: any) { + console.log('โš ๏ธ User not found in database (may not have completed setup)') + } + + // Delete user from Supabase Auth + const { error: deleteError } = await supabase.auth.admin.deleteUser(authUser.id) + + if (deleteError) { + console.error('โŒ Error deleting from Supabase:', deleteError) + // Continue anyway - data is already deleted from Prisma + } + + console.log('โœ… Account deletion complete') + + return NextResponse.json({ + success: true, + message: 'Account deleted successfully' + }) + } catch (error: any) { + console.error('โŒ Delete account error:', error) + return NextResponse.json( + { error: 'Failed to delete account', details: error.message }, + { status: 500 } + ) + } +} + diff --git a/app/api/face-data/route.ts b/app/api/face-data/route.ts index 682c49f..62815e8 100644 --- a/app/api/face-data/route.ts +++ b/app/api/face-data/route.ts @@ -1,6 +1,10 @@ import { NextRequest, NextResponse } from 'next/server' import { PrismaClient } from '@prisma/client' +// Force dynamic rendering for this route +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' + const prisma = new PrismaClient() /** diff --git a/app/api/memory/route.ts b/app/api/memory/route.ts index dac5a20..1f59612 100644 --- a/app/api/memory/route.ts +++ b/app/api/memory/route.ts @@ -17,11 +17,152 @@ type ChromaClient = any * 3. Query - Retrieve relevant memories for context */ +/** + * GET - Fetch all memories for a user + */ +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url) + const userId = searchParams.get('userId') + + if (!userId) { + return NextResponse.json({ error: 'User ID required' }, { status: 400 }) + } + + console.log('๐Ÿ“ฅ GET /api/memory - Fetching memories for:', userId.substring(0, 20)) + + // Fetch all memories for the user + const memories = await prisma.memory.findMany({ + where: { userId }, + orderBy: { createdAt: 'asc' } // Oldest first to show initial contexts first + }) + + console.log(`โœ… Found ${memories.length} memories`) + + return NextResponse.json({ + memories, + count: memories.length + }) + + } catch (error: any) { + console.error('โŒ GET /api/memory error:', error) + return NextResponse.json( + { error: 'Failed to fetch memories', details: error.message }, + { status: 500 } + ) + } +} + +/** + * DELETE - Delete a specific memory + */ +export async function DELETE(request: NextRequest) { + try { + const { searchParams } = new URL(request.url) + const userId = searchParams.get('userId') + const memoryId = searchParams.get('memoryId') + + if (!userId || !memoryId) { + return NextResponse.json({ error: 'User ID and Memory ID required' }, { status: 400 }) + } + + console.log('๐Ÿ—‘๏ธ DELETE /api/memory - Deleting memory:', memoryId.substring(0, 20)) + + // Verify memory belongs to user before deleting + const memory = await prisma.memory.findUnique({ + where: { id: memoryId } + }) + + if (!memory) { + return NextResponse.json({ error: 'Memory not found' }, { status: 404 }) + } + + if (memory.userId !== userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 403 }) + } + + // Delete the memory + await prisma.memory.delete({ + where: { id: memoryId } + }) + + console.log('โœ… Memory deleted') + + return NextResponse.json({ + success: true, + message: 'Memory deleted' + }) + + } catch (error: any) { + console.error('โŒ DELETE /api/memory error:', error) + return NextResponse.json( + { error: 'Failed to delete memory', details: error.message }, + { status: 500 } + ) + } +} + +/** + * PUT - Update a specific memory + */ +export async function PUT(request: NextRequest) { + try { + const { userId, memoryId, content } = await request.json() + + if (!userId || !memoryId || !content) { + return NextResponse.json({ error: 'User ID, Memory ID, and content required' }, { status: 400 }) + } + + console.log('โœ๏ธ PUT /api/memory - Updating memory:', memoryId.substring(0, 20)) + console.log(' New content length:', content.length) + + // Verify memory belongs to user before updating + const memory = await prisma.memory.findUnique({ + where: { id: memoryId } + }) + + if (!memory) { + return NextResponse.json({ error: 'Memory not found' }, { status: 404 }) + } + + if (memory.userId !== userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 403 }) + } + + // Update the memory + const updatedMemory = await prisma.memory.update({ + where: { id: memoryId }, + data: { content: content.trim() } + }) + + console.log('โœ… Memory updated') + + return NextResponse.json({ + success: true, + message: 'Memory updated', + memory: updatedMemory + }) + + } catch (error: any) { + console.error('โŒ PUT /api/memory error:', error) + return NextResponse.json( + { error: 'Failed to update memory', details: error.message }, + { status: 500 } + ) + } +} + export async function POST(request: NextRequest) { - console.log('๐Ÿ’พ Memory API called') + console.log('๐Ÿ’พ POST /api/memory called') try { - const { userId, action, content, query } = await request.json() - console.log(` Action: ${action}, UserId: ${userId?.substring(0, 10)}...`) + const { userId, action, content, query, category } = await request.json() + console.log(` Action: ${action || 'add'}, UserId: ${userId?.substring(0, 10)}...`) + + // If no action specified but content is provided, default to 'add' + if (!action && content) { + console.log(' No action specified, defaulting to "add"') + return await handleAddMemory(userId, content, category || 'story') + } if (!userId) { return NextResponse.json({ error: 'User ID required' }, { status: 400 }) @@ -191,6 +332,48 @@ async function queryMemories( } } +/** + * Helper function to add a memory directly (used by ContextBuilder) + */ +async function handleAddMemory(userId: string, content: string, category: string) { + if (!userId) { + return NextResponse.json({ error: 'User ID required' }, { status: 400 }) + } + if (!content || !content.trim()) { + return NextResponse.json({ error: 'Content required' }, { status: 400 }) + } + + console.log(`๐Ÿ’พ Adding memory for user: ${userId.substring(0, 20)}`) + console.log(` Category: ${category}`) + console.log(` Content length: ${content.length}`) + + try { + const memory = await prisma.memory.create({ + data: { + userId, + content: content.trim(), + embedding: '', + category: category || 'story' + } + }) + + console.log('โœ… Memory created:', memory.id) + + return NextResponse.json({ + success: true, + message: 'Memory added', + memoryId: memory.id, + memory + }) + } catch (error: any) { + console.error('โŒ Error creating memory:', error) + return NextResponse.json( + { error: 'Failed to add memory', details: error.message }, + { status: 500 } + ) + } +} + // Mock memory storage for when ChromaDB is not available async function handleMockMemory( userId: string, diff --git a/app/api/minecraft/export/[userId]/route.ts b/app/api/minecraft/export/[userId]/route.ts new file mode 100644 index 0000000..ec0fdc5 --- /dev/null +++ b/app/api/minecraft/export/[userId]/route.ts @@ -0,0 +1,120 @@ +import { NextRequest, NextResponse } from 'next/server' +import { PrismaClient } from '@prisma/client' +import axios from 'axios' + +const prisma = new PrismaClient() + +/** + * Minecraft Export API + * + * Returns minimal twin data for Minecraft mod integration + * No authentication for MVP - can add later + */ +export async function GET( + request: NextRequest, + { params }: { params: { userId: string } } +) { + try { + const { userId } = params + + // Get user data + const user = await prisma.user.findUnique({ + where: { id: userId } + }) + + if (!user) { + return NextResponse.json( + { error: 'Twin not found' }, + { status: 404 } + ) + } + + // Fetch Minecraft skin if username is set + let minecraftSkinUrl = null + if (user.minecraftUsername) { + const mojangProfile = await fetchMinecraftProfile(user.minecraftUsername) + minecraftSkinUrl = mojangProfile?.skinUrl || null + } + + // Return minimal data needed for Minecraft + const twinData = { + twin_id: user.id, + username: user.username || user.name, + name: user.name || 'Unknown', + display_name: user.name || 'Digital Twin', + voice_model_id: user.voiceModelId, + minecraft_username: user.minecraftUsername, + minecraft_skin_url: minecraftSkinUrl, + api_endpoint: `${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/speak`, + created_at: user.createdAt.toISOString() + } + + return NextResponse.json(twinData) + + } catch (error: any) { + console.error('Export API error:', error) + return NextResponse.json( + { error: 'Failed to export twin data', details: error.message }, + { status: 500 } + ) + } +} + +/** + * Fetch Minecraft profile from Mojang API + */ +async function fetchMinecraftProfile(username: string): Promise<{ + uuid: string + username: string + skinUrl: string | null +} | null> { + try { + const uuidResponse = await axios.get( + `https://api.mojang.com/users/profiles/minecraft/${username}`, + { timeout: 5000 } + ) + + if (!uuidResponse.data || !uuidResponse.data.id) { + return null + } + + const uuid = uuidResponse.data.id + + const profileResponse = await axios.get( + `https://sessionserver.mojang.com/session/minecraft/profile/${uuid}`, + { timeout: 5000 } + ) + + if (!profileResponse.data) { + return null + } + + const texturesProperty = profileResponse.data.properties.find( + (prop: any) => prop.name === 'textures' + ) + + if (!texturesProperty) { + return { + uuid, + username: profileResponse.data.name, + skinUrl: null + } + } + + const texturesData = JSON.parse( + Buffer.from(texturesProperty.value, 'base64').toString() + ) + + const skinUrl = texturesData.textures?.SKIN?.url || null + + return { + uuid, + username: profileResponse.data.name, + skinUrl + } + + } catch (error: any) { + console.error('Mojang API error:', error.message) + return null + } +} diff --git a/app/api/minecraft/export/username/[username]/route.ts b/app/api/minecraft/export/username/[username]/route.ts new file mode 100644 index 0000000..108fcc2 --- /dev/null +++ b/app/api/minecraft/export/username/[username]/route.ts @@ -0,0 +1,141 @@ +import { NextRequest, NextResponse } from 'next/server' +import { PrismaClient } from '@prisma/client' +import axios from 'axios' + +const prisma = new PrismaClient() + +/** + * Minecraft Export API - Username Lookup + * + * Returns twin data by username instead of UUID + * Includes Minecraft skin URL if minecraftUsername is set + */ +export async function GET( + request: NextRequest, + { params }: { params: { username: string } } +) { + try { + const { username } = params + + console.log('๐ŸŽฎ Minecraft export request for username:', username) + + // Find user by username (case-insensitive) + const user = await prisma.user.findFirst({ + where: { + OR: [ + { username: { equals: username, mode: 'insensitive' } }, + { name: { equals: username, mode: 'insensitive' } } + ] + } + }) + + if (!user) { + console.log('โŒ User not found:', username) + return NextResponse.json( + { error: 'User not found' }, + { status: 404 } + ) + } + + console.log('โœ… User found:', user.name || user.username) + console.log('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•') + console.log('๐ŸŽค USER VOICE DATA:') + console.log(' voiceModelId:', user.voiceModelId || 'NULL') + console.log(' audioUrl:', user.audioUrl || 'NULL') + console.log('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•') + + // Fetch Minecraft skin if username is set + let minecraftSkinUrl = null + if (user.minecraftUsername) { + console.log('๐ŸŽจ Fetching Minecraft skin for:', user.minecraftUsername) + const mojangProfile = await fetchMinecraftProfile(user.minecraftUsername) + minecraftSkinUrl = mojangProfile?.skinUrl || null + console.log('๐ŸŽจ Skin URL:', minecraftSkinUrl || 'None (will use Steve)') + } + + // Return twin data + const twinData = { + twin_id: user.id, + username: user.username || user.name, + name: user.name || 'Unknown', + display_name: user.name || 'Digital Twin', + voice_model_id: user.voiceModelId, + minecraft_username: user.minecraftUsername, + minecraft_skin_url: minecraftSkinUrl, + api_endpoint: `${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/speak`, + created_at: user.createdAt.toISOString() + } + + console.log('โœ… Returning twin data with voice_model_id:', twinData.voice_model_id || 'NULL') + return NextResponse.json(twinData) + + } catch (error: any) { + console.error('โŒ Export API error:', error) + return NextResponse.json( + { error: 'Failed to export twin data', details: error.message }, + { status: 500 } + ) + } +} + +/** + * Fetch Minecraft profile from Mojang API + */ +async function fetchMinecraftProfile(username: string): Promise<{ + uuid: string + username: string + skinUrl: string | null +} | null> { + try { + // Step 1: Get UUID from username + const uuidResponse = await axios.get( + `https://api.mojang.com/users/profiles/minecraft/${username}`, + { timeout: 5000 } + ) + + if (!uuidResponse.data || !uuidResponse.data.id) { + return null + } + + const uuid = uuidResponse.data.id + + // Step 2: Get profile with skin data + const profileResponse = await axios.get( + `https://sessionserver.mojang.com/session/minecraft/profile/${uuid}`, + { timeout: 5000 } + ) + + if (!profileResponse.data) { + return null + } + + // Step 3: Decode skin URL from base64 textures + const texturesProperty = profileResponse.data.properties.find( + (prop: any) => prop.name === 'textures' + ) + + if (!texturesProperty) { + return { + uuid, + username: profileResponse.data.name, + skinUrl: null + } + } + + const texturesData = JSON.parse( + Buffer.from(texturesProperty.value, 'base64').toString() + ) + + const skinUrl = texturesData.textures?.SKIN?.url || null + + return { + uuid, + username: profileResponse.data.name, + skinUrl + } + + } catch (error: any) { + console.error('Mojang API error:', error.message) + return null + } +} diff --git a/app/api/personality/route.ts b/app/api/personality/route.ts index 7f2b118..db79573 100644 --- a/app/api/personality/route.ts +++ b/app/api/personality/route.ts @@ -13,14 +13,93 @@ const prisma = new PrismaClient() * 3. Stores personality traits for future conversations */ +/** + * GET - Fetch user's personality data and profile info + */ +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url) + const userId = searchParams.get('userId') + + if (!userId) { + return NextResponse.json({ error: 'User ID required' }, { status: 400 }) + } + + console.log('๐Ÿ” GET /api/personality - userId:', userId) + console.log(' Type:', typeof userId, '| Length:', userId.length) + + // Validate UUID format (8-4-4-4-12 hex characters) + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + if (!uuidRegex.test(userId)) { + console.error('โŒ Invalid UUID format received:', userId) + console.error(' First 20 chars:', userId.substring(0, 20)) + return NextResponse.json( + { + error: 'Invalid user ID format', + details: `Expected UUID, got: ${userId.substring(0, 50)}`, + hint: 'Check that clone.userId (not username) is being passed' + }, + { status: 400 } + ) + } + + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { + personalityData: true, + audioUrl: true, + voiceModelId: true, + faceData: true, + name: true, + email: true, + createdAt: true, + isPublic: true, + username: true, + bio: true + } + }) + + if (!user) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }) + } + + return NextResponse.json(user) + } catch (error: any) { + console.error('โŒ GET personality error:', error) + return NextResponse.json( + { error: 'Failed to fetch personality', details: error.message }, + { status: 500 } + ) + } +} + +/** + * POST - Save personality data or generate from memories + */ export async function POST(request: NextRequest) { try { - const { userId } = await request.json() + const body = await request.json() + const { userId, personalityData } = body if (!userId) { return NextResponse.json({ error: 'User ID required' }, { status: 400 }) } + // If personalityData is provided, save it directly (from ContextBuilder) + if (personalityData) { + console.log('๐Ÿ’พ Saving personality data for user:', userId) + + await prisma.user.update({ + where: { id: userId }, + data: { personalityData: JSON.stringify(personalityData) } + }) + + return NextResponse.json({ + success: true, + message: 'Personality data saved' + }) + } + // Get user memories const memories = await prisma.memory.findMany({ where: { userId }, @@ -66,6 +145,23 @@ export async function POST(request: NextRequest) { const storiesMem = memories.filter(m => m.category === 'story' || m.category === 'stories').map(m => m.content).join('\n') const habitsMem = memories.filter(m => m.category === 'habit' || m.category === 'habits').map(m => m.content).join('\n') const reactionsMem = memories.filter(m => m.category === 'reaction' || m.category === 'reactions').map(m => m.content).join('\n') + const correctionsMem = memories.filter(m => m.category === 'correction').map(m => m.content).join('\n') + const preferencesMem = memories.filter(m => m.category === 'preference').map(m => m.content).join('\n') + const skillsMem = memories.filter(m => m.category === 'skill').map(m => m.content).join('\n') + const goalsMem = memories.filter(m => m.category === 'goal').map(m => m.content).join('\n') + const valuesMem = memories.filter(m => m.category === 'value').map(m => m.content).join('\n') + const memoriesMem = memories.filter(m => m.category === 'memory').map(m => m.content).join('\n') + + console.log('๐Ÿ“Š Memory counts by category:') + console.log(' Stories:', memories.filter(m => m.category === 'story').length) + console.log(' Habits:', memories.filter(m => m.category === 'habit').length) + console.log(' Reactions:', memories.filter(m => m.category === 'reaction').length) + console.log(' Corrections:', memories.filter(m => m.category === 'correction').length) + console.log(' Preferences:', memories.filter(m => m.category === 'preference').length) + console.log(' Skills:', memories.filter(m => m.category === 'skill').length) + console.log(' Goals:', memories.filter(m => m.category === 'goal').length) + console.log(' Values:', memories.filter(m => m.category === 'value').length) + console.log(' Memories:', memories.filter(m => m.category === 'memory').length) // Store the raw contexts directly (no Claude processing) // This preserves the exact personality traits like "I am always angry!" @@ -73,7 +169,13 @@ export async function POST(request: NextRequest) { stories: storiesMem || 'N/A', habits: habitsMem || 'N/A', reactions: reactionsMem || 'N/A', - background: `This person's stories: ${storiesMem}. Their habits: ${habitsMem}. How they react: ${reactionsMem}` + corrections: correctionsMem || 'N/A', + preferences: preferencesMem || 'N/A', + skills: skillsMem || 'N/A', + goals: goalsMem || 'N/A', + values: valuesMem || 'N/A', + memories: memoriesMem || 'N/A', + background: `This person's stories: ${storiesMem}. Their habits: ${habitsMem}. How they react: ${reactionsMem}. User corrections: ${correctionsMem}. Preferences: ${preferencesMem}. Skills: ${skillsMem}. Goals: ${goalsMem}. Values: ${valuesMem}. Memories: ${memoriesMem}` } console.log('๐Ÿ’พ Storing personality data:', personality) @@ -89,28 +191,20 @@ export async function POST(request: NextRequest) { }) } catch (error: any) { - console.error('Claude API error:', error) - - // Fallback mock personality - const { userId } = await request.json() - const mockPersonality = { - traits: ['curious', 'analytical', 'friendly', 'creative'], - quirks: ['thoughtful pauses', 'uses analogies'], - conversationStyle: 'warm and engaging', - interests: ['technology', 'creativity', 'problem-solving'], - background: 'An individual with diverse interests and experiences' - } - - await prisma.user.update({ - where: { id: userId }, - data: { personalityData: JSON.stringify(mockPersonality) } - }) - - return NextResponse.json({ - personality: mockPersonality, - message: 'Mock personality created (API error)', - error: error.message + console.error('โŒ POST personality error:', error) + console.error(' Error details:', { + message: error.message, + code: error.code, + meta: error.meta }) + return NextResponse.json( + { + error: 'Failed to save personality', + details: error.message, + code: error.code + }, + { status: 500 } + ) } } diff --git a/app/api/refresh-personality/route.ts b/app/api/refresh-personality/route.ts new file mode 100644 index 0000000..a9a99db --- /dev/null +++ b/app/api/refresh-personality/route.ts @@ -0,0 +1,80 @@ +import { NextRequest, NextResponse } from 'next/server' +import { PrismaClient } from '@prisma/client' + +const prisma = new PrismaClient() + +/** + * POST /api/refresh-personality + * Force refresh personality data from memories + * Useful when context seems stale or incorrect + */ +export async function POST(request: NextRequest) { + try { + const { userId } = await request.json() + + if (!userId) { + return NextResponse.json({ error: 'User ID required' }, { status: 400 }) + } + + console.log('๐Ÿ”„ Force refreshing personality for user:', userId) + + // Get all memories + const memories = await prisma.memory.findMany({ + where: { userId }, + orderBy: { createdAt: 'desc' } + }) + + console.log(`๐Ÿ“ Found ${memories.length} memories`) + + // Group by category + const byCategory: Record = {} + memories.forEach(m => { + const cat = m.category || 'other' + if (!byCategory[cat]) byCategory[cat] = [] + byCategory[cat].push(m.content) + }) + + // Build personality object + const personality = { + stories: (byCategory.story || []).join('\n\n'), + habits: (byCategory.habit || []).join('\n\n'), + reactions: (byCategory.reaction || []).join('\n\n'), + background: [ + ...(byCategory.preference || []), + ...(byCategory.skill || []), + ...(byCategory.memory || []), + ...(byCategory.goal || []), + ...(byCategory.value || []), + ...(byCategory.other || []) + ].join('\n\n') + } + + console.log('๐Ÿ’พ Updating personality data...') + console.log(' Stories length:', personality.stories.length) + console.log(' Habits length:', personality.habits.length) + console.log(' Reactions length:', personality.reactions.length) + + // Update user + await prisma.user.update({ + where: { id: userId }, + data: { + personalityData: JSON.stringify(personality) + } + }) + + console.log('โœ… Personality refreshed successfully') + + return NextResponse.json({ + success: true, + message: 'Personality data refreshed', + memoriesFound: memories.length + }) + } catch (error: any) { + console.error('โŒ Error refreshing personality:', error) + return NextResponse.json( + { error: 'Failed to refresh personality', details: error.message }, + { status: 500 } + ) + } +} + diff --git a/app/api/save-consent/route.ts b/app/api/save-consent/route.ts new file mode 100644 index 0000000..33cd207 --- /dev/null +++ b/app/api/save-consent/route.ts @@ -0,0 +1,87 @@ +import { NextRequest, NextResponse } from 'next/server' +import { PrismaClient } from '@prisma/client' +import { createRouteHandlerClient } from '@/lib/supabase-server' + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' + +const prisma = new PrismaClient() + +/** + * Save user consent preferences + */ +export async function POST(request: NextRequest) { + try { + // Get authenticated user + const { supabase } = createRouteHandlerClient(request) + const { data: { user: authUser }, error: authError } = await supabase.auth.getUser() + + if (authError || !authUser) { + console.error('Auth error in save-consent:', authError) + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ) + } + + const { audio, chat, context, faceData } = await request.json() + + console.log('๐Ÿ’พ Saving consent for user:', authUser.id) + console.log(' Consent:', { audio, chat, context, faceData }) + + // Create or update user with consent preferences + const user = await prisma.user.upsert({ + where: { id: authUser.id }, + update: { + consentAudio: audio, + consentChat: chat, + consentContext: context, + consentFaceData: faceData, + consentTimestamp: new Date() + }, + create: { + id: authUser.id, + email: authUser.email, + name: authUser.user_metadata?.name || null, + username: authUser.user_metadata?.username, + photoUrls: '[]', + consentAudio: audio, + consentChat: chat, + consentContext: context, + consentFaceData: faceData, + consentTimestamp: new Date() + } + }) + + console.log('โœ… Consent saved successfully') + + return NextResponse.json({ + success: true, + consent: { + audio: user.consentAudio, + chat: user.consentChat, + context: user.consentContext, + faceData: user.consentFaceData, + timestamp: user.consentTimestamp + } + }) + } catch (error: any) { + console.error('โŒ Save consent error:', error) + console.error(' Error details:', { + message: error.message, + code: error.code, + meta: error.meta, + stack: error.stack + }) + return NextResponse.json( + { + error: 'Failed to save consent', + details: error.message, + code: error.code, + meta: error.meta + }, + { status: 500 } + ) + } +} + diff --git a/app/api/speak/route.ts b/app/api/speak/route.ts index 36c80a4..80e7761 100644 --- a/app/api/speak/route.ts +++ b/app/api/speak/route.ts @@ -2,8 +2,9 @@ import { NextRequest, NextResponse } from 'next/server' import { PrismaClient } from '@prisma/client' import Anthropic from '@anthropic-ai/sdk' import axios from 'axios' -import { writeFile, readFile } from 'fs/promises' +import { writeFile, readFile, mkdir } from 'fs/promises' import { join } from 'path' +import { createRouteHandlerClient } from '@/lib/supabase-server' const prisma = new PrismaClient() @@ -22,7 +23,7 @@ export async function POST(request: NextRequest) { try { const body = await request.json() console.log('๐Ÿ“ฅ Request body:', { userId: body.userId, message: body.message?.substring(0, 50) }) - + const { userId, message, conversationHistory = [] } = body if (!userId || !message) { @@ -33,20 +34,96 @@ export async function POST(request: NextRequest) { ) } + // AUTHENTICATION: Support Supabase session, API key, or browsing public clones + const apiKey = request.headers.get('X-API-Key') + const validApiKey = process.env.MINECRAFT_API_KEY + + // Try Supabase authentication first (for web users) + let isAuthenticated = false + let authUserId: string | null = null + try { + const { supabase } = createRouteHandlerClient(request) + const { data: { user: authUser }, error } = await supabase.auth.getUser() + + if (authUser) { + authUserId = authUser.id + // Allow authenticated users to chat with their own clone + if (authUser.id === userId) { + console.log('โœ… Authenticated via Supabase session (own clone)') + isAuthenticated = true + } else { + // Check if target user's clone is public + const targetUser = await prisma.user.findUnique({ + where: { id: userId }, + select: { isPublic: true } + }) + if (targetUser?.isPublic) { + console.log('โœ… Authenticated user browsing public clone') + isAuthenticated = true + } + } + } + } catch (authError) { + console.log('โš ๏ธ Supabase auth check failed, trying API key...') + } + + // Fallback to API key authentication (for Minecraft mod) + if (!isAuthenticated && apiKey && validApiKey && apiKey === validApiKey) { + console.log('โœ… Authenticated via API key') + isAuthenticated = true + } + + // If neither authentication method worked, reject the request + if (!isAuthenticated) { + console.error('โŒ Authentication failed') + return NextResponse.json( + { + error: 'Unauthorized', + details: 'You must be authenticated to use this endpoint. Either log in or provide a valid API key.' + }, + { status: 401 } + ) + } + // Get user data console.log('๐Ÿ” Looking up user:', userId) - const user = await prisma.user.findUnique({ where: { id: userId } }) + console.log(' Full user ID:', userId) + + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { + id: true, + voiceModelId: true, + audioUrl: true, + personalityData: true, + name: true, + email: true, + username: true + } + }) + if (!user) { console.error('โŒ User not found:', userId) return NextResponse.json({ error: 'User not found' }, { status: 404 }) } + console.log('โœ… User found') + console.log(' User name:', user.name || user.username) + console.log(' User email:', user.email) + console.log('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•') + console.log('๐ŸŽค VOICE MODEL CHECK FROM DATABASE:') + console.log(' voiceModelId:', user.voiceModelId || 'NULL') + console.log(' audioUrl:', user.audioUrl || 'NULL') + console.log(' voiceModelId type:', typeof user.voiceModelId) + console.log(' voiceModelId length:', user.voiceModelId?.length || 0) + console.log(' First 50 chars:', user.voiceModelId?.substring(0, 50) || 'N/A') + console.log('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•') // Check for keyword commands to update context const lowerMessage = message.toLowerCase() - + // Handle "i have new stories:" or similar context updates - if (lowerMessage.includes('i have new stor') || lowerMessage.includes('new story:') || + if (lowerMessage.includes('i have new stor') || lowerMessage.includes('new story:') || lowerMessage.includes('i have new habit') || lowerMessage.includes('new habit:') || lowerMessage.includes('i have new reaction') || lowerMessage.includes('new reaction:')) { @@ -68,8 +145,13 @@ export async function POST(request: NextRequest) { }) // Reprocess personality in background + // Use Railway domain for internal API calls (works on both localhost and Railway) + const baseUrl = process.env.RAILWAY_PUBLIC_DOMAIN + ? `https://${process.env.RAILWAY_PUBLIC_DOMAIN}` + : 'http://localhost:3000' + setTimeout(() => { - fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/personality`, { + fetch(baseUrl + '/api/personality', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userId }) @@ -84,9 +166,16 @@ export async function POST(request: NextRequest) { } // Get personality data + console.log('๐ŸŽญ Loading personality data...') + console.log(' Raw personalityData:', user.personalityData ? 'Present' : 'NULL') const personality = user.personalityData ? JSON.parse(user.personalityData) : null + console.log(' Parsed personality:', personality ? 'Present' : 'NULL') + if (personality) { + console.log(' Personality keys:', Object.keys(personality)) + console.log(' Personality sample:', JSON.stringify(personality).substring(0, 200)) + } // Query relevant memories with timeout console.log('๐Ÿง  Querying memories...') @@ -95,7 +184,14 @@ export async function POST(request: NextRequest) { const controller = new AbortController() const timeoutId = setTimeout(() => controller.abort(), 3000) // 3 second timeout - const memoryResponse = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/memory`, { + // Use Railway domain for internal API calls + const baseUrl = process.env.RAILWAY_PUBLIC_DOMAIN + ? `https://${process.env.RAILWAY_PUBLIC_DOMAIN}` + : 'http://localhost:3000' + + console.log(' Memory API URL:', baseUrl + '/api/memory') + + const memoryResponse = await fetch(baseUrl + '/api/memory', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -156,11 +252,17 @@ Remember: You ARE them based on what THEY told you about themselves, not based o // Generate response with Claude console.log('๐Ÿค– Generating Claude response...') + console.log(' Personality prompt length:', personalityPrompt.length) + console.log(' Using personality:', personality ? 'YES' : 'NO (default prompt)') + console.log(' Memory context length:', memoryContext.length) + console.log(' Has memories:', memories.length > 0) + const responseText = await generateResponse( message, conversationHistory, personalityPrompt, - memoryContext + memoryContext, + user.name || user.username || 'User' ) console.log('โœ… Claude response generated:', responseText.substring(0, 100)) @@ -186,6 +288,28 @@ Remember: You ARE them based on what THEY told you about themselves, not based o audioUrl } }) + + // Store in memory asynchronously (don't block response) + if (message.length > 50) { + console.log('๐Ÿ’พ Storing in memory (background)...') + // Use Railway domain for internal API calls + const baseUrl = process.env.RAILWAY_PUBLIC_DOMAIN + ? `https://${process.env.RAILWAY_PUBLIC_DOMAIN}` + : 'http://localhost:3000' + + const memoryContent = 'User asked: ' + message + '. Response: ' + responseText + fetch(baseUrl + '/api/memory', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + userId, + action: 'add', + content: memoryContent + }) + }) + .then(() => console.log('โœ… Stored in memory')) + .catch(err => console.warn('โš ๏ธ Memory storage failed:', err)) + } console.log('๐ŸŽ‰ Response complete!') const responseData = { @@ -195,22 +319,6 @@ Remember: You ARE them based on what THEY told you about themselves, not based o } console.log('๐Ÿ“ค Sending to frontend:', JSON.stringify(responseData).substring(0, 200)) - // Store in memory asynchronously (don't block response) - if (message.length > 50) { - console.log('๐Ÿ’พ Storing in memory (background)...') - fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/memory`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - userId, - action: 'add', - content: `User asked: ${message}. Response: ${responseText}` - }) - }) - .then(() => console.log('โœ… Stored in memory')) - .catch(err => console.warn('โš ๏ธ Memory storage failed:', err)) - } - return NextResponse.json(responseData) } catch (error: any) { @@ -231,7 +339,8 @@ async function generateResponse( message: string, conversationHistory: any[], personalityPrompt: string, - memoryContext: string + memoryContext: string, + userName: string ): Promise { const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY @@ -260,31 +369,40 @@ async function generateResponse( content: message }) - const systemPrompt = `${personalityPrompt} + const systemPrompt = `YOUR NAME IS: ${userName} + +You ARE ${userName} responding naturally in a conversation. Here's what you know about yourself: -${memoryContext ? `Relevant context from past conversations:\n${memoryContext}\n` : ''} +${personalityPrompt} -CRITICAL INSTRUCTIONS - YOU ARE THIS PERSON, NOT AN AI ASSISTANT: -- You ARE this person. Respond EXACTLY as they would based on the context THEY provided. -- This is NOT a roleplay - you ARE them. Their personality is YOUR personality. -- Match their EXACT tone, mood, vocabulary, quirks from THEIR actual context. -- ONLY adopt negative traits (anger/cynicism/sarcasm/pessimism) if THEY explicitly described themselves that way. -- ONLY adopt positive traits (optimism/enthusiasm/energy) if THEY explicitly described themselves that way. -- If they didn't specify a mood/tone, be NEUTRAL and conversational (balanced, not extreme in any direction). -- DO NOT add AI disclaimers or explanations - you're having a natural conversation as them. -- Use their speech patterns, slang, and expressions naturally. -- Keep responses 2-4 sentences, conversational and authentic. -- NO PROFANITY - express strong emotions through tone and actions (*scoffs*, *groans*, *sighs*). -- Mirror what THEY actually said about themselves, not assumptions. +${memoryContext ? `Background context (reference when relevant, don't dump all of it):\n${memoryContext}\n` : ''} -Remember: You're based on what they TOLD you, not stereotypes or assumptions.` +HOW TO RESPOND NATURALLY: +1. Keep responses SHORT (1-2 sentences unless asked for details) +2. ONLY mention specific context when directly relevant to the question + - "Hi" โ†’ Just greet back naturally with your personality/mood + - "How are you?" โ†’ Brief status that matches your emotional baseline + - "Tell me about X" โ†’ THEN you can elaborate on that specific topic + - "What's your name?" โ†’ "I'm ${userName}" (use your actual name!) +3. Use context to inform your TONE and PERSONALITY, not to recite your life story +4. Be conversational and natural - don't try to demonstrate all your knowledge at once +5. Match your emotional baseline from your context (calm/energetic/pessimistic/etc) +6. Use your vocabulary and speech patterns, but don't force them +7. NO AI disclaimers - you're just chatting as yourself + +Example responses: +- Input: "Hey" โ†’ "Hey! *smiles* What's up?" (not a life story) +- Input: "What do you do?" โ†’ "I'm into [topic from context]" (brief, natural) +- Input: "Tell me about your hobbies" โ†’ NOW elaborate on relevant hobbies from context + +Remember: Be authentic, brief, and only elaborate when asked. Context is for reference, not recitation.` console.log('๐Ÿ“ค Calling Claude API...') console.log(' Messages count:', messages.length) const response = await anthropic.messages.create({ model: 'claude-3-haiku-20240307', - max_tokens: 300, + max_tokens: 150, // Shorter to encourage brief responses system: systemPrompt, messages: messages as any }) @@ -456,30 +574,88 @@ async function generateVoice( console.log('๐Ÿงน Cleaned text:', cleanedText.substring(0, 100)) try { - console.log('๐Ÿ“ค Calling Fish Audio TTS with trained model...') - - // Get user's voice model ID - const user = await prisma.user.findUnique({ where: { id: userId } }) - - // Determine which reference to use - let referenceId = '802e3bc2b27e49c2995d23ef70e6ac89' // Default voice - - if (voiceModelId && !voiceModelId.startsWith('mock_')) { - // Use the trained S1 model (HIGH QUALITY) - referenceId = voiceModelId - console.log('โœ… Using trained S1 voice model:', referenceId.substring(0, 20)) - } else { - console.log('โš ๏ธ Using default voice (no trained model yet)') - } - - // Create TTS request with trained model + console.log('๐Ÿ“ค Preparing Fish Audio TTS payload...') + + const DEFAULT_VOICE_ID = process.env.FISH_DEFAULT_VOICE_ID || 'af1ddb5dc0e644ebb16b58ed466e27c6' + const DEFAULT_REFERENCE_TEXT = process.env.FISH_REFERENCE_TEXT || + 'I walk through the park every morning before work. The trees sway gently in the breeze, and birds sing their morning songs. Sometimes I stop to watch a squirrel gather nuts or see dew glistening on spider webs. These quiet moments help me start my day with a clear mind.' + + const userRecord = await prisma.user.findUnique({ + where: { id: userId }, + select: { + audioUrl: true, + voiceModelId: true, + name: true + } + }) + + console.log(' DB voiceModelId:', userRecord?.voiceModelId || 'NULL') + console.log(' DB audioUrl:', userRecord?.audioUrl || 'NULL') + + const trainedVoiceId = (() => { + const candidate = voiceModelId || userRecord?.voiceModelId || null + if (candidate && !candidate.startsWith('mock_')) { + return candidate + } + return null + })() + const FormData = require('form-data') const formData = new FormData() - - formData.append('text', cleanedText) // Use cleaned text - formData.append('reference_id', referenceId) // Trained model ID or default + + formData.append('text', cleanedText) formData.append('format', 'mp3') - + + let usingReferenceAudio = false + + if (trainedVoiceId) { + console.log('โœ… Using trained voice model for TTS:', trainedVoiceId.substring(0, 20)) + formData.append('reference_id', trainedVoiceId) // FIXED: Use reference_id for trained models + } else { + console.log('โš ๏ธ No trained voice model detected, attempting on-the-fly cloning') + + const audioSource = userRecord?.audioUrl || null + + if (audioSource) { + try { + console.log('๐ŸŽค Fetching reference audio for cloning...') + let referenceBuffer: Buffer | null = null + + if (audioSource.startsWith('http')) { + const download = await axios.get(audioSource, { + responseType: 'arraybuffer', + timeout: 15000 + }) + referenceBuffer = Buffer.from(download.data) + } else { + const localPath = join(process.cwd(), 'public', audioSource) + referenceBuffer = await readFile(localPath) + } + + if (referenceBuffer && referenceBuffer.length > 0) { + formData.append('reference_audio', referenceBuffer, { + filename: 'reference.webm', + contentType: 'audio/webm' + }) + formData.append('reference_text', DEFAULT_REFERENCE_TEXT) + usingReferenceAudio = true + console.log('โœ… Added reference audio for on-the-fly cloning (bytes:', referenceBuffer.length, ')') + } else { + console.warn('โš ๏ธ Reference audio buffer empty, skipping cloning payload') + } + } catch (referenceError: any) { + console.warn('โš ๏ธ Failed to load reference audio:', referenceError?.message || referenceError) + } + } else { + console.log('โš ๏ธ No stored reference audio found for user; using fallback voice') + } + + if (!usingReferenceAudio) { + console.log('๐ŸŽ™๏ธ Falling back to default Fish voice ID:', DEFAULT_VOICE_ID) + formData.append('voice_id', DEFAULT_VOICE_ID) + } + } + const response = await axios.post( 'https://api.fish.audio/v1/tts', formData, @@ -493,18 +669,66 @@ async function generateVoice( } ) - // Save audio file - const filename = `response_${Date.now()}.mp3` - const uploadDir = join(process.cwd(), 'public', 'uploads', userId) - const audioPath = join(uploadDir, filename) + // Upload audio to Supabase Storage + const timestamp = Date.now() + const filename = `${userId}/response_${timestamp}.mp3` + const audioBuffer = Buffer.from(response.data) + + console.log('๐Ÿ“ค Uploading audio to Supabase Storage...') + console.log(' Filename:', filename) + console.log(' Size:', audioBuffer.length, 'bytes') + + // Check if Supabase is configured + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL + const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY + + if (!supabaseUrl || !serviceRoleKey) { + console.warn('โš ๏ธ Supabase not configured - saving to local filesystem') + const uploadDir = join(process.cwd(), 'public', 'uploads', userId) + const audioPath = join(uploadDir, `response_${timestamp}.mp3`) + await mkdir(uploadDir, { recursive: true }) + await writeFile(audioPath, audioBuffer) + return `/uploads/${userId}/response_${timestamp}.mp3` + } - await writeFile(audioPath, Buffer.from(response.data)) + // Create admin Supabase client (bypasses RLS) + const { createClient } = await import('@supabase/supabase-js') + const supabase = createClient(supabaseUrl, serviceRoleKey, { + auth: { + autoRefreshToken: false, + persistSession: false + } + }) + + // Upload to Supabase Storage + const { data, error } = await supabase.storage + .from('audio-recordings') + .upload(filename, audioBuffer, { + contentType: 'audio/mpeg', + upsert: true, + }) - return `/uploads/${userId}/${filename}` + if (error) { + console.error('โŒ Supabase Storage error:', error) + throw new Error(`Failed to upload audio: ${error.message}`) + } + + // Get public URL + const { data: urlData } = supabase.storage + .from('audio-recordings') + .getPublicUrl(filename) + + console.log('โœ… Audio uploaded to Supabase:', urlData.publicUrl) + return urlData.publicUrl } catch (error: any) { - console.error('Fish Audio TTS error:', error.response?.data || error.message) + console.error('โŒ Fish Audio TTS error:', error) + console.error(' Status:', error.response?.status) + console.error(' Data:', error.response?.data) + console.error(' Message:', error.message) + + // Return empty string - will fallback to browser TTS + console.warn('โš ๏ธ Falling back to browser TTS') return '' } } - diff --git a/app/api/toggle-public/route.ts b/app/api/toggle-public/route.ts new file mode 100644 index 0000000..7396f12 --- /dev/null +++ b/app/api/toggle-public/route.ts @@ -0,0 +1,38 @@ +import { NextRequest, NextResponse } from 'next/server' +import { PrismaClient } from '@prisma/client' + +const prisma = new PrismaClient() + +/** + * POST /api/toggle-public + * Toggle whether a user's clone is publicly searchable + */ +export async function POST(request: NextRequest) { + try { + const { userId, isPublic } = await request.json() + + if (!userId) { + return NextResponse.json({ error: 'User ID required' }, { status: 400 }) + } + + // Update user's public status + const user = await prisma.user.update({ + where: { id: userId }, + data: { isPublic } + }) + + console.log(`โœ… User ${userId} isPublic set to: ${isPublic}`) + + return NextResponse.json({ + success: true, + isPublic: user.isPublic + }) + } catch (error: any) { + console.error('โŒ Error toggling public status:', error) + return NextResponse.json( + { error: 'Failed to update public status', details: error.message }, + { status: 500 } + ) + } +} + diff --git a/app/api/update-user/route.ts b/app/api/update-user/route.ts index 5b4bb0e..bf22bda 100644 --- a/app/api/update-user/route.ts +++ b/app/api/update-user/route.ts @@ -11,7 +11,7 @@ const prisma = new PrismaClient() export async function POST(request: NextRequest) { try { const body = await request.json() - const { userId, faceContours, contexts } = body + const { userId, faceContours, contexts, photoUrl } = body if (!userId) { return NextResponse.json( @@ -32,8 +32,9 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'User not found' }, { status: 404 }) } - console.log('๐ŸŽญ Updating user with face model and contexts:', userId) + console.log('๐ŸŽญ Updating user with face model, photo, and contexts:', userId) console.log(` Received ${faceContours.length} face contours`) + console.log(` Profile photo URL: ${photoUrl || 'None'}`) // Log sample contour data to verify it's not defaulting const jawline = faceContours.find((c: any) => c.name === 'jawline') @@ -70,21 +71,29 @@ export async function POST(request: NextRequest) { console.error(' โš ๏ธ No hair contour found!') } - // Store face contours in database + // Store face contours and photo URL in database await prisma.user.update({ where: { id: userId }, data: { faceData: JSON.stringify({ contours: faceContours }), + photoUrls: photoUrl ? JSON.stringify([photoUrl]) : null, } }) - console.log('โœ… Face model stored in database') + console.log('โœ… Face model and photo stored in database') // Store contexts as memories + console.log('๐Ÿ’พ Storing initial contexts as memories...') + console.log(' Contexts object:', contexts) + console.log(' Contexts keys:', contexts ? Object.keys(contexts) : 'NULL') + if (contexts) { + let savedCount = 0 for (const [category, content] of Object.entries(contexts)) { + console.log(` Processing "${category}":`, content ? `${(content as string).length} chars` : 'EMPTY') + if (content && typeof content === 'string' && content.trim()) { - await prisma.memory.create({ + const memory = await prisma.memory.create({ data: { userId: userId, content: content as string, @@ -92,16 +101,27 @@ export async function POST(request: NextRequest) { embedding: '', } }) + console.log(` โœ… Saved "${category}" as memory:`, memory.id.substring(0, 20)) + savedCount++ + } else { + console.log(` โš ๏ธ Skipped "${category}" (empty or invalid)`) } } - console.log('โœ… Contexts stored as memories') + console.log(`โœ… Stored ${savedCount} contexts as memories`) + } else { + console.log('โš ๏ธ No contexts provided!') } console.log('โœ… User updated with face model and contexts') // Process personality in background + // Use relative URL for internal API calls (works on both localhost and Railway) setTimeout(() => { - fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/personality`, { + const baseUrl = process.env.RAILWAY_PUBLIC_DOMAIN + ? `https://${process.env.RAILWAY_PUBLIC_DOMAIN}` + : 'http://localhost:3000' + + fetch(`${baseUrl}/api/personality`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userId }) diff --git a/app/api/upload-photo/route.ts b/app/api/upload-photo/route.ts new file mode 100644 index 0000000..95a59c2 --- /dev/null +++ b/app/api/upload-photo/route.ts @@ -0,0 +1,70 @@ +import { NextRequest, NextResponse } from 'next/server' +import { createClient } from '@supabase/supabase-js' + +export async function POST(request: NextRequest) { + try { + const formData = await request.formData() + const photo = formData.get('photo') as File + const userId = formData.get('userId') as string + + if (!photo || !userId) { + return NextResponse.json({ error: 'Photo and userId required' }, { status: 400 }) + } + + console.log('๐Ÿ“ธ Uploading profile photo for user:', userId) + console.log(' Photo name:', photo.name) + console.log(' Photo size:', photo.size, 'bytes') + console.log(' Photo type:', photo.type) + + // Check if Supabase is configured + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL + const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY + + if (!supabaseUrl || !serviceRoleKey) { + console.error('โŒ Supabase not configured') + return NextResponse.json({ error: 'Storage not configured' }, { status: 500 }) + } + + // Create admin Supabase client (bypasses RLS) + const supabase = createClient(supabaseUrl, serviceRoleKey, { + auth: { + autoRefreshToken: false, + persistSession: false + } + }) + + // Convert File to Buffer + const arrayBuffer = await photo.arrayBuffer() + const buffer = Buffer.from(arrayBuffer) + + // Upload to Supabase Storage + const filename = `${userId}/profile.jpg` + const { data, error } = await supabase.storage + .from('user-photos') + .upload(filename, buffer, { + contentType: photo.type, + upsert: true, + }) + + if (error) { + console.error('โŒ Supabase Storage error:', error) + return NextResponse.json({ error: `Failed to upload photo: ${error.message}` }, { status: 500 }) + } + + // Get public URL + const { data: urlData } = supabase.storage + .from('user-photos') + .getPublicUrl(filename) + + console.log('โœ… Profile photo uploaded:', urlData.publicUrl) + + return NextResponse.json({ photoUrl: urlData.publicUrl }) + } catch (error: any) { + console.error('โŒ Upload photo error:', error) + return NextResponse.json( + { error: 'Failed to upload photo', details: error.message }, + { status: 500 } + ) + } +} + diff --git a/app/api/upload/route.ts b/app/api/upload/route.ts index 022a5ff..97ae473 100644 --- a/app/api/upload/route.ts +++ b/app/api/upload/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from 'next/server' import { PrismaClient } from '@prisma/client' -import { createServerSupabaseClient } from '@/lib/supabase-server' +import { createRouteHandlerClient } from '@/lib/supabase-server' import { uploadPhotos } from '@/lib/storage' const prisma = new PrismaClient() @@ -8,10 +8,11 @@ const prisma = new PrismaClient() export async function POST(request: NextRequest) { try { // Get authenticated user from Supabase - const supabase = createServerSupabaseClient() + const { supabase } = createRouteHandlerClient(request) const { data: { user: authUser }, error: authError } = await supabase.auth.getUser() if (authError || !authUser) { + console.error('Auth error in upload:', authError) return NextResponse.json( { error: 'Unauthorized - please log in' }, { status: 401 } @@ -38,7 +39,7 @@ export async function POST(request: NextRequest) { ) } - // Upload photos to Supabase Storage + // Upload photos to Supabase Storage (uses service role key internally) console.log('๐Ÿ“ค Uploading photos to Supabase Storage...') const photoUrls = await uploadPhotos(authUser.id, photoBlobs) console.log('โœ… Photos uploaded:', photoUrls) diff --git a/app/api/user-consent/route.ts b/app/api/user-consent/route.ts new file mode 100644 index 0000000..a2e4be2 --- /dev/null +++ b/app/api/user-consent/route.ts @@ -0,0 +1,59 @@ +import { NextRequest, NextResponse } from 'next/server' +import { PrismaClient } from '@prisma/client' +import { createRouteHandlerClient } from '@/lib/supabase-server' + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' + +const prisma = new PrismaClient() + +/** + * Check if user has given consent + */ +export async function GET(request: NextRequest) { + try { + // Get authenticated user from Supabase + const { supabase } = createRouteHandlerClient(request) + const { data: { user: authUser }, error: authError } = await supabase.auth.getUser() + + if (authError || !authUser) { + console.error('Auth error in user-consent:', authError) + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ) + } + + // Check if user exists and has consent + const user = await prisma.user.findUnique({ + where: { id: authUser.id }, + select: { + consentAudio: true, + consentChat: true, + consentContext: true, + consentFaceData: true, + consentTimestamp: true + } + }) + + // If user doesn't exist yet or no consent timestamp, they haven't consented + const hasConsent = user && user.consentTimestamp !== null + + return NextResponse.json({ + hasConsent, + consent: user || { + consentAudio: false, + consentChat: false, + consentContext: false, + consentFaceData: false + } + }) + } catch (error: any) { + console.error('Check consent error:', error) + return NextResponse.json( + { error: 'Failed to check consent', details: error.message }, + { status: 500 } + ) + } +} + diff --git a/app/api/user/minecraft-username/route.ts b/app/api/user/minecraft-username/route.ts new file mode 100644 index 0000000..02a7599 --- /dev/null +++ b/app/api/user/minecraft-username/route.ts @@ -0,0 +1,121 @@ +import { NextRequest, NextResponse } from 'next/server' +import { PrismaClient } from '@prisma/client' +import axios from 'axios' + +const prisma = new PrismaClient() + +/** + * Save or update user's Minecraft username + * Also validates username with Mojang API + */ +export async function POST(request: NextRequest) { + try { + const { userId, minecraftUsername } = await request.json() + + if (!userId || !minecraftUsername) { + return NextResponse.json( + { error: 'userId and minecraftUsername required' }, + { status: 400 } + ) + } + + // Validate Minecraft username exists + const mojangProfile = await fetchMinecraftProfile(minecraftUsername) + + if (!mojangProfile) { + return NextResponse.json( + { error: 'Minecraft username not found' }, + { status: 404 } + ) + } + + // Update user record + const user = await prisma.user.update({ + where: { id: userId }, + data: { minecraftUsername } + }) + + return NextResponse.json({ + success: true, + minecraftUsername, + skinUrl: mojangProfile.skinUrl, + uuid: mojangProfile.uuid + }) + + } catch (error: any) { + console.error('Minecraft username update error:', error) + return NextResponse.json( + { error: 'Failed to update Minecraft username', details: error.message }, + { status: 500 } + ) + } +} + +/** + * Fetch Minecraft profile from Mojang API + */ +async function fetchMinecraftProfile(username: string): Promise<{ + uuid: string + username: string + skinUrl: string | null +} | null> { + try { + console.log('๐ŸŽฎ Fetching Minecraft profile for:', username) + + // Step 1: Get UUID from username + const uuidResponse = await axios.get( + `https://api.mojang.com/users/profiles/minecraft/${username}`, + { timeout: 5000 } + ) + + if (!uuidResponse.data || !uuidResponse.data.id) { + console.log('โŒ Minecraft username not found') + return null + } + + const uuid = uuidResponse.data.id + console.log('โœ… Found UUID:', uuid) + + // Step 2: Get profile with skin data + const profileResponse = await axios.get( + `https://sessionserver.mojang.com/session/minecraft/profile/${uuid}`, + { timeout: 5000 } + ) + + if (!profileResponse.data) { + console.log('โŒ Failed to fetch profile') + return null + } + + // Step 3: Decode skin URL from base64 textures + const texturesProperty = profileResponse.data.properties.find( + (prop: any) => prop.name === 'textures' + ) + + if (!texturesProperty) { + console.log('โš ๏ธ No textures property found') + return { + uuid, + username: profileResponse.data.name, + skinUrl: null + } + } + + const texturesData = JSON.parse( + Buffer.from(texturesProperty.value, 'base64').toString() + ) + + const skinUrl = texturesData.textures?.SKIN?.url || null + console.log('โœ… Skin URL:', skinUrl || 'None (using default)') + + return { + uuid, + username: profileResponse.data.name, + skinUrl + } + + } catch (error: any) { + console.error('โŒ Mojang API error:', error.message) + return null + } +} diff --git a/app/api/voice-clone/route.ts b/app/api/voice-clone/route.ts index 0c292ea..de70f8d 100644 --- a/app/api/voice-clone/route.ts +++ b/app/api/voice-clone/route.ts @@ -36,10 +36,22 @@ export async function POST(request: NextRequest) { console.log('โœ… User found, audio URL:', user.audioUrl) - // Read audio file - const audioPath = join(process.cwd(), 'public', user.audioUrl) - const audioBuffer = await readFile(audioPath) - console.log('๐Ÿ“ Audio file size:', audioBuffer.length, 'bytes') + // Read audio file (handle both local paths and remote URLs) + let audioBuffer: Buffer + + if (user.audioUrl.startsWith('http://') || user.audioUrl.startsWith('https://')) { + // Remote URL (Supabase Storage) - download it + console.log('๐ŸŒ Downloading audio from remote URL...') + const response = await axios.get(user.audioUrl, { responseType: 'arraybuffer' }) + audioBuffer = Buffer.from(response.data) + console.log('โœ… Downloaded audio, size:', audioBuffer.length, 'bytes') + } else { + // Local file path + console.log('๐Ÿ“ Reading audio from local filesystem...') + const audioPath = join(process.cwd(), 'public', user.audioUrl) + audioBuffer = await readFile(audioPath) + console.log('โœ… Read audio file, size:', audioBuffer.length, 'bytes') + } // Fish Audio API endpoint const FISH_API_KEY = process.env.FISH_AUDIO_API_KEY @@ -119,6 +131,25 @@ export async function POST(request: NextRequest) { console.log('๐Ÿ’พ Voice model ID saved to database:', modelId) + // PRIVACY: Delete audio file after successful training + // The voice model is now stored by Fish Audio, we don't need the raw audio anymore + console.log('๐Ÿ—‘๏ธ Deleting original audio file for privacy...') + try { + const { deleteAudio } = await import('@/lib/storage') + await deleteAudio(userId) + + // Clear audioUrl from database + await prisma.user.update({ + where: { id: userId }, + data: { audioUrl: null } + }) + + console.log('โœ… Audio file deleted for privacy') + } catch (deleteError) { + console.warn('โš ๏ธ Could not delete audio file:', deleteError) + // Don't fail the request if deletion fails + } + return NextResponse.json({ modelId: modelId, status: 'training', diff --git a/app/auth/callback/route.ts b/app/auth/callback/route.ts new file mode 100644 index 0000000..60a4474 --- /dev/null +++ b/app/auth/callback/route.ts @@ -0,0 +1,84 @@ +import { createServerClient, type CookieOptions } from '@supabase/ssr' +import { NextRequest, NextResponse } from 'next/server' +import { cookies } from 'next/headers' +import { PrismaClient } from '@prisma/client' + +const prisma = new PrismaClient() + +export async function GET(request: NextRequest) { + const requestUrl = new URL(request.url) + const code = requestUrl.searchParams.get('code') + const next = requestUrl.searchParams.get('next') || '/' + + if (code) { + const cookieStore = cookies() + + const supabase = createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY!, + { + cookies: { + getAll() { + return cookieStore.getAll() + }, + setAll(cookiesToSet) { + try { + cookiesToSet.forEach(({ name, value, options }) => + cookieStore.set(name, value, options) + ) + } catch { + // The `setAll` method was called from a Server Component. + // This can be ignored if you have middleware refreshing + // user sessions. + } + }, + }, + } + ) + + const { data, error } = await supabase.auth.exchangeCodeForSession(code) + + if (error) { + console.error('โŒ OAuth callback error:', error) + console.error(' Error details:', error.message, error.status) + // Redirect to error page or home with error + const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || `${requestUrl.protocol}//${requestUrl.host}` + return NextResponse.redirect(new URL('/?error=auth_failed', baseUrl)) + } + + console.log('โœ… OAuth session created successfully') + + // Create or update user in Prisma database (for OAuth users) + if (data.user) { + try { + console.log('๐Ÿ”„ Creating/updating Prisma user record for OAuth user:', data.user.id) + + await prisma.user.upsert({ + where: { id: data.user.id }, + update: { + email: data.user.email, + name: data.user.user_metadata?.name || data.user.user_metadata?.full_name, + username: data.user.user_metadata?.username, + }, + create: { + id: data.user.id, + email: data.user.email || '', + name: data.user.user_metadata?.name || data.user.user_metadata?.full_name, + username: data.user.user_metadata?.username, + photoUrls: '[]', + } + }) + + console.log('โœ… Prisma user record created/updated') + } catch (dbError) { + console.error('โš ๏ธ Failed to create Prisma user record:', dbError) + // Don't fail the auth flow, just log the error + } + } + } + + // Redirect to home page after successful confirmation + const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || `${requestUrl.protocol}//${requestUrl.host}` + return NextResponse.redirect(new URL(next, baseUrl)) +} + diff --git a/app/auth/confirm/page.tsx b/app/auth/confirm/page.tsx new file mode 100644 index 0000000..e4e8c07 --- /dev/null +++ b/app/auth/confirm/page.tsx @@ -0,0 +1,111 @@ +'use client' + +import { useEffect, useState } from 'react' +import { motion } from 'framer-motion' +import { Mail, CheckCircle, XCircle } from 'lucide-react' + +export default function ConfirmEmailPage() { + const [status, setStatus] = useState<'waiting' | 'success' | 'error'>('waiting') + + useEffect(() => { + // Check if user just signed up (from URL params) + const params = new URLSearchParams(window.location.search) + const email = params.get('email') + + if (email) { + setStatus('waiting') + } + }, []) + + return ( +
+ +
+ {status === 'waiting' && ( + <> + +
+ +
+
+ +

+ Check your email +

+ +

+ We've sent you a confirmation link. Click the link in your email to activate your account and start creating your digital twin. +

+ +
+

+ โœ‰๏ธ Didn't receive the email? +

+
    +
  • โ€ข Check your spam/junk folder
  • +
  • โ€ข Make sure you entered the correct email
  • +
  • โ€ข Wait a few minutes and refresh
  • +
+
+ + + โ† Back to home + + + )} + + {status === 'success' && ( + <> +
+ +
+ +

+ Email confirmed! +

+ +

+ Your account is now active. Redirecting... +

+ + )} + + {status === 'error' && ( + <> +
+ +
+ +

+ Confirmation failed +

+ +

+ Something went wrong. Please try signing up again or contact support. +

+ + + โ† Back to home + + + )} +
+
+
+ ) +} + diff --git a/app/layout.tsx b/app/layout.tsx index 99199a7..567d97a 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,10 +1,13 @@ import type { Metadata } from 'next' +import { Inter } from 'next/font/google' import './globals.css' import { Providers } from '@/components/Providers' +const inter = Inter({ subsets: ['latin'] }) + export const metadata: Metadata = { - title: 'GhostJournal - Your AI Clone', - description: 'Create your interactive AI clone', + title: 'Replik - Create Your Digital Clone', + description: 'Build and interact with your personalized AI clone. Train your voice, upload your context, and create a digital version of yourself.', } export default function RootLayout({ @@ -14,7 +17,7 @@ export default function RootLayout({ }) { return ( - + {children} diff --git a/app/minecraft/page.tsx b/app/minecraft/page.tsx new file mode 100644 index 0000000..e8a718b --- /dev/null +++ b/app/minecraft/page.tsx @@ -0,0 +1,231 @@ +'use client' + +import { useState } from 'react' +import { useAuth } from '@/lib/hooks/useAuth' +import { Copy, Check } from 'lucide-react' + +export default function MinecraftPage() { + const { user, isLoading } = useAuth() + const [copiedItem, setCopiedItem] = useState(null) + + if (isLoading) { + return ( +
+

Loading...

+
+ ) + } + + if (!user) { + return ( +
+

Please log in to access Minecraft integration

+
+ ) + } + + const exportUrl = `${window.location.origin}/api/minecraft/export/${user.id}` + + const copyToClipboard = (text: string, item: string) => { + navigator.clipboard.writeText(text) + setCopiedItem(item) + setTimeout(() => setCopiedItem(null), 2000) + } + + const downloadJSON = async () => { + try { + const response = await fetch(exportUrl) + const data = await response.json() + + const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `twin-${user.name || 'data'}.json` + a.click() + } catch (error) { + console.error('Download failed:', error) + alert('Failed to download twin data') + } + } + + const CopyButton = ({ text, item }: { text: string; item: string }) => ( + + ) + + const CommandBlock = ({ command, description, item }: { command: string; description?: string; item: string }) => ( +
+
+ {command} + +
+ {description &&

{description}

} +
+ ) + + return ( +
+
+ {/* Header */} +
+

+ Minecraft Integration +

+

+ Deploy your clone to Minecraft with AI personality and voice +

+
+ + {/* Export Section */} +
+

+ Export Your Clone +

+ +
+
+ +
+ + {exportUrl} + + +
+
+ +
+ + +
+
+
+ + {/* Setup Instructions */} +
+

Setup

+ +

+ Requirements: Minecraft 1.20.1, Fabric Loader, Fabric API, Fabric Language Kotlin +

+ +
    +
  1. 1. Install Fabric Loader and required mods
  2. +
  3. 2. Build and install the Digital Twins mod
  4. +
  5. 3. Launch Minecraft 1.20.1
  6. +
+
+ + {/* Import Clone */} +
+

Import Clone

+ +
+
+

Method 1: URL (Recommended)

+ +
+ +
+

Method 2: JSON File

+ +
+
+
+ + {/* Usage */} +
+

Usage

+ +
+
+

Spawn Clone

+ +
+ +
+

Chat with Clone

+ +

Response includes AI-generated text and voice (3-5s delay)

+
+
+
+ + {/* Commands */} +
+

Commands

+ +
+
+ /twinimport <url-or-path> +

Import clone data

+
+ +
+ /twinlist +

Show all imported clones

+
+ +
+ /twinspawn <name> +

Spawn clone NPC

+
+ +
+ /twin <name> <message> +

Chat with clone

+
+ +
+ /twinremove <name> +

Despawn clone NPC

+
+
+
+ + {/* Troubleshooting */} +
+

Troubleshooting

+ +
+

"Twin not found": Run /twinimport first

+

"Connection failed": Check internet connection and API endpoint

+

"Audio playback failed": Check logs at .minecraft/logs/latest.log

+

Mod doesn't load: Verify all dependencies installed

+
+
+
+
+ ) +} diff --git a/app/page.tsx b/app/page.tsx index 71458be..2271ab8 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,15 +1,33 @@ 'use client' -import { useState } from 'react' +import { useState, useEffect } from 'react' import { motion } from 'framer-motion' +import { Settings as SettingsIcon } from 'lucide-react' import Recorder from '@/components/Recorder' import Uploader from '@/components/Uploader' -import CloneChat from '@/components/CloneChat' +import CloneTabs from '@/components/CloneTabs' +import Dashboard from '@/components/Dashboard' +import Settings from '@/components/Settings' +import CloneBrowser from '@/components/CloneBrowser' import LandingPage from '@/components/LandingPage' +import ConsentDialog from '@/components/ConsentDialog' +import { EnvErrorMessage } from '@/components/EnvErrorMessage' import { useAuth } from '@/lib/hooks/useAuth' import axios from 'axios' export default function Home() { + // Check if Supabase is configured + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL + const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY + const isSupabaseMissing = !supabaseUrl || !supabaseKey || + supabaseUrl === 'https://placeholder.supabase.co' || + supabaseKey === 'placeholder-key' + + // Show error message if environment variables are not configured + if (isSupabaseMissing) { + return + } + const { user, isLoading, logout } = useAuth() // Show landing page if not logged in @@ -29,16 +47,118 @@ export default function Home() { return } -function AuthenticatedApp({ user, logout }: { user: any, logout: () => void }) { +function AuthenticatedApp({ user: initialUser, logout }: { user: any, logout: () => void }) { + const [user, setUser] = useState(initialUser) + const [showConsent, setShowConsent] = useState(false) + const [showSettings, setShowSettings] = useState(false) + const [consentGiven, setConsentGiven] = useState(false) + const [view, setView] = useState<'dashboard' | 'character' | 'browse'>('dashboard') const [step, setStep] = useState<'record' | 'upload' | 'chat'>('record') const [userId, setUserId] = useState(null) const [audioBlob, setAudioBlob] = useState(null) + const [browsingUserId, setBrowsingUserId] = useState(null) + const [browsingUserName, setBrowsingUserName] = useState(null) + const [userIsPublic, setUserIsPublic] = useState(user.isPublic || false) const [voiceTraining, setVoiceTraining] = useState({ isTraining: false, progress: 0, - status: 'Not started' + status: 'Not started', + error: null as string | null }) + // Refresh user's isPublic status when returning to dashboard + useEffect(() => { + if (view === 'dashboard') { + console.log('๐Ÿ”„ Refreshing user data for dashboard...') + console.log(' Current user.isPublic:', user.isPublic) + axios.get(`/api/personality?userId=${user.id}`) + .then(res => { + console.log('๐Ÿ“ฆ Fresh user data received:') + console.log(' isPublic:', res.data.isPublic) + console.log(' voiceModelId:', !!res.data.voiceModelId) + console.log(' faceData:', !!res.data.faceData) + console.log(' photoUrls:', !!res.data.photoUrls) + + // Update both local state AND user object + setUserIsPublic(res.data.isPublic || false) + user.isPublic = res.data.isPublic || false + user.voiceModelId = res.data.voiceModelId + user.faceData = res.data.faceData + user.photoUrls = res.data.photoUrls + + console.log('โœ… User object updated') + }) + .catch(err => { + console.error('โŒ Failed to refresh user status:', err) + console.error(' Error response:', err.response?.data) + }) + } + }, [view, user.id]) + + // Check if user has already given consent and if they have audio + useEffect(() => { + const checkConsentAndData = async () => { + console.log('๐Ÿ” Checking consent for user:', user.id) + try { + const response = await axios.get(`/api/user-consent?userId=${user.id}`) + console.log('โœ… Consent check response:', response.data) + if (response.data.hasConsent) { + console.log('โœ… User has consent, setting consentGiven=true') + setConsentGiven(true) + + // Check if user already has audio and skip recording + try { + const userDataResponse = await axios.get(`/api/personality?userId=${user.id}`) + const userData = userDataResponse.data + + if (userData.audioUrl) { + console.log('โœ… User already has audio, skipping to chat') + setUserId(user.id) + setStep('chat') + } else { + console.log('โš ๏ธ User has no audio, staying on record step') + } + } catch (err) { + console.log('โš ๏ธ Could not fetch user data, staying on record step') + } + } else { + console.log('โš ๏ธ User has no consent, showing consent dialog') + setShowConsent(true) + } + } catch (error: any) { + console.error('โŒ Consent check error:', error) + console.error(' Error response:', error.response?.data) + // If error, show consent dialog to be safe + console.log('โš ๏ธ Showing consent dialog due to error') + setShowConsent(true) + } + } + checkConsentAndData() + }, [user.id]) + + const handleConsentAccept = async (consent: { + audio: boolean + chat: boolean + context: boolean + faceData: boolean + }) => { + try { + await axios.post('/api/save-consent', { + userId: user.id, + ...consent + }) + setConsentGiven(true) + setShowConsent(false) + } catch (error) { + console.error('Failed to save consent:', error) + alert('Failed to save consent. Please try again.') + } + } + + const handleConsentDecline = () => { + alert('You must accept data storage to use GhostJournal. You can customize what data to save.') + } + const handleRecordingComplete = async (blob: Blob) => { console.log('๐ŸŽค Recording complete! Blob size:', blob.size, 'bytes') setAudioBlob(blob) @@ -46,10 +166,10 @@ function AuthenticatedApp({ user, logout }: { user: any, logout: () => void }) { // Move to upload page IMMEDIATELY (don't wait for training) console.log('โžก๏ธ Moving to upload step...') setStep('upload') - + // Start voice training in background console.log('๐ŸŽค Recording complete, starting voice training...') - setVoiceTraining({ isTraining: true, progress: 10, status: 'Uploading audio...' }) + setVoiceTraining({ isTraining: true, progress: 10, status: 'Uploading audio...', error: null }) try { // Create user with audio only @@ -58,6 +178,7 @@ function AuthenticatedApp({ user, logout }: { user: any, logout: () => void }) { const response = await axios.post('/api/create-user', formData, { headers: { 'Content-Type': 'multipart/form-data' }, + timeout: 60000, // 60 second timeout onUploadProgress: (progressEvent) => { const percentCompleted = progressEvent.total ? Math.round((progressEvent.loaded * 100) / progressEvent.total) @@ -65,7 +186,8 @@ function AuthenticatedApp({ user, logout }: { user: any, logout: () => void }) { setVoiceTraining({ isTraining: true, progress: Math.min(percentCompleted / 2, 50), // 0-50% for upload - status: 'Uploading audio...' + status: 'Uploading audio...', + error: null }) } }) @@ -75,17 +197,45 @@ function AuthenticatedApp({ user, logout }: { user: any, logout: () => void }) { setUserId(newUserId) // Start voice training - setVoiceTraining({ isTraining: true, progress: 50, status: 'Training voice model (S1)...' }) + setVoiceTraining({ isTraining: true, progress: 50, status: 'Training voice model (S1)...', error: null }) - const voiceResponse = await axios.post('/api/voice-clone', { userId: newUserId }) + const voiceResponse = await axios.post('/api/voice-clone', { + userId: newUserId + }, { + timeout: 200000 // 200 second timeout (generous for Fish Audio training) + }) - setVoiceTraining({ isTraining: true, progress: 100, status: 'Voice model ready!' }) + setVoiceTraining({ isTraining: true, progress: 100, status: 'Voice model ready!', error: null }) console.log('โœ… Voice training complete:', voiceResponse.data.modelId) - } catch (error) { + } catch (error: any) { console.error('โŒ Voice training failed:', error) - setVoiceTraining({ isTraining: false, progress: 0, status: 'Training failed - using default voice' }) + console.error(' Error response:', error.response) + console.error(' Error data:', error.response?.data) + + // Determine which step failed + const step = error.config?.url?.includes('create-user') ? 'User Creation' : + error.config?.url?.includes('voice-clone') ? 'Voice Training' : 'Unknown' + + const errorMessage = error.response?.data?.details || + error.response?.data?.error || + error.message || + 'Unknown error' + + setVoiceTraining({ + isTraining: false, + progress: 0, + status: 'Training failed - using default voice', + error: `${step}: ${errorMessage}` + }) + + // IMPORTANT: Still allow user to continue even if voice training failed + // Set a temporary userId so they can proceed to the upload step + if (!userId) { + console.log('โš ๏ธ Setting userId to user.id to allow continuation') + setUserId(user.id) + } } } @@ -94,92 +244,295 @@ function AuthenticatedApp({ user, logout }: { user: any, logout: () => void }) { setStep('chat') } + const handleReRecord = () => { + setStep('record') + setAudioBlob(null) + setUserId(null) + setVoiceTraining({ + isTraining: false, + progress: 0, + status: 'Not started', + error: null + }) + } + return (
- {/* User Info & Logout */} -
-
-

Logged in as

-

{user.name || user.email}

+ {/* Consent Dialog */} + + + {/* Block UI until consent is given */} + {!consentGiven && !showConsent && ( +
+
+
- -
+ )} - {/* Header */} - -

- GhostJournal -

-

- Your AI Clone -

-
- - {/* Step Indicator */} -
- {['record', 'upload', 'chat'].map((s, idx) => ( -
- ))} -
+ {/* Main app - only show after consent */} + {consentGiven && ( + <> + {/* User Info & Settings & Logout */} +
+
+

Logged in as

+

@{user.username || user.name || user.email}

+
+ + {browsingUserId && ( + + )} + {view !== 'dashboard' && !browsingUserId && ( + + )} + +
+ + {/* Main Content Based on View */} + {view === 'dashboard' && ( + { + console.log('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•') + console.log('๐ŸŽฏ EDIT CHARACTER CLICKED') + console.log('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•') + console.log(' User ID:', user.id) + console.log(' User object audioUrl:', user.audioUrl ? 'EXISTS' : 'NULL') + console.log(' User object faceData:', user.faceData ? 'EXISTS' : 'NULL') + + // Check user's completion status + try { + console.log('๐Ÿ” Fetching fresh user data from /api/personality...') + const userDataResponse = await axios.get(`/api/personality?userId=${user.id}`) + const userData = userDataResponse.data + + console.log('๐Ÿ“ฆ RECEIVED DATA FROM API:') + console.log(' audioUrl:', userData.audioUrl || 'NULL') + console.log(' voiceModelId:', userData.voiceModelId || 'NULL') + console.log(' faceData:', userData.faceData ? `${userData.faceData.substring(0, 50)}...` : 'NULL') + console.log(' photoUrls:', userData.photoUrls || 'NULL') + + // FIX: Check voiceModelId instead of audioUrl + // voiceModelId is what matters - it's the trained Fish Audio model + const hasVoice = !!userData.voiceModelId + const hasFaceData = !!userData.faceData + const hasCompletedSetup = hasVoice && hasFaceData + + console.log('๐Ÿ“Š SETUP STATUS EVALUATION:') + console.log(' hasVoice (voiceModelId):', hasVoice) + console.log(' hasFaceData:', hasFaceData) + console.log(' hasCompletedSetup:', hasCompletedSetup) + + if (hasCompletedSetup) { + console.log('โœ…โœ…โœ… SETUP COMPLETE - GOING TO CLONETABS') + setUserId(user.id) + setStep('chat') + } else if (hasVoice && !hasFaceData) { + console.log('โš ๏ธ PARTIAL SETUP - Voice done, photo/context missing') + console.log(' Going to upload step...') + setAudioBlob(new Blob()) + setUserId(user.id) + setVoiceTraining({ + isTraining: false, + progress: 100, + status: 'Completed', + error: null + }) + setStep('upload') + } else { + console.log('โŒ NO SETUP - Starting from voice recording') + console.log(' Reason: hasVoice =', hasVoice, ', hasFaceData =', hasFaceData) + setStep('record') + } + } catch (err: any) { + console.error('โŒโŒโŒ ERROR CHECKING SETUP STATUS') + console.error(' Error:', err) + console.error(' Response:', err.response?.data) + console.error(' Status:', err.response?.status) + console.log('โš ๏ธ DEFAULTING TO RECORD STEP DUE TO ERROR') + setStep('record') + } + + console.log('๐ŸŽฌ Setting view to character...') + console.log(' Final step will be:', step) + console.log('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•') + setView('character') + }} + onBrowseClones={() => setView('browse')} + onLogout={logout} + onReRecordVoice={() => { + setView('character') + handleReRecord() + }} + onUserUpdate={(updatedUser) => { + // Update the user state to trigger re-render + setUser(updatedUser) + console.log('โœ… User updated:', updatedUser) + }} + /> + )} + + {view === 'browse' && ( + { + setBrowsingUserId(selectedUserId) + setUserId(selectedUserId) + setStep('chat') + setView('character') + + // Fetch the browsing user's name + try { + const response = await axios.get(`/api/personality?userId=${selectedUserId}`) + const userName = response.data.name || response.data.username || 'User' + setBrowsingUserName(userName) + } catch (error) { + console.error('Failed to fetch browsing user name:', error) + setBrowsingUserName('User') + } + }} + /> + )} + + {view === 'character' && ( + <> + {/* Header */} + +

+ {browsingUserId && browsingUserId !== user.id ? `${browsingUserName || 'User'}'s Clone` : 'Replik'} +

+

+ {browsingUserId && browsingUserId !== user.id ? `Talking to ${browsingUserName || "another user"}'s clone` : 'Your Digital Clone'} +

+
- {/* Main Content */} - - {step === 'record' && ( - - )} - {step === 'upload' && audioBlob && ( - <> - {console.log('๐Ÿ” Render check - step:', step, 'audioBlob:', !!audioBlob, 'userId:', userId || 'NOT SET YET')} - {userId ? ( - + {['record', 'upload', 'chat'].map((s, idx) => ( +
+ ))} +
+ )} + + {/* Main Content */} + + {step === 'record' && (!browsingUserId || browsingUserId === user.id) && ( + + )} + {step === 'upload' && audioBlob && (!browsingUserId || browsingUserId === user.id) && ( + <> + {console.log('๐Ÿ” Render check - step:', step, 'audioBlob:', !!audioBlob, 'userId:', userId || 'NOT SET YET')} + {userId ? ( + + ) : ( +
+
+

Creating your profile...

+

Voice model is being set up

+
+ )} + + )} + {step === 'chat' && userId && ( + - ) : ( -
-
-

Creating your profile...

-

Voice model is being set up

-
)} - - )} - {step === 'chat' && userId && ( - - )} -
+
+ + )} {/* Background Effects */}
+ + {/* Settings Modal */} + {showSettings && ( + setShowSettings(false)} + onUserUpdate={(updatedUser) => { + setUser(updatedUser) + console.log('โœ… User updated from settings:', updatedUser) + }} + onLogout={logout} + /> + )} + + )}
) } diff --git a/components/CloneBrowser.tsx b/components/CloneBrowser.tsx new file mode 100644 index 0000000..bf77732 --- /dev/null +++ b/components/CloneBrowser.tsx @@ -0,0 +1,230 @@ +'use client' + +import { useState, useEffect } from 'react' +import { motion } from 'framer-motion' +import axios from 'axios' +import { Search, MessageCircle, Download, User } from 'lucide-react' + +interface Clone { + userId: string + username: string + name?: string + bio?: string + createdAt: string + isPublic: boolean + photoUrls?: string | null +} + +interface CloneBrowserProps { + currentUserId: string + onSelectClone: (userId: string) => void +} + +export default function CloneBrowser({ currentUserId, onSelectClone }: CloneBrowserProps) { + const [clones, setClones] = useState([]) + const [searchQuery, setSearchQuery] = useState('') + const [loading, setLoading] = useState(true) + + useEffect(() => { + loadClones() + }, []) + + const loadClones = async () => { + try { + const response = await axios.get('/api/clones') + setClones(response.data.clones || []) + } catch (error) { + console.error('Error loading clones:', error) + setClones([]) + } finally { + setLoading(false) + } + } + + const filteredClones = clones.filter(clone => + clone.username.toLowerCase().includes(searchQuery.toLowerCase()) || + clone.name?.toLowerCase().includes(searchQuery.toLowerCase()) + ) + + const downloadClone = async (clone: Clone, e: React.MouseEvent) => { + e.stopPropagation() // Prevent triggering onSelectClone + + try { + console.log('๐Ÿ“ฅ Downloading clone data for:', clone.username) + + // Fetch full clone data + const response = await axios.get(`/api/personality?userId=${clone.userId}`) + const cloneData = response.data + + // Build export package + const exportData = { + // User info + userId: clone.userId, + username: clone.username, + name: cloneData.name || clone.name || clone.username, + bio: clone.bio, + createdAt: clone.createdAt, + + // Context entries + context: cloneData.personalityData ? JSON.parse(cloneData.personalityData) : {}, + + // Voice model info (for Fish Audio API calls) + voiceModelId: cloneData.voiceModelId, + audioUrl: cloneData.audioUrl, + voiceProvider: 'fish-audio', + + // Face/appearance data + faceData: cloneData.faceData ? JSON.parse(cloneData.faceData) : null, + + // Minecraft integration instructions + minecraftIntegration: { + apiUrl: window.location.origin + '/api/speak', + usage: 'See MINECRAFT_INTEGRATION.md for implementation guide', + note: 'voiceModelId is used to call Fish Audio API for voice synthesis' + } + } + + // Download as JSON file + const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `${clone.username}_clone.json` + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) + + console.log('โœ… Clone downloaded successfully') + } catch (error: any) { + console.error('โŒ Download failed:', error) + alert(`Failed to download clone: ${error.message}`) + } + } + + if (loading) { + return ( +
+
+
+ ) + } + + return ( +
+ {/* Header */} +
+

Browse Clone Models

+

+ Discover and interact with other people's digital clones +

+
+ + {/* Search */} +
+
+ setSearchQuery(e.target.value)} + placeholder="Search by username or name..." + className="w-full px-6 py-4 bg-dark-surface border border-white/30 rounded-xl + text-white text-lg focus:border-white focus:outline-none + placeholder-gray-500" + /> +
+ +
+
+
+ + {/* Clone Grid */} +
+ {filteredClones.length === 0 ? ( +
+

+ {searchQuery ? 'No clone models found matching your search' : 'No public clone models available yet'} +

+
+ ) : ( + filteredClones.map((clone) => ( + +
+ {/* Avatar */} +
+ {clone.photoUrls ? ( + {clone.username { + // Fallback to icon if image fails to load + const target = e.target as HTMLImageElement + target.style.display = 'none' + const parent = target.parentElement + if (parent) { + parent.className = 'w-16 h-16 bg-gradient-to-br from-white/20 to-white/5 rounded-full flex items-center justify-center flex-shrink-0' + parent.innerHTML = '' + } + }} + /> + ) : ( +
+ +
+ )} +
+ + {/* Info */} +
+

+ {clone.name || clone.username} +

+ {clone.username && clone.username !== 'unknown' && ( +

+ @{clone.username} +

+ )} +
+
+ + {clone.bio && ( +

+ {clone.bio} +

+ )} + + {/* Actions */} +
+ + +
+
+ )) + )} +
+
+ ) +} + diff --git a/components/CloneChat.tsx b/components/CloneChat.tsx index 11b09ce..d7af139 100644 --- a/components/CloneChat.tsx +++ b/components/CloneChat.tsx @@ -4,6 +4,7 @@ import { useState, useRef, useEffect, Suspense } from 'react' import { motion, AnimatePresence } from 'framer-motion' import axios from 'axios' import dynamic from 'next/dynamic' +import { StopCircle, Edit3, Volume2, Music } from 'lucide-react' const FaceWaveform3D = dynamic(() => import('./FaceWaveform3D'), { ssr: false, @@ -18,21 +19,53 @@ interface Message { interface CloneChatProps { userId: string + ownerName?: string | null // Name of the clone owner (if browsing another user's clone) } -export default function CloneChat({ userId }: CloneChatProps) { +export default function CloneChat({ userId, ownerName }: CloneChatProps) { + console.log('๐ŸŽญ CloneChat initialized:') + console.log(' userId:', userId) + console.log(' ownerName:', ownerName) + console.log(' Is browsing another user:', !!ownerName) + + // Generate initial message based on ownerName + const getInitialMessage = () => { + return ownerName + ? `Hey! I'm ${ownerName}'s clone. Talk to me like you're talking to ${ownerName}- I'll respond exactly how they would.` + : "Hey! I'm your clone. Talk to me like you're talking to yourself - I'll respond exactly how YOU would." + } + const [messages, setMessages] = useState([ { role: 'assistant', - content: "Hey! I'm your AI clone. Talk to me like you'd talk to yourself - I'll respond exactly how YOU would. You can also update my knowledge:\nโ€ข Say 'I have new stories: [story]' to add context\nโ€ข Ask 'How would you respond to [scenario]?' for specific reactions" + content: getInitialMessage() } ]) + + // Track previous ownerName to detect actual changes + const [prevOwnerName, setPrevOwnerName] = useState(ownerName) + + // Only reset messages if ownerName actually changes (not just tab switch) + useEffect(() => { + if (ownerName !== prevOwnerName) { + console.log('๐Ÿ”„ ownerName changed from', prevOwnerName, 'to', ownerName, '- resetting messages') + setMessages([ + { + role: 'assistant', + content: getInitialMessage() + } + ]) + setPrevOwnerName(ownerName) + } + }, [ownerName, prevOwnerName]) const [input, setInput] = useState('') const [isLoading, setIsLoading] = useState(false) const [isPlaying, setIsPlaying] = useState(false) const [audioData, setAudioData] = useState([]) const [currentEmotion, setCurrentEmotion] = useState('neutral') const [canInterrupt, setCanInterrupt] = useState(true) + const [critiquingIdx, setCritiquingIdx] = useState(null) + const [critiqueInput, setCritiqueInput] = useState('') // Store userId in sessionStorage for FaceWaveform3D useEffect(() => { @@ -88,7 +121,7 @@ export default function CloneChat({ userId }: CloneChatProps) { // Show success message const successMsg = document.createElement('div') - successMsg.textContent = 'โœ… Audio Enabled!' + successMsg.textContent = 'Audio Enabled!' successMsg.style.cssText = 'position:fixed;top:20px;left:50%;transform:translateX(-50%);padding:15px 30px;background:#00ff88;color:#000;border-radius:10px;font-weight:bold;z-index:9999;' document.body.appendChild(successMsg) setTimeout(() => document.body.removeChild(successMsg), 2000) @@ -298,7 +331,7 @@ export default function CloneChat({ userId }: CloneChatProps) { // For hackathon demo: show a visual indicator instead of alert const playBtn = document.createElement('button') - playBtn.textContent = '๐Ÿ”Š Click to Play Audio' + playBtn.textContent = 'Click to Play Audio' playBtn.style.cssText = 'position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);padding:20px 40px;font-size:20px;background:#ffffff;color:#000;border:none;border-radius:10px;cursor:pointer;z-index:9999;animation:pulse 1s infinite;' document.body.appendChild(playBtn) @@ -323,6 +356,42 @@ export default function CloneChat({ userId }: CloneChatProps) { } } + const handleCritique = async (messageIdx: number) => { + if (!critiqueInput.trim()) return + + console.log('โœ๏ธ User critiquing response:', messages[messageIdx].content.substring(0, 50)) + console.log(' Critique:', critiqueInput) + + try { + // Save critique as a memory to improve future responses + await axios.post('/api/memory', { + userId, + content: `User feedback: I wouldn't respond like "${messages[messageIdx].content.substring(0, 100)}...". Instead, I would say: "${critiqueInput}"`, + category: 'correction', + action: 'add' + }) + + console.log('โœ… Critique saved as memory') + + // IMMEDIATELY regenerate personality to include this correction + console.log('๐Ÿ”„ Regenerating personality with new correction...') + await axios.post('/api/personality', { + userId + }) + console.log('โœ… Personality regenerated!') + + // Show success message + alert('Feedback saved! I\'ll respond more accurately next time.') + + // Reset critique state + setCritiquingIdx(null) + setCritiqueInput('') + } catch (error) { + console.error('โŒ Failed to save critique:', error) + alert('Failed to save feedback. Please try again.') + } + } + const handleSend = async () => { if (!input.trim() || isLoading) return @@ -334,8 +403,18 @@ export default function CloneChat({ userId }: CloneChatProps) { console.log('๐Ÿ’ฌ Sending message:', input) try { + // Admin bypass now uses REAL APIs (just skips database auth checks) + // This allows testing with Fish Audio TTS and Claude personality + // Send message to API console.log('๐Ÿ“ก Calling /api/speak...') + console.log('๐Ÿ“ค Sending message to /api/speak:') + console.log(' userId:', userId) + console.log(' userId type:', typeof userId) + console.log(' userId length:', userId?.length) + console.log(' Is valid UUID?:', /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(userId)) + console.log(' message:', input.substring(0, 50)) + const response = await axios.post('/api/speak', { userId, message: input, @@ -347,9 +426,17 @@ export default function CloneChat({ userId }: CloneChatProps) { } }) - console.log('โœ… Received response:', response.data) - console.log(' Text:', response.data.text?.substring(0, 100)) + console.log('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•') + console.log('โœ… /api/speak RESPONSE RECEIVED') + console.log(' Full response:', JSON.stringify(response.data).substring(0, 200)) + console.log(' Text length:', response.data.text?.length || 0) + console.log(' Text preview:', response.data.text?.substring(0, 100)) console.log(' Audio URL:', response.data.audioUrl) + console.log(' Audio URL type:', typeof response.data.audioUrl) + console.log(' Audio URL length:', response.data.audioUrl?.length || 0) + console.log(' Is URL absolute?:', response.data.audioUrl?.startsWith('http')) + console.log(' Is URL relative?:', response.data.audioUrl?.startsWith('/')) + console.log('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•') const assistantMessage: Message = { role: 'assistant', @@ -369,14 +456,24 @@ export default function CloneChat({ userId }: CloneChatProps) { console.log('๐Ÿ”Š Playing audio:', response.data.audioUrl) await playAudio(response.data.audioUrl) } else { - console.warn('โš ๏ธ No audio URL in response') + console.warn('โš ๏ธ No audio URL in response - using browser TTS fallback') + // Fallback to browser text-to-speech if no audio URL + if ('speechSynthesis' in window) { + const utterance = new SpeechSynthesisUtterance(response.data.text) + utterance.rate = 1.0 + utterance.pitch = 1.0 + utterance.volume = 1.0 + window.speechSynthesis.speak(utterance) + } } } catch (error: any) { console.error('โŒ Error sending message:', error) console.error('Error details:', error.response?.data) + console.error('Error status:', error.response?.status) + console.error('Full error:', JSON.stringify(error.response, null, 2)) setMessages(prev => [...prev, { role: 'assistant', - content: error.response?.data?.details || "Sorry, I encountered an error. Please try again." + content: error.response?.data?.details || error.response?.data?.error || "Sorry, I encountered an error. Please try again." }]) } finally { setIsLoading(false) @@ -420,7 +517,9 @@ export default function CloneChat({ userId }: CloneChatProps) { animate={{ y: 0 }} className="bg-dark-card border border-white/30 rounded-2xl p-8 max-w-md mx-4 text-center" > -
๐Ÿ”Š
+
+ +

Enable Audio

@@ -435,9 +534,10 @@ export default function CloneChat({ userId }: CloneChatProps) { whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }} onClick={enableAudio} - className="w-full bg-transparent border-2 border-white text-white font-bold py-4 px-8 rounded-xl hover:bg-white hover:text-black transition-all" + className="w-full bg-transparent border-2 border-white text-white font-bold py-4 px-8 rounded-xl hover:bg-white hover:text-black transition-all flex items-center justify-center gap-2" > - ๐ŸŽต Enable Audio & Continue + + Enable Audio & Continue

Required for voice playback @@ -456,6 +556,28 @@ export default function CloneChat({ userId }: CloneChatProps) { emotion={currentEmotion} /> + {/* Emotion Indicator */} +

+ + Emotion: {currentEmotion.charAt(0).toUpperCase() + currentEmotion.slice(1)} + +
+ {/* Status Indicator */}

{message.content}

- - {/* Audio auto-plays when response arrives - no button needed */}
+ + {/* Critique button for assistant messages (not the initial message) */} + {message.role === 'assistant' && idx > 0 && ( +
+ {critiquingIdx === idx ? ( +
+