Spaces:
Sleeping
Sleeping
Nagesh Muralidhar
commited on
Commit
·
fd52f31
1
Parent(s):
06aa799
Initial commit of PodCraft application
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .gitattributes +6 -0
- .github/workflows/docker-image.yml +22 -0
- .gitignore +14 -0
- Dockerfile +36 -0
- Dockerfile.spaces +39 -0
- app/main.py +199 -0
- backend/app/agents/debaters.py +206 -0
- backend/app/agents/podcast_manager.py +393 -0
- backend/app/agents/researcher.py +153 -0
- backend/app/database.py +13 -0
- backend/app/main.py +1020 -0
- backend/app/models.py +114 -0
- backend/app/routers/__pycache__/podcast.cpython-311.pyc +0 -0
- backend/requirements.txt +15 -0
- backend/run.py +14 -0
- backend/temp_audio/Default/final_podcast.mp3 +3 -0
- build_and_deploy.sh +32 -0
- build_for_spaces.sh +33 -0
- docker-compose.yml +15 -0
- frontend/package-lock.json +6 -0
- frontend/podcraft/.gitignore +24 -0
- frontend/podcraft/README.md +12 -0
- frontend/podcraft/assets/bg.gif +3 -0
- frontend/podcraft/assets/bg2.gif +3 -0
- frontend/podcraft/assets/bg3.gif +3 -0
- frontend/podcraft/eslint.config.js +33 -0
- frontend/podcraft/index.html +13 -0
- frontend/podcraft/package-lock.json +0 -0
- frontend/podcraft/package.json +30 -0
- frontend/podcraft/public/vite.svg +1 -0
- frontend/podcraft/src/App.css +496 -0
- frontend/podcraft/src/App.jsx +268 -0
- frontend/podcraft/src/assets/react.svg +1 -0
- frontend/podcraft/src/components/AgentModal.css +470 -0
- frontend/podcraft/src/components/AgentModal.tsx +558 -0
- frontend/podcraft/src/components/ChatDetailModal.css +400 -0
- frontend/podcraft/src/components/ChatDetailModal.jsx +191 -0
- frontend/podcraft/src/components/CustomEdge.jsx +104 -0
- frontend/podcraft/src/components/CustomNodes.css +251 -0
- frontend/podcraft/src/components/CustomNodes.jsx +174 -0
- frontend/podcraft/src/components/DeleteModal.css +138 -0
- frontend/podcraft/src/components/DeleteModal.jsx +28 -0
- frontend/podcraft/src/components/InputNodeModal.css +194 -0
- frontend/podcraft/src/components/InputNodeModal.jsx +47 -0
- frontend/podcraft/src/components/NodeSelectionPanel.css +413 -0
- frontend/podcraft/src/components/NodeSelectionPanel.jsx +116 -0
- frontend/podcraft/src/components/ResponseEditModal.css +186 -0
- frontend/podcraft/src/components/ResponseEditModal.jsx +87 -0
- frontend/podcraft/src/components/Toast.css +63 -0
- frontend/podcraft/src/components/Toast.jsx +22 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,9 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
*.gif filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
*.mp3 filter=lfs diff=lfs merge=lfs -text
|
| 38 |
+
*.mp4 filter=lfs diff=lfs merge=lfs -text
|
| 39 |
+
*.png filter=lfs diff=lfs merge=lfs -text
|
| 40 |
+
*.jpg filter=lfs diff=lfs merge=lfs -text
|
| 41 |
+
*.jpeg filter=lfs diff=lfs merge=lfs -text
|
.github/workflows/docker-image.yml
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Docker Image CI/CD
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
branches: [ main ]
|
| 6 |
+
pull_request:
|
| 7 |
+
branches: [ main ]
|
| 8 |
+
|
| 9 |
+
jobs:
|
| 10 |
+
build:
|
| 11 |
+
runs-on: ubuntu-latest
|
| 12 |
+
steps:
|
| 13 |
+
- uses: actions/checkout@v2
|
| 14 |
+
|
| 15 |
+
- name: Set up Docker Buildx
|
| 16 |
+
uses: docker/setup-buildx-action@v1
|
| 17 |
+
|
| 18 |
+
- name: Build the Docker image
|
| 19 |
+
run: |
|
| 20 |
+
docker build . --file Dockerfile --tag podcraft-app:$(date +%s)
|
| 21 |
+
|
| 22 |
+
# Add deployment steps here if needed for your specific platform
|
.gitignore
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
backend/app/__pycache__
|
| 2 |
+
backend/.env
|
| 3 |
+
frontend/podcraft/node_modules
|
| 4 |
+
backend/.venv/
|
| 5 |
+
backend/venv/
|
| 6 |
+
.DS_Store
|
| 7 |
+
frontend/podcraft/.DS_Store
|
| 8 |
+
backend/.DS_Store
|
| 9 |
+
frontend/.DS_Store
|
| 10 |
+
backend/temp_audio/*
|
| 11 |
+
!backend/temp_audio/Default/
|
| 12 |
+
!backend/temp_audio/Default/final_podcast.mp3
|
| 13 |
+
backend/temp/*
|
| 14 |
+
backend/app/agents/__pycache__/
|
Dockerfile
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.9-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
# Install system dependencies including ffmpeg
|
| 6 |
+
RUN apt-get update && \
|
| 7 |
+
apt-get install -y --no-install-recommends \
|
| 8 |
+
ffmpeg \
|
| 9 |
+
build-essential \
|
| 10 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 11 |
+
|
| 12 |
+
# Copy requirements first to leverage Docker cache
|
| 13 |
+
COPY backend/requirements.txt .
|
| 14 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 15 |
+
|
| 16 |
+
# Copy backend code
|
| 17 |
+
COPY backend/app/ /app/app/
|
| 18 |
+
|
| 19 |
+
# Copy frontend build to static directory
|
| 20 |
+
COPY frontend/podcraft/build/ /app/static/
|
| 21 |
+
|
| 22 |
+
# Install additional packages needed for serving frontend
|
| 23 |
+
RUN pip install --no-cache-dir python-multipart
|
| 24 |
+
|
| 25 |
+
# Create directory for temporary files
|
| 26 |
+
RUN mkdir -p /app/temp_audio
|
| 27 |
+
|
| 28 |
+
# Set environment variables
|
| 29 |
+
ENV PYTHONPATH=/app
|
| 30 |
+
ENV MONGODB_URL="mongodb+srv://NageshBM:Nash166^@podcraft.ozqmc.mongodb.net/?retryWrites=true&w=majority&appName=Podcraft"
|
| 31 |
+
|
| 32 |
+
# Expose the port the app runs on
|
| 33 |
+
EXPOSE 8000
|
| 34 |
+
|
| 35 |
+
# Command to run the application
|
| 36 |
+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
Dockerfile.spaces
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.9-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
# Install system dependencies including ffmpeg
|
| 6 |
+
RUN apt-get update && \
|
| 7 |
+
apt-get install -y --no-install-recommends \
|
| 8 |
+
ffmpeg \
|
| 9 |
+
build-essential \
|
| 10 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 11 |
+
|
| 12 |
+
# Copy requirements first to leverage Docker cache
|
| 13 |
+
COPY backend/requirements.txt .
|
| 14 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 15 |
+
|
| 16 |
+
# Copy backend code
|
| 17 |
+
COPY backend/app /app/app/
|
| 18 |
+
|
| 19 |
+
# Copy frontend build to static directory if available
|
| 20 |
+
COPY frontend/podcraft/build/ /app/static/
|
| 21 |
+
|
| 22 |
+
# Install additional packages needed for serving frontend
|
| 23 |
+
RUN pip install --no-cache-dir python-multipart
|
| 24 |
+
|
| 25 |
+
# Create directory for temporary files
|
| 26 |
+
RUN mkdir -p /app/temp_audio
|
| 27 |
+
|
| 28 |
+
# Set environment variables
|
| 29 |
+
ENV PYTHONPATH=/app
|
| 30 |
+
|
| 31 |
+
# Using Secrets from HuggingFace Spaces
|
| 32 |
+
# MongoDB_URL is hardcoded to the Atlas URL in this case
|
| 33 |
+
ENV MONGODB_URL="mongodb+srv://NageshBM:Nash166^@podcraft.ozqmc.mongodb.net/?retryWrites=true&w=majority&appName=Podcraft"
|
| 34 |
+
|
| 35 |
+
# Expose the port the app runs on
|
| 36 |
+
EXPOSE 7860
|
| 37 |
+
|
| 38 |
+
# HuggingFace Spaces expects the app to run on port 7860
|
| 39 |
+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
|
app/main.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI, Request, HTTPException, Depends, status, File, UploadFile, Form
|
| 2 |
+
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
| 3 |
+
from fastapi.responses import JSONResponse, FileResponse, HTMLResponse
|
| 4 |
+
from fastapi.staticfiles import StaticFiles
|
| 5 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 6 |
+
from pydantic import BaseModel
|
| 7 |
+
from typing import Optional, Dict, List, Union, Any
|
| 8 |
+
from datetime import datetime, timedelta
|
| 9 |
+
import jwt
|
| 10 |
+
from jwt.exceptions import PyJWTError
|
| 11 |
+
from passlib.context import CryptContext
|
| 12 |
+
import os
|
| 13 |
+
import shutil
|
| 14 |
+
import logging
|
| 15 |
+
import json
|
| 16 |
+
from motor.motor_asyncio import AsyncIOMotorClient
|
| 17 |
+
from decouple import config
|
| 18 |
+
import uuid
|
| 19 |
+
from bson.objectid import ObjectId
|
| 20 |
+
import asyncio
|
| 21 |
+
import time
|
| 22 |
+
import sys
|
| 23 |
+
from pathlib import Path
|
| 24 |
+
|
| 25 |
+
# Import the original app modules
|
| 26 |
+
from app.models import *
|
| 27 |
+
from app.agents.podcast_manager import PodcastManager
|
| 28 |
+
from app.agents.researcher import Researcher
|
| 29 |
+
from app.agents.debate_agent import DebateAgent
|
| 30 |
+
|
| 31 |
+
# Setup logging
|
| 32 |
+
logging.basicConfig(level=logging.INFO)
|
| 33 |
+
logger = logging.getLogger(__name__)
|
| 34 |
+
|
| 35 |
+
# Initialize FastAPI app
|
| 36 |
+
app = FastAPI(title="PodCraft API")
|
| 37 |
+
|
| 38 |
+
# Add CORS middleware
|
| 39 |
+
app.add_middleware(
|
| 40 |
+
CORSMiddleware,
|
| 41 |
+
allow_origins=["*"], # Allow all origins in production
|
| 42 |
+
allow_credentials=True,
|
| 43 |
+
allow_methods=["*"],
|
| 44 |
+
allow_headers=["*"],
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
# Get MongoDB connection string from environment or config
|
| 48 |
+
MONGODB_URL = os.getenv("MONGODB_URL", config("MONGODB_URL", default="mongodb://localhost:27017"))
|
| 49 |
+
|
| 50 |
+
# MongoDB client
|
| 51 |
+
client = AsyncIOMotorClient(MONGODB_URL)
|
| 52 |
+
db = client.podcraft
|
| 53 |
+
users = db.users
|
| 54 |
+
podcasts = db.podcasts
|
| 55 |
+
agents = db.agents
|
| 56 |
+
workflows = db.workflows
|
| 57 |
+
|
| 58 |
+
# Initialize podcast manager
|
| 59 |
+
podcast_manager = PodcastManager()
|
| 60 |
+
|
| 61 |
+
# Initialize researcher
|
| 62 |
+
researcher = Researcher()
|
| 63 |
+
|
| 64 |
+
# Initialize debate agent
|
| 65 |
+
debate_agent = DebateAgent()
|
| 66 |
+
|
| 67 |
+
# Password hashing
|
| 68 |
+
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
| 69 |
+
|
| 70 |
+
# JWT settings
|
| 71 |
+
SECRET_KEY = os.getenv("SECRET_KEY", config("SECRET_KEY", default="your-secret-key"))
|
| 72 |
+
ALGORITHM = "HS256"
|
| 73 |
+
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 1 week
|
| 74 |
+
|
| 75 |
+
# OAuth2 scheme
|
| 76 |
+
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
|
| 77 |
+
|
| 78 |
+
# Keep the original authentication and API routes
|
| 79 |
+
# Include all the functions and routes from the original main.py
|
| 80 |
+
|
| 81 |
+
def create_access_token(data: dict):
|
| 82 |
+
to_encode = data.copy()
|
| 83 |
+
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
| 84 |
+
to_encode.update({"exp": expire})
|
| 85 |
+
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
| 86 |
+
return encoded_jwt
|
| 87 |
+
|
| 88 |
+
def verify_password(plain_password, hashed_password):
|
| 89 |
+
return pwd_context.verify(plain_password, hashed_password)
|
| 90 |
+
|
| 91 |
+
def get_password_hash(password):
|
| 92 |
+
return pwd_context.hash(password)
|
| 93 |
+
|
| 94 |
+
async def get_current_user(token: str = Depends(oauth2_scheme)):
|
| 95 |
+
credentials_exception = HTTPException(
|
| 96 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 97 |
+
detail="Could not validate credentials",
|
| 98 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 99 |
+
)
|
| 100 |
+
try:
|
| 101 |
+
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
| 102 |
+
username: str = payload.get("sub")
|
| 103 |
+
if username is None:
|
| 104 |
+
raise credentials_exception
|
| 105 |
+
except PyJWTError:
|
| 106 |
+
raise credentials_exception
|
| 107 |
+
|
| 108 |
+
user = await users.find_one({"username": username})
|
| 109 |
+
if user is None:
|
| 110 |
+
raise credentials_exception
|
| 111 |
+
|
| 112 |
+
# Convert ObjectId to string for JSON serialization
|
| 113 |
+
user["_id"] = str(user["_id"])
|
| 114 |
+
|
| 115 |
+
return user
|
| 116 |
+
|
| 117 |
+
# Include all the API routes from the original main.py here
|
| 118 |
+
# ...
|
| 119 |
+
|
| 120 |
+
# Mount static files for frontend
|
| 121 |
+
static_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "static"))
|
| 122 |
+
app.mount("/static", StaticFiles(directory=static_path), name="static")
|
| 123 |
+
|
| 124 |
+
# Add route to serve the frontend
|
| 125 |
+
@app.get("/", response_class=HTMLResponse)
|
| 126 |
+
async def serve_frontend():
|
| 127 |
+
html_file = os.path.join(static_path, "index.html")
|
| 128 |
+
if os.path.exists(html_file):
|
| 129 |
+
with open(html_file, "r") as f:
|
| 130 |
+
return f.read()
|
| 131 |
+
else:
|
| 132 |
+
return HTMLResponse(content="<html><body><h1>PodCraft API</h1><p>Frontend not found.</p></body></html>")
|
| 133 |
+
|
| 134 |
+
# Add route to serve audio files
|
| 135 |
+
@app.get("/audio/{path:path}")
|
| 136 |
+
async def serve_audio(path: str):
|
| 137 |
+
audio_file = os.path.join("/app/temp_audio", path)
|
| 138 |
+
if os.path.exists(audio_file):
|
| 139 |
+
return FileResponse(audio_file)
|
| 140 |
+
else:
|
| 141 |
+
raise HTTPException(status_code=404, detail="Audio file not found")
|
| 142 |
+
|
| 143 |
+
# Route for health check
|
| 144 |
+
@app.get("/health")
|
| 145 |
+
async def health():
|
| 146 |
+
return {"status": "healthy"}
|
| 147 |
+
|
| 148 |
+
# Include all the original API routes here from the original main.py
|
| 149 |
+
|
| 150 |
+
@app.post("/signup")
|
| 151 |
+
async def signup(user: UserCreate):
|
| 152 |
+
# Check if username exists
|
| 153 |
+
existing_user = await users.find_one({"username": user.username})
|
| 154 |
+
if existing_user:
|
| 155 |
+
raise HTTPException(status_code=400, detail="Username already registered")
|
| 156 |
+
|
| 157 |
+
# Hash the password
|
| 158 |
+
hashed_password = get_password_hash(user.password)
|
| 159 |
+
|
| 160 |
+
# Create new user
|
| 161 |
+
user_obj = {"username": user.username, "password": hashed_password}
|
| 162 |
+
new_user = await users.insert_one(user_obj)
|
| 163 |
+
|
| 164 |
+
# Create access token
|
| 165 |
+
access_token = create_access_token(data={"sub": user.username})
|
| 166 |
+
|
| 167 |
+
return {"access_token": access_token, "token_type": "bearer"}
|
| 168 |
+
|
| 169 |
+
@app.post("/token", response_model=Token)
|
| 170 |
+
async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends()):
|
| 171 |
+
user = await users.find_one({"username": form_data.username})
|
| 172 |
+
if not user or not verify_password(form_data.password, user["password"]):
|
| 173 |
+
raise HTTPException(
|
| 174 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 175 |
+
detail="Incorrect username or password",
|
| 176 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 177 |
+
)
|
| 178 |
+
|
| 179 |
+
access_token = create_access_token(data={"sub": form_data.username})
|
| 180 |
+
return {"access_token": access_token, "token_type": "bearer"}
|
| 181 |
+
|
| 182 |
+
@app.post("/login", response_model=Token)
|
| 183 |
+
async def login(request: Request, user: UserLogin):
|
| 184 |
+
db_user = await users.find_one({"username": user.username})
|
| 185 |
+
if not db_user or not verify_password(user.password, db_user["password"]):
|
| 186 |
+
raise HTTPException(
|
| 187 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 188 |
+
detail="Incorrect username or password"
|
| 189 |
+
)
|
| 190 |
+
|
| 191 |
+
access_token = create_access_token(data={"sub": user.username})
|
| 192 |
+
return {"access_token": access_token, "token_type": "bearer"}
|
| 193 |
+
|
| 194 |
+
# Add all the other API routes from the original main.py
|
| 195 |
+
# ...
|
| 196 |
+
|
| 197 |
+
if __name__ == "__main__":
|
| 198 |
+
import uvicorn
|
| 199 |
+
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
|
backend/app/agents/debaters.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from langchain_openai import ChatOpenAI
|
| 2 |
+
from langchain_core.prompts import ChatPromptTemplate
|
| 3 |
+
from decouple import config
|
| 4 |
+
from typing import Dict, List, AsyncGenerator
|
| 5 |
+
import json
|
| 6 |
+
import logging
|
| 7 |
+
|
| 8 |
+
# Set up logging
|
| 9 |
+
logging.basicConfig(level=logging.INFO)
|
| 10 |
+
logger = logging.getLogger(__name__)
|
| 11 |
+
|
| 12 |
+
OPENAI_API_KEY = config('OPENAI_API_KEY')
|
| 13 |
+
|
| 14 |
+
# Debug logging
|
| 15 |
+
print(f"\nDebaters - Loaded OpenAI API Key: {OPENAI_API_KEY[:7]}...")
|
| 16 |
+
print(f"Key starts with 'sk-proj-': {OPENAI_API_KEY.startswith('sk-proj-')}")
|
| 17 |
+
print(f"Key starts with 'sk-': {OPENAI_API_KEY.startswith('sk-')}\n")
|
| 18 |
+
|
| 19 |
+
believer_turn_prompt = ChatPromptTemplate.from_messages([
|
| 20 |
+
("system", """You are an optimistic and enthusiastic podcast host who sees the positive potential in new developments.
|
| 21 |
+
Your responses should be engaging, conversational, and STRICTLY LIMITED TO 100 WORDS.
|
| 22 |
+
Focus on the opportunities, benefits, and positive implications of the topic.
|
| 23 |
+
Maintain a non-chalant, happy, podcast-style tone while being informative.
|
| 24 |
+
Your name is {name}, use 'I' when referring to yourself."""),
|
| 25 |
+
("user", "Based on this research and the skeptic's last response (if any), provide your perspective for turn {turn_number}:\n\nResearch: {research}\nSkeptic's last response: {skeptic_response}")
|
| 26 |
+
])
|
| 27 |
+
|
| 28 |
+
skeptic_turn_prompt = ChatPromptTemplate.from_messages([
|
| 29 |
+
("system", """You are a thoughtful and critical podcast host who carefully examines potential drawbacks and challenges.
|
| 30 |
+
Your responses should be engaging, conversational, and STRICTLY LIMITED TO 100 WORDS.
|
| 31 |
+
Focus on potential risks, limitations, and areas needing careful consideration.
|
| 32 |
+
Maintain a enthusiastic and angry, podcast-style tone while being informative.
|
| 33 |
+
Your name is {name}, use 'I' when referring to yourself."""),
|
| 34 |
+
("user", "Based on this research and the believer's last response (if any), provide your perspective for turn {turn_number}:\n\nResearch: {research}\nBeliever's last response: {believer_response}")
|
| 35 |
+
])
|
| 36 |
+
|
| 37 |
+
# Initialize the LLMs with streaming
|
| 38 |
+
believer_llm = ChatOpenAI(
|
| 39 |
+
model="gpt-4o-mini",
|
| 40 |
+
temperature=0.7,
|
| 41 |
+
api_key=OPENAI_API_KEY,
|
| 42 |
+
streaming=True
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
skeptic_llm = ChatOpenAI(
|
| 46 |
+
model="gpt-4o-mini",
|
| 47 |
+
temperature=0.7,
|
| 48 |
+
api_key=OPENAI_API_KEY,
|
| 49 |
+
streaming=True
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
def chunk_text(text: str, max_length: int = 3800) -> List[str]:
|
| 53 |
+
"""Split text into chunks of maximum length while preserving sentence boundaries."""
|
| 54 |
+
# Split into sentences and trim whitespace
|
| 55 |
+
sentences = [s.strip() for s in text.split('.')]
|
| 56 |
+
sentences = [s + '.' for s in sentences if s]
|
| 57 |
+
|
| 58 |
+
chunks = []
|
| 59 |
+
current_chunk = []
|
| 60 |
+
current_length = 0
|
| 61 |
+
|
| 62 |
+
for sentence in sentences:
|
| 63 |
+
sentence_length = len(sentence)
|
| 64 |
+
if current_length + sentence_length > max_length:
|
| 65 |
+
if current_chunk: # If we have accumulated sentences, join them and add to chunks
|
| 66 |
+
chunks.append(' '.join(current_chunk))
|
| 67 |
+
current_chunk = [sentence]
|
| 68 |
+
current_length = sentence_length
|
| 69 |
+
else: # If a single sentence is too long, split it
|
| 70 |
+
if sentence_length > max_length:
|
| 71 |
+
words = sentence.split()
|
| 72 |
+
temp_chunk = []
|
| 73 |
+
temp_length = 0
|
| 74 |
+
for word in words:
|
| 75 |
+
if temp_length + len(word) + 1 > max_length:
|
| 76 |
+
chunks.append(' '.join(temp_chunk))
|
| 77 |
+
temp_chunk = [word]
|
| 78 |
+
temp_length = len(word)
|
| 79 |
+
else:
|
| 80 |
+
temp_chunk.append(word)
|
| 81 |
+
temp_length += len(word) + 1
|
| 82 |
+
if temp_chunk:
|
| 83 |
+
chunks.append(' '.join(temp_chunk))
|
| 84 |
+
else:
|
| 85 |
+
chunks.append(sentence)
|
| 86 |
+
else:
|
| 87 |
+
current_chunk.append(sentence)
|
| 88 |
+
current_length += sentence_length
|
| 89 |
+
|
| 90 |
+
if current_chunk:
|
| 91 |
+
chunks.append(' '.join(current_chunk))
|
| 92 |
+
|
| 93 |
+
return chunks
|
| 94 |
+
|
| 95 |
+
async def generate_debate_stream(research: str, believer_name: str, skeptic_name: str) -> AsyncGenerator[str, None]:
|
| 96 |
+
"""
|
| 97 |
+
Generate a streaming podcast-style debate between believer and skeptic agents with alternating turns.
|
| 98 |
+
"""
|
| 99 |
+
try:
|
| 100 |
+
turns = 3 # Number of turns for each speaker
|
| 101 |
+
skeptic_last_response = ""
|
| 102 |
+
believer_last_response = ""
|
| 103 |
+
|
| 104 |
+
# Start with skeptic for first turn
|
| 105 |
+
for turn in range(1, turns + 1):
|
| 106 |
+
logger.info(f"Starting skeptic ({skeptic_name}) turn {turn}")
|
| 107 |
+
skeptic_response = ""
|
| 108 |
+
# Stream skeptic's perspective
|
| 109 |
+
async for chunk in skeptic_llm.astream(
|
| 110 |
+
skeptic_turn_prompt.format(
|
| 111 |
+
research=research,
|
| 112 |
+
name=skeptic_name,
|
| 113 |
+
turn_number=turn,
|
| 114 |
+
believer_response=believer_last_response
|
| 115 |
+
)
|
| 116 |
+
):
|
| 117 |
+
skeptic_response += chunk.content
|
| 118 |
+
yield json.dumps({
|
| 119 |
+
"type": "skeptic",
|
| 120 |
+
"name": skeptic_name,
|
| 121 |
+
"content": chunk.content,
|
| 122 |
+
"turn": turn
|
| 123 |
+
}) + "\n"
|
| 124 |
+
skeptic_last_response = skeptic_response
|
| 125 |
+
logger.info(f"Skeptic turn {turn}: {skeptic_response}")
|
| 126 |
+
|
| 127 |
+
logger.info(f"Starting believer ({believer_name}) turn {turn}")
|
| 128 |
+
believer_response = ""
|
| 129 |
+
# Stream believer's perspective
|
| 130 |
+
async for chunk in believer_llm.astream(
|
| 131 |
+
believer_turn_prompt.format(
|
| 132 |
+
research=research,
|
| 133 |
+
name=believer_name,
|
| 134 |
+
turn_number=turn,
|
| 135 |
+
skeptic_response=skeptic_last_response
|
| 136 |
+
)
|
| 137 |
+
):
|
| 138 |
+
believer_response += chunk.content
|
| 139 |
+
yield json.dumps({
|
| 140 |
+
"type": "believer",
|
| 141 |
+
"name": believer_name,
|
| 142 |
+
"content": chunk.content,
|
| 143 |
+
"turn": turn
|
| 144 |
+
}) + "\n"
|
| 145 |
+
believer_last_response = believer_response
|
| 146 |
+
logger.info(f"Believer turn {turn}: {believer_response}")
|
| 147 |
+
|
| 148 |
+
except Exception as e:
|
| 149 |
+
logger.error(f"Error in debate generation: {str(e)}")
|
| 150 |
+
yield json.dumps({"type": "error", "content": str(e)}) + "\n"
|
| 151 |
+
|
| 152 |
+
async def generate_debate(research: str, believer_name: str, skeptic_name: str) -> List[Dict]:
|
| 153 |
+
"""
|
| 154 |
+
Generate a complete podcast-style debate between believer and skeptic agents.
|
| 155 |
+
Kept for compatibility with existing code.
|
| 156 |
+
"""
|
| 157 |
+
try:
|
| 158 |
+
logger.info(f"Starting believer ({believer_name}) response generation")
|
| 159 |
+
# Get believer's perspective
|
| 160 |
+
believer_response = await believer_llm.ainvoke(
|
| 161 |
+
believer_prompt.format(research=research, name=believer_name)
|
| 162 |
+
)
|
| 163 |
+
logger.info(f"Believer response: {believer_response.content}")
|
| 164 |
+
|
| 165 |
+
logger.info(f"Starting skeptic ({skeptic_name}) response generation")
|
| 166 |
+
# Get skeptic's perspective
|
| 167 |
+
skeptic_response = await skeptic_llm.ainvoke(
|
| 168 |
+
skeptic_prompt.format(research=research, name=skeptic_name)
|
| 169 |
+
)
|
| 170 |
+
logger.info(f"Skeptic response: {skeptic_response.content}")
|
| 171 |
+
|
| 172 |
+
# Create conversation blocks with chunked text
|
| 173 |
+
blocks = []
|
| 174 |
+
|
| 175 |
+
# Add believer chunks
|
| 176 |
+
believer_chunks = chunk_text(believer_response.content)
|
| 177 |
+
for i, chunk in enumerate(believer_chunks):
|
| 178 |
+
blocks.append({
|
| 179 |
+
"name": f"{believer_name}'s Perspective (Part {i+1})",
|
| 180 |
+
"input": chunk,
|
| 181 |
+
"silence_before": 1,
|
| 182 |
+
"voice_id": "OA001", # Will be updated based on selected voice
|
| 183 |
+
"emotion": "neutral",
|
| 184 |
+
"model": "tts-1",
|
| 185 |
+
"speed": 1,
|
| 186 |
+
"duration": 0
|
| 187 |
+
})
|
| 188 |
+
|
| 189 |
+
# Add skeptic chunks
|
| 190 |
+
skeptic_chunks = chunk_text(skeptic_response.content)
|
| 191 |
+
for i, chunk in enumerate(skeptic_chunks):
|
| 192 |
+
blocks.append({
|
| 193 |
+
"name": f"{skeptic_name}'s Perspective (Part {i+1})",
|
| 194 |
+
"input": chunk,
|
| 195 |
+
"silence_before": 1,
|
| 196 |
+
"voice_id": "OA002", # Will be updated based on selected voice
|
| 197 |
+
"emotion": "neutral",
|
| 198 |
+
"model": "tts-1",
|
| 199 |
+
"speed": 1,
|
| 200 |
+
"duration": 0
|
| 201 |
+
})
|
| 202 |
+
|
| 203 |
+
return blocks
|
| 204 |
+
except Exception as e:
|
| 205 |
+
logger.error(f"Error in debate generation: {str(e)}")
|
| 206 |
+
return []
|
backend/app/agents/podcast_manager.py
ADDED
|
@@ -0,0 +1,393 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import requests
|
| 2 |
+
import json
|
| 3 |
+
import os
|
| 4 |
+
import shutil
|
| 5 |
+
import subprocess
|
| 6 |
+
from datetime import datetime
|
| 7 |
+
from decouple import config
|
| 8 |
+
from motor.motor_asyncio import AsyncIOMotorClient
|
| 9 |
+
from typing import Dict, List
|
| 10 |
+
import logging
|
| 11 |
+
from fastapi import HTTPException, status
|
| 12 |
+
|
| 13 |
+
logger = logging.getLogger(__name__)
|
| 14 |
+
|
| 15 |
+
class Settings:
|
| 16 |
+
MONGODB_URL = config('MONGODB_URL')
|
| 17 |
+
SECRET_KEY = config('SECRET_KEY')
|
| 18 |
+
OPENAI_API_KEY = config('OPENAI_API_KEY')
|
| 19 |
+
# Other settings...
|
| 20 |
+
|
| 21 |
+
settings = Settings()
|
| 22 |
+
|
| 23 |
+
client = AsyncIOMotorClient(settings.MONGODB_URL)
|
| 24 |
+
db = client.podcraft
|
| 25 |
+
podcasts = db.podcasts
|
| 26 |
+
|
| 27 |
+
class PodcastManager:
|
| 28 |
+
def __init__(self):
|
| 29 |
+
self.tts_url = "https://api.openai.com/v1/audio/speech"
|
| 30 |
+
self.headers = {
|
| 31 |
+
"Authorization": f"Bearer {settings.OPENAI_API_KEY}",
|
| 32 |
+
"Content-Type": "application/json"
|
| 33 |
+
}
|
| 34 |
+
# Create absolute path for temp directory
|
| 35 |
+
self.temp_dir = os.path.abspath("temp_audio")
|
| 36 |
+
os.makedirs(self.temp_dir, exist_ok=True)
|
| 37 |
+
|
| 38 |
+
# Define allowed voices
|
| 39 |
+
self.allowed_voices = ["alloy", "echo", "fable", "onyx", "nova", "shimmer", "ash", "sage", "coral"]
|
| 40 |
+
|
| 41 |
+
def generate_speech(self, text: str, voice_id: str, filename: str) -> bool:
|
| 42 |
+
"""Generate speech using OpenAI's TTS API."""
|
| 43 |
+
try:
|
| 44 |
+
# Debug logging for voice selection
|
| 45 |
+
print(f"\n=== TTS Generation Details ===")
|
| 46 |
+
print(f"File: {filename}")
|
| 47 |
+
print(f"Voice ID (original): {voice_id}")
|
| 48 |
+
print(f"Voice ID (lowercase): {voice_id.lower()}")
|
| 49 |
+
print(f"Allowed voices: {self.allowed_voices}")
|
| 50 |
+
|
| 51 |
+
# Validate and normalize voice_id
|
| 52 |
+
voice = voice_id.lower().strip()
|
| 53 |
+
if voice not in self.allowed_voices:
|
| 54 |
+
print(f"Warning: Invalid voice ID: {voice_id}. Using default voice 'alloy'")
|
| 55 |
+
voice = "alloy"
|
| 56 |
+
|
| 57 |
+
print(f"Final voice selection: {voice}")
|
| 58 |
+
|
| 59 |
+
# Ensure the output directory exists
|
| 60 |
+
output_dir = os.path.dirname(filename)
|
| 61 |
+
os.makedirs(output_dir, exist_ok=True)
|
| 62 |
+
|
| 63 |
+
payload = {
|
| 64 |
+
"model": "tts-1",
|
| 65 |
+
"input": text,
|
| 66 |
+
"voice": voice
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
print(f"TTS API payload: {json.dumps(payload, indent=2)}")
|
| 70 |
+
print(f"Request headers: {json.dumps({k: '***' if k == 'Authorization' else v for k, v in self.headers.items()}, indent=2)}")
|
| 71 |
+
|
| 72 |
+
response = requests.post(self.tts_url, json=payload, headers=self.headers)
|
| 73 |
+
if response.status_code != 200:
|
| 74 |
+
print(f"API error response: {response.status_code} - {response.text}")
|
| 75 |
+
return False
|
| 76 |
+
|
| 77 |
+
# Write the audio content to the file
|
| 78 |
+
with open(filename, "wb") as f:
|
| 79 |
+
f.write(response.content)
|
| 80 |
+
|
| 81 |
+
print(f"Successfully generated speech file: {filename}")
|
| 82 |
+
print(f"File size: {os.path.getsize(filename)} bytes")
|
| 83 |
+
|
| 84 |
+
# Verify the file exists and has content
|
| 85 |
+
if not os.path.exists(filename) or os.path.getsize(filename) == 0:
|
| 86 |
+
print(f"Error: Generated file is empty or does not exist: {filename}")
|
| 87 |
+
return False
|
| 88 |
+
|
| 89 |
+
return True
|
| 90 |
+
except Exception as e:
|
| 91 |
+
print(f"Error generating speech: {str(e)}")
|
| 92 |
+
logger.exception(f"Error generating speech: {str(e)}")
|
| 93 |
+
return False
|
| 94 |
+
|
| 95 |
+
def merge_audio_files(self, audio_files: List[str], output_file: str) -> bool:
|
| 96 |
+
"""Merge multiple audio files into one using ffmpeg."""
|
| 97 |
+
try:
|
| 98 |
+
# Ensure output directory exists
|
| 99 |
+
output_dir = os.path.dirname(os.path.abspath(output_file))
|
| 100 |
+
os.makedirs(output_dir, exist_ok=True)
|
| 101 |
+
|
| 102 |
+
if not audio_files:
|
| 103 |
+
print("No audio files to merge")
|
| 104 |
+
return False
|
| 105 |
+
|
| 106 |
+
# Verify all input files exist
|
| 107 |
+
for audio_file in audio_files:
|
| 108 |
+
if not os.path.exists(audio_file):
|
| 109 |
+
print(f"Audio file does not exist: {audio_file}")
|
| 110 |
+
return False
|
| 111 |
+
|
| 112 |
+
# Ensure all paths are absolute
|
| 113 |
+
output_file = os.path.abspath(output_file)
|
| 114 |
+
output_dir = os.path.dirname(output_file)
|
| 115 |
+
os.makedirs(output_dir, exist_ok=True)
|
| 116 |
+
|
| 117 |
+
# Create temporary files in the same directory
|
| 118 |
+
list_file = os.path.join(output_dir, "files.txt")
|
| 119 |
+
silence_file = os.path.join(output_dir, "silence.mp3")
|
| 120 |
+
|
| 121 |
+
print(f"Output directory: {output_dir}")
|
| 122 |
+
print(f"List file: {list_file}")
|
| 123 |
+
print(f"Silence file: {silence_file}")
|
| 124 |
+
|
| 125 |
+
# Generate shorter silence file (0.3 seconds instead of 1 second)
|
| 126 |
+
silence_result = subprocess.run([
|
| 127 |
+
'ffmpeg', '-f', 'lavfi', '-i', 'anullsrc=r=44100:cl=mono',
|
| 128 |
+
'-t', '0.3', '-q:a', '9', '-acodec', 'libmp3lame', silence_file
|
| 129 |
+
], capture_output=True, text=True)
|
| 130 |
+
|
| 131 |
+
if silence_result.returncode != 0:
|
| 132 |
+
print(f"Error generating silence file: {silence_result.stderr}")
|
| 133 |
+
return False
|
| 134 |
+
|
| 135 |
+
if not os.path.exists(silence_file):
|
| 136 |
+
print("Failed to create silence file")
|
| 137 |
+
return False
|
| 138 |
+
|
| 139 |
+
# IMPORTANT: The order here determines the final audio order
|
| 140 |
+
print("\nGenerating files list in exact provided order:")
|
| 141 |
+
try:
|
| 142 |
+
with open(list_file, "w", encoding='utf-8') as f:
|
| 143 |
+
for i, audio_file in enumerate(audio_files):
|
| 144 |
+
abs_audio_path = os.path.abspath(audio_file)
|
| 145 |
+
print(f"{i+1}. Adding audio file: {os.path.basename(abs_audio_path)}")
|
| 146 |
+
# Use forward slashes for ffmpeg compatibility
|
| 147 |
+
abs_audio_path = abs_audio_path.replace('\\', '/')
|
| 148 |
+
silence_path = silence_file.replace('\\', '/')
|
| 149 |
+
f.write(f"file '{abs_audio_path}'\n")
|
| 150 |
+
# Add a shorter silence after each audio segment (except the last one)
|
| 151 |
+
if i < len(audio_files) - 1:
|
| 152 |
+
f.write(f"file '{silence_path}'\n")
|
| 153 |
+
except Exception as e:
|
| 154 |
+
print(f"Error writing list file: {str(e)}")
|
| 155 |
+
return False
|
| 156 |
+
|
| 157 |
+
if not os.path.exists(list_file):
|
| 158 |
+
print("Failed to create list file")
|
| 159 |
+
return False
|
| 160 |
+
|
| 161 |
+
# Print the contents of the list file for debugging
|
| 162 |
+
print("\nContents of files.txt:")
|
| 163 |
+
with open(list_file, 'r', encoding='utf-8') as f:
|
| 164 |
+
print(f.read())
|
| 165 |
+
|
| 166 |
+
# Merge all files using the concat demuxer with optimized settings
|
| 167 |
+
try:
|
| 168 |
+
# Use concat demuxer with additional parameters for better playback
|
| 169 |
+
result = subprocess.run(
|
| 170 |
+
['ffmpeg', '-f', 'concat', '-safe', '0', '-i', list_file,
|
| 171 |
+
'-c:a', 'libmp3lame', '-q:a', '4', '-ar', '44100',
|
| 172 |
+
output_file],
|
| 173 |
+
capture_output=True,
|
| 174 |
+
text=True,
|
| 175 |
+
check=True
|
| 176 |
+
)
|
| 177 |
+
except subprocess.CalledProcessError as e:
|
| 178 |
+
logger.error(f"FFmpeg command failed: {e.stderr}")
|
| 179 |
+
return False
|
| 180 |
+
|
| 181 |
+
# Verify the output file was created
|
| 182 |
+
if not os.path.exists(output_file):
|
| 183 |
+
print("Failed to create output file")
|
| 184 |
+
return False
|
| 185 |
+
|
| 186 |
+
print(f"Successfully created merged audio file: {output_file}")
|
| 187 |
+
return True
|
| 188 |
+
except Exception as e:
|
| 189 |
+
print(f"Error merging audio files: {str(e)}")
|
| 190 |
+
return False
|
| 191 |
+
|
| 192 |
+
async def create_podcast(
|
| 193 |
+
self,
|
| 194 |
+
topic: str,
|
| 195 |
+
research: str,
|
| 196 |
+
conversation_blocks: List[Dict],
|
| 197 |
+
believer_voice_id: str,
|
| 198 |
+
skeptic_voice_id: str,
|
| 199 |
+
user_id: str = None
|
| 200 |
+
) -> Dict:
|
| 201 |
+
"""Create a podcast by converting text to speech and storing the results."""
|
| 202 |
+
podcast_temp_dir = None
|
| 203 |
+
try:
|
| 204 |
+
# Debug logging for voice IDs
|
| 205 |
+
print(f"\nPodcast Creation - Voice Configuration:")
|
| 206 |
+
print(f"Believer Voice ID: {believer_voice_id}")
|
| 207 |
+
print(f"Skeptic Voice ID: {skeptic_voice_id}")
|
| 208 |
+
|
| 209 |
+
# Create a unique directory with absolute path
|
| 210 |
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 211 |
+
podcast_temp_dir = os.path.abspath(os.path.join(self.temp_dir, timestamp))
|
| 212 |
+
os.makedirs(podcast_temp_dir, exist_ok=True)
|
| 213 |
+
|
| 214 |
+
print(f"Created temp directory: {podcast_temp_dir}")
|
| 215 |
+
print(f"Processing conversation blocks: {json.dumps(conversation_blocks, indent=2)}")
|
| 216 |
+
|
| 217 |
+
audio_files = []
|
| 218 |
+
|
| 219 |
+
# Process the blocks differently based on format:
|
| 220 |
+
# 1. New turn-based format with "type" and "turn" fields
|
| 221 |
+
# 2. Blocks with "input" field but no turn-based structure (old format)
|
| 222 |
+
# 3. Blocks with both "input" field and turn-based structure (mixed format)
|
| 223 |
+
|
| 224 |
+
# First check: New format blocks with type and turn
|
| 225 |
+
if any("type" in block and "turn" in block and "content" in block for block in conversation_blocks):
|
| 226 |
+
print("\nProcessing new format blocks with type, turn, and content fields")
|
| 227 |
+
|
| 228 |
+
# Process conversation blocks in the EXACT order they were provided
|
| 229 |
+
# This ensures proper alternation between speakers as specified by the caller
|
| 230 |
+
|
| 231 |
+
for idx, block in enumerate(conversation_blocks):
|
| 232 |
+
if "type" in block and "content" in block and "turn" in block:
|
| 233 |
+
turn = block.get("turn", 0)
|
| 234 |
+
agent_type = block.get("type", "")
|
| 235 |
+
content = block.get("content", "")
|
| 236 |
+
|
| 237 |
+
if not content.strip(): # Skip empty content
|
| 238 |
+
continue
|
| 239 |
+
|
| 240 |
+
# Use the correct voice based on agent type
|
| 241 |
+
voice_id = believer_voice_id if agent_type == "believer" else skeptic_voice_id
|
| 242 |
+
file_prefix = "believer" if agent_type == "believer" else "skeptic"
|
| 243 |
+
|
| 244 |
+
# Create a unique filename with turn number
|
| 245 |
+
audio_file = os.path.join(podcast_temp_dir, f"{file_prefix}_turn_{turn}_{idx}.mp3")
|
| 246 |
+
|
| 247 |
+
print(f"\nProcessing {agent_type} turn {turn} (index {idx}) with voice {voice_id}")
|
| 248 |
+
print(f"Content preview: {content[:100]}...")
|
| 249 |
+
|
| 250 |
+
if self.generate_speech(content, voice_id, audio_file):
|
| 251 |
+
# Add to our audio files list IN THE ORIGINAL ORDER
|
| 252 |
+
audio_files.append(audio_file)
|
| 253 |
+
print(f"Generated {agent_type} audio for turn {turn}, added to position {len(audio_files)}")
|
| 254 |
+
else:
|
| 255 |
+
raise Exception(f"Failed to generate audio for {agent_type} turn {turn}")
|
| 256 |
+
|
| 257 |
+
# Second check: Blocks with input field and possibly turn information
|
| 258 |
+
elif any("input" in block for block in conversation_blocks):
|
| 259 |
+
print("\nProcessing blocks with input field")
|
| 260 |
+
|
| 261 |
+
# Check if these blocks also have type and turn information
|
| 262 |
+
has_turn_info = any("turn" in block and "type" in block for block in conversation_blocks)
|
| 263 |
+
|
| 264 |
+
if has_turn_info:
|
| 265 |
+
print("Blocks have both input field and turn-based structure - using mixed format")
|
| 266 |
+
# Sort by turn if available, ensuring proper sequence
|
| 267 |
+
sorted_blocks = sorted(conversation_blocks, key=lambda b: b.get("turn", float('inf')))
|
| 268 |
+
|
| 269 |
+
for idx, block in enumerate(sorted_blocks):
|
| 270 |
+
if "input" in block and block["input"].strip():
|
| 271 |
+
# Determine voice based on type field or name
|
| 272 |
+
if "type" in block:
|
| 273 |
+
is_believer = block["type"] == "believer"
|
| 274 |
+
else:
|
| 275 |
+
is_believer = "Believer" in block.get("name", "") or block.get("name", "").lower().startswith("alloy")
|
| 276 |
+
|
| 277 |
+
voice_id = believer_voice_id if is_believer else skeptic_voice_id
|
| 278 |
+
speaker_type = "believer" if is_believer else "skeptic"
|
| 279 |
+
turn = block.get("turn", idx + 1)
|
| 280 |
+
|
| 281 |
+
print(f"\nProcessing {speaker_type} block with turn {turn} using voice {voice_id}")
|
| 282 |
+
audio_file = os.path.join(podcast_temp_dir, f"{speaker_type}_turn_{turn}_{idx}.mp3")
|
| 283 |
+
|
| 284 |
+
if self.generate_speech(block["input"], voice_id, audio_file):
|
| 285 |
+
audio_files.append(audio_file)
|
| 286 |
+
print(f"Generated audio for {speaker_type} turn {turn}")
|
| 287 |
+
else:
|
| 288 |
+
raise Exception(f"Failed to generate audio for {speaker_type} turn {turn}")
|
| 289 |
+
else:
|
| 290 |
+
# Old format - process blocks sequentially as they appear
|
| 291 |
+
print("Processing old format blocks sequentially")
|
| 292 |
+
for i, block in enumerate(conversation_blocks):
|
| 293 |
+
if "input" in block and block["input"].strip():
|
| 294 |
+
# Check for either "Believer" in name or if the name starts with "alloy"
|
| 295 |
+
is_believer = "Believer" in block.get("name", "") or block.get("name", "").lower().startswith("alloy")
|
| 296 |
+
voice_id = believer_voice_id if is_believer else skeptic_voice_id
|
| 297 |
+
speaker_type = "believer" if is_believer else "skeptic"
|
| 298 |
+
|
| 299 |
+
print(f"\nProcessing {speaker_type} block {i+1} with voice {voice_id}")
|
| 300 |
+
print(f"Block name: {block.get('name', '')}") # Debug logging
|
| 301 |
+
|
| 302 |
+
audio_file = os.path.join(podcast_temp_dir, f"part_{i+1}.mp3")
|
| 303 |
+
if self.generate_speech(block["input"], voice_id, audio_file):
|
| 304 |
+
audio_files.append(audio_file)
|
| 305 |
+
print(f"Generated audio for part {i+1}")
|
| 306 |
+
else:
|
| 307 |
+
raise Exception(f"Failed to generate audio for part {i+1}")
|
| 308 |
+
else:
|
| 309 |
+
raise Exception("Invalid conversation blocks format - no recognizable structure found")
|
| 310 |
+
|
| 311 |
+
if not audio_files:
|
| 312 |
+
raise Exception("No audio files were generated from the conversation blocks")
|
| 313 |
+
|
| 314 |
+
print(f"\nGenerated {len(audio_files)} audio files in total")
|
| 315 |
+
|
| 316 |
+
# Print the final order of audio files for verification
|
| 317 |
+
print("\nFinal audio file order before merging:")
|
| 318 |
+
for i, file in enumerate(audio_files):
|
| 319 |
+
print(f"{i+1}. {os.path.basename(file)}")
|
| 320 |
+
|
| 321 |
+
# Merge all audio files
|
| 322 |
+
final_audio = os.path.join(podcast_temp_dir, "final_podcast.mp3")
|
| 323 |
+
print(f"Merging to final audio: {final_audio}")
|
| 324 |
+
|
| 325 |
+
if not self.merge_audio_files(audio_files, final_audio):
|
| 326 |
+
raise Exception("Failed to merge audio files")
|
| 327 |
+
|
| 328 |
+
# Calculate audio duration using ffprobe
|
| 329 |
+
duration = 0
|
| 330 |
+
try:
|
| 331 |
+
cmd = [
|
| 332 |
+
'ffprobe',
|
| 333 |
+
'-v', 'error',
|
| 334 |
+
'-show_entries', 'format=duration',
|
| 335 |
+
'-of', 'default=noprint_wrappers=1:nokey=1',
|
| 336 |
+
final_audio
|
| 337 |
+
]
|
| 338 |
+
duration_result = subprocess.run(cmd, capture_output=True, text=True)
|
| 339 |
+
if duration_result.returncode == 0:
|
| 340 |
+
duration = float(duration_result.stdout.strip())
|
| 341 |
+
print(f"Audio duration: {duration} seconds")
|
| 342 |
+
else:
|
| 343 |
+
print(f"Failed to get audio duration: {duration_result.stderr}")
|
| 344 |
+
except Exception as e:
|
| 345 |
+
print(f"Error calculating duration: {str(e)}")
|
| 346 |
+
# Don't fail the entire process for duration calculation
|
| 347 |
+
|
| 348 |
+
podcast_doc = {
|
| 349 |
+
"topic": topic,
|
| 350 |
+
"research": research,
|
| 351 |
+
"conversation_blocks": conversation_blocks,
|
| 352 |
+
"audio_path": final_audio,
|
| 353 |
+
"created_at": datetime.utcnow(),
|
| 354 |
+
"believer_voice_id": believer_voice_id,
|
| 355 |
+
"skeptic_voice_id": skeptic_voice_id,
|
| 356 |
+
"user_id": user_id,
|
| 357 |
+
"duration": duration # Add duration to MongoDB document
|
| 358 |
+
}
|
| 359 |
+
|
| 360 |
+
result = await podcasts.insert_one(podcast_doc)
|
| 361 |
+
|
| 362 |
+
# Clean up individual audio files but keep the final one
|
| 363 |
+
for audio_file in audio_files:
|
| 364 |
+
if os.path.exists(audio_file):
|
| 365 |
+
os.remove(audio_file)
|
| 366 |
+
|
| 367 |
+
return {
|
| 368 |
+
"podcast_id": str(result.inserted_id),
|
| 369 |
+
"audio_path": final_audio,
|
| 370 |
+
"topic": topic,
|
| 371 |
+
"duration": duration # Return duration in the result
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
except Exception as e:
|
| 375 |
+
# Clean up the temp directory in case of error
|
| 376 |
+
if os.path.exists(podcast_temp_dir):
|
| 377 |
+
shutil.rmtree(podcast_temp_dir)
|
| 378 |
+
logger.exception(f"Error in podcast creation: {str(e)}")
|
| 379 |
+
return {
|
| 380 |
+
"error": str(e)
|
| 381 |
+
}
|
| 382 |
+
|
| 383 |
+
async def get_podcast(self, podcast_id: str) -> Dict:
|
| 384 |
+
"""Retrieve a podcast by ID."""
|
| 385 |
+
try:
|
| 386 |
+
from bson.objectid import ObjectId
|
| 387 |
+
podcast = await podcasts.find_one({"_id": ObjectId(podcast_id)})
|
| 388 |
+
if podcast:
|
| 389 |
+
podcast["_id"] = str(podcast["_id"])
|
| 390 |
+
return podcast
|
| 391 |
+
return {"error": "Podcast not found"}
|
| 392 |
+
except Exception as e:
|
| 393 |
+
return {"error": str(e)}
|
backend/app/agents/researcher.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from langchain_core.prompts import ChatPromptTemplate
|
| 2 |
+
from langchain_openai import ChatOpenAI
|
| 3 |
+
from langchain_community.tools.tavily_search import TavilySearchResults
|
| 4 |
+
from langchain.agents import AgentExecutor, create_openai_functions_agent
|
| 5 |
+
from decouple import config
|
| 6 |
+
from typing import AsyncGenerator, List
|
| 7 |
+
import os
|
| 8 |
+
import json
|
| 9 |
+
|
| 10 |
+
# Get API keys from environment
|
| 11 |
+
TAVILY_API_KEY = config('TAVILY_API_KEY')
|
| 12 |
+
OPENAI_API_KEY = config('OPENAI_API_KEY')
|
| 13 |
+
|
| 14 |
+
# Debug logging
|
| 15 |
+
print(f"\nLoaded OpenAI API Key: {OPENAI_API_KEY[:7]}...")
|
| 16 |
+
print(f"Key starts with 'sk-proj-': {OPENAI_API_KEY.startswith('sk-proj-')}")
|
| 17 |
+
print(f"Key starts with 'sk-': {OPENAI_API_KEY.startswith('sk-')}\n")
|
| 18 |
+
|
| 19 |
+
# Set Tavily API key in environment
|
| 20 |
+
os.environ["TAVILY_API_KEY"] = TAVILY_API_KEY
|
| 21 |
+
|
| 22 |
+
# Initialize the search tool
|
| 23 |
+
search_tool = TavilySearchResults(tavily_api_key=TAVILY_API_KEY)
|
| 24 |
+
|
| 25 |
+
# List of available tools for the prompt
|
| 26 |
+
tools_description = """
|
| 27 |
+
Available tools:
|
| 28 |
+
- TavilySearchResults: A search tool that provides comprehensive web search results. Use this to gather information about topics.
|
| 29 |
+
"""
|
| 30 |
+
|
| 31 |
+
# Create the prompt template
|
| 32 |
+
researcher_prompt = ChatPromptTemplate.from_messages([
|
| 33 |
+
("system", """You are an expert researcher tasked with gathering comprehensive information on given topics.
|
| 34 |
+
Your goal is to provide detailed, factual information limited to 500 words.
|
| 35 |
+
Focus on key points, recent developments, and verified facts.
|
| 36 |
+
Structure your response clearly with main points and supporting details.
|
| 37 |
+
Keep your response concise and focused.
|
| 38 |
+
|
| 39 |
+
{tools}
|
| 40 |
+
|
| 41 |
+
Remember to provide accurate and up-to-date information."""),
|
| 42 |
+
("user", "{input}"),
|
| 43 |
+
("assistant", "{agent_scratchpad}")
|
| 44 |
+
])
|
| 45 |
+
|
| 46 |
+
# Initialize the LLM with streaming
|
| 47 |
+
researcher_llm = ChatOpenAI(
|
| 48 |
+
model="gpt-4o-mini",
|
| 49 |
+
temperature=0.3,
|
| 50 |
+
api_key=OPENAI_API_KEY,
|
| 51 |
+
streaming=True
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
# Create the agent
|
| 55 |
+
researcher_agent = create_openai_functions_agent(
|
| 56 |
+
llm=researcher_llm,
|
| 57 |
+
prompt=researcher_prompt,
|
| 58 |
+
tools=[search_tool]
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
# Create the agent executor
|
| 62 |
+
researcher_executor = AgentExecutor(
|
| 63 |
+
agent=researcher_agent,
|
| 64 |
+
tools=[search_tool],
|
| 65 |
+
verbose=True,
|
| 66 |
+
handle_parsing_errors=True,
|
| 67 |
+
return_intermediate_steps=True
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
def chunk_text(text: str, max_length: int = 3800) -> List[str]:
|
| 71 |
+
"""Split text into chunks of maximum length while preserving sentence boundaries."""
|
| 72 |
+
# Split into sentences and trim whitespace
|
| 73 |
+
sentences = [s.strip() for s in text.split('.')]
|
| 74 |
+
sentences = [s + '.' for s in sentences if s]
|
| 75 |
+
|
| 76 |
+
chunks = []
|
| 77 |
+
current_chunk = []
|
| 78 |
+
current_length = 0
|
| 79 |
+
|
| 80 |
+
for sentence in sentences:
|
| 81 |
+
sentence_length = len(sentence)
|
| 82 |
+
if current_length + sentence_length > max_length:
|
| 83 |
+
if current_chunk: # If we have accumulated sentences, join them and add to chunks
|
| 84 |
+
chunks.append(' '.join(current_chunk))
|
| 85 |
+
current_chunk = [sentence]
|
| 86 |
+
current_length = sentence_length
|
| 87 |
+
else: # If a single sentence is too long, split it
|
| 88 |
+
if sentence_length > max_length:
|
| 89 |
+
words = sentence.split()
|
| 90 |
+
temp_chunk = []
|
| 91 |
+
temp_length = 0
|
| 92 |
+
for word in words:
|
| 93 |
+
if temp_length + len(word) + 1 > max_length:
|
| 94 |
+
chunks.append(' '.join(temp_chunk))
|
| 95 |
+
temp_chunk = [word]
|
| 96 |
+
temp_length = len(word)
|
| 97 |
+
else:
|
| 98 |
+
temp_chunk.append(word)
|
| 99 |
+
temp_length += len(word) + 1
|
| 100 |
+
if temp_chunk:
|
| 101 |
+
chunks.append(' '.join(temp_chunk))
|
| 102 |
+
else:
|
| 103 |
+
chunks.append(sentence)
|
| 104 |
+
else:
|
| 105 |
+
current_chunk.append(sentence)
|
| 106 |
+
current_length += sentence_length
|
| 107 |
+
|
| 108 |
+
if current_chunk:
|
| 109 |
+
chunks.append(' '.join(current_chunk))
|
| 110 |
+
|
| 111 |
+
return chunks
|
| 112 |
+
|
| 113 |
+
async def research_topic_stream(topic: str) -> AsyncGenerator[str, None]:
|
| 114 |
+
"""
|
| 115 |
+
Research a topic and stream the results as they are generated.
|
| 116 |
+
"""
|
| 117 |
+
try:
|
| 118 |
+
async for chunk in researcher_executor.astream(
|
| 119 |
+
{
|
| 120 |
+
"input": f"Research this topic thoroughly: {topic}",
|
| 121 |
+
"tools": tools_description
|
| 122 |
+
}
|
| 123 |
+
):
|
| 124 |
+
if isinstance(chunk, dict):
|
| 125 |
+
# Stream intermediate steps for transparency
|
| 126 |
+
if "intermediate_steps" in chunk:
|
| 127 |
+
for step in chunk["intermediate_steps"]:
|
| 128 |
+
yield json.dumps({"type": "intermediate", "content": str(step)}) + "\n"
|
| 129 |
+
|
| 130 |
+
# Stream the final output
|
| 131 |
+
if "output" in chunk:
|
| 132 |
+
yield json.dumps({"type": "final", "content": chunk["output"]}) + "\n"
|
| 133 |
+
else:
|
| 134 |
+
yield json.dumps({"type": "chunk", "content": str(chunk)}) + "\n"
|
| 135 |
+
except Exception as e:
|
| 136 |
+
yield json.dumps({"type": "error", "content": str(e)}) + "\n"
|
| 137 |
+
|
| 138 |
+
async def research_topic(topic: str) -> str:
|
| 139 |
+
"""
|
| 140 |
+
Research a topic and return the complete result.
|
| 141 |
+
Kept for compatibility with existing code.
|
| 142 |
+
"""
|
| 143 |
+
try:
|
| 144 |
+
result = await researcher_executor.ainvoke(
|
| 145 |
+
{
|
| 146 |
+
"input": f"Research this topic thoroughly: {topic}",
|
| 147 |
+
"tools": tools_description
|
| 148 |
+
}
|
| 149 |
+
)
|
| 150 |
+
return result["output"]
|
| 151 |
+
except Exception as e:
|
| 152 |
+
print(f"Error in research: {str(e)}")
|
| 153 |
+
return "Error occurred during research."
|
backend/app/database.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from motor.motor_asyncio import AsyncIOMotorClient
|
| 2 |
+
from decouple import config
|
| 3 |
+
|
| 4 |
+
MONGODB_URL = config('MONGODB_URL')
|
| 5 |
+
client = AsyncIOMotorClient(MONGODB_URL)
|
| 6 |
+
db = client.podcraft
|
| 7 |
+
|
| 8 |
+
# Collections
|
| 9 |
+
users = db.users
|
| 10 |
+
podcasts = db.podcasts
|
| 11 |
+
agents = db.agents # New collection for storing agent configurations
|
| 12 |
+
workflows = db.workflows # Collection for storing workflow configurations
|
| 13 |
+
workflows = db.workflows # Collection for storing workflow configurations
|
backend/app/main.py
ADDED
|
@@ -0,0 +1,1020 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI, HTTPException, Depends, status, Request
|
| 2 |
+
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
| 3 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 4 |
+
from fastapi.responses import StreamingResponse, FileResponse
|
| 5 |
+
from fastapi.staticfiles import StaticFiles
|
| 6 |
+
from passlib.context import CryptContext
|
| 7 |
+
from datetime import datetime, timedelta
|
| 8 |
+
from jose import JWTError, jwt
|
| 9 |
+
from decouple import config
|
| 10 |
+
import logging
|
| 11 |
+
from .database import users, podcasts, agents, workflows
|
| 12 |
+
from .models import (
|
| 13 |
+
UserCreate, UserLogin, Token, UserUpdate, UserResponse,
|
| 14 |
+
PodcastRequest, PodcastResponse, AgentCreate, AgentResponse,
|
| 15 |
+
TextPodcastRequest, TextPodcastResponse,
|
| 16 |
+
WorkflowCreate, WorkflowResponse, InsightsData, TranscriptEntry
|
| 17 |
+
)
|
| 18 |
+
from .agents.researcher import research_topic, research_topic_stream
|
| 19 |
+
from .agents.debaters import generate_debate, generate_debate_stream, chunk_text
|
| 20 |
+
from .agents.podcast_manager import PodcastManager
|
| 21 |
+
import json
|
| 22 |
+
import os
|
| 23 |
+
import shutil
|
| 24 |
+
from typing import List
|
| 25 |
+
import time
|
| 26 |
+
from bson import ObjectId
|
| 27 |
+
|
| 28 |
+
# Set up logging
|
| 29 |
+
logging.basicConfig(level=logging.INFO)
|
| 30 |
+
logger = logging.getLogger(__name__)
|
| 31 |
+
|
| 32 |
+
# Debug environment variables
|
| 33 |
+
openai_key = config('OPENAI_API_KEY')
|
| 34 |
+
logger.info(f"Loaded OpenAI API Key at startup: {openai_key[:7]}...")
|
| 35 |
+
logger.info(f"Key starts with 'sk-proj-': {openai_key.startswith('sk-proj-')}")
|
| 36 |
+
logger.info(f"Key starts with 'sk-': {openai_key.startswith('sk-')}")
|
| 37 |
+
|
| 38 |
+
app = FastAPI()
|
| 39 |
+
|
| 40 |
+
# CORS middleware
|
| 41 |
+
app.add_middleware(
|
| 42 |
+
CORSMiddleware,
|
| 43 |
+
allow_origins=["http://localhost:5173"], # React app
|
| 44 |
+
allow_credentials=True,
|
| 45 |
+
allow_methods=["*"],
|
| 46 |
+
allow_headers=["*"],
|
| 47 |
+
expose_headers=["*"] # Expose all headers
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
# Create necessary directories if they don't exist
|
| 51 |
+
os.makedirs("temp", exist_ok=True)
|
| 52 |
+
os.makedirs("temp_audio", exist_ok=True)
|
| 53 |
+
|
| 54 |
+
# Make sure the directory paths are absolute
|
| 55 |
+
TEMP_AUDIO_DIR = os.path.abspath("temp_audio")
|
| 56 |
+
print(f"Mounting temp_audio directory: {TEMP_AUDIO_DIR}")
|
| 57 |
+
|
| 58 |
+
# Mount static directory for audio files
|
| 59 |
+
app.mount("/audio", StaticFiles(directory=TEMP_AUDIO_DIR), name="audio")
|
| 60 |
+
|
| 61 |
+
# Security
|
| 62 |
+
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
| 63 |
+
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
|
| 64 |
+
SECRET_KEY = config("SECRET_KEY")
|
| 65 |
+
ACCESS_TOKEN_EXPIRE_MINUTES = int(config("ACCESS_TOKEN_EXPIRE_MINUTES"))
|
| 66 |
+
|
| 67 |
+
# Helper functions
|
| 68 |
+
def create_access_token(data: dict):
|
| 69 |
+
expires = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
| 70 |
+
data.update({"exp": expires})
|
| 71 |
+
token = jwt.encode(data, SECRET_KEY, algorithm="HS256")
|
| 72 |
+
return token
|
| 73 |
+
|
| 74 |
+
def verify_password(plain_password, hashed_password):
|
| 75 |
+
return pwd_context.verify(plain_password, hashed_password)
|
| 76 |
+
|
| 77 |
+
def get_password_hash(password):
|
| 78 |
+
return pwd_context.hash(password)
|
| 79 |
+
|
| 80 |
+
async def get_current_user(token: str = Depends(oauth2_scheme)):
|
| 81 |
+
logger.info("Authenticating user with token")
|
| 82 |
+
credentials_exception = HTTPException(
|
| 83 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 84 |
+
detail="Could not validate credentials"
|
| 85 |
+
)
|
| 86 |
+
try:
|
| 87 |
+
logger.info("Decoding JWT token")
|
| 88 |
+
payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
|
| 89 |
+
username: str = payload.get("sub")
|
| 90 |
+
if username is None:
|
| 91 |
+
logger.error("No username found in token")
|
| 92 |
+
raise credentials_exception
|
| 93 |
+
logger.info(f"Token decoded successfully for user: {username}")
|
| 94 |
+
except JWTError as e:
|
| 95 |
+
logger.error(f"JWT Error: {str(e)}")
|
| 96 |
+
raise credentials_exception
|
| 97 |
+
|
| 98 |
+
user = await users.find_one({"username": username})
|
| 99 |
+
if user is None:
|
| 100 |
+
logger.error(f"No user found for username: {username}")
|
| 101 |
+
raise credentials_exception
|
| 102 |
+
logger.info(f"User authenticated successfully: {username}")
|
| 103 |
+
return user
|
| 104 |
+
|
| 105 |
+
# Initialize PodcastManager
|
| 106 |
+
podcast_manager = PodcastManager()
|
| 107 |
+
|
| 108 |
+
# Routes
|
| 109 |
+
@app.post("/signup")
|
| 110 |
+
async def signup(user: UserCreate):
|
| 111 |
+
# Check if username exists
|
| 112 |
+
if await users.find_one({"username": user.username}):
|
| 113 |
+
raise HTTPException(status_code=400, detail="Username already registered")
|
| 114 |
+
|
| 115 |
+
# Create new user
|
| 116 |
+
user_dict = user.dict()
|
| 117 |
+
user_dict["password"] = get_password_hash(user.password)
|
| 118 |
+
await users.insert_one(user_dict)
|
| 119 |
+
|
| 120 |
+
# Create and return token after signup
|
| 121 |
+
access_token = create_access_token(data={"sub": user.username})
|
| 122 |
+
return {"access_token": access_token, "token_type": "bearer"}
|
| 123 |
+
|
| 124 |
+
@app.post("/token", response_model=Token)
|
| 125 |
+
async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends()):
|
| 126 |
+
logger.info(f"Token request for user: {form_data.username}")
|
| 127 |
+
# Find user
|
| 128 |
+
db_user = await users.find_one({"username": form_data.username})
|
| 129 |
+
if not db_user or not verify_password(form_data.password, db_user["password"]):
|
| 130 |
+
logger.error(f"Failed token request for user: {form_data.username}")
|
| 131 |
+
raise HTTPException(
|
| 132 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 133 |
+
detail="Incorrect username or password",
|
| 134 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 135 |
+
)
|
| 136 |
+
|
| 137 |
+
# Create access token
|
| 138 |
+
access_token = create_access_token(data={"sub": form_data.username})
|
| 139 |
+
logger.info(f"Token generated successfully for user: {form_data.username}")
|
| 140 |
+
return {"access_token": access_token, "token_type": "bearer"}
|
| 141 |
+
|
| 142 |
+
@app.post("/login", response_model=Token)
|
| 143 |
+
async def login(request: Request, user: UserLogin):
|
| 144 |
+
logger.info(f"Login attempt for user: {user.username}")
|
| 145 |
+
# Find user
|
| 146 |
+
db_user = await users.find_one({"username": user.username})
|
| 147 |
+
if not db_user or not verify_password(user.password, db_user["password"]):
|
| 148 |
+
logger.error(f"Failed login attempt for user: {user.username}")
|
| 149 |
+
raise HTTPException(
|
| 150 |
+
status_code=401,
|
| 151 |
+
detail="Incorrect username or password"
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
# Create access token
|
| 155 |
+
access_token = create_access_token(data={"sub": user.username})
|
| 156 |
+
logger.info(f"Login successful for user: {user.username}")
|
| 157 |
+
return {"access_token": access_token, "token_type": "bearer"}
|
| 158 |
+
|
| 159 |
+
@app.get("/user/me", response_model=UserResponse)
|
| 160 |
+
async def get_user_profile(current_user: dict = Depends(get_current_user)):
|
| 161 |
+
return {
|
| 162 |
+
"username": current_user["username"]
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
@app.put("/user/update-password")
|
| 166 |
+
async def update_password(user_update: UserUpdate, current_user: dict = Depends(get_current_user)):
|
| 167 |
+
hashed_password = get_password_hash(user_update.password)
|
| 168 |
+
await users.update_one(
|
| 169 |
+
{"username": current_user["username"]},
|
| 170 |
+
{"$set": {"password": hashed_password}}
|
| 171 |
+
)
|
| 172 |
+
return {"message": "Password updated successfully"}
|
| 173 |
+
|
| 174 |
+
@app.get("/")
|
| 175 |
+
async def root():
|
| 176 |
+
return {"message": "Welcome to PodCraft API"}
|
| 177 |
+
|
| 178 |
+
# New podcast endpoints
|
| 179 |
+
@app.post("/generate-podcast", response_model=PodcastResponse)
|
| 180 |
+
async def generate_podcast(request: Request, podcast_req: PodcastRequest, current_user: dict = Depends(get_current_user)):
|
| 181 |
+
logger.info(f"Received podcast generation request for topic: {podcast_req.topic}")
|
| 182 |
+
logger.info(f"Request headers: {dict(request.headers)}")
|
| 183 |
+
|
| 184 |
+
try:
|
| 185 |
+
# Step 1: Research the topic
|
| 186 |
+
logger.info("Starting research phase")
|
| 187 |
+
research_results = await research_topic(podcast_req.topic)
|
| 188 |
+
logger.info("Research phase completed")
|
| 189 |
+
|
| 190 |
+
# Step 2: Generate debate between believer and skeptic
|
| 191 |
+
logger.info("Starting debate generation")
|
| 192 |
+
conversation_blocks = await generate_debate(
|
| 193 |
+
research=research_results,
|
| 194 |
+
believer_name=podcast_req.believer_voice_id,
|
| 195 |
+
skeptic_name=podcast_req.skeptic_voice_id
|
| 196 |
+
)
|
| 197 |
+
|
| 198 |
+
if not conversation_blocks:
|
| 199 |
+
logger.error("Failed to generate debate - no conversation blocks returned")
|
| 200 |
+
raise HTTPException(status_code=500, detail="Failed to generate debate")
|
| 201 |
+
|
| 202 |
+
logger.info("Debate generation completed")
|
| 203 |
+
|
| 204 |
+
# Step 3: Create podcast using TTS and store in MongoDB
|
| 205 |
+
logger.info("Starting podcast creation with TTS")
|
| 206 |
+
result = await podcast_manager.create_podcast(
|
| 207 |
+
topic=podcast_req.topic,
|
| 208 |
+
research=research_results,
|
| 209 |
+
conversation_blocks=conversation_blocks,
|
| 210 |
+
believer_voice_id=podcast_req.believer_voice_id,
|
| 211 |
+
skeptic_voice_id=podcast_req.skeptic_voice_id
|
| 212 |
+
)
|
| 213 |
+
|
| 214 |
+
if "error" in result:
|
| 215 |
+
logger.error(f"Error in podcast creation: {result['error']}")
|
| 216 |
+
raise HTTPException(status_code=500, detail=result["error"])
|
| 217 |
+
|
| 218 |
+
logger.info(f"Podcast generated successfully with ID: {result.get('podcast_id')}")
|
| 219 |
+
return result
|
| 220 |
+
except Exception as e:
|
| 221 |
+
logger.error(f"Error in podcast generation: {str(e)}", exc_info=True)
|
| 222 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 223 |
+
|
| 224 |
+
@app.get("/podcast/{podcast_id}", response_model=PodcastResponse)
|
| 225 |
+
async def get_podcast(podcast_id: str, current_user: dict = Depends(get_current_user)):
|
| 226 |
+
try:
|
| 227 |
+
result = await podcast_manager.get_podcast(podcast_id)
|
| 228 |
+
if "error" in result:
|
| 229 |
+
raise HTTPException(status_code=404, detail=result["error"])
|
| 230 |
+
return result
|
| 231 |
+
except Exception as e:
|
| 232 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 233 |
+
|
| 234 |
+
@app.post("/generate-podcast/stream")
|
| 235 |
+
async def generate_podcast_stream(request: PodcastRequest, current_user: dict = Depends(get_current_user)):
|
| 236 |
+
async def generate():
|
| 237 |
+
try:
|
| 238 |
+
# Store complete responses for podcast creation
|
| 239 |
+
believer_turns = {} # Store responses by turn number
|
| 240 |
+
skeptic_turns = {} # Store responses by turn number
|
| 241 |
+
|
| 242 |
+
# Stream research results
|
| 243 |
+
logger.info("Starting research phase (streaming)")
|
| 244 |
+
research_results = ""
|
| 245 |
+
async for chunk in research_topic_stream(request.topic):
|
| 246 |
+
yield chunk
|
| 247 |
+
if isinstance(chunk, str) and "final" in chunk:
|
| 248 |
+
data = json.loads(chunk)
|
| 249 |
+
if data["type"] == "final":
|
| 250 |
+
research_results = data["content"]
|
| 251 |
+
|
| 252 |
+
# Stream debate and track turns properly
|
| 253 |
+
logger.info("Starting debate phase (streaming)")
|
| 254 |
+
async for chunk in generate_debate_stream(
|
| 255 |
+
research=research_results,
|
| 256 |
+
believer_name=request.believer_voice_id,
|
| 257 |
+
skeptic_name=request.skeptic_voice_id
|
| 258 |
+
):
|
| 259 |
+
yield chunk
|
| 260 |
+
# Parse the chunk
|
| 261 |
+
data = json.loads(chunk)
|
| 262 |
+
|
| 263 |
+
# Track responses by turn to maintain proper ordering
|
| 264 |
+
if data["type"] == "believer" and "turn" in data:
|
| 265 |
+
turn = data["turn"]
|
| 266 |
+
if turn not in believer_turns:
|
| 267 |
+
believer_turns[turn] = ""
|
| 268 |
+
believer_turns[turn] += data["content"]
|
| 269 |
+
elif data["type"] == "skeptic" and "turn" in data:
|
| 270 |
+
turn = data["turn"]
|
| 271 |
+
if turn not in skeptic_turns:
|
| 272 |
+
skeptic_turns[turn] = ""
|
| 273 |
+
skeptic_turns[turn] += data["content"]
|
| 274 |
+
|
| 275 |
+
# Create strictly alternating conversation blocks for podcast
|
| 276 |
+
blocks = []
|
| 277 |
+
|
| 278 |
+
# Find the maximum turn number
|
| 279 |
+
max_turn = max(
|
| 280 |
+
max(skeptic_turns.keys()) if skeptic_turns else 0,
|
| 281 |
+
max(believer_turns.keys()) if believer_turns else 0
|
| 282 |
+
)
|
| 283 |
+
|
| 284 |
+
logger.info(f"Creating podcast with {len(believer_turns)} believer turns and {len(skeptic_turns)} skeptic turns")
|
| 285 |
+
logger.info(f"Max turn number: {max_turn}")
|
| 286 |
+
|
| 287 |
+
# Create blocks in strict turn order: Skeptic 1, Believer 1, Skeptic 2, Believer 2, etc.
|
| 288 |
+
for turn in range(1, max_turn + 1):
|
| 289 |
+
# First Skeptic's turn
|
| 290 |
+
if turn in skeptic_turns and skeptic_turns[turn].strip():
|
| 291 |
+
blocks.append({
|
| 292 |
+
"name": f"{request.skeptic_voice_id}'s Turn {turn}",
|
| 293 |
+
"input": skeptic_turns[turn],
|
| 294 |
+
"silence_before": 1,
|
| 295 |
+
"voice_id": request.skeptic_voice_id,
|
| 296 |
+
"emotion": "neutral",
|
| 297 |
+
"model": "tts-1",
|
| 298 |
+
"speed": 1,
|
| 299 |
+
"duration": 0,
|
| 300 |
+
"type": "skeptic",
|
| 301 |
+
"turn": turn
|
| 302 |
+
})
|
| 303 |
+
|
| 304 |
+
# Then Believer's turn
|
| 305 |
+
if turn in believer_turns and believer_turns[turn].strip():
|
| 306 |
+
blocks.append({
|
| 307 |
+
"name": f"{request.believer_voice_id}'s Turn {turn}",
|
| 308 |
+
"input": believer_turns[turn],
|
| 309 |
+
"silence_before": 1,
|
| 310 |
+
"voice_id": request.believer_voice_id,
|
| 311 |
+
"emotion": "neutral",
|
| 312 |
+
"model": "tts-1",
|
| 313 |
+
"speed": 1,
|
| 314 |
+
"duration": 0,
|
| 315 |
+
"type": "believer",
|
| 316 |
+
"turn": turn
|
| 317 |
+
})
|
| 318 |
+
|
| 319 |
+
# Log the conversational structure for debugging
|
| 320 |
+
turn_structure = [f"{block.get('type', 'unknown')}-{block.get('turn', 'unknown')}" for block in blocks]
|
| 321 |
+
logger.info(f"Conversation structure: {turn_structure}")
|
| 322 |
+
|
| 323 |
+
# Create podcast using TTS and store in MongoDB
|
| 324 |
+
logger.info("Starting podcast creation with TTS")
|
| 325 |
+
result = await podcast_manager.create_podcast(
|
| 326 |
+
topic=request.topic,
|
| 327 |
+
research=research_results,
|
| 328 |
+
conversation_blocks=blocks,
|
| 329 |
+
believer_voice_id=request.believer_voice_id,
|
| 330 |
+
skeptic_voice_id=request.skeptic_voice_id,
|
| 331 |
+
user_id=str(current_user["_id"])
|
| 332 |
+
)
|
| 333 |
+
|
| 334 |
+
if "error" in result:
|
| 335 |
+
logger.error(f"Error in podcast creation: {result['error']}")
|
| 336 |
+
yield json.dumps({"type": "error", "content": result["error"]}) + "\n"
|
| 337 |
+
else:
|
| 338 |
+
logger.info(f"Podcast generated successfully with ID: {result.get('podcast_id')}")
|
| 339 |
+
# Create audio URL from the audio path
|
| 340 |
+
audio_url = f"/audio/{os.path.basename(os.path.dirname(result['audio_path']))}/final_podcast.mp3"
|
| 341 |
+
yield json.dumps({
|
| 342 |
+
"type": "success",
|
| 343 |
+
"content": f"Podcast created successfully! ID: {result.get('podcast_id')}",
|
| 344 |
+
"podcast_url": audio_url
|
| 345 |
+
}) + "\n"
|
| 346 |
+
|
| 347 |
+
except Exception as e:
|
| 348 |
+
logger.error(f"Error in streaming podcast generation: {str(e)}")
|
| 349 |
+
yield json.dumps({"type": "error", "content": str(e)}) + "\n"
|
| 350 |
+
|
| 351 |
+
return StreamingResponse(
|
| 352 |
+
generate(),
|
| 353 |
+
media_type="text/event-stream"
|
| 354 |
+
)
|
| 355 |
+
|
| 356 |
+
@app.get("/podcasts")
|
| 357 |
+
async def list_podcasts(current_user: dict = Depends(get_current_user)):
|
| 358 |
+
try:
|
| 359 |
+
# Query podcasts for the current user
|
| 360 |
+
cursor = podcasts.find({"user_id": str(current_user["_id"])})
|
| 361 |
+
podcast_list = []
|
| 362 |
+
async for podcast in cursor:
|
| 363 |
+
# Convert MongoDB _id to string and create audio URL
|
| 364 |
+
podcast["_id"] = str(podcast["_id"])
|
| 365 |
+
if "audio_path" in podcast:
|
| 366 |
+
audio_url = f"/audio/{os.path.basename(os.path.dirname(podcast['audio_path']))}/final_podcast.mp3"
|
| 367 |
+
podcast["audio_url"] = f"http://localhost:8000{audio_url}"
|
| 368 |
+
podcast_list.append(podcast)
|
| 369 |
+
return podcast_list
|
| 370 |
+
except Exception as e:
|
| 371 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 372 |
+
|
| 373 |
+
@app.get("/podcasts/latest")
|
| 374 |
+
async def get_latest_podcast(current_user: dict = Depends(get_current_user)):
|
| 375 |
+
try:
|
| 376 |
+
# Query podcasts for the current user, sorted by creation date (newest first)
|
| 377 |
+
from bson.objectid import ObjectId
|
| 378 |
+
|
| 379 |
+
# Find the most recent podcast for this user
|
| 380 |
+
latest_podcast = await podcasts.find_one(
|
| 381 |
+
{"user_id": str(current_user["_id"])},
|
| 382 |
+
sort=[("created_at", -1)] # Sort by created_at in descending order
|
| 383 |
+
)
|
| 384 |
+
|
| 385 |
+
if not latest_podcast:
|
| 386 |
+
return {"message": "No podcasts found"}
|
| 387 |
+
|
| 388 |
+
# Convert MongoDB _id to string and create audio URL
|
| 389 |
+
latest_podcast["_id"] = str(latest_podcast["_id"])
|
| 390 |
+
|
| 391 |
+
if "audio_path" in latest_podcast:
|
| 392 |
+
audio_url = f"/audio/{os.path.basename(os.path.dirname(latest_podcast['audio_path']))}/final_podcast.mp3"
|
| 393 |
+
latest_podcast["audio_url"] = f"http://localhost:8000{audio_url}"
|
| 394 |
+
|
| 395 |
+
logger.info(f"Latest podcast found: {latest_podcast['topic']}")
|
| 396 |
+
return latest_podcast
|
| 397 |
+
except Exception as e:
|
| 398 |
+
logger.error(f"Error getting latest podcast: {str(e)}")
|
| 399 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 400 |
+
|
| 401 |
+
@app.delete("/podcast/{podcast_id}")
|
| 402 |
+
async def delete_podcast(podcast_id: str, current_user: dict = Depends(get_current_user)):
|
| 403 |
+
try:
|
| 404 |
+
# Convert string ID to ObjectId
|
| 405 |
+
from bson.objectid import ObjectId
|
| 406 |
+
podcast_obj_id = ObjectId(podcast_id)
|
| 407 |
+
|
| 408 |
+
# Find the podcast first to get its audio path
|
| 409 |
+
podcast = await podcasts.find_one({"_id": podcast_obj_id, "user_id": str(current_user["_id"])})
|
| 410 |
+
if not podcast:
|
| 411 |
+
raise HTTPException(status_code=404, detail="Podcast not found")
|
| 412 |
+
|
| 413 |
+
# Delete the podcast from MongoDB
|
| 414 |
+
result = await podcasts.delete_one({"_id": podcast_obj_id, "user_id": str(current_user["_id"])})
|
| 415 |
+
|
| 416 |
+
if result.deleted_count == 0:
|
| 417 |
+
raise HTTPException(status_code=404, detail="Podcast not found")
|
| 418 |
+
|
| 419 |
+
# Delete the associated audio files if they exist
|
| 420 |
+
if "audio_path" in podcast:
|
| 421 |
+
audio_dir = os.path.dirname(podcast["audio_path"])
|
| 422 |
+
if os.path.exists(audio_dir):
|
| 423 |
+
shutil.rmtree(audio_dir)
|
| 424 |
+
|
| 425 |
+
return {"message": "Podcast deleted successfully"}
|
| 426 |
+
except Exception as e:
|
| 427 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 428 |
+
|
| 429 |
+
@app.post("/agents/create", response_model=AgentResponse)
|
| 430 |
+
async def create_agent(agent: AgentCreate, current_user: dict = Depends(get_current_user)):
|
| 431 |
+
"""Create a new agent configuration for the current user."""
|
| 432 |
+
try:
|
| 433 |
+
# Convert the user ID to string to ensure consistent handling
|
| 434 |
+
user_id = str(current_user["_id"])
|
| 435 |
+
|
| 436 |
+
# Prepare agent data
|
| 437 |
+
agent_data = {
|
| 438 |
+
**agent.dict(),
|
| 439 |
+
"user_id": user_id,
|
| 440 |
+
"created_at": datetime.utcnow()
|
| 441 |
+
}
|
| 442 |
+
|
| 443 |
+
# Insert the agent into the database
|
| 444 |
+
result = await agents.insert_one(agent_data)
|
| 445 |
+
|
| 446 |
+
# Return the created agent with its ID
|
| 447 |
+
created_agent = await agents.find_one({"_id": result.inserted_id})
|
| 448 |
+
if not created_agent:
|
| 449 |
+
raise HTTPException(status_code=500, detail="Failed to retrieve created agent")
|
| 450 |
+
|
| 451 |
+
return {
|
| 452 |
+
"agent_id": str(created_agent["_id"]),
|
| 453 |
+
**{k: v for k, v in created_agent.items() if k != "_id"}
|
| 454 |
+
}
|
| 455 |
+
except Exception as e:
|
| 456 |
+
logger.error(f"Error creating agent: {str(e)}")
|
| 457 |
+
raise HTTPException(status_code=500, detail=f"Failed to create agent: {str(e)}")
|
| 458 |
+
|
| 459 |
+
@app.get("/agents", response_model=List[AgentResponse])
|
| 460 |
+
async def list_agents(current_user: dict = Depends(get_current_user)):
|
| 461 |
+
"""List all agents created by the current user."""
|
| 462 |
+
try:
|
| 463 |
+
# Convert user ID to string for consistent handling
|
| 464 |
+
user_id = str(current_user["_id"])
|
| 465 |
+
user_agents = []
|
| 466 |
+
|
| 467 |
+
# Find agents for the current user
|
| 468 |
+
async for agent in agents.find({"user_id": user_id}):
|
| 469 |
+
user_agents.append({
|
| 470 |
+
"agent_id": str(agent["_id"]),
|
| 471 |
+
**{k: v for k, v in agent.items() if k != "_id"}
|
| 472 |
+
})
|
| 473 |
+
|
| 474 |
+
return user_agents
|
| 475 |
+
except Exception as e:
|
| 476 |
+
logger.error(f"Error listing agents: {str(e)}")
|
| 477 |
+
raise HTTPException(status_code=500, detail=f"Failed to list agents: {str(e)}")
|
| 478 |
+
|
| 479 |
+
@app.post("/agents/test-voice")
|
| 480 |
+
async def test_agent_voice(request: Request):
|
| 481 |
+
try:
|
| 482 |
+
# Parse request body
|
| 483 |
+
data = await request.json()
|
| 484 |
+
text = data.get("text")
|
| 485 |
+
voice_id = data.get("voice_id")
|
| 486 |
+
emotion = data.get("emotion", "neutral") # Default emotion
|
| 487 |
+
speed = data.get("speed", 1.0)
|
| 488 |
+
|
| 489 |
+
# Log the received request
|
| 490 |
+
logger.info(f"Test voice request received: voice_id={voice_id}, text={text[:30]}...")
|
| 491 |
+
|
| 492 |
+
if not text or not voice_id:
|
| 493 |
+
logger.error("Missing required fields in test voice request")
|
| 494 |
+
raise HTTPException(status_code=400, detail="Missing required fields (text or voice_id)")
|
| 495 |
+
|
| 496 |
+
# Initialize the podcast manager
|
| 497 |
+
manager = PodcastManager()
|
| 498 |
+
|
| 499 |
+
# Generate a unique filename for this test
|
| 500 |
+
test_filename = f"test_{voice_id}_{int(time.time())}.mp3"
|
| 501 |
+
output_dir = os.path.join("temp_audio", f"test_{int(time.time())}")
|
| 502 |
+
os.makedirs(output_dir, exist_ok=True)
|
| 503 |
+
output_path = os.path.join(output_dir, test_filename)
|
| 504 |
+
|
| 505 |
+
logger.info(f"Generating test audio to {output_path}")
|
| 506 |
+
|
| 507 |
+
# Generate the speech
|
| 508 |
+
success = manager.generate_speech(text, voice_id, output_path)
|
| 509 |
+
|
| 510 |
+
if not success:
|
| 511 |
+
logger.error("Failed to generate test audio")
|
| 512 |
+
raise HTTPException(status_code=500, detail="Failed to generate test audio")
|
| 513 |
+
|
| 514 |
+
# Construct the audio URL
|
| 515 |
+
audio_url = f"/audio/{os.path.basename(output_dir)}/{test_filename}"
|
| 516 |
+
full_audio_url = f"http://localhost:8000{audio_url}"
|
| 517 |
+
|
| 518 |
+
logger.info(f"Test audio generated successfully at {full_audio_url}")
|
| 519 |
+
|
| 520 |
+
# Return the full URL to the generated audio
|
| 521 |
+
return {"audio_url": full_audio_url, "status": "success"}
|
| 522 |
+
|
| 523 |
+
except Exception as e:
|
| 524 |
+
logger.error(f"Error in test_agent_voice: {str(e)}", exc_info=True)
|
| 525 |
+
return {"error": str(e), "status": "error", "audio_url": None}
|
| 526 |
+
|
| 527 |
+
# Add the new PUT endpoint for updating agents
|
| 528 |
+
@app.put("/agents/{agent_id}", response_model=AgentResponse)
|
| 529 |
+
async def update_agent(agent_id: str, agent: AgentCreate, current_user: dict = Depends(get_current_user)):
|
| 530 |
+
"""Update an existing agent configuration."""
|
| 531 |
+
try:
|
| 532 |
+
# Convert user ID to string for consistent handling
|
| 533 |
+
user_id = str(current_user["_id"])
|
| 534 |
+
|
| 535 |
+
# Convert agent_id to ObjectId
|
| 536 |
+
from bson.objectid import ObjectId
|
| 537 |
+
agent_obj_id = ObjectId(agent_id)
|
| 538 |
+
|
| 539 |
+
# Check if agent exists and belongs to user
|
| 540 |
+
existing_agent = await agents.find_one({
|
| 541 |
+
"_id": agent_obj_id,
|
| 542 |
+
"user_id": user_id
|
| 543 |
+
})
|
| 544 |
+
|
| 545 |
+
if not existing_agent:
|
| 546 |
+
raise HTTPException(status_code=404, detail="Agent not found or unauthorized")
|
| 547 |
+
|
| 548 |
+
# Prepare update data
|
| 549 |
+
update_data = {
|
| 550 |
+
**agent.dict(),
|
| 551 |
+
"updated_at": datetime.utcnow()
|
| 552 |
+
}
|
| 553 |
+
|
| 554 |
+
# Update the agent
|
| 555 |
+
result = await agents.update_one(
|
| 556 |
+
{"_id": agent_obj_id},
|
| 557 |
+
{"$set": update_data}
|
| 558 |
+
)
|
| 559 |
+
|
| 560 |
+
if result.modified_count == 0:
|
| 561 |
+
raise HTTPException(status_code=500, detail="Failed to update agent")
|
| 562 |
+
|
| 563 |
+
# Get the updated agent
|
| 564 |
+
updated_agent = await agents.find_one({"_id": agent_obj_id})
|
| 565 |
+
if not updated_agent:
|
| 566 |
+
raise HTTPException(status_code=500, detail="Failed to retrieve updated agent")
|
| 567 |
+
|
| 568 |
+
return {
|
| 569 |
+
"agent_id": str(updated_agent["_id"]),
|
| 570 |
+
**{k: v for k, v in updated_agent.items() if k != "_id"}
|
| 571 |
+
}
|
| 572 |
+
except Exception as e:
|
| 573 |
+
logger.error(f"Error updating agent: {str(e)}")
|
| 574 |
+
raise HTTPException(status_code=500, detail=f"Failed to update agent: {str(e)}")
|
| 575 |
+
|
| 576 |
+
@app.get("/agents/{agent_id}", response_model=AgentResponse)
|
| 577 |
+
async def get_agent(agent_id: str, current_user: dict = Depends(get_current_user)):
|
| 578 |
+
"""Get a specific agent by ID."""
|
| 579 |
+
try:
|
| 580 |
+
# Convert user ID to string for consistent handling
|
| 581 |
+
user_id = str(current_user["_id"])
|
| 582 |
+
|
| 583 |
+
# Convert agent_id to ObjectId
|
| 584 |
+
from bson.objectid import ObjectId
|
| 585 |
+
agent_obj_id = ObjectId(agent_id)
|
| 586 |
+
|
| 587 |
+
# Check if agent exists and belongs to user
|
| 588 |
+
agent = await agents.find_one({
|
| 589 |
+
"_id": agent_obj_id,
|
| 590 |
+
"user_id": user_id
|
| 591 |
+
})
|
| 592 |
+
|
| 593 |
+
if not agent:
|
| 594 |
+
raise HTTPException(status_code=404, detail="Agent not found or unauthorized")
|
| 595 |
+
|
| 596 |
+
# Return the agent data
|
| 597 |
+
return {
|
| 598 |
+
"agent_id": str(agent["_id"]),
|
| 599 |
+
**{k: v for k, v in agent.items() if k != "_id"}
|
| 600 |
+
}
|
| 601 |
+
except Exception as e:
|
| 602 |
+
logger.error(f"Error getting agent: {str(e)}")
|
| 603 |
+
raise HTTPException(status_code=500, detail=f"Failed to get agent: {str(e)}")
|
| 604 |
+
|
| 605 |
+
@app.post("/generate-text-podcast", response_model=TextPodcastResponse)
|
| 606 |
+
async def generate_text_podcast(request: TextPodcastRequest, current_user: dict = Depends(get_current_user)):
|
| 607 |
+
"""Generate a podcast from text input with a single voice and emotion."""
|
| 608 |
+
logger.info(f"Received text-based podcast generation request from user: {current_user['username']}")
|
| 609 |
+
|
| 610 |
+
try:
|
| 611 |
+
# Create conversation block for the single voice
|
| 612 |
+
conversation_blocks = [
|
| 613 |
+
{
|
| 614 |
+
"name": "Voice",
|
| 615 |
+
"input": request.text,
|
| 616 |
+
"silence_before": 1,
|
| 617 |
+
"voice_id": request.voice_id,
|
| 618 |
+
"emotion": request.emotion,
|
| 619 |
+
"model": "tts-1",
|
| 620 |
+
"speed": request.speed,
|
| 621 |
+
"duration": 0
|
| 622 |
+
}
|
| 623 |
+
]
|
| 624 |
+
|
| 625 |
+
# Use the provided title if available, otherwise use generic title
|
| 626 |
+
podcast_title = request.title if hasattr(request, 'title') and request.title else f"Text Podcast {datetime.now().strftime('%Y-%m-%d %H:%M')}"
|
| 627 |
+
podcast_description = request.text[:150] + "..." if len(request.text) > 150 else request.text
|
| 628 |
+
|
| 629 |
+
# Create podcast using TTS
|
| 630 |
+
result = await podcast_manager.create_podcast(
|
| 631 |
+
topic=podcast_title,
|
| 632 |
+
research=podcast_description,
|
| 633 |
+
conversation_blocks=conversation_blocks,
|
| 634 |
+
believer_voice_id=request.voice_id, # Using same voice for both since we only need one
|
| 635 |
+
skeptic_voice_id=request.voice_id,
|
| 636 |
+
user_id=str(current_user["_id"])
|
| 637 |
+
)
|
| 638 |
+
|
| 639 |
+
if "error" in result:
|
| 640 |
+
logger.error(f"Error in podcast creation: {result['error']}")
|
| 641 |
+
return TextPodcastResponse(
|
| 642 |
+
audio_url="",
|
| 643 |
+
status="failed",
|
| 644 |
+
error=result["error"],
|
| 645 |
+
duration=0,
|
| 646 |
+
updated_at=datetime.now().isoformat()
|
| 647 |
+
)
|
| 648 |
+
|
| 649 |
+
# Create audio URL from the audio path
|
| 650 |
+
audio_url = f"/audio/{os.path.basename(os.path.dirname(result['audio_path']))}/final_podcast.mp3"
|
| 651 |
+
full_audio_url = f"http://localhost:8000{audio_url}"
|
| 652 |
+
|
| 653 |
+
logger.info("Successfully generated text-based podcast")
|
| 654 |
+
|
| 655 |
+
return TextPodcastResponse(
|
| 656 |
+
audio_url=full_audio_url,
|
| 657 |
+
duration=result.get("duration", 0),
|
| 658 |
+
status="completed",
|
| 659 |
+
error=None,
|
| 660 |
+
updated_at=datetime.now().isoformat()
|
| 661 |
+
)
|
| 662 |
+
|
| 663 |
+
except Exception as e:
|
| 664 |
+
logger.error(f"Error generating text-based podcast: {str(e)}", exc_info=True)
|
| 665 |
+
return TextPodcastResponse(
|
| 666 |
+
audio_url="",
|
| 667 |
+
status="failed",
|
| 668 |
+
error=str(e),
|
| 669 |
+
duration=0,
|
| 670 |
+
updated_at=datetime.now().isoformat()
|
| 671 |
+
)
|
| 672 |
+
|
| 673 |
+
|
| 674 |
+
@app.get("/api/workflows", response_model=List[WorkflowResponse])
|
| 675 |
+
async def list_workflows(current_user: dict = Depends(get_current_user)):
|
| 676 |
+
try:
|
| 677 |
+
print("\n=== Debug list_workflows ===")
|
| 678 |
+
print(f"Current user object: {current_user}")
|
| 679 |
+
print(f"User ID type: {type(current_user['_id'])}")
|
| 680 |
+
print(f"Username: {current_user['username']}")
|
| 681 |
+
|
| 682 |
+
# Use email as user_id for consistency
|
| 683 |
+
user_id = current_user["username"]
|
| 684 |
+
print(f"Using user_id (email): {user_id}")
|
| 685 |
+
|
| 686 |
+
# Find workflows for this user and convert cursor to list
|
| 687 |
+
workflows_cursor = workflows.find({"user_id": user_id})
|
| 688 |
+
workflows_list = await workflows_cursor.to_list(length=None)
|
| 689 |
+
|
| 690 |
+
print(f"Found {len(workflows_list)} workflows")
|
| 691 |
+
|
| 692 |
+
# Convert MongoDB _id to string and datetime to ISO format for each workflow
|
| 693 |
+
validated_workflows = []
|
| 694 |
+
for workflow in workflows_list:
|
| 695 |
+
print(f"\nProcessing workflow: {workflow}")
|
| 696 |
+
|
| 697 |
+
# Convert MongoDB _id to string
|
| 698 |
+
workflow_data = {
|
| 699 |
+
"id": str(workflow["_id"]),
|
| 700 |
+
"name": workflow["name"],
|
| 701 |
+
"description": workflow.get("description", ""),
|
| 702 |
+
"nodes": workflow.get("nodes", []),
|
| 703 |
+
"edges": workflow.get("edges", []),
|
| 704 |
+
"user_id": workflow["user_id"],
|
| 705 |
+
"created_at": workflow["created_at"].isoformat() if "created_at" in workflow else None,
|
| 706 |
+
"updated_at": workflow["updated_at"].isoformat() if "updated_at" in workflow else None
|
| 707 |
+
}
|
| 708 |
+
|
| 709 |
+
print(f"Converted workflow data: {workflow_data}")
|
| 710 |
+
|
| 711 |
+
# Validate each workflow
|
| 712 |
+
validated_workflow = WorkflowResponse(**workflow_data)
|
| 713 |
+
print(f"Validated workflow: {validated_workflow}")
|
| 714 |
+
|
| 715 |
+
validated_workflows.append(validated_workflow)
|
| 716 |
+
|
| 717 |
+
print(f"Successfully validated {len(validated_workflows)} workflows")
|
| 718 |
+
print("=== End Debug ===\n")
|
| 719 |
+
|
| 720 |
+
return validated_workflows
|
| 721 |
+
except Exception as e:
|
| 722 |
+
print(f"Error in list_workflows: {str(e)}")
|
| 723 |
+
print(f"Error type: {type(e)}")
|
| 724 |
+
import traceback
|
| 725 |
+
print(f"Traceback: {traceback.format_exc()}")
|
| 726 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 727 |
+
|
| 728 |
+
@app.put("/api/workflows/{workflow_id}", response_model=WorkflowResponse)
|
| 729 |
+
async def update_workflow(workflow_id: str, workflow: WorkflowCreate, current_user: dict = Depends(get_current_user)):
|
| 730 |
+
"""Update a specific workflow."""
|
| 731 |
+
try:
|
| 732 |
+
print("\n=== Debug update_workflow ===")
|
| 733 |
+
print(f"Updating workflow ID: {workflow_id}")
|
| 734 |
+
print(f"Current user: {current_user.get('username')}")
|
| 735 |
+
|
| 736 |
+
# Prepare update data
|
| 737 |
+
now = datetime.utcnow()
|
| 738 |
+
|
| 739 |
+
# Convert insights to dict if it's a Pydantic model
|
| 740 |
+
insights_data = workflow.insights
|
| 741 |
+
if isinstance(insights_data, InsightsData):
|
| 742 |
+
insights_data = insights_data.dict()
|
| 743 |
+
print(f"Converted InsightsData to dict: {type(insights_data)}")
|
| 744 |
+
|
| 745 |
+
workflow_data = {
|
| 746 |
+
"name": workflow.name,
|
| 747 |
+
"description": workflow.description,
|
| 748 |
+
"nodes": workflow.nodes,
|
| 749 |
+
"edges": workflow.edges,
|
| 750 |
+
"insights": insights_data, # Use the converted insights
|
| 751 |
+
"updated_at": now
|
| 752 |
+
}
|
| 753 |
+
|
| 754 |
+
print(f"Update data prepared (insights type: {type(workflow_data['insights'])})")
|
| 755 |
+
|
| 756 |
+
# Update the workflow
|
| 757 |
+
result = await workflows.update_one(
|
| 758 |
+
{"_id": ObjectId(workflow_id), "user_id": current_user.get("username")},
|
| 759 |
+
{"$set": workflow_data}
|
| 760 |
+
)
|
| 761 |
+
|
| 762 |
+
if result.modified_count == 0:
|
| 763 |
+
raise HTTPException(status_code=404, detail="Workflow not found")
|
| 764 |
+
|
| 765 |
+
# Get the updated workflow
|
| 766 |
+
updated_workflow = await workflows.find_one({"_id": ObjectId(workflow_id)})
|
| 767 |
+
|
| 768 |
+
# Prepare response data
|
| 769 |
+
response_data = {
|
| 770 |
+
"id": str(updated_workflow["_id"]),
|
| 771 |
+
"name": updated_workflow["name"],
|
| 772 |
+
"description": updated_workflow.get("description", ""),
|
| 773 |
+
"nodes": updated_workflow.get("nodes", []),
|
| 774 |
+
"edges": updated_workflow.get("edges", []),
|
| 775 |
+
"insights": updated_workflow.get("insights", ""), # Add insights field
|
| 776 |
+
"user_id": updated_workflow["user_id"],
|
| 777 |
+
"created_at": updated_workflow["created_at"].isoformat() if "created_at" in updated_workflow else None,
|
| 778 |
+
"updated_at": updated_workflow["updated_at"].isoformat() if "updated_at" in updated_workflow else None
|
| 779 |
+
}
|
| 780 |
+
|
| 781 |
+
print(f"Response data prepared (insights type: {type(response_data['insights'])})")
|
| 782 |
+
|
| 783 |
+
# Create and validate the response model
|
| 784 |
+
response = WorkflowResponse(**response_data)
|
| 785 |
+
print(f"Validated response: {response}")
|
| 786 |
+
print("=== End Debug ===\n")
|
| 787 |
+
|
| 788 |
+
return response
|
| 789 |
+
except Exception as e:
|
| 790 |
+
print(f"Error in update_workflow: {str(e)}")
|
| 791 |
+
print(f"Error type: {type(e)}")
|
| 792 |
+
import traceback
|
| 793 |
+
print(f"Traceback: {traceback.format_exc()}")
|
| 794 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 795 |
+
|
| 796 |
+
@app.delete("/api/workflows/{workflow_id}")
|
| 797 |
+
async def delete_workflow(workflow_id: str, current_user: dict = Depends(get_current_user)):
|
| 798 |
+
"""Delete a specific workflow."""
|
| 799 |
+
try:
|
| 800 |
+
result = await workflows.delete_one({
|
| 801 |
+
"_id": ObjectId(workflow_id),
|
| 802 |
+
"user_id": current_user.get("username") # This is actually the email from the token
|
| 803 |
+
})
|
| 804 |
+
|
| 805 |
+
if result.deleted_count == 0:
|
| 806 |
+
raise HTTPException(status_code=404, detail="Workflow not found")
|
| 807 |
+
|
| 808 |
+
return {"message": "Workflow deleted successfully"}
|
| 809 |
+
except Exception as e:
|
| 810 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 811 |
+
|
| 812 |
+
@app.post("/api/workflows", response_model=WorkflowResponse)
|
| 813 |
+
async def create_workflow(workflow: WorkflowCreate, current_user: dict = Depends(get_current_user)):
|
| 814 |
+
try:
|
| 815 |
+
print("\n=== Debug create_workflow ===")
|
| 816 |
+
print(f"Current user object: {current_user}")
|
| 817 |
+
print(f"Username: {current_user.get('username')}")
|
| 818 |
+
|
| 819 |
+
# Use email from token as user_id for consistency
|
| 820 |
+
user_id = current_user.get("username") # This is actually the email from the token
|
| 821 |
+
print(f"Using user_id (email): {user_id}")
|
| 822 |
+
|
| 823 |
+
# Convert insights to dict if it's a Pydantic model
|
| 824 |
+
insights_data = workflow.insights
|
| 825 |
+
if isinstance(insights_data, InsightsData):
|
| 826 |
+
insights_data = insights_data.dict()
|
| 827 |
+
print(f"Converted InsightsData to dict: {type(insights_data)}")
|
| 828 |
+
|
| 829 |
+
# Create workflow data
|
| 830 |
+
now = datetime.utcnow()
|
| 831 |
+
workflow_data = {
|
| 832 |
+
"name": workflow.name,
|
| 833 |
+
"description": workflow.description,
|
| 834 |
+
"nodes": workflow.nodes,
|
| 835 |
+
"edges": workflow.edges,
|
| 836 |
+
"insights": insights_data, # Use the converted insights
|
| 837 |
+
"user_id": user_id,
|
| 838 |
+
"created_at": now,
|
| 839 |
+
"updated_at": now
|
| 840 |
+
}
|
| 841 |
+
|
| 842 |
+
print(f"Workflow data prepared (insights type: {type(workflow_data['insights'])})")
|
| 843 |
+
|
| 844 |
+
# Insert into database
|
| 845 |
+
result = await workflows.insert_one(workflow_data)
|
| 846 |
+
|
| 847 |
+
# Prepare response data
|
| 848 |
+
response_data = {
|
| 849 |
+
"id": str(result.inserted_id),
|
| 850 |
+
"name": workflow_data["name"],
|
| 851 |
+
"description": workflow_data["description"],
|
| 852 |
+
"nodes": workflow_data["nodes"],
|
| 853 |
+
"edges": workflow_data["edges"],
|
| 854 |
+
"insights": workflow_data.get("insights"), # Add insights field
|
| 855 |
+
"user_id": workflow_data["user_id"],
|
| 856 |
+
"created_at": workflow_data["created_at"].isoformat(),
|
| 857 |
+
"updated_at": workflow_data["updated_at"].isoformat()
|
| 858 |
+
}
|
| 859 |
+
|
| 860 |
+
print(f"Response data prepared (insights type: {type(response_data['insights'])})")
|
| 861 |
+
|
| 862 |
+
# Create and validate the response model
|
| 863 |
+
response = WorkflowResponse(**response_data)
|
| 864 |
+
print(f"Validated response: {response}")
|
| 865 |
+
print("=== End Debug ===\n")
|
| 866 |
+
|
| 867 |
+
return response
|
| 868 |
+
except Exception as e:
|
| 869 |
+
print(f"Error in create_workflow: {str(e)}")
|
| 870 |
+
print(f"Error type: {type(e)}")
|
| 871 |
+
import traceback
|
| 872 |
+
print(f"Traceback: {traceback.format_exc()}")
|
| 873 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 874 |
+
|
| 875 |
+
@app.get("/api/workflows/{workflow_id}", response_model=WorkflowResponse)
|
| 876 |
+
async def get_workflow(workflow_id: str, current_user: dict = Depends(get_current_user)):
|
| 877 |
+
"""Get a specific workflow by ID."""
|
| 878 |
+
try:
|
| 879 |
+
print("\n=== Debug get_workflow ===")
|
| 880 |
+
print(f"Looking for workflow ID: {workflow_id}")
|
| 881 |
+
print(f"Current user: {current_user.get('username')}")
|
| 882 |
+
|
| 883 |
+
workflow = await workflows.find_one({
|
| 884 |
+
"_id": ObjectId(workflow_id),
|
| 885 |
+
"user_id": current_user.get("username") # This is actually the email from the token
|
| 886 |
+
})
|
| 887 |
+
|
| 888 |
+
if workflow is None:
|
| 889 |
+
raise HTTPException(status_code=404, detail="Workflow not found")
|
| 890 |
+
|
| 891 |
+
print(f"Found workflow: {workflow}")
|
| 892 |
+
|
| 893 |
+
# Convert MongoDB _id to string
|
| 894 |
+
workflow["id"] = str(workflow.pop("_id"))
|
| 895 |
+
|
| 896 |
+
# Convert datetime objects to ISO format strings
|
| 897 |
+
if "created_at" in workflow:
|
| 898 |
+
workflow["created_at"] = workflow["created_at"].isoformat()
|
| 899 |
+
print(f"Converted created_at: {workflow['created_at']}")
|
| 900 |
+
|
| 901 |
+
if "updated_at" in workflow:
|
| 902 |
+
workflow["updated_at"] = workflow["updated_at"].isoformat()
|
| 903 |
+
print(f"Converted updated_at: {workflow['updated_at']}")
|
| 904 |
+
|
| 905 |
+
# Ensure all required fields are present
|
| 906 |
+
response_data = {
|
| 907 |
+
"id": workflow["id"],
|
| 908 |
+
"name": workflow["name"],
|
| 909 |
+
"description": workflow.get("description", ""),
|
| 910 |
+
"nodes": workflow.get("nodes", []),
|
| 911 |
+
"edges": workflow.get("edges", []),
|
| 912 |
+
"insights": workflow.get("insights", ""), # Add insights field
|
| 913 |
+
"user_id": workflow["user_id"],
|
| 914 |
+
"created_at": workflow.get("created_at"),
|
| 915 |
+
"updated_at": workflow.get("updated_at")
|
| 916 |
+
}
|
| 917 |
+
|
| 918 |
+
print(f"Response data: {response_data}")
|
| 919 |
+
|
| 920 |
+
# Create and validate the response model
|
| 921 |
+
response = WorkflowResponse(**response_data)
|
| 922 |
+
print(f"Validated response: {response}")
|
| 923 |
+
print("=== End Debug ===\n")
|
| 924 |
+
|
| 925 |
+
return response
|
| 926 |
+
except Exception as e:
|
| 927 |
+
logger.error(f"Error in get_workflow: {str(e)}")
|
| 928 |
+
print(f"Error in get_workflow: {str(e)}")
|
| 929 |
+
print(f"Error type: {type(e)}")
|
| 930 |
+
import traceback
|
| 931 |
+
print(f"Traceback: {traceback.format_exc()}")
|
| 932 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 933 |
+
|
| 934 |
+
@app.post("/direct-podcast", response_model=TextPodcastResponse)
|
| 935 |
+
async def create_direct_podcast(request: Request, current_user: dict = Depends(get_current_user)):
|
| 936 |
+
"""Generate a podcast directly from conversation blocks with different voices."""
|
| 937 |
+
logger.info(f"Received direct podcast generation request from user: {current_user['username']}")
|
| 938 |
+
|
| 939 |
+
try:
|
| 940 |
+
# Parse the request body
|
| 941 |
+
data = await request.json()
|
| 942 |
+
topic = data.get("topic", "Debate")
|
| 943 |
+
conversation_blocks = data.get("conversation_blocks", [])
|
| 944 |
+
|
| 945 |
+
logger.info(f"Direct podcast request for topic: {topic}")
|
| 946 |
+
logger.info(f"Number of conversation blocks: {len(conversation_blocks)}")
|
| 947 |
+
|
| 948 |
+
if not conversation_blocks:
|
| 949 |
+
raise HTTPException(status_code=400, detail="No conversation blocks provided")
|
| 950 |
+
|
| 951 |
+
# Format conversation blocks for the podcast manager
|
| 952 |
+
formatted_blocks = []
|
| 953 |
+
for idx, block in enumerate(conversation_blocks):
|
| 954 |
+
# Extract data from each block
|
| 955 |
+
content = block.get("content", "")
|
| 956 |
+
voice_id = block.get("voice_id", "alloy") # Default to alloy if not specified
|
| 957 |
+
block_type = block.get("type", "generic")
|
| 958 |
+
turn = block.get("turn", idx + 1)
|
| 959 |
+
agent_id = block.get("agent_id", "")
|
| 960 |
+
|
| 961 |
+
# Format for podcast manager
|
| 962 |
+
formatted_block = {
|
| 963 |
+
"name": f"Turn {turn}",
|
| 964 |
+
"input": content,
|
| 965 |
+
"silence_before": 0.3, # Short pause between blocks
|
| 966 |
+
"voice_id": voice_id,
|
| 967 |
+
"emotion": "neutral",
|
| 968 |
+
"model": "tts-1",
|
| 969 |
+
"speed": 1.0,
|
| 970 |
+
"duration": 0,
|
| 971 |
+
"type": block_type,
|
| 972 |
+
"turn": turn,
|
| 973 |
+
"agent_id": agent_id
|
| 974 |
+
}
|
| 975 |
+
|
| 976 |
+
formatted_blocks.append(formatted_block)
|
| 977 |
+
|
| 978 |
+
# Use the podcast manager to create the audio
|
| 979 |
+
result = await podcast_manager.create_podcast(
|
| 980 |
+
topic=topic,
|
| 981 |
+
research=f"Direct podcast on {topic}",
|
| 982 |
+
conversation_blocks=formatted_blocks,
|
| 983 |
+
believer_voice_id="alloy", # These are just placeholders for the manager
|
| 984 |
+
skeptic_voice_id="echo",
|
| 985 |
+
user_id=str(current_user["_id"])
|
| 986 |
+
)
|
| 987 |
+
|
| 988 |
+
if "error" in result:
|
| 989 |
+
logger.error(f"Error in direct podcast creation: {result['error']}")
|
| 990 |
+
return TextPodcastResponse(
|
| 991 |
+
audio_url="",
|
| 992 |
+
status="failed",
|
| 993 |
+
error=result["error"],
|
| 994 |
+
duration=0,
|
| 995 |
+
updated_at=datetime.now().isoformat()
|
| 996 |
+
)
|
| 997 |
+
|
| 998 |
+
# Create audio URL from the audio path
|
| 999 |
+
audio_url = f"/audio/{os.path.basename(os.path.dirname(result['audio_path']))}/final_podcast.mp3"
|
| 1000 |
+
full_audio_url = f"http://localhost:8000{audio_url}"
|
| 1001 |
+
|
| 1002 |
+
logger.info(f"Successfully generated direct podcast: {result.get('podcast_id')}")
|
| 1003 |
+
|
| 1004 |
+
return TextPodcastResponse(
|
| 1005 |
+
audio_url=full_audio_url,
|
| 1006 |
+
duration=result.get("duration", 0),
|
| 1007 |
+
status="completed",
|
| 1008 |
+
error=None,
|
| 1009 |
+
updated_at=datetime.now().isoformat()
|
| 1010 |
+
)
|
| 1011 |
+
|
| 1012 |
+
except Exception as e:
|
| 1013 |
+
logger.error(f"Error generating direct podcast: {str(e)}", exc_info=True)
|
| 1014 |
+
return TextPodcastResponse(
|
| 1015 |
+
audio_url="",
|
| 1016 |
+
status="failed",
|
| 1017 |
+
error=str(e),
|
| 1018 |
+
duration=0,
|
| 1019 |
+
updated_at=datetime.now().isoformat()
|
| 1020 |
+
)
|
backend/app/models.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel, Field
|
| 2 |
+
from typing import Optional, List, Dict, Union, Any
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
|
| 5 |
+
class UserCreate(BaseModel):
|
| 6 |
+
username: str
|
| 7 |
+
password: str
|
| 8 |
+
|
| 9 |
+
class UserLogin(BaseModel):
|
| 10 |
+
username: str
|
| 11 |
+
password: str
|
| 12 |
+
|
| 13 |
+
class Token(BaseModel):
|
| 14 |
+
access_token: str
|
| 15 |
+
token_type: str
|
| 16 |
+
|
| 17 |
+
class UserUpdate(BaseModel):
|
| 18 |
+
password: str
|
| 19 |
+
|
| 20 |
+
class UserResponse(BaseModel):
|
| 21 |
+
username: str
|
| 22 |
+
|
| 23 |
+
class AgentCreate(BaseModel):
|
| 24 |
+
name: str
|
| 25 |
+
voice_id: str
|
| 26 |
+
voice_name: str
|
| 27 |
+
voice_description: str
|
| 28 |
+
speed: float
|
| 29 |
+
pitch: float
|
| 30 |
+
volume: float
|
| 31 |
+
output_format: str
|
| 32 |
+
personality: str = None # Optional field for agent personality
|
| 33 |
+
|
| 34 |
+
class AgentResponse(BaseModel):
|
| 35 |
+
agent_id: str
|
| 36 |
+
name: str
|
| 37 |
+
voice_id: str
|
| 38 |
+
voice_name: str
|
| 39 |
+
voice_description: str
|
| 40 |
+
speed: float
|
| 41 |
+
pitch: float
|
| 42 |
+
volume: float
|
| 43 |
+
output_format: str
|
| 44 |
+
user_id: str
|
| 45 |
+
personality: str = None # Optional field for agent personality
|
| 46 |
+
|
| 47 |
+
class PodcastRequest(BaseModel):
|
| 48 |
+
topic: str
|
| 49 |
+
believer_voice_id: str
|
| 50 |
+
skeptic_voice_id: str
|
| 51 |
+
|
| 52 |
+
class ConversationBlock(BaseModel):
|
| 53 |
+
name: str
|
| 54 |
+
input: str
|
| 55 |
+
silence_before: int
|
| 56 |
+
voice_id: str
|
| 57 |
+
emotion: str
|
| 58 |
+
model: str
|
| 59 |
+
speed: float
|
| 60 |
+
duration: int
|
| 61 |
+
|
| 62 |
+
class PodcastResponse(BaseModel):
|
| 63 |
+
podcast_id: str
|
| 64 |
+
audio_url: Optional[str]
|
| 65 |
+
topic: str
|
| 66 |
+
error: Optional[str]
|
| 67 |
+
|
| 68 |
+
# Models for structured debate transcript and insights
|
| 69 |
+
class TranscriptEntry(BaseModel):
|
| 70 |
+
agentId: str
|
| 71 |
+
agentName: str
|
| 72 |
+
turn: int
|
| 73 |
+
content: str
|
| 74 |
+
|
| 75 |
+
class InsightsData(BaseModel):
|
| 76 |
+
topic: str
|
| 77 |
+
research: str
|
| 78 |
+
transcript: List[TranscriptEntry]
|
| 79 |
+
keyInsights: List[str]
|
| 80 |
+
conclusion: str
|
| 81 |
+
|
| 82 |
+
# New Workflow Models
|
| 83 |
+
class WorkflowCreate(BaseModel):
|
| 84 |
+
name: str
|
| 85 |
+
description: str
|
| 86 |
+
nodes: List[Dict]
|
| 87 |
+
edges: List[Dict]
|
| 88 |
+
insights: Optional[Union[InsightsData, str]] = None
|
| 89 |
+
|
| 90 |
+
class WorkflowResponse(BaseModel):
|
| 91 |
+
id: str
|
| 92 |
+
name: str
|
| 93 |
+
description: str
|
| 94 |
+
nodes: List[Dict]
|
| 95 |
+
edges: List[Dict]
|
| 96 |
+
insights: Optional[Union[InsightsData, str]] = None
|
| 97 |
+
user_id: str
|
| 98 |
+
created_at: Optional[str]
|
| 99 |
+
updated_at: Optional[str]
|
| 100 |
+
|
| 101 |
+
class TextPodcastRequest(BaseModel):
|
| 102 |
+
text: str
|
| 103 |
+
voice_id: str = "alloy"
|
| 104 |
+
emotion: str = "neutral"
|
| 105 |
+
speed: float = 1.0
|
| 106 |
+
title: Optional[str] = None
|
| 107 |
+
|
| 108 |
+
class TextPodcastResponse(BaseModel):
|
| 109 |
+
audio_url: str
|
| 110 |
+
duration: Optional[float]
|
| 111 |
+
status: str
|
| 112 |
+
error: Optional[str]
|
| 113 |
+
updated_at: Optional[str]
|
| 114 |
+
|
backend/app/routers/__pycache__/podcast.cpython-311.pyc
ADDED
|
Binary file (2.07 kB). View file
|
|
|
backend/requirements.txt
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.104.1
|
| 2 |
+
uvicorn==0.24.0
|
| 3 |
+
motor==3.1.1
|
| 4 |
+
pymongo==4.3.3
|
| 5 |
+
certifi==2024.2.2
|
| 6 |
+
python-jose[cryptography]==3.3.0
|
| 7 |
+
passlib[bcrypt]==1.7.4
|
| 8 |
+
python-multipart==0.0.6
|
| 9 |
+
python-decouple==3.8
|
| 10 |
+
langgraph==0.2.14
|
| 11 |
+
langchain>=0.1.0
|
| 12 |
+
langchain-openai>=0.0.5
|
| 13 |
+
langchain-core>=0.2.35
|
| 14 |
+
langchain-community>=0.0.24
|
| 15 |
+
pydub==0.25.1
|
backend/run.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import uvicorn
|
| 2 |
+
|
| 3 |
+
if __name__ == "__main__":
|
| 4 |
+
uvicorn.run(
|
| 5 |
+
"app.main:app",
|
| 6 |
+
host="0.0.0.0",
|
| 7 |
+
port=8000,
|
| 8 |
+
reload=True,
|
| 9 |
+
reload_dirs=["app"],
|
| 10 |
+
workers=1,
|
| 11 |
+
ws_ping_interval=None,
|
| 12 |
+
ws_ping_timeout=None,
|
| 13 |
+
timeout_keep_alive=0
|
| 14 |
+
)
|
backend/temp_audio/Default/final_podcast.mp3
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:266ec16b7f8b53b12e8ac9899be9932265a8723abb61c57915fb9ae64438b6fc
|
| 3 |
+
size 408620
|
build_and_deploy.sh
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
set -e
|
| 3 |
+
|
| 4 |
+
echo "===> Building PodCraft for deployment <===="
|
| 5 |
+
|
| 6 |
+
# Navigate to frontend directory
|
| 7 |
+
echo "Building frontend..."
|
| 8 |
+
cd frontend/podcraft
|
| 9 |
+
|
| 10 |
+
# Install dependencies
|
| 11 |
+
echo "Installing frontend dependencies..."
|
| 12 |
+
npm install
|
| 13 |
+
|
| 14 |
+
# Build the frontend
|
| 15 |
+
echo "Creating production build..."
|
| 16 |
+
npm run build
|
| 17 |
+
|
| 18 |
+
# Back to root directory
|
| 19 |
+
cd ../../
|
| 20 |
+
|
| 21 |
+
# Make sure static directory exists
|
| 22 |
+
mkdir -p app/static
|
| 23 |
+
|
| 24 |
+
# Copy build to static directory (this will be mounted by FastAPI)
|
| 25 |
+
echo "Copying frontend build to static directory..."
|
| 26 |
+
cp -r frontend/podcraft/build/* app/static/
|
| 27 |
+
|
| 28 |
+
echo "Building Docker image..."
|
| 29 |
+
docker build -t podcraft-app .
|
| 30 |
+
|
| 31 |
+
echo "Build completed successfully!"
|
| 32 |
+
echo "You can now run: docker run -p 8000:8000 podcraft-app"
|
build_for_spaces.sh
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
set -e
|
| 3 |
+
|
| 4 |
+
echo "===> Building PodCraft for HuggingFace Spaces <===="
|
| 5 |
+
|
| 6 |
+
# Navigate to frontend directory
|
| 7 |
+
echo "Building frontend..."
|
| 8 |
+
cd frontend/podcraft
|
| 9 |
+
|
| 10 |
+
# Install dependencies
|
| 11 |
+
echo "Installing frontend dependencies..."
|
| 12 |
+
npm install
|
| 13 |
+
|
| 14 |
+
# Build the frontend
|
| 15 |
+
echo "Creating production build..."
|
| 16 |
+
npm run build
|
| 17 |
+
|
| 18 |
+
# Back to root directory
|
| 19 |
+
cd ../../
|
| 20 |
+
|
| 21 |
+
# Make sure static directory exists
|
| 22 |
+
mkdir -p app/static
|
| 23 |
+
|
| 24 |
+
# Copy build to static directory (this will be mounted by FastAPI)
|
| 25 |
+
echo "Copying frontend build to static directory..."
|
| 26 |
+
cp -r frontend/podcraft/build/* app/static/
|
| 27 |
+
|
| 28 |
+
# Copy the Spaces Dockerfile to main Dockerfile
|
| 29 |
+
echo "Setting up Dockerfile for Spaces deployment..."
|
| 30 |
+
cp Dockerfile.spaces Dockerfile
|
| 31 |
+
|
| 32 |
+
echo "Build setup completed successfully!"
|
| 33 |
+
echo "You can now push this to your HuggingFace Space repository"
|
docker-compose.yml
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version: '3'
|
| 2 |
+
|
| 3 |
+
services:
|
| 4 |
+
podcraft-app:
|
| 5 |
+
build: .
|
| 6 |
+
ports:
|
| 7 |
+
- "8000:8000"
|
| 8 |
+
volumes:
|
| 9 |
+
- ./app:/app/app
|
| 10 |
+
- ./temp_audio:/app/temp_audio
|
| 11 |
+
environment:
|
| 12 |
+
- MONGODB_URL=mongodb+srv://NageshBM:Nash166^@podcraft.ozqmc.mongodb.net/?retryWrites=true&w=majority&appName=Podcraft
|
| 13 |
+
- SECRET_KEY=your-secret-key-change-in-production
|
| 14 |
+
- OPENAI_API_KEY=${OPENAI_API_KEY}
|
| 15 |
+
restart: always
|
frontend/package-lock.json
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "frontend",
|
| 3 |
+
"lockfileVersion": 3,
|
| 4 |
+
"requires": true,
|
| 5 |
+
"packages": {}
|
| 6 |
+
}
|
frontend/podcraft/.gitignore
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Logs
|
| 2 |
+
logs
|
| 3 |
+
*.log
|
| 4 |
+
npm-debug.log*
|
| 5 |
+
yarn-debug.log*
|
| 6 |
+
yarn-error.log*
|
| 7 |
+
pnpm-debug.log*
|
| 8 |
+
lerna-debug.log*
|
| 9 |
+
|
| 10 |
+
node_modules
|
| 11 |
+
dist
|
| 12 |
+
dist-ssr
|
| 13 |
+
*.local
|
| 14 |
+
|
| 15 |
+
# Editor directories and files
|
| 16 |
+
.vscode/*
|
| 17 |
+
!.vscode/extensions.json
|
| 18 |
+
.idea
|
| 19 |
+
.DS_Store
|
| 20 |
+
*.suo
|
| 21 |
+
*.ntvs*
|
| 22 |
+
*.njsproj
|
| 23 |
+
*.sln
|
| 24 |
+
*.sw?
|
frontend/podcraft/README.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# React + Vite
|
| 2 |
+
|
| 3 |
+
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
| 4 |
+
|
| 5 |
+
Currently, two official plugins are available:
|
| 6 |
+
|
| 7 |
+
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
|
| 8 |
+
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
| 9 |
+
|
| 10 |
+
## Expanding the ESLint configuration
|
| 11 |
+
|
| 12 |
+
If you are developing a production application, we recommend using TypeScript and enable type-aware lint rules. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
|
frontend/podcraft/assets/bg.gif
ADDED
|
Git LFS Details
|
frontend/podcraft/assets/bg2.gif
ADDED
|
Git LFS Details
|
frontend/podcraft/assets/bg3.gif
ADDED
|
Git LFS Details
|
frontend/podcraft/eslint.config.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import js from '@eslint/js'
|
| 2 |
+
import globals from 'globals'
|
| 3 |
+
import reactHooks from 'eslint-plugin-react-hooks'
|
| 4 |
+
import reactRefresh from 'eslint-plugin-react-refresh'
|
| 5 |
+
|
| 6 |
+
export default [
|
| 7 |
+
{ ignores: ['dist'] },
|
| 8 |
+
{
|
| 9 |
+
files: ['**/*.{js,jsx}'],
|
| 10 |
+
languageOptions: {
|
| 11 |
+
ecmaVersion: 2020,
|
| 12 |
+
globals: globals.browser,
|
| 13 |
+
parserOptions: {
|
| 14 |
+
ecmaVersion: 'latest',
|
| 15 |
+
ecmaFeatures: { jsx: true },
|
| 16 |
+
sourceType: 'module',
|
| 17 |
+
},
|
| 18 |
+
},
|
| 19 |
+
plugins: {
|
| 20 |
+
'react-hooks': reactHooks,
|
| 21 |
+
'react-refresh': reactRefresh,
|
| 22 |
+
},
|
| 23 |
+
rules: {
|
| 24 |
+
...js.configs.recommended.rules,
|
| 25 |
+
...reactHooks.configs.recommended.rules,
|
| 26 |
+
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
| 27 |
+
'react-refresh/only-export-components': [
|
| 28 |
+
'warn',
|
| 29 |
+
{ allowConstantExport: true },
|
| 30 |
+
],
|
| 31 |
+
},
|
| 32 |
+
},
|
| 33 |
+
]
|
frontend/podcraft/index.html
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 7 |
+
<title>Vite + React</title>
|
| 8 |
+
</head>
|
| 9 |
+
<body>
|
| 10 |
+
<div id="root"></div>
|
| 11 |
+
<script type="module" src="/src/main.jsx"></script>
|
| 12 |
+
</body>
|
| 13 |
+
</html>
|
frontend/podcraft/package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
frontend/podcraft/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "podcraft",
|
| 3 |
+
"private": true,
|
| 4 |
+
"version": "0.0.0",
|
| 5 |
+
"type": "module",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"dev": "vite",
|
| 8 |
+
"build": "vite build",
|
| 9 |
+
"lint": "eslint .",
|
| 10 |
+
"preview": "vite preview"
|
| 11 |
+
},
|
| 12 |
+
"dependencies": {
|
| 13 |
+
"react": "^19.0.0",
|
| 14 |
+
"react-dom": "^19.0.0",
|
| 15 |
+
"react-icons": "^5.5.0",
|
| 16 |
+
"react-router-dom": "^6.22.3",
|
| 17 |
+
"reactflow": "^11.11.4"
|
| 18 |
+
},
|
| 19 |
+
"devDependencies": {
|
| 20 |
+
"@eslint/js": "^9.21.0",
|
| 21 |
+
"@types/react": "^19.0.10",
|
| 22 |
+
"@types/react-dom": "^19.0.4",
|
| 23 |
+
"@vitejs/plugin-react": "^4.3.4",
|
| 24 |
+
"eslint": "^9.21.0",
|
| 25 |
+
"eslint-plugin-react-hooks": "^5.1.0",
|
| 26 |
+
"eslint-plugin-react-refresh": "^0.4.19",
|
| 27 |
+
"globals": "^15.15.0",
|
| 28 |
+
"vite": "^6.2.0"
|
| 29 |
+
}
|
| 30 |
+
}
|
frontend/podcraft/public/vite.svg
ADDED
|
|
frontend/podcraft/src/App.css
ADDED
|
@@ -0,0 +1,496 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#root {
|
| 2 |
+
width: 100%;
|
| 3 |
+
overflow-x: hidden;
|
| 4 |
+
}
|
| 5 |
+
|
| 6 |
+
body {
|
| 7 |
+
margin: 0;
|
| 8 |
+
padding: 0;
|
| 9 |
+
overflow-x: hidden;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
.logo {
|
| 13 |
+
height: 6em;
|
| 14 |
+
padding: 1.5em;
|
| 15 |
+
will-change: filter;
|
| 16 |
+
transition: filter 300ms;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
.logo:hover {
|
| 20 |
+
filter: drop-shadow(0 0 2em #646cffaa);
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
.logo.react:hover {
|
| 24 |
+
filter: drop-shadow(0 0 2em #61dafbaa);
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
@keyframes logo-spin {
|
| 28 |
+
from {
|
| 29 |
+
transform: rotate(0deg);
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
to {
|
| 33 |
+
transform: rotate(360deg);
|
| 34 |
+
}
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
@media (prefers-reduced-motion: no-preference) {
|
| 38 |
+
a:nth-of-type(2) .logo {
|
| 39 |
+
animation: logo-spin infinite 20s linear;
|
| 40 |
+
}
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
.card {
|
| 44 |
+
padding: 2em;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
.read-the-docs {
|
| 48 |
+
color: #888;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
.app-container {
|
| 52 |
+
display: flex;
|
| 53 |
+
min-height: 100vh;
|
| 54 |
+
transition: all 0.3s ease;
|
| 55 |
+
width: 100%;
|
| 56 |
+
overflow-x: hidden;
|
| 57 |
+
position: relative;
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
.app-container.light {
|
| 61 |
+
background-color: #ffffff;
|
| 62 |
+
color: #000000;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
.app-container.dark {
|
| 66 |
+
/* background-color: #040511; */
|
| 67 |
+
color: #ffffff;
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
.sidebar {
|
| 71 |
+
height: 100vh;
|
| 72 |
+
padding: 0.5rem;
|
| 73 |
+
display: flex;
|
| 74 |
+
flex-direction: column;
|
| 75 |
+
transition: all 0.3s ease-in-out;
|
| 76 |
+
position: fixed;
|
| 77 |
+
left: 0;
|
| 78 |
+
top: 0;
|
| 79 |
+
background-color: rgba(0, 0, 0, 0.1);
|
| 80 |
+
backdrop-filter: blur(8px);
|
| 81 |
+
z-index: 999;
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
.sidebar-top {
|
| 85 |
+
display: flex;
|
| 86 |
+
flex-direction: column;
|
| 87 |
+
gap: 0.5rem;
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
.sidebar-bottom {
|
| 91 |
+
margin-top: auto;
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
.sidebar.open {
|
| 95 |
+
width: 200px;
|
| 96 |
+
z-index: 1;
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
.sidebar.closed {
|
| 100 |
+
width: 40px;
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
.toggle-btn {
|
| 104 |
+
background: none;
|
| 105 |
+
border: none;
|
| 106 |
+
color: #fff;
|
| 107 |
+
font-size: 1.25rem;
|
| 108 |
+
cursor: pointer;
|
| 109 |
+
padding: 0.5rem;
|
| 110 |
+
margin-bottom: 1rem;
|
| 111 |
+
transition: color 0.3s ease;
|
| 112 |
+
text-align: left;
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
.toggle-btn:hover {
|
| 116 |
+
color: rgba(255, 255, 255, 0.8);
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
.nav-links {
|
| 120 |
+
display: flex;
|
| 121 |
+
flex-direction: column;
|
| 122 |
+
gap: 0.5rem;
|
| 123 |
+
margin-top: 1rem;
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
.nav-link {
|
| 127 |
+
display: flex;
|
| 128 |
+
align-items: center;
|
| 129 |
+
color: #fff;
|
| 130 |
+
text-decoration: none;
|
| 131 |
+
padding: 0.5rem;
|
| 132 |
+
transition: all 0.3s ease;
|
| 133 |
+
font-size: 1rem;
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
.nav-link.theme-toggle {
|
| 137 |
+
margin-top: auto;
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
.nav-link svg {
|
| 141 |
+
font-size: 1.2rem;
|
| 142 |
+
transition: all 0.3s ease;
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
.nav-link:hover svg {
|
| 146 |
+
color: linear-gradient(90deg,
|
| 147 |
+
#000 0%,
|
| 148 |
+
#e0e0e0 50%,
|
| 149 |
+
#ffffff 100%);
|
| 150 |
+
animation: ease-in-out 3s infinite;
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
.link-text {
|
| 154 |
+
transition: all 0.3s ease;
|
| 155 |
+
white-space: nowrap;
|
| 156 |
+
font-size: 0.9rem;
|
| 157 |
+
margin-left: 0.5rem;
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
.nav-link:hover .link-text {
|
| 161 |
+
background: linear-gradient(90deg,
|
| 162 |
+
#ffffff 0%,
|
| 163 |
+
#e0e0e0 50%,
|
| 164 |
+
#ffffff 100%);
|
| 165 |
+
-webkit-background-clip: text;
|
| 166 |
+
background-clip: text;
|
| 167 |
+
color: transparent;
|
| 168 |
+
animation: textShine 3s infinite;
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
@keyframes textShine {
|
| 172 |
+
0% {
|
| 173 |
+
background-position: -100px;
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
100% {
|
| 177 |
+
background-position: 100px;
|
| 178 |
+
}
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
@keyframes iconShine {
|
| 182 |
+
0% {
|
| 183 |
+
background-position: -50px;
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
100% {
|
| 187 |
+
background-position: 50px;
|
| 188 |
+
}
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
.link-text.hidden {
|
| 192 |
+
opacity: 0;
|
| 193 |
+
width: 0;
|
| 194 |
+
overflow: hidden;
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
.theme-toggle {
|
| 198 |
+
margin-top: auto;
|
| 199 |
+
background: none;
|
| 200 |
+
border: none;
|
| 201 |
+
cursor: pointer;
|
| 202 |
+
color: #fff;
|
| 203 |
+
padding: 0.5rem;
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
.main-content {
|
| 207 |
+
margin-left: 40px;
|
| 208 |
+
padding: 1rem;
|
| 209 |
+
flex: 1;
|
| 210 |
+
transition: margin-left 0.3s ease-in-out;
|
| 211 |
+
width: calc(100% - 40px);
|
| 212 |
+
box-sizing: border-box;
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
.main-content.expanded {
|
| 216 |
+
margin-left: 40px;
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
.light .nav-link,
|
| 220 |
+
.light .toggle-btn {
|
| 221 |
+
color: #000000;
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
.dark .nav-link,
|
| 225 |
+
.dark .toggle-btn {
|
| 226 |
+
color: #ffffff;
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
.auth-container {
|
| 230 |
+
display: flex;
|
| 231 |
+
justify-content: center;
|
| 232 |
+
align-items: center;
|
| 233 |
+
min-height: calc(100vh - 2rem);
|
| 234 |
+
padding: 1rem;
|
| 235 |
+
gap: 4rem;
|
| 236 |
+
max-width: 100%;
|
| 237 |
+
box-sizing: border-box;
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
.bg-login {
|
| 241 |
+
background: url("../assets/bg2.gif") no-repeat center center fixed #040511;
|
| 242 |
+
background-size: cover;
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
.simple-bg {
|
| 246 |
+
/* background: url("../assets/bg3.gif") repeat center center fixed #040511; */
|
| 247 |
+
background: url("../assets/bg3.gif") repeat center center fixed #000;
|
| 248 |
+
background-size: contain;
|
| 249 |
+
background-blend-mode: lighten;
|
| 250 |
+
height: 100%;
|
| 251 |
+
width: 100%;
|
| 252 |
+
position: fixed;
|
| 253 |
+
opacity: 1;
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
.auth-form-container {
|
| 257 |
+
background: rgba(99, 102, 241, 0.05);
|
| 258 |
+
backdrop-filter: blur(10px);
|
| 259 |
+
padding: 1rem;
|
| 260 |
+
width: 100%;
|
| 261 |
+
max-width: 360px;
|
| 262 |
+
position: relative;
|
| 263 |
+
overflow: hidden;
|
| 264 |
+
transition: all 0.3s ease;
|
| 265 |
+
border-radius: 24px;
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
.auth-form-container::before {
|
| 269 |
+
content: '';
|
| 270 |
+
position: absolute;
|
| 271 |
+
top: -50%;
|
| 272 |
+
left: -50%;
|
| 273 |
+
width: 200%;
|
| 274 |
+
height: 200%;
|
| 275 |
+
background: linear-gradient(45deg,
|
| 276 |
+
transparent,
|
| 277 |
+
rgba(255, 255, 255, 0.1),
|
| 278 |
+
rgba(255, 255, 255, 0.2),
|
| 279 |
+
rgba(255, 255, 255, 0.1),
|
| 280 |
+
transparent);
|
| 281 |
+
transform: translateX(-100%) rotate(45deg);
|
| 282 |
+
transition: transform 0.1s ease;
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
.auth-form-container:hover::before {
|
| 286 |
+
animation: cardGloss 1s ease-in-out;
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
@keyframes cardGloss {
|
| 290 |
+
0% {
|
| 291 |
+
transform: translateX(-100%) rotate(45deg);
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
100% {
|
| 295 |
+
transform: translateX(100%) rotate(45deg);
|
| 296 |
+
}
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
.auth-form-container h2 {
|
| 300 |
+
margin-bottom: 1.5rem;
|
| 301 |
+
font-size: 1.75rem;
|
| 302 |
+
text-align: center;
|
| 303 |
+
position: relative;
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
.form-group {
|
| 307 |
+
margin-bottom: 1rem;
|
| 308 |
+
position: relative;
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
.auth-form .form-group input {
|
| 312 |
+
width: 100%;
|
| 313 |
+
padding: 0.5rem 0;
|
| 314 |
+
border: 1px solid rgba(255, 255, 255, 0.2);
|
| 315 |
+
border-radius: 8px;
|
| 316 |
+
background: rgba(255, 255, 255, 0.05);
|
| 317 |
+
color: inherit;
|
| 318 |
+
font-size: 0.9rem;
|
| 319 |
+
transition: all 0.3s ease;
|
| 320 |
+
text-align: center;
|
| 321 |
+
}
|
| 322 |
+
|
| 323 |
+
.light .form-group input {
|
| 324 |
+
background: rgba(0, 0, 0, 0.05);
|
| 325 |
+
border-color: rgba(0, 0, 0, 0.1);
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
.form-group input:focus {
|
| 329 |
+
outline: none;
|
| 330 |
+
border-color: rgba(255, 255, 255, 0.3);
|
| 331 |
+
background: rgba(255, 255, 255, 0.1);
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
+
.light .form-group input:focus {
|
| 335 |
+
border-color: rgba(0, 0, 0, 0.3);
|
| 336 |
+
background: rgba(0, 0, 0, 0.08);
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
.submit-btn {
|
| 340 |
+
width: 100%;
|
| 341 |
+
padding: 0.5rem;
|
| 342 |
+
border: none;
|
| 343 |
+
border-radius: 8px;
|
| 344 |
+
background: #6366f1;
|
| 345 |
+
color: white;
|
| 346 |
+
font-size: 0.9rem;
|
| 347 |
+
font-weight: 600;
|
| 348 |
+
cursor: pointer;
|
| 349 |
+
transition: all 0.3s ease;
|
| 350 |
+
margin-top: 0.5rem;
|
| 351 |
+
}
|
| 352 |
+
|
| 353 |
+
.submit-btn:hover {
|
| 354 |
+
background: #4f46e5;
|
| 355 |
+
transform: translateY(-1px);
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
.form-switch {
|
| 359 |
+
margin-top: 1rem;
|
| 360 |
+
text-align: center;
|
| 361 |
+
font-size: 0.85rem;
|
| 362 |
+
position: relative;
|
| 363 |
+
}
|
| 364 |
+
|
| 365 |
+
.form-switch a {
|
| 366 |
+
color: #6366f1;
|
| 367 |
+
text-decoration: none;
|
| 368 |
+
font-weight: 600;
|
| 369 |
+
transition: all 0.3s ease;
|
| 370 |
+
margin-left: 0.25rem;
|
| 371 |
+
}
|
| 372 |
+
|
| 373 |
+
.form-switch a:hover {
|
| 374 |
+
color: #4f46e5;
|
| 375 |
+
}
|
| 376 |
+
|
| 377 |
+
.hero-section {
|
| 378 |
+
max-width: 400px;
|
| 379 |
+
}
|
| 380 |
+
|
| 381 |
+
.hero-content {
|
| 382 |
+
display: flex;
|
| 383 |
+
flex-direction: column;
|
| 384 |
+
align-items: flex-start;
|
| 385 |
+
gap: 2rem;
|
| 386 |
+
}
|
| 387 |
+
|
| 388 |
+
.hero-logo {
|
| 389 |
+
display: flex;
|
| 390 |
+
align-items: center;
|
| 391 |
+
gap: 1rem;
|
| 392 |
+
}
|
| 393 |
+
|
| 394 |
+
.hero-logo svg {
|
| 395 |
+
font-size: 3rem;
|
| 396 |
+
}
|
| 397 |
+
|
| 398 |
+
.product-name {
|
| 399 |
+
position: fixed;
|
| 400 |
+
font-size: 20px;
|
| 401 |
+
width: 165px;
|
| 402 |
+
bottom: 20px;
|
| 403 |
+
left: 70px;
|
| 404 |
+
}
|
| 405 |
+
|
| 406 |
+
sup {
|
| 407 |
+
font-size: 20px;
|
| 408 |
+
font-weight: bold;
|
| 409 |
+
}
|
| 410 |
+
|
| 411 |
+
sub {
|
| 412 |
+
font-size: 12px;
|
| 413 |
+
background: #6366f1;
|
| 414 |
+
border: 1px solid #999;
|
| 415 |
+
padding: 1px;
|
| 416 |
+
border-radius: 5px;
|
| 417 |
+
position: absolute;
|
| 418 |
+
top: 5px;
|
| 419 |
+
right: 5px;
|
| 420 |
+
font-weight: bold;
|
| 421 |
+
}
|
| 422 |
+
|
| 423 |
+
.hero-logo h1 {
|
| 424 |
+
font-size: 3.5rem;
|
| 425 |
+
font-weight: 700;
|
| 426 |
+
background: linear-gradient(0.25turn, #fff, #8a8f98);
|
| 427 |
+
-webkit-background-clip: text;
|
| 428 |
+
background-clip: text;
|
| 429 |
+
color: transparent;
|
| 430 |
+
}
|
| 431 |
+
|
| 432 |
+
.hero-tagline {
|
| 433 |
+
font-size: 1.5rem;
|
| 434 |
+
font-weight: 500;
|
| 435 |
+
color: #6366f1;
|
| 436 |
+
position: relative;
|
| 437 |
+
padding-left: 1rem;
|
| 438 |
+
}
|
| 439 |
+
|
| 440 |
+
.nav-divider {
|
| 441 |
+
height: 1px;
|
| 442 |
+
background: rgba(255, 255, 255, 0.1);
|
| 443 |
+
margin: 0.5rem 0;
|
| 444 |
+
width: 100%;
|
| 445 |
+
}
|
| 446 |
+
|
| 447 |
+
.light .nav-divider {
|
| 448 |
+
background: rgba(0, 0, 0, 0.1);
|
| 449 |
+
}
|
| 450 |
+
|
| 451 |
+
@media (max-width: 968px) {
|
| 452 |
+
.auth-container {
|
| 453 |
+
padding: 1rem;
|
| 454 |
+
gap: 2rem;
|
| 455 |
+
}
|
| 456 |
+
|
| 457 |
+
.main-content {
|
| 458 |
+
padding: 0.5rem;
|
| 459 |
+
}
|
| 460 |
+
|
| 461 |
+
.hero-section {
|
| 462 |
+
text-align: center;
|
| 463 |
+
}
|
| 464 |
+
|
| 465 |
+
.hero-content {
|
| 466 |
+
align-items: center;
|
| 467 |
+
}
|
| 468 |
+
|
| 469 |
+
.hero-logo h1 {
|
| 470 |
+
font-size: 2.5rem;
|
| 471 |
+
}
|
| 472 |
+
|
| 473 |
+
.hero-tagline {
|
| 474 |
+
font-size: 1.25rem;
|
| 475 |
+
}
|
| 476 |
+
}
|
| 477 |
+
|
| 478 |
+
#toast-container {
|
| 479 |
+
position: fixed;
|
| 480 |
+
bottom: 20px;
|
| 481 |
+
left: 50%;
|
| 482 |
+
transform: translateX(-50%);
|
| 483 |
+
z-index: 10000;
|
| 484 |
+
width: auto;
|
| 485 |
+
max-width: calc(100vw - 40px);
|
| 486 |
+
pointer-events: none;
|
| 487 |
+
}
|
| 488 |
+
|
| 489 |
+
#toast-container>* {
|
| 490 |
+
pointer-events: auto;
|
| 491 |
+
}
|
| 492 |
+
|
| 493 |
+
/* Prevent horizontal scrollbar when toast appears */
|
| 494 |
+
body.has-toast {
|
| 495 |
+
overflow-x: hidden;
|
| 496 |
+
}
|
frontend/podcraft/src/App.jsx
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useRef, useEffect } from 'react'
|
| 2 |
+
import { BrowserRouter as Router, Route, Routes, Navigate, useNavigate, Link } from 'react-router-dom'
|
| 3 |
+
import { TbLayoutSidebarLeftCollapse, TbLayoutSidebarRightCollapse } from "react-icons/tb";
|
| 4 |
+
import { AiFillHome } from "react-icons/ai";
|
| 5 |
+
import { BiPodcast } from "react-icons/bi";
|
| 6 |
+
import { FaMicrophoneAlt } from "react-icons/fa";
|
| 7 |
+
import { MdDarkMode, MdLightMode } from "react-icons/md";
|
| 8 |
+
import { ImPodcast } from "react-icons/im";
|
| 9 |
+
import { RiChatVoiceAiFill } from "react-icons/ri";
|
| 10 |
+
import { FaUser, FaSignOutAlt } from "react-icons/fa";
|
| 11 |
+
import { PiGooglePodcastsLogo } from "react-icons/pi";
|
| 12 |
+
import { TiFlowSwitch } from "react-icons/ti";
|
| 13 |
+
import { SiNodemon } from "react-icons/si";
|
| 14 |
+
import React from 'react';
|
| 15 |
+
|
| 16 |
+
import Home from './pages/Home'
|
| 17 |
+
import Podcasts from './pages/Podcasts'
|
| 18 |
+
import Workflows from './pages/Workflows'
|
| 19 |
+
import Demo from './pages/Demo'
|
| 20 |
+
import WorkflowEditor from './components/WorkflowEditor'
|
| 21 |
+
import UserModal from './components/UserModal'
|
| 22 |
+
import Toast from './components/Toast'
|
| 23 |
+
import './App.css'
|
| 24 |
+
|
| 25 |
+
// Global toast context
|
| 26 |
+
export const ToastContext = React.createContext({
|
| 27 |
+
toast: null,
|
| 28 |
+
setToast: () => { }
|
| 29 |
+
});
|
| 30 |
+
|
| 31 |
+
function App() {
|
| 32 |
+
const [isOpen, setIsOpen] = useState(false);
|
| 33 |
+
const [isDark, setIsDark] = useState(true);
|
| 34 |
+
const [isLogin, setIsLogin] = useState(true);
|
| 35 |
+
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
| 36 |
+
const [isModalOpen, setIsModalOpen] = useState(false);
|
| 37 |
+
const [toast, setToast] = useState(null);
|
| 38 |
+
const sidebarRef = useRef(null);
|
| 39 |
+
|
| 40 |
+
// Check for token on initial load
|
| 41 |
+
useEffect(() => {
|
| 42 |
+
const token = localStorage.getItem('token');
|
| 43 |
+
if (token) {
|
| 44 |
+
// Validate token by making a request to the backend
|
| 45 |
+
const validateToken = async () => {
|
| 46 |
+
try {
|
| 47 |
+
const response = await fetch('http://localhost:8000/user/me', {
|
| 48 |
+
headers: {
|
| 49 |
+
'Authorization': `Bearer ${token}`
|
| 50 |
+
}
|
| 51 |
+
});
|
| 52 |
+
|
| 53 |
+
if (response.ok) {
|
| 54 |
+
setIsAuthenticated(true);
|
| 55 |
+
console.log('User authenticated from stored token');
|
| 56 |
+
} else {
|
| 57 |
+
// Token is invalid, remove it
|
| 58 |
+
localStorage.removeItem('token');
|
| 59 |
+
setIsAuthenticated(false);
|
| 60 |
+
console.log('Stored token is invalid, removed');
|
| 61 |
+
}
|
| 62 |
+
} catch (error) {
|
| 63 |
+
console.error('Error validating token:', error);
|
| 64 |
+
// Don't remove token on network errors to allow offline access
|
| 65 |
+
}
|
| 66 |
+
};
|
| 67 |
+
|
| 68 |
+
validateToken();
|
| 69 |
+
}
|
| 70 |
+
}, []);
|
| 71 |
+
|
| 72 |
+
useEffect(() => {
|
| 73 |
+
const handleClickOutside = (event) => {
|
| 74 |
+
if (sidebarRef.current && !sidebarRef.current.contains(event.target)) {
|
| 75 |
+
setIsOpen(false);
|
| 76 |
+
}
|
| 77 |
+
};
|
| 78 |
+
|
| 79 |
+
document.addEventListener('mousedown', handleClickOutside);
|
| 80 |
+
return () => {
|
| 81 |
+
document.removeEventListener('mousedown', handleClickOutside);
|
| 82 |
+
};
|
| 83 |
+
}, []);
|
| 84 |
+
|
| 85 |
+
const toggleSidebar = () => {
|
| 86 |
+
setIsOpen(!isOpen);
|
| 87 |
+
};
|
| 88 |
+
|
| 89 |
+
const toggleTheme = (e) => {
|
| 90 |
+
e.preventDefault();
|
| 91 |
+
setIsDark(!isDark);
|
| 92 |
+
document.body.style.backgroundColor = !isDark ? '#040511' : '#ffffff';
|
| 93 |
+
document.body.style.color = !isDark ? '#ffffff' : '#000000';
|
| 94 |
+
};
|
| 95 |
+
|
| 96 |
+
const toggleForm = () => {
|
| 97 |
+
setIsLogin(!isLogin);
|
| 98 |
+
};
|
| 99 |
+
|
| 100 |
+
const handleLogout = () => {
|
| 101 |
+
localStorage.removeItem('token');
|
| 102 |
+
setIsAuthenticated(false);
|
| 103 |
+
showToast('Logged out successfully', 'success');
|
| 104 |
+
};
|
| 105 |
+
|
| 106 |
+
const showToast = (message, type = 'success') => {
|
| 107 |
+
setToast({ message, type });
|
| 108 |
+
};
|
| 109 |
+
|
| 110 |
+
const handleSubmit = async (e) => {
|
| 111 |
+
e.preventDefault();
|
| 112 |
+
const formData = new FormData(e.target);
|
| 113 |
+
const username = formData.get('username');
|
| 114 |
+
const password = formData.get('password');
|
| 115 |
+
|
| 116 |
+
try {
|
| 117 |
+
const response = await fetch(`http://localhost:8000/${isLogin ? 'login' : 'signup'}`, {
|
| 118 |
+
method: 'POST',
|
| 119 |
+
headers: {
|
| 120 |
+
'Content-Type': 'application/json',
|
| 121 |
+
},
|
| 122 |
+
body: JSON.stringify({ username, password }),
|
| 123 |
+
});
|
| 124 |
+
|
| 125 |
+
if (response.ok) {
|
| 126 |
+
const data = await response.json();
|
| 127 |
+
localStorage.setItem('token', data.access_token);
|
| 128 |
+
setIsAuthenticated(true);
|
| 129 |
+
showToast(`Successfully ${isLogin ? 'logged in' : 'signed up'}!`, 'success');
|
| 130 |
+
} else {
|
| 131 |
+
const error = await response.json();
|
| 132 |
+
showToast(error.detail, 'error');
|
| 133 |
+
}
|
| 134 |
+
} catch (error) {
|
| 135 |
+
console.error('Error:', error);
|
| 136 |
+
showToast('An error occurred during authentication', 'error');
|
| 137 |
+
}
|
| 138 |
+
};
|
| 139 |
+
|
| 140 |
+
return (
|
| 141 |
+
<ToastContext.Provider value={{ toast, setToast }}>
|
| 142 |
+
<Router>
|
| 143 |
+
<>
|
| 144 |
+
<div className={`${isAuthenticated && 'simple-bg'}`}>
|
| 145 |
+
</div>
|
| 146 |
+
<div className={`app-container ${isDark ? 'dark' : 'light'} ${!isAuthenticated && isDark ? 'bg-login' : ''} `} >
|
| 147 |
+
<nav ref={sidebarRef} className={`sidebar ${isOpen ? 'open' : 'closed'}`}>
|
| 148 |
+
<div className='product-name'>
|
| 149 |
+
<span><PiGooglePodcastsLogo /> PodCraft <sup>©</sup> <sub>Beta</sub></span>
|
| 150 |
+
</div>
|
| 151 |
+
<span className="toggle-btn" onClick={toggleSidebar}>
|
| 152 |
+
{isOpen ? <TbLayoutSidebarLeftCollapse /> : <TbLayoutSidebarRightCollapse />}
|
| 153 |
+
</span>
|
| 154 |
+
|
| 155 |
+
<div className="nav-links">
|
| 156 |
+
{isAuthenticated && (
|
| 157 |
+
<>
|
| 158 |
+
<Link to="/home" className="nav-link">
|
| 159 |
+
<AiFillHome />
|
| 160 |
+
<span className={`link-text ${!isOpen && 'hidden'}`}>Home</span>
|
| 161 |
+
</Link>
|
| 162 |
+
|
| 163 |
+
<Link to="/podcasts" className="nav-link">
|
| 164 |
+
<BiPodcast />
|
| 165 |
+
<span className={`link-text ${!isOpen && 'hidden'}`}>Podcasts</span>
|
| 166 |
+
</Link>
|
| 167 |
+
|
| 168 |
+
<Link to="/workflows" className="nav-link">
|
| 169 |
+
<TiFlowSwitch />
|
| 170 |
+
<span className={`link-text ${!isOpen && 'hidden'}`}>Workflows</span>
|
| 171 |
+
</Link>
|
| 172 |
+
|
| 173 |
+
<Link to="/demo" className="nav-link">
|
| 174 |
+
<SiNodemon />
|
| 175 |
+
<span className={`link-text ${!isOpen && 'hidden'}`}>Demo</span>
|
| 176 |
+
</Link>
|
| 177 |
+
|
| 178 |
+
<div className="nav-divider"></div>
|
| 179 |
+
|
| 180 |
+
<a href="#" className="nav-link" onClick={() => setIsModalOpen(true)}>
|
| 181 |
+
<FaUser />
|
| 182 |
+
<span className={`link-text ${!isOpen && 'hidden'}`}>Profile</span>
|
| 183 |
+
</a>
|
| 184 |
+
|
| 185 |
+
<a href="#" className="nav-link" onClick={handleLogout}>
|
| 186 |
+
<FaSignOutAlt />
|
| 187 |
+
<span className={`link-text ${!isOpen && 'hidden'}`}>Logout</span>
|
| 188 |
+
</a>
|
| 189 |
+
</>
|
| 190 |
+
)}
|
| 191 |
+
|
| 192 |
+
<a href="#" className="nav-link theme-toggle" onClick={toggleTheme}>
|
| 193 |
+
{isDark ? <MdDarkMode /> : <MdLightMode />}
|
| 194 |
+
<span className={`link-text ${!isOpen && 'hidden'}`}>
|
| 195 |
+
{isDark ? 'Dark Mode' : 'Light Mode'}
|
| 196 |
+
</span>
|
| 197 |
+
</a>
|
| 198 |
+
</div>
|
| 199 |
+
</nav>
|
| 200 |
+
|
| 201 |
+
<main className={`main-content ${!isOpen ? 'expanded' : ''}`}>
|
| 202 |
+
{!isAuthenticated ? (
|
| 203 |
+
<div className="auth-container">
|
| 204 |
+
<div className="hero-section">
|
| 205 |
+
<div className="hero-content">
|
| 206 |
+
<div className="hero-logo">
|
| 207 |
+
<ImPodcast />
|
| 208 |
+
<h1>PodCraft</h1>
|
| 209 |
+
</div>
|
| 210 |
+
<p className="hero-tagline">One prompt <RiChatVoiceAiFill /> to Podcast <BiPodcast /></p>
|
| 211 |
+
</div>
|
| 212 |
+
</div>
|
| 213 |
+
<div className="auth-form-container">
|
| 214 |
+
<h2>{isLogin ? 'Login' : 'Sign Up'}</h2>
|
| 215 |
+
<form className="auth-form" onSubmit={handleSubmit}>
|
| 216 |
+
<div className="form-group">
|
| 217 |
+
<input type="text" name="username" placeholder="Username" required />
|
| 218 |
+
</div>
|
| 219 |
+
<div className="form-group">
|
| 220 |
+
<input type="password" name="password" placeholder="Password" required />
|
| 221 |
+
</div>
|
| 222 |
+
<button type="submit" className="submit-btn">
|
| 223 |
+
{isLogin ? 'Login' : 'Sign Up'}
|
| 224 |
+
</button>
|
| 225 |
+
</form>
|
| 226 |
+
<p className="form-switch">
|
| 227 |
+
{isLogin ? "Don't have an account? " : "Already have an account? "}
|
| 228 |
+
<a href="#" onClick={toggleForm}>
|
| 229 |
+
{isLogin ? 'Sign Up' : 'Login'}
|
| 230 |
+
</a>
|
| 231 |
+
</p>
|
| 232 |
+
</div>
|
| 233 |
+
</div>
|
| 234 |
+
) : (
|
| 235 |
+
<Routes>
|
| 236 |
+
<Route path="/home" element={<Home />} />
|
| 237 |
+
<Route path="/podcasts" element={<Podcasts />} />
|
| 238 |
+
<Route path="/workflows" element={<Workflows />} />
|
| 239 |
+
<Route path="/demo" element={<Demo />} />
|
| 240 |
+
<Route path="/workflows/workflow/:workflowId" element={<WorkflowEditor />} />
|
| 241 |
+
<Route path="/" element={<Navigate to="/home" replace />} />
|
| 242 |
+
</Routes>
|
| 243 |
+
)}
|
| 244 |
+
</main>
|
| 245 |
+
|
| 246 |
+
<UserModal
|
| 247 |
+
isOpen={isModalOpen}
|
| 248 |
+
onClose={() => setIsModalOpen(false)}
|
| 249 |
+
token={localStorage.getItem('token')}
|
| 250 |
+
/>
|
| 251 |
+
|
| 252 |
+
<div className="toast-container">
|
| 253 |
+
{toast && (
|
| 254 |
+
<Toast
|
| 255 |
+
message={toast.message}
|
| 256 |
+
type={toast.type}
|
| 257 |
+
onClose={() => setToast(null)}
|
| 258 |
+
/>
|
| 259 |
+
)}
|
| 260 |
+
</div>
|
| 261 |
+
</div>
|
| 262 |
+
</>
|
| 263 |
+
</Router>
|
| 264 |
+
</ToastContext.Provider>
|
| 265 |
+
)
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
export default App
|
frontend/podcraft/src/assets/react.svg
ADDED
|
|
frontend/podcraft/src/components/AgentModal.css
ADDED
|
@@ -0,0 +1,470 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.agent-modal-overlay {
|
| 2 |
+
position: fixed;
|
| 3 |
+
top: 0;
|
| 4 |
+
left: 0;
|
| 5 |
+
right: 0;
|
| 6 |
+
bottom: 0;
|
| 7 |
+
background-color: rgba(0, 0, 0, 0.7);
|
| 8 |
+
display: flex;
|
| 9 |
+
justify-content: center;
|
| 10 |
+
align-items: center;
|
| 11 |
+
z-index: 1000;
|
| 12 |
+
backdrop-filter: blur(5px);
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
.agent-form .form-group {
|
| 16 |
+
margin-bottom: 0.5rem;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
.agent-modal-content {
|
| 20 |
+
background: rgba(20, 20, 20, 0.95);
|
| 21 |
+
backdrop-filter: blur(10px);
|
| 22 |
+
padding: 1rem 2rem;
|
| 23 |
+
border-radius: 12px;
|
| 24 |
+
width: 90%;
|
| 25 |
+
max-width: 600px;
|
| 26 |
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
| 27 |
+
color: white;
|
| 28 |
+
position: relative;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
.agent-modal-header {
|
| 32 |
+
display: flex;
|
| 33 |
+
justify-content: space-between;
|
| 34 |
+
align-items: center;
|
| 35 |
+
margin-bottom: 2rem;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
.agent-modal-header h2 {
|
| 39 |
+
margin: 0;
|
| 40 |
+
font-size: 1.5rem;
|
| 41 |
+
background: linear-gradient(90deg, #fff, #999);
|
| 42 |
+
-webkit-background-clip: text;
|
| 43 |
+
background-clip: text;
|
| 44 |
+
color: transparent;
|
| 45 |
+
display: flex;
|
| 46 |
+
align-items: center;
|
| 47 |
+
gap: 1rem;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
.close-button {
|
| 51 |
+
background: transparent;
|
| 52 |
+
border: none;
|
| 53 |
+
color: rgba(255, 255, 255, 0.6);
|
| 54 |
+
cursor: pointer;
|
| 55 |
+
padding: 0.5rem;
|
| 56 |
+
display: flex;
|
| 57 |
+
align-items: center;
|
| 58 |
+
justify-content: center;
|
| 59 |
+
border-radius: 50%;
|
| 60 |
+
transition: all 0.3s ease;
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
.close-button:hover {
|
| 64 |
+
color: white;
|
| 65 |
+
background: rgba(255, 255, 255, 0.1);
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
.agent-form {
|
| 69 |
+
display: flex;
|
| 70 |
+
flex-direction: column;
|
| 71 |
+
gap: 1rem;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
.form-group {
|
| 75 |
+
display: flex;
|
| 76 |
+
flex-direction: column;
|
| 77 |
+
gap: 0.5rem;
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
.form-group label {
|
| 81 |
+
font-size: 0.9rem;
|
| 82 |
+
color: rgba(255, 255, 255, 0.8);
|
| 83 |
+
font-weight: 500;
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
.form-group input[type="text"],
|
| 87 |
+
.form-group textarea {
|
| 88 |
+
background: rgba(255, 255, 255, 0.05);
|
| 89 |
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
| 90 |
+
border-radius: 8px;
|
| 91 |
+
padding: 0.75rem;
|
| 92 |
+
color: white;
|
| 93 |
+
font-size: 0.9rem;
|
| 94 |
+
transition: all 0.3s ease;
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
.form-group input[type="text"]:focus,
|
| 98 |
+
.form-group textarea:focus {
|
| 99 |
+
outline: none;
|
| 100 |
+
border-color: #6366f1;
|
| 101 |
+
background: rgba(255, 255, 255, 0.1);
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
/* Custom Dropdown Styles */
|
| 105 |
+
.custom-dropdown {
|
| 106 |
+
position: relative;
|
| 107 |
+
width: 100%;
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
.dropdown-header {
|
| 111 |
+
background: rgba(255, 255, 255, 0.05);
|
| 112 |
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
| 113 |
+
border-radius: 8px;
|
| 114 |
+
padding: 0.75rem;
|
| 115 |
+
cursor: pointer;
|
| 116 |
+
transition: all 0.3s ease;
|
| 117 |
+
display: flex;
|
| 118 |
+
align-items: center;
|
| 119 |
+
justify-content: space-between;
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
.dropdown-header:hover {
|
| 123 |
+
background: rgba(255, 255, 255, 0.1);
|
| 124 |
+
border-color: rgba(99, 102, 241, 0.3);
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
.selected-voice,
|
| 128 |
+
.voice-info {
|
| 129 |
+
display: flex;
|
| 130 |
+
align-items: center;
|
| 131 |
+
gap: 0.75rem;
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
.voice-info {
|
| 135 |
+
flex-direction: column;
|
| 136 |
+
align-items: flex-start;
|
| 137 |
+
gap: 0.25rem;
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
.voice-info span {
|
| 141 |
+
font-size: 0.9rem;
|
| 142 |
+
color: white;
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
.voice-info small {
|
| 146 |
+
font-size: 0.8rem;
|
| 147 |
+
color: rgba(255, 255, 255, 0.6);
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
.dropdown-options {
|
| 151 |
+
position: absolute;
|
| 152 |
+
top: 100%;
|
| 153 |
+
left: 0;
|
| 154 |
+
right: 0;
|
| 155 |
+
margin-top: 0.5rem;
|
| 156 |
+
background: rgba(30, 30, 30, 0.95);
|
| 157 |
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
| 158 |
+
border-radius: 8px;
|
| 159 |
+
max-height: 300px;
|
| 160 |
+
overflow-y: auto;
|
| 161 |
+
z-index: 10;
|
| 162 |
+
backdrop-filter: blur(10px);
|
| 163 |
+
scrollbar-width: thin;
|
| 164 |
+
scrollbar-color: rgba(99, 102, 241, 0.3) rgba(255, 255, 255, 0.05);
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
.dropdown-options::-webkit-scrollbar {
|
| 168 |
+
width: 6px;
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
.dropdown-options::-webkit-scrollbar-track {
|
| 172 |
+
background: rgba(255, 255, 255, 0.05);
|
| 173 |
+
border-radius: 3px;
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
.dropdown-options::-webkit-scrollbar-thumb {
|
| 177 |
+
background: rgba(99, 102, 241, 0.3);
|
| 178 |
+
border-radius: 3px;
|
| 179 |
+
transition: background 0.3s ease;
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
.dropdown-options::-webkit-scrollbar-thumb:hover {
|
| 183 |
+
background: rgba(99, 102, 241, 0.5);
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
.dropdown-option {
|
| 187 |
+
padding: 0.75rem;
|
| 188 |
+
cursor: pointer;
|
| 189 |
+
display: flex;
|
| 190 |
+
align-items: center;
|
| 191 |
+
gap: 0.75rem;
|
| 192 |
+
transition: all 0.3s ease;
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
.dropdown-option:hover {
|
| 196 |
+
background: rgba(99, 102, 241, 0.1);
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
/* Slider Styles */
|
| 200 |
+
.slider-container {
|
| 201 |
+
display: flex;
|
| 202 |
+
align-items: center;
|
| 203 |
+
gap: 1rem;
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
.agent-form input#name {
|
| 207 |
+
width: auto;
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
.slider-container input[type="range"] {
|
| 211 |
+
padding: 2px 0;
|
| 212 |
+
border-radius: 5px;
|
| 213 |
+
background: #222;
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
.slider-container input[type="range"] {
|
| 217 |
+
flex: 1;
|
| 218 |
+
-webkit-appearance: none;
|
| 219 |
+
height: 0px;
|
| 220 |
+
background: rgba(255, 255, 255, 0.1);
|
| 221 |
+
border-radius: 2px;
|
| 222 |
+
outline: none;
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
.slider-container input[type="range"]::-webkit-slider-thumb {
|
| 226 |
+
-webkit-appearance: none;
|
| 227 |
+
width: 16px;
|
| 228 |
+
height: 16px;
|
| 229 |
+
background: #6366f1;
|
| 230 |
+
border-radius: 50%;
|
| 231 |
+
cursor: pointer;
|
| 232 |
+
transition: all 0.3s ease;
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
.slider-container input[type="range"]::-webkit-slider-thumb:hover {
|
| 236 |
+
transform: scale(1.1);
|
| 237 |
+
background: #4f46e5;
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
.slider-value {
|
| 241 |
+
min-width: 48px;
|
| 242 |
+
font-size: 0.9rem;
|
| 243 |
+
color: rgba(255, 255, 255, 0.8);
|
| 244 |
+
text-align: right;
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
/* Radio Group Styles */
|
| 248 |
+
.radio-group {
|
| 249 |
+
display: flex;
|
| 250 |
+
gap: 1rem;
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
.radio-label {
|
| 254 |
+
display: flex;
|
| 255 |
+
align-items: center;
|
| 256 |
+
gap: 0.5rem;
|
| 257 |
+
cursor: pointer;
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
.radio-label input[type="radio"] {
|
| 261 |
+
display: none;
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
.radio-label span {
|
| 265 |
+
padding: 0.5rem 1rem;
|
| 266 |
+
border-radius: 6px;
|
| 267 |
+
background: rgba(255, 255, 255, 0.05);
|
| 268 |
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
| 269 |
+
font-size: 0.9rem;
|
| 270 |
+
color: rgba(255, 255, 255, 0.8);
|
| 271 |
+
transition: all 0.3s ease;
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
.radio-label input[type="radio"]:checked+span {
|
| 275 |
+
background: rgba(99, 102, 241, 0.1);
|
| 276 |
+
border-color: #6366f1;
|
| 277 |
+
color: #6366f1;
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
/* Modal Actions */
|
| 281 |
+
.modal-actions {
|
| 282 |
+
display: flex;
|
| 283 |
+
justify-content: space-between;
|
| 284 |
+
align-items: center;
|
| 285 |
+
margin-top: 1rem;
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
.right-actions {
|
| 289 |
+
display: flex;
|
| 290 |
+
gap: 1rem;
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
.test-voice-btn,
|
| 294 |
+
.save-btn,
|
| 295 |
+
.cancel-btn {
|
| 296 |
+
display: flex;
|
| 297 |
+
align-items: center;
|
| 298 |
+
gap: 0.5rem;
|
| 299 |
+
padding: 0.75rem 1.25rem;
|
| 300 |
+
border-radius: 8px;
|
| 301 |
+
font-size: 0.9rem;
|
| 302 |
+
font-weight: 500;
|
| 303 |
+
cursor: pointer;
|
| 304 |
+
transition: all 0.3s ease;
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
.test-voice-btn {
|
| 308 |
+
background: rgba(99, 102, 241, 0.1);
|
| 309 |
+
border: 1px solid rgba(99, 102, 241, 0.3);
|
| 310 |
+
color: #6366f1;
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
.test-voice-btn:hover {
|
| 314 |
+
background: rgba(99, 102, 241, 0.2);
|
| 315 |
+
transform: translateY(-1px);
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
.save-btn {
|
| 319 |
+
background: #6366f1;
|
| 320 |
+
border: none;
|
| 321 |
+
color: white;
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
.save-btn:hover {
|
| 325 |
+
background: #4f46e5;
|
| 326 |
+
transform: translateY(-1px);
|
| 327 |
+
}
|
| 328 |
+
|
| 329 |
+
.cancel-btn {
|
| 330 |
+
background: transparent;
|
| 331 |
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
| 332 |
+
color: rgba(255, 255, 255, 0.8);
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
.cancel-btn:hover {
|
| 336 |
+
background: rgba(255, 255, 255, 0.1);
|
| 337 |
+
transform: translateY(-1px);
|
| 338 |
+
}
|
| 339 |
+
|
| 340 |
+
/* Light Theme Adjustments */
|
| 341 |
+
.light .agent-modal-content {
|
| 342 |
+
background: rgba(255, 255, 255, 0.95);
|
| 343 |
+
border-color: rgba(0, 0, 0, 0.1);
|
| 344 |
+
color: black;
|
| 345 |
+
}
|
| 346 |
+
|
| 347 |
+
.light .agent-modal-header h2 {
|
| 348 |
+
background: linear-gradient(90deg, #333, #666);
|
| 349 |
+
-webkit-background-clip: text;
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
.light .form-group label {
|
| 353 |
+
color: rgba(0, 0, 0, 0.8);
|
| 354 |
+
}
|
| 355 |
+
|
| 356 |
+
.light .form-group input[type="text"],
|
| 357 |
+
.light .form-group textarea {
|
| 358 |
+
background: rgba(0, 0, 0, 0.05);
|
| 359 |
+
border-color: rgba(0, 0, 0, 0.1);
|
| 360 |
+
color: black;
|
| 361 |
+
}
|
| 362 |
+
|
| 363 |
+
.light .form-group input[type="text"]:focus,
|
| 364 |
+
.light .form-group textarea:focus {
|
| 365 |
+
border-color: #6366f1;
|
| 366 |
+
background: rgba(0, 0, 0, 0.08);
|
| 367 |
+
}
|
| 368 |
+
|
| 369 |
+
.light .dropdown-header {
|
| 370 |
+
background: rgba(0, 0, 0, 0.05);
|
| 371 |
+
border-color: rgba(0, 0, 0, 0.1);
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
.light .dropdown-header:hover {
|
| 375 |
+
background: rgba(0, 0, 0, 0.08);
|
| 376 |
+
}
|
| 377 |
+
|
| 378 |
+
.light .voice-info span {
|
| 379 |
+
color: black;
|
| 380 |
+
}
|
| 381 |
+
|
| 382 |
+
.light .voice-info small {
|
| 383 |
+
color: rgba(0, 0, 0, 0.6);
|
| 384 |
+
}
|
| 385 |
+
|
| 386 |
+
.light .dropdown-options {
|
| 387 |
+
background: rgba(255, 255, 255, 0.95);
|
| 388 |
+
border-color: rgba(0, 0, 0, 0.1);
|
| 389 |
+
scrollbar-color: rgba(99, 102, 241, 0.3) rgba(0, 0, 0, 0.05);
|
| 390 |
+
}
|
| 391 |
+
|
| 392 |
+
.light .dropdown-options::-webkit-scrollbar-track {
|
| 393 |
+
background: rgba(0, 0, 0, 0.05);
|
| 394 |
+
}
|
| 395 |
+
|
| 396 |
+
.light .dropdown-options::-webkit-scrollbar-thumb {
|
| 397 |
+
background: rgba(99, 102, 241, 0.3);
|
| 398 |
+
}
|
| 399 |
+
|
| 400 |
+
.light .dropdown-options::-webkit-scrollbar-thumb:hover {
|
| 401 |
+
background: rgba(99, 102, 241, 0.5);
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
.light .slider-container input[type="range"] {
|
| 405 |
+
background: rgba(0, 0, 0, 0.1);
|
| 406 |
+
}
|
| 407 |
+
|
| 408 |
+
.light .slider-value {
|
| 409 |
+
color: rgba(0, 0, 0, 0.8);
|
| 410 |
+
}
|
| 411 |
+
|
| 412 |
+
.light .radio-label span {
|
| 413 |
+
background: rgba(0, 0, 0, 0.05);
|
| 414 |
+
border-color: rgba(0, 0, 0, 0.1);
|
| 415 |
+
color: rgba(0, 0, 0, 0.8);
|
| 416 |
+
}
|
| 417 |
+
|
| 418 |
+
.light .cancel-btn {
|
| 419 |
+
border-color: rgba(0, 0, 0, 0.1);
|
| 420 |
+
color: rgba(0, 0, 0, 0.8);
|
| 421 |
+
}
|
| 422 |
+
|
| 423 |
+
.light .cancel-btn:hover {
|
| 424 |
+
background: rgba(0, 0, 0, 0.1);
|
| 425 |
+
}
|
| 426 |
+
|
| 427 |
+
.toggle-group {
|
| 428 |
+
margin-top: 1rem;
|
| 429 |
+
margin-bottom: 1rem;
|
| 430 |
+
}
|
| 431 |
+
|
| 432 |
+
.toggle-container {
|
| 433 |
+
display: flex;
|
| 434 |
+
align-items: center;
|
| 435 |
+
justify-content: center;
|
| 436 |
+
gap: 10px;
|
| 437 |
+
padding: 8px 12px;
|
| 438 |
+
background: rgba(255, 255, 255, 0.05);
|
| 439 |
+
border-radius: 8px;
|
| 440 |
+
cursor: pointer;
|
| 441 |
+
transition: all 0.3s ease;
|
| 442 |
+
}
|
| 443 |
+
|
| 444 |
+
.toggle-container:hover {
|
| 445 |
+
background: rgba(255, 255, 255, 0.1);
|
| 446 |
+
}
|
| 447 |
+
|
| 448 |
+
.toggle-container span {
|
| 449 |
+
color: rgba(255, 255, 255, 0.6);
|
| 450 |
+
font-size: 0.9rem;
|
| 451 |
+
}
|
| 452 |
+
|
| 453 |
+
.toggle-container span.active {
|
| 454 |
+
color: rgba(255, 255, 255, 1);
|
| 455 |
+
font-weight: 500;
|
| 456 |
+
}
|
| 457 |
+
|
| 458 |
+
.toggle-icon {
|
| 459 |
+
font-size: 1.5rem;
|
| 460 |
+
color: #6366f1;
|
| 461 |
+
transition: transform 0.3s ease;
|
| 462 |
+
}
|
| 463 |
+
|
| 464 |
+
.help-text {
|
| 465 |
+
display: block;
|
| 466 |
+
margin-top: 5px;
|
| 467 |
+
font-size: 0.8rem;
|
| 468 |
+
color: rgba(255, 255, 255, 0.5);
|
| 469 |
+
font-style: italic;
|
| 470 |
+
}
|
frontend/podcraft/src/components/AgentModal.tsx
ADDED
|
@@ -0,0 +1,558 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect } from 'react';
|
| 2 |
+
import { FaPlay, FaSave, FaTimes, FaChevronDown, FaVolumeUp } from 'react-icons/fa';
|
| 3 |
+
import './AgentModal.css';
|
| 4 |
+
import { BsRobot, BsToggleOff, BsToggleOn } from "react-icons/bs";
|
| 5 |
+
import Toast from './Toast';
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
type Voice = {
|
| 9 |
+
id: string;
|
| 10 |
+
name: string;
|
| 11 |
+
description: string;
|
| 12 |
+
};
|
| 13 |
+
|
| 14 |
+
type FormData = {
|
| 15 |
+
name: string;
|
| 16 |
+
voice: Voice | null;
|
| 17 |
+
speed: number;
|
| 18 |
+
pitch: number;
|
| 19 |
+
volume: number;
|
| 20 |
+
outputFormat: 'mp3' | 'wav';
|
| 21 |
+
testInput: string;
|
| 22 |
+
personality: string;
|
| 23 |
+
showPersonality: boolean;
|
| 24 |
+
};
|
| 25 |
+
|
| 26 |
+
const VOICE_OPTIONS: Voice[] = [
|
| 27 |
+
{ id: 'alloy', name: 'Alloy', description: 'Versatile, well-rounded voice' },
|
| 28 |
+
{ id: 'ash', name: 'Ash', description: 'Direct and clear articulation' },
|
| 29 |
+
{ id: 'coral', name: 'Coral', description: 'Warm and inviting tone' },
|
| 30 |
+
{ id: 'echo', name: 'Echo', description: 'Balanced and measured delivery' },
|
| 31 |
+
{ id: 'fable', name: 'Fable', description: 'Expressive storytelling voice' },
|
| 32 |
+
{ id: 'onyx', name: 'Onyx', description: 'Authoritative and professional' },
|
| 33 |
+
{ id: 'nova', name: 'Nova', description: 'Energetic and engaging' },
|
| 34 |
+
{ id: 'sage', name: 'Sage', description: 'Calm and thoughtful delivery' },
|
| 35 |
+
{ id: 'shimmer', name: 'Shimmer', description: 'Bright and optimistic tone' }
|
| 36 |
+
];
|
| 37 |
+
|
| 38 |
+
interface AgentModalProps {
|
| 39 |
+
isOpen: boolean;
|
| 40 |
+
onClose: () => void;
|
| 41 |
+
editAgent?: {
|
| 42 |
+
id: string;
|
| 43 |
+
name: string;
|
| 44 |
+
voice_id: string;
|
| 45 |
+
speed: number;
|
| 46 |
+
pitch: number;
|
| 47 |
+
volume: number;
|
| 48 |
+
output_format: string;
|
| 49 |
+
personality: string;
|
| 50 |
+
} | null;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
const AgentModal: React.FC<AgentModalProps> = ({ isOpen, onClose, editAgent }) => {
|
| 54 |
+
const [formData, setFormData] = useState<FormData>({
|
| 55 |
+
name: '',
|
| 56 |
+
voice: null,
|
| 57 |
+
speed: 1,
|
| 58 |
+
pitch: 1,
|
| 59 |
+
volume: 1,
|
| 60 |
+
outputFormat: 'mp3',
|
| 61 |
+
testInput: '',
|
| 62 |
+
personality: '',
|
| 63 |
+
showPersonality: false
|
| 64 |
+
});
|
| 65 |
+
|
| 66 |
+
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
| 67 |
+
const [isLoading, setIsLoading] = useState(false);
|
| 68 |
+
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' | 'info' } | null>(null);
|
| 69 |
+
const [audioPlayer, setAudioPlayer] = useState<HTMLAudioElement | null>(null);
|
| 70 |
+
const [isTestingVoice, setIsTestingVoice] = useState(false);
|
| 71 |
+
|
| 72 |
+
// Initialize form data when editing an agent
|
| 73 |
+
useEffect(() => {
|
| 74 |
+
if (editAgent) {
|
| 75 |
+
// Find the matching voice from VOICE_OPTIONS
|
| 76 |
+
const matchingVoice = VOICE_OPTIONS.find(voice => voice.id === editAgent.voice_id) || VOICE_OPTIONS[0];
|
| 77 |
+
|
| 78 |
+
// Ensure output_format is either 'mp3' or 'wav'
|
| 79 |
+
const validOutputFormat = editAgent.output_format === 'wav' ? 'wav' : 'mp3';
|
| 80 |
+
|
| 81 |
+
setFormData({
|
| 82 |
+
name: editAgent.name,
|
| 83 |
+
voice: matchingVoice,
|
| 84 |
+
speed: editAgent.speed || 1,
|
| 85 |
+
pitch: editAgent.pitch || 1,
|
| 86 |
+
volume: editAgent.volume || 1,
|
| 87 |
+
outputFormat: validOutputFormat,
|
| 88 |
+
testInput: '',
|
| 89 |
+
personality: editAgent.personality || '',
|
| 90 |
+
showPersonality: !!editAgent.personality
|
| 91 |
+
});
|
| 92 |
+
} else {
|
| 93 |
+
// Reset form when not editing
|
| 94 |
+
setFormData({
|
| 95 |
+
name: '',
|
| 96 |
+
voice: VOICE_OPTIONS[0],
|
| 97 |
+
speed: 1,
|
| 98 |
+
pitch: 1,
|
| 99 |
+
volume: 1,
|
| 100 |
+
outputFormat: 'mp3',
|
| 101 |
+
testInput: '',
|
| 102 |
+
personality: '',
|
| 103 |
+
showPersonality: false
|
| 104 |
+
});
|
| 105 |
+
}
|
| 106 |
+
}, [editAgent]);
|
| 107 |
+
|
| 108 |
+
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
| 109 |
+
const { name, value } = e.target;
|
| 110 |
+
setFormData(prev => ({
|
| 111 |
+
...prev,
|
| 112 |
+
[name]: value
|
| 113 |
+
}));
|
| 114 |
+
};
|
| 115 |
+
|
| 116 |
+
const handleVoiceSelect = (voice: Voice) => {
|
| 117 |
+
setFormData(prev => ({
|
| 118 |
+
...prev,
|
| 119 |
+
voice
|
| 120 |
+
}));
|
| 121 |
+
setIsDropdownOpen(false);
|
| 122 |
+
};
|
| 123 |
+
|
| 124 |
+
const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
| 125 |
+
const { name, value } = e.target;
|
| 126 |
+
const numericValue = parseFloat(value);
|
| 127 |
+
if (!isNaN(numericValue)) {
|
| 128 |
+
setFormData(prev => ({
|
| 129 |
+
...prev,
|
| 130 |
+
[name]: numericValue
|
| 131 |
+
}));
|
| 132 |
+
}
|
| 133 |
+
};
|
| 134 |
+
|
| 135 |
+
const handleTestVoice = async () => {
|
| 136 |
+
if (!formData.testInput.trim()) {
|
| 137 |
+
setToast({ message: 'Please enter some text to test', type: 'error' });
|
| 138 |
+
return;
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
try {
|
| 142 |
+
setIsTestingVoice(true);
|
| 143 |
+
const token = localStorage.getItem('token');
|
| 144 |
+
if (!token) {
|
| 145 |
+
setToast({ message: 'Authentication token not found', type: 'error' });
|
| 146 |
+
setIsTestingVoice(false);
|
| 147 |
+
return;
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
// Stop any currently playing audio
|
| 151 |
+
if (audioPlayer) {
|
| 152 |
+
audioPlayer.pause();
|
| 153 |
+
audioPlayer.src = '';
|
| 154 |
+
setAudioPlayer(null);
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
// Prepare test data
|
| 158 |
+
const testData = {
|
| 159 |
+
text: formData.testInput.trim(),
|
| 160 |
+
voice_id: formData.voice?.id || 'alloy',
|
| 161 |
+
emotion: 'neutral', // Default emotion
|
| 162 |
+
speed: formData.speed
|
| 163 |
+
};
|
| 164 |
+
|
| 165 |
+
console.log('Sending test data:', JSON.stringify(testData, null, 2));
|
| 166 |
+
|
| 167 |
+
// Make API request
|
| 168 |
+
const response = await fetch('http://localhost:8000/agents/test-voice', {
|
| 169 |
+
method: 'POST',
|
| 170 |
+
headers: {
|
| 171 |
+
'Content-Type': 'application/json',
|
| 172 |
+
'Authorization': `Bearer ${token}`
|
| 173 |
+
},
|
| 174 |
+
body: JSON.stringify(testData)
|
| 175 |
+
});
|
| 176 |
+
|
| 177 |
+
console.log('Response status:', response.status);
|
| 178 |
+
const data = await response.json();
|
| 179 |
+
console.log('Response data:', JSON.stringify(data, null, 2));
|
| 180 |
+
|
| 181 |
+
if (!response.ok) {
|
| 182 |
+
throw new Error(data.detail || 'Failed to test voice');
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
if (!data.audio_url) {
|
| 186 |
+
throw new Error('No audio URL returned from server');
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
setToast({ message: 'Creating audio player...', type: 'info' });
|
| 190 |
+
|
| 191 |
+
// Create and configure new audio player
|
| 192 |
+
const newPlayer = new Audio();
|
| 193 |
+
|
| 194 |
+
// Set up event handlers before setting the source
|
| 195 |
+
newPlayer.onerror = (e) => {
|
| 196 |
+
console.error('Audio loading error:', newPlayer.error, e);
|
| 197 |
+
setToast({
|
| 198 |
+
message: `Failed to load audio file: ${newPlayer.error?.message || 'Unknown error'}`,
|
| 199 |
+
type: 'error'
|
| 200 |
+
});
|
| 201 |
+
setIsTestingVoice(false);
|
| 202 |
+
};
|
| 203 |
+
|
| 204 |
+
newPlayer.oncanplaythrough = () => {
|
| 205 |
+
console.log('Audio can play through, starting playback');
|
| 206 |
+
newPlayer.play()
|
| 207 |
+
.then(() => {
|
| 208 |
+
setToast({ message: 'Playing test audio', type: 'success' });
|
| 209 |
+
})
|
| 210 |
+
.catch((error) => {
|
| 211 |
+
console.error('Playback error:', error);
|
| 212 |
+
setToast({
|
| 213 |
+
message: `Failed to play audio: ${error.message}`,
|
| 214 |
+
type: 'error'
|
| 215 |
+
});
|
| 216 |
+
setIsTestingVoice(false);
|
| 217 |
+
});
|
| 218 |
+
};
|
| 219 |
+
|
| 220 |
+
newPlayer.onended = () => {
|
| 221 |
+
console.log('Audio playback ended');
|
| 222 |
+
setIsTestingVoice(false);
|
| 223 |
+
};
|
| 224 |
+
|
| 225 |
+
// Log the audio URL we're trying to play
|
| 226 |
+
console.log('Setting audio source to:', data.audio_url);
|
| 227 |
+
|
| 228 |
+
// Set the source and start loading
|
| 229 |
+
newPlayer.src = data.audio_url;
|
| 230 |
+
setAudioPlayer(newPlayer);
|
| 231 |
+
|
| 232 |
+
// Try to load the audio
|
| 233 |
+
try {
|
| 234 |
+
await newPlayer.load();
|
| 235 |
+
console.log('Audio loaded successfully');
|
| 236 |
+
} catch (loadError) {
|
| 237 |
+
console.error('Error loading audio:', loadError);
|
| 238 |
+
setToast({
|
| 239 |
+
message: `Error loading audio: ${loadError instanceof Error ? loadError.message : 'Unknown error'}`,
|
| 240 |
+
type: 'error'
|
| 241 |
+
});
|
| 242 |
+
setIsTestingVoice(false);
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
} catch (error) {
|
| 246 |
+
console.error('Error testing voice:', error);
|
| 247 |
+
setToast({
|
| 248 |
+
message: error instanceof Error ? error.message : 'Failed to test voice',
|
| 249 |
+
type: 'error'
|
| 250 |
+
});
|
| 251 |
+
setIsTestingVoice(false);
|
| 252 |
+
}
|
| 253 |
+
};
|
| 254 |
+
|
| 255 |
+
// Cleanup audio player on modal close
|
| 256 |
+
React.useEffect(() => {
|
| 257 |
+
return () => {
|
| 258 |
+
if (audioPlayer) {
|
| 259 |
+
audioPlayer.pause();
|
| 260 |
+
audioPlayer.src = '';
|
| 261 |
+
}
|
| 262 |
+
};
|
| 263 |
+
}, [audioPlayer]);
|
| 264 |
+
|
| 265 |
+
const toggleInputType = () => {
|
| 266 |
+
setFormData(prev => ({
|
| 267 |
+
...prev,
|
| 268 |
+
showPersonality: !prev.showPersonality
|
| 269 |
+
}));
|
| 270 |
+
};
|
| 271 |
+
|
| 272 |
+
const handleSubmit = async (e: React.FormEvent) => {
|
| 273 |
+
e.preventDefault();
|
| 274 |
+
if (!formData.voice) {
|
| 275 |
+
setToast({ message: 'Please select a voice', type: 'error' });
|
| 276 |
+
return;
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
try {
|
| 280 |
+
const token = localStorage.getItem('token');
|
| 281 |
+
if (!token) {
|
| 282 |
+
setToast({ message: 'Authentication token not found', type: 'error' });
|
| 283 |
+
return;
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
const requestData = {
|
| 287 |
+
name: formData.name,
|
| 288 |
+
voice_id: formData.voice.id,
|
| 289 |
+
voice_name: formData.voice.name,
|
| 290 |
+
voice_description: formData.voice.description,
|
| 291 |
+
speed: formData.speed,
|
| 292 |
+
pitch: formData.pitch,
|
| 293 |
+
volume: formData.volume,
|
| 294 |
+
output_format: formData.outputFormat, // Use snake_case to match backend
|
| 295 |
+
personality: formData.showPersonality ? formData.personality : null
|
| 296 |
+
};
|
| 297 |
+
|
| 298 |
+
console.log('Request data:', JSON.stringify(requestData, null, 2));
|
| 299 |
+
|
| 300 |
+
const url = editAgent
|
| 301 |
+
? `http://localhost:8000/agents/${editAgent.id}`
|
| 302 |
+
: 'http://localhost:8000/agents/create';
|
| 303 |
+
|
| 304 |
+
const response = await fetch(url, {
|
| 305 |
+
method: editAgent ? 'PUT' : 'POST',
|
| 306 |
+
headers: {
|
| 307 |
+
'Content-Type': 'application/json',
|
| 308 |
+
'Authorization': `Bearer ${token}`
|
| 309 |
+
},
|
| 310 |
+
body: JSON.stringify(requestData)
|
| 311 |
+
});
|
| 312 |
+
|
| 313 |
+
const responseData = await response.json();
|
| 314 |
+
console.log('Response data:', JSON.stringify(responseData, null, 2));
|
| 315 |
+
|
| 316 |
+
if (!response.ok) {
|
| 317 |
+
throw new Error(JSON.stringify(responseData, null, 2));
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
setToast({ message: `Agent ${editAgent ? 'updated' : 'created'} successfully`, type: 'success' });
|
| 321 |
+
onClose();
|
| 322 |
+
} catch (error) {
|
| 323 |
+
console.error('Error saving agent:', error);
|
| 324 |
+
if (error instanceof Error) {
|
| 325 |
+
console.error('Error details:', error.message);
|
| 326 |
+
try {
|
| 327 |
+
const errorDetails = JSON.parse(error.message);
|
| 328 |
+
setToast({
|
| 329 |
+
message: errorDetails.detail?.[0]?.msg || 'Failed to save agent',
|
| 330 |
+
type: 'error'
|
| 331 |
+
});
|
| 332 |
+
} catch {
|
| 333 |
+
setToast({ message: error.message, type: 'error' });
|
| 334 |
+
}
|
| 335 |
+
} else {
|
| 336 |
+
setToast({ message: 'An unexpected error occurred', type: 'error' });
|
| 337 |
+
}
|
| 338 |
+
}
|
| 339 |
+
};
|
| 340 |
+
|
| 341 |
+
if (!isOpen) return null;
|
| 342 |
+
|
| 343 |
+
return (
|
| 344 |
+
<div className="agent-modal-overlay" style={{ display: isOpen ? 'flex' : 'none' }}>
|
| 345 |
+
<div className="agent-modal-content">
|
| 346 |
+
<div className="agent-modal-header">
|
| 347 |
+
<h2>{editAgent ? 'Edit Agent' : 'Create New Agent'}</h2>
|
| 348 |
+
<button className="close-button" onClick={onClose}>×</button>
|
| 349 |
+
</div>
|
| 350 |
+
|
| 351 |
+
<form className="agent-form" onSubmit={handleSubmit}>
|
| 352 |
+
<div className="form-group">
|
| 353 |
+
<label htmlFor="name">Agent Name</label>
|
| 354 |
+
<input
|
| 355 |
+
type="text"
|
| 356 |
+
id="name"
|
| 357 |
+
name="name"
|
| 358 |
+
value={formData.name}
|
| 359 |
+
onChange={handleInputChange}
|
| 360 |
+
placeholder="Enter agent name"
|
| 361 |
+
required
|
| 362 |
+
/>
|
| 363 |
+
</div>
|
| 364 |
+
|
| 365 |
+
<div className="form-group">
|
| 366 |
+
<label>Voice</label>
|
| 367 |
+
<div className="custom-dropdown">
|
| 368 |
+
<div
|
| 369 |
+
className="dropdown-header"
|
| 370 |
+
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
| 371 |
+
>
|
| 372 |
+
<div className="selected-voice">
|
| 373 |
+
<FaVolumeUp />
|
| 374 |
+
<div className="voice-info">
|
| 375 |
+
<span>{formData.voice?.name}</span>
|
| 376 |
+
<small>{formData.voice?.description}</small>
|
| 377 |
+
</div>
|
| 378 |
+
</div>
|
| 379 |
+
<FaChevronDown style={{
|
| 380 |
+
transform: isDropdownOpen ? 'rotate(180deg)' : 'none',
|
| 381 |
+
transition: 'transform 0.3s ease'
|
| 382 |
+
}} />
|
| 383 |
+
</div>
|
| 384 |
+
{isDropdownOpen && (
|
| 385 |
+
<div className="dropdown-options">
|
| 386 |
+
{VOICE_OPTIONS.map(voice => (
|
| 387 |
+
<div
|
| 388 |
+
key={voice.id}
|
| 389 |
+
className="dropdown-option"
|
| 390 |
+
onClick={() => handleVoiceSelect(voice)}
|
| 391 |
+
>
|
| 392 |
+
<FaVolumeUp />
|
| 393 |
+
<div className="voice-info">
|
| 394 |
+
<span>{voice.name}</span>
|
| 395 |
+
<small>{voice.description}</small>
|
| 396 |
+
</div>
|
| 397 |
+
</div>
|
| 398 |
+
))}
|
| 399 |
+
</div>
|
| 400 |
+
)}
|
| 401 |
+
</div>
|
| 402 |
+
</div>
|
| 403 |
+
|
| 404 |
+
<div className="form-group">
|
| 405 |
+
<label htmlFor="speed">Speed</label>
|
| 406 |
+
<div className="slider-container">
|
| 407 |
+
<input
|
| 408 |
+
type="range"
|
| 409 |
+
id="speed"
|
| 410 |
+
name="speed"
|
| 411 |
+
min="0.5"
|
| 412 |
+
max="2"
|
| 413 |
+
step="0.1"
|
| 414 |
+
value={formData.speed}
|
| 415 |
+
onChange={handleSliderChange}
|
| 416 |
+
/>
|
| 417 |
+
<span className="slider-value">{formData.speed}x</span>
|
| 418 |
+
</div>
|
| 419 |
+
</div>
|
| 420 |
+
|
| 421 |
+
<div className="form-group">
|
| 422 |
+
<label htmlFor="pitch">Pitch</label>
|
| 423 |
+
<div className="slider-container">
|
| 424 |
+
<input
|
| 425 |
+
type="range"
|
| 426 |
+
id="pitch"
|
| 427 |
+
name="pitch"
|
| 428 |
+
min="0.5"
|
| 429 |
+
max="2"
|
| 430 |
+
step="0.1"
|
| 431 |
+
value={formData.pitch}
|
| 432 |
+
onChange={handleSliderChange}
|
| 433 |
+
/>
|
| 434 |
+
<span className="slider-value">{formData.pitch}x</span>
|
| 435 |
+
</div>
|
| 436 |
+
</div>
|
| 437 |
+
|
| 438 |
+
<div className="form-group">
|
| 439 |
+
<label htmlFor="volume">Volume</label>
|
| 440 |
+
<div className="slider-container">
|
| 441 |
+
<input
|
| 442 |
+
type="range"
|
| 443 |
+
id="volume"
|
| 444 |
+
name="volume"
|
| 445 |
+
min="0"
|
| 446 |
+
max="2"
|
| 447 |
+
step="0.1"
|
| 448 |
+
value={formData.volume}
|
| 449 |
+
onChange={handleSliderChange}
|
| 450 |
+
/>
|
| 451 |
+
<span className="slider-value">{formData.volume}x</span>
|
| 452 |
+
</div>
|
| 453 |
+
</div>
|
| 454 |
+
|
| 455 |
+
<div className="form-group">
|
| 456 |
+
<label>Output Format</label>
|
| 457 |
+
<div className="radio-group">
|
| 458 |
+
<label className="radio-label">
|
| 459 |
+
<input
|
| 460 |
+
type="radio"
|
| 461 |
+
name="outputFormat"
|
| 462 |
+
value="mp3"
|
| 463 |
+
checked={formData.outputFormat === 'mp3'}
|
| 464 |
+
onChange={handleInputChange}
|
| 465 |
+
/>
|
| 466 |
+
<span>MP3</span>
|
| 467 |
+
</label>
|
| 468 |
+
<label className="radio-label">
|
| 469 |
+
<input
|
| 470 |
+
type="radio"
|
| 471 |
+
name="outputFormat"
|
| 472 |
+
value="wav"
|
| 473 |
+
checked={formData.outputFormat === 'wav'}
|
| 474 |
+
onChange={handleInputChange}
|
| 475 |
+
/>
|
| 476 |
+
<span>WAV</span>
|
| 477 |
+
</label>
|
| 478 |
+
</div>
|
| 479 |
+
</div>
|
| 480 |
+
|
| 481 |
+
<div className="form-group toggle-group">
|
| 482 |
+
<label>Input Type</label>
|
| 483 |
+
<div className="toggle-container" onClick={toggleInputType}>
|
| 484 |
+
<span className={!formData.showPersonality ? 'active' : ''}>Test Input</span>
|
| 485 |
+
{formData.showPersonality ?
|
| 486 |
+
<BsToggleOn className="toggle-icon" /> :
|
| 487 |
+
<BsToggleOff className="toggle-icon" />
|
| 488 |
+
}
|
| 489 |
+
<span className={formData.showPersonality ? 'active' : ''}>Agent Personality</span>
|
| 490 |
+
</div>
|
| 491 |
+
</div>
|
| 492 |
+
|
| 493 |
+
{formData.showPersonality ? (
|
| 494 |
+
<div className="form-group">
|
| 495 |
+
<label htmlFor="personality">Agent Personality</label>
|
| 496 |
+
<textarea
|
| 497 |
+
id="personality"
|
| 498 |
+
name="personality"
|
| 499 |
+
value={formData.personality}
|
| 500 |
+
onChange={handleInputChange}
|
| 501 |
+
placeholder="Describe the personality and characteristics of this agent..."
|
| 502 |
+
rows={4}
|
| 503 |
+
/>
|
| 504 |
+
<small className="help-text">This personality description will be used to guide the agent's responses in workflows.</small>
|
| 505 |
+
</div>
|
| 506 |
+
) : (
|
| 507 |
+
<div className="form-group">
|
| 508 |
+
<label htmlFor="testInput">Test Input</label>
|
| 509 |
+
<textarea
|
| 510 |
+
id="testInput"
|
| 511 |
+
name="testInput"
|
| 512 |
+
value={formData.testInput}
|
| 513 |
+
onChange={handleInputChange}
|
| 514 |
+
placeholder="Enter text to test the voice"
|
| 515 |
+
rows={4}
|
| 516 |
+
/>
|
| 517 |
+
</div>
|
| 518 |
+
)}
|
| 519 |
+
|
| 520 |
+
<div className="modal-actions">
|
| 521 |
+
{!formData.showPersonality && (
|
| 522 |
+
<button
|
| 523 |
+
type="button"
|
| 524 |
+
className="test-voice-btn"
|
| 525 |
+
onClick={handleTestVoice}
|
| 526 |
+
disabled={!formData.testInput || isTestingVoice}
|
| 527 |
+
>
|
| 528 |
+
<FaPlay /> {isTestingVoice ? 'Testing...' : 'Test Voice'}
|
| 529 |
+
</button>
|
| 530 |
+
)}
|
| 531 |
+
<div className="right-actions">
|
| 532 |
+
<button type="button" className="cancel-btn" onClick={onClose}>
|
| 533 |
+
Cancel
|
| 534 |
+
</button>
|
| 535 |
+
<button
|
| 536 |
+
type="submit"
|
| 537 |
+
className="save-btn"
|
| 538 |
+
disabled={isLoading}
|
| 539 |
+
>
|
| 540 |
+
<FaSave /> {isLoading ? 'Saving...' : 'Save Agent'}
|
| 541 |
+
</button>
|
| 542 |
+
</div>
|
| 543 |
+
</div>
|
| 544 |
+
</form>
|
| 545 |
+
|
| 546 |
+
{toast && (
|
| 547 |
+
<Toast
|
| 548 |
+
message={toast.message}
|
| 549 |
+
type={toast.type}
|
| 550 |
+
onClose={() => setToast(null)}
|
| 551 |
+
/>
|
| 552 |
+
)}
|
| 553 |
+
</div>
|
| 554 |
+
</div>
|
| 555 |
+
);
|
| 556 |
+
};
|
| 557 |
+
|
| 558 |
+
export default AgentModal;
|
frontend/podcraft/src/components/ChatDetailModal.css
ADDED
|
@@ -0,0 +1,400 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.chat-modal-overlay {
|
| 2 |
+
position: fixed;
|
| 3 |
+
top: 0;
|
| 4 |
+
left: 0;
|
| 5 |
+
right: 0;
|
| 6 |
+
bottom: 0;
|
| 7 |
+
background-color: rgba(0, 0, 0, 0.75);
|
| 8 |
+
display: flex;
|
| 9 |
+
align-items: center;
|
| 10 |
+
justify-content: center;
|
| 11 |
+
z-index: 1100;
|
| 12 |
+
backdrop-filter: blur(5px);
|
| 13 |
+
animation: fadeIn 0.25s ease-out;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
.chat-modal-content {
|
| 17 |
+
background: rgba(25, 25, 35, 0.95);
|
| 18 |
+
border-radius: 16px;
|
| 19 |
+
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35), 0 0 0 1px rgba(99, 102, 241, 0.3);
|
| 20 |
+
width: 90%;
|
| 21 |
+
max-width: 700px;
|
| 22 |
+
max-height: 90vh;
|
| 23 |
+
display: flex;
|
| 24 |
+
flex-direction: column;
|
| 25 |
+
overflow: hidden;
|
| 26 |
+
position: relative;
|
| 27 |
+
border: 1px solid rgba(99, 102, 241, 0.2);
|
| 28 |
+
animation: slideUp 0.3s ease-out;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
.chat-modal-header {
|
| 32 |
+
display: flex;
|
| 33 |
+
align-items: center;
|
| 34 |
+
justify-content: space-between;
|
| 35 |
+
padding: 16px 20px;
|
| 36 |
+
background: rgba(30, 30, 45, 0.7);
|
| 37 |
+
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
.agent-info {
|
| 41 |
+
display: flex;
|
| 42 |
+
align-items: center;
|
| 43 |
+
gap: 12px;
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
.agent-avatar {
|
| 47 |
+
width: 40px;
|
| 48 |
+
height: 40px;
|
| 49 |
+
border-radius: 50%;
|
| 50 |
+
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
| 51 |
+
display: flex;
|
| 52 |
+
align-items: center;
|
| 53 |
+
justify-content: center;
|
| 54 |
+
color: white;
|
| 55 |
+
font-size: 1.2rem;
|
| 56 |
+
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.3);
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
.agent-details {
|
| 60 |
+
display: flex;
|
| 61 |
+
flex-direction: column;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
.agent-details h3 {
|
| 65 |
+
margin: 0;
|
| 66 |
+
font-size: 1.1rem;
|
| 67 |
+
font-weight: 600;
|
| 68 |
+
color: white;
|
| 69 |
+
display: flex;
|
| 70 |
+
align-items: center;
|
| 71 |
+
gap: 8px;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
.turn-badge {
|
| 75 |
+
font-size: 0.75rem;
|
| 76 |
+
color: rgba(255, 255, 255, 0.7);
|
| 77 |
+
background: rgba(99, 102, 241, 0.2);
|
| 78 |
+
padding: 3px 8px;
|
| 79 |
+
border-radius: 12px;
|
| 80 |
+
margin-top: 4px;
|
| 81 |
+
width: fit-content;
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
.modal-actions {
|
| 85 |
+
display: flex;
|
| 86 |
+
gap: 12px;
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
.copy-button,
|
| 90 |
+
.close-button {
|
| 91 |
+
background: rgba(255, 255, 255, 0.1);
|
| 92 |
+
border: none;
|
| 93 |
+
color: white;
|
| 94 |
+
border-radius: 8px;
|
| 95 |
+
display: flex;
|
| 96 |
+
align-items: center;
|
| 97 |
+
justify-content: center;
|
| 98 |
+
cursor: pointer;
|
| 99 |
+
transition: all 0.2s ease;
|
| 100 |
+
font-size: 1rem;
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
.copy-button {
|
| 104 |
+
font-size: 0.85rem;
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
.copy-button:hover,
|
| 108 |
+
.close-button:hover {
|
| 109 |
+
background: rgba(255, 255, 255, 0.2);
|
| 110 |
+
transform: translateY(-2px);
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
.copy-button.copied {
|
| 114 |
+
background: rgba(34, 197, 94, 0.3);
|
| 115 |
+
color: rgb(134, 239, 172);
|
| 116 |
+
animation: flash 0.5s;
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
.copy-button.copied::after {
|
| 120 |
+
content: 'Copied!';
|
| 121 |
+
position: absolute;
|
| 122 |
+
top: -30px;
|
| 123 |
+
left: 50%;
|
| 124 |
+
transform: translateX(-50%);
|
| 125 |
+
background: rgba(34, 197, 94, 0.9);
|
| 126 |
+
color: white;
|
| 127 |
+
padding: 5px 10px;
|
| 128 |
+
border-radius: 4px;
|
| 129 |
+
font-size: 0.7rem;
|
| 130 |
+
animation: fadeOut 2s;
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
.close-button {
|
| 134 |
+
font-size: 1.4rem;
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
.chat-modal-body {
|
| 138 |
+
flex: 1;
|
| 139 |
+
padding: 20px;
|
| 140 |
+
overflow-y: auto;
|
| 141 |
+
min-height: 200px;
|
| 142 |
+
max-height: 60vh;
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
.content-box {
|
| 146 |
+
line-height: 1.7;
|
| 147 |
+
font-size: 0.95rem;
|
| 148 |
+
color: rgba(255, 255, 255, 0.9);
|
| 149 |
+
white-space: pre-wrap;
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
.chat-modal-footer {
|
| 153 |
+
padding: 16px 20px;
|
| 154 |
+
display: flex;
|
| 155 |
+
justify-content: flex-end;
|
| 156 |
+
background: rgba(30, 30, 45, 0.7);
|
| 157 |
+
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
.modal-button {
|
| 161 |
+
padding: 8px 16px;
|
| 162 |
+
border-radius: 8px;
|
| 163 |
+
font-weight: 500;
|
| 164 |
+
cursor: pointer;
|
| 165 |
+
transition: all 0.2s ease;
|
| 166 |
+
font-size: 0.9rem;
|
| 167 |
+
display: flex;
|
| 168 |
+
align-items: center;
|
| 169 |
+
gap: 8px;
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
.close-btn {
|
| 173 |
+
background: rgba(255, 255, 255, 0.1);
|
| 174 |
+
color: white;
|
| 175 |
+
border: 1px solid rgba(255, 255, 255, 0.2);
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
.close-btn:hover {
|
| 179 |
+
background: rgba(255, 255, 255, 0.2);
|
| 180 |
+
transform: translateY(-2px);
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
/* Animations */
|
| 184 |
+
@keyframes fadeIn {
|
| 185 |
+
from {
|
| 186 |
+
opacity: 0;
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
to {
|
| 190 |
+
opacity: 1;
|
| 191 |
+
}
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
@keyframes slideUp {
|
| 195 |
+
from {
|
| 196 |
+
opacity: 0;
|
| 197 |
+
transform: translateY(20px);
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
to {
|
| 201 |
+
opacity: 1;
|
| 202 |
+
transform: translateY(0);
|
| 203 |
+
}
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
@keyframes flash {
|
| 207 |
+
|
| 208 |
+
0%,
|
| 209 |
+
100% {
|
| 210 |
+
background: rgba(34, 197, 94, 0.3);
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
50% {
|
| 214 |
+
background: rgba(34, 197, 94, 0.5);
|
| 215 |
+
}
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
@keyframes fadeOut {
|
| 219 |
+
|
| 220 |
+
0%,
|
| 221 |
+
10% {
|
| 222 |
+
opacity: 1;
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
90%,
|
| 226 |
+
100% {
|
| 227 |
+
opacity: 0;
|
| 228 |
+
}
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
/* Light theme adjustments */
|
| 232 |
+
:root[data-theme="light"] .chat-modal-content {
|
| 233 |
+
background: rgba(255, 255, 255, 0.95);
|
| 234 |
+
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15), 0 0 0 1px rgba(99, 102, 241, 0.2);
|
| 235 |
+
border: 1px solid rgba(99, 102, 241, 0.1);
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
:root[data-theme="light"] .chat-modal-header,
|
| 239 |
+
:root[data-theme="light"] .chat-modal-footer {
|
| 240 |
+
background: rgba(245, 245, 255, 0.9);
|
| 241 |
+
border-color: rgba(0, 0, 0, 0.05);
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
:root[data-theme="light"] .agent-details h3 {
|
| 245 |
+
color: #333;
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
:root[data-theme="light"] .turn-badge {
|
| 249 |
+
color: rgba(0, 0, 0, 0.7);
|
| 250 |
+
background: rgba(99, 102, 241, 0.1);
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
:root[data-theme="light"] .copy-button,
|
| 254 |
+
:root[data-theme="light"] .close-button {
|
| 255 |
+
background: rgba(0, 0, 0, 0.05);
|
| 256 |
+
color: #333;
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
:root[data-theme="light"] .copy-button:hover,
|
| 260 |
+
:root[data-theme="light"] .close-button:hover {
|
| 261 |
+
background: rgba(0, 0, 0, 0.1);
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
:root[data-theme="light"] .content-box {
|
| 265 |
+
color: #333;
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
:root[data-theme="light"] .close-btn {
|
| 269 |
+
background: rgba(0, 0, 0, 0.05);
|
| 270 |
+
color: #333;
|
| 271 |
+
border: 1px solid rgba(0, 0, 0, 0.1);
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
:root[data-theme="light"] .close-btn:hover {
|
| 275 |
+
background: rgba(0, 0, 0, 0.1);
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
.edit-button,
|
| 279 |
+
.save-button,
|
| 280 |
+
.cancel-button {
|
| 281 |
+
background: rgba(255, 255, 255, 0.1);
|
| 282 |
+
border: none;
|
| 283 |
+
color: white;
|
| 284 |
+
border-radius: 8px;
|
| 285 |
+
display: flex;
|
| 286 |
+
align-items: center;
|
| 287 |
+
justify-content: center;
|
| 288 |
+
cursor: pointer;
|
| 289 |
+
transition: all 0.2s ease;
|
| 290 |
+
font-size: 0.85rem;
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
.edit-button:hover {
|
| 294 |
+
background: rgba(99, 102, 241, 0.3);
|
| 295 |
+
transform: translateY(-2px);
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
.save-button {
|
| 299 |
+
background: rgba(16, 185, 129, 0.2);
|
| 300 |
+
color: rgb(134, 239, 172);
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
.save-button:hover {
|
| 304 |
+
background: rgba(16, 185, 129, 0.4);
|
| 305 |
+
transform: translateY(-2px);
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
+
.cancel-button {
|
| 309 |
+
background: rgba(239, 68, 68, 0.2);
|
| 310 |
+
color: rgb(252, 165, 165);
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
.cancel-button:hover {
|
| 314 |
+
background: rgba(239, 68, 68, 0.4);
|
| 315 |
+
transform: translateY(-2px);
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
.content-editor {
|
| 319 |
+
width: 100%;
|
| 320 |
+
min-height: 200px;
|
| 321 |
+
background: rgba(30, 30, 45, 0.6);
|
| 322 |
+
border: 1px solid rgba(99, 102, 241, 0.3);
|
| 323 |
+
border-radius: 8px;
|
| 324 |
+
color: white;
|
| 325 |
+
font-family: inherit;
|
| 326 |
+
font-size: 0.95rem;
|
| 327 |
+
line-height: 1.7;
|
| 328 |
+
/* padding: 12px; */
|
| 329 |
+
resize: vertical;
|
| 330 |
+
transition: all 0.2s ease;
|
| 331 |
+
}
|
| 332 |
+
|
| 333 |
+
.content-editor:focus {
|
| 334 |
+
outline: none;
|
| 335 |
+
border-color: rgba(99, 102, 241, 0.8);
|
| 336 |
+
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2);
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
.save-btn {
|
| 340 |
+
background: rgba(16, 185, 129, 0.2);
|
| 341 |
+
color: rgb(134, 239, 172);
|
| 342 |
+
border: 1px solid rgba(16, 185, 129, 0.4);
|
| 343 |
+
margin-left: 12px;
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
.save-btn:hover {
|
| 347 |
+
background: rgba(16, 185, 129, 0.4);
|
| 348 |
+
transform: translateY(-2px);
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
+
/* Light theme adjustments for new elements */
|
| 352 |
+
:root[data-theme="light"] .edit-button,
|
| 353 |
+
:root[data-theme="light"] .save-button,
|
| 354 |
+
:root[data-theme="light"] .cancel-button {
|
| 355 |
+
background: rgba(0, 0, 0, 0.05);
|
| 356 |
+
color: #333;
|
| 357 |
+
}
|
| 358 |
+
|
| 359 |
+
:root[data-theme="light"] .edit-button:hover {
|
| 360 |
+
background: rgba(99, 102, 241, 0.2);
|
| 361 |
+
}
|
| 362 |
+
|
| 363 |
+
:root[data-theme="light"] .save-button {
|
| 364 |
+
background: rgba(16, 185, 129, 0.1);
|
| 365 |
+
color: rgb(5, 150, 105);
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
:root[data-theme="light"] .save-button:hover {
|
| 369 |
+
background: rgba(16, 185, 129, 0.2);
|
| 370 |
+
}
|
| 371 |
+
|
| 372 |
+
:root[data-theme="light"] .cancel-button {
|
| 373 |
+
background: rgba(239, 68, 68, 0.1);
|
| 374 |
+
color: rgb(220, 38, 38);
|
| 375 |
+
}
|
| 376 |
+
|
| 377 |
+
:root[data-theme="light"] .cancel-button:hover {
|
| 378 |
+
background: rgba(239, 68, 68, 0.2);
|
| 379 |
+
}
|
| 380 |
+
|
| 381 |
+
:root[data-theme="light"] .content-editor {
|
| 382 |
+
background: #fff;
|
| 383 |
+
border: 1px solid rgba(99, 102, 241, 0.2);
|
| 384 |
+
color: #333;
|
| 385 |
+
}
|
| 386 |
+
|
| 387 |
+
:root[data-theme="light"] .content-editor:focus {
|
| 388 |
+
border-color: rgba(99, 102, 241, 0.6);
|
| 389 |
+
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.1);
|
| 390 |
+
}
|
| 391 |
+
|
| 392 |
+
:root[data-theme="light"] .save-btn {
|
| 393 |
+
background: rgba(16, 185, 129, 0.1);
|
| 394 |
+
color: rgb(5, 150, 105);
|
| 395 |
+
border: 1px solid rgba(16, 185, 129, 0.3);
|
| 396 |
+
}
|
| 397 |
+
|
| 398 |
+
:root[data-theme="light"] .save-btn:hover {
|
| 399 |
+
background: rgba(16, 185, 129, 0.2);
|
| 400 |
+
}
|
frontend/podcraft/src/components/ChatDetailModal.jsx
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useRef, useEffect, useState } from 'react';
|
| 2 |
+
import { FaRobot, FaUser, FaCopy, FaSearch, FaEdit, FaSave, FaTimes } from 'react-icons/fa';
|
| 3 |
+
import './ChatDetailModal.css';
|
| 4 |
+
|
| 5 |
+
/**
|
| 6 |
+
* Modal component for displaying and editing agent chat messages in detail
|
| 7 |
+
* @param {Object} props
|
| 8 |
+
* @param {boolean} props.isOpen - Whether the modal is open
|
| 9 |
+
* @param {function} props.onClose - Function to call when the modal is closed
|
| 10 |
+
* @param {string} props.agentName - The name of the agent
|
| 11 |
+
* @param {string} props.agentId - The ID of the agent
|
| 12 |
+
* @param {number} props.turn - The turn number
|
| 13 |
+
* @param {string} props.content - The chat message content
|
| 14 |
+
* @param {function} props.onSave - Function to call when the content is saved
|
| 15 |
+
*/
|
| 16 |
+
const ChatDetailModal = ({ isOpen, onClose, agentName, agentId, turn, content, onSave }) => {
|
| 17 |
+
const modalRef = useRef(null);
|
| 18 |
+
const [isEditing, setIsEditing] = useState(false);
|
| 19 |
+
const [editedContent, setEditedContent] = useState('');
|
| 20 |
+
const textareaRef = useRef(null);
|
| 21 |
+
|
| 22 |
+
// Initialize the editor with the current content when editing starts
|
| 23 |
+
useEffect(() => {
|
| 24 |
+
if (isEditing && content) {
|
| 25 |
+
// Remove any HTML tags to get plain text for editing
|
| 26 |
+
const plainText = content.replace(/<[^>]*>/g, '');
|
| 27 |
+
setEditedContent(plainText);
|
| 28 |
+
|
| 29 |
+
// Focus the textarea when editing starts
|
| 30 |
+
setTimeout(() => {
|
| 31 |
+
if (textareaRef.current) {
|
| 32 |
+
textareaRef.current.focus();
|
| 33 |
+
}
|
| 34 |
+
}, 100);
|
| 35 |
+
}
|
| 36 |
+
}, [isEditing, content]);
|
| 37 |
+
|
| 38 |
+
// Reset edited content when content changes (even if modal is already open)
|
| 39 |
+
useEffect(() => {
|
| 40 |
+
if (content && isOpen) {
|
| 41 |
+
// If we're currently editing, update the edited content
|
| 42 |
+
if (isEditing) {
|
| 43 |
+
const plainText = content.replace(/<[^>]*>/g, '');
|
| 44 |
+
setEditedContent(plainText);
|
| 45 |
+
}
|
| 46 |
+
}
|
| 47 |
+
}, [content, isOpen]);
|
| 48 |
+
|
| 49 |
+
// Handle clicks outside the modal to close it
|
| 50 |
+
useEffect(() => {
|
| 51 |
+
const handleClickOutside = (event) => {
|
| 52 |
+
if (modalRef.current && !modalRef.current.contains(event.target)) {
|
| 53 |
+
onClose();
|
| 54 |
+
}
|
| 55 |
+
};
|
| 56 |
+
|
| 57 |
+
if (isOpen) {
|
| 58 |
+
document.addEventListener('mousedown', handleClickOutside);
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
return () => {
|
| 62 |
+
document.removeEventListener('mousedown', handleClickOutside);
|
| 63 |
+
};
|
| 64 |
+
}, [isOpen, onClose]);
|
| 65 |
+
|
| 66 |
+
// Copy content to clipboard
|
| 67 |
+
const handleCopyContent = () => {
|
| 68 |
+
const plainText = content.replace(/<[^>]*>/g, '');
|
| 69 |
+
navigator.clipboard.writeText(plainText);
|
| 70 |
+
|
| 71 |
+
// Show a mini toast or feedback
|
| 72 |
+
const copyButton = document.querySelector('.copy-button');
|
| 73 |
+
if (copyButton) {
|
| 74 |
+
copyButton.classList.add('copied');
|
| 75 |
+
setTimeout(() => {
|
| 76 |
+
copyButton.classList.remove('copied');
|
| 77 |
+
}, 2000);
|
| 78 |
+
}
|
| 79 |
+
};
|
| 80 |
+
|
| 81 |
+
// Start editing the content
|
| 82 |
+
const handleStartEditing = () => {
|
| 83 |
+
setIsEditing(true);
|
| 84 |
+
};
|
| 85 |
+
|
| 86 |
+
// Cancel editing and reset
|
| 87 |
+
const handleCancelEdit = () => {
|
| 88 |
+
setIsEditing(false);
|
| 89 |
+
setEditedContent('');
|
| 90 |
+
};
|
| 91 |
+
|
| 92 |
+
// Save the edited content
|
| 93 |
+
const handleSaveEdit = () => {
|
| 94 |
+
if (onSave) {
|
| 95 |
+
onSave(agentId, turn, editedContent);
|
| 96 |
+
}
|
| 97 |
+
setIsEditing(false);
|
| 98 |
+
};
|
| 99 |
+
|
| 100 |
+
// Don't render anything if the modal is not open
|
| 101 |
+
if (!isOpen) return null;
|
| 102 |
+
|
| 103 |
+
// Get agent icon based on agent ID or name
|
| 104 |
+
const getAgentIcon = () => {
|
| 105 |
+
if (agentId === 'researcher') {
|
| 106 |
+
return <FaSearch />;
|
| 107 |
+
}
|
| 108 |
+
return <FaRobot />;
|
| 109 |
+
};
|
| 110 |
+
|
| 111 |
+
return (
|
| 112 |
+
<div className="chat-modal-overlay">
|
| 113 |
+
<div className="chat-modal-content" ref={modalRef}>
|
| 114 |
+
<div className="chat-modal-header">
|
| 115 |
+
<div className="agent-info">
|
| 116 |
+
<div className="agent-avatar">
|
| 117 |
+
{getAgentIcon()}
|
| 118 |
+
</div>
|
| 119 |
+
<div className="agent-details">
|
| 120 |
+
<h3>{agentName}</h3>
|
| 121 |
+
<span className="turn-badge">Turn {turn}</span>
|
| 122 |
+
</div>
|
| 123 |
+
</div>
|
| 124 |
+
<div className="modal-actions">
|
| 125 |
+
{!isEditing ? (
|
| 126 |
+
<>
|
| 127 |
+
<button
|
| 128 |
+
className="edit-button"
|
| 129 |
+
onClick={handleStartEditing}
|
| 130 |
+
title="Edit content"
|
| 131 |
+
>
|
| 132 |
+
<FaEdit />
|
| 133 |
+
</button>
|
| 134 |
+
<button
|
| 135 |
+
className="copy-button"
|
| 136 |
+
onClick={handleCopyContent}
|
| 137 |
+
title="Copy to clipboard"
|
| 138 |
+
>
|
| 139 |
+
<FaCopy />
|
| 140 |
+
</button>
|
| 141 |
+
</>
|
| 142 |
+
) : (
|
| 143 |
+
<>
|
| 144 |
+
<button
|
| 145 |
+
className="save-button"
|
| 146 |
+
onClick={handleSaveEdit}
|
| 147 |
+
title="Save changes"
|
| 148 |
+
>
|
| 149 |
+
<FaSave />
|
| 150 |
+
</button>
|
| 151 |
+
<button
|
| 152 |
+
className="cancel-button"
|
| 153 |
+
onClick={handleCancelEdit}
|
| 154 |
+
title="Cancel editing"
|
| 155 |
+
>
|
| 156 |
+
<FaTimes />
|
| 157 |
+
</button>
|
| 158 |
+
</>
|
| 159 |
+
)}
|
| 160 |
+
<button className="close-button" onClick={onClose}>×</button>
|
| 161 |
+
</div>
|
| 162 |
+
</div>
|
| 163 |
+
<div className="chat-modal-body">
|
| 164 |
+
{!isEditing ? (
|
| 165 |
+
<div className="content-box" dangerouslySetInnerHTML={{ __html: content }} />
|
| 166 |
+
) : (
|
| 167 |
+
<textarea
|
| 168 |
+
ref={textareaRef}
|
| 169 |
+
className="content-editor"
|
| 170 |
+
value={editedContent}
|
| 171 |
+
onChange={(e) => setEditedContent(e.target.value)}
|
| 172 |
+
placeholder="Edit the content..."
|
| 173 |
+
/>
|
| 174 |
+
)}
|
| 175 |
+
</div>
|
| 176 |
+
<div className="chat-modal-footer">
|
| 177 |
+
{!isEditing ? (
|
| 178 |
+
<button className="modal-button close-btn" onClick={onClose}>Close</button>
|
| 179 |
+
) : (
|
| 180 |
+
<>
|
| 181 |
+
<button className="modal-button cancel-btn" onClick={handleCancelEdit}>Cancel</button>
|
| 182 |
+
<button className="modal-button save-btn" onClick={handleSaveEdit}>Save Changes</button>
|
| 183 |
+
</>
|
| 184 |
+
)}
|
| 185 |
+
</div>
|
| 186 |
+
</div>
|
| 187 |
+
</div>
|
| 188 |
+
);
|
| 189 |
+
};
|
| 190 |
+
|
| 191 |
+
export default ChatDetailModal;
|
frontend/podcraft/src/components/CustomEdge.jsx
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import {
|
| 3 |
+
getSmoothStepPath,
|
| 4 |
+
EdgeText
|
| 5 |
+
} from 'reactflow';
|
| 6 |
+
|
| 7 |
+
// Custom animated edge component with contextual styling
|
| 8 |
+
const CustomEdge = (props) => {
|
| 9 |
+
const {
|
| 10 |
+
id,
|
| 11 |
+
sourceX,
|
| 12 |
+
sourceY,
|
| 13 |
+
targetX,
|
| 14 |
+
targetY,
|
| 15 |
+
sourcePosition,
|
| 16 |
+
targetPosition,
|
| 17 |
+
style = {},
|
| 18 |
+
markerEnd,
|
| 19 |
+
data,
|
| 20 |
+
label,
|
| 21 |
+
labelStyle,
|
| 22 |
+
labelShowBg = true
|
| 23 |
+
} = props;
|
| 24 |
+
|
| 25 |
+
// Get edge path based on the edge type (default to smoothstep)
|
| 26 |
+
const [edgePath, labelX, labelY] = getSmoothStepPath({
|
| 27 |
+
sourceX,
|
| 28 |
+
sourceY,
|
| 29 |
+
sourcePosition,
|
| 30 |
+
targetX,
|
| 31 |
+
targetY,
|
| 32 |
+
targetPosition,
|
| 33 |
+
borderRadius: 20, // Add rounded corners to the paths
|
| 34 |
+
});
|
| 35 |
+
|
| 36 |
+
// Determine edge style based on source node type
|
| 37 |
+
const sourceType = data?.sourceType || 'default';
|
| 38 |
+
|
| 39 |
+
// Default style if nothing specific is provided
|
| 40 |
+
const edgeStyle = {
|
| 41 |
+
strokeWidth: 3, // Increase stroke width from default
|
| 42 |
+
...style,
|
| 43 |
+
};
|
| 44 |
+
|
| 45 |
+
return (
|
| 46 |
+
<>
|
| 47 |
+
{/* Main path */}
|
| 48 |
+
<path
|
| 49 |
+
id={id}
|
| 50 |
+
className={`react-flow__edge-path animated source-${sourceType}`}
|
| 51 |
+
d={edgePath}
|
| 52 |
+
style={edgeStyle}
|
| 53 |
+
markerEnd={markerEnd}
|
| 54 |
+
strokeDasharray="6 3" // Improved dash pattern
|
| 55 |
+
strokeLinecap="round"
|
| 56 |
+
filter="drop-shadow(0px 1px 2px rgba(0,0,0,0.3))" // Add subtle shadow
|
| 57 |
+
/>
|
| 58 |
+
|
| 59 |
+
{/* Glow effect for the path */}
|
| 60 |
+
<path
|
| 61 |
+
d={edgePath}
|
| 62 |
+
className={`edge-glow source-${sourceType}`}
|
| 63 |
+
style={{
|
| 64 |
+
...edgeStyle,
|
| 65 |
+
stroke: style.stroke,
|
| 66 |
+
strokeWidth: 10,
|
| 67 |
+
strokeOpacity: 0.15,
|
| 68 |
+
filter: 'blur(3px)',
|
| 69 |
+
pointerEvents: 'none', // Ensure this doesn't interfere with clicks
|
| 70 |
+
}}
|
| 71 |
+
/>
|
| 72 |
+
|
| 73 |
+
{/* Edge label */}
|
| 74 |
+
{label && (
|
| 75 |
+
<EdgeText
|
| 76 |
+
x={labelX}
|
| 77 |
+
y={labelY}
|
| 78 |
+
label={label}
|
| 79 |
+
labelStyle={{
|
| 80 |
+
fontWeight: 500,
|
| 81 |
+
fill: 'white',
|
| 82 |
+
fontSize: 12,
|
| 83 |
+
...labelStyle,
|
| 84 |
+
}}
|
| 85 |
+
labelShowBg={labelShowBg}
|
| 86 |
+
labelBgStyle={{
|
| 87 |
+
fill: '#1E1E28',
|
| 88 |
+
opacity: 0.8,
|
| 89 |
+
rx: 4,
|
| 90 |
+
ry: 4,
|
| 91 |
+
}}
|
| 92 |
+
labelBgPadding={[4, 6]}
|
| 93 |
+
/>
|
| 94 |
+
)}
|
| 95 |
+
</>
|
| 96 |
+
);
|
| 97 |
+
};
|
| 98 |
+
|
| 99 |
+
// Define edge types for ReactFlow
|
| 100 |
+
export const customEdgeTypes = {
|
| 101 |
+
custom: CustomEdge,
|
| 102 |
+
};
|
| 103 |
+
|
| 104 |
+
export default CustomEdge;
|
frontend/podcraft/src/components/CustomNodes.css
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* Custom Node Styling */
|
| 2 |
+
.custom-node {
|
| 3 |
+
background: rgba(30, 30, 40, 0.95);
|
| 4 |
+
border-radius: 10px;
|
| 5 |
+
padding: 5px 10px;
|
| 6 |
+
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.3);
|
| 7 |
+
width: 120px;
|
| 8 |
+
border: 1px solid transparent;
|
| 9 |
+
backdrop-filter: blur(8px);
|
| 10 |
+
transition: all 0.2s ease;
|
| 11 |
+
position: relative;
|
| 12 |
+
overflow: visible;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
.custom-node::before {
|
| 16 |
+
content: '';
|
| 17 |
+
position: absolute;
|
| 18 |
+
inset: -1px;
|
| 19 |
+
background: linear-gradient(45deg, transparent, currentColor, transparent);
|
| 20 |
+
border-radius: 11px;
|
| 21 |
+
z-index: -1;
|
| 22 |
+
opacity: 0.3;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
.custom-node:hover {
|
| 26 |
+
transform: translateY(-3px);
|
| 27 |
+
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.4);
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
.custom-node:hover::before {
|
| 31 |
+
opacity: 0.4;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
.node-content {
|
| 35 |
+
display: flex;
|
| 36 |
+
flex-direction: column;
|
| 37 |
+
gap: 6px;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
.node-header {
|
| 41 |
+
display: flex;
|
| 42 |
+
align-items: center;
|
| 43 |
+
gap: 10px;
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
.node-icon {
|
| 47 |
+
display: flex;
|
| 48 |
+
align-items: center;
|
| 49 |
+
justify-content: center;
|
| 50 |
+
width: 20px;
|
| 51 |
+
height: 20px;
|
| 52 |
+
border-radius: 8px;
|
| 53 |
+
color: white;
|
| 54 |
+
font-size: 16px;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
.node-title {
|
| 58 |
+
flex: 1;
|
| 59 |
+
font-weight: 600;
|
| 60 |
+
font-size: 10px;
|
| 61 |
+
color: white;
|
| 62 |
+
overflow: hidden;
|
| 63 |
+
text-overflow: ellipsis;
|
| 64 |
+
white-space: nowrap;
|
| 65 |
+
text-align: left;
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
.node-description {
|
| 69 |
+
font-size: 7px;
|
| 70 |
+
color: rgba(255, 255, 255, 0.7);
|
| 71 |
+
max-height: 30px;
|
| 72 |
+
overflow: hidden;
|
| 73 |
+
margin-left: 1.9rem;
|
| 74 |
+
line-height: 1.3;
|
| 75 |
+
text-align: left;
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
/* Custom Handle Styling */
|
| 79 |
+
.custom-handle {
|
| 80 |
+
width: 10px !important;
|
| 81 |
+
height: 10px !important;
|
| 82 |
+
border-radius: 50%;
|
| 83 |
+
border: 1px solid rgba(255, 255, 255, 0.6) !important;
|
| 84 |
+
z-index: 10;
|
| 85 |
+
transition: all 0.2s ease;
|
| 86 |
+
top: 50%;
|
| 87 |
+
transform: translateY(-50%);
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
.custom-handle:hover {
|
| 91 |
+
width: 12px !important;
|
| 92 |
+
height: 12px !important;
|
| 93 |
+
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.15);
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
/* Node type-specific styling */
|
| 97 |
+
.input-node {
|
| 98 |
+
color: #6366F1;
|
| 99 |
+
background: rgba(30, 30, 40, 0.95);
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
/* Remove the pseudo-element background for input and publish nodes */
|
| 103 |
+
.input-node::before,
|
| 104 |
+
.publish-node::before {
|
| 105 |
+
background: none;
|
| 106 |
+
opacity: 0;
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
.input-node.has-prompt {
|
| 110 |
+
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.5);
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
.node-prompt {
|
| 114 |
+
margin-top: 5px;
|
| 115 |
+
font-size: 9px;
|
| 116 |
+
text-align: center;
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
.prompt-indicator {
|
| 120 |
+
display: inline-block;
|
| 121 |
+
background-color: rgba(99, 102, 241, 0.2);
|
| 122 |
+
color: #6366F1;
|
| 123 |
+
padding: 2px 5px;
|
| 124 |
+
border-radius: 4px;
|
| 125 |
+
font-weight: 500;
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
/* Light theme adjustments */
|
| 129 |
+
:root[data-theme="light"] .custom-node {
|
| 130 |
+
background: rgba(255, 255, 255, 0.95);
|
| 131 |
+
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.15);
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
:root[data-theme="light"] .node-title {
|
| 135 |
+
color: #333;
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
:root[data-theme="light"] .node-description {
|
| 139 |
+
color: rgba(0, 0, 0, 0.6);
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
:root[data-theme="light"] .custom-handle {
|
| 143 |
+
border: 1px solid rgba(0, 0, 0, 0.3) !important;
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
:root[data-theme="light"] .prompt-indicator {
|
| 147 |
+
background-color: rgba(99, 102, 241, 0.1);
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
/* Execution Status Styling */
|
| 151 |
+
.custom-node.pending {
|
| 152 |
+
opacity: 0.8;
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
.custom-node.in-progress {
|
| 156 |
+
animation: node-pulse 1.5s infinite;
|
| 157 |
+
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.5);
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
.custom-node.in-progress::before {
|
| 161 |
+
opacity: 0.6;
|
| 162 |
+
background: linear-gradient(45deg, transparent, #6366f1, transparent);
|
| 163 |
+
animation: border-pulse 1.5s infinite;
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
.custom-node.completed {
|
| 167 |
+
box-shadow: 0 0 12px rgba(16, 185, 129, 0.5);
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
.custom-node.completed::before {
|
| 171 |
+
opacity: 0.6;
|
| 172 |
+
background: linear-gradient(45deg, transparent, #10B981, transparent);
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
.custom-node.error {
|
| 176 |
+
box-shadow: 0 0 12px rgba(239, 68, 68, 0.6);
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
.custom-node.error::before {
|
| 180 |
+
opacity: 0.6;
|
| 181 |
+
background: linear-gradient(45deg, transparent, #EF4444, transparent);
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
@keyframes node-pulse {
|
| 185 |
+
0% {
|
| 186 |
+
transform: translateY(0);
|
| 187 |
+
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.5);
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
50% {
|
| 191 |
+
transform: translateY(-3px);
|
| 192 |
+
box-shadow: 0 0 15px rgba(99, 102, 241, 0.7);
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
100% {
|
| 196 |
+
transform: translateY(0);
|
| 197 |
+
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.5);
|
| 198 |
+
}
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
@keyframes border-pulse {
|
| 202 |
+
0% {
|
| 203 |
+
opacity: 0.4;
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
50% {
|
| 207 |
+
opacity: 0.8;
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
100% {
|
| 211 |
+
opacity: 0.4;
|
| 212 |
+
}
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
/* Light theme adjustments */
|
| 216 |
+
:root[data-theme="light"] .custom-node.in-progress {
|
| 217 |
+
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.4);
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
:root[data-theme="light"] .custom-node.completed {
|
| 221 |
+
box-shadow: 0 0 12px rgba(16, 185, 129, 0.3);
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
:root[data-theme="light"] .custom-node.error {
|
| 225 |
+
box-shadow: 0 0 12px rgba(239, 68, 68, 0.4);
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
.researcher-node {
|
| 229 |
+
color: #4C1D95;
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
.agent-node {
|
| 233 |
+
color: #10B981;
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
.insights-node {
|
| 237 |
+
color: #F59E0B;
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
.notify-node {
|
| 241 |
+
color: #EF4444;
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
.publish-node {
|
| 245 |
+
color: #DC2626;
|
| 246 |
+
background: rgba(30, 30, 40, 0.95);
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
.default-node {
|
| 250 |
+
color: #8B5CF6;
|
| 251 |
+
}
|
frontend/podcraft/src/components/CustomNodes.jsx
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import { Handle, Position } from 'reactflow';
|
| 3 |
+
import {
|
| 4 |
+
FaKeyboard,
|
| 5 |
+
FaRobot,
|
| 6 |
+
FaLightbulb,
|
| 7 |
+
FaBell,
|
| 8 |
+
FaYoutube,
|
| 9 |
+
FaBrain,
|
| 10 |
+
FaMicrophone,
|
| 11 |
+
FaSlidersH,
|
| 12 |
+
FaSearch,
|
| 13 |
+
FaBookReader
|
| 14 |
+
} from 'react-icons/fa';
|
| 15 |
+
import { BiPodcast } from 'react-icons/bi';
|
| 16 |
+
import './CustomNodes.css';
|
| 17 |
+
|
| 18 |
+
const nodeIcons = {
|
| 19 |
+
input: FaKeyboard,
|
| 20 |
+
researcher: FaSearch,
|
| 21 |
+
agent: FaRobot,
|
| 22 |
+
insights: FaBrain,
|
| 23 |
+
notify: FaBell,
|
| 24 |
+
publish: FaYoutube,
|
| 25 |
+
default: BiPodcast
|
| 26 |
+
};
|
| 27 |
+
|
| 28 |
+
// Base node component
|
| 29 |
+
const BaseNode = ({ data, nodeType, icon: IconComponent, color, showSourceHandle = true, showTargetHandle = true }) => {
|
| 30 |
+
// Use CSS variable through inline style object without TypeScript complaints
|
| 31 |
+
const nodeStyle = {
|
| 32 |
+
borderColor: color,
|
| 33 |
+
// Additional inline styles can be added here
|
| 34 |
+
};
|
| 35 |
+
|
| 36 |
+
// Check if this is an input node with a prompt
|
| 37 |
+
const hasPrompt = nodeType === 'input-node' && data.prompt;
|
| 38 |
+
|
| 39 |
+
return (
|
| 40 |
+
<div className={`custom-node ${nodeType}-node ${data.prompt ? 'has-prompt' : ''}`} style={nodeStyle}>
|
| 41 |
+
{showTargetHandle && (
|
| 42 |
+
<Handle
|
| 43 |
+
type="target"
|
| 44 |
+
position={Position.Left}
|
| 45 |
+
className="custom-handle"
|
| 46 |
+
style={{ backgroundColor: color }}
|
| 47 |
+
/>
|
| 48 |
+
)}
|
| 49 |
+
|
| 50 |
+
<div className="node-content">
|
| 51 |
+
<div className="node-header">
|
| 52 |
+
<div className="node-icon" style={{ backgroundColor: color }}>
|
| 53 |
+
<IconComponent />
|
| 54 |
+
</div>
|
| 55 |
+
<div className="node-title">{data.label}</div>
|
| 56 |
+
</div>
|
| 57 |
+
{data.description && (
|
| 58 |
+
<div className="node-description">{data.description}</div>
|
| 59 |
+
)}
|
| 60 |
+
{data.prompt && nodeType === 'input-node' && (
|
| 61 |
+
<div className="node-prompt">
|
| 62 |
+
<div className="prompt-indicator">Prompt set ✓</div>
|
| 63 |
+
</div>
|
| 64 |
+
)}
|
| 65 |
+
</div>
|
| 66 |
+
|
| 67 |
+
{showSourceHandle && (
|
| 68 |
+
<Handle
|
| 69 |
+
type="source"
|
| 70 |
+
position={Position.Right}
|
| 71 |
+
className="custom-handle"
|
| 72 |
+
style={{ backgroundColor: color }}
|
| 73 |
+
/>
|
| 74 |
+
)}
|
| 75 |
+
</div>
|
| 76 |
+
);
|
| 77 |
+
};
|
| 78 |
+
|
| 79 |
+
// Specific node types
|
| 80 |
+
export const InputNode = (props) => {
|
| 81 |
+
// Input nodes only have source handles (output)
|
| 82 |
+
return (
|
| 83 |
+
<BaseNode
|
| 84 |
+
{...props}
|
| 85 |
+
nodeType="input"
|
| 86 |
+
icon={FaKeyboard}
|
| 87 |
+
color="#6366F1"
|
| 88 |
+
showTargetHandle={false}
|
| 89 |
+
/>
|
| 90 |
+
);
|
| 91 |
+
};
|
| 92 |
+
|
| 93 |
+
// Specialized Researcher node (a type of agent)
|
| 94 |
+
export const ResearcherNode = (props) => {
|
| 95 |
+
return (
|
| 96 |
+
<BaseNode
|
| 97 |
+
{...props}
|
| 98 |
+
nodeType="researcher"
|
| 99 |
+
icon={FaSearch}
|
| 100 |
+
color="#4C1D95" // Deep purple
|
| 101 |
+
/>
|
| 102 |
+
);
|
| 103 |
+
};
|
| 104 |
+
|
| 105 |
+
export const AgentNode = (props) => {
|
| 106 |
+
return (
|
| 107 |
+
<BaseNode
|
| 108 |
+
{...props}
|
| 109 |
+
nodeType="agent"
|
| 110 |
+
icon={FaRobot}
|
| 111 |
+
color="#10B981"
|
| 112 |
+
/>
|
| 113 |
+
);
|
| 114 |
+
};
|
| 115 |
+
|
| 116 |
+
export const InsightsNode = (props) => {
|
| 117 |
+
return (
|
| 118 |
+
<BaseNode
|
| 119 |
+
{...props}
|
| 120 |
+
nodeType="insights"
|
| 121 |
+
icon={FaBrain}
|
| 122 |
+
color="#F59E0B"
|
| 123 |
+
/>
|
| 124 |
+
);
|
| 125 |
+
};
|
| 126 |
+
|
| 127 |
+
export const NotifyNode = (props) => {
|
| 128 |
+
return (
|
| 129 |
+
<BaseNode
|
| 130 |
+
{...props}
|
| 131 |
+
nodeType="notify"
|
| 132 |
+
icon={FaBell}
|
| 133 |
+
color="#EF4444"
|
| 134 |
+
/>
|
| 135 |
+
);
|
| 136 |
+
};
|
| 137 |
+
|
| 138 |
+
export const PublishNode = (props) => {
|
| 139 |
+
// Output nodes only have target handles (input)
|
| 140 |
+
return (
|
| 141 |
+
<BaseNode
|
| 142 |
+
{...props}
|
| 143 |
+
nodeType="publish"
|
| 144 |
+
icon={FaYoutube}
|
| 145 |
+
color="#DC2626"
|
| 146 |
+
showSourceHandle={false}
|
| 147 |
+
/>
|
| 148 |
+
);
|
| 149 |
+
};
|
| 150 |
+
|
| 151 |
+
// Default node for any other types
|
| 152 |
+
export const DefaultNode = (props) => {
|
| 153 |
+
return (
|
| 154 |
+
<BaseNode
|
| 155 |
+
{...props}
|
| 156 |
+
nodeType="default"
|
| 157 |
+
icon={BiPodcast}
|
| 158 |
+
color="#8B5CF6"
|
| 159 |
+
/>
|
| 160 |
+
);
|
| 161 |
+
};
|
| 162 |
+
|
| 163 |
+
// A map of node types to their components for easy registration
|
| 164 |
+
export const nodeTypes = {
|
| 165 |
+
input: InputNode,
|
| 166 |
+
researcher: ResearcherNode,
|
| 167 |
+
agent: AgentNode,
|
| 168 |
+
insights: InsightsNode,
|
| 169 |
+
notify: NotifyNode,
|
| 170 |
+
output: PublishNode,
|
| 171 |
+
default: DefaultNode
|
| 172 |
+
};
|
| 173 |
+
|
| 174 |
+
export default nodeTypes;
|
frontend/podcraft/src/components/DeleteModal.css
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.delete-modal-overlay {
|
| 2 |
+
position: fixed;
|
| 3 |
+
top: 0;
|
| 4 |
+
left: 0;
|
| 5 |
+
right: 0;
|
| 6 |
+
bottom: 0;
|
| 7 |
+
background-color: rgba(0, 0, 0, 0.7);
|
| 8 |
+
display: flex;
|
| 9 |
+
justify-content: center;
|
| 10 |
+
align-items: center;
|
| 11 |
+
z-index: 1000;
|
| 12 |
+
backdrop-filter: blur(5px);
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
.delete-modal-content {
|
| 16 |
+
background: rgba(17, 17, 17, 0.95);
|
| 17 |
+
backdrop-filter: blur(10px);
|
| 18 |
+
padding: 2rem;
|
| 19 |
+
border-radius: 12px;
|
| 20 |
+
width: 90%;
|
| 21 |
+
max-width: 500px;
|
| 22 |
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
| 23 |
+
animation: modalSlideIn 0.3s ease-out;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
.delete-modal-header {
|
| 27 |
+
display: flex;
|
| 28 |
+
align-items: center;
|
| 29 |
+
gap: 1rem;
|
| 30 |
+
margin-bottom: 1.5rem;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
.delete-modal-header h2 {
|
| 34 |
+
color: #fff;
|
| 35 |
+
margin: 0;
|
| 36 |
+
font-size: 1.5rem;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
.warning-icon {
|
| 40 |
+
color: #ef4444;
|
| 41 |
+
font-size: 1.5rem;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
.delete-modal-body {
|
| 45 |
+
margin-bottom: 2rem;
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
.delete-modal-body p {
|
| 49 |
+
color: rgba(255, 255, 255, 0.8);
|
| 50 |
+
margin: 0.5rem 0;
|
| 51 |
+
font-size: 1rem;
|
| 52 |
+
line-height: 1.5;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
.podcast-name {
|
| 56 |
+
color: #6366f1;
|
| 57 |
+
font-weight: 500;
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
.warning-text {
|
| 61 |
+
color: #ef4444 !important;
|
| 62 |
+
font-size: 0.9rem !important;
|
| 63 |
+
margin-top: 1rem !important;
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
.delete-modal-footer {
|
| 67 |
+
display: flex;
|
| 68 |
+
justify-content: flex-end;
|
| 69 |
+
gap: 1rem;
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
.cancel-btn,
|
| 73 |
+
.delete-btn {
|
| 74 |
+
padding: 0.5rem 1rem;
|
| 75 |
+
border-radius: 6px;
|
| 76 |
+
font-size: 0.9rem;
|
| 77 |
+
font-weight: 500;
|
| 78 |
+
cursor: pointer;
|
| 79 |
+
transition: all 0.3s ease;
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
.cancel-btn {
|
| 83 |
+
background: transparent;
|
| 84 |
+
border: 1px solid rgba(255, 255, 255, 0.2);
|
| 85 |
+
color: rgba(255, 255, 255, 0.8);
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
.cancel-btn:hover {
|
| 89 |
+
background: rgba(255, 255, 255, 0.1);
|
| 90 |
+
border-color: rgba(255, 255, 255, 0.3);
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
.delete-btn {
|
| 94 |
+
background: #ef4444;
|
| 95 |
+
border: none;
|
| 96 |
+
color: white;
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
.delete-btn:hover {
|
| 100 |
+
background: #dc2626;
|
| 101 |
+
transform: translateY(-1px);
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
@keyframes modalSlideIn {
|
| 105 |
+
from {
|
| 106 |
+
transform: translateY(20px);
|
| 107 |
+
opacity: 0;
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
to {
|
| 111 |
+
transform: translateY(0);
|
| 112 |
+
opacity: 1;
|
| 113 |
+
}
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
/* Light theme adjustments */
|
| 117 |
+
.light .delete-modal-content {
|
| 118 |
+
background: rgba(255, 255, 255, 0.95);
|
| 119 |
+
border-color: rgba(0, 0, 0, 0.1);
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
.light .delete-modal-header h2 {
|
| 123 |
+
color: #000;
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
.light .delete-modal-body p {
|
| 127 |
+
color: rgba(0, 0, 0, 0.8);
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
.light .cancel-btn {
|
| 131 |
+
border-color: rgba(0, 0, 0, 0.2);
|
| 132 |
+
color: rgba(0, 0, 0, 0.8);
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
.light .cancel-btn:hover {
|
| 136 |
+
background: rgba(0, 0, 0, 0.1);
|
| 137 |
+
border-color: rgba(0, 0, 0, 0.3);
|
| 138 |
+
}
|
frontend/podcraft/src/components/DeleteModal.jsx
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import './DeleteModal.css';
|
| 3 |
+
import { FaExclamationTriangle } from 'react-icons/fa';
|
| 4 |
+
|
| 5 |
+
const DeleteModal = ({ isOpen, onClose, onConfirm, podcastName }) => {
|
| 6 |
+
if (!isOpen) return null;
|
| 7 |
+
|
| 8 |
+
return (
|
| 9 |
+
<div className="delete-modal-overlay">
|
| 10 |
+
<div className="delete-modal-content">
|
| 11 |
+
<div className="delete-modal-header">
|
| 12 |
+
<FaExclamationTriangle className="warning-icon" />
|
| 13 |
+
<h2>Confirm Deletion</h2>
|
| 14 |
+
</div>
|
| 15 |
+
<div className="delete-modal-body">
|
| 16 |
+
<p>This will permanently delete <span className="podcast-name">"{podcastName}"</span> podcast.</p>
|
| 17 |
+
<p className="warning-text">This action cannot be undone.</p>
|
| 18 |
+
</div>
|
| 19 |
+
<div className="delete-modal-footer">
|
| 20 |
+
<button className="cancel-btn" onClick={onClose}>Cancel</button>
|
| 21 |
+
<button className="delete-btn" onClick={onConfirm}>Delete Podcast</button>
|
| 22 |
+
</div>
|
| 23 |
+
</div>
|
| 24 |
+
</div>
|
| 25 |
+
);
|
| 26 |
+
};
|
| 27 |
+
|
| 28 |
+
export default DeleteModal;
|
frontend/podcraft/src/components/InputNodeModal.css
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.input-modal-overlay {
|
| 2 |
+
position: fixed;
|
| 3 |
+
top: 0;
|
| 4 |
+
left: 0;
|
| 5 |
+
right: 0;
|
| 6 |
+
bottom: 0;
|
| 7 |
+
background: rgba(0, 0, 0, 0.7);
|
| 8 |
+
backdrop-filter: blur(8px);
|
| 9 |
+
display: flex;
|
| 10 |
+
align-items: center;
|
| 11 |
+
justify-content: center;
|
| 12 |
+
z-index: 1000;
|
| 13 |
+
animation: fadeIn 0.2s ease-out;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
.input-modal {
|
| 17 |
+
background: rgba(20, 20, 20, 0.95);
|
| 18 |
+
border: 1px solid rgba(99, 102, 241, 0.2);
|
| 19 |
+
border-radius: 12px;
|
| 20 |
+
padding: 1.5rem;
|
| 21 |
+
width: 90%;
|
| 22 |
+
max-width: 600px;
|
| 23 |
+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
| 24 |
+
animation: slideIn 0.3s ease-out;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
.input-modal-header {
|
| 28 |
+
display: flex;
|
| 29 |
+
justify-content: space-between;
|
| 30 |
+
align-items: center;
|
| 31 |
+
margin-bottom: 1.5rem;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
.input-modal-header h2 {
|
| 35 |
+
margin: 0;
|
| 36 |
+
font-size: 1.5rem;
|
| 37 |
+
font-weight: 600;
|
| 38 |
+
background: linear-gradient(0.25turn, #999, #fff);
|
| 39 |
+
-webkit-background-clip: text;
|
| 40 |
+
background-clip: text;
|
| 41 |
+
color: transparent;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
.close-button {
|
| 45 |
+
background: transparent;
|
| 46 |
+
border: none;
|
| 47 |
+
color: rgba(255, 255, 255, 0.6);
|
| 48 |
+
font-size: 1.5rem;
|
| 49 |
+
cursor: pointer;
|
| 50 |
+
width: 32px;
|
| 51 |
+
height: 32px;
|
| 52 |
+
display: flex;
|
| 53 |
+
align-items: center;
|
| 54 |
+
justify-content: center;
|
| 55 |
+
border-radius: 6px;
|
| 56 |
+
transition: all 0.2s ease;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
.close-button:hover {
|
| 60 |
+
background: rgba(255, 255, 255, 0.1);
|
| 61 |
+
color: white;
|
| 62 |
+
transform: rotate(90deg);
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
.form-group {
|
| 66 |
+
margin-bottom: 1.5rem;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
.form-group textarea {
|
| 70 |
+
width: auto;
|
| 71 |
+
background: rgba(255, 255, 255, 0.05);
|
| 72 |
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
| 73 |
+
border-radius: 8px;
|
| 74 |
+
color: white;
|
| 75 |
+
padding: 1rem;
|
| 76 |
+
font-size: 1rem;
|
| 77 |
+
line-height: 1.5;
|
| 78 |
+
resize: vertical;
|
| 79 |
+
transition: all 0.2s ease;
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
.form-group textarea:focus {
|
| 83 |
+
outline: none;
|
| 84 |
+
border-color: #6366f1;
|
| 85 |
+
background: rgba(255, 255, 255, 0.08);
|
| 86 |
+
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2);
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
.form-group textarea::placeholder {
|
| 90 |
+
color: rgba(255, 255, 255, 0.3);
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
.input-modal-footer {
|
| 94 |
+
display: flex;
|
| 95 |
+
justify-content: flex-end;
|
| 96 |
+
gap: 1rem;
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
.input-modal-footer button {
|
| 100 |
+
padding: 0.5rem 1.25rem;
|
| 101 |
+
border-radius: 6px;
|
| 102 |
+
font-weight: 500;
|
| 103 |
+
cursor: pointer;
|
| 104 |
+
transition: all 0.2s ease;
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
.cancel-button {
|
| 108 |
+
background: transparent;
|
| 109 |
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
| 110 |
+
color: rgba(255, 255, 255, 0.8);
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
.cancel-button:hover {
|
| 114 |
+
background: rgba(255, 255, 255, 0.1);
|
| 115 |
+
border-color: rgba(255, 255, 255, 0.2);
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
.submit-button {
|
| 119 |
+
background: #6366f1;
|
| 120 |
+
border: none;
|
| 121 |
+
color: white;
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
.submit-button:hover {
|
| 125 |
+
background: #4f46e5;
|
| 126 |
+
transform: translateY(-1px);
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
@keyframes fadeIn {
|
| 130 |
+
from {
|
| 131 |
+
opacity: 0;
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
to {
|
| 135 |
+
opacity: 1;
|
| 136 |
+
}
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
@keyframes slideIn {
|
| 140 |
+
from {
|
| 141 |
+
transform: translateY(-20px);
|
| 142 |
+
opacity: 0;
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
to {
|
| 146 |
+
transform: translateY(0);
|
| 147 |
+
opacity: 1;
|
| 148 |
+
}
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
/* Light theme adjustments */
|
| 152 |
+
:root[data-theme="light"] .input-modal {
|
| 153 |
+
background: rgba(255, 255, 255, 0.95);
|
| 154 |
+
border-color: rgba(0, 0, 0, 0.1);
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
:root[data-theme="light"] .input-modal-header h2 {
|
| 158 |
+
background: linear-gradient(90deg, #333, #666);
|
| 159 |
+
-webkit-background-clip: text;
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
:root[data-theme="light"] .close-button {
|
| 163 |
+
color: rgba(0, 0, 0, 0.6);
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
:root[data-theme="light"] .close-button:hover {
|
| 167 |
+
background: rgba(0, 0, 0, 0.1);
|
| 168 |
+
color: rgba(0, 0, 0, 0.8);
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
:root[data-theme="light"] .form-group textarea {
|
| 172 |
+
background: rgba(0, 0, 0, 0.05);
|
| 173 |
+
border-color: rgba(0, 0, 0, 0.1);
|
| 174 |
+
color: #000;
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
:root[data-theme="light"] .form-group textarea:focus {
|
| 178 |
+
border-color: rgba(99, 102, 241, 0.5);
|
| 179 |
+
background: rgba(0, 0, 0, 0.02);
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
:root[data-theme="light"] .form-group textarea::placeholder {
|
| 183 |
+
color: rgba(0, 0, 0, 0.3);
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
:root[data-theme="light"] .cancel-button {
|
| 187 |
+
border-color: rgba(0, 0, 0, 0.1);
|
| 188 |
+
color: rgba(0, 0, 0, 0.8);
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
:root[data-theme="light"] .cancel-button:hover {
|
| 192 |
+
background: rgba(0, 0, 0, 0.1);
|
| 193 |
+
border-color: rgba(0, 0, 0, 0.2);
|
| 194 |
+
}
|
frontend/podcraft/src/components/InputNodeModal.jsx
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState } from 'react';
|
| 2 |
+
import './InputNodeModal.css';
|
| 3 |
+
|
| 4 |
+
const InputNodeModal = ({ isOpen, onClose, onSubmit, nodeId }) => {
|
| 5 |
+
const [prompt, setPrompt] = useState('');
|
| 6 |
+
|
| 7 |
+
if (!isOpen) return null;
|
| 8 |
+
|
| 9 |
+
const handleSubmit = (e) => {
|
| 10 |
+
e.preventDefault();
|
| 11 |
+
onSubmit(nodeId, prompt);
|
| 12 |
+
setPrompt('');
|
| 13 |
+
onClose();
|
| 14 |
+
};
|
| 15 |
+
|
| 16 |
+
return (
|
| 17 |
+
<div className="input-modal-overlay">
|
| 18 |
+
<div className="input-modal">
|
| 19 |
+
<div className="input-modal-header">
|
| 20 |
+
<h2>Enter Your Prompt</h2>
|
| 21 |
+
<button className="close-button" onClick={onClose}>×</button>
|
| 22 |
+
</div>
|
| 23 |
+
<form onSubmit={handleSubmit}>
|
| 24 |
+
<div className="form-group">
|
| 25 |
+
<textarea
|
| 26 |
+
value={prompt}
|
| 27 |
+
onChange={(e) => setPrompt(e.target.value)}
|
| 28 |
+
placeholder="Enter your prompt here..."
|
| 29 |
+
rows={6}
|
| 30 |
+
required
|
| 31 |
+
/>
|
| 32 |
+
</div>
|
| 33 |
+
<div className="input-modal-footer">
|
| 34 |
+
<button type="button" className="cancel-button" onClick={onClose}>
|
| 35 |
+
Cancel
|
| 36 |
+
</button>
|
| 37 |
+
<button type="submit" className="submit-button">
|
| 38 |
+
Save
|
| 39 |
+
</button>
|
| 40 |
+
</div>
|
| 41 |
+
</form>
|
| 42 |
+
</div>
|
| 43 |
+
</div>
|
| 44 |
+
);
|
| 45 |
+
};
|
| 46 |
+
|
| 47 |
+
export default InputNodeModal;
|
frontend/podcraft/src/components/NodeSelectionPanel.css
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.node-selection-overlay {
|
| 2 |
+
position: fixed;
|
| 3 |
+
top: 0;
|
| 4 |
+
left: 0;
|
| 5 |
+
right: 0;
|
| 6 |
+
bottom: 0;
|
| 7 |
+
background: rgba(0, 0, 0, 0.7);
|
| 8 |
+
display: flex;
|
| 9 |
+
justify-content: center;
|
| 10 |
+
align-items: center;
|
| 11 |
+
z-index: 1000;
|
| 12 |
+
backdrop-filter: blur(8px);
|
| 13 |
+
animation: fadeIn 0.2s ease-out;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
.node-selection-panel {
|
| 17 |
+
background: rgba(17, 17, 17, 0.95);
|
| 18 |
+
border: 1px solid rgba(99, 102, 241, 0.2);
|
| 19 |
+
border-radius: 16px;
|
| 20 |
+
padding: 2rem;
|
| 21 |
+
width: 90%;
|
| 22 |
+
max-width: 600px;
|
| 23 |
+
max-height: 85vh;
|
| 24 |
+
overflow-y: auto;
|
| 25 |
+
color: white;
|
| 26 |
+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
| 27 |
+
animation: slideUp 0.3s ease-out;
|
| 28 |
+
position: relative;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
.node-selection-panel::-webkit-scrollbar {
|
| 32 |
+
width: 6px;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
.node-selection-panel::-webkit-scrollbar-track {
|
| 36 |
+
background: rgba(255, 255, 255, 0.05);
|
| 37 |
+
border-radius: 3px;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
.node-selection-panel::-webkit-scrollbar-thumb {
|
| 41 |
+
background: rgba(255, 255, 255, 0.2);
|
| 42 |
+
border-radius: 3px;
|
| 43 |
+
transition: background 0.3s ease;
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
.node-selection-panel::-webkit-scrollbar-thumb:hover {
|
| 47 |
+
background: rgba(255, 255, 255, 0.3);
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
.panel-header {
|
| 51 |
+
display: flex;
|
| 52 |
+
justify-content: space-between;
|
| 53 |
+
align-items: center;
|
| 54 |
+
margin-bottom: 2rem;
|
| 55 |
+
padding-bottom: 1rem;
|
| 56 |
+
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
.panel-header h2 {
|
| 60 |
+
margin: 0;
|
| 61 |
+
font-size: 1.75rem;
|
| 62 |
+
font-weight: 600;
|
| 63 |
+
background: linear-gradient(90deg, #fff, #999);
|
| 64 |
+
-webkit-background-clip: text;
|
| 65 |
+
background-clip: text;
|
| 66 |
+
color: transparent;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
.close-button {
|
| 70 |
+
background: rgba(255, 255, 255, 0.1);
|
| 71 |
+
border: none;
|
| 72 |
+
color: rgba(255, 255, 255, 0.6);
|
| 73 |
+
font-size: 1.5rem;
|
| 74 |
+
cursor: pointer;
|
| 75 |
+
padding: 0.5rem;
|
| 76 |
+
display: flex;
|
| 77 |
+
align-items: center;
|
| 78 |
+
justify-content: center;
|
| 79 |
+
transition: all 0.2s ease;
|
| 80 |
+
border-radius: 50%;
|
| 81 |
+
width: 36px;
|
| 82 |
+
height: 36px;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
.close-button:hover {
|
| 86 |
+
color: white;
|
| 87 |
+
background: rgba(255, 255, 255, 0.2);
|
| 88 |
+
transform: rotate(90deg);
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
.node-types {
|
| 92 |
+
display: flex;
|
| 93 |
+
flex-direction: column;
|
| 94 |
+
gap: 1.5rem;
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
.node-type-section {
|
| 98 |
+
display: flex;
|
| 99 |
+
flex-direction: column;
|
| 100 |
+
gap: 1rem;
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
.node-type-item {
|
| 104 |
+
display: flex;
|
| 105 |
+
align-items: center;
|
| 106 |
+
gap: 1.5rem;
|
| 107 |
+
padding: 0.75rem 1.25rem;
|
| 108 |
+
background: rgba(255, 255, 255, 0.03);
|
| 109 |
+
border: 1px solid;
|
| 110 |
+
border-color: var(--node-color);
|
| 111 |
+
border-radius: 12px;
|
| 112 |
+
cursor: pointer;
|
| 113 |
+
transition: all 0.3s ease;
|
| 114 |
+
position: relative;
|
| 115 |
+
overflow: hidden;
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
.node-type-item::before {
|
| 119 |
+
content: '';
|
| 120 |
+
position: absolute;
|
| 121 |
+
top: 0;
|
| 122 |
+
left: 0;
|
| 123 |
+
right: 0;
|
| 124 |
+
bottom: 0;
|
| 125 |
+
background: linear-gradient(45deg, transparent, rgba(255, 255, 255, 0.03), transparent);
|
| 126 |
+
transform: translateX(-100%);
|
| 127 |
+
transition: transform 0.6s ease;
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
.node-type-item:hover::before {
|
| 131 |
+
transform: translateX(100%);
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
.node-type-item:hover {
|
| 135 |
+
transform: translateY(-2px);
|
| 136 |
+
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
.node-icon {
|
| 140 |
+
display: flex;
|
| 141 |
+
align-items: center;
|
| 142 |
+
justify-content: center;
|
| 143 |
+
width: 50px;
|
| 144 |
+
height: 50px;
|
| 145 |
+
border-radius: 6px;
|
| 146 |
+
color: white;
|
| 147 |
+
font-size: 25px;
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
.node-icon::after {
|
| 151 |
+
content: '';
|
| 152 |
+
position: absolute;
|
| 153 |
+
top: 0;
|
| 154 |
+
left: 0;
|
| 155 |
+
right: 0;
|
| 156 |
+
bottom: 0;
|
| 157 |
+
background: linear-gradient(45deg, transparent, rgba(255, 255, 255, 0.2), transparent);
|
| 158 |
+
transition: transform 0.6s ease;
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
.node-type-item:hover .node-icon::after {
|
| 162 |
+
transform: translateX(100%);
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
.node-info {
|
| 166 |
+
flex: 1;
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
.node-info h3 {
|
| 170 |
+
margin: 0;
|
| 171 |
+
font-size: 1.1rem;
|
| 172 |
+
font-weight: 600;
|
| 173 |
+
color: white;
|
| 174 |
+
margin-bottom: 0.25rem;
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
.node-info p {
|
| 178 |
+
margin: 0;
|
| 179 |
+
font-size: 0.9rem;
|
| 180 |
+
color: rgba(255, 255, 255, 0.6);
|
| 181 |
+
line-height: 1.4;
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
.agent-list {
|
| 185 |
+
margin-left: 4rem;
|
| 186 |
+
display: flex;
|
| 187 |
+
flex-direction: column;
|
| 188 |
+
gap: 0.75rem;
|
| 189 |
+
padding-top: 0.5rem;
|
| 190 |
+
height: 15vh;
|
| 191 |
+
overflow-y: auto;
|
| 192 |
+
max-height: 25vh;
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
.agent-list::-webkit-scrollbar {
|
| 196 |
+
width: 6px;
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
.agent-list::-webkit-scrollbar-track {
|
| 200 |
+
background: rgba(255, 255, 255, 0.05);
|
| 201 |
+
border-radius: 3px;
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
.agent-list::-webkit-scrollbar-thumb {
|
| 205 |
+
background: rgba(255, 255, 255, 0.2);
|
| 206 |
+
border-radius: 3px;
|
| 207 |
+
transition: background 0.3s ease;
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
.agent-list::-webkit-scrollbar-thumb:hover {
|
| 211 |
+
background: rgba(255, 255, 255, 0.3);
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
.agent-item {
|
| 215 |
+
display: flex;
|
| 216 |
+
justify-content: space-between;
|
| 217 |
+
align-items: center;
|
| 218 |
+
padding: 0.75rem 1rem;
|
| 219 |
+
background: rgba(99, 102, 241, 0.1);
|
| 220 |
+
border-radius: 8px;
|
| 221 |
+
cursor: pointer;
|
| 222 |
+
transition: all 0.3s ease;
|
| 223 |
+
border: 1px solid rgba(99, 102, 241, 0.2);
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
.agent-item:hover {
|
| 227 |
+
transform: translateX(4px);
|
| 228 |
+
background: rgba(99, 102, 241, 0.15);
|
| 229 |
+
border-color: rgba(99, 102, 241, 0.3);
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
.agent-name {
|
| 233 |
+
font-size: 0.95rem;
|
| 234 |
+
font-weight: 500;
|
| 235 |
+
color: rgba(255, 255, 255, 0.9);
|
| 236 |
+
display: flex;
|
| 237 |
+
align-items: center;
|
| 238 |
+
gap: 8px;
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
.agent-special-icon {
|
| 242 |
+
color: #4C1D95;
|
| 243 |
+
font-size: 0.9rem;
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
.researcher-agent {
|
| 247 |
+
background: rgba(76, 29, 149, 0.15);
|
| 248 |
+
border-color: rgba(76, 29, 149, 0.3);
|
| 249 |
+
position: relative;
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
.researcher-agent:hover {
|
| 253 |
+
background: rgba(76, 29, 149, 0.25);
|
| 254 |
+
border-color: rgba(76, 29, 149, 0.4);
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
.researcher-agent::after {
|
| 258 |
+
content: 'Special node with unique connection rules';
|
| 259 |
+
position: absolute;
|
| 260 |
+
bottom: -10px;
|
| 261 |
+
left: 0;
|
| 262 |
+
right: 0;
|
| 263 |
+
color: #4C1D95;
|
| 264 |
+
font-size: 0.65rem;
|
| 265 |
+
opacity: 0;
|
| 266 |
+
transition: all 0.3s ease;
|
| 267 |
+
text-align: center;
|
| 268 |
+
background: rgba(255, 255, 255, 0.9);
|
| 269 |
+
border-radius: 4px;
|
| 270 |
+
padding: 2px 4px;
|
| 271 |
+
pointer-events: none;
|
| 272 |
+
transform: translateY(5px);
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
.researcher-agent:hover::after {
|
| 276 |
+
opacity: 1;
|
| 277 |
+
transform: translateY(0);
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
.node-constraint {
|
| 281 |
+
display: flex;
|
| 282 |
+
align-items: center;
|
| 283 |
+
gap: 6px;
|
| 284 |
+
margin-top: 6px;
|
| 285 |
+
font-size: 0.7rem;
|
| 286 |
+
color: rgba(255, 255, 255, 0.5);
|
| 287 |
+
line-height: 1.2;
|
| 288 |
+
padding: 4px 8px;
|
| 289 |
+
background: rgba(0, 0, 0, 0.15);
|
| 290 |
+
border-radius: 4px;
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
.node-constraint svg {
|
| 294 |
+
flex-shrink: 0;
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
.agent-status {
|
| 298 |
+
font-size: 0.8rem;
|
| 299 |
+
color: rgba(255, 255, 255, 0.6);
|
| 300 |
+
padding: 0.25rem 0.75rem;
|
| 301 |
+
background: rgba(255, 255, 255, 0.1);
|
| 302 |
+
border-radius: 12px;
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
@keyframes fadeIn {
|
| 306 |
+
from {
|
| 307 |
+
opacity: 0;
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
to {
|
| 311 |
+
opacity: 1;
|
| 312 |
+
}
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
@keyframes slideUp {
|
| 316 |
+
from {
|
| 317 |
+
opacity: 0;
|
| 318 |
+
transform: translateY(20px);
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
to {
|
| 322 |
+
opacity: 1;
|
| 323 |
+
transform: translateY(0);
|
| 324 |
+
}
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
/* Light theme adjustments */
|
| 328 |
+
:root[data-theme="light"] .node-selection-panel {
|
| 329 |
+
background: rgba(255, 255, 255, 0.95);
|
| 330 |
+
border-color: rgba(0, 0, 0, 0.1);
|
| 331 |
+
color: black;
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
+
:root[data-theme="light"] .panel-header h2 {
|
| 335 |
+
background: linear-gradient(90deg, #333, #666);
|
| 336 |
+
-webkit-background-clip: text;
|
| 337 |
+
background-clip: text;
|
| 338 |
+
}
|
| 339 |
+
|
| 340 |
+
:root[data-theme="light"] .close-button {
|
| 341 |
+
background: rgba(0, 0, 0, 0.1);
|
| 342 |
+
color: rgba(0, 0, 0, 0.6);
|
| 343 |
+
}
|
| 344 |
+
|
| 345 |
+
:root[data-theme="light"] .close-button:hover {
|
| 346 |
+
color: black;
|
| 347 |
+
background: rgba(0, 0, 0, 0.15);
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
:root[data-theme="light"] .node-type-item {
|
| 351 |
+
background: rgba(0, 0, 0, 0.03);
|
| 352 |
+
}
|
| 353 |
+
|
| 354 |
+
:root[data-theme="light"] .node-type-item:hover {
|
| 355 |
+
background: rgba(var(--node-color-rgb), 0.1);
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
:root[data-theme="light"] .node-info h3 {
|
| 359 |
+
color: black;
|
| 360 |
+
}
|
| 361 |
+
|
| 362 |
+
:root[data-theme="light"] .node-info p {
|
| 363 |
+
color: rgba(0, 0, 0, 0.6);
|
| 364 |
+
}
|
| 365 |
+
|
| 366 |
+
:root[data-theme="light"] .agent-name {
|
| 367 |
+
color: rgba(0, 0, 0, 0.9);
|
| 368 |
+
}
|
| 369 |
+
|
| 370 |
+
:root[data-theme="light"] .agent-status {
|
| 371 |
+
color: rgba(0, 0, 0, 0.6);
|
| 372 |
+
background: rgba(0, 0, 0, 0.05);
|
| 373 |
+
}
|
| 374 |
+
|
| 375 |
+
:root[data-theme="light"] .node-constraint {
|
| 376 |
+
background: rgba(0, 0, 0, 0.05);
|
| 377 |
+
color: rgba(0, 0, 0, 0.6);
|
| 378 |
+
}
|
| 379 |
+
|
| 380 |
+
:root[data-theme="light"] .researcher-agent {
|
| 381 |
+
background: rgba(76, 29, 149, 0.1);
|
| 382 |
+
}
|
| 383 |
+
|
| 384 |
+
:root[data-theme="light"] .researcher-agent:hover {
|
| 385 |
+
background: rgba(76, 29, 149, 0.15);
|
| 386 |
+
}
|
| 387 |
+
|
| 388 |
+
:root[data-theme="light"] .researcher-agent::after {
|
| 389 |
+
background: rgba(255, 255, 255, 0.95);
|
| 390 |
+
color: #4C1D95;
|
| 391 |
+
border: 1px solid rgba(76, 29, 149, 0.2);
|
| 392 |
+
}
|
| 393 |
+
|
| 394 |
+
/* Node color classes for each type */
|
| 395 |
+
.node-color-input:hover {
|
| 396 |
+
background: rgba(99, 102, 241, 0.1);
|
| 397 |
+
}
|
| 398 |
+
|
| 399 |
+
.node-color-agent:hover {
|
| 400 |
+
background: rgba(16, 185, 129, 0.1);
|
| 401 |
+
}
|
| 402 |
+
|
| 403 |
+
.node-color-insights:hover {
|
| 404 |
+
background: rgba(245, 158, 11, 0.1);
|
| 405 |
+
}
|
| 406 |
+
|
| 407 |
+
.node-color-notify:hover {
|
| 408 |
+
background: rgba(239, 68, 68, 0.1);
|
| 409 |
+
}
|
| 410 |
+
|
| 411 |
+
.node-color-publish:hover {
|
| 412 |
+
background: rgba(220, 38, 38, 0.1);
|
| 413 |
+
}
|
frontend/podcraft/src/components/NodeSelectionPanel.jsx
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import { FaRobot, FaYoutube, FaBell, FaLightbulb, FaKeyboard, FaSearch, FaInfoCircle } from 'react-icons/fa';
|
| 3 |
+
import './NodeSelectionPanel.css';
|
| 4 |
+
|
| 5 |
+
const NodeSelectionPanel = ({ isOpen, onClose, agents, onSelectNode }) => {
|
| 6 |
+
const nodeTypes = [
|
| 7 |
+
{
|
| 8 |
+
id: 'input',
|
| 9 |
+
label: 'Input Node',
|
| 10 |
+
description: 'Add an input prompt for your podcast',
|
| 11 |
+
constraint: 'Can only connect to Research Agent',
|
| 12 |
+
icon: <FaKeyboard />,
|
| 13 |
+
color: '#6366F1',
|
| 14 |
+
colorRgb: '99, 102, 241',
|
| 15 |
+
subItems: null
|
| 16 |
+
},
|
| 17 |
+
{
|
| 18 |
+
id: 'agent',
|
| 19 |
+
label: 'Agents Node',
|
| 20 |
+
description: 'Add AI agents to process your content',
|
| 21 |
+
constraint: 'Research Agent can receive from Input and connect to Agents or Insights. Regular Agents can only connect to Insights',
|
| 22 |
+
icon: <FaRobot />,
|
| 23 |
+
color: '#10B981',
|
| 24 |
+
colorRgb: '16, 185, 129',
|
| 25 |
+
subItems: agents
|
| 26 |
+
},
|
| 27 |
+
{
|
| 28 |
+
id: 'insights',
|
| 29 |
+
label: 'Insights Node',
|
| 30 |
+
description: 'Add analytics and insights processing',
|
| 31 |
+
constraint: 'Can receive from Agents or Research Agent and connect to Notify or Publish nodes',
|
| 32 |
+
icon: <FaLightbulb />,
|
| 33 |
+
color: '#F59E0B',
|
| 34 |
+
colorRgb: '245, 158, 11',
|
| 35 |
+
subItems: null
|
| 36 |
+
},
|
| 37 |
+
{
|
| 38 |
+
id: 'notify',
|
| 39 |
+
label: 'Notify Node',
|
| 40 |
+
description: 'Add notifications and alerts',
|
| 41 |
+
constraint: 'Can only receive from Insights nodes',
|
| 42 |
+
icon: <FaBell />,
|
| 43 |
+
color: '#EF4444',
|
| 44 |
+
colorRgb: '239, 68, 68',
|
| 45 |
+
subItems: null
|
| 46 |
+
},
|
| 47 |
+
{
|
| 48 |
+
id: 'publish',
|
| 49 |
+
label: 'Publish Node',
|
| 50 |
+
description: 'Publish to YouTube',
|
| 51 |
+
constraint: 'Can only receive from Insights nodes',
|
| 52 |
+
icon: <FaYoutube />,
|
| 53 |
+
color: '#DC2626',
|
| 54 |
+
colorRgb: '220, 38, 38',
|
| 55 |
+
subItems: null
|
| 56 |
+
}
|
| 57 |
+
];
|
| 58 |
+
|
| 59 |
+
if (!isOpen) return null;
|
| 60 |
+
|
| 61 |
+
const handleNodeSelect = (nodeType, agentId = null) => {
|
| 62 |
+
onSelectNode({ type: nodeType, agentId });
|
| 63 |
+
onClose();
|
| 64 |
+
};
|
| 65 |
+
|
| 66 |
+
return (
|
| 67 |
+
<div className="node-selection-overlay" onClick={onClose}>
|
| 68 |
+
<div className="node-selection-panel" onClick={e => e.stopPropagation()}>
|
| 69 |
+
<div className="panel-header">
|
| 70 |
+
<h2>Add Node</h2>
|
| 71 |
+
<button className="close-button" onClick={onClose}>×</button>
|
| 72 |
+
</div>
|
| 73 |
+
<div className="node-types">
|
| 74 |
+
{nodeTypes.map((nodeType) => (
|
| 75 |
+
<div key={nodeType.id} className="node-type-section">
|
| 76 |
+
<div
|
| 77 |
+
className={`node-type-item node-color-${nodeType.id}`}
|
| 78 |
+
onClick={() => nodeType.id !== 'agent' && handleNodeSelect(nodeType.id)}
|
| 79 |
+
style={{ borderColor: nodeType.color }}
|
| 80 |
+
>
|
| 81 |
+
<div className="node-icon" style={{ backgroundColor: nodeType.color }}>{nodeType.icon}</div>
|
| 82 |
+
<div className="node-info">
|
| 83 |
+
<h3>{nodeType.label}</h3>
|
| 84 |
+
<p>{nodeType.description}</p>
|
| 85 |
+
<div className="node-constraint" style={{ color: `rgba(${nodeType.colorRgb}, 0.7)` }}>
|
| 86 |
+
<FaInfoCircle style={{ color: nodeType.color }} />
|
| 87 |
+
<span>{nodeType.constraint}</span>
|
| 88 |
+
</div>
|
| 89 |
+
</div>
|
| 90 |
+
</div>
|
| 91 |
+
{nodeType.id === 'agent' && nodeType.subItems && (
|
| 92 |
+
<div className="agent-list">
|
| 93 |
+
{nodeType.subItems.map((agent) => (
|
| 94 |
+
<div
|
| 95 |
+
key={agent.id}
|
| 96 |
+
className={`agent-item ${agent.isDefault ? 'default' : 'custom'} ${agent.id === 'researcher' ? 'researcher-agent' : ''}`}
|
| 97 |
+
onClick={() => handleNodeSelect('agent', agent.id)}
|
| 98 |
+
>
|
| 99 |
+
<span className="agent-name">
|
| 100 |
+
{agent.id === 'researcher' && <FaSearch className="agent-special-icon" />}
|
| 101 |
+
{agent.name}
|
| 102 |
+
</span>
|
| 103 |
+
<span className="agent-status">{agent.status}</span>
|
| 104 |
+
</div>
|
| 105 |
+
))}
|
| 106 |
+
</div>
|
| 107 |
+
)}
|
| 108 |
+
</div>
|
| 109 |
+
))}
|
| 110 |
+
</div>
|
| 111 |
+
</div>
|
| 112 |
+
</div>
|
| 113 |
+
);
|
| 114 |
+
};
|
| 115 |
+
|
| 116 |
+
export default NodeSelectionPanel;
|
frontend/podcraft/src/components/ResponseEditModal.css
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.response-modal-overlay {
|
| 2 |
+
position: fixed;
|
| 3 |
+
top: 0;
|
| 4 |
+
left: 0;
|
| 5 |
+
right: 0;
|
| 6 |
+
bottom: 0;
|
| 7 |
+
display: flex;
|
| 8 |
+
align-items: center;
|
| 9 |
+
justify-content: center;
|
| 10 |
+
background-color: rgba(0, 0, 0, 0.7);
|
| 11 |
+
z-index: 1000;
|
| 12 |
+
backdrop-filter: blur(3px);
|
| 13 |
+
animation: fadeIn 0.2s ease-out;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
.response-modal-content {
|
| 17 |
+
background-color: #1a1a2e;
|
| 18 |
+
padding: 1.5rem;
|
| 19 |
+
border-radius: 12px;
|
| 20 |
+
width: 90%;
|
| 21 |
+
max-width: 700px;
|
| 22 |
+
max-height: 90vh;
|
| 23 |
+
display: flex;
|
| 24 |
+
flex-direction: column;
|
| 25 |
+
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.5);
|
| 26 |
+
border: 1px solid #2a2a42;
|
| 27 |
+
animation: slideUp 0.3s ease-out;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
.response-modal-header {
|
| 31 |
+
display: flex;
|
| 32 |
+
align-items: center;
|
| 33 |
+
justify-content: space-between;
|
| 34 |
+
margin-bottom: 1rem;
|
| 35 |
+
padding-bottom: 1rem;
|
| 36 |
+
border-bottom: 1px solid #2a2a42;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
.response-modal-header h3 {
|
| 40 |
+
margin: 0;
|
| 41 |
+
font-size: 1.3rem;
|
| 42 |
+
background: linear-gradient(45deg, #8b5cf6, #3b82f6);
|
| 43 |
+
-webkit-background-clip: text;
|
| 44 |
+
background-clip: text;
|
| 45 |
+
-webkit-text-fill-color: transparent;
|
| 46 |
+
flex-grow: 1;
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
.response-modal-turn {
|
| 50 |
+
margin-right: 1rem;
|
| 51 |
+
color: #9ca3af;
|
| 52 |
+
font-size: 0.9rem;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
.close-btn {
|
| 56 |
+
background: none;
|
| 57 |
+
border: none;
|
| 58 |
+
color: #9ca3af;
|
| 59 |
+
font-size: 1.5rem;
|
| 60 |
+
cursor: pointer;
|
| 61 |
+
padding: 0;
|
| 62 |
+
transition: color 0.2s;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
.close-btn:hover {
|
| 66 |
+
color: #f43f5e;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
.response-editor {
|
| 70 |
+
width: 100%;
|
| 71 |
+
min-height: 200px;
|
| 72 |
+
padding: 0.8rem;
|
| 73 |
+
margin-bottom: 1rem;
|
| 74 |
+
background-color: #1e1e30;
|
| 75 |
+
border: 1px solid #3a3a5a;
|
| 76 |
+
border-radius: 8px;
|
| 77 |
+
color: #e5e7eb;
|
| 78 |
+
font-family: inherit;
|
| 79 |
+
font-size: 1rem;
|
| 80 |
+
line-height: 1.6;
|
| 81 |
+
resize: vertical;
|
| 82 |
+
transition: border 0.2s ease;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
.response-editor:focus {
|
| 86 |
+
outline: none;
|
| 87 |
+
border-color: #8b5cf6;
|
| 88 |
+
box-shadow: 0 0 0 2px rgba(139, 92, 246, 0.25);
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
.response-modal-footer {
|
| 92 |
+
display: flex;
|
| 93 |
+
justify-content: flex-end;
|
| 94 |
+
gap: 1rem;
|
| 95 |
+
margin-top: 1rem;
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
.cancel-btn,
|
| 99 |
+
.save-btn {
|
| 100 |
+
padding: 0.6rem 1.2rem;
|
| 101 |
+
border-radius: 6px;
|
| 102 |
+
font-weight: 500;
|
| 103 |
+
cursor: pointer;
|
| 104 |
+
transition: all 0.2s;
|
| 105 |
+
border: none;
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
.cancel-btn {
|
| 109 |
+
background-color: transparent;
|
| 110 |
+
color: #9ca3af;
|
| 111 |
+
border: 1px solid #3a3a5a;
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
.cancel-btn:hover {
|
| 115 |
+
background-color: #2a2a42;
|
| 116 |
+
color: #e5e7eb;
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
.save-btn {
|
| 120 |
+
background-color: #8b5cf6;
|
| 121 |
+
color: white;
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
.save-btn:hover {
|
| 125 |
+
background-color: #7c3aed;
|
| 126 |
+
box-shadow: 0 2px 8px rgba(124, 58, 237, 0.4);
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
/* Animations */
|
| 130 |
+
@keyframes fadeIn {
|
| 131 |
+
from {
|
| 132 |
+
opacity: 0;
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
to {
|
| 136 |
+
opacity: 1;
|
| 137 |
+
}
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
@keyframes slideUp {
|
| 141 |
+
from {
|
| 142 |
+
transform: translateY(20px);
|
| 143 |
+
opacity: 0;
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
to {
|
| 147 |
+
transform: translateY(0);
|
| 148 |
+
opacity: 1;
|
| 149 |
+
}
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
/* Light theme adjustments */
|
| 153 |
+
:root[data-theme="light"] .response-modal-overlay {
|
| 154 |
+
background-color: rgba(0, 0, 0, 0.4);
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
:root[data-theme="light"] .response-modal-content {
|
| 158 |
+
background-color: #ffffff;
|
| 159 |
+
border: 1px solid #e5e7eb;
|
| 160 |
+
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
:root[data-theme="light"] .response-modal-header {
|
| 164 |
+
border-bottom: 1px solid #e5e7eb;
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
:root[data-theme="light"] .response-editor {
|
| 168 |
+
background-color: #f9fafb;
|
| 169 |
+
border: 1px solid #d1d5db;
|
| 170 |
+
color: #1f2937;
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
:root[data-theme="light"] .response-editor:focus {
|
| 174 |
+
border-color: #8b5cf6;
|
| 175 |
+
box-shadow: 0 0 0 2px rgba(139, 92, 246, 0.2);
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
:root[data-theme="light"] .cancel-btn {
|
| 179 |
+
color: #4b5563;
|
| 180 |
+
border: 1px solid #d1d5db;
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
:root[data-theme="light"] .cancel-btn:hover {
|
| 184 |
+
background-color: #f3f4f6;
|
| 185 |
+
color: #1f2937;
|
| 186 |
+
}
|
frontend/podcraft/src/components/ResponseEditModal.jsx
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect, useRef } from 'react';
|
| 2 |
+
import './ResponseEditModal.css';
|
| 3 |
+
|
| 4 |
+
/**
|
| 5 |
+
* Modal component for editing agent responses in the insights
|
| 6 |
+
* @param {Object} props
|
| 7 |
+
* @param {boolean} props.isOpen - Whether the modal is open
|
| 8 |
+
* @param {function} props.onClose - Function to call when the modal is closed
|
| 9 |
+
* @param {string} props.agentName - The name of the agent whose response is being edited
|
| 10 |
+
* @param {string} props.agentId - The ID of the agent
|
| 11 |
+
* @param {number} props.turn - The turn number of the response
|
| 12 |
+
* @param {string} props.response - The current response text
|
| 13 |
+
* @param {function} props.onSave - Function to call when the response is saved, receives (agentId, turn, newResponse)
|
| 14 |
+
*/
|
| 15 |
+
const ResponseEditModal = ({ isOpen, onClose, agentName, agentId, turn, response, onSave }) => {
|
| 16 |
+
const [editedResponse, setEditedResponse] = useState('');
|
| 17 |
+
const modalRef = useRef(null);
|
| 18 |
+
const editorRef = useRef(null);
|
| 19 |
+
|
| 20 |
+
// Initialize the editor with the current response when the modal opens
|
| 21 |
+
useEffect(() => {
|
| 22 |
+
if (isOpen && response) {
|
| 23 |
+
// Remove any HTML tags to get plain text for editing
|
| 24 |
+
const plainText = response.replace(/<[^>]*>/g, '');
|
| 25 |
+
setEditedResponse(plainText);
|
| 26 |
+
|
| 27 |
+
// Focus the editor when the modal opens
|
| 28 |
+
setTimeout(() => {
|
| 29 |
+
if (editorRef.current) {
|
| 30 |
+
editorRef.current.focus();
|
| 31 |
+
}
|
| 32 |
+
}, 100);
|
| 33 |
+
}
|
| 34 |
+
}, [isOpen, response]);
|
| 35 |
+
|
| 36 |
+
// Handle clicks outside the modal to close it
|
| 37 |
+
useEffect(() => {
|
| 38 |
+
const handleClickOutside = (event) => {
|
| 39 |
+
if (modalRef.current && !modalRef.current.contains(event.target)) {
|
| 40 |
+
onClose();
|
| 41 |
+
}
|
| 42 |
+
};
|
| 43 |
+
|
| 44 |
+
if (isOpen) {
|
| 45 |
+
document.addEventListener('mousedown', handleClickOutside);
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
return () => {
|
| 49 |
+
document.removeEventListener('mousedown', handleClickOutside);
|
| 50 |
+
};
|
| 51 |
+
}, [isOpen, onClose]);
|
| 52 |
+
|
| 53 |
+
// Handle saving the edited response
|
| 54 |
+
const handleSave = () => {
|
| 55 |
+
// Call the onSave callback with the agent ID, turn, and new response
|
| 56 |
+
onSave(agentId, turn, editedResponse);
|
| 57 |
+
onClose();
|
| 58 |
+
};
|
| 59 |
+
|
| 60 |
+
// Don't render anything if the modal is not open
|
| 61 |
+
if (!isOpen) return null;
|
| 62 |
+
|
| 63 |
+
return (
|
| 64 |
+
<div className="response-modal-overlay">
|
| 65 |
+
<div className="response-modal-content" ref={modalRef}>
|
| 66 |
+
<div className="response-modal-header">
|
| 67 |
+
<h3>Edit Response: {agentName}</h3>
|
| 68 |
+
<span className="response-modal-turn">Turn {turn}</span>
|
| 69 |
+
<button className="close-btn" onClick={onClose}>×</button>
|
| 70 |
+
</div>
|
| 71 |
+
<textarea
|
| 72 |
+
ref={editorRef}
|
| 73 |
+
className="response-editor"
|
| 74 |
+
value={editedResponse}
|
| 75 |
+
onChange={(e) => setEditedResponse(e.target.value)}
|
| 76 |
+
placeholder="Edit the agent's response..."
|
| 77 |
+
/>
|
| 78 |
+
<div className="response-modal-footer">
|
| 79 |
+
<button className="cancel-btn" onClick={onClose}>Cancel</button>
|
| 80 |
+
<button className="save-btn" onClick={handleSave}>Save Changes</button>
|
| 81 |
+
</div>
|
| 82 |
+
</div>
|
| 83 |
+
</div>
|
| 84 |
+
);
|
| 85 |
+
};
|
| 86 |
+
|
| 87 |
+
export default ResponseEditModal;
|
frontend/podcraft/src/components/Toast.css
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.toast-container {
|
| 2 |
+
position: fixed;
|
| 3 |
+
bottom: 20px;
|
| 4 |
+
right: 20px;
|
| 5 |
+
z-index: 9999;
|
| 6 |
+
display: flex;
|
| 7 |
+
flex-direction: column;
|
| 8 |
+
gap: 10px;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
.toast {
|
| 12 |
+
background: rgba(99, 102, 241, 0.95);
|
| 13 |
+
backdrop-filter: blur(8px);
|
| 14 |
+
color: white;
|
| 15 |
+
padding: 1rem 1.5rem;
|
| 16 |
+
border-radius: 8px;
|
| 17 |
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
| 18 |
+
display: flex;
|
| 19 |
+
align-items: center;
|
| 20 |
+
gap: 0.75rem;
|
| 21 |
+
font-size: 0.9rem;
|
| 22 |
+
transform: translateX(120%);
|
| 23 |
+
opacity: 0;
|
| 24 |
+
animation: slideIn 0.3s ease forwards, fadeOut 0.3s ease 2.7s forwards;
|
| 25 |
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
| 26 |
+
width: auto;
|
| 27 |
+
max-width: 400px;
|
| 28 |
+
white-space: nowrap;
|
| 29 |
+
overflow: hidden;
|
| 30 |
+
text-overflow: ellipsis;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
.toast.success {
|
| 34 |
+
background: rgba(34, 197, 94, 0.95);
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
.toast.error {
|
| 38 |
+
background: rgba(239, 68, 68, 0.95);
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
.toast svg {
|
| 42 |
+
font-size: 1.2rem;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
@keyframes slideIn {
|
| 46 |
+
to {
|
| 47 |
+
transform: translateX(0);
|
| 48 |
+
opacity: 1;
|
| 49 |
+
}
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
@keyframes fadeOut {
|
| 53 |
+
to {
|
| 54 |
+
opacity: 0;
|
| 55 |
+
transform: translateX(120%);
|
| 56 |
+
}
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
/* Light theme adjustments */
|
| 60 |
+
.light .toast {
|
| 61 |
+
background: rgba(99, 102, 241, 0.9);
|
| 62 |
+
color: white;
|
| 63 |
+
}
|
frontend/podcraft/src/components/Toast.jsx
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useEffect } from 'react';
|
| 2 |
+
import { FaCheckCircle, FaTimesCircle } from 'react-icons/fa';
|
| 3 |
+
import './Toast.css';
|
| 4 |
+
|
| 5 |
+
const Toast = ({ message, type = 'success', onClose }) => {
|
| 6 |
+
useEffect(() => {
|
| 7 |
+
const timer = setTimeout(() => {
|
| 8 |
+
onClose();
|
| 9 |
+
}, 3000);
|
| 10 |
+
|
| 11 |
+
return () => clearTimeout(timer);
|
| 12 |
+
}, [onClose]);
|
| 13 |
+
|
| 14 |
+
return (
|
| 15 |
+
<div className={`toast ${type}`}>
|
| 16 |
+
{type === 'success' ? <FaCheckCircle /> : <FaTimesCircle />}
|
| 17 |
+
{message}
|
| 18 |
+
</div>
|
| 19 |
+
);
|
| 20 |
+
};
|
| 21 |
+
|
| 22 |
+
export default Toast;
|