Nagesh Muralidhar commited on
Commit
fd52f31
·
1 Parent(s): 06aa799

Initial commit of PodCraft application

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +6 -0
  2. .github/workflows/docker-image.yml +22 -0
  3. .gitignore +14 -0
  4. Dockerfile +36 -0
  5. Dockerfile.spaces +39 -0
  6. app/main.py +199 -0
  7. backend/app/agents/debaters.py +206 -0
  8. backend/app/agents/podcast_manager.py +393 -0
  9. backend/app/agents/researcher.py +153 -0
  10. backend/app/database.py +13 -0
  11. backend/app/main.py +1020 -0
  12. backend/app/models.py +114 -0
  13. backend/app/routers/__pycache__/podcast.cpython-311.pyc +0 -0
  14. backend/requirements.txt +15 -0
  15. backend/run.py +14 -0
  16. backend/temp_audio/Default/final_podcast.mp3 +3 -0
  17. build_and_deploy.sh +32 -0
  18. build_for_spaces.sh +33 -0
  19. docker-compose.yml +15 -0
  20. frontend/package-lock.json +6 -0
  21. frontend/podcraft/.gitignore +24 -0
  22. frontend/podcraft/README.md +12 -0
  23. frontend/podcraft/assets/bg.gif +3 -0
  24. frontend/podcraft/assets/bg2.gif +3 -0
  25. frontend/podcraft/assets/bg3.gif +3 -0
  26. frontend/podcraft/eslint.config.js +33 -0
  27. frontend/podcraft/index.html +13 -0
  28. frontend/podcraft/package-lock.json +0 -0
  29. frontend/podcraft/package.json +30 -0
  30. frontend/podcraft/public/vite.svg +1 -0
  31. frontend/podcraft/src/App.css +496 -0
  32. frontend/podcraft/src/App.jsx +268 -0
  33. frontend/podcraft/src/assets/react.svg +1 -0
  34. frontend/podcraft/src/components/AgentModal.css +470 -0
  35. frontend/podcraft/src/components/AgentModal.tsx +558 -0
  36. frontend/podcraft/src/components/ChatDetailModal.css +400 -0
  37. frontend/podcraft/src/components/ChatDetailModal.jsx +191 -0
  38. frontend/podcraft/src/components/CustomEdge.jsx +104 -0
  39. frontend/podcraft/src/components/CustomNodes.css +251 -0
  40. frontend/podcraft/src/components/CustomNodes.jsx +174 -0
  41. frontend/podcraft/src/components/DeleteModal.css +138 -0
  42. frontend/podcraft/src/components/DeleteModal.jsx +28 -0
  43. frontend/podcraft/src/components/InputNodeModal.css +194 -0
  44. frontend/podcraft/src/components/InputNodeModal.jsx +47 -0
  45. frontend/podcraft/src/components/NodeSelectionPanel.css +413 -0
  46. frontend/podcraft/src/components/NodeSelectionPanel.jsx +116 -0
  47. frontend/podcraft/src/components/ResponseEditModal.css +186 -0
  48. frontend/podcraft/src/components/ResponseEditModal.jsx +87 -0
  49. frontend/podcraft/src/components/Toast.css +63 -0
  50. 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

  • SHA256: 2d48e5a618e4a404b15baa881dfbb687da0e4fe9564dbb6cd5dd3d984ef08910
  • Pointer size: 132 Bytes
  • Size of remote file: 1.06 MB
frontend/podcraft/assets/bg2.gif ADDED

Git LFS Details

  • SHA256: 9b9d6681b74f7e1451bcaaa41de66b55fa9cebe6bec379025186637e16bd8b27
  • Pointer size: 132 Bytes
  • Size of remote file: 9.86 MB
frontend/podcraft/assets/bg3.gif ADDED

Git LFS Details

  • SHA256: d1cd5c03f9b78e517532e35c866d2627ec96e56935ba1b62c4e8fb845ed1e74c
  • Pointer size: 132 Bytes
  • Size of remote file: 7.66 MB
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}>&times;</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}>&times;</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;