fix: WYSIWYG editor, content generation, and writing assistant bug fixes

- Fix text selection menu not showing: wire contentRef via inputRef on multiline TextField
- Fix blog title not truncating: add min-w-0 for flex item overflow
- Fix outline generation 500: escape curly braces in f-string prompt template
- Fix content generation 'NoneType not callable': replace SessionLocal() with get_session_for_user(), add db param to MediumBlogGenerator, fix signature mismatch in database_task_manager
- Fix writing assistant suggest 500: add auth + user_id to API endpoint and service, replace sync requests with httpx.AsyncClient
- Fix hallucination detector 404: explicitly include router in main.py and app.py
- Fix missing error_data in task failure responses
- Hide CopilotKit web inspector button
- Remove hardcoded fallback suggestions from SmartTypingAssist
- Fix stale closure refs in SmartTypingAssist handleTypingChange
- Add two-column editor layout, stats bar, section hover menu
- Various subscription, billing, and research module improvements
This commit is contained in:
ajaysi
2026-05-14 09:11:30 +05:30
parent 7385100017
commit 928c2f20aa
113 changed files with 4344 additions and 10064 deletions

4
.gitignore vendored
View File

@@ -8,6 +8,10 @@ nul
LICENSE
CHANGELOG.md
.planning
.planning/
.trae/
.trae

View File

@@ -1,46 +0,0 @@
# ALwrity Project
## What This Is
ALwrity is an AI-powered content creation platform that helps users generate various types of content including podcasts, videos, blogs, and social media content. The platform features a React frontend and a FastAPI backend with onboarding workflows, API key management, and content generation capabilities.
## Core Value
To provide an all-in-one AI content creation suite that simplifies the content production process for creators, marketers, and businesses.
## Current Focus
Based on recent git commits, the team has been working on:
- Podcast production features (voice cloning, avatar generation, B-roll integration)
- Onboarding flow improvements
- Backend stability and debugging
- Frontend UI/UX enhancements
## Requirements
### Validated
- User authentication (Clerk)
- API key management for AI providers
- Basic podcast generation workflow
- File storage and media handling
### Active
- Podcast script generation and editing
- Voice cloning and avatar creation
- B-roll scene rendering and integration
- Onboarding flow completion tracking
- API endpoint stability and debugging
### Out of Scope
- Mobile applications (currently web-only)
- Enterprise team collaboration features
- Advanced analytics dashboard
## Key Decisions
- Using FastAPI for backend performance
- React with Material-UI for frontend consistency
- Modular API design for extensibility
- Database-first approach for persistence
## Constraints
- Must maintain backward compatibility with existing API
- Deployment targets include both development and production environments
- Must support multiple AI providers (OpenAI, HuggingFace, etc.)
- Budget-conscious resource usage for AI API calls

View File

@@ -1,13 +1 @@
web: cd backend && ALWRITY_ENABLED_FEATURES=podcast python -c "
import os
import sys
# Ensure podcast mode
os.environ.setdefault('ALWRITY_ENABLED_FEATURES', 'podcast')
# Set HOST/PORT for Render
port = os.getenv('PORT', '10000')
host = os.getenv('HOST', '0.0.0.0')
print(f'[STARTUP] Starting uvicorn on {host}:{port}', flush=True)
sys.stdout.flush()
import uvicorn
uvicorn.run('app:app', host=host, port=int(port), reload=False)
"
web: cd backend && python start_alwrity_backend.py --production

View File

@@ -1,14 +0,0 @@
# Render CLI
## Installation
- [Homebrew](https://render.com/docs/cli#homebrew-macos-linux)
- [Direct Download](https://render.com/docs/cli#direct-download)
## Documentation
Documentation is hosted at https://render.com/docs/cli.
## Contributing
To create a new command, use the `cmd/template.go` template file as a starting point. Reference the [CLI Style Guide](docs/STYLE.md) to learn more about command naming, flags, arguments, and help text conventions.

View File

@@ -1,672 +0,0 @@
import React from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate, useLocation } from 'react-router-dom';
import { Box, CircularProgress, Typography } from '@mui/material';
import { CopilotKit } from "@copilotkit/react-core";
import { ClerkProvider, useAuth } from '@clerk/clerk-react';
import "@copilotkit/react-ui/styles.css";
import Wizard from './components/OnboardingWizard/Wizard';
import MainDashboard from './components/MainDashboard/MainDashboard';
import SEODashboard from './components/SEODashboard/SEODashboard';
import ContentPlanningDashboard from './components/ContentPlanningDashboard/ContentPlanningDashboard';
import FacebookWriter from './components/FacebookWriter/FacebookWriter';
import LinkedInWriter from './components/LinkedInWriter/LinkedInWriter';
import BlogWriter from './components/BlogWriter/BlogWriter';
import StoryWriter from './components/StoryWriter/StoryWriter';
import { StoryProjectList } from './components/StoryWriter/StoryProjectList';
import YouTubeCreator from './components/YouTubeCreator/YouTubeCreator';
import { CreateStudio, EditStudio, UpscaleStudio, ControlStudio, SocialOptimizer, AssetLibrary, ImageStudioDashboard, FaceSwapStudio, CompressionStudio, ImageProcessingStudio } from './components/ImageStudio';
import {
VideoStudioDashboard,
CreateVideo,
AvatarVideo,
EnhanceVideo,
ExtendVideo,
EditVideo,
TransformVideo,
SocialVideo,
FaceSwap,
VideoTranslate,
VideoBackgroundRemover,
AddAudioToVideo,
LibraryVideo,
} from './components/VideoStudio';
import {
ProductMarketingDashboard,
ProductPhotoshootStudio,
ProductAnimationStudio,
ProductVideoStudio,
ProductAvatarStudio,
} from './components/ProductMarketing';
import PodcastDashboard from './components/PodcastMaker/PodcastDashboard';
import PricingPage from './components/Pricing/PricingPage';
import WixTestPage from './components/WixTestPage/WixTestPage';
import WixCallbackPage from './components/WixCallbackPage/WixCallbackPage';
import WordPressCallbackPage from './components/WordPressCallbackPage/WordPressCallbackPage';
import BingCallbackPage from './components/BingCallbackPage/BingCallbackPage';
import BingAnalyticsStorage from './components/BingAnalyticsStorage/BingAnalyticsStorage';
import ResearchDashboard from './pages/ResearchDashboard';
import IntentResearchTest from './pages/IntentResearchTest';
import SchedulerDashboard from './pages/SchedulerDashboard';
import BillingPage from './pages/BillingPage';
import ApprovalsPage from './pages/ApprovalsPage';
import TeamActivityPage from './pages/TeamActivityPage';
import StripeDisputesDashboard from './pages/StripeDisputesDashboard';
import ProtectedRoute from './components/shared/ProtectedRoute';
import GSCAuthCallback from './components/SEODashboard/components/GSCAuthCallback';
import Landing from './components/Landing/Landing';
import ErrorBoundary from './components/shared/ErrorBoundary';
import ErrorBoundaryTest from './components/shared/ErrorBoundaryTest';
import CopilotKitDegradedBanner from './components/shared/CopilotKitDegradedBanner';
import { OnboardingProvider } from './contexts/OnboardingContext';
import { SubscriptionProvider, useSubscription } from './contexts/SubscriptionContext';
import { CopilotKitHealthProvider } from './contexts/CopilotKitHealthContext';
import { useOAuthTokenAlerts } from './hooks/useOAuthTokenAlerts';
import { setAuthTokenGetter, setClerkSignOut } from './api/client';
import { setMediaAuthTokenGetter } from './utils/fetchMediaBlobUrl';
import { setBillingAuthTokenGetter } from './services/billingService';
import { useOnboarding } from './contexts/OnboardingContext';
import { useState, useEffect } from 'react';
import ConnectionErrorPage from './components/shared/ConnectionErrorPage';
import { isPodcastOnlyDemoMode } from './utils/demoMode';
// interface OnboardingStatus {
// onboarding_required: boolean;
// onboarding_complete: boolean;
// current_step?: number;
// total_steps?: number;
// completion_percentage?: number;
// }
// Conditional CopilotKit wrapper that only shows sidebar on content-planning route
const ConditionalCopilotKit: React.FC<{ children: React.ReactNode }> = ({ children }) => {
// Do not render CopilotSidebar here. Let specific pages/components control it.
return <>{children}</>;
};
// Wrapper to only enable CopilotKit checks/provider when user is authenticated
// This prevents CopilotKit from running on the Landing page
const AuthenticatedCopilotWrapper: React.FC<{
children: React.ReactNode;
apiKey: string;
}> = ({ children, apiKey }) => {
const { isSignedIn } = useAuth();
const location = useLocation();
// Exclude CopilotKit from running on:
// 1. Landing page (handled by !isSignedIn)
// 2. Onboarding pages (to prevent health check timeouts)
// 3. Podcast-only demo mode (CopilotKit not needed)
const isPodcastOnly = isPodcastOnlyDemoMode();
const shouldExcludeCopilot = !isSignedIn || location.pathname.startsWith('/onboarding') || isPodcastOnly;
if (shouldExcludeCopilot) {
return <>{children}</>;
}
const hasKey = apiKey && apiKey.trim();
if (hasKey) {
// Enhanced error handler that updates health context
const handleCopilotKitError = (e: any) => {
console.error("CopilotKit Error:", e);
// Try to get health context if available
// We'll use a custom event to notify health context since we can't access it directly here
const errorMessage = e?.error?.message || e?.message || 'CopilotKit error occurred';
const errorType = errorMessage.toLowerCase();
// Differentiate between fatal and transient errors
const isFatalError =
errorType.includes('cors') ||
errorType.includes('ssl') ||
errorType.includes('certificate') ||
errorType.includes('403') ||
errorType.includes('forbidden') ||
errorType.includes('ERR_CERT_COMMON_NAME_INVALID');
// Dispatch event for health context to listen to
window.dispatchEvent(new CustomEvent('copilotkit-error', {
detail: {
error: e,
errorMessage,
isFatal: isFatalError,
}
}));
};
return (
<CopilotKitHealthProvider initialHealthStatus={true}>
<CopilotKitDegradedBanner />
<ErrorBoundary
context="CopilotKit"
showDetails={process.env.NODE_ENV === 'development'}
fallback={
<Box sx={{ p: 3, textAlign: 'center' }}>
<Typography variant="h6" color="warning" gutterBottom>
Chat Unavailable
</Typography>
<Typography variant="body2" color="textSecondary">
CopilotKit encountered an error. The app continues to work with manual controls.
</Typography>
</Box>
}
>
<CopilotKit
publicApiKey={apiKey}
showDevConsole={false}
onError={handleCopilotKitError}
>
{children}
</CopilotKit>
</ErrorBoundary>
</CopilotKitHealthProvider>
);
}
return (
<CopilotKitHealthProvider initialHealthStatus={false}>
<CopilotKitDegradedBanner />
{children}
</CopilotKitHealthProvider>
);
};
// Component to handle initial routing based on subscription and onboarding status
// Flow: Subscription → Onboarding → Dashboard
const InitialRouteHandler: React.FC = () => {
const { loading, error, isOnboardingComplete, initializeOnboarding, data } = useOnboarding();
const { subscription, loading: subscriptionLoading, checkSubscription } = useSubscription();
const [connectionError, setConnectionError] = useState<{
hasError: boolean;
error: Error | null;
}>({
hasError: false,
error: null,
});
// Poll for OAuth token alerts and show toast notifications
// Only enabled when user is authenticated (has subscription)
useOAuthTokenAlerts({
enabled: subscription?.active === true,
interval: 60000, // Poll every 1 minute
});
// Check subscription on mount (non-blocking - don't wait for it to route)
useEffect(() => {
// Delay subscription check slightly to allow auth token getter to be installed first
const timeoutId = setTimeout(async () => {
// Retry logic for initial subscription check
const maxRetries = 3;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
await checkSubscription();
break; // Success
} catch (err) {
console.error(`App: Subscription check attempt ${attempt + 1} failed:`, err);
// If it's a connection error and we have retries left, wait and retry
const isConnectionError = err instanceof Error && (err.name === 'NetworkError' || err.name === 'ConnectionError');
if (isConnectionError && attempt < maxRetries - 1) {
const delay = 1000 * Math.pow(2, attempt); // 1s, 2s
await new Promise(resolve => setTimeout(resolve, delay));
continue;
}
// If final attempt or not a connection error, handle it
if (attempt === maxRetries - 1 || !isConnectionError) {
if (isConnectionError) {
setConnectionError({
hasError: true,
error: err as Error,
});
}
// Don't block routing on other errors
}
}
}
}, 100); // Small delay to ensure TokenInstaller has run
return () => clearTimeout(timeoutId);
}, []); // Remove checkSubscription dependency to prevent loop
// Initialize onboarding only after subscription is confirmed
useEffect(() => {
if (subscription && !subscriptionLoading) {
// Check if user is new (no subscription record at all)
const isNewUser = !subscription || subscription.plan === 'none';
console.log('InitialRouteHandler: Subscription data received:', {
plan: subscription.plan,
active: subscription.active,
isNewUser,
subscriptionLoading
});
if (subscription.active && !isNewUser) {
console.log('InitialRouteHandler: Subscription confirmed, initializing onboarding...');
initializeOnboarding();
}
}
}, [subscription, subscriptionLoading, initializeOnboarding]);
// Handle connection error - show connection error page
if (connectionError.hasError) {
const handleRetry = () => {
setConnectionError({
hasError: false,
error: null,
});
// Re-trigger the subscription check using context
checkSubscription().catch((err) => {
if (err instanceof Error && (err.name === 'NetworkError' || err.name === 'ConnectionError')) {
setConnectionError({
hasError: true,
error: err,
});
}
});
};
const handleGoHome = () => {
window.location.href = '/';
};
return (
<ConnectionErrorPage
onRetry={handleRetry}
onGoHome={handleGoHome}
message={connectionError.error?.message || "Backend service is not available. Please check if the server is running."}
title="Connection Error"
/>
);
}
// Loading state - only wait for onboarding init, not subscription check
// Subscription check is non-blocking and happens in background
const waitingForOnboardingInit = loading || !data;
if (loading || waitingForOnboardingInit) {
return (
<Box
display="flex"
flexDirection="column"
alignItems="center"
justifyContent="center"
minHeight="100vh"
gap={2}
>
<CircularProgress size={60} />
<Typography variant="h6" color="textSecondary">
{subscriptionLoading ? 'Checking subscription...' : 'Preparing your workspace...'}
</Typography>
</Box>
);
}
// Error state
if (error) {
return (
<Box
display="flex"
flexDirection="column"
alignItems="center"
justifyContent="center"
minHeight="100vh"
gap={2}
p={3}
>
<Typography variant="h5" color="error" gutterBottom>
Error
</Typography>
<Typography variant="body1" color="textSecondary" textAlign="center">
{error}
</Typography>
</Box>
);
}
// Decision tree for SIGNED-IN users:
// Priority: Subscription → Onboarding → Dashboard (as per user flow: Landing → Subscription → Onboarding → Dashboard)
// 1. If subscription is still loading, show loading state
if (subscriptionLoading) {
return (
<Box
display="flex"
flexDirection="column"
alignItems="center"
justifyContent="center"
minHeight="100vh"
gap={2}
>
<CircularProgress size={60} />
<Typography variant="h6" color="textSecondary">
Checking subscription...
</Typography>
</Box>
);
}
// 2. No subscription data yet - handle gracefully
// If onboarding is complete, allow access to dashboard (user already went through flow)
// If onboarding not complete, check if subscription check is still loading or failed
if (!subscription) {
if (isOnboardingComplete) {
console.log('InitialRouteHandler: Onboarding complete but no subscription data → Dashboard (allow access)');
return <Navigate to="/dashboard" replace />;
}
// Onboarding not complete and no subscription data
// If subscription check is still loading, show loading state
if (subscriptionLoading) {
return (
<Box
display="flex"
flexDirection="column"
alignItems="center"
justifyContent="center"
minHeight="100vh"
gap={2}
>
<CircularProgress size={60} />
<Typography variant="h6" color="textSecondary">
Checking subscription...
</Typography>
</Box>
);
}
// Subscription check completed but returned null/undefined
// This likely means no subscription - redirect to pricing
console.log('InitialRouteHandler: No subscription data after check → Pricing page');
return <Navigate to="/pricing" replace />;
}
// 3. Check subscription status first
const isNewUser = !subscription || subscription.plan === 'none';
// No active subscription → Show modal (SubscriptionContext handles this)
// Don't redirect immediately - let the modal show first
// User can click "Renew Subscription" button in modal to go to pricing
// Or click "Maybe Later" to dismiss (but they still can't use features)
if (isNewUser || !subscription.active) {
console.log('InitialRouteHandler: No active subscription - modal will be shown by SubscriptionContext');
// Note: SubscriptionContext will show the modal automatically when subscription is inactive
// We still redirect to pricing for new users, but allow existing users with expired subscriptions
// to see the modal first. The modal has a "Renew Subscription" button that navigates to pricing.
// For new users (no subscription at all), redirect to pricing immediately
if (isNewUser) {
console.log('InitialRouteHandler: New user (no subscription) → Pricing page');
return <Navigate to="/pricing" replace />;
}
// For existing users with inactive subscription, show modal but don't redirect immediately
// The modal will be shown by SubscriptionContext, and user can click "Renew Subscription"
// Allow access to dashboard (modal will be shown and block functionality)
console.log('InitialRouteHandler: Inactive subscription - allowing access to show modal');
// Continue to onboarding/dashboard flow - modal will be shown by SubscriptionContext
}
// 4. Has active subscription, check onboarding status
if (!isOnboardingComplete) {
console.log('InitialRouteHandler: Subscription active but onboarding incomplete → Onboarding');
return <Navigate to="/onboarding" replace />;
}
// 5. Has subscription AND completed onboarding → Dashboard
console.log('InitialRouteHandler: All set (subscription + onboarding) → Dashboard');
return <Navigate to="/dashboard" replace />;
};
// Root route that chooses Landing (signed out) or InitialRouteHandler (signed in)
const RootRoute: React.FC = () => {
const { isSignedIn } = useAuth();
if (isSignedIn) {
return <InitialRouteHandler />;
}
return <Landing />;
};
// Installs Clerk auth token getter into axios clients and stores user_id
// Must render under ClerkProvider
const TokenInstaller: React.FC = () => {
const { getToken, userId, isSignedIn, signOut } = useAuth();
// Store user_id in localStorage when user signs in
useEffect(() => {
if (isSignedIn && userId) {
console.log('TokenInstaller: Storing user_id in localStorage:', userId);
localStorage.setItem('user_id', userId);
// Trigger event to notify SubscriptionContext that user is authenticated
window.dispatchEvent(new CustomEvent('user-authenticated', { detail: { userId } }));
} else if (!isSignedIn) {
// Clear user_id when signed out
console.log('TokenInstaller: Clearing user_id from localStorage');
localStorage.removeItem('user_id');
}
}, [isSignedIn, userId]);
// Install token getter for API calls
useEffect(() => {
const tokenGetter = async () => {
try {
const template = process.env.REACT_APP_CLERK_JWT_TEMPLATE;
// If a template is provided and it's not a placeholder, request a template-specific JWT
if (template && template !== 'your_jwt_template_name_here') {
// @ts-ignore Clerk types allow options object
return await getToken({ template });
}
return await getToken();
} catch {
return null;
}
};
// Set token getter for main API client
setAuthTokenGetter(tokenGetter);
// Set token getter for billing API client (same function)
setBillingAuthTokenGetter(tokenGetter);
// Set token getter for media blob URL fetcher (for authenticated image/video requests)
setMediaAuthTokenGetter(tokenGetter);
}, [getToken]);
// Install Clerk signOut function for handling expired tokens
useEffect(() => {
if (signOut) {
setClerkSignOut(async () => {
await signOut();
});
}
}, [signOut]);
return null;
};
const App: React.FC = () => {
// React Hooks MUST be at the top before any conditionals
const [loading, setLoading] = useState(true);
// Get CopilotKit key from localStorage or .env
const [copilotApiKey, setCopilotApiKey] = useState(() => {
const savedKey = localStorage.getItem('copilotkit_api_key');
const envKey = process.env.REACT_APP_COPILOTKIT_API_KEY || '';
const key = (savedKey || envKey).trim();
// Validate key format if present
if (key && !key.startsWith('ck_pub_')) {
console.warn('CopilotKit API key format invalid - must start with ck_pub_');
}
return key;
});
// Initialize app - loading state will be managed by InitialRouteHandler
useEffect(() => {
// Remove manual health check - connection errors are handled by ErrorBoundary
setLoading(false);
}, []);
// Listen for CopilotKit key updates
useEffect(() => {
const handleKeyUpdate = (event: CustomEvent) => {
const newKey = event.detail?.apiKey;
if (newKey) {
console.log('App: CopilotKit key updated, reloading...');
setCopilotApiKey(newKey);
setTimeout(() => window.location.reload(), 500);
}
};
window.addEventListener('copilotkit-key-updated', handleKeyUpdate as EventListener);
return () => window.removeEventListener('copilotkit-key-updated', handleKeyUpdate as EventListener);
}, []);
// Token installer must be inside ClerkProvider; see TokenInstaller below
if (loading) {
return (
<Box
display="flex"
flexDirection="column"
alignItems="center"
justifyContent="center"
minHeight="100vh"
gap={2}
>
<CircularProgress size={60} />
<Typography variant="h6" color="textSecondary">
Connecting to ALwrity...
</Typography>
</Box>
);
}
// Get environment variables with fallbacks
const clerkPublishableKey = process.env.REACT_APP_CLERK_PUBLISHABLE_KEY || '';
const clerkJSUrl = process.env.REACT_APP_CLERK_JS_URL;
// Show error if required keys are missing
if (!clerkPublishableKey) {
return (
<Box sx={{ p: 3, textAlign: 'center' }}>
<Typography color="error" variant="h6">
Missing Clerk Publishable Key
</Typography>
<Typography variant="body2" sx={{ mt: 1 }}>
Please add REACT_APP_CLERK_PUBLISHABLE_KEY to your .env file
</Typography>
</Box>
);
}
// Render app with or without CopilotKit based on whether we have a key
const renderApp = () => {
return (
<Router>
<AuthenticatedCopilotWrapper apiKey={copilotApiKey}>
<ConditionalCopilotKit>
<TokenInstaller />
<Routes>
<Route path="/" element={<RootRoute />} />
<Route
path="/onboarding"
element={
<ErrorBoundary context="Onboarding Wizard" showDetails>
<Wizard />
</ErrorBoundary>
}
/>
{/* Error Boundary Testing - Development Only */}
{process.env.NODE_ENV === 'development' && (
<Route path="/error-test" element={<ErrorBoundaryTest />} />
)}
<Route path="/dashboard" element={<ProtectedRoute><MainDashboard /></ProtectedRoute>} />
<Route path="/seo" element={<ProtectedRoute><SEODashboard /></ProtectedRoute>} />
<Route path="/seo-dashboard" element={<ProtectedRoute><SEODashboard /></ProtectedRoute>} />
<Route path="/content-planning" element={<ProtectedRoute><ContentPlanningDashboard /></ProtectedRoute>} />
<Route path="/facebook-writer" element={<ProtectedRoute><FacebookWriter /></ProtectedRoute>} />
<Route path="/linkedin-writer" element={<ProtectedRoute><LinkedInWriter /></ProtectedRoute>} />
<Route path="/blog-writer" element={<ProtectedRoute><BlogWriter /></ProtectedRoute>} />
<Route path="/story-writer" element={<ProtectedRoute><StoryWriter /></ProtectedRoute>} />
<Route path="/story-projects" element={<ProtectedRoute><StoryProjectList /></ProtectedRoute>} />
<Route path="/youtube-creator" element={<ProtectedRoute><YouTubeCreator /></ProtectedRoute>} />
<Route path="/podcast-maker" element={<ProtectedRoute><PodcastDashboard /></ProtectedRoute>} />
<Route path="/image-studio" element={<ProtectedRoute><ImageStudioDashboard /></ProtectedRoute>} />
<Route path="/video-studio" element={<ProtectedRoute><VideoStudioDashboard /></ProtectedRoute>} />
<Route path="/video-studio/create" element={<ProtectedRoute><CreateVideo /></ProtectedRoute>} />
<Route path="/video-studio/avatar" element={<ProtectedRoute><AvatarVideo /></ProtectedRoute>} />
<Route path="/video-studio/enhance" element={<ProtectedRoute><EnhanceVideo /></ProtectedRoute>} />
<Route path="/video-studio/extend" element={<ProtectedRoute><ExtendVideo /></ProtectedRoute>} />
<Route path="/video-studio/edit" element={<ProtectedRoute><EditVideo /></ProtectedRoute>} />
<Route path="/video-studio/transform" element={<ProtectedRoute><TransformVideo /></ProtectedRoute>} />
<Route path="/video-studio/social" element={<ProtectedRoute><SocialVideo /></ProtectedRoute>} />
<Route path="/video-studio/face-swap" element={<ProtectedRoute><FaceSwap /></ProtectedRoute>} />
<Route path="/video-studio/video-translate" element={<ProtectedRoute><VideoTranslate /></ProtectedRoute>} />
<Route path="/video-studio/video-background-remover" element={<ProtectedRoute><VideoBackgroundRemover /></ProtectedRoute>} />
<Route path="/video-studio/add-audio-to-video" element={<ProtectedRoute><AddAudioToVideo /></ProtectedRoute>} />
<Route path="/video-studio/library" element={<ProtectedRoute><LibraryVideo /></ProtectedRoute>} />
<Route path="/image-generator" element={<ProtectedRoute><CreateStudio /></ProtectedRoute>} />
<Route path="/image-editor" element={<ProtectedRoute><EditStudio /></ProtectedRoute>} />
<Route path="/image-upscale" element={<ProtectedRoute><UpscaleStudio /></ProtectedRoute>} />
<Route path="/image-control" element={<ProtectedRoute><ControlStudio /></ProtectedRoute>} />
<Route path="/image-studio/face-swap" element={<ProtectedRoute><FaceSwapStudio /></ProtectedRoute>} />
<Route path="/image-studio/compress" element={<ProtectedRoute><CompressionStudio /></ProtectedRoute>} />
<Route path="/image-studio/processing" element={<ProtectedRoute><ImageProcessingStudio /></ProtectedRoute>} />
<Route path="/image-studio/social-optimizer" element={<ProtectedRoute><SocialOptimizer /></ProtectedRoute>} />
<Route path="/asset-library" element={<ProtectedRoute><AssetLibrary /></ProtectedRoute>} />
<Route path="/campaign-creator" element={<ProtectedRoute><ProductMarketingDashboard /></ProtectedRoute>} />
<Route path="/campaign-creator/photoshoot" element={<ProtectedRoute><ProductPhotoshootStudio /></ProtectedRoute>} />
<Route path="/campaign-creator/animation" element={<ProtectedRoute><ProductAnimationStudio /></ProtectedRoute>} />
<Route path="/campaign-creator/video" element={<ProtectedRoute><ProductVideoStudio /></ProtectedRoute>} />
<Route path="/campaign-creator/avatar" element={<ProtectedRoute><ProductAvatarStudio /></ProtectedRoute>} />
<Route path="/product-marketing" element={<Navigate to="/campaign-creator" replace />} />
<Route path="/scheduler-dashboard" element={<ProtectedRoute><SchedulerDashboard /></ProtectedRoute>} />
<Route path="/billing" element={<ProtectedRoute><BillingPage /></ProtectedRoute>} />
<Route path="/approvals" element={<ProtectedRoute><ApprovalsPage /></ProtectedRoute>} />
<Route path="/team-activity" element={<ProtectedRoute><TeamActivityPage /></ProtectedRoute>} />
<Route path="/stripe-disputes" element={<ProtectedRoute><StripeDisputesDashboard /></ProtectedRoute>} />
<Route path="/pricing" element={<PricingPage />} />
<Route path="/research-test" element={<ResearchDashboard />} />
<Route path="/research-dashboard" element={<ResearchDashboard />} />
<Route path="/alwrity-researcher" element={<ResearchDashboard />} />
<Route path="/intent-research" element={<IntentResearchTest />} />
<Route path="/wix-test" element={<WixTestPage />} />
<Route path="/wix-test-direct" element={<WixTestPage />} />
<Route path="/wix/callback" element={<WixCallbackPage />} />
<Route path="/wp/callback" element={<WordPressCallbackPage />} />
<Route path="/gsc/callback" element={<GSCAuthCallback />} />
<Route path="/bing/callback" element={<BingCallbackPage />} />
<Route path="/bing-analytics-storage" element={<ProtectedRoute><BingAnalyticsStorage /></ProtectedRoute>} />
</Routes>
</ConditionalCopilotKit>
</AuthenticatedCopilotWrapper>
</Router>
);
};
return (
<ErrorBoundary
context="Application Root"
showDetails={process.env.NODE_ENV === 'development'}
onError={(error, errorInfo) => {
// Custom error handler - send to analytics/monitoring
console.error('Global error caught:', { error, errorInfo });
// TODO: Send to error tracking service (Sentry, LogRocket, etc.)
}}
>
<ClerkProvider publishableKey={clerkPublishableKey} clerkJSUrl={clerkJSUrl}>
<SubscriptionProvider>
<OnboardingProvider>
{renderApp()}
</OnboardingProvider>
</SubscriptionProvider>
</ClerkProvider>
</ErrorBoundary>
);
};
export default App;

View File

@@ -1,537 +0,0 @@
import React, { useMemo, useCallback } from "react";
import { Stack, Typography, Chip, Divider, Box, alpha, Paper, Tooltip } from "@mui/material";
import {
Insights as InsightsIcon,
Search as SearchIcon,
AttachMoney as AttachMoneyIcon,
EditNote as EditNoteIcon,
Article as ArticleIcon,
AutoAwesome as AutoAwesomeIcon,
FormatQuote as FormatQuoteIcon,
Campaign as CampaignIcon,
Explore as ExploreIcon,
} from "@mui/icons-material";
import { Research, ResearchInsight } from "../types";
import { GlassyCard, glassyCardSx, PrimaryButton } from "../ui";
import { FactCard } from "../FactCard";
interface ResearchSummaryProps {
research: Research;
canGenerateScript: boolean;
onGenerateScript: () => void;
}
export const ResearchSummary: React.FC<ResearchSummaryProps> = ({
research,
canGenerateScript,
onGenerateScript,
}) => {
// Simple markdown-to-HTML converter
const renderMarkdown = useCallback((text: string) => {
if (!text) return null;
return text
.split('\n')
.filter(line => line.trim() !== '') // Remove empty lines
.map((line, i) => {
// Handle bold
let processedLine = line.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
// Handle lists
if (processedLine.trim().startsWith('- ') || processedLine.trim().startsWith('* ')) {
return <li key={i} dangerouslySetInnerHTML={{ __html: processedLine.trim().substring(2) }} style={{ marginBottom: '4px', fontSize: '0.9rem' }} />;
}
// Handle headers - make them smaller
if (processedLine.startsWith('### ')) {
return <Typography key={i} variant="subtitle2" fontWeight={700} sx={{ mt: 1.5, mb: 0.5, color: '#1e293b' }}>{processedLine.substring(4)}</Typography>;
}
if (processedLine.startsWith('## ')) {
return <Typography key={i} variant="subtitle1" fontWeight={700} sx={{ mt: 1.5, mb: 0.5, color: '#0f172a' }}>{processedLine.substring(3)}</Typography>;
}
// Paragraphs - compact spacing
return processedLine.trim() ? <p key={i} dangerouslySetInnerHTML={{ __html: processedLine }} style={{ margin: '4px 0', fontSize: '0.9rem' }} /> : null;
});
}, []);
return (
<GlassyCard sx={glassyCardSx}>
<Stack spacing={3}>
<Stack direction="row" justifyContent="space-between" alignItems="center" flexWrap="wrap" gap={2}>
<Stack direction="row" alignItems="center" spacing={2} sx={{ flex: 1 }}>
<Typography variant="h6" sx={{ display: "flex", alignItems: "center", gap: 1, color: "#0f172a", fontWeight: 700 }}>
<InsightsIcon />
Research Summary
</Typography>
{/* Research Metadata - Moved alongside title */}
<Stack direction="row" spacing={1.5} flexWrap="wrap">
{research.searchQueries && research.searchQueries.length > 0 && (
<Chip
icon={<SearchIcon sx={{ fontSize: "1rem !important" }} />}
label={`${research.searchQueries.length} search${research.searchQueries.length > 1 ? "es" : ""}`}
size="small"
sx={{
background: alpha("#667eea", 0.1),
color: "#667eea",
fontWeight: 600,
border: "1px solid rgba(102, 126, 234, 0.2)",
}}
/>
)}
{research.searchType && (
<Chip
label={`${research.searchType.charAt(0).toUpperCase() + research.searchType.slice(1)} search`}
size="small"
sx={{
background: alpha("#10b981", 0.1),
color: "#059669",
fontWeight: 600,
border: "1px solid rgba(16, 185, 129, 0.2)",
}}
/>
)}
{research.sourceCount !== undefined && (
<Chip
label={`${research.sourceCount} source${research.sourceCount !== 1 ? "s" : ""}`}
size="small"
sx={{
background: alpha("#6366f1", 0.1),
color: "#4f46e5",
fontWeight: 600,
border: "1px solid rgba(99, 102, 241, 0.2)",
}}
/>
)}
{research.cost !== undefined && (
<Chip
icon={<AttachMoneyIcon sx={{ fontSize: "0.875rem !important" }} />}
label={`$${research.cost.toFixed(3)}`}
size="small"
sx={{
background: alpha("#f59e0b", 0.1),
color: "#d97706",
fontWeight: 600,
border: "1px solid rgba(245, 158, 11, 0.2)",
}}
/>
)}
</Stack>
</Stack>
<PrimaryButton
onClick={onGenerateScript}
disabled={!canGenerateScript}
startIcon={<EditNoteIcon />}
tooltip={!canGenerateScript ? "Complete research to generate script" : "Generate AI-powered script from research"}
>
Generate Script
</PrimaryButton>
</Stack>
<Box sx={{ width: "100%" }}>
{/* Main Summary */}
{research.summary && (
<Paper
elevation={0}
sx={{
p: 2.5,
mb: 3,
background: "#f8fafc",
border: "1px solid rgba(0,0,0,0.06)",
borderRadius: 2,
}}
>
<Typography variant="subtitle2" sx={{ mb: 1.5, color: "#64748b", fontWeight: 700, fontSize: "0.75rem", textTransform: "uppercase", letterSpacing: "0.05em", display: "flex", alignItems: "center", gap: 1 }}>
<AutoAwesomeIcon fontSize="small" sx={{ color: "#667eea", fontSize: "1rem" }} />
Executive Summary
</Typography>
<Box sx={{
lineHeight: 1.6,
fontSize: "0.9rem",
color: "#334155",
"& p": { m: 0, mb: 1 },
"& ul": { m: 0, mb: 1, pl: 2.5 },
"& li": { mb: 0.5 },
"& strong": { color: "#0f172a", fontWeight: 600 }
}}>
{renderMarkdown(research.summary)}
</Box>
</Paper>
)}
{/* Deep Insights */}
{(research.keyInsights && research.keyInsights.length > 0) ? (
<Box sx={{ mb: 4 }}>
<Typography variant="h6" sx={{ mb: 2, color: "#0f172a", fontWeight: 700, display: "flex", alignItems: "center", gap: 1 }}>
<ArticleIcon sx={{ color: "#667eea" }} />
Deep Insights
</Typography>
<Stack spacing={2.5}>
{research.keyInsights.map((insight: ResearchInsight, idx: number) => (
<Paper
key={idx}
elevation={0}
sx={{
p: 2.5,
background: "#ffffff",
border: "1px solid rgba(0,0,0,0.06)",
boxShadow: "0 2px 12px rgba(0,0,0,0.03)",
borderRadius: 2,
}}
>
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" sx={{ mb: 1.5 }}>
<Typography variant="subtitle1" sx={{ color: "#0f172a", fontWeight: 700 }}>
{insight.title}
</Typography>
{insight.source_indices && insight.source_indices.length > 0 && (
<Stack direction="row" spacing={0.5}>
{insight.source_indices.map(sIdx => {
const sourceIdx = sIdx - 1;
const fact = research.factCards[sourceIdx];
const sourceUrl = fact?.url;
const hasUrl = !!sourceUrl;
const hue = (sIdx * 47 + 220) % 360;
const gradientFrom = `hsl(${hue}, 70%, 55%)`;
const gradientTo = `hsl(${(hue + 30) % 360}, 80%, 65%)`;
return (
<Tooltip
key={sIdx}
title={hasUrl ? (
<Box sx={{ maxWidth: 300, wordBreak: "break-all" }}>
<Typography variant="caption" sx={{ color: "#fff", fontWeight: 600 }}>Source {sIdx}</Typography>
<br />
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.8)", fontSize: "0.65rem" }}>{sourceUrl}</Typography>
</Box>
) : `Source ${sIdx}`}
arrow
placement="top"
>
<Chip
label={hasUrl ? `S${sIdx}` : `S${sIdx}`}
size="small"
onClick={hasUrl ? () => window.open(sourceUrl, "_blank", "noopener,noreferrer") : undefined}
sx={{
height: 24,
minWidth: 36,
fontSize: '0.7rem',
fontWeight: 800,
fontFamily: "'Inter', 'Roboto', monospace",
letterSpacing: "0.02em",
border: "none",
background: hasUrl
? `linear-gradient(135deg, ${gradientFrom}, ${gradientTo})`
: `linear-gradient(135deg, ${alpha(gradientFrom, 0.3)}, ${alpha(gradientTo, 0.3)})`,
color: hasUrl ? "#fff" : alpha("#fff", 0.7),
cursor: hasUrl ? "pointer" : "default",
borderRadius: "8px",
px: 0.5,
boxShadow: hasUrl
? `0 2px 8px ${alpha(gradientFrom, 0.35)}, inset 0 1px 0 ${alpha("#fff", 0.2)}`
: "none",
transition: "all 0.2s ease",
"&:hover": hasUrl ? {
background: `linear-gradient(135deg, ${gradientTo}, ${gradientFrom})`,
boxShadow: `0 4px 14px ${alpha(gradientFrom, 0.5)}, inset 0 1px 0 ${alpha("#fff", 0.3)}`,
transform: "translateY(-1px)",
} : {},
}}
/>
</Tooltip>
);
})}
</Stack>
)}
</Stack>
<Box sx={{
color: "#475569",
lineHeight: 1.7,
fontSize: "0.9rem",
"& p": { m: 0, mb: 1.5 },
"& ul": { m: 0, mb: 1.5, pl: 2 }
}}>
{renderMarkdown(insight.content)}
</Box>
</Paper>
))}
</Stack>
</Box>
) : (
/* Fallback if keyInsights is missing but we have summary paragraphs */
research.summary && research.summary.length > 500 && !research.keyInsights && (
<Box sx={{ mb: 4 }}>
<Typography variant="h6" sx={{ mb: 2, color: "#0f172a", fontWeight: 700, display: "flex", alignItems: "center", gap: 1 }}>
<ArticleIcon sx={{ color: "#667eea" }} />
Additional Insights
</Typography>
<Paper
elevation={0}
sx={{
p: 2.5,
background: "#ffffff",
border: "1px solid rgba(0,0,0,0.06)",
boxShadow: "0 2px 12px rgba(0,0,0,0.03)",
borderRadius: 2,
}}
>
<Box sx={{
color: "#475569",
lineHeight: 1.7,
fontSize: "0.9rem",
}}>
{/* Render parts of summary that might contain insights if structured data is missing */}
{renderMarkdown(research.summary.split('\n\n').slice(1).join('\n\n'))}
</Box>
</Paper>
</Box>
)
)}
{/* Expert Quotes Section */}
{research.expertQuotes && research.expertQuotes.length > 0 && (
<Box sx={{ mt: 4, pt: 3, borderTop: "1px solid rgba(0,0,0,0.04)" }}>
<Typography variant="h6" sx={{ mb: 2, color: "#0f172a", fontWeight: 700, display: "flex", alignItems: "center", gap: 1 }}>
<FormatQuoteIcon sx={{ color: "#8b5cf6" }} />
Expert Quotes ({research.expertQuotes.length})
</Typography>
<Stack spacing={2}>
{research.expertQuotes.map((eq, idx) => (
<Paper
key={idx}
elevation={0}
sx={{
p: 2.5,
background: "linear-gradient(135deg, rgba(139, 92, 246, 0.04) 0%, rgba(99, 102, 241, 0.04) 100%)",
border: "1px solid rgba(139, 92, 246, 0.15)",
borderLeft: "4px solid #8b5cf6",
borderRadius: 2,
}}
>
<Stack direction="row" spacing={1.5} alignItems="flex-start">
<FormatQuoteIcon sx={{ color: "#8b5cf6", fontSize: "1.5rem", mt: -0.5, opacity: 0.7 }} />
<Box sx={{ flex: 1 }}>
<Typography variant="body2" sx={{ color: "#1e293b", fontStyle: "italic", lineHeight: 1.7, fontSize: "0.95rem" }}>
&ldquo;{eq.quote}&rdquo;
</Typography>
{eq.source_index !== undefined && (() => {
const fact = research.factCards[eq.source_index - 1];
const sourceUrl = fact?.url;
const hasUrl = !!sourceUrl;
const hue = (eq.source_index * 47 + 270) % 360;
const gradientFrom = `hsl(${hue}, 70%, 55%)`;
const gradientTo = `hsl(${(hue + 30) % 360}, 80%, 65%)`;
return (
<Box sx={{ mt: 1 }}>
<Tooltip title={hasUrl ? (
<Box sx={{ maxWidth: 300, wordBreak: "break-all" }}>
<Typography variant="caption" sx={{ color: "#fff", fontWeight: 600 }}>Source {eq.source_index}</Typography>
<br />
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.8)", fontSize: "0.65rem" }}>{sourceUrl}</Typography>
</Box>
) : `Source ${eq.source_index}`} arrow placement="top">
<Chip
label={hasUrl ? `Source ${eq.source_index}` : `Source ${eq.source_index}`}
size="small"
onClick={hasUrl ? () => window.open(sourceUrl, "_blank", "noopener,noreferrer") : undefined}
sx={{
height: 24,
fontSize: "0.7rem",
fontWeight: 800,
fontFamily: "'Inter', 'Roboto', monospace",
border: "none",
background: hasUrl
? `linear-gradient(135deg, ${gradientFrom}, ${gradientTo})`
: `linear-gradient(135deg, ${alpha(gradientFrom, 0.3)}, ${alpha(gradientTo, 0.3)})`,
color: hasUrl ? "#fff" : alpha("#fff", 0.7),
cursor: hasUrl ? "pointer" : "default",
borderRadius: "8px",
px: 1,
boxShadow: hasUrl
? `0 2px 8px ${alpha(gradientFrom, 0.35)}, inset 0 1px 0 ${alpha("#fff", 0.2)}`
: "none",
transition: "all 0.2s ease",
"&:hover": hasUrl ? {
background: `linear-gradient(135deg, ${gradientTo}, ${gradientFrom})`,
boxShadow: `0 4px 14px ${alpha(gradientFrom, 0.5)}, inset 0 1px 0 ${alpha("#fff", 0.3)}`,
transform: "translateY(-1px)",
} : {},
}}
/>
</Tooltip>
</Box>
);
})()}
</Box>
</Stack>
</Paper>
))}
</Stack>
</Box>
)}
{/* Search Queries Used */}
{research.searchQueries && research.searchQueries.length > 0 && (
<Box sx={{ mt: 4, pt: 3, borderTop: "1px solid rgba(0,0,0,0.04)" }}>
<Typography variant="subtitle2" sx={{ mb: 1.5, color: "#64748b", fontWeight: 700, fontSize: "0.7rem", textTransform: "uppercase", letterSpacing: "0.05em" }}>
Search Queries Used
</Typography>
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
{research.searchQueries.map((query, idx) => (
<Chip
key={idx}
label={query}
size="small"
variant="outlined"
sx={{
borderColor: "rgba(102, 126, 234, 0.15)",
color: "#94a3b8",
background: alpha("#f8fafc", 0.3),
fontSize: "0.7rem",
borderRadius: 1,
}}
/>
))}
</Stack>
</Box>
)}
</Box>
{research.factCards.length > 0 && (
<>
<Divider sx={{ borderColor: "rgba(0,0,0,0.08)" }} />
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 1.5, flexWrap: "wrap", gap: 1 }}>
<Typography variant="subtitle2" sx={{ color: "#0f172a", fontWeight: 600 }}>
Research Sources & Facts ({research.factCards.length})
</Typography>
<Typography variant="caption" sx={{ color: "#64748b", fontSize: "0.75rem" }}>
Click to expand Hover to see source
</Typography>
</Stack>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "repeat(2, 1fr)", md: "repeat(3, 1fr)", lg: "repeat(4, 1fr)" },
gap: 1.5,
width: "100%",
overflow: "hidden",
}}
>
{research.factCards.map((fact) => (
<FactCard key={fact.id} fact={fact} />
))}
</Box>
</>
)}
{/* Listener CTA Section */}
{research.listenerCta && research.listenerCta.length > 0 && (
<>
<Divider sx={{ borderColor: "rgba(0,0,0,0.08)" }} />
<Box>
<Typography variant="h6" sx={{ mb: 2, color: "#0f172a", fontWeight: 700, display: "flex", alignItems: "center", gap: 1 }}>
<CampaignIcon sx={{ color: "#f59e0b" }} />
Listener Call-to-Action Ideas ({research.listenerCta.length})
</Typography>
<Stack spacing={1.5}>
{research.listenerCta.map((cta, idx) => (
<Paper
key={idx}
elevation={0}
sx={{
p: 2,
background: "linear-gradient(135deg, rgba(245, 158, 11, 0.05) 0%, rgba(251, 191, 36, 0.05) 100%)",
border: "1px solid rgba(245, 158, 11, 0.15)",
borderRadius: 2,
display: "flex",
alignItems: "flex-start",
gap: 1.5,
}}
>
<Chip
label={`#${idx + 1}`}
size="small"
sx={{
bgcolor: alpha("#f59e0b", 0.15),
color: "#b45309",
fontWeight: 700,
fontSize: "0.7rem",
height: 24,
minWidth: 32,
}}
/>
<Typography variant="body2" sx={{ color: "#475569", lineHeight: 1.6, flex: 1, pt: 0.2 }}>
{cta}
</Typography>
</Paper>
))}
</Stack>
</Box>
</>
)}
{/* Mapped Angles Section */}
{research.mappedAngles && research.mappedAngles.length > 0 && (
<>
<Divider sx={{ borderColor: "rgba(0,0,0,0.08)" }} />
<Box>
<Typography variant="h6" sx={{ mb: 2, color: "#0f172a", fontWeight: 700, display: "flex", alignItems: "center", gap: 1 }}>
<ExploreIcon sx={{ color: "#06b6d4" }} />
Content Angles ({research.mappedAngles.length})
</Typography>
<Stack spacing={2}>
{research.mappedAngles.map((angle, idx) => (
<Paper
key={idx}
elevation={0}
sx={{
p: 2.5,
background: "#ffffff",
border: "1px solid rgba(0,0,0,0.06)",
borderLeft: "4px solid #06b6d4",
boxShadow: "0 2px 12px rgba(0,0,0,0.03)",
borderRadius: 2,
}}
>
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" sx={{ mb: 1 }}>
<Typography variant="subtitle1" sx={{ color: "#0f172a", fontWeight: 700 }}>
{angle.title}
</Typography>
{angle.mappedFactIds && angle.mappedFactIds.length > 0 && (
<Stack direction="row" spacing={0.5}>
{angle.mappedFactIds.slice(0, 4).map((fid: string) => (
<Chip
key={fid}
label={fid.replace("fact_", "F")}
size="small"
variant="outlined"
sx={{
height: 18,
fontSize: "0.6rem",
fontWeight: 700,
borderColor: alpha("#06b6d4", 0.3),
color: "#06b6d4",
bgcolor: alpha("#06b6d4", 0.05),
}}
/>
))}
{angle.mappedFactIds.length > 4 && (
<Chip
label={`+${angle.mappedFactIds.length - 4}`}
size="small"
sx={{ height: 18, fontSize: "0.6rem", color: "#64748b" }}
/>
)}
</Stack>
)}
</Stack>
<Typography variant="body2" sx={{ color: "#475569", lineHeight: 1.7, fontSize: "0.9rem" }}>
{angle.why}
</Typography>
</Paper>
))}
</Stack>
</Box>
</>
)}
</Stack>
</GlassyCard>
);
};

View File

@@ -1,811 +0,0 @@
import React, { useState, useEffect } from "react";
import { Stack, Box, Typography, Divider, Chip, alpha, CircularProgress, LinearProgress, IconButton, Tooltip } from "@mui/material";
import {
EditNote as EditNoteIcon,
CheckCircle as CheckCircleIcon,
RadioButtonUnchecked as RadioButtonUncheckedIcon,
VolumeUp as VolumeUpIcon,
PlayArrow as PlayArrowIcon,
Image as ImageIcon,
Delete as DeleteIcon,
} from "@mui/icons-material";
import { Scene, Line, Knobs } from "../types";
import { GlassyCard, glassyCardSx, PrimaryButton } from "../ui";
import { LineEditor } from "./LineEditor";
import { ImageRegenerateModal, ImageGenerationSettings } from "./ImageRegenerateModal";
import { AudioRegenerateModal, AudioGenerationSettings } from "./AudioRegenerateModal";
import { podcastApi } from "../../../services/podcastApi";
import { aiApiClient } from "../../../api/client";
import { getCachedMedia, setCachedMedia } from "../../../utils/mediaCache";
interface SceneEditorProps {
scene: Scene;
onUpdateScene: (s: Scene) => void;
onApprove: (id: string) => Promise<void>;
onDelete: (sceneId: string) => void;
knobs: Knobs;
approvingSceneId?: string | null;
generatingAudioId?: string | null;
onAudioGenerationStart?: (sceneId: string) => void;
onAudioGenerated?: (sceneId: string, audioUrl: string) => void;
idea?: string; // Podcast idea for image generation context
avatarUrl?: string | null; // Base avatar URL for consistent scene image generation
totalScenes?: number; // Total number of scenes in the script
}
export const SceneEditor: React.FC<SceneEditorProps> = ({
scene,
onUpdateScene,
onApprove,
onDelete,
knobs,
approvingSceneId,
generatingAudioId,
onAudioGenerationStart,
onAudioGenerated,
idea,
avatarUrl,
totalScenes,
}) => {
const [localGenerating, setLocalGenerating] = useState(false);
const [generatingImage, setGeneratingImage] = useState(false);
const [imageGenerationStatus, setImageGenerationStatus] = useState<string>("");
const [imageGenerationProgress, setImageGenerationProgress] = useState<number>(0);
const [audioBlobUrl, setAudioBlobUrl] = useState<string | null>(null);
const [imageBlobUrl, setImageBlobUrl] = useState<string | null>(null);
const [imageLoading, setImageLoading] = useState(false);
const [showRegenerateModal, setShowRegenerateModal] = useState(false);
const [showAudioModal, setShowAudioModal] = useState(false);
const [audioSettings, setAudioSettings] = useState<AudioGenerationSettings>({
voiceId: "Wise_Woman",
speed: 1.0,
volume: 1.0,
pitch: 0.0,
emotion: scene.emotion || "neutral",
englishNormalization: true,
sampleRate: 24000,
bitrate: 64000,
channel: "1",
format: "mp3",
languageBoost: "auto",
});
// Load audio as blob when audioUrl is available
useEffect(() => {
if (!scene.audioUrl) {
// Clean up blob URL if audioUrl is removed
setAudioBlobUrl((currentBlobUrl) => {
if (currentBlobUrl) {
URL.revokeObjectURL(currentBlobUrl);
}
return null;
});
return;
}
let isMounted = true;
const currentAudioUrl = scene.audioUrl; // Capture current value
const loadAudioBlob = async () => {
try {
// Normalize path
let audioPath = currentAudioUrl.startsWith('/') ? currentAudioUrl : `/${currentAudioUrl}`;
// Convert /api/story/audio/ to /api/podcast/audio/ if needed
if (audioPath.includes('/api/story/audio/')) {
const filename = audioPath.split('/api/story/audio/').pop() || '';
audioPath = `/api/podcast/audio/${filename}`;
}
// Ensure it's a podcast audio endpoint
if (!audioPath.includes('/api/podcast/audio/')) {
const filename = audioPath.split('/').pop() || currentAudioUrl;
audioPath = `/api/podcast/audio/${filename}`;
}
// Remove query parameters if present
audioPath = audioPath.split('?')[0];
const response = await aiApiClient.get(audioPath, {
responseType: 'blob',
});
if (!isMounted) {
// Component unmounted or audioUrl changed, don't set blob URL
return;
}
// Double-check that audioUrl hasn't changed
if (scene.audioUrl !== currentAudioUrl) {
return;
}
const blob = response.data;
const blobUrl = URL.createObjectURL(blob);
setAudioBlobUrl((prevBlobUrl) => {
// Clean up previous blob URL if exists
if (prevBlobUrl && prevBlobUrl !== blobUrl) {
URL.revokeObjectURL(prevBlobUrl);
}
return blobUrl;
});
} catch (error) {
console.error(`Failed to load audio blob for scene ${scene.id}:`, error);
// Don't set blob URL on error - will show error state
}
};
loadAudioBlob();
// Cleanup: only mark as unmounted, don't revoke blob URL here
// The blob URL will be cleaned up when audioUrl changes (new effect) or component unmounts
return () => {
isMounted = false;
};
}, [scene.audioUrl, scene.id]);
// Load image as blob when imageUrl is available
useEffect(() => {
if (!scene.imageUrl) {
// Clean up blob URL if imageUrl is removed
setImageBlobUrl((currentBlobUrl) => {
if (currentBlobUrl && currentBlobUrl.startsWith('blob:')) {
URL.revokeObjectURL(currentBlobUrl);
}
return null;
});
return;
}
// Check cache first with scene context
const cachedUrl = getCachedMedia(scene.imageUrl, scene.id);
if (cachedUrl) {
console.log('[SceneEditor] Using cached image:', scene.imageUrl, `(scene: ${scene.id})`);
setImageBlobUrl(cachedUrl);
setImageLoading(false);
return;
}
let isMounted = true;
const currentImageUrl = scene.imageUrl; // Capture current value
const loadImageBlob = async () => {
try {
setImageLoading(true);
// Check cache again in case it was loaded while we were waiting
const cachedUrl = getCachedMedia(currentImageUrl, scene.id);
if (cachedUrl) {
if (isMounted) {
setImageBlobUrl(cachedUrl);
setImageLoading(false);
}
return;
}
console.log('[SceneEditor] Loading image blob for:', currentImageUrl);
// Normalize path
let imagePath = currentImageUrl.startsWith('/') ? currentImageUrl : `/${currentImageUrl}`;
// Convert /api/story/images/ to /api/podcast/images/ if needed
if (imagePath.includes('/api/story/images/')) {
const filename = imagePath.split('/api/story/images/').pop() || '';
imagePath = `/api/podcast/images/${filename}`;
}
// Ensure it's a podcast image endpoint
if (!imagePath.includes('/api/podcast/images/')) {
const filename = imagePath.split('/').pop() || currentImageUrl;
imagePath = `/api/podcast/images/${filename}`;
}
// Remove query parameters if present
imagePath = imagePath.split('?')[0];
const response = await aiApiClient.get(imagePath, {
responseType: 'blob',
});
if (!isMounted) {
return;
}
// Double-check that imageUrl hasn't changed
if (scene.imageUrl !== currentImageUrl) {
return;
}
const blob = response.data;
const blobUrl = URL.createObjectURL(blob);
// Cache the blob URL with scene context
setCachedMedia(currentImageUrl, blobUrl, 'image', blob.size, scene.id);
setImageBlobUrl((prevBlobUrl) => {
// Clean up previous blob URL if exists
if (prevBlobUrl && prevBlobUrl !== blobUrl && prevBlobUrl.startsWith('blob:')) {
URL.revokeObjectURL(prevBlobUrl);
}
return blobUrl;
});
console.log('[SceneEditor] Image blob loaded and cached successfully:', currentImageUrl);
} catch (error) {
console.error('[SceneEditor] Failed to load image blob:', error);
if (isMounted) {
// Try adding query token as fallback
try {
const token = localStorage.getItem('clerk_dashboard_token') || '';
if (token) {
const urlWithToken = `${currentImageUrl}?token=${encodeURIComponent(token)}`;
setImageBlobUrl(urlWithToken);
setCachedMedia(currentImageUrl, urlWithToken, 'image', undefined, scene.id);
}
} catch (fallbackError) {
console.error('[SceneEditor] Fallback image loading failed:', fallbackError);
}
}
} finally {
if (isMounted) {
setImageLoading(false);
}
}
};
loadImageBlob();
return () => {
isMounted = false;
// Don't cleanup blob URL here - let the cache handle it
};
}, [scene.imageUrl]);
const updateLine = (updatedLine: Line) => {
const updated = { ...scene, lines: scene.lines.map((l) => (l.id === updatedLine.id ? updatedLine : l)) };
onUpdateScene(updated);
};
const approving = approvingSceneId === scene.id;
const generating = generatingAudioId === scene.id || localGenerating;
const hasAudio = Boolean(scene.audioUrl && audioBlobUrl);
const hasImage = Boolean(scene.imageUrl);
const handleApproveAndGenerate = async (settings?: AudioGenerationSettings) => {
const wasAlreadyApproved = scene.approved;
const sceneId = scene.id;
try {
// Set generating state
setLocalGenerating(true);
if (onAudioGenerationStart) {
onAudioGenerationStart(sceneId);
}
// If scene is not approved yet, approve it first
// This will update the parent script state
if (!scene.approved) {
await onApprove(sceneId);
// The parent's approveScene already updated the script state
// We need to wait for React to propagate the updated scene prop
// For now, we'll update it locally too to ensure UI updates immediately
onUpdateScene({ ...scene, approved: true });
}
// Use the current scene (which should now be approved)
// If scene prop hasn't updated yet, use the local update we just made
const currentScene = { ...scene, approved: true };
// Generate audio
const effectiveSettings = settings || audioSettings;
const result = await podcastApi.renderSceneAudio({
scene: currentScene,
voiceId: effectiveSettings.voiceId || "Wise_Woman",
emotion: effectiveSettings.emotion || scene.emotion || knobs.voice_emotion || "neutral",
speed: effectiveSettings.speed ?? knobs.voice_speed ?? 1.0,
volume: effectiveSettings.volume ?? 1.0,
pitch: effectiveSettings.pitch ?? 0.0,
englishNormalization: effectiveSettings.englishNormalization ?? true,
sampleRate: effectiveSettings.sampleRate,
bitrate: effectiveSettings.bitrate,
channel: effectiveSettings.channel,
format: effectiveSettings.format,
languageBoost: effectiveSettings.languageBoost,
});
// Update scene with audio URL and ensure approved state
// This will sync with parent script state
const updatedScene = { ...currentScene, audioUrl: result.audioUrl, approved: true };
onUpdateScene(updatedScene);
if (onAudioGenerated) {
onAudioGenerated(sceneId, result.audioUrl);
}
} catch (error) {
console.error("Failed to approve and generate audio:", error);
// On error, revert approval only if we just approved it in this call
if (!wasAlreadyApproved) {
onUpdateScene({ ...scene, approved: false, audioUrl: undefined });
}
throw error;
} finally {
setLocalGenerating(false);
}
};
const handleGenerateImage = async (settings?: ImageGenerationSettings) => {
const sceneId = scene.id;
const startTime = Date.now();
let progressInterval: NodeJS.Timeout | null = null;
try {
setGeneratingImage(true);
setShowRegenerateModal(false);
setImageGenerationStatus("Submitting image generation request...");
setImageGenerationProgress(10);
// Build scene content from lines for context
const sceneContent = scene.lines.map((line) => line.text).join(" ");
// Log avatar URL for debugging
console.log("[SceneEditor] Generating image with avatarUrl:", avatarUrl);
console.log("[SceneEditor] Custom settings:", settings);
// Simulate progress updates during API call
progressInterval = setInterval(() => {
const elapsed = Date.now() - startTime;
const seconds = Math.floor(elapsed / 1000);
// Update status based on elapsed time
if (seconds < 5) {
setImageGenerationStatus("Submitting request to AI service...");
setImageGenerationProgress(15);
} else if (seconds < 15) {
setImageGenerationStatus("AI is generating your image...");
setImageGenerationProgress(30);
} else if (seconds < 30) {
setImageGenerationStatus("Creating character-consistent scene image...");
setImageGenerationProgress(50);
} else if (seconds < 60) {
setImageGenerationStatus("Rendering image details...");
setImageGenerationProgress(70);
} else {
setImageGenerationStatus(`Processing... (${seconds}s elapsed)`);
setImageGenerationProgress(Math.min(90, 50 + (seconds - 30) / 2));
}
}, 1000);
const result = await podcastApi.generateSceneImage({
sceneId: scene.id,
sceneTitle: scene.title,
sceneContent: sceneContent,
baseAvatarUrl: avatarUrl || undefined, // Pass base avatar URL for character consistency
idea: idea,
width: 1024,
height: 1024,
// Pass custom settings if provided
customPrompt: settings?.prompt,
style: settings?.style,
renderingSpeed: settings?.renderingSpeed,
aspectRatio: settings?.aspectRatio,
});
if (progressInterval) {
clearInterval(progressInterval);
progressInterval = null;
}
setImageGenerationStatus("Finalizing image...");
setImageGenerationProgress(95);
// Update scene with image URL
const updatedScene = { ...scene, imageUrl: result.image_url };
onUpdateScene(updatedScene);
const elapsed = Math.floor((Date.now() - startTime) / 1000);
setImageGenerationStatus(`Image generated successfully in ${elapsed}s`);
setImageGenerationProgress(100);
// Clear status after a moment
setTimeout(() => {
setImageGenerationStatus("");
setImageGenerationProgress(0);
}, 2000);
} catch (error: any) {
// Clear interval on error
if (progressInterval) {
clearInterval(progressInterval);
progressInterval = null;
}
console.error("Failed to generate image:", error);
// Extract error message from response if available
const errorMessage = error?.response?.data?.detail?.message
|| error?.response?.data?.detail?.error
|| error?.response?.data?.detail
|| error?.message
|| "Failed to generate image. Please try again.";
console.error("Error details:", {
status: error?.response?.status,
statusText: error?.response?.statusText,
data: error?.response?.data,
message: errorMessage,
});
setImageGenerationStatus(`Error: ${errorMessage}`);
setImageGenerationProgress(0);
// Show user-friendly error message
alert(`Image generation failed: ${errorMessage}`);
throw error;
} finally {
// Ensure interval is cleared
if (progressInterval) {
clearInterval(progressInterval);
}
setGeneratingImage(false);
}
};
const handleRegenerateClick = () => {
setShowRegenerateModal(true);
};
const handleAudioRegenerateClick = () => {
if (hasAudio) {
setShowAudioModal(true);
} else {
handleApproveAndGenerate(audioSettings);
}
};
const handleAudioRegenerate = (settings: AudioGenerationSettings) => {
setAudioSettings(settings);
setShowAudioModal(false);
handleApproveAndGenerate(settings);
};
return (
<GlassyCard sx={glassyCardSx}>
<Stack spacing={2.5}>
<Stack direction="row" justifyContent="space-between" alignItems="flex-start">
<Box sx={{ flex: 1 }}>
<Typography
variant="h6"
sx={{
display: "flex",
alignItems: "center",
gap: 1.5,
mb: 1,
color: "#0f172a",
fontWeight: 600,
fontSize: "1.25rem",
letterSpacing: "-0.01em",
}}
>
<EditNoteIcon fontSize="small" sx={{ color: "#667eea", fontSize: "1.5rem" }} />
{scene.title}
</Typography>
<Stack direction="row" spacing={1.5} alignItems="center" flexWrap="wrap">
<Chip
icon={scene.approved ? <CheckCircleIcon /> : <RadioButtonUncheckedIcon />}
label={scene.approved ? "Approved" : "Pending Approval"}
size="small"
color={scene.approved ? "success" : "warning"}
sx={{
background: scene.approved
? "linear-gradient(135deg, rgba(16, 185, 129, 0.12) 0%, rgba(5, 150, 105, 0.12) 100%)"
: "linear-gradient(135deg, rgba(245, 158, 11, 0.12) 0%, rgba(217, 119, 6, 0.12) 100%)",
color: scene.approved ? "#059669" : "#d97706",
border: scene.approved
? "1px solid rgba(16, 185, 129, 0.25)"
: "1px solid rgba(245, 158, 11, 0.25)",
fontWeight: 600,
fontSize: "0.75rem",
height: 26,
boxShadow: "0 1px 2px rgba(0, 0, 0, 0.05)",
}}
/>
<Typography variant="caption" sx={{ color: "#64748b", fontWeight: 500, fontSize: "0.8125rem" }}>
Duration: {scene.duration}s
</Typography>
</Stack>
</Box>
<Stack direction="row" spacing={1.5} flexWrap="wrap" useFlexGap>
<PrimaryButton
onClick={handleAudioRegenerateClick}
disabled={approving || generating}
loading={approving || generating}
startIcon={
hasAudio && !generating ? (
<VolumeUpIcon />
) : generating ? (
<CircularProgress size={16} sx={{ color: "white" }} />
) : (
<PlayArrowIcon />
)
}
tooltip={
hasAudio && !generating
? "Regenerate audio for this scene with custom settings"
: generating
? "Generating audio..."
: scene.approved
? "Generate audio for this scene"
: "Approve scene and generate audio"
}
sx={{
minWidth: 200,
}}
>
{hasAudio && !generating
? "Regenerate Audio"
: generating
? "Generating Audio..."
: scene.approved
? "Generate Audio"
: "Approve & Generate Audio"}
</PrimaryButton>
<PrimaryButton
onClick={hasImage ? handleRegenerateClick : () => handleGenerateImage()}
disabled={generatingImage}
loading={generatingImage}
startIcon={
hasImage && !generatingImage ? (
<ImageIcon />
) : generatingImage ? (
<CircularProgress size={16} sx={{ color: "white" }} />
) : (
<ImageIcon />
)
}
tooltip={
hasImage
? "Regenerate image for this scene"
: generatingImage
? "Generating image..."
: "Generate image for video (optional)"
}
sx={{
minWidth: 180,
background: hasImage
? "linear-gradient(135deg, #10b981 0%, #059669 100%)"
: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
"&:hover": {
background: hasImage
? "linear-gradient(135deg, #059669 0%, #047857 100%)"
: "linear-gradient(135deg, #764ba2 0%, #667eea 100%)",
},
}}
>
{hasImage && !generatingImage
? "Regenerate Image"
: generatingImage
? "Generating Image..."
: "Generate Image"}
</PrimaryButton>
<Tooltip title={totalScenes && totalScenes <= 1 ? "Cannot delete the last scene" : "Delete this scene"}>
<IconButton
onClick={() => onDelete(scene.id)}
disabled={approving || generating || (totalScenes !== undefined && totalScenes <= 1)}
sx={{
color: "#ef4444",
backgroundColor: "rgba(239, 68, 68, 0.1)",
border: "1px solid rgba(239, 68, 68, 0.2)",
borderRadius: 2,
padding: 1.5,
"&:hover": {
backgroundColor: "rgba(239, 68, 68, 0.15)",
borderColor: "rgba(239, 68, 68, 0.3)",
},
"&:disabled": {
backgroundColor: "rgba(156, 163, 175, 0.1)",
borderColor: "rgba(156, 163, 175, 0.2)",
color: "#9ca3af",
},
}}
>
<DeleteIcon sx={{ fontSize: "1.25rem" }} />
</IconButton>
</Tooltip>
</Stack>
</Stack>
<Divider sx={{ borderColor: "rgba(15, 23, 42, 0.08)", borderWidth: 1 }} />
<Stack spacing={2}>
{scene.lines.map((line) => (
<LineEditor key={line.id} line={line} onChange={updateLine} />
))}
</Stack>
{scene.audioUrl && (
<>
<Divider sx={{ borderColor: "rgba(15, 23, 42, 0.08)", borderWidth: 1, mt: 1 }} />
<Box
sx={{
p: 2,
background: hasAudio
? "linear-gradient(135deg, rgba(16, 185, 129, 0.08) 0%, rgba(5, 150, 105, 0.08) 100%)"
: "linear-gradient(135deg, rgba(245, 158, 11, 0.08) 0%, rgba(217, 119, 6, 0.08) 100%)",
borderRadius: 2,
border: hasAudio
? "1px solid rgba(16, 185, 129, 0.2)"
: "1px solid rgba(245, 158, 11, 0.2)",
}}
>
<Stack direction="row" alignItems="center" spacing={1.5} sx={{ mb: 1.5 }}>
<VolumeUpIcon sx={{ color: hasAudio ? "#059669" : "#d97706", fontSize: "1.25rem" }} />
<Typography variant="subtitle2" sx={{ color: hasAudio ? "#059669" : "#d97706", fontWeight: 600 }}>
{hasAudio ? "Audio Generated" : "Loading Audio..."}
</Typography>
</Stack>
{hasAudio && audioBlobUrl ? (
<audio controls style={{ width: "100%", borderRadius: 8 }}>
<source src={audioBlobUrl} type="audio/mpeg" />
Your browser does not support the audio element.
</audio>
) : (
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "center", py: 2 }}>
<CircularProgress size={24} sx={{ color: "#d97706" }} />
</Box>
)}
</Box>
</>
)}
{/* Image Generation Progress - Show when generating */}
{generatingImage && (
<>
<Divider sx={{ borderColor: "rgba(15, 23, 42, 0.08)", borderWidth: 1, mt: 1 }} />
<Box
sx={{
p: 2,
background: "linear-gradient(135deg, rgba(102, 126, 234, 0.08) 0%, rgba(118, 75, 162, 0.08) 100%)",
borderRadius: 2,
border: "1px solid rgba(102, 126, 234, 0.2)",
}}
>
<Stack direction="row" alignItems="center" spacing={1.5} sx={{ mb: 1.5 }}>
<ImageIcon sx={{ color: "#667eea", fontSize: "1.25rem" }} />
<Typography variant="subtitle2" sx={{ color: "#667eea", fontWeight: 600 }}>
Generating Image...
</Typography>
</Stack>
{/* Progress Bar */}
<Box sx={{ mb: 1.5 }}>
<LinearProgress
variant="determinate"
value={imageGenerationProgress}
sx={{
height: 8,
borderRadius: 4,
backgroundColor: alpha("#667eea", 0.1),
"& .MuiLinearProgress-bar": {
backgroundColor: "#667eea",
borderRadius: 4,
}
}}
/>
<Typography variant="caption" sx={{ color: "#667eea", mt: 0.5, display: "block", textAlign: "right" }}>
{imageGenerationProgress}%
</Typography>
</Box>
{/* Status Message */}
{imageGenerationStatus && (
<Typography variant="body2" sx={{ color: "#667eea", fontSize: "0.875rem", lineHeight: 1.6, mb: 1 }}>
{imageGenerationStatus}
</Typography>
)}
{/* Spinner */}
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "center", mt: 1 }}>
<CircularProgress size={32} sx={{ color: "#667eea" }} />
</Box>
</Box>
</>
)}
{/* Generated Image Display - Show when image exists and not generating */}
{scene.imageUrl && !generatingImage && (
<>
<Divider sx={{ borderColor: "rgba(15, 23, 42, 0.08)", borderWidth: 1, mt: 1 }} />
<Box
sx={{
p: 2,
background: imageBlobUrl && !imageLoading
? "linear-gradient(135deg, rgba(102, 126, 234, 0.08) 0%, rgba(118, 75, 162, 0.08) 100%)"
: "linear-gradient(135deg, rgba(245, 158, 11, 0.08) 0%, rgba(217, 119, 6, 0.08) 100%)",
borderRadius: 2,
border: imageBlobUrl && !imageLoading
? "1px solid rgba(102, 126, 234, 0.2)"
: "1px solid rgba(245, 158, 11, 0.2)",
}}
>
<Stack direction="row" alignItems="center" spacing={1.5} sx={{ mb: 1.5 }}>
<ImageIcon sx={{ color: imageBlobUrl && !imageLoading ? "#667eea" : "#d97706", fontSize: "1.25rem" }} />
<Typography variant="subtitle2" sx={{ color: imageBlobUrl && !imageLoading ? "#667eea" : "#d97706", fontWeight: 600 }}>
{imageBlobUrl && !imageLoading ? "Image Generated" : "Loading Image..."}
</Typography>
</Stack>
{imageBlobUrl && !imageLoading ? (
<Box
sx={{
width: "100%",
borderRadius: 2,
overflow: "hidden",
border: "1px solid rgba(102,126,234,0.2)",
background: alpha("#667eea", 0.05),
}}
>
<Box
component="img"
src={imageBlobUrl}
alt={scene.title}
sx={{
width: "100%",
height: "auto",
display: "block",
maxHeight: 400,
objectFit: "cover",
}}
onError={(e) => {
console.error('[SceneEditor] Image failed to load:', {
src: e.currentTarget.src,
imageUrl: scene.imageUrl,
imageBlobUrl,
});
}}
onLoad={() => {
console.log('[SceneEditor] Image loaded successfully');
}}
/>
</Box>
) : (
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "center", py: 2 }}>
<CircularProgress size={24} sx={{ color: "#d97706" }} />
</Box>
)}
</Box>
</>
)}
</Stack>
{/* Image Regeneration Modal */}
<ImageRegenerateModal
open={showRegenerateModal}
onClose={() => setShowRegenerateModal(false)}
onRegenerate={handleGenerateImage}
initialPrompt={(() => {
const promptParts = [
`Scene: ${scene.title}`,
"Professional podcast recording studio",
"Modern microphone setup",
"Clean background, professional lighting",
"16:9 aspect ratio, video-optimized composition"
];
if (idea) {
promptParts.push(`Topic: ${idea.substring(0, 60)}`);
}
return promptParts.join(", ");
})()}
initialStyle="Realistic"
initialRenderingSpeed="Quality"
initialAspectRatio="16:9"
isGenerating={generatingImage}
/>
<AudioRegenerateModal
open={showAudioModal}
onClose={() => setShowAudioModal(false)}
onRegenerate={handleAudioRegenerate}
initialSettings={audioSettings}
isGenerating={generating}
/>
</GlassyCard>
);
};

View File

@@ -1,818 +0,0 @@
import React, { useEffect, useState, useCallback } from "react";
import { Box, Stack, Typography, Alert, Paper, LinearProgress, CircularProgress, alpha, Collapse, IconButton, Divider } from "@mui/material";
import { EditNote as EditNoteIcon, CheckCircle as CheckCircleIcon, PlayArrow as PlayArrowIcon, ArrowBack as ArrowBackIcon, Info as InfoIcon, ExpandMore as ExpandMoreIcon, ExpandLess as ExpandLessIcon, Download as DownloadIcon, Refresh as RefreshIcon } from "@mui/icons-material";
import { Script, Knobs, Scene } from "../types";
import { BlogResearchResponse } from "../../../services/blogWriterApi";
import { podcastApi } from "../../../services/podcastApi";
import { GlassyCard, PrimaryButton, SecondaryButton } from "../ui";
import { SceneEditor } from "./SceneEditor";
import { InlineAudioPlayer } from "../InlineAudioPlayer";
import { aiApiClient } from "../../../api/client";
interface ScriptEditorProps {
projectId: string;
idea: string;
research: any; // Research type
rawResearch: BlogResearchResponse | null;
knobs: Knobs;
speakers: number;
durationMinutes: number;
script: Script | null;
onScriptChange: (script: Script) => void;
onBackToResearch: () => void;
onProceedToRendering: (script: Script) => void;
onError: (message: string) => void;
avatarUrl?: string | null; // Base avatar URL for consistent scene image generation
analysis?: any;
outline?: any;
}
export const ScriptEditor: React.FC<ScriptEditorProps> = ({
projectId,
idea,
research,
rawResearch,
knobs,
speakers,
durationMinutes,
script: initialScript,
onScriptChange,
onBackToResearch,
onProceedToRendering,
onError,
avatarUrl,
analysis,
outline,
}) => {
const [script, setScript] = useState<Script | null>(initialScript);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [approvingSceneId, setApprovingSceneId] = useState<string | null>(null);
const [generatingAudioId, setGeneratingAudioId] = useState<string | null>(null);
const [showScriptFormatInfo, setShowScriptFormatInfo] = useState(true);
const [combiningAudio, setCombiningAudio] = useState(false);
const [combinedAudioResult, setCombinedAudioResult] = useState<{
url: string;
filename: string;
duration: number;
sceneCount: number;
} | null>(null);
// Defer upward script updates to avoid setState during render warnings
const emitScriptChange = useCallback(
(next: Script) => Promise.resolve().then(() => onScriptChange(next)),
[onScriptChange]
);
// Sync with parent state
useEffect(() => {
if (initialScript) {
setScript(initialScript);
}
}, [initialScript]);
useEffect(() => {
// If script already exists, don't regenerate
if (script) {
return;
}
// Only generate if we have research data
if (!rawResearch) {
return;
}
let mounted = true;
setLoading(true);
setError(null);
podcastApi
.generateScript({
projectId,
idea,
research: rawResearch,
knobs,
speakers,
durationMinutes,
analysis,
outline,
})
.then((res) => {
if (mounted) {
setScript(res);
emitScriptChange(res);
setError(null);
}
})
.catch((err) => {
const message = err instanceof Error ? err.message : "Failed to generate script";
setError(message);
onError(message);
})
.finally(() => mounted && setLoading(false));
return () => {
mounted = false;
};
}, [projectId, rawResearch, idea, knobs, speakers, durationMinutes, analysis, outline, emitScriptChange, onError, script]);
const updateScene = (updated: Scene) => {
// Use functional update to ensure we're working with latest state
setScript((currentScript) => {
if (!currentScript) return currentScript;
const updatedScript = {
...currentScript,
scenes: currentScript.scenes.map((s) => (s.id === updated.id ? { ...s, ...updated } : s))
};
emitScriptChange(updatedScript);
return updatedScript;
});
};
const approveScene = async (sceneId: string) => {
try {
setApprovingSceneId(sceneId);
await podcastApi.approveScene({ projectId, sceneId });
// Use functional update to ensure we're working with latest state
setScript((currentScript) => {
if (!currentScript) return currentScript;
const updatedScript = {
...currentScript,
scenes: currentScript.scenes.map((s) => (s.id === sceneId ? { ...s, approved: true } : s)),
};
emitScriptChange(updatedScript);
return updatedScript;
});
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to approve scene";
setError(message);
onError(message);
throw err;
} finally {
setApprovingSceneId((current) => (current === sceneId ? null : current));
}
};
const deleteScene = useCallback((sceneId: string) => {
if (!script) return;
// Prevent deleting if it's the last scene
if (script.scenes.length <= 1) {
onError("Cannot delete the last scene. At least one scene is required.");
return;
}
// Add confirmation dialog
const sceneToDelete = script.scenes.find(s => s.id === sceneId);
if (!sceneToDelete) return;
const confirmDelete = window.confirm(
`Are you sure you want to delete "${sceneToDelete.title}"? This action cannot be undone.`
);
if (!confirmDelete) return;
// Remove the scene from the script
const updatedScenes = script.scenes.filter(s => s.id !== sceneId);
const updatedScript = { ...script, scenes: updatedScenes };
emitScriptChange(updatedScript);
setScript(updatedScript);
// Show success message
console.log(`[ScriptEditor] Scene "${sceneToDelete.title}" deleted successfully`);
}, [script, emitScriptChange, onError]);
const allApproved = script && script.scenes.every((s) => s.approved);
const approvedCount = script ? script.scenes.filter((s) => s.approved).length : 0;
const totalScenes = script ? script.scenes.length : 0;
// Check if all scenes have both audio and images (required for video rendering)
const allScenesHaveAudioAndImages = script && script.scenes.every((s) => s.audioUrl && s.imageUrl);
const scenesWithAudio = script ? script.scenes.filter((s) => s.audioUrl).length : 0;
const allScenesHaveAudio = script && script.scenes.every((s) => s.audioUrl);
const combineAudio = useCallback(async () => {
if (!script || !projectId) return;
try {
setCombiningAudio(true);
const sceneIds: string[] = [];
const sceneAudioUrls: string[] = [];
script.scenes.forEach((scene) => {
if (scene.audioUrl) {
// Ensure we're using the correct URL format (not blob URLs)
const audioUrl = scene.audioUrl.startsWith('blob:') ? '' : scene.audioUrl;
if (audioUrl) {
sceneIds.push(scene.id);
sceneAudioUrls.push(audioUrl);
}
}
});
if (sceneIds.length === 0) {
onError("No audio files found to combine.");
return;
}
const result = await podcastApi.combineAudio({
projectId,
sceneIds,
sceneAudioUrls,
});
// Store combined audio result for preview
setCombinedAudioResult({
url: result.combined_audio_url,
filename: result.combined_audio_filename,
duration: result.total_duration,
sceneCount: result.scene_count,
});
// Download the combined audio as blob (for authenticated endpoints)
try {
// Normalize path
let audioPath = result.combined_audio_url.startsWith('/')
? result.combined_audio_url
: `/${result.combined_audio_url}`;
// Ensure it's a podcast audio endpoint
if (!audioPath.includes('/api/podcast/audio/')) {
const filename = audioPath.split('/').pop() || result.combined_audio_filename;
audioPath = `/api/podcast/audio/${filename}`;
}
// Remove query parameters if present
audioPath = audioPath.split('?')[0];
// Fetch as blob using authenticated client
const response = await aiApiClient.get(audioPath, {
responseType: 'blob',
});
// Create blob URL and download
const blob = response.data;
const blobUrl = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = blobUrl;
link.download = result.combined_audio_filename || `podcast-episode-${projectId.slice(-8)}.mp3`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// Clean up blob URL after a delay
setTimeout(() => {
URL.revokeObjectURL(blobUrl);
}, 100);
} catch (downloadError) {
console.error('Failed to download combined audio:', downloadError);
onError('Failed to download audio file. You can try downloading again from the preview.');
}
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to combine audio";
onError(`Failed to combine audio: ${message}`);
} finally {
setCombiningAudio(false);
}
}, [script, projectId, onError]);
return (
<Box sx={{ mt: 4 }}>
<Stack direction="row" spacing={2} alignItems="center" sx={{ mb: 4 }}>
<SecondaryButton onClick={onBackToResearch} startIcon={<ArrowBackIcon />}>
Back to Research
</SecondaryButton>
<Box sx={{ flex: 1 }}>
<Typography
variant="h4"
sx={{
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
fontWeight: 700,
letterSpacing: "-0.02em",
display: "flex",
alignItems: "center",
gap: 1.5,
fontSize: { xs: "1.75rem", md: "2rem" },
}}
>
<EditNoteIcon sx={{ fontSize: "2rem" }} />
Script Editor
</Typography>
<Typography variant="body2" sx={{ color: "#64748b", mt: 0.5, ml: 5.5 }}>
Review and refine your podcast script before rendering
</Typography>
</Box>
</Stack>
{loading && (
<Alert
severity="info"
icon={<CircularProgress size={20} />}
sx={{
mb: 3,
background: "linear-gradient(135deg, rgba(99, 102, 241, 0.08) 0%, rgba(139, 92, 246, 0.08) 100%)",
border: "1px solid rgba(99, 102, 241, 0.2)",
borderRadius: 2,
boxShadow: "0 1px 2px rgba(99, 102, 241, 0.05)",
"& .MuiAlert-icon": {
color: "#6366f1",
},
}}
>
<Typography variant="body2" sx={{ color: "#0f172a", fontWeight: 500 }}>
Generating script with AI... This may take a moment.
</Typography>
</Alert>
)}
{error && (
<Alert
severity="error"
sx={{
mb: 3,
background: "linear-gradient(135deg, rgba(239, 68, 68, 0.08) 0%, rgba(220, 38, 38, 0.08) 100%)",
border: "1px solid rgba(239, 68, 68, 0.2)",
borderRadius: 2,
boxShadow: "0 1px 2px rgba(239, 68, 68, 0.05)",
"& .MuiAlert-icon": {
color: "#ef4444",
},
}}
>
<Typography variant="body2" sx={{ color: "#0f172a", fontWeight: 500 }}>
{error}
</Typography>
</Alert>
)}
{script && (
<Stack spacing={3}>
{/* Script Format Explanation Panel */}
<Paper
sx={{
p: 3,
background: "linear-gradient(135deg, rgba(99, 102, 241, 0.05) 0%, rgba(139, 92, 246, 0.05) 100%)",
border: "1px solid rgba(99, 102, 241, 0.15)",
borderRadius: 2,
boxShadow: "0 2px 8px rgba(99, 102, 241, 0.08)",
}}
>
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: showScriptFormatInfo ? 2 : 0 }}>
<Stack direction="row" alignItems="center" spacing={1.5}>
<Box
sx={{
width: 40,
height: 40,
borderRadius: "50%",
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
display: "flex",
alignItems: "center",
justifyContent: "center",
boxShadow: "0 2px 8px rgba(102, 126, 234, 0.3)",
}}
>
<InfoIcon sx={{ color: "#ffffff", fontSize: "1.5rem" }} />
</Box>
<Box>
<Typography variant="h6" sx={{ color: "#0f172a", fontWeight: 600, fontSize: "1.1rem" }}>
Why This Script Format?
</Typography>
<Typography variant="body2" sx={{ color: "#64748b", mt: 0.25 }}>
Understanding how your script creates natural, human-like audio
</Typography>
</Box>
</Stack>
<IconButton
onClick={() => setShowScriptFormatInfo(!showScriptFormatInfo)}
sx={{
color: "#6366f1",
"&:hover": {
background: "rgba(99, 102, 241, 0.1)",
},
}}
>
{showScriptFormatInfo ? <ExpandLessIcon /> : <ExpandMoreIcon />}
</IconButton>
</Stack>
<Collapse in={showScriptFormatInfo}>
<Stack spacing={2.5}>
<Box>
<Typography variant="body2" sx={{ color: "#0f172a", lineHeight: 1.8, mb: 2 }}>
Our AI script generator creates scripts specifically optimized for <strong style={{ fontWeight: 600 }}>high-quality text-to-speech</strong>.
The format you see here is designed to produce audio that sounds natural and human-like, not robotic.
</Typography>
</Box>
<Stack spacing={2}>
<Box sx={{ display: "flex", gap: 2 }}>
<Box
sx={{
minWidth: 32,
height: 32,
borderRadius: "8px",
background: "linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(139, 92, 246, 0.1) 100%)",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}
>
<Typography variant="body2" sx={{ color: "#6366f1", fontWeight: 700 }}>
1
</Typography>
</Box>
<Box>
<Typography variant="subtitle2" sx={{ color: "#0f172a", fontWeight: 600, mb: 0.5 }}>
Natural Pauses & Rhythm
</Typography>
<Typography variant="body2" sx={{ color: "#475569", lineHeight: 1.7 }}>
The script includes strategic pauses between lines and when speakers change. This creates natural breathing patterns
and conversation flow, just like real human speech. Without these pauses, the audio would sound rushed and robotic.
</Typography>
</Box>
</Box>
<Box sx={{ display: "flex", gap: 2 }}>
<Box
sx={{
minWidth: 32,
height: 32,
borderRadius: "8px",
background: "linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(139, 92, 246, 0.1) 100%)",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}
>
<Typography variant="body2" sx={{ color: "#6366f1", fontWeight: 700 }}>
2
</Typography>
</Box>
<Box>
<Typography variant="subtitle2" sx={{ color: "#0f172a", fontWeight: 600, mb: 0.5 }}>
Emphasis Markers
</Typography>
<Typography variant="body2" sx={{ color: "#475569", lineHeight: 1.7 }}>
Lines marked with emphasis help highlight important points, statistics, or key insights. The AI voice will naturally
stress these parts, making your podcast more engaging and easier to followjust like a real host would emphasize important information.
</Typography>
</Box>
</Box>
<Box sx={{ display: "flex", gap: 2 }}>
<Box
sx={{
minWidth: 32,
height: 32,
borderRadius: "8px",
background: "linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(139, 92, 246, 0.1) 100%)",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}
>
<Typography variant="body2" sx={{ color: "#6366f1", fontWeight: 700 }}>
3
</Typography>
</Box>
<Box>
<Typography variant="subtitle2" sx={{ color: "#0f172a", fontWeight: 600, mb: 0.5 }}>
Short, Conversational Sentences
</Typography>
<Typography variant="body2" sx={{ color: "#475569", lineHeight: 1.7 }}>
The script uses shorter sentences (15-20 words) written in a conversational style. This matches how people actually
speak, making the audio sound more natural. Long, complex sentences would sound awkward when spoken aloud.
</Typography>
</Box>
</Box>
<Box sx={{ display: "flex", gap: 2 }}>
<Box
sx={{
minWidth: 32,
height: 32,
borderRadius: "8px",
background: "linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(139, 92, 246, 0.1) 100%)",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}
>
<Typography variant="body2" sx={{ color: "#6366f1", fontWeight: 700 }}>
4
</Typography>
</Box>
<Box>
<Typography variant="subtitle2" sx={{ color: "#0f172a", fontWeight: 600, mb: 0.5 }}>
Scene-Specific Emotions
</Typography>
<Typography variant="body2" sx={{ color: "#475569", lineHeight: 1.7 }}>
Each scene has an emotional tone (excited, serious, curious, etc.) that guides the AI voice's delivery. This creates
variety and keeps listeners engaged, just like a real podcast host would vary their tone based on the topic.
</Typography>
</Box>
</Box>
<Box sx={{ display: "flex", gap: 2 }}>
<Box
sx={{
minWidth: 32,
height: 32,
borderRadius: "8px",
background: "linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(139, 92, 246, 0.1) 100%)",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}
>
<Typography variant="body2" sx={{ color: "#6366f1", fontWeight: 700 }}>
5
</Typography>
</Box>
<Box>
<Typography variant="subtitle2" sx={{ color: "#0f172a", fontWeight: 600, mb: 0.5 }}>
Optimized for Podcast Narration
</Typography>
<Typography variant="body2" sx={{ color: "#475569", lineHeight: 1.7 }}>
The script is optimized with slightly slower pacing and natural pronunciation settings specifically for podcast narration.
This ensures clarity and makes the content easy to understand, even when listeners are multitasking.
</Typography>
</Box>
</Box>
</Stack>
<Alert
severity="info"
sx={{
mt: 1,
background: "rgba(99, 102, 241, 0.06)",
border: "1px solid rgba(99, 102, 241, 0.15)",
"& .MuiAlert-icon": {
color: "#6366f1",
},
}}
>
<Typography variant="body2" sx={{ color: "#0f172a", lineHeight: 1.7 }}>
<strong style={{ fontWeight: 600 }}>Tip:</strong> You can edit any line or scene to match your preferences.
The format will be preserved when rendering, ensuring your audio still sounds natural and professional.
</Typography>
</Alert>
</Stack>
</Collapse>
</Paper>
<Alert
severity="info"
sx={{
background: "linear-gradient(135deg, rgba(99, 102, 241, 0.08) 0%, rgba(139, 92, 246, 0.08) 100%)",
border: "1px solid rgba(99, 102, 241, 0.2)",
borderRadius: 2,
boxShadow: "0 1px 2px rgba(99, 102, 241, 0.05)",
"& .MuiAlert-icon": {
color: "#6366f1",
},
}}
>
<Typography variant="body2" sx={{ color: "#0f172a", fontWeight: 500, lineHeight: 1.6 }}>
<strong style={{ fontWeight: 600 }}>Approval Required:</strong> Each scene must be approved before rendering. Review and edit lines as needed, then approve each scene.
</Typography>
</Alert>
<Stack spacing={2}>
{script.scenes.map((scene, idx) => (
<GlassyCard
key={scene.id}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: idx * 0.1 }}
>
<SceneEditor
scene={scene}
onUpdateScene={updateScene}
onApprove={approveScene}
onDelete={deleteScene}
knobs={knobs}
approvingSceneId={approvingSceneId}
generatingAudioId={generatingAudioId}
totalScenes={script.scenes.length}
onAudioGenerationStart={(sceneId) => {
setGeneratingAudioId(sceneId);
}}
onAudioGenerated={async (sceneId, audioUrl) => {
setGeneratingAudioId(null);
// Use functional update to ensure we're working with latest state
// Ensure scene is marked as approved and has audioUrl
setScript((currentScript) => {
if (!currentScript) return currentScript;
const updatedScenes = currentScript.scenes.map((s) =>
s.id === sceneId ? { ...s, audioUrl, approved: true } : s
);
const updatedScript = { ...currentScript, scenes: updatedScenes };
emitScriptChange(updatedScript);
return updatedScript;
});
}}
idea={idea}
avatarUrl={avatarUrl}
/>
</GlassyCard>
))}
</Stack>
<Paper
sx={{
p: 3.5,
background: allApproved
? "linear-gradient(135deg, rgba(16, 185, 129, 0.05) 0%, rgba(5, 150, 105, 0.05) 100%)"
: "#ffffff",
border: allApproved
? "2px solid rgba(16, 185, 129, 0.25)"
: "1px solid rgba(15, 23, 42, 0.08)",
borderRadius: 3,
boxShadow: allApproved
? "0 4px 6px rgba(16, 185, 129, 0.08), 0 8px 24px rgba(16, 185, 129, 0.06)"
: "0 1px 3px rgba(15, 23, 42, 0.06), 0 4px 12px rgba(15, 23, 42, 0.04)",
transition: "all 0.3s cubic-bezier(0.4, 0, 0.2, 1)",
}}
>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Box>
<Typography variant="subtitle1" sx={{ mb: 1, display: "flex", alignItems: "center", gap: 1.5, color: "#0f172a", fontWeight: 600, fontSize: "1.1rem" }}>
<CheckCircleIcon fontSize="small" sx={{ color: allApproved ? "#10b981" : "#94a3b8", fontSize: "1.25rem" }} />
Approval Status
</Typography>
<Typography variant="body2" sx={{ color: "#64748b", fontWeight: 400, lineHeight: 1.6 }}>
{approvedCount} of {totalScenes} scenes approved
{allScenesHaveAudioAndImages && " • All scenes ready for video rendering"}
{!allScenesHaveAudioAndImages && allApproved && " • Generate images for all scenes to enable video rendering"}
{!allApproved && " — Approve all scenes first"}
</Typography>
{!allScenesHaveAudioAndImages && (
<LinearProgress
variant="determinate"
value={
allScenesHaveAudioAndImages
? 100
: script
? (script.scenes.filter((s) => s.audioUrl && s.imageUrl).length / totalScenes) * 100
: 0
}
sx={{ mt: 1, height: 6, borderRadius: 3 }}
/>
)}
</Box>
<PrimaryButton
onClick={() => script && onProceedToRendering(script)}
disabled={!allScenesHaveAudioAndImages}
startIcon={<PlayArrowIcon />}
tooltip={
!allScenesHaveAudioAndImages
? "Generate audio and images for all scenes to proceed to video rendering"
: "Proceed to video rendering (all scenes have audio and images)"
}
>
Proceed to Rendering
</PrimaryButton>
</Stack>
</Paper>
{/* Download Audio-Only Podcast Section */}
{allScenesHaveAudio && (
<Paper
sx={{
p: 3,
background: "linear-gradient(135deg, rgba(102, 126, 234, 0.05) 0%, rgba(118, 75, 162, 0.05) 100%)",
border: "1px solid rgba(102, 126, 234, 0.15)",
borderRadius: 2,
}}
>
<Stack spacing={3}>
<Typography variant="h6" sx={{ color: "#0f172a", fontWeight: 600 }}>
Download Audio-Only Podcast
</Typography>
{!combinedAudioResult ? (
<>
<PrimaryButton
onClick={combineAudio}
disabled={combiningAudio}
loading={combiningAudio}
startIcon={<DownloadIcon />}
tooltip="Combine all scene audio files into a single podcast episode"
sx={{
minWidth: 280,
fontSize: "1rem",
py: 1.5,
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
"&:hover": {
background: "linear-gradient(135deg, #764ba2 0%, #667eea 100%)",
},
}}
>
{combiningAudio ? "Combining Audio..." : "Download Audio-Only Podcast"}
</PrimaryButton>
<Typography variant="caption" sx={{ color: "#64748b", fontStyle: "italic" }}>
This will combine all {scenesWithAudio} scene audio files into one complete podcast episode.
</Typography>
</>
) : (
<Stack spacing={2}>
{/* Success Alert */}
<Alert
severity="success"
sx={{
background: alpha("#10b981", 0.1),
border: "1px solid rgba(16,185,129,0.3)",
"& .MuiAlert-icon": { color: "#10b981" },
}}
>
<Typography variant="body2" sx={{ color: "#059669", fontWeight: 500 }}>
Combined audio generated successfully! ({combinedAudioResult.sceneCount} scenes,{" "}
{Math.round(combinedAudioResult.duration)}s)
</Typography>
</Alert>
{/* Combined Audio Preview */}
<InlineAudioPlayer audioUrl={combinedAudioResult.url} title="Complete Podcast Episode" />
{/* Action Buttons */}
<Stack direction="row" spacing={2}>
<SecondaryButton
onClick={async () => {
try {
// Normalize path
let audioPath = combinedAudioResult.url.startsWith('/')
? combinedAudioResult.url
: `/${combinedAudioResult.url}`;
// Ensure it's a podcast audio endpoint
if (!audioPath.includes('/api/podcast/audio/')) {
const filename = audioPath.split('/').pop() || combinedAudioResult.filename;
audioPath = `/api/podcast/audio/${filename}`;
}
// Remove query parameters if present
audioPath = audioPath.split('?')[0];
// Fetch as blob using authenticated client
const response = await aiApiClient.get(audioPath, {
responseType: 'blob',
});
// Create blob URL and download
const blob = response.data;
const blobUrl = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = blobUrl;
link.download = combinedAudioResult.filename || `podcast-episode-${projectId.slice(-8)}.mp3`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// Clean up blob URL after a delay
setTimeout(() => {
URL.revokeObjectURL(blobUrl);
}, 100);
} catch (error) {
console.error('Failed to download audio:', error);
onError('Failed to download audio file. Please try again.');
}
}}
startIcon={<DownloadIcon />}
tooltip="Download the combined audio file again"
>
Download Again
</SecondaryButton>
<SecondaryButton
onClick={() => {
setCombinedAudioResult(null);
combineAudio();
}}
disabled={combiningAudio}
loading={combiningAudio}
startIcon={<RefreshIcon />}
tooltip="Regenerate combined audio (useful if scenes were updated)"
>
Regenerate
</SecondaryButton>
</Stack>
</Stack>
)}
</Stack>
</Paper>
)}
</Stack>
)}
</Box>
);
};

View File

@@ -1,334 +0,0 @@
"""
Podcast Analysis Handlers
Analysis endpoint for podcast ideas.
"""
from fastapi import APIRouter, Depends, HTTPException
from typing import Dict, Any
import json
import uuid
from sqlalchemy.orm import Session
from services.database import get_db
from middleware.auth_middleware import get_current_user
from api.story_writer.utils.auth import require_authenticated_user
from services.llm_providers.main_text_generation import llm_text_gen
from services.llm_providers.main_image_generation import generate_image
from services.podcast_bible_service import PodcastBibleService
from utils.asset_tracker import save_asset_to_library
from loguru import logger
from ..constants import PODCAST_IMAGES_DIR
from ..models import (
PodcastAnalyzeRequest,
PodcastAnalyzeResponse,
PodcastEnhanceIdeaRequest,
PodcastEnhanceIdeaResponse
)
router = APIRouter()
@router.post("/idea/enhance", response_model=PodcastEnhanceIdeaResponse)
async def enhance_podcast_idea(
request: PodcastEnhanceIdeaRequest,
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""
Take raw keywords/topic and use AI to craft a presentable, detailed podcast idea.
Uses the user's Podcast Bible for hyper-personalization if available.
"""
user_id = require_authenticated_user(current_user)
# Serialize Bible context if provided or generate from onboarding
bible_context = ""
try:
bible_service = PodcastBibleService()
if request.bible:
from models.podcast_bible_models import PodcastBible
bible_data = PodcastBible(**request.bible)
bible_context = bible_service.serialize_bible(bible_data)
else:
# Generate from onboarding data directly
bible_obj = bible_service.generate_bible(user_id, "temp_enhance")
bible_context = bible_service.serialize_bible(bible_obj)
except Exception as exc:
logger.warning(f"[Podcast Enhance] Failed to parse or generate bible context: {exc}")
prompt = f"""
You are a creative podcast producer. Generate 3 distinct, compelling podcast episode concepts from the raw idea.
{f"USER PERSONALIZATION CONTEXT (Podcast Bible):\n{bible_context}\n" if bible_context else ""}
RAW IDEA/KEYWORDS: "{request.idea}"
TASK:
Generate 3 different enhanced versions, each with a unique angle:
1. Professional & Expert-led angle (focus on authority, insights, and expertise)
2. Storytelling & Human interest angle (focus on narratives, emotions, and personal connections)
3. Trendy & Contemporary angle (focus on current trends, modern perspectives, and relevance)
Each version should be 2-3 sentences, audience-focused, and align with host persona if provided.
Return JSON with:
- enhanced_ideas: array of 3 enhanced episode pitches (in order: Professional, Storytelling, Trendy)
- rationales: array of 3 rationales explaining the approach for each version
"""
try:
raw = llm_text_gen(
prompt=prompt,
user_id=user_id,
json_struct=None,
preferred_provider="huggingface",
flow_type="premium_tool",
)
# Normalize response
if isinstance(raw, str):
data = json.loads(raw)
else:
data = raw
# Extract enhanced ideas and rationales with fallbacks
enhanced_ideas = data.get("enhanced_ideas", [])
rationales = data.get("rationales", [])
# Ensure we have exactly 3 ideas, fallback to original if needed
if not isinstance(enhanced_ideas, list) or len(enhanced_ideas) != 3:
# Fallback: create 3 variations of the original idea
base_idea = request.idea
enhanced_ideas = [
f"Expert insights on {base_idea}: A deep dive into industry trends and best practices.",
f"The human side of {base_idea}: Personal stories and real-world experiences that resonate.",
f"Modern perspectives on {base_idea}: Current trends and forward-thinking approaches."
]
rationales = [
"Professional approach focusing on expertise and authority",
"Storytelling approach emphasizing human connection",
"Contemporary approach highlighting current relevance"
]
# Ensure rationales match the number of ideas
if not isinstance(rationales, list) or len(rationales) != 3:
rationales = [
"Professional angle with expert insights",
"Storytelling angle with human interest",
"Trendy angle with contemporary relevance"
]
return PodcastEnhanceIdeaResponse(
enhanced_ideas=enhanced_ideas[:3], # Ensure exactly 3
rationales=rationales[:3] # Ensure exactly 3
)
except Exception as exc:
logger.error(f"[Podcast Enhance] Failed for user {user_id}: {exc}")
# Fallback to basic variations of original idea
base_idea = request.idea
return PodcastEnhanceIdeaResponse(
enhanced_ideas=[
f"Expert insights on {base_idea}: A deep dive into industry trends and best practices.",
f"The human side of {base_idea}: Personal stories and real-world experiences that resonate.",
f"Modern perspectives on {base_idea}: Current trends and forward-thinking approaches."
],
rationales=[
"Professional approach focusing on expertise and authority",
"Storytelling approach emphasizing human connection",
"Contemporary approach highlighting current relevance"
]
)
@router.post("/analyze", response_model=PodcastAnalyzeResponse)
async def analyze_podcast_idea(
request: PodcastAnalyzeRequest,
current_user: Dict[str, Any] = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""
Analyze a podcast idea and return podcast-oriented outlines, keywords, and titles.
If no avatar_url is provided, it generates one automatically based on the host's look.
"""
user_id = require_authenticated_user(current_user)
# Serialize Bible context if provided or generate from onboarding
bible_context = ""
bible_obj = None
try:
bible_service = PodcastBibleService()
if request.bible:
from models.podcast_bible_models import PodcastBible
bible_data = PodcastBible(**request.bible)
bible_context = bible_service.serialize_bible(bible_data)
bible_obj = bible_data
else:
# Generate from onboarding data directly
bible_obj = bible_service.generate_bible(user_id, "temp_analyze")
bible_context = bible_service.serialize_bible(bible_obj)
bible_obj = bible_obj
except Exception as exc:
logger.warning(f"[Podcast Analyze] Failed to parse or generate bible context: {exc}")
# --- NEW: Generate Presenter Avatar if missing ---
final_avatar_url = request.avatar_url
final_avatar_prompt = None
if not final_avatar_url:
logger.info(f"[Podcast Analyze] No avatar_url provided, generating one for user {user_id}")
try:
# 1. PRE-FLIGHT VALIDATION: Check subscription limits for image generation
from services.subscription import PricingService
from services.subscription.preflight_validator import validate_image_generation_operations
pricing_service = PricingService(db)
validate_image_generation_operations(
pricing_service=pricing_service,
user_id=user_id,
num_images=1
)
# 2. Build avatar prompt from Bible host look or fallback
host_look = bible_obj.host.look if bible_obj and bible_obj.host.look else "A professional podcast host"
visual_style = bible_obj.visual_style.style_preset if bible_obj else "Realistic Photography"
final_avatar_prompt = f"Professional headshot of a podcast host, {host_look}, {visual_style} style, clean background, soft studio lighting, center-focused, high resolution, sharp focus, professional photography quality, 16:9 aspect ratio."
# 3. Generate the image
logger.info(f"[Podcast Analyze] Generating avatar with prompt: {final_avatar_prompt}")
image_result = generate_image(
prompt=final_avatar_prompt,
user_id=user_id,
width=1024,
height=1024
)
# 4. Save to disk and library
if image_result and image_result.image_bytes:
img_id = str(uuid.uuid4())[:8]
filename = f"presenter_podcast_{user_id}_{img_id}.png"
output_path = PODCAST_IMAGES_DIR / filename
PODCAST_IMAGES_DIR.mkdir(parents=True, exist_ok=True)
with open(output_path, "wb") as f:
f.write(image_result.image_bytes)
final_avatar_url = f"/api/podcast/images/avatars/{filename}"
# Save to asset library for reuse
save_asset_to_library(
db=db,
user_id=user_id,
asset_type="image",
file_url=final_avatar_url,
filename=filename,
title=f"Presenter Avatar - {request.idea[:40]}",
description=f"AI-generated podcast presenter for: {request.idea}",
provider=image_result.provider,
model=image_result.model,
cost=image_result.cost
)
logger.info(f"[Podcast Analyze] ✅ Generated and saved avatar to {final_avatar_url}")
except Exception as e:
logger.error(f"[Podcast Analyze] ❌ Failed to generate avatar: {e}")
# Non-fatal: continue analysis even if avatar generation fails
# --- END: Avatar Generation ---
# Incorporate user feedback if provided
feedback_context = ""
if request.feedback:
feedback_context = f"""
USER REGENERATION FEEDBACK:
The user was not satisfied with the previous analysis. They provided the following instructions for improvement:
"{request.feedback}"
Please prioritize this feedback and adjust the analysis accordingly.
"""
prompt = f"""
You are an expert podcast producer and research strategist. Given a podcast idea, craft concise podcast-ready assets
that sound like episode plans (not fiction stories).
{f"USER PERSONALIZATION CONTEXT (Podcast Bible):\n{bible_context}\n" if bible_context else ""}
{feedback_context}
Podcast Idea: "{request.idea}"
Duration: ~{request.duration} minutes
Speakers: {request.speakers} (host + optional guest)
TASK:
1. Define the target audience and content type aligned with the Bible's "Audience DNA" and "Brand DNA".
2. Identify 5 high-impact keywords.
3. Propose 2 episode outlines with factual segments.
4. Suggest 3 titles.
5. IMPORTANT: Generate 4-6 specific research queries for Exa. These queries MUST be highly targeted to the episode's topic, the host's expertise level, and the audience's interests as defined in the Bible.
* Do NOT use generic queries like "latest trends in X".
* DO use queries that look for case studies, specific data points, expert opinions, or contrasting viewpoints that would make for a deep, insightful podcast conversation.
Return JSON with:
- audience: short target audience description
- content_type: podcast style/format
- top_keywords: 5 podcast-relevant keywords/phrases
- suggested_outlines: 2 items, each with title (<=60 chars) and 4-6 short segments (bullet-friendly, factual)
- title_suggestions: 3 concise episode titles
- research_queries: array of {{"query": "string", "rationale": "string"}}
- exa_suggested_config: suggested Exa search options with:
- exa_search_type: "auto" | "neural" | "keyword"
- exa_category: one of ["research paper","news","company","github","tweet","personal site","pdf","financial report","linkedin profile"]
- exa_include_domains: up to 3 reputable domains
- exa_exclude_domains: up to 3 domains
- max_sources: 6-10
- include_statistics: boolean
- date_range: one of ["last_month","last_3_months","last_year","all_time"]
Requirements:
- Keep language factual, actionable, and suited for spoken audio.
- Avoid narrative fiction tone.
- Prefer 2024-2025 context.
"""
try:
raw = llm_text_gen(
prompt=prompt,
user_id=user_id,
json_struct=None,
preferred_provider="huggingface",
flow_type="premium_tool",
)
except HTTPException:
# Re-raise HTTPExceptions (e.g., 429 subscription limit) - preserve error details
raise
except Exception as exc:
logger.error(f"[Podcast Analyze] Analysis failed for user {user_id}: {exc}")
raise HTTPException(status_code=500, detail=f"Analysis failed: {exc}")
# Normalize response (accept dict or JSON string)
if isinstance(raw, str):
try:
data = json.loads(raw)
except json.JSONDecodeError:
raise HTTPException(status_code=500, detail="LLM returned non-JSON output")
elif isinstance(raw, dict):
data = raw
else:
raise HTTPException(status_code=500, detail="Unexpected LLM response format")
audience = data.get("audience") or "Growth-focused professionals"
content_type = data.get("content_type") or "Interview + insights"
top_keywords = data.get("top_keywords") or []
suggested_outlines = data.get("suggested_outlines") or []
title_suggestions = data.get("title_suggestions") or []
research_queries = data.get("research_queries") or []
exa_suggested_config = data.get("exa_suggested_config") or None
return PodcastAnalyzeResponse(
audience=audience,
content_type=content_type,
top_keywords=top_keywords,
suggested_outlines=suggested_outlines,
title_suggestions=title_suggestions,
research_queries=research_queries,
exa_suggested_config=exa_suggested_config,
bible=bible_obj.model_dump() if bible_obj else None,
avatar_url=final_avatar_url,
avatar_prompt=final_avatar_prompt,
)

View File

@@ -1,422 +0,0 @@
"""
Podcast API Models
All Pydantic request/response models for podcast endpoints.
"""
from pydantic import BaseModel, Field, model_validator
from typing import List, Optional, Dict, Any
from datetime import datetime
from enum import Enum
class PodcastProjectResponse(BaseModel):
"""Response model for podcast project."""
id: int
project_id: str
user_id: str
idea: str
duration: int
speakers: int
budget_cap: float
analysis: Optional[Dict[str, Any]] = None
queries: Optional[List[Dict[str, Any]]] = None
selected_queries: Optional[List[str]] = None
research: Optional[Dict[str, Any]] = None
raw_research: Optional[Dict[str, Any]] = None
estimate: Optional[Dict[str, Any]] = None
script_data: Optional[Dict[str, Any]] = None
bible: Optional[Dict[str, Any]] = None
render_jobs: Optional[List[Dict[str, Any]]] = None
knobs: Optional[Dict[str, Any]] = None
research_provider: Optional[str] = None
show_script_editor: bool = False
show_render_queue: bool = False
current_step: Optional[str] = None
status: str = "draft"
is_favorite: bool = False
final_video_url: Optional[str] = None
avatar_url: Optional[str] = None
avatar_prompt: Optional[str] = None
avatar_persona_id: Optional[str] = None
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class PodcastAnalyzeRequest(BaseModel):
"""Request model for podcast idea analysis."""
idea: str = Field(..., description="Podcast topic or idea")
duration: int = Field(default=10, description="Target duration in minutes")
speakers: int = Field(default=1, description="Number of speakers")
bible: Optional[Dict[str, Any]] = Field(None, description="Optional Podcast Bible for context")
avatar_url: Optional[str] = Field(None, description="Current avatar URL if selected")
feedback: Optional[str] = Field(None, description="User feedback for regeneration")
class PodcastAnalyzeResponse(BaseModel):
"""Response model for podcast idea analysis."""
audience: str
content_type: str
top_keywords: list[str]
suggested_outlines: list[Dict[str, Any]]
title_suggestions: list[str]
research_queries: Optional[List[Dict[str, str]]] = None
exa_suggested_config: Optional[Dict[str, Any]] = None
bible: Optional[Dict[str, Any]] = None
avatar_url: Optional[str] = None
avatar_prompt: Optional[str] = None
class PodcastEnhanceIdeaRequest(BaseModel):
"""Request model for enhancing a podcast idea with AI."""
idea: str = Field(..., description="The raw podcast idea or keywords")
bible: Optional[Dict[str, Any]] = Field(None, description="Optional Podcast Bible for context")
class PodcastEnhanceIdeaResponse(BaseModel):
"""Response model for enhanced podcast idea."""
enhanced_ideas: List[str] = Field(..., description="3 AI-enhanced topic choices")
rationales: List[str] = Field(..., description="Rationale for each enhanced idea")
class PodcastScriptRequest(BaseModel):
"""Request model for podcast script generation."""
idea: str = Field(..., description="Podcast idea or topic")
duration_minutes: int = Field(default=10, description="Target duration in minutes")
speakers: int = Field(default=1, description="Number of speakers")
research: Optional[Dict[str, Any]] = Field(None, description="Optional research payload to ground the script")
bible: Optional[Dict[str, Any]] = Field(None, description="Podcast Bible for hyper-personalization")
outline: Optional[Dict[str, Any]] = Field(None, description="The refined episode outline to follow")
analysis: Optional[Dict[str, Any]] = Field(None, description="The full analysis context (audience, keywords, etc.)")
class PodcastSceneLine(BaseModel):
speaker: str
text: str
emphasis: Optional[bool] = False
class PodcastScene(BaseModel):
id: str
title: str
duration: int
lines: list[PodcastSceneLine]
approved: bool = False
emotion: Optional[str] = None
imageUrl: Optional[str] = None # Generated image URL for video generation
class PodcastExaConfig(BaseModel):
"""Exa config for podcast research."""
exa_search_type: Optional[str] = Field(default="auto", description="auto | keyword | neural")
exa_category: Optional[str] = None
exa_include_domains: List[str] = []
exa_exclude_domains: List[str] = []
max_sources: int = 8
include_statistics: Optional[bool] = False
date_range: Optional[str] = Field(default=None, description="last_month | last_3_months | last_year | all_time")
@model_validator(mode="after")
def validate_domains(self):
if self.exa_include_domains and self.exa_exclude_domains:
# Exa API does not allow both include and exclude domains together with contents
# Prefer include_domains and drop exclude_domains
self.exa_exclude_domains = []
return self
class PodcastExaResearchRequest(BaseModel):
"""Request for podcast research using Exa directly (no blog writer)."""
topic: str
queries: List[str]
exa_config: Optional[PodcastExaConfig] = None
bible: Optional[Dict[str, Any]] = Field(None, description="Podcast Bible for hyper-personalization")
analysis: Optional[Dict[str, Any]] = Field(None, description="Podcast analysis context (audience, content type, etc.)")
class PodcastExaSource(BaseModel):
title: str = ""
url: str = ""
excerpt: str = ""
published_at: Optional[str] = None
highlights: Optional[List[str]] = None
summary: Optional[str] = None
source_type: Optional[str] = None
index: Optional[int] = None
image: Optional[str] = None
author: Optional[str] = None
class PodcastResearchInsight(BaseModel):
"""Deep insight extracted from research."""
title: str
content: str
source_indices: List[int] = []
class PodcastExaResearchResponse(BaseModel):
sources: List[PodcastExaSource]
search_queries: List[str] = []
summary: str = ""
key_insights: List[PodcastResearchInsight] = []
expert_quotes: List[Dict[str, Any]] = []
listener_cta: List[str] = []
mapped_angles: List[Dict[str, Any]] = []
cost: Optional[Dict[str, Any]] = None
search_type: Optional[str] = None
provider: str = "exa"
content: Optional[str] = None # Raw aggregated content (deprecated)
class PodcastScriptResponse(BaseModel):
scenes: list[PodcastScene]
class PodcastAudioRequest(BaseModel):
"""Generate TTS for a podcast scene."""
scene_id: str
scene_title: str
text: str
voice_id: Optional[str] = "Wise_Woman"
speed: Optional[float] = 1.0
volume: Optional[float] = 1.0
pitch: Optional[float] = 0.0
emotion: Optional[str] = "neutral"
english_normalization: Optional[bool] = False # Better number reading for statistics
sample_rate: Optional[int] = None
bitrate: Optional[int] = None
channel: Optional[str] = None
format: Optional[str] = None
language_boost: Optional[str] = None
enable_sync_mode: Optional[bool] = True
class PodcastAudioResponse(BaseModel):
scene_id: str
scene_title: str
audio_filename: str
audio_url: str
provider: str
model: str
voice_id: str
text_length: int
file_size: int
cost: float
class PodcastProjectListResponse(BaseModel):
"""Response model for project list."""
projects: List[PodcastProjectResponse]
total: int
limit: int
offset: int
class CreateProjectRequest(BaseModel):
"""Request model for creating a project."""
project_id: str = Field(..., description="Unique project ID")
idea: str = Field(..., description="Episode idea or URL")
duration: int = Field(..., description="Duration in minutes")
speakers: int = Field(default=1, description="Number of speakers")
budget_cap: float = Field(default=50.0, description="Budget cap in USD")
avatar_url: Optional[str] = Field(None, description="Optional presenter avatar URL")
class UpdateProjectRequest(BaseModel):
"""Request model for updating project state."""
analysis: Optional[Dict[str, Any]] = None
queries: Optional[List[Dict[str, Any]]] = None
selected_queries: Optional[List[str]] = None
research: Optional[Dict[str, Any]] = None
raw_research: Optional[Dict[str, Any]] = None
estimate: Optional[Dict[str, Any]] = None
script_data: Optional[Dict[str, Any]] = None
bible: Optional[Dict[str, Any]] = None
render_jobs: Optional[List[Dict[str, Any]]] = None
knobs: Optional[Dict[str, Any]] = None
research_provider: Optional[str] = None
show_script_editor: Optional[bool] = None
show_render_queue: Optional[bool] = None
current_step: Optional[str] = None
status: Optional[str] = None
final_video_url: Optional[str] = None
class PodcastCombineAudioRequest(BaseModel):
"""Request model for combining podcast audio files."""
project_id: str
scene_ids: List[str] = Field(..., description="List of scene IDs to combine")
scene_audio_urls: List[str] = Field(..., description="List of audio URLs for each scene")
class PodcastCombineAudioResponse(BaseModel):
"""Response model for combined podcast audio."""
combined_audio_url: str
combined_audio_filename: str
total_duration: float
file_size: int
scene_count: int
class PodcastImageRequest(BaseModel):
"""Request for generating an image for a podcast scene."""
scene_id: str
scene_title: str
scene_content: Optional[str] = None # Optional: scene lines text for context
idea: Optional[str] = None # Optional: podcast idea for context
base_avatar_url: Optional[str] = None # Base avatar image URL for scene variations
bible: Optional[Dict[str, Any]] = Field(None, description="Podcast Bible for hyper-personalization")
width: int = 1024
height: int = 1024
custom_prompt: Optional[str] = None # Custom prompt from user (overrides auto-generated prompt)
style: Optional[str] = None # "Auto", "Fiction", or "Realistic"
rendering_speed: Optional[str] = None # "Default", "Turbo", or "Quality"
aspect_ratio: Optional[str] = None # "1:1", "16:9", "9:16", "4:3", "3:4"
class PodcastImageResponse(BaseModel):
"""Response for podcast scene image generation."""
scene_id: str
scene_title: str
image_filename: str
image_url: str
width: int
height: int
provider: str
model: Optional[str] = None
cost: float
class PodcastVideoGenerationRequest(BaseModel):
"""Request model for podcast video generation."""
project_id: str = Field(..., description="Podcast project ID")
scene_id: str = Field(..., description="Scene ID")
scene_title: str = Field(..., description="Scene title")
audio_url: str = Field(..., description="URL to the generated audio file")
avatar_image_url: Optional[str] = Field(None, description="URL to scene image (required for video generation)")
bible: Optional[Dict[str, Any]] = Field(None, description="Podcast Bible for hyper-personalization")
resolution: str = Field("720p", description="Video resolution (480p or 720p)")
prompt: Optional[str] = Field(None, description="Optional animation prompt override")
seed: Optional[int] = Field(-1, description="Random seed; -1 for random")
mask_image_url: Optional[str] = Field(None, description="Optional mask image URL to specify animated region")
class PodcastVideoGenerationResponse(BaseModel):
"""Response model for podcast video generation."""
task_id: str
status: str
message: str
class PodcastCombineVideosRequest(BaseModel):
"""Request to combine scene videos into final podcast"""
project_id: str = Field(..., description="Project ID")
scene_video_urls: list[str] = Field(..., description="List of scene video URLs in order")
podcast_title: str = Field(default="Podcast", description="Title for the final podcast video")
class PodcastCombineVideosResponse(BaseModel):
"""Response from combine videos endpoint"""
task_id: str
status: str
message: str
class AudioDubbingQuality(str, Enum):
LOW = "low"
HIGH = "high"
@classmethod
def from_string(cls, value: str) -> "AudioDubbingQuality":
if value.lower() == "high":
return cls.HIGH
return cls.LOW
class PodcastAudioDubRequest(BaseModel):
"""Request model for audio dubbing."""
source_audio_url: str = Field(..., description="URL or path to source audio file")
source_language: Optional[str] = Field(None, description="Source language code (auto-detected if None)")
target_language: str = Field(..., description="Target language for dubbing")
quality: str = Field(default="low", description="Translation quality: low (DeepL) or high (WaveSpeed)")
voice_id: Optional[str] = Field(default="Wise_Woman", description="Voice ID for TTS")
speed: Optional[float] = Field(default=1.0, ge=0.5, le=2.0, description="Speech speed (0.5-2.0)")
emotion: Optional[str] = Field(default="happy", description="Emotion for TTS voice")
preserve_emotion: Optional[bool] = Field(default=True, description="Preserve emotional tone in translation")
use_voice_clone: Optional[bool] = Field(default=False, description="Use voice cloning to preserve original speaker's voice")
custom_voice_id: Optional[str] = Field(None, description="Custom name for the cloned voice")
voice_clone_accuracy: Optional[float] = Field(default=0.7, ge=0.1, le=1.0, description="Voice cloning accuracy (0.1-1.0)")
class PodcastAudioDubResponse(BaseModel):
"""Response model for audio dubbing task creation."""
task_id: str
status: str = "pending"
message: str = "Audio dubbing task created"
class PodcastAudioDubResult(BaseModel):
"""Response model for completed audio dubbing."""
dubbed_audio_url: str
dubbed_audio_filename: str
original_transcript: str
translated_transcript: str
source_language: str
target_language: str
voice_id: str
quality: str
duration_seconds: int
file_size: int
cost: float
task_id: str
status: str = "completed"
voice_clone_used: Optional[bool] = Field(default=False, description="Whether voice cloning was used")
cloned_voice_id: Optional[str] = Field(None, description="ID of the cloned voice if voice_clone_used=True")
class PodcastAudioDubEstimateRequest(BaseModel):
"""Request model for dubbing cost estimation."""
audio_duration_seconds: float = Field(..., description="Duration of source audio in seconds")
target_language: str = Field(..., description="Target language")
quality: str = Field(default="low", description="Translation quality")
use_voice_clone: Optional[bool] = Field(default=False, description="Include voice cloning cost")
class PodcastAudioDubEstimateResponse(BaseModel):
"""Response model for dubbing cost estimation."""
estimated_characters: int
translation_cost: float
tts_cost: float
voice_clone_cost: float = 0.0
total_cost: float
currency: str = "USD"
class VoiceCloneRequest(BaseModel):
"""Request model for voice cloning."""
source_audio_url: str = Field(..., description="URL or path to source audio file (10-60 seconds recommended)")
custom_voice_id: Optional[str] = Field(None, description="Custom name for the cloned voice")
accuracy: Optional[float] = Field(default=0.7, ge=0.1, le=1.0, description="Cloning accuracy (0.1-1.0)")
language_boost: Optional[str] = Field(None, description="Language to optimize the voice for")
class VoiceCloneResponse(BaseModel):
"""Response model for voice cloning."""
task_id: str
status: str = "pending"
message: str = "Voice cloning task created"
class VoiceCloneResult(BaseModel):
"""Response model for completed voice cloning."""
voice_id: str
voice_url: str
source_language: str
accuracy: float
file_size: int
task_id: str
status: str = "completed"

View File

@@ -1,837 +0,0 @@
import { ResearchProvider, ResearchConfig } from "./blogWriterApi";
import {
storyWriterApi,
StorySetupGenerationResponse,
} from "./storyWriterApi";
import { getResearchConfig, ResearchPersona } from "../api/researchConfig";
import { aiApiClient } from "../api/client";
import {
CreateProjectPayload,
CreateProjectResult,
Fact,
Knobs,
PodcastAnalysis,
PodcastEstimate,
Query,
RenderJobResult,
Research,
Scene,
Script,
} from "../components/PodcastMaker/types";
import { checkPreflight, PreflightOperation } from "./billingService";
import { TaskStatus } from "./storyWriterApi";
const DEFAULT_KNOBS: Knobs = {
voice_emotion: "neutral",
voice_speed: 1,
resolution: "720p",
scene_length_target: 45,
sample_rate: 24000,
bitrate: "standard",
};
// const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const createId = (prefix: string) => {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return `${prefix}_${crypto.randomUUID()}`;
}
return `${prefix}_${Date.now()}_${Math.floor(Math.random() * 10000)}`;
};
type OptionLike = StorySetupGenerationResponse["options"][0] | { plot_elements?: string; premise?: string };
const deriveSegments = (option?: OptionLike): string[] => {
const segments: string[] = [];
if (option?.plot_elements) {
option.plot_elements
.split(/[,.;]+/)
.map((p) => p.trim())
.filter(Boolean)
.forEach((p) => segments.push(p));
}
if (!segments.length && "premise" in (option || {}) && (option as any)?.premise) {
segments.push("Intro", "Key Takeaways", "Examples", "CTA");
}
return segments.slice(0, 5);
};
const estimateCosts = ({
minutes,
scenes,
chars,
quality,
avatars,
queryCount = 3,
}: {
minutes: number;
scenes: number;
chars: number;
quality: string;
avatars: number;
queryCount?: number;
}): PodcastEstimate => {
const secs = Math.max(60, minutes * 60);
const ttsCost = (chars / 1000) * 0.05;
const avatarCost = avatars * 0.15;
const videoRate = quality === "hd" ? 0.06 : 0.03;
const videoCost = secs * videoRate;
const researchCost = +(Math.max(1, queryCount) * 0.1).toFixed(2);
const total = +(ttsCost + avatarCost + videoCost + researchCost).toFixed(2);
return {
ttsCost: +ttsCost.toFixed(2),
avatarCost: +avatarCost.toFixed(2),
videoCost: +videoCost.toFixed(2),
researchCost,
total,
};
};
const mapPersonaQueries = (persona: ResearchPersona | undefined, seed: string): Query[] => {
const baseIdea = seed || "AI marketing for small businesses";
const personaKeywords = persona?.suggested_keywords?.filter(Boolean) || [];
const angles = persona?.research_angles ?? [];
const generated: Query[] = [];
const addQuery = (q: string, why: string, needsRecent = false) => {
if (!q.trim()) return;
generated.push({
id: createId("q"),
query: q.trim(),
rationale: why,
needsRecentStats: needsRecent,
});
};
if (personaKeywords.length) {
personaKeywords.slice(0, 4).forEach((k, idx) =>
addQuery(k, angles[idx % Math.max(1, angles.length)] || "Persona-aligned query", /202[45]|latest|trend/i.test(k))
);
}
if (!generated.length) {
addQuery(`How is ${baseIdea} evolving in 2024?`, "Trend + outcome focus", true);
addQuery(`Best practices for ${baseIdea}`, "Actionable guidance", false);
addQuery(`${baseIdea} case studies with ROI`, "Proof and outcomes", true);
addQuery(`${baseIdea} risks and objections`, "Address listener concerns", false);
}
return generated.slice(0, 6);
};
const mapSourcesToFacts = (sources: ExaSource[]): Fact[] => {
if (!sources || !sources.length) return [];
return sources.slice(0, 12).map((source: ExaSource, idx: number) => ({
id: source.url || createId("fact"),
quote: source.excerpt || source.title || "Insight",
url: source.url || "",
date: source.published_at || "Unknown",
confidence: typeof (source as any).credibility_score === "number" ? (source as any).credibility_score : Math.max(0.5, 0.85 - idx * 0.02),
image: source.image,
author: source.author,
highlights: source.highlights,
}));
};
type ExaSource = {
title?: string;
url?: string;
excerpt?: string;
published_at?: string;
highlights?: string[];
summary?: string;
source_type?: string;
index?: number;
image?: string;
author?: string;
};
type ExaResearchResult = {
sources: ExaSource[];
search_queries?: string[];
cost?: { total?: number };
search_type?: string;
provider?: string;
content?: string;
};
const mapExaResearchResponse = (response: any): Research => {
const factCards = mapSourcesToFacts(response.sources);
// Use backend summary if available, otherwise use full content (no truncation) or fallback text
const summary = response.summary || response.content || "Research completed.";
const keyInsights = (response.key_insights || []).map((insight: any) => ({
title: insight.title || "Insight",
content: insight.content || "",
source_indices: insight.source_indices || []
}));
const expertQuotes = (response.expert_quotes || []).map((eq: any) => ({
quote: eq.quote || eq.text || "",
source_index: eq.source_index ?? 0
}));
const listenerCta = response.listener_cta || [];
const mappedAngles = (response.mapped_angles || []).map((angle: any) => ({
title: angle.title || "",
why: angle.why || angle.rationale || "",
mappedFactIds: angle.mapped_fact_ids || angle.mappedFactIds || []
}));
return {
summary,
keyInsights,
factCards,
mappedAngles,
expertQuotes,
listenerCta,
searchQueries: response.search_queries,
searchType: response.search_type,
provider: response.provider || "exa",
cost: response.cost?.total,
sourceCount: response.sources?.length || 0,
};
};
const ensurePreflight = async (operation: PreflightOperation) => {
const result = await checkPreflight(operation);
if (!result.can_proceed) {
const message = result.operations[0]?.message || "Pre-flight validation failed";
throw new Error(message);
}
return result;
};
export const podcastApi = {
async createProject(payload: CreateProjectPayload, bible?: any, feedback?: string): Promise<CreateProjectResult> {
const storyIdea = payload.ideaOrUrl || "AI marketing for small businesses";
await ensurePreflight({
provider: "gemini",
operation_type: "podcast_analysis",
tokens_requested: 1500,
actual_provider_name: "gemini",
});
// Podcast-specific analysis (not story setup)
const analysisResp = await aiApiClient.post("/api/podcast/analyze", {
idea: storyIdea,
duration: payload.duration,
speakers: payload.speakers,
bible: bible,
avatar_url: payload.avatarUrl,
feedback: feedback, // Pass feedback to backend
});
const outlines = (analysisResp.data?.suggested_outlines || []).map((o: any, idx: number) => ({
id: o.id || `outline-${idx + 1}`,
title: o.title || `Outline ${idx + 1}`,
segments: Array.isArray(o.segments) ? o.segments : deriveSegments({ plot_elements: o.segments }),
}));
const analysis: PodcastAnalysis = {
audience: analysisResp.data?.audience || "Growth-minded pros",
contentType: analysisResp.data?.content_type || "Podcast interview",
topKeywords: analysisResp.data?.top_keywords || outlines[0]?.segments?.slice(0, 3) || [],
suggestedOutlines: outlines,
suggestedKnobs: { ...DEFAULT_KNOBS, ...payload.knobs },
titleSuggestions: (analysisResp.data?.title_suggestions || []).filter(Boolean),
research_queries: analysisResp.data?.research_queries || [],
exaSuggestedConfig: analysisResp.data?.exa_suggested_config || undefined,
};
const researchConfig = await getResearchConfig().catch(() => null);
// Use AI-generated queries if available, fallback to legacy mapping
let queries: Query[] = [];
if (analysis.research_queries && analysis.research_queries.length > 0) {
queries = analysis.research_queries.map(rq => ({
id: createId("q"),
query: rq.query,
rationale: rq.rationale,
needsRecentStats: /202[45]|latest|trend/i.test(rq.query)
}));
} else {
queries = mapPersonaQueries(researchConfig?.research_persona, storyIdea);
}
const projectId = createId("podcast");
const estimate = estimateCosts({
minutes: payload.duration,
scenes: Math.ceil((payload.duration * 60) / (payload.knobs.scene_length_target || DEFAULT_KNOBS.scene_length_target)),
chars: Math.max(1000, payload.duration * 900),
quality: payload.knobs.bitrate || "standard",
avatars: payload.speakers,
queryCount: queries.length || 3,
});
return {
projectId,
analysis,
estimate,
queries,
bible: analysisResp.data?.bible || undefined,
avatar_url: analysisResp.data?.avatar_url || null,
avatar_prompt: analysisResp.data?.avatar_prompt || null,
};
},
async enhanceIdea(params: { idea: string; bible?: any }): Promise<{ enhanced_ideas: string[]; rationales: string[] }> {
const response = await aiApiClient.post("/api/podcast/idea/enhance", params);
return response.data;
},
async runResearch(params: {
projectId: string;
topic: string;
approvedQueries: Query[];
provider?: ResearchProvider;
exaConfig?: ResearchConfig;
bible?: any;
analysis?: PodcastAnalysis | null;
onProgress?: (message: string) => void;
}): Promise<{ research: Research; raw: any }> {
const keywords = params.approvedQueries.map((q) => q.query).filter(Boolean);
if (!keywords.length) {
throw new Error("At least one query must be approved for research.");
}
// Ensure Exa payload respects API constraint: when requesting contents, only one of includeDomains or excludeDomains.
let sanitizedExaConfig: ResearchConfig | undefined = params.exaConfig;
if (sanitizedExaConfig && sanitizedExaConfig.exa_include_domains?.length) {
sanitizedExaConfig = {
...sanitizedExaConfig,
exa_exclude_domains: undefined,
};
} else if (sanitizedExaConfig && sanitizedExaConfig.exa_exclude_domains?.length) {
sanitizedExaConfig = {
...sanitizedExaConfig,
exa_include_domains: undefined,
};
}
await ensurePreflight({
provider: "exa",
operation_type: "exa_neural_search",
tokens_requested: 0,
actual_provider_name: "exa",
});
const response = await aiApiClient.post("/api/podcast/research/exa", {
topic: params.topic || keywords[0],
queries: keywords,
exa_config: sanitizedExaConfig,
bible: params.bible,
analysis: params.analysis,
});
const exaResult = response.data as ExaResearchResult;
if (params.onProgress) {
params.onProgress("Deep research completed with Exa.");
}
const mapped = mapExaResearchResponse(exaResult);
return { research: mapped, raw: exaResult };
},
async generateScript(params: {
projectId: string;
idea: string;
research?: ExaResearchResult | null;
knobs: Knobs;
speakers: number;
durationMinutes: number;
bible?: any;
outline?: any;
analysis?: PodcastAnalysis | null;
}): Promise<Script> {
await ensurePreflight({
provider: "gemini",
operation_type: "script_generation",
tokens_requested: 2000,
actual_provider_name: "gemini",
});
const response = await aiApiClient.post("/api/podcast/script", {
idea: params.idea,
duration_minutes: params.durationMinutes,
speakers: params.speakers,
research: params.research,
bible: params.bible,
outline: params.outline,
analysis: params.analysis,
});
const scenes = response.data?.scenes || [];
const scriptScenes: Scene[] = scenes.map((scene: any) => ({
id: scene.id || createId("scene"),
title: scene.title || "Scene",
duration: scene.duration || Math.max(20, params.knobs.scene_length_target || DEFAULT_KNOBS.scene_length_target),
lines:
Array.isArray(scene.lines) && scene.lines.length
? scene.lines.map((l: any) => ({
id: createId("line"),
speaker: l.speaker || "Host",
text: l.text || "",
}))
: [
{
id: createId("line"),
speaker: "Host",
text: "Let's dive into today's topic.",
},
],
approved: false,
}));
return { scenes: scriptScenes };
},
async previewLine(
text: string,
options: { voiceId?: string; speed?: number; emotion?: string } = {}
): Promise<{ ok: boolean; message: string; audioUrl?: string }> {
await ensurePreflight({
provider: "audio",
operation_type: "tts_preview",
tokens_requested: text.length,
actual_provider_name: "wavespeed",
});
const response = await storyWriterApi.generateAIAudio({
scene_number: 0,
scene_title: "Preview",
text,
voice_id: options.voiceId || "Wise_Woman",
speed: options.speed || 1.0,
emotion: options.emotion || "neutral",
});
if (!response.success) {
throw new Error(response.error || "Preview failed");
}
return {
ok: true,
message: "Preview ready opening audio in new tab.",
audioUrl: response.audio_url,
};
},
async renderSceneAudio(params: {
scene: Scene;
voiceId?: string;
emotion?: string; // Fallback if scene doesn't have emotion
speed?: number;
volume?: number;
pitch?: number;
englishNormalization?: boolean;
sampleRate?: number;
bitrate?: number;
channel?: "1" | "2";
format?: "mp3" | "wav" | "pcm" | "flac";
languageBoost?: string;
}): Promise<RenderJobResult> {
// Use scene-specific emotion if available, otherwise fallback to provided/default
const sceneEmotion = params.scene.emotion || params.emotion || "neutral";
// Optimize text for Minimax Speech-02-HD TTS
// - Strip markdown formatting (bold, italic, etc.) - TTS reads it literally
// - Use pause markers <#x#> for natural speech rhythm
// - Add longer pauses for speaker changes
// - Preserve punctuation for natural breathing
// - Add emphasis pauses for important points
const text = params.scene.lines
.map((line, idx) => {
let lineText = line.text.trim();
// Strip markdown formatting - TTS reads asterisks and other markdown literally
// Remove bold (**text** or __text__)
lineText = lineText.replace(/\*\*([^*]+)\*\*/g, '$1'); // **bold**
lineText = lineText.replace(/\*([^*]+)\*/g, '$1'); // *bold* (single asterisk)
lineText = lineText.replace(/__([^_]+)__/g, '$1'); // __bold__
lineText = lineText.replace(/_([^_]+)_/g, '$1'); // _italic_ (single underscore)
// Remove any remaining stray asterisks or underscores
lineText = lineText.replace(/\*+/g, ''); // Remove any remaining asterisks
lineText = lineText.replace(/_+/g, ''); // Remove any remaining underscores
// Clean up extra spaces
lineText = lineText.replace(/\s+/g, ' ').trim();
// Preserve punctuation (Minimax uses it for natural breathing)
// Don't strip punctuation - it helps TTS understand natural pauses
// Add emphasis pause after lines marked with emphasis
if (line.emphasis) {
// Minimal pause after emphasized content (0.15s for subtle emphasis)
lineText = `${lineText}<#0.15#>`;
}
// Check for speaker change (longer pause for natural conversation flow)
const prevLine = idx > 0 ? params.scene.lines[idx - 1] : null;
const isSpeakerChange = prevLine && prevLine.speaker !== line.speaker;
if (isSpeakerChange) {
// Short pause for speaker changes (0.2s - enough for natural transition)
lineText = `<#0.2#>${lineText}`;
}
// Add minimal pause between lines (only between regular lines, very short)
if (idx < params.scene.lines.length - 1) {
if (!line.emphasis && !isSpeakerChange) {
// Very short pause between lines (0.08s - barely noticeable but helps flow)
lineText = `${lineText}<#0.08#>`;
}
// If emphasis or speaker change, the pause is already added above
}
return lineText;
})
.join(" ");
// Validate character limit (Minimax max: 10,000 characters)
const MAX_CHARS = 10000;
let textToUse = text;
if (text.length > MAX_CHARS) {
console.warn(
`[Podcast] Scene "${params.scene.title}" exceeds ${MAX_CHARS} character limit (${text.length} chars). Truncating...`
);
// Truncate at word boundary to avoid cutting mid-word
const truncated = text.substring(0, MAX_CHARS);
const lastSpace = truncated.lastIndexOf(" ");
textToUse = lastSpace > 0 ? truncated.substring(0, lastSpace) : truncated;
}
await ensurePreflight({
provider: "audio",
operation_type: "tts_full_render",
tokens_requested: textToUse.length,
actual_provider_name: "wavespeed",
});
const response = await aiApiClient.post("/api/podcast/audio", {
scene_id: params.scene.id,
scene_title: params.scene.title,
text: textToUse,
voice_id: params.voiceId || "Wise_Woman",
speed: params.speed ?? 1.0, // Normal speed (was 0.9, but too slow - causing duration issues)
volume: params.volume ?? 1.0,
pitch: params.pitch ?? 0.0,
emotion: sceneEmotion,
english_normalization: params.englishNormalization ?? true, // Better number reading for statistics
sample_rate: params.sampleRate || null,
bitrate: params.bitrate || null,
channel: params.channel || null,
format: params.format || null,
language_boost: params.languageBoost || null,
});
return {
audioUrl: response.data.audio_url,
audioFilename: response.data.audio_filename,
provider: response.data.provider,
model: response.data.model,
cost: response.data.cost,
voiceId: response.data.voice_id,
fileSize: response.data.file_size,
};
},
async approveScene(params: { projectId: string; sceneId: string; notes?: string }) {
await aiApiClient.post("/api/story/script/approve", {
project_id: params.projectId,
scene_id: params.sceneId,
approved: true,
notes: params.notes,
});
},
// Project persistence endpoints
async saveProject(projectId: string, state: any): Promise<void> {
try {
await aiApiClient.put(`/api/podcast/projects/${projectId}`, state);
} catch (error) {
console.error("Failed to save project to database:", error);
// Don't throw - localStorage fallback is acceptable
}
},
async loadProject(projectId: string): Promise<any> {
const response = await aiApiClient.get(`/api/podcast/projects/${projectId}`);
return response.data;
},
async listProjects(params?: {
status?: string;
favorites_only?: boolean;
limit?: number;
offset?: number;
order_by?: "updated_at" | "created_at";
}): Promise<{ projects: any[]; total: number; limit: number; offset: number }> {
const response = await aiApiClient.get("/api/podcast/projects", { params });
return response.data;
},
async createProjectInDb(params: {
project_id: string;
idea: string;
duration: number;
speakers: number;
budget_cap: number;
avatar_url?: string | null;
}): Promise<any> {
const response = await aiApiClient.post("/api/podcast/projects", params);
return response.data;
},
async updateProject(projectId: string, updates: any): Promise<any> {
const response = await aiApiClient.put(`/api/podcast/projects/${projectId}`, updates);
return response.data;
},
async deleteProject(projectId: string): Promise<void> {
await aiApiClient.delete(`/api/podcast/projects/${projectId}`);
},
async toggleFavorite(projectId: string): Promise<any> {
const response = await aiApiClient.post(`/api/podcast/projects/${projectId}/favorite`);
return response.data;
},
async saveAudioToAssetLibrary(params: {
audioUrl: string;
filename: string;
title: string;
description?: string;
projectId: string;
sceneId?: string;
cost?: number;
provider?: string;
model?: string;
fileSize?: number;
}): Promise<{ assetId: number }> {
const response = await aiApiClient.post("/api/content-assets/", {
asset_type: "audio",
source_module: "podcast_maker",
filename: params.filename,
file_url: params.audioUrl,
title: params.title,
description: params.description || `Podcast episode audio: ${params.title}`,
tags: ["podcast", "audio", params.projectId],
asset_metadata: {
project_id: params.projectId,
scene_id: params.sceneId,
provider: params.provider,
model: params.model,
},
provider: params.provider,
model: params.model,
cost: params.cost || 0,
file_size: params.fileSize,
mime_type: "audio/mpeg",
});
return { assetId: response.data.id };
},
async generateVideo(params: {
projectId: string;
sceneId: string;
sceneTitle: string;
audioUrl: string;
avatarImageUrl?: string;
bible?: any;
resolution?: string;
prompt?: string;
seed?: number;
maskImageUrl?: string;
}): Promise<{ taskId: string; status: string; message: string }> {
const response = await aiApiClient.post("/api/podcast/render/video", {
project_id: params.projectId,
scene_id: params.sceneId,
scene_title: params.sceneTitle,
audio_url: params.audioUrl,
avatar_image_url: params.avatarImageUrl,
bible: params.bible,
resolution: params.resolution || "720p",
prompt: params.prompt,
seed: params.seed ?? -1,
mask_image_url: params.maskImageUrl,
});
// Backend returns snake_case (task_id); normalize to camelCase for callers
const { task_id, status, message } = response.data || {};
return {
taskId: task_id,
status,
message,
};
},
async pollTaskStatus(taskId: string): Promise<TaskStatus | null> {
const response = await aiApiClient.get(`/api/podcast/task/${taskId}/status`);
// Backend returns null if task not found
return response.data || null;
},
async listVideos(projectId?: string): Promise<{
videos: Array<{
scene_number: number;
filename: string;
video_url: string;
file_size: number;
}>;
}> {
const params = projectId ? { project_id: projectId } : {};
const response = await aiApiClient.get("/api/podcast/videos", { params });
return response.data;
},
async combineVideos(params: {
projectId: string;
sceneVideoUrls: string[];
podcastTitle?: string;
}): Promise<{
taskId: string;
status: string;
message: string;
}> {
const response = await aiApiClient.post("/api/podcast/render/combine-videos", {
project_id: params.projectId,
scene_video_urls: params.sceneVideoUrls,
podcast_title: params.podcastTitle || "Podcast",
});
const { task_id, status, message } = response.data || {};
return {
taskId: task_id,
status,
message,
};
},
async generateSceneImage(params: {
sceneId: string;
sceneTitle: string;
sceneContent?: string;
baseAvatarUrl?: string;
bible?: any;
idea?: string;
width?: number;
height?: number;
customPrompt?: string;
style?: "Auto" | "Fiction" | "Realistic";
renderingSpeed?: "Default" | "Turbo" | "Quality";
aspectRatio?: "1:1" | "16:9" | "9:16" | "4:3" | "3:4";
}): Promise<{
scene_id: string;
scene_title: string;
image_filename: string;
image_url: string;
width: number;
height: number;
provider: string;
model?: string;
cost: number;
}> {
const response = await aiApiClient.post("/api/podcast/image", {
scene_id: params.sceneId,
scene_title: params.sceneTitle,
scene_content: params.sceneContent,
base_avatar_url: params.baseAvatarUrl || null,
bible: params.bible,
idea: params.idea || null,
width: params.width || 1024,
height: params.height || 1024,
custom_prompt: params.customPrompt || null,
style: params.style || null,
rendering_speed: params.renderingSpeed || null,
aspect_ratio: params.aspectRatio || null,
});
return response.data;
},
async cancelTask(taskId: string): Promise<void> {
// Note: Task cancellation may not be fully supported by backend yet
// This is a placeholder for future implementation
try {
await aiApiClient.post(`/api/story/task/${taskId}/cancel`);
} catch (error) {
console.warn("Task cancellation not supported:", error);
}
},
async combineAudio(params: {
projectId: string;
sceneIds: string[];
sceneAudioUrls: string[];
}): Promise<{
combined_audio_url: string;
combined_audio_filename: string;
total_duration: number;
file_size: number;
scene_count: number;
}> {
const response = await aiApiClient.post("/api/podcast/combine-audio", {
project_id: params.projectId,
scene_ids: params.sceneIds,
scene_audio_urls: params.sceneAudioUrls,
});
return response.data;
},
async uploadAvatar(file: File, projectId?: string): Promise<{ avatar_url: string; avatar_filename: string }> {
const formData = new FormData();
formData.append('file', file);
if (projectId) {
formData.append('project_id', projectId);
}
const response = await aiApiClient.post('/api/podcast/avatar/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return response.data;
},
async generatePresenters(
speakers: number,
projectId?: string,
audience?: string,
contentType?: string,
topKeywords?: string[]
): Promise<{
avatars: Array<{ avatar_url: string; speaker_number: number; prompt?: string; persona_id?: string; seed?: number }>;
persona_id?: string;
}> {
const formData = new FormData();
formData.append('speakers', speakers.toString());
if (projectId) {
formData.append('project_id', projectId);
}
if (audience) {
formData.append('audience', audience);
}
if (contentType) {
formData.append('content_type', contentType);
}
if (topKeywords && Array.isArray(topKeywords) && topKeywords.length > 0) {
formData.append('top_keywords', JSON.stringify(topKeywords));
}
const response = await aiApiClient.post('/api/podcast/avatar/generate', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return response.data;
},
async makeAvatarPresentable(avatarUrl: string, projectId?: string): Promise<{ avatar_url: string; avatar_filename: string }> {
const formData = new FormData();
formData.append('avatar_url', avatarUrl);
if (projectId) {
formData.append('project_id', projectId);
}
const response = await aiApiClient.post('/api/podcast/avatar/make-presentable', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return response.data;
},
};
export type PodcastApi = typeof podcastApi;

View File

@@ -1,244 +0,0 @@
"""
Podcast Research Handlers
Research endpoints using Exa provider and LLM summarization.
"""
from fastapi import APIRouter, Depends, HTTPException
from typing import Dict, Any, List
from types import SimpleNamespace
import json
from middleware.auth_middleware import get_current_user
from api.story_writer.utils.auth import require_authenticated_user
from services.blog_writer.research.exa_provider import ExaResearchProvider
from services.llm_providers.main_text_generation import llm_text_gen
from services.podcast_bible_service import PodcastBibleService
from loguru import logger
from ..models import (
PodcastExaResearchRequest,
PodcastExaResearchResponse,
PodcastExaSource,
PodcastExaConfig,
PodcastResearchInsight,
)
router = APIRouter()
@router.post("/research/exa", response_model=PodcastExaResearchResponse)
async def podcast_research_exa(
request: PodcastExaResearchRequest,
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""
Run podcast research via Exa and then use LLM to extract deep insights.
Uses Podcast Bible and Analysis context for hyper-personalization.
"""
user_id = require_authenticated_user(current_user)
queries = [q.strip() for q in request.queries if q and q.strip()]
if not queries:
raise HTTPException(status_code=400, detail="At least one query is required for research.")
exa_cfg = request.exa_config or PodcastExaConfig()
cfg = SimpleNamespace(
exa_search_type=exa_cfg.exa_search_type or "auto",
exa_category=exa_cfg.exa_category,
exa_include_domains=exa_cfg.exa_include_domains or [],
exa_exclude_domains=exa_cfg.exa_exclude_domains or [],
max_sources=exa_cfg.max_sources or 8,
source_types=[],
)
provider = ExaResearchProvider()
# --- Context Building ---
bible_service = PodcastBibleService()
bible_context = ""
if request.bible:
try:
from models.podcast_bible_models import PodcastBible
bible_data = PodcastBible(**request.bible)
bible_context = bible_service.serialize_bible(bible_data)
except Exception as exc:
logger.warning(f"[Podcast Research] Failed to serialize bible: {exc}")
analysis_context = ""
if request.analysis:
analysis_context = f"""
PODCAST ANALYSIS CONTEXT:
Audience: {request.analysis.get('audience', 'General')}
Content Type: {request.analysis.get('content_type', 'Informative')}
Top Keywords: {', '.join(request.analysis.get('top_keywords', []))}
"""
# Exa search params
industry = request.bible.get("brand", {}).get("industry", "") if request.bible else ""
target_audience = ""
if request.bible:
audience_dna = request.bible.get("audience", {})
if audience_dna:
interests = ", ".join(audience_dna.get("interests", []))
target_audience = f"Expertise: {audience_dna.get('expertise_level', '')}. Interests: {interests}."
try:
# 1. RUN EXA SEARCH
result = await provider.search(
prompt=request.topic,
topic=request.topic,
industry=industry,
target_audience=target_audience,
config=cfg,
user_id=user_id,
)
except Exception as exc:
logger.error(f"[Podcast Exa Research] Search failed for user {user_id}: {exc}")
raise HTTPException(status_code=500, detail=f"Exa research failed: {exc}")
# 2. EXTRACT INSIGHTS VIA LLM
raw_content = result.get("content", "")
sources = result.get("sources", [])
summary = ""
key_insights = []
expert_quotes = []
listener_cta = []
mapped_angles = []
if raw_content and sources:
logger.info(f"[Podcast Research] Extracting insights from {len(sources)} sources for user {user_id}")
prompt = f"""
You are an expert research analyst for a high-end podcast production team.
Your task is to analyze the following research data and extract deep, actionable insights for a podcast episode.
PODCAST CONTEXT:
Topic: {request.topic}
{bible_context}
{analysis_context}
RESEARCH DATA (from {len(sources)} sources):
{raw_content}
TASK:
1. Provide a comprehensive summary (2-3 paragraphs) of the most important findings. Use Markdown for formatting (bolding, lists).
2. Extract 3-5 "Key Insights". Each insight should have a title and a detailed explanation.
3. For each insight, identify which source indices (e.g. 1, 2) it was derived from.
4. Extract notable "Expert Quotes" - direct quotes from industry leaders, researchers, or authoritative voices found in the sources.
5. Suggest 2-4 "Listener CTA" (call-to-action) ideas that the podcast host can use to engage the audience.
6. Identify 3-5 "Mapped Angles" - unique content angles with rationale for why they matter for this topic.
NOTE: The research data includes "Key Highlights", "Summaries", and "Excerpts" from various sources.
Pay special attention to the "Key Highlights" sections as they contain the most relevant information extracted by the neural search engine.
Return JSON structure:
{{
"summary": "Detailed markdown summary...",
"key_insights": [
{{
"title": "Insight Title",
"content": "Detailed markdown content...",
"source_indices": [1, 2]
}}
],
"expert_quotes": [
{{
"quote": "Exact quote from source...",
"source_index": 1
}}
],
"listener_cta": [
"Call-to-action suggestion 1",
"Call-to-action suggestion 2"
],
"mapped_angles": [
{{
"title": "Angle Title",
"why": "Why this angle matters for the audience...",
"mapped_fact_ids": ["fact_1", "fact_2"]
}}
]
}}
Requirements:
- Ensure insights are deep, not just superficial facts. Look for trends, expert opinions, and specific data points.
- Expert quotes should be exact or near-exact quotes from the sources, with attribution.
- Listener CTAs should be practical and engaging (e.g., "Share your experience with X on social media").
- Mapped angles should be unique perspectives that make the episode stand out.
- Tone should be professional, insightful, and ready for a podcast host to discuss.
- Avoid generic filler.
"""
try:
llm_response = llm_text_gen(
prompt=prompt,
user_id=user_id,
json_struct=None,
preferred_provider="huggingface",
flow_type="premium_tool",
)
# Normalize response
if isinstance(llm_response, str):
data = json.loads(llm_response)
else:
data = llm_response
summary = data.get("summary", "")
key_insights = [PodcastResearchInsight(**insight) for insight in data.get("key_insights", [])]
expert_quotes = data.get("expert_quotes", [])
listener_cta = data.get("listener_cta", [])
mapped_angles = data.get("mapped_angles", [])
except Exception as exc:
logger.error(f"[Podcast Research] LLM Insight extraction failed: {exc}")
# Fallback to a basic summary if LLM fails
summary = f"Research completed for '{request.topic}'. Found {len(sources)} sources."
# Fallback: if summary is still empty (e.g. LLM returned empty string), use raw content first paragraph or basic text
if not summary:
if raw_content:
summary = raw_content[:2000] # Use first 2000 chars of raw content as summary
else:
summary = f"Research completed for '{request.topic}'. Found {len(sources)} sources."
# 3. TRACK USAGE
try:
cost_total = 0.0
if isinstance(result, dict):
cost_total = result.get("cost", {}).get("total", 0.005) if result.get("cost") else 0.005
provider.track_exa_usage(user_id, cost_total)
except Exception as track_err:
logger.warning(f"[Podcast Exa Research] Failed to track usage: {track_err}")
sources_payload = []
for src in sources:
try:
sources_payload.append(PodcastExaSource(**src))
except Exception:
sources_payload.append(PodcastExaSource(**{
"title": src.get("title", ""),
"url": src.get("url", ""),
"excerpt": src.get("excerpt", ""),
"published_at": src.get("published_at"),
"highlights": src.get("highlights"),
"summary": src.get("summary"),
"source_type": src.get("source_type"),
"index": src.get("index"),
"image": src.get("image"),
"author": src.get("author"),
}))
return PodcastExaResearchResponse(
sources=sources_payload,
search_queries=result.get("search_queries", queries) if isinstance(result, dict) else queries,
summary=summary,
key_insights=key_insights,
expert_quotes=expert_quotes,
listener_cta=listener_cta,
mapped_angles=mapped_angles,
cost=result.get("cost") if isinstance(result, dict) else None,
search_type=result.get("search_type") if isinstance(result, dict) else None,
provider=result.get("provider", "exa") if isinstance(result, dict) else "exa",
content=raw_content,
)

View File

@@ -1,183 +0,0 @@
"""
Podcast Script Handlers
Script generation endpoint.
"""
from fastapi import APIRouter, Depends, HTTPException
from typing import Dict, Any
import json
from middleware.auth_middleware import get_current_user
from api.story_writer.utils.auth import require_authenticated_user
from services.llm_providers.main_text_generation import llm_text_gen
from services.podcast_bible_service import PodcastBibleService
from models.podcast_bible_models import PodcastBible
from loguru import logger
from ..models import (
PodcastScriptRequest,
PodcastScriptResponse,
PodcastScene,
PodcastSceneLine,
)
router = APIRouter()
@router.post("/script", response_model=PodcastScriptResponse)
async def generate_podcast_script(
request: PodcastScriptRequest,
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""
Generate a podcast script outline (scenes + lines) using podcast-oriented prompting.
"""
user_id = require_authenticated_user(current_user)
# Build comprehensive research context for higher-quality scripts
research_context = ""
if request.research:
try:
key_insights = request.research.get("keyword_analysis", {}).get("key_insights") or []
fact_cards = request.research.get("factCards", []) or []
mapped_angles = request.research.get("mappedAngles", []) or []
sources = request.research.get("sources", []) or []
top_facts = [f.get("quote", "") for f in fact_cards[:5] if f.get("quote")]
angles_summary = [
f"{a.get('title', '')}: {a.get('why', '')}" for a in mapped_angles[:3] if a.get("title") or a.get("why")
]
top_sources = [s.get("url") for s in sources[:3] if s.get("url")]
research_parts = []
if key_insights:
research_parts.append(f"Key Insights: {', '.join(key_insights[:5])}")
if top_facts:
research_parts.append(f"Key Facts: {', '.join(top_facts)}")
if angles_summary:
research_parts.append(f"Research Angles: {' | '.join(angles_summary)}")
if top_sources:
research_parts.append(f"Top Sources: {', '.join(top_sources)}")
research_context = "\n".join(research_parts)
except Exception as exc:
logger.warning(f"Failed to parse research context: {exc}")
research_context = ""
# Extract Podcast Bible context for hyper-personalization
bible_context = ""
if request.bible:
try:
bible_service = PodcastBibleService()
bible_obj = PodcastBible(**request.bible)
bible_context = bible_service.serialize_bible(bible_obj)
except Exception as exc:
logger.warning(f"Failed to serialize podcast bible: {exc}")
# Extract Analysis and Outline context for grounding
analysis_context = ""
if request.analysis:
analysis_context = f"""
TARGET AUDIENCE: {request.analysis.get('audience', 'General')}
CONTENT TYPE: {request.analysis.get('contentType', 'Conversational')}
TOP KEYWORDS: {', '.join(request.analysis.get('topKeywords', []))}
"""
outline_context = ""
if request.outline:
outline_context = f"""
REFINED EPISODE OUTLINE (Follow this structure closely):
Title: {request.outline.get('title', 'N/A')}
Segments: {' | '.join(request.outline.get('segments', []))}
"""
prompt = f"""You are an expert podcast script planner. Create natural, conversational podcast scenes.
{f"PODCAST BIBLE (Hyper-Personalization Context):\n{bible_context}\n" if bible_context else ""}
{f"ANALYSIS CONTEXT:\n{analysis_context}\n" if analysis_context else ""}
{f"REFINED OUTLINE:\n{outline_context}\n" if outline_context else ""}
Podcast Idea: "{request.idea}"
Duration: ~{request.duration_minutes} minutes
Speakers: {request.speakers} (Host + optional Guest)
{f"RESEARCH CONTEXT:\n{research_context}\n" if research_context else ""}
Return JSON with:
- scenes: array of scenes. Each scene has:
- id: string
- title: short scene title (<= 60 chars)
- duration: duration in seconds (evenly split across total duration)
- emotion: string (one of: "neutral", "happy", "excited", "serious", "curious", "confident")
- lines: array of {{"speaker": "...", "text": "...", "emphasis": boolean}}
* Write natural, conversational dialogue
* Each line can be a sentence or a few sentences that flow together
* Use plain text only - no markdown formatting (no asterisks, underscores, etc.)
* Mark "emphasis": true for key statistics or important points
Guidelines:
- Write for spoken delivery: conversational, natural, with contractions.
- Follow the interaction tone specified in the Bible.
- Ensure the Host persona matches the background and personality traits from the Bible.
- Structure the intro and outro scenes according to the Bible's "Intro Format" and "Outro Format".
- Adhere to any constraints mentioned in the Bible.
- Use insights from the Research Context to ground the conversation in facts.
- IMPORTANT: Follow the REFINED OUTLINE segments as the primary structure for the episode.
"""
try:
raw = llm_text_gen(
prompt=prompt,
user_id=user_id,
json_struct=None,
preferred_provider="huggingface",
flow_type="premium_tool",
)
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Script generation failed: {exc}")
if isinstance(raw, str):
try:
data = json.loads(raw)
except json.JSONDecodeError:
raise HTTPException(status_code=500, detail="LLM returned non-JSON output")
elif isinstance(raw, dict):
data = raw
else:
raise HTTPException(status_code=500, detail="Unexpected LLM response format")
scenes_data = data.get("scenes") or []
if not isinstance(scenes_data, list):
raise HTTPException(status_code=500, detail="LLM response missing scenes array")
valid_emotions = {"neutral", "happy", "excited", "serious", "curious", "confident"}
# Normalize scenes
scenes: list[PodcastScene] = []
for idx, scene in enumerate(scenes_data):
title = scene.get("title") or f"Scene {idx + 1}"
duration = int(scene.get("duration") or max(30, (request.duration_minutes * 60) // max(1, len(scenes_data))))
emotion = scene.get("emotion") or "neutral"
if emotion not in valid_emotions:
emotion = "neutral"
lines_raw = scene.get("lines") or []
lines: list[PodcastSceneLine] = []
for line in lines_raw:
speaker = line.get("speaker") or ("Host" if len(lines) % request.speakers == 0 else "Guest")
text = line.get("text") or ""
emphasis = line.get("emphasis", False)
if text:
lines.append(PodcastSceneLine(speaker=speaker, text=text, emphasis=emphasis))
scenes.append(
PodcastScene(
id=scene.get("id") or f"scene-{idx + 1}",
title=title,
duration=duration,
lines=lines,
approved=False,
emotion=emotion,
)
)
return PodcastScriptResponse(scenes=scenes)

View File

@@ -1,209 +0,0 @@
export type Knobs = {
voice_emotion: string;
voice_speed: number;
resolution: string;
scene_length_target: number;
sample_rate: number;
bitrate: string;
};
export type Query = {
id: string;
query: string;
rationale: string;
needsRecentStats: boolean;
};
export type Fact = {
id: string;
quote: string;
url: string;
date: string;
confidence: number;
image?: string;
author?: string;
highlights?: string[];
};
export type ResearchInsight = {
title: string;
content: string;
source_indices: number[];
};
export type Research = {
summary: string;
keyInsights: ResearchInsight[];
factCards: Fact[];
mappedAngles: {
title: string;
why: string;
mappedFactIds: string[];
}[];
searchQueries?: string[];
searchType?: string;
provider?: string;
cost?: number;
sourceCount?: number;
expertQuotes?: { quote: string; source_index: number }[];
listenerCta?: string[];
};
export type Line = {
id: string;
speaker: string;
text: string;
usedFactIds?: string[];
emphasis?: boolean; // Mark lines that need vocal emphasis
};
export type Scene = {
id: string;
title: string;
duration: number;
lines: Line[];
approved?: boolean;
emotion?: string; // Scene-specific emotion
audioUrl?: string; // Generated audio URL for this scene
imageUrl?: string; // Generated image URL for this scene (for video generation)
};
export type Script = {
scenes: Scene[];
};
export type JobStatus =
| "idle"
| "previewing"
| "queued"
| "running"
| "completed"
| "cancelled"
| "failed";
export type Job = {
sceneId: string;
title: string;
status: JobStatus;
progress: number;
previewUrl?: string | null;
finalUrl?: string | null;
videoUrl?: string | null;
jobId?: string | null;
taskId?: string | null;
cost?: number | null;
provider?: string | null;
voiceId?: string | null;
fileSize?: number | null;
avatarImageUrl?: string | null;
imageUrl?: string | null; // Scene-specific image URL
};
export type PodcastAnalysis = {
audience: string;
contentType: string;
topKeywords: string[];
suggestedOutlines: { id: number | string; title: string; segments: string[] }[];
suggestedKnobs: Knobs;
titleSuggestions: string[];
research_queries?: { query: string; rationale: string }[];
exaSuggestedConfig?: {
exa_search_type?: "auto" | "keyword" | "neural";
exa_category?: string;
exa_include_domains?: string[];
exa_exclude_domains?: string[];
max_sources?: number;
include_statistics?: boolean;
date_range?: string;
};
};
export type PodcastEstimate = {
ttsCost: number;
avatarCost: number;
videoCost: number;
researchCost: number;
total: number;
};
export type HostPersona = {
name: string;
background: string;
expertise_level: string;
personality_traits: string[];
vocal_style: string;
catchphrases: string[];
};
export type AudienceDNA = {
expertise_level: string;
interests: string[];
pain_points: string[];
demographics?: string;
};
export type BrandDNA = {
industry: string;
tone: string;
communication_style: string;
key_messages: string[];
competitor_context?: string;
};
export type PodcastBible = {
project_id?: string;
host: HostPersona;
audience: AudienceDNA;
brand: BrandDNA;
};
export type CreateProjectPayload = {
ideaOrUrl: string;
speakers: number;
duration: number;
knobs: Knobs;
budgetCap: number;
files: { voiceFile?: File | null; avatarFile?: File | null };
avatarUrl?: string | null;
};
export type CreateProjectResult = {
projectId: string;
analysis: PodcastAnalysis;
estimate: PodcastEstimate;
queries: Query[];
bible?: PodcastBible;
avatar_url?: string | null;
avatar_prompt?: string | null;
};
export type RenderJobResult = {
audioUrl: string;
audioFilename: string;
provider: string;
model: string;
cost: number;
voiceId: string;
fileSize: number;
videoUrl?: string;
videoFilename?: string;
};
export interface VideoGenerationSettings {
prompt: string;
resolution: "480p" | "720p";
seed?: number | null;
maskImageUrl?: string | null;
}
export type TaskStatus = {
task_id: string;
status: "pending" | "processing" | "completed" | "failed";
progress?: number;
message?: string;
result?: any;
error?: string;
created_at?: string;
updated_at?: string;
};

View File

@@ -1,425 +0,0 @@
import { useState, useEffect, useMemo, useCallback } from "react";
import { podcastApi } from "../../../services/podcastApi";
import { usePreflightCheck } from "../../../hooks/usePreflightCheck";
import { useBudgetTracking } from "../../../hooks/useBudgetTracking";
import { CreateProjectPayload, Script } from "../types";
import { usePodcastProjectState } from "../../../hooks/usePodcastProjectState";
import { sanitizeExaConfig, announceError, getStepLabel } from "./utils";
type PodcastProjectStateReturn = ReturnType<typeof usePodcastProjectState>;
interface UsePodcastWorkflowProps {
projectState: PodcastProjectStateReturn;
onError: (message: string) => void;
}
export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflowProps) => {
const {
project,
analysis,
queries,
selectedQueries,
research,
rawResearch,
researchProvider,
showScriptEditor,
showRenderQueue,
currentStep,
renderJobs,
budgetCap,
setProject,
setAnalysis,
setQueries,
setSelectedQueries,
setResearch,
setRawResearch,
setEstimate,
setScriptData,
setShowScriptEditor,
setShowRenderQueue,
setKnobs,
setResearchProvider,
setBudgetCap,
updateRenderJob,
initializeProject,
setBible,
} = projectState;
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [isResearching, setIsResearching] = useState(false);
const [announcement, setAnnouncement] = useState("");
const [showResumeAlert, setShowResumeAlert] = useState(false);
const [showPreflightDialog, setShowPreflightDialog] = useState(false);
const [preflightResponse, setPreflightResponse] = useState<any>(null);
const [preflightOperationName, setPreflightOperationName] = useState<string>("");
const budgetTracking = useBudgetTracking(budgetCap || 50);
const preflightCheck = usePreflightCheck({
onBlocked: (response) => {
setPreflightResponse(response);
setShowPreflightDialog(true);
},
});
// Update budget cap when project state changes
useEffect(() => {
if (budgetCap) {
budgetTracking.setBudgetCap(budgetCap);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [budgetCap]);
// Check if we have a saved project on mount
useEffect(() => {
if (project && currentStep && currentStep !== "create") {
setShowResumeAlert(true);
setTimeout(() => setShowResumeAlert(false), 5000);
}
}, [project, currentStep]);
useEffect(() => {
if (announcement) {
const t = setTimeout(() => setAnnouncement(""), 4000);
return () => clearTimeout(t);
}
return undefined;
}, [announcement]);
const handleCreate = useCallback(async (payload: CreateProjectPayload, feedback?: string) => {
if (isAnalyzing) return;
setResearch(null);
setRawResearch(null);
setScriptData(null);
setShowScriptEditor(false);
setShowRenderQueue(false);
try {
setIsAnalyzing(true);
// Use existing avatar URL if provided (e.g. brand avatar), or upload new file
let avatarUrl: string | null = payload.avatarUrl || null;
if (payload.files.avatarFile) {
try {
setAnnouncement("Uploading presenter avatar...");
const uploadResponse = await podcastApi.uploadAvatar(payload.files.avatarFile);
avatarUrl = uploadResponse.avatar_url;
} catch (error) {
console.error('Avatar upload failed:', error);
// Continue without avatar - will generate one later
}
}
// NEW FLOW: Create project first to generate/get the Podcast Bible
// This allows the analysis to be personalized using the Bible context
const projectId = project?.id || `podcast_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
setAnnouncement("Initializing project and brand context...");
const dbProject = project ? null : await initializeProject(payload, projectId, avatarUrl);
const bible = dbProject?.bible || projectState.bible;
setAnnouncement(feedback ? "Regenerating analysis using your feedback..." : "Analyzing your idea — AI suggestions incoming");
const result = await podcastApi.createProject(payload, bible, feedback);
if (result.bible) {
setBible(result.bible);
} else if (dbProject?.bible) {
setBible(dbProject.bible);
}
// Update the project in database with the analysis results
try {
await podcastApi.updateProject(projectId, {
analysis: result.analysis,
estimate: result.estimate,
queries: result.queries,
selected_queries: result.queries.map(q => q.id),
avatar_url: result.avatar_url,
avatar_prompt: result.avatar_prompt,
});
} catch (error) {
console.error('Failed to update project with analysis results:', error);
}
setProject({
id: projectId,
idea: payload.ideaOrUrl,
duration: payload.duration,
speakers: payload.speakers,
avatarUrl: result.avatar_url || avatarUrl,
avatarPrompt: result.avatar_prompt || null,
avatarPersonaId: null,
});
setAnalysis(result.analysis);
setEstimate(result.estimate);
setQueries(result.queries);
setSelectedQueries(new Set(result.queries.map((q) => q.id)));
setKnobs(payload.knobs);
setBudgetCap(payload.budgetCap);
// Generate presenters AFTER analysis completes (to use analysis insights)
// This happens only if no avatar was uploaded
if (!avatarUrl && payload.speakers > 0 && result.analysis) {
try {
setAnnouncement("Generating presenter avatars using AI insights...");
const presentersResponse = await podcastApi.generatePresenters(
payload.speakers,
result.projectId,
result.analysis.audience,
result.analysis.contentType,
result.analysis.topKeywords
);
if (presentersResponse.avatars && presentersResponse.avatars.length > 0) {
// Store the first presenter avatar URL and prompt
const firstAvatar = presentersResponse.avatars[0];
const prompt = firstAvatar.prompt || null;
setProject({
id: result.projectId,
idea: payload.ideaOrUrl,
duration: payload.duration,
speakers: payload.speakers,
avatarUrl: firstAvatar.avatar_url,
avatarPrompt: prompt,
avatarPersonaId: firstAvatar.persona_id || presentersResponse.persona_id || null,
});
setAnnouncement("Analysis complete - Presenter avatars generated");
}
} catch (error) {
console.error('Presenter generation failed:', error);
setAnnouncement("Analysis complete - Avatar generation will happen later");
// Continue without presenters - can generate later
}
} else {
setAnnouncement("Analysis complete");
}
} catch (error: any) {
if (error?.response?.status === 429 || error?.response?.data?.detail) {
const errorDetail = error.response.data.detail;
if (typeof errorDetail === 'object' && errorDetail.error && errorDetail.error.includes('limit')) {
const usageInfo = errorDetail.usage_info || {};
const blockedResponse = {
can_proceed: false,
estimated_cost: 0,
operations: [{
provider: errorDetail.provider || 'huggingface',
operation_type: 'ai_text_generation',
cost: 0,
allowed: false,
limit_info: usageInfo.limit_info || null,
message: errorDetail.message || errorDetail.error || 'Subscription limit exceeded',
}],
total_cost: 0,
usage_summary: usageInfo.usage_summary || null,
cached: false,
};
setPreflightResponse(blockedResponse);
setPreflightOperationName('Podcast Analysis');
setShowPreflightDialog(true);
setAnnouncement("Subscription limit reached. Please upgrade to continue.");
} else {
const message = typeof errorDetail === 'string' ? errorDetail : errorDetail.message || errorDetail.error || 'Request limit exceeded';
announceError(setAnnouncement, new Error(message));
}
} else {
announceError(setAnnouncement, error);
}
} finally {
setIsAnalyzing(false);
}
}, [isAnalyzing, setResearch, setRawResearch, setScriptData, setShowScriptEditor, setShowRenderQueue, initializeProject, setProject, setAnalysis, setEstimate, setQueries, setSelectedQueries, setKnobs, setBudgetCap, setBible]);
const handleRunResearch = useCallback(async () => {
if (isResearching) return;
if (!project) {
setAnnouncement("Create a project first.");
return;
}
if (selectedQueries.size === 0) {
setAnnouncement("Select at least one query to research.");
return;
}
setPreflightOperationName("Research");
const approvedQueries = queries.filter((q) => selectedQueries.has(q.id));
const preflightResult = await preflightCheck.check({
provider: researchProvider === "exa" ? "exa" : "gemini",
operation_type: researchProvider === "exa" ? "exa_neural_search" : "google_grounding",
tokens_requested: researchProvider === "exa" ? 0 : 1200,
actual_provider_name: researchProvider || "exa",
});
if (!preflightResult.can_proceed) {
return;
}
try {
setIsResearching(true);
setAnnouncement(`Starting ${researchProvider === "exa" ? "deep" : "standard"} research — this may take a moment...`);
setResearch(null);
setRawResearch(null);
setScriptData(null);
setShowScriptEditor(false);
setShowRenderQueue(false);
try {
const { research: mapped, raw } = await podcastApi.runResearch({
projectId: project.id,
topic: project.idea,
approvedQueries,
provider: researchProvider,
exaConfig: sanitizeExaConfig(analysis?.exaSuggestedConfig),
bible: projectState.bible,
analysis: analysis,
onProgress: (message) => {
setAnnouncement(message);
},
});
setResearch(mapped);
setRawResearch(raw);
setAnnouncement("Research complete — review fact cards below");
} catch (researchError) {
const errorMessage = researchError instanceof Error
? researchError.message
: "Research failed. Please try again or switch to Standard Research.";
if (errorMessage.includes("Exa") || errorMessage.includes("exa")) {
setAnnouncement(`Deep research failed: ${errorMessage}. Try Standard Research instead.`);
} else if (errorMessage.includes("timeout")) {
setAnnouncement("Research timed out. Please try again with fewer queries.");
} else {
setAnnouncement(`Research failed: ${errorMessage}`);
}
console.error("Research error:", researchError);
throw researchError;
}
} catch (error) {
announceError(setAnnouncement, error);
} finally {
setIsResearching(false);
}
}, [isResearching, project, selectedQueries, queries, researchProvider, preflightCheck, analysis, setResearch, setRawResearch, setScriptData, setShowScriptEditor, setShowRenderQueue, projectState.bible]);
const handleGenerateScript = useCallback(async () => {
if (showScriptEditor) return;
if (!project || !research) {
setAnnouncement("Project or research missing — cannot generate script");
return;
}
setPreflightOperationName("Script Generation");
const preflightResult = await preflightCheck.check({
provider: "gemini",
operation_type: "script_generation",
tokens_requested: 2000,
actual_provider_name: "gemini",
});
if (!preflightResult.can_proceed) {
return;
}
setScriptData(null);
setShowRenderQueue(false);
setShowScriptEditor(true);
try {
const result = await podcastApi.generateScript({
projectId: project.id,
idea: project.idea,
research: rawResearch,
knobs: projectState.knobs,
speakers: project.speakers,
durationMinutes: project.duration,
bible: projectState.bible,
outline: analysis?.suggestedOutlines?.[0], // Pass the first (possibly refined) outline
analysis: analysis, // Pass full analysis context
});
setScriptData(result);
} catch (error) {
announceError(setAnnouncement, error);
}
}, [showScriptEditor, project, research, preflightCheck, setScriptData, setShowRenderQueue, setShowScriptEditor, rawResearch, projectState.knobs, projectState.bible])
const handleProceedToRendering = useCallback((script: Script) => {
setScriptData(script);
if (renderJobs.length === 0) {
script.scenes.forEach((scene) => {
const hasExistingAudio = Boolean(scene.audioUrl);
updateRenderJob(scene.id, {
sceneId: scene.id,
title: scene.title,
status: hasExistingAudio ? ("completed" as const) : ("idle" as const),
progress: hasExistingAudio ? 100 : 0,
previewUrl: null,
finalUrl: hasExistingAudio ? scene.audioUrl : null,
jobId: null,
});
});
}
setShowRenderQueue(true);
setShowScriptEditor(false);
}, [renderJobs.length, setScriptData, updateRenderJob, setShowRenderQueue, setShowScriptEditor]);
const toggleQuery = useCallback((id: string) => {
if (isResearching) return;
const current = selectedQueries;
const next = new Set<string>(current);
if (next.has(id)) next.delete(id);
else next.add(id);
setSelectedQueries(next);
}, [isResearching, selectedQueries, setSelectedQueries]);
const activeStep = useMemo(() => {
if (showRenderQueue) return 3;
if (showScriptEditor) return 2;
if (currentStep === 'research' || research) return 1;
if (currentStep === 'analysis' || analysis) return 0;
return -1;
}, [showRenderQueue, showScriptEditor, currentStep, research, analysis]);
const canGenerateScript = Boolean(project && research && rawResearch);
const handleRegenerate = useCallback(async (feedback?: string) => {
if (!project) return;
// Prepare the payload from existing project state
const payload: CreateProjectPayload = {
ideaOrUrl: project.idea,
duration: project.duration,
speakers: project.speakers,
knobs: projectState.knobs,
budgetCap: projectState.budgetCap,
avatarUrl: project.avatarUrl,
files: {} // No new files for regeneration
};
await handleCreate(payload, feedback);
}, [project, projectState.knobs, projectState.budgetCap, handleCreate]);
return {
// State
isAnalyzing,
isResearching,
announcement,
showResumeAlert,
showPreflightDialog,
preflightResponse,
preflightOperationName,
activeStep,
canGenerateScript,
// Handlers
handleCreate,
handleRegenerate,
handleRunResearch,
handleGenerateScript,
handleProceedToRendering,
toggleQuery,
setAnnouncement,
setShowResumeAlert,
setShowPreflightDialog,
setPreflightResponse,
setResearchProvider,
getStepLabel,
};
};

View File

@@ -1,184 +0,0 @@
#!/usr/bin/env python3
"""
Migration script to add missing columns to usage_summaries table.
Run this once to fix the database schema.
Usage:
python add_missing_columns.py
"""
import sqlite3
from pathlib import Path
def get_db_path():
"""Find the database path."""
possible_paths = [
Path(__file__).parent / "backend" / "alwrity.db",
Path(__file__).parent.parent / "backend" / "alwrity.db",
Path("C:/Users/diksha rawat/Desktop/ALwrity_github/windsurf/ALwrity/backend/alwrity.db"),
]
for db_path in possible_paths:
if db_path.exists():
print(f"Using database: {db_path}")
return db_path
backend_dir = Path(__file__).parent / "backend"
if backend_dir.exists():
db_files = list(backend_dir.glob("*.db"))
if db_files:
print(f"Found database: {db_files[0]}")
return db_files[0]
raise FileNotFoundError(f"Database not found. Searched: {possible_paths}")
def create_usage_summaries_table(cursor):
"""Create the usage_summaries table if it doesn't exist."""
cursor.execute("""
CREATE TABLE IF NOT EXISTS usage_summaries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id VARCHAR(100) NOT NULL,
billing_period VARCHAR(20) NOT NULL,
-- API Call Counts
gemini_calls INTEGER DEFAULT 0,
openai_calls INTEGER DEFAULT 0,
anthropic_calls INTEGER DEFAULT 0,
mistral_calls INTEGER DEFAULT 0,
wavespeed_calls INTEGER DEFAULT 0,
tavily_calls INTEGER DEFAULT 0,
serper_calls INTEGER DEFAULT 0,
metaphor_calls INTEGER DEFAULT 0,
firecrawl_calls INTEGER DEFAULT 0,
stability_calls INTEGER DEFAULT 0,
exa_calls INTEGER DEFAULT 0,
video_calls INTEGER DEFAULT 0,
image_edit_calls INTEGER DEFAULT 0,
audio_calls INTEGER DEFAULT 0,
-- Token Usage
gemini_tokens INTEGER DEFAULT 0,
openai_tokens INTEGER DEFAULT 0,
anthropic_tokens INTEGER DEFAULT 0,
mistral_tokens INTEGER DEFAULT 0,
wavespeed_tokens INTEGER DEFAULT 0,
-- Cost Tracking
gemini_cost REAL DEFAULT 0.0,
openai_cost REAL DEFAULT 0.0,
anthropic_cost REAL DEFAULT 0.0,
mistral_cost REAL DEFAULT 0.0,
wavespeed_cost REAL DEFAULT 0.0,
tavily_cost REAL DEFAULT 0.0,
serper_cost REAL DEFAULT 0.0,
metaphor_cost REAL DEFAULT 0.0,
firecrawl_cost REAL DEFAULT 0.0,
stability_cost REAL DEFAULT 0.0,
exa_cost REAL DEFAULT 0.0,
video_cost REAL DEFAULT 0.0,
image_edit_cost REAL DEFAULT 0.0,
audio_cost REAL DEFAULT 0.0,
-- Totals
total_calls INTEGER DEFAULT 0,
total_tokens INTEGER DEFAULT 0,
total_cost REAL DEFAULT 0.0,
-- Performance Metrics
avg_response_time REAL DEFAULT 0.0,
error_rate REAL DEFAULT 0.0,
usage_status VARCHAR(20) DEFAULT 'active',
warnings_sent INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, billing_period)
)
""")
print("Created usage_summaries table")
def add_missing_columns():
db_path = get_db_path()
print(f"Using database: {db_path}")
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# Check what tables exist
cursor.execute("SELECT name FROM sqlite_master WHERE type='table'")
tables = [row[0] for row in cursor.fetchall()]
print(f"Tables in database: {tables}")
# Check if usage_summaries exists
if "usage_summaries" not in tables:
print("usage_summaries table doesn't exist. Creating it...")
create_usage_summaries_table(cursor)
conn.commit()
conn.close()
print("Done! Table created successfully.")
return
# Get existing columns
cursor.execute("PRAGMA table_info(usage_summaries)")
existing_columns = {row[1] for row in cursor.fetchall()}
print(f"Existing columns in usage_summaries: {len(existing_columns)}")
# Columns to add (name, type, default)
columns_to_add = [
# Call counts
("wavespeed_calls", "INTEGER", "0"),
("tavily_calls", "INTEGER", "0"),
("serper_calls", "INTEGER", "0"),
("metaphor_calls", "INTEGER", "0"),
("firecrawl_calls", "INTEGER", "0"),
("stability_calls", "INTEGER", "0"),
("exa_calls", "INTEGER", "0"),
("video_calls", "INTEGER", "0"),
("image_edit_calls", "INTEGER", "0"),
("audio_calls", "INTEGER", "0"),
# Token usage
("wavespeed_tokens", "INTEGER", "0"),
# Cost tracking
("wavespeed_cost", "REAL", "0.0"),
("tavily_cost", "REAL", "0.0"),
("serper_cost", "REAL", "0.0"),
("metaphor_cost", "REAL", "0.0"),
("firecrawl_cost", "REAL", "0.0"),
("stability_cost", "REAL", "0.0"),
("exa_cost", "REAL", "0.0"),
("video_cost", "REAL", "0.0"),
("image_edit_cost", "REAL", "0.0"),
("audio_cost", "REAL", "0.0"),
]
added = []
skipped = []
for col_name, col_type, default in columns_to_add:
if col_name in existing_columns:
skipped.append(col_name)
continue
try:
sql = f"ALTER TABLE usage_summaries ADD COLUMN {col_name} {col_type} DEFAULT {default}"
cursor.execute(sql)
added.append(col_name)
print(f" Added: {col_name}")
except sqlite3.Error as e:
print(f" Error adding {col_name}: {e}")
conn.commit()
conn.close()
print(f"\nSummary:")
print(f" Added: {len(added)} columns")
print(f" Skipped (already exist): {len(skipped)} columns")
if added:
print(f"\nColumns added: {', '.join(added)}")
if skipped:
print(f"Already existed: {', '.join(skipped)}")
if __name__ == "__main__":
add_missing_columns()

157
backend/add_method.py Normal file
View File

@@ -0,0 +1,157 @@
#!/usr/bin/env python
# Add _get_all_historical_usage method to usage_tracking_service.py
with open('services/subscription/usage_tracking_service.py', 'r', encoding='utf-8') as f:
lines = f.readlines()
# Find where to insert (before get_usage_trends)
insert_idx = None
for i, line in enumerate(lines):
if ' def get_usage_trends(' in line:
insert_idx = i
break
if insert_idx is None:
print("Error: Could not find insertion point")
exit(1)
print(f"Inserting at line {insert_idx + 1}")
# Method to insert
new_method = ''' def _get_all_historical_usage(self, user_id: str) -> Dict[str, Any]:
"""Get ALL historical usage data aggregated across all billing periods."""
# Get all usage summaries for the user
all_summaries = self.db.query(UsageSummary).filter(
UsageSummary.user_id == user_id
).order_by(UsageSummary.billing_period.desc()).all()
if not all_summaries:
return {
'billing_period': 'all',
'usage_status': 'active',
'total_calls': 0,
'total_tokens': 0,
'total_cost': 0.0,
'avg_response_time': 0.0,
'error_rate': 0.0,
'limits': self.pricing_service.get_user_limits(user_id),
'provider_breakdown': {},
'usage_percentages': {},
'historical_breakdown': [],
'last_updated': datetime.now().isoformat()
}
# Aggregate all data from UsageSummary
total_calls = sum(s.total_calls or 0 for s in all_summaries)
total_tokens = sum(s.total_tokens or 0 for s in all_summaries)
total_cost = sum(float(s.total_cost or 0) for s in all_summaries)
# Calculate weighted average response time
total_weighted_time = sum((s.avg_response_time or 0) * (s.total_calls or 0) for s in all_summaries)
avg_response_time = total_weighted_time / total_calls if total_calls > 0 else 0.0
# Calculate overall error rate
total_errors = sum((s.total_calls or 0) * (s.error_rate or 0) / 100 for s in all_summaries)
error_rate = (total_errors / total_calls * 100) if total_calls > 0 else 0.0
# Get user limits
limits = self.pricing_service.get_user_limits(user_id)
# Map database columns to frontend keys
provider_mapping = {
'gemini_calls': 'gemini',
'openai_calls': 'openai',
'anthropic_calls': 'anthropic',
'mistral_calls': 'huggingface',
'wavespeed_calls': 'wavespeed',
'exa_calls': 'exa',
'video_calls': 'video',
'image_edit_calls': 'image_edit',
'audio_calls': 'audio',
}
# Build provider_breakdown for frontend
provider_breakdown = {}
for db_col, frontend_key in provider_mapping.items():
total_provider_calls = sum(getattr(s, db_col, 0) or 0 for s in all_summaries)
provider_breakdown[frontend_key] = {
'calls': total_provider_calls,
'cost': 0,
'tokens': 0
}
# Calculate usage_percentages based on limits
usage_percentages = {}
if limits and limits.get('limits'):
# Gemini calls percentage
gemini_calls = provider_breakdown.get('gemini', {}).get('calls', 0)
gemini_limit = limits.get('limits', {}).get('gemini_calls', 0) or 0
if gemini_limit > 0:
usage_percentages['gemini_calls'] = (gemini_calls / gemini_limit) * 100
# HuggingFace calls percentage (from mistral_calls)
huggingface_calls = provider_breakdown.get('huggingface', {}).get('calls', 0)
huggingface_limit = limits.get('limits', {}).get('mistral_calls', 0) or 0
if huggingface_limit > 0:
usage_percentages['huggingface_calls'] = (huggingface_calls / huggingface_limit) * 100
# Cost percentage
cost_limit = limits.get('limits', {}).get('monthly_cost', 0) or 0
if cost_limit > 0:
usage_percentages['cost'] = (total_cost / cost_limit) * 100
# Build historical breakdown
historical_breakdown = []
for s in all_summaries:
try:
status_val = s.usage_status.value
except:
status_val = str(s.usage_status)
historical_breakdown.append({
'billing_period': s.billing_period,
'total_calls': s.total_calls or 0,
'total_tokens': s.total_tokens or 0,
'total_cost': float(s.total_cost or 0),
'usage_status': status_val,
'updated_at': s.updated_at.isoformat() if s.updated_at else None
})
# Determine overall status
usage_status = 'active'
for s in all_summaries:
try:
status = s.usage_status.value
except:
status = str(s.usage_status)
if status == 'limit_reached':
usage_status = 'limit_reached'
break
elif status == 'warning' and usage_status != 'limit_reached':
usage_status = 'warning'
return {
'billing_period': 'all',
'usage_status': usage_status,
'total_calls': total_calls,
'total_tokens': total_tokens,
'total_cost': round(total_cost, 2),
'avg_response_time': round(avg_response_time, 2),
'error_rate': round(error_rate, 2),
'limits': limits,
'provider_breakdown': provider_breakdown,
'usage_percentages': usage_percentages,
'historical_breakdown': historical_breakdown,
'last_updated': datetime.now().isoformat()
}
'''
# Insert the new method
new_lines = lines[:insert_idx] + [new_method] + lines[insert_idx:]
# Write back
with open('services/subscription/usage_tracking_service.py', 'w', encoding='utf-8') as f:
f.writelines(new_lines)
print("Successfully added _get_all_historical_usage method")

View File

@@ -5,8 +5,8 @@ Modular utilities for ALwrity backend startup and configuration.
import os
# Check podcast mode early to skip heavy imports
_is_podcast = os.getenv("ALWRITY_ENABLED_FEATURES", "").strip().lower() == "podcast"
# Check feature mode early to skip heavy imports
_is_full_mode = os.getenv("ALWRITY_ENABLED_FEATURES", "").strip().lower() in ("", "all")
from .dependency_manager import DependencyManager
from .environment_setup import EnvironmentSetup
@@ -26,41 +26,25 @@ from .feature_runtime import (
)
# Lazy load OnboardingManager - it triggers heavy imports (aiohttp, etc.)
if not _is_podcast:
if _is_full_mode:
from .onboarding_manager import OnboardingManager
__all__ = [
'DependencyManager',
'EnvironmentSetup',
'DatabaseSetup',
'ProductionOptimizer',
'HealthChecker',
'RateLimiter',
'FrontendServing',
'RouterManager',
'OnboardingManager',
'get_active_profiles',
'get_enabled_groups',
'get_enabled_optional_services',
'get_enabled_routers',
'get_enabled_startup_hooks',
'is_enabled'
]
else:
OnboardingManager = None
__all__ = [
'DependencyManager',
'EnvironmentSetup',
'DatabaseSetup',
'ProductionOptimizer',
'HealthChecker',
'RateLimiter',
'FrontendServing',
'RouterManager',
'OnboardingManager',
'get_active_profiles',
'get_enabled_groups',
'get_enabled_optional_services',
'get_enabled_routers',
'get_enabled_startup_hooks',
'is_enabled'
]
__all__ = [
'DependencyManager',
'EnvironmentSetup',
'DatabaseSetup',
'ProductionOptimizer',
'HealthChecker',
'RateLimiter',
'FrontendServing',
'RouterManager',
'OnboardingManager',
'get_active_profiles',
'get_enabled_groups',
'get_enabled_optional_services',
'get_enabled_routers',
'get_enabled_startup_hooks',
'is_enabled'
]

View File

@@ -51,6 +51,13 @@ FEATURE_GROUPS: Dict[str, FeatureGroup] = {
"api.content_planning.strategy_copilot:router",
),
),
"blog_writer": FeatureGroup(
features=("blog_writer",),
routers=(
"api.blog_writer.router:router",
"api.blog_writer.seo_analysis:router",
),
),
}
@@ -59,5 +66,6 @@ PROFILE_GROUP_MAP: Dict[str, Tuple[str, ...]] = {
"core": ("core",),
"podcast": ("core", "podcast"),
"youtube": ("core", "youtube"),
"blog_writer": ("core", "blog_writer"),
"planning": ("core", "content_planning"),
}

View File

@@ -14,7 +14,7 @@ from loguru import logger
CORE_ROUTER_REGISTRY = [
{"name": "component_logic", "module": "api.component_logic", "attr": "router", "features": {"all", "core"}},
{"name": "subscription", "module": "api.subscription", "attr": "router", "features": {"all", "core", "podcast", "blog-writer", "youtube"}},
{"name": "subscription", "module": "api.subscription", "attr": "router", "features": {"all", "core", "podcast", "blog_writer", "youtube"}},
{"name": "step3_research", "module": "api.onboarding_utils.step3_routes", "attr": "router", "features": {"all", "core"}},
{"name": "step4_assets", "module": "api.onboarding_utils.step4_asset_routes", "attr": "router", "features": {"all", "core", "podcast"}},
{"name": "step4_persona", "module": "api.onboarding_utils.step4_persona_routes_optimized", "attr": "router", "features": {"all", "core"}},
@@ -29,31 +29,31 @@ CORE_ROUTER_REGISTRY = [
{"name": "linkedin_image", "module": "api.linkedin_image_generation", "attr": "router", "features": {"all", "core", "linkedin"}},
{"name": "brainstorm", "module": "api.brainstorm", "attr": "router", "features": {"all", "core"}},
{"name": "hallucination_detector", "module": "api.hallucination_detector", "attr": "router", "features": {"all", "core"}},
{"name": "writing_assistant", "module": "api.writing_assistant", "attr": "router", "features": {"all", "core"}},
{"name": "content_planning", "module": "api.content_planning.api.router", "attr": "router", "features": {"all", "core", "content-planning"}},
{"name": "user_data", "module": "api.user_data", "attr": "router", "features": {"all", "core"}},
{"name": "user_environment", "module": "api.user_environment", "attr": "router", "features": {"all", "core"}},
{"name": "strategy_copilot", "module": "api.content_planning.strategy_copilot", "attr": "router", "features": {"all", "core", "content-planning"}},
{"name": "error_logging", "module": "routers.error_logging", "attr": "router", "features": {"all", "core"}},
{"name": "frontend_env_manager", "module": "routers.frontend_env_manager", "attr": "router", "features": {"all", "core"}},
{"name": "writing_assistant", "module": "api.writing_assistant", "attr": "router", "features": {"all", "core", "blog_writer"}},
{"name": "content_planning", "module": "api.content_planning.api.router", "attr": "router", "features": {"all", "core", "content_planning"}},
{"name": "user_data", "module": "api.user_data", "attr": "router", "features": {"all", "core", "blog_writer"}},
{"name": "user_environment", "module": "api.user_environment", "attr": "router", "features": {"all", "core", "blog_writer"}},
{"name": "strategy_copilot", "module": "api.content_planning.strategy_copilot", "attr": "router", "features": {"all", "core", "content_planning"}},
{"name": "error_logging", "module": "routers.error_logging", "attr": "router", "features": {"all", "core", "blog_writer"}},
{"name": "frontend_env_manager", "module": "routers.frontend_env_manager", "attr": "router", "features": {"all", "core", "blog_writer"}},
{"name": "platform_analytics", "module": "routers.platform_analytics", "attr": "router", "features": {"all", "core"}},
{"name": "bing_insights", "module": "routers.bing_insights", "attr": "router", "features": {"all", "core", "seo"}},
{"name": "background_jobs", "module": "routers.background_jobs", "attr": "router", "features": {"all", "core"}},
]
OPTIONAL_ROUTER_REGISTRY = [
{"name": "blog_writer", "module": "api.blog_writer.router", "attr": "router", "features": {"all", "blog-writer"}},
{"name": "story_writer", "module": "api.story_writer.router", "attr": "router", "features": {"all", "story-writer"}},
{"name": "blog_writer", "module": "api.blog_writer.router", "attr": "router", "features": {"all", "blog_writer"}},
{"name": "story_writer", "module": "api.story_writer.router", "attr": "router", "features": {"all", "story_writer"}},
{"name": "wix", "module": "api.wix_routes", "attr": "router", "features": {"all"}},
{"name": "blog_seo_analysis", "module": "api.blog_writer.seo_analysis", "attr": "router", "features": {"all", "blog-writer"}},
{"name": "blog_seo_analysis", "module": "api.blog_writer.seo_analysis", "attr": "router", "features": {"all", "blog_writer"}},
{"name": "persona", "module": "api.persona_routes", "attr": "router", "features": {"all", "persona"}},
{"name": "video_studio", "module": "api.video_studio.router", "attr": "router", "features": {"all", "video-studio"}},
{"name": "stability", "module": "routers.stability", "attr": "router", "features": {"all", "image-studio"}},
{"name": "stability_advanced", "module": "routers.stability_advanced", "attr": "router", "features": {"all", "image-studio"}},
{"name": "stability_admin", "module": "routers.stability_admin", "attr": "router", "features": {"all", "image-studio"}},
{"name": "images", "module": "api.images", "attr": "router", "features": {"all", "image-studio"}},
{"name": "image_studio", "module": "routers.image_studio", "attr": "router", "features": {"all", "image-studio"}},
{"name": "product_marketing", "module": "routers.product_marketing", "attr": "router", "features": {"all", "product-marketing"}},
{"name": "video_studio", "module": "api.video_studio.router", "attr": "router", "features": {"all", "video_studio"}},
{"name": "stability", "module": "routers.stability", "attr": "router", "features": {"all", "image_studio"}},
{"name": "stability_advanced", "module": "routers.stability_advanced", "attr": "router", "features": {"all", "image_studio"}},
{"name": "stability_admin", "module": "routers.stability_admin", "attr": "router", "features": {"all", "image_studio"}},
{"name": "images", "module": "api.images", "attr": "router", "features": {"all", "image_studio"}},
{"name": "image_studio", "module": "routers.image_studio", "attr": "router", "features": {"all", "image_studio"}},
{"name": "product_marketing", "module": "routers.product_marketing", "attr": "router", "features": {"all", "product_marketing"}},
{"name": "campaign_creator", "module": "routers.campaign_creator", "attr": "router", "features": {"all"}},
{"name": "content_assets", "module": "api.content_assets.router", "attr": "router", "features": {"all"}},
{"name": "podcast", "module": "api.podcast.router", "attr": "router", "features": {"all", "podcast"}},

View File

@@ -7,12 +7,11 @@ The onboarding endpoints are re-exported from a stable module
import os
# Check podcast mode early
_is_podcast = os.getenv("ALWRITY_ENABLED_FEATURES", "").strip().lower() == "podcast"
# In podcast mode, don't import heavy onboarding endpoints
# In feature-only modes, don't import heavy onboarding endpoints
# They trigger heavy dependencies (exa_py, etc.)
if _is_podcast:
_is_full_mode = os.getenv("ALWRITY_ENABLED_FEATURES", "").strip().lower() in ("", "all")
if not _is_full_mode:
__all__ = []
else:
from .onboarding_endpoints import (

View File

@@ -1195,3 +1195,68 @@ async def generate_introductions(
except Exception as e:
logger.error(f"Failed to generate introductions: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ---------------------------
# Save Complete Blog Asset
# ---------------------------
class SaveCompleteBlogAssetRequest(BaseModel):
title: str
content: str
seo_title: Optional[str] = None
meta_description: Optional[str] = None
focus_keyword: Optional[str] = None
tags: List[str] = Field(default_factory=list)
categories: List[str] = Field(default_factory=list)
@router.post("/save-complete-asset")
async def save_complete_blog_asset(
request: SaveCompleteBlogAssetRequest,
current_user: Dict[str, Any] = Depends(get_current_user),
db: Session = Depends(get_db),
) -> Dict[str, Any]:
"""Save the complete blog content as a single asset in the asset library."""
try:
if not current_user:
raise HTTPException(status_code=401, detail="Authentication required")
user_id = str(current_user.get('id', ''))
if not user_id:
raise HTTPException(status_code=401, detail="Invalid user ID in authentication token")
full_content = f"# {request.title}\n\n{request.content}"
asset_id = save_and_track_text_content(
db=db,
user_id=user_id,
content=full_content,
source_module="blog_writer",
title=f"Published Blog: {request.title[:60]}",
description=request.meta_description or f"Complete published blog post: {request.title}",
prompt=f"SEO Title: {request.seo_title or request.title}\nFocus Keyword: {request.focus_keyword or ''}",
tags=["blog", "published"] + [t for t in (request.tags or []) if t],
asset_metadata={
"status": "published",
"focus_keyword": request.focus_keyword,
"categories": request.categories,
"word_count": len(full_content.split()),
},
subdirectory="published",
file_extension=".md"
)
if asset_id:
logger.info(f"✅ Complete blog asset saved to library: ID={asset_id}")
return {"success": True, "asset_id": asset_id}
else:
logger.warning("save_and_track_text_content returned None for published blog")
return {"success": False, "error": "Failed to save blog asset"}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to save complete blog asset: {e}")
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -13,7 +13,7 @@ from typing import Any, Dict, List
from fastapi import HTTPException
from loguru import logger
from sqlalchemy.orm import Session
from services.database import SessionLocal, get_session_for_user
from services.database import get_session_for_user
from models.blog_models import (
BlogResearchRequest,
@@ -264,7 +264,7 @@ class TaskManager:
raise ValueError("Global target words exceed 1000; medium generation not allowed")
# Create a sync session for asset saving
db_session = SessionLocal()
db_session = get_session_for_user(user_id)
try:
result: MediumBlogGenerateResult = await self.service.generate_medium_blog_with_progress(
request,
@@ -326,6 +326,7 @@ class TaskManager:
await self.update_progress(task_id, f"❌ Medium generation failed: {str(e)}")
self.task_storage[task_id]["status"] = "failed"
self.task_storage[task_id]["error"] = str(e)
self.task_storage[task_id]["error_data"] = {"error_message": str(e), "error_type": type(e).__name__}
# Global task manager instance

View File

@@ -202,6 +202,26 @@ Listener CTA: {request.analysis.get('listener_cta', 'N/A')}
interests = ", ".join(audience_dna.get("interests", []))
target_audience = f"Expertise: {audience_dna.get('expertise_level', '')}. Interests: {interests}."
# Preflight subscription check for Exa
try:
pricing_service = PricingService(db)
can_proceed, message, usage_info = pricing_service.check_usage_limits(
user_id=user_id,
provider=APIProvider.EXA,
tokens_requested=0,
actual_provider_name="exa",
)
if not can_proceed:
raise HTTPException(status_code=429, detail={
'error': message, 'message': message,
'provider': 'exa', 'usage_info': usage_info or {}
})
logger.info(f"[Podcast Research] Preflight check passed for user {user_id}")
except HTTPException:
raise
except Exception as e:
logger.warning(f"[Podcast Research] Preflight check failed: {e}")
try:
# 1. RUN EXA SEARCH
logger.warning(f"[Podcast Research] Calling Exa search with topic: {request.topic[:100]}...")

View File

@@ -9,10 +9,13 @@ from typing import Dict, Any, List, Optional
from pydantic import BaseModel
from loguru import logger
from types import SimpleNamespace
from sqlalchemy import text
from middleware.auth_middleware import get_current_user
from api.story_writer.utils.auth import require_authenticated_user
from services.research.tavily_service import TavilyService
from services.blog_writer.research.exa_provider import ExaResearchProvider
from services.subscription import PricingService
from models.subscription_models import APIProvider
router = APIRouter(prefix="/research", tags=["Podcast Category Research"])
@@ -29,6 +32,75 @@ EXA_CATEGORY_MAP = {
}
def _preflight_check(user_id: str, provider: APIProvider, provider_name: str):
"""Check subscription limits before making a research API call."""
from services.database import get_session_for_user
db = get_session_for_user(user_id)
if not db:
return
try:
pricing_service = PricingService(db)
can_proceed, message, usage_info = pricing_service.check_usage_limits(
user_id=user_id,
provider=provider,
tokens_requested=0,
actual_provider_name=provider_name,
)
if not can_proceed:
raise HTTPException(status_code=429, detail={
'error': message, 'message': message,
'provider': provider_name, 'usage_info': usage_info or {}
})
except HTTPException:
raise
except Exception as e:
logger.warning(f"[CategoryResearch] Preflight check failed for {provider_name}: {e}")
finally:
db.close()
def _track_research_usage(user_id: str, provider_name: str, cost: float, calls_column: str, cost_column: str):
"""Track research API usage after successful call."""
from services.database import get_session_for_user
db = get_session_for_user(user_id)
if not db:
logger.warning(f"[CategoryResearch] Could not get DB session for user {user_id}")
return
try:
pricing_service = PricingService(db)
current_period = pricing_service.get_current_billing_period(user_id)
update_query = text(f"""
UPDATE usage_summaries
SET {calls_column} = COALESCE({calls_column}, 0) + 1,
{cost_column} = COALESCE({cost_column}, 0) + :cost,
total_calls = COALESCE(total_calls, 0) + 1,
total_cost = COALESCE(total_cost, 0) + :cost
WHERE user_id = :user_id AND billing_period = :period
""")
db.execute(update_query, {
'cost': cost,
'user_id': user_id,
'period': current_period,
})
db.commit()
logger.info(f"[CategoryResearch] Tracked {provider_name} usage: user={user_id}, cost=${cost}")
# Clear dashboard cache so header stats update immediately
try:
from api.subscription.cache import clear_dashboard_cache
clear_dashboard_cache(user_id)
except Exception as cache_err:
logger.warning(f"[CategoryResearch] Failed to clear dashboard cache: {cache_err}")
except Exception as e:
logger.error(f"[CategoryResearch] Failed to track {provider_name} usage: {e}")
db.rollback()
finally:
db.close()
class CategoryResearchRequest(BaseModel):
category: str
keyword: Optional[str] = None
@@ -80,9 +152,12 @@ def _normalize_exa_results(results: List[Dict], query: str) -> List[CategoryTopi
return topics
async def _search_tavily(category: str, keyword: str, max_results: int) -> CategoryResearchResponse:
async def _search_tavily(category: str, keyword: str, max_results: int, user_id: str) -> CategoryResearchResponse:
logger.info(f"[CategoryResearch] Using Tavily for category={category}, keyword={keyword}")
# Preflight subscription check
_preflight_check(user_id, APIProvider.TAVILY, "tavily")
try:
tavily = TavilyService()
result = await tavily.search(
@@ -102,6 +177,10 @@ async def _search_tavily(category: str, keyword: str, max_results: int) -> Categ
topics = _normalize_tavily_results(result.get("results", []))
logger.info(f"[CategoryResearch] Tavily found {len(topics)} topics")
# Track usage
cost = 0.001 # basic search = 1 credit
_track_research_usage(user_id, "tavily", cost, "tavily_calls", "tavily_cost")
return CategoryResearchResponse(
success=True,
category=category,
@@ -117,7 +196,7 @@ async def _search_tavily(category: str, keyword: str, max_results: int) -> Categ
raise HTTPException(status_code=500, detail=str(e))
async def _search_exa(category: str, keyword: str, max_results: int, website_url: Optional[str] = None) -> CategoryResearchResponse:
async def _search_exa(category: str, keyword: str, max_results: int, user_id: str, website_url: Optional[str] = None) -> CategoryResearchResponse:
exa_category = EXA_CATEGORY_MAP.get(category, category)
logger.info(f"[CategoryResearch] Exa: category={category}, exa_category={exa_category}, keyword={keyword}, website_url={website_url}")
@@ -133,6 +212,9 @@ async def _search_exa(category: str, keyword: str, max_results: int, website_url
from exa_py import Exa
exa = Exa(exa_api_key)
logger.info(f"[CategoryResearch] Exa client initialized")
# Preflight subscription check
_preflight_check(user_id, APIProvider.EXA, "exa")
# Build search parameters
search_params = {
@@ -189,6 +271,10 @@ async def _search_exa(category: str, keyword: str, max_results: int, website_url
logger.info(f"[CategoryResearch] Exa found {len(topics)} topics")
# Track usage
cost = 0.005 # Default Exa cost for 1-25 results
_track_research_usage(user_id, "exa", cost, "exa_calls", "exa_cost")
return CategoryResearchResponse(
success=True,
category=category,
@@ -218,6 +304,7 @@ async def research_by_category(
- news, finance: Uses Tavily
- research-paper, personal-site: Uses Exa
"""
user_id = require_authenticated_user(current_user)
category = request.category.lower()
valid_categories = list(CATEGORY_PROVIDER_MAP.keys())
@@ -241,9 +328,9 @@ async def research_by_category(
try:
if provider == "tavily":
return await _search_tavily(category, keyword, max_results)
return await _search_tavily(category, keyword, max_results, user_id)
elif provider == "exa":
return await _search_exa(category, keyword, max_results, website_url)
return await _search_exa(category, keyword, max_results, user_id, website_url)
else:
raise HTTPException(status_code=500, detail="Unknown provider")
except Exception as e:

View File

@@ -4,6 +4,7 @@ Podcast Trends Handler
Endpoints for fetching Google Trends data relevant to podcast topics.
"""
import asyncio
from fastapi import APIRouter, Depends, HTTPException
from typing import Dict, Any, List, Optional
from pydantic import BaseModel, Field
@@ -13,6 +14,25 @@ from middleware.auth_middleware import get_current_user
router = APIRouter(prefix="/trends", tags=["Podcast Trends"])
# Module-level shared instance (singleton pattern)
_trends_service_instance = None
_trends_service_lock = None
def get_trends_service():
"""Get or create shared GoogleTrendsService instance."""
global _trends_service_instance, _trends_service_lock
if _trends_service_instance is None:
try:
from services.research.trends import GoogleTrendsService
_trends_service_instance = GoogleTrendsService()
_trends_service_lock = asyncio.Lock()
logger.info("[Podcast Trends] Created shared GoogleTrendsService instance")
except (ImportError, RuntimeError) as e:
logger.error(f"[Podcast Trends] Failed to create GoogleTrendsService: {e}")
raise
return _trends_service_instance
class PodcastTrendsRequest(BaseModel):
keywords: List[str] = Field(..., min_length=1, max_length=5, description="1-5 keywords to analyze")
@@ -38,7 +58,7 @@ async def get_podcast_trends(
raise HTTPException(status_code=401, detail="User ID not found")
try:
from services.research.trends import GoogleTrendsService
service = get_trends_service()
except (ImportError, RuntimeError) as e:
logger.error(f"[Podcast Trends] GoogleTrendsService unavailable: {e}")
raise HTTPException(
@@ -47,11 +67,10 @@ async def get_podcast_trends(
)
try:
service = GoogleTrendsService()
# Map 'source' to 'gprop' - 'podcast' uses YouTube for video/podcast relevance
gprop_map = {"": "", "web": "", "podcast": "youtube", "news": "news", "images": "images", "shopping": "froogle"}
gprop = gprop_map.get(request.source, "")
result = await service.analyze_trends(
keywords=request.keywords,
timeframe=request.timeframe,
@@ -73,7 +92,15 @@ async def get_podcast_trends(
# Return error if: has error OR no data (meaning blocked/empty)
if has_error and not has_data:
error_msg = result.get("error", "")
cooldown_active = result.get("cooldown_active", False)
logger.warning(f"[Trends] No data or error: {error_msg[:100]}")
# Provide helpful message during cooldown
if cooldown_active:
return PodcastTrendsResponse(
success=False,
data=result,
error="Google is rate limiting requests. Try using 'Get Trending Topics' instead, or wait 30 minutes."
)
return PodcastTrendsResponse(success=False, data=result, error=error_msg or "No trends data available. Google may be blocking requests.")
# Even if no error but empty data - return error

View File

@@ -12,7 +12,7 @@ import sqlite3
from services.database import get_db
from services.subscription import UsageTrackingService, PricingService
from services.subscription.schema_utils import ensure_subscription_plan_columns, ensure_usage_summaries_columns
from models.subscription_models import UsageAlert
from models.subscription_models import UsageAlert, UserSubscription
from middleware.auth_middleware import get_current_user
from ..dependencies import verify_user_access
from ..cache import get_cached_dashboard, set_cached_dashboard
@@ -27,7 +27,9 @@ async def get_dashboard_data(
db: Session = Depends(get_db),
current_user: Dict[str, Any] = Depends(get_current_user)
) -> Dict[str, Any]:
"""Get comprehensive dashboard data for usage monitoring."""
"""Get comprehensive dashboard data for usage monitoring.
Returns all-time total + current period usage by default.
When billing_period is specified, returns that period's data only."""
verify_user_access(user_id, current_user)
@@ -35,17 +37,23 @@ async def get_dashboard_data(
ensure_subscription_plan_columns(db)
ensure_usage_summaries_columns(db)
# Check cache first (skip if billing_period is specified)
if not billing_period:
cached_data = get_cached_dashboard(user_id)
if cached_data:
return cached_data
# Check cache first (only for default view, skip when a specific period is requested)
cached_data = get_cached_dashboard(user_id)
if cached_data and not billing_period:
return cached_data
usage_service = UsageTrackingService(db)
pricing_service = PricingService(db)
# Get current usage stats (for the requested period)
current_usage = usage_service.get_user_usage_stats(user_id, billing_period)
# When a specific billing_period is requested, show only that period's data
# Otherwise show all-time total + current period usage
if billing_period:
period_usage = usage_service.get_usage_for_period(user_id, billing_period)
total_usage = period_usage
current_period_usage = period_usage
else:
total_usage = usage_service.get_user_usage_stats(user_id, None)
current_period_usage = usage_service.get_current_period_usage(user_id)
# Get usage trends (last 6 months)
trends = usage_service.get_usage_trends(user_id, 6)
@@ -76,13 +84,44 @@ async def get_dashboard_data(
]
# Calculate cost projections (only relevant for current month)
current_cost = current_usage.get('total_cost', 0)
current_cost = total_usage.get('total_cost', 0)
days_in_period = 30
current_day = datetime.now().day
# Only project costs if viewing current month
is_current_month = not billing_period or billing_period == datetime.now().strftime("%Y-%m")
if is_current_month:
# Determine if viewing current period based on subscription, not calendar
subscription = db.query(UserSubscription).filter(
UserSubscription.user_id == user_id,
UserSubscription.is_active == True
).first()
# Use subscription's billing period or fallback to calendar
if subscription and subscription.current_period_start:
sub_period = subscription.current_period_start.strftime("%Y-%m")
calendar_period = datetime.now().strftime("%Y-%m")
# Check if we have data for subscription period or calendar period
from models.subscription_models import UsageSummary
sub_data_exists = db.query(UsageSummary).filter(
UsageSummary.user_id == user_id,
UsageSummary.billing_period == sub_period
).first()
# Determine which period to use for "current"
if sub_data_exists:
effective_period = sub_period
else:
# Check calendar period for backward compatibility
cal_data_exists = db.query(UsageSummary).filter(
UsageSummary.user_id == user_id,
UsageSummary.billing_period == calendar_period
).first()
effective_period = calendar_period if cal_data_exists else sub_period
is_current_period = not billing_period or billing_period == effective_period
else:
is_current_period = not billing_period or billing_period == datetime.now().strftime("%Y-%m")
if is_current_period:
projected_cost = (current_cost / current_day) * days_in_period if current_day > 0 else 0
else:
projected_cost = current_cost # For past months, projected is actual
@@ -90,7 +129,8 @@ async def get_dashboard_data(
response_payload = {
"success": True,
"data": {
"current_usage": current_usage,
"total_usage": total_usage,
"current_period_usage": current_period_usage,
"trends": trends,
"limits": limits,
"alerts": alerts_data,
@@ -100,9 +140,9 @@ async def get_dashboard_data(
"projected_usage_percentage": (projected_cost / max(limits.get('limits', {}).get('monthly_cost', 1), 1)) * 100 if limits else 0
},
"summary": {
"total_api_calls_this_month": current_usage.get('total_calls', 0),
"total_cost_this_month": current_usage.get('total_cost', 0),
"usage_status": current_usage.get('usage_status', 'active'),
"total_api_calls_this_month": total_usage.get('total_calls', 0),
"total_cost_this_month": total_usage.get('total_cost', 0),
"usage_status": total_usage.get('usage_status', 'active'),
"unread_alerts": len(alerts_data)
}
}
@@ -131,7 +171,13 @@ async def get_dashboard_data(
usage_service = UsageTrackingService(db)
pricing_service = PricingService(db)
current_usage = usage_service.get_user_usage_stats(user_id)
if billing_period:
period_usage = usage_service.get_usage_for_period(user_id, billing_period)
total_usage = period_usage
current_period_usage = period_usage
else:
total_usage = usage_service.get_user_usage_stats(user_id, None)
current_period_usage = usage_service.get_current_period_usage(user_id)
trends = usage_service.get_usage_trends(user_id, 6)
limits = pricing_service.get_user_limits(user_id)
@@ -152,7 +198,7 @@ async def get_dashboard_data(
for alert in alerts
]
current_cost = current_usage.get('total_cost', 0)
current_cost = total_usage.get('total_cost', 0)
days_in_period = 30
current_day = datetime.now().day
projected_cost = (current_cost / current_day) * days_in_period if current_day > 0 else 0
@@ -160,7 +206,8 @@ async def get_dashboard_data(
response_payload = {
"success": True,
"data": {
"current_usage": current_usage,
"total_usage": total_usage,
"current_period_usage": current_period_usage,
"trends": trends,
"limits": limits,
"alerts": alerts_data,
@@ -170,16 +217,17 @@ async def get_dashboard_data(
"projected_usage_percentage": (projected_cost / max(limits.get('limits', {}).get('monthly_cost', 1), 1)) * 100 if limits else 0
},
"summary": {
"total_api_calls_this_month": current_usage.get('total_calls', 0),
"total_cost_this_month": current_usage.get('total_cost', 0),
"usage_status": current_usage.get('usage_status', 'active'),
"total_api_calls_this_month": total_usage.get('total_calls', 0),
"total_cost_this_month": total_usage.get('total_cost', 0),
"usage_status": total_usage.get('usage_status', 'active'),
"unread_alerts": len(alerts_data)
}
}
}
# Cache the response after successful retry
set_cached_dashboard(user_id, response_payload)
# Cache the response after successful retry (only for default view)
if not billing_period:
set_cached_dashboard(user_id, response_payload)
return response_payload
except Exception as retry_err:
logger.error(f"Schema fix and retry failed: {retry_err}")
@@ -187,7 +235,8 @@ async def get_dashboard_data(
"success": False,
"error": str(retry_err),
"data": {
"current_usage": {"total_calls": 0, "total_cost": 0, "usage_status": "error", "provider_breakdown": {}},
"total_usage": {"total_calls": 0, "total_cost": 0, "usage_status": "error", "provider_breakdown": {}},
"current_period_usage": {"total_calls": 0, "total_cost": 0, "usage_status": "error", "provider_breakdown": {}, "usage_percentages": {}},
"trends": [],
"limits": {"limits": {"monthly_cost": 0}},
"alerts": [],
@@ -201,7 +250,8 @@ async def get_dashboard_data(
"success": False,
"error": str(e),
"data": {
"current_usage": {"total_calls": 0, "total_cost": 0, "usage_status": "error", "provider_breakdown": {}},
"total_usage": {"total_calls": 0, "total_cost": 0, "usage_status": "error", "provider_breakdown": {}},
"current_period_usage": {"total_calls": 0, "total_cost": 0, "usage_status": "error", "provider_breakdown": {}, "usage_percentages": {}},
"trends": [],
"limits": {"limits": {"monthly_cost": 0}},
"alerts": [],

View File

@@ -14,13 +14,21 @@ def format_plan_limits(plan: SubscriptionPlan) -> Dict[str, Any]:
"""
Format subscription plan limits for API response.
Includes _zero_means metadata per field to disambiguate:
- 'disabled': 0 means the feature is not available (Free tier)
- 'unlimited': 0 means unlimited usage (Enterprise tier)
- 'limited': >0 means numerical limit applies
Args:
plan: SubscriptionPlan model instance
Returns:
Dictionary with formatted limits
Dictionary with formatted limits and _zero_means metadata
"""
return {
tier = plan.tier.value if hasattr(plan.tier, 'value') else str(plan.tier)
is_enterprise = tier == 'enterprise'
limit_fields = {
"ai_text_generation_calls": getattr(plan, 'ai_text_generation_calls_limit', None) or 0,
"gemini_calls": plan.gemini_calls_limit,
"openai_calls": plan.openai_calls_limit,
@@ -35,11 +43,43 @@ def format_plan_limits(plan: SubscriptionPlan) -> Dict[str, Any]:
"image_edit_calls": getattr(plan, 'image_edit_calls_limit', 0) or 0,
"audio_calls": getattr(plan, 'audio_calls_limit', 0) or 0,
"exa_calls": getattr(plan, 'exa_calls_limit', 0) or 0,
"wavespeed_calls": getattr(plan, 'wavespeed_calls_limit', 0) or 0,
"gemini_tokens": plan.gemini_tokens_limit,
"openai_tokens": plan.openai_tokens_limit,
"anthropic_tokens": plan.anthropic_tokens_limit,
"mistral_tokens": plan.mistral_tokens_limit,
"monthly_cost": plan.monthly_cost_limit
"monthly_cost": plan.monthly_cost_limit,
}
# Build _zero_means metadata: indicates whether 0 means 'disabled' or 'unlimited'
zero_means = {}
for field, value in limit_fields.items():
if field == "monthly_cost":
zero_means[field] = "disabled"
elif is_enterprise:
# Enterprise: 0 means unlimited for all call/token fields
zero_means[field] = "unlimited"
else:
# Free/Basic/Pro: determine per-field
# Fields that are 0=disabled on Free tier but 0=unlimited on Basic/Pro
call_and_token_fields = {
"gemini_calls", "openai_calls", "anthropic_calls", "mistral_calls",
"tavily_calls", "serper_calls", "metaphor_calls", "firecrawl_calls",
"stability_calls", "video_calls", "image_edit_calls", "audio_calls",
"exa_calls", "wavespeed_calls", "ai_text_generation_calls",
"gemini_tokens", "openai_tokens", "anthropic_tokens", "mistral_tokens",
}
if field in call_and_token_fields:
if value == 0:
zero_means[field] = "disabled" if tier == "free" else "unlimited"
else:
zero_means[field] = "limited"
else:
zero_means[field] = "limited" if value > 0 else "disabled"
return {
**limit_fields,
"_zero_means": zero_means,
}

View File

@@ -1,9 +1,10 @@
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from typing import List, Any, Dict
from loguru import logger
from services.writing_assistant import WritingAssistantService
from middleware.auth_middleware import get_current_user
router = APIRouter(prefix="/api/writing-assistant", tags=["writing-assistant"])
@@ -11,7 +12,6 @@ router = APIRouter(prefix="/api/writing-assistant", tags=["writing-assistant"])
class SuggestRequest(BaseModel):
text: str
max_results: int | None = 1
class SourceModel(BaseModel):
@@ -38,9 +38,10 @@ assistant_service = WritingAssistantService()
@router.post("/suggest", response_model=SuggestResponse)
async def suggest_endpoint(req: SuggestRequest) -> SuggestResponse:
async def suggest_endpoint(req: SuggestRequest, current_user: Dict[str, Any] = Depends(get_current_user)) -> SuggestResponse:
try:
suggestions = await assistant_service.suggest(req.text, req.max_results or 1)
user_id = current_user.get("id")
suggestions = await assistant_service.suggest(req.text, user_id=user_id)
return SuggestResponse(
success=True,
suggestions=[

View File

@@ -27,11 +27,11 @@ load_dotenv(backend_dir / '.env', override=False)
load_dotenv(project_root / '.env', override=False)
load_dotenv(override=False)
# Set LOG_LEVEL early to WARNING to suppress DEBUG persona logs in podcast mode
# Set LOG_LEVEL early to WARNING in feature-only modes to suppress DEBUG persona logs
import os
if os.getenv("ALWRITY_ENABLED_FEATURES", "").strip().lower() == "podcast":
if os.getenv("ALWRITY_ENABLED_FEATURES", "").strip().lower() not in ("", "all"):
os.environ["LOG_LEVEL"] = "WARNING"
print(f"[app.py] Starting... ALWRITY_ENABLED_FEATURES={os.getenv('ALWRITY_ENABLED_FEATURES')}", flush=True)
@@ -43,22 +43,21 @@ def get_enabled_features() -> set:
return {f.strip() for f in env_value.split(",") if f.strip()}
def _is_full_mode() -> bool:
"""Check if running in full mode (all features enabled)."""
enabled = get_enabled_features()
return "all" in enabled
def _is_feature_enabled(feature: str) -> bool:
"""Check if a specific feature is enabled (including in 'all' mode)."""
enabled = get_enabled_features()
return feature in enabled or "all" in enabled
# Print env var IMMEDIATELY at module start
print(f"[app.py] ALWRITY_ENABLED_FEATURES at start: {os.getenv('ALWRITY_ENABLED_FEATURES')}", flush=True)
def is_podcast_only_demo_mode() -> bool:
"""Check if podcast-only mode is enabled."""
import os
env_val = os.getenv("ALWRITY_ENABLED_FEATURES", "all")
enabled = get_enabled_features()
result = "podcast" in enabled and "all" not in enabled
# Removed debug print - too verbose during startup
return result
# Podcast-only check BEFORE heavy imports
PODCAST_ONLY_DEMO_MODE = is_podcast_only_demo_mode()
# Import onboarding models (after env is loaded, before heavy imports)
from models.onboarding import APIKey, WebsiteAnalysis, ResearchPreferences, PersonaData, CompetitorAnalysis
@@ -90,28 +89,18 @@ _log_memory_usage()
logger.info("app.py: Early memory checkpoint after env load")
# Import modular utilities (skip OnboardingManager import in podcast-only mode)
# Import modular utilities (skip OnboardingManager import in feature-only modes)
from alwrity_utils import HealthChecker, RateLimiter, FrontendServing, RouterManager
if not is_podcast_only_demo_mode():
if _is_full_mode():
from alwrity_utils import OnboardingManager
# Skip monitoring middleware in podcast-only mode to save memory
if not is_podcast_only_demo_mode():
# Skip monitoring middleware in feature-only modes to save memory
if _is_full_mode():
from services.subscription import monitoring_middleware
else:
monitoring_middleware = None
def should_include_non_podcast_features() -> bool:
"""Check if non-podcast features should be included."""
enabled = get_enabled_features()
return "all" in enabled or "core" in enabled
# Legacy constant for backwards compatibility
PODCAST_ONLY_DEMO_MODE = is_podcast_only_demo_mode()
# Set up clean logging for end users
from logging_config import setup_clean_logging
setup_clean_logging()
@@ -119,27 +108,27 @@ setup_clean_logging()
# Import middleware
from middleware.auth_middleware import get_current_user
# Import component logic endpoints (skip in podcast-only mode - uses seo_analyzer)
# Import component logic endpoints (skip in feature-only modes - uses seo_analyzer)
component_logic_router = None
if not PODCAST_ONLY_DEMO_MODE:
if _is_full_mode():
from api.component_logic import router as component_logic_router
# Import subscription API endpoints
from api.subscription import router as subscription_router
# Import Step 3 onboarding routes (skip in podcast-only mode)
# Import Step 3 onboarding routes (skip in feature-only modes)
step3_routes = None
if not PODCAST_ONLY_DEMO_MODE:
if _is_full_mode():
from api.onboarding_utils.step3_routes import router as step3_routes
# Import SEO tools router (skip in podcast-only mode - uses seo_analyzer)
# Import SEO tools router (skip in feature-only modes - uses seo_analyzer)
seo_tools_router = None
if not PODCAST_ONLY_DEMO_MODE:
if _is_full_mode():
from routers.seo_tools import router as seo_tools_router
# Skip Facebook Writer, LinkedIn, and other non-podcast routes in podcast-only mode
# Skip Facebook Writer, LinkedIn, and other non-essential routes in feature-only modes
# Also skip other heavy services that trigger PersonaAnalysisService initialization
if not PODCAST_ONLY_DEMO_MODE:
if _is_full_mode():
from api.facebook_writer.routers import facebook_router
from routers.linkedin import router as linkedin_router
from api.linkedin_image_generation import router as linkedin_image_router
@@ -150,7 +139,7 @@ if not PODCAST_ONLY_DEMO_MODE:
from routers.product_marketing import router as product_marketing_router
from routers.campaign_creator import router as campaign_creator_router
else:
# In podcast-only mode, only load essential podcast assets router
# In feature-only modes, only load essential assets router
from api.assets_serving import router as assets_serving_router
brainstorm_router = None
images_router = None
@@ -158,31 +147,31 @@ else:
product_marketing_router = None
campaign_creator_router = None
# Import hallucination detector router (skip in podcast-only mode - triggers heavy ML)
if not PODCAST_ONLY_DEMO_MODE:
# Import hallucination detector router (skip in feature-only modes - triggers heavy ML)
if _is_full_mode():
from api.hallucination_detector import router as hallucination_detector_router
from api.writing_assistant import router as writing_assistant_router
else:
hallucination_detector_router = None
writing_assistant_router = None
# Import research configuration router (skip in podcast-only mode)
if not is_podcast_only_demo_mode():
# Import research configuration router (skip in feature-only modes)
if _is_full_mode():
from api.research_config import router as research_config_router
else:
research_config_router = None
# Import user data endpoints
# Import content planning endpoints (skip in podcast-only mode)
if not is_podcast_only_demo_mode():
# Import content planning endpoints (skip in feature-only modes)
if _is_full_mode():
from api.content_planning.api.router import router as content_planning_router
from api.content_planning.strategy_copilot import router as strategy_copilot_router
else:
content_planning_router = None
strategy_copilot_router = None
# Import user data endpoints (skip in podcast-only mode to save memory)
if not is_podcast_only_demo_mode():
# Import user data endpoints (skip in feature-only modes to save memory)
if _is_full_mode():
from api.user_data import router as user_data_router
else:
user_data_router = None
@@ -197,14 +186,14 @@ from services.startup_health import (
# Trigger reload for monitoring fix
# Import OAuth token monitoring routes (skip in podcast-only mode)
if not is_podcast_only_demo_mode():
# Import OAuth token monitoring routes (skip in feature-only modes)
if _is_full_mode():
from api.oauth_token_monitoring_routes import router as oauth_token_monitoring_router
else:
oauth_token_monitoring_router = None
# Import SEO Dashboard endpoints (skip in podcast-only mode to save memory)
if not is_podcast_only_demo_mode():
# Import SEO Dashboard endpoints (skip in feature-only modes to save memory)
if _is_full_mode():
from api.seo_dashboard import (
get_seo_dashboard_data,
get_seo_health_score,
@@ -318,8 +307,8 @@ router_manager = RouterManager(app)
router_group_status: Dict[str, Dict[str, Any]] = {}
onboarding_manager = None
# Only create OnboardingManager if NOT in podcast-only mode
if not PODCAST_ONLY_DEMO_MODE:
# Only create OnboardingManager in full mode
if _is_full_mode():
from alwrity_utils import OnboardingManager
onboarding_manager = OnboardingManager(app)
@@ -346,7 +335,8 @@ app.middleware("http")(api_key_injection_middleware)
async def health():
"""Health check endpoint."""
health_data = health_checker.basic_health_check()
health_data["podcast_only_demo_mode"] = PODCAST_ONLY_DEMO_MODE
health_data["feature_mode"] = "single" if not _is_full_mode() else "full"
health_data["enabled_features"] = list(get_enabled_features())
return health_data
@app.get("/health/database")
@@ -363,7 +353,8 @@ async def comprehensive_health():
async def readiness(current_user: dict = Depends(get_current_user)):
"""Readiness check that validates tenant DB resolution/session under auth context."""
return {
"podcast_only_demo_mode": PODCAST_ONLY_DEMO_MODE,
"feature_mode": "single" if not _is_full_mode() else "full",
"enabled_features": list(get_enabled_features()),
"startup": get_startup_status(),
"tenant": readiness_under_auth_context(current_user),
}
@@ -395,7 +386,8 @@ async def router_status():
status = router_manager.get_router_status()
status.update(
{
"podcast_only_demo_mode": PODCAST_ONLY_DEMO_MODE,
"feature_mode": "single" if not _is_full_mode() else "full",
"enabled_features": list(get_enabled_features()),
"router_groups": router_group_status,
}
)
@@ -410,53 +402,19 @@ async def feature_profile_status():
@app.get("/api/onboarding/status")
async def onboarding_status():
"""Get onboarding manager status (or demo-mode disabled state)."""
if PODCAST_ONLY_DEMO_MODE:
if not _is_full_mode():
return {
"enabled": False,
"status": "disabled",
"message": "Onboarding is disabled for podcast-only demo mode.",
"demo_mode": "podcast_only",
"message": f"Onboarding is disabled in feature-only mode. Enabled features: {list(get_enabled_features())}",
"feature_mode": "single",
}
return onboarding_manager.get_onboarding_status()
# Include routers using modular utilities
if PODCAST_ONLY_DEMO_MODE:
# In podcast-only mode, include only podcast-enabled routers from core registry
from alwrity_utils.router_manager import CORE_ROUTER_REGISTRY
podcast_routers = [r for r in CORE_ROUTER_REGISTRY if "podcast" in r.get("features", set())]
logger.info(f"[PODCAST-ONLY] Found {len(podcast_routers)} podcast routers: {[r['name'] for r in podcast_routers]}")
# Try to include step4_assets for voice cloning (may fail if nltk not installed)
step4_entry = next((r for r in CORE_ROUTER_REGISTRY if r.get("name") == "step4_assets"), None)
if step4_entry:
try:
logger.info(f"[PODCAST-ONLY] Attempting to load step4_assets for voice cloning")
router = router_manager._load_router_from_registry(step4_entry)
router_manager.include_router_safely(router, step4_entry["name"], step4_entry.get("include_kwargs"))
except ImportError as e:
logger.warning(f"[PODCAST-ONLY] Skipping step4_assets (missing optional dependency): {e}")
except Exception as e:
logger.error(f"[PODCAST-ONLY] Failed to mount step4_assets: {e}")
# Load other podcast routers
for entry in podcast_routers:
if entry.get("name") == "step4_assets":
continue # Already loaded above
try:
logger.info(f"[PODCAST-ONLY] Loading router: {entry['name']}")
router = router_manager._load_router_from_registry(entry)
router_manager.include_router_safely(router, entry["name"], entry.get("include_kwargs"))
except Exception as e:
logger.error(f"[PODCAST-ONLY] Failed to mount {entry.get('name', 'unknown')}: {e}")
router_group_status["modular_core"] = {
"mounted": True,
"reason": "Podcast routers only in podcast-only mode",
}
router_group_status["modular_optional"] = {
"mounted": False,
"reason": "Skipped in podcast-only demo mode",
}
else:
enabled_features = get_enabled_features()
if "all" in enabled_features:
# Full mode: load all core and optional routers
router_group_status["modular_core"] = {
"mounted": router_manager.include_core_routers(),
"reason": "Full mode",
@@ -465,6 +423,72 @@ else:
"mounted": router_manager.include_optional_routers(),
"reason": "Full mode",
}
else:
# Feature-only mode: load only routers matching enabled features
from alwrity_utils.router_manager import CORE_ROUTER_REGISTRY
# Filter core routers that match any enabled feature
matching_core = [
r for r in CORE_ROUTER_REGISTRY
if r.get("features", set()) & enabled_features
]
logger.info(
f"[FEATURE-MODE] Enabled features: {enabled_features}, "
f"matching {len(matching_core)} core routers: {[r['name'] for r in matching_core]}"
)
# Try to include step4_assets for voice cloning (may fail if nltk not installed)
step4_entry = next((r for r in matching_core if r.get("name") == "step4_assets"), None)
if step4_entry:
try:
logger.info(f"[FEATURE-MODE] Attempting to load step4_assets")
router = router_manager._load_router_from_registry(step4_entry)
router_manager.include_router_safely(router, step4_entry["name"], step4_entry.get("include_kwargs"))
except ImportError as e:
logger.warning(f"[FEATURE-MODE] Skipping step4_assets (missing optional dependency): {e}")
except Exception as e:
logger.error(f"[FEATURE-MODE] Failed to mount step4_assets: {e}")
# Load other matching core routers
for entry in matching_core:
if entry.get("name") == "step4_assets":
continue # Already loaded above
if entry.get("name") == "subscription":
continue # Loaded separately below
try:
logger.info(f"[FEATURE-MODE] Loading router: {entry['name']}")
router = router_manager._load_router_from_registry(entry)
router_manager.include_router_safely(router, entry["name"], entry.get("include_kwargs"))
except Exception as e:
logger.error(f"[FEATURE-MODE] Failed to mount {entry.get('name', 'unknown')}: {e}")
router_group_status["modular_core"] = {
"mounted": True,
"reason": f"Feature-only mode: {enabled_features}",
}
# Load optional routers matching enabled features
from alwrity_utils.router_manager import OPTIONAL_ROUTER_REGISTRY
matching_optional = [
r for r in OPTIONAL_ROUTER_REGISTRY
if r.get("features", set()) & enabled_features
]
for entry in matching_optional:
try:
logger.info(f"[FEATURE-MODE] Loading optional router: {entry['name']}")
router = router_manager._load_router_from_registry(entry)
router_manager.include_router_safely(router, entry["name"], entry.get("include_kwargs"))
except Exception as e:
logger.error(f"[FEATURE-MODE] Failed to mount optional {entry.get('name', 'unknown')}: {e}")
router_group_status["modular_optional"] = {
"mounted": True,
"reason": f"Feature-only mode: {enabled_features}",
}
# Safety net: explicitly include hallucination detector (router_manager may skip silently)
if hallucination_detector_router:
router_manager.include_router_safely(hallucination_detector_router, "hallucination_detector")
# Log startup summary
router_manager.log_startup_summary()
@@ -480,8 +504,8 @@ router_group_status["assets_serving"] = {
"reason": "Required for podcast media assets",
}
# SEO Dashboard endpoints (skip in podcast-only mode)
if not is_podcast_only_demo_mode():
# SEO Dashboard endpoints (skip in feature-only modes)
if _is_full_mode():
@app.get("/api/seo-dashboard/data")
async def seo_dashboard_data():
"""Get complete SEO dashboard data."""
@@ -619,7 +643,7 @@ if not is_podcast_only_demo_mode():
return await analyze_urls_ai(request, current_user)
# Include platform analytics router
if not PODCAST_ONLY_DEMO_MODE:
if _is_full_mode():
from routers.platform_analytics import router as platform_analytics_router
app.include_router(platform_analytics_router)
# Include Bing Analytics Storage router to expose storage-backed endpoints
@@ -644,25 +668,38 @@ if not PODCAST_ONLY_DEMO_MODE:
else:
router_group_status["platform_extensions"] = {
"mounted": False,
"reason": "Skipped in podcast-only demo mode",
"reason": "Skipped in feature-only mode",
}
# Include Podcast Maker router (always needed for podcast mode)
from api.podcast.router import router as podcast_router
logger.info(f"[PODCAST] Including podcast_router with prefixes: {podcast_router.routes}")
app.include_router(podcast_router)
router_group_status["podcast_maker"] = {
"mounted": True,
"reason": "Always mounted",
}
# Include Podcast Maker router (only when podcast feature is enabled)
if _is_feature_enabled("podcast") and "all" not in get_enabled_features():
from api.podcast.router import router as podcast_router
logger.info(f"[ROUTER] Including podcast_router")
app.include_router(podcast_router)
router_group_status["podcast_maker"] = {
"mounted": True,
"reason": "Podcast feature enabled",
}
elif "all" in get_enabled_features():
# In full mode, podcast is loaded via optional router registry
router_group_status["podcast_maker"] = {
"mounted": True,
"reason": "Full mode (loaded via registry)",
}
else:
router_group_status["podcast_maker"] = {
"mounted": False,
"reason": "Podcast feature not enabled",
}
if not PODCAST_ONLY_DEMO_MODE:
if _is_full_mode():
# Include YouTube Creator Studio router
from api.youtube.router import router as youtube_router
app.include_router(youtube_router, prefix="/api")
# Include research configuration router
app.include_router(research_config_router, prefix="/api/research", tags=["research"])
if research_config_router:
app.include_router(research_config_router, prefix="/api/research", tags=["research"])
# Include Research Engine router (standalone AI research module)
from api.research.router import router as research_engine_router
@@ -688,7 +725,7 @@ if not PODCAST_ONLY_DEMO_MODE:
else:
router_group_status["advanced_workflows"] = {
"mounted": False,
"reason": "Skipped in podcast-only demo mode",
"reason": "Skipped in feature-only mode",
}
# Setup frontend serving using modular utilities
@@ -715,20 +752,23 @@ async def startup_event():
# Note: Pricing is initialized per-user in services/database.py:init_user_database()
# which runs on first database access for each user. No global seeding needed at startup.
# Skip startup health checks in podcast-only mode to avoid unnecessary DB errors
if not is_podcast_only_demo_mode():
enabled_features = get_enabled_features()
is_single_mode = "all" not in enabled_features
# Skip startup health checks in feature-only modes to avoid unnecessary DB errors
if _is_full_mode():
startup_report = run_startup_health_routine(app)
if startup_report.get("status") != "healthy":
logger.error(f"Startup readiness finished with failures: {startup_report.get('errors', [])}")
else:
logger.info("[Podcast] Skipping startup health routine (podcast-only mode)")
logger.info(f"[FEATURE-MODE] Skipping startup health routine (features: {enabled_features})")
# Start task scheduler only if NOT in podcast-only mode
if not is_podcast_only_demo_mode():
# Start task scheduler only in full mode
if _is_full_mode():
from services.scheduler import get_scheduler
await get_scheduler().start()
else:
logger.info("[Podcast] Skipping scheduler startup (podcast-only mode)")
logger.info(f"[FEATURE-MODE] Skipping scheduler startup (features: {enabled_features})")
# Check Wix API key configuration
wix_api_key = os.getenv('WIX_API_KEY')
@@ -740,9 +780,12 @@ async def startup_event():
elapsed = time.time() - startup_start
logger.info(f"ALwrity backend started successfully in {elapsed:.1f}s")
# Critical router mount assertions for podcast-only demo mode
# Critical router mount assertions for feature-only modes
_assert_router_mounted("subscription")
_assert_router_mounted("podcast")
if _is_feature_enabled("podcast"):
_assert_router_mounted("podcast")
if _is_feature_enabled("blog_writer"):
_assert_router_mounted("blog_writer")
except Exception as e:
logger.error(f"Error during startup: {e}")
# Don't raise - let the server start anyway
@@ -757,6 +800,7 @@ def _assert_router_mounted(router_name: str) -> None:
router_path_indicators = {
"subscription": ["/api/subscription/plans", "/api/subscription/preflight"],
"podcast": ["/api/podcast/projects", "/api/podcast/"],
"blog_writer": ["/api/blog/health", "/api/blog/research/start"],
}
expected_paths = router_path_indicators.get(router_name, [])
@@ -767,10 +811,9 @@ def _assert_router_mounted(router_name: str) -> None:
else:
error_msg = f"❌ CRITICAL: Router '{router_name}' is NOT mounted! Expected paths: {expected_paths}"
logger.error(error_msg)
if PODCAST_ONLY_DEMO_MODE:
# In demo mode, podcast router MUST be mounted
if router_name == "podcast":
raise RuntimeError(error_msg)
# In feature-only mode, only fail if the feature is expected
if not _is_full_mode() and _is_feature_enabled(router_name):
raise RuntimeError(error_msg)
# Shutdown event
@app.on_event("shutdown")

View File

@@ -252,6 +252,8 @@ router_manager.include_core_routers()
# Safety net: keep subscription routes available even if core inclusion flow changes
# in special modes (e.g., demo mode). De-dup is handled by RouterManager.
router_manager.include_router_safely(subscription_router, "subscription")
# Include hallucination detector explicitly (router_manager may skip silently on import failure)
router_manager.include_router_safely(hallucination_detector_router, "hallucination_detector")
router_manager.include_optional_routers()
# SEO Dashboard endpoints

View File

@@ -11,17 +11,30 @@ echo "📦 Checking ALWRITY_ENABLED_FEATURES..."
ENABLED_FEATURES="${ALWRITY_ENABLED_FEATURES:-all}"
echo "DEBUG: ENABLED_FEATURES='$ENABLED_FEATURES'"
if [[ "$ENABLED_FEATURES" == "podcast" ]]; then
echo "🔊 Podcast-only mode: Installing lean requirements..."
python -m pip install --no-cache-dir -r requirements-podcast.txt --only-binary :all: --retries 10 --timeout 120
else
echo "📦 Full mode: Installing all requirements..."
python -m pip install --no-cache-dir -r requirements.txt --only-binary :all: --retries 10 --timeout 120
# Download spaCy/NLTK models for full mode
echo "🧠 Installing spaCy and NLTK models..."
python -m spacy download en_core_web_sm
python -m nltk.downloader punkt_tab stopwords averaged_perceptron_tagger
fi
case "$ENABLED_FEATURES" in
all)
echo "📦 Full mode: Installing all requirements..."
python -m pip install --no-cache-dir -r requirements.txt --only-binary :all: --retries 10 --timeout 120
# Download spaCy/NLTK models for full mode
echo "🧠 Installing spaCy and NLTK models..."
python -m spacy download en_core_web_sm
python -m nltk.downloader punkt_tab stopwords averaged_perceptron_tagger
;;
podcast)
echo "🔊 Podcast-only mode: Installing lean requirements..."
python -m pip install --no-cache-dir -r requirements-podcast.txt --only-binary :all: --retries 10 --timeout 120
;;
*)
echo "🎯 Feature-limited mode ($ENABLED_FEATURES): Installing requirements..."
req_file="requirements-${ENABLED_FEATURES}.txt"
if [[ -f "$req_file" ]]; then
python -m pip install --no-cache-dir -r "$req_file" --only-binary :all: --retries 10 --timeout 120
else
echo "⚠️ No feature-specific requirements file found ($req_file), installing full requirements..."
python -m pip install --no-cache-dir -r requirements.txt --only-binary :all: --retries 10 --timeout 120
fi
;;
esac
# 3. Clean up unnecessary build artifacts
find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,7 @@ import json
from typing import Dict, Any, List
from loguru import logger
from fastapi import HTTPException
from sqlalchemy.orm import Session
from models.blog_models import (
MediumBlogGenerateRequest,
@@ -26,7 +27,7 @@ class MediumBlogGenerator:
def __init__(self):
self.cache = persistent_content_cache
async def generate_medium_blog_with_progress(self, req: MediumBlogGenerateRequest, task_id: str, user_id: str) -> MediumBlogGenerateResult:
async def generate_medium_blog_with_progress(self, req: MediumBlogGenerateRequest, task_id: str, user_id: str, db: Session = None) -> MediumBlogGenerateResult:
"""Use Gemini structured JSON to generate a medium-length blog in one call.
Args:

View File

@@ -499,7 +499,7 @@ class DatabaseTaskManager:
)
blog_writer_logger.log_error(e, "outline_generation_task", context={"task_id": task_id})
async def _run_medium_generation_task(self, task_id: str, request: MediumBlogGenerateRequest):
async def _run_medium_generation_task(self, task_id: str, request: MediumBlogGenerateRequest, user_id: str):
"""Background task to generate a medium blog using a single structured JSON call."""
try:
await self.update_progress(task_id, "📦 Packaging outline and metadata...", 0)
@@ -512,7 +512,7 @@ class DatabaseTaskManager:
result: MediumBlogGenerateResult = await self.service.generate_medium_blog_with_progress(
request,
task_id,
user_id=request.user_id if hasattr(request, 'user_id') else (await self.get_task_status(task_id))['user_id'],
user_id,
db=self.db
)

View File

@@ -70,22 +70,22 @@ STRATEGIC REQUIREMENTS:
- Ensure engaging, actionable content throughout
Return JSON format:
{
{{
"title_options": [
"Title option 1",
"Title option 2",
"Title option 3"
],
"outline": [
{
{{
"heading": "Section heading with primary keyword",
"subheadings": ["Subheading 1", "Subheading 2", "Subheading 3"],
"key_points": ["Key point 1", "Key point 2", "Key point 3"],
"target_words": 300,
"keywords": ["primary keyword", "secondary keyword"]
}
}}
]
}"""
}}"""
def get_outline_schema(self) -> Dict[str, Any]:
"""Get the structured JSON schema for outline generation."""

View File

@@ -5,8 +5,8 @@ Enhances individual outline sections for better engagement and value.
"""
from loguru import logger
from models.blog_models import BlogOutlineSection
import json
class SectionEnhancer:
@@ -73,14 +73,45 @@ class SectionEnhancer:
"required": ["heading", "subheadings", "key_points", "target_words", "keywords"]
}
enhanced_data = llm_text_gen(
raw = llm_text_gen(
prompt=enhancement_prompt,
json_struct=enhancement_schema,
system_prompt=None,
user_id=user_id
)
if isinstance(enhanced_data, dict) and 'error' not in enhanced_data:
# Parse JSON from LLM response (works with both string and dict return types)
import re
if isinstance(raw, str):
cleaned = raw.strip()
if cleaned.startswith('```json'):
cleaned = cleaned[7:]
if cleaned.startswith('```'):
cleaned = cleaned[3:]
if cleaned.endswith('```'):
cleaned = cleaned[:-3]
cleaned = cleaned.strip()
try:
enhanced_data = json.loads(cleaned)
except json.JSONDecodeError:
json_match = re.search(r'\{.*\}', cleaned, re.DOTALL)
if json_match:
try:
enhanced_data = json.loads(json_match.group(0))
except json.JSONDecodeError as e:
logger.warning(f"Section enhancement returned invalid JSON: {e}")
return section
else:
logger.warning(f"Section enhancement returned non-JSON string: {cleaned[:200]}")
return section
elif isinstance(raw, dict):
enhanced_data = raw
else:
logger.warning(f"Unexpected LLM response type: {type(raw)}")
return section
if 'error' in enhanced_data:
logger.warning(f"AI section enhancement failed: {enhanced_data.get('error', 'Unknown error')}")
else:
return BlogOutlineSection(
id=section.id,
heading=enhanced_data.get('heading', section.heading),

View File

@@ -6,6 +6,7 @@ Extracts competitor insights and market intelligence from research content.
from typing import Dict, Any
from loguru import logger
import json
class CompetitorAnalyzer:
@@ -22,7 +23,7 @@ class CompetitorAnalyzer:
Extract and analyze:
1. Top competitors mentioned (companies, brands, platforms)
2. Content gaps (what competitors are missing)
3. Market opportunities (untapped areas)
3. Opportunities (untapped areas)
4. Competitive advantages (what makes content unique)
5. Market positioning insights
6. Industry leaders and their strategies
@@ -55,18 +56,38 @@ class CompetitorAnalyzer:
"required": ["top_competitors", "content_gaps", "opportunities", "competitive_advantages", "market_positioning", "industry_leaders", "analysis_notes"]
}
competitor_analysis = llm_text_gen(
raw = llm_text_gen(
prompt=competitor_prompt,
json_struct=competitor_schema,
user_id=user_id
)
if isinstance(competitor_analysis, dict) and 'error' not in competitor_analysis:
logger.info("✅ AI competitor analysis completed successfully")
return competitor_analysis
# Parse JSON from LLM response (works with both string and dict return types)
import re
if isinstance(raw, str):
cleaned = raw.strip()
if cleaned.startswith('```json'):
cleaned = cleaned[7:]
if cleaned.startswith('```'):
cleaned = cleaned[3:]
if cleaned.endswith('```'):
cleaned = cleaned[:-3]
cleaned = cleaned.strip()
try:
competitor_analysis = json.loads(cleaned)
except json.JSONDecodeError:
json_match = re.search(r'\{.*\}', cleaned, re.DOTALL)
if json_match:
competitor_analysis = json.loads(json_match.group(0))
else:
raise ValueError(f"Competitor analysis returned non-JSON string: {cleaned[:200]}")
elif isinstance(raw, dict):
competitor_analysis = raw
else:
# Fail gracefully - no fallback data
error_msg = competitor_analysis.get('error', 'Unknown error') if isinstance(competitor_analysis, dict) else str(competitor_analysis)
logger.error(f"AI competitor analysis failed: {error_msg}")
raise ValueError(f"Competitor analysis failed: {error_msg}")
raise ValueError(f"Unexpected LLM response type: {type(raw)}")
if 'error' in competitor_analysis:
raise ValueError(f"Competitor analysis failed: {competitor_analysis.get('error', 'Unknown error')}")
logger.info("✅ AI competitor analysis completed successfully")
return competitor_analysis

View File

@@ -63,18 +63,41 @@ class ContentAngleGenerator:
"required": ["content_angles"]
}
angles_result = llm_text_gen(
raw = llm_text_gen(
prompt=angles_prompt,
json_struct=angles_schema,
user_id=user_id
)
if isinstance(angles_result, dict) and 'content_angles' in angles_result:
logger.info("✅ AI content angles generation completed successfully")
return angles_result['content_angles'][:7]
# Parse JSON from LLM response (works with both string and dict return types)
import json, re
if isinstance(raw, str):
cleaned = raw.strip()
if cleaned.startswith('```json'):
cleaned = cleaned[7:]
if cleaned.startswith('```'):
cleaned = cleaned[3:]
if cleaned.endswith('```'):
cleaned = cleaned[:-3]
cleaned = cleaned.strip()
try:
angles_result = json.loads(cleaned)
except json.JSONDecodeError:
json_match = re.search(r'\{.*\}', cleaned, re.DOTALL)
if json_match:
angles_result = json.loads(json_match.group(0))
else:
raise ValueError(f"Content angles returned non-JSON string: {cleaned[:200]}")
elif isinstance(raw, dict):
angles_result = raw
else:
# Fail gracefully - no fallback data
error_msg = angles_result.get('error', 'Unknown error') if isinstance(angles_result, dict) else str(angles_result)
logger.error(f"AI content angles generation failed: {error_msg}")
raise ValueError(f"Content angles generation failed: {error_msg}")
raise ValueError(f"Unexpected LLM response type: {type(raw)}")
if 'error' in angles_result:
raise ValueError(f"Content angles generation failed: {angles_result.get('error', 'Unknown error')}")
if 'content_angles' not in angles_result:
raise ValueError(f"Content angles missing from response")
logger.info("✅ AI content angles generation completed successfully")
return angles_result['content_angles'][:7]

View File

@@ -6,6 +6,7 @@ Extracts and analyzes keywords from research content using structured AI respons
from typing import Dict, Any, List
from loguru import logger
import json
class KeywordAnalyzer:
@@ -62,18 +63,38 @@ class KeywordAnalyzer:
"required": ["primary", "secondary", "long_tail", "search_intent", "difficulty", "content_gaps", "semantic_keywords", "trending_terms", "analysis_insights"]
}
keyword_analysis = llm_text_gen(
raw = llm_text_gen(
prompt=keyword_prompt,
json_struct=keyword_schema,
user_id=user_id
)
if isinstance(keyword_analysis, dict) and 'error' not in keyword_analysis:
logger.info("✅ AI keyword analysis completed successfully")
return keyword_analysis
# Parse JSON from LLM response (works with both string and dict return types)
import re
if isinstance(raw, str):
cleaned = raw.strip()
if cleaned.startswith('```json'):
cleaned = cleaned[7:]
if cleaned.startswith('```'):
cleaned = cleaned[3:]
if cleaned.endswith('```'):
cleaned = cleaned[:-3]
cleaned = cleaned.strip()
try:
keyword_analysis = json.loads(cleaned)
except json.JSONDecodeError:
json_match = re.search(r'\{.*\}', cleaned, re.DOTALL)
if json_match:
keyword_analysis = json.loads(json_match.group(0))
else:
raise ValueError(f"Keyword analysis returned non-JSON string: {cleaned[:200]}")
elif isinstance(raw, dict):
keyword_analysis = raw
else:
# Fail gracefully - no fallback data
error_msg = keyword_analysis.get('error', 'Unknown error') if isinstance(keyword_analysis, dict) else str(keyword_analysis)
logger.error(f"AI keyword analysis failed: {error_msg}")
raise ValueError(f"Keyword analysis failed: {error_msg}")
raise ValueError(f"Unexpected LLM response type: {type(raw)}")
if 'error' in keyword_analysis:
raise ValueError(f"Keyword analysis failed: {keyword_analysis.get('error', 'Unknown error')}")
logger.info("✅ AI keyword analysis completed successfully")
return keyword_analysis

View File

@@ -111,19 +111,22 @@ class ResearchService:
# Exa research workflow
from .exa_provider import ExaResearchProvider
from services.subscription.preflight_validator import validate_exa_research_operations
from services.database import get_db
from services.database import get_session_for_user
from services.subscription import PricingService
import os
import time
# Pre-flight validation
db_val = next(get_db())
# Pre-flight validation (use get_session_for_user since get_db is a FastAPI dependency)
db_val = get_session_for_user(user_id)
if not db_val:
raise HTTPException(status_code=503, detail="Database temporarily unavailable. Please try again.")
try:
pricing_service = PricingService(db_val)
gpt_provider = os.getenv("GPT_PROVIDER", "google")
validate_exa_research_operations(pricing_service, user_id, gpt_provider)
finally:
db_val.close()
if db_val:
db_val.close()
# Execute Exa search
api_start_time = time.time()
@@ -162,13 +165,15 @@ class ResearchService:
elif config.provider == ResearchProvider.TAVILY:
# Tavily research workflow
from .tavily_provider import TavilyResearchProvider
from services.database import get_db
from services.database import get_session_for_user
from services.subscription import PricingService
import os
import time
# Pre-flight validation (similar to Exa)
db_val = next(get_db())
# Pre-flight validation (use get_session_for_user since get_db is a FastAPI dependency)
db_val = get_session_for_user(user_id)
if not db_val:
raise HTTPException(status_code=503, detail="Database temporarily unavailable. Please try again.")
try:
pricing_service = PricingService(db_val)
# Check Tavily usage limits
@@ -429,14 +434,16 @@ class ResearchService:
# Exa research workflow
from .exa_provider import ExaResearchProvider
from services.subscription.preflight_validator import validate_exa_research_operations
from services.database import get_db
from services.database import get_session_for_user
from services.subscription import PricingService
import os
await task_manager.update_progress(task_id, "🌐 Connecting to Exa neural search...")
# Pre-flight validation
db_val = next(get_db())
# Pre-flight validation (use get_session_for_user since get_db is a FastAPI dependency)
db_val = get_session_for_user(user_id)
if not db_val:
raise HTTPException(status_code=503, detail="Database temporarily unavailable. Please try again.")
try:
pricing_service = PricingService(db_val)
gpt_provider = os.getenv("GPT_PROVIDER", "google")
@@ -446,7 +453,8 @@ class ResearchService:
await task_manager.update_progress(task_id, f"❌ Subscription limit exceeded: {http_error.detail.get('message', str(http_error.detail)) if isinstance(http_error.detail, dict) else str(http_error.detail)}")
raise
finally:
db_val.close()
if db_val:
db_val.close()
# Execute Exa search
await task_manager.update_progress(task_id, "🤖 Executing Exa neural search...")
@@ -485,14 +493,16 @@ class ResearchService:
elif config.provider == ResearchProvider.TAVILY:
# Tavily research workflow
from .tavily_provider import TavilyResearchProvider
from services.database import get_db
from services.database import get_session_for_user
from services.subscription import PricingService
import os
await task_manager.update_progress(task_id, "🌐 Connecting to Tavily AI search...")
# Pre-flight validation
db_val = next(get_db())
# Pre-flight validation (use get_session_for_user since get_db is a FastAPI dependency)
db_val = get_session_for_user(user_id)
if not db_val:
raise HTTPException(status_code=503, detail="Database temporarily unavailable. Please try again.")
try:
pricing_service = PricingService(db_val)
# Check Tavily usage limits
@@ -529,7 +539,8 @@ class ResearchService:
except Exception as e:
logger.warning(f"Error checking Tavily limits: {e}")
finally:
db_val.close()
if db_val:
db_val.close()
# Execute Tavily search
await task_manager.update_progress(task_id, "🤖 Executing Tavily AI search...")

View File

@@ -135,11 +135,14 @@ class TavilyResearchProvider(BaseProvider):
def track_tavily_usage(self, user_id: str, cost: float, search_depth: str):
"""Track Tavily API usage after successful call."""
from services.database import get_db
from services.database import get_session_for_user
from services.subscription import PricingService
from sqlalchemy import text
db = next(get_db())
db = get_session_for_user(user_id)
if not db:
logger.warning(f"[Tavily] Could not get DB session for user {user_id}, skipping usage tracking")
return
try:
pricing_service = PricingService(db)
current_period = pricing_service.get_current_billing_period(user_id)

View File

@@ -92,6 +92,7 @@ class BlogSEORecommendationApplier:
None,
schema,
user_id, # Pass user_id for subscription checking
max_tokens=8192,
)
if not result or result.get("error"):

View File

@@ -233,7 +233,7 @@ def create_blog_post(
# BACK TO BASICS MODE: Try simplest possible structure FIRST
# Since posting worked before Ricos/SEO, let's test with absolute minimum
BACK_TO_BASICS_MODE = True # Set to True to test with simplest structure
BACK_TO_BASICS_MODE = False # Disabled: full Ricos conversion now produces valid output
wix_logger.reset()
wix_logger.log_operation_start("Blog Post Creation", title=title[:50] if title else None, member_id=member_id[:20] if member_id else None)
@@ -257,8 +257,7 @@ def create_blog_post(
'text': (content[:500] if content else "This is a post from ALwrity.").strip(),
'decorations': []
}
}],
'paragraphData': {}
}]
}]
}

View File

@@ -256,17 +256,16 @@ def convert_content_to_ricos(content: str, images: List[str] = None) -> Dict[str
quote_content = ' '.join(quote_lines)
text_nodes = parse_markdown_inline(quote_content)
# CRITICAL: TEXT nodes must be wrapped in PARAGRAPH nodes within BLOCKQUOTE
# Wix API: omit empty data objects, don't include them as {}
paragraph_node = {
'id': str(uuid.uuid4()),
'type': 'PARAGRAPH',
'nodes': text_nodes,
'paragraphData': {}
}
blockquote_node = {
'id': node_id,
'type': 'BLOCKQUOTE',
'nodes': [paragraph_node],
'blockquoteData': {}
}
nodes.append(blockquote_node)
@@ -332,7 +331,6 @@ def convert_content_to_ricos(content: str, images: List[str] = None) -> Dict[str
'id': str(uuid.uuid4()),
'type': 'PARAGRAPH',
'nodes': text_nodes,
'paragraphData': {}
}
list_item_node = {
'id': item_node_id,
@@ -345,7 +343,6 @@ def convert_content_to_ricos(content: str, images: List[str] = None) -> Dict[str
'id': node_id,
'type': 'BULLETED_LIST',
'nodes': list_node_items,
'bulletedListData': {}
}
nodes.append(bulleted_list_node)
@@ -373,7 +370,6 @@ def convert_content_to_ricos(content: str, images: List[str] = None) -> Dict[str
'id': str(uuid.uuid4()),
'type': 'PARAGRAPH',
'nodes': text_nodes,
'paragraphData': {}
}
list_item_node = {
'id': item_node_id,
@@ -386,7 +382,6 @@ def convert_content_to_ricos(content: str, images: List[str] = None) -> Dict[str
'id': node_id,
'type': 'ORDERED_LIST',
'nodes': list_node_items,
'orderedListData': {}
}
nodes.append(ordered_list_node)
@@ -442,7 +437,6 @@ def convert_content_to_ricos(content: str, images: List[str] = None) -> Dict[str
'id': node_id,
'type': 'PARAGRAPH',
'nodes': text_nodes,
'paragraphData': {}
}
nodes.append(paragraph_node)
@@ -461,7 +455,6 @@ def convert_content_to_ricos(content: str, images: List[str] = None) -> Dict[str
'decorations': []
}
}],
'paragraphData': {}
}
nodes.append(fallback_paragraph)

View File

@@ -20,13 +20,14 @@ class SemanticHarvesterService:
"last_harvest_time": None
}
async def harvest_website(self, website_url: str, limit: int = 100) -> List[Dict[str, Any]]:
async def harvest_website(self, website_url: str, limit: int = 100, user_id: Optional[str] = None) -> List[Dict[str, Any]]:
"""
Deep crawl a website using Exa AI.
Args:
website_url: The root URL to crawl.
limit: Maximum number of pages to retrieve.
user_id: Optional user ID for usage tracking and preflight checks.
Returns:
List of pages with content and metadata.
@@ -59,6 +60,30 @@ class SemanticHarvesterService:
logger.warning("[SemanticHarvester] Exa service disabled. Returning placeholder data.")
return self._get_placeholder_data(website_url)
# Preflight subscription check if user_id provided
if user_id:
try:
from services.database import get_session_for_user
from services.subscription import PricingService
from models.subscription_models import APIProvider
db = get_session_for_user(user_id)
if db:
try:
pricing_service = PricingService(db)
can_proceed, message, usage_info = pricing_service.check_usage_limits(
user_id=user_id,
provider=APIProvider.EXA,
tokens_requested=0,
actual_provider_name="exa",
)
if not can_proceed:
logger.warning(f"[SemanticHarvester] Exa blocked for user {user_id}: {message}")
return []
finally:
db.close()
except Exception as e:
logger.warning(f"[SemanticHarvester] Preflight check failed: {e}")
# Use Exa to search for all pages in this domain
search_response = self.exa_service.exa.search_and_contents(
query=f"site:{website_url}",
@@ -82,6 +107,38 @@ class SemanticHarvesterService:
})
logger.info(f"[SemanticHarvester] Successfully harvested {len(results)} pages from {website_url}")
# Track Exa usage if user_id provided
if user_id and results:
try:
from services.database import get_session_for_user
from services.subscription import PricingService
from sqlalchemy import text
db = get_session_for_user(user_id)
if db:
try:
pricing_service = PricingService(db)
current_period = pricing_service.get_current_billing_period(user_id)
cost = 0.005 # Exa search cost estimate
update_query = text("""
UPDATE usage_summaries
SET exa_calls = COALESCE(exa_calls, 0) + 1,
exa_cost = COALESCE(exa_cost, 0) + :cost,
total_calls = COALESCE(total_calls, 0) + 1,
total_cost = COALESCE(total_cost, 0) + :cost
WHERE user_id = :user_id AND billing_period = :period
""")
db.execute(update_query, {
'cost': cost, 'user_id': user_id, 'period': current_period,
})
db.commit()
logger.info(f"[SemanticHarvester] Tracked Exa usage: user={user_id}, cost=${cost}")
finally:
db.close()
except Exception as track_err:
logger.warning(f"[SemanticHarvester] Failed to track Exa usage: {track_err}")
return results
except Exception as e:

View File

@@ -133,9 +133,9 @@ def edit_image(
raise
except Exception as e:
logger.error(f"[Image Editing] ❌ Unexpected error during pre-flight validation: {e}")
# In podcast-only mode, allow the operation to continue on validation errors
if os.getenv("ALWRITY_ENABLED_FEATURES") == "podcast":
logger.warning(f"[Image Editing] ⚠️ Validation error in podcast mode - allowing operation to continue")
# In feature-limited mode, allow the operation to continue on validation errors
if os.getenv("ALWRITY_ENABLED_FEATURES", "").strip().lower() not in ("", "all"):
logger.warning(f"[Image Editing] ⚠️ Validation error in feature-limited mode - allowing operation to continue")
else:
raise HTTPException(status_code=500, detail=f"Image editing validation failed: {str(e)}")
finally:

View File

@@ -45,6 +45,7 @@ def llm_text_gen(
preferred_hf_models: Optional[List[str]] = None,
preferred_provider: Optional[str] = None,
flow_type: Optional[str] = None,
max_tokens: Optional[int] = None,
) -> str:
"""
Generate text using Language Model (LLM) based on the provided prompt.
@@ -75,7 +76,8 @@ def llm_text_gen(
gpt_provider = "google" # Default to Google Gemini
model = "gemini-2.0-flash-001"
temperature = 0.7
max_tokens = 4000
if max_tokens is None:
max_tokens = 4000
top_p = 0.9
n = 1
fp = 16
@@ -371,16 +373,27 @@ def llm_text_gen(
system_prompt=system_instructions
)
elif gpt_provider == "wavespeed":
from services.llm_providers.wavespeed_provider import wavespeed_text_response
llm_start = time.time()
response_text = wavespeed_text_response(
prompt=prompt,
model=model or "openai/gpt-oss-120b",
temperature=temperature,
max_tokens=max_tokens,
top_p=top_p,
system_prompt=system_instructions
)
if json_struct:
from services.llm_providers.wavespeed_provider import wavespeed_structured_json_response
response_text = wavespeed_structured_json_response(
prompt=prompt,
schema=json_struct,
model=model or "openai/gpt-oss-120b",
temperature=temperature,
max_tokens=max_tokens,
system_prompt=system_instructions
)
else:
from services.llm_providers.wavespeed_provider import wavespeed_text_response
response_text = wavespeed_text_response(
prompt=prompt,
model=model or "openai/gpt-oss-120b",
temperature=temperature,
max_tokens=max_tokens,
top_p=top_p,
system_prompt=system_instructions
)
llm_ms = (time.time() - llm_start) * 1000
logger.warning(f"[llm_text_gen][{flow_tag}] LLM API call took {llm_ms:.0f}ms for user {user_id} (wavespeed)")
else:

View File

@@ -179,6 +179,43 @@ def get_wavespeed_api_key() -> str:
return api_key
def _retry_with_increased_tokens(
client: "OpenAI",
messages: List[Dict[str, str]],
model: str,
fallback_models: Optional[List[str]],
temperature: float,
max_tokens: int,
) -> Optional[str]:
"""Retry the API call with increased max_tokens when JSON parsing fails due to truncation."""
max_tokens = min(max_tokens, 16384)
last_error = None
for candidate_model in _fallback_model_sequence(model, fallback_models):
try:
response = client.chat.completions.create(
model=candidate_model,
messages=messages,
temperature=temperature,
max_tokens=max_tokens,
)
text = response.choices[0].message.content
text = text.strip() if text else ""
if text.startswith("```json"):
text = text[7:]
if text.startswith("```"):
text = text[3:]
if text.endswith("```"):
text = text[:-3]
return text.strip()
except NotFoundError as nf_err:
last_error = nf_err
continue
if last_error:
logger.error(f"All fallback models failed on retry with increased tokens: {last_error}")
return None
@retry(
retry=retry_if_exception(_should_retry_wavespeed_error),
wait=wait_random_exponential(min=1, max=60),
@@ -446,24 +483,69 @@ def wavespeed_structured_json_response(
raise last_error or Exception("WaveSpeed structured generation failed: all fallback models failed")
response_text = response.choices[0].message.content
response_text = response_text.strip() if response_text else ""
# If response_format returned empty content, retry without it
if not response_text:
logger.warning("WaveSpeed structured call returned empty content with response_format, retrying without it...")
response = None
last_error = None
for candidate_model in _fallback_model_sequence(model, fallback_models):
try:
response = client.chat.completions.create(
model=candidate_model,
messages=messages,
temperature=temperature,
max_tokens=max_tokens
)
break
except NotFoundError as nf_err:
last_error = nf_err
continue
if response is not None:
response_text = response.choices[0].message.content
response_text = response_text.strip() if response_text else ""
# Clean up response text if needed
response_text = response_text.strip()
if response_text.startswith("```json"):
response_text = response_text[7:]
if response_text.startswith("```"):
response_text = response_text[3:]
if response_text.endswith("```"):
response_text = response_text[:-3]
response_text = response_text.strip()
try:
parsed_json = json.loads(response_text)
logger.info("✅ WaveSpeed structured JSON response parsed successfully")
return parsed_json
parsed_json = json.loads(response_text) if response_text else None
if parsed_json is not None:
logger.info("✅ WaveSpeed structured JSON response parsed successfully")
return parsed_json
except json.JSONDecodeError as json_err:
logger.error(f"❌ JSON parsing failed: {json_err}")
logger.error(f"Raw response: {response_text}")
# Retry once with increased max_tokens — likely a truncation issue
if max_tokens < 16384:
logger.warning(f"Retrying with increased max_tokens ({max_tokens}{max_tokens * 2}) due to JSON parse failure")
response_text = _retry_with_increased_tokens(
client=client,
messages=messages,
model=model,
fallback_models=fallback_models,
temperature=temperature,
max_tokens=max_tokens * 2,
)
if response_text:
try:
parsed_json = json.loads(response_text)
if parsed_json is not None:
logger.info("✅ WaveSpeed structured JSON parsed successfully after max_tokens increase")
return parsed_json
except json.JSONDecodeError:
logger.error("❌ JSON parsing failed even after max_tokens increase")
# Try to extract JSON from the response using regex
logger.error(f"Raw response: {response_text}")
# Try to extract JSON from the response using regex
if response_text:
json_match = re.search(r'\{.*\}', response_text, re.DOTALL)
if json_match:
try:
@@ -472,8 +554,8 @@ def wavespeed_structured_json_response(
return extracted_json
except json.JSONDecodeError:
pass
return {"error": "Failed to parse JSON response", "raw_response": response_text}
return {"error": "Failed to parse JSON response", "raw_response": response_text}
except Exception as e:
logger.error(f"❌ WaveSpeed API call failed: {e}")
@@ -501,14 +583,24 @@ def wavespeed_structured_json_response(
if response is None:
raise last_error or e
response_text = response.choices[0].message.content
# ... (same parsing logic would apply, simplified here for brevity)
response_text = response_text.strip() if response_text else ""
# Parse JSON with robust cleaning
if response_text.startswith("```json"):
response_text = response_text[7:]
if response_text.startswith("```"):
response_text = response_text[3:]
if response_text.endswith("```"):
response_text = response_text[:-3]
response_text = response_text.strip()
try:
return json.loads(response_text)
except:
# Regex fallback
return json.loads(response_text) if response_text else {"error": "Empty response"}
except json.JSONDecodeError:
json_match = re.search(r'\{.*\}', response_text, re.DOTALL)
if json_match:
return json.loads(json_match.group())
try:
return json.loads(json_match.group())
except json.JSONDecodeError:
pass
return {"error": "Failed to parse JSON response", "raw_response": response_text}
raise e

View File

@@ -19,11 +19,11 @@ from services.database import get_db_session
from models.onboarding import OnboardingSession, WebsiteAnalysis, ResearchPreferences
from models.persona_models import WritingPersona, PlatformPersona, PersonaAnalysisResult
def _get_podcast_mode():
"""Check if running in podcast-only mode to skip heavy initialization."""
def _is_feature_limited_mode():
"""Check if running in feature-limited mode to skip heavy initialization."""
import os
env_val = os.getenv("ALWRITY_ENABLED_FEATURES", "").strip().lower()
return env_val == "podcast"
return env_val not in ("", "all")
class PersonaAnalysisService:
"""Service for analyzing onboarding data and generating writing personas using Gemini AI."""
@@ -40,9 +40,9 @@ class PersonaAnalysisService:
def __init__(self):
"""Initialize the persona analysis service (only once)."""
if not self._initialized:
# Skip heavy initialization in podcast-only mode
if _get_podcast_mode():
logger.debug("PersonaAnalysisService: Skipping heavy init in podcast mode")
# Skip heavy initialization in feature-limited mode
if _is_feature_limited_mode():
logger.debug(f"PersonaAnalysisService: Skipping heavy init in feature-limited mode")
self._initialized = True
return
@@ -55,8 +55,8 @@ class PersonaAnalysisService:
return
# Check again in case mode changed
if _get_podcast_mode():
logger.debug("PersonaAnalysisService: Skipping heavy init in podcast mode")
if _is_feature_limited_mode():
logger.debug("PersonaAnalysisService: Skipping heavy init in feature-limited mode")
self._heavy_init_done = True
return
@@ -89,9 +89,9 @@ class PersonaAnalysisService:
# Ensure heavy services are initialized
self._ensure_heavy_init()
# Check if heavy init failed (podcast mode)
# Check if heavy init failed (feature-limited mode)
if not getattr(self, '_heavy_init_done', False):
return {"error": "Persona service unavailable in podcast-only mode"}
return {"error": "Persona service unavailable in feature-limited mode"}
try:
logger.info(f"Generating persona for user {user_id}")

View File

@@ -296,6 +296,33 @@ class ResearchEngine:
target_audience = request.target_audience or "General"
research_prompt = strategy.build_research_prompt(topic, industry, target_audience, config)
# Preflight subscription check
try:
db = self._db_session
if not db:
from services.database import get_db_session
db = get_db_session()
if db:
from services.subscription import PricingService
from models.subscription_models import APIProvider
pricing_service = PricingService(db)
can_proceed, message, usage_info = pricing_service.check_usage_limits(
user_id=user_id,
provider=APIProvider.EXA,
tokens_requested=0,
actual_provider_name="exa",
)
if not can_proceed:
raise HTTPException(status_code=429, detail={
'error': message, 'message': message,
'provider': 'exa', 'usage_info': usage_info or {}
})
logger.info(f"[ResearchEngine] Exa preflight check passed for user {user_id}")
except HTTPException:
raise
except Exception as e:
logger.warning(f"[ResearchEngine] Exa preflight check failed: {e}")
# Execute Exa search
try:
@@ -341,6 +368,33 @@ class ResearchEngine:
target_audience = request.target_audience or "General"
research_prompt = strategy.build_research_prompt(topic, industry, target_audience, config)
# Preflight subscription check
try:
db = self._db_session
if not db:
from services.database import get_db_session
db = get_db_session()
if db:
from services.subscription import PricingService
from models.subscription_models import APIProvider
pricing_service = PricingService(db)
can_proceed, message, usage_info = pricing_service.check_usage_limits(
user_id=user_id,
provider=APIProvider.TAVILY,
tokens_requested=0,
actual_provider_name="tavily",
)
if not can_proceed:
raise HTTPException(status_code=429, detail={
'error': message, 'message': message,
'provider': 'tavily', 'usage_info': usage_info or {}
})
logger.info(f"[ResearchEngine] Tavily preflight check passed for user {user_id}")
except HTTPException:
raise
except Exception as e:
logger.warning(f"[ResearchEngine] Tavily preflight check failed: {e}")
# Execute Tavily search
try:

View File

@@ -83,6 +83,30 @@ class DeepCrawlService:
tavily_results.append(res)
logger.info(f"Found {len(tavily_urls)} URLs from Tavily")
# Track Tavily usage
try:
from services.subscription import PricingService
from sqlalchemy import text
pricing_service = PricingService(db)
current_period = pricing_service.get_current_billing_period(user_id)
cost = 0.005 # Tavily crawl cost estimate
update_query = text("""
UPDATE usage_summaries
SET tavily_calls = COALESCE(tavily_calls, 0) + 1,
tavily_cost = COALESCE(tavily_cost, 0) + :cost,
total_calls = COALESCE(total_calls, 0) + 1,
total_cost = COALESCE(total_cost, 0) + :cost
WHERE user_id = :user_id AND billing_period = :period
""")
db.execute(update_query, {
'cost': cost, 'user_id': user_id, 'period': current_period,
})
db.commit()
logger.info(f"[DeepCrawl] Tracked Tavily crawl usage: user={user_id}, cost=${cost}")
except Exception as track_err:
logger.warning(f"[DeepCrawl] Failed to track Tavily usage: {track_err}")
except Exception as e:
logger.warning(f"Tavily crawl failed: {e}")

View File

@@ -49,9 +49,11 @@ except Exception as _patch_err:
# Now safe to import pytrends
try:
from pytrends.request import TrendReq as _TrendReq
from pytrends.exceptions import TooManyRequestsError as _TooManyRequestsError
PYTrends_AVAILABLE = True
except ImportError:
PYTrends_AVAILABLE = False
_TooManyRequestsError = None
logger.warning("pytrends not installed. Google Trends features will be unavailable.")
# Patch 2: pytrends related_topics() and related_queries() use keyword[0]
@@ -139,6 +141,8 @@ class GoogleTrendsService:
Uses TrendReq with no retries (fail-fast) to avoid hitting CAPTCHA on blocks.
429 retry handling (1s, 2s, 4s backoff). Random user-agent is set
per instance to reduce fingerprinting.
Rate limiter is shared across all instances to enforce global rate limiting.
"""
USER_AGENTS = [
@@ -150,15 +154,28 @@ class GoogleTrendsService:
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 Edg/124.0.0.0",
]
# Class-level shared resources (shared across all instances)
_shared_rate_limiter = None
_shared_cache = None
_cache_ttl = timedelta(hours=24)
_last_429_time = 0 # Timestamp of last 429 error (Unix epoch)
_429_cooldown_period = 1800 # 30 minutes cooldown after 429
def __init__(self):
if not PYTrends_AVAILABLE:
raise RuntimeError("pytrends library is required. Install with: pip install pytrends")
self.rate_limiter = RateLimiter(max_calls=1, period=1.0)
self.cache: Dict[str, Any] = {}
self.cache_ttl = timedelta(hours=24)
# Initialize shared rate limiter at class level (lazy init)
if self.__class__._shared_rate_limiter is None:
self.__class__._shared_rate_limiter = RateLimiter(max_calls=1, period=3.0) # 1 call per 3 seconds
if self.__class__._shared_cache is None:
self.__class__._shared_cache = {}
logger.info("GoogleTrendsService initialized (pytrends 4.9.2, fail-fast, 2s delays)")
self.rate_limiter = self.__class__._shared_rate_limiter
self.cache = self.__class__._shared_cache
self.cache_ttl = self._cache_ttl
logger.info("GoogleTrendsService initialized (pytrends 4.9.2, shared rate limiter, 3s period, shared cache, 30min 429 cooldown)")
# -----------------------------------------------------------------------
# Public API
@@ -173,7 +190,7 @@ class GoogleTrendsService:
user_id: Optional[str] = None,
) -> Dict[str, Any]:
"""
Comprehensive trends analysis.
Comprehensive trends analysis with retry logic for 429 errors.
Args:
keywords: List of keywords to analyze (1-5)
@@ -193,11 +210,97 @@ class GoogleTrendsService:
keywords = keywords[:5]
cache_key = self._build_cache_key(keywords, timeframe, geo)
# Check if we're in a 429 cooldown period
now = time.time()
if now - self.__class__._last_429_time < self.__class__._429_cooldown_period:
remaining_cooldown = int(self.__class__._429_cooldown_period - (now - self.__class__._last_429_time))
logger.warning(
f"[Trends] In 429 cooldown period. {remaining_cooldown}s remaining. "
f"Returning cached data if available."
)
cached_data = self._get_from_cache(cache_key, ignore_ttl=True) # Use stale cache
if cached_data:
logger.info(f"[Trends] Returning stale cached data for {keywords} during cooldown")
return {**cached_data, "cached": True, "cooldown_active": True}
return self._create_fallback_response(
keywords, timeframe, geo, gprop,
f"Rate limited by Google. Cooldown active for {remaining_cooldown}s. Try again later."
)
# Check fresh cache
cached_data = self._get_from_cache(cache_key)
if cached_data:
logger.info(f"Returning cached trends data for: {keywords}")
return {**cached_data, "cached": True}
# Retry logic for 429 errors
max_retries = 3
retry_delays = [30, 60, 120] # Longer delays: 30s, 60s, 120s
for attempt in range(max_retries + 1):
try:
return await self._do_analyze_trends(
keywords, timeframe, geo, gprop, cache_key, attempt, max_retries
)
except Exception as e:
# Check if this is a 429 error (pytrends raises TooManyRequestsError)
is_429 = False
if _TooManyRequestsError and isinstance(e, _TooManyRequestsError):
is_429 = True
else:
error_str = str(e).lower()
is_429 = "429" in error_str or "rate limit" in error_str or "too many requests" in error_str
if is_429:
# Update the last 429 time for cooldown
self.__class__._last_429_time = time.time()
if attempt < max_retries:
delay = retry_delays[attempt]
logger.warning(
f"[Trends] 429 rate limit hit (attempt {attempt + 1}/{max_retries + 1}), "
f"retrying in {delay}s..."
)
await asyncio.sleep(delay)
continue
else:
# Out of retries - enter cooldown
logger.error(
f"[Trends] 429 rate limit persisted after {max_retries + 1} attempts. "
f"Entering {self.__class__._429_cooldown_period}s cooldown period."
)
# Try to return stale cache
stale_cache = self._get_from_cache(cache_key, ignore_ttl=True)
if stale_cache:
logger.info(f"[Trends] Returning stale cache after 429 exhaustion for {keywords}")
result = {**stale_cache}
result["cached"] = True
result["cooldown_active"] = True
return result
return self._create_fallback_response(
keywords, timeframe, geo, gprop,
f"Google is rate limiting requests. Cooldown active for {self.__class__._429_cooldown_period}s. Try again later."
)
else:
# Non-429 error
logger.error(f"Google Trends analysis failed after {attempt + 1} attempts: {e}")
return self._create_fallback_response(keywords, timeframe, geo, gprop, str(e))
# Should not reach here, but just in case
return self._create_fallback_response(keywords, timeframe, geo, gprop, "Max retries exceeded")
async def _do_analyze_trends(
self,
keywords: List[str],
timeframe: str,
geo: str,
gprop: str,
cache_key: str,
attempt: int,
max_retries: int,
) -> Dict[str, Any]:
"""Internal method to perform the actual trends analysis."""
await self.rate_limiter.acquire()
total_start = time.monotonic()
@@ -207,95 +310,63 @@ class GoogleTrendsService:
related_topics: Dict[str, List[Dict[str, Any]]] = {"top": [], "rising": []}
related_queries: Dict[str, List[Dict[str, Any]]] = {"top": [], "rising": []}
try:
logger.info(f"[Trends] ===== START analyze_trends ===== keywords={keywords} timeframe={timeframe} geo={geo}")
logger.info(
f"[Trends] ===== START analyze_trends (attempt {attempt + 1}/{max_retries + 1}) ===== "
f"keywords={keywords} timeframe={timeframe} geo={geo}"
)
# Initialize TrendReq with gprop (youtube for video/podcast relevance)
init_start = time.monotonic()
pytrends = await asyncio.to_thread(
self._create_pytrends,
keywords,
timeframe,
geo,
gprop,
)
init_ms = int((time.monotonic() - init_start) * 1000)
logger.info(f"[Trends] TrendReq init + build_payload took {init_ms}ms")
# Initialize TrendReq with gprop (youtube for video/podcast relevance)
init_start = time.monotonic()
pytrends = await asyncio.to_thread(
self._create_pytrends,
keywords,
timeframe,
geo,
gprop,
)
init_ms = int((time.monotonic() - init_start) * 1000)
logger.info(f"[Trends] TrendReq init + build_payload took {init_ms}ms")
# --- Interest Over Time ---
iot_start = time.monotonic()
interest_over_time = await asyncio.to_thread(
lambda: self._fetch_interest_over_time(pytrends)
)
iot_ms = int((time.monotonic() - iot_start) * 1000)
logger.info(f"[Trends] interest_over_time took {iot_ms}ms, returned {len(interest_over_time)} points")
# --- Interest Over Time ONLY (skip others to avoid 429) ---
await self.rate_limiter.acquire() # Rate limit check BEFORE each request
iot_start = time.monotonic()
interest_over_time = await asyncio.to_thread(
lambda: self._fetch_interest_over_time(pytrends)
)
iot_ms = int((time.monotonic() - iot_start) * 1000)
logger.info(f"[Trends] interest_over_time took {iot_ms}ms, returned {len(interest_over_time)} points")
await asyncio.sleep(2)
# Skip other requests to avoid 429 - only fetch interest_over_time for now
logger.info(f"[Trends] Skipping other requests to avoid 429 (interest_by_region, related_topics, related_queries)")
# --- Interest By Region ---
ibr_start = time.monotonic()
interest_by_region = await asyncio.to_thread(
lambda: self._fetch_interest_by_region(pytrends)
)
ibr_ms = int((time.monotonic() - ibr_start) * 1000)
logger.info(f"[Trends] interest_by_region took {ibr_ms}ms, returned {len(interest_by_region)} regions")
total_ms = int((time.monotonic() - total_start) * 1000)
logger.info(
f"[Trends] ===== DONE analyze_trends ===== total={total_ms}ms "
f"iot={len(interest_over_time)} ibr={len(interest_by_region)} "
f"rt_top={rt_top} rq_top={rq_top}"
)
await asyncio.sleep(2)
result = {
"interest_over_time": interest_over_time,
"interest_by_region": interest_by_region,
"related_topics": related_topics,
"related_queries": related_queries,
"timeframe": timeframe,
"geo": geo,
"keywords": keywords,
"source": "web" if gprop == "" else "podcast" if gprop == "youtube" else gprop,
"timestamp": datetime.utcnow().isoformat(),
"cached": False,
}
# --- Related Topics ---
rt_start = time.monotonic()
related_topics = await asyncio.to_thread(
lambda: self._fetch_related_topics(pytrends)
)
rt_ms = int((time.monotonic() - rt_start) * 1000)
rt_top = len(related_topics.get("top", []))
rt_rising = len(related_topics.get("rising", []))
logger.info(f"[Trends] related_topics took {rt_ms}ms, top={rt_top} rising={rt_rising}")
self._save_to_cache(cache_key, result)
await asyncio.sleep(2)
logger.info(
f"Google Trends data fetched successfully: "
f"{len(interest_over_time)} time points, {len(interest_by_region)} regions"
)
# --- Related Queries ---
rq_start = time.monotonic()
related_queries = await asyncio.to_thread(
lambda: self._fetch_related_queries(pytrends)
)
rq_ms = int((time.monotonic() - rq_start) * 1000)
rq_top = len(related_queries.get("top", []))
rq_rising = len(related_queries.get("rising", []))
logger.info(f"[Trends] related_queries took {rq_ms}ms, top={rq_top} rising={rq_rising}")
total_ms = int((time.monotonic() - total_start) * 1000)
logger.info(
f"[Trends] ===== DONE analyze_trends ===== total={total_ms}ms "
f"iot={len(interest_over_time)} ibr={len(interest_by_region)} "
f"rt_top={rt_top} rq_top={rq_top}"
)
result = {
"interest_over_time": interest_over_time,
"interest_by_region": interest_by_region,
"related_topics": related_topics,
"related_queries": related_queries,
"timeframe": timeframe,
"geo": geo,
"keywords": keywords,
"source": "web" if gprop == "" else "podcast" if gprop == "youtube" else gprop,
"timestamp": datetime.utcnow().isoformat(),
"cached": False,
}
self._save_to_cache(cache_key, result)
logger.info(
f"Google Trends data fetched successfully: "
f"{len(interest_over_time)} time points, {len(interest_by_region)} regions"
)
return result
except Exception as e:
logger.error(f"Google Trends analysis failed: {e}")
return self._create_fallback_response(keywords, timeframe, geo, gprop, str(e))
return result
# -----------------------------------------------------------------------
# TrendReq factory
@@ -346,6 +417,12 @@ class GoogleTrendsService:
return result
except Exception as e:
elapsed = int((time.monotonic() - start) * 1000)
# Re-raise 429 errors so retry logic can handle them
if _TooManyRequestsError and isinstance(e, _TooManyRequestsError):
raise
error_str = str(e).lower()
if "429" in error_str or "rate limit" in error_str or "too many requests" in error_str:
raise
logger.error(f"[Trends] interest_over_time failed in {elapsed}ms: {e}")
return []
@@ -363,6 +440,12 @@ class GoogleTrendsService:
return result
except Exception as e:
elapsed = int((time.monotonic() - start) * 1000)
# Re-raise 429 errors so retry logic can handle them
if _TooManyRequestsError and isinstance(e, _TooManyRequestsError):
raise
error_str = str(e).lower()
if "429" in error_str or "rate limit" in error_str or "too many requests" in error_str:
raise
logger.error(f"[Trends] interest_by_region failed in {elapsed}ms: {e}")
return []
@@ -409,6 +492,12 @@ class GoogleTrendsService:
return result
except Exception as e:
elapsed = int((time.monotonic() - start) * 1000)
# Re-raise 429 errors so retry logic can handle them
if _TooManyRequestsError and isinstance(e, _TooManyRequestsError):
raise
error_str = str(e).lower()
if "429" in error_str or "rate limit" in error_str or "too many requests" in error_str:
raise
logger.error(f"[Trends] related_topics failed in {elapsed}ms: {e}")
return result
@@ -452,6 +541,12 @@ class GoogleTrendsService:
return result
except Exception as e:
elapsed = int((time.monotonic() - start) * 1000)
# Re-raise 429 errors so retry logic can handle them
if _TooManyRequestsError and isinstance(e, _TooManyRequestsError):
raise
error_str = str(e).lower()
if "429" in error_str or "rate limit" in error_str or "too many requests" in error_str:
raise
logger.error(f"[Trends] related_queries failed in {elapsed}ms: {e}")
return result
@@ -503,14 +598,18 @@ class GoogleTrendsService:
keywords_str = ":".join(sorted(keywords))
return f"google_trends:{keywords_str}:{timeframe}:{geo}"
def _get_from_cache(self, cache_key: str) -> Optional[Dict[str, Any]]:
def _get_from_cache(self, cache_key: str, ignore_ttl: bool = False) -> Optional[Dict[str, Any]]:
"""Get cached data. If ignore_ttl=True, return stale data too (for 429 cooldown)."""
if cache_key not in self.cache:
return None
cached_entry = self.cache[cache_key]
cached_time = datetime.fromisoformat(cached_entry.get("timestamp", ""))
if datetime.utcnow() - cached_time > self.cache_ttl:
del self.cache[cache_key]
return None
if not ignore_ttl:
cached_time = datetime.fromisoformat(cached_entry.get("timestamp", ""))
if datetime.utcnow() - cached_time > self.cache_ttl:
del self.cache[cache_key]
return None
result = {**cached_entry}
result.pop("cached", None)
return result

View File

@@ -157,10 +157,10 @@ def _check_production_api_key_loading(
_record_check(checks, "production_api_key_loading", True, "skipped in local deploy mode")
return
# Also skip in podcast-only mode (no production API keys needed)
# Skip when in feature-limited mode (no production API keys needed)
enabled_features = os.getenv("ALWRITY_ENABLED_FEATURES", "all").strip().lower()
if enabled_features == "podcast":
_record_check(checks, "production_api_key_loading", True, "skipped in podcast-only mode")
if enabled_features and enabled_features not in ("", "all"):
_record_check(checks, "production_api_key_loading", True, f"skipped in feature-limited mode: {enabled_features}")
return
test_tenant_id = os.getenv("ALWRITY_STARTUP_TEST_TENANT_ID", "").strip()

View File

@@ -12,7 +12,7 @@ from loguru import logger
from sqlalchemy.orm import Session
from sqlalchemy.exc import SQLAlchemyError
from models.subscription_models import APIProvider, UsageAlert
from models.subscription_models import APIProvider, UsageAlert, UserSubscription
class SubscriptionErrorType(Enum):
USAGE_LIMIT_EXCEEDED = "usage_limit_exceeded"
@@ -248,6 +248,18 @@ class SubscriptionExceptionHandler:
return
try:
# Get billing period from subscription, fallback to calendar month
billing_period = datetime.now().strftime("%Y-%m") # default
try:
subscription = self.db.query(UserSubscription).filter(
UserSubscription.user_id == error.user_id,
UserSubscription.is_active == True
).first()
if subscription and subscription.current_period_start:
billing_period = subscription.current_period_start.strftime("%Y-%m")
except:
pass # Use default calendar period
alert = UsageAlert(
user_id=error.user_id,
alert_type="system_error",
@@ -256,7 +268,7 @@ class SubscriptionExceptionHandler:
title=f"System Error: {error.error_type.value}",
message=error.message,
severity=error.severity.value,
billing_period=datetime.now().strftime("%Y-%m")
billing_period=billing_period
)
self.db.add(alert)

View File

@@ -157,39 +157,38 @@ class LimitValidator:
user_tier = limits.get('tier', 'free') if limits else 'free'
# Get current usage for this billing period with error handling
# Use targeted expiry instead of expire_all() to avoid nuking the entire session cache
# Use subscription period, not calendar month
current_period = self.pricing_service.get_current_billing_period(user_id)
# Only expire specific objects that might have changed after renewal
# (subscription was already checked above; plan was expired above)
# The usage record is the main object we need fresh, and we query it directly below
if subscription:
self.db.expire(subscription)
# Use raw SQL query first to bypass ORM cache, fallback to ORM if SQL fails
usage = None
try:
current_period = self.pricing_service.get_current_billing_period(user_id) or datetime.now().strftime("%Y-%m")
# Only expire specific objects that might have changed after renewal
# (subscription was already checked above; plan was expired above)
# The usage record is the main object we need fresh, and we query it directly below
if subscription:
self.db.expire(subscription)
# Use raw SQL query first to bypass ORM cache, fallback to ORM if SQL fails
usage = None
try:
from sqlalchemy import text
sql_query = text("SELECT * FROM usage_summaries WHERE user_id = :user_id AND billing_period = :period LIMIT 1")
result = self.db.execute(sql_query, {'user_id': user_id, 'period': current_period}).first()
if result:
# Map result to UsageSummary object
usage = self.db.query(UsageSummary).filter(
UsageSummary.user_id == user_id,
UsageSummary.billing_period == current_period
).first()
if usage:
self.db.refresh(usage) # Ensure fresh data
except Exception as sql_error:
logger.debug(f"[Subscription Check] Raw SQL query failed, using ORM: {sql_error}")
# Fallback to ORM query
from sqlalchemy import text
sql_query = text("SELECT * FROM usage_summaries WHERE user_id = :user_id AND billing_period = :period LIMIT 1")
result = self.db.execute(sql_query, {'user_id': user_id, 'period': current_period}).first()
if result:
# Map result to UsageSummary object
usage = self.db.query(UsageSummary).filter(
UsageSummary.user_id == user_id,
UsageSummary.billing_period == current_period
).first()
if usage:
self.db.refresh(usage) # Ensure fresh data
except Exception as sql_error:
logger.debug(f"[Subscription Check] Raw SQL query failed, using ORM: {sql_error}")
# Fallback to ORM query
usage = self.db.query(UsageSummary).filter(
UsageSummary.user_id == user_id,
UsageSummary.billing_period == current_period
).first()
if usage:
self.db.refresh(usage) # Ensure fresh data
if not usage:
# First usage this period, create summary
@@ -448,7 +447,7 @@ class LimitValidator:
logger.info(f"[Pre-flight Check] 📋 Validating {len(operations)} operation(s) before making any API calls")
# Get current usage and limits once
current_period = self.pricing_service.get_current_billing_period(user_id) or datetime.now().strftime("%Y-%m")
current_period = self.pricing_service.get_current_billing_period(user_id)
logger.info(f"[Pre-flight Check] 📅 Billing Period: {current_period} (for user {user_id})")

View File

@@ -67,15 +67,56 @@ class PricingService:
self.db.rollback()
return True
def get_current_billing_period(self, user_id: str) -> Optional[str]:
"""Return current billing period key (YYYY-MM) after ensuring subscription is current."""
def get_current_billing_period(self, user_id: str) -> str:
"""Return current billing period key (YYYY-MM) based on subscription, not calendar.
Maintains backward compatibility with existing calendar-month data."""
subscription = self.db.query(UserSubscription).filter(
UserSubscription.user_id == user_id,
UserSubscription.is_active == True
).first()
# Ensure subscription is current (advance if auto_renew)
self._ensure_subscription_current(subscription)
# Continue to use YYYY-MM for summaries
# Use subscription's billing period, NOT calendar month
if subscription and subscription.current_period_start:
sub_period = subscription.current_period_start.strftime("%Y-%m")
# Check if usage data exists for this subscription period
from models.subscription_models import UsageSummary
usage_exists = self.db.query(UsageSummary).filter(
UsageSummary.user_id == user_id,
UsageSummary.billing_period == sub_period
).first()
if usage_exists:
return sub_period
# If no data for subscription period, check for calendar month data
# This handles backward compatibility for existing users
calendar_period = datetime.now().strftime("%Y-%m")
if calendar_period != sub_period:
calendar_usage = self.db.query(UsageSummary).filter(
UsageSummary.user_id == user_id,
UsageSummary.billing_period == calendar_period
).first()
if calendar_usage:
logger.info(f"Using calendar period {calendar_period} for backward compatibility (subscription period {sub_period} has no data)")
return calendar_period
return sub_period
# Fallback: Check if user has any usage summary and use that period
from models.subscription_models import UsageSummary
latest_summary = self.db.query(UsageSummary).filter(
UsageSummary.user_id == user_id
).order_by(UsageSummary.billing_period.desc()).first()
if latest_summary:
logger.info(f"Using latest billing period from UsageSummary: {latest_summary.billing_period}")
return latest_summary.billing_period
# Last fallback to calendar month for free tier / no data
return datetime.now().strftime("%Y-%m")
@classmethod
@@ -830,6 +871,7 @@ class PricingService:
'serper_calls': plan.serper_calls_limit,
'metaphor_calls': plan.metaphor_calls_limit,
'firecrawl_calls': plan.firecrawl_calls_limit,
'exa_calls': getattr(plan, 'exa_calls_limit', 0), # Exa research API
'stability_calls': plan.stability_calls_limit,
'video_calls': getattr(plan, 'video_calls_limit', 0), # Support missing column
'image_edit_calls': getattr(plan, 'image_edit_calls_limit', 0), # Support missing column

View File

@@ -8,7 +8,7 @@ from sqlalchemy.orm import Session
from sqlalchemy.exc import IntegrityError
from models.subscription_models import UserSubscription, SubscriptionPlan, SubscriptionTier, BillingCycle, UsageStatus, FraudWarning, ProcessedStripeEvent
from services.subscription.pricing_service import PricingService
from datetime import datetime
from datetime import datetime, timedelta
REQUIRED_STRIPE_PLAN_KEYS = {
(SubscriptionTier.BASIC.value, BillingCycle.MONTHLY.value),
@@ -421,10 +421,6 @@ class StripeService:
try:
sub = stripe.Subscription.retrieve(subscription_id)
price_id = sub['items']['data'][0]['price']['id']
# Map price_id to internal plan_id
# Note: You need a way to map Stripe Price IDs to your Plan IDs.
# For now, we'll assume the metadata or a lookup.
# Ideally, store price_id in SubscriptionPlan table or config.
# Update DB
self._update_user_subscription(
@@ -434,6 +430,24 @@ class StripeService:
status="active",
price_id=price_id
)
# Clear PricingService cache so next status check returns updated limits
try:
from services.subscription import PricingService
PricingService.clear_user_cache(user_id)
except Exception as cache_err:
logger.warning(f"Failed to clear user cache after checkout for user {user_id}: {cache_err}")
try:
from api.subscription.cache import clear_dashboard_cache
clear_dashboard_cache(user_id)
logger.info(f"Cleared dashboard cache for user {user_id} after checkout")
except Exception as cache_err:
logger.warning(f"Failed to clear cache after checkout for user {user_id}: {cache_err}")
# Expire all SQLAlchemy objects to force fresh reads
self.db.expire_all()
logger.info(f"Expired all SQLAlchemy objects for user {user_id} after checkout")
except Exception as e:
logger.error(f"Error processing checkout subscription: {e}")
@@ -457,11 +471,28 @@ class StripeService:
logger.info(f"Payment succeeded for user {subscription.user_id}")
subscription.status = UsageStatus.ACTIVE
subscription.is_active = True
# Update period end based on invoice lines period
subscription.auto_renew = True
# Update period start/end based on invoice lines period
if invoice.get('lines'):
period_start = invoice['lines']['data'][0]['period']['start']
period_end = invoice['lines']['data'][0]['period']['end']
subscription.current_period_start = datetime.fromtimestamp(period_start)
subscription.current_period_end = datetime.fromtimestamp(period_end)
self.db.commit()
# Clear PricingService cache so next status check returns updated limits
try:
from services.subscription import PricingService
PricingService.clear_user_cache(subscription.user_id)
logger.info(f"Cleared subscription cache for user {subscription.user_id} after payment success")
except Exception as cache_err:
logger.warning(f"Failed to clear user cache after payment success for user {subscription.user_id}: {cache_err}")
try:
from api.subscription.cache import clear_dashboard_cache
clear_dashboard_cache(subscription.user_id)
except Exception as dash_cache_err:
logger.warning(f"Failed to clear dashboard cache after payment success for user {subscription.user_id}: {dash_cache_err}")
self.db.expire_all()
async def _handle_invoice_payment_failed(self, invoice: Dict[str, Any]):
subscription_id = invoice.get("subscription")
@@ -497,6 +528,12 @@ class StripeService:
if status in ["active", "trialing"]:
subscription.status = UsageStatus.ACTIVE
subscription.is_active = True
subscription.auto_renew = True
# Update period boundaries from Stripe event
current_period = subscription_obj.get("current_period", {})
if current_period:
subscription.current_period_start = datetime.fromtimestamp(current_period.get("start", 0))
subscription.current_period_end = datetime.fromtimestamp(current_period.get("end", 0))
elif status in ["past_due", "unpaid", "incomplete", "incomplete_expired"]:
subscription.status = UsageStatus.PAST_DUE
subscription.is_active = False
@@ -506,6 +543,20 @@ class StripeService:
subscription.auto_renew = False
self.db.commit()
# Clear PricingService cache so next status check returns updated limits
try:
from services.subscription import PricingService
PricingService.clear_user_cache(subscription.user_id)
logger.info(f"Cleared subscription cache for user {subscription.user_id} after subscription update")
except Exception as cache_err:
logger.warning(f"Failed to clear user cache after subscription update for user {subscription.user_id}: {cache_err}")
try:
from api.subscription.cache import clear_dashboard_cache
clear_dashboard_cache(subscription.user_id)
except Exception as dash_cache_err:
logger.warning(f"Failed to clear dashboard cache after subscription update for user {subscription.user_id}: {dash_cache_err}")
self.db.expire_all()
async def _handle_subscription_deleted(self, subscription_obj: Dict[str, Any]):
"""
@@ -610,6 +661,11 @@ class StripeService:
)
now = datetime.utcnow()
# Calculate billing period end based on cycle
if billing_cycle == BillingCycle.YEARLY:
period_end = now + timedelta(days=365)
else:
period_end = now + timedelta(days=30)
if not subscription:
subscription = UserSubscription(
@@ -617,7 +673,7 @@ class StripeService:
plan_id=plan.id,
billing_cycle=billing_cycle,
current_period_start=now,
current_period_end=now,
current_period_end=period_end,
status=UsageStatus.ACTIVE if status == "active" else UsageStatus.SUSPENDED,
is_active=status == "active",
auto_renew=True,
@@ -627,6 +683,11 @@ class StripeService:
subscription.plan_id = plan.id
subscription.billing_cycle = billing_cycle
subscription.is_active = status == "active"
subscription.status = UsageStatus.ACTIVE if status == "active" else UsageStatus.SUSPENDED
# Reset billing period on upgrade/plan change
subscription.current_period_start = now
subscription.current_period_end = period_end
subscription.auto_renew = True
subscription.stripe_customer_id = stripe_customer_id
subscription.stripe_subscription_id = stripe_subscription_id

View File

@@ -0,0 +1,21 @@
"""
Usage tracking modules package.
Split from the monolithic usage_tracking_service.py for better maintainability.
"""
from .historical_usage import get_all_historical_usage, get_current_period_usage, get_usage_for_period
from .usage_stats import get_user_usage_stats
from .usage_trends import get_usage_trends
from .limits_enforcement import enforce_usage_limits
from .alerts import check_usage_alerts, create_usage_alert
__all__ = [
'get_all_historical_usage',
'get_current_period_usage',
'get_usage_for_period',
'get_user_usage_stats',
'get_usage_trends',
'enforce_usage_limits',
'check_usage_alerts',
'create_usage_alert',
]

View File

@@ -0,0 +1,101 @@
"""
Usage alert functions.
Extracted from usage_tracking_service.py for better maintainability.
"""
from typing import Dict, Any
from sqlalchemy.orm import Session
from loguru import logger
from models.subscription_models import UsageAlert, UsageSummary, APIProvider, UsageStatus
def check_usage_alerts(user_id: str, provider: APIProvider,
billing_period: str, db: Session, pricing_service):
"""Check if usage alerts should be sent."""
# Get current usage
period_keys = {'billing_period': billing_period, 'lookup_periods': [billing_period]}
summary = db.query(UsageSummary).filter(
UsageSummary.user_id == user_id,
UsageSummary.billing_period.in_(period_keys["lookup_periods"])
).first()
if not summary:
return
# Get user limits
limits = pricing_service.get_user_limits(user_id)
if not limits:
return
# Check for alert thresholds (80%, 90%, 100%)
thresholds = [80, 90, 100]
for threshold in thresholds:
# Check if alert already sent for this threshold
existing_alert = db.query(UsageAlert).filter(
UsageAlert.user_id == user_id,
UsageAlert.billing_period == billing_period,
UsageAlert.threshold_percentage == threshold,
UsageAlert.provider == provider,
UsageAlert.is_sent == True
).first()
if existing_alert:
continue
# Check if threshold is reached
provider_name = provider.value
current_calls = getattr(summary, f"{provider_name}_calls", 0)
call_limit = limits['limits'].get(f"{provider_name}_calls", 0)
if call_limit > 0:
usage_percentage = (current_calls / call_limit) * 100
if usage_percentage >= threshold:
create_usage_alert(
user_id=user_id,
provider=provider,
threshold=threshold,
current_usage=current_calls,
limit=call_limit,
billing_period=billing_period,
db=db
)
def create_usage_alert(user_id: str, provider: APIProvider,
threshold: int, current_usage: int, limit: int,
billing_period: str, db: Session):
"""Create a usage alert."""
# Determine alert type and severity
if threshold >= 100:
alert_type = "limit_reached"
severity = "error"
title = f"API Limit Reached - {provider.value.title()}"
message = f"You have reached your {provider.value} API limit of {limit:,} calls for this billing period."
elif threshold >= 90:
alert_type = "usage_warning"
severity = "warning"
title = f"API Usage Warning - {provider.value.title()}"
message = f"You have used {current_usage:,} of {limit:,} {provider.value} API calls ({threshold}% of your limit)."
else:
alert_type = "usage_warning"
severity = "info"
title = f"API Usage Notice - {provider.value.title()}"
message = f"You have used {current_usage:,} of {limit:,} {provider.value} API calls ({threshold}% of your limit)."
alert = UsageAlert(
user_id=user_id,
alert_type=alert_type,
threshold_percentage=threshold,
provider=provider,
title=title,
message=message,
severity=severity,
billing_period=billing_period
)
db.add(alert)
logger.info(f"Created usage alert for {user_id}: {title}")

View File

@@ -0,0 +1,250 @@
"""
Historical usage aggregation functions.
Extracted from usage_tracking_service.py for better maintainability.
"""
from typing import Dict, Any
from sqlalchemy.orm import Session
from loguru import logger
from datetime import datetime
from models.subscription_models import UsageSummary, UsageStatus
# Shared provider mapping: DB column → frontend key
PROVIDER_MAPPING = {
'gemini_calls': 'gemini',
'openai_calls': 'openai',
'anthropic_calls': 'anthropic',
'mistral_calls': 'huggingface', # HuggingFace stored as mistral
'wavespeed_calls': 'wavespeed',
'exa_calls': 'exa',
'tavily_calls': 'tavily',
'serper_calls': 'serper',
'firecrawl_calls': 'firecrawl',
'metaphor_calls': 'metaphor',
'stability_calls': 'stability',
'video_calls': 'video',
'image_edit_calls': 'image_edit',
'audio_calls': 'audio',
}
def _build_provider_breakdown(summaries: list, mapping: dict) -> dict:
"""Build provider_breakdown dict from a list of UsageSummary records."""
breakdown = {}
for db_col, frontend_key in mapping.items():
total = sum(getattr(s, db_col, 0) or 0 for s in summaries)
breakdown[frontend_key] = {'calls': total, 'cost': 0, 'tokens': 0}
return breakdown
def _build_usage_percentages(provider_breakdown: dict, limits: dict) -> dict:
"""Build usage_percentages dict from provider_breakdown and per-period limits."""
pcts = {}
if not limits or not limits.get('limits'):
return pcts
limit_map = {
'gemini_calls': ('gemini', 'gemini_calls'),
'huggingface_calls': ('huggingface', 'mistral_calls'),
'stability_calls': ('stability', 'stability_calls'),
'video_calls': ('video', 'video_calls'),
'audio_calls': ('audio', 'audio_calls'),
'image_edit_calls': ('image_edit', 'image_edit_calls'),
'wavespeed_calls': ('wavespeed', 'wavespeed_calls'),
'tavily_calls': ('tavily', 'tavily_calls'),
'serper_calls': ('serper', 'serper_calls'),
'firecrawl_calls': ('firecrawl', 'firecrawl_calls'),
'metaphor_calls': ('metaphor', 'metaphor_calls'),
'exa_calls': ('exa', 'exa_calls'),
}
for pct_key, (bk_key, limit_key) in limit_map.items():
used = provider_breakdown.get(bk_key, {}).get('calls', 0)
limit_val = limits.get('limits', {}).get(limit_key, 0) or 0
if limit_val > 0:
pcts[pct_key] = (used / limit_val) * 100
# Cost percentage
total_cost = provider_breakdown.get('total_cost', 0)
cost_limit = limits.get('limits', {}).get('monthly_cost', 0) or 0
if cost_limit > 0:
pcts['cost'] = (total_cost / cost_limit) * 100
return pcts
def _summaries_usage_status(summaries: list) -> str:
"""Derive overall usage_status from a list of summaries."""
status = 'active'
for s in summaries:
try:
st = s.usage_status.value
except Exception:
st = str(s.usage_status)
if st == 'limit_reached':
return 'limit_reached'
if st == 'warning' and status != 'limit_reached':
status = 'warning'
return status
def _empty_usage_response(billing_period: str, limits: dict) -> Dict[str, Any]:
"""Return a zeroed UsageStats-shaped response."""
return {
'billing_period': billing_period,
'usage_status': 'active',
'total_calls': 0,
'total_tokens': 0,
'total_cost': 0.0,
'avg_response_time': 0.0,
'error_rate': 0.0,
'limits': limits,
'provider_breakdown': {},
'usage_percentages': {},
'historical_breakdown': [],
'last_updated': datetime.now().isoformat()
}
def get_all_historical_usage(user_id: str, db: Session, pricing_service) -> Dict[str, Any]:
"""Get ALL historical usage data aggregated across all billing periods."""
all_summaries = db.query(UsageSummary).filter(
UsageSummary.user_id == user_id
).order_by(UsageSummary.billing_period.desc()).all()
limits = pricing_service.get_user_limits(user_id)
if not all_summaries:
return _empty_usage_response('all', limits)
# Aggregate
total_calls = sum(s.total_calls or 0 for s in all_summaries)
total_tokens = sum(s.total_tokens or 0 for s in all_summaries)
total_cost = sum(float(s.total_cost or 0) for s in all_summaries)
total_weighted_time = sum((s.avg_response_time or 0) * (s.total_calls or 0) for s in all_summaries)
avg_response_time = total_weighted_time / total_calls if total_calls > 0 else 0.0
total_errors = sum((s.total_calls or 0) * (s.error_rate or 0) / 100 for s in all_summaries)
error_rate = (total_errors / total_calls * 100) if total_calls > 0 else 0.0
provider_breakdown = _build_provider_breakdown(all_summaries, PROVIDER_MAPPING)
# Historical breakdown per period
historical_breakdown = []
for s in all_summaries:
try:
status_val = s.usage_status.value
except Exception:
status_val = str(s.usage_status)
historical_breakdown.append({
'billing_period': s.billing_period,
'total_calls': s.total_calls or 0,
'total_tokens': s.total_tokens or 0,
'total_cost': float(s.total_cost or 0),
'usage_status': status_val,
'updated_at': s.updated_at.isoformat() if s.updated_at else None
})
return {
'billing_period': 'all',
'usage_status': _summaries_usage_status(all_summaries),
'total_calls': total_calls,
'total_tokens': total_tokens,
'total_cost': round(total_cost, 2),
'avg_response_time': round(avg_response_time, 2),
'error_rate': round(error_rate, 2),
'limits': limits,
'provider_breakdown': provider_breakdown,
'usage_percentages': {}, # misleading for all-time vs per-period limits
'historical_breakdown': historical_breakdown,
'last_updated': datetime.now().isoformat()
}
def get_current_period_usage(user_id: str, db: Session, pricing_service) -> Dict[str, Any]:
"""Get current billing period usage data with correct per-period limit percentages.
Returns a UsageStats-shaped dict with provider_breakdown and usage_percentages
computed against the plan's per-period limits.
"""
current_period = pricing_service.get_current_billing_period(user_id)
limits = pricing_service.get_user_limits(user_id)
summary = db.query(UsageSummary).filter(
UsageSummary.user_id == user_id,
UsageSummary.billing_period == current_period
).first()
if not summary:
result = _empty_usage_response(current_period, limits)
result['usage_percentages'] = _build_usage_percentages({}, limits)
return result
provider_breakdown = _build_provider_breakdown([summary], PROVIDER_MAPPING)
usage_percentages = _build_usage_percentages(provider_breakdown, limits)
try:
status_val = summary.usage_status.value
except Exception:
status_val = str(summary.usage_status)
return {
'billing_period': current_period,
'usage_status': status_val,
'total_calls': summary.total_calls or 0,
'total_tokens': summary.total_tokens or 0,
'total_cost': round(float(summary.total_cost or 0), 2),
'avg_response_time': summary.avg_response_time or 0.0,
'error_rate': summary.error_rate or 0.0,
'limits': limits,
'provider_breakdown': provider_breakdown,
'usage_percentages': usage_percentages,
'historical_breakdown': [],
'last_updated': datetime.now().isoformat()
}
def get_usage_for_period(user_id: str, billing_period: str, db: Session, pricing_service) -> Dict[str, Any]:
"""Get usage data for a specific billing period.
Returns a UsageStats-shaped dict with that period's provider_breakdown
and usage_percentages computed against plan limits.
"""
limits = pricing_service.get_user_limits(user_id)
summary = db.query(UsageSummary).filter(
UsageSummary.user_id == user_id,
UsageSummary.billing_period == billing_period
).first()
if not summary:
result = _empty_usage_response(billing_period, limits)
result['usage_percentages'] = _build_usage_percentages({}, limits)
return result
provider_breakdown = _build_provider_breakdown([summary], PROVIDER_MAPPING)
usage_percentages = _build_usage_percentages(provider_breakdown, limits)
try:
status_val = summary.usage_status.value
except Exception:
status_val = str(summary.usage_status)
return {
'billing_period': billing_period,
'usage_status': status_val,
'total_calls': summary.total_calls or 0,
'total_tokens': summary.total_tokens or 0,
'total_cost': round(float(summary.total_cost or 0), 2),
'avg_response_time': summary.avg_response_time or 0.0,
'error_rate': summary.error_rate or 0.0,
'limits': limits,
'provider_breakdown': provider_breakdown,
'usage_percentages': usage_percentages,
'historical_breakdown': [],
'last_updated': datetime.now().isoformat()
}

View File

@@ -0,0 +1,38 @@
"""
Usage limit enforcement functions.
Extracted from usage_tracking_service.py for better maintainability.
"""
from typing import Tuple, Dict, Any
from datetime import datetime, timedelta
from sqlalchemy.orm import Session
from loguru import logger
from models.subscription_models import APIProvider
from services.subscription.pricing_service import PricingService
def enforce_usage_limits(user_id: str, provider: APIProvider,
tokens_requested: int, db: Session,
pricing_service: PricingService) -> Tuple[bool, str, Dict[str, Any]]:
"""Enforce usage limits before making an API call."""
# Check short-lived cache first (30s)
cache_key = f"{user_id}:{provider.value}"
now = datetime.utcnow()
# This would need access to self._enforce_cache
# For now, keeping the structure
result = pricing_service.check_usage_limits(
user_id=user_id,
provider=provider,
tokens_requested=tokens_requested
)
# Cache the result
# self._enforce_cache[cache_key] = {
# 'result': result,
# 'expires_at': now + timedelta(seconds=30)
# }
return tuple(result)

View File

@@ -0,0 +1,29 @@
"""
Usage statistics functions.
Extracted from usage_tracking_service.py for better maintainability.
"""
from typing import Dict, Any
from sqlalchemy.orm import Session
from loguru import logger
from datetime import datetime
from models.subscription_models import UsageSummary, UsageStatus, APIProvider
from services.subscription.usage_tracking_modules.historical_usage import get_all_historical_usage, get_usage_for_period
def get_user_usage_stats(user_id: str, billing_period: str, db: Session, pricing_service) -> Dict[str, Any]:
"""Get comprehensive usage statistics for a user.
When no billing_period is specified, returns ALL historical usage data.
When a specific period is given, returns only that period's data."""
if not user_id:
logger.error("get_user_usage_stats called without user_id")
raise ValueError("user_id is required")
# If no billing_period requested, return ALL historical data
if not billing_period:
return get_all_historical_usage(user_id, db, pricing_service)
# Return data for the specific billing period
return get_usage_for_period(user_id, billing_period, db, pricing_service)

View File

@@ -0,0 +1,18 @@
"""
Usage trends functions.
Extracted from usage_tracking_service.py for better maintainability.
"""
from typing import Dict, Any
from sqlalchemy.orm import Session
from loguru import logger
def get_usage_trends(user_id: str, months: int, db: Session) -> Dict[str, Any]:
"""Get usage trends over time with self-healing from logs."""
from services.subscription.usage_tracking_helpers import build_billing_periods, query_usage_summaries, self_heal_summaries_from_logs, build_usage_trends_response
periods = build_billing_periods(months)
summary_dict = query_usage_summaries(db, user_id, periods)
self_heal_summaries_from_logs(db, user_id, periods, summary_dict)
return build_usage_trends_response(periods, summary_dict)

View File

@@ -1,41 +1,60 @@
"""
Usage Tracking Service
Comprehensive tracking of API usage, costs, and subscription limits.
Usage Tracking Service - Refactored into modular components.
This file now serves as a facade that delegates to specialized modules
in the usage_tracking_modules package.
Modules:
- historical_usage: Functions for aggregating historical usage data
- usage_stats: Functions for getting user usage statistics
- usage_trends: Functions for usage trend analysis
- limit_enforcement: Functions for enforcing usage limits
- alerts: Functions for usage alerts
"""
# Ensure Optional is available in global scope for dynamic imports
from typing import Optional
import asyncio
from typing import Dict, Any, List, Tuple
from datetime import datetime, timedelta
from typing import Dict, Any, Tuple, Optional
from sqlalchemy.orm import Session
from sqlalchemy import desc
from sqlalchemy import text
from loguru import logger
import json
from api.subscription.cache import clear_dashboard_cache
from datetime import datetime, timedelta
import time
from models.subscription_models import (
APIUsageLog, UsageSummary, APIProvider, UsageAlert,
UserSubscription, UsageStatus
APIProvider, UsageStatus, UserSubscription,
UsageSummary, APIUsageLog, UsageAlert
)
from .pricing_service import PricingService
from .provider_detection import detect_actual_provider
from .usage_tracking_helpers import (
build_billing_periods,
build_default_usage_percentages,
build_empty_usage_response,
from services.subscription.pricing_service import PricingService
from services.subscription.provider_detection import detect_actual_provider
from services.subscription.usage_tracking_helpers import (
build_provider_breakdown,
build_usage_trends_response,
build_default_usage_percentages,
calculate_final_total_cost,
maybe_persist_reconciled_costs,
build_usage_trends_response,
build_billing_periods,
query_usage_summaries,
reset_usage_summary_counters,
self_heal_summaries_from_logs,
reset_usage_summary_counters,
)
# Import clear_dashboard_cache lazily to avoid circular import
def _clear_dashboard_cache_for_user(user_id: str):
from api.subscription.cache import clear_dashboard_cache as _clear
return _clear(user_id)
from .usage_tracking_modules import (
get_all_historical_usage,
get_current_period_usage,
get_usage_for_period,
get_user_usage_stats,
get_usage_trends,
enforce_usage_limits,
check_usage_alerts,
create_usage_alert,
)
class UsageTrackingService:
"""Service for tracking API usage and managing subscription limits."""
"""Service for tracking API usage and managing billing information."""
def __init__(self, db: Session):
self.db = db
@@ -43,13 +62,14 @@ class UsageTrackingService:
# TTL cache (30s) for enforcement results to cut DB chatter
# key: f"{user_id}:{provider}", value: { 'result': (bool,str,dict), 'expires_at': datetime }
self._enforce_cache: Dict[str, Dict[str, Any]] = {}
def _get_authoritative_billing_period_keys(self, user_id: str, billing_period: Optional[str] = None) -> Dict[str, Any]:
"""Return authoritative billing period lookup keys. Always uses calendar month for consistency."""
"""Return authoritative billing period lookup keys. Always uses subscription period for consistency.
Maintains backward compatibility with existing calendar-month data."""
subscription = self.db.query(UserSubscription).filter(
UserSubscription.user_id == user_id
).first()
# If caller explicitly requested a billing period, use it
if billing_period:
return {
@@ -58,26 +78,125 @@ class UsageTrackingService:
"period_start": subscription.current_period_start if subscription else None,
"period_end": subscription.current_period_end if subscription else None,
}
# ALWAYS use current calendar month for billing period to ensure consistency
# This prevents data loss when subscription spans month boundaries
current_period = datetime.now().strftime("%Y-%m")
# Get subscription period if available
subscription_period = None
if subscription and subscription.current_period_start:
subscription_period = subscription.current_period_start.strftime("%Y-%m")
# Get calendar period
calendar_period = datetime.now().strftime("%Y-%m")
# Check which period has usage data
from models.subscription_models import UsageSummary
if subscription_period:
# Check if data exists for subscription period
sub_data = self.db.query(UsageSummary).filter(
UsageSummary.user_id == user_id,
UsageSummary.billing_period == subscription_period
).first()
if sub_data:
# Use subscription period (has data)
return {
"billing_period": subscription_period,
"lookup_periods": [subscription_period],
"period_start": subscription.current_period_start,
"period_end": subscription.current_period_end,
}
# No data for subscription period, check calendar period (backward compatibility)
if calendar_period != subscription_period:
cal_data = self.db.query(UsageSummary).filter(
UsageSummary.user_id == user_id,
UsageSummary.billing_period == calendar_period
).first()
if cal_data:
logger.info(f"Using calendar period {calendar_period} for backward compatibility (subscription period {subscription_period} has no data)")
return {
"billing_period": calendar_period,
"lookup_periods": [calendar_period],
"period_start": None,
"period_end": None,
}
# No data in either period, use subscription period
return {
"billing_period": subscription_period,
"lookup_periods": [subscription_period],
"period_start": subscription.current_period_start,
"period_end": subscription.current_period_end,
}
# No subscription, check for any existing data
latest_summary = self.db.query(UsageSummary).filter(
UsageSummary.user_id == user_id
).order_by(UsageSummary.billing_period.desc()).first()
if latest_summary:
logger.info(f"Using latest billing period from UsageSummary: {latest_summary.billing_period} for user {user_id}")
return {
"billing_period": latest_summary.billing_period,
"lookup_periods": [latest_summary.billing_period],
"period_start": None,
"period_end": None,
}
# Last fallback to calendar month for free tier / no subscription
return {
"billing_period": current_period,
"lookup_periods": [current_period],
"period_start": subscription.current_period_start if subscription else None,
"period_end": subscription.current_period_end if subscription else None,
"billing_period": calendar_period,
"lookup_periods": [calendar_period],
"period_start": None,
"period_end": None,
}
# Delegate to modular functions
def get_user_usage_stats(self, user_id: str, billing_period: str = None) -> Dict[str, Any]:
"""Get comprehensive usage statistics for a user."""
return get_user_usage_stats(user_id, billing_period, self.db, self.pricing_service)
def _get_all_historical_usage(self, user_id: str) -> Dict[str, Any]:
"""Get ALL historical usage data aggregated across all billing periods."""
return get_all_historical_usage(user_id, self.db, self.pricing_service)
def get_current_period_usage(self, user_id: str) -> Dict[str, Any]:
"""Get current billing period usage with correct per-period limit percentages."""
return get_current_period_usage(user_id, self.db, self.pricing_service)
def get_usage_for_period(self, user_id: str, billing_period: str) -> Dict[str, Any]:
"""Get usage for a specific billing period."""
return get_usage_for_period(user_id, billing_period, self.db, self.pricing_service)
def get_usage_trends(self, user_id: str, months: int = 6) -> Dict[str, Any]:
"""Get usage trends over time with self-healing from logs."""
return get_usage_trends(user_id, months, self.db)
async def enforce_usage_limits(self, user_id: str, provider: APIProvider,
tokens_requested: int = 0) -> Tuple[bool, str, Dict[str, Any]]:
"""Enforce usage limits before making an API call."""
return enforce_usage_limits(user_id, provider, tokens_requested, self.db, self.pricing_service)
async def _check_usage_alerts(self, user_id: str, provider: APIProvider, billing_period: str):
"""Check if usage alerts should be sent."""
check_usage_alerts(user_id, provider, billing_period, self.db, self.pricing_service)
async def _create_usage_alert(self, user_id: str, provider: APIProvider,
threshold: int, current_usage: int, limit: int,
billing_period: str):
"""Create a usage alert."""
create_usage_alert(user_id, provider, threshold, current_usage, limit, billing_period, self.db)
# Keep the track_api_usage method here as it's the core functionality
async def track_api_usage(self, user_id: str, provider: APIProvider,
endpoint: str, method: str, model_used: str = None,
tokens_input: int = 0, tokens_output: int = 0,
response_time: float = 0.0, status_code: int = 200,
request_size: int = None, response_size: int = None,
user_agent: str = None, ip_address: str = None,
error_message: str = None, retry_count: int = 0,
**kwargs) -> Dict[str, Any]:
endpoint: str, method: str, model_used: str = None,
tokens_input: int = 0, tokens_output: int = 0,
response_time: float = 0.0, status_code: int = 200,
request_size: int = None, response_size: int = None,
user_agent: str = None, ip_address: str = None,
error_message: str = None, retry_count: int = 0,
**kwargs) -> Dict[str, Any]:
"""Track an API usage event and update billing information."""
try:
@@ -165,394 +284,81 @@ class UsageTrackingService:
# Invalidate dashboard cache so header stats update immediately
try:
clear_dashboard_cache(user_id)
_clear_dashboard_cache_for_user(user_id)
except Exception as cache_err:
logger.debug(f"Could not clear dashboard cache: {cache_err}")
logger.info(f"Tracked API usage: {user_id} -> {provider.value} -> ${cost_data['cost_total']:.6f}")
logger.warning(f"Failed to clear dashboard cache: {cache_err}")
return {
'usage_logged': True,
'cost': cost_data['cost_total'],
'tokens_used': (tokens_input or 0) + (tokens_output or 0),
'billing_period': billing_period
"success": True,
"cost": cost_data['cost_total'],
"tokens": (tokens_input or 0) + (tokens_output or 0),
"billing_period": billing_period
}
except Exception as e:
logger.error(f"Error tracking API usage: {str(e)}")
logger.error(f"Failed to track API usage: {e}")
self.db.rollback()
return {
'usage_logged': False,
'error': str(e)
"success": False,
"error": str(e)
}
async def _update_usage_summary(self, user_id: str, provider: APIProvider,
tokens_used: int, cost: float, billing_period: str,
response_time: float, is_error: bool):
"""Update the usage summary for a user."""
tokens_used: int, cost: float,
billing_period: str,
response_time: float = 0.0,
is_error: bool = False):
"""Update or create usage summary for the billing period."""
# Get or create usage summary
period_keys = self._get_authoritative_billing_period_keys(user_id, billing_period)
# Get or create summary
summary = self.db.query(UsageSummary).filter(
UsageSummary.user_id == user_id,
UsageSummary.billing_period.in_(period_keys["lookup_periods"])
UsageSummary.billing_period == billing_period
).first()
if not summary:
logger.info(f"[UsageTracking] Creating new UsageSummary for user={user_id}, period={period_keys['billing_period']}")
summary = UsageSummary(
user_id=user_id,
billing_period=period_keys["billing_period"]
billing_period=billing_period,
usage_status=UsageStatus.ACTIVE,
total_calls=0,
total_tokens=0,
total_cost=0.0
)
self.db.add(summary)
else:
logger.debug(f"[UsageTracking] Found existing UsageSummary for user={user_id}, period={summary.billing_period}, calls={summary.total_calls}")
# Update provider-specific counters
# Update counts
summary.total_calls = (summary.total_calls or 0) + 1
summary.total_tokens = (summary.total_tokens or 0) + tokens_used
summary.total_cost = (summary.total_cost or 0.0) + cost
# Update provider-specific counts
provider_name = provider.value
current_calls = getattr(summary, f"{provider_name}_calls", 0)
current_calls = getattr(summary, f"{provider_name}_calls", 0) or 0
setattr(summary, f"{provider_name}_calls", current_calls + 1)
# Update token usage for LLM providers
if provider in [APIProvider.GEMINI, APIProvider.OPENAI, APIProvider.ANTHROPIC, APIProvider.MISTRAL, APIProvider.WAVESPEED]:
current_tokens = getattr(summary, f"{provider_name}_tokens", 0)
setattr(summary, f"{provider_name}_tokens", current_tokens + tokens_used)
# Update provider-specific tokens
tokens_attr = f"{provider_name}_tokens"
if hasattr(summary, tokens_attr):
current_tokens = getattr(summary, tokens_attr, 0) or 0
setattr(summary, tokens_attr, current_tokens + tokens_used)
# Update cost
current_cost = getattr(summary, f"{provider_name}_cost", 0.0)
setattr(summary, f"{provider_name}_cost", current_cost + cost)
# Update provider-specific cost
cost_attr = f"{provider_name}_cost"
if hasattr(summary, cost_attr):
current_cost = getattr(summary, cost_attr, 0.0) or 0.0
setattr(summary, cost_attr, current_cost + cost)
# Update totals
summary.total_calls += 1
summary.total_tokens += tokens_used
summary.total_cost += cost
# Update response time (rolling average)
if response_time > 0:
current_avg = summary.avg_response_time or 0.0
current_calls = summary.total_calls or 1
summary.avg_response_time = ((current_avg * (current_calls - 1)) + response_time) / current_calls
# Update performance metrics
if summary.total_calls > 0:
# Update average response time
total_response_time = summary.avg_response_time * (summary.total_calls - 1) + response_time
summary.avg_response_time = total_response_time / summary.total_calls
# Update error rate
if is_error:
error_count = int(summary.error_rate * (summary.total_calls - 1) / 100) + 1
summary.error_rate = (error_count / summary.total_calls) * 100
else:
error_count = int(summary.error_rate * (summary.total_calls - 1) / 100)
summary.error_rate = (error_count / summary.total_calls) * 100
# Update usage status based on limits
await self._update_usage_status(summary)
# Update error rate
if is_error:
summary.error_count = (summary.error_count or 0) + 1
total_calls = summary.total_calls or 1
summary.error_rate = (summary.error_count / total_calls) * 100
summary.updated_at = datetime.utcnow()
async def _update_usage_status(self, summary: UsageSummary):
"""Update usage status based on subscription limits."""
limits = self.pricing_service.get_user_limits(summary.user_id)
if not limits:
return
# Check various limits and determine status
max_usage_percentage = 0.0
# Check cost limit
cost_limit = limits['limits'].get('monthly_cost', 0)
if cost_limit > 0:
cost_usage_pct = (summary.total_cost / cost_limit) * 100
max_usage_percentage = max(max_usage_percentage, cost_usage_pct)
# Check call limits for each provider
for provider in APIProvider:
provider_name = provider.value
current_calls = getattr(summary, f"{provider_name}_calls", 0)
call_limit = limits['limits'].get(f"{provider_name}_calls", 0)
if call_limit > 0:
call_usage_pct = (current_calls / call_limit) * 100
max_usage_percentage = max(max_usage_percentage, call_usage_pct)
# Update status based on highest usage percentage
if max_usage_percentage >= 100:
summary.usage_status = UsageStatus.LIMIT_REACHED
elif max_usage_percentage >= 80:
summary.usage_status = UsageStatus.WARNING
else:
summary.usage_status = UsageStatus.ACTIVE
async def _check_usage_alerts(self, user_id: str, provider: APIProvider, billing_period: str):
"""Check if usage alerts should be sent."""
# Get current usage
period_keys = self._get_authoritative_billing_period_keys(user_id, billing_period)
summary = self.db.query(UsageSummary).filter(
UsageSummary.user_id == user_id,
UsageSummary.billing_period.in_(period_keys["lookup_periods"])
).first()
if not summary:
return
# Get user limits
limits = self.pricing_service.get_user_limits(user_id)
if not limits:
return
# Check for alert thresholds (80%, 90%, 100%)
thresholds = [80, 90, 100]
for threshold in thresholds:
# Check if alert already sent for this threshold
existing_alert = self.db.query(UsageAlert).filter(
UsageAlert.user_id == user_id,
UsageAlert.billing_period == billing_period,
UsageAlert.threshold_percentage == threshold,
UsageAlert.provider == provider,
UsageAlert.is_sent == True
).first()
if existing_alert:
continue
# Check if threshold is reached
provider_name = provider.value
current_calls = getattr(summary, f"{provider_name}_calls", 0)
call_limit = limits['limits'].get(f"{provider_name}_calls", 0)
if call_limit > 0:
usage_percentage = (current_calls / call_limit) * 100
if usage_percentage >= threshold:
await self._create_usage_alert(
user_id=user_id,
provider=provider,
threshold=threshold,
current_usage=current_calls,
limit=call_limit,
billing_period=billing_period
)
async def _create_usage_alert(self, user_id: str, provider: APIProvider,
threshold: int, current_usage: int, limit: int,
billing_period: str):
"""Create a usage alert."""
# Determine alert type and severity
if threshold >= 100:
alert_type = "limit_reached"
severity = "error"
title = f"API Limit Reached - {provider.value.title()}"
message = f"You have reached your {provider.value} API limit of {limit:,} calls for this billing period."
elif threshold >= 90:
alert_type = "usage_warning"
severity = "warning"
title = f"API Usage Warning - {provider.value.title()}"
message = f"You have used {current_usage:,} of {limit:,} {provider.value} API calls ({threshold}% of your limit)."
else:
alert_type = "usage_warning"
severity = "info"
title = f"API Usage Notice - {provider.value.title()}"
message = f"You have used {current_usage:,} of {limit:,} {provider.value} API calls ({threshold}% of your limit)."
alert = UsageAlert(
user_id=user_id,
alert_type=alert_type,
threshold_percentage=threshold,
provider=provider,
title=title,
message=message,
severity=severity,
billing_period=billing_period
)
self.db.add(alert)
logger.info(f"Created usage alert for {user_id}: {title}")
def get_user_usage_stats(self, user_id: str, billing_period: str = None) -> Dict[str, Any]:
"""Get comprehensive usage statistics for a user."""
if not user_id:
logger.error("get_user_usage_stats called without user_id")
raise ValueError("user_id is required")
requested_billing_period = billing_period
period_keys = self._get_authoritative_billing_period_keys(user_id, requested_billing_period)
billing_period = period_keys["billing_period"]
logger.debug(f"[get_user_usage_stats] user={user_id}, billing_period={billing_period}, lookup_periods={period_keys['lookup_periods']}")
# Get usage summary
summary = self.db.query(UsageSummary).filter(
UsageSummary.user_id == user_id,
UsageSummary.billing_period.in_(period_keys["lookup_periods"])
).first()
if summary:
logger.debug(f"[get_user_usage_stats] Found summary: period={summary.billing_period}, calls={summary.total_calls}, cost={summary.total_cost}")
else:
logger.debug(f"[get_user_usage_stats] No summary found for user={user_id}, period={billing_period}")
# Get user limits
limits = self.pricing_service.get_user_limits(user_id)
# Get recent alerts
alerts = self.db.query(UsageAlert).filter(
UsageAlert.user_id == user_id,
UsageAlert.billing_period == billing_period,
UsageAlert.is_read == False
).order_by(UsageAlert.created_at.desc()).limit(10).all()
if not summary:
# If no summary exists for current period, we should initialize it
# This handles the "start of month" case where a user logs in but hasn't made calls yet
if not requested_billing_period:
logger.info(f"Initializing empty UsageSummary for user {user_id} in period {billing_period}")
summary = UsageSummary(
user_id=user_id,
billing_period=billing_period,
usage_status=UsageStatus.ACTIVE,
total_calls=0,
total_tokens=0,
total_cost=0.0
)
try:
self.db.add(summary)
self.db.commit()
self.db.refresh(summary)
except Exception as e:
logger.error(f"Failed to initialize summary: {e}")
self.db.rollback()
# Fallback to zero-struct return if DB write fails
pass
if not summary: # Still no summary after attempt
return build_empty_usage_response(
billing_period=billing_period,
limits=limits,
providers=APIProvider,
)
# Provider breakdown - calculate costs first, then use for percentages
# Only include Gemini and HuggingFace (HuggingFace is stored under MISTRAL enum)
provider_breakdown, resolved_costs, core_counts = build_provider_breakdown(
db=self.db,
user_id=user_id,
billing_period=billing_period,
summary=summary,
)
summary_total_cost = summary.total_cost or 0.0
calculated_total_cost, final_total_cost = calculate_final_total_cost(
summary_total_cost=summary_total_cost,
resolved_costs=resolved_costs,
)
maybe_persist_reconciled_costs(
db=self.db,
summary=summary,
summary_total_cost=summary_total_cost,
calculated_total_cost=calculated_total_cost,
final_total_cost=final_total_cost,
resolved_costs=resolved_costs,
)
# Calculate usage percentages - only for Gemini and HuggingFace
# Use the calculated costs for accurate percentages
usage_percentages = build_default_usage_percentages(APIProvider)
if limits:
# Gemini
gemini_call_limit = limits['limits'].get("gemini_calls", 0) or 0
if gemini_call_limit > 0:
usage_percentages['gemini_calls'] = (core_counts['gemini_calls'] / gemini_call_limit) * 100
# HuggingFace (stored as mistral in database)
mistral_call_limit = limits['limits'].get("mistral_calls", 0) or 0
if mistral_call_limit > 0:
usage_percentages['mistral_calls'] = (core_counts['mistral_calls'] / mistral_call_limit) * 100
# Cost usage percentage - use final_total_cost (calculated from logs if needed)
cost_limit = limits['limits'].get('monthly_cost', 0) or 0
if cost_limit > 0:
usage_percentages['cost'] = (final_total_cost / cost_limit) * 100
return {
'billing_period': billing_period,
'usage_status': summary.usage_status.value if hasattr(summary.usage_status, 'value') else str(summary.usage_status),
'total_calls': summary.total_calls or 0,
'total_tokens': summary.total_tokens or 0,
'total_cost': final_total_cost,
'avg_response_time': summary.avg_response_time or 0.0,
'error_rate': summary.error_rate or 0.0,
'limits': limits,
'provider_breakdown': provider_breakdown,
'alerts': [
{
'id': alert.id,
'type': alert.alert_type,
'title': alert.title,
'message': alert.message,
'severity': alert.severity,
'created_at': alert.created_at.isoformat()
}
for alert in alerts
],
'usage_percentages': usage_percentages,
'last_updated': summary.updated_at.isoformat()
}
def get_usage_trends(self, user_id: str, months: int = 6) -> Dict[str, Any]:
"""Get usage trends over time with self-healing from logs."""
periods = build_billing_periods(months)
summary_dict = query_usage_summaries(self.db, user_id, periods)
self_heal_summaries_from_logs(self.db, user_id, periods, summary_dict)
return build_usage_trends_response(periods, summary_dict)
async def enforce_usage_limits(self, user_id: str, provider: APIProvider,
tokens_requested: int = 0) -> Tuple[bool, str, Dict[str, Any]]:
"""Enforce usage limits before making an API call."""
# Check short-lived cache first (30s)
cache_key = f"{user_id}:{provider.value}"
now = datetime.utcnow()
cached = self._enforce_cache.get(cache_key)
if cached and cached.get('expires_at') and cached['expires_at'] > now:
return tuple(cached['result']) # type: ignore
result = self.pricing_service.check_usage_limits(
user_id=user_id,
provider=provider,
tokens_requested=tokens_requested
)
self._enforce_cache[cache_key] = {
'result': result,
'expires_at': now + timedelta(seconds=30)
}
return result
async def reset_current_billing_period(self, user_id: str) -> Dict[str, Any]:
"""Reset usage status and counters for the current billing period (after plan renewal/change)."""
period_keys = self._get_authoritative_billing_period_keys(user_id)
billing_period = period_keys["billing_period"]
summary = self.db.query(UsageSummary).filter(
UsageSummary.user_id == user_id,
UsageSummary.billing_period.in_(period_keys["lookup_periods"])
).first()
if not summary:
return {"reset": False, "reason": "no_summary"}
try:
reset_usage_summary_counters(summary)
self.db.commit()
# Invalidate dashboard cache so header stats update after reset
try:
clear_dashboard_cache(user_id)
except Exception as cache_err:
logger.debug(f"Could not clear dashboard cache: {cache_err}")
logger.info(f"Reset usage counters for user {user_id} in billing period {billing_period} after renewal")
return {"reset": True, "counters_reset": True}
except Exception as e:
self.db.rollback()
logger.error(f"Error resetting usage status: {e}")
return {"reset": False, "error": str(e)}

View File

@@ -2,9 +2,8 @@ import os
import asyncio
from typing import Any, Dict, List
from dataclasses import dataclass
import requests
import httpx
from loguru import logger
import time
import random
from services.llm_providers.main_text_generation import llm_text_gen
@@ -61,30 +60,26 @@ class WritingAssistantService:
logger.info(f"Writing assistant API call #{self.daily_api_calls}/{self.daily_limit} today")
return True
async def suggest(self, text: str, max_results: int = 1) -> List[WritingSuggestion]:
async def suggest(self, text: str, user_id: str | None = None) -> List[WritingSuggestion]:
if not text or len(text.strip()) < 6:
return []
# COST OPTIMIZATION: Use cached/static suggestions for common patterns
# This reduces API calls by 90%+ while maintaining usefulness
cached_suggestion = self._get_cached_suggestion(text)
if cached_suggestion:
return [cached_suggestion]
# COST CONTROL: Check daily usage limits
if not self._check_daily_limit():
logger.warning("Daily API limit reached for writing assistant")
return []
# Only make expensive API calls for unique, substantial content
if len(text.strip()) < 50: # Skip API calls for very short text
if len(text.strip()) < 50:
return []
# 1) Find relevant sources via Exa (reduced results for cost)
# 1) Find relevant sources via Exa
sources = await self._search_sources(text)
# 2) Generate continuation suggestion via Gemini
suggestion_text, confidence = await self._generate_continuation(text, sources)
# 2) Generate continuation suggestion via LLM grounded in sources
suggestion_text, confidence = await self._generate_continuation(text, sources, user_id=user_id)
if not suggestion_text:
return []
@@ -110,12 +105,12 @@ class WritingAssistantService:
}
try:
resp = requests.post(
"https://api.exa.ai/search",
headers={"x-api-key": self.exa_api_key, "Content-Type": "application/json"},
json=payload,
timeout=self.http_timeout_seconds,
)
async with httpx.AsyncClient(timeout=self.http_timeout_seconds) as client:
resp = await client.post(
"https://api.exa.ai/search",
headers={"x-api-key": self.exa_api_key, "Content-Type": "application/json"},
json=payload,
)
if resp.status_code != 200:
raise Exception(f"Exa error {resp.status_code}: {resp.text}")
data = resp.json()
@@ -140,8 +135,7 @@ class WritingAssistantService:
logger.error(f"WritingAssistant _search_sources error: {e}")
raise
async def _generate_continuation(self, text: str, sources: List[Dict[str, Any]]) -> tuple[str, float]:
# Build compact sources context block
async def _generate_continuation(self, text: str, sources: List[Dict[str, Any]], user_id: str | None = None) -> tuple[str, float]:
source_blocks: List[str] = []
for i, s in enumerate(sources[:5]):
excerpt = (s.get("text", "") or "")
@@ -149,16 +143,14 @@ class WritingAssistantService:
source_blocks.append(
f"Source {i+1}: {s.get('title','') or 'Source'}\nURL: {s.get('url','')}\nExcerpt: {excerpt}"
)
sources_text = "\n\n".join(source_blocks) if source_blocks else "(No sources)"
sources_text = "\n\n".join(source_blocks)
# Provider-agnostic behavior: short continuation with one inline citation hint
system_prompt = (
"You are an assistive writing continuation bot. "
"Only produce 1-2 SHORT sentences. Do not repeat or paraphrase the user's stub. "
"Match tone and topic. Prefer concrete, current facts from the provided sources. "
"Include exactly one brief citation hint in parentheses with an author (or 'Source') and URL in square brackets, e.g., ((Doe, 2021)[https://example.com])."
)
user_prompt = (
f"User text to continue (do not repeat):\n{text}\n\n"
f"Relevant sources to inform your continuation:\n{sources_text}\n\n"
@@ -166,13 +158,13 @@ class WritingAssistantService:
)
try:
# Inter-call jitter to reduce burst rate limits
time.sleep(random.uniform(0.05, 0.15))
await asyncio.sleep(random.uniform(0.05, 0.15))
ai_resp = llm_text_gen(
prompt=user_prompt,
json_struct=None,
system_prompt=system_prompt,
user_id=user_id,
)
if isinstance(ai_resp, dict) and ai_resp.get("text"):
suggestion = (ai_resp.get("text", "") or "").strip()
@@ -180,12 +172,10 @@ class WritingAssistantService:
suggestion = (str(ai_resp or "")).strip()
if not suggestion:
raise Exception("Assistive writer returned empty suggestion")
# naive confidence from number of sources present
confidence = 0.7 if sources else 0.5
confidence = 0.7
return suggestion, confidence
except Exception as e:
logger.error(f"WritingAssistant _generate_continuation error: {e}")
# Propagate to ensure frontend does not show stale/generic content
raise

View File

@@ -70,7 +70,7 @@ def should_bootstrap_linguistic_models() -> bool:
}
# Check if any linguistic-required feature is enabled
linguistic_features = {"content_planning", "facebook", "linkedin", "blog-writer", "persona"}
linguistic_features = {"content_planning", "facebook", "linkedin", "blog_writer", "persona"}
return bool(enabled_features & linguistic_features)
@@ -287,12 +287,16 @@ from alwrity_utils import (
def start_backend(enable_reload=False, production_mode=False):
"""Start the backend server."""
print("==> Starting ALwrity Backend...")
podcast_only_demo_mode = os.getenv("ALWRITY_PODCAST_ONLY_DEMO_MODE", os.getenv("PODCAST_ONLY_DEMO_MODE", "false")).lower() in {"1", "true", "yes", "on"}
# Check for legacy podcast-only demo mode env vars (backward compat)
is_legacy_podcast_mode = os.getenv("ALWRITY_PODCAST_ONLY_DEMO_MODE", os.getenv("PODCAST_ONLY_DEMO_MODE", "false")).lower() in {"1", "true", "yes", "on"}
enabled = get_enabled_features()
is_feature_limited = "all" not in enabled
if podcast_only_demo_mode:
print("\n" + "=" * 60)
print("==> PODCAST-ONLY DEMO MODE ACTIVE")
print(" Non-podcast router groups are intentionally skipped.")
if is_legacy_podcast_mode or is_feature_limited:
mode_label = "legacy podcast-only" if is_legacy_podcast_mode else f"feature-limited ({', '.join(sorted(enabled))})"
print(f"\n{'=' * 60}")
print(f"==> {mode_label.upper()} MODE ACTIVE")
print(" Non-matching router groups are intentionally skipped.")
print("=" * 60)
# Set host based on environment and mode
@@ -385,12 +389,12 @@ def start_backend(enable_reload=False, production_mode=False):
print(f"[DEBUG] Starting uvicorn with host={host} port={port}", flush=True)
print("[DEBUG] >>> ABOUT TO CALL UVICORN.RUN() <<<", flush=True)
# Skip video preflight in podcast-only mode to save memory/time
is_podcast = os.getenv("ALWRITY_ENABLED_FEATURES", "").strip().lower() == "podcast"
print(f"[DEBUG] Podcast mode check: {is_podcast}", flush=True)
# Skip video preflight in feature-limited mode to save memory/time
is_feature_limited = os.getenv("ALWRITY_ENABLED_FEATURES", "").strip().lower() not in ("", "all")
print(f"[DEBUG] Feature-limited mode check: {is_feature_limited}", flush=True)
if is_podcast:
print("[DEBUG] Podcast mode - skipping video preflight", flush=True)
if is_feature_limited:
print("[DEBUG] Feature-limited mode - skipping video preflight", flush=True)
else:
# Log diagnostics and assert versions (fail fast if misconfigured)
try:

83
backend/temp_method.py Normal file
View File

@@ -0,0 +1,83 @@
def _get_all_historical_usage(self, user_id: str) -> Dict[str, Any]:
\ \\Get ALL historical usage data aggregated across all billing periods.\\\
# Get all usage summaries for the user
all_summaries = self.db.query(UsageSummary).filter(
UsageSummary.user_id == user_id
).order_by(UsageSummary.billing_period.desc()).all()
if not all_summaries:
return {
\billing_period\: \all\,
\usage_status\: \active\,
\total_calls\: 0,
\total_tokens\: 0,
\total_cost\: 0.0,
\avg_response_time\: 0.0,
\error_rate\: 0.0,
\limits\: self.pricing_service.get_user_limits(user_id),
\provider_breakdown\: {},
\usage_percentages\: {},
\historical_breakdown\: [],
\last_updated\: datetime.now().isoformat()
}
# Aggregate all data
total_calls = sum(s.total_calls or 0 for s in all_summaries)
total_tokens = sum(s.total_tokens or 0 for s in all_summaries)
total_cost = sum(float(s.total_cost or 0) for s in all_summaries)
# Calculate weighted average response time
total_weighted_time = sum((s.avg_response_time or 0) * (s.total_calls or 0) for s in all_summaries)
avg_response_time = total_weighted_time / total_calls if total_calls > 0 else 0.0
# Calculate overall error rate
total_errors = sum((s.total_calls or 0) * (s.error_rate or 0) / 100 for s in all_summaries)
error_rate = (total_errors / total_calls * 100) if total_calls > 0 else 0.0
# Get user limits
limits = self.pricing_service.get_user_limits(user_id)
# Build historical breakdown
historical_breakdown = []
for s in all_summaries:
try:
status_val = s.usage_status.value
except:
status_val = str(s.usage_status)
historical_breakdown.append({
\billing_period\: s.billing_period,
\total_calls\: s.total_calls or 0,
\total_tokens\: s.total_tokens or 0,
\total_cost\: float(s.total_cost or 0),
\usage_status\: status_val,
\updated_at\: s.updated_at.isoformat() if s.updated_at else None
})
# Determine overall status
usage_status = \active\
for s in all_summaries:
try:
status = s.usage_status.value
except:
status = str(s.usage_status)
if status == \limit_reached\:
usage_status = \limit_reached\
break
elif status == \warning\ and usage_status != \limit_reached\:
usage_status = \warning\
return {
\billing_period\: \all\,
\usage_status\: usage_status,
\total_calls\: total_calls,
\total_tokens\: total_tokens,
\total_cost\: round(total_cost, 2),
\avg_response_time\: round(avg_response_time, 2),
\error_rate\: round(error_rate, 2),
\limits\: limits,
\provider_breakdown\: {},
\usage_percentages\: {},
\historical_breakdown\: historical_breakdown,
\last_updated\: datetime.now().isoformat()
}

View File

@@ -5,7 +5,7 @@
import { ResearchMode, ResearchProvider } from '../services/blogWriterApi';
import { apiClient } from './client';
import { isPodcastOnlyDemoMode } from '../utils/demoMode';
import { isFeatureOnlyMode } from '../utils/demoMode';
export interface ProviderAvailability {
google_available: boolean;
@@ -130,9 +130,9 @@ let pendingConfigRequest: Promise<ResearchConfigResponse> | null = null;
* and research persona from the unified /api/research/config endpoint.
*/
export const getResearchConfig = async (): Promise<ResearchConfigResponse> => {
// Skip in podcast-only mode — backend always provides AI-generated research_queries
if (isPodcastOnlyDemoMode()) {
throw new Error('Research config not available in podcast-only mode');
// Skip in feature-limited mode — backend always provides AI-generated research_queries
if (isFeatureOnlyMode()) {
throw new Error('Research config not available in feature-limited mode');
}
// If a request is already in flight, return the same promise

View File

@@ -5,7 +5,6 @@ import { CopilotKit } from "@copilotkit/react-core";
import { CopilotKitHealthProvider } from '../../contexts/CopilotKitHealthContext';
import CopilotKitDegradedBanner from '../shared/CopilotKitDegradedBanner';
import ErrorBoundary from '../shared/ErrorBoundary';
import { isPodcastOnlyDemoMode } from '../../utils/demoMode';
interface ConditionalCopilotKitProps {
children: React.ReactNode;
@@ -24,10 +23,12 @@ export const AuthenticatedCopilotWrapper: React.FC<AuthenticatedCopilotWrapperPr
const { isSignedIn } = useAuth();
const location = useLocation();
const isPodcastOnly = isPodcastOnlyDemoMode();
const shouldExcludeCopilot = !isSignedIn || location.pathname.startsWith('/onboarding') || isPodcastOnly;
// Only fully exclude CopilotKit when user is not signed in or on onboarding
// Feature-limited mode (blog_writer, etc.) still needs CopilotKit providers
// because BlogWriter uses useCopilotAction and useCopilotKitHealth hooks
const shouldExcludeCopilotKit = !isSignedIn || location.pathname.startsWith('/onboarding');
if (shouldExcludeCopilot) {
if (shouldExcludeCopilotKit) {
return <>{children}</>;
}

View File

@@ -1,14 +1,17 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { Box, CircularProgress, Typography } from '@mui/material';
import { useOnboarding } from '../../contexts/OnboardingContext';
import { useSubscription } from '../../contexts/SubscriptionContext';
import { useOAuthTokenAlerts } from '../../hooks/useOAuthTokenAlerts';
import { shouldSkipOnboarding } from '../../utils/demoMode';
import { shouldSkipOnboarding, getDefaultLandingRoute, isFeatureOnlyMode, getSingleFeature } from '../../utils/demoMode';
import { restoreNavigationState } from '../../utils/navigationState';
import ConnectionErrorPage from '../shared/ConnectionErrorPage';
const CHECKOUT_POLL_INTERVAL_MS = 2000;
const CHECKOUT_POLL_MAX_ATTEMPTS = 10;
const InitialRouteHandler: React.FC = () => {
// Helper to log and navigate in a single place
const navigateAndLog = (to: string) => {
console.log(`InitialRouteHandler: Redirecting to ${to}`);
return <Navigate to={to} replace />;
@@ -23,12 +26,24 @@ const InitialRouteHandler: React.FC = () => {
hasError: false,
error: null,
});
// Post-checkout polling state
const [checkoutPolling, setCheckoutPolling] = useState(false);
const checkoutPollAttempts = useRef(0);
// Track whether the initial subscription check has completed
// Prevents premature routing decisions before we know the user's plan
const [initialCheckDone, setInitialCheckDone] = useState(false);
const urlParams = new URLSearchParams(location.search);
const isCheckoutSuccess = urlParams.get('subscription') === 'success';
const returnTo = urlParams.get('return_to');
useOAuthTokenAlerts({
enabled: subscription?.active === true,
interval: 60000,
});
// Initial subscription check with retries
useEffect(() => {
const timeoutId = setTimeout(async () => {
const maxRetries = 3;
@@ -38,47 +53,91 @@ const InitialRouteHandler: React.FC = () => {
break;
} catch (err) {
console.error(`App: Subscription check attempt ${attempt + 1} failed:`, err);
const isConnectionError = err instanceof Error && (err.name === 'NetworkError' || err.name === 'ConnectionError');
if (isConnectionError && attempt < maxRetries - 1) {
const delay = 1000 * Math.pow(2, attempt);
await new Promise(resolve => setTimeout(resolve, delay));
continue;
}
if (attempt === maxRetries - 1 || !isConnectionError) {
if (isConnectionError) {
setConnectionError({
hasError: true,
error: err as Error,
});
}
}
const delay = 1000 * Math.pow(2, attempt);
await new Promise(resolve => setTimeout(resolve, delay));
continue;
}
if (attempt === maxRetries - 1 || !isConnectionError) {
if (isConnectionError) {
setConnectionError({
hasError: true,
error: err as Error,
});
}
}
}
}
// Mark initial check as done regardless of success/failure
setInitialCheckDone(true);
}, 100);
return () => clearTimeout(timeoutId);
}, []);
const urlParams = new URLSearchParams(location.search);
const isCheckoutSuccess = urlParams.get('subscription') === 'success';
// Handle post-checkout: when Stripe redirects back with ?subscription=success,
// the webhook may not have processed yet. Poll until subscription becomes active.
useEffect(() => {
if (!isCheckoutSuccess) return;
if (subscription?.active && subscription.plan !== 'none' && subscription.plan !== 'free') {
// Webhook has processed — subscription is active, stop polling
if (checkoutPolling) {
console.log('InitialRouteHandler: Checkout success — subscription confirmed active, stopping poll');
setCheckoutPolling(false);
}
return;
}
// Start polling if webhook hasn't processed yet
if (!checkoutPolling && checkoutPollAttempts.current === 0) {
console.log('InitialRouteHandler: Checkout success — subscription not yet active, starting poll');
setCheckoutPolling(true);
}
}, [isCheckoutSuccess, subscription, checkoutPolling]);
// Polling effect for post-checkout
useEffect(() => {
if (!checkoutPolling) return;
if (checkoutPollAttempts.current >= CHECKOUT_POLL_MAX_ATTEMPTS) {
console.log('InitialRouteHandler: Checkout polling exhausted — proceeding with current state');
setCheckoutPolling(false);
return;
}
const timer = setTimeout(async () => {
checkoutPollAttempts.current += 1;
console.log(`InitialRouteHandler: Checkout poll attempt ${checkoutPollAttempts.current}/${CHECKOUT_POLL_MAX_ATTEMPTS}`);
try {
await checkSubscription();
} catch (err) {
console.error('InitialRouteHandler: Checkout poll check failed:', err);
}
}, CHECKOUT_POLL_INTERVAL_MS);
return () => clearTimeout(timer);
}, [checkoutPolling, checkSubscription]);
// Initialize onboarding when subscription is confirmed (but not on checkout success — let redirect happen)
useEffect(() => {
if (subscription && !subscriptionLoading) {
const isNewUser = !subscription || subscription.plan === 'none';
console.log('InitialRouteHandler: Subscription data received:', {
plan: subscription.plan,
active: subscription.active,
isNewUser,
subscriptionLoading
subscriptionLoading,
isCheckoutSuccess,
});
if (subscription.active && !isNewUser) {
console.log('InitialRouteHandler: Subscription confirmed, initializing onboarding...');
if (!isCheckoutSuccess) {
initializeOnboarding();
}
@@ -86,9 +145,85 @@ const InitialRouteHandler: React.FC = () => {
}
}, [subscription, subscriptionLoading, initializeOnboarding, isCheckoutSuccess]);
if (isCheckoutSuccess && subscription?.active && shouldSkipOnboarding()) {
console.log('InitialRouteHandler: Early redirect - Stripe checkout success in demo mode → Podcast Maker');
return navigateAndLog("/podcast-maker");
// --- Render decisions ---
// Wait for initial subscription check before making routing decisions.
// Without this, a null subscription (before API response) can trigger
// incorrect redirects (e.g., to feature routes instead of /pricing).
if (!initialCheckDone && !connectionError.hasError) {
return (
<Box
display="flex"
flexDirection="column"
alignItems="center"
justifyContent="center"
minHeight="100vh"
gap={2}
>
<CircularProgress size={60} />
<Typography variant="h6" color="textSecondary">
Checking subscription...
</Typography>
</Box>
);
}
// Show polling spinner during post-checkout webhook wait
if (checkoutPolling) {
return (
<Box
display="flex"
flexDirection="column"
alignItems="center"
justifyContent="center"
minHeight="100vh"
gap={2}
>
<CircularProgress size={60} />
<Typography variant="h6" color="textSecondary">
Activating your subscription...
</Typography>
<Typography variant="body2" color="textSecondary">
This may take a few seconds.
</Typography>
</Box>
);
}
// Post-checkout: subscription is now active (or poll exhausted)
if (isCheckoutSuccess && subscription?.active && subscription.plan !== 'none' && subscription.plan !== 'free') {
// Restore navigation state (saved before Stripe redirect)
const navState = restoreNavigationState();
const redirectTo = returnTo || navState?.path;
if (redirectTo && redirectTo !== '/pricing' && redirectTo !== '/onboarding') {
console.log(`InitialRouteHandler: Checkout success — redirecting to saved page: ${redirectTo}`);
return navigateAndLog(redirectTo);
}
if (shouldSkipOnboarding()) {
const route = getDefaultLandingRoute();
console.log(`InitialRouteHandler: Checkout success in demo mode → ${route}`);
return navigateAndLog(route);
}
if (!isOnboardingComplete) {
console.log('InitialRouteHandler: Checkout success — onboarding incomplete → Onboarding');
return navigateAndLog('/onboarding');
}
console.log('InitialRouteHandler: Checkout success → Dashboard');
return navigateAndLog('/dashboard');
}
// Checkout success but subscription still not active after polling — treat as inactive
// SubscriptionContext will show the expired modal
if (isCheckoutSuccess && (!subscription?.active || subscription.plan === 'none' || subscription.plan === 'free')) {
console.log('InitialRouteHandler: Checkout success but subscription not yet active — showing pricing');
if (shouldSkipOnboarding()) {
return navigateAndLog(getDefaultLandingRoute());
}
return <Navigate to="/pricing" replace />;
}
if (connectionError.hasError) {
@@ -128,9 +263,9 @@ const InitialRouteHandler: React.FC = () => {
subscription: subscription ? { plan: subscription.plan, active: subscription.active } : null,
subscriptionLoading,
loading,
data: !!data
data: !!data,
});
const isActiveSubscriber = Boolean(subscription && subscription.active && subscription.plan !== 'none');
const isActiveSubscriber = Boolean(subscription && subscription.active && subscription.plan !== 'none' && subscription.plan !== 'free');
console.log('InitialRouteHandler: isActiveSubscriber =', isActiveSubscriber);
const waitingForOnboardingInit = !isDemoMode && isActiveSubscriber && (loading || !data);
if (waitingForOnboardingInit) {
@@ -192,10 +327,15 @@ const InitialRouteHandler: React.FC = () => {
if (!subscription) {
if (isOnboardingComplete) {
if (isDemoMode) {
const route = getDefaultLandingRoute();
console.log(`InitialRouteHandler: Onboarding complete, no sub, demo mode → ${route}`);
return navigateAndLog(route);
}
console.log('InitialRouteHandler: Onboarding complete but no subscription data → Dashboard (allow access)');
return navigateAndLog("/dashboard");
}
if (subscriptionLoading) {
return (
<Box
@@ -213,43 +353,19 @@ const InitialRouteHandler: React.FC = () => {
</Box>
);
}
if (!subscription) {
if (isOnboardingComplete) {
console.log('InitialRouteHandler: Onboarding complete but no subscription data → Dashboard (allow access)');
return navigateAndLog("/dashboard");
}
if (subscriptionLoading) {
return (
<Box
display="flex"
flexDirection="column"
alignItems="center"
justifyContent="center"
minHeight="100vh"
gap={2}
>
<CircularProgress size={60} />
<Typography variant="h6" color="textSecondary">
Checking subscription...
</Typography>
</Box>
);
}
if (shouldSkipOnboarding()) {
console.log('InitialRouteHandler: Demo mode - no subscription but allowing access to podcast-maker');
return navigateAndLog("/podcast-maker");
}
console.log('InitialRouteHandler: No subscription data after check → Pricing page');
return navigateAndLog("/pricing");
if (shouldSkipOnboarding()) {
const route = getDefaultLandingRoute();
console.log(`InitialRouteHandler: Demo mode - no subscription but allowing access to ${route}`);
return navigateAndLog(route);
}
console.log('InitialRouteHandler: No subscription data after check → Pricing page');
return navigateAndLog("/pricing");
}
const isNewUser = !subscription || subscription.plan === 'none';
const isNewUser = !subscription || subscription.plan === 'none' || subscription.plan === 'free';
if (isNewUser || !subscription.active) {
console.log('InitialRouteHandler: No active subscription - modal will be shown by SubscriptionContext');
if (isNewUser) {
@@ -262,15 +378,21 @@ const InitialRouteHandler: React.FC = () => {
if (!isOnboardingComplete) {
console.log('InitialRouteHandler: isOnboardingComplete = false, shouldSkipOnboarding() =', shouldSkipOnboarding());
if (shouldSkipOnboarding()) {
console.log('InitialRouteHandler: Demo mode - skipping onboarding → Podcast Maker');
return navigateAndLog("/podcast-maker");
const route = getDefaultLandingRoute();
console.log(`InitialRouteHandler: Demo mode - skipping onboarding → ${route}`);
return navigateAndLog(route);
}
console.log('InitialRouteHandler: Subscription active but onboarding incomplete → Onboarding');
return navigateAndLog("/onboarding");
}
if (isDemoMode) {
const route = getDefaultLandingRoute();
console.log(`InitialRouteHandler: All set in demo mode → ${route}`);
return navigateAndLog(route);
}
console.log('InitialRouteHandler: All set (subscription + onboarding) → Dashboard');
return navigateAndLog("/dashboard");
};
export default InitialRouteHandler;
export default InitialRouteHandler;

View File

@@ -1,4 +1,11 @@
import React, { useRef, useCallback } from 'react';
import React, { useRef, useCallback, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import Dialog from '@mui/material/Dialog';
import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent';
import DialogContentText from '@mui/material/DialogContentText';
import DialogActions from '@mui/material/DialogActions';
import Button from '@mui/material/Button';
import { debug } from '../../utils/debug';
import WriterCopilotSidebar from './BlogWriterUtils/WriterCopilotSidebar';
import { blogWriterApi } from '../../services/blogWriterApi';
@@ -28,7 +35,7 @@ import { useBlogWriterRefs } from './BlogWriterUtils/useBlogWriterRefs';
import { BlogWriterLandingSection } from './BlogWriterUtils/BlogWriterLandingSection';
import { CopilotKitComponents } from './BlogWriterUtils/CopilotKitComponents';
export const BlogWriter: React.FC = () => {
const BlogWriter: React.FC = () => {
// Add light theme class to body/html on mount, remove on unmount
React.useEffect(() => {
document.body.classList.add('blog-writer-page');
@@ -44,6 +51,8 @@ export const BlogWriter: React.FC = () => {
enabled: true, // Enable health checking
});
const navigate = useNavigate();
// Use custom hook for all state management
const {
research,
@@ -67,6 +76,7 @@ export const BlogWriter: React.FC = () => {
flowAnalysisCompleted,
flowAnalysisResults,
sectionImages,
setResearch,
setOutline,
setTitleOptions,
setSelectedTitle,
@@ -77,6 +87,7 @@ export const BlogWriter: React.FC = () => {
setContinuityRefresh,
setOutlineTaskId,
setContentConfirmed,
setOutlineConfirmed,
setFlowAnalysisCompleted,
setFlowAnalysisResults,
setSectionImages,
@@ -291,6 +302,48 @@ export const BlogWriter: React.FC = () => {
}
}, [navigateToPhase, seoAnalysis, research, runSEOAnalysisDirect, setIsSEOAnalysisModalOpen]);
const handleNewBlog = useCallback(() => {
setResearch(null);
setOutline([]);
setSections({});
setSeoAnalysis(null);
setSeoMetadata(null);
setContentConfirmed(false);
setOutlineConfirmed(false);
setSelectedTitle('');
setTitleOptions([]);
setCurrentPhase('');
try {
localStorage.removeItem('blog_outline');
localStorage.removeItem('blog_title_options');
localStorage.removeItem('blog_selected_title');
localStorage.removeItem('blogwriter_current_phase');
localStorage.removeItem('blogwriter_user_selected_phase');
localStorage.removeItem('blog_content_confirmed');
localStorage.removeItem('blog_seo_recommendations_applied');
} catch {
// ignore localStorage errors
}
}, [setResearch, setOutline, setSections, setSeoAnalysis, setSeoMetadata,
setContentConfirmed, setOutlineConfirmed, setSelectedTitle, setTitleOptions,
setCurrentPhase]);
const handleMyBlogs = useCallback(() => {
navigate('/asset-library?source_module=blog_writer&asset_type=text');
}, [navigate]);
const [newBlogDialogOpen, setNewBlogDialogOpen] = useState(false);
const hasExistingWork = !!(research || outline.length > 0 || Object.keys(sections).length > 0);
const confirmNewBlog = useCallback(() => {
if (hasExistingWork) {
setNewBlogDialogOpen(true);
} else {
handleNewBlog();
}
}, [hasExistingWork, handleNewBlog]);
const outlineGenRef = useRef<any>(null);
// Callback to handle cached outline completion
@@ -332,6 +385,7 @@ export const BlogWriter: React.FC = () => {
setIsSEOAnalysisModalOpen,
setIsSEOMetadataModalOpen,
runSEOAnalysisDirect,
onResearchComplete: handleResearchComplete,
onOutlineComplete: handleCachedOutlineComplete,
onContentComplete: handleCachedContentComplete,
});
@@ -443,6 +497,7 @@ export const BlogWriter: React.FC = () => {
/>
{/* Phase navigation header - always visible as default interface */}
<div style={{ flexShrink: 0 }}>
<HeaderBar
phases={phases}
currentPhase={currentPhase}
@@ -464,7 +519,11 @@ export const BlogWriter: React.FC = () => {
hasSEOAnalysis={!!seoAnalysis}
seoRecommendationsApplied={seoRecommendationsApplied}
hasSEOMetadata={!!seoMetadata}
onNewBlog={confirmNewBlog}
onMyBlogs={handleMyBlogs}
onHelp={() => window.open('/docs', '_blank')}
/>
</div>
{/* Landing section - extracted to BlogWriterLandingSection */}
<BlogWriterLandingSection
@@ -560,6 +619,26 @@ export const BlogWriter: React.FC = () => {
// Publisher component will use this metadata when calling publish API
}}
/>
{/* New Blog confirmation dialog */}
<Dialog
open={newBlogDialogOpen}
onClose={() => setNewBlogDialogOpen(false)}
aria-labelledby="new-blog-dialog-title"
>
<DialogTitle id="new-blog-dialog-title">Start New Blog?</DialogTitle>
<DialogContent>
<DialogContentText>
This will clear all your current work and start a new blog. This action cannot be undone.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setNewBlogDialogOpen(false)}>Cancel</Button>
<Button onClick={() => { handleNewBlog(); setNewBlogDialogOpen(false); }} color="primary" variant="contained">
Start New
</Button>
</DialogActions>
</Dialog>
</div>
);
};

View File

@@ -1,4 +1,21 @@
import React from 'react';
import React, { useState } from 'react';
import Box from '@mui/material/Box';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import Divider from '@mui/material/Divider';
import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
import useMediaQuery from '@mui/material/useMediaQuery';
import { useTheme } from '@mui/material/styles';
import MenuIcon from '@mui/icons-material/Menu';
import CloseIcon from '@mui/icons-material/Close';
import AddIcon from '@mui/icons-material/Add';
import ArticleIcon from '@mui/icons-material/Article';
import HelpIcon from '@mui/icons-material/Help';
import HeaderControls from '../../shared/HeaderControls';
import PhaseNavigation, { PhaseActionHandlers } from '../PhaseNavigation';
interface HeaderBarProps {
@@ -15,61 +32,154 @@ interface HeaderBarProps {
hasSEOAnalysis?: boolean;
seoRecommendationsApplied?: boolean;
hasSEOMetadata?: boolean;
onNewBlog?: () => void;
onMyBlogs?: () => void;
onHelp?: () => void;
}
export const HeaderBar: React.FC<HeaderBarProps> = ({
phases,
currentPhase,
onPhaseClick,
copilotKitAvailable = true,
actionHandlers,
hasResearch = false,
hasOutline = false,
outlineConfirmed = false,
hasContent = false,
contentConfirmed = false,
hasSEOAnalysis = false,
seoRecommendationsApplied = false,
hasSEOMetadata = false,
phases, currentPhase, onPhaseClick, copilotKitAvailable = true, actionHandlers,
hasResearch = false, hasOutline = false, outlineConfirmed = false,
hasContent = false, contentConfirmed = false, hasSEOAnalysis = false,
seoRecommendationsApplied = false, hasSEOMetadata = false,
onNewBlog, onMyBlogs, onHelp,
}) => {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const isMenuOpen = Boolean(anchorEl);
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => setAnchorEl(event.currentTarget);
const handleMenuClose = () => setAnchorEl(null);
const handleNewBlog = () => { handleMenuClose(); onNewBlog?.(); };
const handleMyBlogs = () => { handleMenuClose(); onMyBlogs?.(); };
const handleHelp = () => { handleMenuClose(); onHelp?.(); };
return (
<div style={{ padding: 16, borderBottom: '1px solid #eee' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<h2 style={{ margin: 0 }}>AI Blog Writer</h2>
<div style={{
width: '40px',
height: '40px',
backgroundColor: '#f0f0f0',
borderRadius: '8px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '20px',
fontWeight: 'bold',
color: '#666'
}}>
A
</div>
</div>
<PhaseNavigation
phases={phases}
currentPhase={currentPhase}
onPhaseClick={onPhaseClick}
copilotKitAvailable={copilotKitAvailable}
actionHandlers={actionHandlers}
hasResearch={hasResearch}
hasOutline={hasOutline}
outlineConfirmed={outlineConfirmed}
hasContent={hasContent}
contentConfirmed={contentConfirmed}
hasSEOAnalysis={hasSEOAnalysis}
seoRecommendationsApplied={seoRecommendationsApplied}
hasSEOMetadata={hasSEOMetadata}
/>
</div>
<Box sx={{
width: '100%',
background: 'linear-gradient(135deg, rgba(37, 99, 235, 0.08) 0%, rgba(59, 130, 246, 0.08) 100%)',
borderRadius: 3,
p: { xs: 1.5, md: 2.5 },
border: '1px solid rgba(37, 99, 235, 0.15)',
position: 'relative',
overflow: 'hidden',
'&::before': {
content: '""',
position: 'absolute',
top: 0, left: 0, right: 0,
height: '3px',
background: 'linear-gradient(90deg, #2563eb 0%, #3b82f6 50%, #60a5fa 100%)',
},
}}>
<Stack direction="row" justifyContent="space-between" alignItems="center" flexWrap="wrap" gap={1}>
<Stack direction="row" alignItems="center" gap={1.5}>
<Box sx={{
width: { xs: 36, md: 44 },
height: { xs: 36, md: 44 },
borderRadius: 2,
background: 'linear-gradient(135deg, #2563eb 0%, #3b82f6 100%)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: '0 4px 12px rgba(37, 99, 235, 0.3)',
}}>
<ArticleIcon sx={{ color: '#fff', fontSize: { xs: 20, md: 24 } }} />
</Box>
<Typography variant="h5" sx={{
background: 'linear-gradient(135deg, #1e293b 0%, #334155 100%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
fontWeight: 700,
fontSize: { xs: '1.1rem', sm: '1.25rem', md: '1.5rem' },
letterSpacing: '-0.02em',
}}>
AI Blog Writer
</Typography>
</Stack>
<Stack direction="row" spacing={1} alignItems="center">
<HeaderControls colorMode="light" showAlerts={true} showUser={true} />
<IconButton onClick={handleMenuOpen} sx={{
background: isMenuOpen
? 'linear-gradient(135deg, #2563eb 0%, #3b82f6 100%)'
: 'rgba(37, 99, 235, 0.1)',
border: '1px solid',
borderColor: isMenuOpen ? 'transparent' : 'rgba(37, 99, 235, 0.3)',
borderRadius: 2,
p: 1,
transition: 'all 0.2s ease',
'&:hover': {
background: 'linear-gradient(135deg, #2563eb 0%, #3b82f6 100%)',
borderColor: 'transparent',
transform: 'scale(1.05)',
},
}}>
{isMenuOpen ? <CloseIcon sx={{ color: '#fff', fontSize: 20 }} /> : <MenuIcon sx={{ color: '#2563eb', fontSize: 20 }} />}
</IconButton>
<Menu
anchorEl={anchorEl}
open={isMenuOpen}
onClose={handleMenuClose}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
PaperProps={{
sx: {
mt: 1, minWidth: 220, borderRadius: 2,
background: 'linear-gradient(135deg, #1e293b 0%, #0f172a 100%)',
border: '1px solid rgba(37, 99, 235, 0.3)',
boxShadow: '0 10px 40px rgba(37, 99, 235, 0.25)',
'& .MuiMenuItem-root': {
color: 'rgba(255, 255, 255, 0.85)',
px: 2, py: 1.5,
transition: 'all 0.15s ease',
'&:hover': {
background: 'linear-gradient(135deg, rgba(37, 99, 235, 0.2) 0%, rgba(59, 130, 246, 0.2) 100%)',
color: '#fff',
},
},
'& .MuiListItemIcon-root': { color: '#60a5fa', minWidth: 36 },
'& .MuiDivider-root': { borderColor: 'rgba(37, 99, 235, 0.2)', my: 0.5 },
},
}}
>
<MenuItem onClick={handleNewBlog}>
<ListItemIcon><AddIcon fontSize="small" /></ListItemIcon>
<ListItemText primary="New Blog" primaryTypographyProps={{ fontWeight: 600 }} />
</MenuItem>
<MenuItem onClick={handleMyBlogs}>
<ListItemIcon><ArticleIcon fontSize="small" /></ListItemIcon>
<ListItemText primary="My Blogs" primaryTypographyProps={{ fontWeight: 500 }} />
</MenuItem>
<Divider />
<MenuItem onClick={handleHelp}>
<ListItemIcon><HelpIcon fontSize="small" /></ListItemIcon>
<ListItemText primary="Help & Docs" primaryTypographyProps={{ fontWeight: 500 }} />
</MenuItem>
</Menu>
</Stack>
</Stack>
<Box sx={{ mt: 1 }}>
<PhaseNavigation
phases={phases}
currentPhase={currentPhase}
onPhaseClick={onPhaseClick}
copilotKitAvailable={copilotKitAvailable}
actionHandlers={actionHandlers}
hasResearch={hasResearch}
hasOutline={hasOutline}
outlineConfirmed={outlineConfirmed}
hasContent={hasContent}
contentConfirmed={contentConfirmed}
hasSEOAnalysis={hasSEOAnalysis}
seoRecommendationsApplied={seoRecommendationsApplied}
hasSEOMetadata={hasSEOMetadata}
/>
</Box>
</Box>
);
};
export default HeaderBar;

View File

@@ -198,6 +198,18 @@ export const WriterCopilotSidebar: React.FC<WriterCopilotSidebarProps> = ({
* {
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
/* Hide CopilotKit announcement/notification icon (bell badge) */
[class*="announcement"] {
display: none !important;
}
[class*="announce"] {
display: none !important;
}
/* Hide the floating Web Inspector button (shadow DOM - target the custom element itself) */
cpk-web-inspector {
display: none !important;
}
`}</style>
{/* Inject data attributes to identify Next suggestions */}

View File

@@ -20,6 +20,7 @@ interface UsePhaseActionHandlersProps {
setIsSEOAnalysisModalOpen: (open: boolean) => void;
setIsSEOMetadataModalOpen: (open: boolean) => void;
runSEOAnalysisDirect: () => string;
onResearchComplete?: (research: any) => void;
onOutlineComplete?: (outline: any) => void;
onContentComplete?: (sections: Record<string, string>) => void;
}
@@ -40,14 +41,32 @@ export const usePhaseActionHandlers = ({
setIsSEOAnalysisModalOpen,
setIsSEOMetadataModalOpen,
runSEOAnalysisDirect,
onResearchComplete,
onOutlineComplete,
onContentComplete,
}: UsePhaseActionHandlersProps) => {
const handleResearchAction = useCallback(() => {
if (research) {
navigateToPhase('research');
return;
}
const cachedEntries = researchCache.getAllCachedEntries();
const latestCached = cachedEntries.find(entry => {
try {
return new Date(entry.expires_at) > new Date();
} catch {
return false;
}
});
if (latestCached && onResearchComplete) {
debug.log('[BlogWriter] Restoring cached research data', { keywords: latestCached.keywords });
onResearchComplete(latestCached.result);
}
navigateToPhase('research');
debug.log('[BlogWriter] Research action triggered - navigating to research phase');
// Note: Research caching is handled by ManualResearchForm component
}, [navigateToPhase]);
}, [navigateToPhase, onResearchComplete, research]);
const handleOutlineAction = useCallback(async () => {
if (!research) {

View File

@@ -1,5 +1,6 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { debug } from '../../../utils/debug';
import { hashContent, getSeoCacheKey } from '../../../utils/contentHash';
import { blogWriterApi, BlogSEOActionableRecommendation } from '../../../services/blogWriterApi';
import { blogWriterCache } from '../../../services/blogWriterCache';
@@ -218,6 +219,44 @@ export const useSEOManager = ({
const [seoRecommendationsApplied, setSeoRecommendationsApplied] = useState(false);
const lastSEOModalOpenRef = useRef<number>(0);
// Restore cached SEO analysis on mount when sections are available
useEffect(() => {
const restoreCachedSEO = async () => {
if (seoAnalysis) return;
const title = selectedTitle || '';
if (!title && (!outline || outline.length === 0)) return;
const fullMarkdown = (outline || []).map(s => `## ${s.heading}\n\n${(sections || {})[s.id] || ''}`).join('\n\n');
if (!fullMarkdown && !title) return;
try {
const hash = await hashContent(`${title}\n${fullMarkdown}`);
const cacheKey = getSeoCacheKey(hash, title);
const cached = window.localStorage.getItem(cacheKey);
if (cached) {
const parsed = JSON.parse(cached);
if (parsed && typeof parsed.overall_score === 'number' && parsed.category_scores) {
debug.log('[SEOManager] Restored cached SEO analysis', { cacheKey, score: parsed.overall_score });
setSeoAnalysis(parsed);
}
}
} catch (e) {
debug.log('[SEOManager] Failed to restore cached SEO analysis', e);
}
};
restoreCachedSEO();
try {
const wasApplied = localStorage.getItem('blog_seo_recommendations_applied') === 'true';
if (wasApplied) {
setSeoRecommendationsApplied(true);
debug.log('[SEOManager] Restored seoRecommendationsApplied flag');
}
} catch {}
}, [selectedTitle, sections, outline, seoAnalysis, setSeoAnalysis, setSeoRecommendationsApplied]);
// Helper: run same checks as analyzeSEO and open modal
const runSEOAnalysisDirect = useCallback((): string => {
const hasSections = !!sections && Object.keys(sections).length > 0;
@@ -387,6 +426,9 @@ export const useSEOManager = ({
// Mark recommendations as applied (this will trigger phase navigation check)
// But we'll stay in SEO phase to show updated content
setSeoRecommendationsApplied(true);
try {
localStorage.setItem('blog_seo_recommendations_applied', 'true');
} catch {}
debug.log('[BlogWriter] seoRecommendationsApplied set to true');
// Ensure we stay in SEO phase to show updated content

View File

@@ -1,95 +1,37 @@
import React, { useState, useRef } from 'react';
import { blogWriterApi, BlogResearchRequest, BlogResearchResponse } from '../../services/blogWriterApi';
import { useBlogWriterResearchPolling } from '../../hooks/usePolling';
import React, { useRef } from 'react';
import { BlogResearchResponse } from '../../services/blogWriterApi';
import { useResearchSubmit } from '../../hooks/useResearchSubmit';
import ResearchProgressModal from './ResearchProgressModal';
import { researchCache } from '../../services/researchCache';
interface ManualResearchFormProps {
onResearchComplete?: (research: BlogResearchResponse) => void;
}
/**
* Manual research form component that works independently of CopilotKit
* Extracted from ResearchAction.tsx for use when CopilotKit is unavailable
*/
export const ManualResearchForm: React.FC<ManualResearchFormProps> = ({ onResearchComplete }) => {
const [currentTaskId, setCurrentTaskId] = useState<string | null>(null);
const [currentMessage, setCurrentMessage] = useState<string>('');
const [showProgressModal, setShowProgressModal] = useState<boolean>(false);
const [isSubmitting, setIsSubmitting] = useState(false);
// Refs for form inputs (uncontrolled, avoids typing issues)
const keywordsRef = useRef<HTMLInputElement | null>(null);
const blogLengthRef = useRef<HTMLSelectElement | null>(null);
const polling = useBlogWriterResearchPolling({
onProgress: (message) => {
setCurrentMessage(message);
},
onComplete: (result) => {
if (result && result.keywords) {
researchCache.cacheResult(
result.keywords,
result.industry || 'General',
result.target_audience || 'General',
result
);
}
onResearchComplete?.(result);
setCurrentTaskId(null);
setCurrentMessage('');
setShowProgressModal(false);
setIsSubmitting(false);
},
onError: (error) => {
console.error('Research polling error:', error);
setCurrentTaskId(null);
setCurrentMessage('');
setShowProgressModal(false);
setIsSubmitting(false);
}
});
const {
startResearch,
isSubmitting,
showProgressModal,
setShowProgressModal,
currentMessage,
currentStatus,
progressMessages,
error,
} = useResearchSubmit({ onResearchComplete });
const handleSubmit = async () => {
const keywords = (keywordsRef.current?.value || '').trim();
const blogLength = blogLengthRef.current?.value || '1000';
if (!keywords) {
alert('Please enter keywords or a topic for research.');
return;
}
setIsSubmitting(true);
try {
const keywordList = keywords.includes(',')
? keywords.split(',').map(k => k.trim()).filter(Boolean)
: [keywords];
// Check cache first
const cachedResult = researchCache.getCachedResult(keywordList, 'General', 'General');
if (cachedResult) {
onResearchComplete?.(cachedResult);
setIsSubmitting(false);
return;
}
const payload: BlogResearchRequest = {
keywords: keywordList,
industry: 'General',
target_audience: 'General',
word_count_target: parseInt(blogLength)
};
const { task_id } = await blogWriterApi.startResearch(payload);
setCurrentTaskId(task_id);
setShowProgressModal(true);
polling.startPolling(task_id);
await startResearch(keywords, blogLengthRef.current?.value || '1000');
} catch (error) {
console.error('Research failed:', error);
alert(`Research failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
setIsSubmitting(false);
}
};
@@ -170,9 +112,9 @@ export const ManualResearchForm: React.FC<ManualResearchFormProps> = ({ onResear
<ResearchProgressModal
open={showProgressModal}
title="Research in progress"
status={polling.currentStatus}
messages={polling.progressMessages}
error={polling.error}
status={currentStatus}
messages={progressMessages}
error={error}
onClose={() => setShowProgressModal(false)}
/>
)}

View File

@@ -1,4 +1,8 @@
import React from 'react';
import Box from '@mui/material/Box';
import Tooltip from '@mui/material/Tooltip';
import CircularProgress from '@mui/material/CircularProgress';
import Typography from '@mui/material/Typography';
export interface Phase {
id: string;
@@ -11,12 +15,12 @@ export interface Phase {
}
export interface PhaseActionHandlers {
onResearchAction?: () => void; // Show research form
onOutlineAction?: () => void; // Generate outline
onContentAction?: () => void; // Confirm outline + generate content
onSEOAction?: () => void; // Run SEO analysis
onApplySEORecommendations?: () => void; // Apply SEO recommendations
onPublishAction?: () => void; // Generate SEO metadata or publish
onResearchAction?: () => void;
onOutlineAction?: () => void;
onContentAction?: () => void;
onSEOAction?: () => void;
onApplySEORecommendations?: () => void;
onPublishAction?: () => void;
}
interface PhaseNavigationProps {
@@ -25,7 +29,6 @@ interface PhaseNavigationProps {
currentPhase: string;
copilotKitAvailable?: boolean;
actionHandlers?: PhaseActionHandlers;
// State for determining which actions to show
hasResearch?: boolean;
hasOutline?: boolean;
outlineConfirmed?: boolean;
@@ -36,6 +39,22 @@ interface PhaseNavigationProps {
hasSEOMetadata?: boolean;
}
const PHASE_TOOLTIPS: Record<string, string> = {
research: 'Research your topic and gather data from the web to create a well-informed blog post.',
outline: 'Create and refine your blog outline with AI-generated structure and key talking points.',
content: 'Generate, edit, and perfect your blog content using the WYSIWYG editor and AI assistance.',
seo: 'Optimize your blog for search engines with AI-powered SEO analysis, recommendations, and metadata.',
publish: 'Publish your blog to WordPress, Wix, or export as HTML or Markdown.',
};
const PHASE_ACTIONS: Record<string, string> = {
research: 'Enter keywords to research your topic',
outline: 'Create your blog outline to structure your content',
content: 'Generate and refine your blog content',
seo: 'Optimize your blog for search engines',
publish: 'Publish or export your finished blog',
};
export const PhaseNavigation: React.FC<PhaseNavigationProps> = ({
phases,
onPhaseClick,
@@ -51,32 +70,22 @@ export const PhaseNavigation: React.FC<PhaseNavigationProps> = ({
seoRecommendationsApplied = false,
hasSEOMetadata = false,
}) => {
// Phase Navigation: Default interface for blog writing workflow
// - Phase buttons are always clickable and functional (for both CopilotKit and manual flows)
// - Action buttons (▶) only appear when CopilotKit is unavailable (manual fallback)
// - When CopilotKit is available, users can use either phase buttons or CopilotKit suggestions
// Determine which action to show for each phase when CopilotKit is unavailable
const totalPhases = phases.length;
const completedCount = phases.filter(p => p.completed).length;
const completionPct = totalPhases > 0 ? Math.round((completedCount / totalPhases) * 100) : 0;
const getActionForPhase = (phaseId: string): { label: string; handler: (() => void) | null } => {
// Show action buttons for both CopilotKit and manual flows (dual mode)
// Users can use either CopilotKit suggestions or phase navigation buttons
if (!actionHandlers) {
return { label: '', handler: null };
}
switch (phaseId) {
case 'research':
// Always show "Start Research" button when on research phase and no research exists yet
// This allows users to manually trigger research form
// If research already exists, don't show the button (user can click the phase button to view)
if (!hasResearch) {
return { label: 'Start Research', handler: actionHandlers.onResearchAction || null };
}
break;
case 'outline':
// Show "Create Outline" if research exists and outline is not yet confirmed
// This ensures users can create/regenerate outline after research, even if cached one exists
// Once outline is confirmed, we hide the button to avoid confusion during content generation
if (hasResearch && !outlineConfirmed) {
return { label: 'Create Outline', handler: actionHandlers.onOutlineAction || null };
}
@@ -87,343 +96,329 @@ export const PhaseNavigation: React.FC<PhaseNavigationProps> = ({
}
break;
case 'seo':
// Priority order matching CopilotKit suggestions:
// 1. No SEO analysis yet - Run SEO Analysis
// Note: We check hasContent (sections exist) - contentConfirmed is checked but not strictly required
// This allows users to run SEO analysis even if contentConfirmed hasn't been explicitly set
if (hasContent && !hasSEOAnalysis) {
return { label: 'Run SEO Analysis', handler: actionHandlers.onSEOAction || null };
}
// 2. SEO analysis exists but recommendations not applied - Apply SEO Recommendations
if (hasSEOAnalysis && !seoRecommendationsApplied) {
return { label: 'Apply SEO Recommendations', handler: actionHandlers.onApplySEORecommendations || null };
}
// 3. SEO analysis exists and recommendations applied but no metadata - Generate SEO Metadata
if (hasSEOAnalysis && seoRecommendationsApplied && !hasSEOMetadata) {
return { label: 'Generate SEO Metadata', handler: actionHandlers.onPublishAction || null };
}
break;
case 'publish':
// Only show if SEO metadata exists (ready to publish)
if (hasSEOAnalysis && seoRecommendationsApplied && hasSEOMetadata) {
return { label: 'Ready to Publish', handler: null }; // Publish handled separately
return { label: 'Ready to Publish', handler: null };
}
break;
}
return { label: '', handler: null };
};
const activePhase = phases.find(p => p.current);
const infoText = (() => {
if (!activePhase || !activePhase.id) {
if (completedCount === 0) return '📍 Start with Research to begin';
const next = phases.find(p => !p.completed && !p.disabled);
return next ? `👉 Next: ${next.name}` : '✅ All phases complete!';
}
const next = phases.find(p => !p.completed && !p.disabled);
if (activePhase.completed && !next) return '✅ All phases complete!';
if (activePhase.completed) return `${activePhase.name} done — Next: ${next!.name}`;
return `📍 ${activePhase.name}: ${PHASE_ACTIONS[activePhase.id] || 'Complete this phase'}`;
})();
return (
<>
<style>{`
/* Enterprise Phase Navigation Styles */
.phase-nav-container {
display: flex;
gap: 10px;
alignItems: center;
padding: 12px 0;
flexWrap: wrap;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.95) 0%, rgba(249, 250, 251, 0.98) 100%);
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
.phase-chip {
position: relative;
display: flex;
align-items: center;
gap: 8px;
padding: 10px 18px;
border-radius: 24px;
border: none;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
letter-spacing: 0.01em;
}
.phase-chip::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3), transparent);
transition: left 0.5s ease;
}
.phase-chip:hover::before {
left: 100%;
}
/* Current Phase - Active Gradient */
.phase-chip.current {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.1) inset;
transform: translateY(-2px) scale(1.02);
}
.phase-chip.current:hover {
transform: translateY(-3px) scale(1.03);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.2) inset;
}
.phase-chip.current:active {
transform: translateY(-1px) scale(1.01);
box-shadow: 0 3px 12px rgba(102, 126, 234, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.15) inset;
}
/* Completed Phase - Success Gradient */
.phase-chip.completed {
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
color: white;
box-shadow: 0 3px 12px rgba(16, 185, 129, 0.3), 0 0 0 1px rgba(255, 255, 255, 0.1) inset;
}
.phase-chip.completed:hover {
transform: translateY(-2px) scale(1.02);
box-shadow: 0 5px 16px rgba(16, 185, 129, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.15) inset;
}
/* Pending Phase - Subtle Gradient */
.phase-chip.pending {
background: linear-gradient(135deg, #e0e7ff 0%, #dbeafe 100%);
color: #4b5563;
border: 1px solid rgba(99, 102, 241, 0.2);
box-shadow: 0 2px 6px rgba(99, 102, 241, 0.1);
}
.phase-chip.pending:hover {
background: linear-gradient(135deg, #c7d2fe 0%, #bfdbfe 100%);
transform: translateY(-2px) scale(1.02);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.2);
border-color: rgba(99, 102, 241, 0.3);
}
/* Disabled Phase */
.phase-chip.disabled {
background: linear-gradient(135deg, #f3f4f6 0%, #e5e7eb 100%);
color: #9ca3af;
cursor: not-allowed;
opacity: 0.6;
box-shadow: none;
border: 1px solid #e5e7eb;
}
.phase-chip.disabled:hover {
transform: none;
box-shadow: none;
}
/* Phase Icon */
.phase-icon {
font-size: 18px;
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.1));
transition: transform 0.3s ease;
}
.phase-chip.current .phase-icon,
.phase-chip.completed .phase-icon {
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2));
}
.phase-chip:hover:not(.disabled) .phase-icon {
transform: scale(1.1) rotate(5deg);
}
/* Checkmark for completed */
.phase-checkmark {
font-size: 14px;
margin-left: 4px;
animation: checkmarkPop 0.3s ease;
}
@keyframes checkmarkPop {
0% { transform: scale(0); }
50% { transform: scale(1.2); }
100% { transform: scale(1); }
}
/* Action Button - Enterprise Style */
.phase-action-btn {
position: relative;
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border-radius: 20px;
border: none;
font-size: 13px;
font-weight: 700;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
text-transform: uppercase;
letter-spacing: 0.5px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.35),
0 0 0 1px rgba(255, 255, 255, 0.1) inset,
0 1px 2px rgba(0, 0, 0, 0.1) inset;
}
.phase-action-btn::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.25), transparent);
transition: left 0.5s ease;
}
.phase-action-btn:hover::before {
left: 100%;
}
.phase-action-btn:hover {
transform: translateY(-2px) scale(1.05);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.5),
0 0 0 1px rgba(255, 255, 255, 0.2) inset,
0 2px 4px rgba(0, 0, 0, 0.15) inset;
}
.phase-action-btn:active {
transform: translateY(0) scale(1.02);
box-shadow: 0 3px 10px rgba(102, 126, 234, 0.4),
0 0 0 1px rgba(255, 255, 255, 0.15) inset;
}
.phase-action-btn:focus-visible {
outline: none;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.3),
0 4px 12px rgba(102, 126, 234, 0.35),
0 0 0 1px rgba(255, 255, 255, 0.1) inset;
}
.phase-action-icon {
font-size: 12px;
transition: transform 0.3s ease;
}
.phase-action-btn:hover .phase-action-icon {
transform: translateX(2px);
@keyframes phaseActivePulse {
0%, 100% {
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.35), 0 0 0 0 rgba(37, 99, 235, 0.25);
}
50% {
box-shadow: 0 2px 16px rgba(37, 99, 235, 0.55), 0 0 0 4px rgba(37, 99, 235, 0.1);
}
}
`}</style>
<div className="phase-nav-container">
<Box sx={{
display: 'flex',
gap: 0.75,
alignItems: 'center',
flexWrap: 'wrap',
}}>
{/* Dynamic phase info text */}
<Box sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
px: 1.25,
py: 0.4,
borderRadius: '20px',
background: 'rgba(37, 99, 235, 0.06)',
border: '1px solid rgba(37, 99, 235, 0.12)',
fontSize: '0.75rem',
color: '#475569',
fontWeight: 500,
whiteSpace: 'nowrap',
maxWidth: { xs: '180px', sm: '300px', md: '400px' },
overflow: 'hidden',
textOverflow: 'ellipsis',
flexShrink: 0,
}}>
{infoText}
</Box>
{/* Phase chips */}
{phases.map((phase) => {
const isCurrent = phase.current;
const isCompleted = phase.completed;
const isDisabled = phase.disabled;
const action = getActionForPhase(phase.id);
// Show action button when:
// 1. CopilotKit is unavailable
// 2. Action handler exists
// 3. Phase is not disabled
// 4. Show for current phase OR next actionable phase (not completed) OR phases with available actions
// For research phase: always show button when on research phase (allows manual trigger)
// For outline phase: always show if research exists but no outline (like research phase)
// For SEO phase: always show if action handler exists (prerequisites are met)
const isResearchPhase = phase.id === 'research' && action.handler; // Always show if handler exists
// Outline phase: show action whenever research exists and action handler is available
// This allows users to create/regenerate outline after research, even if cached one exists
const isResearchPhase = phase.id === 'research' && action.handler;
const isOutlinePhase = phase.id === 'outline' && hasResearch && action.handler;
// SEO phase: show action whenever prerequisites are met (action handler exists)
// Similar to research/outline, show SEO actions whenever handler exists and phase is enabled
const isSEOPhase = phase.id === 'seo' && action.handler;
// Debug logging for SEO phase (temporary - for troubleshooting)
if (phase.id === 'seo' && !copilotKitAvailable && process.env.NODE_ENV === 'development') {
console.log('[PhaseNavigation] SEO phase debug:', {
phaseId: phase.id,
isCurrent,
isCompleted,
isDisabled,
hasContent,
contentConfirmed,
hasSEOAnalysis,
seoRecommendationsApplied,
hasSEOMetadata,
actionLabel: action.label,
actionHandler: !!action.handler,
copilotKitAvailable,
isSEOPhase,
showActionWillBe: !copilotKitAvailable && action.handler && !isDisabled && (
isCurrent ||
(!isCompleted && !isDisabled) ||
isResearchPhase ||
isOutlinePhase ||
isSEOPhase
)
});
}
// Show action if: current phase, or phase is not completed and not disabled, or it's research/outline/SEO with available action
// For SEO: show whenever action handler exists (prerequisites are met), even if phase is marked as disabled/completed
// This is critical because SEO prerequisites (hasContent && contentConfirmed) are validated in getActionForPhase,
// so if action.handler exists, we should show it regardless of phase navigation's disabled state
// DUAL MODE: Show action buttons even when CopilotKit is available (users can use either method)
// For research phase: show action button when on research phase and no research exists yet (to start research)
const showAction = action.handler && (
(isCurrent && phase.id === 'research' && !hasResearch) || // Show "Start Research" when on research phase with no research
(isCurrent && phase.id !== 'research') || // For other phases, show action when current
(!isCompleted && !isDisabled) ||
(phase.id !== 'research' && (isResearchPhase || isOutlinePhase || isSEOPhase)) // Show for outline/SEO when appropriate
/* Phase state derivation:
- Active: phase is current AND not yet completed (user needs to work on it)
- Done: phase is completed (show green regardless of whether it's current)
- Pending: not current, not completed, not disabled */
const isActive = isCurrent && !isCompleted;
const isDone = isCompleted;
const isPending = !isCurrent && !isCompleted && !isDisabled;
/* Chip click: use action handler when available (same as action button),
fall back to navigation for viewing completed/disabled phases */
const handleChipClick = () => {
if (isDisabled) return;
if (action.handler) {
action.handler();
} else {
onPhaseClick(phase.id);
}
};
/* Show action button only when phase is NOT completed.
Research action: only on landing page (not current), to invite start.
Other phase actions: show when current, pending, or next-actionable. */
const showAction = action.handler && !isDone && (
(!isCurrent && phase.id === 'research' && !hasResearch) ||
(isCurrent && phase.id !== 'research') ||
(!isCurrent && !isDisabled && phase.id !== 'research') ||
(phase.id !== 'research' && (isResearchPhase || isOutlinePhase || isSEOPhase))
);
// Determine chip class
const chipClass = [
'phase-chip',
isCurrent ? 'current' : '',
isCompleted && !isCurrent ? 'completed' : '',
!isCurrent && !isCompleted && !isDisabled ? 'pending' : '',
isDisabled ? 'disabled' : ''
].filter(Boolean).join(' ');
const iconOnly = isDone && !isCurrent;
const chipSx = {
display: 'flex',
alignItems: 'center',
gap: 0.5,
borderRadius: '20px',
border: 'none',
fontWeight: 600,
cursor: isDisabled ? 'not-allowed' : 'pointer',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'clip',
/* Disabled phase: muted */
...(isDisabled && {
px: 1.25,
py: 0.5,
fontSize: '0.8125rem',
background: '#f1f5f9',
color: '#94a3b8',
border: '1px solid #e2e8f0',
opacity: 0.5,
}),
/* Done phase: green, collapsed to icon if not current */
...(isDone && !isDisabled && {
px: iconOnly ? 0.5 : 1.5,
py: 0.5,
fontSize: '0.8125rem',
justifyContent: iconOnly ? 'center' : 'flex-start',
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
color: '#fff',
boxShadow: '0 2px 6px rgba(16, 185, 129, 0.25)',
maxWidth: iconOnly ? '36px' : 'none',
opacity: iconOnly ? 0.85 : 1,
'&:hover': {
maxWidth: iconOnly ? '160px' : 'none',
px: iconOnly ? 1.5 : 1.5,
opacity: 1,
transform: 'translateY(-1px)',
boxShadow: '0 4px 12px rgba(16, 185, 129, 0.35)',
},
}),
/* Active phase (current but not done): larger, pulse glow */
...(isActive && !isDisabled && {
px: 2,
py: 0.75,
fontSize: '0.875rem',
background: 'linear-gradient(135deg, #2563eb 0%, #3b82f6 100%)',
color: '#fff',
boxShadow: '0 2px 8px rgba(37, 99, 235, 0.35), inset 0 0 0 1px rgba(255,255,255,0.15)',
animation: 'phaseActivePulse 2s ease-in-out infinite',
'&:hover': {
transform: 'translateY(-1px) scale(1.03)',
boxShadow: '0 4px 16px rgba(37, 99, 235, 0.5), inset 0 0 0 1px rgba(255,255,255,0.2)',
},
'&:active': {
transform: 'translateY(0) scale(1.01)',
},
}),
/* Pending phase: compact, subtle */
...(isPending && {
px: 1.25,
py: 0.5,
fontSize: '0.8125rem',
background: 'rgba(37, 99, 235, 0.08)',
color: '#475569',
border: '1px solid rgba(37, 99, 235, 0.15)',
'&:hover': {
transform: 'translateY(-1px)',
background: 'rgba(37, 99, 235, 0.12)',
boxShadow: '0 3px 8px rgba(37, 99, 235, 0.15)',
},
}),
};
const actionBtnSx = {
display: 'flex',
alignItems: 'center',
gap: 0.5,
px: 1.25,
py: 0.4,
borderRadius: '16px',
border: 'none',
fontSize: '0.75rem',
fontWeight: 700,
cursor: 'pointer',
transition: 'all 0.2s ease',
background: 'linear-gradient(135deg, #2563eb 0%, #3b82f6 100%)',
color: '#fff',
boxShadow: '0 2px 6px rgba(37, 99, 235, 0.3)',
textTransform: 'uppercase',
letterSpacing: '0.03em',
whiteSpace: 'nowrap',
'&:hover': {
transform: 'translateY(-1px) scale(1.03)',
boxShadow: '0 4px 12px rgba(37, 99, 235, 0.4)',
},
'&:active': {
transform: 'translateY(0) scale(1.01)',
},
};
const iconSx = {
fontSize: '14px',
lineHeight: 1,
flexShrink: 0,
};
return (
<div key={phase.id} style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<button
onClick={() => !isDisabled && onPhaseClick(phase.id)}
disabled={isDisabled}
className={chipClass}
title={phase.disabled ? `Complete ${phase.name} first` : phase.description}
<Box key={phase.id} sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Tooltip
title={
<Box>
<Box sx={{ fontWeight: 700, mb: 0.5, fontSize: '0.875rem' }}>{phase.name}</Box>
<Box sx={{ fontSize: '0.75rem', opacity: 0.9 }}>
{isDisabled
? `Complete the previous phase first to unlock ${phase.name}.`
: (PHASE_TOOLTIPS[phase.id] || phase.description)}
</Box>
</Box>
}
arrow
placement="top"
enterDelay={300}
leaveDelay={100}
>
<span className="phase-icon">
{phase.icon}
</span>
<span>{phase.name}</span>
{isCompleted && !isCurrent && (
<span className="phase-checkmark">
</span>
)}
</button>
{showAction && (
<button
onClick={(e) => {
e.stopPropagation();
action.handler?.();
}}
className="phase-action-btn"
title={`${action.label}`}
<Box
component="button"
onClick={handleChipClick}
sx={chipSx}
>
<span className="phase-action-icon"></span>
<span>{action.label}</span>
</button>
<Box component="span" sx={iconSx}>{phase.icon}</Box>
<Box component="span" sx={{ flexShrink: 0 }}>{phase.name}</Box>
{isDone && (
<Box component="span" sx={{ fontSize: '12px', flexShrink: 0, ml: 0.25 }}></Box>
)}
</Box>
</Tooltip>
{showAction && (
<Tooltip
title={`${action.label}`}
arrow
placement="top"
>
<Box
component="button"
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
action.handler?.();
}}
sx={actionBtnSx}
>
<Box component="span" sx={{ fontSize: '10px' }}></Box>
<Box component="span">{action.label}</Box>
</Box>
</Tooltip>
)}
</div>
</Box>
);
})}
</div>
{/* Circular progress indicator */}
{totalPhases > 0 && (
<Tooltip
title={`${completedCount} of ${totalPhases} phases complete (${completionPct}%)`}
arrow
placement="top"
>
<Box sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
ml: 0.5,
position: 'relative',
cursor: 'pointer',
}}>
<CircularProgress
variant="determinate"
value={completionPct}
size={26}
thickness={3}
sx={{
color: completionPct === 100 ? '#10b981' : '#2563eb',
}}
/>
<Typography
variant="caption"
sx={{
fontSize: '0.65rem',
fontWeight: 700,
color: '#64748b',
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
}}
>
{completionPct}
</Typography>
</Box>
</Tooltip>
)}
</Box>
</>
);
};

View File

@@ -12,6 +12,26 @@ interface PublisherProps {
seoMetadata: BlogSEOMetadataResponse | null;
}
const saveCompleteBlogAsset = async (
title: string,
content: string,
seoMetadata: BlogSEOMetadataResponse | null
) => {
try {
await apiClient.post('/api/blog/save-complete-asset', {
title,
content,
seo_title: seoMetadata?.seo_title,
meta_description: seoMetadata?.meta_description,
focus_keyword: seoMetadata?.focus_keyword,
tags: seoMetadata?.blog_tags || [],
categories: seoMetadata?.blog_categories || [],
});
} catch (error) {
console.error('Failed to save complete blog asset:', error);
}
};
const useCopilotActionTyped = useCopilotAction as any;
interface WixConnectionStatus {
@@ -230,7 +250,15 @@ export const Publisher: React.FC<PublisherProps> = ({
}
// We have a valid access token, proceed with publishing
return await publishToWix(md, seoMetadata, tokenResult.accessToken);
const wixResult = await publishToWix(md, seoMetadata, tokenResult.accessToken);
if (wixResult.success) {
saveCompleteBlogAsset(
seoMetadata?.seo_title || 'Blog Post',
md,
seoMetadata
);
}
return wixResult;
} else if (platform === 'wordpress') {
// WordPress publishing
if (!seoMetadata) {
@@ -284,6 +312,7 @@ export const Publisher: React.FC<PublisherProps> = ({
const result = await wordpressAPI.publishContent(publishRequest);
if (result.success) {
saveCompleteBlogAsset(title, md, seoMetadata);
return {
success: true,
url: result.post_url || `${activeSite.site_url}/?p=${result.post_id}`,

View File

@@ -1,9 +1,8 @@
import React, { useState, useRef, useEffect } from 'react';
import React, { useRef, useEffect } from 'react';
import { useCopilotAction } from '@copilotkit/react-core';
import { blogWriterApi, BlogResearchRequest, BlogResearchResponse } from '../../services/blogWriterApi';
import { useBlogWriterResearchPolling } from '../../hooks/usePolling';
import { BlogResearchResponse } from '../../services/blogWriterApi';
import { useResearchSubmit } from '../../hooks/useResearchSubmit';
import ResearchProgressModal from './ResearchProgressModal';
import { researchCache } from '../../services/researchCache';
const useCopilotActionTyped = useCopilotAction as any;
@@ -13,111 +12,50 @@ interface ResearchActionProps {
}
export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComplete, navigateToPhase }) => {
const [currentTaskId, setCurrentTaskId] = useState<string | null>(null);
const [currentMessage, setCurrentMessage] = useState<string>('');
const [showProgressModal, setShowProgressModal] = useState<boolean>(false);
const [forceUpdate, setForceUpdate] = useState<number>(0);
// Refs for form inputs (uncontrolled, avoids typing issues inside Copilot render)
const keywordsRef = useRef<HTMLInputElement | null>(null);
const blogLengthRef = useRef<HTMLSelectElement | null>(null);
// Track if we've navigated to research phase for this form display
const hasNavigatedRef = useRef<boolean>(false);
const polling = useBlogWriterResearchPolling({
onProgress: (message) => {
setCurrentMessage(message);
setForceUpdate(prev => prev + 1); // Force re-render
},
onComplete: (result) => {
console.info('[ResearchAction] ✅ Research completed (onComplete callback)', {
hasResult: !!result,
resultKeys: result ? Object.keys(result) : [],
status: polling.currentStatus
});
if (result && result.keywords) {
researchCache.cacheResult(
result.keywords,
result.industry || 'General',
result.target_audience || 'General',
result
);
}
// Reset navigation tracking when research completes
hasNavigatedRef.current = false;
// Call parent callback first
onResearchComplete?.(result);
// Close modal immediately when research completes
setCurrentTaskId(null);
setCurrentMessage('');
setShowProgressModal(false);
setForceUpdate(prev => prev + 1);
},
onError: (error) => {
console.error('Research polling error:', error);
setCurrentTaskId(null);
setCurrentMessage('');
setShowProgressModal(false);
setForceUpdate(prev => prev + 1);
}
});
const {
startResearch,
isSubmitting,
showProgressModal,
setShowProgressModal,
currentMessage,
currentStatus,
progressMessages,
error,
isPolling,
result,
} = useResearchSubmit({ onResearchComplete, navigateToPhase });
// Set of statuses that indicate successful completion
// Close modal when research completes (status becomes a completed state or polling stops with a result)
const COMPLETED_STATUSES = React.useMemo(
() => new Set(['completed', 'success', 'succeeded', 'finished']),
[]
);
// Close modal when research completes (status becomes a completed state or polling stops with a result)
useEffect(() => {
const normalizedStatus = (polling.currentStatus || '').toLowerCase();
const normalizedStatus = (currentStatus || '').toLowerCase();
const isCompleted = COMPLETED_STATUSES.has(normalizedStatus);
// Check if we have a result (indicates completion even if status isn't updated yet)
const hasResult = !!polling.result;
// Check if polling stopped and we have a result, or status indicates completion
const hasResult = !!result;
const shouldClose = showProgressModal && (
isCompleted ||
(hasResult && normalizedStatus !== 'failed') ||
(!polling.isPolling && hasResult && normalizedStatus !== 'failed')
(!isPolling && hasResult && normalizedStatus !== 'failed')
);
if (shouldClose) {
console.info('[ResearchAction] Closing modal - research completed', {
status: polling.currentStatus,
isPolling: polling.isPolling,
hasResult: hasResult,
normalizedStatus: normalizedStatus,
isCompleted: isCompleted
});
// Close modal immediately when research completes
setShowProgressModal(false);
setCurrentTaskId(null);
setCurrentMessage('');
}
}, [
COMPLETED_STATUSES,
polling.currentStatus,
polling.isPolling,
polling.result,
showProgressModal
]);
}, [COMPLETED_STATUSES, currentStatus, isPolling, result, showProgressModal, setShowProgressModal]);
useCopilotActionTyped({
name: 'showResearchForm',
description: 'Show keyword input form for blog research',
parameters: [],
handler: async () => {
// Navigate to research phase when research form is shown
// Reset navigation tracking so form render can navigate again if needed
hasNavigatedRef.current = false;
// Navigate immediately when handler is called
if (navigateToPhase) {
navigateToPhase('research');
}
@@ -128,64 +66,34 @@ export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComple
};
},
render: ({ status }: any) => {
try {
const _ = forceUpdate;
// Safely access polling state with defaults - handle case where polling might not be initialized
let currentStatus = 'idle';
let progressMessages: Array<{ timestamp: string; message: string }> = [];
try {
if (polling) {
currentStatus = polling.currentStatus || 'idle';
progressMessages = polling.progressMessages || [];
const isShowingForm = currentStatus !== 'completed' &&
currentStatus !== 'in_progress' &&
currentStatus !== 'running';
if (isShowingForm && !hasNavigatedRef.current && navigateToPhase) {
setTimeout(() => {
if (!hasNavigatedRef.current) {
navigateToPhase('research');
hasNavigatedRef.current = true;
}
} catch (pollingError) {
console.warn('[ResearchAction] Error accessing polling state in render:', pollingError);
// Use defaults already set above
}
// Navigate to research phase when form is rendered (if not already navigated and form is shown)
// This ensures phase navigation updates when CopilotKit shows the research form
// Only navigate when showing the form (not progress or completion states)
const isShowingForm = currentStatus !== 'completed' &&
currentStatus !== 'in_progress' &&
currentStatus !== 'running';
if (isShowingForm && !hasNavigatedRef.current && navigateToPhase) {
// Use setTimeout to avoid calling during render
setTimeout(() => {
if (!hasNavigatedRef.current) {
navigateToPhase('research');
hasNavigatedRef.current = true;
}
}, 0);
}
if (currentStatus === 'completed' && progressMessages.length > 0) {
const latestMessage = progressMessages[progressMessages.length - 1];
return (
<div style={{ padding: '16px', backgroundColor: '#e8f5e8', borderRadius: '8px', border: '1px solid #4caf50', margin: '8px 0' }}>
<p style={{ margin: 0, color: '#4caf50', fontWeight: '500' }}> Research completed successfully!</p>
<p style={{ margin: '8px 0 0 0', color: '#666', fontSize: '14px' }}>{latestMessage?.message || 'Research data is now available for your blog.'}</p>
</div>
);
}
if (currentStatus === 'in_progress' || currentStatus === 'running') {
return (
<div style={{ padding: '16px', backgroundColor: '#fff3e0', borderRadius: '8px', border: '1px solid #ff9800', margin: '8px 0' }}>
<p style={{ margin: 0, color: '#ff9800', fontWeight: '500' }}>🔄 Research in progress...</p>
<p style={{ margin: '8px 0 0 0', color: '#666', fontSize: '14px' }}>{currentMessage || 'Gathering research data...'}</p>
</div>
);
}
} catch (renderError) {
console.error('[ResearchAction] Error in render function:', renderError);
// Return a safe fallback UI
}, 0);
}
if (currentStatus === 'completed' && progressMessages.length > 0) {
const latestMessage = progressMessages[progressMessages.length - 1];
return (
<div style={{ padding: '16px', backgroundColor: '#f8f9fa', borderRadius: '8px', border: '1px solid #e0e0e0', margin: '8px 0' }}>
<p style={{ margin: 0, color: '#666', fontSize: '14px' }}>🔍 Research form is loading...</p>
<div style={{ padding: '16px', backgroundColor: '#e8f5e8', borderRadius: '8px', border: '1px solid #4caf50', margin: '8px 0' }}>
<p style={{ margin: 0, color: '#4caf50', fontWeight: '500' }}> Research completed successfully!</p>
<p style={{ margin: '8px 0 0 0', color: '#666', fontSize: '14px' }}>{latestMessage?.message || 'Research data is now available for your blog.'}</p>
</div>
);
}
if (currentStatus === 'in_progress' || currentStatus === 'running') {
return (
<div style={{ padding: '16px', backgroundColor: '#fff3e0', borderRadius: '8px', border: '1px solid #ff9800', margin: '8px 0' }}>
<p style={{ margin: 0, color: '#ff9800', fontWeight: '500' }}>🔄 Research in progress...</p>
<p style={{ margin: '8px 0 0 0', color: '#666', fontSize: '14px' }}>{currentMessage || 'Gathering research data...'}</p>
</div>
);
}
@@ -204,7 +112,8 @@ export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComple
id="research-keywords-input"
placeholder="e.g., artificial intelligence, machine learning, AI trends"
ref={keywordsRef}
style={{ width: '100%', padding: '12px', border: '1px solid #ddd', borderRadius: '6px', fontSize: '14px', boxSizing: 'border-box' }}
disabled={isSubmitting}
style={{ width: '100%', padding: '12px', border: '1px solid #ddd', borderRadius: '6px', fontSize: '14px', boxSizing: 'border-box', opacity: isSubmitting ? 0.6 : 1 }}
/>
</div>
@@ -214,7 +123,8 @@ export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComple
id="research-blog-length-select"
defaultValue="1000"
ref={blogLengthRef}
style={{ width: '100%', padding: '12px', border: '1px solid #ddd', borderRadius: '6px', fontSize: '14px', boxSizing: 'border-box' }}
disabled={isSubmitting}
style={{ width: '100%', padding: '12px', border: '1px solid #ddd', borderRadius: '6px', fontSize: '14px', boxSizing: 'border-box', opacity: isSubmitting ? 0.6 : 1 }}
>
<option value="500">500 words (Short blog)</option>
<option value="1000">1000 words (Medium blog)</option>
@@ -225,38 +135,20 @@ export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComple
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end' }}>
<button
onClick={async () => {
const keywords = (keywordsRef.current?.value || '').trim();
const blogLength = blogLengthRef.current?.value || '1000';
if (!keywords) return;
try {
const keywordList = keywords.includes(',') ? keywords.split(',').map(k => k.trim()).filter(Boolean) : [keywords];
const cachedResult = researchCache.getCachedResult(keywordList, 'General', 'General');
if (cachedResult) {
onResearchComplete?.(cachedResult);
setForceUpdate(prev => prev + 1);
return;
onClick={async () => {
const keywords = (keywordsRef.current?.value || '').trim();
const blogLength = blogLengthRef.current?.value || '1000';
if (!keywords) return;
try {
await startResearch(keywords, blogLength);
} catch (error) {
console.error(`Research failed: ${error}`);
}
const payload: BlogResearchRequest = {
keywords: keywordList,
industry: 'General',
target_audience: 'General',
word_count_target: parseInt(blogLength)
};
// Navigate to research phase when research starts
navigateToPhase?.('research');
const { task_id } = await blogWriterApi.startResearch(payload);
setCurrentTaskId(task_id);
setShowProgressModal(true);
polling.startPolling(task_id);
setForceUpdate(prev => prev + 1);
} catch (error) {
console.error(`Research failed: ${error}`);
}
}}
style={{ padding: '12px 24px', backgroundColor: '#1976d2', color: 'white', border: 'none', borderRadius: '6px', fontSize: '14px', fontWeight: '500', cursor: 'pointer' }}
}}
disabled={isSubmitting}
style={{ padding: '12px 24px', backgroundColor: isSubmitting ? '#ccc' : '#1976d2', color: 'white', border: 'none', borderRadius: '6px', fontSize: '14px', fontWeight: '500', cursor: isSubmitting ? 'not-allowed' : 'pointer' }}
>
🚀 Start Research
{isSubmitting ? ' Starting Research...' : '🚀 Start Research'}
</button>
</div>
</div>
@@ -264,7 +156,7 @@ export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComple
}
});
// Additional action to catch the specific suggestion message
// CopilotKit chat action: research topic with keywords
useCopilotActionTyped({
name: 'researchTopic',
description: 'Research topic with keywords and persona context using Google Search grounding',
@@ -276,25 +168,7 @@ export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComple
],
handler: async ({ keywords = '', industry = 'General', target_audience = 'General', blogLength = '1000' }: any) => {
try {
const trimmed = keywords.trim();
if (!trimmed) {
return "Please provide keywords or a topic for research.";
}
const keywordList = trimmed.includes(',')
? trimmed.split(',').map((k: string) => k.trim()).filter(Boolean)
: [trimmed];
// Navigate to research phase when research starts
navigateToPhase?.('research');
const payload: BlogResearchRequest = {
keywords: keywordList,
industry,
target_audience,
word_count_target: parseInt(blogLength)
};
const { task_id } = await blogWriterApi.startResearch(payload);
setCurrentTaskId(task_id);
setShowProgressModal(true);
polling.startPolling(task_id);
await startResearch(keywords, blogLength, industry, target_audience);
return "Starting research with your provided keywords.";
} catch (error) {
console.error('Failed to start research:', error);
@@ -303,21 +177,16 @@ export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComple
}
});
return (
<>
{showProgressModal && (
<ResearchProgressModal
open={showProgressModal}
title={"Research in progress"}
status={polling.currentStatus}
messages={polling.progressMessages}
error={polling.error}
onClose={() => {
console.info('[ResearchAction] Modal closed manually');
setShowProgressModal(false);
setCurrentTaskId(null);
}}
title="Research in progress"
status={currentStatus}
messages={progressMessages}
error={error}
onClose={() => setShowProgressModal(false)}
/>
)}
</>

View File

@@ -27,6 +27,7 @@ import {
Avatar,
CircularProgress
} from '@mui/material';
import { hashContent, getSeoCacheKey } from '../../utils/contentHash';
import { apiClient, triggerSubscriptionError } from '../../api/client';
import {
CheckCircle,
@@ -145,24 +146,7 @@ interface SEOAnalysisModalProps {
onAnalysisComplete?: (analysis: SEOAnalysisResult) => void;
}
// Simple content hashing helper (SHA-256)
async function hashContent(text: string): Promise<string> {
try {
const enc = new TextEncoder().encode(text);
const digest = await crypto.subtle.digest('SHA-256', enc);
const bytes = Array.from(new Uint8Array(digest));
return bytes.map(b => b.toString(16).padStart(2, '0')).join('');
} catch {
// Fallback hash
let h = 0;
for (let i = 0; i < text.length; i++) h = (h * 31 + text.charCodeAt(i)) | 0;
return String(h);
}
}
function getSeoCacheKey(contentHash: string, title?: string) {
return `seo_cache:${contentHash}:${title || ''}`;
}
export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
isOpen,

View File

@@ -1,20 +1,20 @@
import React, { useState, useCallback, useEffect, useRef } from 'react';
import { createTheme, ThemeProvider, Paper, IconButton, Tooltip, CircularProgress, Dialog, DialogTitle, DialogContent, DialogActions, Button, Typography, Box, Divider } from '@mui/material';
import React, { useState, useCallback, useEffect, useRef, useMemo } from 'react';
import { createTheme, ThemeProvider, Paper, IconButton, Tooltip, CircularProgress, Dialog, DialogTitle, DialogContent, DialogActions, Button, Typography, Box, Divider, TextField } from '@mui/material';
import {
AutoAwesome as AutoAwesomeIcon,
MoreHoriz as MoreHorizIcon,
} from '@mui/icons-material';
import { BlogOutlineSection, BlogResearchResponse, blogWriterApi } from '../../../services/blogWriterApi';
import BlogSection from './BlogSection';
import EditorSidebar from './EditorSidebar';
import HoverMenu from './HoverMenu';
// Helper to create a consistent theme
const theme = createTheme({
typography: {
fontFamily: '"Inter", "Roboto", "Helvetica", "Arial", sans-serif',
},
palette: {
primary: {
main: '#4f46e5',
},
primary: { main: '#4f46e5' },
},
});
@@ -48,16 +48,26 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
sectionImages = {}
}) => {
const [blogTitle, setBlogTitle] = useState(initialTitle || 'Your Amazing Blog Title');
const [introduction, setIntroduction] = useState('Click "Generate Introduction" to create a compelling opening for your blog post based on your content and research.');
const [introduction, setIntroduction] = useState('');
const [sections, setSections] = useState<any[]>([]);
// const [isTitleLoading, setIsTitleLoading] = useState(false); // Unused state
const [isIntroductionLoading, setIsIntroductionLoading] = useState(false);
const [expandedSections, setExpandedSections] = useState<Set<any>>(new Set());
const [showTitleModal, setShowTitleModal] = useState(false);
const [showIntroductionModal, setShowIntroductionModal] = useState(false);
const [generatedIntroductions, setGeneratedIntroductions] = useState<string[]>([]);
const [editingTitle, setEditingTitle] = useState(false);
const [editingIntro, setEditingIntro] = useState(false);
const [titleMenuAnchor, setTitleMenuAnchor] = useState<HTMLElement | null>(null);
const titleInputRef = useRef<HTMLInputElement>(null);
const introInputRef = useRef<HTMLInputElement>(null);
const totalWords = useMemo(() =>
sections.reduce((sum, s) => sum + (s.content?.split(/\s+/).filter(Boolean).length || 0), 0),
[sections]
);
const readingTime = useMemo(() => Math.max(1, Math.ceil(totalWords / 200)), [totalWords]);
// Initialize sections from outline or use parent sections
useEffect(() => {
if (outline && outline.length > 0) {
const initialSections = outline.map((section, index) => ({
@@ -78,53 +88,39 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
}
}, [outline, parentSections]);
// Update sections when parentSections content changes (e.g., after SEO recommendations are applied)
// This effect specifically watches for content changes in parentSections and updates the corresponding sections
// Use a ref to track the previous parentSections content to detect actual content changes
const prevParentSectionsRef = useRef<string>('');
const prevContinuityRefreshRef = useRef<number | undefined>(undefined);
useEffect(() => {
if (!parentSections || !outline || outline.length === 0) return;
// Create a stringified version of parentSections for comparison
const parentSectionsString = JSON.stringify(parentSections);
const continuityRefreshChanged = continuityRefresh !== prevContinuityRefreshRef.current;
// Update if content changed OR continuityRefresh changed (forced refresh)
if (parentSectionsString === prevParentSectionsRef.current && !continuityRefreshChanged) {
return; // No changes detected
return;
}
prevParentSectionsRef.current = parentSectionsString;
prevContinuityRefreshRef.current = continuityRefresh;
setSections(prevSections => {
// Update sections with new content from parentSections
const updatedSections = prevSections.map(section => {
// Try multiple ID formats to match sections (string, number, or stringified number)
const sectionIdStr = String(section.id);
const parentContent = parentSections[section.id] ||
parentSections[sectionIdStr] ||
parentSections[Number(section.id)];
// Update if parent has content for this section ID and it's different
if (parentContent !== undefined && parentContent !== section.content) {
console.log(`[BlogEditor] Updating section ${section.id} with new content (length: ${parentContent.length})`);
return {
...section,
content: parentContent
};
return { ...section, content: parentContent };
}
return section;
});
// Check if any sections were actually updated
const hasUpdates = updatedSections.some((section, index) =>
section.content !== prevSections[index]?.content
);
// Notify parent component of content update if changes were made
if (onContentUpdate && hasUpdates) {
onContentUpdate(updatedSections);
}
@@ -133,17 +129,41 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
});
}, [parentSections, outline, continuityRefresh, onContentUpdate]);
// Initialize title from parent when provided
useEffect(() => {
if (initialTitle && initialTitle.trim().length > 0) {
setBlogTitle(initialTitle);
}
}, [initialTitle]);
useEffect(() => {
if (editingTitle && titleInputRef.current) {
titleInputRef.current.focus();
titleInputRef.current.select();
}
}, [editingTitle]);
useEffect(() => {
if (editingIntro && introInputRef.current) {
introInputRef.current.focus();
introInputRef.current.select();
}
}, [editingIntro]);
const handleSuggestTitle = useCallback(() => {
console.log('Available titles:', { researchTitles, aiGeneratedTitles, titleOptions });
setShowTitleModal(true);
}, [researchTitles, aiGeneratedTitles, titleOptions]);
}, []);
const handleTitleAction = useCallback((action: string) => {
switch (action) {
case 'generate-titles':
case 'research-titles':
setShowTitleModal(true);
break;
case 'seo-optimize':
case 'ab-test':
break;
}
}, []);
const handleTitleSelect = useCallback((selectedTitle: string) => {
setBlogTitle(selectedTitle);
@@ -151,9 +171,7 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
}, []);
const handleGenerateIntroductions = useCallback(async () => {
if (!research || !outline.length || isIntroductionLoading) {
return;
}
if (!research || !outline.length || isIntroductionLoading) return;
setIsIntroductionLoading(true);
try {
@@ -161,7 +179,6 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
const primaryKeywords = keywordAnalysis.primary || [];
const searchIntent = keywordAnalysis.search_intent || 'informational';
// Build sections_content from current sections
const sectionsContent: Record<string, string> = {};
sections.forEach(section => {
if (section.content) {
@@ -184,7 +201,6 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
}
} catch (error) {
console.error('Failed to generate introductions:', error);
alert('Failed to generate introductions. Please try again.');
} finally {
setIsIntroductionLoading(false);
}
@@ -198,75 +214,107 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
const toggleSectionExpansion = useCallback((sectionId: any) => {
setExpandedSections(prev => {
const newSet = new Set(prev);
if (newSet.has(sectionId)) {
newSet.delete(sectionId);
} else {
newSet.add(sectionId);
}
if (newSet.has(sectionId)) newSet.delete(sectionId);
else newSet.add(sectionId);
return newSet;
});
}, []);
// Main Render - Exactly like your example
return (
<ThemeProvider theme={theme}>
<div className="bg-gray-50 min-h-screen font-sans">
<main className="w-full px-4 sm:px-6 lg:px-8 py-8">
<div className="w-full max-w-4xl mx-auto">
<Paper elevation={0} className="bg-white p-8 md:p-12 rounded-xl border border-gray-200/80 w-full">
<div className="mb-8 pb-6 border-b">
<div className="min-h-screen bg-white">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="flex gap-8">
{/* Main editor column */}
<div className="flex-1 min-w-0 max-w-4xl">
<Paper elevation={0} className="bg-white p-8 md:p-10 rounded-xl border border-gray-200/60">
{/* Title */}
<div className="mb-6 pb-6 border-b border-gray-100">
<div className="flex items-start gap-2 group">
<h1
className="flex-1 text-2xl md:text-4xl font-bold font-serif text-gray-900 leading-tight cursor-pointer hover:bg-gray-50 p-2 rounded-md transition-colors duration-200"
style={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
lineHeight: '1.3'
}}
onClick={() => {
const newTitle = prompt('Edit blog title:', blogTitle);
if (newTitle !== null) {
setBlogTitle(newTitle);
}
}}
title="Click to edit title"
>
{blogTitle}
</h1>
<div className="opacity-0 group-hover:opacity-100 transition-opacity duration-300 mt-1">
<Tooltip title="✨ ALwrity it">
{/* isTitleLoading is currently unused but kept for future implementation */}
{editingTitle ? (
<TextField
inputRef={titleInputRef}
fullWidth
variant="standard"
value={blogTitle}
onChange={(e) => setBlogTitle(e.target.value)}
onBlur={() => setEditingTitle(false)}
onKeyDown={(e) => {
if (e.key === 'Enter') setEditingTitle(false);
if (e.key === 'Escape') setEditingTitle(false);
}}
InputProps={{
disableUnderline: true,
className: 'text-2xl md:text-4xl font-bold font-serif text-gray-900 leading-tight truncate min-w-0',
}}
/>
) : (
<h1
className="flex-1 min-w-0 text-2xl md:text-4xl font-bold font-serif text-gray-900 leading-tight cursor-text hover:bg-gray-50/50 px-2 -ml-2 py-1 rounded transition-colors duration-150 truncate"
onClick={() => setEditingTitle(true)}
>
{blogTitle}
</h1>
)}
<div className="opacity-0 group-hover:opacity-100 transition-opacity duration-200 mt-1 shrink-0 flex items-center gap-1">
<Tooltip title="Title actions">
<IconButton size="small" onClick={(e) => setTitleMenuAnchor(e.currentTarget)}>
<MoreHorizIcon className="text-gray-400" fontSize="small"/>
</IconButton>
</Tooltip>
<Tooltip title="Choose from AI titles">
<IconButton onClick={handleSuggestTitle} size="small">
<AutoAwesomeIcon className="text-purple-500" fontSize="small"/>
</IconButton>
</Tooltip>
</div>
<HoverMenu
anchorEl={titleMenuAnchor}
open={Boolean(titleMenuAnchor)}
onClose={() => setTitleMenuAnchor(null)}
type="title"
onAction={handleTitleAction}
/>
</div>
<div className="mt-3 group/intro">
{/* Introduction */}
<div className="mt-4 group/intro">
<div className="flex items-start gap-2">
<p
className="flex-1 text-gray-600 text-sm leading-relaxed cursor-pointer hover:bg-gray-50 p-2 rounded-md transition-colors duration-200"
onClick={() => {
const newIntro = prompt('Edit introduction:', introduction);
if (newIntro !== null && newIntro.trim()) {
setIntroduction(newIntro.trim());
}
}}
title="Click to edit introduction"
>
{introduction}
</p>
<div className="opacity-0 group-hover/intro:opacity-100 transition-opacity duration-300">
<Tooltip title="✨ Generate Introduction">
<IconButton
onClick={handleGenerateIntroductions}
disabled={isIntroductionLoading || !research || !outline.length}
{editingIntro ? (
<TextField
inputRef={introInputRef}
fullWidth
variant="standard"
multiline
minRows={2}
value={introduction}
onChange={(e) => setIntroduction(e.target.value)}
onBlur={() => setEditingIntro(false)}
placeholder="Write an engaging introduction..."
InputProps={{
disableUnderline: true,
className: 'text-base text-gray-600 leading-relaxed',
}}
/>
) : (
<p
className={`flex-1 text-base leading-relaxed cursor-text hover:bg-gray-50/50 px-2 -ml-2 py-1 rounded transition-colors duration-150 ${
introduction ? 'text-gray-600' : 'text-gray-400'
}`}
onClick={() => setEditingIntro(true)}
>
{introduction || 'Click to write your introduction...'}
</p>
)}
<div className="opacity-0 group-hover/intro:opacity-100 transition-opacity duration-200 shrink-0">
<Tooltip title="Generate Introduction">
<IconButton
onClick={handleGenerateIntroductions}
disabled={isIntroductionLoading || !research || !outline.length}
size="small"
>
{isIntroductionLoading ? (
<CircularProgress size={20} />
<CircularProgress size={18} />
) : (
<AutoAwesomeIcon className="text-blue-500" fontSize="small"/>
)}
@@ -275,11 +323,11 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
</div>
</div>
</div>
<Divider sx={{ mt: 3, opacity: 0.3 }} />
</div>
<div>
{/* Sections */}
<div className="space-y-1">
{sections.map((section, index) => {
// Robust image mapping: prefer outline index id (order is consistent across phases)
const imageIdByIndex = outline[index]?.id;
const outlineSection = outline.find(s => (s.id === section.id) || (s.heading === section.title));
const imageId = imageIdByIndex || outlineSection?.id || section.id;
@@ -298,17 +346,46 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
);
})}
</div>
</Paper>
{/* Stats bar */}
<div className="mt-8 pt-4 border-t border-gray-100">
<div className="flex items-center justify-between text-sm text-gray-400">
<div className="flex items-center gap-4">
<span>{sections.length} {sections.length === 1 ? 'section' : 'sections'}</span>
<span className="text-gray-300">|</span>
<span>{totalWords.toLocaleString()} words</span>
<span className="text-gray-300">|</span>
<span>{readingTime} min read</span>
</div>
<div className="flex items-center gap-2">
<div className="w-32 h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full bg-indigo-500 rounded-full transition-all duration-300"
style={{ width: `${Math.min(100, (totalWords / Math.max(1, sections.reduce((s, sec) => s + (sec.outlineData?.targetWords || 500), 0))) * 100)}%` }}
/>
</div>
<span className="text-xs text-gray-400">
{totalWords > 0
? `${Math.round(Math.min(100, (totalWords / Math.max(1, sections.reduce((s, sec) => s + (sec.outlineData?.targetWords || 500), 0))) * 100))}%`
: '0%'}
</span>
</div>
</div>
</div>
</Paper>
</div>
{/* Sidebar */}
<div className="hidden lg:block w-72 shrink-0">
<div className="sticky top-6">
<EditorSidebar sections={sections} totalWords={totalWords} />
</div>
</div>
</div>
</main>
</div>
{/* Title Selection Modal */}
<Dialog
open={showTitleModal}
onClose={() => setShowTitleModal(false)}
maxWidth="md"
fullWidth
>
<Dialog open={showTitleModal} onClose={() => setShowTitleModal(false)} maxWidth="md" fullWidth>
<DialogTitle>
<Typography variant="h6" component="div" sx={{ fontWeight: 'bold' }}>
Choose Your Blog Title
@@ -316,11 +393,10 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
</DialogTitle>
<DialogContent>
<Box sx={{ mt: 2 }}>
{/* Research Titles */}
{researchTitles.length > 0 && (
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold', mb: 2, color: 'primary.main' }}>
📊 Research-Based Titles
Research-Based Titles
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{researchTitles.map((title, index) => (
@@ -329,17 +405,7 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
variant="outlined"
fullWidth
onClick={() => handleTitleSelect(title)}
sx={{
justifyContent: 'flex-start',
textAlign: 'left',
textTransform: 'none',
py: 1.5,
px: 2,
'&:hover': {
backgroundColor: 'primary.light',
color: 'white',
}
}}
sx={{ justifyContent: 'flex-start', textAlign: 'left', textTransform: 'none', py: 1.5, px: 2 }}
>
{title}
</Button>
@@ -347,12 +413,10 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
</Box>
</Box>
)}
{/* AI Generated Titles */}
{aiGeneratedTitles.length > 0 && (
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold', mb: 2, color: 'secondary.main' }}>
🤖 AI Generated Titles
AI Generated Titles
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{aiGeneratedTitles.map((title, index) => (
@@ -361,17 +425,7 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
variant="outlined"
fullWidth
onClick={() => handleTitleSelect(title)}
sx={{
justifyContent: 'flex-start',
textAlign: 'left',
textTransform: 'none',
py: 1.5,
px: 2,
'&:hover': {
backgroundColor: 'secondary.light',
color: 'white',
}
}}
sx={{ justifyContent: 'flex-start', textAlign: 'left', textTransform: 'none', py: 1.5, px: 2 }}
>
{title}
</Button>
@@ -379,12 +433,10 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
</Box>
</Box>
)}
{/* Title Options */}
{titleOptions.length > 0 && (
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold', mb: 2, color: 'success.main' }}>
Additional Options
Additional Options
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{titleOptions.map((title, index) => (
@@ -393,17 +445,7 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
variant="outlined"
fullWidth
onClick={() => handleTitleSelect(title)}
sx={{
justifyContent: 'flex-start',
textAlign: 'left',
textTransform: 'none',
py: 1.5,
px: 2,
'&:hover': {
backgroundColor: 'success.light',
color: 'white',
}
}}
sx={{ justifyContent: 'flex-start', textAlign: 'left', textTransform: 'none', py: 1.5, px: 2 }}
>
{title}
</Button>
@@ -411,82 +453,48 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
</Box>
</Box>
)}
{researchTitles.length === 0 && aiGeneratedTitles.length === 0 && titleOptions.length === 0 && (
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center', py: 4 }}>
No title options available. Please generate an outline first.
</Typography>
)}
{/* Debug info */}
{process.env.NODE_ENV === 'development' && (
<Box sx={{ mt: 2, p: 2, bgcolor: 'grey.100', borderRadius: 1 }}>
<Typography variant="caption" color="text.secondary">
Debug: Research titles: {researchTitles.length}, AI titles: {aiGeneratedTitles.length}, Options: {titleOptions.length}
</Typography>
</Box>
)}
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setShowTitleModal(false)}>
Cancel
</Button>
<Button onClick={() => setShowTitleModal(false)}>Cancel</Button>
</DialogActions>
</Dialog>
{/* Introduction Selection Modal */}
<Dialog
open={showIntroductionModal}
onClose={() => setShowIntroductionModal(false)}
maxWidth="md"
fullWidth
>
<Dialog open={showIntroductionModal} onClose={() => setShowIntroductionModal(false)} maxWidth="md" fullWidth>
<DialogTitle>
<Typography variant="h6" component="div" sx={{ fontWeight: 'bold' }}>
Choose Your Blog Introduction
</Typography>
<Typography variant="body2" sx={{ mt: 1, color: 'text.secondary' }}>
Select one of the AI-generated introductions below. Each offers a different approach to hooking your readers.
Select one of the AI-generated introductions below.
</Typography>
</DialogTitle>
<DialogContent>
<Box sx={{ mt: 2 }}>
{generatedIntroductions.map((intro, index) => (
<Box
key={index}
sx={{
mb: 3,
p: 2,
<Box
key={index}
sx={{
mb: 3, p: 2,
border: '1px solid',
borderColor: index === 0 ? 'primary.main' : index === 1 ? 'secondary.main' : 'success.main',
borderRadius: 2,
'&:hover': {
backgroundColor: 'action.hover',
},
cursor: 'pointer',
transition: 'all 0.2s ease'
transition: 'all 0.2s ease',
'&:hover': { backgroundColor: 'action.hover' },
}}
onClick={() => handleIntroductionSelect(intro)}
>
<Typography
variant="subtitle2"
sx={{
fontWeight: 'bold',
mb: 1,
color: index === 0 ? 'primary.main' : index === 1 ? 'secondary.main' : 'success.main'
}}
>
{index === 0 ? '📌 Option 1: Problem-Focused' : index === 1 ? '✨ Option 2: Benefit-Focused' : '📊 Option 3: Story/Statistic-Focused'}
<Typography variant="subtitle2" sx={{ fontWeight: 'bold', mb: 1, color: index === 0 ? 'primary.main' : index === 1 ? 'secondary.main' : 'success.main' }}>
{index === 0 ? 'Problem-Focused' : index === 1 ? 'Benefit-Focused' : 'Story/Statistic-Focused'}
</Typography>
<Typography
variant="body1"
sx={{
color: 'text.primary',
lineHeight: 1.7,
whiteSpace: 'pre-wrap'
}}
>
<Typography variant="body1" sx={{ color: 'text.primary', lineHeight: 1.7, whiteSpace: 'pre-wrap' }}>
{intro}
</Typography>
</Box>
@@ -494,9 +502,7 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setShowIntroductionModal(false)}>
Cancel
</Button>
<Button onClick={() => setShowIntroductionModal(false)}>Cancel</Button>
</DialogActions>
</Dialog>
</div>
@@ -504,4 +510,4 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
);
};
export default BlogEditor;
export default BlogEditor;

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef } from 'react';
import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import {
Paper,
IconButton,
@@ -6,17 +6,7 @@ import {
TextField,
Tooltip,
CircularProgress,
Divider,
Button,
Menu,
MenuItem,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
List,
ListItem,
ListItemText
Divider
} from '@mui/material';
import {
Edit as EditIcon,
@@ -24,12 +14,12 @@ import {
FileCopyOutlined as FileCopyOutlinedIcon,
Link as LinkIcon,
AutoAwesome as AutoAwesomeIcon,
Info as InfoIcon,
ExpandMore as ExpandMoreIcon,
ExpandLess as ExpandLessIcon,
MoreHoriz as MoreHorizIcon,
} from '@mui/icons-material';
import useBlogTextSelectionHandler from './BlogTextSelectionHandler';
import { ContinuityBadge } from '../ContinuityBadge';
import HoverMenu from './HoverMenu';
import { blogWriterApi } from '../../../services/blogWriterApi';
interface BlogSectionProps {
@@ -57,7 +47,6 @@ const BlogSection: React.FC<BlogSectionProps> = ({
id,
title,
content: initialContent,
wordCount,
sources,
outlineData,
onContentUpdate,
@@ -80,205 +69,142 @@ const BlogSection: React.FC<BlogSectionProps> = ({
const [toolResult, setToolResult] = useState<any>(null);
const [toolDialogOpen, setToolDialogOpen] = useState(false);
// Initialize assistive writing handler
const wordCount_ = useMemo(() => content.split(/\s+/).filter(Boolean).length, [content]);
const assistiveWriting = useBlogTextSelectionHandler(
contentRef,
(originalText: string, newText: string, editType: string) => {
// Handle text replacement in the textarea
if (contentRef.current) {
const textarea = contentRef.current;
// For smart suggestions, newText is already the complete updated content with insertion
// For other edits (like text selection improvements), we need to replace originalText with newText
let updatedContent: string;
if (editType === 'smart-suggestion') {
// newText already contains the full content with suggestion inserted
updatedContent = newText;
} else {
// For other edits, replace the selected text
const currentContent = textarea.value;
updatedContent = currentContent.replace(originalText, newText);
updatedContent = textarea.value.replace(originalText, newText);
}
console.log('🔍 [BlogSection] Text updated, editType:', editType, 'New length:', updatedContent.length);
setContent(updatedContent);
// Update parent state
if (onContentUpdate) {
onContentUpdate([{ id, content: updatedContent }]);
}
// Note: Cursor positioning is handled by SmartTypingAssist for smart-suggestion edits
// For other edits, we may need to handle cursor positioning here if needed
if (onContentUpdate) onContentUpdate([{ id, content: updatedContent }]);
}
}
);
// Format content helper - ensures proper paragraph breaks
const formatContent = (rawContent: string) => {
if (!rawContent) return rawContent;
// Ensure double line breaks between paragraphs
// Replace single line breaks with double line breaks if they're not already double
let formatted = rawContent
.replace(/\n{3,}/g, '\n\n') // Replace 3+ line breaks with double
.replace(/\n(?!\n)/g, '\n\n') // Replace single line breaks with double
.trim();
return formatted;
return rawContent.replace(/\n{3,}/g, '\n\n').replace(/\n(?!\n)/g, '\n\n').trim();
};
// Sync content when initialContent changes (e.g., from AI generation)
useEffect(() => {
if (initialContent !== content) {
const formattedContent = formatContent(initialContent);
setContent(formattedContent);
setContent(formatContent(initialContent));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialContent]);
const handleContentChange = (e: any) => {
const newContent = e.target.value;
console.log('🔍 [BlogSection] handleContentChange called, content length:', newContent.length);
setContent(newContent);
// Trigger smart typing assist
assistiveWriting.handleTypingChange(newContent);
};
const handleFocus = () => setIsFocused(true);
const handleBlur = () => setIsFocused(false);
const openToolsMenu = (event: React.MouseEvent<HTMLElement>) => {
event.preventDefault();
event.stopPropagation();
setToolsAnchorEl(event.currentTarget);
};
const closeToolsMenu = () => {
setToolsAnchorEl(null);
};
const closeToolDialog = () => {
setToolDialogOpen(false);
setToolLoading(false);
};
const runSectionTool = async (tool: 'originality' | 'optimize' | 'fact' | 'links' | 'flow') => {
closeToolsMenu();
const runSectionTool = useCallback(async (tool: 'originality' | 'optimize' | 'fact' | 'links' | 'flow') => {
setActiveTool(tool);
setToolResult(null);
setToolLoading(true);
setToolDialogOpen(true);
try {
let res;
if (tool === 'originality') {
const res = await blogWriterApi.sectionOriginalityTools({
section_id: String(id),
title: sectionTitle,
content
res = await blogWriterApi.sectionOriginalityTools({ section_id: String(id), title: sectionTitle, content });
} else if (tool === 'links') {
res = await blogWriterApi.sectionInternalLinkTools({ section_id: String(id), title: sectionTitle, content });
} else if (tool === 'fact') {
res = await blogWriterApi.sectionFactCheckTools({ section_id: String(id), title: sectionTitle, content });
} else if (tool === 'optimize') {
res = await blogWriterApi.sectionOptimizeTools({
section_id: String(id), title: sectionTitle, content,
keywords: outlineData?.keywords || [], goal: 'readability'
});
setToolResult(res);
return;
}
if (tool === 'links') {
const res = await blogWriterApi.sectionInternalLinkTools({
section_id: String(id),
title: sectionTitle,
content
});
setToolResult(res);
return;
}
if (tool === 'fact') {
const res = await blogWriterApi.sectionFactCheckTools({
section_id: String(id),
title: sectionTitle,
content
});
setToolResult(res);
return;
}
if (tool === 'optimize') {
const res = await blogWriterApi.sectionOptimizeTools({
section_id: String(id),
title: sectionTitle,
content,
keywords: outlineData?.keywords || [],
goal: 'readability'
});
setToolResult(res);
return;
}
if (tool === 'flow') {
const res = await blogWriterApi.analyzeFlowAdvanced({
} else if (tool === 'flow') {
res = await blogWriterApi.analyzeFlowAdvanced({
title: sectionTitle,
sections: [{ id: String(id), heading: sectionTitle, content }]
});
setToolResult(res);
return;
}
setToolResult(res);
} catch (error: any) {
setToolResult({ success: false, error: error?.message || 'Request failed' });
} finally {
setToolLoading(false);
}
};
}, [id, sectionTitle, content, outlineData]);
const applyOptimizedContent = () => {
const next = toolResult?.optimized_content;
if (!next) return;
setContent(next);
if (onContentUpdate) {
onContentUpdate([{ id, content: next }]);
}
if (onContentUpdate) onContentUpdate([{ id, content: next }]);
closeToolDialog();
};
const insertLinkSuggestion = (url: string) => {
if (!url) return;
const insertion = `\n\n[Related](${url})`;
const next = `${content || ''}${insertion}`;
const next = `${content || ''}\n\n[Related](${url})`;
setContent(next);
if (onContentUpdate) {
onContentUpdate([{ id, content: next }]);
}
if (onContentUpdate) onContentUpdate([{ id, content: next }]);
};
const handleGenerateContent = async () => {
setIsGenerating(true);
try {
// This would call your AI service for content generation
await new Promise(resolve => setTimeout(resolve, 2000));
const generated = `This is AI-generated content for "${sectionTitle}" with engaging, well-structured paragraphs grounded in your research.`;
setContent(generated);
// Update parent state if needed
if (onContentUpdate) {
onContentUpdate([{ id, content: generated }]);
}
} catch (error) {
console.error('Failed to generate content:', error);
} finally {
setIsGenerating(false);
}
await new Promise(resolve => setTimeout(resolve, 2000));
setIsGenerating(false);
};
// HoverMenu action handler
const handleSectionAction = useCallback((action: string) => {
switch (action) {
case 'generate-content':
handleGenerateContent();
break;
case 'enhance-section':
runSectionTool('optimize');
break;
case 'fact-check':
runSectionTool('fact');
break;
case 'source-mapping':
runSectionTool('originality');
break;
case 'seo-analysis':
runSectionTool('flow');
break;
case 'add-subsection':
break;
case 'copy-section':
break;
case 'delete-section':
break;
default:
break;
}
}, []);
return (
<div
className="group relative mb-6"
className="group relative mb-8"
id={`section-${id}`}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div className="flex items-center gap-3 mb-4">
<span className="text-xs font-medium text-gray-300 select-none">{id}.</span>
{isEditing ? (
<TextField
fullWidth
@@ -286,182 +212,117 @@ const BlogSection: React.FC<BlogSectionProps> = ({
value={sectionTitle}
onChange={(e) => setSectionTitle(e.target.value)}
onBlur={() => setIsEditing(false)}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === 'Escape') setIsEditing(false); }}
autoFocus
InputProps={{ className: 'text-2xl md:text-3xl font-bold !font-serif text-gray-800' }}
InputProps={{ disableUnderline: true, className: 'text-xl md:text-2xl font-bold font-serif text-gray-800' }}
/>
) : (
<h2
className="text-2xl md:text-3xl font-bold font-serif text-gray-800 cursor-pointer"
className="flex-1 text-xl md:text-2xl font-bold font-serif text-gray-800 cursor-text hover:text-indigo-600 transition-colors duration-150"
onClick={() => setIsEditing(true)}
>
{sectionTitle}
</h2>
)}
</div>
{/* Section Image Display */}
{sectionImage && (
<div style={{ marginBottom: '16px', marginTop: '8px' }}>
<div style={{
border: '1px solid #e0e0e0',
borderRadius: '8px',
overflow: 'hidden',
maxWidth: '100%',
backgroundColor: '#fff'
}}>
<div className="mb-4">
<div className="rounded-lg overflow-hidden border border-gray-100 bg-white">
<img
src={`data:image/png;base64,${sectionImage}`}
alt={`Cover image for ${sectionTitle}`}
style={{
width: '100%',
height: 'auto',
display: 'block',
maxHeight: '400px',
objectFit: 'contain'
}}
className="w-full h-auto max-h-96 object-contain"
/>
</div>
</div>
)}
{isGenerating ? (
<div className="flex items-center justify-center p-8 bg-gray-50 rounded-lg animate-pulse">
<span className="text-gray-500 font-medium">Generating content based on research...</span>
</div>
) : (
<div className="relative">
{/* Image Placeholder */}
{outlineData?.keywords && outlineData.keywords.length > 0 && (
<div className="absolute -right-4 top-0 opacity-0 group-hover:opacity-100 transition-opacity">
<Tooltip title="Section image coming soon">
<IconButton size="small">
<img
src={`https://source.unsplash.com/random/800x600?${outlineData.keywords[0]}`}
alt=""
className="w-8 h-8 rounded object-cover"
/>
</IconButton>
</Tooltip>
</div>
)}
<TextField
multiline
fullWidth
variant="outlined"
placeholder="Start writing or use AI to generate content..."
value={content}
onChange={handleContentChange}
onFocus={handleFocus}
onBlur={handleBlur}
minRows={6}
InputProps={{
className: `font-serif text-lg leading-relaxed text-gray-700 p-0 border-none ${isFocused ? 'bg-white' : 'bg-transparent'} transition-colors duration-200`,
style: { lineHeight: '1.8' }
}}
sx={{
'& .MuiOutlinedInput-notchedOutline': { border: 'none' },
'& .MuiOutlinedInput-root': { padding: 0 }
}}
/>
</div>
)}
{isGenerating ? (
<div className="flex items-center gap-3 p-6 bg-indigo-50/50 rounded-lg border border-indigo-100/50 mb-3">
<CircularProgress size={20} className="text-indigo-400" />
<span className="text-sm text-indigo-600 font-medium">Generating content...</span>
</div>
) : (
<div className="relative">
<TextField
multiline
fullWidth
variant="outlined"
placeholder="Start writing..."
value={content}
onChange={handleContentChange}
onFocus={handleFocus}
onBlur={handleBlur}
onSelect={assistiveWriting.handleTextSelection}
inputRef={contentRef}
minRows={5}
InputProps={{
className: `font-serif text-base leading-relaxed text-gray-700 p-0 ${isFocused ? 'bg-white' : 'bg-transparent'}`,
style: { lineHeight: '1.8' }
}}
sx={{
'& .MuiOutlinedInput-notchedOutline': { border: 'none' },
'& .MuiOutlinedInput-root': { padding: 0 },
'& .MuiOutlinedInput-root:hover .MuiOutlinedInput-notchedOutline': { border: 'none' },
'& .MuiOutlinedInput-root.Mui-focused .MuiOutlinedInput-notchedOutline': { border: 'none' },
}}
/>
</div>
)}
{/* Outline Information Section */}
{/* Outline info */}
{outlineData && expandedSections.has(id) && (
<div className="mt-4">
<Paper elevation={0} sx={{ p: 2, bgcolor: '#f8f9fa', borderRadius: 2, mb: 2 }}>
<div className="flex flex-col gap-4">
{/* Key Points */}
{outlineData.keyPoints && outlineData.keyPoints.length > 0 && (
<div className="mt-3 mb-2">
<Paper elevation={0} sx={{ p: 3, bgcolor: '#f8f9fa', borderRadius: 2, border: '1px solid #f0f0f0' }}>
<div className="grid grid-cols-2 gap-4">
{outlineData.keyPoints?.length > 0 && (
<div>
<div className="text-sm font-bold text-blue-600 mb-2">Key Points:</div>
<div className="flex flex-wrap gap-1">
{outlineData.keyPoints.map((point: any, index: any) => (
<Chip
key={index}
label={point}
size="small"
variant="outlined"
sx={{ fontSize: '0.75rem' }}
/>
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">Key Points</div>
<div className="flex flex-wrap gap-1.5">
{outlineData.keyPoints.map((point: any, i: any) => (
<Chip key={i} label={point} size="small" variant="outlined" sx={{ fontSize: '0.7rem', height: 24 }} />
))}
</div>
</div>
)}
{/* Subheadings */}
{outlineData.subheadings && outlineData.subheadings.length > 0 && (
{outlineData.subheadings?.length > 0 && (
<div>
<div className="text-sm font-bold text-blue-600 mb-2">Subheadings:</div>
<div className="flex flex-wrap gap-1">
{outlineData.subheadings.map((subheading: any, index: any) => (
<Chip
key={index}
label={subheading}
size="small"
variant="outlined"
color="secondary"
sx={{ fontSize: '0.75rem' }}
/>
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">Subheadings</div>
<div className="flex flex-wrap gap-1.5">
{outlineData.subheadings.map((sub: any, i: any) => (
<Chip key={i} label={sub} size="small" variant="outlined" color="secondary" sx={{ fontSize: '0.7rem', height: 24 }} />
))}
</div>
</div>
)}
{/* Keywords */}
{outlineData.keywords && outlineData.keywords.length > 0 && (
<div>
<div className="text-sm font-bold text-blue-600 mb-2">Keywords:</div>
<div className="flex flex-wrap gap-1">
{outlineData.keywords.map((keyword: any, index: any) => (
<Chip
key={index}
label={keyword}
size="small"
variant="filled"
color="primary"
sx={{ fontSize: '0.75rem' }}
/>
))}
</div>
</div>
)}
{/* Target Words */}
{outlineData.targetWords > 0 && (
<div>
<div className="text-sm font-bold text-blue-600 mb-2">
Target Words: {outlineData.targetWords}
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-1">Target words</div>
<div className="text-sm text-gray-700">{outlineData.targetWords}</div>
</div>
)}
{outlineData.keywords?.length > 0 && (
<div>
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">Keywords</div>
<div className="flex flex-wrap gap-1.5">
{outlineData.keywords.map((kw: any, i: any) => (
<Chip key={i} label={kw} size="small" variant="filled" color="primary" sx={{ fontSize: '0.7rem', height: 24 }} />
))}
</div>
</div>
)}
{/* References */}
{outlineData.references && outlineData.references.length > 0 && (
<div>
<div className="text-sm font-bold text-blue-600 mb-2">
References ({outlineData.references.length}):
{outlineData.references?.length > 0 && (
<div className="col-span-2">
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">
References ({outlineData.references.length})
</div>
<div className="flex flex-wrap gap-1">
{outlineData.references.slice(0, 3).map((ref: any, index: any) => (
<Chip
key={index}
label={ref.title || `Source ${index + 1}`}
size="small"
variant="outlined"
color="info"
sx={{ fontSize: '0.75rem' }}
/>
<div className="flex flex-wrap gap-1.5">
{outlineData.references.slice(0, 3).map((ref: any, i: any) => (
<Chip key={i} label={ref.title || `Source ${i + 1}`} size="small" variant="outlined" color="info" sx={{ fontSize: '0.7rem', height: 24 }} />
))}
{outlineData.references.length > 3 && (
<Chip
label={`+${outlineData.references.length - 3} more`}
size="small"
variant="outlined"
sx={{ fontSize: '0.75rem' }}
/>
<Chip label={`+${outlineData.references.length - 3} more`} size="small" variant="outlined" sx={{ fontSize: '0.7rem', height: 24 }} />
)}
</div>
</div>
@@ -471,180 +332,156 @@ const BlogSection: React.FC<BlogSectionProps> = ({
</div>
)}
<div className="absolute -bottom-4 right-0 flex items-center space-x-1" style={{ opacity: isHovered ? 1 : 0, transition: 'opacity 0.3s' }}>
<Chip label={`${content.split(' ').length} words`} size="small" variant="outlined" className="!text-gray-500" />
<Chip icon={<LinkIcon />} label={`${sources} sources`} size="small" variant="outlined" className="!text-gray-500" />
{outlineData && (
<Chip
icon={expandedSections.has(id) ? <ExpandLessIcon /> : <ExpandMoreIcon />}
label="Outline Info"
size="small"
variant="outlined"
clickable
onClick={() => toggleSectionExpansion(id)}
sx={{
fontSize: '0.75rem',
'&:hover': {
backgroundColor: 'rgba(25, 118, 210, 0.08)',
}
}}
/>
)}
<Tooltip title="Generate Content">
<IconButton size="small" onClick={handleGenerateContent}>
<AutoAwesomeIcon fontSize="small" />
{/* Bottom toolbar */}
<div className="flex items-center justify-between mt-2">
<div className="flex items-center gap-2">
<span className="text-xs text-gray-400">{wordCount_} words</span>
{outlineData?.targetWords && outlineData.targetWords > 0 && (
<>
<span className="text-gray-300 text-xs">/</span>
<span className="text-xs text-gray-400">{outlineData.targetWords} target</span>
</>
)}
</div>
<div className="flex items-center gap-0.5" style={{ opacity: isHovered ? 1 : 0, transition: 'opacity 0.2s' }}>
{outlineData && (
<Tooltip title={expandedSections.has(id) ? 'Hide outline info' : 'Show outline info'}>
<IconButton size="small" onClick={() => toggleSectionExpansion(id)} sx={{ width: 28, height: 28 }}>
{expandedSections.has(id) ? <ExpandLessIcon sx={{ fontSize: 16 }} /> : <ExpandMoreIcon sx={{ fontSize: 16 }} />}
</IconButton>
</Tooltip>
)}
<Tooltip title="Section actions">
<IconButton size="small" onClick={(e) => setToolsAnchorEl(e.currentTarget)} sx={{ width: 28, height: 28 }}>
<MoreHorizIcon sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
<IconButton size="small" sx={{ width: 28, height: 28 }}>
<FileCopyOutlinedIcon sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
{/* Flow Analysis Badge - Enabled when flow analysis results are available */}
<ContinuityBadge
sectionId={id}
refreshToken={refreshToken}
disabled={!flowAnalysisResults}
flowAnalysisResults={flowAnalysisResults}
/>
<Tooltip title="Section Tools">
<IconButton size="small" onClick={openToolsMenu}>
<InfoIcon fontSize="small" />
<IconButton size="small" sx={{ width: 28, height: 28 }}>
<DeleteOutlineIcon sx={{ fontSize: 16, color: '#9ca3af' }} />
</IconButton>
</Tooltip>
<Tooltip title="Copy Section"><IconButton size="small"><FileCopyOutlinedIcon fontSize="small" /></IconButton></Tooltip>
<Tooltip title="Edit Metadata"><IconButton size="small"><EditIcon fontSize="small" /></IconButton></Tooltip>
<Tooltip title="Delete Section"><IconButton size="small" className="text-red-500"><DeleteOutlineIcon fontSize="small" /></IconButton></Tooltip>
</div>
</div>
<Menu
{/* HoverMenu for section-level actions */}
<HoverMenu
anchorEl={toolsAnchorEl}
open={Boolean(toolsAnchorEl)}
onClose={closeToolsMenu}
>
<MenuItem onClick={() => runSectionTool('originality')}>Originality Check</MenuItem>
<MenuItem onClick={() => runSectionTool('optimize')}>Optimize Section</MenuItem>
<MenuItem onClick={() => runSectionTool('fact')}>SIF Fact Check</MenuItem>
<MenuItem onClick={() => runSectionTool('links')}>Internal Link Suggestions</MenuItem>
<MenuItem onClick={() => runSectionTool('flow')}>Flow Analysis</MenuItem>
</Menu>
onClose={() => setToolsAnchorEl(null)}
type="section"
onAction={handleSectionAction}
context={{
sectionId: String(id),
hasContent: content.trim().length > 0,
sources,
wordCount: wordCount_,
}}
/>
<Dialog open={toolDialogOpen} onClose={closeToolDialog} fullWidth maxWidth="md">
<DialogTitle>
{activeTool === 'originality' && 'Originality Check'}
{activeTool === 'optimize' && 'Optimize Section'}
{activeTool === 'fact' && 'SIF Fact Check'}
{activeTool === 'links' && 'Internal Link Suggestions'}
{activeTool === 'flow' && 'Flow Analysis'}
</DialogTitle>
<DialogContent dividers>
{toolLoading && (
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<CircularProgress size={18} />
<div>Working</div>
{/* Tool result dialog */}
{toolDialogOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/20" onClick={closeToolDialog}>
<div className="bg-white rounded-xl shadow-xl max-w-2xl w-full mx-4 max-h-[80vh] overflow-hidden flex flex-col" onClick={e => e.stopPropagation()}>
<div className="px-6 py-4 border-b border-gray-100">
<h3 className="text-lg font-semibold text-gray-800">
{activeTool === 'originality' && 'Originality Check'}
{activeTool === 'optimize' && 'Optimize Section'}
{activeTool === 'fact' && 'SIF Fact Check'}
{activeTool === 'links' && 'Internal Link Suggestions'}
{activeTool === 'flow' && 'Flow Analysis'}
</h3>
</div>
)}
{!toolLoading && toolResult?.error && (
<div style={{ color: '#b91c1c', fontWeight: 600 }}>{toolResult.error}</div>
)}
{!toolLoading && activeTool === 'optimize' && toolResult?.optimized_content && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{toolResult?.diff_summary && (
<div style={{ fontWeight: 600 }}>{toolResult.diff_summary}</div>
<div className="px-6 py-4 overflow-y-auto flex-1">
{toolLoading && (
<div className="flex items-center gap-3">
<CircularProgress size={18} />
<span className="text-sm text-gray-500">Working...</span>
</div>
)}
{Array.isArray(toolResult?.changes_made) && toolResult.changes_made.length > 0 && (
<List dense>
{toolResult.changes_made.map((c: string, idx: number) => (
<ListItem key={idx}>
<ListItemText primary={c} />
</ListItem>
))}
</List>
{!toolLoading && toolResult?.error && (
<div className="text-red-600 font-medium">{toolResult.error}</div>
)}
<TextField
multiline
minRows={10}
value={toolResult.optimized_content}
fullWidth
InputProps={{ readOnly: true }}
/>
</div>
)}
{!toolLoading && activeTool === 'links' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{Array.isArray(toolResult?.suggestions) && toolResult.suggestions.length > 0 ? (
<List>
{toolResult.suggestions.map((s: any, idx: number) => (
<ListItem key={idx} secondaryAction={
<Button size="small" onClick={() => insertLinkSuggestion(s.url)}>Insert</Button>
}>
<ListItemText
primary={s.url}
secondary={`confidence: ${(s.confidence ?? 0).toFixed?.(2) ?? s.confidence}${s.reason ?? ''}`}
/>
</ListItem>
))}
</List>
) : (
<div>No suggestions yet. Make sure SIF index has your website content.</div>
{!toolLoading && activeTool === 'optimize' && toolResult?.optimized_content && (
<div className="space-y-3">
{toolResult?.diff_summary && <p className="font-medium">{toolResult.diff_summary}</p>}
{Array.isArray(toolResult?.changes_made) && toolResult.changes_made.length > 0 && (
<ul className="list-disc pl-5 space-y-1">
{toolResult.changes_made.map((c: string, idx: number) => (
<li key={idx} className="text-sm text-gray-600">{c}</li>
))}
</ul>
)}
<TextField multiline minRows={10} value={toolResult.optimized_content} fullWidth InputProps={{ readOnly: true }} />
</div>
)}
{!toolLoading && activeTool === 'links' && (
<div className="space-y-2">
{Array.isArray(toolResult?.suggestions) && toolResult.suggestions.length > 0 ? (
toolResult.suggestions.map((s: any, idx: number) => (
<div key={idx} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div className="flex-1 min-w-0">
<p className="text-sm text-gray-700 truncate">{s.url}</p>
<p className="text-xs text-gray-500">confidence: {(s.confidence ?? 0).toFixed?.(2) ?? s.confidence}</p>
</div>
<button onClick={() => insertLinkSuggestion(s.url)} className="text-sm text-indigo-600 hover:text-indigo-800 ml-3 shrink-0">Insert</button>
</div>
))
) : (
<p className="text-sm text-gray-500">No suggestions yet. Make sure SIF index has your website content.</p>
)}
</div>
)}
{!toolLoading && activeTool === 'originality' && (
<div className="space-y-3">
{toolResult?.cannibalization && <pre className="text-sm whitespace-pre-wrap">{JSON.stringify(toolResult.cannibalization, null, 2)}</pre>}
{Array.isArray(toolResult?.matches) && toolResult.matches.length > 0 ? (
<div className="space-y-2">
{toolResult.matches.map((m: any, idx: number) => (
<div key={idx} className="p-3 bg-gray-50 rounded-lg">
<p className="text-sm font-medium">{m.id ?? 'unknown'} ({(m.score ?? 0).toFixed?.(3) ?? m.score})</p>
{m.excerpt && <p className="text-xs text-gray-500 mt-1">{m.excerpt}</p>}
</div>
))}
</div>
) : (
<p className="text-sm text-gray-500">No close matches found.</p>
)}
</div>
)}
{!toolLoading && activeTool === 'fact' && (
<div className="space-y-3">
{toolResult?.verification && <pre className="text-sm whitespace-pre-wrap">{JSON.stringify(toolResult.verification, null, 2)}</pre>}
{Array.isArray(toolResult?.citations) && toolResult.citations.length > 0 && (
<div className="space-y-2">
{toolResult.citations.map((c: any, idx: number) => (
<div key={idx} className="p-3 bg-gray-50 rounded-lg">
<p className="text-sm">{c.citation_text || c.title || c.source}</p>
<p className="text-xs text-gray-500">{c.source}</p>
</div>
))}
</div>
)}
</div>
)}
{!toolLoading && activeTool === 'flow' && (
<pre className="text-sm whitespace-pre-wrap">{JSON.stringify(toolResult, null, 2)}</pre>
)}
</div>
)}
{!toolLoading && activeTool === 'originality' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{toolResult?.cannibalization && (
<pre style={{ whiteSpace: 'pre-wrap' }}>{JSON.stringify(toolResult.cannibalization, null, 2)}</pre>
)}
{Array.isArray(toolResult?.matches) && toolResult.matches.length > 0 ? (
<List>
{toolResult.matches.map((m: any, idx: number) => (
<ListItem key={idx}>
<ListItemText
primary={`${m.id ?? 'unknown'} (${(m.score ?? 0).toFixed?.(3) ?? m.score})`}
secondary={m.excerpt}
/>
</ListItem>
))}
</List>
) : (
<div>No close matches found.</div>
<div className="px-6 py-3 border-t border-gray-100 flex justify-end gap-2">
<button onClick={closeToolDialog} className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 transition-colors">Close</button>
{activeTool === 'optimize' && toolResult?.optimized_content && (
<button onClick={applyOptimizedContent} className="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition-colors">Replace Section Content</button>
)}
</div>
)}
{!toolLoading && activeTool === 'fact' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{toolResult?.verification && (
<pre style={{ whiteSpace: 'pre-wrap' }}>{JSON.stringify(toolResult.verification, null, 2)}</pre>
)}
{Array.isArray(toolResult?.citations) && toolResult.citations.length > 0 && (
<List>
{toolResult.citations.map((c: any, idx: number) => (
<ListItem key={idx}>
<ListItemText primary={c.citation_text || c.title || c.source} secondary={c.source} />
</ListItem>
))}
</List>
)}
</div>
)}
{!toolLoading && activeTool === 'flow' && (
<pre style={{ whiteSpace: 'pre-wrap' }}>{JSON.stringify(toolResult, null, 2)}</pre>
)}
</DialogContent>
<DialogActions>
<Button onClick={closeToolDialog}>Close</Button>
{activeTool === 'optimize' && toolResult?.optimized_content && (
<Button variant="contained" onClick={applyOptimizedContent}>Replace Section Content</Button>
)}
</DialogActions>
</Dialog>
</div>
</div>
)}
{/* Section Divider */}
<Divider sx={{ mt: 1.2, mb: 1, opacity: 0.3 }} />
<Divider sx={{ mt: 2, opacity: 0.2 }} />
{assistiveWriting.renderSelectionMenu()}
</div>
);
};

View File

@@ -191,72 +191,72 @@ const useBlogTextSelectionHandler = (
// Text selection handler with debouncing
const handleTextSelection = () => {
console.log('🔍 [BlogTextSelectionHandler] handleTextSelection called');
// Clear any existing timeout
if (selectionTimeoutRef.current) {
clearTimeout(selectionTimeoutRef.current);
}
// Debounce the selection handling
selectionTimeoutRef.current = setTimeout(() => {
try {
const sel = window.getSelection();
console.log('🔍 [BlogTextSelectionHandler] Selection object (debounced):', sel);
if (!sel || sel.rangeCount === 0) {
console.log('🔍 [BlogTextSelectionHandler] No selection or range count is 0');
setSelectionMenu(null);
return;
}
const text = (sel.toString() || '').trim();
console.log('🔍 [BlogTextSelectionHandler] Selected text (debounced):', text, 'Length:', text.length);
if (!text || text.length < 10) {
console.log('🔍 [BlogTextSelectionHandler] Text too short or empty, hiding menu');
setSelectionMenu(null);
return;
}
const range = sel.getRangeAt(0);
const rect = range.getBoundingClientRect();
console.log('🔍 [BlogTextSelectionHandler] Range rect:', rect);
// Check if rect has valid dimensions
if (rect.width === 0 && rect.height === 0) {
console.log('🔍 [BlogTextSelectionHandler] Invalid rect dimensions, trying alternative positioning');
// Try to get position from the textarea element itself
if (contentRef.current) {
const textareaRect = contentRef.current.getBoundingClientRect();
console.log('🔍 [BlogTextSelectionHandler] Textarea rect:', textareaRect);
// Position menu near the textarea center
const x = Math.max(8, Math.min(textareaRect.left + (textareaRect.width / 2), window.innerWidth - 280));
const y = Math.max(8, textareaRect.top + window.scrollY - 60);
const menuPosition = { x, y, text };
console.log('🔍 [BlogTextSelectionHandler] Using textarea position:', menuPosition);
setSelectionMenu(menuPosition);
return;
let text = '';
let rect: DOMRect | null = null;
const el = contentRef.current;
if (el instanceof HTMLTextAreaElement) {
const start = el.selectionStart;
const end = el.selectionEnd;
if (start !== end) {
text = el.value.substring(start, end).trim();
try {
const { selectionStart, selectionEnd } = el;
if (selectionStart !== null && selectionEnd !== null) {
const textRect = el.getBoundingClientRect();
const lineHeight = parseFloat(getComputedStyle(el).lineHeight) || 20;
const linesBefore = el.value.substring(0, selectionStart).split('\n').length - 1;
rect = new DOMRect(
textRect.left + 10,
textRect.top + (linesBefore * lineHeight) + 10,
100,
20
);
}
} catch (_) {}
}
} else {
const sel = window.getSelection();
if (sel && sel.rangeCount > 0) {
text = (sel.toString() || '').trim();
if (text.length >= 10) {
rect = sel.getRangeAt(0).getBoundingClientRect();
}
}
}
// Use viewport coordinates for absolute positioning
const x = Math.max(8, Math.min(rect.left + (rect.width / 2), window.innerWidth - 280)); // Account for menu width
const y = Math.max(8, rect.top + window.scrollY - 60); // Position above selection
const menuPosition = { x, y, text };
console.log('🔍 [BlogTextSelectionHandler] Setting selection menu at position (debounced):', menuPosition);
setSelectionMenu(menuPosition);
if (!text || text.length < 10) {
setSelectionMenu(null);
return;
}
if (!rect || (rect.width === 0 && rect.height === 0)) {
if (el) {
const elRect = el.getBoundingClientRect();
const x = Math.max(8, Math.min(elRect.left + (elRect.width / 2), window.innerWidth - 280));
const y = Math.max(8, elRect.top + window.scrollY - 60);
setSelectionMenu({ x, y, text });
return;
}
setSelectionMenu(null);
return;
}
const x = Math.max(8, Math.min(rect.left + (rect.width / 2), window.innerWidth - 280));
const y = Math.max(8, rect.top + window.scrollY - 60);
setSelectionMenu({ x, y, text });
} catch (error) {
console.error('🔍 [BlogTextSelectionHandler] Error handling text selection:', error);
console.error('Text selection error:', error);
setSelectionMenu(null);
}
}, 150); // 150ms debounce
}, 150);
};
return {

View File

@@ -1,8 +1,6 @@
import React from 'react';
import { Paper, Button, Chip } from '@mui/material';
import { Paper, Chip } from '@mui/material';
import {
Add as AddIcon,
AutoAwesome as AutoAwesomeIcon,
BarChart as BarChartIcon,
Hub as HubIcon,
GpsFixed as GpsFixedIcon,
@@ -14,74 +12,112 @@ interface EditorSidebarProps {
}
const EditorSidebar: React.FC<EditorSidebarProps> = ({ sections, totalWords }) => {
const wordTarget = sections.reduce((s, sec) => s + (sec.outlineData?.targetWords || 500), 0);
const progress = wordTarget > 0 ? Math.min(100, Math.round((totalWords / wordTarget) * 100)) : 0;
return (
<div className="sticky top-24 hidden lg:block">
<Paper elevation={2} className="p-4 rounded-xl shadow-lg border border-gray-100">
<h3 className="font-bold text-lg mb-4 text-gray-700">Editor's Toolkit</h3>
<div className="space-y-3 mb-6">
<Button
fullWidth
variant="contained"
startIcon={<AutoAwesomeIcon />}
className="!bg-gradient-to-r !from-indigo-500 !to-purple-500 !capitalize !font-semibold !rounded-lg"
>
ALwrity it
</Button>
<Button
fullWidth
variant="outlined"
startIcon={<AddIcon />}
className="!capitalize !rounded-lg"
>
Add Section
</Button>
<div>
<Paper elevation={0} className="p-5 rounded-xl border border-gray-200/60 bg-white">
{/* Progress ring */}
<div className="text-center mb-5">
<div className="relative inline-flex items-center justify-center">
<svg className="w-20 h-20 -rotate-90">
<circle cx="40" cy="40" r="34" fill="none" stroke="#f3f4f6" strokeWidth="4" />
<circle
cx="40" cy="40" r="34"
fill="none" stroke="#4f46e5" strokeWidth="4"
strokeLinecap="round"
strokeDasharray={`${2 * Math.PI * 34}`}
strokeDashoffset={`${2 * Math.PI * 34 * (1 - progress / 100)}`}
className="transition-all duration-500"
/>
</svg>
<span className="absolute text-lg font-bold text-gray-700">{progress}%</span>
</div>
<div className="mt-2 text-xs text-gray-400">content complete</div>
</div>
<div className="mb-6">
<h4 className="font-semibold text-sm text-gray-600 mb-3">Outline</h4>
<ul className="space-y-2">
{sections.map(section => (
<li key={section.id}>
<a
href={`#section-${section.id}`}
className="text-sm text-gray-500 hover:text-indigo-600 transition-colors flex items-start"
>
<span className="mr-2 font-semibold">{section.id}.</span>
<span className="flex-1">{section.title}</span>
</a>
</li>
))}
</ul>
{/* Stats */}
<div className="space-y-2 mb-5">
<div className="flex justify-between text-sm">
<span className="text-gray-500">Sections</span>
<span className="font-medium text-gray-800">{sections.length}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-500">Words</span>
<span className="font-medium text-gray-800">{totalWords.toLocaleString()}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-500">Target</span>
<span className="font-medium text-gray-800">{wordTarget.toLocaleString()}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-500">Reading time</span>
<span className="font-medium text-gray-800">{Math.max(1, Math.ceil(totalWords / 200))} min</span>
</div>
</div>
<div className="border-t pt-4">
<h4 className="font-semibold text-sm text-gray-600 mb-3">SuperPowers</h4>
<div className="border-t border-gray-100 pt-4">
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Research Tools</h4>
<div className="flex flex-wrap gap-2">
<Chip
icon={<BarChartIcon />}
label="Research"
size="small"
clickable
variant="outlined"
<Chip
icon={<BarChartIcon sx={{ fontSize: 14 }} />}
label="Keywords"
size="small"
variant="outlined"
sx={{
fontSize: '12px',
height: 28,
'&:hover': { borderColor: '#4f46e5', color: '#4f46e5', backgroundColor: 'rgba(79, 70, 229, 0.04)' },
transition: 'all 0.15s ease',
}}
/>
<Chip
icon={<HubIcon />}
label="Source Mapping"
size="small"
clickable
variant="outlined"
<Chip
icon={<HubIcon sx={{ fontSize: 14 }} />}
label="Sources"
size="small"
variant="outlined"
sx={{
fontSize: '12px',
height: 28,
'&:hover': { borderColor: '#4f46e5', color: '#4f46e5', backgroundColor: 'rgba(79, 70, 229, 0.04)' },
transition: 'all 0.15s ease',
}}
/>
<Chip
icon={<GpsFixedIcon />}
label="Grounding"
size="small"
clickable
variant="outlined"
<Chip
icon={<GpsFixedIcon sx={{ fontSize: 14 }} />}
label="Grounding"
size="small"
variant="outlined"
sx={{
fontSize: '12px',
height: 28,
'&:hover': { borderColor: '#4f46e5', color: '#4f46e5', backgroundColor: 'rgba(79, 70, 229, 0.04)' },
transition: 'all 0.15s ease',
}}
/>
</div>
</div>
{/* Section navigation */}
{sections.length > 0 && (
<div className="border-t border-gray-100 pt-4 mt-4">
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">On this page</h4>
<nav className="space-y-1">
{sections.map((section, i) => (
<a
key={section.id}
href={`#section-${section.id}`}
className="block text-sm text-gray-500 hover:text-indigo-600 transition-colors py-1 truncate"
>
<span className="text-xs text-gray-300 mr-2">{i + 1}.</span>
{section.title || `Section ${i + 1}`}
</a>
))}
</nav>
</div>
)}
</Paper>
<div className="text-center text-xs text-gray-400 mt-4">
<span>{sections.length} sections</span> &bull; <span>{totalWords} words total</span>
</div>
</div>
);
};

View File

@@ -44,6 +44,9 @@ const useSmartTypingAssist = (
const [showContinueWritingPrompt, setShowContinueWritingPrompt] = useState(false);
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const lastGeneratedAtRef = useRef<number>(0);
const hasShownFirstRef = useRef(false);
const isGeneratingRef = useRef(false);
const smartSuggestionRef = useRef<typeof smartSuggestion>(null);
// Quality improvement tracking
const [suggestionStats, setSuggestionStats] = useState({
@@ -64,13 +67,14 @@ const useSmartTypingAssist = (
debug.log('[SmartTypingAssist] Starting suggestion generation...');
setIsGeneratingSuggestion(true);
isGeneratingRef.current = true;
try {
// Import the assistive writing API
const { assistiveWritingApi } = await import('../../../services/blogWriterApi');
debug.log('[SmartTypingAssist] Calling assistive writing API...');
const response = await assistiveWritingApi.getSuggestion(currentText, 3); // Get 3 suggestions
const response = await assistiveWritingApi.getSuggestion(currentText);
if (response.success && response.suggestions.length > 0) {
debug.log('[SmartTypingAssist] Received suggestions from API', { count: response.suggestions.length });
@@ -94,23 +98,19 @@ const useSmartTypingAssist = (
const element = contentRef.current;
const rect = element.getBoundingClientRect();
const maxWidth = 420;
const maxHeight = 350; // Increased to accommodate full suggestion with buttons
const maxHeight = 350;
// Try to position below the editor
let x = Math.max(20, Math.min(rect.left + 20, window.innerWidth - (maxWidth + 20)));
let y = rect.bottom + 10;
// If it would be cut off at the bottom, position above instead
if (y + maxHeight > window.innerHeight - 20) {
y = rect.top - maxHeight - 10;
// If it would be cut off at the top, position in viewport center
if (y < 20) {
y = Math.max(20, (window.innerHeight - maxHeight) / 2);
x = Math.max(20, (window.innerWidth - maxWidth) / 2);
}
}
// Ensure it's never cut off
y = Math.max(20, Math.min(y, window.innerHeight - maxHeight - 20));
x = Math.max(20, Math.min(x, window.innerWidth - maxWidth - 20));
@@ -121,118 +121,37 @@ const useSmartTypingAssist = (
sources: firstSuggestion.sources
});
}
} else {
debug.log('[SmartTypingAssist] No suggestions received from API');
// Fallback to generic suggestions if API fails
const fallbackSuggestions = [
"This approach provides significant value to readers by offering actionable insights they can implement immediately.",
"Research indicates that this strategy has proven effective across multiple industries and use cases.",
"Furthermore, this method demonstrates measurable improvements in key performance indicators.",
"Additionally, industry experts recommend this technique for sustainable long-term growth.",
"Moreover, this framework addresses common challenges while providing practical solutions."
];
const randomSuggestion = fallbackSuggestions[Math.floor(Math.random() * fallbackSuggestions.length)];
if (contentRef.current) {
const element = contentRef.current;
const rect = element.getBoundingClientRect();
const maxWidth = 420;
const maxHeight = 350; // Increased to accommodate full suggestion with buttons
// Try to position below the editor
let x = Math.max(20, Math.min(rect.left + 20, window.innerWidth - (maxWidth + 20)));
let y = rect.bottom + 10;
// If it would be cut off at the bottom, position above instead
if (y + maxHeight > window.innerHeight - 20) {
y = rect.top - maxHeight - 10;
// If it would be cut off at the top, position in viewport center
if (y < 20) {
y = Math.max(20, (window.innerHeight - maxHeight) / 2);
x = Math.max(20, (window.innerWidth - maxWidth) / 2);
}
}
// Ensure it's never cut off
y = Math.max(20, Math.min(y, window.innerHeight - maxHeight - 20));
x = Math.max(20, Math.min(x, window.innerWidth - maxWidth - 20));
setSmartSuggestion({
text: randomSuggestion,
position: { x, y }
});
}
}
} catch (error) {
debug.error('[SmartTypingAssist] Failed to generate smart suggestion', error);
// Fallback to generic suggestions on error
const fallbackSuggestions = [
"This approach provides significant value to readers by offering actionable insights they can implement immediately.",
"Research indicates that this strategy has proven effective across multiple industries and use cases.",
"Furthermore, this method demonstrates measurable improvements in key performance indicators.",
"Additionally, industry experts recommend this technique for sustainable long-term growth.",
"Moreover, this framework addresses common challenges while providing practical solutions."
];
const randomSuggestion = fallbackSuggestions[Math.floor(Math.random() * fallbackSuggestions.length)];
if (contentRef.current) {
const element = contentRef.current;
const rect = element.getBoundingClientRect();
const maxWidth = 420;
const maxHeight = 160;
let x = Math.max(20, Math.min(rect.left + 20, window.innerWidth - (maxWidth + 20)));
let y = rect.bottom + 5;
if (y > window.innerHeight - maxHeight) {
y = window.innerHeight - (maxHeight + 20);
x = Math.max(20, window.innerWidth - (maxWidth + 20));
}
setSmartSuggestion({
text: randomSuggestion,
position: { x, y }
});
}
} finally {
setIsGeneratingSuggestion(false);
isGeneratingRef.current = false;
}
};
const handleTypingChange = (newText: string) => {
// Not logging this as it fires on every keystroke - too noisy
// Clear existing timeout
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
// Clear any existing suggestion when user types
setSmartSuggestion(null);
// Set new timeout for suggestion generation
typingTimeoutRef.current = setTimeout(() => {
debug.log('[SmartTypingAssist] Typing timeout triggered', { textLength: newText.length, hasShownFirst: hasShownFirstSuggestion });
const cooldownMs = 15000; // 15s cooldown between suggestions
const cooldownMs = 15000;
const now = Date.now();
const sinceLast = now - lastGeneratedAtRef.current;
// First time suggestion appears automatically with sufficient content
if (!hasShownFirstSuggestion && newText.length > 50 && !isGeneratingSuggestion) {
if (!hasShownFirstRef.current && newText.length > 50 && !isGeneratingRef.current) {
debug.log('[SmartTypingAssist] Generating first suggestion');
generateSmartSuggestion(newText);
setHasShownFirstSuggestion(true);
lastGeneratedAtRef.current = now;
}
// After first time, show "Continue writing" prompt instead of random suggestions
else if (hasShownFirstSuggestion && newText.length > 100 && sinceLast >= cooldownMs && !isGeneratingSuggestion && !smartSuggestion) {
} else if (hasShownFirstRef.current && newText.length > 100 && sinceLast >= cooldownMs && !isGeneratingRef.current && !smartSuggestionRef.current) {
debug.log('[SmartTypingAssist] Showing "Continue writing" prompt');
setShowContinueWritingPrompt(true);
}
// Removed verbose log about skipping prompts as it's too noisy
}, 3000); // 3 second pause before suggesting
}, 3000);
};
const handleAcceptSuggestion = () => {
@@ -350,6 +269,11 @@ const useSmartTypingAssist = (
};
};
// Sync refs with state so timeout callbacks always read latest values
useEffect(() => { hasShownFirstRef.current = hasShownFirstSuggestion; }, [hasShownFirstSuggestion]);
useEffect(() => { isGeneratingRef.current = isGeneratingSuggestion; }, [isGeneratingSuggestion]);
useEffect(() => { smartSuggestionRef.current = smartSuggestion; }, [smartSuggestion]);
// Cleanup timeouts on unmount
useEffect(() => {
return () => {

View File

@@ -21,7 +21,7 @@ import {
Avatar
} from '@mui/material';
import { apiClient } from '../../../api/client';
import { isPodcastOnlyDemoMode } from '../../../utils/demoMode';
import { isFeatureOnlyMode } from '../../../utils/demoMode';
import {
CheckCircle as HealthyIcon,
Warning as WarningIcon,
@@ -91,8 +91,8 @@ const SystemStatusIndicator: React.FC<SystemStatusIndicatorProps> = ({ className
const [, setCachePerf] = useState<{ hits: number; misses: number; hit_rate: number } | null>(null);
const fetchStatus = async () => {
// Skip system status checks in podcast-only mode (endpoint not available)
if (isPodcastOnlyDemoMode()) {
// Skip system status checks in feature-limited mode (endpoint not available)
if (isFeatureOnlyMode()) {
setStatusData({
status: 'unknown',
icon: '⚪',
@@ -131,8 +131,8 @@ const SystemStatusIndicator: React.FC<SystemStatusIndicatorProps> = ({ className
};
const fetchDetailedStats = async () => {
// Skip detailed stats in podcast-only mode (endpoint not available)
if (isPodcastOnlyDemoMode()) {
// Skip detailed stats in feature-limited mode (endpoint not available)
if (isFeatureOnlyMode()) {
setChartData([]);
return;
}
@@ -182,8 +182,8 @@ const SystemStatusIndicator: React.FC<SystemStatusIndicatorProps> = ({ className
useEffect(() => {
fetchStatus();
// Skip detailed stats in podcast-only mode
if (!isPodcastOnlyDemoMode()) {
// Skip detailed stats in feature-limited mode
if (!isFeatureOnlyMode()) {
fetchDetailedStats();
}

View File

@@ -37,6 +37,7 @@ import {
Warning,
} from '@mui/icons-material';
import { ImageStudioLayout } from './ImageStudioLayout';
import { DashboardHeaderProps } from '../shared/types';
import { useContentAssets, AssetFilters, ContentAsset } from '../../hooks/useContentAssets';
import { intentResearchApi } from '../../api/intentResearchApi';
import { AssetFilters as AssetFiltersComponent } from './AssetLibraryComponents/AssetFilters';
@@ -127,6 +128,29 @@ export const AssetLibrary: React.FC = () => {
return baseFilters;
}, [debouncedSearch, idSearch, modelSearch, filterType, tabValue, page, pageSize, urlSourceModule]);
const headerProps: DashboardHeaderProps | undefined = useMemo(() => {
if (!urlSourceModule) return undefined;
switch (urlSourceModule) {
case 'blog_writer':
return {
title: 'Blog Posts',
subtitle: 'Manage and review your published blog posts.',
};
case 'research_tools':
return {
title: 'Research Documents',
subtitle: 'Access and manage your research projects.',
};
case 'product_marketing':
return {
title: 'Marketing Assets',
subtitle: 'Marketing content generated by Product Marketing tools.',
};
default:
return undefined;
}
}, [urlSourceModule]);
const { assets, loading, error, total, toggleFavorite, deleteAsset, trackUsage, refetch } = useContentAssets(filters);
// Refetch assets when component mounts with research_tools filter to show latest drafts
@@ -338,7 +362,7 @@ export const AssetLibrary: React.FC = () => {
}, [assets, statusFilter, dateFilter]);
return (
<ImageStudioLayout>
<ImageStudioLayout headerProps={headerProps}>
<Paper
elevation={0}
sx={{
@@ -561,7 +585,13 @@ export const AssetLibrary: React.FC = () => {
No assets found
</Typography>
<Typography variant="body2">
Generated content from all ALwrity tools will appear here.
{urlSourceModule === 'blog_writer'
? 'No blog posts found. Generate your first blog post in Blog Writer.'
: urlSourceModule === 'research_tools'
? 'No research documents found. Start a new research project.'
: urlSourceModule === 'product_marketing'
? 'No marketing assets found. Create one in Product Marketing.'
: 'Generated content from all ALwrity tools will appear here.'}
</Typography>
</Box>
) : viewMode === 'list' ? (

View File

@@ -18,6 +18,7 @@ import {
} from '@mui/icons-material';
import { PreflightCheckResponse } from '../../services/billingService';
import { useNavigate } from 'react-router-dom';
import { saveNavigationState } from '../../utils/navigationState';
interface PreflightBlockDialogProps {
open: boolean;
@@ -41,6 +42,7 @@ export const PreflightBlockDialog: React.FC<PreflightBlockDialogProps> = ({
const limitInfo = blockedOperation?.limit_info;
const handleUpgrade = () => {
saveNavigationState(window.location.pathname);
navigate('/pricing');
onClose();
};

View File

@@ -21,7 +21,7 @@ import {
import Warning from '@mui/icons-material/Warning';
import { useNavigate } from 'react-router-dom';
import { apiClient } from '../../api/client';
import { restoreNavigationState, saveCurrentPhaseForTool } from '../../utils/navigationState';
import { saveNavigationState, restoreNavigationState, saveCurrentPhaseForTool } from '../../utils/navigationState';
import { getEnabledFeatures, getDefaultLandingRoute } from '../../utils/demoMode';
import PlanCard from './PricingPage/PlanCard';
@@ -44,11 +44,12 @@ export interface SubscriptionPlan {
firecrawl_calls: number;
stability_calls: number;
monthly_cost: number;
// New limit fields (optional for backward compatibility)
image_edit_calls?: number;
video_calls?: number;
audio_calls?: number;
ai_text_generation_calls_limit?: number; // Unified limit for Basic tier
ai_text_generation_calls_limit?: number;
exa_calls?: number;
wavespeed_calls?: number;
};
}
@@ -123,7 +124,22 @@ const PricingPage: React.FC = () => {
return;
}
// Full mode keeps existing onboarding redirect behavior.
// Try to restore navigation state (saved before redirect to pricing)
const navState = restoreNavigationState();
if (navState?.path && navState.path !== '/pricing' && navState.path !== '/onboarding') {
console.log('[PricingPage] Redirecting to saved navigation state:', navState.path);
navigate(navState.path);
return;
}
// Fallback: try legacy referrer
const referrer = sessionStorage.getItem('subscription_referrer');
if (referrer && referrer !== '/pricing') {
navigate(referrer);
return;
}
// Final fallback
const onboardingComplete = localStorage.getItem('onboarding_complete') === 'true';
if (onboardingComplete) {
navigate('/dashboard');
@@ -234,12 +250,26 @@ const PricingPage: React.FC = () => {
if (stripePublishableKey) {
console.log('[PricingPage] Initiating Stripe Checkout');
// Save current navigation state so we can return here after payment
// If we're already on /pricing, don't overwrite — the caller (e.g., SubscriptionGuard,
// UserBadge, PreflightBlockDialog) already saved the original page's state.
if (window.location.pathname !== '/pricing') {
saveNavigationState(window.location.pathname);
}
// Include return_to in success_url so InitialRouteHandler can restore navigation
const returnTo = window.location.pathname !== '/pricing' ? window.location.pathname : '';
const successUrlBase = isFeatureLimitedMode()
? `${window.location.origin}${getDefaultLandingRoute()}`
: `${window.location.origin}/dashboard`;
const successUrl = returnTo
? `${successUrlBase}?subscription=success&return_to=${encodeURIComponent(returnTo)}`
: `${successUrlBase}?subscription=success`;
const response = await apiClient.post('/api/subscription/create-checkout-session', {
tier: plan.tier,
billing_cycle: yearlyBilling ? 'yearly' : 'monthly',
success_url: isFeatureLimitedMode()
? `${window.location.origin}${getDefaultLandingRoute()}?subscription=success`
: `${window.location.origin}/dashboard?subscription=success`,
success_url: successUrl,
cancel_url: `${window.location.origin}/pricing?subscription=cancel`,
});

View File

@@ -61,6 +61,8 @@ interface SubscriptionPlan {
video_calls?: number;
audio_calls?: number;
ai_text_generation_calls_limit?: number;
exa_calls?: number;
wavespeed_calls?: number;
};
}
@@ -864,6 +866,32 @@ const PlanCard: React.FC<PlanCardProps> = ({
</ListItem>
)}
{(plan.limits.exa_calls ?? 0) > 0 && (
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<SearchIcon color="primary" fontSize="small" />
</ListItemIcon>
<ListItemText
primary={`${plan.limits.exa_calls} Exa AI Searches`}
secondary="AI-powered search and content discovery"
sx={{ '& .MuiListItemText-primary': { fontSize: '0.875rem' } }}
/>
</ListItem>
)}
{(plan.limits.wavespeed_calls ?? 0) > 0 && (
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<AudioIcon color="primary" fontSize="small" />
</ListItemIcon>
<ListItemText
primary={`${plan.limits.wavespeed_calls} WaveSpeed AI Calls`}
secondary="TTS, video, image, and LLM via Minimax"
sx={{ '& .MuiListItemText-primary': { fontSize: '0.875rem' } }}
/>
</ListItem>
)}
{plan.limits.monthly_cost > 0 && (
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>

View File

@@ -1,4 +1,4 @@
import { useState, useCallback, useEffect } from 'react';
import { useState, useCallback, useEffect, useRef } from 'react';
import { researchCache } from '../../../services/researchCache';
import { WizardState } from '../types/research.types';
import { researchEngineApi, ResearchEngineRequest } from '../../../services/researchEngineApi';
@@ -17,6 +17,8 @@ export const useResearchExecution = () => {
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<any>(null);
const keywordsRef = useRef<string[]>([]);
// Intent-driven research state
const [isAnalyzingIntent, setIsAnalyzingIntent] = useState(false);
const [intentAnalysis, setIntentAnalysis] = useState<AnalyzeIntentResponse | null>(null);
@@ -45,9 +47,9 @@ export const useResearchExecution = () => {
const polling = useResearchPolling({
onComplete: (result) => {
if (result && result.keywords) {
if (result) {
researchCache.cacheResult(
result.keywords,
keywordsRef.current,
'General',
'General',
result
@@ -68,6 +70,8 @@ export const useResearchExecution = () => {
setError(null);
try {
keywordsRef.current = state.keywords;
// Check cache first
const cachedResult = researchCache.getCachedResult(
state.keywords,

View File

@@ -16,6 +16,7 @@ import {
} from '@mui/material';
import { useNavigate } from 'react-router-dom';
import { useSubscriptionGuard, SubscriptionGuardOptions } from '../hooks/useSubscriptionGuard';
import { saveNavigationState } from '../utils/navigationState';
import LockIcon from '@mui/icons-material/Lock';
import UpgradeIcon from '@mui/icons-material/Upgrade';
@@ -98,6 +99,7 @@ export const SubscriptionGuard: React.FC<SubscriptionGuardProps> = ({
variant="contained"
startIcon={<UpgradeIcon />}
onClick={() => {
saveNavigationState(window.location.pathname);
navigate('/pricing');
}}
>
@@ -123,6 +125,7 @@ export const SubscriptionGuard: React.FC<SubscriptionGuardProps> = ({
variant="outlined"
sx={{ mt: 1 }}
onClick={() => {
saveNavigationState(window.location.pathname);
navigate('/pricing');
}}
>

View File

@@ -6,7 +6,7 @@ import { formatCurrency } from '../utils/formatting';
import { DashboardData } from '../../../../types/billing';
interface CostEfficiencyMetricsProps {
currentUsage: DashboardData['current_usage'];
currentUsage: DashboardData['total_usage'];
terminalTheme?: boolean;
TypographyComponent: typeof TerminalTypography | React.ComponentType<any>;
}

View File

@@ -10,7 +10,7 @@ import { DashboardData } from '../../../../types/billing';
import { SystemHealth } from '../../../../types/monitoring';
interface MainMetricsGridProps {
currentUsage: DashboardData['current_usage'];
currentUsage: DashboardData['total_usage'];
systemHealth: SystemHealth | null;
healthError: string | null;
sparklineData: {

View File

@@ -9,7 +9,7 @@ import { formatCurrency } from '../utils/formatting';
import { DashboardData } from '../../../../types/billing';
interface MonthlyBudgetUsageProps {
currentUsage: DashboardData['current_usage'];
currentUsage: DashboardData['total_usage'];
limits: DashboardData['limits'];
terminalTheme?: boolean;
TypographyComponent: typeof TerminalTypography | React.ComponentType<any>;

View File

@@ -7,7 +7,8 @@ import { UsageLimitRing } from '../../../shared/UsageLimitRing';
import { DashboardData } from '../../../../types/billing';
interface UsageLimitRingsProps {
currentUsage: DashboardData['current_usage'];
currentUsage: DashboardData['total_usage'];
currentPeriodUsage: DashboardData['total_usage'];
limits: DashboardData['limits'];
terminalTheme?: boolean;
TypographyComponent: typeof TerminalTypography | React.ComponentType<any>;
@@ -15,59 +16,34 @@ interface UsageLimitRingsProps {
/**
* UsageLimitRings - Displays circular progress rings for key usage limits
* Uses currentPeriodUsage for ring values (per-period budget), currentUsage for total display.
*/
export const UsageLimitRings: React.FC<UsageLimitRingsProps> = ({
currentUsage,
currentPeriodUsage,
limits,
terminalTheme = false,
TypographyComponent
}) => {
// Calculate image calls - check multiple possible sources
const imageCalls = useMemo(() => {
// Primary: provider_breakdown.image
const imageFromBreakdown = currentUsage.provider_breakdown?.image?.calls ?? 0;
const imageEditFromBreakdown = currentUsage.provider_breakdown?.image_edit?.calls ?? 0;
// Fallback: Check if there's a stability key (legacy)
const stabilityFromBreakdown = currentUsage.provider_breakdown?.stability?.calls ?? 0;
// Sum all image-related calls
const total = imageFromBreakdown + imageEditFromBreakdown + stabilityFromBreakdown;
// Debug logging (can be removed in production)
if (total > 0 || imageFromBreakdown > 0 || stabilityFromBreakdown > 0) {
console.log('[UsageLimitRings] Image calls calculation:', {
image: imageFromBreakdown,
image_edit: imageEditFromBreakdown,
stability: stabilityFromBreakdown,
total,
provider_breakdown_keys: Object.keys(currentUsage.provider_breakdown || {})
});
}
return total;
}, [currentUsage.provider_breakdown]);
const periodBreakdown = currentPeriodUsage?.provider_breakdown || {};
const totalBreakdown = currentUsage.provider_breakdown || {};
// Calculate video calls - check multiple possible sources
// Calculate image calls from current period
const imageCalls = useMemo(() => {
const stabilityFromBreakdown = periodBreakdown.stability?.calls ?? 0;
const imageEditFromBreakdown = periodBreakdown.image_edit?.calls ?? 0;
return stabilityFromBreakdown + imageEditFromBreakdown;
}, [periodBreakdown]);
// Calculate video calls from current period
const videoCalls = useMemo(() => {
// Primary: provider_breakdown.video
const videoFromBreakdown = currentUsage.provider_breakdown?.video?.calls ?? 0;
// Debug logging (can be removed in production)
if (videoFromBreakdown > 0) {
console.log('[UsageLimitRings] Video calls calculation:', {
video: videoFromBreakdown,
provider_breakdown_keys: Object.keys(currentUsage.provider_breakdown || {})
});
}
return videoFromBreakdown;
}, [currentUsage.provider_breakdown]);
return periodBreakdown.video?.calls ?? 0;
}, [periodBreakdown]);
const keyLimits = [
{
label: 'AI Calls',
used: currentUsage.total_calls,
used: currentPeriodUsage?.total_calls ?? 0,
limit: limits.limits.ai_text_generation_calls || limits.limits.gemini_calls || limits.limits.openai_calls || 50,
color: '#3b82f6',
unlimited: limits.limits.ai_text_generation_calls === 0 && limits.limits.gemini_calls === 0 && limits.limits.openai_calls === 0,
@@ -88,7 +64,7 @@ export const UsageLimitRings: React.FC<UsageLimitRingsProps> = ({
},
{
label: 'Audio',
used: currentUsage.provider_breakdown?.audio?.calls ?? 0,
used: periodBreakdown.audio?.calls ?? 0,
limit: limits.limits.audio_calls,
color: '#22c55e',
unlimited: limits.limits.audio_calls === 0,

View File

@@ -109,7 +109,7 @@ const CompactBillingDashboard: React.FC<CompactBillingDashboardProps> = ({
if (!dashboardData) return null;
const { current_usage, limits, alerts } = dashboardData;
const { total_usage: current_usage, current_period_usage, limits, alerts } = dashboardData;
const mainCardStyles = terminalTheme
? {
@@ -187,6 +187,7 @@ const CompactBillingDashboard: React.FC<CompactBillingDashboardProps> = ({
{/* Usage Limit Rings */}
<UsageLimitRings
currentUsage={current_usage}
currentPeriodUsage={current_period_usage}
limits={limits}
terminalTheme={terminalTheme}
TypographyComponent={TypographyComponent}

View File

@@ -330,7 +330,7 @@ const EnhancedBillingDashboard: React.FC<EnhancedBillingDashboardProps> = ({ use
{/* Active Providers Chips */}
{dashboardData && (
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
{Object.entries(dashboardData.current_usage.provider_breakdown)
{Object.entries(dashboardData.total_usage.provider_breakdown)
.filter(([_, data]) => data && data.cost > 0)
.map(([provider, data]) => {
const providerData = data!; // Safe after filter
@@ -493,7 +493,7 @@ const EnhancedBillingDashboard: React.FC<EnhancedBillingDashboardProps> = ({ use
{/* Top Row */}
<Grid item xs={12} md={4}>
<BillingOverview
usageStats={dashboardData.current_usage}
usageStats={dashboardData.total_usage}
onRefresh={fetchDashboardData}
terminalTheme={terminalTheme}
/>
@@ -519,8 +519,8 @@ const EnhancedBillingDashboard: React.FC<EnhancedBillingDashboardProps> = ({ use
{/* Middle Row */}
<Grid item xs={12} md={6}>
<CostBreakdown
providerBreakdown={dashboardData.current_usage.provider_breakdown}
totalCost={dashboardData.current_usage.total_cost}
providerBreakdown={dashboardData.total_usage.provider_breakdown}
totalCost={dashboardData.total_usage.total_cost}
/>
</Grid>
@@ -542,8 +542,8 @@ const EnhancedBillingDashboard: React.FC<EnhancedBillingDashboardProps> = ({ use
{/* Bottom Row - Comprehensive API Breakdown */}
<Grid item xs={12} md={6}>
<ComprehensiveAPIBreakdown
providerBreakdown={dashboardData.current_usage.provider_breakdown}
totalCost={dashboardData.current_usage.total_cost}
providerBreakdown={dashboardData.total_usage.provider_breakdown}
totalCost={dashboardData.total_usage.total_cost}
/>
</Grid>

View File

@@ -64,7 +64,8 @@ interface UsageLimits {
}
interface DashboardData {
current_usage: UsageStats;
total_usage: UsageStats;
current_period_usage: UsageStats;
limits: UsageLimits;
projections: {
projected_monthly_cost: number;
@@ -248,14 +249,15 @@ const UsageDashboard: React.FC<UsageDashboardProps> = ({
if (!dashboardData) return null;
const currentUsage = dashboardData.current_usage;
const totalUsage = dashboardData.total_usage;
const currentPeriodUsage = dashboardData.current_period_usage;
const limits = dashboardData.limits;
if (compact) {
// Compact view - show key metrics as chips
// Use current_usage for accurate cost (properly coerced from provider breakdown)
// Fallback to summary if current_usage is not available
const usageData = dashboardData?.current_usage || {
// Use total_usage for accurate cost (properly coerced from provider breakdown)
// Fallback to summary if total_usage is not available
const usageData = dashboardData?.total_usage || {
total_calls: dashboardData?.summary?.total_api_calls_this_month || 0,
total_cost: dashboardData?.summary?.total_cost_this_month || 0,
usage_status: dashboardData?.summary?.usage_status || 'active',
@@ -267,37 +269,49 @@ const UsageDashboard: React.FC<UsageDashboardProps> = ({
const monthlyLimit = dashboardData?.limits?.limits?.monthly_cost || 0;
const usagePercentage = monthlyLimit > 0 ? (totalCost / monthlyLimit) * 100 : 0;
// Build per-category usage summaries from provider_breakdown and limits
const providerBreakdown = usageData.provider_breakdown || {};
// Use current_period provider_breakdown for budget bars, total_usage for total display
const periodBreakdown = currentPeriodUsage?.provider_breakdown || {};
const totalBreakdown = usageData.provider_breakdown || {};
const providerLimits = dashboardData?.limits?.limits || {};
// Aggregate AI text calls (gemini + openai + anthropic + mistral)
const aiCalls = (providerBreakdown.gemini?.calls || 0) + (providerBreakdown.openai?.calls || 0) + (providerBreakdown.anthropic?.calls || 0) + (providerBreakdown.mistral?.calls || 0) + (providerBreakdown.huggingface?.calls || 0) + (providerBreakdown.wavespeed?.calls || 0);
// Aggregate AI text calls (gemini + openai + anthropic + mistral) — from current period
const aiCalls = (periodBreakdown.gemini?.calls || 0) + (periodBreakdown.openai?.calls || 0) + (periodBreakdown.anthropic?.calls || 0) + (periodBreakdown.mistral?.calls || 0) + (periodBreakdown.huggingface?.calls || 0) + (periodBreakdown.wavespeed?.calls || 0);
const aiCallLimit = providerLimits.ai_text_generation_calls || providerLimits.gemini_calls || 0;
// Image calls (stability + wavespeed image)
const imageCalls = (providerBreakdown.stability?.calls || 0) + (providerBreakdown.image_edit?.calls || 0);
// Image calls (stability + wavespeed image) — from current period
const imageCalls = (periodBreakdown.stability?.calls || 0) + (periodBreakdown.image_edit?.calls || 0);
const imageCallLimit = providerLimits.stability_calls || 0;
const imageTotal = (totalBreakdown.stability?.calls || 0) + (totalBreakdown.image_edit?.calls || 0);
// Audio calls
const audioCalls = providerBreakdown.audio?.calls || 0;
// Audio calls — from current period
const audioCalls = periodBreakdown.audio?.calls || 0;
const audioCallLimit = providerLimits.audio_calls || 0;
const audioTotal = totalBreakdown.audio?.calls || 0;
// Video calls
const videoCalls = providerBreakdown.video?.calls || 0;
// Video calls — from current period
const videoCalls = periodBreakdown.video?.calls || 0;
const videoCallLimit = providerLimits.video_calls || 0;
const videoTotal = totalBreakdown.video?.calls || 0;
// Research calls (exa + tavily + serper + firecrawl)
const researchCalls = (providerBreakdown.exa?.calls || 0) + (providerBreakdown.tavily?.calls || 0) + (providerBreakdown.serper?.calls || 0) + (providerBreakdown.firecrawl?.calls || 0);
// Research calls (exa + tavily + serper + firecrawl) — from current period
const researchCalls = (periodBreakdown.exa?.calls || 0) + (periodBreakdown.tavily?.calls || 0) + (periodBreakdown.serper?.calls || 0) + (periodBreakdown.firecrawl?.calls || 0);
const researchCallLimit = (providerLimits.exa_calls || 0) + (providerLimits.tavily_calls || 0) + (providerLimits.serper_calls || 0) + (providerLimits.firecrawl_calls || 0);
// WaveSpeed calls (all WaveSpeed API calls)
const wavespeedCalls = providerBreakdown.wavespeed?.calls || 0;
// WaveSpeed calls (all WaveSpeed API calls) — from current period
const wavespeedCalls = periodBreakdown.wavespeed?.calls || 0;
const wavespeedCallLimit = providerLimits.wavespeed_calls || 0;
const wavespeedTotal = totalBreakdown.wavespeed?.calls || 0;
const formatLimit = (used: number, limit: number) => {
if (limit === 0) return `${used} / ∞`;
return `${used} / ${limit}`;
// All-time totals for rows without separate total variables
const aiTotal = (totalBreakdown.gemini?.calls || 0) + (totalBreakdown.openai?.calls || 0) + (totalBreakdown.anthropic?.calls || 0) + (totalBreakdown.mistral?.calls || 0) + (totalBreakdown.huggingface?.calls || 0) + (totalBreakdown.wavespeed?.calls || 0);
const researchTotal = (totalBreakdown.exa?.calls || 0) + (totalBreakdown.tavily?.calls || 0) + (totalBreakdown.serper?.calls || 0) + (totalBreakdown.firecrawl?.calls || 0);
const formatLimit = (used: number, limit: number, total?: number) => {
const periodStr = limit === 0 ? `${used} / ∞` : `${used} / ${limit}`;
if (total !== undefined && total !== used) {
return `${periodStr} • Total: ${total}`;
}
return periodStr;
};
return (
@@ -434,7 +448,7 @@ const UsageDashboard: React.FC<UsageDashboardProps> = ({
sx={{ flex: 1, height: 4, borderRadius: 2, bgcolor: '#e5e7eb', '& .MuiLinearProgress-bar': { bgcolor: getUsageColor(aiCalls, aiCallLimit), borderRadius: 2 } }}
/>
<Typography variant="caption" sx={{ fontSize: '0.65rem', fontWeight: 600, color: getUsageColor(aiCalls, aiCallLimit), minWidth: 55, textAlign: 'right' }}>
{formatLimit(aiCalls, aiCallLimit)}
{formatLimit(aiCalls, aiCallLimit, aiTotal)}
</Typography>
</Box>
</Box>
@@ -449,7 +463,7 @@ const UsageDashboard: React.FC<UsageDashboardProps> = ({
sx={{ flex: 1, height: 4, borderRadius: 2, bgcolor: '#e5e7eb', '& .MuiLinearProgress-bar': { bgcolor: getUsageColor(imageCalls, imageCallLimit), borderRadius: 2 } }}
/>
<Typography variant="caption" sx={{ fontSize: '0.65rem', fontWeight: 600, color: getUsageColor(imageCalls, imageCallLimit), minWidth: 55, textAlign: 'right' }}>
{formatLimit(imageCalls, imageCallLimit)}
{formatLimit(imageCalls, imageCallLimit, imageTotal)}
</Typography>
</Box>
</Box>
@@ -464,7 +478,7 @@ const UsageDashboard: React.FC<UsageDashboardProps> = ({
sx={{ flex: 1, height: 4, borderRadius: 2, bgcolor: '#e5e7eb', '& .MuiLinearProgress-bar': { bgcolor: getUsageColor(audioCalls, audioCallLimit), borderRadius: 2 } }}
/>
<Typography variant="caption" sx={{ fontSize: '0.65rem', fontWeight: 600, color: getUsageColor(audioCalls, audioCallLimit), minWidth: 55, textAlign: 'right' }}>
{formatLimit(audioCalls, audioCallLimit)}
{formatLimit(audioCalls, audioCallLimit, audioTotal)}
</Typography>
</Box>
</Box>
@@ -479,7 +493,7 @@ const UsageDashboard: React.FC<UsageDashboardProps> = ({
sx={{ flex: 1, height: 4, borderRadius: 2, bgcolor: '#e5e7eb', '& .MuiLinearProgress-bar': { bgcolor: getUsageColor(videoCalls, videoCallLimit), borderRadius: 2 } }}
/>
<Typography variant="caption" sx={{ fontSize: '0.65rem', fontWeight: 600, color: getUsageColor(videoCalls, videoCallLimit), minWidth: 55, textAlign: 'right' }}>
{formatLimit(videoCalls, videoCallLimit)}
{formatLimit(videoCalls, videoCallLimit, videoTotal)}
</Typography>
</Box>
</Box>
@@ -494,7 +508,7 @@ const UsageDashboard: React.FC<UsageDashboardProps> = ({
sx={{ flex: 1, height: 4, borderRadius: 2, bgcolor: '#e5e7eb', '& .MuiLinearProgress-bar': { bgcolor: getUsageColor(researchCalls, researchCallLimit), borderRadius: 2 } }}
/>
<Typography variant="caption" sx={{ fontSize: '0.65rem', fontWeight: 600, color: getUsageColor(researchCalls, researchCallLimit), minWidth: 55, textAlign: 'right' }}>
{formatLimit(researchCalls, researchCallLimit)}
{formatLimit(researchCalls, researchCallLimit, researchTotal)}
</Typography>
</Box>
</Box>
@@ -509,7 +523,7 @@ const UsageDashboard: React.FC<UsageDashboardProps> = ({
sx={{ flex: 1, height: 4, borderRadius: 2, bgcolor: '#e5e7eb', '& .MuiLinearProgress-bar': { bgcolor: getUsageColor(wavespeedCalls, wavespeedCallLimit), borderRadius: 2 } }}
/>
<Typography variant="caption" sx={{ fontSize: '0.65rem', fontWeight: 600, color: getUsageColor(wavespeedCalls, wavespeedCallLimit), minWidth: 55, textAlign: 'right' }}>
{formatLimit(wavespeedCalls, wavespeedCallLimit)}
{formatLimit(wavespeedCalls, wavespeedCallLimit, wavespeedTotal)}
</Typography>
</Box>
</Box>
@@ -552,7 +566,7 @@ const UsageDashboard: React.FC<UsageDashboardProps> = ({
}
// Full dashboard view (for dedicated usage page)
const usageData = dashboardData?.current_usage || {
const usageData = dashboardData?.total_usage || {
total_calls: dashboardData?.summary?.total_api_calls_this_month || 0,
total_cost: dashboardData?.summary?.total_cost_this_month || 0,
provider_breakdown: {}

Some files were not shown because too many files have changed in this diff Show More