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;
|