AI Analysis and Content Strategy fixes. Enhanced Strategy Routes refactoring.

This commit is contained in:
ajaysi
2026-01-10 19:32:50 +05:30
parent 0b63ae7fc1
commit 8193cdba67
298 changed files with 45678 additions and 10952 deletions

View File

@@ -275,6 +275,12 @@ export const BlogWriter: React.FC = () => {
const handlePhaseClick = useCallback((phaseId: string) => {
navigateToPhase(phaseId);
// When clicking Research phase, ensure we navigate to research phase (this will trigger research form to show)
if (phaseId === 'research' && !research) {
debug.log('[BlogWriter] Research phase clicked - navigating to research phase to show form');
// navigateToPhase already called above, which will set currentPhase to 'research'
// BlogWriterLandingSection will detect currentPhase === 'research' and show ManualResearchForm
}
if (phaseId === 'seo') {
if (seoAnalysis) {
setIsSEOAnalysisModalOpen(true);
@@ -283,7 +289,7 @@ export const BlogWriter: React.FC = () => {
runSEOAnalysisDirect();
}
}
}, [navigateToPhase, seoAnalysis, runSEOAnalysisDirect, setIsSEOAnalysisModalOpen]);
}, [navigateToPhase, seoAnalysis, research, runSEOAnalysisDirect, setIsSEOAnalysisModalOpen]);
const outlineGenRef = useRef<any>(null);

View File

@@ -0,0 +1,245 @@
/**
* Blog Writer Cost Alerts Integration
*
* Example integration of Priority 2 alerts (cost estimation, trends, OSS recommendations)
* into the Blog Writer component.
*/
import React, { useEffect } from 'react';
import { Box, Alert, AlertTitle, Button, Collapse } from '@mui/material';
import { usePriority2Alerts, useCostEstimationAlert } from '../../../hooks/usePriority2Alerts';
import Priority2AlertBanner from '../../shared/Priority2AlertBanner';
import { useSubscription } from '../../../contexts/SubscriptionContext';
import { checkPreflight, PreflightOperation } from '../../../services/billingService';
import { showToastNotification } from '../../../utils/toastNotifications';
interface BlogWriterCostAlertsProps {
userId?: string;
onResearchStart?: () => void;
onOutlineStart?: () => void;
onContentStart?: () => void;
}
/**
* Blog Writer Cost Alerts Component
*
* Displays Priority 2 alerts and provides cost estimation before operations.
* Integrates with Blog Writer's research, outline, and content generation workflows.
*/
export const BlogWriterCostAlerts: React.FC<BlogWriterCostAlertsProps> = ({
userId,
onResearchStart,
onOutlineStart,
onContentStart,
}) => {
const { subscription } = useSubscription();
const { alerts, refreshAlerts, dismissAlert } = usePriority2Alerts({
userId,
enabled: !!userId && subscription?.active,
checkInterval: 120000, // Check every 2 minutes
});
const { showEstimationAlert } = useCostEstimationAlert();
// Estimate cost for blog generation workflow
const estimateBlogWorkflowCost = async (workflowType: 'research' | 'outline' | 'content') => {
if (!userId) return;
try {
const operations: PreflightOperation[] = [];
if (workflowType === 'research') {
// Research typically involves: 3-5 Exa searches + 1 LLM call for analysis
operations.push(
{
provider: 'exa',
operation_type: 'research',
tokens_requested: 0,
},
{
provider: 'gemini',
model: 'gemini-2.5-flash',
operation_type: 'research',
tokens_requested: 2000, // Estimated tokens for research analysis
}
);
} else if (workflowType === 'outline') {
// Outline generation: 1 LLM call
operations.push({
provider: 'gemini',
model: 'gemini-2.5-flash',
operation_type: 'outline_generation',
tokens_requested: 1500, // Estimated tokens for outline
});
} else if (workflowType === 'content') {
// Content generation: 2-3 LLM calls (one per section typically)
operations.push(
{
provider: 'gemini',
model: 'gemini-2.5-flash',
operation_type: 'content_generation',
tokens_requested: 3000, // Estimated tokens per section
},
{
provider: 'gemini',
model: 'gemini-2.5-flash',
operation_type: 'content_generation',
tokens_requested: 3000,
}
);
}
const preflightResult = await checkPreflight(operations[0]); // Check first operation
const estimatedCost = preflightResult.estimated_cost || 0;
if (estimatedCost > 0.01) {
showEstimationAlert(
estimatedCost,
`${workflowType} generation`,
() => {
// User confirmed - proceed with operation
if (workflowType === 'research' && onResearchStart) {
onResearchStart();
} else if (workflowType === 'outline' && onOutlineStart) {
onOutlineStart();
} else if (workflowType === 'content' && onContentStart) {
onContentStart();
}
},
() => {
showToastNotification('Operation cancelled', 'info');
}
);
}
} catch (error) {
console.error('[BlogWriterCostAlerts] Error estimating cost:', error);
// Don't block operation on estimation failure
}
};
// Filter alerts relevant to Blog Writer
const blogWriterAlerts = alerts.filter(alert =>
alert.type === 'cost_trend' ||
alert.type === 'oss_recommendation' ||
(alert.type === 'cost_estimation' && alert.message.includes('blog'))
);
return (
<Box sx={{ mb: 2 }}>
{/* Priority 2 Alert Banner */}
{blogWriterAlerts.length > 0 && (
<Priority2AlertBanner
alerts={blogWriterAlerts}
onDismiss={dismissAlert}
maxAlerts={2}
/>
)}
{/* Cost Estimation Info Alert */}
<Collapse in={blogWriterAlerts.length === 0}>
<Alert
severity="info"
icon={<></>}
sx={{
mb: 2,
backgroundColor: 'rgba(59, 130, 246, 0.1)',
border: '1px solid rgba(59, 130, 246, 0.2)',
}}
>
<AlertTitle sx={{ fontWeight: 'bold', mb: 0.5 }}>
💡 Cost Transparency
</AlertTitle>
<Box sx={{ fontSize: '0.875rem' }}>
Blog generation typically costs:
<ul style={{ margin: '8px 0', paddingLeft: '20px' }}>
<li><strong>Research</strong>: ~$0.01-0.02 (3-5 searches + analysis)</li>
<li><strong>Outline</strong>: ~$0.005-0.01 (1 LLM call)</li>
<li><strong>Content</strong>: ~$0.01-0.03 (2-3 LLM calls per section)</li>
</ul>
<strong>Total per blog</strong>: ~$0.03-0.06 (using OSS models)
</Box>
</Alert>
</Collapse>
</Box>
);
};
/**
* Hook for Blog Writer cost estimation
* Use this in Blog Writer components before triggering operations
*/
export const useBlogWriterCostEstimation = () => {
const { showEstimationAlert } = useCostEstimationAlert();
const estimateAndProceed = async (
workflowType: 'research' | 'outline' | 'content',
onProceed: () => void,
userId?: string
) => {
if (!userId) {
// No user ID - proceed without estimation
onProceed();
return;
}
try {
const operations: PreflightOperation[] = [];
// Define operations based on workflow type
if (workflowType === 'research') {
operations.push(
{ provider: 'exa', operation_type: 'research', tokens_requested: 0 },
{
provider: 'gemini',
model: 'gemini-2.5-flash',
operation_type: 'research',
tokens_requested: 2000
}
);
} else if (workflowType === 'outline') {
operations.push({
provider: 'gemini',
model: 'gemini-2.5-flash',
operation_type: 'outline_generation',
tokens_requested: 1500,
});
} else if (workflowType === 'content') {
operations.push(
{
provider: 'gemini',
model: 'gemini-2.5-flash',
operation_type: 'content_generation',
tokens_requested: 3000,
}
);
}
if (operations.length > 0) {
const preflightResult = await checkPreflight(operations[0]);
const estimatedCost = preflightResult.estimated_cost || 0;
if (estimatedCost > 0.01) {
showEstimationAlert(
estimatedCost,
`${workflowType} generation`,
onProceed,
() => showToastNotification('Operation cancelled', 'info')
);
} else {
// Low cost - proceed directly
onProceed();
}
} else {
onProceed();
}
} catch (error) {
console.error('[BlogWriterCostEstimation] Error:', error);
// On error, proceed anyway (don't block user)
onProceed();
}
};
return { estimateAndProceed };
};
export default BlogWriterCostAlerts;

View File

@@ -20,24 +20,24 @@ export const BlogWriterLandingSection: React.FC<BlogWriterLandingSectionProps> =
// Only show landing/initial content when no research exists
// Phase navigation header is always visible, so this is just the initial content
if (!research) {
// Show research form only when user explicitly navigated to research phase (clicked "Start Research")
if (currentPhase === 'research') {
return <ManualResearchForm onResearchComplete={onResearchComplete} />;
}
// Default: Always show landing page when no research exists
// This ensures landing page is shown on initial load
return (
<>
{/* Show manual research form when on research phase and CopilotKit unavailable */}
{!copilotKitAvailable && currentPhase === 'research' && (
<ManualResearchForm onResearchComplete={onResearchComplete} />
)}
{/* Show landing page for CopilotKit flow or when not on research phase */}
{(!copilotKitAvailable && currentPhase !== 'research') || copilotKitAvailable ? (
<BlogWriterLanding
onStartWriting={() => {
// Navigate to research phase to start the workflow
navigateToPhase('research');
}}
/>
) : null}
</>
<BlogWriterLanding
onStartWriting={() => {
// Navigate to research phase to show the research form
navigateToPhase('research');
}}
/>
);
}
// If research exists, don't show landing section (phase content will be shown instead)
return null;
};

View File

@@ -1,6 +1,6 @@
import React from 'react';
import {
useResearchPolling,
useBlogWriterResearchPolling,
useOutlinePolling,
useMediumGenerationPolling,
useRewritePolling,
@@ -24,8 +24,8 @@ export const useBlogWriterPolling = ({
onContentConfirmed,
navigateToPhase,
}: UseBlogWriterPollingProps) => {
// Research polling hook (for context awareness)
const researchPolling = useResearchPolling({
// Research polling hook (for context awareness) - uses blog writer endpoint
const researchPolling = useBlogWriterResearchPolling({
onComplete: onResearchComplete,
onError: (error) => console.error('Research polling error:', error)
});

View File

@@ -1,6 +1,6 @@
import React, { useState, useRef } from 'react';
import { blogWriterApi, BlogResearchRequest, BlogResearchResponse } from '../../services/blogWriterApi';
import { useResearchPolling } from '../../hooks/usePolling';
import { useBlogWriterResearchPolling } from '../../hooks/usePolling';
import ResearchProgressModal from './ResearchProgressModal';
import { researchCache } from '../../services/researchCache';
@@ -22,7 +22,7 @@ export const ManualResearchForm: React.FC<ManualResearchFormProps> = ({ onResear
const keywordsRef = useRef<HTMLInputElement | null>(null);
const blogLengthRef = useRef<HTMLSelectElement | null>(null);
const polling = useResearchPolling({
const polling = useBlogWriterResearchPolling({
onProgress: (message) => {
setCurrentMessage(message);
},

View File

@@ -66,6 +66,9 @@ export const PhaseNavigation: React.FC<PhaseNavigationProps> = ({
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 };
}
@@ -326,10 +329,10 @@ export const PhaseNavigation: React.FC<PhaseNavigationProps> = ({
// 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 if no research exists
// 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' && !hasResearch;
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 isOutlinePhase = phase.id === 'outline' && hasResearch && action.handler;
@@ -368,12 +371,12 @@ export const PhaseNavigation: React.FC<PhaseNavigationProps> = ({
// 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 ||
(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) ||
isResearchPhase ||
isOutlinePhase ||
isSEOPhase // Show SEO actions when handler exists - handler existence means prerequisites are met, so ignore isDisabled
(phase.id !== 'research' && (isResearchPhase || isOutlinePhase || isSEOPhase)) // Show for outline/SEO when appropriate
);
// Determine chip class

View File

@@ -1,7 +1,7 @@
import React, { useState, useRef, useEffect } from 'react';
import { useCopilotAction } from '@copilotkit/react-core';
import { blogWriterApi, BlogResearchRequest, BlogResearchResponse } from '../../services/blogWriterApi';
import { useResearchPolling } from '../../hooks/usePolling';
import { useBlogWriterResearchPolling } from '../../hooks/usePolling';
import ResearchProgressModal from './ResearchProgressModal';
import { researchCache } from '../../services/researchCache';
@@ -25,7 +25,7 @@ export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComple
// Track if we've navigated to research phase for this form display
const hasNavigatedRef = useRef<boolean>(false);
const polling = useResearchPolling({
const polling = useBlogWriterResearchPolling({
onProgress: (message) => {
setCurrentMessage(message);
setForceUpdate(prev => prev + 1); // Force re-render
@@ -128,40 +128,64 @@ export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComple
};
},
render: ({ status }: any) => {
const _ = forceUpdate;
// 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 = polling.currentStatus !== 'completed' &&
polling.currentStatus !== 'in_progress' &&
polling.currentStatus !== 'running';
if (isShowingForm && !hasNavigatedRef.current && navigateToPhase) {
// Use setTimeout to avoid calling during render
setTimeout(() => {
if (!hasNavigatedRef.current) {
navigateToPhase('research');
hasNavigatedRef.current = true;
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 || [];
}
}, 0);
}
if (polling.currentStatus === 'completed' && polling.progressMessages.length > 0) {
const latestMessage = polling.progressMessages[polling.progressMessages.length - 1];
} 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
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 (polling.currentStatus === 'in_progress' || polling.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 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>
);
}

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
import { useResearchPolling } from '../../hooks/usePolling';
import { useBlogWriterResearchPolling } from '../../hooks/usePolling';
import ResearchProgressModal from './ResearchProgressModal';
import { BlogResearchResponse } from '../../services/blogWriterApi';
import { researchCache } from '../../services/researchCache';
@@ -18,7 +18,7 @@ export const ResearchPollingHandler: React.FC<ResearchPollingHandlerProps> = ({
}) => {
const [currentMessage, setCurrentMessage] = useState<string>('');
const polling = useResearchPolling({
const polling = useBlogWriterResearchPolling({
onProgress: (message) => {
debug.log('[ResearchPollingHandler] progress', { message });
setCurrentMessage(message);

View File

@@ -1,4 +1,5 @@
import React, { useState, useEffect } from 'react';
import { useAuth } from '@clerk/clerk-react';
import { useLocation } from 'react-router-dom';
import {
Box,
@@ -130,6 +131,7 @@ const ContentPlanningDashboard: React.FC = () => {
}, [location.state]);
// Load dashboard data using orchestrator
// Note: ProtectedRoute ensures user is authenticated before this component renders
useEffect(() => {
const loadDashboardData = async () => {
setLoading(true);

View File

@@ -21,6 +21,7 @@ import { StrategyData } from '../components/StrategyIntelligence/types/strategy.
const ContentStrategyTab: React.FC = () => {
const location = useLocation();
// Use selective store subscriptions to prevent unnecessary re-renders
const strategies = useContentPlanningStore(state => state.strategies);
const currentStrategy = useContentPlanningStore(state => state.currentStrategy);
@@ -51,6 +52,7 @@ const ContentStrategyTab: React.FC = () => {
const [isFromStrategyBuilder, setIsFromStrategyBuilder] = useState(false);
// Load data on component mount
// Note: ProtectedRoute ensures user is authenticated before this component renders
useEffect(() => {
loadInitialData();
// eslint-disable-next-line react-hooks/exhaustive-deps

View File

@@ -1,10 +1,15 @@
import React, { useEffect, useMemo, useState, forwardRef, useImperativeHandle } from 'react';
import { Box, Button, MenuItem, Select, TextField, Typography, FormControl, InputLabel, Grid, Card, CardMedia, CircularProgress, LinearProgress, Collapse, IconButton, Tabs, Tab, Tooltip } from '@mui/material';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import {
Box, Button, MenuItem, Select, TextField, Typography, FormControl, InputLabel, Grid,
Card, CardMedia, CircularProgress, LinearProgress, Tabs, Tab,
Tooltip, Alert, Chip, IconButton
} from '@mui/material';
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
import InfoIcon from '@mui/icons-material/Info';
import { useImageGeneration, ImageGenerationRequest, fetchPromptSuggestions } from './useImageGeneration';
type Provider = 'gemini' | 'huggingface' | 'stability';
type Provider = 'huggingface' | 'stability' | 'wavespeed';
type ImageType = 'realistic' | 'chart' | 'conceptual' | 'diagram' | 'illustration' | 'background';
interface ImageGeneratorProps {
defaultProvider?: Provider;
@@ -30,60 +35,181 @@ interface ImageGeneratorProps {
export interface ImageGeneratorHandle {
suggest: () => Promise<void> | void;
generate: () => Promise<void> | void;
openAdvanced: () => void;
closeAdvanced: () => void;
}
export const ImageGenerator = React.forwardRef<ImageGeneratorHandle, ImageGeneratorProps>((
{ defaultProvider, defaultModel, defaultPrompt, onImageReady, context },
ref
) => {
const [provider, setProvider] = useState<Provider>(defaultProvider || (process.env.NEXT_PUBLIC_GPT_PROVIDER as Provider) || 'huggingface');
const [model, setModel] = useState<string>(defaultModel || 'black-forest-labs/FLUX.1-Krea-dev');
// Default to wavespeed for cost-effective blog images
const initialProvider = defaultProvider || 'wavespeed';
const [provider, setProvider] = useState<Provider>(initialProvider);
// Initialize model based on the actual provider
const getDefaultModelForProvider = (prov: Provider): string => {
if (prov === 'wavespeed') return 'qwen-image';
if (prov === 'huggingface') return 'black-forest-labs/FLUX.1-Krea-dev';
if (prov === 'stability') return 'stable-diffusion-xl-1024-v1-0';
return '';
};
const getAvailableModelsForProvider = (prov: Provider): string[] => {
if (prov === 'wavespeed') return ['qwen-image', 'ideogram-v3-turbo', 'flux-kontext-pro'];
if (prov === 'huggingface') return ['black-forest-labs/FLUX.1-Krea-dev', 'black-forest-labs/FLUX.1-dev', 'runwayml/flux-dev'];
if (prov === 'stability') return ['stable-diffusion-xl-1024-v1-0', 'stable-diffusion-xl-base-1.0'];
return [];
};
// Get max dimensions for a model
const getMaxDimensionsForModel = (modelName: string): { maxWidth: number; maxHeight: number } => {
const modelLower = modelName.toLowerCase();
// Wavespeed models have 1024x1024 max
if (modelLower === 'qwen-image' || modelLower === 'ideogram-v3-turbo' || modelLower === 'flux-kontext-pro') {
return { maxWidth: 1024, maxHeight: 1024 };
}
// HuggingFace and Stability models typically support higher resolutions
return { maxWidth: 2048, maxHeight: 2048 };
};
// Get model-specific tips and warnings
const getModelGuidance = (modelName: string, imgType: ImageType): { tips: string[]; warnings: string[]; recommendations: string } => {
const modelLower = modelName.toLowerCase();
const tips: string[] = [];
const warnings: string[] = [];
let recommendations = '';
if (modelLower === 'ideogram-v3-turbo') {
tips.push('Best for images with simple text overlays (3-5 words max)');
tips.push('Excellent photorealistic quality');
tips.push('Superior text rendering compared to other models');
if (imgType === 'chart' || imgType === 'diagram') {
warnings.push('Avoid complex infographics - use simple charts with designated text overlay areas');
recommendations = 'Create clean backgrounds with high-contrast zones for text placement, not embedded text';
}
if (imgType === 'conceptual' || imgType === 'background') {
recommendations = 'Design with text overlay zones in mind (top 20% or bottom 20% of image)';
}
} else if (modelLower === 'qwen-image') {
tips.push('Fast and cost-effective generation');
tips.push('Best for abstract concepts and simple compositions');
warnings.push('⚠️ Does NOT render readable text well - design for text overlay areas only');
warnings.push('Avoid requesting text, words, or labels in the image itself');
if (imgType === 'chart' || imgType === 'diagram') {
warnings.push('Use abstract representations of data, not actual charts with text');
recommendations = 'Create visual metaphors and patterns that represent data concepts';
}
recommendations = 'Design clean backgrounds with space for text overlays (never embed text)';
} else if (modelLower === 'flux-kontext-pro') {
tips.push('Excellent typography and text rendering capabilities');
tips.push('Improved prompt adherence for consistent results');
tips.push('Best for images with text elements, typography, and professional designs');
tips.push('Cost-effective at $0.04 per image');
if (imgType === 'chart' || imgType === 'diagram') {
tips.push('Can render simple charts with text labels effectively');
recommendations = 'Use for data visualizations that require clear text labels and typography';
} else if (imgType === 'realistic' || imgType === 'illustration') {
recommendations = 'Great for professional designs with text overlays or embedded typography';
} else {
recommendations = 'Ideal for blog images that need clear, readable text elements';
}
}
// Image type specific warnings
if (imgType === 'chart') {
warnings.push('Complex infographics are too difficult for current AI models');
recommendations = 'Use simple visual representations with designated text overlay areas';
}
return { tips, warnings, recommendations };
};
// Initialize model - ensure it's valid for the initial provider
const initialModel = defaultModel || getDefaultModelForProvider(initialProvider);
const [model, setModel] = useState<string>(initialModel);
const [imageType, setImageType] = useState<ImageType>('conceptual');
const [prompt, setPrompt] = useState<string>(defaultPrompt || '');
const [negative, setNegative] = useState<string>('');
const [width, setWidth] = useState<number>(1024);
const [height, setHeight] = useState<number>(1024);
const [showAdvanced, setShowAdvanced] = useState(false);
const { isGenerating, error, result, generate } = useImageGeneration();
const [loadingSuggestions, setLoadingSuggestions] = useState(false);
const [suggestions, setSuggestions] = useState<Array<{ prompt: string; negative_prompt?: string; width?: number; height?: number; overlay_text?: string }>>([]);
const [suggestionIndex, setSuggestionIndex] = useState<number>(0);
const canGenerate = useMemo(() => prompt.trim().length > 0 && !isGenerating, [prompt, isGenerating]);
const canOptimize = useMemo(() => prompt.trim().length > 0 && !loadingSuggestions, [prompt, loadingSuggestions]);
// High-contrast input styling for readability on light backgrounds
// Sync model when provider changes - ensure model is always valid for current provider
useEffect(() => {
const availableModels = getAvailableModelsForProvider(provider);
// Check if current model is valid for the new provider
if (!availableModels.includes(model)) {
// Model is not valid for this provider, set to default
const defaultModelForProvider = getDefaultModelForProvider(provider);
if (defaultModelForProvider) {
setModel(defaultModelForProvider);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [provider]); // Only depend on provider to avoid loops
// Clamp dimensions when model changes to ensure they don't exceed model limits
useEffect(() => {
const { maxWidth, maxHeight } = getMaxDimensionsForModel(model);
if (width > maxWidth) {
setWidth(maxWidth);
}
if (height > maxHeight) {
setHeight(maxHeight);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [model]); // Only depend on model to avoid loops
// Get current model guidance for display
const modelGuidance = useMemo(() => getModelGuidance(model, imageType), [model, imageType]);
// Professional styling with improved contrast and readability
const textInputSx = {
'& .MuiInputBase-input': { color: '#202124' },
'& .MuiInputLabel-root': { color: '#5f6368' },
'& .MuiOutlinedInput-notchedOutline': { borderColor: '#cbd5e1' },
'&:hover .MuiOutlinedInput-notchedOutline': { borderColor: '#94a3b8' },
'&.Mui-focused .MuiOutlinedInput-notchedOutline': { borderColor: '#1976d2' },
backgroundColor: '#ffffff'
'& .MuiInputBase-input': {
color: '#1a1a1a',
fontSize: '14px',
lineHeight: '1.5'
},
'& .MuiInputLabel-root': {
color: '#5f6368',
fontSize: '14px',
fontWeight: 500
},
'& .MuiOutlinedInput-notchedOutline': {
borderColor: '#dadce0',
borderWidth: '1.5px'
},
'&:hover .MuiOutlinedInput-notchedOutline': {
borderColor: '#80868b'
},
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
borderColor: '#1976d2',
borderWidth: '2px'
},
backgroundColor: '#ffffff',
'& .MuiFormHelperText-root': {
fontSize: '12px',
color: '#5f6368',
marginTop: '4px'
}
} as const;
// Default negative prompts by provider for blog writer use-case
useEffect(() => {
if (negative.trim().length > 0) return;
if (provider === 'huggingface') {
if (provider === 'wavespeed') {
setNegative('people posing, social media graphics, posters, text rendered as images, busy compositions, watermarks, brand logos, random people, cartoon, low quality, blurry, distorted');
} else if (provider === 'huggingface') {
setNegative('blurry, distorted, cartoon, low quality, bad anatomy, extra limbs, watermark, brand logos, text artifacts, oversaturated, noisy, jpeg artifacts');
} else if (provider === 'gemini') {
setNegative('cartoon, clip-art, abstract, noisy, low resolution, artifacts, watermark, brand logos, text artifacts');
} else {
setNegative('blurry, distorted, low quality, bad anatomy, extra limbs, watermark, brand logos, jpeg artifacts, oversharpened, text artifacts');
}
// run once on mount (and when provider changes if negative is empty)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [provider]);
// Auto-suggest on open for better defaults (only if no initial prompt)
useEffect(() => {
if (!prompt || prompt.trim().length === 0) {
// fire and forget; UI shows spinner on the button if user clicks again
suggestPrompt().catch(() => {});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [provider, negative]);
// Provider-specialized prompt suggestions using backend structured response; fallback locally
const suggestPrompt = async () => {
@@ -91,6 +217,8 @@ export const ImageGenerator = React.forwardRef<ImageGeneratorHandle, ImageGenera
try {
const payload = {
provider,
model,
image_type: imageType,
title: context?.title || context?.section?.heading || defaultPrompt || '',
section: context?.section || undefined,
research: context?.research || undefined,
@@ -130,6 +258,13 @@ export const ImageGenerator = React.forwardRef<ImageGeneratorHandle, ImageGenera
};
const onGenerate = async () => {
// Validate dimensions against model limits
const { maxWidth, maxHeight } = getMaxDimensionsForModel(model);
if (width > maxWidth || height > maxHeight) {
alert(`Resolution ${width}x${height} exceeds maximum ${maxWidth}x${maxHeight} for model ${model}. Please adjust the dimensions.`);
return;
}
const req: ImageGenerationRequest = { prompt, negative_prompt: negative, provider, model, width, height };
const res = await generate(req);
if (res && onImageReady) onImageReady(res.image_base64);
@@ -142,154 +277,634 @@ export const ImageGenerator = React.forwardRef<ImageGeneratorHandle, ImageGenera
useImperativeHandle(ref, () => ({
suggest: () => suggestPrompt(),
generate: () => onGenerate(),
openAdvanced: () => setShowAdvanced(v => !v),
closeAdvanced: () => setShowAdvanced(false)
generate: () => onGenerate()
}));
// Get cost info for display
const getCostInfo = () => {
if (provider === 'wavespeed') {
if (model === 'qwen-image') return { cost: '$0.05', description: 'Fast generation, optimized for blog content' };
if (model === 'ideogram-v3-turbo') return { cost: '$0.10', description: 'Superior text rendering, photorealistic' };
if (model === 'flux-kontext-pro') return { cost: '$0.04', description: 'Professional typography, improved prompt adherence' };
return { cost: '$0.05', description: 'Cost-effective blog images' };
}
if (provider === 'huggingface') {
return { cost: '~$0.08', description: 'Photorealistic Flux models' };
}
if (provider === 'stability') {
return { cost: '$0.04', description: 'SDXL-quality professional outputs' };
}
return { cost: 'Varies', description: 'Check provider pricing' };
};
const costInfo = getCostInfo();
return (
<Box>
<Typography variant="h6" sx={{ mb: 2, fontWeight: 600, color: '#202124' }}>Generate Blog Section Image</Typography>
{/* Advanced Options in Header Area */}
<Collapse in={showAdvanced}>
<Box sx={{ mb: 2, border: '1px solid #e0e0e0', borderRadius: 1, p: 1.5, backgroundColor: '#fafafa', color: '#202124' }}>
<Box sx={{
maxWidth: '900px',
mx: 'auto',
p: 3,
backgroundColor: '#ffffff',
borderRadius: '8px'
}}>
{/* Removed header - title is in modal header */}
{/* Cost Information Alert */}
{provider === 'wavespeed' && (
<Alert
severity="info"
icon={<InfoIcon />}
sx={{
mb: 2,
backgroundColor: '#e3f2fd',
'& .MuiAlert-icon': { color: '#1976d2' },
'& .MuiAlert-message': { color: '#1565c0' }
}}
>
<Typography variant="body2" sx={{ fontWeight: 500, mb: 0.5 }}>
💰 WaveSpeed Pricing (Cost-Effective for Blog Images)
</Typography>
<Grid container spacing={1} sx={{ mt: 0.5 }}>
<Grid item xs={4}>
<Typography variant="body2" sx={{ fontSize: '13px' }}>
<strong>Qwen Image:</strong> $0.05/image
</Typography>
<Typography variant="caption" sx={{ display: 'block', color: '#5f6368' }}>
Fast generation, optimized for blog content
</Typography>
</Grid>
<Grid item xs={4}>
<Typography variant="body2" sx={{ fontSize: '13px' }}>
<strong>Ideogram V3 Turbo:</strong> $0.10/image
</Typography>
<Typography variant="caption" sx={{ display: 'block', color: '#5f6368' }}>
Superior text rendering, photorealistic
</Typography>
</Grid>
<Grid item xs={4}>
<Typography variant="body2" sx={{ fontSize: '13px' }}>
<strong>FLUX Kontext Pro:</strong> $0.04/image
</Typography>
<Typography variant="caption" sx={{ display: 'block', color: '#5f6368' }}>
Professional typography, improved prompt adherence
</Typography>
</Grid>
</Grid>
</Alert>
)}
{/* Advanced Options - Always Visible */}
<Box sx={{
mb: 2,
p: 2,
border: '1.5px solid #e8eaed',
borderRadius: '6px',
backgroundColor: '#f8f9fa'
}}>
<Grid container spacing={2}>
<Grid item xs={12} md={3}>
<Tooltip title="Select the AI image generation provider. Hugging Face offers photorealistic Flux models, Gemini provides brand-safe editorial images, and Stability AI delivers SDXL-quality professional outputs." placement="top" arrow>
<FormControl fullWidth>
<InputLabel>Provider</InputLabel>
<Select value={provider} label="Provider" onChange={(e) => setProvider(e.target.value as Provider)} sx={textInputSx} MenuProps={{ PaperProps: { sx: { color: '#202124' } } }}>
<MenuItem value="huggingface">Hugging Face</MenuItem>
<MenuItem value="gemini">Gemini</MenuItem>
<MenuItem value="stability">Stability</MenuItem>
</Select>
</FormControl>
</Tooltip>
<Grid item xs={12} md={4}>
<FormControl fullWidth>
<InputLabel id="provider-select-label" sx={{ fontSize: '14px' }}>Provider</InputLabel>
<Select
labelId="provider-select-label"
value={provider}
label="Provider"
onChange={(e) => {
const newProvider = e.target.value as Provider;
setProvider(newProvider);
setModel(getDefaultModelForProvider(newProvider));
}}
sx={{
...textInputSx,
'& .MuiSelect-select': {
cursor: 'pointer'
}
}}
MenuProps={{
disablePortal: true,
PaperProps: {
sx: {
zIndex: 2200,
color: '#202124',
maxHeight: 300,
'& .MuiMenuItem-root': {
cursor: 'pointer',
'&:hover': {
backgroundColor: '#f5f5f5'
}
}
}
},
anchorOrigin: {
vertical: 'bottom',
horizontal: 'left',
},
transformOrigin: {
vertical: 'top',
horizontal: 'left',
}
}}
>
<MenuItem value="wavespeed">WaveSpeed AI</MenuItem>
<MenuItem value="huggingface">Hugging Face</MenuItem>
<MenuItem value="stability">Stability AI</MenuItem>
</Select>
</FormControl>
</Grid>
<Grid item xs={12} md={5}>
<Tooltip title="Specify the exact model to use. Leave empty to use the provider's default. For Hugging Face, the default is FLUX.1-Krea-dev, optimized for photorealistic blog images." placement="top" arrow>
<TextField fullWidth label="Model" value={model} onChange={(e) => setModel(e.target.value)} helperText={provider === 'huggingface' ? 'Default: black-forest-labs/FLUX.1-Krea-dev' : 'Leave empty to use provider default'} sx={textInputSx} />
<FormControl fullWidth>
<InputLabel id="model-select-label" sx={{ fontSize: '14px' }}>Model</InputLabel>
<Select
labelId="model-select-label"
value={model}
label="Model"
onChange={(e) => setModel(e.target.value)}
sx={{
...textInputSx,
'& .MuiSelect-select': {
cursor: 'pointer'
}
}}
MenuProps={{
disablePortal: true,
PaperProps: {
sx: {
zIndex: 2200,
color: '#202124',
maxHeight: 300,
'& .MuiMenuItem-root': {
cursor: 'pointer',
'&:hover': {
backgroundColor: '#f5f5f5'
}
}
}
},
anchorOrigin: {
vertical: 'bottom',
horizontal: 'left',
},
transformOrigin: {
vertical: 'top',
horizontal: 'left',
}
}}
>
{getAvailableModelsForProvider(provider).map((m) => (
<MenuItem key={m} value={m}>{m}</MenuItem>
))}
</Select>
<Typography variant="caption" sx={{ mt: 0.5, display: 'block', color: '#5f6368', fontSize: '12px' }}>
{provider === 'wavespeed'
? 'qwen-image ($0.05), ideogram-v3-turbo ($0.10), or flux-kontext-pro ($0.04)'
: provider === 'huggingface'
? 'Default: black-forest-labs/FLUX.1-Krea-dev'
: 'Default: stable-diffusion-xl-1024-v1-0'}
</Typography>
</FormControl>
</Grid>
<Grid item xs={12} md={3}>
<FormControl fullWidth>
<InputLabel id="image-type-select-label" sx={{ fontSize: '14px' }}>Image Type</InputLabel>
<Select
labelId="image-type-select-label"
value={imageType}
label="Image Type"
onChange={(e) => setImageType(e.target.value as ImageType)}
sx={{
...textInputSx,
'& .MuiSelect-select': {
cursor: 'pointer'
}
}}
MenuProps={{
disablePortal: true,
PaperProps: {
sx: {
zIndex: 2200,
color: '#202124',
maxHeight: 300,
'& .MuiMenuItem-root': {
cursor: 'pointer',
'&:hover': {
backgroundColor: '#f5f5f5'
}
}
}
}
}}
>
<MenuItem value="realistic">Realistic (Photography)</MenuItem>
<MenuItem value="chart">Chart/Data Visualization</MenuItem>
<MenuItem value="conceptual">Conceptual (Abstract)</MenuItem>
<MenuItem value="diagram">Diagram (Technical)</MenuItem>
<MenuItem value="illustration">Illustration (Stylized)</MenuItem>
<MenuItem value="background">Background (Text Overlay)</MenuItem>
</Select>
<Typography variant="caption" sx={{ mt: 0.5, display: 'block', color: '#5f6368', fontSize: '12px' }}>
Select the type of image you want to generate
</Typography>
</FormControl>
</Grid>
<Grid item xs={6} md={1.5}>
<Tooltip
title={`Image width in pixels. Max for ${model}: ${getMaxDimensionsForModel(model).maxWidth}px. Recommended: 1024 for square images, 1920 for landscape covers.`}
placement="top"
arrow
>
<TextField
fullWidth
type="number"
label="Width"
value={width}
onChange={(e) => {
const newWidth = parseInt(e.target.value || '0', 10);
const { maxWidth } = getMaxDimensionsForModel(model);
setWidth(Math.min(newWidth, maxWidth));
}}
inputProps={{ min: 64, max: getMaxDimensionsForModel(model).maxWidth }}
sx={textInputSx}
error={width > getMaxDimensionsForModel(model).maxWidth}
helperText={width > getMaxDimensionsForModel(model).maxWidth ? `Max: ${getMaxDimensionsForModel(model).maxWidth}px` : ''}
/>
</Tooltip>
</Grid>
<Grid item xs={6} md={2}>
<Tooltip title="Image width in pixels. Recommended: 1024 for square images, 1920 for landscape covers. Higher values increase quality but take longer to generate." placement="top" arrow>
<TextField fullWidth type="number" label="Width" value={width} onChange={(e) => setWidth(parseInt(e.target.value || '0', 10))} sx={textInputSx} />
</Tooltip>
</Grid>
<Grid item xs={6} md={2}>
<Tooltip title="Image height in pixels. Recommended: 1024 for square images, 1080 for portrait covers. Aspect ratio affects composition and visual appeal." placement="top" arrow>
<TextField fullWidth type="number" label="Height" value={height} onChange={(e) => setHeight(parseInt(e.target.value || '0', 10))} sx={textInputSx} />
<Grid item xs={6} md={1.5}>
<Tooltip
title={`Image height in pixels. Max for ${model}: ${getMaxDimensionsForModel(model).maxHeight}px. Recommended: 1024 for square images, 1080 for portrait covers.`}
placement="top"
arrow
>
<TextField
fullWidth
type="number"
label="Height"
value={height}
onChange={(e) => {
const newHeight = parseInt(e.target.value || '0', 10);
const { maxHeight } = getMaxDimensionsForModel(model);
setHeight(Math.min(newHeight, maxHeight));
}}
inputProps={{ min: 64, max: getMaxDimensionsForModel(model).maxHeight }}
sx={textInputSx}
error={height > getMaxDimensionsForModel(model).maxHeight}
helperText={height > getMaxDimensionsForModel(model).maxHeight ? `Max: ${getMaxDimensionsForModel(model).maxHeight}px` : ''}
/>
</Tooltip>
</Grid>
</Grid>
{/* Cost Chip */}
<Box sx={{ mt: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
<Chip
label={`Estimated Cost: ${costInfo.cost}/image`}
size="small"
color="primary"
variant="outlined"
sx={{ fontSize: '12px', fontWeight: 500 }}
/>
<Typography variant="caption" sx={{ color: '#5f6368' }}>
{costInfo.description}
</Typography>
</Box>
</Box>
</Collapse>
{/* Model-Specific Guidance */}
{(() => {
const guidance = modelGuidance;
if (guidance.tips.length === 0 && guidance.warnings.length === 0 && !guidance.recommendations) return null;
return (
<Box sx={{ mb: 2 }}>
{guidance.warnings.length > 0 && (
<Alert
severity="warning"
icon={<InfoIcon />}
sx={{
mb: 1,
backgroundColor: '#fff3cd',
'& .MuiAlert-icon': { color: '#856404' },
'& .MuiAlert-message': { color: '#856404' }
}}
>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5 }}>
Important Notes:
</Typography>
{guidance.warnings.map((warning, idx) => (
<Typography key={idx} variant="body2" sx={{ fontSize: '13px', mb: 0.5 }}>
{warning}
</Typography>
))}
</Alert>
)}
{guidance.tips.length > 0 && (
<Alert
severity="info"
icon={<InfoIcon />}
sx={{
mb: guidance.recommendations ? 1 : 0,
backgroundColor: '#e3f2fd',
'& .MuiAlert-icon': { color: '#1976d2' },
'& .MuiAlert-message': { color: '#1565c0' }
}}
>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5 }}>
💡 Best Practices for {model}:
</Typography>
{guidance.tips.map((tip, idx) => (
<Typography key={idx} variant="body2" sx={{ fontSize: '13px', mb: 0.5 }}>
{tip}
</Typography>
))}
</Alert>
)}
{guidance.recommendations && (
<Alert
severity="success"
icon={<InfoIcon />}
sx={{
backgroundColor: '#d4edda',
'& .MuiAlert-icon': { color: '#155724' },
'& .MuiAlert-message': { color: '#155724' }
}}
>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5 }}>
Recommendation:
</Typography>
<Typography variant="body2" sx={{ fontSize: '13px' }}>
{guidance.recommendations}
</Typography>
</Alert>
)}
</Box>
);
})()}
{/* Loading indicators */}
{loadingSuggestions && (
<Box sx={{ mb: 2 }}>
<Typography variant="caption" sx={{ mb: 0.5, display: 'block' }}>Loading suggestions...</Typography>
<LinearProgress />
<LinearProgress sx={{ height: 4, borderRadius: 2 }} />
<Typography variant="caption" sx={{ mt: 0.5, display: 'block', color: '#5f6368' }}>
Optimizing prompt...
</Typography>
</Box>
)}
{isGenerating && (
<Box sx={{ mb: 2 }}>
<Typography variant="caption" sx={{ mb: 0.5, display: 'block' }}>Generating image...</Typography>
<LinearProgress />
<LinearProgress sx={{ height: 4, borderRadius: 2 }} />
<Typography variant="caption" sx={{ mt: 0.5, display: 'block', color: '#5f6368' }}>
Generating image... This may take 10-30 seconds
</Typography>
</Box>
)}
{/* Prompt and Negative Prompt Side by Side - 80/20 split, stack on mobile */}
<Box sx={{ mb: 2, display: { xs: 'block', md: 'flex' }, gap: 2 }}>
<Tooltip title="Describe what you want in the image. Be specific: mention style (photorealistic, editorial, cinematic), subjects, composition, lighting, and mood. The AI uses this to generate your image. Tips: Include camera settings (e.g., '50mm lens, f/2.8'), lighting direction, and visual emphasis." placement="top" arrow>
{/* Prompt Input with Optimize Button Inside */}
<Box sx={{ mb: 2, position: 'relative' }}>
<Tooltip
title="Describe what you want in the image. Be specific: mention style (photorealistic, editorial, cinematic), subjects, composition, lighting, and mood. The AI uses this to generate your image."
placement="top"
arrow
>
<TextField
sx={{ flex: { md: '0 0 80%' }, width: { xs: '100%' }, mb: { xs: 2, md: 0 } }}
InputProps={{ sx: { color: '#202124' } }}
InputLabelProps={{ sx: { color: '#5f6368' } }}
FormHelperTextProps={{ sx: { color: '#5f6368' } }}
fullWidth
multiline
minRows={3}
minRows={4}
maxRows={8}
label="Prompt"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="Describe the image..."
onChange={(e) => setPrompt(e.target.value)}
placeholder="Describe the image you want to generate. Be specific about style, composition, and mood..."
sx={{
...textInputSx,
'& .MuiInputBase-root': {
paddingRight: '140px', // Make room for button
paddingBottom: '8px'
}
}}
helperText="Tip: Include camera settings (e.g., '50mm lens, f/2.8'), lighting direction, and visual emphasis for better results."
/>
</Tooltip>
<Tooltip title="List elements you want to avoid in the image (e.g., blurry, cartoon, watermark, low quality). This helps the AI exclude unwanted features. Common items: text artifacts, brand logos, distorted anatomy, oversaturation, noise." placement="top" arrow>
{/* Optimize Prompt Button - Positioned inside textarea */}
<Box sx={{
position: 'absolute',
bottom: '32px', // Position above helper text
right: '14px',
zIndex: 1
}}>
<Tooltip
title="Get AI-generated prompt suggestions optimized for blog images. Focuses on data visualization, infographics, clean layouts with text overlay areas, and conceptual illustrations."
placement="left"
arrow
>
<span>
<Button
variant="outlined"
size="small"
startIcon={loadingSuggestions ? <CircularProgress size={14} /> : <AutoFixHighIcon />}
onClick={suggestPrompt}
disabled={!canOptimize}
sx={{
minWidth: 'auto',
px: 1.5,
py: 0.5,
fontSize: '12px',
textTransform: 'none',
background: canOptimize
? 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'
: '#f5f5f5',
border: 'none',
color: canOptimize ? '#ffffff' : '#9aa0a6',
boxShadow: canOptimize
? '0 2px 8px rgba(102, 126, 234, 0.3)'
: 'none',
'&:hover': {
background: canOptimize
? 'linear-gradient(135deg, #764ba2 0%, #667eea 100%)'
: '#f5f5f5',
boxShadow: canOptimize
? '0 4px 12px rgba(102, 126, 234, 0.4)'
: 'none',
transform: canOptimize ? 'translateY(-1px)' : 'none'
},
'&:disabled': {
background: '#f5f5f5',
color: '#9aa0a6',
border: 'none'
},
transition: 'all 0.3s ease'
}}
>
{loadingSuggestions ? 'Optimizing...' : 'Optimize Prompt'}
</Button>
</span>
</Tooltip>
</Box>
</Box>
{/* Negative Prompt */}
<Box sx={{ mb: 3 }}>
<Tooltip
title="List elements you want to avoid in the image (e.g., blurry, cartoon, watermark, low quality). This helps the AI exclude unwanted features."
placement="top"
arrow
>
<TextField
sx={{ flex: { md: '0 0 20%' }, width: { xs: '100%' } }}
InputProps={{ sx: { color: '#202124' } }}
InputLabelProps={{ sx: { color: '#5f6368' } }}
fullWidth
multiline
minRows={3}
minRows={2}
maxRows={4}
label="Negative Prompt (optional)"
value={negative}
onChange={(e) => setNegative(e.target.value)}
onChange={(e) => setNegative(e.target.value)}
placeholder="Elements to avoid: blurry, distorted, watermark, low quality..."
sx={textInputSx}
helperText="Common exclusions: text artifacts, brand logos, distorted anatomy, oversaturation, noise"
/>
</Tooltip>
</Box>
{/* Action Buttons */}
<Grid container spacing={2}>
<Grid item xs={12}>
<Tooltip title="Get AI-generated prompt suggestions tailored to your blog section. Uses your section title, subheadings, key points, keywords, and research data to create hyper-personalized prompts optimized for your chosen provider. Click to see multiple suggestions in tabs." placement="top" arrow>
<span>
<Button sx={{ mr: 1 }} variant="outlined" onClick={suggestPrompt} disabled={loadingSuggestions}>{loadingSuggestions ? 'Suggesting…' : 'Suggest prompt'}</Button>
</span>
{/* Generate Button */}
<Box sx={{ mb: 2, display: 'flex', justifyContent: 'flex-end' }}>
<Tooltip
title="Generate the image using your current prompt and settings. The process may take 10-30 seconds depending on provider and image size."
placement="top"
arrow
>
<span>
<Button
variant="contained"
disabled={!canGenerate}
onClick={onGenerate}
startIcon={isGenerating ? <CircularProgress size={18} color="inherit" /> : undefined}
sx={{
px: 3,
py: 1.2,
fontSize: '14px',
fontWeight: 600,
textTransform: 'none',
background: canGenerate
? 'linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%)'
: 'linear-gradient(135deg, #e0e0e0 0%, #bdbdbd 100%)',
border: 'none',
color: canGenerate ? '#ffffff' : '#9e9e9e',
boxShadow: canGenerate
? '0 4px 15px rgba(102, 126, 234, 0.4)'
: 'none',
'&:hover': {
background: canGenerate
? 'linear-gradient(135deg, #764ba2 0%, #667eea 50%, #f093fb 100%)'
: 'linear-gradient(135deg, #e0e0e0 0%, #bdbdbd 100%)',
boxShadow: canGenerate
? '0 6px 20px rgba(102, 126, 234, 0.5)'
: 'none',
transform: canGenerate ? 'translateY(-2px)' : 'none'
},
'&:disabled': {
background: 'linear-gradient(135deg, #e0e0e0 0%, #bdbdbd 100%)',
color: '#9e9e9e',
boxShadow: 'none'
},
transition: 'all 0.3s ease'
}}
>
{isGenerating ? 'Generating…' : 'Generate Image'}
</Button>
</span>
</Tooltip>
</Box>
{/* Error Display */}
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
<Typography variant="body2">{error}</Typography>
</Alert>
)}
{/* Generated Image */}
{result && (
<Box sx={{ mb: 2 }}>
<Card sx={{
maxWidth: 512,
mx: 'auto',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
borderRadius: '8px',
overflow: 'hidden'
}}>
<CardMedia
component="img"
image={`data:image/png;base64,${result.image_base64}`}
alt="Generated image"
sx={{ width: '100%', height: 'auto' }}
/>
</Card>
</Box>
)}
{/* Prompt Suggestions Tabs */}
{suggestions.length > 0 && (
<Box sx={{ mt: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600, color: '#202124' }}>
Optimized Prompt Suggestions
</Typography>
<Tooltip
title="Browse through AI-generated prompt suggestions. Each tab shows a different prompt optimized for your section and provider. Click a tab to preview and auto-fill the prompt fields."
placement="top"
arrow
>
<Tabs
value={suggestionIndex}
onChange={(e, v) => {
setSuggestionIndex(v);
const s = suggestions[v];
if (s) {
setPrompt(s.prompt || '');
setNegative(s.negative_prompt || '');
if (s.width) setWidth(s.width);
if (s.height) setHeight(s.height);
}
}}
variant="scrollable"
scrollButtons="auto"
sx={{
borderBottom: '1px solid #e8eaed',
'& .MuiTab-root': {
textTransform: 'none',
fontSize: '13px',
fontWeight: 500,
minHeight: 40
}
}}
>
{suggestions.map((_, i) => (
<Tab key={i} label={`Suggestion ${i + 1}`} />
))}
</Tabs>
</Tooltip>
<Tooltip title="Generate the image using your current prompt and settings. The process may take 10-30 seconds depending on provider and image size. Once generated, the image will appear below and can be used for your blog section." placement="top" arrow>
<span>
<Button variant="contained" disabled={!canGenerate} onClick={onGenerate} startIcon={isGenerating ? <CircularProgress size={18} /> : undefined}>
{isGenerating ? 'Generating…' : 'Generate Image'}
</Button>
</span>
</Tooltip>
</Grid>
{error && (
<Grid item xs={12}>
<Typography color="error" variant="body2">{error}</Typography>
</Grid>
)}
{result && (
<Grid item xs={12}>
<Card sx={{ maxWidth: 512 }}>
<CardMedia component="img" image={`data:image/png;base64,${result.image_base64}`} alt="generated" />
</Card>
</Grid>
)}
{suggestions.length > 0 && (
<Grid item xs={12}>
<Tooltip title="Browse through AI-generated prompt suggestions. Each tab shows a different prompt optimized for your section and provider. Click a tab to preview and auto-fill the prompt fields. You can then modify or use it directly." placement="top" arrow>
<div>
<Tabs value={suggestionIndex} onChange={(e, v) => {
setSuggestionIndex(v);
const s = suggestions[v];
if (s) {
setPrompt(s.prompt || '');
setNegative(s.negative_prompt || '');
if (s.width) setWidth(s.width);
if (s.height) setHeight(s.height);
}
}} variant="scrollable" scrollButtons allowScrollButtonsMobile>
{suggestions.map((_, i) => (
<Tab key={i} label={`Prompt ${i + 1}`} />
))}
</Tabs>
</div>
</Tooltip>
<Tooltip title="Preview of the currently selected prompt suggestion. Shows the main prompt and negative prompt (if any). This preview updates when you click different tabs above." placement="top" arrow>
<Box sx={{ p: 2, border: '1px solid #e0e0e0', borderTop: 'none', borderRadius: '0 0 8px 8px', background: '#fff' }}>
<Typography variant="subtitle2" sx={{ mb: 1 }}>Preview</Typography>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', color: '#202124' }}>{suggestions[suggestionIndex]?.prompt}</Typography>
{suggestions[suggestionIndex]?.negative_prompt && (
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block', mt: 1 }}>Negative: {suggestions[suggestionIndex]?.negative_prompt}</Typography>
)}
<Box sx={{
p: 2,
border: '1px solid #e8eaed',
borderTop: 'none',
borderRadius: '0 0 8px 8px',
backgroundColor: '#f8f9fa'
}}>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', color: '#202124', mb: 1 }}>
{suggestions[suggestionIndex]?.prompt}
</Typography>
{suggestions[suggestionIndex]?.negative_prompt && (
<Box sx={{ mt: 1.5, pt: 1.5, borderTop: '1px solid #e8eaed' }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#5f6368', display: 'block', mb: 0.5 }}>
Negative Prompt:
</Typography>
<Typography variant="caption" sx={{ color: '#5f6368' }}>
{suggestions[suggestionIndex]?.negative_prompt}
</Typography>
</Box>
</Tooltip>
</Grid>
)}
</Grid>
)}
</Box>
</Box>
)}
</Box>
);
});

View File

@@ -65,34 +65,37 @@ const ImageGeneratorModal: React.FC<ImageGeneratorModalProps> = ({ isOpen, onClo
<div style={modalStyle} onClick={(e) => e.stopPropagation()}>
<div style={headerStyle}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<h3 style={{ margin: 0 }}>{sectionTitle}</h3>
<span style={{ fontSize: 12, color: '#5f6368' }}>Generate Blog Section Image</span>
<h3 style={{ margin: 0, fontSize: '18px', fontWeight: 600, color: '#202124' }}>{sectionTitle}</h3>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Tooltip title="Toggle advanced image generation settings. Opens provider selection (Hugging Face, Gemini, Stability AI), model specification, and image dimensions (width/height). Hover or click to show/hide these options." placement="bottom" arrow>
<button
onMouseEnter={() => imageRef.current?.openAdvanced()}
onClick={() => {
// toggle
if (imageRef.current) {
imageRef.current.openAdvanced();
}
}}
style={{ border: '1px solid #cbd5e1', background: '#ffffff', color: '#334155', borderRadius: 20, padding: '6px 12px', cursor: 'pointer', boxShadow: '0 1px 2px rgba(0,0,0,0.04)' }}
>
Advanced Image Options
</button>
</Tooltip>
<Tooltip title="Get AI-powered prompt suggestions tailored to your blog section. Uses section title, subheadings, key points, keywords, and research data to generate multiple hyper-personalized prompts. Suggestions appear as tabs below." placement="bottom" arrow>
<button
onClick={() => imageRef.current?.suggest()}
style={{ border: '1px solid #1976d2', background: '#fff', color: '#1976d2', borderRadius: 20, padding: '6px 12px', cursor: 'pointer' }}
>
Suggest Prompt
</button>
</Tooltip>
<Tooltip title="Close the image generator modal. Any generated images are saved and will appear in your blog section." placement="bottom" arrow>
<button onClick={onClose} style={{ border: '1px solid #ddd', background: '#f5f5f5', borderRadius: 6, padding: '6px 10px', cursor: 'pointer' }}>Close</button>
<button
onClick={onClose}
style={{
border: 'none',
background: 'linear-gradient(135deg, #f5f5f5 0%, #e0e0e0 100%)',
color: '#5f6368',
borderRadius: 8,
padding: '8px 20px',
cursor: 'pointer',
fontSize: '13px',
fontWeight: 500,
transition: 'all 0.2s ease',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'linear-gradient(135deg, #e8eaed 0%, #dadce0 100%)';
e.currentTarget.style.transform = 'translateY(-1px)';
e.currentTarget.style.boxShadow = '0 4px 8px rgba(0,0,0,0.15)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'linear-gradient(135deg, #f5f5f5 0%, #e0e0e0 100%)';
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = '0 2px 4px rgba(0,0,0,0.1)';
}}
>
Close
</button>
</Tooltip>
</div>
</div>

View File

@@ -4,7 +4,7 @@ import { apiClient } from '../../api/client';
export interface ImageGenerationRequest {
prompt: string;
negative_prompt?: string;
provider?: 'gemini' | 'huggingface' | 'stability';
provider?: 'gemini' | 'huggingface' | 'stability' | 'wavespeed';
model?: string;
width?: number;
height?: number;
@@ -31,12 +31,34 @@ export function useImageGeneration() {
const generate = useCallback(async (req: ImageGenerationRequest) => {
setIsGenerating(true);
setError(null);
setResult(null);
try {
const { data } = await apiClient.post<ImageGenerationResponse>('/api/images/generate', req);
setResult(data);
return data;
const response = await apiClient.post<ImageGenerationResponse>('/api/images/generate', req);
const data = response.data;
// Check if response has success field and image data
if (data && (data.success !== false) && data.image_base64) {
setResult(data);
setError(null);
return data;
} else {
// Response received but missing required data
const message = 'Image generation completed but response is incomplete';
setError(message);
throw new Error(message);
}
} catch (e: any) {
const message = e?.response?.data?.detail || e?.message || 'Image generation failed';
// Check if error response contains image data (partial success)
if (e?.response?.data?.image_base64) {
// Image was generated but there was an error in post-processing
const data = e.response.data;
console.warn('Image generation succeeded but post-processing had issues', data);
setResult(data);
setError(null);
return data;
}
const message = e?.response?.data?.detail || e?.response?.data?.message || e?.message || 'Image generation failed';
setError(message);
throw new Error(message);
} finally {
@@ -55,7 +77,15 @@ export interface PromptSuggestion {
overlay_text?: string;
}
export async function fetchPromptSuggestions(payload: any): Promise<PromptSuggestion[]> {
export async function fetchPromptSuggestions(payload: {
provider?: string;
model?: string;
image_type?: string;
title?: string;
section?: any;
research?: any;
persona?: any;
}): Promise<PromptSuggestion[]> {
// Use apiClient directly (same pattern as SEO analysis in SEOAnalysisModal.tsx)
// The apiClient interceptor will handle auth token injection automatically
const response = await apiClient.post('/api/images/suggest-prompts', payload);

View File

@@ -1,5 +1,5 @@
import React, { useState, useMemo, useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useSearchParams, useNavigate } from 'react-router-dom';
import {
Box,
Paper,
@@ -66,6 +66,7 @@ import {
} from '@mui/icons-material';
import { ImageStudioLayout } from './ImageStudioLayout';
import { useContentAssets, AssetFilters, ContentAsset } from '../../hooks/useContentAssets';
import { intentResearchApi } from '../../api/intentResearchApi';
interface TabPanelProps {
children?: React.ReactNode;
@@ -122,6 +123,7 @@ const getStatusChip = (status: string) => {
export const AssetLibrary: React.FC = () => {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
// Initialize filters from URL params if present
const urlSourceModule = searchParams.get('source_module');
@@ -201,6 +203,18 @@ export const AssetLibrary: React.FC = () => {
const { assets, loading, error, total, toggleFavorite, deleteAsset, trackUsage, refetch } = useContentAssets(filters);
// Refetch assets when component mounts with research_tools filter to show latest drafts
useEffect(() => {
if (urlSourceModule === 'research_tools' && urlAssetType === 'text') {
console.log('[AssetLibrary] Refetching assets for research_tools/text filter');
// Small delay to ensure any recent saves are complete
const timer = setTimeout(() => {
refetch();
}, 1000); // Increased delay to ensure save completes
return () => clearTimeout(timer);
}
}, [urlSourceModule, urlAssetType, refetch]);
const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => {
setTabValue(newValue);
setPage(0);
@@ -314,6 +328,36 @@ export const AssetLibrary: React.FC = () => {
setAnchorEl({ ...anchorEl, [assetId]: null });
};
const handleRestoreResearchProject = async (asset: ContentAsset) => {
try {
// Extract project_id from asset metadata
const projectId = asset.asset_metadata?.project_id;
if (!projectId) {
setSnackbar({ open: true, message: 'Project ID not found', severity: 'error' });
return;
}
// Load full project from database
const project = await intentResearchApi.getResearchProject(projectId);
if (!project) {
setSnackbar({ open: true, message: 'Project not found', severity: 'error' });
return;
}
// Store project ID for restoration hook to pick up
localStorage.setItem('alwrity_research_project_id', projectId);
// Navigate to Research Dashboard
navigate('/research-dashboard');
setSnackbar({ open: true, message: 'Research project restored', severity: 'success' });
} catch (error) {
console.error('[AssetLibrary] Error restoring research project:', error);
setSnackbar({ open: true, message: 'Failed to restore research project', severity: 'error' });
}
};
const formatDate = (dateString: string) => {
try {
const date = new Date(dateString);
@@ -1004,6 +1048,21 @@ export const AssetLibrary: React.FC = () => {
open={Boolean(anchorEl[asset.id])}
onClose={() => handleMenuClose(asset.id)}
>
{/* Restore Research Project option for research_tools assets */}
{asset.source_module === 'research_tools' && asset.asset_type === 'text' && asset.asset_metadata?.project_type === 'research_project' && (
<MenuItem
onClick={() => {
handleRestoreResearchProject(asset);
handleMenuClose(asset.id);
}}
sx={{ color: '#667eea' }}
>
<ListItemIcon>
<Box sx={{ color: '#667eea', fontSize: 20 }}>🔬</Box>
</ListItemIcon>
<ListItemText>Restore in Researcher</ListItemText>
</MenuItem>
)}
<MenuItem onClick={() => { handleFavorite(asset.id); handleMenuClose(asset.id); }}>
<ListItemIcon>
{asset.is_favorite ? <Favorite fontSize="small" /> : <FavoriteBorder fontSize="small" />}
@@ -1214,6 +1273,18 @@ export const AssetLibrary: React.FC = () => {
{formatDate(asset.created_at)}
</Typography>
<Stack direction="row" spacing={1} justifyContent="flex-end">
{/* Restore Research Project button for research_tools assets */}
{asset.source_module === 'research_tools' && asset.asset_type === 'text' && asset.asset_metadata?.project_type === 'research_project' && (
<Tooltip title="Restore in Researcher">
<IconButton
size="small"
onClick={() => handleRestoreResearchProject(asset)}
sx={{ color: '#667eea' }}
>
<Box sx={{ fontSize: 20 }}>🔬</Box>
</IconButton>
</Tooltip>
)}
<IconButton
size="small"
onClick={() => handleDownload(asset)}

View File

@@ -0,0 +1,310 @@
/**
* Image Studio Cost Alerts Integration
*
* Example integration of Priority 2 alerts (cost estimation, OSS recommendations)
* into the Image Studio Create Studio component.
*/
import React, { useEffect, useState } from 'react';
import { Box, Alert, AlertTitle, Button, Collapse, Chip, Stack, Typography } from '@mui/material';
import { usePriority2Alerts, useCostEstimationAlert } from '../../hooks/usePriority2Alerts';
import Priority2AlertBanner from '../shared/Priority2AlertBanner';
import { useSubscription } from '../../contexts/SubscriptionContext';
import { checkPreflight, PreflightOperation } from '../../services/billingService';
import { showToastNotification } from '../../utils/toastNotifications';
import { AttachMoney, Lightbulb, TrendingUp } from '@mui/icons-material';
interface CreateStudioCostAlertsProps {
userId?: string;
provider?: string;
model?: string;
numVariations?: number;
onGenerate?: () => void;
}
/**
* Image Studio Cost Alerts Component
*
* Displays Priority 2 alerts and provides cost estimation before image generation.
* Shows OSS model recommendations and cost comparisons.
*/
export const CreateStudioCostAlerts: React.FC<CreateStudioCostAlertsProps> = ({
userId,
provider = 'auto',
model,
numVariations = 1,
onGenerate,
}) => {
const { subscription } = useSubscription();
const { alerts, refreshAlerts, dismissAlert } = usePriority2Alerts({
userId,
enabled: !!userId && subscription?.active,
checkInterval: 120000,
});
const { showEstimationAlert } = useCostEstimationAlert();
const [estimatedCost, setEstimatedCost] = useState<number | null>(null);
const [ossRecommendation, setOssRecommendation] = useState<{
model: string;
savings: number;
currentCost: number;
} | null>(null);
// Estimate cost for image generation
useEffect(() => {
const estimateCost = async () => {
if (!userId || provider === 'auto') return;
try {
// Determine actual provider (default to wavespeed for OSS)
const actualProvider = provider === 'wavespeed' ? 'stability' : provider;
const actualModel = model || (provider === 'wavespeed' ? 'qwen-image' : 'stable-diffusion');
const operations: PreflightOperation[] = Array(numVariations).fill(null).map(() => ({
provider: actualProvider,
model: actualModel,
operation_type: 'image_generation',
tokens_requested: 0,
}));
if (operations.length > 0) {
const preflightResult = await checkPreflight(operations[0]);
const cost = (preflightResult.estimated_cost || 0) * numVariations;
setEstimatedCost(cost);
// Check if OSS alternative would be cheaper
if (provider !== 'wavespeed' && actualProvider === 'stability') {
// Compare with OSS model
const ossOperation: PreflightOperation = {
provider: 'stability',
model: 'qwen-image',
operation_type: 'image_generation',
tokens_requested: 0,
};
const ossResult = await checkPreflight(ossOperation);
const ossCost = (ossResult.estimated_cost || 0) * numVariations;
if (ossCost < cost) {
setOssRecommendation({
model: 'Qwen Image (OSS)',
savings: cost - ossCost,
currentCost: cost,
});
}
}
}
} catch (error) {
console.error('[CreateStudioCostAlerts] Error estimating cost:', error);
}
};
estimateCost();
}, [userId, provider, model, numVariations]);
const handleGenerateWithEstimation = async () => {
if (!estimatedCost || estimatedCost < 0.01) {
// Low cost - proceed directly
if (onGenerate) onGenerate();
return;
}
showEstimationAlert(
estimatedCost,
`image generation (${numVariations} image${numVariations > 1 ? 's' : ''})`,
() => {
if (onGenerate) onGenerate();
},
() => {
showToastNotification('Image generation cancelled', 'info');
}
);
};
// Filter alerts relevant to Image Studio
const imageStudioAlerts = alerts.filter(alert =>
alert.type === 'oss_recommendation' ||
alert.type === 'cost_trend' ||
(alert.type === 'cost_estimation' && alert.message.includes('image'))
);
// Get OSS model cost info
const getModelCost = (modelName: string): number => {
const costs: Record<string, number> = {
'qwen-image': 0.03,
'ideogram-v3-turbo': 0.05,
'stable-diffusion': 0.04,
'stability-ultra': 0.08,
'stability-core': 0.03,
};
return costs[modelName] || 0.04;
};
const currentModelCost = model ? getModelCost(model) : 0.03; // Default to OSS
const totalEstimatedCost = currentModelCost * numVariations;
return (
<Box sx={{ mb: 2 }}>
{/* Priority 2 Alert Banner */}
{imageStudioAlerts.length > 0 && (
<Priority2AlertBanner
alerts={imageStudioAlerts}
onDismiss={dismissAlert}
maxAlerts={2}
/>
)}
{/* Cost Estimation Display */}
<Collapse in={true}>
<Alert
severity="info"
icon={<AttachMoney />}
sx={{
mb: 2,
backgroundColor: 'rgba(59, 130, 246, 0.1)',
border: '1px solid rgba(59, 130, 246, 0.2)',
}}
>
<AlertTitle sx={{ fontWeight: 'bold', mb: 0.5, display: 'flex', alignItems: 'center', gap: 1 }}>
<AttachMoney sx={{ fontSize: 18 }} />
Estimated Cost
</AlertTitle>
<Stack spacing={1}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Typography variant="body2">
<strong>{numVariations} image{numVariations > 1 ? 's' : ''}</strong> using{' '}
<strong>{model || (provider === 'wavespeed' ? 'Qwen Image (OSS)' : 'Default')}</strong>:
</Typography>
<Chip
label={`$${totalEstimatedCost.toFixed(4)}`}
color="primary"
size="small"
sx={{ fontWeight: 'bold' }}
/>
</Box>
{/* OSS Recommendation */}
{ossRecommendation && (
<Box sx={{
mt: 1,
p: 1.5,
backgroundColor: 'rgba(251, 191, 36, 0.1)',
borderRadius: 1,
border: '1px solid rgba(251, 191, 36, 0.3)',
}}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
<Lightbulb sx={{ fontSize: 18, color: '#f59e0b' }} />
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>
💡 Cost Savings Opportunity
</Typography>
</Box>
<Typography variant="body2" sx={{ mb: 1 }}>
Switch to <strong>{ossRecommendation.model}</strong> to save{' '}
<strong>${ossRecommendation.savings.toFixed(4)}</strong> per generation
({((ossRecommendation.savings / ossRecommendation.currentCost) * 100).toFixed(0)}% savings).
</Typography>
<Button
size="small"
variant="outlined"
onClick={() => {
showToastNotification(
'OSS models are automatically used as defaults in Basic tier',
'info'
);
}}
sx={{ textTransform: 'none' }}
>
Learn More About OSS Models
</Button>
</Box>
)}
{/* Cost Breakdown */}
<Box sx={{ mt: 1, fontSize: '0.75rem', color: 'text.secondary' }}>
<Typography variant="caption">
Cost per image: ${currentModelCost.toFixed(4)} Total: ${totalEstimatedCost.toFixed(4)}
{subscription?.tier === 'basic' && ' (Basic tier uses OSS models by default)'}
</Typography>
</Box>
</Stack>
</Alert>
</Collapse>
{/* Generate Button with Cost Awareness */}
{onGenerate && (
<Button
variant="contained"
color="primary"
fullWidth
onClick={handleGenerateWithEstimation}
startIcon={<AttachMoney />}
sx={{ mt: 1 }}
>
Generate {numVariations} Image{numVariations > 1 ? 's' : ''}
{estimatedCost && estimatedCost > 0 && (
<Chip
label={`$${estimatedCost.toFixed(4)}`}
size="small"
sx={{ ml: 1, height: 20, fontSize: '0.7rem' }}
/>
)}
</Button>
)}
</Box>
);
};
/**
* Hook for Image Studio cost estimation
* Use this in Image Studio components before triggering image generation
*/
export const useImageStudioCostEstimation = () => {
const { showEstimationAlert } = useCostEstimationAlert();
const estimateAndGenerate = async (
provider: string,
model: string,
numVariations: number,
onGenerate: () => void,
userId?: string
) => {
if (!userId) {
onGenerate();
return;
}
try {
const actualProvider = provider === 'wavespeed' ? 'stability' : provider;
const operations: PreflightOperation[] = Array(numVariations).fill(null).map(() => ({
provider: actualProvider,
model: model || (provider === 'wavespeed' ? 'qwen-image' : 'stable-diffusion'),
operation_type: 'image_generation',
tokens_requested: 0,
}));
if (operations.length > 0) {
const preflightResult = await checkPreflight(operations[0]);
const estimatedCost = (preflightResult.estimated_cost || 0) * numVariations;
if (estimatedCost > 0.01) {
showEstimationAlert(
estimatedCost,
`image generation (${numVariations} image${numVariations > 1 ? 's' : ''})`,
onGenerate,
() => showToastNotification('Image generation cancelled', 'info')
);
} else {
onGenerate();
}
} else {
onGenerate();
}
} catch (error) {
console.error('[ImageStudioCostEstimation] Error:', error);
onGenerate();
}
};
return { estimateAndGenerate };
};
export default CreateStudioCostAlerts;

View File

@@ -38,7 +38,7 @@ import {
Info as InfoIcon,
Warning,
Psychology,
Search,
Search as SearchIcon,
FactCheck,
Edit,
Assistant,
@@ -49,6 +49,10 @@ import {
Business,
Group,
} from '@mui/icons-material';
import MenuBookIcon from '@mui/icons-material/MenuBook';
import ImageIcon from '@mui/icons-material/Image';
import VideoIcon from '@mui/icons-material/VideoLibrary';
import AudioIcon from '@mui/icons-material/Audiotrack';
import { useNavigate } from 'react-router-dom';
import { apiClient } from '../../api/client';
import { restoreNavigationState, saveCurrentPhaseForTool } from '../../utils/navigationState';
@@ -72,6 +76,11 @@ 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
};
}
@@ -350,9 +359,18 @@ const PricingPage: React.FC = () => {
<Typography variant="h3" component="h1" gutterBottom>
Choose Your Plan
</Typography>
<Typography variant="h6" color="text.secondary" sx={{ mb: 4 }}>
<Typography variant="h6" color="text.secondary" sx={{ mb: 2 }}>
Select the perfect plan for your AI content creation needs
</Typography>
<Alert severity="info" sx={{ maxWidth: 800, mx: 'auto', mb: 4, textAlign: 'left' }}>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5 }}>
💡 Perfect for Content Creators, Marketers, Solopreneurs & Startups
</Typography>
<Typography variant="caption">
All plans include access to every ALwrity tool. Limits reset monthly, and you're protected by automatic cost caps.
{yearlyBilling && ' Save 20% with yearly billing!'}
</Typography>
</Alert>
{/* Billing Toggle */}
<FormControlLabel
@@ -427,12 +445,12 @@ const PricingPage: React.FC = () => {
{/* Features */}
<List dense>
{/* Platform Access - Free & Basic */}
{/* All Tools Access - Free & Basic */}
{(plan.tier === 'free' || plan.tier === 'basic') && (
<>
<Divider sx={{ my: 1 }} />
<Typography variant="caption" color="text.secondary" sx={{ px: 2 }}>
Platform Access:
<Typography variant="caption" color="text.secondary" sx={{ px: 2, fontWeight: 600 }}>
✨ All ALwrity Tools Included:
</Typography>
<ListItem>
@@ -539,6 +557,66 @@ const PricingPage: React.FC = () => {
</Tooltip>
</Box>
</ListItem>
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<MenuBookIcon color="primary" fontSize="small" />
</ListItemIcon>
<ListItemText
primary="Story Writer"
secondary="Create stories with AI: outline, images, narration, and video"
/>
</ListItem>
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<AudioIcon color="primary" fontSize="small" />
</ListItemIcon>
<ListItemText
primary="Podcast Maker"
secondary="AI-powered research, scriptwriting, and voice narration"
/>
</ListItem>
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<ImageIcon color="primary" fontSize="small" />
</ListItemIcon>
<ListItemText
primary="Image Generator & Editor"
secondary="AI image creation and editing (background removal, inpainting)"
/>
</ListItem>
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<VideoIcon color="primary" fontSize="small" />
</ListItemIcon>
<ListItemText
primary="Video Studio & YouTube Creator"
secondary="AI video creation for social media and YouTube"
/>
</ListItem>
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<SearchIcon color="primary" fontSize="small" />
</ListItemIcon>
<ListItemText
primary="All SEO Tools & Dashboards"
secondary="Keyword research, content optimization, SEO analytics"
/>
</ListItem>
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<Timeline color="primary" fontSize="small" />
</ListItemIcon>
<ListItemText
primary="Content Planning & Strategy"
secondary="Content calendars, strategy planning, and analytics"
/>
</ListItem>
</>
)}
@@ -807,26 +885,32 @@ const PricingPage: React.FC = () => {
</Box>
</ListItem>
{/* Audio/Video for Pro & Enterprise */}
{(plan.tier === 'pro' || plan.tier === 'enterprise') && (
{/* Audio/Video for Basic, Pro & Enterprise */}
{(plan.tier === 'basic' || plan.tier === 'pro' || plan.tier === 'enterprise') && (
<>
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<Assistant color="secondary" fontSize="small" />
<AudioIcon color="primary" fontSize="small" />
</ListItemIcon>
<ListItemText
primary="Audio Generation"
secondary="AI-powered audio content creation and voice synthesis"
secondary={plan.tier === 'basic'
? "AI voice synthesis for podcasts, stories, and narration"
: "AI-powered audio content creation and voice synthesis"
}
/>
</ListItem>
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<Assistant color="secondary" fontSize="small" />
<VideoIcon color="primary" fontSize="small" />
</ListItemIcon>
<ListItemText
primary="Video Generation"
secondary="AI video creation with script writing and editing"
secondary={plan.tier === 'basic'
? "Create AI videos for YouTube, social media, and stories"
: "AI video creation with script writing and editing"
}
/>
</ListItem>
</>
@@ -875,32 +959,144 @@ const PricingPage: React.FC = () => {
</>
)}
{/* API Limits */}
{/* Usage Limits - User-Friendly Display */}
<Divider sx={{ my: 1 }} />
<Typography variant="caption" color="text.secondary" sx={{ px: 2 }}>
Monthly Limits:
<Typography variant="caption" color="text.secondary" sx={{ px: 2, fontWeight: 600 }}>
Monthly Usage Limits:
</Typography>
<ListItem>
<ListItemText
primary={`${plan.limits.gemini_calls} AI content generations`}
sx={{ '& .MuiListItemText-primary': { fontSize: '0.875rem' } }}
/>
</ListItem>
{/* For Basic tier, show unified AI text generation limit */}
{plan.tier === 'basic' && (
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<Psychology color="primary" fontSize="small" />
</ListItemIcon>
<ListItemText
primary="50 AI Text Generations"
secondary="~16-25 blog posts or ~25-50 social posts per month"
sx={{ '& .MuiListItemText-primary': { fontSize: '0.875rem', fontWeight: 500 } }}
/>
</ListItem>
)}
<ListItem>
<ListItemText
primary={`${plan.limits.openai_calls} Advanced AI calls`}
sx={{ '& .MuiListItemText-primary': { fontSize: '0.875rem' } }}
/>
</ListItem>
{/* For other tiers, show provider-specific limits */}
{plan.tier !== 'basic' && (
<>
{plan.limits.gemini_calls > 0 && (
<ListItem>
<ListItemText
primary={`${plan.limits.gemini_calls === 0 ? '∞' : plan.limits.gemini_calls} Gemini AI calls`}
sx={{ '& .MuiListItemText-primary': { fontSize: '0.875rem' } }}
/>
</ListItem>
)}
{plan.limits.openai_calls > 0 && (
<ListItem>
<ListItemText
primary={`${plan.limits.openai_calls === 0 ? '∞' : plan.limits.openai_calls} OpenAI calls`}
sx={{ '& .MuiListItemText-primary': { fontSize: '0.875rem' } }}
/>
</ListItem>
)}
</>
)}
<ListItem>
<ListItemText
primary={`${plan.limits.tavily_calls} Research queries`}
sx={{ '& .MuiListItemText-primary': { fontSize: '0.875rem' } }}
/>
</ListItem>
{/* Image Generation */}
{plan.limits.stability_calls > 0 && (
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<ImageIcon color="primary" fontSize="small" />
</ListItemIcon>
<ListItemText
primary={`${plan.limits.stability_calls} AI Images`}
secondary={plan.tier === 'basic' ? "Powered by open-source models (25% cost savings)" : undefined}
sx={{ '& .MuiListItemText-primary': { fontSize: '0.875rem', fontWeight: plan.tier === 'basic' ? 500 : 400 } }}
/>
</ListItem>
)}
{/* Image Editing */}
{(plan.limits.image_edit_calls ?? 0) > 0 && (
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<Edit color="primary" fontSize="small" />
</ListItemIcon>
<ListItemText
primary={`${plan.limits.image_edit_calls ?? 0} Image Edits`}
secondary={plan.tier === 'basic' ? "Background removal, inpainting, recolor (50% cost savings with OSS)" : undefined}
sx={{ '& .MuiListItemText-primary': { fontSize: '0.875rem', fontWeight: plan.tier === 'basic' ? 500 : 400 } }}
/>
</ListItem>
)}
{/* Video Generation */}
{(plan.limits.video_calls ?? 0) > 0 && (
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<VideoIcon color="primary" fontSize="small" />
</ListItemIcon>
<ListItemText
primary={`${plan.limits.video_calls ?? 0} AI Videos`}
secondary={plan.tier === 'basic' ? "~5-6 full video projects (5 scenes each) per month" : undefined}
sx={{ '& .MuiListItemText-primary': { fontSize: '0.875rem', fontWeight: plan.tier === 'basic' ? 500 : 400 } }}
/>
</ListItem>
)}
{/* Audio Generation */}
{(plan.limits.audio_calls ?? 0) > 0 && (
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<AudioIcon color="primary" fontSize="small" />
</ListItemIcon>
<ListItemText
primary={`${plan.limits.audio_calls ?? 0} Audio Generations`}
secondary={plan.tier === 'basic' ? "Podcast narration, story audio, voice synthesis" : undefined}
sx={{ '& .MuiListItemText-primary': { fontSize: '0.875rem', fontWeight: plan.tier === 'basic' ? 500 : 400 } }}
/>
</ListItem>
)}
{/* Research Queries */}
{plan.limits.tavily_calls > 0 && (
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<SearchIcon color="primary" fontSize="small" />
</ListItemIcon>
<ListItemText
primary={`${plan.limits.tavily_calls} Research Searches`}
secondary="Web research, fact-checking, content discovery"
sx={{ '& .MuiListItemText-primary': { fontSize: '0.875rem' } }}
/>
</ListItem>
)}
{/* Cost Cap Protection */}
{plan.limits.monthly_cost > 0 && (
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<Verified color="success" fontSize="small" />
</ListItemIcon>
<ListItemText
primary={`$${plan.limits.monthly_cost} Monthly Cost Cap`}
secondary="Automatic protection - you'll never exceed this amount"
sx={{ '& .MuiListItemText-primary': { fontSize: '0.875rem', fontWeight: 500, color: 'success.main' } }}
/>
</ListItem>
)}
{/* OSS Model Notice for Basic Tier */}
{plan.tier === 'basic' && (
<Box sx={{ mt: 1, p: 1.5, bgcolor: 'info.lighter', borderRadius: 1, mx: 2 }}>
<Typography variant="caption" sx={{ display: 'flex', alignItems: 'center', gap: 0.5, fontWeight: 500 }}>
<StarIcon fontSize="small" sx={{ color: 'info.main' }} />
Powered by Open-Source AI Models
</Typography>
<Typography variant="caption" sx={{ display: 'block', mt: 0.5, color: 'text.secondary' }}>
We use cost-effective open-source models to give you more value. 25-50% savings vs proprietary models.
</Typography>
</Box>
)}
</List>
</CardContent>

View File

@@ -30,14 +30,14 @@ import { motion } from 'framer-motion';
import { ImageStudioLayout } from '../ImageStudio/ImageStudioLayout';
import { GlassyCard } from '../ImageStudio/ui/GlassyCard';
import { SectionHeader } from '../ImageStudio/ui/SectionHeader';
import { useProductMarketing } from '../../hooks/useProductMarketing';
import { useCampaignCreator } from '../../hooks/useCampaignCreator';
interface AssetAuditPanelProps {
onClose: () => void;
}
export const AssetAuditPanel: React.FC<AssetAuditPanelProps> = ({ onClose }) => {
const { auditAsset, auditResult, isAuditing, error, clearAuditResult } = useProductMarketing();
const { auditAsset, auditResult, isAuditing, error, clearAuditResult } = useCampaignCreator();
const [dragActive, setDragActive] = useState(false);
const [uploadedImage, setUploadedImage] = useState<string | null>(null);
const fileInputRef = React.useRef<HTMLInputElement>(null);

View File

@@ -0,0 +1,252 @@
/**
* Campaign Preview Component
* Shows a visual preview of what the campaign will look like based on user selections.
*/
import React from 'react';
import {
Box,
Paper,
Typography,
Stack,
Chip,
Grid,
Divider,
} from '@mui/material';
import {
Campaign,
TrendingUp,
PhotoLibrary,
Description,
VideoLibrary,
} from '@mui/icons-material';
interface CampaignPreviewProps {
campaignName: string;
goal: string;
kpi?: string;
channels: string[];
productName?: string;
productDescription?: string;
goalOptions: Array<{ value: string; label: string; description: string }>;
channelOptions: Array<{ value: string; label: string; icon: string }>;
}
export const CampaignPreview: React.FC<CampaignPreviewProps> = ({
campaignName,
goal,
kpi,
channels,
productName,
productDescription,
goalOptions,
channelOptions,
}) => {
const goalLabel = goalOptions.find((g) => g.value === goal)?.label || goal;
// Estimate asset counts per channel (rough estimate)
const estimatedAssetsPerChannel = {
instagram: { images: 3, videos: 1, text: 2 },
linkedin: { images: 2, videos: 1, text: 3 },
facebook: { images: 2, videos: 1, text: 2 },
tiktok: { images: 1, videos: 2, text: 1 },
twitter: { images: 2, videos: 0, text: 3 },
pinterest: { images: 4, videos: 0, text: 1 },
youtube: { images: 1, videos: 1, text: 2 },
};
const totalAssets = channels.reduce(
(acc, channel) => {
const counts = estimatedAssetsPerChannel[channel as keyof typeof estimatedAssetsPerChannel] || {
images: 2,
videos: 1,
text: 2,
};
return {
images: acc.images + counts.images,
videos: acc.videos + counts.videos,
text: acc.text + counts.text,
};
},
{ images: 0, videos: 0, text: 0 }
);
return (
<Paper
sx={{
p: 3,
background: 'rgba(124, 58, 237, 0.05)',
border: '1px solid rgba(124, 58, 237, 0.2)',
borderRadius: 2,
}}
>
<Stack spacing={3}>
<Box display="flex" alignItems="center" gap={1}>
<Campaign sx={{ color: '#c4b5fd' }} />
<Typography variant="h6" fontWeight={700}>
Campaign Preview
</Typography>
</Box>
{/* Campaign Overview */}
<Box>
<Typography variant="body2" color="text.secondary" gutterBottom>
Campaign Name
</Typography>
<Typography variant="h6">{campaignName || 'Untitled Campaign'}</Typography>
</Box>
<Divider sx={{ borderColor: 'rgba(255,255,255,0.1)' }} />
{/* Goal */}
<Box>
<Typography variant="body2" color="text.secondary" gutterBottom>
Goal
</Typography>
<Box display="flex" alignItems="center" gap={1}>
<TrendingUp sx={{ fontSize: 20, color: '#c4b5fd' }} />
<Typography variant="body1" fontWeight={600}>
{goalLabel}
</Typography>
</Box>
</Box>
{kpi && (
<>
<Divider sx={{ borderColor: 'rgba(255,255,255,0.1)' }} />
<Box>
<Typography variant="body2" color="text.secondary" gutterBottom>
Success Metric
</Typography>
<Typography variant="body2">{kpi}</Typography>
</Box>
</>
)}
{/* Platforms */}
<Box>
<Typography variant="body2" color="text.secondary" gutterBottom>
Platforms ({channels.length})
</Typography>
<Box display="flex" gap={1} flexWrap="wrap">
{channels.map((channel) => {
const channelInfo = channelOptions.find((c) => c.value === channel);
return (
<Chip
key={channel}
icon={<span>{channelInfo?.icon || '📱'}</span>}
label={channelInfo?.label || channel}
size="small"
sx={{
background: 'rgba(124, 58, 237, 0.2)',
color: '#c4b5fd',
border: '1px solid rgba(124, 58, 237, 0.3)',
}}
/>
);
})}
</Box>
</Box>
{/* Product Info */}
{productName && (
<>
<Divider sx={{ borderColor: 'rgba(255,255,255,0.1)' }} />
<Box>
<Typography variant="body2" color="text.secondary" gutterBottom>
Product
</Typography>
<Typography variant="body1" fontWeight={600}>
{productName}
</Typography>
{productDescription && (
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
{productDescription.substring(0, 100)}
{productDescription.length > 100 ? '...' : ''}
</Typography>
)}
</Box>
</>
)}
{/* Estimated Content */}
<Divider sx={{ borderColor: 'rgba(255,255,255,0.1)' }} />
<Box>
<Typography variant="body2" color="text.secondary" gutterBottom>
Estimated Content Pieces
</Typography>
<Grid container spacing={2} sx={{ mt: 1 }}>
<Grid item xs={4}>
<Paper
sx={{
p: 2,
textAlign: 'center',
background: 'rgba(59, 130, 246, 0.1)',
border: '1px solid rgba(59, 130, 246, 0.2)',
}}
>
<PhotoLibrary sx={{ color: '#93c5fd', fontSize: 32, mb: 1 }} />
<Typography variant="h5" fontWeight={700}>
{totalAssets.images}
</Typography>
<Typography variant="caption" color="text.secondary">
Images
</Typography>
</Paper>
</Grid>
<Grid item xs={4}>
<Paper
sx={{
p: 2,
textAlign: 'center',
background: 'rgba(16, 185, 129, 0.1)',
border: '1px solid rgba(16, 185, 129, 0.2)',
}}
>
<VideoLibrary sx={{ color: '#6ee7b7', fontSize: 32, mb: 1 }} />
<Typography variant="h5" fontWeight={700}>
{totalAssets.videos}
</Typography>
<Typography variant="caption" color="text.secondary">
Videos
</Typography>
</Paper>
</Grid>
<Grid item xs={4}>
<Paper
sx={{
p: 2,
textAlign: 'center',
background: 'rgba(251, 191, 36, 0.1)',
border: '1px solid rgba(251, 191, 36, 0.2)',
}}
>
<Description sx={{ color: '#fbbf24', fontSize: 32, mb: 1 }} />
<Typography variant="h5" fontWeight={700}>
{totalAssets.text}
</Typography>
<Typography variant="caption" color="text.secondary">
Text Posts
</Typography>
</Paper>
</Grid>
</Grid>
</Box>
{/* Preview Note */}
<Box
sx={{
p: 2,
background: 'rgba(124, 58, 237, 0.1)',
borderRadius: 1,
border: '1px dashed rgba(124, 58, 237, 0.3)',
}}
>
<Typography variant="caption" color="text.secondary">
💡 AI will generate personalized content for each platform based on your brand style and campaign goal.
</Typography>
</Box>
</Stack>
</Paper>
);
};

View File

@@ -19,6 +19,8 @@ import {
CircularProgress,
Divider,
Grid,
Tooltip,
IconButton,
} from '@mui/material';
import {
ArrowBack,
@@ -34,7 +36,10 @@ import { GlassyCard } from '../ImageStudio/ui/GlassyCard';
import { SectionHeader } from '../ImageStudio/ui/SectionHeader';
import { CampaignFlowIndicator } from './CampaignFlowIndicator';
import { PreflightValidationAlert } from './PreflightValidationAlert';
import { useProductMarketing } from '../../hooks/useProductMarketing';
import { CampaignPreview } from './CampaignPreview';
import { useCampaignCreator } from '../../hooks/useCampaignCreator';
import { getSimpleTerm, getTooltipText, getTermExamples, getTermDescription } from '../../utils/terminology';
import { Info as InfoIcon } from '@mui/icons-material';
const MotionBox = motion(Box);
@@ -44,9 +49,9 @@ interface CampaignWizardProps {
}
const steps = [
'Campaign Goal & KPI',
'Select Channels',
'Product Context',
'Campaign Goal & Success Metric',
'Select Platforms',
'Product Information',
'Review & Create',
];
@@ -79,7 +84,9 @@ export const CampaignWizard: React.FC<CampaignWizardProps> = ({ onComplete, onCa
validateCampaignPreflight,
preflightResult,
isValidatingPreflight,
} = useProductMarketing();
getPersonalizedDefaults,
getRecommendations,
} = useCampaignCreator();
const [activeStep, setActiveStep] = useState(0);
const [campaignName, setCampaignName] = useState('');
const [goal, setGoal] = useState('');
@@ -94,7 +101,26 @@ export const CampaignWizard: React.FC<CampaignWizardProps> = ({ onComplete, onCa
if (!brandDNA) {
getBrandDNA();
}
}, [brandDNA, getBrandDNA]);
// Load personalized defaults for campaign creator
getPersonalizedDefaults('campaign_creator')
.then((defaults) => {
if (defaults) {
// Pre-select recommended channels
if (defaults.channels && defaults.channels.length > 0) {
setSelectedChannels(defaults.channels);
}
// Pre-select goal if available
if (defaults.goal) {
setGoal(defaults.goal);
}
}
})
.catch((err) => {
console.warn('Failed to load personalized defaults:', err);
// Continue without defaults
});
}, [brandDNA, getBrandDNA, getPersonalizedDefaults]);
// Run pre-flight validation when on review step (step 3) and we have all required data
useEffect(() => {
@@ -203,11 +229,11 @@ export const CampaignWizard: React.FC<CampaignWizardProps> = ({ onComplete, onCa
)}
<Stepper activeStep={activeStep} orientation="vertical">
{/* Step 1: Campaign Goal & KPI */}
{/* Step 1: Campaign Goal & Success Metric */}
<Step>
<StepLabel>
<Typography variant="h6" fontWeight={700}>
Campaign Goal & KPI
Campaign Goal & Success Metric
</Typography>
</StepLabel>
<StepContent>
@@ -238,13 +264,40 @@ export const CampaignWizard: React.FC<CampaignWizardProps> = ({ onComplete, onCa
</FormControl>
<TextField
label="Key Performance Indicator (Optional)"
label={getSimpleTerm('KPI')}
value={kpi}
onChange={(e) => setKpi(e.target.value)}
fullWidth
placeholder="e.g., 10,000 impressions, 500 sign-ups"
helperText="How will you measure campaign success?"
helperText={getTermDescription('KPI')}
InputProps={{
endAdornment: (
<Tooltip title={getTooltipText('KPI')}>
<IconButton size="small" edge="end">
<InfoIcon fontSize="small" />
</IconButton>
</Tooltip>
),
}}
/>
{getTermExamples('KPI') && (
<Box sx={{ mt: 1 }}>
<Typography variant="caption" color="text.secondary" sx={{ mb: 0.5, display: 'block' }}>
Examples:
</Typography>
<Stack direction="row" spacing={1} flexWrap="wrap">
{getTermExamples('KPI')?.map((example, idx) => (
<Chip
key={idx}
label={example}
size="small"
onClick={() => setKpi(example)}
sx={{ cursor: 'pointer' }}
/>
))}
</Stack>
</Box>
)}
{brandDNA && (
<Alert severity="info">
@@ -267,17 +320,17 @@ export const CampaignWizard: React.FC<CampaignWizardProps> = ({ onComplete, onCa
</StepContent>
</Step>
{/* Step 2: Select Channels */}
{/* Step 2: Select Platforms */}
<Step>
<StepLabel>
<Typography variant="h6" fontWeight={700}>
Select Channels
Select Platforms
</Typography>
</StepLabel>
<StepContent>
<Stack spacing={3}>
<Typography variant="body2" color="text.secondary">
Select the platforms where you want to publish your campaign. AI will generate platform-optimized assets for each.
Select the platforms where you want to publish your campaign. AI will generate platform-optimized content for each.
</Typography>
<Grid container spacing={2}>
@@ -316,7 +369,7 @@ export const CampaignWizard: React.FC<CampaignWizardProps> = ({ onComplete, onCa
{selectedChannels.length > 0 && (
<Alert severity="success">
{selectedChannels.length} channel(s) selected. AI will generate optimized assets for each platform.
{selectedChannels.length} platform(s) selected. AI will generate optimized content for each platform.
</Alert>
)}
@@ -330,19 +383,33 @@ export const CampaignWizard: React.FC<CampaignWizardProps> = ({ onComplete, onCa
</StepContent>
</Step>
{/* Step 3: Product Context */}
{/* Step 3: Product Information */}
<Step>
<StepLabel>
<Typography variant="h6" fontWeight={700}>
Product Context
Product Information
</Typography>
</StepLabel>
<StepContent>
<Stack spacing={3}>
<Typography variant="body2" color="text.secondary">
Provide information about your product. This helps AI generate more accurate and relevant marketing assets.
Provide information about your product. This helps AI generate more accurate and relevant marketing content.
</Typography>
{/* Campaign Preview */}
{campaignName && goal && selectedChannels.length > 0 && (
<CampaignPreview
campaignName={campaignName}
goal={goal}
kpi={kpi}
channels={selectedChannels}
productName={productName}
productDescription={productDescription}
goalOptions={goalOptions}
channelOptions={channelOptions}
/>
)}
<TextField
label="Product Name"
value={productName}
@@ -415,7 +482,7 @@ export const CampaignWizard: React.FC<CampaignWizardProps> = ({ onComplete, onCa
<Divider sx={{ borderColor: 'rgba(255,255,255,0.08)' }} />
<Box>
<Typography variant="body2" color="text.secondary" gutterBottom>
KPI
{getSimpleTerm('KPI')}
</Typography>
<Typography variant="body1">{kpi}</Typography>
</Box>
@@ -467,7 +534,7 @@ export const CampaignWizard: React.FC<CampaignWizardProps> = ({ onComplete, onCa
Next Steps
</Typography>
<Typography variant="body2">
After creating the blueprint, AI will automatically generate personalized asset proposals. You'll then review and approve them before assets are generated.
After creating your campaign, AI will automatically generate personalized content ideas. You'll then review and approve them before content is generated.
</Typography>
</Alert>

View File

@@ -27,7 +27,7 @@ import {
import { motion } from 'framer-motion';
import { GlassyCard } from '../ImageStudio/ui/GlassyCard';
import { SectionHeader } from '../ImageStudio/ui/SectionHeader';
import { useProductMarketing } from '../../hooks/useProductMarketing';
import { useCampaignCreator } from '../../hooks/useCampaignCreator';
interface ChannelPackBuilderProps {
channels: string[];
@@ -48,7 +48,7 @@ export const ChannelPackBuilder: React.FC<ChannelPackBuilderProps> = ({
channels,
onChannelPackReady,
}) => {
const { getChannelPack, channelPack, isLoadingChannelPack, error } = useProductMarketing();
const { getChannelPack, channelPack, isLoadingChannelPack, error } = useCampaignCreator();
const [selectedChannel, setSelectedChannel] = useState<string>(channels[0] || 'instagram');
const [channelPacks, setChannelPacks] = useState<Record<string, any>>({});

View File

@@ -0,0 +1,237 @@
/**
* Personalized Recommendations Component
* Shows recommendations based on user's onboarding data and preferences.
*/
import React, { useEffect, useState } from 'react';
import {
Box,
Paper,
Typography,
Stack,
Chip,
Grid,
Card,
CardContent,
Button,
CircularProgress,
Alert,
} from '@mui/material';
import {
AutoAwesome,
PhotoLibrary,
VideoLibrary,
Campaign,
TrendingUp,
} from '@mui/icons-material';
import { motion } from 'framer-motion';
import { useProductMarketing } from '../../hooks/useProductMarketing';
import { useCampaignCreator } from '../../hooks/useCampaignCreator';
import { useNavigate } from 'react-router-dom';
const MotionCard = motion(Card);
interface PersonalizedRecommendationsProps {
variant?: 'product_marketing' | 'campaign_creator';
}
export const PersonalizedRecommendations: React.FC<PersonalizedRecommendationsProps> = ({
variant = 'product_marketing',
}) => {
const navigate = useNavigate();
// Always call both hooks (React rules)
const productMarketingHook = useProductMarketing();
const campaignCreatorHook = useCampaignCreator();
// Select the appropriate hook based on variant
const { getRecommendations, recommendations, isLoadingRecommendations } =
variant === 'product_marketing'
? productMarketingHook
: campaignCreatorHook;
const [hasLoaded, setHasLoaded] = useState(false);
useEffect(() => {
if (!hasLoaded && !isLoadingRecommendations && !recommendations) {
getRecommendations().catch(console.error);
setHasLoaded(true);
}
}, [hasLoaded, isLoadingRecommendations, recommendations, getRecommendations]);
if (isLoadingRecommendations) {
return (
<Box display="flex" justifyContent="center" py={4}>
<CircularProgress />
</Box>
);
}
if (!recommendations) {
return null;
}
const { templates, channels, asset_types, industry, reasoning } = recommendations;
return (
<Paper
sx={{
p: 3,
mb: 3,
background: 'rgba(124, 58, 237, 0.05)',
border: '1px solid rgba(124, 58, 237, 0.2)',
borderRadius: 2,
}}
>
<Stack spacing={3}>
<Box display="flex" alignItems="center" gap={1}>
<AutoAwesome sx={{ color: '#c4b5fd' }} />
<Typography variant="h6" fontWeight={700}>
Recommended for You
</Typography>
</Box>
{reasoning && (
<Alert severity="info" icon={<TrendingUp />}>
<Typography variant="body2">{reasoning}</Typography>
</Alert>
)}
{/* Recommended Templates */}
{templates && templates.length > 0 && (
<Box>
<Typography variant="body2" color="text.secondary" fontWeight={600} gutterBottom>
Recommended Templates
</Typography>
<Box display="flex" gap={1} flexWrap="wrap">
{templates.map((templateId: string, idx: number) => (
<Chip
key={idx}
label={templateId.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase())}
size="small"
icon={<PhotoLibrary />}
sx={{
background: 'rgba(124, 58, 237, 0.2)',
color: '#c4b5fd',
border: '1px solid rgba(124, 58, 237, 0.3)',
cursor: 'pointer',
'&:hover': {
background: 'rgba(124, 58, 237, 0.3)',
},
}}
onClick={() => {
// Navigate to template or apply template
navigate(`/campaign-creator/photoshoot?template=${templateId}`);
}}
/>
))}
</Box>
</Box>
)}
{/* Recommended Platforms */}
{channels && channels.length > 0 && variant === 'campaign_creator' && (
<Box>
<Typography variant="body2" color="text.secondary" fontWeight={600} gutterBottom>
Recommended Platforms
</Typography>
<Box display="flex" gap={1} flexWrap="wrap">
{channels.map((channel: string, idx: number) => (
<Chip
key={idx}
label={channel.charAt(0).toUpperCase() + channel.slice(1)}
size="small"
icon={<Campaign />}
sx={{
background: 'rgba(59, 130, 246, 0.2)',
color: '#93c5fd',
border: '1px solid rgba(59, 130, 246, 0.3)',
}}
/>
))}
</Box>
</Box>
)}
{/* Quick Actions */}
<Grid container spacing={2}>
{variant === 'product_marketing' && (
<>
<Grid item xs={12} sm={6} md={4}>
<MotionCard
whileHover={{ scale: 1.02 }}
sx={{
cursor: 'pointer',
background: 'rgba(16, 185, 129, 0.1)',
border: '1px solid rgba(16, 185, 129, 0.2)',
}}
onClick={() => navigate('/campaign-creator/photoshoot')}
>
<CardContent>
<Stack spacing={1} alignItems="center">
<PhotoLibrary sx={{ color: '#6ee7b7', fontSize: 32 }} />
<Typography variant="body2" fontWeight={600}>
Product Photos
</Typography>
<Typography variant="caption" color="text.secondary" textAlign="center">
Generate product images
</Typography>
</Stack>
</CardContent>
</MotionCard>
</Grid>
{asset_types && asset_types.includes('product_videos') && (
<Grid item xs={12} sm={6} md={4}>
<MotionCard
whileHover={{ scale: 1.02 }}
sx={{
cursor: 'pointer',
background: 'rgba(59, 130, 246, 0.1)',
border: '1px solid rgba(59, 130, 246, 0.2)',
}}
onClick={() => navigate('/campaign-creator/video')}
>
<CardContent>
<Stack spacing={1} alignItems="center">
<VideoLibrary sx={{ color: '#93c5fd', fontSize: 32 }} />
<Typography variant="body2" fontWeight={600}>
Product Videos
</Typography>
<Typography variant="caption" color="text.secondary" textAlign="center">
Create product videos
</Typography>
</Stack>
</CardContent>
</MotionCard>
</Grid>
)}
</>
)}
{variant === 'campaign_creator' && channels && channels.length > 0 && (
<Grid item xs={12}>
<Button
variant="contained"
startIcon={<Campaign />}
fullWidth
onClick={() => {
// This would trigger campaign creation with recommended channels pre-selected
// For now, just show a message
alert(`Creating campaign with recommended platforms: ${channels.join(', ')}`);
}}
sx={{
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
'&:hover': {
background: 'linear-gradient(135deg, #764ba2 0%, #667eea 100%)',
},
}}
>
Start Campaign with Recommended Platforms
</Button>
</Grid>
)}
</Grid>
</Stack>
</Paper>
);
};

View File

@@ -19,7 +19,7 @@ import {
TextFields,
AttachMoney,
} from '@mui/icons-material';
import { PreflightValidationResult } from '../../hooks/useProductMarketing';
import { PreflightValidationResult } from '../../hooks/useCampaignCreator';
interface PreflightValidationAlertProps {
validationResult: PreflightValidationResult | null;

View File

@@ -0,0 +1,334 @@
import React, { useState, useCallback } from 'react';
import {
Grid,
Box,
Button,
Typography,
Stack,
CircularProgress,
LinearProgress,
Alert,
Paper,
TextField,
MenuItem,
Card,
CardContent,
} from '@mui/material';
import { ImageStudioLayout } from '../../ImageStudio/ImageStudioLayout';
import { useProductMarketing } from '../../../hooks/useProductMarketing';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import ErrorIcon from '@mui/icons-material/Error';
import AnimationIcon from '@mui/icons-material/Animation';
import ImageIcon from '@mui/icons-material/Image';
const ProductAnimationStudio: React.FC = () => {
const { generateProductAnimation, isGeneratingAnimation, generatedAnimation, animationError } = useProductMarketing();
const [productImageBase64, setProductImageBase64] = useState<string | null>(null);
const [productImagePreview, setProductImagePreview] = useState<string | null>(null);
const [productName, setProductName] = useState('');
const [productDescription, setProductDescription] = useState('');
const [animationType, setAnimationType] = useState('reveal');
const [resolution, setResolution] = useState('720p');
const [duration, setDuration] = useState(5);
const [additionalContext, setAdditionalContext] = useState('');
const [progress, setProgress] = useState(0);
const [statusMessage, setStatusMessage] = useState('');
const handleImageSelect = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onload = (e) => {
const base64 = e.target?.result as string;
setProductImageBase64(base64);
setProductImagePreview(base64);
};
reader.readAsDataURL(file);
}
}, []);
const handleGenerate = async () => {
if (!productImageBase64 || !productName) {
return;
}
setProgress(0);
setStatusMessage('Starting animation generation...');
try {
setProgress(20);
setStatusMessage('Submitting animation request...');
const result = await generateProductAnimation({
product_image_base64: productImageBase64,
animation_type: animationType,
product_name: productName,
product_description: productDescription || undefined,
resolution: resolution as '480p' | '720p' | '1080p',
duration: duration,
additional_context: additionalContext || undefined,
});
setProgress(100);
setStatusMessage('Animation generated successfully!');
} catch (err: any) {
console.error('Animation generation error:', err);
}
};
const canGenerate = productImageBase64 !== null && productName.trim() !== '';
const costEstimate = useCallback(() => {
// WAN 2.5 pricing: $0.05/s (480p), $0.10/s (720p), $0.15/s (1080p)
const costPerSecond = resolution === '480p' ? 0.05 : resolution === '720p' ? 0.10 : 0.15;
return (costPerSecond * duration).toFixed(2);
}, [resolution, duration]);
return (
<ImageStudioLayout
headerProps={{
title: 'Product Animation Studio',
subtitle:
'Transform your product images into engaging animations using WAN 2.5 Image-to-Video. Create reveal animations, 360° rotations, and product demos.',
}}
>
<Grid container spacing={4}>
{/* Left Panel - Upload & Settings */}
<Grid item xs={12} lg={5}>
<Stack spacing={3}>
{/* Product Image Upload */}
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.02)', border: '1px solid rgba(255,255,255,0.08)' }}>
<Typography variant="h6" gutterBottom>
Product Image
</Typography>
<Box
sx={{
border: '2px dashed rgba(255,255,255,0.2)',
borderRadius: 2,
p: 3,
textAlign: 'center',
cursor: 'pointer',
'&:hover': {
borderColor: 'rgba(124, 58, 237, 0.5)',
background: 'rgba(124, 58, 237, 0.05)',
},
}}
onClick={() => document.getElementById('image-upload')?.click()}
>
<input
id="image-upload"
type="file"
accept="image/*"
style={{ display: 'none' }}
onChange={handleImageSelect}
/>
{productImagePreview ? (
<Box>
<img
src={productImagePreview}
alt="Product preview"
style={{ maxWidth: '100%', maxHeight: '200px', borderRadius: 8 }}
/>
<Button size="small" sx={{ mt: 1 }} onClick={() => setProductImageBase64(null)}>
Change Image
</Button>
</Box>
) : (
<Box>
<ImageIcon sx={{ fontSize: 48, color: 'text.secondary', mb: 1 }} />
<Typography variant="body2" color="text.secondary">
Click to upload product image
</Typography>
</Box>
)}
</Box>
</Paper>
{/* Product Information */}
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.02)', border: '1px solid rgba(255,255,255,0.08)' }}>
<Typography variant="h6" gutterBottom>
Product Information
</Typography>
<Stack spacing={2}>
<TextField
label="Product Name"
value={productName}
onChange={(e) => setProductName(e.target.value)}
fullWidth
required
/>
<TextField
label="Product Description (Optional)"
value={productDescription}
onChange={(e) => setProductDescription(e.target.value)}
fullWidth
multiline
rows={3}
/>
</Stack>
</Paper>
{/* Animation Settings */}
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.02)', border: '1px solid rgba(255,255,255,0.08)' }}>
<Typography variant="h6" gutterBottom>
Animation Settings
</Typography>
<Stack spacing={2}>
<TextField
select
label="Animation Type"
value={animationType}
onChange={(e) => setAnimationType(e.target.value)}
fullWidth
>
<MenuItem value="reveal">Reveal - Elegant product unveiling</MenuItem>
<MenuItem value="rotation">Rotation - 360° product rotation</MenuItem>
<MenuItem value="demo">Demo - Product in use demonstration</MenuItem>
<MenuItem value="lifestyle">Lifestyle - Realistic lifestyle setting</MenuItem>
</TextField>
<TextField
select
label="Resolution"
value={resolution}
onChange={(e) => setResolution(e.target.value)}
fullWidth
>
<MenuItem value="480p">480p - $0.05/second</MenuItem>
<MenuItem value="720p">720p - $0.10/second</MenuItem>
<MenuItem value="1080p">1080p - $0.15/second</MenuItem>
</TextField>
<TextField
select
label="Duration"
value={duration}
onChange={(e) => setDuration(Number(e.target.value))}
fullWidth
>
<MenuItem value={5}>5 seconds</MenuItem>
<MenuItem value={10}>10 seconds</MenuItem>
</TextField>
<TextField
label="Additional Context (Optional)"
value={additionalContext}
onChange={(e) => setAdditionalContext(e.target.value)}
fullWidth
multiline
rows={2}
placeholder="e.g., 'cinematic lighting', 'smooth camera movement'"
/>
</Stack>
</Paper>
{/* Cost Estimate */}
<Card sx={{ background: 'rgba(124, 58, 237, 0.1)', border: '1px solid rgba(124, 58, 237, 0.3)' }}>
<CardContent>
<Typography variant="body2" color="text.secondary" gutterBottom>
Estimated Cost
</Typography>
<Typography variant="h6" color="primary">
${costEstimate()}
</Typography>
</CardContent>
</Card>
{/* Generate Button */}
<Button
fullWidth
variant="contained"
size="large"
startIcon={isGeneratingAnimation ? <CircularProgress size={20} color="inherit" /> : <AnimationIcon />}
onClick={handleGenerate}
disabled={!canGenerate || isGeneratingAnimation}
sx={{
py: 1.5,
backgroundColor: '#7c3aed',
'&:hover': {
backgroundColor: '#6d28d9',
},
'&:disabled': {
backgroundColor: '#cbd5e1',
color: '#94a3b8',
},
}}
>
{isGeneratingAnimation ? 'Generating Animation...' : 'Generate Animation'}
</Button>
{/* Progress */}
{isGeneratingAnimation && (
<Box>
<LinearProgress variant="determinate" value={progress} sx={{ mb: 1 }} />
<Typography variant="caption" color="text.secondary">
{statusMessage}
</Typography>
</Box>
)}
{/* Error */}
{animationError && (
<Alert severity="error" icon={<ErrorIcon />}>
{animationError}
</Alert>
)}
</Stack>
</Grid>
{/* Right Panel - Preview & Result */}
<Grid item xs={12} lg={7}>
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.02)', border: '1px solid rgba(255,255,255,0.08)' }}>
<Typography variant="h6" gutterBottom>
Preview & Result
</Typography>
{generatedAnimation ? (
<Box>
<Alert severity="success" icon={<CheckCircleIcon />} sx={{ mb: 2 }}>
Animation generated successfully!
</Alert>
<Box sx={{ mb: 2 }}>
<video
src={generatedAnimation.video_url?.startsWith('http') ? generatedAnimation.video_url : `${window.location.origin}${generatedAnimation.video_url}`}
controls
style={{ width: '100%', borderRadius: 8 }}
/>
</Box>
<Stack spacing={1}>
<Typography variant="body2">
<strong>Cost:</strong> ${generatedAnimation.cost?.toFixed(2) || '0.00'}
</Typography>
<Typography variant="body2">
<strong>Animation Type:</strong> {generatedAnimation.animation_type}
</Typography>
<Typography variant="body2">
<strong>Resolution:</strong> {generatedAnimation.resolution || resolution}
</Typography>
</Stack>
</Box>
) : (
<Box
sx={{
border: '2px dashed rgba(255,255,255,0.2)',
borderRadius: 2,
p: 6,
textAlign: 'center',
}}
>
<PlayArrowIcon sx={{ fontSize: 64, color: 'text.secondary', mb: 2 }} />
<Typography variant="body1" color="text.secondary">
Generated animation will appear here
</Typography>
</Box>
)}
</Paper>
</Grid>
</Grid>
</ImageStudioLayout>
);
};
export default ProductAnimationStudio;

View File

@@ -0,0 +1 @@
export { default as ProductAnimationStudio } from './ProductAnimationStudio';

View File

@@ -0,0 +1,359 @@
import React, { useState, useCallback } from 'react';
import {
Grid,
Box,
Button,
Typography,
Stack,
CircularProgress,
LinearProgress,
Alert,
Paper,
TextField,
MenuItem,
Card,
CardContent,
} from '@mui/material';
import { ImageStudioLayout } from '../../ImageStudio/ImageStudioLayout';
import { useProductMarketing } from '../../../hooks/useProductMarketing';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import ErrorIcon from '@mui/icons-material/Error';
import RecordVoiceOverIcon from '@mui/icons-material/RecordVoiceOver';
import ImageIcon from '@mui/icons-material/Image';
const ProductAvatarStudio: React.FC = () => {
const { generateProductAvatar, isGeneratingAvatar, generatedAvatar, avatarError } = useProductMarketing();
const [avatarImageBase64, setAvatarImageBase64] = useState<string | null>(null);
const [avatarImagePreview, setAvatarImagePreview] = useState<string | null>(null);
const [productName, setProductName] = useState('');
const [productDescription, setProductDescription] = useState('');
const [scriptText, setScriptText] = useState('');
const [explainerType, setExplainerType] = useState('product_overview');
const [resolution, setResolution] = useState('720p');
const [additionalContext, setAdditionalContext] = useState('');
const [progress, setProgress] = useState(0);
const [statusMessage, setStatusMessage] = useState('');
const handleImageSelect = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onload = (e) => {
const base64 = e.target?.result as string;
setAvatarImageBase64(base64);
setAvatarImagePreview(base64);
};
reader.readAsDataURL(file);
}
}, []);
const handleGenerate = async () => {
if (!avatarImageBase64 || !productName || (!scriptText.trim() && !productDescription.trim())) {
return;
}
setProgress(0);
setStatusMessage('Starting avatar video generation...');
try {
setProgress(20);
setStatusMessage('Submitting avatar request...');
// Use script_text if provided, otherwise use product_description
const script = scriptText.trim() || productDescription;
const result = await generateProductAvatar({
avatar_image_base64: avatarImageBase64,
script_text: script,
product_name: productName,
product_description: productDescription || undefined,
explainer_type: explainerType,
resolution: resolution as '480p' | '720p',
additional_context: additionalContext || undefined,
});
setProgress(100);
setStatusMessage('Avatar video generated successfully!');
} catch (err: any) {
console.error('Avatar generation error:', err);
}
};
const canGenerate =
avatarImageBase64 !== null &&
productName.trim() !== '' &&
(scriptText.trim() !== '' || productDescription.trim() !== '');
const costEstimate = useCallback(() => {
// InfiniteTalk pricing: $0.03/s (480p) or $0.06/s (720p)
// Estimate based on script length (roughly 150 words per minute)
const estimatedWords = scriptText.trim().split(/\s+/).length || productDescription.trim().split(/\s+/).length || 50;
const estimatedDuration = Math.max(5, (estimatedWords / 150) * 60); // Minimum 5 seconds
const costPerSecond = resolution === '480p' ? 0.03 : 0.06;
return (costPerSecond * estimatedDuration).toFixed(2);
}, [resolution, scriptText, productDescription]);
return (
<ImageStudioLayout
headerProps={{
title: 'Product Avatar Studio',
subtitle:
'Create product explainer videos with talking avatars using InfiniteTalk. Generate overview videos, feature explainers, tutorials, and brand messages with precise lip-sync.',
}}
>
<Grid container spacing={4}>
{/* Left Panel - Upload & Settings */}
<Grid item xs={12} lg={5}>
<Stack spacing={3}>
{/* Avatar Image Upload */}
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.02)', border: '1px solid rgba(255,255,255,0.08)' }}>
<Typography variant="h6" gutterBottom>
Avatar Image
</Typography>
<Typography variant="body2" color="text.secondary" gutterBottom>
Upload product image, brand spokesperson, or brand mascot
</Typography>
<Box
sx={{
border: '2px dashed rgba(255,255,255,0.2)',
borderRadius: 2,
p: 3,
textAlign: 'center',
cursor: 'pointer',
'&:hover': {
borderColor: 'rgba(16, 185, 129, 0.5)',
background: 'rgba(16, 185, 129, 0.05)',
},
}}
onClick={() => document.getElementById('avatar-upload')?.click()}
>
<input
id="avatar-upload"
type="file"
accept="image/*"
style={{ display: 'none' }}
onChange={handleImageSelect}
/>
{avatarImagePreview ? (
<Box>
<img
src={avatarImagePreview}
alt="Avatar preview"
style={{ maxWidth: '100%', maxHeight: '200px', borderRadius: 8 }}
/>
<Button size="small" sx={{ mt: 1 }} onClick={() => setAvatarImageBase64(null)}>
Change Image
</Button>
</Box>
) : (
<Box>
<ImageIcon sx={{ fontSize: 48, color: 'text.secondary', mb: 1 }} />
<Typography variant="body2" color="text.secondary">
Click to upload avatar image
</Typography>
</Box>
)}
</Box>
</Paper>
{/* Product Information */}
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.02)', border: '1px solid rgba(255,255,255,0.08)' }}>
<Typography variant="h6" gutterBottom>
Product Information
</Typography>
<Stack spacing={2}>
<TextField
label="Product Name"
value={productName}
onChange={(e) => setProductName(e.target.value)}
fullWidth
required
/>
<TextField
label="Product Description (Optional)"
value={productDescription}
onChange={(e) => setProductDescription(e.target.value)}
fullWidth
multiline
rows={3}
/>
</Stack>
</Paper>
{/* Script */}
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.02)', border: '1px solid rgba(255,255,255,0.08)' }}>
<Typography variant="h6" gutterBottom>
Script
</Typography>
<Typography variant="body2" color="text.secondary" gutterBottom>
Enter the script that will be converted to speech. If empty, product description will be used.
</Typography>
<TextField
label="Script Text"
value={scriptText}
onChange={(e) => setScriptText(e.target.value)}
fullWidth
multiline
rows={6}
placeholder="Enter the script for the avatar to speak. This will be converted to speech automatically."
/>
</Paper>
{/* Explainer Settings */}
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.02)', border: '1px solid rgba(255,255,255,0.08)' }}>
<Typography variant="h6" gutterBottom>
Explainer Settings
</Typography>
<Stack spacing={2}>
<TextField
select
label="Explainer Type"
value={explainerType}
onChange={(e) => setExplainerType(e.target.value)}
fullWidth
>
<MenuItem value="product_overview">Product Overview - Professional presentation</MenuItem>
<MenuItem value="feature_explainer">Feature Explainer - Detailed feature demonstration</MenuItem>
<MenuItem value="tutorial">Tutorial - Step-by-step instruction</MenuItem>
<MenuItem value="brand_message">Brand Message - Authentic brand storytelling</MenuItem>
</TextField>
<TextField
select
label="Resolution"
value={resolution}
onChange={(e) => setResolution(e.target.value)}
fullWidth
>
<MenuItem value="480p">480p - $0.03/second</MenuItem>
<MenuItem value="720p">720p - $0.06/second</MenuItem>
</TextField>
<TextField
label="Additional Context (Optional)"
value={additionalContext}
onChange={(e) => setAdditionalContext(e.target.value)}
fullWidth
multiline
rows={2}
placeholder="e.g., 'professional setting', 'friendly tone'"
/>
</Stack>
</Paper>
{/* Cost Estimate */}
<Card sx={{ background: 'rgba(16, 185, 129, 0.1)', border: '1px solid rgba(16, 185, 129, 0.3)' }}>
<CardContent>
<Typography variant="body2" color="text.secondary" gutterBottom>
Estimated Cost
</Typography>
<Typography variant="h6" color="primary">
${costEstimate()}
</Typography>
<Typography variant="caption" color="text.secondary">
Based on script length (minimum 5 seconds)
</Typography>
</CardContent>
</Card>
{/* Generate Button */}
<Button
fullWidth
variant="contained"
size="large"
startIcon={isGeneratingAvatar ? <CircularProgress size={20} color="inherit" /> : <RecordVoiceOverIcon />}
onClick={handleGenerate}
disabled={!canGenerate || isGeneratingAvatar}
sx={{
py: 1.5,
backgroundColor: '#10b981',
'&:hover': {
backgroundColor: '#059669',
},
'&:disabled': {
backgroundColor: '#cbd5e1',
color: '#94a3b8',
},
}}
>
{isGeneratingAvatar ? 'Generating Avatar Video...' : 'Generate Avatar Video'}
</Button>
{/* Progress */}
{isGeneratingAvatar && (
<Box>
<LinearProgress variant="determinate" value={progress} sx={{ mb: 1 }} />
<Typography variant="caption" color="text.secondary">
{statusMessage}
</Typography>
</Box>
)}
{/* Error */}
{avatarError && (
<Alert severity="error" icon={<ErrorIcon />}>
{avatarError}
</Alert>
)}
</Stack>
</Grid>
{/* Right Panel - Preview & Result */}
<Grid item xs={12} lg={7}>
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.02)', border: '1px solid rgba(255,255,255,0.08)' }}>
<Typography variant="h6" gutterBottom>
Preview & Result
</Typography>
{generatedAvatar ? (
<Box>
<Alert severity="success" icon={<CheckCircleIcon />} sx={{ mb: 2 }}>
Avatar video generated successfully!
</Alert>
<Box sx={{ mb: 2 }}>
<video
src={generatedAvatar.video_url?.startsWith('http') ? generatedAvatar.video_url : `${window.location.origin}${generatedAvatar.video_url}`}
controls
style={{ width: '100%', borderRadius: 8 }}
/>
</Box>
<Stack spacing={1}>
<Typography variant="body2">
<strong>Cost:</strong> ${generatedAvatar.cost?.toFixed(2) || '0.00'}
</Typography>
<Typography variant="body2">
<strong>Explainer Type:</strong> {generatedAvatar.explainer_type}
</Typography>
<Typography variant="body2">
<strong>Resolution:</strong> {generatedAvatar.resolution || resolution}
</Typography>
<Typography variant="body2">
<strong>Duration:</strong> {generatedAvatar.duration?.toFixed(1) || 'N/A'} seconds
</Typography>
</Stack>
</Box>
) : (
<Box
sx={{
border: '2px dashed rgba(255,255,255,0.2)',
borderRadius: 2,
p: 6,
textAlign: 'center',
}}
>
<PlayArrowIcon sx={{ fontSize: 64, color: 'text.secondary', mb: 2 }} />
<Typography variant="body1" color="text.secondary">
Generated avatar video will appear here
</Typography>
</Box>
)}
</Paper>
</Grid>
</Grid>
</ImageStudioLayout>
);
};
export default ProductAvatarStudio;

View File

@@ -0,0 +1 @@
export { default as ProductAvatarStudio } from './ProductAvatarStudio';

View File

@@ -0,0 +1,220 @@
/**
* Product Image Settings Preview Component
* Shows a visual mockup preview of what the product image will look like based on selected settings.
*/
import React from 'react';
import {
Box,
Paper,
Typography,
Stack,
Chip,
} from '@mui/material';
import {
PhotoCamera,
Palette,
LightMode,
Style as StyleIcon,
} from '@mui/icons-material';
interface ProductImageSettingsPreviewProps {
productName: string;
environment: string;
backgroundStyle: string;
lighting: string;
style: string;
angle?: string;
resolution?: string;
}
const ENVIRONMENT_ICONS: Record<string, string> = {
studio: '🏛️',
lifestyle: '🏠',
outdoor: '🌲',
minimalist: '✨',
luxury: '💎',
};
const BACKGROUND_COLORS: Record<string, string> = {
white: '#ffffff',
transparent: 'transparent',
lifestyle: '#f0f0f0',
branded: '#7c3aed',
};
const LIGHTING_STYLES: Record<string, { gradient: string; opacity: number }> = {
natural: { gradient: 'linear-gradient(135deg, rgba(255,255,255,0.3), rgba(255,255,255,0.1))', opacity: 0.5 },
studio: { gradient: 'linear-gradient(135deg, rgba(255,255,255,0.5), rgba(255,255,255,0.2))', opacity: 0.7 },
dramatic: { gradient: 'linear-gradient(135deg, rgba(0,0,0,0.3), rgba(255,255,255,0.2))', opacity: 0.6 },
soft: { gradient: 'linear-gradient(135deg, rgba(255,255,255,0.2), rgba(255,255,255,0.05))', opacity: 0.4 },
};
export const ProductImageSettingsPreview: React.FC<ProductImageSettingsPreviewProps> = ({
productName,
environment,
backgroundStyle,
lighting,
style,
angle,
resolution = '1024x1024',
}) => {
const backgroundColor = BACKGROUND_COLORS[backgroundStyle] || '#ffffff';
const lightingStyle = LIGHTING_STYLES[lighting] || LIGHTING_STYLES.natural;
const environmentIcon = ENVIRONMENT_ICONS[environment] || '📦';
return (
<Paper
sx={{
p: 3,
background: 'rgba(16, 185, 129, 0.05)',
border: '1px solid rgba(16, 185, 129, 0.2)',
borderRadius: 2,
}}
>
<Stack spacing={3}>
<Box display="flex" alignItems="center" gap={1}>
<PhotoCamera sx={{ color: '#6ee7b7' }} />
<Typography variant="h6" fontWeight={700}>
Image Preview
</Typography>
</Box>
{/* Mockup Preview */}
<Box
sx={{
position: 'relative',
width: '100%',
aspectRatio: '1 / 1',
background: backgroundStyle === 'transparent'
? 'repeating-conic-gradient(#f0f0f0 0% 25%, #ffffff 0% 50%) 50% / 20px 20px'
: backgroundColor,
borderRadius: 2,
overflow: 'hidden',
border: '2px solid rgba(255,255,255,0.1)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
// Apply lighting effect
'&::before': {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: lightingStyle.gradient,
opacity: lightingStyle.opacity,
pointerEvents: 'none',
},
}}
>
{/* Product Placeholder */}
<Box
sx={{
position: 'relative',
zIndex: 1,
textAlign: 'center',
p: 3,
}}
>
<Typography variant="h2" sx={{ mb: 1 }}>
{environmentIcon}
</Typography>
<Typography
variant="body1"
fontWeight={600}
sx={{
color: backgroundStyle === 'white' ? '#333' : '#fff',
textShadow: backgroundStyle === 'white'
? 'none'
: '0 2px 4px rgba(0,0,0,0.3)',
}}
>
{productName || 'Your Product'}
</Typography>
{angle && (
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
Angle: {angle}
</Typography>
)}
</Box>
{/* Style Overlay Indicator */}
{style === 'luxury' && (
<Box
sx={{
position: 'absolute',
top: 8,
right: 8,
background: 'rgba(0,0,0,0.6)',
color: '#ffd700',
px: 1.5,
py: 0.5,
borderRadius: 1,
fontSize: '0.75rem',
fontWeight: 700,
}}
>
💎 Luxury
</Box>
)}
</Box>
{/* Settings Summary */}
<Stack spacing={1}>
<Typography variant="body2" color="text.secondary" fontWeight={600}>
Preview Settings
</Typography>
<Box display="flex" gap={1} flexWrap="wrap">
<Chip
icon={<span>{environmentIcon}</span>}
label={`${environment.charAt(0).toUpperCase() + environment.slice(1)}`}
size="small"
sx={{ background: 'rgba(16, 185, 129, 0.2)', color: '#6ee7b7' }}
/>
<Chip
icon={<Palette sx={{ fontSize: 16 }} />}
label={`${backgroundStyle.charAt(0).toUpperCase() + backgroundStyle.slice(1)} Background`}
size="small"
sx={{ background: 'rgba(16, 185, 129, 0.2)', color: '#6ee7b7' }}
/>
<Chip
icon={<LightMode sx={{ fontSize: 16 }} />}
label={`${lighting.charAt(0).toUpperCase() + lighting.slice(1)} Lighting`}
size="small"
sx={{ background: 'rgba(16, 185, 129, 0.2)', color: '#6ee7b7' }}
/>
<Chip
icon={<StyleIcon sx={{ fontSize: 16 }} />}
label={`${style.charAt(0).toUpperCase() + style.slice(1)} Style`}
size="small"
sx={{ background: 'rgba(16, 185, 129, 0.2)', color: '#6ee7b7' }}
/>
{resolution && (
<Chip
label={resolution}
size="small"
sx={{ background: 'rgba(16, 185, 129, 0.2)', color: '#6ee7b7' }}
/>
)}
</Box>
</Stack>
{/* Preview Note */}
<Box
sx={{
p: 1.5,
background: 'rgba(16, 185, 129, 0.1)',
borderRadius: 1,
border: '1px dashed rgba(16, 185, 129, 0.3)',
}}
>
<Typography variant="caption" color="text.secondary">
💡 This is a preview mockup. The actual generated image will match your product description and brand style.
</Typography>
</Box>
</Stack>
</Paper>
);
};

View File

@@ -23,15 +23,20 @@ import {
RadioButtonUnchecked,
PhotoCamera,
} from '@mui/icons-material';
import Joyride, { CallBackProps, STATUS } from 'react-joyride';
import { motion } from 'framer-motion';
import { ImageStudioLayout } from '../ImageStudio/ImageStudioLayout';
import { GlassyCard } from '../ImageStudio/ui/GlassyCard';
import { SectionHeader } from '../ImageStudio/ui/SectionHeader';
import { useCampaignCreator } from '../../hooks/useCampaignCreator';
import { useProductMarketing } from '../../hooks/useProductMarketing';
import { CampaignWizard } from './CampaignWizard';
import { AssetAuditPanel } from './AssetAuditPanel';
import { ProposalReview } from './ProposalReview';
import { PersonalizedRecommendations } from './PersonalizedRecommendations';
import { useNavigate } from 'react-router-dom';
import { productMarketingSteps } from '../../utils/walkthroughs/productMarketingSteps';
import { campaignCreatorSteps } from '../../utils/walkthroughs/campaignCreatorSteps';
const MotionCard = motion(Card);
@@ -53,10 +58,12 @@ export const ProductMarketingDashboard: React.FC = () => {
listCampaigns,
campaigns: apiCampaigns,
isLoadingCampaigns,
} = useProductMarketing();
} = useCampaignCreator();
const [showWizard, setShowWizard] = useState(false);
const [showAssetAudit, setShowAssetAudit] = useState(false);
const [reviewCampaignId, setReviewCampaignId] = useState<string | null>(null);
const [runTour, setRunTour] = useState(false);
const [tourType, setTourType] = useState<'campaign' | 'product'>('campaign');
const navigate = useNavigate();
useEffect(() => {
@@ -66,8 +73,29 @@ export const ProductMarketingDashboard: React.FC = () => {
}
// Load campaigns on mount
listCampaigns();
// Auto-run campaign tour for first-time visitors
const hasSeenCampaignTour = localStorage.getItem('pm_campaign_tour_seen');
if (!hasSeenCampaignTour) {
setTourType('campaign');
setRunTour(true);
}
}, [brandDNA, getBrandDNA, listCampaigns]);
const handleJoyrideCallback = (data: CallBackProps) => {
const { status } = data;
const finished = status === STATUS.FINISHED || status === STATUS.SKIPPED;
if (finished) {
setRunTour(false);
const key = tourType === 'campaign' ? 'pm_campaign_tour_seen' : 'pm_product_tour_seen';
localStorage.setItem(key, 'true');
}
};
const startTour = (type: 'campaign' | 'product') => {
setTourType(type);
setRunTour(true);
};
const handleCreateCampaign = () => {
setShowWizard(true);
};
@@ -77,6 +105,12 @@ export const ProductMarketingDashboard: React.FC = () => {
setShowWizard(true);
} else if (journey === 'photoshoot') {
navigate('/campaign-creator/photoshoot');
} else if (journey === 'animation') {
navigate('/campaign-creator/animation');
} else if (journey === 'video') {
navigate('/campaign-creator/video');
} else if (journey === 'avatar') {
navigate('/campaign-creator/avatar');
} else if (journey === 'optimize') {
// TODO: Show optimization insights
alert('Optimization insights coming soon!');
@@ -118,9 +152,9 @@ export const ProductMarketingDashboard: React.FC = () => {
return (
<ImageStudioLayout
headerProps={{
title: 'AI Campaign Creator',
title: 'Campaign Creator & Product Marketing',
subtitle:
'Create consistent, personalized marketing campaigns across all digital platforms. AI handles the heavy lifting—you just approve.',
'Create multi-channel campaigns or generate individual product assets. Choose your workflow below.',
}}
>
<GlassyCard
@@ -130,30 +164,62 @@ export const ProductMarketingDashboard: React.FC = () => {
p: { xs: 3, md: 5 },
}}
>
{/* Brand DNA Status */}
{isLoadingBrandDNA ? (
<Box display="flex" justifyContent="center" py={4}>
<CircularProgress />
</Box>
) : brandDNA ? (
<Alert severity="success" sx={{ mb: 3 }}>
Brand DNA loaded: {brandDNA.persona?.persona_name || 'Default Persona'} {' '}
{brandDNA.writing_style?.tone || 'professional'} tone {brandDNA.target_audience?.industry_focus || 'general'} industry
</Alert>
) : (
<Alert severity="info" sx={{ mb: 3 }}>
Brand DNA not available. Complete onboarding to enable personalized campaigns.
</Alert>
)}
{/* Walkthrough Controls */}
<Box display="flex" justifyContent="flex-end" gap={1} mb={2}>
<Button size="small" variant="outlined" onClick={() => startTour('campaign')}>
Show Campaign Tour
</Button>
<Button size="small" variant="outlined" onClick={() => startTour('product')}>
Show Product Tour
</Button>
</Box>
{/* User Journey Selection */}
<Joyride
steps={tourType === 'campaign' ? campaignCreatorSteps : productMarketingSteps}
continuous
showSkipButton
showProgress
run={runTour}
scrollToFirstStep
disableScrolling={false}
styles={{
options: {
primaryColor: '#7c3aed',
zIndex: 3000,
},
}}
callback={handleJoyrideCallback}
/>
{/* Brand DNA Status */}
<Box data-tour="cc-recommendations">
{isLoadingBrandDNA ? (
<Box display="flex" justifyContent="center" py={4}>
<CircularProgress />
</Box>
) : brandDNA ? (
<Alert severity="success" sx={{ mb: 3 }}>
Your Brand Style loaded: {brandDNA.persona?.persona_name || 'Default Persona'} {' '}
{brandDNA.writing_style?.tone || 'professional'} tone {brandDNA.target_audience?.industry_focus || 'general'} industry
</Alert>
) : (
<Alert severity="info" sx={{ mb: 3 }}>
Complete onboarding to enable personalized campaigns with your brand style.
</Alert>
)}
{/* Personalized Recommendations */}
<PersonalizedRecommendations variant="campaign_creator" />
</Box>
{/* Campaign Creator Section */}
<SectionHeader
title="Choose Your Journey"
subtitle="Select how you want to create marketing assets"
title="Campaign Creator"
subtitle="Create multi-channel marketing campaigns with AI-generated assets"
sx={{ mb: 3 }}
/>
<Grid container spacing={3} sx={{ mb: 4 }}>
<Grid container spacing={3} sx={{ mb: 4 }} data-tour="cc-journeys">
<Grid item xs={12} md={4}>
<MotionCard
whileHover={{ scale: 1.02 }}
@@ -243,6 +309,52 @@ export const ProductMarketingDashboard: React.FC = () => {
</CardContent>
</MotionCard>
</Grid>
</Grid>
<Divider sx={{ my: 4, borderColor: 'rgba(255,255,255,0.08)' }} />
{/* Personalized Recommendations for Product Marketing */}
<Box data-tour="pm-recommendations">
<PersonalizedRecommendations variant="product_marketing" />
</Box>
{/* Product Marketing Section */}
<SectionHeader
title="Product Marketing Suite"
subtitle="Generate individual product assets: images, animations, videos, and avatars"
sx={{ mb: 3 }}
/>
<Grid container spacing={3} sx={{ mb: 4 }} data-tour="pm-product-grid">
<Grid item xs={12} md={4}>
<MotionCard
whileHover={{ scale: 1.02 }}
sx={{
height: '100%',
cursor: 'pointer',
background: 'rgba(251, 191, 36, 0.1)',
border: '1px solid rgba(251, 191, 36, 0.3)',
}}
onClick={() => handleJourneySelect('animation')}
>
<CardContent>
<Stack spacing={2}>
<Box display="flex" alignItems="center" gap={1}>
<PhotoCamera sx={{ color: '#fbbf24', fontSize: 32 }} />
<Typography variant="h6" fontWeight={700}>
Product Animation Studio
</Typography>
</Box>
<Typography variant="body2" color="text.secondary">
Transform product images into engaging animations. Create reveal animations, 360° rotations, and product demos.
</Typography>
<Button variant="contained" startIcon={<PhotoCamera />} fullWidth>
Launch Animation Studio
</Button>
</Stack>
</CardContent>
</MotionCard>
</Grid>
<Grid item xs={12} md={4}>
<MotionCard
@@ -250,24 +362,54 @@ export const ProductMarketingDashboard: React.FC = () => {
sx={{
height: '100%',
cursor: 'pointer',
background: 'rgba(191, 219, 254, 0.1)',
border: '1px solid rgba(191, 219, 254, 0.3)',
background: 'rgba(59, 130, 246, 0.1)',
border: '1px solid rgba(59, 130, 246, 0.3)',
}}
onClick={() => handleJourneySelect('optimize')}
onClick={() => handleJourneySelect('video')}
>
<CardContent>
<Stack spacing={2}>
<Box display="flex" alignItems="center" gap={1}>
<TrendingUp sx={{ color: '#bfdbfe', fontSize: 32 }} />
<PhotoLibrary sx={{ color: '#93c5fd', fontSize: 32 }} />
<Typography variant="h6" fontWeight={700}>
Journey D: Optimize
Product Video Studio
</Typography>
</Box>
<Typography variant="body2" color="text.secondary">
Get AI-powered insights and suggestions to optimize your existing campaigns and assets.
Create product demo videos from text descriptions. Generate demo videos, storytelling content, and feature highlights.
</Typography>
<Button variant="contained" startIcon={<TrendingUp />} fullWidth>
View Insights
<Button variant="contained" startIcon={<PhotoLibrary />} fullWidth>
Launch Video Studio
</Button>
</Stack>
</CardContent>
</MotionCard>
</Grid>
<Grid item xs={12} md={4}>
<MotionCard
whileHover={{ scale: 1.02 }}
sx={{
height: '100%',
cursor: 'pointer',
background: 'rgba(16, 185, 129, 0.1)',
border: '1px solid rgba(16, 185, 129, 0.3)',
}}
onClick={() => handleJourneySelect('avatar')}
>
<CardContent>
<Stack spacing={2}>
<Box display="flex" alignItems="center" gap={1}>
<PhotoCamera sx={{ color: '#6ee7b7', fontSize: 32 }} />
<Typography variant="h6" fontWeight={700}>
Product Avatar Studio
</Typography>
</Box>
<Typography variant="body2" color="text.secondary">
Create product explainer videos with talking avatars. Generate overview videos, tutorials, and brand messages.
</Typography>
<Button variant="contained" startIcon={<PhotoCamera />} fullWidth>
Launch Avatar Studio
</Button>
</Stack>
</CardContent>
@@ -278,13 +420,14 @@ export const ProductMarketingDashboard: React.FC = () => {
<Divider sx={{ my: 4, borderColor: 'rgba(255,255,255,0.08)' }} />
{/* Quick Actions */}
<SectionHeader
title="Quick Actions"
subtitle="Start a new campaign or enhance existing assets"
sx={{ mb: 3 }}
/>
<Box data-tour="quick-actions">
<SectionHeader
title="Quick Actions"
subtitle="Start a new campaign or enhance existing assets"
sx={{ mb: 3 }}
/>
<Grid container spacing={3} sx={{ mb: 4 }}>
<Grid container spacing={3} sx={{ mb: 4 }}>
<Grid item xs={12} md={6}>
<MotionCard
whileHover={{ scale: 1.02 }}
@@ -344,22 +487,24 @@ export const ProductMarketingDashboard: React.FC = () => {
</CardContent>
</MotionCard>
</Grid>
</Grid>
</Grid>
</Box>
<Divider sx={{ my: 4, borderColor: 'rgba(255,255,255,0.08)' }} />
{/* Active Campaigns */}
<SectionHeader
title="Active Campaigns"
subtitle={
isLoadingCampaigns
? 'Loading campaigns...'
: apiCampaigns.length === 0
? 'No active campaigns. Create your first campaign to get started.'
: `${apiCampaigns.length} campaign(s) in progress`
}
sx={{ mb: 3 }}
/>
<Box data-tour="active-campaigns">
<SectionHeader
title="Active Campaigns"
subtitle={
isLoadingCampaigns
? 'Loading campaigns...'
: apiCampaigns.length === 0
? 'No active campaigns. Create your first campaign to get started.'
: `${apiCampaigns.length} campaign(s) in progress`
}
sx={{ mb: 3 }}
/>
{isLoadingCampaigns ? (
<Box display="flex" justifyContent="center" py={4}>
@@ -466,6 +611,7 @@ export const ProductMarketingDashboard: React.FC = () => {
))}
</Grid>
)}
</Box>
</GlassyCard>
</ImageStudioLayout>
);

View File

@@ -1,6 +1,7 @@
import React from 'react';
import { Box, TextField, Stack, Typography } from '@mui/material';
import { Inventory2 as ProductIcon } from '@mui/icons-material';
import { Box, TextField, Stack, Typography, Tooltip, IconButton } from '@mui/material';
import { Inventory2 as ProductIcon, Info as InfoIcon } from '@mui/icons-material';
import { getTooltipText } from '../../../utils/terminology';
interface ProductInfoFormProps {
productName: string;
@@ -32,6 +33,15 @@ export const ProductInfoForm: React.FC<ProductInfoFormProps> = ({
required
placeholder="e.g., Premium Wireless Headphones"
helperText="Enter the name of your product"
InputProps={{
endAdornment: (
<Tooltip title="The name of your product as it will appear in photos and marketing materials">
<IconButton size="small" edge="end">
<InfoIcon fontSize="small" />
</IconButton>
</Tooltip>
),
}}
sx={{
'& .MuiOutlinedInput-root': {
background: 'rgba(255, 255, 255, 0.05)',
@@ -52,6 +62,15 @@ export const ProductInfoForm: React.FC<ProductInfoFormProps> = ({
rows={4}
placeholder="Describe your product: features, benefits, target audience..."
helperText="Provide details about your product to help AI generate accurate images"
InputProps={{
endAdornment: (
<Tooltip title="Describe your product's key features, benefits, and who it's for. The more details you provide, the better the AI can create accurate product photos.">
<IconButton size="small" edge="end" sx={{ alignSelf: 'flex-start', mt: 1 }}>
<InfoIcon fontSize="small" />
</IconButton>
</Tooltip>
),
}}
sx={{
'& .MuiOutlinedInput-root': {
background: 'rgba(255, 255, 255, 0.05)',

View File

@@ -14,7 +14,9 @@ import {
AutoAwesome,
PhotoCamera,
ArrowBack,
SmartToy,
} from '@mui/icons-material';
import { TextField, Chip } from '@mui/material';
import { ImageStudioLayout } from '../../ImageStudio/ImageStudioLayout';
import { GlassyCard } from '../../ImageStudio/ui/GlassyCard';
import { SectionHeader } from '../../ImageStudio/ui/SectionHeader';
@@ -24,6 +26,7 @@ import { StyleSelector } from './StyleSelector';
import { ProductVariations } from './ProductVariations';
import { ProductImagePreview } from './ProductImagePreview';
import { ProductAssetsGallery } from './ProductAssetsGallery';
import { ProductImageSettingsPreview } from '../ProductImagePreview';
import { useNavigate } from 'react-router-dom';
interface ProductPhotoshootStudioProps {
@@ -43,6 +46,10 @@ export const ProductPhotoshootStudio: React.FC<ProductPhotoshootStudioProps> = (
brandDNA,
getBrandDNA,
isLoadingBrandDNA,
inferRequirements,
isInferringPrompt,
inferenceError,
getPersonalizedDefaults,
} = useProductMarketing();
// Product Information
@@ -69,12 +76,37 @@ export const ProductPhotoshootStudio: React.FC<ProductPhotoshootStudioProps> = (
const [generatedImages, setGeneratedImages] = useState<any[]>([]);
const [assetsGalleryRefetch, setAssetsGalleryRefetch] = useState(0);
// Load brand DNA on mount
// Intelligent Prompt Input
const [intelligentInput, setIntelligentInput] = useState('');
const [showIntelligentInput, setShowIntelligentInput] = useState(true);
// Load brand DNA and personalized defaults on mount
useEffect(() => {
if (!brandDNA) {
getBrandDNA().catch(console.error);
}
}, [brandDNA, getBrandDNA]);
// Load personalized defaults
getPersonalizedDefaults('product_photoshoot')
.then((defaults) => {
if (defaults) {
// Pre-fill form with personalized defaults
if (defaults.environment) setEnvironment(defaults.environment);
if (defaults.background_style) setBackgroundStyle(defaults.background_style);
if (defaults.lighting) setLighting(defaults.lighting);
if (defaults.style) setStyle(defaults.style);
if (defaults.resolution) setResolution(defaults.resolution);
if (defaults.num_variations) setNumVariations(defaults.num_variations);
if (defaults.brand_colors && defaults.brand_colors.length > 0) {
setBrandColors(defaults.brand_colors);
}
}
})
.catch((err) => {
console.warn('Failed to load personalized defaults:', err);
// Continue without defaults
});
}, [brandDNA, getBrandDNA, getPersonalizedDefaults]);
// Extract brand colors from brand DNA
useEffect(() => {
@@ -134,6 +166,56 @@ export const ProductPhotoshootStudio: React.FC<ProductPhotoshootStudioProps> = (
console.log('Image saved to library:', image.asset_id);
};
const handleIntelligentInference = async () => {
if (!intelligentInput.trim()) {
return;
}
try {
const config = await inferRequirements(intelligentInput, 'image');
// Pre-fill all fields from inferred configuration
if (config.product_name) {
setProductName(config.product_name);
}
if (config.product_description) {
setProductDescription(config.product_description);
}
if (config.environment) {
setEnvironment(config.environment);
}
if (config.background_style) {
setBackgroundStyle(config.background_style);
}
if (config.lighting) {
setLighting(config.lighting);
}
if (config.style) {
setStyle(config.style);
}
if (config.angle) {
setAngle(config.angle);
}
if (config.resolution) {
setResolution(config.resolution);
}
if (config.num_variations) {
setNumVariations(config.num_variations);
}
if (config.brand_colors) {
setBrandColors(config.brand_colors);
}
if (config.additional_context) {
setAdditionalContext(config.additional_context);
}
// Hide intelligent input after successful inference
setShowIntelligentInput(false);
} catch (error: any) {
console.error('Failed to infer requirements:', error);
}
};
const navigate = useNavigate();
const canGenerate = productName.trim() && productDescription.trim();
@@ -186,6 +268,61 @@ export const ProductPhotoshootStudio: React.FC<ProductPhotoshootStudioProps> = (
</Alert>
)}
{/* Intelligent Prompt Input */}
{showIntelligentInput && (
<Paper
sx={{
p: 3,
mb: 3,
background: 'rgba(124, 58, 237, 0.1)',
border: '1px solid rgba(124, 58, 237, 0.3)',
}}
>
<Stack spacing={2}>
<Box display="flex" alignItems="center" gap={1}>
<SmartToy sx={{ color: '#c4b5fd' }} />
<Typography variant="h6" fontWeight={600}>
AI Quick Start
</Typography>
</Box>
<Typography variant="body2" color="text.secondary">
Describe your product in a few words, and AI will automatically fill in all the settings for you.
</Typography>
<Box display="flex" gap={2}>
<TextField
fullWidth
placeholder="e.g., iPhone case for my store, luxury watch photoshoot, product demo video"
value={intelligentInput}
onChange={(e) => setIntelligentInput(e.target.value)}
onKeyPress={(e) => {
if (e.key === 'Enter' && intelligentInput.trim()) {
handleIntelligentInference();
}
}}
sx={{
'& .MuiOutlinedInput-root': {
background: 'rgba(255, 255, 255, 0.05)',
},
}}
/>
<Button
variant="contained"
startIcon={<SmartToy />}
onClick={handleIntelligentInference}
disabled={!intelligentInput.trim() || isInferringPrompt}
>
{isInferringPrompt ? 'Analyzing...' : 'Auto-Fill'}
</Button>
</Box>
{inferenceError && (
<Alert severity="error" sx={{ mt: 1 }}>
{inferenceError}
</Alert>
)}
</Stack>
</Paper>
)}
<Grid container spacing={4}>
{/* Left Column: Form */}
<Grid item xs={12} md={5}>
@@ -226,6 +363,27 @@ export const ProductPhotoshootStudio: React.FC<ProductPhotoshootStudioProps> = (
/>
</Paper>
{/* Image Settings Preview */}
{productName && (
<Paper
sx={{
p: 3,
background: 'rgba(255, 255, 255, 0.03)',
border: '1px solid rgba(255, 255, 255, 0.1)',
}}
>
<ProductImageSettingsPreview
productName={productName}
environment={environment}
backgroundStyle={backgroundStyle}
lighting={lighting}
style={style}
angle={angle}
resolution={resolution}
/>
</Paper>
)}
{/* Product Variations */}
<Paper
sx={{

View File

@@ -0,0 +1,274 @@
import React, { useState, useCallback } from 'react';
import {
Grid,
Box,
Button,
Typography,
Stack,
CircularProgress,
LinearProgress,
Alert,
Paper,
TextField,
MenuItem,
Card,
CardContent,
} from '@mui/material';
import { ImageStudioLayout } from '../../ImageStudio/ImageStudioLayout';
import { useProductMarketing } from '../../../hooks/useProductMarketing';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import ErrorIcon from '@mui/icons-material/Error';
import VideoLibraryIcon from '@mui/icons-material/VideoLibrary';
const ProductVideoStudio: React.FC = () => {
const { generateProductVideo, isGeneratingVideo, generatedVideo, videoError } = useProductMarketing();
const [productName, setProductName] = useState('');
const [productDescription, setProductDescription] = useState('');
const [videoType, setVideoType] = useState('demo');
const [resolution, setResolution] = useState('720p');
const [duration, setDuration] = useState(10);
const [additionalContext, setAdditionalContext] = useState('');
const [progress, setProgress] = useState(0);
const [statusMessage, setStatusMessage] = useState('');
const handleGenerate = async () => {
if (!productName.trim() || !productDescription.trim()) {
return;
}
setProgress(0);
setStatusMessage('Starting video generation...');
try {
setProgress(20);
setStatusMessage('Submitting video request...');
const result = await generateProductVideo({
product_name: productName,
product_description: productDescription,
video_type: videoType,
resolution: resolution as '480p' | '720p' | '1080p',
duration: duration,
additional_context: additionalContext || undefined,
});
setProgress(100);
setStatusMessage('Video generated successfully!');
} catch (err: any) {
console.error('Video generation error:', err);
}
};
const canGenerate = productName.trim() !== '' && productDescription.trim() !== '';
const costEstimate = useCallback(() => {
// WAN 2.5 Text-to-Video pricing: $0.05/s (480p), $0.10/s (720p), $0.15/s (1080p)
const costPerSecond = resolution === '480p' ? 0.05 : resolution === '720p' ? 0.10 : 0.15;
return (costPerSecond * duration).toFixed(2);
}, [resolution, duration]);
return (
<ImageStudioLayout
headerProps={{
title: 'Product Video Studio',
subtitle:
'Create product demo videos from text descriptions using WAN 2.5 Text-to-Video. Generate demo videos, storytelling content, feature highlights, and launch videos.',
}}
>
<Grid container spacing={4}>
{/* Left Panel - Settings */}
<Grid item xs={12} lg={5}>
<Stack spacing={3}>
{/* Product Information */}
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.02)', border: '1px solid rgba(255,255,255,0.08)' }}>
<Typography variant="h6" gutterBottom>
Product Information
</Typography>
<Stack spacing={2}>
<TextField
label="Product Name"
value={productName}
onChange={(e) => setProductName(e.target.value)}
fullWidth
required
/>
<TextField
label="Product Description"
value={productDescription}
onChange={(e) => setProductDescription(e.target.value)}
fullWidth
multiline
rows={5}
required
placeholder="Describe your product, its features, and benefits. This will be used to generate the video."
/>
</Stack>
</Paper>
{/* Video Settings */}
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.02)', border: '1px solid rgba(255,255,255,0.08)' }}>
<Typography variant="h6" gutterBottom>
Video Settings
</Typography>
<Stack spacing={2}>
<TextField
select
label="Video Type"
value={videoType}
onChange={(e) => setVideoType(e.target.value)}
fullWidth
>
<MenuItem value="demo">Demo - Product in use, demonstrating features</MenuItem>
<MenuItem value="storytelling">Storytelling - Narrative-driven product showcase</MenuItem>
<MenuItem value="feature_highlight">Feature Highlight - Close-up shots of key features</MenuItem>
<MenuItem value="launch">Launch - Product launch reveal, exciting unveiling</MenuItem>
</TextField>
<TextField
select
label="Resolution"
value={resolution}
onChange={(e) => setResolution(e.target.value)}
fullWidth
>
<MenuItem value="480p">480p - $0.05/second</MenuItem>
<MenuItem value="720p">720p - $0.10/second</MenuItem>
<MenuItem value="1080p">1080p - $0.15/second</MenuItem>
</TextField>
<TextField
select
label="Duration"
value={duration}
onChange={(e) => setDuration(Number(e.target.value))}
fullWidth
>
<MenuItem value={5}>5 seconds</MenuItem>
<MenuItem value={10}>10 seconds</MenuItem>
</TextField>
<TextField
label="Additional Context (Optional)"
value={additionalContext}
onChange={(e) => setAdditionalContext(e.target.value)}
fullWidth
multiline
rows={2}
placeholder="e.g., 'modern aesthetic', 'professional setting'"
/>
</Stack>
</Paper>
{/* Cost Estimate */}
<Card sx={{ background: 'rgba(59, 130, 246, 0.1)', border: '1px solid rgba(59, 130, 246, 0.3)' }}>
<CardContent>
<Typography variant="body2" color="text.secondary" gutterBottom>
Estimated Cost
</Typography>
<Typography variant="h6" color="primary">
${costEstimate()}
</Typography>
</CardContent>
</Card>
{/* Generate Button */}
<Button
fullWidth
variant="contained"
size="large"
startIcon={isGeneratingVideo ? <CircularProgress size={20} color="inherit" /> : <VideoLibraryIcon />}
onClick={handleGenerate}
disabled={!canGenerate || isGeneratingVideo}
sx={{
py: 1.5,
backgroundColor: '#3b82f6',
'&:hover': {
backgroundColor: '#2563eb',
},
'&:disabled': {
backgroundColor: '#cbd5e1',
color: '#94a3b8',
},
}}
>
{isGeneratingVideo ? 'Generating Video...' : 'Generate Video'}
</Button>
{/* Progress */}
{isGeneratingVideo && (
<Box>
<LinearProgress variant="determinate" value={progress} sx={{ mb: 1 }} />
<Typography variant="caption" color="text.secondary">
{statusMessage}
</Typography>
</Box>
)}
{/* Error */}
{videoError && (
<Alert severity="error" icon={<ErrorIcon />}>
{videoError}
</Alert>
)}
</Stack>
</Grid>
{/* Right Panel - Preview & Result */}
<Grid item xs={12} lg={7}>
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.02)', border: '1px solid rgba(255,255,255,0.08)' }}>
<Typography variant="h6" gutterBottom>
Preview & Result
</Typography>
{generatedVideo ? (
<Box>
<Alert severity="success" icon={<CheckCircleIcon />} sx={{ mb: 2 }}>
Video generated successfully!
</Alert>
<Box sx={{ mb: 2 }}>
<video
src={generatedVideo.video_url?.startsWith('http') ? generatedVideo.video_url : `${window.location.origin}${generatedVideo.video_url}`}
controls
style={{ width: '100%', borderRadius: 8 }}
/>
</Box>
<Stack spacing={1}>
<Typography variant="body2">
<strong>Cost:</strong> ${generatedVideo.cost?.toFixed(2) || '0.00'}
</Typography>
<Typography variant="body2">
<strong>Video Type:</strong> {generatedVideo.video_type}
</Typography>
<Typography variant="body2">
<strong>Resolution:</strong> {generatedVideo.resolution || resolution}
</Typography>
<Typography variant="body2">
<strong>Duration:</strong> {generatedVideo.duration || duration} seconds
</Typography>
</Stack>
</Box>
) : (
<Box
sx={{
border: '2px dashed rgba(255,255,255,0.2)',
borderRadius: 2,
p: 6,
textAlign: 'center',
}}
>
<PlayArrowIcon sx={{ fontSize: 64, color: 'text.secondary', mb: 2 }} />
<Typography variant="body1" color="text.secondary">
Generated video will appear here
</Typography>
</Box>
)}
</Paper>
</Grid>
</Grid>
</ImageStudioLayout>
);
};
export default ProductVideoStudio;

View File

@@ -0,0 +1 @@
export { default as ProductVideoStudio } from './ProductVideoStudio';

View File

@@ -39,7 +39,7 @@ import { ImageStudioLayout } from '../ImageStudio/ImageStudioLayout';
import { GlassyCard } from '../ImageStudio/ui/GlassyCard';
import { SectionHeader } from '../ImageStudio/ui/SectionHeader';
import { CampaignFlowIndicator } from './CampaignFlowIndicator';
import { useProductMarketing, AssetProposal } from '../../hooks/useProductMarketing';
import { useCampaignCreator, AssetProposal } from '../../hooks/useCampaignCreator';
interface ProposalReviewProps {
campaignId: string;
@@ -59,7 +59,7 @@ export const ProposalReview: React.FC<ProposalReviewProps> = ({
generateAsset,
isGeneratingAsset,
error,
} = useProductMarketing();
} = useCampaignCreator();
const [selectedProposals, setSelectedProposals] = useState<Set<string>>(new Set());
const [editingProposal, setEditingProposal] = useState<string | null>(null);
@@ -249,7 +249,7 @@ export const ProposalReview: React.FC<ProposalReviewProps> = ({
const isSelected = selectedProposals.has(assetId);
const isGenerating = generationProgress[assetId];
const isEditing = editingProposal === assetId;
const editedPrompt = editedPrompts[assetId] || proposal.proposed_prompt;
const editedPrompt = editedPrompts[assetId] || proposal.proposed_prompt || '';
return (
<Grid item xs={12} key={assetId}>

View File

@@ -4,5 +4,11 @@ export { AssetAuditPanel } from './AssetAuditPanel';
export { ChannelPackBuilder } from './ChannelPackBuilder';
export { ProposalReview } from './ProposalReview';
export { CampaignFlowIndicator } from './CampaignFlowIndicator';
export { CampaignPreview } from './CampaignPreview';
export { ProductImageSettingsPreview } from './ProductImagePreview';
export { PersonalizedRecommendations } from './PersonalizedRecommendations';
export { ProductPhotoshootStudio } from './ProductPhotoshootStudio';
export { ProductAnimationStudio } from './ProductAnimationStudio';
export { ProductVideoStudio } from './ProductVideoStudio';
export { ProductAvatarStudio } from './ProductAvatarStudio';

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useRef } from 'react';
import { useResearchWizard } from './hooks/useResearchWizard';
import { useResearchExecution } from './hooks/useResearchExecution';
import { ResearchInput } from './steps/ResearchInput';
@@ -7,11 +7,18 @@ import { StepResults } from './steps/StepResults';
import { ResearchWizardProps } from './types/research.types';
import { addResearchHistory } from '../../utils/researchHistory';
import { getResearchConfig, ProviderAvailability } from '../../api/researchConfig';
import { ProviderChips } from './steps/components/ProviderChips';
import { AdvancedChip } from './steps/components/AdvancedChip';
import { SmartResearchInfo } from './steps/components/SmartResearchInfo';
import { intentResearchApi } from '../../api/intentResearchApi';
import { clearDraftFromStorage } from '../../utils/researchDraftManager';
export const ResearchWizard: React.FC<ResearchWizardProps> = ({
export interface ResearchWizardHeaderActions {
onOpenPersona?: () => void;
onOpenCompetitors?: () => void;
personaExists?: boolean;
}
export const ResearchWizard: React.FC<ResearchWizardProps & { headerActions?: ResearchWizardHeaderActions }> = ({
onComplete,
onCancel,
initialKeywords,
@@ -19,6 +26,8 @@ export const ResearchWizard: React.FC<ResearchWizardProps> = ({
initialTargetAudience,
initialResearchMode,
initialConfig,
initialResults,
headerActions,
}) => {
const wizard = useResearchWizard(
initialKeywords,
@@ -30,6 +39,30 @@ export const ResearchWizard: React.FC<ResearchWizardProps> = ({
const execution = useResearchExecution();
const [providerAvailability, setProviderAvailability] = useState<ProviderAvailability | null>(null);
const [advanced, setAdvanced] = useState<boolean>(false);
const hasSavedProject = useRef(false); // Track if we've already saved this project
// Restore initial results if provided (e.g., from saved project)
useEffect(() => {
if (initialResults && !wizard.state.results) {
wizard.updateState({ results: initialResults });
// Navigate to results step if results are available
if (wizard.state.currentStep < 3) {
wizard.updateState({ currentStep: 3 });
}
}
}, [initialResults]); // Only run once on mount
// Restore intent analysis and confirmed intent from draft
useEffect(() => {
if (execution.intentAnalysis && wizard.state.keywords.length > 0) {
// Intent analysis already restored by useResearchExecution hook
console.log('[ResearchWizard] ✅ Intent analysis restored from draft');
}
if (execution.confirmedIntent && wizard.state.keywords.length > 0) {
// Confirmed intent already restored by useResearchExecution hook
console.log('[ResearchWizard] ✅ Confirmed intent restored from draft');
}
}, [execution.intentAnalysis, execution.confirmedIntent, wizard.state.keywords]);
// Load provider availability on mount
useEffect(() => {
@@ -68,6 +101,61 @@ export const ResearchWizard: React.FC<ResearchWizardProps> = ({
}
}, [execution.result, execution.isExecuting]); // Don't depend on currentStep to avoid loops
// Auto-save research project when research completes
useEffect(() => {
// Save when intent-driven research completes
if (execution.intentResult?.success && !hasSavedProject.current && wizard.state.keywords.length > 0) {
hasSavedProject.current = true;
// Generate project title from keywords
const projectTitle = `Research: ${wizard.state.keywords.slice(0, 3).join(', ')}`;
// Save project to Asset Library
intentResearchApi.saveResearchProject(wizard.state, {
intentAnalysis: execution.intentAnalysis,
confirmedIntent: execution.confirmedIntent,
intentResult: execution.intentResult,
title: projectTitle,
description: `Research project on ${wizard.state.keywords.join(', ')}. ` +
`Industry: ${wizard.state.industry}, Target Audience: ${wizard.state.targetAudience}`,
}).then((response) => {
if (response.success) {
console.log('[ResearchWizard] ✅ Final research project saved to Asset Library:', response.asset_id);
// Clear draft after successful final save
clearDraftFromStorage();
} else {
console.warn('[ResearchWizard] ⚠️ Failed to save final research project:', response.message);
}
}).catch((error) => {
console.error('[ResearchWizard] ❌ Error saving final research project:', error);
});
}
// Save when legacy research completes (fallback)
if (wizard.state.results && !hasSavedProject.current && wizard.state.keywords.length > 0 && !execution.intentResult) {
hasSavedProject.current = true;
const projectTitle = `Research: ${wizard.state.keywords.slice(0, 3).join(', ')}`;
intentResearchApi.saveResearchProject(wizard.state, {
legacyResult: wizard.state.results,
title: projectTitle,
description: `Completed research project on ${wizard.state.keywords.join(', ')}. ` +
`Industry: ${wizard.state.industry}, Target Audience: ${wizard.state.targetAudience}`,
}).then((response) => {
if (response.success) {
console.log('[ResearchWizard] ✅ Final research project saved to Asset Library:', response.asset_id);
// Clear draft after successful final save
clearDraftFromStorage();
} else {
console.warn('[ResearchWizard] ⚠️ Failed to save final research project:', response.message);
}
}).catch((error) => {
console.error('[ResearchWizard] ❌ Error saving final research project:', error);
});
}
}, [execution.intentResult, wizard.state.results, wizard.state.keywords, wizard.state.industry, wizard.state.targetAudience, execution.intentAnalysis, execution.confirmedIntent]);
// Handle completion callback and track history
useEffect(() => {
if (wizard.state.results && onComplete) {
@@ -144,8 +232,91 @@ export const ResearchWizard: React.FC<ResearchWizardProps> = ({
Research Wizard
</h1>
{/* Provider Status Chips */}
<ProviderChips providerAvailability={providerAvailability} advanced={advanced} />
{/* Persona Button */}
{headerActions?.onOpenPersona && (
<button
onClick={headerActions.onOpenPersona}
style={{
padding: '6px 12px',
backgroundColor: headerActions.personaExists ? '#22c55e' : '#ef4444',
color: 'white',
border: 'none',
borderRadius: '8px',
cursor: 'pointer',
fontSize: '12px',
fontWeight: '500',
display: 'flex',
alignItems: 'center',
gap: '6px',
boxShadow: headerActions.personaExists
? '0 0 12px rgba(34, 197, 94, 0.4), 0 1px 4px rgba(34, 197, 94, 0.2)'
: '0 0 12px rgba(239, 68, 68, 0.4), 0 1px 4px rgba(239, 68, 68, 0.2)',
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateY(-1px)';
if (headerActions.personaExists) {
e.currentTarget.style.boxShadow = '0 0 16px rgba(34, 197, 94, 0.5), 0 2px 6px rgba(34, 197, 94, 0.3)';
} else {
e.currentTarget.style.boxShadow = '0 0 16px rgba(239, 68, 68, 0.5), 0 2px 6px rgba(239, 68, 68, 0.3)';
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
if (headerActions.personaExists) {
e.currentTarget.style.boxShadow = '0 0 12px rgba(34, 197, 94, 0.4), 0 1px 4px rgba(34, 197, 94, 0.2)';
} else {
e.currentTarget.style.boxShadow = '0 0 12px rgba(239, 68, 68, 0.4), 0 1px 4px rgba(239, 68, 68, 0.2)';
}
}}
title={headerActions.personaExists ? 'View Research Persona' : 'Create Research Persona'}
>
<span style={{
width: '6px',
height: '6px',
borderRadius: '50%',
background: 'white',
boxShadow: '0 0 4px rgba(255, 255, 255, 0.8)',
}} />
<span>Persona</span>
</button>
)}
{/* Competitors Button */}
{headerActions?.onOpenCompetitors && (
<button
onClick={headerActions.onOpenCompetitors}
style={{
padding: '6px 12px',
backgroundColor: '#0284c7',
color: 'white',
border: 'none',
borderRadius: '8px',
cursor: 'pointer',
fontSize: '12px',
fontWeight: '500',
display: 'flex',
alignItems: 'center',
gap: '6px',
boxShadow: '0 2px 6px rgba(2, 132, 199, 0.2)',
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = '#0369a1';
e.currentTarget.style.transform = 'translateY(-1px)';
e.currentTarget.style.boxShadow = '0 4px 10px rgba(2, 132, 199, 0.3)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = '#0284c7';
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = '0 2px 6px rgba(2, 132, 199, 0.2)';
}}
title="View Competitor Analysis"
>
<span>📊</span>
<span>Competitors</span>
</button>
)}
{/* Advanced Chip */}
<AdvancedChip advanced={advanced} />
@@ -337,106 +508,60 @@ export const ResearchWizard: React.FC<ResearchWizardProps> = ({
Back
</button>
{/* Research Button (Unified - enabled only after intent analysis on Step 1) */}
<button
onClick={() => {
if (wizard.state.currentStep === 1) {
// On Step 1: If intent is analyzed with high confidence, execute directly
if (execution.intentAnalysis?.success &&
execution.intentAnalysis.intent.confidence >= 0.7) {
const queriesToUse = execution.intentAnalysis.suggested_queries?.slice(0, 5) || [];
execution.executeIntentResearch(wizard.state, queriesToUse).then(result => {
if (result?.success) {
wizard.updateState({ currentStep: 3 }); // Skip to results
}
});
{/* Research Button - Hidden on Step 1 when IntentConfirmationPanel is visible (has its own "Start Research" button) */}
{!(wizard.state.currentStep === 1 && execution.intentAnalysis) && (
<button
onClick={() => {
if (wizard.state.currentStep === 1) {
// On Step 1: No intent analysis - go to progress step for traditional research
wizard.nextStep();
} else {
// No intent or low confidence - go to progress step for traditional research
wizard.nextStep();
}
} else {
wizard.nextStep();
}
}}
disabled={
wizard.state.currentStep === 1
? !wizard.canGoNext() || !execution.intentAnalysis || execution.isExecuting
: !wizard.canGoNext()
}
style={{
padding: '10px 24px',
background: (() => {
const canProceed = wizard.state.currentStep === 1
? wizard.canGoNext() && execution.intentAnalysis && !execution.isExecuting
: wizard.canGoNext();
return canProceed
}}
disabled={!wizard.canGoNext()}
style={{
padding: '10px 24px',
background: wizard.canGoNext()
? 'linear-gradient(135deg, #0ea5e9 0%, #38bdf8 100%)'
: 'rgba(100, 116, 139, 0.2)';
})(),
color: (() => {
const canProceed = wizard.state.currentStep === 1
? wizard.canGoNext() && execution.intentAnalysis && !execution.isExecuting
: wizard.canGoNext();
return canProceed ? 'white' : '#94a3b8';
})(),
border: 'none',
borderRadius: '10px',
cursor: (() => {
const canProceed = wizard.state.currentStep === 1
? wizard.canGoNext() && execution.intentAnalysis && !execution.isExecuting
: wizard.canGoNext();
return canProceed ? 'pointer' : 'not-allowed';
})(),
fontSize: '13px',
fontWeight: '600',
transition: 'all 0.2s ease',
boxShadow: (() => {
const canProceed = wizard.state.currentStep === 1
? wizard.canGoNext() && execution.intentAnalysis && !execution.isExecuting
: wizard.canGoNext();
return canProceed ? '0 2px 8px rgba(14, 165, 233, 0.3)' : 'none';
})(),
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
onMouseEnter={(e) => {
const canProceed = wizard.state.currentStep === 1
? wizard.canGoNext() && execution.intentAnalysis && !execution.isExecuting
: wizard.canGoNext();
if (canProceed) {
e.currentTarget.style.transform = 'translateX(4px)';
e.currentTarget.style.boxShadow = '0 4px 12px rgba(14, 165, 233, 0.4)';
: 'rgba(100, 116, 139, 0.2)',
color: wizard.canGoNext() ? 'white' : '#94a3b8',
border: 'none',
borderRadius: '10px',
cursor: wizard.canGoNext() ? 'pointer' : 'not-allowed',
fontSize: '13px',
fontWeight: '600',
transition: 'all 0.2s ease',
boxShadow: wizard.canGoNext() ? '0 2px 8px rgba(14, 165, 233, 0.3)' : 'none',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
onMouseEnter={(e) => {
if (wizard.canGoNext()) {
e.currentTarget.style.transform = 'translateX(4px)';
e.currentTarget.style.boxShadow = '0 4px 12px rgba(14, 165, 233, 0.4)';
}
}}
onMouseLeave={(e) => {
if (wizard.canGoNext()) {
e.currentTarget.style.transform = 'translateX(0)';
e.currentTarget.style.boxShadow = '0 2px 8px rgba(14, 165, 233, 0.3)';
}
}}
title={
wizard.isLastStep ? 'Complete research' : 'Continue to next step'
}
}}
onMouseLeave={(e) => {
const canProceed = wizard.state.currentStep === 1
? wizard.canGoNext() && execution.intentAnalysis && !execution.isExecuting
: wizard.canGoNext();
if (canProceed) {
e.currentTarget.style.transform = 'translateX(0)';
e.currentTarget.style.boxShadow = '0 2px 8px rgba(14, 165, 233, 0.3)';
}
}}
title={
wizard.state.currentStep === 1 && !execution.intentAnalysis
? 'Click "Intent & Options" in the text area to analyze your research first'
: wizard.isLastStep ? 'Complete research' : 'Start research'
}
>
{execution.isExecuting ? (
<>
<span style={{ animation: 'pulse 1.5s ease-in-out infinite' }}>🔍</span>
Researching...
</>
) : wizard.isLastStep ? (
'Finish'
) : (
<>
🚀 Research
</>
)}
</button>
>
{wizard.isLastStep ? (
'Finish'
) : (
<>
Next
</>
)}
</button>
)}
</div>
)}
</div>

View File

@@ -1,4 +1,4 @@
import { useState, useCallback } from 'react';
import { useState, useCallback, useEffect } from 'react';
import { researchCache } from '../../../services/researchCache';
import { WizardState } from '../types/research.types';
import { researchEngineApi, ResearchEngineRequest } from '../../../services/researchEngineApi';
@@ -10,6 +10,7 @@ import {
AnalyzeIntentResponse,
ResearchQuery,
} from '../types/intent.types';
import { autoSaveDraft, restoreDraft } from '../../../utils/researchDraftManager';
export const useResearchExecution = () => {
const [isExecuting, setIsExecuting] = useState(false);
@@ -22,6 +23,25 @@ export const useResearchExecution = () => {
const [confirmedIntent, setConfirmedIntent] = useState<ResearchIntent | null>(null);
const [intentResult, setIntentResult] = useState<IntentDrivenResearchResponse | null>(null);
const [useIntentMode, setUseIntentMode] = useState(true); // Enable by default
// Restore intent analysis and confirmed intent from draft on mount
useEffect(() => {
const draft = restoreDraft();
if (draft) {
if (draft.intent_analysis) {
setIntentAnalysis(draft.intent_analysis);
console.log('[useResearchExecution] 🔄 Restored intent analysis from draft');
}
if (draft.confirmed_intent) {
setConfirmedIntent(draft.confirmed_intent);
console.log('[useResearchExecution] 🔄 Restored confirmed intent from draft');
}
if (draft.intent_result) {
setIntentResult(draft.intent_result);
console.log('[useResearchExecution] 🔄 Restored intent result from draft');
}
}
}, []);
const polling = useResearchPolling({
onComplete: (result) => {
@@ -145,6 +165,9 @@ export const useResearchExecution = () => {
keywords: state.keywords,
use_persona: true,
use_competitor_data: true,
user_provided_purpose: state.userPurpose,
user_provided_content_output: state.userContentOutput,
user_provided_depth: state.userDepth,
});
if (!response.success) {
@@ -161,6 +184,14 @@ export const useResearchExecution = () => {
setConfirmedIntent(response.intent);
}
// Save draft with intent analysis
autoSaveDraft(state, {
intentAnalysis: response,
confirmedIntent: response.intent.confidence >= 0.85 && !response.intent.needs_clarification ? response.intent : undefined,
}).catch(error => {
console.warn('[useResearchExecution] Failed to save draft after intent analysis:', error);
});
setIsAnalyzingIntent(false);
return response;
} catch (err: any) {
@@ -199,6 +230,7 @@ export const useResearchExecution = () => {
expected_deliverables: ['key_statistics'],
depth: 'detailed',
focus_areas: [],
also_answering: [],
perspective: null,
time_sensitivity: null,
input_type: 'keywords',
@@ -220,9 +252,19 @@ export const useResearchExecution = () => {
/**
* Confirm the analyzed intent (possibly with user modifications).
*/
const confirmIntent = useCallback((intent: ResearchIntent) => {
const confirmIntent = useCallback((intent: ResearchIntent, state?: WizardState) => {
setConfirmedIntent(intent);
}, []);
// Save draft with confirmed intent
if (state) {
autoSaveDraft(state, {
intentAnalysis: intentAnalysis || undefined,
confirmedIntent: intent,
}).catch(error => {
console.warn('[useResearchExecution] Failed to save draft after intent confirmation:', error);
});
}
}, [intentAnalysis]);
/**
* Update a specific field in the analyzed intent.
@@ -289,6 +331,15 @@ export const useResearchExecution = () => {
setIntentResult(response);
// Save draft with research results
autoSaveDraft(state, {
intentAnalysis: intentAnalysis || undefined,
confirmedIntent: intent,
intentResult: response,
}).catch(error => {
console.warn('[useResearchExecution] Failed to save draft after research completion:', error);
});
// Also set the legacy result for backward compatibility with StepResults
// Transform intent result to match the expected format
const legacyResult = {

View File

@@ -1,6 +1,7 @@
import { useState, useCallback, useEffect } from 'react';
import { WizardState, WizardStepProps } from '../types/research.types';
import { ResearchMode, ResearchConfig, BlogResearchResponse } from '../../../services/blogWriterApi';
import { ResearchMode, ResearchConfig, ResearchResponse } from '../../../services/researchApi';
import { restoreDraft, autoSaveDraft } from '../../../utils/researchDraftManager';
const WIZARD_STATE_KEY = 'alwrity_research_wizard_state';
const MAX_STEPS = 3; // Input (combined) -> Progress -> Results
@@ -34,7 +35,7 @@ export const useResearchWizard = (
initialConfig?: ResearchConfig
) => {
const [state, setState] = useState<WizardState>(() => {
// If initial values are provided (preset clicked), clear localStorage and use them
// If initial values are provided (preset clicked), clear drafts and use them
if (initialKeywords || initialIndustry || initialTargetAudience || initialResearchMode || initialConfig) {
localStorage.removeItem(WIZARD_STATE_KEY);
return {
@@ -47,7 +48,27 @@ export const useResearchWizard = (
};
}
// Try to load from localStorage only if no initial values
// Try to restore from draft first (more comprehensive)
const draft = restoreDraft();
if (draft && ((draft.keywords && draft.keywords.length > 0) || draft.intent_analysis || draft.confirmed_intent)) {
console.log('[useResearchWizard] 🔄 Restoring from draft:', {
step: draft.current_step,
keywords: draft.keywords?.length || 0,
hasIntentAnalysis: !!draft.intent_analysis,
hasConfirmedIntent: !!draft.confirmed_intent,
});
return {
currentStep: draft.current_step || 1,
keywords: draft.keywords || [],
industry: draft.industry || defaultState.industry,
targetAudience: draft.target_audience || defaultState.targetAudience,
researchMode: (draft.research_mode as ResearchMode) || defaultState.researchMode,
config: draft.config || defaultState.config,
results: draft.legacy_result || null, // Only restore legacy_result for WizardState.results
};
}
// Fallback to localStorage (legacy)
const saved = localStorage.getItem(WIZARD_STATE_KEY);
if (saved) {
try {
@@ -78,11 +99,16 @@ export const useResearchWizard = (
}
}, [initialKeywords, initialIndustry, initialTargetAudience, initialResearchMode, initialConfig]);
// Persist state to localStorage
// Persist state to localStorage only (no database save until intent analysis)
useEffect(() => {
if (state.currentStep > 1) {
// Always save to localStorage for backward compatibility
if (state.keywords.length > 0 || state.currentStep > 1) {
localStorage.setItem(WIZARD_STATE_KEY, JSON.stringify(state));
}
// NOTE: Database draft saving only happens after user clicks "intent and options"
// This is handled in useResearchExecution.analyzeIntent()
// We only save to localStorage here to preserve state across refreshes
}, [state]);
const updateState = useCallback((updates: Partial<WizardState>) => {

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect, useRef } from 'react';
import { WizardStepProps } from '../types/research.types';
import { ResearchProvider, ResearchMode } from '../../../services/blogWriterApi';
import { ResearchProvider, ResearchMode } from '../../../services/researchApi';
import { getResearchConfig, ProviderAvailability } from '../../../api/researchConfig';
import {
getResearchHistory,
@@ -18,16 +18,16 @@ import { ResearchHistory } from './components/ResearchHistory';
import { ResearchInputContainer } from './components/ResearchInputContainer';
import { SmartInputIndicator } from './components/SmartInputIndicator';
import { KeywordExpansion } from './components/KeywordExpansion';
import { CurrentKeywords } from './components/CurrentKeywords';
import { ResearchAngles } from './components/ResearchAngles';
// Removed: CurrentKeywords - keywords now managed in IntentConfirmationPanel
// Removed: ResearchAngles - intent-driven research already generates targeted queries
import { ResearchInputHeader } from './components/ResearchInputHeader';
import { AdvancedOptionsSection } from './components/AdvancedOptionsSection';
// Removed: AdvancedOptionsSection - now handled by AdvancedProviderOptionsSection in IntentConfirmationPanel
import { IntentConfirmationPanel } from './components/IntentConfirmationPanel';
import { ResearchExecution } from '../types/research.types';
// Hooks
import { useKeywordExpansion } from './hooks/useKeywordExpansion';
import { useResearchAngles } from './hooks/useResearchAngles';
// Removed: useResearchAngles - ResearchAngles component removed
interface ResearchInputProps extends WizardStepProps {
advanced?: boolean;
@@ -140,7 +140,7 @@ export const ResearchInput: React.FC<ResearchInputProps> = ({ state, onUpdate, o
onUpdate({
config: {
...state.config,
exa_search_type: defaults.suggested_exa_search_type as 'auto' | 'keyword' | 'neural'
exa_search_type: defaults.suggested_exa_search_type as 'auto' | 'keyword' | 'neural' | 'fast'
}
});
}
@@ -348,9 +348,6 @@ export const ResearchInput: React.FC<ResearchInputProps> = ({ state, onUpdate, o
// Use keyword expansion hook
const keywordExpansion = useKeywordExpansion(state.keywords, state.industry, researchPersona);
// Use research angles hook
const researchAngles = useResearchAngles(state.keywords, state.industry, researchPersona);
// Event handlers
const handleKeywordsChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value;
@@ -372,16 +369,8 @@ export const ResearchInput: React.FC<ResearchInputProps> = ({ state, onUpdate, o
}
};
const handleRemoveKeyword = (keywordToRemove: string) => {
const currentKeywords = state.keywords.filter(k => k.toLowerCase() !== keywordToRemove.toLowerCase());
onUpdate({ keywords: currentKeywords });
};
const handleUseAngle = (angle: string) => {
// Parse the angle as a new research query
const keywords = parseIntelligentInput(angle);
onUpdate({ keywords });
};
// Removed: handleRemoveKeyword - keywords now managed in IntentConfirmationPanel
// Removed: handleUseAngle - intent-driven research already generates targeted queries
const handleIndustryChange = (industry: string) => {
onUpdate({ industry });
@@ -461,6 +450,12 @@ export const ResearchInput: React.FC<ResearchInputProps> = ({ state, onUpdate, o
keywords={state.keywords}
placeholder={placeholderExamples[currentPlaceholder]}
onKeywordsChange={handleKeywordsChange}
userPurpose={state.userPurpose}
userContentOutput={state.userContentOutput}
userDepth={state.userDepth}
onPurposeChange={(purpose) => onUpdate({ userPurpose: purpose })}
onContentOutputChange={(output) => onUpdate({ userContentOutput: output })}
onDepthChange={(depth) => onUpdate({ userDepth: depth })}
onIntentAndOptions={async () => {
if (execution?.analyzeIntent) {
try {
@@ -478,9 +473,25 @@ export const ResearchInput: React.FC<ResearchInputProps> = ({ state, onUpdate, o
// Apply Exa settings (note: backend uses exa_type, but frontend state uses exa_search_type)
if (optConfig.exa_category) configUpdates.exa_category = optConfig.exa_category;
if (optConfig.exa_type) configUpdates.exa_search_type = optConfig.exa_type as 'auto' | 'keyword' | 'neural';
if (optConfig.exa_type) configUpdates.exa_search_type = optConfig.exa_type as 'auto' | 'keyword' | 'neural' | 'fast' | 'deep';
if (optConfig.exa_include_domains) configUpdates.exa_include_domains = optConfig.exa_include_domains;
if (optConfig.exa_num_results) configUpdates.exa_num_results = optConfig.exa_num_results;
if (optConfig.exa_num_results !== undefined) configUpdates.exa_num_results = optConfig.exa_num_results;
if (optConfig.exa_date_filter) configUpdates.exa_date_filter = optConfig.exa_date_filter;
if (optConfig.exa_end_published_date) configUpdates.exa_end_published_date = optConfig.exa_end_published_date;
if (optConfig.exa_start_crawl_date) configUpdates.exa_start_crawl_date = optConfig.exa_start_crawl_date;
if (optConfig.exa_end_crawl_date) configUpdates.exa_end_crawl_date = optConfig.exa_end_crawl_date;
if (optConfig.exa_include_text) configUpdates.exa_include_text = optConfig.exa_include_text;
if (optConfig.exa_exclude_text) configUpdates.exa_exclude_text = optConfig.exa_exclude_text;
if (optConfig.exa_highlights !== undefined) configUpdates.exa_highlights = optConfig.exa_highlights;
if (optConfig.exa_highlights_num_sentences !== undefined) configUpdates.exa_highlights_num_sentences = optConfig.exa_highlights_num_sentences;
if (optConfig.exa_highlights_per_url !== undefined) configUpdates.exa_highlights_per_url = optConfig.exa_highlights_per_url;
if (optConfig.exa_context !== undefined) configUpdates.exa_context = optConfig.exa_context;
if (optConfig.exa_context_max_characters !== undefined) configUpdates.exa_context_max_characters = optConfig.exa_context_max_characters;
if (optConfig.exa_text_max_characters !== undefined) configUpdates.exa_text_max_characters = optConfig.exa_text_max_characters;
if (optConfig.exa_summary_query) configUpdates.exa_summary_query = optConfig.exa_summary_query;
if (optConfig.exa_additional_queries && optConfig.exa_additional_queries.length > 0) {
configUpdates.exa_additional_queries = optConfig.exa_additional_queries;
}
// Apply Tavily settings
if (optConfig.tavily_topic) configUpdates.tavily_topic = optConfig.tavily_topic;
@@ -566,7 +577,8 @@ export const ResearchInput: React.FC<ResearchInputProps> = ({ state, onUpdate, o
isAnalyzing={execution.isAnalyzingIntent}
intentAnalysis={execution.intentAnalysis}
confirmedIntent={execution.confirmedIntent}
onConfirm={execution.confirmIntent}
onConfirm={(intent, wizardState) => execution.confirmIntent(intent, wizardState || state)}
wizardState={state}
onUpdateField={execution.updateIntentField}
onExecute={async (selectedQueries) => {
const result = await execution.executeIntentResearch(state, selectedQueries);
@@ -596,30 +608,11 @@ export const ResearchInput: React.FC<ResearchInputProps> = ({ state, onUpdate, o
/>
)}
{/* Current Keywords Display */}
<CurrentKeywords
keywords={state.keywords}
onRemoveKeyword={handleRemoveKeyword}
/>
{/* Alternative Research Angles */}
<ResearchAngles
angles={researchAngles}
onUseAngle={handleUseAngle}
hasPersona={!!researchPersona}
/>
{/* Note: Current Keywords removed - keywords are now managed in IntentConfirmationPanel */}
{/* Note: Research Angles removed - intent-driven research already generates targeted queries */}
</div>
{/* Advanced Options Section */}
<AdvancedOptionsSection
advanced={advanced}
providerAvailability={providerAvailability}
config={state.config}
onConfigUpdate={handleConfigUpdate}
/>
</div>
);
};

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { WizardStepProps, ModeCardInfo } from '../types/research.types';
import { ResearchProvider } from '../../../services/blogWriterApi';
import { ResearchProvider } from '../../../services/researchApi';
const modeCards: ModeCardInfo[] = [
{

View File

@@ -1,6 +1,7 @@
import React, { useState } from 'react';
import { WizardStepProps, ResearchExecution } from '../types/research.types';
import { ResearchResults } from '../../BlogWriter/ResearchResults';
import { ResearchResponse } from '../../../services/researchApi';
import { BlogResearchResponse } from '../../../services/blogWriterApi';
import { IntentResultsDisplay } from './components/IntentResultsDisplay';
import { IntentDrivenResearchResponse } from '../types/intent.types';
@@ -332,7 +333,7 @@ export const StepResults: React.FC<StepResultsProps> = ({ state, onUpdate, onBac
{activeTab === 'analysis' && (
<div style={{ animation: 'fadeIn 0.3s ease' }}>
{state.results ? (
<ResearchResults research={state.results} showAnalysisOnly />
<ResearchResults research={state.results as BlogResearchResponse} showAnalysisOnly />
) : (
<div>
{intentResult.suggested_outline && intentResult.suggested_outline.length > 0 && (
@@ -372,7 +373,7 @@ export const StepResults: React.FC<StepResultsProps> = ({ state, onUpdate, onBac
</>
) : state.results ? (
// Traditional results display (no tabs)
<ResearchResults research={state.results} />
<ResearchResults research={state.results as BlogResearchResponse} />
) : (
<p style={{ color: '#666', textAlign: 'center', padding: '40px' }}>
No results available

View File

@@ -6,7 +6,7 @@
import React from 'react';
import { ProviderAvailability } from '../../../../api/researchConfig';
import { ResearchConfig } from '../../../../services/blogWriterApi';
import { ResearchConfig } from '../../../../services/researchApi';
import { ExaOptions } from './ExaOptions';
import { TavilyOptions } from './TavilyOptions';

View File

@@ -1,20 +1,24 @@
import React from 'react';
import { ResearchConfig } from '../../../../services/blogWriterApi';
import { ResearchConfig } from '../../../../services/researchApi';
import { exaCategories, exaSearchTypes } from '../utils/constants';
import { OptimizedConfig } from '../../types/intent.types';
import { Tooltip } from '@mui/material';
import { exaOptionTooltips } from './utils/exaTooltips';
interface ExaOptionsProps {
config: ResearchConfig;
onConfigUpdate: (updates: Partial<ResearchConfig>) => void;
optimizedConfig?: OptimizedConfig; // AI-optimized config with justifications
}
export const ExaOptions: React.FC<ExaOptionsProps> = ({ config, onConfigUpdate }) => {
export const ExaOptions: React.FC<ExaOptionsProps> = ({ config, onConfigUpdate, optimizedConfig }) => {
const handleCategoryChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const value = e.target.value;
onConfigUpdate({ exa_category: value || undefined });
};
const handleSearchTypeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const value = e.target.value as 'auto' | 'keyword' | 'neural';
const value = e.target.value as 'auto' | 'keyword' | 'neural' | 'fast' | 'deep';
onConfigUpdate({ exa_search_type: value });
};
@@ -30,6 +34,210 @@ export const ExaOptions: React.FC<ExaOptionsProps> = ({ config, onConfigUpdate }
onConfigUpdate({ exa_exclude_domains: domains });
};
const handleNumResultsChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = parseInt(e.target.value, 10);
if (!isNaN(value) && value >= 1 && value <= 100) {
onConfigUpdate({ exa_num_results: value });
}
};
const handleDateFilterChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
// Convert YYYY-MM-DD to ISO format if needed
const isoDate = value ? `${value}T00:00:00.000Z` : undefined;
onConfigUpdate({ exa_date_filter: isoDate || undefined });
};
const handleEndPublishedDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
const isoDate = value ? `${value}T23:59:59.999Z` : undefined;
onConfigUpdate({ exa_end_published_date: isoDate || undefined });
};
const handleStartCrawlDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
const isoDate = value ? `${value}T00:00:00.000Z` : undefined;
onConfigUpdate({ exa_start_crawl_date: isoDate || undefined });
};
const handleEndCrawlDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
const isoDate = value ? `${value}T23:59:59.999Z` : undefined;
onConfigUpdate({ exa_end_crawl_date: isoDate || undefined });
};
const handleIncludeTextChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
// Only one string supported, up to 5 words
const words = value.trim().split(/\s+/).filter(Boolean).slice(0, 5);
onConfigUpdate({ exa_include_text: words.length > 0 ? [words.join(' ')] : undefined });
};
const handleExcludeTextChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
// Only one string supported, up to 5 words
const words = value.trim().split(/\s+/).filter(Boolean).slice(0, 5);
onConfigUpdate({ exa_exclude_text: words.length > 0 ? [words.join(' ')] : undefined });
};
const handleHighlightsChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onConfigUpdate({ exa_highlights: e.target.checked });
};
const handleHighlightsNumSentencesChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = parseInt(e.target.value, 10);
if (!isNaN(value) && value >= 1) {
onConfigUpdate({ exa_highlights_num_sentences: value });
}
};
const handleHighlightsPerUrlChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = parseInt(e.target.value, 10);
if (!isNaN(value) && value >= 1) {
onConfigUpdate({ exa_highlights_per_url: value });
}
};
const handleContextMaxCharactersChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = parseInt(e.target.value, 10);
if (!isNaN(value) && value > 0) {
onConfigUpdate({
exa_context: true,
exa_context_max_characters: value
});
} else if (value === 0 || e.target.value === '') {
onConfigUpdate({
exa_context: false,
exa_context_max_characters: undefined
});
}
};
const handleTextMaxCharactersChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = parseInt(e.target.value, 10);
if (!isNaN(value) && value > 0) {
onConfigUpdate({ exa_text_max_characters: value });
} else if (e.target.value === '') {
onConfigUpdate({ exa_text_max_characters: undefined });
}
};
const handleSummaryQueryChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value.trim();
onConfigUpdate({ exa_summary_query: value || undefined });
};
const handleContextChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onConfigUpdate({ exa_context: e.target.checked });
};
const handleAdditionalQueriesChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value;
// Parse comma or newline-separated queries
const queries = value
.split(/[,\n]/)
.map(q => q.trim())
.filter(Boolean);
onConfigUpdate({ exa_additional_queries: queries.length > 0 ? queries : undefined });
};
// Get AI justification for a field
const getJustification = (field: string): string | undefined => {
if (!optimizedConfig) return undefined;
const justificationKey = `exa_${field}_justification` as keyof OptimizedConfig;
return optimizedConfig[justificationKey] as string | undefined;
};
// Get detailed tooltip content for a field
const getTooltipContent = (field: string): string => {
const aiJustification = getJustification(field);
const tooltipKey = field as keyof typeof exaOptionTooltips;
const baseTooltip = exaOptionTooltips[tooltipKey];
if (!baseTooltip) {
// Fallback to AI justification if no base tooltip
return aiJustification || '';
}
let tooltip = '';
switch (field) {
case 'category':
const categoryTooltip = baseTooltip as any;
tooltip = `${baseTooltip.description}\n\nExamples:\n${Object.entries(categoryTooltip.examples || {}).map(([key, val]) => ` ${key}: ${val}`).join('\n')}`;
break;
case 'searchType':
const selectedType = config.exa_search_type || 'auto';
const searchTypeTooltip = baseTooltip as any;
const types = searchTypeTooltip.types;
const typeInfo = types?.[selectedType];
if (typeInfo) {
tooltip = `${typeInfo.description}\n\nWhen to use: ${typeInfo.whenToUse}`;
if (typeInfo.latency) tooltip += `\n\nLatency: ${typeInfo.latency}`;
if (typeInfo.quality) tooltip += `\n\nQuality: ${typeInfo.quality}`;
if (typeInfo.limits) tooltip += `\n\nLimits: ${typeInfo.limits}`;
if (typeInfo.note) tooltip += `\n\nNote: ${typeInfo.note}`;
} else {
tooltip = baseTooltip.description || '';
}
break;
case 'numResults':
tooltip = `${baseTooltip.description}\n\n${(baseTooltip as any).limits || ''}\n\nRecommendations:\n${Object.entries((baseTooltip as any).recommendations || {}).map(([key, val]) => ` ${key} results: ${val}`).join('\n')}`;
break;
case 'dateFilter':
case 'endPublishedDate':
case 'startCrawlDate':
case 'endCrawlDate':
case 'includeText':
case 'excludeText':
case 'highlightsNumSentences':
case 'highlightsPerUrl':
case 'contextMaxCharacters':
case 'textMaxCharacters':
case 'summaryQuery':
tooltip = `${baseTooltip.description}\n\nWhen to use:\n${((baseTooltip as any).whenToUse || []).map((u: string) => `${u}`).join('\n')}\n\n${(baseTooltip as any).format || ''}\n\nExample: ${(baseTooltip as any).example || ''}\n\n${(baseTooltip as any).recommendation || ''}\n\n${(baseTooltip as any).limit || ''}\n\n${(baseTooltip as any).note || ''}`;
break;
case 'highlights':
tooltip = `${baseTooltip.description}\n\nBenefits:\n${((baseTooltip as any).benefits || []).map((b: string) => `${b}`).join('\n')}\n\n${(baseTooltip as any).whenToUse || ''}`;
break;
case 'context':
tooltip = `${baseTooltip.description}\n\nBenefits:\n${((baseTooltip as any).benefits || []).map((b: string) => `${b}`).join('\n')}\n\n${(baseTooltip as any).whenToUse || ''}\n\n${(baseTooltip as any).recommendation || ''}`;
break;
case 'includeDomains':
tooltip = `${baseTooltip.description}\n\nWhen to use:\n${((baseTooltip as any).whenToUse || []).map((u: string) => `${u}`).join('\n')}\n\n${(baseTooltip as any).format || ''}\n\nExample: ${(baseTooltip as any).example || ''}\n\n${(baseTooltip as any).limit || ''}`;
break;
case 'excludeDomains':
tooltip = `${baseTooltip.description}\n\nWhen to use:\n${((baseTooltip as any).whenToUse || []).map((u: string) => `${u}`).join('\n')}\n\n${(baseTooltip as any).format || ''}\n\nExample: ${(baseTooltip as any).example || ''}\n\n${(baseTooltip as any).limit || ''}`;
break;
case 'dateFilter':
case 'endPublishedDate':
case 'startCrawlDate':
case 'endCrawlDate':
tooltip = `${baseTooltip.description}\n\nWhen to use:\n${((baseTooltip as any).whenToUse || []).map((u: string) => `${u}`).join('\n')}\n\n${(baseTooltip as any).format || ''}\n\nExample: ${(baseTooltip as any).example || ''}\n\n${(baseTooltip as any).note || ''}`;
break;
default:
tooltip = (baseTooltip as any).description || '';
}
// Append AI justification if available
if (aiJustification) {
tooltip += `\n\n🤖 AI Recommendation: ${aiJustification}`;
}
return tooltip;
};
// Format date for input (YYYY-MM-DD from ISO string)
const formatDateForInput = (isoDate?: string): string => {
if (!isoDate) return '';
try {
const date = new Date(isoDate);
return date.toISOString().split('T')[0];
} catch {
return '';
}
};
return (
<div style={{
background: 'linear-gradient(135deg, rgba(139, 92, 246, 0.05) 0%, rgba(99, 102, 241, 0.05) 100%)',
@@ -57,13 +265,22 @@ export const ExaOptions: React.FC<ExaOptionsProps> = ({ config, onConfigUpdate }
{/* Exa Category */}
<div>
<label style={{
display: 'block',
display: 'flex',
alignItems: 'center',
gap: '4px',
marginBottom: '6px',
fontSize: '12px',
fontWeight: '600',
color: '#6b21a8',
}}>
Content Category
<Tooltip
title={<div style={{ whiteSpace: 'pre-line', fontSize: '12px', lineHeight: '1.5' }}>{getTooltipContent('category')}</div>}
arrow
placement="top"
>
<span style={{ fontSize: '10px', color: '#0ea5e9', cursor: 'help' }}></span>
</Tooltip>
</label>
<select
value={config.exa_category || ''}
@@ -88,13 +305,22 @@ export const ExaOptions: React.FC<ExaOptionsProps> = ({ config, onConfigUpdate }
{/* Exa Search Type */}
<div>
<label style={{
display: 'block',
display: 'flex',
alignItems: 'center',
gap: '4px',
marginBottom: '6px',
fontSize: '12px',
fontWeight: '600',
color: '#6b21a8',
}}>
Search Algorithm
<Tooltip
title={<div style={{ whiteSpace: 'pre-line', fontSize: '12px', lineHeight: '1.5' }}>{getTooltipContent('searchType')}</div>}
arrow
placement="top"
>
<span style={{ fontSize: '10px', color: '#0ea5e9', cursor: 'help' }}></span>
</Tooltip>
</label>
<select
value={config.exa_search_type || 'auto'}
@@ -115,8 +341,630 @@ export const ExaOptions: React.FC<ExaOptionsProps> = ({ config, onConfigUpdate }
))}
</select>
</div>
{/* Number of Results */}
<div>
<label style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
marginBottom: '6px',
fontSize: '12px',
fontWeight: '600',
color: '#6b21a8',
}}>
Number of Results
<Tooltip
title={<div style={{ whiteSpace: 'pre-line', fontSize: '12px', lineHeight: '1.5' }}>{getTooltipContent('numResults')}</div>}
arrow
placement="top"
>
<span style={{ fontSize: '10px', color: '#0ea5e9', cursor: 'help' }}></span>
</Tooltip>
</label>
<input
type="number"
min="1"
max="100"
value={config.exa_num_results || optimizedConfig?.exa_num_results || 10}
onChange={handleNumResultsChange}
style={{
width: '100%',
padding: '8px 10px',
fontSize: '12px',
border: '1px solid rgba(139, 92, 246, 0.2)',
borderRadius: '8px',
background: 'rgba(255, 255, 255, 0.9)',
color: '#0f172a',
}}
/>
</div>
{/* Date Filters - Published Dates */}
<div>
<label style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
marginBottom: '6px',
fontSize: '12px',
fontWeight: '600',
color: '#6b21a8',
}}>
Start Published Date (optional)
<Tooltip
title={<div style={{ whiteSpace: 'pre-line', fontSize: '12px', lineHeight: '1.5' }}>{getTooltipContent('dateFilter')}</div>}
arrow
placement="top"
>
<span style={{ fontSize: '10px', color: '#0ea5e9', cursor: 'help' }}></span>
</Tooltip>
</label>
<input
type="date"
value={formatDateForInput(config.exa_date_filter || optimizedConfig?.exa_date_filter)}
onChange={handleDateFilterChange}
style={{
width: '100%',
padding: '8px 10px',
fontSize: '12px',
border: '1px solid rgba(139, 92, 246, 0.2)',
borderRadius: '8px',
background: 'rgba(255, 255, 255, 0.9)',
color: '#0f172a',
}}
/>
</div>
<div>
<label style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
marginBottom: '6px',
fontSize: '12px',
fontWeight: '600',
color: '#6b21a8',
}}>
End Published Date (optional)
<Tooltip
title={<div style={{ whiteSpace: 'pre-line', fontSize: '12px', lineHeight: '1.5' }}>{getTooltipContent('endPublishedDate')}</div>}
arrow
placement="top"
>
<span style={{ fontSize: '10px', color: '#0ea5e9', cursor: 'help' }}></span>
</Tooltip>
</label>
<input
type="date"
value={formatDateForInput(config.exa_end_published_date)}
onChange={handleEndPublishedDateChange}
style={{
width: '100%',
padding: '8px 10px',
fontSize: '12px',
border: '1px solid rgba(139, 92, 246, 0.2)',
borderRadius: '8px',
background: 'rgba(255, 255, 255, 0.9)',
color: '#0f172a',
}}
/>
</div>
</div>
{/* Crawl Date Filters */}
<div style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '12px',
marginTop: '12px',
}}>
<div>
<label style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
marginBottom: '6px',
fontSize: '12px',
fontWeight: '600',
color: '#6b21a8',
}}>
Start Crawl Date (optional)
<Tooltip
title={<div style={{ whiteSpace: 'pre-line', fontSize: '12px', lineHeight: '1.5' }}>{getTooltipContent('startCrawlDate')}</div>}
arrow
placement="top"
>
<span style={{ fontSize: '10px', color: '#0ea5e9', cursor: 'help' }}></span>
</Tooltip>
</label>
<input
type="date"
value={formatDateForInput(config.exa_start_crawl_date)}
onChange={handleStartCrawlDateChange}
style={{
width: '100%',
padding: '8px 10px',
fontSize: '12px',
border: '1px solid rgba(139, 92, 246, 0.2)',
borderRadius: '8px',
background: 'rgba(255, 255, 255, 0.9)',
color: '#0f172a',
}}
/>
</div>
<div>
<label style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
marginBottom: '6px',
fontSize: '12px',
fontWeight: '600',
color: '#6b21a8',
}}>
End Crawl Date (optional)
<Tooltip
title={<div style={{ whiteSpace: 'pre-line', fontSize: '12px', lineHeight: '1.5' }}>{getTooltipContent('endCrawlDate')}</div>}
arrow
placement="top"
>
<span style={{ fontSize: '10px', color: '#0ea5e9', cursor: 'help' }}></span>
</Tooltip>
</label>
<input
type="date"
value={formatDateForInput(config.exa_end_crawl_date)}
onChange={handleEndCrawlDateChange}
style={{
width: '100%',
padding: '8px 10px',
fontSize: '12px',
border: '1px solid rgba(139, 92, 246, 0.2)',
borderRadius: '8px',
background: 'rgba(255, 255, 255, 0.9)',
color: '#0f172a',
}}
/>
</div>
</div>
{/* Text Filters */}
<div style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '12px',
marginTop: '12px',
}}>
<div>
<label style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
marginBottom: '6px',
fontSize: '12px',
fontWeight: '600',
color: '#6b21a8',
}}>
Include Text (optional, max 5 words)
<Tooltip
title={<div style={{ whiteSpace: 'pre-line', fontSize: '12px', lineHeight: '1.5' }}>{getTooltipContent('includeText')}</div>}
arrow
placement="top"
>
<span style={{ fontSize: '10px', color: '#0ea5e9', cursor: 'help' }}></span>
</Tooltip>
</label>
<input
type="text"
value={config.exa_include_text?.[0] || ''}
onChange={handleIncludeTextChange}
placeholder="e.g., large language model"
maxLength={50}
style={{
width: '100%',
padding: '8px 10px',
fontSize: '12px',
border: '1px solid rgba(139, 92, 246, 0.2)',
borderRadius: '8px',
background: 'rgba(255, 255, 255, 0.9)',
color: '#0f172a',
}}
/>
</div>
<div>
<label style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
marginBottom: '6px',
fontSize: '12px',
fontWeight: '600',
color: '#6b21a8',
}}>
Exclude Text (optional, max 5 words)
<Tooltip
title={<div style={{ whiteSpace: 'pre-line', fontSize: '12px', lineHeight: '1.5' }}>{getTooltipContent('excludeText')}</div>}
arrow
placement="top"
>
<span style={{ fontSize: '10px', color: '#0ea5e9', cursor: 'help' }}></span>
</Tooltip>
</label>
<input
type="text"
value={config.exa_exclude_text?.[0] || ''}
onChange={handleExcludeTextChange}
placeholder="e.g., course tutorial"
maxLength={50}
style={{
width: '100%',
padding: '8px 10px',
fontSize: '12px',
border: '1px solid rgba(139, 92, 246, 0.2)',
borderRadius: '8px',
background: 'rgba(255, 255, 255, 0.9)',
color: '#0f172a',
}}
/>
</div>
</div>
{/* Boolean Options Row */}
<div style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '12px',
marginTop: '12px',
marginBottom: '12px',
}}>
{/* Highlights */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '8px',
border: '1px solid rgba(139, 92, 246, 0.2)',
borderRadius: '8px',
background: 'rgba(255, 255, 255, 0.9)',
}}>
<input
type="checkbox"
checked={config.exa_highlights ?? optimizedConfig?.exa_highlights ?? true}
onChange={handleHighlightsChange}
style={{
cursor: 'pointer',
}}
/>
<label style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
fontSize: '12px',
fontWeight: '600',
color: '#6b21a8',
cursor: 'pointer',
flex: 1,
}}>
Extract Highlights
<Tooltip
title={<div style={{ whiteSpace: 'pre-line', fontSize: '12px', lineHeight: '1.5' }}>{getTooltipContent('highlights')}</div>}
arrow
placement="top"
>
<span style={{ fontSize: '10px', color: '#0ea5e9', cursor: 'help' }}></span>
</Tooltip>
</label>
</div>
{/* Context */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '8px',
border: '1px solid rgba(139, 92, 246, 0.2)',
borderRadius: '8px',
background: 'rgba(255, 255, 255, 0.9)',
}}>
<input
type="checkbox"
checked={typeof config.exa_context === 'object' ? true : (config.exa_context ?? (typeof optimizedConfig?.exa_context === 'object' ? true : optimizedConfig?.exa_context ?? true))}
onChange={handleContextChange}
style={{
cursor: 'pointer',
}}
/>
<label style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
fontSize: '12px',
fontWeight: '600',
color: '#6b21a8',
cursor: 'pointer',
flex: 1,
}}>
Return Context String
<Tooltip
title={<div style={{ whiteSpace: 'pre-line', fontSize: '12px', lineHeight: '1.5' }}>{getTooltipContent('context')}</div>}
arrow
placement="top"
>
<span style={{ fontSize: '10px', color: '#0ea5e9', cursor: 'help' }}></span>
</Tooltip>
</label>
</div>
</div>
{/* Configurable Contents Options */}
{config.exa_highlights && (
<div style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '12px',
marginTop: '12px',
}}>
<div>
<label style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
marginBottom: '6px',
fontSize: '12px',
fontWeight: '600',
color: '#6b21a8',
}}>
Highlights: Sentences Per Snippet
<Tooltip
title={<div style={{ whiteSpace: 'pre-line', fontSize: '12px', lineHeight: '1.5' }}>{getTooltipContent('highlightsNumSentences')}</div>}
arrow
placement="top"
>
<span style={{ fontSize: '10px', color: '#0ea5e9', cursor: 'help' }}></span>
</Tooltip>
</label>
<input
type="number"
min="1"
value={config.exa_highlights_num_sentences || 2}
onChange={handleHighlightsNumSentencesChange}
style={{
width: '100%',
padding: '8px 10px',
fontSize: '12px',
border: '1px solid rgba(139, 92, 246, 0.2)',
borderRadius: '8px',
background: 'rgba(255, 255, 255, 0.9)',
color: '#0f172a',
}}
/>
</div>
<div>
<label style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
marginBottom: '6px',
fontSize: '12px',
fontWeight: '600',
color: '#6b21a8',
}}>
Highlights: Snippets Per URL
<Tooltip
title={<div style={{ whiteSpace: 'pre-line', fontSize: '12px', lineHeight: '1.5' }}>{getTooltipContent('highlightsPerUrl')}</div>}
arrow
placement="top"
>
<span style={{ fontSize: '10px', color: '#0ea5e9', cursor: 'help' }}></span>
</Tooltip>
</label>
<input
type="number"
min="1"
value={config.exa_highlights_per_url || 3}
onChange={handleHighlightsPerUrlChange}
style={{
width: '100%',
padding: '8px 10px',
fontSize: '12px',
border: '1px solid rgba(139, 92, 246, 0.2)',
borderRadius: '8px',
background: 'rgba(255, 255, 255, 0.9)',
color: '#0f172a',
}}
/>
</div>
</div>
)}
{config.exa_context && (
<div style={{
marginTop: '12px',
}}>
<label style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
marginBottom: '6px',
fontSize: '12px',
fontWeight: '600',
color: '#6b21a8',
}}>
Context: Max Characters (optional, recommended 10,000+)
<Tooltip
title={<div style={{ whiteSpace: 'pre-line', fontSize: '12px', lineHeight: '1.5' }}>{getTooltipContent('contextMaxCharacters')}</div>}
arrow
placement="top"
>
<span style={{ fontSize: '10px', color: '#0ea5e9', cursor: 'help' }}></span>
</Tooltip>
</label>
<input
type="number"
min="0"
value={config.exa_context_max_characters || ''}
onChange={handleContextMaxCharactersChange}
placeholder="Leave empty for no limit (recommended)"
style={{
width: '100%',
padding: '8px 10px',
fontSize: '12px',
border: '1px solid rgba(139, 92, 246, 0.2)',
borderRadius: '8px',
background: 'rgba(255, 255, 255, 0.9)',
color: '#0f172a',
}}
/>
</div>
)}
<div style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '12px',
marginTop: '12px',
}}>
<div>
<label style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
marginBottom: '6px',
fontSize: '12px',
fontWeight: '600',
color: '#6b21a8',
}}>
Text: Max Characters (optional)
<Tooltip
title={<div style={{ whiteSpace: 'pre-line', fontSize: '12px', lineHeight: '1.5' }}>{getTooltipContent('textMaxCharacters')}</div>}
arrow
placement="top"
>
<span style={{ fontSize: '10px', color: '#0ea5e9', cursor: 'help' }}></span>
</Tooltip>
</label>
<input
type="number"
min="0"
value={config.exa_text_max_characters || 1000}
onChange={handleTextMaxCharactersChange}
style={{
width: '100%',
padding: '8px 10px',
fontSize: '12px',
border: '1px solid rgba(139, 92, 246, 0.2)',
borderRadius: '8px',
background: 'rgba(255, 255, 255, 0.9)',
color: '#0f172a',
}}
/>
</div>
<div>
<label style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
marginBottom: '6px',
fontSize: '12px',
fontWeight: '600',
color: '#6b21a8',
}}>
Summary: Custom Query (optional)
<Tooltip
title={<div style={{ whiteSpace: 'pre-line', fontSize: '12px', lineHeight: '1.5' }}>{getTooltipContent('summaryQuery')}</div>}
arrow
placement="top"
>
<span style={{ fontSize: '10px', color: '#0ea5e9', cursor: 'help' }}></span>
</Tooltip>
</label>
<input
type="text"
value={config.exa_summary_query || ''}
onChange={handleSummaryQueryChange}
placeholder="e.g., Key insights about..."
style={{
width: '100%',
padding: '8px 10px',
fontSize: '12px',
border: '1px solid rgba(139, 92, 246, 0.2)',
borderRadius: '8px',
background: 'rgba(255, 255, 255, 0.9)',
color: '#0f172a',
}}
/>
</div>
</div>
{/* Additional Queries for Deep Search */}
{config.exa_search_type === 'deep' && (
<div style={{ marginBottom: '12px' }}>
<label style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
marginBottom: '6px',
fontSize: '12px',
fontWeight: '600',
color: '#6b21a8',
}}>
Additional Query Variations (Deep Search Only)
<Tooltip
title={
<div style={{ whiteSpace: 'pre-line', fontSize: '12px', lineHeight: '1.5' }}>
Provide 2-3 query variations to expand your Deep search. These queries are used alongside the main query for comprehensive results. Deep search will auto-generate variations if not provided.
{'\n\n'}
Example:
{'\n'} LLM advancements
{'\n'} large language model progress
{'\n'} recent AI breakthroughs
{optimizedConfig?.exa_additional_queries_justification && `\n\n🤖 AI Recommendation: ${optimizedConfig.exa_additional_queries_justification}`}
</div>
}
arrow
placement="top"
>
<span style={{ fontSize: '10px', color: '#0ea5e9', cursor: 'help' }}></span>
</Tooltip>
</label>
<textarea
value={config.exa_additional_queries?.join(', ') || ''}
onChange={handleAdditionalQueriesChange}
placeholder="Enter query variations separated by commas or new lines (e.g., LLM advancements, large language model progress)"
rows={3}
style={{
width: '100%',
padding: '8px 10px',
fontSize: '12px',
border: '1px solid rgba(139, 92, 246, 0.2)',
borderRadius: '8px',
background: 'rgba(255, 255, 255, 0.9)',
color: '#0f172a',
fontFamily: 'inherit',
resize: 'vertical',
}}
/>
{optimizedConfig?.exa_additional_queries && optimizedConfig.exa_additional_queries.length > 0 && (
<div style={{
marginTop: '6px',
padding: '6px 8px',
backgroundColor: '#f0fdf4',
border: '1px solid #86efac',
borderRadius: '6px',
fontSize: '11px',
color: '#166534',
}}>
<strong>AI Suggested:</strong> {optimizedConfig.exa_additional_queries.join(', ')}
</div>
)}
</div>
)}
{/* Domain Filters */}
<div style={{
display: 'grid',
@@ -125,13 +973,22 @@ export const ExaOptions: React.FC<ExaOptionsProps> = ({ config, onConfigUpdate }
}}>
<div>
<label style={{
display: 'block',
display: 'flex',
alignItems: 'center',
gap: '4px',
marginBottom: '6px',
fontSize: '12px',
fontWeight: '600',
color: '#6b21a8',
}}>
Include Domains (optional)
<Tooltip
title={<div style={{ whiteSpace: 'pre-line', fontSize: '12px', lineHeight: '1.5' }}>{getTooltipContent('includeDomains')}</div>}
arrow
placement="top"
>
<span style={{ fontSize: '10px', color: '#0ea5e9', cursor: 'help' }}></span>
</Tooltip>
</label>
<input
type="text"
@@ -152,13 +1009,22 @@ export const ExaOptions: React.FC<ExaOptionsProps> = ({ config, onConfigUpdate }
<div>
<label style={{
display: 'block',
display: 'flex',
alignItems: 'center',
gap: '4px',
marginBottom: '6px',
fontSize: '12px',
fontWeight: '600',
color: '#6b21a8',
}}>
Exclude Domains (optional)
<Tooltip
title={<div style={{ whiteSpace: 'pre-line', fontSize: '12px', lineHeight: '1.5' }}>{getTooltipContent('excludeDomains')}</div>}
arrow
placement="top"
>
<span style={{ fontSize: '10px', color: '#0ea5e9', cursor: 'help' }}></span>
</Tooltip>
</label>
<input
type="text"

View File

@@ -9,11 +9,14 @@ import {
Box,
Button,
CircularProgress,
Alert,
Typography,
} from '@mui/material';
import {
ExpandMore as ExpandMoreIcon,
ExpandLess as ExpandLessIcon,
PlayArrow as PlayIcon,
Info as InfoIcon,
} from '@mui/icons-material';
interface ActionButtonsProps {
@@ -32,39 +35,89 @@ export const ActionButtons: React.FC<ActionButtonsProps> = ({
canExecute,
}) => {
return (
<Box
display="flex"
justifyContent="space-between"
alignItems="center"
mt={2}
pt={2}
borderTop="1px solid #e5e7eb"
>
<Button
size="small"
onClick={onToggleDetails}
endIcon={showDetails ? <ExpandLessIcon /> : <ExpandMoreIcon />}
sx={{
color: '#666',
'&:hover': { backgroundColor: '#f3f4f6' },
}}
<Box mt={2}>
{/* Guidance Message */}
{canExecute && !isExecuting && (
<Alert
severity="info"
icon={<InfoIcon />}
sx={{
mb: 2,
backgroundColor: '#e0f2fe',
border: '1px solid #bae6fd',
'& .MuiAlert-icon': {
color: '#0ea5e9',
},
'& .MuiAlert-message': {
color: '#0c4a6e',
},
}}
>
<Typography variant="body2" fontWeight={500} gutterBottom>
Ready to start research!
</Typography>
<Typography variant="caption" display="block">
Review the research question and parameters above. Click "Start Research" to begin gathering information. You can edit any field before starting.
</Typography>
</Alert>
)}
{!canExecute && !isExecuting && (
<Alert
severity="warning"
sx={{
mb: 2,
backgroundColor: '#fff7ed',
border: '1px solid #fed7aa',
'& .MuiAlert-icon': {
color: '#f59e0b',
},
'& .MuiAlert-message': {
color: '#92400e',
},
}}
>
<Typography variant="body2" fontWeight={500}>
Please select at least one research query to proceed.
</Typography>
</Alert>
)}
<Box
display="flex"
justifyContent="space-between"
alignItems="center"
pt={2}
borderTop="1px solid #e5e7eb"
>
{showDetails ? 'Less details' : 'More details'}
</Button>
<Button
variant="contained"
color="primary"
startIcon={isExecuting ? <CircularProgress size={16} color="inherit" /> : <PlayIcon />}
onClick={onExecute}
disabled={isExecuting || !canExecute}
sx={{
backgroundColor: '#0ea5e9',
'&:hover': { backgroundColor: '#0284c7' },
'&:disabled': { backgroundColor: '#d1d5db', color: '#9ca3af' },
}}
>
{isExecuting ? 'Researching...' : 'Start Research'}
</Button>
<Button
size="small"
onClick={onToggleDetails}
endIcon={showDetails ? <ExpandLessIcon /> : <ExpandMoreIcon />}
sx={{
color: '#666',
'&:hover': { backgroundColor: '#f3f4f6' },
}}
>
{showDetails ? 'Less details' : 'More details'}
</Button>
<Button
variant="contained"
color="primary"
startIcon={isExecuting ? <CircularProgress size={16} color="inherit" /> : <PlayIcon />}
onClick={onExecute}
disabled={isExecuting || !canExecute}
sx={{
backgroundColor: '#0ea5e9',
'&:hover': { backgroundColor: '#0284c7' },
'&:disabled': { backgroundColor: '#d1d5db', color: '#9ca3af' },
fontWeight: 600,
px: 3,
}}
>
{isExecuting ? 'Researching...' : 'Start Research'}
</Button>
</Box>
</Box>
);
};

View File

@@ -5,13 +5,15 @@
* This is specific to IntentConfirmationPanel and includes AI justifications.
*/
import React from 'react';
import React, { useState, useEffect } from 'react';
import {
Box,
Typography,
Chip,
Tooltip,
Button,
Tabs,
Tab,
} from '@mui/material';
import {
Info as InfoIcon,
@@ -21,7 +23,7 @@ import { ProviderAvailability } from '../../../../../api/researchConfig';
import { ExaOptions } from '../ExaOptions';
import { TavilyOptions } from '../TavilyOptions';
import { ProviderChips } from '../ProviderChips';
import { ResearchProvider } from '../../../../../services/blogWriterApi';
import { ResearchProvider } from '../../../../../services/researchApi';
interface AdvancedProviderOptionsSectionProps {
intentAnalysis: AnalyzeIntentResponse;
@@ -40,6 +42,35 @@ export const AdvancedProviderOptionsSection: React.FC<AdvancedProviderOptionsSec
showAdvancedOptions,
onAdvancedOptionsChange,
}) => {
const [activeTab, setActiveTab] = useState<number>(() => {
// Initialize tab based on current provider
if (config.provider === 'tavily' && providerAvailability.tavily_available) return 1;
if (config.provider === 'exa' && providerAvailability.exa_available) return 0;
// Default to first available provider
if (providerAvailability.exa_available) return 0;
if (providerAvailability.tavily_available) return 1;
return 0;
});
// Sync active tab when provider changes externally
useEffect(() => {
if (config.provider === 'tavily' && providerAvailability.tavily_available) {
setActiveTab(1);
} else if (config.provider === 'exa' && providerAvailability.exa_available) {
setActiveTab(0);
}
}, [config.provider, providerAvailability.exa_available, providerAvailability.tavily_available]);
const handleTabChange = (event: React.SyntheticEvent, newValue: number) => {
setActiveTab(newValue);
// Update provider based on selected tab
if (newValue === 0 && providerAvailability.exa_available) {
onConfigUpdate({ provider: 'exa' });
} else if (newValue === 1 && providerAvailability.tavily_available) {
onConfigUpdate({ provider: 'tavily' });
}
};
return (
<>
{/* Toggle Advanced Options Button */}
@@ -127,139 +158,60 @@ export const AdvancedProviderOptionsSection: React.FC<AdvancedProviderOptionsSec
)}
</Box>
<ProviderChips providerAvailability={providerAvailability} />
{/* Provider Selector */}
<Box sx={{ mt: 1 }}>
<select
value={config.provider || 'exa'}
onChange={(e) => onConfigUpdate({ provider: e.target.value as ResearchProvider })}
style={{
padding: '6px 12px',
borderRadius: '6px',
border: '1px solid #e2e8f0',
fontSize: '13px',
backgroundColor: 'white',
cursor: 'pointer',
}}
>
{providerAvailability.exa_available && <option value="exa">Exa</option>}
{providerAvailability.tavily_available && <option value="tavily">Tavily</option>}
<option value="google">Google</option>
</select>
</Box>
</Box>
{/* Provider-specific Options with AI tooltips */}
{config.provider === 'exa' && providerAvailability.exa_available && (
<>
{/* AI Settings Summary for Exa */}
{intentAnalysis?.optimized_config && (
<Box sx={{
mb: 2,
p: 1.5,
backgroundColor: '#f8fafc',
borderRadius: '6px',
border: '1px solid #e2e8f0',
}}>
<Typography variant="caption" color="#666" fontWeight={600} gutterBottom display="block">
AI-Selected Exa Settings
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mt: 1 }}>
{intentAnalysis.optimized_config.exa_type && (
<Tooltip title={intentAnalysis.optimized_config.exa_type_justification || 'Search type'} arrow>
<Chip
label={`Type: ${intentAnalysis.optimized_config.exa_type}`}
size="small"
sx={{ fontSize: '11px', backgroundColor: '#e0f2fe', color: '#0369a1' }}
/>
</Tooltip>
)}
{intentAnalysis.optimized_config.exa_category && (
<Tooltip title={intentAnalysis.optimized_config.exa_category_justification || 'Category focus'} arrow>
<Chip
label={`Category: ${intentAnalysis.optimized_config.exa_category}`}
size="small"
sx={{ fontSize: '11px', backgroundColor: '#fef3c7', color: '#92400e' }}
/>
</Tooltip>
)}
{intentAnalysis.optimized_config.exa_date_filter && (
<Tooltip title={intentAnalysis.optimized_config.exa_date_justification || 'Date filter'} arrow>
<Chip
label={`Since: ${intentAnalysis.optimized_config.exa_date_filter.split('T')[0]}`}
size="small"
sx={{ fontSize: '11px', backgroundColor: '#f3e8ff', color: '#7c3aed' }}
/>
</Tooltip>
)}
</Box>
</Box>
{/* Provider Tabs */}
<Box sx={{ mb: 2 }}>
<Tabs
value={activeTab}
onChange={handleTabChange}
sx={{
borderBottom: 1,
borderColor: 'divider',
'& .MuiTab-root': {
textTransform: 'none',
fontSize: '13px',
fontWeight: 500,
minHeight: '40px',
'&.Mui-selected': {
color: '#0ea5e9',
fontWeight: 600,
},
},
'& .MuiTabs-indicator': {
backgroundColor: '#0ea5e9',
},
}}
>
{providerAvailability.exa_available && (
<Tab
label="Exa Options"
disabled={!providerAvailability.exa_available}
/>
)}
<ExaOptions
config={config}
onConfigUpdate={onConfigUpdate}
/>
</>
{providerAvailability.tavily_available && (
<Tab
label="Tavily Options"
disabled={!providerAvailability.tavily_available}
/>
)}
</Tabs>
</Box>
{/* Provider-specific Options */}
{activeTab === 0 && providerAvailability.exa_available && (
<ExaOptions
config={config}
onConfigUpdate={onConfigUpdate}
optimizedConfig={intentAnalysis?.optimized_config}
/>
)}
{config.provider === 'tavily' && providerAvailability.tavily_available && (
<>
{/* AI Settings Summary for Tavily */}
{intentAnalysis?.optimized_config && (
<Box sx={{
mb: 2,
p: 1.5,
backgroundColor: '#f8fafc',
borderRadius: '6px',
border: '1px solid #e2e8f0',
}}>
<Typography variant="caption" color="#666" fontWeight={600} gutterBottom display="block">
AI-Selected Tavily Settings
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mt: 1 }}>
{intentAnalysis.optimized_config.tavily_topic && (
<Tooltip title={intentAnalysis.optimized_config.tavily_topic_justification || 'Topic category'} arrow>
<Chip
label={`Topic: ${intentAnalysis.optimized_config.tavily_topic}`}
size="small"
sx={{ fontSize: '11px', backgroundColor: '#e0f2fe', color: '#0369a1' }}
/>
</Tooltip>
)}
{intentAnalysis.optimized_config.tavily_search_depth && (
<Tooltip title={intentAnalysis.optimized_config.tavily_search_depth_justification || 'Search depth'} arrow>
<Chip
label={`Depth: ${intentAnalysis.optimized_config.tavily_search_depth}`}
size="small"
sx={{ fontSize: '11px', backgroundColor: '#fef3c7', color: '#92400e' }}
/>
</Tooltip>
)}
{intentAnalysis.optimized_config.tavily_time_range && (
<Tooltip title={intentAnalysis.optimized_config.tavily_time_range_justification || 'Time filter'} arrow>
<Chip
label={`Time: ${intentAnalysis.optimized_config.tavily_time_range}`}
size="small"
sx={{ fontSize: '11px', backgroundColor: '#dcfce7', color: '#166534' }}
/>
</Tooltip>
)}
{intentAnalysis.optimized_config.tavily_include_answer && (
<Tooltip title={intentAnalysis.optimized_config.tavily_include_answer_justification || 'AI answer'} arrow>
<Chip
label="AI Answer ✓"
size="small"
sx={{ fontSize: '11px', backgroundColor: '#f3e8ff', color: '#7c3aed' }}
/>
</Tooltip>
)}
</Box>
</Box>
)}
<TavilyOptions
config={config}
onConfigUpdate={onConfigUpdate}
/>
</>
{activeTab === 1 && providerAvailability.tavily_available && (
<TavilyOptions
config={config}
onConfigUpdate={onConfigUpdate}
/>
)}
</Box>
)}

View File

@@ -40,11 +40,27 @@ export const DeliverablesSelector: React.FC<DeliverablesSelectorProps> = ({
}}
>
<Box display="flex" alignItems="center" justifyContent="space-between" mb={1}>
<Typography variant="caption" color="#666" fontWeight={600}>
What I'll find for you:
</Typography>
<Tooltip title="Click chips to select/remove deliverables">
<InfoIcon sx={{ fontSize: 14, color: '#9ca3af' }} />
<Tooltip
title={
<Box sx={{ p: 0.5 }}>
<Typography variant="caption" fontWeight={600} display="block" gutterBottom>
Research Deliverables
</Typography>
<Typography variant="caption" display="block" sx={{ mb: 0.5 }}>
These are the specific types of information ALwrity will extract from the research results. Click chips to toggle them on/off.
</Typography>
<Typography variant="caption" display="block" sx={{ mt: 1, fontStyle: 'italic' }}>
Selected deliverables will be highlighted in blue. Unselected ones will be skipped during research analysis.
</Typography>
</Box>
}
arrow
placement="top"
>
<Typography variant="caption" color="#666" fontWeight={600} sx={{ cursor: 'help', display: 'flex', alignItems: 'center', gap: 0.5 }}>
What I'll find for you:
<InfoIcon sx={{ fontSize: 14, color: '#9ca3af' }} />
</Typography>
</Tooltip>
</Box>
<Box display="flex" flexWrap="wrap" gap={0.5}>

View File

@@ -58,7 +58,47 @@ export const EditableField: React.FC<EditableFieldProps> = ({
<Select
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
sx={{ backgroundColor: '#ffffff' }}
sx={{
backgroundColor: '#ffffff',
color: '#1e293b',
border: '1px solid #0ea5e9',
borderRadius: '6px',
fontSize: '0.875rem',
'&:hover': {
borderColor: '#0284c7',
},
'& .MuiSelect-select': {
color: '#1e293b',
padding: '6px 14px',
},
'& .MuiOutlinedInput-notchedOutline': {
borderColor: '#0ea5e9',
},
'&:hover .MuiOutlinedInput-notchedOutline': {
borderColor: '#0284c7',
},
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
borderColor: '#0284c7',
borderWidth: '2px',
},
}}
MenuProps={{
PaperProps: {
sx: {
backgroundColor: '#ffffff',
'& .MuiMenuItem-root': {
color: '#1e293b',
'&:hover': {
backgroundColor: '#f3f4f6',
},
'&.Mui-selected': {
backgroundColor: '#e0f2fe',
color: '#0284c7',
},
},
},
},
}}
autoFocus
>
{options.map(opt => (
@@ -72,7 +112,24 @@ export const EditableField: React.FC<EditableFieldProps> = ({
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
fullWidth
sx={{ backgroundColor: '#ffffff' }}
sx={{
backgroundColor: '#ffffff',
'& .MuiInputBase-input': {
color: '#1e293b',
},
'& .MuiOutlinedInput-root': {
'& fieldset': {
borderColor: '#0ea5e9',
},
'&:hover fieldset': {
borderColor: '#0284c7',
},
'&.Mui-focused fieldset': {
borderColor: '#0284c7',
borderWidth: '2px',
},
},
}}
autoFocus
/>
)}

View File

@@ -0,0 +1,211 @@
/**
* EditableListField Component
*
* Editable list field for managing arrays of strings (e.g., focus areas, also answering).
*/
import React, { useState } from 'react';
import {
Box,
Typography,
TextField,
Chip,
IconButton,
InputAdornment,
Tooltip,
} from '@mui/material';
import {
Add as AddIcon,
Edit as EditIcon,
Delete as DeleteIcon,
Info as InfoIcon,
} from '@mui/icons-material';
interface EditableListFieldProps {
label: string;
items: string[];
onUpdate: (items: string[]) => void;
placeholder?: string;
tooltip?: string;
maxItems?: number;
}
export const EditableListField: React.FC<EditableListFieldProps> = ({
label,
items,
onUpdate,
placeholder = 'Add item...',
tooltip,
maxItems,
}) => {
const [newItem, setNewItem] = useState('');
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [editingValue, setEditingValue] = useState('');
const handleAdd = () => {
if (newItem.trim() && (!maxItems || items.length < maxItems)) {
onUpdate([...items, newItem.trim()]);
setNewItem('');
}
};
const handleDelete = (index: number) => {
onUpdate(items.filter((_, i) => i !== index));
};
const handleStartEdit = (index: number) => {
setEditingIndex(index);
setEditingValue(items[index]);
};
const handleSaveEdit = () => {
if (editingIndex !== null && editingValue.trim()) {
const updated = [...items];
updated[editingIndex] = editingValue.trim();
onUpdate(updated);
setEditingIndex(null);
setEditingValue('');
}
};
const handleCancelEdit = () => {
setEditingIndex(null);
setEditingValue('');
};
return (
<Box sx={{ mb: 2 }}>
<Box display="flex" alignItems="center" gap={0.5} mb={1}>
<Typography variant="caption" color="#666" fontWeight={600}>
{label}:
</Typography>
{tooltip && (
<Tooltip title={tooltip} arrow>
<InfoIcon sx={{ fontSize: 14, color: '#9ca3af', cursor: 'help' }} />
</Tooltip>
)}
</Box>
{/* Existing Items */}
{items.length > 0 && (
<Box display="flex" flexWrap="wrap" gap={0.5} mb={1}>
{items.map((item, idx) => (
<Chip
key={idx}
label={
editingIndex === idx ? (
<TextField
value={editingValue}
onChange={(e) => setEditingValue(e.target.value)}
onBlur={handleSaveEdit}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleSaveEdit();
} else if (e.key === 'Escape') {
handleCancelEdit();
}
}}
autoFocus
size="small"
sx={{
width: '120px',
'& .MuiInputBase-root': {
fontSize: '0.75rem',
height: '20px',
color: '#1e293b',
backgroundColor: '#ffffff',
},
'& .MuiInputBase-input': {
color: '#1e293b',
},
'& .MuiOutlinedInput-root': {
'& fieldset': {
borderColor: '#d1d5db',
},
},
}}
onClick={(e) => e.stopPropagation()}
/>
) : (
item
)
}
size="small"
onDelete={editingIndex === idx ? undefined : () => handleDelete(idx)}
deleteIcon={editingIndex === idx ? undefined : <DeleteIcon sx={{ fontSize: 14 }} />}
onClick={() => editingIndex !== idx && handleStartEdit(idx)}
sx={{
backgroundColor: '#f3f4f6',
border: '1px solid #d1d5db',
color: '#374151',
cursor: editingIndex === idx ? 'default' : 'pointer',
'&:hover': {
backgroundColor: '#e5e7eb',
},
'& .MuiChip-label': {
padding: editingIndex === idx ? '0 4px' : '0 8px',
},
}}
/>
))}
</Box>
)}
{/* Add New Item */}
{(!maxItems || items.length < maxItems) && (
<TextField
fullWidth
size="small"
placeholder={placeholder}
value={newItem}
onChange={(e) => setNewItem(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && newItem.trim()) {
handleAdd();
}
}}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton
size="small"
onClick={handleAdd}
disabled={!newItem.trim()}
sx={{ p: 0.5 }}
>
<AddIcon sx={{ fontSize: 18 }} />
</IconButton>
</InputAdornment>
),
}}
sx={{
'& .MuiInputBase-root': {
fontSize: '0.875rem',
color: '#1e293b',
backgroundColor: '#ffffff',
},
'& .MuiInputBase-input': {
color: '#1e293b',
'&::placeholder': {
color: '#9ca3af',
opacity: 1,
},
},
'& .MuiOutlinedInput-root': {
'& fieldset': {
borderColor: '#d1d5db',
},
'&:hover fieldset': {
borderColor: '#9ca3af',
},
'&.Mui-focused fieldset': {
borderColor: '#0ea5e9',
borderWidth: '2px',
},
},
}}
/>
)}
</Box>
);
};

View File

@@ -2,38 +2,61 @@
* ExpandableDetails Component
*
* Collapsible section showing secondary questions, focus areas, and research angles.
* Now with editable "Also Answering" and "Focus Areas" sections.
*/
import React from 'react';
import {
Box,
Typography,
Chip,
Collapse,
} from '@mui/material';
import { AnalyzeIntentResponse } from '../../../types/intent.types';
import { AnalyzeIntentResponse, ResearchIntent } from '../../../types/intent.types';
import { EditableListField } from './EditableListField';
interface ExpandableDetailsProps {
intentAnalysis: AnalyzeIntentResponse;
expanded: boolean;
intent: ResearchIntent;
onUpdateField: <K extends keyof ResearchIntent>(field: K, value: ResearchIntent[K]) => void;
}
export const ExpandableDetails: React.FC<ExpandableDetailsProps> = ({
intentAnalysis,
expanded,
intent,
onUpdateField,
}) => {
const intent = intentAnalysis.intent;
return (
<Collapse in={expanded}>
<Box sx={{ pt: 2, borderTop: '1px solid #e5e7eb', mt: 2 }}>
{/* Secondary Questions */}
{/* Also Answering (Secondary Questions) - Editable */}
<EditableListField
label="Also Answering"
items={intent.also_answering || []}
onUpdate={(items) => onUpdateField('also_answering', items)}
placeholder="Add a question or topic to also answer..."
tooltip="Additional questions or topics that should be addressed in the research results, even if not explicitly asked."
maxItems={10}
/>
{/* Focus Areas - Editable */}
<EditableListField
label="Focus Areas"
items={intent.focus_areas || []}
onUpdate={(items) => onUpdateField('focus_areas', items)}
placeholder="Add a focus area..."
tooltip="Specific aspects or areas to focus on during research (e.g., 'academic research', 'industry trends', 'company analysis')."
maxItems={10}
/>
{/* Secondary Questions (Read-only, for reference) */}
{intent.secondary_questions.length > 0 && (
<Box mb={2}>
<Typography variant="caption" color="#666" fontWeight={600} gutterBottom display="block">
Also answering:
Secondary Questions:
</Typography>
{intent.secondary_questions.slice(0, 3).map((q, idx) => (
{intent.secondary_questions.map((q, idx) => (
<Typography key={idx} variant="body2" color="#333" sx={{ ml: 1, mb: 0.5 }}>
{q}
</Typography>
@@ -41,29 +64,6 @@ export const ExpandableDetails: React.FC<ExpandableDetailsProps> = ({
</Box>
)}
{/* Focus Areas */}
{intent.focus_areas.length > 0 && (
<Box mb={2}>
<Typography variant="caption" color="#666" fontWeight={600} gutterBottom display="block">
Focus areas:
</Typography>
<Box display="flex" flexWrap="wrap" gap={0.5}>
{intent.focus_areas.map((area, idx) => (
<Chip
key={idx}
label={area}
size="small"
sx={{
backgroundColor: '#f3f4f6',
border: '1px solid #d1d5db',
color: '#374151',
}}
/>
))}
</Box>
</Box>
)}
{/* Research Angles */}
{intentAnalysis.suggested_angles?.length > 0 && (
<Box mb={2}>

View File

@@ -12,6 +12,7 @@ import {
AnalyzeIntentResponse,
ResearchQuery,
ExpectedDeliverable,
TrendsConfig,
} from '../../../types/intent.types';
import { ProviderAvailability } from '../../../../../api/researchConfig';
@@ -31,7 +32,7 @@ export interface IntentConfirmationPanelProps {
isAnalyzing: boolean;
intentAnalysis: AnalyzeIntentResponse | null;
confirmedIntent: ResearchIntent | null;
onConfirm: (intent: ResearchIntent) => void;
onConfirm: (intent: ResearchIntent, state?: any) => void; // Added optional state parameter
onUpdateField: <K extends keyof ResearchIntent>(field: K, value: ResearchIntent[K]) => void;
onExecute: (selectedQueries?: ResearchQuery[]) => void;
onDismiss: () => void;
@@ -41,6 +42,7 @@ export interface IntentConfirmationPanelProps {
providerAvailability?: ProviderAvailability | null;
config?: any;
onConfigUpdate?: (updates: any) => void;
wizardState?: any; // Add wizard state for draft saving
}
export const IntentConfirmationPanel: React.FC<IntentConfirmationPanelProps> = ({
@@ -57,6 +59,7 @@ export const IntentConfirmationPanel: React.FC<IntentConfirmationPanelProps> = (
providerAvailability,
config,
onConfigUpdate,
wizardState,
}) => {
const [showDetails, setShowDetails] = useState(false);
const [selectedQueries, setSelectedQueries] = useState<Set<number>>(
@@ -65,13 +68,19 @@ export const IntentConfirmationPanel: React.FC<IntentConfirmationPanelProps> = (
const [editedQueries, setEditedQueries] = useState<ResearchQuery[]>(
intentAnalysis?.suggested_queries || []
);
const [editedTrendsConfig, setEditedTrendsConfig] = useState<TrendsConfig | null>(
intentAnalysis?.trends_config || null
);
// Update edited queries when intentAnalysis changes
// Update edited queries and trends config when intentAnalysis changes
useEffect(() => {
if (intentAnalysis?.suggested_queries) {
setEditedQueries(intentAnalysis.suggested_queries);
setSelectedQueries(new Set(intentAnalysis.suggested_queries.map((_, idx) => idx)));
}
if (intentAnalysis?.trends_config) {
setEditedTrendsConfig(intentAnalysis.trends_config);
}
}, [intentAnalysis]);
// Loading state
@@ -96,14 +105,30 @@ export const IntentConfirmationPanel: React.FC<IntentConfirmationPanelProps> = (
const handleExecute = () => {
const updatedIntent = { ...intent };
onConfirm(updatedIntent);
// Pass wizard state to onConfirm for draft saving
onConfirm(updatedIntent, wizardState);
const queriesToUse = Array.from(selectedQueries)
.sort((a, b) => a - b)
.map(idx => editedQueries[idx])
.filter(q => q && q.query.trim().length > 0);
// Store updated trends config in intentAnalysis for execution
// The execution hook will use trends_config from intentAnalysis
if (editedTrendsConfig && intentAnalysis) {
intentAnalysis.trends_config = editedTrendsConfig;
}
onExecute(queriesToUse);
};
const handleTrendsConfigUpdate = (updatedConfig: TrendsConfig) => {
setEditedTrendsConfig(updatedConfig);
// Also update intentAnalysis to keep it in sync
if (intentAnalysis) {
intentAnalysis.trends_config = updatedConfig;
}
};
return (
<Paper
elevation={0}
@@ -152,8 +177,11 @@ export const IntentConfirmationPanel: React.FC<IntentConfirmationPanelProps> = (
/>
{/* Google Trends Section */}
{intentAnalysis.trends_config && (
<TrendsConfigSection trendsConfig={intentAnalysis.trends_config} />
{editedTrendsConfig && (
<TrendsConfigSection
trendsConfig={editedTrendsConfig}
onUpdate={handleTrendsConfigUpdate}
/>
)}
{/* Advanced Options Section */}
@@ -172,6 +200,8 @@ export const IntentConfirmationPanel: React.FC<IntentConfirmationPanelProps> = (
<ExpandableDetails
intentAnalysis={intentAnalysis}
expanded={showDetails}
intent={intent}
onUpdateField={onUpdateField}
/>
{/* Action Buttons */}

View File

@@ -11,7 +11,11 @@ import {
Grid,
Card,
CardContent,
Tooltip,
} from '@mui/material';
import {
Info as InfoIcon,
} from '@mui/icons-material';
import {
ResearchIntent,
ResearchPurpose,
@@ -38,11 +42,18 @@ export const IntentSummaryGrid: React.FC<IntentSummaryGridProps> = ({
<Grid container spacing={2} mb={2}>
{/* Purpose */}
<Grid item xs={6} sm={3}>
<Card variant="outlined" sx={{ height: '100%', backgroundColor: '#ffffff', border: '1px solid #e5e7eb' }}>
<Card variant="outlined" sx={{ height: '100%', backgroundColor: '#ffffff', border: '1px solid #e5e7eb', '&:hover': { borderColor: '#0ea5e9', boxShadow: '0 2px 4px rgba(14, 165, 233, 0.1)' }, transition: 'all 0.2s ease' }}>
<CardContent sx={{ py: 1.5, px: 1.5, '&:last-child': { pb: 1.5 } }}>
<Typography variant="caption" color="#666" fontWeight={500} display="block" mb={0.5}>
Purpose
</Typography>
<Tooltip
title="The primary goal of your research. This helps ALwrity understand what you're trying to accomplish (e.g., learning, comparing options, making decisions, creating content)."
arrow
placement="top"
>
<Typography variant="caption" color="#666" fontWeight={500} display="flex" alignItems="center" gap={0.5} mb={0.5} sx={{ cursor: 'help' }}>
Purpose
<InfoIcon sx={{ fontSize: 12, color: '#9ca3af' }} />
</Typography>
</Tooltip>
<EditableField
field="purpose"
value={intent.purpose}
@@ -56,11 +67,18 @@ export const IntentSummaryGrid: React.FC<IntentSummaryGridProps> = ({
{/* Content Type */}
<Grid item xs={6} sm={3}>
<Card variant="outlined" sx={{ height: '100%', backgroundColor: '#ffffff', border: '1px solid #e5e7eb' }}>
<Card variant="outlined" sx={{ height: '100%', backgroundColor: '#ffffff', border: '1px solid #e5e7eb', '&:hover': { borderColor: '#0ea5e9', boxShadow: '0 2px 4px rgba(14, 165, 233, 0.1)' }, transition: 'all 0.2s ease' }}>
<CardContent sx={{ py: 1.5, px: 1.5, '&:last-child': { pb: 1.5 } }}>
<Typography variant="caption" color="#666" fontWeight={500} display="block" mb={0.5}>
Creating
</Typography>
<Tooltip
title="The type of content you're creating with this research. This helps ALwrity tailor the research results and format them appropriately (e.g., blog post, report, presentation, video script)."
arrow
placement="top"
>
<Typography variant="caption" color="#666" fontWeight={500} display="flex" alignItems="center" gap={0.5} mb={0.5} sx={{ cursor: 'help' }}>
Creating
<InfoIcon sx={{ fontSize: 12, color: '#9ca3af' }} />
</Typography>
</Tooltip>
<EditableField
field="content_output"
value={intent.content_output}
@@ -74,11 +92,18 @@ export const IntentSummaryGrid: React.FC<IntentSummaryGridProps> = ({
{/* Depth */}
<Grid item xs={6} sm={3}>
<Card variant="outlined" sx={{ height: '100%', backgroundColor: '#ffffff', border: '1px solid #e5e7eb' }}>
<Card variant="outlined" sx={{ height: '100%', backgroundColor: '#ffffff', border: '1px solid #e5e7eb', '&:hover': { borderColor: '#0ea5e9', boxShadow: '0 2px 4px rgba(14, 165, 233, 0.1)' }, transition: 'all 0.2s ease' }}>
<CardContent sx={{ py: 1.5, px: 1.5, '&:last-child': { pb: 1.5 } }}>
<Typography variant="caption" color="#666" fontWeight={500} display="block" mb={0.5}>
Depth
</Typography>
<Tooltip
title="How deep and comprehensive you want the research to be. Overview = quick summary, Detailed = thorough analysis, Expert = in-depth with advanced insights and multiple perspectives."
arrow
placement="top"
>
<Typography variant="caption" color="#666" fontWeight={500} display="flex" alignItems="center" gap={0.5} mb={0.5} sx={{ cursor: 'help' }}>
Depth
<InfoIcon sx={{ fontSize: 12, color: '#9ca3af' }} />
</Typography>
</Tooltip>
<EditableField
field="depth"
value={intent.depth}

View File

@@ -15,7 +15,9 @@ import {
Edit as EditIcon,
Save as SaveIcon,
Cancel as CancelIcon,
Info as InfoIcon,
} from '@mui/icons-material';
import { Tooltip } from '@mui/material';
import { ResearchIntent } from '../../../types/intent.types';
interface PrimaryQuestionEditorProps {
@@ -57,9 +59,16 @@ export const PrimaryQuestionEditor: React.FC<PrimaryQuestionEditorProps> = ({
}}
>
<Box display="flex" alignItems="center" justifyContent="space-between" mb={1}>
<Typography variant="caption" fontWeight={600} color="#0c4a6e">
Main Question:
</Typography>
<Tooltip
title="The primary research question that guides the entire research process. This question will be used to generate targeted search queries and extract relevant information."
arrow
placement="top"
>
<Typography variant="caption" fontWeight={600} color="#0c4a6e" sx={{ cursor: 'help', display: 'flex', alignItems: 'center', gap: 0.5 }}>
Research Question:
<InfoIcon sx={{ fontSize: 12, color: '#9ca3af' }} />
</Typography>
</Tooltip>
{!isEditing && (
<IconButton
size="small"

View File

@@ -1,7 +1,8 @@
/**
* QueryEditor Component
*
* Individual query editor with provider, purpose, priority, and expected results.
* Compact, professional query editor with tooltips and helpful messaging.
* Each query targets a specific deliverable and uses the optimal provider.
*/
import React from 'react';
@@ -15,9 +16,19 @@ import {
IconButton,
ListItem,
ListItemSecondaryAction,
Tooltip,
Typography,
Chip,
InputAdornment,
} from '@mui/material';
import {
Delete as DeleteIcon,
Info as InfoIcon,
Search as SearchIcon,
Storage as ProviderIcon,
Category as PurposeIcon,
PriorityHigh as PriorityIcon,
ExpandMore as ExpandMoreIcon,
} from '@mui/icons-material';
import {
ResearchQuery,
@@ -29,121 +40,656 @@ interface QueryEditorProps {
query: ResearchQuery;
index: number;
isSelected: boolean;
isExpanded?: boolean;
onToggle: () => void;
onEdit: (field: keyof ResearchQuery, value: any) => void;
onDelete: () => void;
onToggleExpansion?: () => void;
totalQueries?: number;
selectedCount?: number;
estimatedCost?: number;
}
// Provider descriptions for tooltips
const PROVIDER_INFO = {
exa: {
name: 'Exa',
description: 'Semantic search engine. Best for deep research, academic papers, and comprehensive content.',
color: '#6366f1',
},
tavily: {
name: 'Tavily',
description: 'AI-powered real-time search. Best for news, trends, and current events.',
color: '#10b981',
},
google: {
name: 'Google',
description: 'Factual web search. Best for general information and quick facts.',
color: '#3b82f6',
},
};
export const QueryEditor: React.FC<QueryEditorProps> = ({
query,
index,
isSelected,
isExpanded = true,
onToggle,
onEdit,
onDelete,
onToggleExpansion,
totalQueries = 0,
selectedCount = 0,
estimatedCost = 0.01,
}) => {
const providerInfo = PROVIDER_INFO[query.provider as keyof typeof PROVIDER_INFO] || PROVIDER_INFO.exa;
const deliverableLabel = DELIVERABLE_DISPLAY[query.purpose as ExpectedDeliverable] || query.purpose;
const isFirstQuery = index === 0;
// Generate justification text based on query properties
const getJustification = () => {
const parts: string[] = [];
// Provider justification
if (query.provider === 'exa') {
parts.push(`This query uses Exa because it's best for finding ${deliverableLabel.toLowerCase()} in academic papers, technical reports, and comprehensive content.`);
} else if (query.provider === 'tavily') {
parts.push(`This query uses Tavily because it excels at finding ${deliverableLabel.toLowerCase()} in recent news, trends, and real-time information.`);
} else {
parts.push(`This query uses Google for general factual information and quick answers.`);
}
// Purpose justification
parts.push(`It targets "${deliverableLabel}" to extract specific ${deliverableLabel.toLowerCase()} from the research results.`);
// Priority justification
if (query.priority >= 4) {
parts.push(`High priority (${query.priority}/5) - this query is essential for answering your research question.`);
} else if (query.priority <= 2) {
parts.push(`Lower priority (${query.priority}/5) - this query provides supplementary information.`);
}
return parts.join(' ');
};
const getComprehensiveTooltip = () => {
return (
<Box sx={{ p: 1.5, maxWidth: 400 }}>
<Typography variant="caption" fontWeight={700} display="block" gutterBottom sx={{ fontSize: '0.85rem', mb: 1 }}>
Research Query #{index + 1}
</Typography>
<Typography variant="caption" fontWeight={600} display="block" gutterBottom sx={{ fontSize: '0.75rem', mt: 1 }}>
What is this query?
</Typography>
<Typography variant="caption" display="block" sx={{ fontSize: '0.7rem', mb: 1 }}>
{query.query || 'No query text'}
</Typography>
<Typography variant="caption" display="block" sx={{ fontSize: '0.7rem', mb: 1, color: '#64748b' }}>
This query will search for: {query.expected_results || deliverableLabel}
</Typography>
<Typography variant="caption" fontWeight={600} display="block" gutterBottom sx={{ fontSize: '0.75rem', mt: 1 }}>
How was it suggested?
</Typography>
<Typography variant="caption" display="block" sx={{ fontSize: '0.7rem', mb: 1 }}>
ALwrity's AI analyzed your research question and automatically generated this query to find the specific information you need. It's designed to target "{deliverableLabel}" using the {providerInfo.name} provider.
</Typography>
<Typography variant="caption" fontWeight={600} display="block" gutterBottom sx={{ fontSize: '0.75rem', mt: 1 }}>
Why this query?
</Typography>
<Typography variant="caption" display="block" sx={{ fontSize: '0.7rem', mb: 1 }}>
{getJustification()}
</Typography>
<Typography variant="caption" fontWeight={600} display="block" gutterBottom sx={{ fontSize: '0.75rem', mt: 1 }}>
What can you do?
</Typography>
<Typography variant="caption" display="block" sx={{ fontSize: '0.7rem', mb: 0.5 }}>
<strong>Select/Deselect:</strong> Check/uncheck to include or exclude this query
</Typography>
<Typography variant="caption" display="block" sx={{ fontSize: '0.7rem', mb: 0.5 }}>
<strong>Edit:</strong> Click on the query text or parameters to modify
</Typography>
<Typography variant="caption" display="block" sx={{ fontSize: '0.7rem', mb: 0.5 }}>
<strong>Delete:</strong> Remove this query if not needed
</Typography>
<Typography variant="caption" display="block" sx={{ fontSize: '0.7rem', mb: 1 }}>
<strong>Change Provider:</strong> Switch between Exa, Tavily, or Google
</Typography>
<Box sx={{ mt: 1.5, pt: 1, borderTop: '1px solid rgba(255,255,255,0.2)' }}>
<Typography variant="caption" fontWeight={600} display="block" gutterBottom sx={{ fontSize: '0.75rem' }}>
Cost & Execution
</Typography>
<Typography variant="caption" display="block" sx={{ fontSize: '0.7rem', mb: 0.5 }}>
Estimated cost: <strong>${estimatedCost.toFixed(3)}</strong> per query
</Typography>
<Typography variant="caption" display="block" sx={{ fontSize: '0.7rem', mb: 0.5 }}>
Total queries: {totalQueries} ({selectedCount} selected)
</Typography>
<Typography variant="caption" display="block" sx={{ fontSize: '0.7rem', fontStyle: 'italic', color: '#9ca3af' }}>
* Costs are estimates and may vary based on API response length
</Typography>
</Box>
</Box>
);
};
// Collapsed view (for queries after the first one)
if (!isExpanded && !isFirstQuery) {
return (
<ListItem
onMouseEnter={onToggleExpansion}
sx={{
backgroundColor: isSelected ? '#f0f9ff' : '#ffffff',
borderLeft: isSelected ? '4px solid #0ea5e9' : '4px solid transparent',
border: '1px solid',
borderColor: isSelected ? '#bae6fd' : '#e5e7eb',
borderRadius: 1,
mb: 1,
py: 1,
px: 2,
cursor: 'pointer',
'&:hover': {
backgroundColor: isSelected ? '#e0f2fe' : '#f9fafb',
borderColor: isSelected ? '#7dd3fc' : '#0ea5e9',
boxShadow: '0 2px 8px rgba(14, 165, 233, 0.15)',
},
transition: 'all 0.2s ease',
}}
>
<Checkbox
checked={isSelected}
onChange={(e) => {
e.stopPropagation();
onToggle();
}}
size="small"
sx={{
mr: 1.5,
color: '#0ea5e9',
'&.Mui-checked': {
color: '#0ea5e9',
},
}}
onClick={(e) => e.stopPropagation()}
/>
<Box flex={1} sx={{ minWidth: 0 }} onClick={onToggleExpansion}>
<Box display="flex" alignItems="center" gap={1} mb={0.5}>
<SearchIcon sx={{ fontSize: 16, color: '#6b7280', flexShrink: 0 }} />
<Tooltip title={getComprehensiveTooltip()} arrow placement="right">
<Typography
variant="body2"
sx={{
fontWeight: 500,
color: '#1f2937',
fontSize: '0.875rem',
flex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
cursor: 'help',
}}
>
{query.query || 'Empty query'}
</Typography>
</Tooltip>
</Box>
<Box display="flex" alignItems="center" gap={0.5} flexWrap="wrap">
<Chip
size="small"
label={providerInfo.name}
sx={{
height: 20,
fontSize: '0.65rem',
backgroundColor: `${providerInfo.color}15`,
color: providerInfo.color,
border: `1px solid ${providerInfo.color}40`,
}}
icon={
<Box
component="span"
sx={{
width: 6,
height: 6,
borderRadius: '50%',
backgroundColor: providerInfo.color,
ml: 0.5,
}}
/>
}
/>
<Chip
size="small"
label={deliverableLabel}
sx={{
height: 20,
fontSize: '0.65rem',
backgroundColor: '#f3f4f6',
color: '#4b5563',
}}
/>
<Chip
size="small"
label={`~$${estimatedCost.toFixed(3)}`}
sx={{
height: 20,
fontSize: '0.65rem',
backgroundColor: '#fef3c7',
color: '#92400e',
}}
/>
</Box>
</Box>
<ListItemSecondaryAction>
<Tooltip title="Remove this query" arrow>
<IconButton
edge="end"
size="small"
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
sx={{
color: '#dc2626',
'&:hover': {
backgroundColor: '#fee2e2',
color: '#b91c1c',
},
}}
>
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
</ListItemSecondaryAction>
</ListItem>
);
}
// Expanded view (default for first query, or when hovered/clicked)
return (
<ListItem
sx={{
backgroundColor: isSelected ? '#e0f2fe' : '#ffffff',
borderLeft: isSelected ? '3px solid #0ea5e9' : '3px solid transparent',
'&:hover': { backgroundColor: isSelected ? '#bae6fd' : '#f9fafb' },
backgroundColor: isSelected ? '#f0f9ff' : '#ffffff',
borderLeft: isSelected ? '4px solid #0ea5e9' : '4px solid transparent',
border: '1px solid',
borderColor: isSelected ? '#bae6fd' : '#e5e7eb',
borderRadius: 1,
mb: 1,
py: 1.5,
px: 2,
'&:hover': {
backgroundColor: isSelected ? '#e0f2fe' : '#f9fafb',
borderColor: isSelected ? '#7dd3fc' : '#d1d5db',
},
transition: 'all 0.2s ease',
}}
>
<Checkbox
checked={isSelected}
onChange={onToggle}
size="small"
sx={{ mr: 1 }}
sx={{
mr: 1.5,
color: '#0ea5e9',
'&.Mui-checked': {
color: '#0ea5e9',
},
}}
/>
<Box flex={1}>
<TextField
fullWidth
size="small"
value={query.query}
onChange={(e) => onEdit('query', e.target.value)}
placeholder="Enter research query"
sx={{
mb: 1,
backgroundColor: '#ffffff',
'& .MuiOutlinedInput-root': {
'&:hover fieldset': { borderColor: '#0ea5e9' },
'&.Mui-focused fieldset': { borderColor: '#0ea5e9' },
},
}}
/>
<Box display="flex" gap={1} flexWrap="wrap">
<FormControl size="small" sx={{ minWidth: 100 }}>
<Select
value={query.provider}
onChange={(e) => onEdit('provider', e.target.value)}
sx={{
backgroundColor: '#ffffff',
fontSize: '0.75rem',
}}
>
<MenuItem value="exa">Exa</MenuItem>
<MenuItem value="tavily">Tavily</MenuItem>
<MenuItem value="google">Google</MenuItem>
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 140 }}>
<Select
value={query.purpose}
onChange={(e) => onEdit('purpose', e.target.value)}
sx={{
backgroundColor: '#ffffff',
fontSize: '0.75rem',
}}
>
{Object.entries(DELIVERABLE_DISPLAY).map(([key, label]) => (
<MenuItem key={key} value={key}>{label}</MenuItem>
))}
</Select>
</FormControl>
<Box flex={1} sx={{ minWidth: 0 }}>
{/* Query Header with Tooltip */}
<Box display="flex" alignItems="center" justifyContent="space-between" mb={1}>
<Box display="flex" alignItems="center" gap={1} flex={1}>
<Tooltip title={getComprehensiveTooltip()} arrow placement="top">
<InfoIcon
sx={{
fontSize: 18,
color: '#0ea5e9',
cursor: 'help',
flexShrink: 0,
}}
/>
</Tooltip>
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 500 }}>
Query #{index + 1} {deliverableLabel} {providerInfo.name}
</Typography>
</Box>
{!isFirstQuery && onToggleExpansion && (
<Tooltip title="Click to collapse" arrow>
<IconButton
size="small"
onClick={onToggleExpansion}
sx={{
color: '#9ca3af',
'&:hover': { color: '#0ea5e9' },
}}
>
<ExpandMoreIcon sx={{ transform: 'rotate(180deg)' }} />
</IconButton>
</Tooltip>
)}
</Box>
{/* Main Query Input - Enhanced Focus */}
<Box display="flex" alignItems="center" gap={1} mb={1.5}>
<SearchIcon sx={{ fontSize: 18, color: '#0ea5e9', flexShrink: 0 }} />
<TextField
fullWidth
size="small"
type="number"
value={query.priority}
onChange={(e) => onEdit('priority', parseInt(e.target.value) || 1)}
inputProps={{ min: 1, max: 5 }}
value={query.query}
onChange={(e) => onEdit('query', e.target.value)}
placeholder="Research query (e.g., 'AI trends 2024')"
variant="outlined"
sx={{
width: 90,
backgroundColor: '#ffffff',
fontSize: '0.75rem',
'& .MuiOutlinedInput-root': {
backgroundColor: '#ffffff',
color: '#1f2937',
fontSize: '0.9rem',
fontWeight: 500,
'& input': {
color: '#0c4a6e',
py: 1,
fontWeight: 500,
},
'& fieldset': {
borderColor: '#0ea5e9',
borderWidth: '2px',
},
'&:hover fieldset': {
borderColor: '#0284c7',
borderWidth: '2px',
},
'&.Mui-focused fieldset': {
borderColor: '#0284c7',
borderWidth: '2.5px',
boxShadow: '0 0 0 3px rgba(14, 165, 233, 0.1)',
},
},
}}
/>
</Box>
{/* Compact Controls Row */}
<Box display="flex" alignItems="center" gap={1.5} flexWrap="wrap">
{/* Provider Selector with Tooltip */}
<Tooltip
title={
<Box sx={{ p: 0.5 }}>
<Typography variant="caption" fontWeight={600} display="block" gutterBottom>
{providerInfo.name}
</Typography>
<Typography variant="caption" display="block">
{providerInfo.description}
</Typography>
</Box>
}
arrow
placement="top"
>
<FormControl
size="small"
sx={{
minWidth: 100,
'& .MuiOutlinedInput-root': {
backgroundColor: '#ffffff',
fontSize: '0.8125rem',
height: '32px',
},
}}
>
<Select
value={query.provider}
onChange={(e) => onEdit('provider', e.target.value)}
sx={{
color: '#1f2937',
'& .MuiSelect-select': {
color: '#1f2937',
py: 0.5,
},
'& .MuiOutlinedInput-notchedOutline': {
borderColor: '#d1d5db',
},
'&:hover .MuiOutlinedInput-notchedOutline': {
borderColor: '#0ea5e9',
},
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
borderColor: '#0ea5e9',
},
}}
startAdornment={
<Box
component="span"
sx={{
width: 8,
height: 8,
borderRadius: '50%',
backgroundColor: providerInfo.color,
mr: 1,
display: 'inline-block',
}}
/>
}
>
<MenuItem value="exa" sx={{ fontSize: '0.8125rem', color: '#1f2937' }}>
Exa
</MenuItem>
<MenuItem value="tavily" sx={{ fontSize: '0.8125rem', color: '#1f2937' }}>
Tavily
</MenuItem>
<MenuItem value="google" sx={{ fontSize: '0.8125rem', color: '#1f2937' }}>
Google
</MenuItem>
</Select>
</FormControl>
</Tooltip>
{/* Purpose/Deliverable Selector with Tooltip */}
<Tooltip
title={
<Box sx={{ p: 0.5 }}>
<Typography variant="caption" fontWeight={600} display="block" gutterBottom>
Deliverable Type
</Typography>
<Typography variant="caption" display="block">
What type of information this query will find: statistics, quotes, case studies, trends, etc.
</Typography>
</Box>
}
arrow
placement="top"
>
<FormControl
size="small"
sx={{
minWidth: 140,
'& .MuiOutlinedInput-root': {
backgroundColor: '#ffffff',
fontSize: '0.8125rem',
height: '32px',
},
}}
>
<Select
value={query.purpose}
onChange={(e) => onEdit('purpose', e.target.value)}
sx={{
color: '#1f2937',
'& .MuiSelect-select': {
color: '#1f2937',
py: 0.5,
},
'& .MuiOutlinedInput-notchedOutline': {
borderColor: '#d1d5db',
},
'&:hover .MuiOutlinedInput-notchedOutline': {
borderColor: '#0ea5e9',
},
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
borderColor: '#0ea5e9',
},
}}
>
{Object.entries(DELIVERABLE_DISPLAY).map(([key, label]) => (
<MenuItem
key={key}
value={key}
sx={{ fontSize: '0.8125rem', color: '#1f2937' }}
>
{label}
</MenuItem>
))}
</Select>
</FormControl>
</Tooltip>
{/* Priority Input with Tooltip */}
<Tooltip
title={
<Box sx={{ p: 0.5 }}>
<Typography variant="caption" fontWeight={600} display="block" gutterBottom>
Priority (1-5)
</Typography>
<Typography variant="caption" display="block">
Higher priority queries are executed first. Use 5 for most important, 1 for optional.
</Typography>
</Box>
}
arrow
placement="top"
>
<TextField
size="small"
type="number"
value={query.priority}
onChange={(e) => onEdit('priority', parseInt(e.target.value) || 1)}
inputProps={{ min: 1, max: 5 }}
sx={{
width: 75,
'& .MuiOutlinedInput-root': {
backgroundColor: '#ffffff',
fontSize: '0.8125rem',
height: '32px',
color: '#1f2937',
'& input': {
color: '#1f2937',
textAlign: 'center',
py: 0.5,
},
'& fieldset': {
borderColor: '#d1d5db',
},
'&:hover fieldset': {
borderColor: '#0ea5e9',
},
'&.Mui-focused fieldset': {
borderColor: '#0ea5e9',
},
},
}}
placeholder="1-5"
/>
</Tooltip>
{/* Expected Results - Compact Display */}
{query.expected_results && (
<Chip
label={query.expected_results}
size="small"
sx={{
height: '24px',
fontSize: '0.75rem',
backgroundColor: '#f3f4f6',
color: '#4b5563',
border: '1px solid #e5e7eb',
maxWidth: '200px',
'& .MuiChip-label': {
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
},
}}
/>
)}
</Box>
{/* Expected Results Input - Collapsible/Compact */}
<Box mt={1}>
<TextField
fullWidth
size="small"
value={query.expected_results || ''}
onChange={(e) => onEdit('expected_results', e.target.value)}
placeholder="What we expect to find (optional)"
variant="outlined"
multiline
maxRows={2}
sx={{
'& .MuiOutlinedInput-root': {
backgroundColor: '#ffffff',
color: '#1f2937',
fontSize: '0.8125rem',
'& textarea': {
color: '#1f2937',
py: 0.5,
},
'& fieldset': {
borderColor: '#d1d5db',
},
'&:hover fieldset': {
borderColor: '#0ea5e9',
},
'&.Mui-focused fieldset': {
borderColor: '#0ea5e9',
},
},
}}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Tooltip
title="Describe what type of information this query should find. This helps the AI understand the research goal."
arrow
placement="top"
>
<InfoIcon
sx={{
fontSize: 16,
color: '#9ca3af',
cursor: 'help',
}}
/>
</Tooltip>
</InputAdornment>
),
}}
label="Priority"
/>
</Box>
<TextField
fullWidth
size="small"
value={query.expected_results}
onChange={(e) => onEdit('expected_results', e.target.value)}
placeholder="What we expect to find"
sx={{
mt: 1,
backgroundColor: '#ffffff',
fontSize: '0.75rem',
'& .MuiOutlinedInput-root': {
'&:hover fieldset': { borderColor: '#0ea5e9' },
},
}}
/>
</Box>
<ListItemSecondaryAction>
<IconButton
edge="end"
size="small"
onClick={onDelete}
sx={{
color: '#dc2626',
'&:hover': { backgroundColor: '#fee2e2' },
}}
>
<DeleteIcon fontSize="small" />
</IconButton>
<Tooltip title="Remove this query" arrow>
<IconButton
edge="end"
size="small"
onClick={onDelete}
sx={{
color: '#dc2626',
'&:hover': {
backgroundColor: '#fee2e2',
color: '#b91c1c',
},
}}
>
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
</ListItemSecondaryAction>
</ListItem>
);

View File

@@ -2,9 +2,10 @@
* ResearchQueriesSection Component
*
* Accordion section for managing research queries (add, edit, delete, select).
* Enhanced with expand/collapse functionality and cost breakdown.
*/
import React, { useState } from 'react';
import React, { useState, useMemo } from 'react';
import {
Box,
Typography,
@@ -15,10 +16,12 @@ import {
Button,
Divider,
Chip,
Tooltip,
} from '@mui/material';
import {
ExpandMore as ExpandMoreIcon,
Search as SearchIcon,
Info as InfoIcon,
} from '@mui/icons-material';
import {
ResearchQuery,
@@ -32,6 +35,13 @@ interface ResearchQueriesSectionProps {
onSelectionChange: (selected: Set<number>) => void;
}
// Cost estimation per provider (approximate)
const PROVIDER_COST_ESTIMATE = {
exa: 0.02, // ~$0.02 per query
tavily: 0.015, // ~$0.015 per query
google: 0.001, // ~$0.001 per query (Gemini grounding)
};
export const ResearchQueriesSection: React.FC<ResearchQueriesSectionProps> = ({
queries,
selectedQueries,
@@ -39,6 +49,7 @@ export const ResearchQueriesSection: React.FC<ResearchQueriesSectionProps> = ({
onSelectionChange,
}) => {
const [expanded, setExpanded] = useState(true);
const [expandedQueries, setExpandedQueries] = useState<Set<number>>(new Set([0])); // First query expanded by default
const handleQueryToggle = (index: number) => {
const newSelected = new Set(selectedQueries);
@@ -85,8 +96,43 @@ export const ResearchQueriesSection: React.FC<ResearchQueriesSectionProps> = ({
const newSelected = new Set(selectedQueries);
newSelected.add(queries.length);
onSelectionChange(newSelected);
// Expand the new query
setExpandedQueries(new Set([...expandedQueries, queries.length]));
};
const handleToggleQueryExpansion = (index: number) => {
const newExpanded = new Set(expandedQueries);
if (newExpanded.has(index)) {
newExpanded.delete(index);
} else {
newExpanded.add(index);
}
setExpandedQueries(newExpanded);
};
// Calculate cost breakdown
const costBreakdown = useMemo(() => {
const selected = Array.from(selectedQueries);
let totalCost = 0;
const providerCosts: Record<string, number> = {};
selected.forEach(idx => {
const query = queries[idx];
if (query) {
const cost = PROVIDER_COST_ESTIMATE[query.provider] || 0.01;
totalCost += cost;
providerCosts[query.provider] = (providerCosts[query.provider] || 0) + cost;
}
});
return {
total: totalCost,
perQuery: selected.length > 0 ? totalCost / selected.length : 0,
providerCosts,
queryCount: selected.length,
};
}, [selectedQueries, queries]);
return (
<Accordion
expanded={expanded}
@@ -123,6 +169,48 @@ export const ResearchQueriesSection: React.FC<ResearchQueriesSectionProps> = ({
fontWeight: 500,
}}
/>
{selectedQueries.size > 0 && (
<Tooltip
title={
<Box sx={{ p: 1 }}>
<Typography variant="caption" fontWeight={600} display="block" gutterBottom>
Cost Breakdown
</Typography>
<Typography variant="caption" display="block" sx={{ mb: 0.5 }}>
Estimated cost: ${costBreakdown.total.toFixed(3)}
</Typography>
<Typography variant="caption" display="block" sx={{ mb: 0.5 }}>
Queries to fire: {costBreakdown.queryCount}
</Typography>
{Object.entries(costBreakdown.providerCosts).map(([provider, cost]) => (
<Typography key={provider} variant="caption" display="block" sx={{ fontSize: '0.7rem' }}>
{provider}: ${cost.toFixed(3)}
</Typography>
))}
<Typography variant="caption" display="block" sx={{ mt: 1, fontStyle: 'italic', fontSize: '0.7rem' }}>
* Estimates may vary based on actual API usage
</Typography>
</Box>
}
arrow
placement="top"
>
<Chip
size="small"
label={`~$${costBreakdown.total.toFixed(3)}`}
sx={{
ml: 1,
backgroundColor: '#fef3c7',
color: '#92400e',
fontSize: '0.7rem',
height: 20,
fontWeight: 500,
cursor: 'help',
}}
icon={<InfoIcon sx={{ fontSize: 12 }} />}
/>
</Tooltip>
)}
</Box>
</AccordionSummary>
<AccordionDetails sx={{ p: 0, backgroundColor: '#ffffff' }}>
@@ -133,9 +221,14 @@ export const ResearchQueriesSection: React.FC<ResearchQueriesSectionProps> = ({
query={query}
index={idx}
isSelected={selectedQueries.has(idx)}
isExpanded={expandedQueries.has(idx)}
onToggle={() => handleQueryToggle(idx)}
onEdit={(field, value) => handleQueryEdit(idx, field, value)}
onDelete={() => handleDeleteQuery(idx)}
onToggleExpansion={() => handleToggleQueryExpansion(idx)}
totalQueries={queries.length}
selectedCount={selectedQueries.size}
estimatedCost={PROVIDER_COST_ESTIMATE[query.provider] || 0.01}
/>
{idx < queries.length - 1 && <Divider />}
</React.Fragment>

View File

@@ -2,16 +2,16 @@
* TrendsConfigSection Component
*
* Google Trends configuration section with keywords, expected insights, and settings.
* Enhanced with editing capabilities and educational modal.
*/
import React from 'react';
import React, { useState } from 'react';
import {
Box,
Typography,
Accordion,
AccordionSummary,
AccordionDetails,
TextField,
List,
ListItem,
ListItemIcon,
@@ -19,26 +19,125 @@ import {
Grid,
Chip,
Tooltip,
TextField,
IconButton,
FormControl,
Select,
MenuItem,
InputAdornment,
} from '@mui/material';
import {
ExpandMore as ExpandMoreIcon,
TrendingUp as TrendIcon,
CheckCircle as CheckIcon,
Info as InfoIcon,
Edit as EditIcon,
Save as SaveIcon,
Cancel as CancelIcon,
Add as AddIcon,
Delete as DeleteIcon,
HelpOutline as HelpIcon,
} from '@mui/icons-material';
import { TrendsConfig } from '../../../types/intent.types';
import { TrendsKnowMoreModal } from './TrendsKnowMoreModal';
interface TrendsConfigSectionProps {
trendsConfig: TrendsConfig;
onUpdate?: (updatedConfig: TrendsConfig) => void;
}
// Common timeframe options
const TIMEFRAME_OPTIONS = [
{ value: 'today 12-m', label: 'Past 12 months' },
{ value: 'today 3-m', label: 'Past 3 months' },
{ value: 'today 1-m', label: 'Past month' },
{ value: 'today 7-d', label: 'Past 7 days' },
{ value: 'today 5-y', label: 'Past 5 years' },
];
// Common region options (top regions)
const REGION_OPTIONS = [
{ value: 'US', label: 'United States' },
{ value: 'GB', label: 'United Kingdom' },
{ value: 'CA', label: 'Canada' },
{ value: 'AU', label: 'Australia' },
{ value: 'DE', label: 'Germany' },
{ value: 'FR', label: 'France' },
{ value: 'IN', label: 'India' },
{ value: 'JP', label: 'Japan' },
{ value: 'BR', label: 'Brazil' },
{ value: 'MX', label: 'Mexico' },
{ value: 'ES', label: 'Spain' },
{ value: 'IT', label: 'Italy' },
{ value: 'NL', label: 'Netherlands' },
{ value: 'SE', label: 'Sweden' },
{ value: 'NO', label: 'Norway' },
];
export const TrendsConfigSection: React.FC<TrendsConfigSectionProps> = ({
trendsConfig,
onUpdate,
}) => {
const [showKnowMoreModal, setShowKnowMoreModal] = useState(false);
const [editingKeywords, setEditingKeywords] = useState(false);
const [editingTimeframe, setEditingTimeframe] = useState(false);
const [editingRegion, setEditingRegion] = useState(false);
const [editedKeywords, setEditedKeywords] = useState<string[]>(trendsConfig.keywords);
const [newKeyword, setNewKeyword] = useState('');
const [editedTimeframe, setEditedTimeframe] = useState(trendsConfig.timeframe);
const [editedRegion, setEditedRegion] = useState(trendsConfig.geo);
if (!trendsConfig.enabled) {
return null;
}
const handleSaveKeywords = () => {
if (onUpdate) {
onUpdate({
...trendsConfig,
keywords: editedKeywords.filter(k => k.trim().length > 0),
});
}
setEditingKeywords(false);
};
const handleCancelKeywords = () => {
setEditedKeywords(trendsConfig.keywords);
setEditingKeywords(false);
setNewKeyword('');
};
const handleAddKeyword = () => {
if (newKeyword.trim()) {
setEditedKeywords([...editedKeywords, newKeyword.trim()]);
setNewKeyword('');
}
};
const handleDeleteKeyword = (index: number) => {
setEditedKeywords(editedKeywords.filter((_, idx) => idx !== index));
};
const handleSaveTimeframe = () => {
if (onUpdate) {
onUpdate({
...trendsConfig,
timeframe: editedTimeframe,
});
}
setEditingTimeframe(false);
};
const handleSaveRegion = () => {
if (onUpdate) {
onUpdate({
...trendsConfig,
geo: editedRegion,
});
}
setEditingRegion(false);
};
return (
<Accordion
defaultExpanded={true}
@@ -77,50 +176,189 @@ export const TrendsConfigSection: React.FC<TrendsConfigSectionProps> = ({
</Box>
</AccordionSummary>
<AccordionDetails sx={{ p: 2, backgroundColor: '#ffffff' }}>
{/* Trends Keywords */}
<Box mb={2}>
<Typography variant="caption" color="#666" fontWeight={600} gutterBottom display="block">
Trends Keywords
</Typography>
<TextField
fullWidth
size="small"
value={trendsConfig.keywords.join(', ')}
disabled
helperText={trendsConfig.keywords_justification}
sx={{
backgroundColor: '#ffffff',
'& .MuiOutlinedInput-root': {
'&:hover fieldset': { borderColor: '#10b981' },
'&.Mui-focused fieldset': { borderColor: '#10b981' },
},
}}
/>
{/* Trends Keywords - Editable */}
<Box
mb={2}
sx={{
padding: '12px',
background: 'rgba(241, 245, 249, 0.5)',
border: '1px solid rgba(203, 213, 225, 0.3)',
borderRadius: '8px',
}}
>
<Box display="flex" alignItems="center" justifyContent="space-between" mb={1.5}>
<Box display="flex" alignItems="center" gap={1}>
<Typography
variant="caption"
sx={{
fontSize: '12px',
fontWeight: '600',
color: '#475569',
}}
>
Trends Keywords ({editingKeywords ? editedKeywords.length : trendsConfig.keywords.length})
</Typography>
<Chip
label="Know More"
size="small"
onClick={() => setShowKnowMoreModal(true)}
icon={<HelpIcon sx={{ fontSize: 14 }} />}
sx={{
height: 20,
fontSize: '0.7rem',
backgroundColor: '#e0f2fe',
color: '#0369a1',
cursor: 'pointer',
'&:hover': {
backgroundColor: '#bae6fd',
},
}}
/>
</Box>
{!editingKeywords && onUpdate && (
<IconButton
size="small"
onClick={() => setEditingKeywords(true)}
sx={{
color: '#64748b',
'&:hover': { color: '#0ea5e9', backgroundColor: '#f0f9ff' },
}}
>
<EditIcon fontSize="small" />
</IconButton>
)}
</Box>
{editingKeywords ? (
<Box>
<Box
sx={{
display: 'flex',
flexWrap: 'wrap',
gap: '6px',
mb: 1.5,
}}
>
{editedKeywords.map((keyword, idx) => (
<Chip
key={idx}
label={keyword}
onDelete={() => handleDeleteKeyword(idx)}
sx={{
padding: '5px 10px',
background: 'white',
border: '1px solid rgba(203, 213, 225, 0.5)',
fontSize: '12px',
color: '#334155',
fontWeight: 500,
'& .MuiChip-deleteIcon': {
color: '#dc2626',
fontSize: '16px',
'&:hover': { color: '#b91c1c' },
},
}}
/>
))}
</Box>
<Box display="flex" gap={1} mb={1}>
<TextField
size="small"
placeholder="Add keyword"
value={newKeyword}
onChange={(e) => setNewKeyword(e.target.value)}
onKeyPress={(e) => {
if (e.key === 'Enter') {
handleAddKeyword();
}
}}
sx={{
flex: 1,
'& .MuiOutlinedInput-root': {
fontSize: '0.8125rem',
backgroundColor: '#ffffff',
},
}}
/>
<IconButton
size="small"
onClick={handleAddKeyword}
disabled={!newKeyword.trim()}
sx={{
backgroundColor: '#0ea5e9',
color: 'white',
'&:hover': { backgroundColor: '#0284c7' },
'&:disabled': { backgroundColor: '#d1d5db' },
}}
>
<AddIcon fontSize="small" />
</IconButton>
</Box>
<Box display="flex" gap={1} justifyContent="flex-end">
<IconButton
size="small"
onClick={handleCancelKeywords}
sx={{ color: '#64748b' }}
>
<CancelIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
onClick={handleSaveKeywords}
sx={{
color: '#10b981',
'&:hover': { backgroundColor: '#dcfce7' },
}}
>
<SaveIcon fontSize="small" />
</IconButton>
</Box>
</Box>
) : (
<>
<Box
sx={{
display: 'flex',
flexWrap: 'wrap',
gap: '6px',
}}
>
{trendsConfig.keywords.map((keyword, idx) => (
<Box
key={idx}
sx={{
padding: '5px 10px',
background: 'white',
border: '1px solid rgba(203, 213, 225, 0.5)',
borderRadius: '6px',
fontSize: '12px',
color: '#334155',
fontWeight: 500,
}}
>
{keyword}
</Box>
))}
</Box>
{trendsConfig.keywords_justification && (
<Typography
variant="caption"
sx={{
display: 'block',
marginTop: '8px',
color: '#64748b',
fontSize: '11px',
fontStyle: 'italic',
}}
>
{trendsConfig.keywords_justification}
</Typography>
)}
</>
)}
</Box>
{/* Expected Insights Preview */}
{trendsConfig.expected_insights.length > 0 && (
<Box mb={2}>
<Typography variant="caption" color="#666" fontWeight={600} gutterBottom display="block">
What Trends Will Uncover:
</Typography>
<List dense sx={{ backgroundColor: '#f9fafb', borderRadius: 1, p: 1 }}>
{trendsConfig.expected_insights.map((insight, idx) => (
<ListItem key={idx} sx={{ py: 0.5, px: 1 }}>
<ListItemIcon sx={{ minWidth: 24 }}>
<CheckIcon color="success" fontSize="small" />
</ListItemIcon>
<ListItemText
primary={insight}
primaryTypographyProps={{ variant: 'caption', color: '#374151' }}
/>
</ListItem>
))}
</List>
</Box>
)}
{/* Settings with Justifications */}
{/* Settings with Justifications - Editable */}
<Box
sx={{
p: 1.5,
@@ -129,35 +367,156 @@ export const TrendsConfigSection: React.FC<TrendsConfigSectionProps> = ({
border: '1px solid #e5e7eb',
}}
>
<Grid container spacing={1}>
<Grid container spacing={2}>
<Grid item xs={6}>
<Typography variant="caption" color="#666" fontWeight={500} display="block" gutterBottom>
Timeframe
</Typography>
<Box display="flex" alignItems="center" gap={0.5}>
<Typography variant="body2" fontWeight={500} color="#333">
{trendsConfig.timeframe}
<Box display="flex" alignItems="center" justifyContent="space-between" mb={0.5}>
<Typography variant="caption" color="#666" fontWeight={500}>
Timeframe
</Typography>
<Tooltip title={trendsConfig.timeframe_justification} arrow>
<InfoIcon sx={{ fontSize: 14, color: '#9ca3af', cursor: 'help' }} />
</Tooltip>
{!editingTimeframe && onUpdate && (
<IconButton
size="small"
onClick={() => setEditingTimeframe(true)}
sx={{
color: '#64748b',
'&:hover': { color: '#0ea5e9', backgroundColor: '#f0f9ff' },
}}
>
<EditIcon fontSize="small" />
</IconButton>
)}
</Box>
{editingTimeframe ? (
<Box>
<FormControl fullWidth size="small">
<Select
value={editedTimeframe}
onChange={(e) => setEditedTimeframe(e.target.value)}
sx={{
backgroundColor: '#ffffff',
fontSize: '0.8125rem',
}}
>
{TIMEFRAME_OPTIONS.map((option) => (
<MenuItem key={option.value} value={option.value}>
{option.label}
</MenuItem>
))}
</Select>
</FormControl>
<Box display="flex" gap={0.5} mt={1} justifyContent="flex-end">
<IconButton
size="small"
onClick={() => {
setEditedTimeframe(trendsConfig.timeframe);
setEditingTimeframe(false);
}}
sx={{ color: '#64748b' }}
>
<CancelIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
onClick={handleSaveTimeframe}
sx={{
color: '#10b981',
'&:hover': { backgroundColor: '#dcfce7' },
}}
>
<SaveIcon fontSize="small" />
</IconButton>
</Box>
</Box>
) : (
<Box display="flex" alignItems="center" gap={0.5}>
<Typography variant="body2" fontWeight={500} color="#333">
{TIMEFRAME_OPTIONS.find(opt => opt.value === trendsConfig.timeframe)?.label || trendsConfig.timeframe}
</Typography>
<Tooltip title={trendsConfig.timeframe_justification || 'Time period for trends analysis'} arrow>
<InfoIcon sx={{ fontSize: 14, color: '#9ca3af', cursor: 'help' }} />
</Tooltip>
</Box>
)}
</Grid>
<Grid item xs={6}>
<Typography variant="caption" color="#666" fontWeight={500} display="block" gutterBottom>
Region
</Typography>
<Box display="flex" alignItems="center" gap={0.5}>
<Typography variant="body2" fontWeight={500} color="#333">
{trendsConfig.geo}
<Box display="flex" alignItems="center" justifyContent="space-between" mb={0.5}>
<Typography variant="caption" color="#666" fontWeight={500}>
Region
</Typography>
<Tooltip title={trendsConfig.geo_justification} arrow>
<InfoIcon sx={{ fontSize: 14, color: '#9ca3af', cursor: 'help' }} />
</Tooltip>
{!editingRegion && onUpdate && (
<IconButton
size="small"
onClick={() => setEditingRegion(true)}
sx={{
color: '#64748b',
'&:hover': { color: '#0ea5e9', backgroundColor: '#f0f9ff' },
}}
>
<EditIcon fontSize="small" />
</IconButton>
)}
</Box>
{editingRegion ? (
<Box>
<FormControl fullWidth size="small">
<Select
value={editedRegion}
onChange={(e) => setEditedRegion(e.target.value)}
sx={{
backgroundColor: '#ffffff',
fontSize: '0.8125rem',
}}
>
{REGION_OPTIONS.map((option) => (
<MenuItem key={option.value} value={option.value}>
{option.label}
</MenuItem>
))}
</Select>
</FormControl>
<Box display="flex" gap={0.5} mt={1} justifyContent="flex-end">
<IconButton
size="small"
onClick={() => {
setEditedRegion(trendsConfig.geo);
setEditingRegion(false);
}}
sx={{ color: '#64748b' }}
>
<CancelIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
onClick={handleSaveRegion}
sx={{
color: '#10b981',
'&:hover': { backgroundColor: '#dcfce7' },
}}
>
<SaveIcon fontSize="small" />
</IconButton>
</Box>
</Box>
) : (
<Box display="flex" alignItems="center" gap={0.5}>
<Typography variant="body2" fontWeight={500} color="#333">
{REGION_OPTIONS.find(opt => opt.value === trendsConfig.geo)?.label || trendsConfig.geo}
</Typography>
<Tooltip title={trendsConfig.geo_justification || 'Geographic region for trends analysis'} arrow>
<InfoIcon sx={{ fontSize: 14, color: '#9ca3af', cursor: 'help' }} />
</Tooltip>
</Box>
)}
</Grid>
</Grid>
</Box>
{/* Know More Modal */}
<TrendsKnowMoreModal
open={showKnowMoreModal}
onClose={() => setShowKnowMoreModal(false)}
trendsConfig={trendsConfig}
/>
</AccordionDetails>
</Accordion>
);

View File

@@ -0,0 +1,348 @@
import React from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Typography,
Box,
List,
ListItem,
ListItemIcon,
ListItemText,
Divider,
} from '@mui/material';
import {
Close as CloseIcon,
CheckCircle as CheckIcon,
TrendingUp as TrendIcon,
Info as InfoIcon,
AutoAwesome as AIIcon,
} from '@mui/icons-material';
import { TrendsConfig } from '../../../types/intent.types';
interface TrendsKnowMoreModalProps {
open: boolean;
onClose: () => void;
trendsConfig: TrendsConfig;
}
export const TrendsKnowMoreModal: React.FC<TrendsKnowMoreModalProps> = ({
open,
onClose,
trendsConfig,
}) => {
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="md"
fullWidth
PaperProps={{
sx: {
borderRadius: 2,
boxShadow: '0 10px 40px rgba(0, 0, 0, 0.15)',
},
}}
>
<DialogTitle
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
pb: 2,
borderBottom: '1px solid #e5e7eb',
background: 'linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%)',
}}
>
<Box display="flex" alignItems="center" gap={1.5}>
<TrendIcon sx={{ color: '#10b981', fontSize: 28 }} />
<Box>
<Typography variant="h6" fontWeight={700} color="#166534">
Google Trends Analysis
</Typography>
<Typography variant="caption" color="#166534" sx={{ opacity: 0.8 }}>
Understanding search interest and market trends
</Typography>
</Box>
</Box>
<Button
onClick={onClose}
sx={{
minWidth: 'auto',
p: 0.5,
color: '#64748b',
'&:hover': { backgroundColor: 'rgba(0,0,0,0.05)' },
}}
>
<CloseIcon />
</Button>
</DialogTitle>
<DialogContent sx={{ p: 3 }}>
{/* What is Google Trends? */}
<Box mb={3}>
<Typography variant="subtitle1" fontWeight={700} color="#0c4a6e" gutterBottom>
What is Google Trends?
</Typography>
<Typography variant="body2" color="#475569" paragraph>
Google Trends is a free tool that shows how often specific search terms are entered into Google's search engine
relative to the total search volume. It provides insights into search interest over time, regional popularity,
and related topics/queries.
</Typography>
<Box
sx={{
mt: 2,
p: 2,
backgroundColor: '#f0f9ff',
borderRadius: 1,
border: '1px solid #bae6fd',
}}
>
<Box display="flex" alignItems="start" gap={1}>
<InfoIcon sx={{ color: '#0ea5e9', fontSize: 20, mt: 0.5 }} />
<Box>
<Typography variant="caption" fontWeight={600} color="#0c4a6e" display="block" gutterBottom>
Real-World Example
</Typography>
<Typography variant="caption" color="#0369a1">
If you're researching "AI content generation", Google Trends shows you when interest peaked, which regions
show the most interest, and what related topics people are searching for. This helps you understand market
timing and content opportunities.
</Typography>
</Box>
</Box>
</Box>
</Box>
<Divider sx={{ my: 3 }} />
{/* What Happens in the Backend? */}
<Box mb={3}>
<Typography variant="subtitle1" fontWeight={700} color="#0c4a6e" gutterBottom>
What Happens in the Backend?
</Typography>
<List dense>
<ListItem sx={{ px: 0, py: 0.5 }}>
<ListItemIcon sx={{ minWidth: 32 }}>
<Box
sx={{
width: 24,
height: 24,
borderRadius: '50%',
backgroundColor: '#e0f2fe',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '12px',
fontWeight: 700,
color: '#0ea5e9',
}}
>
1
</Box>
</ListItemIcon>
<ListItemText
primary="API Request"
secondary="ALwrity sends your keywords to Google Trends API with the specified timeframe and region"
primaryTypographyProps={{ variant: 'body2', fontWeight: 600, color: '#1e293b' }}
secondaryTypographyProps={{ variant: 'caption', color: '#64748b' }}
/>
</ListItem>
<ListItem sx={{ px: 0, py: 0.5 }}>
<ListItemIcon sx={{ minWidth: 32 }}>
<Box
sx={{
width: 24,
height: 24,
borderRadius: '50%',
backgroundColor: '#e0f2fe',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '12px',
fontWeight: 700,
color: '#0ea5e9',
}}
>
2
</Box>
</ListItemIcon>
<ListItemText
primary="Data Retrieval"
secondary="Google Trends returns interest over time, regional distribution, related topics, and related queries"
primaryTypographyProps={{ variant: 'body2', fontWeight: 600, color: '#1e293b' }}
secondaryTypographyProps={{ variant: 'caption', color: '#64748b' }}
/>
</ListItem>
<ListItem sx={{ px: 0, py: 0.5 }}>
<ListItemIcon sx={{ minWidth: 32 }}>
<Box
sx={{
width: 24,
height: 24,
borderRadius: '50%',
backgroundColor: '#e0f2fe',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '12px',
fontWeight: 700,
color: '#0ea5e9',
}}
>
3
</Box>
</ListItemIcon>
<ListItemText
primary="AI Analysis"
secondary="ALwrity's AI analyzes the trends data to identify patterns, opportunities, and optimal timing for content publication"
primaryTypographyProps={{ variant: 'body2', fontWeight: 600, color: '#1e293b' }}
secondaryTypographyProps={{ variant: 'caption', color: '#64748b' }}
/>
</ListItem>
<ListItem sx={{ px: 0, py: 0.5 }}>
<ListItemIcon sx={{ minWidth: 32 }}>
<Box
sx={{
width: 24,
height: 24,
borderRadius: '50%',
backgroundColor: '#e0f2fe',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '12px',
fontWeight: 700,
color: '#0ea5e9',
}}
>
4
</Box>
</ListItemIcon>
<ListItemText
primary="Integration"
secondary="Trends insights are integrated into your research results, providing context for content timing, regional targeting, and topic expansion"
primaryTypographyProps={{ variant: 'body2', fontWeight: 600, color: '#1e293b' }}
secondaryTypographyProps={{ variant: 'caption', color: '#64748b' }}
/>
</ListItem>
</List>
</Box>
<Divider sx={{ my: 3 }} />
{/* Why is it Important? */}
<Box mb={3}>
<Typography variant="subtitle1" fontWeight={700} color="#0c4a6e" gutterBottom>
Why is Google Trends Important?
</Typography>
<Box
sx={{
p: 2,
backgroundColor: '#fef3c7',
borderRadius: 1,
border: '1px solid #fde68a',
}}
>
<Typography variant="body2" color="#92400e" fontWeight={600} gutterBottom>
🎯 Strategic Benefits:
</Typography>
<List dense sx={{ mt: 1 }}>
<ListItem sx={{ px: 0, py: 0.5 }}>
<ListItemIcon sx={{ minWidth: 24 }}>
<CheckIcon sx={{ color: '#10b981', fontSize: 18 }} />
</ListItemIcon>
<ListItemText
primary="Timing Optimization"
secondary="Publish content when search interest is highest for maximum visibility"
primaryTypographyProps={{ variant: 'caption', fontWeight: 600, color: '#92400e' }}
secondaryTypographyProps={{ variant: 'caption', color: '#78350f' }}
/>
</ListItem>
<ListItem sx={{ px: 0, py: 0.5 }}>
<ListItemIcon sx={{ minWidth: 24 }}>
<CheckIcon sx={{ color: '#10b981', fontSize: 18 }} />
</ListItemIcon>
<ListItemText
primary="Regional Targeting"
secondary="Understand which regions show the most interest to tailor content accordingly"
primaryTypographyProps={{ variant: 'caption', fontWeight: 600, color: '#92400e' }}
secondaryTypographyProps={{ variant: 'caption', color: '#78350f' }}
/>
</ListItem>
<ListItem sx={{ px: 0, py: 0.5 }}>
<ListItemIcon sx={{ minWidth: 24 }}>
<CheckIcon sx={{ color: '#10b981', fontSize: 18 }} />
</ListItemIcon>
<ListItemText
primary="Content Expansion"
secondary="Discover related topics and queries to expand your content strategy"
primaryTypographyProps={{ variant: 'caption', fontWeight: 600, color: '#92400e' }}
secondaryTypographyProps={{ variant: 'caption', color: '#78350f' }}
/>
</ListItem>
<ListItem sx={{ px: 0, py: 0.5 }}>
<ListItemIcon sx={{ minWidth: 24 }}>
<CheckIcon sx={{ color: '#10b981', fontSize: 18 }} />
</ListItemIcon>
<ListItemText
primary="Market Intelligence"
secondary="Gain insights into market trends, emerging topics, and competitor interest"
primaryTypographyProps={{ variant: 'caption', fontWeight: 600, color: '#92400e' }}
secondaryTypographyProps={{ variant: 'caption', color: '#78350f' }}
/>
</ListItem>
</List>
</Box>
</Box>
<Divider sx={{ my: 3 }} />
{/* What Trends Will Uncover */}
<Box>
<Box display="flex" alignItems="center" gap={1} mb={2}>
<AIIcon sx={{ color: '#0ea5e9', fontSize: 24 }} />
<Typography variant="subtitle1" fontWeight={700} color="#0c4a6e">
What Trends Will Uncover
</Typography>
</Box>
{trendsConfig.expected_insights.length > 0 ? (
<List dense sx={{ backgroundColor: '#f9fafb', borderRadius: 1, p: 1 }}>
{trendsConfig.expected_insights.map((insight, idx) => (
<ListItem key={idx} sx={{ py: 0.75, px: 1 }}>
<ListItemIcon sx={{ minWidth: 28 }}>
<CheckIcon color="success" fontSize="small" />
</ListItemIcon>
<ListItemText
primary={insight}
primaryTypographyProps={{ variant: 'body2', color: '#374151', fontWeight: 500 }}
/>
</ListItem>
))}
</List>
) : (
<Typography variant="body2" color="#64748b" sx={{ fontStyle: 'italic' }}>
No specific insights configured for this research.
</Typography>
)}
</Box>
</DialogContent>
<DialogActions sx={{ px: 3, py: 2, borderTop: '1px solid #e5e7eb', backgroundColor: '#f9fafb' }}>
<Button
onClick={onClose}
variant="contained"
sx={{
backgroundColor: '#10b981',
'&:hover': { backgroundColor: '#059669' },
fontWeight: 600,
}}
>
Got it!
</Button>
</DialogActions>
</Dialog>
);
};

View File

@@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react';
import { Tooltip, CircularProgress } from '@mui/material';
import { Tooltip, CircularProgress, Select, MenuItem, FormControl, InputLabel } from '@mui/material';
import { Psychology as BrainIcon, Settings as SettingsIcon, Info as InfoIcon } from '@mui/icons-material';
import { ResearchPurpose, ContentOutput, ResearchDepthLevel } from '../../types/intent.types';
interface ResearchInputContainerProps {
keywords: string[];
@@ -11,8 +12,46 @@ interface ResearchInputContainerProps {
isAnalyzingIntent?: boolean;
hasIntentAnalysis?: boolean;
intentConfidence?: number;
// User-provided intent settings
userPurpose?: ResearchPurpose;
userContentOutput?: ContentOutput;
userDepth?: ResearchDepthLevel;
onPurposeChange?: (purpose: ResearchPurpose) => void;
onContentOutputChange?: (output: ContentOutput) => void;
onDepthChange?: (depth: ResearchDepthLevel) => void;
}
const PURPOSE_OPTIONS: { value: ResearchPurpose; label: string }[] = [
{ value: 'learn', label: 'Learn' },
{ value: 'create_content', label: 'Create Content' },
{ value: 'make_decision', label: 'Make Decision' },
{ value: 'compare', label: 'Compare' },
{ value: 'solve_problem', label: 'Solve Problem' },
{ value: 'find_data', label: 'Find Data' },
{ value: 'explore_trends', label: 'Explore Trends' },
{ value: 'validate', label: 'Validate' },
{ value: 'generate_ideas', label: 'Generate Ideas' },
];
const CONTENT_OUTPUT_OPTIONS: { value: ContentOutput; label: string }[] = [
{ value: 'blog', label: 'Blog Post' },
{ value: 'podcast', label: 'Podcast' },
{ value: 'video', label: 'Video' },
{ value: 'social_post', label: 'Social Post' },
{ value: 'newsletter', label: 'Newsletter' },
{ value: 'presentation', label: 'Presentation' },
{ value: 'report', label: 'Report' },
{ value: 'whitepaper', label: 'Whitepaper' },
{ value: 'email', label: 'Email' },
{ value: 'general', label: 'General' },
];
const DEPTH_OPTIONS: { value: ResearchDepthLevel; label: string }[] = [
{ value: 'overview', label: 'Overview' },
{ value: 'detailed', label: 'Detailed' },
{ value: 'expert', label: 'Expert-Level' },
];
export const ResearchInputContainer: React.FC<ResearchInputContainerProps> = ({
keywords,
placeholder,
@@ -21,6 +60,12 @@ export const ResearchInputContainer: React.FC<ResearchInputContainerProps> = ({
isAnalyzingIntent = false,
hasIntentAnalysis = false,
intentConfidence = 0,
userPurpose,
userContentOutput,
userDepth,
onPurposeChange,
onContentOutputChange,
onDepthChange,
}) => {
const [inputValue, setInputValue] = useState('');
const [wordCount, setWordCount] = useState(0);
@@ -106,7 +151,7 @@ export const ResearchInputContainer: React.FC<ResearchInputContainerProps> = ({
style={{
width: '100%',
flex: '1',
minHeight: '195px', // Reduced by 35% from 300px
minHeight: '150px', // Reduced to make room for controls
padding: '12px',
fontSize: '15px',
lineHeight: '1.7',
@@ -124,6 +169,187 @@ export const ResearchInputContainer: React.FC<ResearchInputContainerProps> = ({
}}
/>
{/* Compact Select Controls - Purpose, Creating, Depth */}
<div style={{
display: 'flex',
gap: '8px',
padding: '8px 0',
borderTop: '1px solid rgba(14, 165, 233, 0.1)',
borderBottom: '1px solid rgba(14, 165, 233, 0.1)',
marginTop: '8px',
}}>
{/* Purpose */}
<FormControl size="small" sx={{ flex: 1, minWidth: 100 }}>
<Select
value={userPurpose || ''}
onChange={(e) => onPurposeChange?.(e.target.value as ResearchPurpose)}
displayEmpty
sx={{
backgroundColor: '#ffffff',
color: '#1e293b',
fontSize: '0.75rem',
height: '32px',
'& .MuiSelect-select': {
padding: '6px 10px',
color: '#1e293b',
},
'& .MuiOutlinedInput-notchedOutline': {
borderColor: '#d1d5db',
},
'&:hover .MuiOutlinedInput-notchedOutline': {
borderColor: '#9ca3af',
},
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
borderColor: '#0ea5e9',
borderWidth: '1.5px',
},
'& .MuiSelect-icon': {
color: '#64748b',
},
}}
MenuProps={{
PaperProps: {
sx: {
backgroundColor: '#ffffff',
'& .MuiMenuItem-root': {
color: '#1e293b',
fontSize: '0.75rem',
'&:hover': {
backgroundColor: '#f3f4f6',
},
'&.Mui-selected': {
backgroundColor: '#e0f2fe',
color: '#0284c7',
},
},
},
},
}}
>
<MenuItem value="" disabled>
<em style={{ color: '#9ca3af', fontSize: '0.75rem' }}>Purpose</em>
</MenuItem>
{PURPOSE_OPTIONS.map(opt => (
<MenuItem key={opt.value} value={opt.value}>{opt.label}</MenuItem>
))}
</Select>
</FormControl>
{/* Creating (Content Output) */}
<FormControl size="small" sx={{ flex: 1, minWidth: 100 }}>
<Select
value={userContentOutput || ''}
onChange={(e) => onContentOutputChange?.(e.target.value as ContentOutput)}
displayEmpty
sx={{
backgroundColor: '#ffffff',
color: '#1e293b',
fontSize: '0.75rem',
height: '32px',
'& .MuiSelect-select': {
padding: '6px 10px',
color: '#1e293b',
},
'& .MuiOutlinedInput-notchedOutline': {
borderColor: '#d1d5db',
},
'&:hover .MuiOutlinedInput-notchedOutline': {
borderColor: '#9ca3af',
},
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
borderColor: '#0ea5e9',
borderWidth: '1.5px',
},
'& .MuiSelect-icon': {
color: '#64748b',
},
}}
MenuProps={{
PaperProps: {
sx: {
backgroundColor: '#ffffff',
'& .MuiMenuItem-root': {
color: '#1e293b',
fontSize: '0.75rem',
'&:hover': {
backgroundColor: '#f3f4f6',
},
'&.Mui-selected': {
backgroundColor: '#e0f2fe',
color: '#0284c7',
},
},
},
},
}}
>
<MenuItem value="" disabled>
<em style={{ color: '#9ca3af', fontSize: '0.75rem' }}>Creating</em>
</MenuItem>
{CONTENT_OUTPUT_OPTIONS.map(opt => (
<MenuItem key={opt.value} value={opt.value}>{opt.label}</MenuItem>
))}
</Select>
</FormControl>
{/* Depth */}
<FormControl size="small" sx={{ flex: 1, minWidth: 100 }}>
<Select
value={userDepth || ''}
onChange={(e) => onDepthChange?.(e.target.value as ResearchDepthLevel)}
displayEmpty
sx={{
backgroundColor: '#ffffff',
color: '#1e293b',
fontSize: '0.75rem',
height: '32px',
'& .MuiSelect-select': {
padding: '6px 10px',
color: '#1e293b',
},
'& .MuiOutlinedInput-notchedOutline': {
borderColor: '#d1d5db',
},
'&:hover .MuiOutlinedInput-notchedOutline': {
borderColor: '#9ca3af',
},
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
borderColor: '#0ea5e9',
borderWidth: '1.5px',
},
'& .MuiSelect-icon': {
color: '#64748b',
},
}}
MenuProps={{
PaperProps: {
sx: {
backgroundColor: '#ffffff',
'& .MuiMenuItem-root': {
color: '#1e293b',
fontSize: '0.75rem',
'&:hover': {
backgroundColor: '#f3f4f6',
},
'&.Mui-selected': {
backgroundColor: '#e0f2fe',
color: '#0284c7',
},
},
},
},
}}
>
<MenuItem value="" disabled>
<em style={{ color: '#9ca3af', fontSize: '0.75rem' }}>Depth</em>
</MenuItem>
{DEPTH_OPTIONS.map(opt => (
<MenuItem key={opt.value} value={opt.value}>{opt.label}</MenuItem>
))}
</Select>
</FormControl>
</div>
{/* Bottom bar with word count and Intent & Options button */}
<div style={{
display: 'flex',

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { ResearchConfig } from '../../../../services/blogWriterApi';
import { Tooltip } from '@mui/material';
import { ResearchConfig } from '../../../../services/researchApi';
import {
tavilyTopics,
tavilySearchDepths,
@@ -7,6 +8,7 @@ import {
tavilyAnswerOptions,
tavilyRawContentOptions
} from '../utils/constants';
import { getTavilyTooltipContent } from './utils/tavilyTooltips';
interface TavilyOptionsProps {
config: ResearchConfig;
@@ -130,13 +132,22 @@ export const TavilyOptions: React.FC<TavilyOptionsProps> = ({ config, onConfigUp
{/* Tavily Topic */}
<div>
<label style={{
display: 'block',
display: 'flex',
alignItems: 'center',
gap: '4px',
marginBottom: '6px',
fontSize: '12px',
fontWeight: '600',
color: '#0ea5e9',
}}>
Search Topic
<Tooltip
title={<div style={{ whiteSpace: 'pre-line', fontSize: '12px', lineHeight: '1.5' }}>{getTavilyTooltipContent('topic', config.tavily_topic)}</div>}
arrow
placement="top"
>
<span style={{ fontSize: '10px', color: '#0ea5e9', cursor: 'help' }}></span>
</Tooltip>
</label>
<select
value={config.tavily_topic || 'general'}
@@ -161,13 +172,22 @@ export const TavilyOptions: React.FC<TavilyOptionsProps> = ({ config, onConfigUp
{/* Tavily Search Depth */}
<div>
<label style={{
display: 'block',
display: 'flex',
alignItems: 'center',
gap: '4px',
marginBottom: '6px',
fontSize: '12px',
fontWeight: '600',
color: '#0ea5e9',
}}>
Search Depth
<Tooltip
title={<div style={{ whiteSpace: 'pre-line', fontSize: '12px', lineHeight: '1.5' }}>{getTavilyTooltipContent('searchDepth', config.tavily_search_depth)}</div>}
arrow
placement="top"
>
<span style={{ fontSize: '10px', color: '#0ea5e9', cursor: 'help' }}></span>
</Tooltip>
</label>
<select
value={config.tavily_search_depth || 'basic'}
@@ -192,13 +212,22 @@ export const TavilyOptions: React.FC<TavilyOptionsProps> = ({ config, onConfigUp
{/* Tavily Include Answer */}
<div>
<label style={{
display: 'block',
display: 'flex',
alignItems: 'center',
gap: '4px',
marginBottom: '6px',
fontSize: '12px',
fontWeight: '600',
color: '#0ea5e9',
}}>
AI Answer
<Tooltip
title={<div style={{ whiteSpace: 'pre-line', fontSize: '12px', lineHeight: '1.5' }}>{getTavilyTooltipContent('includeAnswer', typeof config.tavily_include_answer === 'string' ? config.tavily_include_answer : config.tavily_include_answer ? 'true' : 'false')}</div>}
arrow
placement="top"
>
<span style={{ fontSize: '10px', color: '#0ea5e9', cursor: 'help' }}></span>
</Tooltip>
</label>
<select
value={config.tavily_include_answer === true ? 'true' : typeof config.tavily_include_answer === 'string' ? config.tavily_include_answer : 'false'}
@@ -223,13 +252,22 @@ export const TavilyOptions: React.FC<TavilyOptionsProps> = ({ config, onConfigUp
{/* Tavily Time Range */}
<div>
<label style={{
display: 'block',
display: 'flex',
alignItems: 'center',
gap: '4px',
marginBottom: '6px',
fontSize: '12px',
fontWeight: '600',
color: '#0ea5e9',
}}>
Time Range
<Tooltip
title={<div style={{ whiteSpace: 'pre-line', fontSize: '12px', lineHeight: '1.5' }}>{getTavilyTooltipContent('timeRange', config.tavily_time_range)}</div>}
arrow
placement="top"
>
<span style={{ fontSize: '10px', color: '#0ea5e9', cursor: 'help' }}></span>
</Tooltip>
</label>
<select
value={config.tavily_time_range || ''}
@@ -260,13 +298,22 @@ export const TavilyOptions: React.FC<TavilyOptionsProps> = ({ config, onConfigUp
}}>
<div>
<label style={{
display: 'block',
display: 'flex',
alignItems: 'center',
gap: '4px',
marginBottom: '6px',
fontSize: '12px',
fontWeight: '600',
color: '#0ea5e9',
}}>
Include Domains (optional)
<Tooltip
title={<div style={{ whiteSpace: 'pre-line', fontSize: '12px', lineHeight: '1.5' }}>{getTavilyTooltipContent('includeDomains')}</div>}
arrow
placement="top"
>
<span style={{ fontSize: '10px', color: '#0ea5e9', cursor: 'help' }}></span>
</Tooltip>
</label>
<input
type="text"
@@ -287,13 +334,22 @@ export const TavilyOptions: React.FC<TavilyOptionsProps> = ({ config, onConfigUp
<div>
<label style={{
display: 'block',
display: 'flex',
alignItems: 'center',
gap: '4px',
marginBottom: '6px',
fontSize: '12px',
fontWeight: '600',
color: '#0ea5e9',
}}>
Exclude Domains (optional)
<Tooltip
title={<div style={{ whiteSpace: 'pre-line', fontSize: '12px', lineHeight: '1.5' }}>{getTavilyTooltipContent('excludeDomains')}</div>}
arrow
placement="top"
>
<span style={{ fontSize: '10px', color: '#0ea5e9', cursor: 'help' }}></span>
</Tooltip>
</label>
<input
type="text"
@@ -323,13 +379,22 @@ export const TavilyOptions: React.FC<TavilyOptionsProps> = ({ config, onConfigUp
{/* Include Raw Content */}
<div>
<label style={{
display: 'block',
display: 'flex',
alignItems: 'center',
gap: '4px',
marginBottom: '6px',
fontSize: '12px',
fontWeight: '600',
color: '#0ea5e9',
}}>
Raw Content Format
<Tooltip
title={<div style={{ whiteSpace: 'pre-line', fontSize: '12px', lineHeight: '1.5' }}>{getTavilyTooltipContent('includeRawContent', typeof config.tavily_include_raw_content === 'string' ? config.tavily_include_raw_content : config.tavily_include_raw_content ? 'true' : 'false')}</div>}
arrow
placement="top"
>
<span style={{ fontSize: '10px', color: '#0ea5e9', cursor: 'help' }}></span>
</Tooltip>
</label>
<select
value={config.tavily_include_raw_content === true ? 'true' : typeof config.tavily_include_raw_content === 'string' ? config.tavily_include_raw_content : 'false'}
@@ -354,13 +419,22 @@ export const TavilyOptions: React.FC<TavilyOptionsProps> = ({ config, onConfigUp
{/* Country */}
<div>
<label style={{
display: 'block',
display: 'flex',
alignItems: 'center',
gap: '4px',
marginBottom: '6px',
fontSize: '12px',
fontWeight: '600',
color: '#0ea5e9',
}}>
Country Code (optional)
<Tooltip
title={<div style={{ whiteSpace: 'pre-line', fontSize: '12px', lineHeight: '1.5' }}>{getTavilyTooltipContent('country')}</div>}
arrow
placement="top"
>
<span style={{ fontSize: '10px', color: '#0ea5e9', cursor: 'help' }}></span>
</Tooltip>
</label>
<input
type="text"
@@ -379,17 +453,26 @@ export const TavilyOptions: React.FC<TavilyOptionsProps> = ({ config, onConfigUp
/>
</div>
{/* Chunks Per Source (only for advanced) */}
{config.tavily_search_depth === 'advanced' && (
{/* Chunks Per Source (only for advanced or fast) */}
{(config.tavily_search_depth === 'advanced' || (config.tavily_search_depth as string) === 'fast') && (
<div>
<label style={{
display: 'block',
display: 'flex',
alignItems: 'center',
gap: '4px',
marginBottom: '6px',
fontSize: '12px',
fontWeight: '600',
color: '#0ea5e9',
}}>
Chunks Per Source (1-3)
<Tooltip
title={<div style={{ whiteSpace: 'pre-line', fontSize: '12px', lineHeight: '1.5' }}>{getTavilyTooltipContent('chunksPerSource')}</div>}
arrow
placement="top"
>
<span style={{ fontSize: '10px', color: '#0ea5e9', cursor: 'help' }}></span>
</Tooltip>
</label>
<input
type="number"
@@ -420,13 +503,22 @@ export const TavilyOptions: React.FC<TavilyOptionsProps> = ({ config, onConfigUp
}}>
<div>
<label style={{
display: 'block',
display: 'flex',
alignItems: 'center',
gap: '4px',
marginBottom: '6px',
fontSize: '12px',
fontWeight: '600',
color: '#0ea5e9',
}}>
Start Date (YYYY-MM-DD)
<Tooltip
title={<div style={{ whiteSpace: 'pre-line', fontSize: '12px', lineHeight: '1.5' }}>{getTavilyTooltipContent('startDate')}</div>}
arrow
placement="top"
>
<span style={{ fontSize: '10px', color: '#0ea5e9', cursor: 'help' }}></span>
</Tooltip>
</label>
<input
type="date"
@@ -446,13 +538,22 @@ export const TavilyOptions: React.FC<TavilyOptionsProps> = ({ config, onConfigUp
<div>
<label style={{
display: 'block',
display: 'flex',
alignItems: 'center',
gap: '4px',
marginBottom: '6px',
fontSize: '12px',
fontWeight: '600',
color: '#0ea5e9',
}}>
End Date (YYYY-MM-DD)
<Tooltip
title={<div style={{ whiteSpace: 'pre-line', fontSize: '12px', lineHeight: '1.5' }}>{getTavilyTooltipContent('endDate')}</div>}
arrow
placement="top"
>
<span style={{ fontSize: '10px', color: '#0ea5e9', cursor: 'help' }}></span>
</Tooltip>
</label>
<input
type="date"
@@ -496,7 +597,16 @@ export const TavilyOptions: React.FC<TavilyOptionsProps> = ({ config, onConfigUp
cursor: 'pointer',
}}
/>
<span>Include Images</span>
<span style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
Include Images
<Tooltip
title={<div style={{ whiteSpace: 'pre-line', fontSize: '12px', lineHeight: '1.5' }}>{getTavilyTooltipContent('includeImages')}</div>}
arrow
placement="top"
>
<span style={{ fontSize: '10px', color: '#0ea5e9', cursor: 'help' }}></span>
</Tooltip>
</span>
</label>
<label style={{
@@ -519,7 +629,16 @@ export const TavilyOptions: React.FC<TavilyOptionsProps> = ({ config, onConfigUp
opacity: config.tavily_include_images ? 1 : 0.5,
}}
/>
<span>Include Image Descriptions</span>
<span style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
Include Image Descriptions
<Tooltip
title={<div style={{ whiteSpace: 'pre-line', fontSize: '12px', lineHeight: '1.5' }}>{getTavilyTooltipContent('includeImageDescriptions')}</div>}
arrow
placement="top"
>
<span style={{ fontSize: '10px', color: '#0ea5e9', cursor: 'help' }}></span>
</Tooltip>
</span>
</label>
<label style={{
@@ -540,7 +659,16 @@ export const TavilyOptions: React.FC<TavilyOptionsProps> = ({ config, onConfigUp
cursor: 'pointer',
}}
/>
<span>Include Favicon URLs</span>
<span style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
Include Favicon URLs
<Tooltip
title={<div style={{ whiteSpace: 'pre-line', fontSize: '12px', lineHeight: '1.5' }}>{getTavilyTooltipContent('includeFavicon')}</div>}
arrow
placement="top"
>
<span style={{ fontSize: '10px', color: '#0ea5e9', cursor: 'help' }}></span>
</Tooltip>
</span>
</label>
<label style={{
@@ -561,7 +689,16 @@ export const TavilyOptions: React.FC<TavilyOptionsProps> = ({ config, onConfigUp
cursor: 'pointer',
}}
/>
<span>Auto-Configure Parameters</span>
<span style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
Auto-Configure Parameters
<Tooltip
title={<div style={{ whiteSpace: 'pre-line', fontSize: '12px', lineHeight: '1.5' }}>{getTavilyTooltipContent('autoParameters')}</div>}
arrow
placement="top"
>
<span style={{ fontSize: '10px', color: '#0ea5e9', cursor: 'help' }}></span>
</Tooltip>
</span>
</label>
</div>
</div>

View File

@@ -0,0 +1,288 @@
/**
* Detailed tooltips for Exa search options
* These help educate end users about each Exa option
*/
export const exaOptionTooltips = {
category: {
title: "Content Category",
description: "Filter results by content type. Choose a specific category to focus your search on particular content formats like research papers, news articles, or company profiles. Leave empty to search across all categories.",
examples: {
"research paper": "Best for academic papers, scientific publications, and scholarly content from sources like arXiv, Nature, IEEE.",
"news": "Recent news articles and current events from news websites.",
"company": "Company profiles, business information, and corporate websites.",
"pdf": "PDF documents and downloadable files.",
"github": "GitHub repositories, code, and technical documentation.",
"tweet": "Twitter/X posts and social media content.",
"personal site": "Personal blogs, portfolios, and individual websites.",
"linkedin profile": "LinkedIn profiles and professional networking content.",
"financial report": "Financial statements, earnings reports, and economic data.",
}
},
searchType: {
title: "Search Algorithm",
description: "Choose how Exa searches the web. Each algorithm is optimized for different use cases and latency requirements. Exa uses embeddings-based 'next-link prediction' to understand semantic meaning, not just keyword matches.",
types: {
auto: {
title: "Auto (Default) - Best of all worlds",
description: "Intelligently combines multiple search methods (neural, keyword, and others) using a reranker model that adapts to your query type. Provides the best balance of quality and versatility without manual tuning.",
whenToUse: "Recommended for most use cases. Use for general research, production workloads, or when query types vary significantly. Best when you want versatility without choosing a specific method.",
latency: "Median latency: ~1000ms",
quality: "High quality, versatile across query types",
},
fast: {
title: "Fast - World's fastest search API",
description: "Streamlined versions of neural and reranker models optimized for speed. Trades a small amount of performance for significant speed improvements. Best for applications where milliseconds matter.",
whenToUse: "Use for real-time applications (voice agents, autocomplete), low-latency QA, or high-volume agent workflows where latency accumulates. Perfect when speed is critical.",
latency: "Median latency: <500ms (excluding network)",
quality: "Good factual accuracy, optimized for speed",
note: "Best for single-step factual queries",
},
deep: {
title: "Deep - Comprehensive research",
description: "Comprehensive search with automatic query expansion or custom query variations. Runs parallel searches across multiple query formulations to find comprehensive results. Returns rich contextual summaries for each result.",
whenToUse: "Use for agentic workflows, complex research tasks, multi-hop queries, or when comprehensive coverage matters more than speed. Ideal for research assistants and deep analysis.",
latency: "Median latency: ~5000ms",
quality: "Highest quality, comprehensive coverage",
note: "Requires context=true for detailed summaries. Best for multi-step reasoning workflows.",
},
neural: {
title: "Neural - Embeddings-based semantic search",
description: "Uses AI embeddings and 'next-link prediction' to understand semantic meaning. Finds results that are conceptually similar even without exact keyword matches. Incorporated into Fast and Auto search types.",
whenToUse: "Use for exploratory searches, finding semantically related content, or when you want to discover related concepts beyond exact keyword matches. Best for thematic and conceptual relationships.",
latency: "Variable (incorporated into Fast/Auto)",
quality: "Excellent semantic understanding",
note: "Also available as part of Auto and Fast search types",
},
keyword: {
title: "Keyword - Traditional search",
description: "Traditional keyword-based search similar to Google. Uses exact keyword matching and ranking algorithms. Faster and more cost-effective than neural search.",
whenToUse: "Use when you need precise keyword matching, want faster results, or are searching for specific terms, brands, or exact phrases.",
limits: "Maximum 10 results with keyword search.",
latency: "Fastest (traditional search)",
},
}
},
numResults: {
title: "Number of Results",
description: "How many search results to return. More results provide comprehensive coverage but take longer and cost more. Fewer results are faster and more focused.",
recommendations: {
"1-10": "Quick overview, fast results, lower cost. Good for simple queries or when you need just a few high-quality sources.",
"11-25": "Balanced coverage. Recommended for most research tasks. Provides good depth without excessive cost.",
"26-50": "Comprehensive research. Use for in-depth analysis, expert-level research, or when you need extensive source coverage.",
"51-100": "Maximum coverage. Use for exhaustive research, literature reviews, or when you need to find every relevant source. Note: Only available with neural search.",
},
limits: "Keyword search: max 10 results. Neural search: max 100 results.",
},
highlights: {
title: "Extract Highlights",
description: "Enable AI-powered highlight extraction. Exa's AI identifies and extracts the most relevant text snippets from each result that match your query. These highlights are perfect for quick scanning and understanding key points.",
benefits: [
"Quick overview of each source without reading full content",
"AI-identified most relevant passages",
"Great for research summaries and citations",
"Helps you quickly assess source relevance",
],
whenToUse: "Enable for research, content creation, or when you want to quickly identify key information from sources. Recommended for most use cases.",
cost: "Additional cost per page for highlight generation.",
},
context: {
title: "Return Context String",
description: "Combine all result contents into a single context string optimized for LLM processing. This is ideal for RAG (Retrieval-Augmented Generation) applications, AI analysis, or when you need to process all content together.",
benefits: [
"Single unified text string from all results",
"Optimized for AI/LLM processing",
"Better for RAG applications than highlights",
"Recommended 10,000+ characters for best results",
],
whenToUse: "Enable when you're using results with AI/LLM tools, need full content for analysis, or building RAG applications. Context strings often perform better than highlights for AI processing.",
recommendation: "We recommend using 10,000+ characters for best performance, though no limit works best.",
},
includeDomains: {
title: "Include Domains",
description: "Restrict search results to specific domains only. If specified, results will ONLY come from these domains. Useful for searching within trusted sources, specific websites, or curated domain lists.",
whenToUse: [
"Searching within trusted sources (e.g., academic journals, reputable news sites)",
"Focusing on specific websites or organizations",
"Building curated research from known high-quality sources",
"Ensuring all results come from verified domains",
],
format: "Enter domains separated by commas. Example: arxiv.org, nature.com, ieee.org",
example: "arxiv.org, paperswithcode.com, openai.com, deepmind.com",
limit: "Maximum 1200 domains can be specified.",
},
excludeDomains: {
title: "Exclude Domains",
description: "Exclude specific domains from search results. If specified, no results will be returned from these domains. Useful for filtering out unwanted sources, spam sites, or low-quality content.",
whenToUse: [
"Filtering out spam or low-quality websites",
"Excluding competitor sites from results",
"Removing unwanted content sources",
"Focusing on higher-quality domains",
],
format: "Enter domains separated by commas. Example: spam.com, ads.com, low-quality-site.com",
example: "spam.com, ads.com, clickbait-site.com",
limit: "Maximum 1200 domains can be excluded.",
},
dateFilter: {
title: "Start Published Date",
description: "Filter results to only include content published after this date. This helps you find recent content or content from a specific time period onwards.",
whenToUse: [
"Finding recent content (e.g., last year, last month)",
"Filtering by publication date",
"Ensuring content freshness",
"Time-sensitive research",
],
format: "ISO 8601 format: YYYY-MM-DDTHH:mm:ss.sssZ (e.g., 2025-01-01T00:00:00.000Z)",
example: "2025-01-01T00:00:00.000Z (content from January 1, 2025 onwards)",
note: "Only links with a published date after this date will be returned.",
},
endPublishedDate: {
title: "End Published Date",
description: "Filter results to only include content published before this date. Use with Start Published Date to create a precise date range.",
whenToUse: [
"Creating a date range for published content",
"Finding content from a specific time period",
"Historical research within a timeframe",
"Combining with Start Published Date for precise filtering",
],
format: "ISO 8601 format: YYYY-MM-DDTHH:mm:ss.sssZ (e.g., 2025-12-31T23:59:59.999Z)",
example: "2025-12-31T23:59:59.999Z (content up to December 31, 2025)",
note: "Only links with a published date before this date will be returned. Use with Start Published Date to create a range.",
},
startCrawlDate: {
title: "Start Crawl Date",
description: "Filter results to only include links that Exa discovered (crawled) after this date. Crawl date refers to when Exa first found the link, not when it was published.",
whenToUse: [
"Finding recently discovered content",
"Filtering by when Exa indexed the content",
"Ensuring content is in Exa's index after a certain date",
"Time-sensitive indexing requirements",
],
format: "ISO 8601 format: YYYY-MM-DDTHH:mm:ss.sssZ (e.g., 2025-01-01T00:00:00.000Z)",
example: "2025-01-01T00:00:00.000Z (links crawled after January 1, 2025)",
note: "Crawl date is different from published date. This filters by when Exa discovered the link, not when it was originally published.",
},
endCrawlDate: {
title: "End Crawl Date",
description: "Filter results to only include links that Exa discovered (crawled) before this date. Use with Start Crawl Date to create a crawl date range.",
whenToUse: [
"Creating a crawl date range",
"Finding content indexed within a specific period",
"Historical indexing research",
"Combining with Start Crawl Date for precise filtering",
],
format: "ISO 8601 format: YYYY-MM-DDTHH:mm:ss.sssZ (e.g., 2025-12-31T23:59:59.999Z)",
example: "2025-12-31T23:59:59.999Z (links crawled before December 31, 2025)",
note: "Crawl date is different from published date. This filters by when Exa discovered the link. Use with Start Crawl Date to create a range.",
},
includeText: {
title: "Include Text Filter",
description: "Filter results to only include webpages that contain specific text. This helps narrow down results to pages mentioning specific terms or phrases.",
whenToUse: [
"Finding pages that mention specific terms",
"Filtering by content keywords",
"Ensuring results contain specific phrases",
"Narrowing search to relevant content",
],
format: "Enter up to 5 words. Example: 'large language model'",
example: "large language model, artificial intelligence, machine learning",
limit: "Currently only 1 string is supported, of up to 5 words. Checks webpage text.",
note: "This filters results based on text content, not just metadata.",
},
excludeText: {
title: "Exclude Text Filter",
description: "Filter results to exclude webpages that contain specific text. This helps filter out unwanted content or pages mentioning terms you want to avoid.",
whenToUse: [
"Excluding pages with specific terms",
"Filtering out unwanted content",
"Avoiding certain topics or phrases",
"Removing irrelevant results",
],
format: "Enter up to 5 words. Example: 'course tutorial'",
example: "course, tutorial, advertisement",
limit: "Currently only 1 string is supported, of up to 5 words. Checks the first 1000 words of webpage text.",
note: "This filters results based on text content, helping avoid unwanted pages.",
},
highlightsNumSentences: {
title: "Highlights: Sentences Per Snippet",
description: "Number of sentences to return for each highlight snippet. More sentences provide more context but increase response size.",
whenToUse: [
"Need more context in highlights (use 2-3 sentences)",
"Want concise highlights (use 1 sentence)",
"Balancing detail vs response size",
],
format: "Integer, minimum 1",
example: "2 sentences per highlight (default)",
recommendation: "Use 1-2 sentences for concise highlights, 3+ for more context.",
},
highlightsPerUrl: {
title: "Highlights: Snippets Per URL",
description: "Number of highlight snippets to return for each search result. More highlights provide better coverage of the content but increase response size.",
whenToUse: [
"Need comprehensive coverage (use 3-5 highlights)",
"Want quick overview (use 1-2 highlights)",
"Balancing coverage vs response size",
],
format: "Integer, minimum 1",
example: "3 highlights per URL (default)",
recommendation: "Use 3-5 highlights for comprehensive research, 1-2 for quick overviews.",
},
contextMaxCharacters: {
title: "Context: Max Characters",
description: "Maximum character limit for the context string. When context is enabled, all result contents are combined into one string. Higher limits provide more content but increase response size.",
whenToUse: [
"RAG applications (use 10,000+ characters)",
"Comprehensive analysis (use 10,000+ characters)",
"Limited response size (use 5,000-10,000 characters)",
"Quick summaries (use 1,000-5,000 characters)",
],
format: "Integer, recommended 10,000+ for best results",
example: "10,000 characters (recommended for RAG)",
recommendation: "We recommend using 10,000+ characters for best results, though no limit works best. Context strings often perform better than highlights for RAG applications.",
note: "If you have 5 results and set 1000 characters, each result gets about 200 characters.",
},
textMaxCharacters: {
title: "Text: Max Characters",
description: "Maximum character limit for the full page text. This controls how much text content is retrieved from each webpage. Useful for controlling response size and API costs.",
whenToUse: [
"Controlling response size",
"Limiting API costs",
"Quick content preview (use 500-1000 characters)",
"Full content analysis (use 5000+ characters)",
],
format: "Integer, no strict limit",
example: "1000 characters (default)",
recommendation: "Use 1000-2000 for quick previews, 5000+ for comprehensive content analysis.",
},
summaryQuery: {
title: "Summary: Custom Query",
description: "Custom query to direct the LLM's generation of summaries. This helps get summaries focused on specific aspects of the content.",
whenToUse: [
"Need summaries focused on specific topics",
"Customizing summary content",
"Directing LLM attention to key aspects",
"Getting targeted insights",
],
format: "String query, e.g., 'Key advancements' or 'Main developments'",
example: "Key insights about artificial intelligence",
recommendation: "Use specific queries to get summaries focused on what you need. Leave empty for general summaries.",
},
};

View File

@@ -0,0 +1,302 @@
/**
* Detailed tooltips for Tavily search options
* These help educate end users about each Tavily option
*/
export const tavilyOptionTooltips = {
topic: {
title: "Search Topic",
description: "Choose the category of content you want to search. This helps Tavily optimize its search algorithm for your specific use case.",
options: {
general: {
title: "General - Broad searches",
description: "Best for general web searches, informational queries, and diverse content types. Use when you need a wide range of sources and perspectives.",
whenToUse: "Use for general research, educational content, or when you're not sure which topic category fits best.",
},
news: {
title: "News - Real-time updates",
description: "Optimized for recent news articles, current events, and time-sensitive information. Provides access to the latest developments and breaking news.",
whenToUse: "Use for current events, recent developments, breaking news, or when you need the most up-to-date information.",
},
finance: {
title: "Finance - Financial data",
description: "Focused on financial markets, economic data, company financials, stock information, and investment-related content.",
whenToUse: "Use for financial research, market analysis, company financials, economic trends, or investment information.",
},
}
},
searchDepth: {
title: "Search Depth",
description: "Controls the depth and quality of search results. Higher depth means better relevance but longer latency and higher cost.",
options: {
basic: {
title: "Basic - Balanced (1 credit)",
description: "Provides one NLP summary per URL. Good balance between relevance, speed, and cost. Recommended for most use cases.",
whenToUse: "Use for general research, quick searches, or when you need a good balance of quality and speed.",
latency: "Fast",
quality: "Good relevance",
cost: "1 credit per search",
},
advanced: {
title: "Advanced - Highest quality (2 credits)",
description: "Provides multiple semantic snippets per URL for the highest relevance. Best for comprehensive research and expert-level analysis.",
whenToUse: "Use for comprehensive research, expert analysis, or when you need the highest quality results. Enables chunks_per_source parameter.",
latency: "Slower",
quality: "Highest relevance",
cost: "2 credits per search",
note: "Allows chunks_per_source up to 3 for more detailed content per source",
},
fast: {
title: "Fast - Good quality, lower latency (1 credit)",
description: "Provides multiple semantic snippets per URL with optimized latency. Good quality with faster response times than advanced.",
whenToUse: "Use when you need good relevance with lower latency than advanced mode. Enables chunks_per_source parameter.",
latency: "Faster than advanced",
quality: "Good relevance",
cost: "1 credit per search",
note: "Allows chunks_per_source up to 3",
},
"ultra-fast": {
title: "Ultra-Fast - Minimal latency (1 credit)",
description: "Minimizes latency while providing one NLP summary per URL. Best for time-critical queries where speed is essential.",
whenToUse: "Use for time-critical queries, real-time applications, or when minimal latency is more important than maximum relevance.",
latency: "Minimal",
quality: "Good relevance",
cost: "1 credit per search",
},
}
},
includeAnswer: {
title: "AI Answer",
description: "Get an AI-generated answer summarizing the search results. This provides a quick overview of the findings without reading all sources.",
options: {
false: {
title: "Disabled",
description: "No AI-generated answer. You'll only get the raw search results.",
whenToUse: "Use when you want to analyze results yourself or when you don't need a summary.",
},
true: {
title: "Basic Answer",
description: "Quick AI-generated summary of the search results. Provides a concise overview.",
whenToUse: "Use for quick summaries or when you need a brief overview of findings.",
},
basic: {
title: "Basic Answer",
description: "Quick AI-generated summary of the search results. Provides a concise overview.",
whenToUse: "Use for quick summaries or when you need a brief overview of findings.",
},
advanced: {
title: "Advanced Answer",
description: "Detailed AI-generated answer with comprehensive insights from all search results. Best for in-depth analysis.",
whenToUse: "Use for comprehensive research, detailed analysis, or when you need thorough insights from all sources.",
},
}
},
timeRange: {
title: "Time Range",
description: "Filter results by recency. Use this to find recent content within a specific time period. Shorthand options (d, w, m, y) are supported.",
options: {
day: {
title: "Past Day",
description: "Results from the last 24 hours. Best for breaking news and very recent developments.",
whenToUse: "Use for breaking news, real-time updates, or very recent events.",
},
week: {
title: "Past Week",
description: "Results from the last 7 days. Good for recent news and current events.",
whenToUse: "Use for recent news, current events, or weekly updates.",
},
month: {
title: "Past Month",
description: "Results from the last 30 days. Good for recent trends and monthly updates.",
whenToUse: "Use for recent trends, monthly updates, or when you need relatively fresh content.",
},
year: {
title: "Past Year",
description: "Results from the last 12 months. Good for annual trends and yearly analysis.",
whenToUse: "Use for annual trends, yearly analysis, or when you need content from the past year.",
},
},
note: "For more precise date ranges, use Start Date and End Date fields instead.",
},
includeRawContent: {
title: "Raw Content Format",
description: "Include the raw HTML content from web pages. This provides full text content but may increase latency and response size.",
options: {
false: {
title: "Disabled",
description: "No raw content. You'll only get summaries and metadata.",
whenToUse: "Use when you only need summaries and don't need full page content.",
},
true: {
title: "Markdown Format",
description: "Full page content formatted as Markdown. Preserves structure and formatting.",
whenToUse: "Use when you need full page content with formatting preserved.",
},
markdown: {
title: "Markdown Format",
description: "Full page content formatted as Markdown. Preserves structure and formatting.",
whenToUse: "Use when you need full page content with formatting preserved.",
},
text: {
title: "Plain Text",
description: "Full page content as plain text. No formatting, just raw text content.",
whenToUse: "Use when you need full page content but don't need formatting.",
},
},
note: "Including raw content may increase latency and response size. Use only when necessary.",
},
includeDomains: {
title: "Include Domains",
description: "Restrict search results to specific domains. Only results from these domains will be returned. Useful for trusted sources or specific websites.",
examples: [
"nature.com - For scientific articles from Nature",
"arxiv.org - For academic papers from arXiv",
"github.com - For code repositories and technical content",
"news.ycombinator.com - For Hacker News discussions",
],
whenToUse: "Use when you want to search only specific trusted sources or websites. Maximum 300 domains.",
note: "Separate multiple domains with commas. Maximum 300 domains allowed.",
},
excludeDomains: {
title: "Exclude Domains",
description: "Exclude specific domains from search results. Results from these domains will be filtered out. Useful for avoiding spam or unwanted sources.",
examples: [
"spam.com - Exclude spam websites",
"ads.com - Exclude ad-heavy sites",
"example.com - Exclude specific unwanted sources",
],
whenToUse: "Use when you want to filter out specific domains or avoid unwanted sources. Maximum 150 domains.",
note: "Separate multiple domains with commas. Maximum 150 domains allowed.",
},
country: {
title: "Country Code",
description: "Boost results from a specific country. This helps prioritize content from a particular geographic region. Use lowercase full country name.",
examples: [
"united states - For US-focused results",
"united kingdom - For UK-focused results",
"india - For India-focused results",
"canada - For Canada-focused results",
],
whenToUse: "Use when you need region-specific content or want to prioritize results from a particular country.",
note: "Use lowercase full country name (e.g., 'united states' not 'US'). Works best with 'general' topic.",
},
startDate: {
title: "Start Date",
description: "Return only results published after this date. Provides precise date filtering for more accurate time-based searches.",
whenToUse: "Use when you need results from a specific date onwards. More precise than time_range.",
note: "Format: YYYY-MM-DD. Use with End Date to create a date range.",
},
endDate: {
title: "End Date",
description: "Return only results published before this date. Use with Start Date to create a precise date range.",
whenToUse: "Use when you need results up to a specific date. More precise than time_range.",
note: "Format: YYYY-MM-DD. Use with Start Date to create a date range.",
},
chunksPerSource: {
title: "Chunks Per Source",
description: "Number of content chunks to return per source. Higher values provide more detailed content from each source but may increase response size.",
whenToUse: "Use with 'advanced' or 'fast' search_depth to get more detailed content per source. Maximum is 3.",
note: "Only available with 'advanced' or 'fast' search_depth. Maximum is 3 chunks per source.",
range: "1-3 chunks per source",
},
includeImages: {
title: "Include Images",
description: "Include query-related images in the search results. Images are selected based on relevance to your search query.",
whenToUse: "Use when you need visual content related to your search query, such as charts, diagrams, or illustrations.",
note: "Enabling this may increase response size. Use include_image_descriptions for AI-generated image descriptions.",
},
includeImageDescriptions: {
title: "Include Image Descriptions",
description: "Include AI-generated descriptions of images. Provides context about what each image contains. Requires include_images to be enabled.",
whenToUse: "Use when you need to understand image content without viewing the images directly.",
note: "Requires 'Include Images' to be enabled. Provides AI-generated descriptions of image content.",
},
includeFavicon: {
title: "Include Favicon URLs",
description: "Include the favicon (website icon) URL for each search result. Useful for visual identification of sources.",
whenToUse: "Use when you want to display source icons or visually identify different sources in your UI.",
note: "Provides the favicon URL for each result, useful for UI display purposes.",
},
autoParameters: {
title: "Auto-Configure Parameters",
description: "Let Tavily automatically optimize search parameters based on your query. Uses AI to select the best settings for your specific search.",
whenToUse: "Use when you're unsure about optimal settings or want Tavily to automatically optimize for your query.",
note: "Costs 2 credits per search. Use sparingly as it's more expensive than manual configuration.",
},
};
/**
* Get tooltip content for a specific Tavily option
*/
export const getTavilyTooltipContent = (optionKey: keyof typeof tavilyOptionTooltips, value?: string): string => {
const tooltip = tavilyOptionTooltips[optionKey];
if (!tooltip) {
return `Information about ${optionKey}`;
}
// Handle options with nested value-specific tooltips
if ('options' in tooltip && value) {
const valueTooltip = (tooltip.options as any)[value];
if (valueTooltip) {
let content = `**${valueTooltip.title}**\n\n${valueTooltip.description}\n\n`;
if (valueTooltip.whenToUse) {
content += `**When to use:** ${valueTooltip.whenToUse}\n\n`;
}
if (valueTooltip.latency) {
content += `**Latency:** ${valueTooltip.latency}\n\n`;
}
if (valueTooltip.quality) {
content += `**Quality:** ${valueTooltip.quality}\n\n`;
}
if (valueTooltip.cost) {
content += `**Cost:** ${valueTooltip.cost}\n\n`;
}
if (valueTooltip.note) {
content += `**Note:** ${valueTooltip.note}`;
}
return content;
}
}
// Handle tooltips with examples
if ('examples' in tooltip && Array.isArray(tooltip.examples)) {
let content = `**${tooltip.title}**\n\n${tooltip.description}\n\n`;
if (tooltip.whenToUse) {
content += `**When to use:** ${tooltip.whenToUse}\n\n`;
}
content += `**Examples:**\n${tooltip.examples.map(ex => `${ex}`).join('\n')}\n\n`;
if (tooltip.note) {
content += `**Note:** ${tooltip.note}`;
}
return content;
}
// Default tooltip format
let content = `**${tooltip.title}**\n\n${tooltip.description}\n\n`;
if ('whenToUse' in tooltip && tooltip.whenToUse) {
content += `**When to use:** ${tooltip.whenToUse}\n\n`;
}
if ('note' in tooltip && tooltip.note) {
content += `**Note:** ${tooltip.note}`;
}
if ('range' in tooltip && tooltip.range) {
content += `**Range:** ${tooltip.range}`;
}
return content;
};

View File

@@ -5,7 +5,7 @@
import { useState, useEffect } from 'react';
import { getResearchConfig, ProviderAvailability } from '../../../../api/researchConfig';
import { WizardState } from '../../types/research.types';
import { ResearchProvider } from '../../../../services/blogWriterApi';
import { ResearchProvider } from '../../../../services/researchApi';
interface ResearchPersona {
research_angles?: string[];

View File

@@ -34,20 +34,20 @@ export const exaCategories = [
{ value: 'company', label: 'Company Profiles' },
{ value: 'research paper', label: 'Research Papers' },
{ value: 'news', label: 'News Articles' },
{ value: 'linkedin profile', label: 'LinkedIn Profiles' },
{ value: 'pdf', label: 'PDF Documents' },
{ value: 'github', label: 'GitHub Repos' },
{ value: 'tweet', label: 'Tweets' },
{ value: 'movie', label: 'Movies' },
{ value: 'song', label: 'Songs' },
{ value: 'personal site', label: 'Personal Sites' },
{ value: 'pdf', label: 'PDF Documents' },
{ value: 'linkedin profile', label: 'LinkedIn Profiles' },
{ value: 'financial report', label: 'Financial Reports' },
];
export const exaSearchTypes = [
{ value: 'auto', label: 'Auto - Let AI decide' },
{ value: 'keyword', label: 'Keyword - Precise matching' },
{ value: 'neural', label: 'Neural - Semantic search' },
{ value: 'auto', label: 'Auto (Default) - Best of all worlds' },
{ value: 'fast', label: 'Fast - <500ms, speed-critical' },
{ value: 'deep', label: 'Deep - ~5000ms, comprehensive research' },
{ value: 'neural', label: 'Neural - Embeddings-based semantic' },
{ value: 'keyword', label: 'Keyword - Traditional search' },
];
export const tavilyTopics = [

View File

@@ -79,40 +79,52 @@ export const getIndustryPlaceholders = (
const getIndustryDefaults = (industry: string): string[] => {
const industryExamples: Record<string, string[]> = {
Healthcare: [
"AI diagnostic tools and clinical applications",
"Telemedicine adoption and patient outcomes",
"Personalized medicine and genomic testing",
"Healthcare automation and workflow optimization"
"AI diagnostic tools: accuracy rates and clinical implementation",
"Telemedicine adoption statistics and patient satisfaction outcomes",
"Personalized medicine: genomic testing costs and benefits",
"Healthcare automation: workflow optimization case studies",
"Compare telehealth platforms: features, pricing, and ROI",
"Future of healthcare AI: predictions for 2025-2026"
],
Technology: [
"Edge computing and IoT deployment strategies",
"Cloud provider comparison and cost optimization",
"Quantum computing breakthroughs and applications",
"AI and machine learning industry trends"
"Latest AI advancements in multimodal content generation 2026",
"Compare cloud providers: AWS vs Azure vs GCP pricing and features",
"Edge computing deployment strategies and IoT best practices",
"Quantum computing breakthroughs and real-world applications",
"How to implement AI automation in small businesses",
"Future of AI: predictions and emerging opportunities 2025-2030"
],
Finance: [
"DeFi regulations and compliance strategies",
"Digital banking and customer retention",
"ESG investing trends and performance",
"Fintech innovations and market analysis"
"DeFi regulations: compliance strategies and risk management",
"Digital banking: customer retention tactics and ROI",
"ESG investing trends: performance metrics and market analysis",
"Fintech innovations: comparison of top payment platforms",
"Cryptocurrency adoption: statistics and future outlook 2025",
"How to choose the right financial software for small businesses"
],
Marketing: [
"AI marketing automation and personalization",
"Influencer marketing ROI and best practices",
"Privacy-first marketing in cookieless world",
"Content marketing strategies and trends"
"AI marketing automation tools: comparison and ROI analysis",
"Influencer marketing ROI: statistics and best practices 2025",
"Privacy-first marketing strategies in cookieless world",
"Content marketing trends: what works in 2025",
"How to measure marketing attribution and conversion rates",
"Social media marketing: platform comparison and audience insights"
],
Business: [
"Remote work policies and hybrid models",
"Supply chain resilience and diversification",
"Sustainability initiatives and ESG programs",
"Business automation and efficiency"
"Remote work policies: best practices and productivity metrics",
"Supply chain resilience: diversification strategies and case studies",
"Sustainability initiatives: ESG programs and ROI analysis",
"Business automation: tools comparison and implementation guides",
"How to scale a startup: funding strategies and growth tactics",
"Customer retention: strategies that work in 2025"
],
Education: [
"EdTech tools and personalized learning",
"Microlearning and skill-based education",
"AI tutoring systems and student support",
"Online learning platforms and outcomes"
"EdTech tools: comparison of top learning platforms",
"Microlearning: effectiveness statistics and best practices",
"AI tutoring systems: student outcomes and implementation",
"Online learning platforms: ROI and engagement metrics",
"How to create effective online courses: step-by-step guide",
"Future of education: predictions and emerging technologies"
],
'Real Estate': [
"PropTech innovations and property management",
@@ -128,12 +140,14 @@ const getIndustryDefaults = (industry: string): string[] => {
]
};
// Default placeholders - concise and actionable
// Default placeholders - diverse, actionable examples that inspire research
return industryExamples[industry] || [
"Latest AI trends and innovations",
"Best practices and case studies",
"Market analysis and competitor insights",
"Emerging technologies and future predictions"
"What are the latest trends in [your industry] for 2025-2026?",
"Compare top solutions: [solution A] vs [solution B]",
"Best practices and real-world case studies",
"Expert insights and statistics on [topic]",
"How to [achieve goal] - step-by-step guide",
"Future predictions and emerging opportunities"
];
};

View File

@@ -1,4 +1,4 @@
import { ResearchMode } from '../../../../services/blogWriterApi';
import { ResearchMode } from '../../../../services/researchApi';
/**
* Smart mode suggestion based on query complexity

View File

@@ -64,6 +64,7 @@ export interface ResearchIntent {
expected_deliverables: ExpectedDeliverable[];
depth: ResearchDepthLevel;
focus_areas: string[];
also_answering: string[];
perspective: string | null;
time_sensitivity: string | null;
input_type: InputType;
@@ -210,9 +211,13 @@ export interface SourceWithRelevance {
export interface AnalyzeIntentRequest {
user_input: string;
keywords: string[];
use_persona: boolean;
use_competitor_data: boolean;
keywords?: string[];
use_persona?: boolean;
use_competitor_data?: boolean;
// User-provided intent settings (optional - if provided, use these instead of inferring)
user_provided_purpose?: ResearchPurpose;
user_provided_content_output?: ContentOutput;
user_provided_depth?: ResearchDepthLevel;
}
// Optimized provider configuration with AI-driven justifications
@@ -231,10 +236,33 @@ export interface OptimizedConfig {
exa_num_results_justification?: string;
exa_date_filter?: string;
exa_date_justification?: string;
exa_end_published_date?: string;
exa_end_published_date_justification?: string;
exa_start_crawl_date?: string;
exa_start_crawl_date_justification?: string;
exa_end_crawl_date?: string;
exa_end_crawl_date_justification?: string;
exa_include_text?: string[];
exa_include_text_justification?: string;
exa_exclude_text?: string[];
exa_exclude_text_justification?: string;
exa_highlights?: boolean;
exa_highlights_justification?: string;
exa_context?: boolean;
exa_highlights_num_sentences?: number;
exa_highlights_num_sentences_justification?: string;
exa_highlights_per_url?: number;
exa_highlights_per_url_justification?: string;
exa_context?: boolean | { maxCharacters?: number };
exa_context_justification?: string;
exa_context_max_characters?: number;
exa_context_max_characters_justification?: string;
exa_text_max_characters?: number;
exa_text_max_characters_justification?: string;
exa_summary_query?: string;
exa_summary_query_justification?: string;
exa_additional_queries?: string[];
exa_additional_queries_justification?: string;
// Note: exa_search_type is mapped from exa_type in the backend
// Tavily settings with justifications
tavily_topic?: string;

View File

@@ -1,9 +1,12 @@
import { BlogResearchResponse, ResearchMode, ResearchConfig } from '../../../services/blogWriterApi';
import { ResearchResponse, ResearchMode, ResearchConfig } from '../../../services/researchApi';
import {
ResearchIntent,
AnalyzeIntentResponse,
IntentDrivenResearchResponse,
ResearchQuery,
ResearchPurpose,
ContentOutput,
ResearchDepthLevel,
} from './intent.types';
export interface WizardState {
@@ -13,7 +16,11 @@ export interface WizardState {
targetAudience: string;
researchMode: ResearchMode;
config: ResearchConfig;
results: BlogResearchResponse | null;
results: ResearchResponse | null;
// User-provided intent settings (optional, if not provided, AI will infer)
userPurpose?: ResearchPurpose;
userContentOutput?: ContentOutput;
userDepth?: ResearchDepthLevel;
}
export interface ResearchExecution {
@@ -34,7 +41,7 @@ export interface ResearchExecution {
confirmedIntent: ResearchIntent | null;
intentResult: IntentDrivenResearchResponse | null;
analyzeIntent: (state: WizardState) => Promise<AnalyzeIntentResponse | null>;
confirmIntent: (intent: ResearchIntent) => void;
confirmIntent: (intent: ResearchIntent, state?: WizardState) => void;
updateIntentField: <K extends keyof ResearchIntent>(field: K, value: ResearchIntent[K]) => void;
executeIntentResearch: (state: WizardState, selectedQueries?: ResearchQuery[]) => Promise<IntentDrivenResearchResponse | null>;
clearIntent: () => void;
@@ -49,13 +56,14 @@ export interface WizardStepProps {
}
export interface ResearchWizardProps {
onComplete?: (results: BlogResearchResponse) => void;
onComplete?: (results: ResearchResponse) => void;
onCancel?: () => void;
initialKeywords?: string[];
initialIndustry?: string;
initialTargetAudience?: string;
initialResearchMode?: ResearchMode;
initialConfig?: ResearchConfig;
initialResults?: ResearchResponse | null; // For restoring saved projects
}
export interface ModeCardInfo {

View File

@@ -0,0 +1,590 @@
import React, { useState, useEffect, useMemo, Suspense } from 'react';
import {
Card,
CardContent,
Typography,
Box,
Grid,
Chip,
Tooltip,
CircularProgress,
Tabs,
Tab,
Alert,
} from '@mui/material';
import { motion } from 'framer-motion';
import {
Clock,
Activity,
TrendingUp,
BarChart3,
Zap,
Calendar
} from 'lucide-react';
import {
LazyBarChart,
LazyPieChart,
Bar,
Pie,
Cell,
XAxis,
YAxis,
CartesianGrid,
Tooltip as RechartsTooltip,
ResponsiveContainer,
Legend,
ChartLoadingFallback
} from '../../utils/lazyRecharts';
// Types
import { UsageLog } from '../../types/billing';
// Services
import { billingService, formatCurrency, formatNumber } from '../../services/billingService';
// Components
import DailyCostHeatmap from './DailyCostHeatmap';
interface AdvancedCostAnalyticsProps {
userId?: string;
terminalTheme?: boolean;
}
interface TimeOfDayData {
hour: number;
label: string;
cost: number;
calls: number;
avgCostPerCall: number;
}
interface UserActionData {
endpoint: string;
action: string;
cost: number;
calls: number;
avgCostPerCall: number;
avgResponseTime: number;
}
interface EfficiencyMetric {
label: string;
value: number;
unit: string;
trend: 'up' | 'down' | 'stable';
description: string;
}
const AdvancedCostAnalytics: React.FC<AdvancedCostAnalyticsProps> = ({
userId,
terminalTheme = false
}) => {
const [usageLogs, setUsageLogs] = useState<UsageLog[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState(0);
useEffect(() => {
const fetchUsageLogs = async () => {
try {
setLoading(true);
setError(null);
const currentDate = new Date();
const billingPeriod = `${currentDate.getFullYear()}-${String(currentDate.getMonth() + 1).padStart(2, '0')}`;
console.log('[AdvancedCostAnalytics] Fetching usage logs for period:', billingPeriod);
// Try with billing period first, then without if no results
let response = await billingService.getUsageLogs(2000, 0, undefined, undefined, billingPeriod);
let logs = response.logs || [];
// If no logs for current period, try without billing period filter to get recent logs
if (logs.length === 0) {
console.log('[AdvancedCostAnalytics] No logs for current period, fetching all recent logs...');
response = await billingService.getUsageLogs(2000, 0);
logs = response.logs || [];
}
console.log('[AdvancedCostAnalytics] Received logs:', {
total: logs.length,
sample: logs.slice(0, 3),
totalCost: logs.reduce((sum, log) => sum + (log.cost_total || 0), 0),
totalCalls: logs.length,
logsWithCost: logs.filter(log => (log.cost_total || 0) > 0).length,
logsWithTokens: logs.filter(log => (log.tokens_total || 0) > 0).length,
sampleLogStructure: logs[0] ? {
id: logs[0].id,
cost_total: logs[0].cost_total,
tokens_total: logs[0].tokens_total,
status: logs[0].status,
status_code: logs[0].status_code,
response_time: logs[0].response_time
} : null
});
setUsageLogs(logs);
} catch (err) {
console.error('[AdvancedCostAnalytics] Error fetching usage logs:', err);
setError(err instanceof Error ? err.message : 'Failed to fetch usage logs');
// Set empty array on error to prevent showing stale data
setUsageLogs([]);
} finally {
setLoading(false);
}
};
fetchUsageLogs();
}, [userId]);
// Time of Day Analysis
const timeOfDayData = useMemo(() => {
const hourlyData: Record<number, { cost: number; calls: number }> = {};
usageLogs.forEach(log => {
// Include all logs, not just those with cost > 0, for accurate call counts
const cost = log.cost_total || 0;
try {
const date = new Date(log.timestamp);
if (isNaN(date.getTime())) {
console.warn('[AdvancedCostAnalytics] Invalid timestamp:', log.timestamp);
return;
}
const hour = date.getHours();
if (!hourlyData[hour]) {
hourlyData[hour] = { cost: 0, calls: 0 };
}
hourlyData[hour].cost += cost;
hourlyData[hour].calls += 1;
} catch (err) {
console.warn('[AdvancedCostAnalytics] Error processing log timestamp:', err, log);
}
});
return Array.from({ length: 24 }, (_, hour) => {
const data = hourlyData[hour] || { cost: 0, calls: 0 };
return {
hour,
label: `${hour.toString().padStart(2, '0')}:00`,
cost: data.cost,
calls: data.calls,
avgCostPerCall: data.calls > 0 ? data.cost / data.calls : 0
} as TimeOfDayData;
});
}, [usageLogs]);
// User Action Breakdown
const userActionData = useMemo(() => {
const actionMap: Record<string, { cost: number; calls: number; responseTime: number }> = {};
usageLogs.forEach(log => {
// Include all logs for accurate call counts
const cost = log.cost_total || 0;
// Extract action from endpoint (e.g., /api/blog-writer/generate -> blog-writer)
const endpoint = log.endpoint || '';
const endpointParts = endpoint.split('/');
const action = endpointParts.length > 2 ? endpointParts[2] : 'other';
if (!actionMap[action]) {
actionMap[action] = { cost: 0, calls: 0, responseTime: 0 };
}
actionMap[action].cost += cost;
actionMap[action].calls += 1;
actionMap[action].responseTime += log.response_time || 0;
});
return Object.entries(actionMap)
.map(([endpoint, data]) => ({
endpoint,
action: endpoint.replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase()),
cost: data.cost,
calls: data.calls,
avgCostPerCall: data.calls > 0 ? data.cost / data.calls : 0,
avgResponseTime: data.calls > 0 ? data.responseTime / data.calls : 0
} as UserActionData))
.sort((a, b) => b.cost - a.cost)
.slice(0, 10); // Top 10 actions
}, [usageLogs]);
// Cost Efficiency Metrics
const efficiencyMetrics = useMemo(() => {
const totalCost = usageLogs.reduce((sum, log) => sum + (log.cost_total || 0), 0);
const totalCalls = usageLogs.length;
const totalTokens = usageLogs.reduce((sum, log) => sum + (log.tokens_total || 0), 0);
const totalResponseTime = usageLogs.reduce((sum, log) => sum + (log.response_time || 0), 0);
const successfulCalls = usageLogs.filter(log => log.status === 'success' || (log.status_code && log.status_code < 400)).length;
const failedCalls = usageLogs.filter(log => log.status === 'failed' || (log.status_code && log.status_code >= 400)).length;
// Debug logging
if (usageLogs.length > 0) {
console.log('[AdvancedCostAnalytics] Efficiency metrics calculation:', {
totalLogs: usageLogs.length,
totalCost,
totalTokens,
totalCalls,
successfulCalls,
failedCalls,
sampleLog: usageLogs[0]
});
}
const avgCostPerCall = totalCalls > 0 ? totalCost / totalCalls : 0;
const avgCostPerToken = totalTokens > 0 ? totalCost / totalTokens : 0;
const avgResponseTime = totalCalls > 0 ? totalResponseTime / totalCalls : 0;
const successRate = totalCalls > 0 ? (successfulCalls / totalCalls) * 100 : 0;
// Tokens per dollar - handle division by zero
const costEfficiency = totalCost > 0 && totalTokens > 0 ? totalTokens / totalCost : 0;
// Calculate trends (simplified - compare first half vs second half)
const midPoint = Math.floor(usageLogs.length / 2);
const firstHalf = usageLogs.slice(0, midPoint);
const secondHalf = usageLogs.slice(midPoint);
const firstHalfAvgCost = firstHalf.length > 0
? firstHalf.reduce((sum, log) => sum + (log.cost_total || 0), 0) / firstHalf.length
: 0;
const secondHalfAvgCost = secondHalf.length > 0
? secondHalf.reduce((sum, log) => sum + (log.cost_total || 0), 0) / secondHalf.length
: 0;
const costTrend = secondHalfAvgCost > firstHalfAvgCost * 1.05 ? 'up' :
secondHalfAvgCost < firstHalfAvgCost * 0.95 ? 'down' : 'stable';
return [
{
label: 'Avg Cost per Call',
value: avgCostPerCall,
unit: '$',
trend: costTrend,
description: 'Average cost per API call'
},
{
label: 'Cost per 1K Tokens',
value: avgCostPerToken * 1000,
unit: '$',
trend: costTrend,
description: 'Cost efficiency for token usage'
},
{
label: 'Tokens per Dollar',
value: costEfficiency,
unit: '',
trend: costTrend === 'down' ? 'up' : costTrend === 'up' ? 'down' : 'stable',
description: 'How many tokens you get per dollar spent'
},
{
label: 'Avg Response Time',
value: avgResponseTime,
unit: 's',
trend: 'stable',
description: 'Average API response time'
},
{
label: 'Success Rate',
value: successRate,
unit: '%',
trend: successRate > 95 ? 'up' : successRate < 90 ? 'down' : 'stable',
description: 'Percentage of successful API calls'
},
{
label: 'Failed Calls',
value: failedCalls,
unit: '',
trend: failedCalls > 0 ? 'down' : 'stable',
description: 'Number of failed API calls'
}
] as EfficiencyMetric[];
}, [usageLogs]);
const COLORS = ['#667eea', '#764ba2', '#f093fb', '#4facfe', '#00f2fe', '#43e97b', '#fa709a', '#fee140', '#30cfd0', '#a8edea'];
if (loading) {
return (
<Card sx={{ p: 3, textAlign: 'center' }}>
<CircularProgress />
<Typography sx={{ mt: 2, color: 'rgba(255,255,255,0.7)' }}>
Loading analytics...
</Typography>
</Card>
);
}
if (error) {
return (
<Card sx={{ p: 3 }}>
<Alert severity="error">
{error}
<Typography variant="body2" sx={{ mt: 1, color: 'rgba(255,255,255,0.7)' }}>
Unable to load usage analytics. Please try refreshing the page.
</Typography>
</Alert>
</Card>
);
}
// Show message if no logs available
if (usageLogs.length === 0) {
return (
<Card sx={{ p: 3, textAlign: 'center' }}>
<Alert severity="info">
<Typography variant="h6" sx={{ mb: 1 }}>
No Usage Data Available
</Typography>
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.7)' }}>
Usage analytics will appear here once you start making API calls.
</Typography>
</Alert>
</Card>
);
}
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
<Card
sx={{
height: '100%',
background: 'linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.05) 100%)',
backdropFilter: 'blur(10px)',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: 3,
}}
>
<CardContent sx={{ pb: 1 }}>
<Typography variant="h6" sx={{ display: 'flex', alignItems: 'center', gap: 1, fontWeight: 'bold', color: '#ffffff', mb: 2 }}>
<BarChart3 size={20} />
Advanced Cost Analytics
</Typography>
<Tabs
value={activeTab}
onChange={(_, newValue) => setActiveTab(newValue)}
sx={{
borderBottom: '1px solid rgba(255,255,255,0.1)',
mb: 2,
'& .MuiTab-root': {
color: 'rgba(255,255,255,0.7)',
'&.Mui-selected': {
color: '#667eea',
fontWeight: 'bold'
}
},
'& .MuiTabs-indicator': {
backgroundColor: '#667eea'
}
}}
>
<Tab icon={<Clock size={16} />} label="Time of Day" iconPosition="start" />
<Tab icon={<Activity size={16} />} label="User Actions" iconPosition="start" />
<Tab icon={<Zap size={16} />} label="Efficiency Metrics" iconPosition="start" />
<Tab icon={<Calendar size={16} />} label="Daily Heatmap" iconPosition="start" />
</Tabs>
</CardContent>
<CardContent sx={{ pt: 0 }}>
{/* Time of Day Tab */}
{activeTab === 0 && (
<Box>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 'bold', color: '#ffffff' }}>
Cost Distribution by Hour of Day
</Typography>
<Box sx={{ height: 300, mb: 3 }}>
<ResponsiveContainer width="100%" height="100%">
<Suspense fallback={<ChartLoadingFallback />}>
<LazyBarChart data={timeOfDayData}>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.1)" />
<XAxis
dataKey="label"
stroke="rgba(255,255,255,0.9)"
fontSize={10}
angle={-45}
textAnchor="end"
height={60}
/>
<YAxis
stroke="rgba(255,255,255,0.9)"
fontSize={12}
tickFormatter={(value) => `$${value.toFixed(2)}`}
/>
<RechartsTooltip
contentStyle={{
backgroundColor: 'rgba(0, 0, 0, 0.8)',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: 8
}}
formatter={(value: number) => [formatCurrency(value), 'Cost']}
/>
<Bar dataKey="cost" fill="#667eea" radius={[4, 4, 0, 0]} />
</LazyBarChart>
</Suspense>
</ResponsiveContainer>
</Box>
{/* Peak Hours Summary */}
<Grid container spacing={2}>
{timeOfDayData
.sort((a, b) => b.cost - a.cost)
.slice(0, 3)
.map((data, idx) => (
<Grid item xs={4} key={data.hour}>
<Box sx={{ p: 1.5, backgroundColor: 'rgba(102, 126, 234, 0.1)', borderRadius: 1, textAlign: 'center' }}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.7)' }}>
Peak Hour {idx + 1}
</Typography>
<Typography variant="body2" sx={{ fontWeight: 'bold', color: '#667eea' }}>
{data.label}
</Typography>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.6)' }}>
{formatCurrency(data.cost)}
</Typography>
</Box>
</Grid>
))}
</Grid>
</Box>
)}
{/* User Actions Tab */}
{activeTab === 1 && (
<Box>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 'bold', color: '#ffffff' }}>
Cost Breakdown by User Action
</Typography>
<Box sx={{ height: 300, mb: 3 }}>
<ResponsiveContainer width="100%" height="100%">
<Suspense fallback={<ChartLoadingFallback />}>
<LazyPieChart>
<Pie
data={userActionData}
dataKey="cost"
nameKey="action"
cx="50%"
cy="50%"
outerRadius={100}
label={(entry: any) => {
const data = userActionData[entry.index];
return data ? `${data.action}: ${(entry.percent * 100).toFixed(0)}%` : '';
}}
>
{userActionData.map((_, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<RechartsTooltip
formatter={(value: number) => formatCurrency(value)}
contentStyle={{
backgroundColor: 'rgba(0, 0, 0, 0.8)',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: 8
}}
/>
</LazyPieChart>
</Suspense>
</ResponsiveContainer>
</Box>
{/* Top Actions Table */}
<Box sx={{ maxHeight: 200, overflowY: 'auto' }}>
{userActionData.map((action, idx) => (
<Box
key={action.endpoint}
sx={{
p: 1.5,
mb: 1,
backgroundColor: 'rgba(255,255,255,0.05)',
borderRadius: 1,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}
>
<Box sx={{ flex: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 'bold', color: '#ffffff' }}>
{action.action}
</Typography>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.6)' }}>
{action.calls} calls {formatCurrency(action.avgCostPerCall)} avg
</Typography>
</Box>
<Typography variant="h6" sx={{ fontWeight: 'bold', color: COLORS[idx % COLORS.length] }}>
{formatCurrency(action.cost)}
</Typography>
</Box>
))}
</Box>
</Box>
)}
{/* Efficiency Metrics Tab */}
{activeTab === 2 && (
<Box>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 'bold', color: '#ffffff' }}>
Cost Efficiency Metrics
</Typography>
<Grid container spacing={2}>
{efficiencyMetrics.map((metric, idx) => (
<Grid item xs={6} sm={4} key={metric.label}>
<Tooltip title={metric.description} arrow>
<Box
sx={{
p: 2,
backgroundColor: 'rgba(255,255,255,0.05)',
borderRadius: 2,
border: '1px solid rgba(255,255,255,0.1)',
textAlign: 'center',
cursor: 'help'
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 1, mb: 1 }}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.7)' }}>
{metric.label}
</Typography>
{metric.trend === 'up' && <TrendingUp size={14} color="#22c55e" />}
{metric.trend === 'down' && <TrendingUp size={14} color="#ef4444" style={{ transform: 'rotate(180deg)' }} />}
</Box>
<Typography
variant="h5"
sx={{
fontWeight: 'bold',
color: metric.trend === 'up' ? '#22c55e' :
metric.trend === 'down' ? '#ef4444' : '#ffffff'
}}
>
{metric.unit === '$' ? formatCurrency(metric.value) :
metric.unit === '%' ? `${metric.value.toFixed(1)}%` :
metric.unit === 's' ? `${metric.value.toFixed(2)}s` :
formatNumber(metric.value)}
</Typography>
</Box>
</Tooltip>
</Grid>
))}
</Grid>
</Box>
)}
{/* Daily Heatmap Tab */}
{activeTab === 3 && (
<Box>
<DailyCostHeatmap
usageLogs={usageLogs}
currentMonth={new Date().getMonth()}
currentYear={new Date().getFullYear()}
/>
</Box>
)}
</CardContent>
</Card>
</motion.div>
);
};
export default AdvancedCostAnalytics;

View File

@@ -1,350 +0,0 @@
import React, { useState, useEffect } from 'react';
import {
Box,
Container,
Grid,
Card,
CardContent,
Typography,
Alert,
CircularProgress,
useTheme,
useMediaQuery,
} from '@mui/material';
import { motion, AnimatePresence } from 'framer-motion';
import {
DollarSign,
TrendingUp,
AlertTriangle,
Activity,
Zap,
BarChart3,
PieChart,
Clock
} from 'lucide-react';
// Services
import { billingService } from '../../services/billingService';
import { monitoringService } from '../../services/monitoringService';
// Types
import { DashboardData, UsageStats } from '../../types/billing';
import { SystemHealth } from '../../types/monitoring';
// Components (we'll create these next)
import BillingOverview from './BillingOverview';
import CostBreakdown from './CostBreakdown';
import UsageTrends from './UsageTrends';
import SystemHealthIndicator from '../monitoring/SystemHealthIndicator';
import UsageAlerts from './UsageAlerts';
// Animation variants
const containerVariants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: {
duration: 0.6,
staggerChildren: 0.1
}
}
};
const cardVariants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.4 }
}
};
const BillingDashboard: React.FC = () => {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
// State management
const [dashboardData, setDashboardData] = useState<DashboardData | null>(null);
const [systemHealth, setSystemHealth] = useState<SystemHealth | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [lastUpdated, setLastUpdated] = useState<Date>(new Date());
// Fetch dashboard data
const fetchDashboardData = async () => {
try {
setLoading(true);
setError(null);
console.log('🔍 [DASHBOARD DEBUG] Starting data fetch...');
// Fetch billing and monitoring data in parallel
const [billingData, healthData] = await Promise.all([
billingService.getDashboardData(),
monitoringService.getSystemHealth()
]);
console.log('🔍 [DASHBOARD DEBUG] Received billing data:', billingData);
console.log('🔍 [DASHBOARD DEBUG] Received health data:', healthData);
console.log('🔍 [DASHBOARD DEBUG] Billing data current_usage:', billingData?.current_usage);
console.log('🔍 [DASHBOARD DEBUG] Billing data summary:', billingData?.summary);
console.log('🔍 [DASHBOARD DEBUG] Billing data trends:', billingData?.trends);
setDashboardData(billingData);
setSystemHealth(healthData);
setLastUpdated(new Date());
console.log('✅ [DASHBOARD DEBUG] Data set successfully');
} catch (err) {
console.error('❌ [DASHBOARD DEBUG] Error fetching dashboard data:', err);
setError(err instanceof Error ? err.message : 'Failed to load dashboard data');
} finally {
setLoading(false);
}
};
// Initial data fetch
useEffect(() => {
fetchDashboardData();
}, []);
// Auto-refresh every 30 seconds
useEffect(() => {
const interval = setInterval(() => {
fetchDashboardData();
}, 30000);
return () => clearInterval(interval);
}, []);
// Loading state
if (loading && !dashboardData) {
return (
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: '400px',
flexDirection: 'column',
gap: 2
}}
>
<CircularProgress size={48} />
<Typography variant="body1" color="text.secondary">
Loading billing dashboard...
</Typography>
</Box>
);
}
// Error state
if (error && !dashboardData) {
return (
<Box sx={{ p: 3 }}>
<Alert
severity="error"
action={
<motion.button
onClick={fetchDashboardData}
style={{
background: 'none',
border: 'none',
color: 'inherit',
cursor: 'pointer',
textDecoration: 'underline'
}}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
Retry
</motion.button>
}
>
{error}
</Alert>
</Box>
);
}
if (!dashboardData) {
return null;
}
return (
<motion.div
variants={containerVariants}
initial="hidden"
animate="visible"
>
<Container maxWidth="xl" sx={{ py: 4 }}>
{/* Section Header */}
<motion.div variants={cardVariants}>
<Box sx={{ mb: 4, textAlign: 'center' }}>
<Typography
variant="h4"
component="h2"
sx={{
fontWeight: 'bold',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
backgroundClip: 'text',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
mb: 1
}}
>
💰 Billing & Usage Dashboard
</Typography>
<Typography variant="body1" color="text.secondary">
Monitor your API usage, costs, and system performance in real-time
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
Last updated: {lastUpdated.toLocaleTimeString()}
</Typography>
</Box>
</motion.div>
{/* Main Dashboard Grid */}
<Grid container spacing={3}>
{/* Top Row - Overview Cards */}
<Grid item xs={12} md={4}>
<motion.div variants={cardVariants}>
<BillingOverview
usageStats={dashboardData.current_usage}
onRefresh={fetchDashboardData}
/>
</motion.div>
</Grid>
<Grid item xs={12} md={4}>
<motion.div variants={cardVariants}>
<SystemHealthIndicator
systemHealth={systemHealth}
onRefresh={fetchDashboardData}
/>
</motion.div>
</Grid>
<Grid item xs={12} md={4}>
<motion.div variants={cardVariants}>
<UsageAlerts
alerts={dashboardData.alerts}
onMarkRead={billingService.markAlertRead}
/>
</motion.div>
</Grid>
{/* Middle Row - Cost Breakdown */}
<Grid item xs={12} lg={6}>
<motion.div variants={cardVariants}>
<CostBreakdown
providerBreakdown={dashboardData.current_usage.provider_breakdown}
totalCost={dashboardData.current_usage.total_cost}
/>
</motion.div>
</Grid>
{/* Middle Row - Usage Trends */}
<Grid item xs={12} lg={6}>
<motion.div variants={cardVariants}>
<UsageTrends
trends={dashboardData.trends}
projections={dashboardData.projections}
/>
</motion.div>
</Grid>
{/* Bottom Row - Detailed Metrics */}
<Grid item xs={12}>
<motion.div variants={cardVariants}>
<Card
sx={{
background: 'linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.05) 100%)',
backdropFilter: 'blur(10px)',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: 3
}}
>
<CardContent>
<Typography variant="h6" sx={{ mb: 3, display: 'flex', alignItems: 'center', gap: 1 }}>
<BarChart3 size={20} />
Detailed Usage Metrics
</Typography>
<Grid container spacing={3}>
{/* Usage Summary */}
<Grid item xs={12} sm={6} md={3}>
<Box sx={{ textAlign: 'center' }}>
<Typography variant="h4" sx={{ color: 'primary.main', fontWeight: 'bold' }}>
{dashboardData.current_usage.total_calls.toLocaleString()}
</Typography>
<Typography variant="body2" color="text.secondary">
Total API Calls
</Typography>
<Typography variant="caption" color="text.secondary">
This month
</Typography>
</Box>
</Grid>
{/* Token Usage */}
<Grid item xs={12} sm={6} md={3}>
<Box sx={{ textAlign: 'center' }}>
<Typography variant="h4" sx={{ color: 'secondary.main', fontWeight: 'bold' }}>
{(dashboardData.current_usage.total_tokens / 1000).toFixed(1)}k
</Typography>
<Typography variant="body2" color="text.secondary">
Tokens Used
</Typography>
<Typography variant="caption" color="text.secondary">
This month
</Typography>
</Box>
</Grid>
{/* Average Response Time */}
<Grid item xs={12} sm={6} md={3}>
<Box sx={{ textAlign: 'center' }}>
<Typography variant="h4" sx={{ color: 'warning.main', fontWeight: 'bold' }}>
{dashboardData.current_usage.avg_response_time.toFixed(0)}ms
</Typography>
<Typography variant="body2" color="text.secondary">
Avg Response Time
</Typography>
<Typography variant="caption" color="text.secondary">
Last 24 hours
</Typography>
</Box>
</Grid>
{/* Error Rate */}
<Grid item xs={12} sm={6} md={3}>
<Box sx={{ textAlign: 'center' }}>
<Typography
variant="h4"
sx={{
color: dashboardData.current_usage.error_rate > 5 ? 'error.main' : 'success.main',
fontWeight: 'bold'
}}
>
{dashboardData.current_usage.error_rate.toFixed(2)}%
</Typography>
<Typography variant="body2" color="text.secondary">
Error Rate
</Typography>
<Typography variant="caption" color="text.secondary">
Last 24 hours
</Typography>
</Box>
</Grid>
</Grid>
</CardContent>
</Card>
</motion.div>
</Grid>
</Grid>
</Container>
</motion.div>
);
};
export default BillingDashboard;

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useState, useEffect } from 'react';
import {
Card,
CardContent,
@@ -28,6 +28,11 @@ import {
calculateUsagePercentage
} from '../../services/billingService';
// Shared Components
import { AnimatedNumber } from '../shared/AnimatedNumber';
import { AnimatedProgressBar } from '../shared/AnimatedProgressBar';
import LiveCostCounter from './LiveCostCounter';
// Terminal Theme
import {
TerminalCard,
@@ -55,14 +60,20 @@ const BillingOverview: React.FC<BillingOverviewProps> = ({
const CardComponent = terminalTheme ? TerminalCard : Card;
const CardContentComponent = terminalTheme ? TerminalCardContent : CardContent;
const TypographyComponent = terminalTheme ? TerminalTypography : Typography;
// Debug logs removed to reduce console noise
const [previousCost, setPreviousCost] = useState<number | undefined>(undefined);
// Track previous cost for velocity calculation
useEffect(() => {
if (usageStats.total_cost !== undefined) {
setPreviousCost(usageStats.total_cost);
}
}, [usageStats.total_cost]);
const costUsagePercentage = calculateUsagePercentage(
usageStats.total_cost,
usageStats.limits.limits.monthly_cost || 1
);
// Debug logs removed to reduce console noise
const getStatusChip = () => {
const status: string = usageStats.usage_status;
@@ -186,28 +197,16 @@ const BillingOverview: React.FC<BillingOverviewProps> = ({
</CardContentComponent>
<CardContentComponent sx={{ pt: 0 }}>
{/* Current Cost */}
<Box sx={{ mb: 3, textAlign: 'center' }}>
<motion.div
initial={{ scale: 0.8 }}
animate={{ scale: 1 }}
transition={{ duration: 0.5, delay: 0.2 }}
>
<TypographyComponent
variant="h3"
sx={{
fontWeight: 'bold',
color: terminalTheme ? terminalColors.text : '#ffffff',
textShadow: terminalTheme ? 'none' : '0 2px 4px rgba(0,0,0,0.3)',
mb: 1
}}
>
{formatCurrency(usageStats.total_cost)}
</TypographyComponent>
<TypographyComponent variant="body2" sx={{ color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.8)' }}>
Total Cost This Month
</TypographyComponent>
</motion.div>
{/* Live Cost Counter */}
<Box sx={{ mb: 3 }}>
<LiveCostCounter
currentCost={usageStats.total_cost}
previousCost={previousCost}
velocity={0} // TODO: Calculate from trends if available
terminalTheme={terminalTheme}
terminalColors={terminalColors}
TypographyComponent={TypographyComponent}
/>
</Box>
{/* Usage Metrics */}
@@ -218,7 +217,10 @@ const BillingOverview: React.FC<BillingOverviewProps> = ({
API Calls
</TypographyComponent>
<TypographyComponent variant="body2" sx={{ fontWeight: 'bold', color: terminalTheme ? terminalColors.text : '#ffffff' }}>
{formatNumber(usageStats.total_calls)}
<AnimatedNumber
value={usageStats.total_calls}
format={(n) => formatNumber(n)}
/>
</TypographyComponent>
</Box>
</Tooltip>
@@ -229,7 +231,10 @@ const BillingOverview: React.FC<BillingOverviewProps> = ({
Tokens Used
</TypographyComponent>
<TypographyComponent variant="body2" sx={{ fontWeight: 'bold', color: terminalTheme ? terminalColors.text : '#ffffff' }}>
{formatNumber(usageStats.total_tokens)}
<AnimatedNumber
value={usageStats.total_tokens}
format={(n) => formatNumber(n)}
/>
</TypographyComponent>
</Box>
</Tooltip>
@@ -257,22 +262,16 @@ const BillingOverview: React.FC<BillingOverviewProps> = ({
{formatPercentage(costUsagePercentage)}
</TypographyComponent>
</Box>
<LinearProgress
variant="determinate"
<AnimatedProgressBar
value={Math.min(costUsagePercentage, 100)}
sx={{
height: 8,
borderRadius: 4,
backgroundColor: terminalTheme ? terminalColors.backgroundLight : 'rgba(255,255,255,0.1)',
'& .MuiLinearProgress-bar': {
backgroundColor: terminalTheme
? (costUsagePercentage > 80 ? terminalColors.error :
costUsagePercentage > 60 ? terminalColors.warning : terminalColors.success)
: (costUsagePercentage > 80 ? '#ef4444' :
costUsagePercentage > 60 ? '#f59e0b' : '#22c55e'),
borderRadius: 4,
}
}}
height={8}
color={terminalTheme
? (costUsagePercentage > 80 ? terminalColors.error :
costUsagePercentage > 60 ? terminalColors.warning : terminalColors.success)
: (costUsagePercentage > 80 ? '#ef4444' :
costUsagePercentage > 60 ? '#f59e0b' : '#22c55e')
}
showPercentage={false}
/>
<TypographyComponent variant="caption" sx={{ mt: 0.5, display: 'block', color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.7)' }}>
{formatCurrency(usageStats.total_cost)} of {formatCurrency(usageStats.limits.limits.monthly_cost)} limit

View File

@@ -1,837 +1,6 @@
import React, { useState, useEffect } from 'react';
import {
Card,
CardContent,
Typography,
Box,
Grid,
Chip,
Tooltip,
LinearProgress,
IconButton,
} from '@mui/material';
import { motion } from 'framer-motion';
import {
AlertTriangle,
CheckCircle,
RefreshCw
} from 'lucide-react';
// Types
import { DashboardData } from '../../types/billing';
import { SystemHealth } from '../../types/monitoring';
// Services
import { billingService } from '../../services/billingService';
import { monitoringService } from '../../services/monitoringService';
import { onApiEvent } from '../../utils/apiEvents';
import { showToastNotification } from '../../utils/toastNotifications';
// Terminal Theme
import {
TerminalCard,
TerminalCardContent,
TerminalTypography,
TerminalChip,
TerminalChipError,
TerminalChipWarning,
terminalColors
} from '../SchedulerDashboard/terminalTheme';
interface CompactBillingDashboardProps {
userId?: string;
terminalTheme?: boolean;
}
const CompactBillingDashboard: React.FC<CompactBillingDashboardProps> = ({ userId, terminalTheme = false }) => {
// Conditional component selection based on terminal theme
const CardComponent = terminalTheme ? TerminalCard : Card;
const CardContentComponent = terminalTheme ? TerminalCardContent : CardContent;
const TypographyComponent = terminalTheme ? TerminalTypography : Typography;
const ChipComponent = terminalTheme ? TerminalChip : Chip;
const [dashboardData, setDashboardData] = useState<DashboardData | null>(null);
const [systemHealth, setSystemHealth] = useState<SystemHealth | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchData = async (showSuccessToast: boolean = false) => {
try {
setLoading(true);
setError(null);
const [billingData, healthData] = await Promise.all([
billingService.getDashboardData(userId),
monitoringService.getSystemHealth()
]);
setDashboardData(billingData);
setSystemHealth(healthData);
// Show success toast only if explicitly requested (user-initiated refresh)
if (showSuccessToast && billingData && healthData) {
showToastNotification(
'Billing data refreshed successfully',
'success',
{ duration: 3000 }
);
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch data';
setError(errorMessage);
// Always show error toast for failures
showToastNotification(errorMessage, 'error', { duration: 5000 });
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [userId]);
// Event-driven refresh
useEffect(() => {
const lastRefreshRef = { current: 0 } as { current: number };
const MIN_REFRESH_INTERVAL_MS = 4000;
const unsubscribe = onApiEvent((detail) => {
// Only react to non-billing/monitoring events to avoid feedback loops
if (detail.source && detail.source !== 'other') return;
const now = Date.now();
if (now - lastRefreshRef.current < MIN_REFRESH_INTERVAL_MS) return;
lastRefreshRef.current = now;
fetchData();
});
return unsubscribe;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const formatCurrency = (amount: number) => `$${amount.toFixed(4)}`;
const formatNumber = (num: number) => num.toLocaleString();
if (loading && !dashboardData) {
const loadingCardStyles = terminalTheme
? {
backgroundColor: terminalColors.background,
border: `1px solid ${terminalColors.border}`,
borderRadius: 3
}
: {
background: 'linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.05) 100%)',
backdropFilter: 'blur(10px)',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: 3
};
return (
<CardComponent sx={loadingCardStyles}>
<CardContentComponent sx={{ textAlign: 'center', py: 4 }}>
<TypographyComponent sx={{ color: terminalTheme ? terminalColors.text : 'rgba(255,255,255,0.8)' }}>
Loading billing data...
</TypographyComponent>
</CardContentComponent>
</CardComponent>
);
}
if (error) {
const errorCardStyles = terminalTheme
? {
backgroundColor: terminalColors.background,
border: `1px solid ${terminalColors.error}`,
borderRadius: 3
}
: {
background: 'linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.05) 100%)',
backdropFilter: 'blur(10px)',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: 3
};
return (
<CardComponent sx={errorCardStyles}>
<CardContentComponent sx={{ textAlign: 'center', py: 4 }}>
<TypographyComponent sx={{ color: terminalTheme ? terminalColors.error : '#ff6b6b' }}>
Error: {error}
</TypographyComponent>
<IconButton onClick={() => fetchData(true)} sx={{ mt: 1, color: terminalTheme ? terminalColors.text : 'inherit' }}>
<RefreshCw size={16} />
</IconButton>
</CardContentComponent>
</CardComponent>
);
}
if (!dashboardData) return null;
const { current_usage, limits, alerts } = dashboardData;
const mainCardStyles = terminalTheme
? {
backgroundColor: terminalColors.background,
border: `1px solid ${terminalColors.border}`,
borderRadius: 4,
position: 'relative' as const,
overflow: 'hidden' as const,
boxShadow: '0 0 15px rgba(0, 255, 0, 0.2)',
'&::before': {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '1px',
background: `linear-gradient(90deg, transparent, ${terminalColors.border}, transparent)`,
zIndex: 1
}
}
: {
background: 'linear-gradient(135deg, rgba(255,255,255,0.12) 0%, rgba(255,255,255,0.08) 100%)',
backdropFilter: 'blur(15px)',
border: '1px solid rgba(255,255,255,0.15)',
borderRadius: 4,
position: 'relative' as const,
overflow: 'hidden' as const,
boxShadow: '0 8px 32px rgba(0,0,0,0.2), 0 0 0 1px rgba(255,255,255,0.1)',
'&::before': {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '1px',
background: 'linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent)',
zIndex: 1
}
};
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
<CardComponent sx={mainCardStyles}>
{/* Header - Removed to save space */}
<CardContentComponent sx={{ pt: 2 }}>
{/* Compact Overview */}
<Grid container spacing={2} sx={{ mb: 2 }}>
{/* Total Cost */}
<Grid item xs={6} sm={3}>
<Tooltip
title={
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 'bold', mb: 0.5 }}>
Monthly API Usage Cost
</Typography>
<Typography variant="body2" sx={{ opacity: 0.9 }}>
Total spending across all AI providers this month
</Typography>
<Typography variant="caption" sx={{ opacity: 0.7, mt: 1, display: 'block' }}>
Includes: Gemini, OpenAI, Anthropic, Mistral
</Typography>
</Box>
}
arrow
placement="top"
>
<Box sx={{
textAlign: 'center',
p: 2.5,
...(terminalTheme ? {
backgroundColor: terminalColors.backgroundLight,
borderRadius: 3,
border: `1px solid ${terminalColors.border}`,
position: 'relative',
overflow: 'hidden',
cursor: 'help',
transition: 'all 0.3s ease',
'&:hover': {
transform: 'translateY(-2px)',
boxShadow: `0 0 15px ${terminalColors.border}40`,
borderColor: terminalColors.secondary
},
'&::before': {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '3px',
background: terminalColors.border,
zIndex: 1
}
} : {
background: 'linear-gradient(135deg, rgba(74, 222, 128, 0.12) 0%, rgba(34, 197, 94, 0.08) 100%)',
borderRadius: 3,
border: '1px solid rgba(74, 222, 128, 0.25)',
position: 'relative',
overflow: 'hidden',
cursor: 'help',
transition: 'all 0.3s ease',
'&:hover': {
transform: 'translateY(-2px)',
boxShadow: '0 8px 25px rgba(74, 222, 128, 0.2)',
border: '1px solid rgba(74, 222, 128, 0.4)'
},
'&::before': {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '3px',
background: 'linear-gradient(90deg, #4ade80, #22c55e)',
zIndex: 1
}
})
}}>
<TypographyComponent variant="h5" sx={{
fontWeight: 800,
color: terminalTheme ? terminalColors.text : '#ffffff',
textShadow: terminalTheme ? 'none' : '0 2px 8px rgba(0,0,0,0.4)',
mb: 0.5
}}>
{formatCurrency(current_usage.total_cost)}
</TypographyComponent>
<TypographyComponent variant="body2" sx={{
color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.9)',
fontWeight: 500,
textTransform: 'uppercase',
letterSpacing: '0.5px',
fontSize: '0.75rem'
}}>
Total Cost
</TypographyComponent>
</Box>
</Tooltip>
</Grid>
{/* API Calls */}
<Grid item xs={6} sm={3}>
<Tooltip
title={
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 'bold', mb: 0.5 }}>
API Request Volume
</Typography>
<Typography variant="body2" sx={{ opacity: 0.9 }}>
Total number of AI API requests made this month
</Typography>
<Typography variant="caption" sx={{ opacity: 0.7, mt: 1, display: 'block' }}>
Each request generates content, analyzes data, or processes information
</Typography>
</Box>
}
arrow
placement="top"
>
<Box sx={{
textAlign: 'center',
p: 2.5,
...(terminalTheme ? {
backgroundColor: terminalColors.backgroundLight,
borderRadius: 3,
border: `1px solid ${terminalColors.border}`,
position: 'relative',
overflow: 'hidden',
cursor: 'help',
transition: 'all 0.3s ease',
'&:hover': {
transform: 'translateY(-2px)',
boxShadow: `0 0 15px ${terminalColors.border}40`,
borderColor: terminalColors.secondary
},
'&::before': {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '3px',
background: terminalColors.border,
zIndex: 1
}
} : {
background: 'linear-gradient(135deg, rgba(59, 130, 246, 0.12) 0%, rgba(37, 99, 235, 0.08) 100%)',
borderRadius: 3,
border: '1px solid rgba(59, 130, 246, 0.25)',
position: 'relative',
overflow: 'hidden',
cursor: 'help',
transition: 'all 0.3s ease',
'&:hover': {
transform: 'translateY(-2px)',
boxShadow: '0 8px 25px rgba(59, 130, 246, 0.2)',
border: '1px solid rgba(59, 130, 246, 0.4)'
},
'&::before': {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '3px',
background: 'linear-gradient(90deg, #3b82f6, #2563eb)',
zIndex: 1
}
})
}}>
<TypographyComponent variant="h5" sx={{
fontWeight: 800,
color: terminalTheme ? terminalColors.text : '#ffffff',
textShadow: terminalTheme ? 'none' : '0 2px 8px rgba(0,0,0,0.4)',
mb: 0.5
}}>
{formatNumber(current_usage.total_calls)}
</TypographyComponent>
<TypographyComponent variant="body2" sx={{
color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.9)',
fontWeight: 500,
textTransform: 'uppercase',
letterSpacing: '0.5px',
fontSize: '0.75rem'
}}>
API Calls
</TypographyComponent>
</Box>
</Tooltip>
</Grid>
{/* Tokens */}
<Grid item xs={6} sm={3}>
<Tooltip
title={
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 'bold', mb: 0.5 }}>
AI Processing Units
</Typography>
<Typography variant="body2" sx={{ opacity: 0.9 }}>
Total tokens processed by AI models this month
</Typography>
<Typography variant="caption" sx={{ opacity: 0.7, mt: 1, display: 'block' }}>
Tokens represent words, characters, and data processed by AI
</Typography>
</Box>
}
arrow
placement="top"
>
<Box sx={{
textAlign: 'center',
p: 2.5,
...(terminalTheme ? {
backgroundColor: terminalColors.backgroundLight,
borderRadius: 3,
border: `1px solid ${terminalColors.border}`,
position: 'relative',
overflow: 'hidden',
cursor: 'help',
transition: 'all 0.3s ease',
'&:hover': {
transform: 'translateY(-2px)',
boxShadow: `0 0 15px ${terminalColors.border}40`,
borderColor: terminalColors.secondary
},
'&::before': {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '3px',
background: terminalColors.border,
zIndex: 1
}
} : {
background: 'linear-gradient(135deg, rgba(168, 85, 247, 0.12) 0%, rgba(147, 51, 234, 0.08) 100%)',
borderRadius: 3,
border: '1px solid rgba(168, 85, 247, 0.25)',
position: 'relative',
overflow: 'hidden',
cursor: 'help',
transition: 'all 0.3s ease',
'&:hover': {
transform: 'translateY(-2px)',
boxShadow: '0 8px 25px rgba(168, 85, 247, 0.2)',
border: '1px solid rgba(168, 85, 247, 0.4)'
},
'&::before': {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '3px',
background: 'linear-gradient(90deg, #a855f7, #9333ea)',
zIndex: 1
}
})
}}>
<TypographyComponent variant="h5" sx={{
fontWeight: 800,
color: terminalTheme ? terminalColors.text : '#ffffff',
textShadow: terminalTheme ? 'none' : '0 2px 8px rgba(0,0,0,0.4)',
mb: 0.5
}}>
{(current_usage.total_tokens / 1000).toFixed(1)}k
</TypographyComponent>
<TypographyComponent variant="body2" sx={{
color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.9)',
fontWeight: 500,
textTransform: 'uppercase',
letterSpacing: '0.5px',
fontSize: '0.75rem'
}}>
Tokens
</TypographyComponent>
</Box>
</Tooltip>
</Grid>
{/* System Health */}
<Grid item xs={6} sm={3}>
<Tooltip
title={
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 'bold', mb: 0.5 }}>
System Performance Status
</Typography>
<Typography variant="body2" sx={{ opacity: 0.9 }}>
Real-time monitoring of API services and system performance
</Typography>
<Typography variant="caption" sx={{ opacity: 0.7, mt: 1, display: 'block' }}>
{systemHealth?.status === 'healthy'
? 'All systems operational and responding normally'
: 'Some services may be experiencing issues'
}
</Typography>
</Box>
}
arrow
placement="top"
>
<Box sx={{
textAlign: 'center',
p: 2.5,
...(terminalTheme ? {
backgroundColor: terminalColors.backgroundLight,
borderRadius: 3,
border: `1px solid ${systemHealth?.status === 'healthy' ? terminalColors.success : terminalColors.error}`,
position: 'relative',
overflow: 'hidden',
cursor: 'help',
transition: 'all 0.3s ease',
'&:hover': {
transform: 'translateY(-2px)',
boxShadow: `0 0 15px ${systemHealth?.status === 'healthy' ? terminalColors.success : terminalColors.error}40`,
borderColor: systemHealth?.status === 'healthy' ? terminalColors.secondary : terminalColors.error
},
'&::before': {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '3px',
background: systemHealth?.status === 'healthy' ? terminalColors.success : terminalColors.error,
zIndex: 1
}
} : {
background: systemHealth?.status === 'healthy'
? 'linear-gradient(135deg, rgba(34, 197, 94, 0.12) 0%, rgba(22, 163, 74, 0.08) 100%)'
: 'linear-gradient(135deg, rgba(239, 68, 68, 0.12) 0%, rgba(220, 38, 38, 0.08) 100%)',
borderRadius: 3,
border: systemHealth?.status === 'healthy'
? '1px solid rgba(34, 197, 94, 0.25)'
: '1px solid rgba(239, 68, 68, 0.25)',
position: 'relative',
overflow: 'hidden',
cursor: 'help',
transition: 'all 0.3s ease',
'&:hover': {
transform: 'translateY(-2px)',
boxShadow: systemHealth?.status === 'healthy'
? '0 8px 25px rgba(34, 197, 94, 0.2)'
: '0 8px 25px rgba(239, 68, 68, 0.2)',
border: systemHealth?.status === 'healthy'
? '1px solid rgba(34, 197, 94, 0.4)'
: '1px solid rgba(239, 68, 68, 0.4)'
},
'&::before': {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '3px',
background: systemHealth?.status === 'healthy'
? 'linear-gradient(90deg, #22c55e, #16a34a)'
: 'linear-gradient(90deg, #ef4444, #dc2626)',
zIndex: 1
}
})
}}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 0.5, mb: 0.5 }}>
<CheckCircle size={18} color={terminalTheme
? (systemHealth?.status === 'healthy' ? terminalColors.success : terminalColors.error)
: (systemHealth?.status === 'healthy' ? '#4ade80' : '#ff6b6b')
} />
<TypographyComponent variant="body1" sx={{
color: terminalTheme
? (systemHealth?.status === 'healthy' ? terminalColors.success : terminalColors.error)
: (systemHealth?.status === 'healthy' ? '#4ade80' : '#ff6b6b'),
fontWeight: 700,
textTransform: 'capitalize',
textShadow: terminalTheme ? 'none' : '0 2px 4px rgba(0,0,0,0.3)'
}}>
{systemHealth?.status || 'Unknown'}
</TypographyComponent>
</Box>
<TypographyComponent variant="body2" sx={{
color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.9)',
fontWeight: 500,
textTransform: 'uppercase',
letterSpacing: '0.5px',
fontSize: '0.75rem'
}}>
System Health
</TypographyComponent>
</Box>
</Tooltip>
</Grid>
</Grid>
{/* Usage Progress */}
{limits.limits.monthly_cost > 0 && (
<Box sx={{
mb: 3,
p: 2.5,
...(terminalTheme ? {
backgroundColor: terminalColors.backgroundLight,
borderRadius: 3,
border: `1px solid ${terminalColors.border}`
} : {
background: 'linear-gradient(135deg, rgba(255,255,255,0.08) 0%, rgba(255,255,255,0.04) 100%)',
borderRadius: 3,
border: '1px solid rgba(255,255,255,0.1)'
})
}}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Box>
<TypographyComponent variant="subtitle2" sx={{
color: terminalTheme ? terminalColors.text : '#ffffff',
fontWeight: 600,
mb: 0.5
}}>
Monthly Budget Usage
</TypographyComponent>
<TypographyComponent variant="caption" sx={{
color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.7)',
display: 'block'
}}>
Track your AI spending against monthly limits
</TypographyComponent>
</Box>
<Box sx={{ textAlign: 'right' }}>
<TypographyComponent variant="h6" sx={{
color: terminalTheme ? terminalColors.text : '#ffffff',
fontWeight: 'bold',
textShadow: terminalTheme ? 'none' : '0 2px 4px rgba(0,0,0,0.3)'
}}>
{formatCurrency(current_usage.total_cost)}
</TypographyComponent>
<TypographyComponent variant="caption" sx={{
color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.7)',
display: 'block'
}}>
of {formatCurrency(limits.limits.monthly_cost)}
</TypographyComponent>
</Box>
</Box>
<LinearProgress
variant="determinate"
value={(current_usage.total_cost / limits.limits.monthly_cost) * 100}
sx={{
height: 8,
borderRadius: 4,
backgroundColor: terminalTheme ? terminalColors.backgroundLight : 'rgba(255,255,255,0.1)',
'& .MuiLinearProgress-bar': {
background: terminalTheme
? (current_usage.total_cost / limits.limits.monthly_cost > 0.8
? terminalColors.error
: current_usage.total_cost / limits.limits.monthly_cost > 0.6
? terminalColors.warning
: terminalColors.success)
: (current_usage.total_cost / limits.limits.monthly_cost > 0.8
? 'linear-gradient(90deg, #ff6b6b, #ff5252)'
: current_usage.total_cost / limits.limits.monthly_cost > 0.6
? 'linear-gradient(90deg, #ffa726, #ff9800)'
: 'linear-gradient(90deg, #4ade80, #22c55e)'),
borderRadius: 4,
boxShadow: terminalTheme ? 'none' : '0 2px 8px rgba(0,0,0,0.2)'
}
}}
/>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mt: 1 }}>
<TypographyComponent variant="caption" sx={{
color: terminalTheme
? (current_usage.total_cost / limits.limits.monthly_cost > 0.8 ? terminalColors.error : terminalColors.textSecondary)
: (current_usage.total_cost / limits.limits.monthly_cost > 0.8 ? '#ff6b6b' : 'rgba(255,255,255,0.7)'),
fontWeight: current_usage.total_cost / limits.limits.monthly_cost > 0.8 ? 600 : 400
}}>
{current_usage.total_cost / limits.limits.monthly_cost > 0.8
? '⚠️ Approaching limit'
: current_usage.total_cost / limits.limits.monthly_cost > 0.6
? '⚡ Moderate usage'
: '✅ Within budget'
}
</TypographyComponent>
<TypographyComponent variant="caption" sx={{
color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.7)',
fontWeight: 500
}}>
{((current_usage.total_cost / limits.limits.monthly_cost) * 100).toFixed(1)}% used
</TypographyComponent>
</Box>
</Box>
)}
{/* Alerts */}
{alerts.length > 0 && (
<Box sx={{
mb: 3,
p: 2.5,
...(terminalTheme ? {
backgroundColor: terminalColors.backgroundLight,
borderRadius: 3,
border: `1px solid ${terminalColors.error}`,
position: 'relative',
'&::before': {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '3px',
background: terminalColors.error,
borderRadius: '3px 3px 0 0'
}
} : {
background: 'linear-gradient(135deg, rgba(255, 107, 107, 0.1) 0%, rgba(239, 68, 68, 0.05) 100%)',
borderRadius: 3,
border: '1px solid rgba(255, 107, 107, 0.2)',
position: 'relative',
'&::before': {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '3px',
background: 'linear-gradient(90deg, #ff6b6b, #ef4444)',
borderRadius: '3px 3px 0 0'
}
})
}}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<AlertTriangle size={18} color={terminalTheme ? terminalColors.error : "#ff6b6b"} />
<TypographyComponent variant="subtitle2" sx={{
fontWeight: 700,
color: terminalTheme ? terminalColors.error : '#ff6b6b',
textShadow: terminalTheme ? 'none' : '0 1px 2px rgba(0,0,0,0.3)'
}}>
System Alerts ({alerts.length})
</TypographyComponent>
</Box>
<TypographyComponent variant="caption" sx={{
color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.8)',
display: 'block',
mb: 2
}}>
Important notifications requiring your attention
</TypographyComponent>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{alerts.slice(0, 3).map((alert) => (
<Tooltip
key={alert.id}
title={
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 'bold', mb: 0.5 }}>
{alert.title}
</Typography>
<Typography variant="body2" sx={{ opacity: 0.9 }}>
{alert.message}
</Typography>
</Box>
}
arrow
placement="top"
>
{terminalTheme ? (
<TerminalChipError
label={alert.title}
size="small"
icon={<AlertTriangle size={14} />}
sx={{
fontWeight: 500,
'&:hover': {
transform: 'translateY(-1px)'
},
transition: 'all 0.2s ease'
}}
/>
) : (
<Chip
label={alert.title}
size="small"
icon={<AlertTriangle size={14} />}
sx={{
backgroundColor: 'rgba(255, 107, 107, 0.2)',
color: '#ff6b6b',
border: '1px solid rgba(255, 107, 107, 0.3)',
fontWeight: 500,
'&:hover': {
backgroundColor: 'rgba(255, 107, 107, 0.3)',
transform: 'translateY(-1px)'
},
transition: 'all 0.2s ease'
}}
/>
)}
</Tooltip>
))}
{alerts.length > 3 && (
terminalTheme ? (
<TerminalChip
label={`+${alerts.length - 3} more`}
size="small"
sx={{ fontWeight: 500 }}
/>
) : (
<Chip
label={`+${alerts.length - 3} more`}
size="small"
sx={{
backgroundColor: 'rgba(255,255,255,0.1)',
color: 'rgba(255,255,255,0.8)',
border: '1px solid rgba(255,255,255,0.2)',
fontWeight: 500
}}
/>
)
)}
</Box>
</Box>
)}
</CardContentComponent>
</CardComponent>
</motion.div>
);
};
export default CompactBillingDashboard;
/**
* @deprecated This file has been refactored into a modular structure.
* Please import from './CompactBillingDashboard/index' instead.
* This file is kept for backward compatibility and will be removed in a future version.
*/
export { default } from './CompactBillingDashboard/index';

View File

@@ -0,0 +1,153 @@
import React from 'react';
import { Box, Tooltip } from '@mui/material';
import { AlertTriangle } from 'lucide-react';
import { Chip } from '@mui/material';
import { TerminalTypography, TerminalChip, TerminalChipError } from '../../../SchedulerDashboard/terminalTheme';
import { terminalColors } from '../../../SchedulerDashboard/terminalTheme';
import { DashboardData } from '../../../../types/billing';
interface AlertsSectionProps {
alerts: DashboardData['alerts'];
terminalTheme?: boolean;
TypographyComponent: typeof TerminalTypography | React.ComponentType<any>;
ChipComponent: typeof TerminalChip | typeof Chip;
}
/**
* AlertsSection - Displays system alerts
*/
export const AlertsSection: React.FC<AlertsSectionProps> = ({
alerts,
terminalTheme = false,
TypographyComponent,
ChipComponent
}) => {
if (alerts.length === 0) return null;
return (
<Box sx={{
mb: 3,
p: 2.5,
...(terminalTheme ? {
backgroundColor: terminalColors.backgroundLight,
borderRadius: 3,
border: `1px solid ${terminalColors.error}`,
position: 'relative',
'&::before': {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '3px',
background: terminalColors.error,
borderRadius: '3px 3px 0 0'
}
} : {
background: 'linear-gradient(135deg, rgba(255, 107, 107, 0.1) 0%, rgba(239, 68, 68, 0.05) 100%)',
borderRadius: 3,
border: '1px solid rgba(255, 107, 107, 0.2)',
position: 'relative',
'&::before': {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '3px',
background: 'linear-gradient(90deg, #ff6b6b, #ef4444)',
borderRadius: '3px 3px 0 0'
}
})
}}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<AlertTriangle size={18} color={terminalTheme ? terminalColors.error : "#ff6b6b"} />
<TypographyComponent variant="subtitle2" sx={{
fontWeight: 700,
color: terminalTheme ? terminalColors.error : '#ff6b6b',
textShadow: terminalTheme ? 'none' : '0 1px 2px rgba(0,0,0,0.3)'
}}>
System Alerts ({alerts.length})
</TypographyComponent>
</Box>
<TypographyComponent variant="caption" sx={{
color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.8)',
display: 'block',
mb: 2
}}>
Important notifications requiring your attention
</TypographyComponent>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{alerts.slice(0, 3).map((alert) => (
<Tooltip
key={alert.id}
title={
<Box>
<TypographyComponent variant="subtitle2" sx={{ fontWeight: 'bold', mb: 0.5 }}>
{alert.title}
</TypographyComponent>
<TypographyComponent variant="body2" sx={{ opacity: 0.9 }}>
{alert.message}
</TypographyComponent>
</Box>
}
arrow
placement="top"
>
{terminalTheme ? (
<TerminalChipError
label={alert.title}
size="small"
icon={<AlertTriangle size={14} />}
sx={{
fontWeight: 500,
'&:hover': {
transform: 'translateY(-1px)'
},
transition: 'all 0.2s ease'
}}
/>
) : (
<Chip
label={alert.title}
size="small"
icon={<AlertTriangle size={14} />}
sx={{
backgroundColor: 'rgba(255, 107, 107, 0.2)',
color: '#ff6b6b',
border: '1px solid rgba(255, 107, 107, 0.3)',
fontWeight: 500,
'&:hover': {
backgroundColor: 'rgba(255, 107, 107, 0.3)',
transform: 'translateY(-1px)'
},
transition: 'all 0.2s ease'
}}
/>
)}
</Tooltip>
))}
{alerts.length > 3 && (
terminalTheme ? (
<TerminalChip
label={`+${alerts.length - 3} more`}
size="small"
sx={{ fontWeight: 500 }}
/>
) : (
<Chip
label={`+${alerts.length - 3} more`}
size="small"
sx={{
backgroundColor: 'rgba(255,255,255,0.1)',
color: 'rgba(255,255,255,0.8)',
border: '1px solid rgba(255,255,255,0.2)',
fontWeight: 500
}}
/>
)
)}
</Box>
</Box>
);
};

View File

@@ -0,0 +1,254 @@
import React from 'react';
import { Grid, Box, Tooltip } from '@mui/material';
import { TerminalTypography } from '../../../SchedulerDashboard/terminalTheme';
import { terminalColors } from '../../../SchedulerDashboard/terminalTheme';
import { formatCurrency } from '../utils/formatting';
import { DashboardData } from '../../../../types/billing';
interface CostEfficiencyMetricsProps {
currentUsage: DashboardData['current_usage'];
terminalTheme?: boolean;
TypographyComponent: typeof TerminalTypography | React.ComponentType<any>;
}
/**
* CostEfficiencyMetrics - Displays cost efficiency metrics (Avg Cost/Call, Cost/1K Tokens, Efficiency Score)
*/
export const CostEfficiencyMetrics: React.FC<CostEfficiencyMetricsProps> = ({
currentUsage,
terminalTheme = false,
TypographyComponent
}) => {
if (currentUsage.total_calls === 0) return null;
const avgCostPerCall = currentUsage.total_cost / currentUsage.total_calls;
const costPer1KTokens = currentUsage.total_tokens > 0
? (currentUsage.total_cost / currentUsage.total_tokens) * 1000
: 0;
const getEfficiencyScore = () => {
if (avgCostPerCall < 0.01) return '⭐ Excellent';
if (avgCostPerCall < 0.05) return '✅ Good';
if (avgCostPerCall < 0.10) return '⚡ Fair';
return '⚠️ High';
};
return (
<Grid container spacing={2} sx={{ mb: 2 }}>
{/* Average Cost Per Call */}
<Grid item xs={6} sm={4}>
<Tooltip
title={
<Box>
<TypographyComponent variant="subtitle2" sx={{ fontWeight: 'bold', mb: 0.5 }}>
Average Cost Per API Call
</TypographyComponent>
<TypographyComponent variant="body2" sx={{ opacity: 0.9 }}>
Calculated as total cost divided by total API calls this month
</TypographyComponent>
<TypographyComponent variant="caption" sx={{ opacity: 0.7, mt: 1, display: 'block' }}>
Lower values indicate more cost-efficient usage
</TypographyComponent>
</Box>
}
arrow
placement="top"
>
<Box sx={{
textAlign: 'center',
p: 2,
...(terminalTheme ? {
backgroundColor: terminalColors.backgroundLight,
borderRadius: 3,
border: `1px solid ${terminalColors.border}`,
position: 'relative',
overflow: 'hidden',
cursor: 'help',
transition: 'all 0.3s ease',
'&:hover': {
transform: 'translateY(-2px)',
boxShadow: `0 0 10px ${terminalColors.border}30`,
borderColor: terminalColors.secondary
}
} : {
background: 'linear-gradient(135deg, rgba(99, 102, 241, 0.12) 0%, rgba(79, 70, 229, 0.08) 100%)',
borderRadius: 3,
border: '1px solid rgba(99, 102, 241, 0.25)',
position: 'relative',
overflow: 'hidden',
cursor: 'help',
transition: 'all 0.3s ease',
'&:hover': {
transform: 'translateY(-2px)',
boxShadow: '0 6px 20px rgba(99, 102, 241, 0.2)',
border: '1px solid rgba(99, 102, 241, 0.4)'
}
})
}}>
<TypographyComponent variant="h6" sx={{
fontWeight: 700,
color: terminalTheme ? terminalColors.text : '#ffffff',
textShadow: terminalTheme ? 'none' : '0 2px 4px rgba(0,0,0,0.3)',
mb: 0.5
}}>
{formatCurrency(avgCostPerCall)}
</TypographyComponent>
<TypographyComponent variant="body2" sx={{
color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.9)',
fontWeight: 500,
textTransform: 'uppercase',
letterSpacing: '0.5px',
fontSize: '0.7rem'
}}>
Avg Cost/Call
</TypographyComponent>
</Box>
</Tooltip>
</Grid>
{/* Cost Per 1K Tokens */}
{currentUsage.total_tokens > 0 && (
<Grid item xs={6} sm={4}>
<Tooltip
title={
<Box>
<TypographyComponent variant="subtitle2" sx={{ fontWeight: 'bold', mb: 0.5 }}>
Cost Per 1,000 Tokens
</TypographyComponent>
<TypographyComponent variant="body2" sx={{ opacity: 0.9 }}>
Average cost for processing 1,000 tokens (input + output)
</TypographyComponent>
<TypographyComponent variant="caption" sx={{ opacity: 0.7, mt: 1, display: 'block' }}>
Useful for estimating costs of future operations
</TypographyComponent>
</Box>
}
arrow
placement="top"
>
<Box sx={{
textAlign: 'center',
p: 2,
...(terminalTheme ? {
backgroundColor: terminalColors.backgroundLight,
borderRadius: 3,
border: `1px solid ${terminalColors.border}`,
position: 'relative',
overflow: 'hidden',
cursor: 'help',
transition: 'all 0.3s ease',
'&:hover': {
transform: 'translateY(-2px)',
boxShadow: `0 0 10px ${terminalColors.border}30`,
borderColor: terminalColors.secondary
}
} : {
background: 'linear-gradient(135deg, rgba(168, 85, 247, 0.12) 0%, rgba(147, 51, 234, 0.08) 100%)',
borderRadius: 3,
border: '1px solid rgba(168, 85, 247, 0.25)',
position: 'relative',
overflow: 'hidden',
cursor: 'help',
transition: 'all 0.3s ease',
'&:hover': {
transform: 'translateY(-2px)',
boxShadow: '0 6px 20px rgba(168, 85, 247, 0.2)',
border: '1px solid rgba(168, 85, 247, 0.4)'
}
})
}}>
<TypographyComponent variant="h6" sx={{
fontWeight: 700,
color: terminalTheme ? terminalColors.text : '#ffffff',
textShadow: terminalTheme ? 'none' : '0 2px 4px rgba(0,0,0,0.3)',
mb: 0.5
}}>
{formatCurrency(costPer1KTokens)}
</TypographyComponent>
<TypographyComponent variant="body2" sx={{
color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.9)',
fontWeight: 500,
textTransform: 'uppercase',
letterSpacing: '0.5px',
fontSize: '0.7rem'
}}>
Cost/1K Tokens
</TypographyComponent>
</Box>
</Tooltip>
</Grid>
)}
{/* Cost Efficiency Score */}
<Grid item xs={6} sm={4}>
<Tooltip
title={
<Box>
<TypographyComponent variant="subtitle2" sx={{ fontWeight: 'bold', mb: 0.5 }}>
Cost Efficiency Indicator
</TypographyComponent>
<TypographyComponent variant="body2" sx={{ opacity: 0.9 }}>
Based on average cost per call and token usage
</TypographyComponent>
<TypographyComponent variant="caption" sx={{ opacity: 0.7, mt: 1, display: 'block' }}>
Lower cost per call = Higher efficiency
</TypographyComponent>
</Box>
}
arrow
placement="top"
>
<Box sx={{
textAlign: 'center',
p: 2,
...(terminalTheme ? {
backgroundColor: terminalColors.backgroundLight,
borderRadius: 3,
border: `1px solid ${terminalColors.border}`,
position: 'relative',
overflow: 'hidden',
cursor: 'help',
transition: 'all 0.3s ease',
'&:hover': {
transform: 'translateY(-2px)',
boxShadow: `0 0 10px ${terminalColors.border}30`,
borderColor: terminalColors.secondary
}
} : {
background: 'linear-gradient(135deg, rgba(34, 197, 94, 0.12) 0%, rgba(22, 163, 74, 0.08) 100%)',
borderRadius: 3,
border: '1px solid rgba(34, 197, 94, 0.25)',
position: 'relative',
overflow: 'hidden',
cursor: 'help',
transition: 'all 0.3s ease',
'&:hover': {
transform: 'translateY(-2px)',
boxShadow: '0 6px 20px rgba(34, 197, 94, 0.2)',
border: '1px solid rgba(34, 197, 94, 0.4)'
}
})
}}>
<TypographyComponent variant="h6" sx={{
fontWeight: 700,
color: terminalTheme ? terminalColors.text : '#ffffff',
textShadow: terminalTheme ? 'none' : '0 2px 4px rgba(0,0,0,0.3)',
mb: 0.5
}}>
{getEfficiencyScore()}
</TypographyComponent>
<TypographyComponent variant="body2" sx={{
color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.9)',
fontWeight: 500,
textTransform: 'uppercase',
letterSpacing: '0.5px',
fontSize: '0.7rem'
}}>
Efficiency
</TypographyComponent>
</Box>
</Tooltip>
</Grid>
</Grid>
);
};

View File

@@ -0,0 +1,59 @@
import React from 'react';
import { Box, Tooltip, IconButton } from '@mui/material';
import { RefreshCw } from 'lucide-react';
import { TerminalTypography } from '../../../SchedulerDashboard/terminalTheme';
import { terminalColors } from '../../../SchedulerDashboard/terminalTheme';
interface DashboardHeaderProps {
lastRefreshTime: Date | null;
onRefresh: () => void;
loading: boolean;
terminalTheme?: boolean;
TypographyComponent: typeof TerminalTypography | React.ComponentType<any>;
}
/**
* DashboardHeader - Displays last refresh time and refresh button
*/
export const DashboardHeader: React.FC<DashboardHeaderProps> = ({
lastRefreshTime,
onRefresh,
loading,
terminalTheme = false,
TypographyComponent
}) => {
return (
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
{lastRefreshTime && (
<Tooltip title={`Data last refreshed at ${lastRefreshTime.toLocaleTimeString()}`}>
<TypographyComponent
variant="caption"
sx={{
color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.6)',
fontSize: '0.7rem',
fontStyle: 'italic'
}}
>
Last updated: {lastRefreshTime.toLocaleTimeString()}
</TypographyComponent>
</Tooltip>
)}
<Tooltip title="Refresh billing data">
<IconButton
size="small"
onClick={onRefresh}
disabled={loading}
sx={{
color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.7)',
'&:hover': {
color: terminalTheme ? terminalColors.text : '#ffffff',
backgroundColor: terminalTheme ? terminalColors.backgroundLight : 'rgba(255,255,255,0.1)'
}
}}
>
<RefreshCw size={14} className={loading ? 'animate-spin' : ''} />
</IconButton>
</Tooltip>
</Box>
);
};

View File

@@ -0,0 +1,281 @@
import React from 'react';
import { Grid, Box, Tooltip, Typography } from '@mui/material';
import { motion } from 'framer-motion';
import { CheckCircle } from 'lucide-react';
import { TerminalTypography } from '../../../SchedulerDashboard/terminalTheme';
import { terminalColors } from '../../../SchedulerDashboard/terminalTheme';
import { MetricCard } from './MetricCard';
import { formatCurrency, formatNumber } from '../utils/formatting';
import { DashboardData } from '../../../../types/billing';
import { SystemHealth } from '../../../../types/monitoring';
interface MainMetricsGridProps {
currentUsage: DashboardData['current_usage'];
systemHealth: SystemHealth | null;
healthError: string | null;
sparklineData: {
cost: Array<{ date: string; value: number }>;
calls: Array<{ date: string; value: number }>;
tokens: Array<{ date: string; value: number }>;
};
terminalTheme?: boolean;
TypographyComponent: typeof TerminalTypography | React.ComponentType<any>;
}
/**
* MainMetricsGrid - Displays the 4 main metric cards (Cost, Calls, Tokens, System Health)
*/
export const MainMetricsGrid: React.FC<MainMetricsGridProps> = ({
currentUsage,
systemHealth,
healthError,
sparklineData,
terminalTheme = false,
TypographyComponent
}) => {
return (
<Grid container spacing={2} sx={{ mb: 2 }}>
{/* Total Cost */}
<Grid item xs={6} sm={3}>
<MetricCard
title="Total Cost"
value={currentUsage.total_cost}
formatValue={formatCurrency}
decimals={4}
tooltip={
<Box>
<TypographyComponent variant="subtitle2" sx={{ fontWeight: 'bold', mb: 0.5 }}>
Monthly API Usage Cost
</TypographyComponent>
<TypographyComponent variant="body2" sx={{ opacity: 0.9 }}>
Total spending across all AI providers this month
</TypographyComponent>
<TypographyComponent variant="caption" sx={{ opacity: 0.7, mt: 1, display: 'block' }}>
Includes: Gemini, OpenAI, Anthropic, Mistral
</TypographyComponent>
</Box>
}
sparklineData={sparklineData.cost}
sparklineColor={terminalTheme ? terminalColors.success : '#4ade80'}
sparklineFormatValue={formatCurrency}
sparklineLabel="Cost"
gradientColors={{
start: 'rgba(74, 222, 128, 0.12)',
end: 'rgba(34, 197, 94, 0.08)',
border: 'rgba(74, 222, 128, 0.25)',
hoverBorder: 'rgba(74, 222, 128, 0.4)',
topBar: 'linear-gradient(90deg, #4ade80, #22c55e)'
}}
animationDelay={0}
terminalTheme={terminalTheme}
TypographyComponent={TypographyComponent}
/>
</Grid>
{/* API Calls */}
<Grid item xs={6} sm={3}>
<MetricCard
title="API Calls"
value={currentUsage.total_calls}
formatValue={formatNumber}
tooltip={
<Box>
<TypographyComponent variant="subtitle2" sx={{ fontWeight: 'bold', mb: 0.5 }}>
API Request Volume
</TypographyComponent>
<TypographyComponent variant="body2" sx={{ opacity: 0.9 }}>
Total number of AI API requests made this month
</TypographyComponent>
<TypographyComponent variant="caption" sx={{ opacity: 0.7, mt: 1, display: 'block' }}>
Each request generates content, analyzes data, or processes information
</TypographyComponent>
</Box>
}
sparklineData={sparklineData.calls}
sparklineColor={terminalTheme ? terminalColors.secondary : '#3b82f6'}
sparklineFormatValue={formatNumber}
sparklineLabel="API Calls"
gradientColors={{
start: 'rgba(59, 130, 246, 0.12)',
end: 'rgba(37, 99, 235, 0.08)',
border: 'rgba(59, 130, 246, 0.25)',
hoverBorder: 'rgba(59, 130, 246, 0.4)',
topBar: 'linear-gradient(90deg, #3b82f6, #2563eb)'
}}
animationDelay={0.1}
terminalTheme={terminalTheme}
TypographyComponent={TypographyComponent}
/>
</Grid>
{/* Tokens */}
<Grid item xs={6} sm={3}>
<MetricCard
title="Tokens"
value={currentUsage.total_tokens / 1000}
formatValue={(n) => `${n.toFixed(1)}k`}
decimals={1}
tooltip={
<Box>
<TypographyComponent variant="subtitle2" sx={{ fontWeight: 'bold', mb: 0.5 }}>
AI Processing Units
</TypographyComponent>
<TypographyComponent variant="body2" sx={{ opacity: 0.9 }}>
Total tokens processed by AI models this month
</TypographyComponent>
<TypographyComponent variant="caption" sx={{ opacity: 0.7, mt: 1, display: 'block' }}>
Tokens represent words, characters, and data processed by AI
</TypographyComponent>
</Box>
}
sparklineData={sparklineData.tokens}
sparklineColor={terminalTheme ? terminalColors.warning : '#a855f7'}
sparklineFormatValue={(n) => `${n.toFixed(1)}k`}
sparklineLabel="Tokens (k)"
gradientColors={{
start: 'rgba(168, 85, 247, 0.12)',
end: 'rgba(147, 51, 234, 0.08)',
border: 'rgba(168, 85, 247, 0.25)',
hoverBorder: 'rgba(168, 85, 247, 0.4)',
topBar: 'linear-gradient(90deg, #a855f7, #9333ea)'
}}
animationDelay={0.2}
terminalTheme={terminalTheme}
TypographyComponent={TypographyComponent}
/>
</Grid>
{/* System Health */}
<Grid item xs={6} sm={3}>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3, duration: 0.4, ease: "easeOut" }}
>
<Tooltip
title={
<Box>
<TypographyComponent variant="subtitle2" sx={{ fontWeight: 'bold', mb: 0.5 }}>
System Performance Status
</TypographyComponent>
<TypographyComponent variant="body2" sx={{ opacity: 0.9 }}>
Real-time monitoring of API services and system performance
</TypographyComponent>
{healthError && (
<TypographyComponent variant="caption" sx={{ color: '#ff9800', mt: 1, display: 'block', fontWeight: 'bold' }}>
Showing last known values - Unable to fetch latest data
</TypographyComponent>
)}
<TypographyComponent variant="caption" sx={{ opacity: 0.7, mt: 1, display: 'block' }}>
{systemHealth?.status === 'healthy'
? 'All systems operational and responding normally'
: systemHealth?.status === 'warning'
? 'Some services may be experiencing issues'
: systemHealth?.status === 'critical'
? 'Critical issues detected'
: 'Status unknown'
}
</TypographyComponent>
{systemHealth?.timestamp && (
<TypographyComponent variant="caption" sx={{ opacity: 0.6, mt: 0.5, display: 'block', fontSize: '0.65rem' }}>
Last updated: {new Date(systemHealth.timestamp).toLocaleTimeString()}
</TypographyComponent>
)}
</Box>
}
arrow
placement="top"
>
<Box sx={{
textAlign: 'center',
p: 2.5,
...(terminalTheme ? {
backgroundColor: terminalColors.backgroundLight,
borderRadius: 3,
border: `1px solid ${systemHealth?.status === 'healthy' ? terminalColors.success : terminalColors.error}`,
position: 'relative',
overflow: 'hidden',
cursor: 'help',
transition: 'all 0.3s ease',
'&:hover': {
transform: 'translateY(-2px)',
boxShadow: `0 0 15px ${systemHealth?.status === 'healthy' ? terminalColors.success : terminalColors.error}40`,
borderColor: systemHealth?.status === 'healthy' ? terminalColors.secondary : terminalColors.error
},
'&::before': {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '3px',
background: systemHealth?.status === 'healthy' ? terminalColors.success : terminalColors.error,
zIndex: 1
}
} : {
background: systemHealth?.status === 'healthy'
? 'linear-gradient(135deg, rgba(34, 197, 94, 0.12) 0%, rgba(22, 163, 74, 0.08) 100%)'
: 'linear-gradient(135deg, rgba(239, 68, 68, 0.12) 0%, rgba(220, 38, 38, 0.08) 100%)',
borderRadius: 3,
border: systemHealth?.status === 'healthy'
? '1px solid rgba(34, 197, 94, 0.25)'
: '1px solid rgba(239, 68, 68, 0.25)',
position: 'relative',
overflow: 'hidden',
cursor: 'help',
transition: 'all 0.3s ease',
'&:hover': {
transform: 'translateY(-2px)',
boxShadow: systemHealth?.status === 'healthy'
? '0 8px 25px rgba(34, 197, 94, 0.2)'
: '0 8px 25px rgba(239, 68, 68, 0.2)',
border: systemHealth?.status === 'healthy'
? '1px solid rgba(34, 197, 94, 0.4)'
: '1px solid rgba(239, 68, 68, 0.4)'
},
'&::before': {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '3px',
background: systemHealth?.status === 'healthy'
? 'linear-gradient(90deg, #22c55e, #16a34a)'
: 'linear-gradient(90deg, #ef4444, #dc2626)',
zIndex: 1
}
})
}}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 0.5, mb: 0.5 }}>
<CheckCircle size={18} color={terminalTheme
? (systemHealth?.status === 'healthy' ? terminalColors.success : terminalColors.error)
: (systemHealth?.status === 'healthy' ? '#4ade80' : '#ff6b6b')
} />
<TypographyComponent variant="body1" sx={{
color: terminalTheme
? (systemHealth?.status === 'healthy' ? terminalColors.success : terminalColors.error)
: (systemHealth?.status === 'healthy' ? '#4ade80' : '#ff6b6b'),
fontWeight: 700,
textTransform: 'capitalize',
textShadow: terminalTheme ? 'none' : '0 2px 4px rgba(0,0,0,0.3)'
}}>
{systemHealth?.status || 'Unknown'}
</TypographyComponent>
</Box>
<TypographyComponent variant="body2" sx={{
color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.9)',
fontWeight: 500,
textTransform: 'uppercase',
letterSpacing: '0.5px',
fontSize: '0.75rem'
}}>
System Health
</TypographyComponent>
</Box>
</Tooltip>
</motion.div>
</Grid>
</Grid>
);
};

View File

@@ -0,0 +1,145 @@
import React from 'react';
import { Box, Tooltip } from '@mui/material';
import { motion } from 'framer-motion';
import { TerminalTypography } from '../../../SchedulerDashboard/terminalTheme';
import { terminalColors } from '../../../SchedulerDashboard/terminalTheme';
import { AnimatedNumber } from '../../../shared/AnimatedNumber';
import { MiniSparkline } from '../../../shared/MiniSparkline';
interface MetricCardProps {
title: string;
value: number;
formatValue: (n: number) => string;
decimals?: number;
tooltip: React.ReactNode;
sparklineData?: Array<{ date: string; value: number }>;
sparklineColor?: string;
sparklineFormatValue?: (n: number) => string;
sparklineLabel?: string;
gradientColors: {
start: string;
end: string;
border: string;
hoverBorder: string;
topBar: string;
};
animationDelay?: number;
terminalTheme?: boolean;
TypographyComponent: typeof TerminalTypography | React.ComponentType<any>;
}
/**
* MetricCard - Reusable metric card component for displaying key metrics
*/
export const MetricCard: React.FC<MetricCardProps> = ({
title,
value,
formatValue,
decimals = 0,
tooltip,
sparklineData,
sparklineColor,
sparklineFormatValue,
sparklineLabel,
gradientColors,
animationDelay = 0,
terminalTheme = false,
TypographyComponent
}) => {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: animationDelay, duration: 0.4, ease: "easeOut" }}
>
<Tooltip
title={tooltip}
arrow
placement="top"
>
<Box sx={{
textAlign: 'center',
p: 2.5,
...(terminalTheme ? {
backgroundColor: terminalColors.backgroundLight,
borderRadius: 3,
border: `1px solid ${terminalColors.border}`,
position: 'relative',
overflow: 'hidden',
cursor: 'help',
transition: 'all 0.3s ease',
'&:hover': {
transform: 'translateY(-2px)',
boxShadow: `0 0 15px ${terminalColors.border}40`,
borderColor: terminalColors.secondary
},
'&::before': {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '3px',
background: terminalColors.border,
zIndex: 1
}
} : {
background: `linear-gradient(135deg, ${gradientColors.start} 0%, ${gradientColors.end} 100%)`,
borderRadius: 3,
border: `1px solid ${gradientColors.border}`,
position: 'relative',
overflow: 'hidden',
cursor: 'help',
transition: 'all 0.3s ease',
'&:hover': {
transform: 'translateY(-2px)',
boxShadow: `0 8px 25px ${gradientColors.start.replace('0.12', '0.2')}`,
border: `1px solid ${gradientColors.hoverBorder}`
},
'&::before': {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '3px',
background: gradientColors.topBar,
zIndex: 1
}
})
}}>
<TypographyComponent variant="h5" sx={{
fontWeight: 800,
color: terminalTheme ? terminalColors.text : '#ffffff',
textShadow: terminalTheme ? 'none' : '0 2px 8px rgba(0,0,0,0.4)',
mb: 0.5
}}>
<AnimatedNumber
value={value}
format={formatValue}
decimals={decimals}
/>
</TypographyComponent>
<TypographyComponent variant="body2" sx={{
color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.9)',
fontWeight: 500,
textTransform: 'uppercase',
letterSpacing: '0.5px',
fontSize: '0.75rem'
}}>
{title}
</TypographyComponent>
{sparklineData && sparklineData.length > 0 && sparklineColor && (
<MiniSparkline
data={sparklineData}
color={sparklineColor}
height={50}
formatValue={sparklineFormatValue || formatValue}
label={sparklineLabel || title}
/>
)}
</Box>
</Tooltip>
</motion.div>
);
};

View File

@@ -0,0 +1,260 @@
import React from 'react';
import { Box, Tooltip } from '@mui/material';
import { AlertTriangle } from 'lucide-react';
import { TerminalTypography } from '../../../SchedulerDashboard/terminalTheme';
import { terminalColors } from '../../../SchedulerDashboard/terminalTheme';
import { AnimatedProgressBar } from '../../../shared/AnimatedProgressBar';
import ProviderCostComparison from '../../ProviderCostComparison';
import { formatCurrency } from '../utils/formatting';
import { DashboardData } from '../../../../types/billing';
interface MonthlyBudgetUsageProps {
currentUsage: DashboardData['current_usage'];
limits: DashboardData['limits'];
terminalTheme?: boolean;
TypographyComponent: typeof TerminalTypography | React.ComponentType<any>;
}
/**
* MonthlyBudgetUsage - Displays monthly budget usage with progress bar and provider breakdown
*/
export const MonthlyBudgetUsage: React.FC<MonthlyBudgetUsageProps> = ({
currentUsage,
limits,
terminalTheme = false,
TypographyComponent
}) => {
if (limits.limits.monthly_cost <= 0) return null;
const usagePercentage = (currentUsage.total_cost / limits.limits.monthly_cost) * 100;
const remainingBudget = limits.limits.monthly_cost - currentUsage.total_cost;
const daysInMonth = new Date(new Date().getFullYear(), new Date().getMonth() + 1, 0).getDate();
const currentDay = new Date().getDate();
const daysRemaining = daysInMonth - currentDay;
const avgDailyCost = currentUsage.total_cost / currentDay;
const estimatedDaysUntilExhaustion = avgDailyCost > 0 ? Math.ceil(remainingBudget / avgDailyCost) : daysRemaining;
// Determine warning level
const isCritical = usagePercentage >= 95;
const isWarning = usagePercentage >= 80;
const isModerate = usagePercentage >= 50;
return (
<Box sx={{ mb: 3 }}>
{/* Enhanced Warning Banner */}
{(isCritical || isWarning || isModerate) && (
<Box sx={{
mb: 2,
p: 2.5,
...(terminalTheme ? {
backgroundColor: isCritical
? terminalColors.error + '20'
: isWarning
? terminalColors.warning + '20'
: terminalColors.secondary + '20',
borderRadius: 3,
border: `2px solid ${isCritical
? terminalColors.error
: isWarning
? terminalColors.warning
: terminalColors.secondary}`,
position: 'relative',
overflow: 'hidden',
'&::before': {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '4px',
background: isCritical
? terminalColors.error
: isWarning
? terminalColors.warning
: terminalColors.secondary,
zIndex: 1
}
} : {
background: isCritical
? 'linear-gradient(135deg, rgba(239, 68, 68, 0.2) 0%, rgba(220, 38, 38, 0.15) 100%)'
: isWarning
? 'linear-gradient(135deg, rgba(245, 158, 11, 0.2) 0%, rgba(217, 119, 6, 0.15) 100%)'
: 'linear-gradient(135deg, rgba(59, 130, 246, 0.2) 0%, rgba(37, 99, 235, 0.15) 100%)',
borderRadius: 3,
border: `2px solid ${isCritical
? 'rgba(239, 68, 68, 0.5)'
: isWarning
? 'rgba(245, 158, 11, 0.5)'
: 'rgba(59, 130, 246, 0.5)'}`,
position: 'relative',
overflow: 'hidden',
'&::before': {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '4px',
background: isCritical
? 'linear-gradient(90deg, #ef4444, #dc2626)'
: isWarning
? 'linear-gradient(90deg, #f59e0b, #d97706)'
: 'linear-gradient(90deg, #3b82f6, #2563eb)',
zIndex: 1
}
})
}}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 1 }}>
<AlertTriangle
size={24}
color={terminalTheme
? (isCritical ? terminalColors.error : isWarning ? terminalColors.warning : terminalColors.secondary)
: (isCritical ? '#ef4444' : isWarning ? '#f59e0b' : '#3b82f6')
}
/>
<Box sx={{ flex: 1 }}>
<TypographyComponent variant="subtitle1" sx={{
fontWeight: 700,
color: terminalTheme
? (isCritical ? terminalColors.error : isWarning ? terminalColors.warning : terminalColors.text)
: (isCritical ? '#ef4444' : isWarning ? '#f59e0b' : '#ffffff'),
mb: 0.5
}}>
{isCritical
? '🚨 Critical: Approaching Budget Limit'
: isWarning
? '⚠️ Warning: High Budget Usage'
: ' Notice: 50% of Budget Used'
}
</TypographyComponent>
<TypographyComponent variant="body2" sx={{
color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.9)',
mb: 1
}}>
{isCritical
? `You've used ${usagePercentage.toFixed(1)}% of your monthly budget. Only ${formatCurrency(remainingBudget)} remaining.`
: isWarning
? `You've used ${usagePercentage.toFixed(1)}% of your monthly budget. ${formatCurrency(remainingBudget)} remaining.`
: `You've used ${usagePercentage.toFixed(1)}% of your monthly budget. ${formatCurrency(remainingBudget)} remaining.`
}
</TypographyComponent>
{avgDailyCost > 0 && estimatedDaysUntilExhaustion > 0 && (
<TypographyComponent variant="caption" sx={{
color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.8)',
display: 'block',
fontStyle: 'italic'
}}>
{estimatedDaysUntilExhaustion <= daysRemaining
? `At current spending rate, budget will be exhausted in ~${estimatedDaysUntilExhaustion} day${estimatedDaysUntilExhaustion !== 1 ? 's' : ''}.`
: `Current spending rate is sustainable for the remainder of the month.`
}
</TypographyComponent>
)}
</Box>
</Box>
</Box>
)}
{/* Budget Progress Bar */}
<Box sx={{
p: 2.5,
...(terminalTheme ? {
backgroundColor: terminalColors.backgroundLight,
borderRadius: 3,
border: `1px solid ${terminalColors.border}`
} : {
background: 'linear-gradient(135deg, rgba(255,255,255,0.08) 0%, rgba(255,255,255,0.04) 100%)',
borderRadius: 3,
border: '1px solid rgba(255,255,255,0.1)'
})
}}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Box>
<TypographyComponent variant="subtitle2" sx={{
color: terminalTheme ? terminalColors.text : '#ffffff',
fontWeight: 600,
mb: 0.5
}}>
Monthly Budget Usage
</TypographyComponent>
<TypographyComponent variant="caption" sx={{
color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.7)',
display: 'block'
}}>
Track your AI spending against monthly limits
</TypographyComponent>
</Box>
<Box sx={{ textAlign: 'right' }}>
<TypographyComponent variant="h6" sx={{
color: terminalTheme ? terminalColors.text : '#ffffff',
fontWeight: 'bold',
textShadow: terminalTheme ? 'none' : '0 2px 4px rgba(0,0,0,0.3)'
}}>
{formatCurrency(currentUsage.total_cost)}
</TypographyComponent>
<TypographyComponent variant="caption" sx={{
color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.7)',
display: 'block'
}}>
of {formatCurrency(limits.limits.monthly_cost)}
</TypographyComponent>
</Box>
</Box>
<AnimatedProgressBar
value={Math.min(usagePercentage, 100)}
height={10}
color={terminalTheme
? (isCritical
? terminalColors.error
: isWarning
? terminalColors.warning
: isModerate
? terminalColors.secondary
: terminalColors.success)
: (isCritical
? '#ef4444'
: isWarning
? '#f59e0b'
: isModerate
? '#3b82f6'
: '#4ade80')
}
showPercentage={false}
/>
{/* Provider Cost Comparison Chart */}
{currentUsage.provider_breakdown && Object.keys(currentUsage.provider_breakdown).length > 0 && (
<ProviderCostComparison
providerBreakdown={currentUsage.provider_breakdown}
terminalTheme={terminalTheme}
terminalColors={terminalColors}
/>
)}
<Box sx={{ display: 'flex', justifyContent: 'space-between', mt: 1.5 }}>
<TypographyComponent variant="caption" sx={{
color: terminalTheme
? (isCritical ? terminalColors.error : isWarning ? terminalColors.warning : terminalColors.textSecondary)
: (isCritical ? '#ef4444' : isWarning ? '#f59e0b' : 'rgba(255,255,255,0.7)'),
fontWeight: (isCritical || isWarning) ? 600 : 400
}}>
{isCritical
? '🚨 Critical: Approaching limit'
: isWarning
? '⚠️ Warning: High usage'
: isModerate
? ' Moderate usage'
: '✅ Within budget'
}
</TypographyComponent>
<TypographyComponent variant="caption" sx={{
color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.7)',
fontWeight: 600
}}>
{usagePercentage.toFixed(1)}% used {formatCurrency(remainingBudget)} remaining
</TypographyComponent>
</Box>
</Box>
</Box>
);
};

View File

@@ -0,0 +1,121 @@
import React, { useMemo } from 'react';
import { Box } from '@mui/material';
import { motion } from 'framer-motion';
import { TerminalTypography } from '../../../SchedulerDashboard/terminalTheme';
import { terminalColors } from '../../../SchedulerDashboard/terminalTheme';
import { UsageLimitRing } from '../../../shared/UsageLimitRing';
import { DashboardData } from '../../../../types/billing';
interface UsageLimitRingsProps {
currentUsage: DashboardData['current_usage'];
limits: DashboardData['limits'];
terminalTheme?: boolean;
TypographyComponent: typeof TerminalTypography | React.ComponentType<any>;
}
/**
* UsageLimitRings - Displays circular progress rings for key usage limits
*/
export const UsageLimitRings: React.FC<UsageLimitRingsProps> = ({
currentUsage,
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]);
// Calculate video calls - check multiple possible sources
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]);
const keyLimits = [
{
label: 'AI Calls',
used: currentUsage.total_calls,
limit: limits.limits.gemini_calls || limits.limits.openai_calls || 50,
color: '#3b82f6'
},
{
label: 'Images',
used: imageCalls,
limit: limits.limits.stability_calls || 50,
color: '#a855f7'
},
{
label: 'Videos',
used: videoCalls,
limit: limits.limits.video_calls || 30,
color: '#ec4899'
}
].filter(item => item.limit > 0);
if (keyLimits.length === 0) return null;
return (
<Box sx={{ mb: 3 }}>
<TypographyComponent variant="subtitle2" sx={{
fontWeight: 600,
mb: 2,
color: terminalTheme ? terminalColors.text : 'rgba(255,255,255,0.9)'
}}>
Usage Limits Overview
</TypographyComponent>
<Box sx={{ display: 'flex', justifyContent: 'space-around', flexWrap: 'wrap', gap: 2 }}>
{keyLimits.map((item, index) => (
<motion.div
key={item.label}
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: index * 0.1, duration: 0.4 }}
>
<UsageLimitRing
used={item.used}
limit={item.limit}
label={item.label}
color={item.color}
size={100}
terminalTheme={terminalTheme}
terminalColors={terminalColors}
/>
</motion.div>
))}
</Box>
</Box>
);
};

View File

@@ -0,0 +1,197 @@
import { useState, useEffect, useMemo } from 'react';
import { DashboardData } from '../../../../types/billing';
import { SystemHealth } from '../../../../types/monitoring';
import { billingService } from '../../../../services/billingService';
import { monitoringService } from '../../../../services/monitoringService';
import { onApiEvent } from '../../../../utils/apiEvents';
import { showToastNotification } from '../../../../utils/toastNotifications';
interface UseCompactBillingDataReturn {
dashboardData: DashboardData | null;
systemHealth: SystemHealth | null;
loading: boolean;
error: string | null;
lastRefreshTime: Date | null;
healthError: string | null;
sparklineData: {
cost: Array<{ date: string; value: number }>;
calls: Array<{ date: string; value: number }>;
tokens: Array<{ date: string; value: number }>;
};
refresh: (showSuccessToast?: boolean) => Promise<void>;
}
/**
* Custom hook for managing CompactBillingDashboard data fetching and state
*/
export const useCompactBillingData = (userId?: string): UseCompactBillingDataReturn => {
const [dashboardData, setDashboardData] = useState<DashboardData | null>(null);
const [systemHealth, setSystemHealth] = useState<SystemHealth | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [lastRefreshTime, setLastRefreshTime] = useState<Date | null>(null);
const [healthError, setHealthError] = useState<string | null>(null);
const fetchData = async (showSuccessToast: boolean = false) => {
try {
setLoading(true);
setError(null);
// Use Promise.allSettled to prevent health check timeout from blocking dashboard
const results = await Promise.allSettled([
billingService.getDashboardData(userId),
monitoringService.getSystemHealth()
]);
// Handle billing data (required)
if (results[0].status === 'fulfilled') {
setDashboardData(results[0].value);
setLastRefreshTime(new Date());
setError(null); // Clear any previous errors
} else {
// Billing data is critical - show error
const errorMessage = results[0].reason instanceof Error
? results[0].reason.message
: 'Failed to fetch data';
setError(errorMessage);
showToastNotification(
`Unable to fetch latest billing data: ${errorMessage}. Showing last known values.`,
'error',
{ duration: 7000 }
);
setLoading(false);
return;
}
// Handle health data (optional - don't block dashboard if it fails)
if (results[1].status === 'fulfilled') {
setSystemHealth(results[1].value);
setHealthError(null); // Clear health error on success
} else {
// Health check failed - keep last successful value, show error toast
const healthErrorMessage = results[1].reason instanceof Error
? results[1].reason.message
: 'System health check timed out or failed';
setHealthError(healthErrorMessage);
showToastNotification(
`Unable to fetch latest system health data: ${healthErrorMessage}. Showing last known values.`,
'warning',
{ duration: 6000 }
);
// Don't update systemHealth - keep last successful value
// Only set to null if we never had a successful fetch
if (!systemHealth) {
setSystemHealth(null);
}
}
// Show success toast only if explicitly requested (user-initiated refresh)
if (showSuccessToast && results[0].status === 'fulfilled' && results[1].status === 'fulfilled') {
showToastNotification(
'Billing data refreshed successfully',
'success',
{ duration: 3000 }
);
} else if (showSuccessToast && results[0].status === 'fulfilled' && results[1].status === 'rejected') {
showToastNotification(
'Billing data refreshed, but system health check failed',
'warning',
{ duration: 4000 }
);
}
} catch (err) {
// Fallback error handling (shouldn't reach here with allSettled)
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch data';
setError(errorMessage);
showToastNotification(errorMessage, 'error', { duration: 5000 });
} finally {
setLoading(false);
}
};
// Initial fetch
useEffect(() => {
fetchData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [userId]);
// Event-driven refresh
useEffect(() => {
const lastRefreshRef = { current: 0 } as { current: number };
const MIN_REFRESH_INTERVAL_MS = 4000;
const unsubscribe = onApiEvent((detail) => {
// Only react to non-billing/monitoring events to avoid feedback loops
if (detail.source && detail.source !== 'other') return;
const now = Date.now();
if (now - lastRefreshRef.current < MIN_REFRESH_INTERVAL_MS) return;
lastRefreshRef.current = now;
Promise.allSettled([billingService.getDashboardData(userId), monitoringService.getSystemHealth()])
.then((results) => {
if (results[0].status === 'fulfilled') {
setDashboardData(results[0].value);
setLastRefreshTime(new Date());
setError(null);
}
if (results[1].status === 'fulfilled') {
setSystemHealth(results[1].value);
setHealthError(null);
} else {
// Keep last successful health value, don't set fake defaults
const healthErrorMessage = results[1].reason instanceof Error
? results[1].reason.message
: 'System health check failed';
setHealthError(healthErrorMessage);
// Don't update systemHealth - keep last successful value
}
})
.catch(() => {/* ignore */});
});
return unsubscribe;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Prepare sparkline data for last 7 days (or available data)
const sparklineData = useMemo(() => {
if (!dashboardData || !dashboardData.trends || !dashboardData.trends.periods || dashboardData.trends.periods.length === 0) {
return {
cost: [],
calls: [],
tokens: []
};
}
const { trends } = dashboardData;
// Get last 7 periods (or all if less than 7)
const last7Periods = trends.periods.slice(-7);
const last7Costs = trends.total_cost.slice(-7);
const last7Calls = trends.total_calls.slice(-7);
const last7Tokens = trends.total_tokens.slice(-7);
return {
cost: last7Periods.map((period, index) => ({
date: period,
value: last7Costs[index] || 0
})),
calls: last7Periods.map((period, index) => ({
date: period,
value: last7Calls[index] || 0
})),
tokens: last7Periods.map((period, index) => ({
date: period,
value: (last7Tokens[index] || 0) / 1000 // Convert to thousands
}))
};
}, [dashboardData]);
return {
dashboardData,
systemHealth,
loading,
error,
lastRefreshTime,
healthError,
sparklineData,
refresh: fetchData
};
};

View File

@@ -0,0 +1,216 @@
import React from 'react';
import { Card, CardContent, Typography, Chip } from '@mui/material';
import { motion } from 'framer-motion';
import {
TerminalCard,
TerminalCardContent,
TerminalTypography,
TerminalChip,
terminalColors
} from '../../SchedulerDashboard/terminalTheme';
// Hooks
import { useCompactBillingData } from './hooks/useCompactBillingData';
// Components
import { DashboardHeader } from './components/DashboardHeader';
import { MainMetricsGrid } from './components/MainMetricsGrid';
import { CostEfficiencyMetrics } from './components/CostEfficiencyMetrics';
import { UsageLimitRings } from './components/UsageLimitRings';
import { MonthlyBudgetUsage } from './components/MonthlyBudgetUsage';
import { AlertsSection } from './components/AlertsSection';
interface CompactBillingDashboardProps {
userId?: string;
terminalTheme?: boolean;
}
/**
* CompactBillingDashboard - Main orchestrator component
*
* Refactored from monolithic component into modular structure:
* - Data fetching: useCompactBillingData hook
* - UI Components: Separated into focused, reusable components
* - Utils: Formatting utilities extracted
*/
const CompactBillingDashboard: React.FC<CompactBillingDashboardProps> = ({
userId,
terminalTheme = false
}) => {
// Conditional component selection based on terminal theme
const CardComponent = terminalTheme ? TerminalCard : Card;
const CardContentComponent = terminalTheme ? TerminalCardContent : CardContent;
const TypographyComponent = terminalTheme ? TerminalTypography : Typography;
const ChipComponent = terminalTheme ? TerminalChip : Chip;
// Data fetching hook
const {
dashboardData,
systemHealth,
loading,
error,
lastRefreshTime,
healthError,
sparklineData,
refresh
} = useCompactBillingData(userId);
// Loading state
if (loading && !dashboardData) {
const loadingCardStyles = terminalTheme
? {
backgroundColor: terminalColors.background,
border: `1px solid ${terminalColors.border}`,
borderRadius: 3
}
: {
background: 'linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.05) 100%)',
backdropFilter: 'blur(10px)',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: 3
};
return (
<CardComponent sx={loadingCardStyles}>
<CardContentComponent sx={{ textAlign: 'center', py: 4 }}>
<TypographyComponent sx={{ color: terminalTheme ? terminalColors.text : 'rgba(255,255,255,0.8)' }}>
Loading billing data...
</TypographyComponent>
</CardContentComponent>
</CardComponent>
);
}
// Error state
if (error) {
const errorCardStyles = terminalTheme
? {
backgroundColor: terminalColors.background,
border: `1px solid ${terminalColors.error}`,
borderRadius: 3
}
: {
background: 'linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.05) 100%)',
backdropFilter: 'blur(10px)',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: 3
};
return (
<CardComponent sx={errorCardStyles}>
<CardContentComponent sx={{ textAlign: 'center', py: 4 }}>
<TypographyComponent sx={{ color: terminalTheme ? terminalColors.error : '#ff6b6b' }}>
Error: {error}
</TypographyComponent>
</CardContentComponent>
</CardComponent>
);
}
if (!dashboardData) return null;
const { current_usage, limits, alerts } = dashboardData;
const mainCardStyles = terminalTheme
? {
backgroundColor: terminalColors.background,
border: `1px solid ${terminalColors.border}`,
borderRadius: 4,
position: 'relative' as const,
overflow: 'hidden' as const,
boxShadow: '0 0 15px rgba(0, 255, 0, 0.2)',
'&::before': {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '1px',
background: `linear-gradient(90deg, transparent, ${terminalColors.border}, transparent)`,
zIndex: 1
}
}
: {
background: 'linear-gradient(135deg, rgba(255,255,255,0.12) 0%, rgba(255,255,255,0.08) 100%)',
backdropFilter: 'blur(15px)',
border: '1px solid rgba(255,255,255,0.15)',
borderRadius: 4,
position: 'relative' as const,
overflow: 'hidden' as const,
boxShadow: '0 8px 32px rgba(0,0,0,0.2), 0 0 0 1px rgba(255,255,255,0.1)',
'&::before': {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '1px',
background: 'linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent)',
zIndex: 1
}
};
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
<CardComponent sx={mainCardStyles}>
<CardContentComponent sx={{ pt: 2 }}>
{/* Header */}
<DashboardHeader
lastRefreshTime={lastRefreshTime}
onRefresh={() => refresh(true)}
loading={loading}
terminalTheme={terminalTheme}
TypographyComponent={TypographyComponent}
/>
{/* Main Metrics Grid */}
<MainMetricsGrid
currentUsage={current_usage}
systemHealth={systemHealth}
healthError={healthError}
sparklineData={sparklineData}
terminalTheme={terminalTheme}
TypographyComponent={TypographyComponent}
/>
{/* Cost Efficiency Metrics */}
<CostEfficiencyMetrics
currentUsage={current_usage}
terminalTheme={terminalTheme}
TypographyComponent={TypographyComponent}
/>
{/* Usage Limit Rings */}
<UsageLimitRings
currentUsage={current_usage}
limits={limits}
terminalTheme={terminalTheme}
TypographyComponent={TypographyComponent}
/>
{/* Monthly Budget Usage */}
<MonthlyBudgetUsage
currentUsage={current_usage}
limits={limits}
terminalTheme={terminalTheme}
TypographyComponent={TypographyComponent}
/>
{/* Alerts Section */}
<AlertsSection
alerts={alerts}
terminalTheme={terminalTheme}
TypographyComponent={TypographyComponent}
ChipComponent={ChipComponent}
/>
</CardContentComponent>
</CardComponent>
</motion.div>
);
};
export default CompactBillingDashboard;

View File

@@ -0,0 +1,7 @@
/**
* Formatting utilities for CompactBillingDashboard
*/
export const formatCurrency = (amount: number): string => `$${amount.toFixed(4)}`;
export const formatNumber = (num: number): string => num.toLocaleString();

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useState, useEffect } from 'react';
import {
Card,
CardContent,
@@ -10,6 +10,7 @@ import {
Accordion,
AccordionSummary,
AccordionDetails,
CircularProgress,
} from '@mui/material';
import { ExpandMore } from '@mui/icons-material';
import { motion } from 'framer-motion';
@@ -20,11 +21,15 @@ import {
Code,
Database,
FileText,
BarChart3
BarChart3,
RefreshCw
} from 'lucide-react';
// Types
import { ProviderBreakdown } from '../../types/billing';
import { ProviderBreakdown, APIPricing } from '../../types/billing';
// Services
import { billingService } from '../../services/billingService';
interface ComprehensiveAPIBreakdownProps {
providerBreakdown: ProviderBreakdown;
@@ -151,10 +156,96 @@ const ComprehensiveAPIBreakdown: React.FC<ComprehensiveAPIBreakdownProps> = ({
providerBreakdown,
totalCost
}) => {
const [pricing, setPricing] = useState<APIPricing[]>([]);
const [loadingPricing, setLoadingPricing] = useState(true);
const [pricingError, setPricingError] = useState<string | null>(null);
// Fetch dynamic pricing on mount
useEffect(() => {
const fetchPricing = async () => {
try {
setLoadingPricing(true);
setPricingError(null);
const pricingData = await billingService.getAPIPricing();
setPricing(pricingData);
} catch (err) {
console.error('[ComprehensiveAPIBreakdown] Error fetching pricing:', err);
setPricingError(err instanceof Error ? err.message : 'Failed to fetch pricing');
} finally {
setLoadingPricing(false);
}
};
fetchPricing();
}, []);
// Get active providers from breakdown
const activeProviders = Object.entries(providerBreakdown)
.filter(([_, data]) => data.cost > 0)
.map(([provider, data]) => ({ provider, ...data }));
.filter(([_, data]) => data && data.cost > 0)
.map(([provider, data]) => ({
provider,
cost: data?.cost ?? 0,
calls: data?.calls ?? 0,
tokens: data?.tokens ?? 0
}));
// Helper function to format pricing from API or fallback to static
const getPricingDisplay = (apiName: string, fallbackPricing: string): string => {
if (loadingPricing) {
return 'Loading pricing...';
}
if (pricingError) {
return fallbackPricing; // Fallback to static pricing on error
}
// Find matching pricing by provider name
const apiPricing = pricing.find(p =>
p.provider.toLowerCase() === apiName.toLowerCase() ||
p.model_name.toLowerCase().includes(apiName.toLowerCase())
);
if (apiPricing) {
// Format pricing based on what's available
const parts: string[] = [];
if (apiPricing.cost_per_input_token > 0 || apiPricing.cost_per_output_token > 0) {
const inputCost = apiPricing.cost_per_input_token > 0
? `$${(apiPricing.cost_per_input_token * 1000000).toFixed(2)}/1M input`
: '';
const outputCost = apiPricing.cost_per_output_token > 0
? `$${(apiPricing.cost_per_output_token * 1000000).toFixed(2)}/1M output`
: '';
if (inputCost && outputCost) {
parts.push(`${inputCost}, ${outputCost}`);
} else if (inputCost) {
parts.push(inputCost);
} else if (outputCost) {
parts.push(outputCost);
}
}
if (apiPricing.cost_per_request > 0) {
parts.push(`$${apiPricing.cost_per_request.toFixed(4)} per request`);
}
if (apiPricing.cost_per_search > 0) {
parts.push(`$${apiPricing.cost_per_search.toFixed(4)} per search`);
}
if (apiPricing.cost_per_image > 0) {
parts.push(`$${apiPricing.cost_per_image.toFixed(2)} per image`);
}
if (apiPricing.cost_per_page > 0) {
parts.push(`$${apiPricing.cost_per_page.toFixed(4)} per page`);
}
return parts.length > 0 ? parts.join(', ') : fallbackPricing;
}
return fallbackPricing; // Fallback to static pricing if not found
};
const getProviderCategory = (providerName: string) => {
const provider = providerName.toLowerCase();
@@ -180,9 +271,9 @@ const ComprehensiveAPIBreakdown: React.FC<ComprehensiveAPIBreakdownProps> = ({
return {
count: categoryProviders.length,
totalCost: categoryProviders.reduce((sum, p) => sum + p.cost, 0),
totalCalls: categoryProviders.reduce((sum, p) => sum + p.calls, 0),
totalTokens: categoryProviders.reduce((sum, p) => sum + p.tokens, 0)
totalCost: categoryProviders.reduce((sum, p) => sum + (p.cost ?? 0), 0),
totalCalls: categoryProviders.reduce((sum, p) => sum + (p.calls ?? 0), 0),
totalTokens: categoryProviders.reduce((sum, p) => sum + (p.tokens ?? 0), 0)
};
};
@@ -210,9 +301,35 @@ const ComprehensiveAPIBreakdown: React.FC<ComprehensiveAPIBreakdownProps> = ({
<BarChart3 size={20} />
Comprehensive API Breakdown
</Typography>
<Tooltip title="Detailed breakdown of all API usage across categories">
<Info size={16} color="rgba(255,255,255,0.7)" />
</Tooltip>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{loadingPricing && (
<CircularProgress size={16} sx={{ color: 'rgba(255,255,255,0.7)' }} />
)}
<Tooltip title={pricingError ? `Pricing error: ${pricingError}. Showing static pricing.` : "Detailed breakdown with real-time pricing from API"}>
<Info size={16} color="rgba(255,255,255,0.7)" />
</Tooltip>
{!loadingPricing && !pricingError && (
<Tooltip title="Refresh pricing">
<RefreshCw
size={16}
color="rgba(255,255,255,0.7)"
style={{ cursor: 'pointer' }}
onClick={async () => {
try {
setLoadingPricing(true);
const pricingData = await billingService.getAPIPricing();
setPricing(pricingData);
setPricingError(null);
} catch (err) {
setPricingError(err instanceof Error ? err.message : 'Failed to refresh pricing');
} finally {
setLoadingPricing(false);
}
}}
/>
</Tooltip>
)}
</Box>
</Box>
</CardContent>
@@ -253,7 +370,7 @@ const ComprehensiveAPIBreakdown: React.FC<ComprehensiveAPIBreakdownProps> = ({
<Grid item xs={6} sm={3}>
<Box sx={{ textAlign: 'center' }}>
<Typography variant="h6" sx={{ fontWeight: 'bold', color: '#ffffff' }}>
{activeProviders.reduce((sum, p) => sum + p.calls, 0)}
{activeProviders.reduce((sum, p) => sum + (p.calls ?? 0), 0)}
</Typography>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.7)' }}>
Total Calls
@@ -352,9 +469,50 @@ const ComprehensiveAPIBreakdown: React.FC<ComprehensiveAPIBreakdownProps> = ({
{api.description}
</Typography>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.6)', display: 'block', mb: 1 }}>
Pricing: {api.pricing}
</Typography>
<Tooltip
title={
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 'bold', mb: 0.5 }}>
Current Pricing
</Typography>
<Typography variant="body2">
{loadingPricing
? 'Loading...'
: pricingError
? `Using fallback pricing. Error: ${pricingError}`
: 'Real-time pricing from API'
}
</Typography>
{!loadingPricing && !pricingError && (
<Typography variant="caption" sx={{ mt: 1, display: 'block', opacity: 0.8 }}>
Last updated: {pricing.find(p =>
p.provider.toLowerCase() === api.name.toLowerCase()
)?.effective_date || 'N/A'}
</Typography>
)}
</Box>
}
arrow
placement="top"
>
<Typography
variant="caption"
sx={{
color: loadingPricing
? 'rgba(255,255,255,0.5)'
: pricingError
? 'rgba(255,193,7,0.8)'
: 'rgba(74, 222, 128, 0.9)',
display: 'block',
mb: 1,
fontWeight: !loadingPricing && !pricingError ? 500 : 400
}}
>
Pricing: {getPricingDisplay(api.name, api.pricing)}
{!loadingPricing && !pricingError && ' ✓'}
{pricingError && ' (static)'}
</Typography>
</Tooltip>
{providerData && (
<Box sx={{ mt: 2, p: 1, backgroundColor: 'rgba(0,0,0,0.2)', borderRadius: 1 }}>
@@ -364,7 +522,7 @@ const ComprehensiveAPIBreakdown: React.FC<ComprehensiveAPIBreakdownProps> = ({
Cost
</Typography>
<Typography variant="caption" sx={{ display: 'block', color: '#4ade80', fontWeight: 'bold' }}>
${providerData.cost.toFixed(4)}
${(providerData.cost ?? 0).toFixed(4)}
</Typography>
</Grid>
<Grid item xs={4}>
@@ -372,7 +530,7 @@ const ComprehensiveAPIBreakdown: React.FC<ComprehensiveAPIBreakdownProps> = ({
Calls
</Typography>
<Typography variant="caption" sx={{ display: 'block', color: '#ffffff', fontWeight: 'bold' }}>
{providerData.calls}
{providerData.calls ?? 0}
</Typography>
</Grid>
<Grid item xs={4}>
@@ -380,7 +538,7 @@ const ComprehensiveAPIBreakdown: React.FC<ComprehensiveAPIBreakdownProps> = ({
Tokens
</Typography>
<Typography variant="caption" sx={{ display: 'block', color: '#ffffff', fontWeight: 'bold' }}>
{providerData.tokens.toLocaleString()}
{(providerData.tokens ?? 0).toLocaleString()}
</Typography>
</Grid>
</Grid>

View File

@@ -35,12 +35,12 @@ const CostBreakdown: React.FC<CostBreakdownProps> = ({
}) => {
// Transform data for pie chart
const chartData = Object.entries(providerBreakdown)
.filter(([_, data]) => data.cost > 0)
.filter(([_, data]) => data && data.cost > 0)
.map(([provider, data]) => ({
name: provider.charAt(0).toUpperCase() + provider.slice(1),
value: data.cost,
calls: data.calls,
tokens: data.tokens,
value: data?.cost ?? 0,
calls: data?.calls ?? 0,
tokens: data?.tokens ?? 0,
color: getProviderColor(provider),
icon: getProviderIcon(provider)
}))
@@ -124,9 +124,14 @@ const CostBreakdown: React.FC<CostBreakdownProps> = ({
outerRadius={80}
fill="#8884d8"
dataKey="value"
animationBegin={0}
animationDuration={1000}
>
{chartData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
<Cell
key={`cell-${index}`}
fill={entry.color}
/>
))}
</Pie>
<Tooltip content={<CustomTooltip />} />

View File

@@ -0,0 +1,308 @@
import React, { useState, useEffect } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Typography,
Box,
Grid,
Chip,
CircularProgress,
Alert,
Divider,
} from '@mui/material';
import {
DollarSign,
Info,
CheckCircle,
AlertTriangle
} from 'lucide-react';
// Types
import { PreflightCheckResponse, PreflightOperation } from '../../services/billingService';
// Services
import { billingService, formatCurrency, checkPreflightBatch } from '../../services/billingService';
interface CostEstimationModalProps {
open: boolean;
onClose: () => void;
onConfirm: () => void;
operations: PreflightOperation[];
userId?: string;
}
const CostEstimationModal: React.FC<CostEstimationModalProps> = ({
open,
onClose,
onConfirm,
operations,
userId
}) => {
const [estimation, setEstimation] = useState<PreflightCheckResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (open && operations.length > 0) {
fetchEstimation();
} else {
setEstimation(null);
setError(null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, JSON.stringify(operations)]);
const fetchEstimation = async () => {
try {
setLoading(true);
setError(null);
const result = await checkPreflightBatch(operations);
setEstimation(result);
} catch (err) {
console.error('[CostEstimationModal] Error fetching estimation:', err);
setError(err instanceof Error ? err.message : 'Failed to estimate costs');
} finally {
setLoading(false);
}
};
const handleConfirm = () => {
if (estimation?.can_proceed) {
onConfirm();
onClose();
}
};
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="sm"
fullWidth
PaperProps={{
sx: {
background: 'linear-gradient(135deg, rgba(255,255,255,0.95) 0%, rgba(255,255,255,0.98) 100%)',
borderRadius: 3,
boxShadow: '0 20px 60px rgba(0,0,0,0.3)'
}
}}
>
<DialogTitle sx={{
display: 'flex',
alignItems: 'center',
gap: 1.5,
pb: 1,
borderBottom: '1px solid rgba(0,0,0,0.1)'
}}>
<DollarSign size={24} color="#667eea" />
<Box sx={{ flex: 1 }}>
<Typography variant="h6" sx={{ fontWeight: 'bold', color: '#1e293b' }}>
Cost Estimation
</Typography>
<Typography variant="caption" sx={{ color: '#64748b' }}>
Estimated cost for this operation
</Typography>
</Box>
</DialogTitle>
<DialogContent sx={{ pt: 3 }}>
{loading && (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', py: 4 }}>
<CircularProgress size={40} sx={{ color: '#667eea', mb: 2 }} />
<Typography variant="body2" sx={{ color: '#64748b' }}>
Calculating estimated costs...
</Typography>
</Box>
)}
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
{estimation && !loading && (
<>
{/* Overall Estimation */}
<Box
sx={{
p: 2.5,
mb: 3,
background: estimation.can_proceed
? 'linear-gradient(135deg, rgba(34, 197, 94, 0.1) 0%, rgba(22, 163, 74, 0.05) 100%)'
: 'linear-gradient(135deg, rgba(239, 68, 68, 0.1) 0%, rgba(220, 38, 38, 0.05) 100%)',
borderRadius: 2,
border: `2px solid ${estimation.can_proceed ? '#22c55e' : '#ef4444'}`,
textAlign: 'center'
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 1, mb: 1 }}>
{estimation.can_proceed ? (
<CheckCircle size={24} color="#22c55e" />
) : (
<AlertTriangle size={24} color="#ef4444" />
)}
<Typography variant="h5" sx={{ fontWeight: 'bold', color: estimation.can_proceed ? '#22c55e' : '#ef4444' }}>
{formatCurrency(estimation.total_cost)}
</Typography>
</Box>
<Typography variant="body2" sx={{ color: '#64748b', mb: 1 }}>
Estimated Total Cost
</Typography>
{!estimation.can_proceed && (
<Alert severity="error" sx={{ mt: 1 }}>
This operation cannot proceed. {estimation.operations.find(op => !op.allowed)?.message || 'Limit exceeded'}
</Alert>
)}
</Box>
{/* Operation Breakdown */}
<Typography variant="subtitle2" sx={{ fontWeight: 'bold', mb: 2, color: '#1e293b' }}>
Operation Breakdown
</Typography>
<Grid container spacing={2} sx={{ mb: 2 }}>
{estimation.operations.map((op, index) => (
<Grid item xs={12} key={index}>
<Box
sx={{
p: 2,
backgroundColor: op.allowed ? 'rgba(34, 197, 94, 0.05)' : 'rgba(239, 68, 68, 0.05)',
borderRadius: 2,
border: `1px solid ${op.allowed ? 'rgba(34, 197, 94, 0.2)' : 'rgba(239, 68, 68, 0.2)'}`
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 'bold', color: '#1e293b' }}>
{op.provider}
</Typography>
<Chip
label={op.allowed ? 'Allowed' : 'Blocked'}
size="small"
sx={{
backgroundColor: op.allowed ? 'rgba(34, 197, 94, 0.2)' : 'rgba(239, 68, 68, 0.2)',
color: op.allowed ? '#22c55e' : '#ef4444',
fontWeight: 'bold'
}}
/>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="caption" sx={{ color: '#64748b' }}>
Operation:
</Typography>
<Typography variant="caption" sx={{ fontWeight: 500, color: '#1e293b' }}>
{op.operation_type}
</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="caption" sx={{ color: '#64748b' }}>
Estimated Cost:
</Typography>
<Typography variant="caption" sx={{ fontWeight: 'bold', color: '#667eea' }}>
{formatCurrency(op.cost)}
</Typography>
</Box>
{op.limit_info && (
<Box sx={{ mt: 1, pt: 1, borderTop: '1px solid rgba(0,0,0,0.1)' }}>
<Typography variant="caption" sx={{ color: '#64748b' }}>
Usage: {op.limit_info.current_usage} / {op.limit_info.limit}
({((op.limit_info.current_usage / op.limit_info.limit) * 100).toFixed(1)}%)
</Typography>
</Box>
)}
{op.message && (
<Typography variant="caption" sx={{ color: op.allowed ? '#22c55e' : '#ef4444', display: 'block', mt: 0.5 }}>
{op.message}
</Typography>
)}
</Box>
</Grid>
))}
</Grid>
{/* Usage Summary */}
{estimation.usage_summary && (
<>
<Divider sx={{ my: 2 }} />
<Box sx={{ p: 2, backgroundColor: 'rgba(102, 126, 234, 0.05)', borderRadius: 2 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 'bold', mb: 1, color: '#1e293b' }}>
Current Usage Summary
</Typography>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="body2" sx={{ color: '#64748b' }}>
Current Calls:
</Typography>
<Typography variant="body2" sx={{ fontWeight: 'bold', color: '#1e293b' }}>
{estimation.usage_summary.current_calls}
</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="body2" sx={{ color: '#64748b' }}>
Limit:
</Typography>
<Typography variant="body2" sx={{ fontWeight: 'bold', color: '#1e293b' }}>
{estimation.usage_summary.limit}
</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography variant="body2" sx={{ color: '#64748b' }}>
Remaining:
</Typography>
<Typography
variant="body2"
sx={{
fontWeight: 'bold',
color: estimation.usage_summary.remaining > 0 ? '#22c55e' : '#ef4444'
}}
>
{estimation.usage_summary.remaining}
</Typography>
</Box>
</Box>
</>
)}
{/* Info Note */}
<Box sx={{ mt: 2, p: 1.5, backgroundColor: 'rgba(59, 130, 246, 0.05)', borderRadius: 1 }}>
<Box sx={{ display: 'flex', gap: 1 }}>
<Info size={16} color="#3b82f6" style={{ marginTop: 2 }} />
<Typography variant="caption" sx={{ color: '#64748b' }}>
This is an estimate. Actual costs may vary based on token usage and API response length.
</Typography>
</Box>
</Box>
</>
)}
</DialogContent>
<DialogActions sx={{ px: 3, py: 2, borderTop: '1px solid rgba(0,0,0,0.1)' }}>
<Button onClick={onClose} sx={{ color: '#64748b' }}>
Cancel
</Button>
<Button
onClick={handleConfirm}
variant="contained"
disabled={loading || !estimation?.can_proceed}
sx={{
background: estimation?.can_proceed
? 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'
: 'rgba(100, 116, 139, 0.3)',
'&:hover': {
background: estimation?.can_proceed
? 'linear-gradient(135deg, #5568d3 0%, #6b3d91 100%)'
: 'rgba(100, 116, 139, 0.3)'
}
}}
>
{loading ? 'Calculating...' : estimation?.can_proceed ? 'Proceed' : 'Cannot Proceed'}
</Button>
</DialogActions>
</Dialog>
);
};
export default CostEstimationModal;

View File

@@ -0,0 +1,443 @@
import React, { useState, useEffect, useMemo } from 'react';
import {
Card,
CardContent,
Typography,
Box,
Grid,
Chip,
Tooltip,
Button,
Alert,
CircularProgress,
Accordion,
AccordionSummary,
AccordionDetails,
} from '@mui/material';
import { ExpandMore } from '@mui/icons-material';
import { motion } from 'framer-motion';
import {
Lightbulb,
TrendingDown,
DollarSign,
ArrowRight,
Info,
Sparkles
} from 'lucide-react';
// Types
import { UsageLog } from '../../types/billing';
// Services
import { billingService, formatCurrency } from '../../services/billingService';
interface CostOptimizationRecommendationsProps {
userId?: string;
terminalTheme?: boolean;
}
interface OptimizationRecommendation {
id: string;
title: string;
description: string;
potentialSavings: number;
savingsPercentage: number;
category: 'model_switch' | 'provider_switch' | 'usage_pattern' | 'efficiency';
priority: 'high' | 'medium' | 'low';
actionItems: string[];
currentCost: number;
recommendedCost: number;
}
const CostOptimizationRecommendations: React.FC<CostOptimizationRecommendationsProps> = ({
userId,
terminalTheme = false
}) => {
const [usageLogs, setUsageLogs] = useState<UsageLog[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchUsageLogs = async () => {
try {
setLoading(true);
setError(null);
const currentDate = new Date();
const billingPeriod = `${currentDate.getFullYear()}-${String(currentDate.getMonth() + 1).padStart(2, '0')}`;
const response = await billingService.getUsageLogs(1000, 0, undefined, undefined, billingPeriod);
setUsageLogs(response.logs || []);
} catch (err) {
console.error('[CostOptimizationRecommendations] Error fetching usage logs:', err);
setError(err instanceof Error ? err.message : 'Failed to fetch usage logs');
} finally {
setLoading(false);
}
};
fetchUsageLogs();
}, [userId]);
// Analyze usage patterns and generate recommendations
const recommendations = useMemo(() => {
if (usageLogs.length === 0) return [];
const recs: OptimizationRecommendation[] = [];
// 1. Model Switch Recommendations
// Analyze if user is using expensive models when cheaper alternatives exist
const modelUsage = usageLogs.reduce((acc, log) => {
if (!log.model_used || log.cost_total === 0) return acc;
const key = `${log.provider}:${log.model_used}`;
if (!acc[key]) {
acc[key] = { cost: 0, calls: 0, tokens: 0 };
}
acc[key].cost += log.cost_total;
acc[key].calls += 1;
acc[key].tokens += log.tokens_total;
return acc;
}, {} as Record<string, { cost: number; calls: number; tokens: number }>);
// Check for Gemini Pro usage when Flash could work
const geminiProUsage = Object.entries(modelUsage).find(([key]) =>
key.includes('gemini') && key.includes('pro') && !key.includes('flash')
);
if (geminiProUsage) {
const [_, data] = geminiProUsage;
// Estimate Flash cost (typically 10-20x cheaper)
const estimatedFlashCost = data.cost * 0.1; // Conservative estimate
const savings = data.cost - estimatedFlashCost;
if (savings > 0.01) { // Only show if savings > $0.01
recs.push({
id: 'gemini-pro-to-flash',
title: 'Switch Gemini Pro to Flash for Simple Tasks',
description: `You're using Gemini Pro for ${data.calls} calls. Consider using Gemini Flash for simpler tasks - it's 10x cheaper with similar quality for most use cases.`,
potentialSavings: savings,
savingsPercentage: (savings / data.cost) * 100,
category: 'model_switch',
priority: 'high',
actionItems: [
'Use Gemini Flash for content generation',
'Use Gemini Flash for simple Q&A',
'Reserve Gemini Pro for complex reasoning tasks only'
],
currentCost: data.cost,
recommendedCost: estimatedFlashCost
});
}
}
// 2. Provider Cost Analysis
const providerCosts = usageLogs.reduce((acc, log) => {
if (log.cost_total === 0) return acc;
if (!acc[log.provider]) {
acc[log.provider] = { cost: 0, calls: 0, avgCostPerCall: 0 };
}
acc[log.provider].cost += log.cost_total;
acc[log.provider].calls += 1;
acc[log.provider].avgCostPerCall = acc[log.provider].cost / acc[log.provider].calls;
return acc;
}, {} as Record<string, { cost: number; calls: number; avgCostPerCall: number }>);
// Find expensive providers
const sortedProviders = Object.entries(providerCosts)
.sort(([, a], [, b]) => b.avgCostPerCall - a.avgCostPerCall);
if (sortedProviders.length > 1) {
const [mostExpensive, secondMost] = sortedProviders;
const [expensiveProvider, expensiveData] = mostExpensive;
const [alternativeProvider, alternativeData] = secondMost;
// If expensive provider has significantly higher cost per call
if (expensiveData.avgCostPerCall > alternativeData.avgCostPerCall * 1.5 && expensiveData.calls > 10) {
const estimatedAlternativeCost = expensiveData.calls * alternativeData.avgCostPerCall;
const savings = expensiveData.cost - estimatedAlternativeCost;
if (savings > 0.01) {
recs.push({
id: `provider-switch-${expensiveProvider}`,
title: `Consider ${alternativeProvider} for Some Operations`,
description: `${expensiveProvider} costs $${expensiveData.avgCostPerCall.toFixed(4)} per call on average, while ${alternativeProvider} costs $${alternativeData.avgCostPerCall.toFixed(4)}. Consider switching for non-critical operations.`,
potentialSavings: savings,
savingsPercentage: (savings / expensiveData.cost) * 100,
category: 'provider_switch',
priority: 'medium',
actionItems: [
`Use ${alternativeProvider} for batch operations`,
`Reserve ${expensiveProvider} for high-priority tasks only`,
'Review operation requirements to identify switchable tasks'
],
currentCost: expensiveData.cost,
recommendedCost: estimatedAlternativeCost
});
}
}
}
// 3. Usage Pattern Analysis - High Token Usage
const highTokenUsage = usageLogs.filter(log =>
log.tokens_total > 10000 && log.cost_total > 0.01
);
if (highTokenUsage.length > 5) {
const totalHighTokenCost = highTokenUsage.reduce((sum, log) => sum + log.cost_total, 0);
const avgTokensPerCall = highTokenUsage.reduce((sum, log) => sum + log.tokens_total, 0) / highTokenUsage.length;
recs.push({
id: 'optimize-high-token-usage',
title: 'Optimize High Token Usage Operations',
description: `You have ${highTokenUsage.length} operations using >10K tokens each. Consider breaking down large requests or using more efficient prompts.`,
potentialSavings: totalHighTokenCost * 0.15, // Estimate 15% savings
savingsPercentage: 15,
category: 'usage_pattern',
priority: 'medium',
actionItems: [
'Break down large requests into smaller chunks',
'Use more concise prompts',
'Implement result caching for repeated queries',
`Average tokens per call: ${Math.round(avgTokensPerCall).toLocaleString()}`
],
currentCost: totalHighTokenCost,
recommendedCost: totalHighTokenCost * 0.85
});
}
// 4. Efficiency - Failed Requests
const failedRequests = usageLogs.filter(log => log.status === 'failed' && log.cost_total > 0);
if (failedRequests.length > 0) {
const failedCost = failedRequests.reduce((sum, log) => sum + log.cost_total, 0);
const failureRate = (failedRequests.length / usageLogs.length) * 100;
if (failureRate > 5) { // More than 5% failure rate
recs.push({
id: 'reduce-failed-requests',
title: 'Reduce Failed API Requests',
description: `${failedRequests.length} requests failed (${failureRate.toFixed(1)}% failure rate), costing $${failedCost.toFixed(4)}. Improve error handling and retry logic.`,
potentialSavings: failedCost * 0.8, // Can save 80% by preventing failures
savingsPercentage: 80,
category: 'efficiency',
priority: 'high',
actionItems: [
'Review and fix error-prone operations',
'Implement better retry logic',
'Add input validation before API calls',
'Monitor error patterns and address root causes'
],
currentCost: failedCost,
recommendedCost: failedCost * 0.2
});
}
}
// Sort by priority and potential savings
return recs.sort((a, b) => {
const priorityOrder = { high: 3, medium: 2, low: 1 };
if (priorityOrder[a.priority] !== priorityOrder[b.priority]) {
return priorityOrder[b.priority] - priorityOrder[a.priority];
}
return b.potentialSavings - a.potentialSavings;
});
}, [usageLogs]);
const totalPotentialSavings = recommendations.reduce((sum, rec) => sum + rec.potentialSavings, 0);
const totalCurrentCost = usageLogs.reduce((sum, log) => sum + log.cost_total, 0);
if (loading) {
return (
<Card sx={{ p: 3, textAlign: 'center' }}>
<CircularProgress />
<Typography sx={{ mt: 2, color: 'rgba(255,255,255,0.7)' }}>
Analyzing usage patterns...
</Typography>
</Card>
);
}
if (error) {
return (
<Card sx={{ p: 3 }}>
<Alert severity="error">{error}</Alert>
</Card>
);
}
if (recommendations.length === 0) {
return (
<Card
sx={{
height: '100%',
background: 'linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.05) 100%)',
backdropFilter: 'blur(10px)',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: 3,
}}
>
<CardContent sx={{ textAlign: 'center', py: 4 }}>
<Sparkles size={48} color="#22c55e" style={{ marginBottom: 16 }} />
<Typography variant="h6" sx={{ fontWeight: 'bold', color: '#ffffff', mb: 1 }}>
Great Job!
</Typography>
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.7)' }}>
Your usage patterns are already optimized. No recommendations at this time.
</Typography>
</CardContent>
</Card>
);
}
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
<Card
sx={{
height: '100%',
background: 'linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.05) 100%)',
backdropFilter: 'blur(10px)',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: 3,
}}
>
<CardContent sx={{ pb: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6" sx={{ display: 'flex', alignItems: 'center', gap: 1, fontWeight: 'bold', color: '#ffffff' }}>
<Lightbulb size={20} />
Cost Optimization Recommendations
</Typography>
<Tooltip title="AI-powered suggestions to reduce your API costs">
<Info size={16} color="rgba(255,255,255,0.7)" />
</Tooltip>
</Box>
{/* Summary */}
<Box
sx={{
p: 2.5,
mb: 3,
background: 'linear-gradient(135deg, rgba(34, 197, 94, 0.15) 0%, rgba(22, 163, 74, 0.1) 100%)',
borderRadius: 2,
border: '1px solid rgba(34, 197, 94, 0.3)',
textAlign: 'center'
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 1, mb: 1 }}>
<TrendingDown size={24} color="#22c55e" />
<Typography variant="h5" sx={{ fontWeight: 'bold', color: '#22c55e' }}>
{formatCurrency(totalPotentialSavings)}
</Typography>
</Box>
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.9)', mb: 0.5 }}>
Potential Monthly Savings
</Typography>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.7)' }}>
{recommendations.length} recommendation{recommendations.length !== 1 ? 's' : ''}
{totalCurrentCost > 0 ? ` ${((totalPotentialSavings / totalCurrentCost) * 100).toFixed(1)}% of current spending` : ''}
</Typography>
</Box>
</CardContent>
<CardContent sx={{ pt: 0 }}>
{/* Recommendations List */}
{recommendations.map((rec, index) => (
<Accordion
key={rec.id}
sx={{
mb: 2,
backgroundColor: 'rgba(255,255,255,0.05)',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: 2,
'&:before': { display: 'none' },
boxShadow: 'none'
}}
>
<AccordionSummary
expandIcon={<ExpandMore sx={{ color: 'rgba(255,255,255,0.7)' }} />}
sx={{ px: 2, py: 1.5 }}
>
<Box sx={{ width: '100%', display: 'flex', alignItems: 'center', gap: 2 }}>
<Box sx={{ flex: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 'bold', color: '#ffffff' }}>
{rec.title}
</Typography>
<Chip
label={rec.priority}
size="small"
sx={{
backgroundColor: rec.priority === 'high' ? 'rgba(239, 68, 68, 0.2)' :
rec.priority === 'medium' ? 'rgba(245, 158, 11, 0.2)' :
'rgba(100, 116, 139, 0.2)',
color: rec.priority === 'high' ? '#ef4444' :
rec.priority === 'medium' ? '#f59e0b' :
'#64748b',
fontWeight: 'bold',
fontSize: '0.7rem',
height: 20
}}
/>
</Box>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.7)', display: 'block' }}>
{rec.description}
</Typography>
</Box>
<Box sx={{ textAlign: 'right', minWidth: 120 }}>
<Typography variant="h6" sx={{ fontWeight: 'bold', color: '#22c55e' }}>
{formatCurrency(rec.potentialSavings)}
</Typography>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.6)' }}>
{rec.savingsPercentage.toFixed(1)}% savings
</Typography>
</Box>
</Box>
</AccordionSummary>
<AccordionDetails sx={{ px: 2, pb: 2 }}>
<Box sx={{ mb: 2 }}>
<Grid container spacing={2}>
<Grid item xs={6}>
<Box sx={{ p: 1.5, backgroundColor: 'rgba(239, 68, 68, 0.1)', borderRadius: 1 }}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.7)' }}>
Current Cost
</Typography>
<Typography variant="body2" sx={{ fontWeight: 'bold', color: '#ef4444' }}>
{formatCurrency(rec.currentCost)}
</Typography>
</Box>
</Grid>
<Grid item xs={6}>
<Box sx={{ p: 1.5, backgroundColor: 'rgba(34, 197, 94, 0.1)', borderRadius: 1 }}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.7)' }}>
Recommended Cost
</Typography>
<Typography variant="body2" sx={{ fontWeight: 'bold', color: '#22c55e' }}>
{formatCurrency(rec.recommendedCost)}
</Typography>
</Box>
</Grid>
</Grid>
</Box>
<Typography variant="subtitle2" sx={{ fontWeight: 'bold', mb: 1, color: '#ffffff' }}>
Action Items:
</Typography>
<Box component="ul" sx={{ m: 0, pl: 2 }}>
{rec.actionItems.map((item, idx) => (
<li key={idx}>
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.8)', mb: 0.5 }}>
{item}
</Typography>
</li>
))}
</Box>
</AccordionDetails>
</Accordion>
))}
</CardContent>
</Card>
</motion.div>
);
};
export default CostOptimizationRecommendations;

View File

@@ -0,0 +1,243 @@
import React, { Suspense, useMemo } from 'react';
import {
Card,
CardContent,
Typography,
Box,
Chip,
Tooltip as MuiTooltip,
} from '@mui/material';
import { motion } from 'framer-motion';
import {
TrendingUp,
TrendingDown,
AlertTriangle
} from 'lucide-react';
import {
LazyLineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip as RechartsTooltip,
ResponsiveContainer,
ReferenceLine,
ChartLoadingFallback
} from '../../utils/lazyRecharts';
// Types
import { UsageTrends as UsageTrendsType, CostProjections } from '../../types/billing';
// Utils
import { formatCurrency } from '../../services/billingService';
interface CostVelocityChartProps {
trends: UsageTrendsType;
projections: CostProjections;
monthlyLimit: number;
}
/**
* CostVelocityChart - Shows daily spending rate (cost velocity) over time
* with projected monthly cost and budget limit annotations
*/
const CostVelocityChart: React.FC<CostVelocityChartProps> = ({
trends,
projections,
monthlyLimit
}) => {
// Calculate daily spending rate for each period
const velocityData = useMemo(() => {
if (!trends.periods || trends.periods.length === 0) {
return [];
}
const data = [];
const currentDate = new Date();
const daysInMonth = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 0).getDate();
for (let i = 0; i < trends.periods.length; i++) {
const period = trends.periods[i];
const cost = trends.total_cost[i] || 0;
// Parse period (assuming format like "2025-01" or day number)
// For monthly periods, calculate daily average
const dayNumber = i + 1; // Approximate day in month
const dailyRate = dayNumber > 0 ? cost / dayNumber : 0;
const projectedMonthly = dailyRate * daysInMonth;
data.push({
period,
day: dayNumber,
dailyRate,
projectedMonthly,
actualCost: cost
});
}
return data;
}, [trends]);
// Calculate 7-day moving average
const movingAverageData = useMemo(() => {
if (velocityData.length === 0) return [];
const windowSize = Math.min(7, velocityData.length);
return velocityData.map((point, index) => {
const start = Math.max(0, index - windowSize + 1);
const window = velocityData.slice(start, index + 1);
const avg = window.reduce((sum, p) => sum + p.dailyRate, 0) / window.length;
return { ...point, movingAvg: avg };
});
}, [velocityData]);
// Current velocity metrics
const currentVelocity = velocityData.length > 0
? velocityData[velocityData.length - 1].dailyRate
: 0;
const projectedCost = projections.projected_monthly_cost || 0;
const isOverBudget = projectedCost > monthlyLimit;
// Custom tooltip
const CustomTooltip = ({ active, payload, label }: any) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
return (
<Box
sx={{
backgroundColor: 'rgba(0, 0, 0, 0.8)',
color: 'white',
padding: 2,
borderRadius: 2,
border: '1px solid rgba(255,255,255,0.1)'
}}
>
<Typography variant="body2" sx={{ fontWeight: 'bold', mb: 1 }}>
Day {data.day}
</Typography>
<Typography variant="body2">
Daily Rate: {formatCurrency(data.dailyRate)}
</Typography>
<Typography variant="body2">
7-Day Avg: {formatCurrency(data.movingAvg || 0)}
</Typography>
<Typography variant="body2">
Projected Monthly: {formatCurrency(data.projectedMonthly)}
</Typography>
</Box>
);
}
return null;
};
if (velocityData.length === 0) {
return null;
}
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
<Card
sx={{
height: '100%',
background: 'linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.05) 100%)',
backdropFilter: 'blur(10px)',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: 3,
}}
>
<CardContent>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6" sx={{ fontWeight: 'bold', color: '#ffffff' }}>
Cost Velocity Trend
</Typography>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
{isOverBudget && (
<Chip
icon={<AlertTriangle size={14} />}
label="Over Budget"
color="error"
size="small"
/>
)}
<Chip
icon={currentVelocity > (monthlyLimit / 30) ? <TrendingUp size={14} /> : <TrendingDown size={14} />}
label={`${formatCurrency(currentVelocity)}/day`}
color={isOverBudget ? 'error' : 'default'}
size="small"
/>
</Box>
</Box>
<Box sx={{ mb: 2 }}>
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.7)', mb: 0.5 }}>
Projected Monthly Cost: <strong style={{ color: isOverBudget ? '#ef4444' : '#4ade80' }}>
{formatCurrency(projectedCost)}
</strong>
</Typography>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.6)' }}>
Based on current daily spending rate
</Typography>
</Box>
<Suspense fallback={<ChartLoadingFallback />}>
<ResponsiveContainer width="100%" height={300}>
<LazyLineChart data={movingAverageData} margin={{ top: 5, right: 20, bottom: 5, left: 10 }}>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.1)" />
<XAxis
dataKey="day"
stroke="rgba(255,255,255,0.7)"
tick={{ fill: 'rgba(255,255,255,0.7)', fontSize: 12 }}
/>
<YAxis
stroke="rgba(255,255,255,0.7)"
tick={{ fill: 'rgba(255,255,255,0.7)', fontSize: 12 }}
tickFormatter={(value) => formatCurrency(value)}
/>
<RechartsTooltip content={<CustomTooltip />} />
{/* Daily Rate Line */}
<Line
type="monotone"
dataKey="dailyRate"
stroke="#3b82f6"
strokeWidth={2}
dot={{ fill: '#3b82f6', r: 4 }}
name="Daily Rate"
animationDuration={1000}
animationBegin={0}
/>
{/* 7-Day Moving Average */}
<Line
type="monotone"
dataKey="movingAvg"
stroke="#4ade80"
strokeWidth={2}
strokeDasharray="5 5"
dot={false}
name="7-Day Avg"
animationDuration={1000}
animationBegin={200}
/>
{/* Budget Limit Reference Line */}
<ReferenceLine
y={monthlyLimit / 30}
stroke="#ef4444"
strokeDasharray="3 3"
label={{ value: "Budget Limit", position: "right", fill: "#ef4444" }}
/>
</LazyLineChart>
</ResponsiveContainer>
</Suspense>
</CardContent>
</Card>
</motion.div>
);
};
export default CostVelocityChart;

View File

@@ -0,0 +1,231 @@
import React, { Suspense, useMemo } from 'react';
import {
Card,
CardContent,
Typography,
Box,
Tooltip as MuiTooltip,
} from '@mui/material';
import { motion } from 'framer-motion';
import { Calendar } from 'lucide-react';
import { formatCurrency } from '../../services/billingService';
import { UsageLog } from '../../types/billing';
interface DailyCostHeatmapProps {
usageLogs: UsageLog[];
currentMonth: number;
currentYear: number;
}
/**
* DailyCostHeatmap - Calendar-style heatmap showing cost patterns by day
*
* Visualizes daily spending patterns to identify high-cost days
*/
const DailyCostHeatmap: React.FC<DailyCostHeatmapProps> = ({
usageLogs,
currentMonth,
currentYear
}) => {
// Aggregate logs by day
const dailyCosts = useMemo(() => {
const costs: Record<number, number> = {};
usageLogs.forEach(log => {
const logDate = new Date(log.timestamp);
if (logDate.getMonth() === currentMonth && logDate.getFullYear() === currentYear) {
const day = logDate.getDate();
costs[day] = (costs[day] || 0) + (log.cost_total || 0);
}
});
return costs;
}, [usageLogs, currentMonth, currentYear]);
// Get days in month
const daysInMonth = useMemo(() => {
return new Date(currentYear, currentMonth + 1, 0).getDate();
}, [currentMonth, currentYear]);
// Calculate max cost for color scaling
const maxCost = useMemo(() => {
return Math.max(...Object.values(dailyCosts), 0.01);
}, [dailyCosts]);
// Get color intensity based on cost
const getColorIntensity = (cost: number) => {
if (cost === 0) return 'rgba(255,255,255,0.05)';
const intensity = Math.min(cost / maxCost, 1);
// Green (low) to Red (high)
if (intensity < 0.3) {
return `rgba(34, 197, 94, ${0.3 + intensity * 0.4})`; // Green
} else if (intensity < 0.7) {
return `rgba(234, 179, 8, ${0.5 + (intensity - 0.3) * 0.3})`; // Yellow
} else {
return `rgba(239, 68, 68, ${0.6 + (intensity - 0.7) * 0.4})`; // Red
}
};
// Generate calendar grid
const calendarDays = useMemo(() => {
const firstDay = new Date(currentYear, currentMonth, 1).getDay();
const days: Array<{ day: number; cost: number; date: Date | null }> = [];
// Add empty cells for days before month starts
for (let i = 0; i < firstDay; i++) {
days.push({ day: 0, cost: 0, date: null });
}
// Add days of month
for (let day = 1; day <= daysInMonth; day++) {
const date = new Date(currentYear, currentMonth, day);
days.push({
day,
cost: dailyCosts[day] || 0,
date
});
}
return days;
}, [currentMonth, currentYear, daysInMonth, dailyCosts]);
if (usageLogs.length === 0) {
return null;
}
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
<Card
sx={{
height: '100%',
background: 'linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.05) 100%)',
backdropFilter: 'blur(10px)',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: 3,
}}
>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<Calendar size={20} color="#4ade80" />
<Typography variant="h6" sx={{ fontWeight: 'bold', color: '#ffffff' }}>
Daily Cost Heatmap
</Typography>
</Box>
<Box sx={{ mb: 2 }}>
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.7)', mb: 1 }}>
Cost intensity by day of month
</Typography>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', fontSize: '0.75rem' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Box sx={{ width: 12, height: 12, backgroundColor: 'rgba(34, 197, 94, 0.5)', borderRadius: 1 }} />
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.7)' }}>Low</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Box sx={{ width: 12, height: 12, backgroundColor: 'rgba(234, 179, 8, 0.7)', borderRadius: 1 }} />
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.7)' }}>Medium</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Box sx={{ width: 12, height: 12, backgroundColor: 'rgba(239, 68, 68, 0.8)', borderRadius: 1 }} />
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.7)' }}>High</Typography>
</Box>
</Box>
</Box>
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'repeat(7, 1fr)',
gap: 1,
mb: 1
}}
>
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map(day => (
<Typography
key={day}
variant="caption"
sx={{
textAlign: 'center',
color: 'rgba(255,255,255,0.6)',
fontWeight: 600,
fontSize: '0.7rem'
}}
>
{day}
</Typography>
))}
</Box>
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'repeat(7, 1fr)',
gap: 1
}}
>
{calendarDays.map((item, index) => (
<MuiTooltip
key={index}
title={
item.date ? (
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 'bold', mb: 0.5 }}>
{item.date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
</Typography>
<Typography variant="body2">
Cost: {formatCurrency(item.cost)}
</Typography>
</Box>
) : (
'No data'
)
}
arrow
placement="top"
>
<Box
sx={{
aspectRatio: '1',
backgroundColor: getColorIntensity(item.cost),
borderRadius: 1,
border: item.cost > 0 ? '1px solid rgba(255,255,255,0.2)' : '1px solid rgba(255,255,255,0.05)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: item.date ? 'pointer' : 'default',
transition: 'all 0.2s ease',
'&:hover': item.date ? {
transform: 'scale(1.1)',
zIndex: 1,
boxShadow: '0 4px 12px rgba(0,0,0,0.3)'
} : {},
position: 'relative'
}}
>
{item.day > 0 && (
<Typography
variant="caption"
sx={{
color: item.cost > maxCost * 0.5 ? '#ffffff' : 'rgba(255,255,255,0.8)',
fontSize: '0.65rem',
fontWeight: item.cost > 0 ? 600 : 400
}}
>
{item.day}
</Typography>
)}
</Box>
</MuiTooltip>
))}
</Box>
</CardContent>
</Card>
</motion.div>
);
};
export default DailyCostHeatmap;

View File

@@ -38,6 +38,13 @@ import CostBreakdown from './CostBreakdown';
import UsageTrends from './UsageTrends';
import UsageAlerts from './UsageAlerts';
import ComprehensiveAPIBreakdown from './ComprehensiveAPIBreakdown';
import ToolCostBreakdown from './ToolCostBreakdown';
import CostOptimizationRecommendations from './CostOptimizationRecommendations';
import AdvancedCostAnalytics from './AdvancedCostAnalytics';
import DailyCostHeatmap from './DailyCostHeatmap';
import LiveCostCounter from './LiveCostCounter';
import ErrorRateGauge from './ErrorRateGauge';
import MultiSeriesCostChart from './MultiSeriesCostChart';
// Terminal Theme
import {
@@ -62,29 +69,77 @@ const EnhancedBillingDashboard: React.FC<EnhancedBillingDashboardProps> = ({ use
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<ViewMode>('compact');
const [lastRefreshTime, setLastRefreshTime] = useState<Date | null>(null);
const [healthError, setHealthError] = useState<string | null>(null);
const fetchDashboardData = async (showSuccessToast: boolean = false) => {
try {
const [billingData, healthData] = await Promise.all([
// Use Promise.allSettled to prevent health check timeout from blocking dashboard
const results = await Promise.allSettled([
billingService.getDashboardData(),
monitoringService.getSystemHealth()
]);
setDashboardData(billingData);
setSystemHealth(healthData);
// Handle billing data (required)
if (results[0].status === 'fulfilled') {
setDashboardData(results[0].value);
setLastRefreshTime(new Date());
setError(null); // Clear any previous errors
} else {
// Billing data is critical - show error
const errorMessage = results[0].reason instanceof Error
? results[0].reason.message
: 'Failed to fetch dashboard data';
setError(errorMessage);
showToastNotification(
`Unable to fetch latest billing data: ${errorMessage}. Showing last known values.`,
'error',
{ duration: 7000 }
);
setLoading(false);
return;
}
// Handle health data (optional - don't block dashboard if it fails)
if (results[1].status === 'fulfilled') {
setSystemHealth(results[1].value);
setHealthError(null); // Clear health error on success
} else {
// Health check failed - keep last successful value, show error toast
const healthErrorMessage = results[1].reason instanceof Error
? results[1].reason.message
: 'System health check timed out or failed';
setHealthError(healthErrorMessage);
showToastNotification(
`Unable to fetch latest system health data: ${healthErrorMessage}. Showing last known values.`,
'warning',
{ duration: 6000 }
);
// Don't update systemHealth - keep last successful value
// Only set to null if we never had a successful fetch
if (!systemHealth) {
setSystemHealth(null);
}
}
// Show success toast only if explicitly requested (user-initiated refresh)
if (showSuccessToast && billingData && healthData) {
if (showSuccessToast && results[0].status === 'fulfilled' && results[1].status === 'fulfilled') {
showToastNotification(
'Billing data refreshed successfully',
'success',
{ duration: 3000 }
);
} else if (showSuccessToast && results[0].status === 'fulfilled' && results[1].status === 'rejected') {
showToastNotification(
'Billing data refreshed, but system health check failed',
'warning',
{ duration: 4000 }
);
}
} catch (error) {
// Fallback error handling (shouldn't reach here with allSettled)
const errorMessage = error instanceof Error ? error.message : 'Failed to fetch dashboard data';
setError(errorMessage);
// Always show error toast for failures
showToastNotification(errorMessage, 'error', { duration: 5000 });
} finally {
setLoading(false);
@@ -99,10 +154,24 @@ const EnhancedBillingDashboard: React.FC<EnhancedBillingDashboardProps> = ({ use
useEffect(() => {
const unsubscribe = onApiEvent((detail) => {
if (detail.source && detail.source !== 'other') return;
Promise.all([billingService.getDashboardData(), monitoringService.getSystemHealth()])
.then(([billingData, health]) => {
setDashboardData(billingData);
setSystemHealth(health);
Promise.allSettled([billingService.getDashboardData(), monitoringService.getSystemHealth()])
.then((results) => {
if (results[0].status === 'fulfilled') {
setDashboardData(results[0].value);
setLastRefreshTime(new Date());
setError(null);
}
if (results[1].status === 'fulfilled') {
setSystemHealth(results[1].value);
setHealthError(null);
} else {
// Keep last successful health value, don't set fake defaults
const healthErrorMessage = results[1].reason instanceof Error
? results[1].reason.message
: 'System health check failed';
setHealthError(healthErrorMessage);
// Don't update systemHealth - keep last successful value
}
})
.catch(() => {/* ignore */});
});
@@ -124,16 +193,39 @@ const EnhancedBillingDashboard: React.FC<EnhancedBillingDashboardProps> = ({ use
useEffect(() => {
const handleBillingRefresh = () => {
console.log('EnhancedBillingDashboard: Billing refresh requested, refreshing data...');
// Use a fresh call to fetchDashboardData to ensure we get latest data
Promise.all([billingService.getDashboardData(), monitoringService.getSystemHealth()])
.then(([billingData, healthData]) => {
setDashboardData(billingData);
setSystemHealth(healthData);
// Use allSettled to prevent health check from blocking refresh
Promise.allSettled([billingService.getDashboardData(), monitoringService.getSystemHealth()])
.then((results) => {
if (results[0].status === 'fulfilled') {
setDashboardData(results[0].value);
setLastRefreshTime(new Date());
setError(null);
} else {
const errorMessage = results[0].reason instanceof Error
? results[0].reason.message
: 'Failed to refresh billing data';
setError(errorMessage);
showToastNotification(
`Unable to refresh billing data: ${errorMessage}. Showing last known values.`,
'error',
{ duration: 6000 }
);
console.error('Error refreshing billing data:', results[0].reason);
}
if (results[1].status === 'fulfilled') {
setSystemHealth(results[1].value);
setHealthError(null);
} else {
// Keep last successful health value, don't set fake defaults
const healthErrorMessage = results[1].reason instanceof Error
? results[1].reason.message
: 'System health check failed';
setHealthError(healthErrorMessage);
// Don't update systemHealth - keep last successful value
}
})
.catch((error) => {
const errorMessage = error instanceof Error ? error.message : 'Failed to refresh billing data';
setError(errorMessage);
console.error('Error refreshing billing data:', error);
console.error('Unexpected error in billing refresh:', error);
});
};
@@ -227,55 +319,73 @@ const EnhancedBillingDashboard: React.FC<EnhancedBillingDashboardProps> = ({ use
{dashboardData && (
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
{Object.entries(dashboardData.current_usage.provider_breakdown)
.filter(([_, data]) => data.cost > 0)
.map(([provider, data]) => (
<Tooltip
key={provider}
title={
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 'bold', mb: 0.5 }}>
{provider.toUpperCase()} Usage
</Typography>
<Typography variant="body2" sx={{ opacity: 0.9 }}>
Cost: ${data.cost.toFixed(4)}
</Typography>
<Typography variant="body2" sx={{ opacity: 0.9 }}>
Calls: {data.calls.toLocaleString()}
</Typography>
<Typography variant="body2" sx={{ opacity: 0.9 }}>
Tokens: {data.tokens.toLocaleString()}
</Typography>
</Box>
}
arrow
placement="top"
>
<Chip
label={`${provider}: $${data.cost.toFixed(4)}`}
size="small"
sx={{
backgroundColor: 'rgba(74, 222, 128, 0.2)',
color: '#4ade80',
border: '1px solid rgba(74, 222, 128, 0.3)',
fontSize: '0.7rem',
height: 24,
fontWeight: 500,
'&:hover': {
backgroundColor: 'rgba(74, 222, 128, 0.3)',
transform: 'translateY(-1px)',
boxShadow: '0 4px 12px rgba(74, 222, 128, 0.2)'
},
transition: 'all 0.2s ease'
}}
/>
</Tooltip>
))}
.filter(([_, data]) => data && data.cost > 0)
.map(([provider, data]) => {
const providerData = data!; // Safe after filter
return (
<Tooltip
key={provider}
title={
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 'bold', mb: 0.5 }}>
{provider.toUpperCase()} Usage
</Typography>
<Typography variant="body2" sx={{ opacity: 0.9 }}>
Cost: ${(providerData.cost ?? 0).toFixed(4)}
</Typography>
<Typography variant="body2" sx={{ opacity: 0.9 }}>
Calls: {(providerData.calls ?? 0).toLocaleString()}
</Typography>
<Typography variant="body2" sx={{ opacity: 0.9 }}>
Tokens: {(providerData.tokens ?? 0).toLocaleString()}
</Typography>
</Box>
}
arrow
placement="top"
>
<Chip
label={`${provider}: $${(providerData.cost ?? 0).toFixed(4)}`}
size="small"
sx={{
backgroundColor: 'rgba(74, 222, 128, 0.2)',
color: '#4ade80',
border: '1px solid rgba(74, 222, 128, 0.3)',
fontSize: '0.7rem',
height: 24,
fontWeight: 500,
'&:hover': {
backgroundColor: 'rgba(74, 222, 128, 0.3)',
transform: 'translateY(-1px)',
boxShadow: '0 4px 12px rgba(74, 222, 128, 0.2)'
},
transition: 'all 0.2s ease'
}}
/>
</Tooltip>
);
})}
</Box>
)}
</Box>
{/* View Mode Toggle and Refresh */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
{/* Last Refresh Timestamp */}
{lastRefreshTime && (
<Tooltip title={`Data last refreshed at ${lastRefreshTime.toLocaleTimeString()}`}>
<TypographyComponent
variant="caption"
sx={{
color: 'rgba(255,255,255,0.6)',
fontSize: '0.7rem',
fontStyle: 'italic'
}}
>
Last updated: {lastRefreshTime.toLocaleTimeString()}
</TypographyComponent>
</Tooltip>
)}
<Tooltip title="Refresh billing data">
<IconButton
size="small"
@@ -405,13 +515,54 @@ const EnhancedBillingDashboard: React.FC<EnhancedBillingDashboardProps> = ({ use
/>
</Grid>
{/* Tool-Level Cost Breakdown */}
<Grid item xs={12} md={6}>
<ToolCostBreakdown
userId={userId}
terminalTheme={terminalTheme}
/>
</Grid>
{/* Bottom Row - Comprehensive API Breakdown */}
<Grid item xs={12}>
<Grid item xs={12} md={6}>
<ComprehensiveAPIBreakdown
providerBreakdown={dashboardData.current_usage.provider_breakdown}
totalCost={dashboardData.current_usage.total_cost}
/>
</Grid>
{/* Priority 3: Cost Optimization Recommendations */}
<Grid item xs={12} md={6}>
<CostOptimizationRecommendations
userId={userId}
terminalTheme={terminalTheme}
/>
</Grid>
{/* Priority 3: Advanced Cost Analytics */}
<Grid item xs={12}>
<AdvancedCostAnalytics
userId={userId}
terminalTheme={terminalTheme}
/>
</Grid>
{/* Phase 3: Multi-Series Cost Chart */}
<Grid item xs={12} md={6}>
<MultiSeriesCostChart
trends={dashboardData.trends}
monthlyLimit={dashboardData.projections.cost_limit || 0}
/>
</Grid>
{/* Phase 3: Error Rate Gauge */}
<Grid item xs={12} md={6}>
<ErrorRateGauge
systemHealth={systemHealth}
terminalTheme={terminalTheme}
terminalColors={terminalColors}
/>
</Grid>
</Grid>
</motion.div>
)}

View File

@@ -0,0 +1,244 @@
import React, { Suspense, useMemo } from 'react';
import {
Card,
CardContent,
Typography,
Box,
Tooltip as MuiTooltip,
} from '@mui/material';
import { motion } from 'framer-motion';
import { AlertTriangle, CheckCircle, AlertCircle } from 'lucide-react';
import {
LazyPieChart,
Pie,
Cell,
ResponsiveContainer,
ChartLoadingFallback
} from '../../utils/lazyRecharts';
import { SystemHealth } from '../../types/monitoring';
interface ErrorRateGaugeProps {
systemHealth: SystemHealth | null;
terminalTheme?: boolean;
terminalColors?: any;
}
/**
* ErrorRateGauge - Semi-circular gauge showing API error rate
*
* Visual gauge with:
* - Green (0-5%): Healthy
* - Yellow (5-10%): Warning
* - Red (>10%): Critical
*/
const ErrorRateGauge: React.FC<ErrorRateGaugeProps> = ({
systemHealth,
terminalTheme = false,
terminalColors
}) => {
const errorRate = systemHealth?.error_rate || 0;
const recentRequests = systemHealth?.recent_requests || 0;
const recentErrors = systemHealth?.recent_errors || 0;
// Determine status and color
const { status, color, icon } = useMemo(() => {
if (errorRate <= 5) {
return {
status: 'Healthy',
color: terminalTheme ? (terminalColors?.success || '#22c55e') : '#22c55e',
icon: <CheckCircle size={20} color="#22c55e" />
};
} else if (errorRate <= 10) {
return {
status: 'Warning',
color: terminalTheme ? (terminalColors?.warning || '#f59e0b') : '#f59e0b',
icon: <AlertCircle size={20} color="#f59e0b" />
};
} else {
return {
status: 'Critical',
color: terminalTheme ? (terminalColors?.error || '#ef4444') : '#ef4444',
icon: <AlertTriangle size={20} color="#ef4444" />
};
}
}, [errorRate, terminalTheme, terminalColors]);
// Data for semi-circular gauge (using Pie chart)
const gaugeData = [
{ name: 'Errors', value: errorRate, fill: color },
{ name: 'Success', value: Math.max(0, 100 - errorRate), fill: 'rgba(255,255,255,0.1)' }
];
return (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.4 }}
>
<Card
sx={{
height: '100%',
background: terminalTheme
? (terminalColors?.background || 'rgba(0,0,0,0.8)')
: 'linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.05) 100%)',
backdropFilter: 'blur(10px)',
border: `1px solid ${terminalTheme
? (terminalColors?.border || 'rgba(255,255,255,0.1)')
: 'rgba(255,255,255,0.1)'}`,
borderRadius: 3,
}}
>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
{icon}
<Typography variant="h6" sx={{ fontWeight: 'bold', color: terminalTheme ? terminalColors?.text : '#ffffff' }}>
Error Rate Gauge
</Typography>
</Box>
<Box sx={{ position: 'relative', height: 200, mb: 2 }}>
<Suspense fallback={<ChartLoadingFallback />}>
<ResponsiveContainer width="100%" height="100%">
<LazyPieChart>
<Pie
data={gaugeData}
cx="50%"
cy="80%"
startAngle={180}
endAngle={0}
innerRadius={60}
outerRadius={80}
dataKey="value"
animationDuration={1000}
animationBegin={0}
>
<Cell fill={color} />
<Cell fill={terminalTheme
? (terminalColors?.backgroundLight || 'rgba(255,255,255,0.1)')
: 'rgba(255,255,255,0.1)'}
/>
</Pie>
</LazyPieChart>
</ResponsiveContainer>
</Suspense>
{/* Center value display */}
<Box
sx={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
textAlign: 'center',
mt: 2
}}
>
<Typography
variant="h3"
sx={{
fontWeight: 800,
color: color,
textShadow: terminalTheme ? 'none' : '0 2px 8px rgba(0,0,0,0.3)'
}}
>
{errorRate.toFixed(1)}%
</Typography>
<Typography
variant="caption"
sx={{
color: terminalTheme
? (terminalColors?.textSecondary || 'rgba(255,255,255,0.7)')
: 'rgba(255,255,255,0.7)',
fontSize: '0.7rem',
textTransform: 'uppercase',
letterSpacing: '1px'
}}
>
Error Rate
</Typography>
</Box>
</Box>
{/* Stats */}
<Box sx={{ display: 'flex', justifyContent: 'space-around', gap: 2 }}>
<MuiTooltip title="Total API requests in the last 5 minutes">
<Box sx={{ textAlign: 'center' }}>
<Typography
variant="h6"
sx={{
fontWeight: 'bold',
color: terminalTheme ? terminalColors?.text : '#ffffff'
}}
>
{recentRequests.toLocaleString()}
</Typography>
<Typography
variant="caption"
sx={{
color: terminalTheme
? (terminalColors?.textSecondary || 'rgba(255,255,255,0.7)')
: 'rgba(255,255,255,0.7)',
fontSize: '0.7rem'
}}
>
Requests
</Typography>
</Box>
</MuiTooltip>
<MuiTooltip title="Failed requests in the last 5 minutes">
<Box sx={{ textAlign: 'center' }}>
<Typography
variant="h6"
sx={{
fontWeight: 'bold',
color: color
}}
>
{recentErrors.toLocaleString()}
</Typography>
<Typography
variant="caption"
sx={{
color: terminalTheme
? (terminalColors?.textSecondary || 'rgba(255,255,255,0.7)')
: 'rgba(255,255,255,0.7)',
fontSize: '0.7rem'
}}
>
Errors
</Typography>
</Box>
</MuiTooltip>
<MuiTooltip title="System health status">
<Box sx={{ textAlign: 'center' }}>
<Typography
variant="h6"
sx={{
fontWeight: 'bold',
color: color,
textTransform: 'capitalize'
}}
>
{status}
</Typography>
<Typography
variant="caption"
sx={{
color: terminalTheme
? (terminalColors?.textSecondary || 'rgba(255,255,255,0.7)')
: 'rgba(255,255,255,0.7)',
fontSize: '0.7rem'
}}
>
Status
</Typography>
</Box>
</MuiTooltip>
</Box>
</CardContent>
</Card>
</motion.div>
);
};
export default ErrorRateGauge;

View File

@@ -0,0 +1,154 @@
import React, { useEffect, useState } from 'react';
import { Box, Typography } from '@mui/material';
import { motion, useSpring, useTransform } from 'framer-motion';
import { DollarSign, TrendingUp, TrendingDown } from 'lucide-react';
import { AnimatedNumber } from '../shared/AnimatedNumber';
import { formatCurrency } from '../../services/billingService';
interface LiveCostCounterProps {
currentCost: number;
previousCost?: number;
velocity?: number; // Daily spending rate
terminalTheme?: boolean;
terminalColors?: any;
TypographyComponent?: React.ComponentType<any>;
}
/**
* LiveCostCounter - Animated counter showing real-time cost accumulation
*
* Features:
* - Smooth number animation
* - Velocity indicator (trending up/down)
* - Pulse animation on cost increase
* - Color changes based on velocity
*/
const LiveCostCounter: React.FC<LiveCostCounterProps> = ({
currentCost,
previousCost,
velocity = 0,
terminalTheme = false,
terminalColors,
TypographyComponent = Typography
}) => {
const [hasIncreased, setHasIncreased] = useState(false);
const [pulseKey, setPulseKey] = useState(0);
// Detect cost increase
useEffect(() => {
if (previousCost !== undefined && currentCost > previousCost) {
setHasIncreased(true);
setPulseKey(prev => prev + 1);
const timer = setTimeout(() => setHasIncreased(false), 1000);
return () => clearTimeout(timer);
}
}, [currentCost, previousCost]);
// Calculate velocity trend
const isIncreasing = velocity > 0;
const velocityPercent = Math.abs(velocity);
// Color based on velocity
const getColor = () => {
if (terminalTheme) {
if (velocityPercent > 20) return terminalColors?.error || '#ef4444';
if (velocityPercent > 10) return terminalColors?.warning || '#f59e0b';
return terminalColors?.success || '#22c55e';
}
if (velocityPercent > 20) return '#ef4444';
if (velocityPercent > 10) return '#f59e0b';
return '#22c55e';
};
return (
<motion.div
key={pulseKey}
animate={hasIncreased ? {
scale: [1, 1.05, 1],
} : {}}
transition={{ duration: 0.5 }}
>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
p: 3,
...(terminalTheme ? {
backgroundColor: terminalColors?.backgroundLight,
borderRadius: 3,
border: `1px solid ${terminalColors?.border || 'rgba(255,255,255,0.1)'}`
} : {
background: 'linear-gradient(135deg, rgba(102, 126, 234, 0.15) 0%, rgba(79, 70, 229, 0.1) 100%)',
borderRadius: 3,
border: '1px solid rgba(102, 126, 234, 0.3)'
})
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<DollarSign
size={24}
color={terminalTheme ? (terminalColors?.text || '#ffffff') : '#667eea'}
/>
<TypographyComponent
variant="caption"
sx={{
color: terminalTheme
? (terminalColors?.textSecondary || 'rgba(255,255,255,0.7)')
: 'rgba(255,255,255,0.7)',
textTransform: 'uppercase',
letterSpacing: '1px',
fontSize: '0.7rem',
fontWeight: 600
}}
>
Live Cost
</TypographyComponent>
</Box>
<TypographyComponent
variant="h3"
sx={{
fontWeight: 800,
color: getColor(),
mb: 1,
textShadow: terminalTheme ? 'none' : '0 2px 8px rgba(0,0,0,0.3)',
display: 'flex',
alignItems: 'baseline',
gap: 0.5
}}
>
<AnimatedNumber
value={currentCost}
format={formatCurrency}
decimals={4}
duration={0.8}
/>
</TypographyComponent>
{previousCost !== undefined && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{isIncreasing ? (
<TrendingUp size={14} color={getColor()} />
) : (
<TrendingDown size={14} color={getColor()} />
)}
<TypographyComponent
variant="caption"
sx={{
color: getColor(),
fontWeight: 600,
fontSize: '0.75rem'
}}
>
{isIncreasing ? '+' : ''}{velocityPercent.toFixed(1)}% daily rate
</TypographyComponent>
</Box>
)}
</Box>
</motion.div>
);
};
export default LiveCostCounter;

View File

@@ -0,0 +1,210 @@
import React, { Suspense, useMemo } from 'react';
import {
Card,
CardContent,
Typography,
Box,
Chip,
Tooltip as MuiTooltip,
} from '@mui/material';
import { motion } from 'framer-motion';
import {
TrendingUp,
TrendingDown,
DollarSign
} from 'lucide-react';
import {
LazyComposedChart,
Area,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip as RechartsTooltip,
ResponsiveContainer,
Legend,
ChartLoadingFallback
} from '../../utils/lazyRecharts';
// Types
import { UsageTrends as UsageTrendsType } from '../../types/billing';
// Utils
import { formatCurrency } from '../../services/billingService';
interface MultiSeriesCostChartProps {
trends: UsageTrendsType;
monthlyLimit?: number;
}
/**
* MultiSeriesCostChart - Multi-series area chart showing cost breakdown over time
* Displays total cost, provider-specific costs, and budget limit
*/
const MultiSeriesCostChart: React.FC<MultiSeriesCostChartProps> = ({
trends,
monthlyLimit
}) => {
// Transform data for multi-series chart
const chartData = useMemo(() => {
if (!trends.periods || trends.periods.length === 0) {
return [];
}
return trends.periods.map((period, index) => ({
period,
totalCost: trends.total_cost[index] || 0,
calls: trends.total_calls[index] || 0,
tokens: trends.total_tokens[index] || 0,
}));
}, [trends]);
// Calculate trend
const costTrend = useMemo(() => {
if (chartData.length < 2) return 0;
const first = chartData[0].totalCost;
const last = chartData[chartData.length - 1].totalCost;
if (first === 0) return last > 0 ? 100 : 0;
return ((last - first) / first) * 100;
}, [chartData]);
// Custom tooltip
const CustomTooltip = ({ active, payload, label }: any) => {
if (active && payload && payload.length) {
return (
<Box
sx={{
backgroundColor: 'rgba(0, 0, 0, 0.9)',
color: 'white',
padding: 2,
borderRadius: 2,
border: '1px solid rgba(255,255,255,0.1)',
minWidth: 200
}}
>
<Typography variant="body2" sx={{ fontWeight: 'bold', mb: 1 }}>
{label}
</Typography>
{payload.map((entry: any, index: number) => (
<Box key={index} sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
<Box
sx={{
width: 12,
height: 12,
backgroundColor: entry.color,
borderRadius: '50%'
}}
/>
<Typography variant="body2" sx={{ flex: 1 }}>
{entry.name}:
</Typography>
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>
{formatCurrency(entry.value)}
</Typography>
</Box>
))}
</Box>
);
}
return null;
};
if (chartData.length === 0) {
return null;
}
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
<Card
sx={{
height: '100%',
background: 'linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.05) 100%)',
backdropFilter: 'blur(10px)',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: 3,
}}
>
<CardContent>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<DollarSign size={20} color="#4ade80" />
<Typography variant="h6" sx={{ fontWeight: 'bold', color: '#ffffff' }}>
Cost Over Time
</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<Chip
icon={costTrend >= 0 ? <TrendingUp size={14} /> : <TrendingDown size={14} />}
label={`${costTrend >= 0 ? '+' : ''}${costTrend.toFixed(1)}%`}
color={costTrend >= 0 ? 'error' : 'success'}
size="small"
/>
</Box>
</Box>
<Suspense fallback={<ChartLoadingFallback />}>
<ResponsiveContainer width="100%" height={350}>
<LazyComposedChart data={chartData} margin={{ top: 5, right: 20, bottom: 5, left: 10 }}>
<defs>
<linearGradient id="colorTotalCost" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#667eea" stopOpacity={0.8}/>
<stop offset="95%" stopColor="#667eea" stopOpacity={0}/>
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.1)" />
<XAxis
dataKey="period"
stroke="rgba(255,255,255,0.7)"
tick={{ fill: 'rgba(255,255,255,0.7)', fontSize: 12 }}
/>
<YAxis
stroke="rgba(255,255,255,0.7)"
tick={{ fill: 'rgba(255,255,255,0.7)', fontSize: 12 }}
tickFormatter={(value) => formatCurrency(value)}
/>
<RechartsTooltip content={<CustomTooltip />} />
<Legend
wrapperStyle={{ paddingTop: '20px' }}
iconType="line"
/>
{/* Total Cost Area */}
<Area
type="monotone"
dataKey="totalCost"
stroke="#667eea"
strokeWidth={3}
fillOpacity={1}
fill="url(#colorTotalCost)"
name="Total Cost"
animationDuration={1000}
animationBegin={0}
/>
{/* Budget Limit Reference Line */}
{monthlyLimit && monthlyLimit > 0 && (
<Line
type="monotone"
dataKey={() => monthlyLimit}
stroke="#ef4444"
strokeDasharray="5 5"
strokeWidth={2}
dot={false}
name="Budget Limit"
legendType="line"
/>
)}
</LazyComposedChart>
</ResponsiveContainer>
</Suspense>
</CardContent>
</Card>
</motion.div>
);
};
export default MultiSeriesCostChart;

View File

@@ -0,0 +1,171 @@
import React, { Suspense } from 'react';
import {
Box,
Typography,
Tooltip as MuiTooltip,
} from '@mui/material';
import { motion } from 'framer-motion';
import {
LazyBarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip as RechartsTooltip,
ResponsiveContainer,
Cell,
ChartLoadingFallback
} from '../../utils/lazyRecharts';
// Types
import { ProviderBreakdown } from '../../types/billing';
// Utils
import {
formatCurrency,
getProviderColor,
getProviderIcon
} from '../../services/billingService';
interface ProviderCostComparisonProps {
providerBreakdown: ProviderBreakdown;
terminalTheme?: boolean;
terminalColors?: any;
}
/**
* ProviderCostComparison - Horizontal bar chart comparing costs across providers
*
* Usage:
* <ProviderCostComparison providerBreakdown={breakdown} />
*/
const ProviderCostComparison: React.FC<ProviderCostComparisonProps> = ({
providerBreakdown,
terminalTheme = false,
terminalColors
}) => {
// Transform data for chart
const chartData = Object.entries(providerBreakdown)
.filter(([_, data]) => data && (data.cost ?? 0) > 0)
.map(([provider, data]) => ({
name: provider.charAt(0).toUpperCase() + provider.slice(1),
cost: data?.cost ?? 0,
calls: data?.calls ?? 0,
tokens: data?.tokens ?? 0,
color: getProviderColor(provider),
icon: getProviderIcon(provider)
}))
.sort((a, b) => b.cost - a.cost)
.slice(0, 5); // Top 5 providers
if (chartData.length === 0) {
return null;
}
// Custom tooltip
const CustomTooltip = ({ active, payload }: any) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
return (
<Box
sx={{
backgroundColor: 'rgba(0, 0, 0, 0.8)',
color: 'white',
padding: 2,
borderRadius: 2,
border: '1px solid rgba(255,255,255,0.1)'
}}
>
<Typography variant="body2" sx={{ fontWeight: 'bold', mb: 1 }}>
{data.icon} {data.name}
</Typography>
<Typography variant="body2">
Cost: {formatCurrency(data.cost)}
</Typography>
<Typography variant="body2">
Calls: {data.calls.toLocaleString()}
</Typography>
<Typography variant="body2">
Tokens: {data.tokens.toLocaleString()}
</Typography>
</Box>
);
}
return null;
};
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.2 }}
>
<Box sx={{ mt: 2 }}>
<Typography
variant="subtitle2"
sx={{
fontWeight: 600,
mb: 1.5,
color: terminalTheme
? (terminalColors?.text || '#ffffff')
: 'rgba(255,255,255,0.9)'
}}
>
Provider Cost Comparison
</Typography>
<Suspense fallback={<ChartLoadingFallback />}>
<ResponsiveContainer width="100%" height={200}>
<LazyBarChart
data={chartData}
layout="vertical"
margin={{ top: 5, right: 20, bottom: 5, left: 60 }}
>
<CartesianGrid strokeDasharray="3 3" stroke={terminalTheme
? (terminalColors?.border || 'rgba(255,255,255,0.1)')
: 'rgba(255,255,255,0.1)'}
/>
<XAxis
type="number"
stroke={terminalTheme
? (terminalColors?.textSecondary || 'rgba(255,255,255,0.7)')
: 'rgba(255,255,255,0.7)'}
tick={{ fill: terminalTheme
? (terminalColors?.textSecondary || 'rgba(255,255,255,0.7)')
: 'rgba(255,255,255,0.7)', fontSize: 11 }}
tickFormatter={(value) => formatCurrency(value)}
/>
<YAxis
type="category"
dataKey="name"
stroke={terminalTheme
? (terminalColors?.textSecondary || 'rgba(255,255,255,0.7)')
: 'rgba(255,255,255,0.7)'}
tick={{ fill: terminalTheme
? (terminalColors?.textSecondary || 'rgba(255,255,255,0.7)')
: 'rgba(255,255,255,0.7)', fontSize: 11 }}
width={55}
/>
<RechartsTooltip content={<CustomTooltip />} />
<Bar
dataKey="cost"
radius={[0, 4, 4, 0]}
animationDuration={800}
animationBegin={0}
>
{chartData.map((entry, index) => (
<Cell
key={`cell-${index}`}
fill={entry.color}
/>
))}
</Bar>
</LazyBarChart>
</ResponsiveContainer>
</Suspense>
</Box>
</motion.div>
);
};
export default ProviderCostComparison;

View File

@@ -0,0 +1,397 @@
import React, { useState, useEffect, useMemo } from 'react';
import {
Card,
CardContent,
Typography,
Box,
Grid,
Chip,
Tooltip,
CircularProgress,
} from '@mui/material';
import { motion } from 'framer-motion';
import {
FileText,
Image as ImageIcon,
Video,
Search,
Mic,
Code
} from 'lucide-react';
// Types
import { UsageLog } from '../../types/billing';
// Services
import { billingService } from '../../services/billingService';
import { formatCurrency, formatNumber } from '../../services/billingService';
interface ToolCostBreakdownProps {
userId?: string;
terminalTheme?: boolean;
}
// Map endpoints to tool names
const endpointToTool = (endpoint: string): string => {
const endpointLower = endpoint.toLowerCase();
if (endpointLower.includes('blog') || endpointLower.includes('blog-writer')) {
return 'Blog Writer';
}
if (endpointLower.includes('story') || endpointLower.includes('story-writer')) {
return 'Story Writer';
}
if (endpointLower.includes('podcast') || endpointLower.includes('podcast-maker')) {
return 'Podcast Maker';
}
if (endpointLower.includes('image') || endpointLower.includes('image-studio')) {
return 'Image Studio';
}
if (endpointLower.includes('video') || endpointLower.includes('video-studio')) {
return 'Video Studio';
}
if (endpointLower.includes('research') || endpointLower.includes('researcher')) {
return 'Research Tools';
}
if (endpointLower.includes('linkedin')) {
return 'LinkedIn Writer';
}
if (endpointLower.includes('facebook')) {
return 'Facebook Writer';
}
if (endpointLower.includes('seo')) {
return 'SEO Tools';
}
if (endpointLower.includes('audio') || endpointLower.includes('tts')) {
return 'Audio Generation';
}
return 'Other';
};
const getToolIcon = (tool: string) => {
const toolLower = tool.toLowerCase();
if (toolLower.includes('blog')) return <FileText size={18} />;
if (toolLower.includes('story')) return <FileText size={18} />;
if (toolLower.includes('podcast')) return <Mic size={18} />;
if (toolLower.includes('image')) return <ImageIcon size={18} />;
if (toolLower.includes('video')) return <Video size={18} />;
if (toolLower.includes('research')) return <Search size={18} />;
if (toolLower.includes('audio')) return <Mic size={18} />;
return <Code size={18} />;
};
const ToolCostBreakdown: React.FC<ToolCostBreakdownProps> = ({ userId, terminalTheme = false }) => {
const [usageLogs, setUsageLogs] = useState<UsageLog[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchUsageLogs = async () => {
try {
setLoading(true);
setError(null);
// Get current billing period
const currentDate = new Date();
const billingPeriod = `${currentDate.getFullYear()}-${String(currentDate.getMonth() + 1).padStart(2, '0')}`;
// Fetch usage logs with high limit to get comprehensive data
const response = await billingService.getUsageLogs(1000, 0, undefined, undefined, billingPeriod);
setUsageLogs(response.logs || []);
} catch (err) {
console.error('[ToolCostBreakdown] Error fetching usage logs:', err);
setError(err instanceof Error ? err.message : 'Failed to fetch usage logs');
} finally {
setLoading(false);
}
};
fetchUsageLogs();
}, [userId]);
const toolCosts = useMemo(() => {
const grouped = usageLogs.reduce((acc, log) => {
const tool = endpointToTool(log.endpoint || '');
if (!acc[tool]) {
acc[tool] = { cost: 0, calls: 0, tokens: 0 };
}
acc[tool].cost += log.cost_total || 0;
acc[tool].calls += 1;
acc[tool].tokens += log.tokens_total || 0;
return acc;
}, {} as Record<string, { cost: number; calls: number; tokens: number }>);
return Object.entries(grouped)
.map(([tool, data]) => ({
tool,
...data
}))
.sort((a, b) => b.cost - a.cost)
.filter(item => item.cost > 0); // Only show tools with costs
}, [usageLogs]);
const totalCost = toolCosts.reduce((sum, item) => sum + item.cost, 0);
if (loading) {
return (
<Card sx={{
background: terminalTheme
? 'transparent'
: 'linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.05) 100%)',
border: terminalTheme ? '1px solid #00ff00' : '1px solid rgba(255,255,255,0.1)',
borderRadius: 3
}}>
<CardContent sx={{ textAlign: 'center', py: 4 }}>
<CircularProgress size={24} sx={{ color: terminalTheme ? '#00ff00' : undefined }} />
<Typography sx={{ mt: 2, color: terminalTheme ? '#00ff00' : 'rgba(255,255,255,0.8)' }}>
Loading tool costs...
</Typography>
</CardContent>
</Card>
);
}
if (error) {
return (
<Card sx={{
background: terminalTheme
? 'transparent'
: 'linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.05) 100%)',
border: terminalTheme ? '1px solid #ff0000' : '1px solid rgba(255,107,107,0.3)',
borderRadius: 3
}}>
<CardContent sx={{ textAlign: 'center', py: 2 }}>
<Typography sx={{ color: terminalTheme ? '#ff0000' : '#ff6b6b', fontSize: '0.875rem' }}>
{error}
</Typography>
</CardContent>
</Card>
);
}
if (toolCosts.length === 0) {
return (
<Card sx={{
background: terminalTheme
? 'transparent'
: 'linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.05) 100%)',
border: terminalTheme ? `1px solid ${terminalTheme ? '#00ff00' : 'rgba(255,255,255,0.1)'}` : '1px solid rgba(255,255,255,0.1)',
borderRadius: 3
}}>
<CardContent>
<Typography variant="h6" sx={{ mb: 2, color: terminalTheme ? '#00ff00' : '#ffffff', fontWeight: 'bold' }}>
Cost by Tool
</Typography>
<Typography sx={{ color: terminalTheme ? '#00ff00' : 'rgba(255,255,255,0.7)', fontSize: '0.875rem' }}>
No usage data available yet. Costs will appear here as you use different tools.
</Typography>
</CardContent>
</Card>
);
}
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
<Card sx={{
height: '100%',
background: terminalTheme
? 'transparent'
: 'linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.05) 100%)',
backdropFilter: terminalTheme ? 'none' : 'blur(10px)',
border: terminalTheme ? '1px solid #00ff00' : '1px solid rgba(255,255,255,0.1)',
borderRadius: 3,
position: 'relative',
overflow: 'hidden'
}}>
<CardContent>
<Typography variant="h6" sx={{
mb: 3,
color: terminalTheme ? '#00ff00' : '#ffffff',
fontWeight: 'bold',
display: 'flex',
alignItems: 'center',
gap: 1
}}>
<Code size={20} />
Cost by Tool
</Typography>
<Grid container spacing={2}>
{toolCosts.map((item, index) => {
const percentage = totalCost > 0 ? ((item.cost / totalCost) * 100).toFixed(1) : '0';
return (
<Grid item xs={12} sm={6} key={item.tool}>
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.3, delay: index * 0.1 }}
>
<Tooltip
title={
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 'bold', mb: 0.5 }}>
{item.tool} Usage
</Typography>
<Typography variant="body2">
Cost: {formatCurrency(item.cost)} ({percentage}%)
</Typography>
<Typography variant="body2">
Calls: {formatNumber(item.calls)}
</Typography>
<Typography variant="body2">
Tokens: {formatNumber(item.tokens)}
</Typography>
<Typography variant="caption" sx={{ mt: 1, display: 'block', opacity: 0.8 }}>
Avg cost per call: {formatCurrency(item.cost / item.calls)}
</Typography>
</Box>
}
arrow
placement="top"
>
<Box
sx={{
p: 2,
backgroundColor: terminalTheme
? 'rgba(0, 255, 0, 0.05)'
: 'rgba(255,255,255,0.05)',
borderRadius: 2,
border: terminalTheme
? '1px solid rgba(0, 255, 0, 0.2)'
: '1px solid rgba(255,255,255,0.1)',
position: 'relative',
cursor: 'help',
transition: 'all 0.2s ease',
'&:hover': {
transform: 'translateY(-2px)',
backgroundColor: terminalTheme
? 'rgba(0, 255, 0, 0.1)'
: 'rgba(255,255,255,0.08)',
border: terminalTheme
? '1px solid rgba(0, 255, 0, 0.4)'
: '1px solid rgba(255,255,255,0.2)'
}
}}
>
{/* Tool Header */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box sx={{ color: terminalTheme ? '#00ff00' : '#ffffff' }}>
{getToolIcon(item.tool)}
</Box>
<Typography variant="body2" sx={{ fontWeight: 'bold', color: terminalTheme ? '#00ff00' : '#ffffff' }}>
{item.tool}
</Typography>
</Box>
<Chip
label={`${percentage}%`}
size="small"
sx={{
backgroundColor: terminalTheme
? 'rgba(0, 255, 0, 0.2)'
: 'rgba(74, 222, 128, 0.2)',
color: terminalTheme ? '#00ff00' : '#4ade80',
fontWeight: 'bold',
border: terminalTheme
? '1px solid rgba(0, 255, 0, 0.3)'
: '1px solid rgba(74, 222, 128, 0.3)'
}}
/>
</Box>
{/* Metrics */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="caption" sx={{ color: terminalTheme ? '#00ff00' : 'rgba(255,255,255,0.7)' }}>
Cost:
</Typography>
<Typography variant="caption" sx={{ fontWeight: 'bold', color: terminalTheme ? '#00ff00' : '#ffffff' }}>
{formatCurrency(item.cost)}
</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="caption" sx={{ color: terminalTheme ? '#00ff00' : 'rgba(255,255,255,0.7)' }}>
Calls:
</Typography>
<Typography variant="caption" sx={{ fontWeight: 'bold', color: terminalTheme ? '#00ff00' : '#ffffff' }}>
{formatNumber(item.calls)}
</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="caption" sx={{ color: terminalTheme ? '#00ff00' : 'rgba(255,255,255,0.7)' }}>
Tokens:
</Typography>
<Typography variant="caption" sx={{ fontWeight: 'bold', color: terminalTheme ? '#00ff00' : '#ffffff' }}>
{formatNumber(item.tokens)}
</Typography>
</Box>
{/* Progress bar */}
<Box sx={{ mt: 1 }}>
<Box
sx={{
height: 4,
backgroundColor: terminalTheme
? 'rgba(0, 255, 0, 0.1)'
: 'rgba(255,255,255,0.1)',
borderRadius: 2,
overflow: 'hidden'
}}
>
<motion.div
initial={{ width: 0 }}
animate={{ width: `${percentage}%` }}
transition={{ duration: 1, delay: index * 0.1 }}
style={{
height: '100%',
backgroundColor: terminalTheme ? '#00ff00' : '#4ade80',
borderRadius: 2
}}
/>
</Box>
</Box>
</Box>
</Tooltip>
</motion.div>
</Grid>
);
})}
</Grid>
{/* Summary */}
<Box
sx={{
mt: 3,
p: 2,
backgroundColor: terminalTheme
? 'rgba(0, 255, 0, 0.05)'
: 'rgba(255,255,255,0.05)',
borderRadius: 2,
border: terminalTheme
? '1px solid rgba(0, 255, 0, 0.2)'
: '1px solid rgba(255,255,255,0.1)'
}}
>
<Typography variant="body2" sx={{ mb: 1, color: terminalTheme ? '#00ff00' : 'rgba(255,255,255,0.8)' }}>
Total Tool Costs
</Typography>
<Typography variant="h5" sx={{ fontWeight: 'bold', color: terminalTheme ? '#00ff00' : '#ffffff' }}>
{formatCurrency(totalCost)}
</Typography>
<Typography variant="caption" sx={{ color: terminalTheme ? '#00ff00' : 'rgba(255,255,255,0.7)' }}>
Across {toolCosts.length} active tool{toolCosts.length !== 1 ? 's' : ''}
</Typography>
</Box>
</CardContent>
</Card>
</motion.div>
);
};
export default ToolCostBreakdown;

View File

@@ -324,7 +324,7 @@ const UsageLogsTable: React.FC<UsageLogsTableProps> = ({ initialLimit = 50 }) =>
</TerminalTypography>
</TerminalTableCell>
<TerminalTableCell>
<TerminalTypography variant="body2" sx={{ textTransform: 'capitalize' }}>
<TerminalTypography variant="body2" sx={{ textTransform: 'capitalize', fontWeight: 'bold' }}>
{log.provider}
</TerminalTypography>
</TerminalTableCell>

View File

@@ -7,6 +7,7 @@ import {
Grid,
Chip,
CircularProgress,
Tooltip as MuiTooltip,
} from '@mui/material';
import {
TrendingUp as TrendingUpIcon,
@@ -21,7 +22,7 @@ import {
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Tooltip as RechartsTooltip,
ResponsiveContainer,
Area,
ChartLoadingFallback
@@ -37,6 +38,10 @@ import {
formatPercentage
} from '../../services/billingService';
// Components
import CostVelocityChart from './CostVelocityChart';
import MultiSeriesCostChart from './MultiSeriesCostChart';
interface UsageTrendsProps {
trends: UsageTrendsType;
projections: CostProjections;
@@ -67,6 +72,28 @@ const UsageTrends: React.FC<UsageTrendsProps> = ({
: chartData[chartData.length - 1].calls > 0 ? 100 : 0
: 0;
// Calculate cost velocity (daily spending rate)
const currentDate = new Date();
const daysInMonth = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 0).getDate();
const currentDay = currentDate.getDate();
const currentMonthCost = chartData.length > 0 ? chartData[chartData.length - 1].cost : 0;
const avgDailyCost = currentDay > 0 ? currentMonthCost / currentDay : 0;
const projectedMonthlyCost = avgDailyCost * daysInMonth;
const daysRemaining = daysInMonth - currentDay;
// Calculate cost velocity trend (comparing recent vs earlier period)
const recentPeriods = chartData.slice(-3); // Last 3 periods
const earlierPeriods = chartData.slice(0, Math.min(3, chartData.length - 3));
const recentAvgCost = recentPeriods.length > 0
? recentPeriods.reduce((sum, p) => sum + p.cost, 0) / recentPeriods.length
: 0;
const earlierAvgCost = earlierPeriods.length > 0
? earlierPeriods.reduce((sum, p) => sum + p.cost, 0) / earlierPeriods.length
: 0;
const velocityTrend = earlierAvgCost > 0
? ((recentAvgCost - earlierAvgCost) / earlierAvgCost) * 100
: 0;
// Custom tooltip for charts
const CustomTooltip = ({ active, payload, label }: any) => {
if (active && payload && payload.length) {
@@ -121,126 +148,163 @@ const UsageTrends: React.FC<UsageTrendsProps> = ({
</CardContent>
<CardContent sx={{ pt: 0 }}>
{/* Growth Indicators */}
{/* Growth Indicators & Cost Velocity */}
<Grid container spacing={2} sx={{ mb: 3 }}>
<Grid item xs={6}>
<Grid item xs={6} sm={4}>
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.3 }}
>
<Box
sx={{
p: 2,
backgroundColor: 'rgba(255,255,255,0.05)',
borderRadius: 2,
border: '1px solid rgba(255,255,255,0.1)',
textAlign: 'center'
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 1, mb: 1 }}>
{costGrowth >= 0 ? (
<TrendingUpIcon sx={{ fontSize: 16, color: '#22c55e' }} />
) : (
<TrendingDownIcon sx={{ fontSize: 16, color: '#ef4444' }} />
)}
<Typography variant="body2" color="text.secondary">
Cost Growth
</Typography>
</Box>
<Typography
variant="h6"
sx={{
fontWeight: 'bold',
color: costGrowth >= 0 ? '#22c55e' : '#ef4444'
<MuiTooltip title="Percentage change in cost compared to first period" arrow>
<Box component="span"
sx={{
p: 2,
backgroundColor: 'rgba(255,255,255,0.05)',
borderRadius: 2,
border: '1px solid rgba(255,255,255,0.1)',
textAlign: 'center',
cursor: 'help'
}}
>
{costGrowth >= 0 ? '+' : ''}{costGrowth.toFixed(1)}%
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 1, mb: 1 }}>
{costGrowth >= 0 ? (
<TrendingUpIcon sx={{ fontSize: 16, color: '#22c55e' }} />
) : (
<TrendingDownIcon sx={{ fontSize: 16, color: '#ef4444' }} />
)}
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.7)' }}>
Cost Growth
</Typography>
</Box>
<Typography
variant="h6"
sx={{
fontWeight: 'bold',
color: costGrowth >= 0 ? '#22c55e' : '#ef4444'
}}
>
{costGrowth >= 0 ? '+' : ''}{costGrowth.toFixed(1)}%
</Typography>
</Box>
</MuiTooltip>
</motion.div>
</Grid>
<Grid item xs={6}>
<Grid item xs={6} sm={4}>
<motion.div
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.3, delay: 0.1 }}
>
<Box
sx={{
p: 2,
backgroundColor: 'rgba(255,255,255,0.05)',
borderRadius: 2,
border: '1px solid rgba(255,255,255,0.1)',
textAlign: 'center'
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 1, mb: 1 }}>
{callsGrowth >= 0 ? (
<TrendingUpIcon sx={{ fontSize: 16, color: '#22c55e' }} />
) : (
<TrendingDownIcon sx={{ fontSize: 16, color: '#ef4444' }} />
)}
<Typography variant="body2" color="text.secondary">
Calls Growth
</Typography>
</Box>
<Typography
variant="h6"
sx={{
fontWeight: 'bold',
color: callsGrowth >= 0 ? '#22c55e' : '#ef4444'
<MuiTooltip title="Percentage change in API calls compared to first period" arrow>
<Box component="span"
sx={{
p: 2,
backgroundColor: 'rgba(255,255,255,0.05)',
borderRadius: 2,
border: '1px solid rgba(255,255,255,0.1)',
textAlign: 'center',
cursor: 'help'
}}
>
{callsGrowth >= 0 ? '+' : ''}{callsGrowth.toFixed(1)}%
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 1, mb: 1 }}>
{callsGrowth >= 0 ? (
<TrendingUpIcon sx={{ fontSize: 16, color: '#22c55e' }} />
) : (
<TrendingDownIcon sx={{ fontSize: 16, color: '#ef4444' }} />
)}
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.7)' }}>
Calls Growth
</Typography>
</Box>
<Typography
variant="h6"
sx={{
fontWeight: 'bold',
color: callsGrowth >= 0 ? '#22c55e' : '#ef4444'
}}
>
{callsGrowth >= 0 ? '+' : ''}{callsGrowth.toFixed(1)}%
</Typography>
</Box>
</MuiTooltip>
</motion.div>
</Grid>
<Grid item xs={12} sm={4}>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.2 }}
>
<MuiTooltip
title={
<React.Fragment>
<Typography variant="subtitle2" sx={{ fontWeight: 'bold', mb: 0.5, display: 'block' }}>
Daily Spending Rate
</Typography>
<Typography variant="body2" sx={{ display: 'block' }}>
Average: {formatCurrency(avgDailyCost)}/day
</Typography>
<Typography variant="body2" sx={{ display: 'block' }}>
Velocity trend: {velocityTrend >= 0 ? '+' : ''}{velocityTrend.toFixed(1)}%
</Typography>
<Typography variant="caption" sx={{ mt: 1, display: 'block', opacity: 0.8 }}>
Based on current month's spending pattern
</Typography>
</React.Fragment>
}
arrow
placement="top"
>
<Box component="span"
sx={{
p: 2,
backgroundColor: 'rgba(102, 126, 234, 0.1)',
borderRadius: 2,
border: '1px solid rgba(102, 126, 234, 0.3)',
textAlign: 'center',
cursor: 'help'
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 1, mb: 1 }}>
<CalendarIcon sx={{ fontSize: 16, color: '#667eea' }} />
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.9)', fontWeight: 500 }}>
Cost Velocity
</Typography>
</Box>
<Typography
variant="h6"
sx={{
fontWeight: 'bold',
color: '#667eea'
}}
>
{formatCurrency(avgDailyCost)}/day
</Typography>
<Typography
variant="caption"
sx={{
color: velocityTrend >= 0 ? '#ef4444' : '#22c55e',
display: 'block',
mt: 0.5
}}
>
{velocityTrend >= 0 ? '' : ''} {Math.abs(velocityTrend).toFixed(1)}% trend
</Typography>
</Box>
</MuiTooltip>
</motion.div>
</Grid>
</Grid>
{/* Cost Trend Chart */}
{/* Multi-Series Cost Chart */}
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 'bold', color: '#ffffff' }}>
Monthly Cost Trend
</Typography>
<Box sx={{ height: 200 }}>
<ResponsiveContainer width="100%" height="100%">
<Suspense fallback={<ChartLoadingFallback />}>
<LazyAreaChart data={chartData}>
<defs>
<linearGradient id="costGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#667eea" stopOpacity={0.3}/>
<stop offset="95%" stopColor="#667eea" stopOpacity={0}/>
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.1)" />
<XAxis
dataKey="period"
stroke="rgba(255,255,255,0.9)"
fontSize={12}
tick={{ fill: 'rgba(255,255,255,0.9)' }}
/>
<YAxis
stroke="rgba(255,255,255,0.9)"
fontSize={12}
tick={{ fill: 'rgba(255,255,255,0.9)' }}
tickFormatter={(value) => `$${value.toFixed(0)}`}
/>
<Tooltip content={<CustomTooltip />} />
<Area
type="monotone"
dataKey="cost"
stroke="#667eea"
strokeWidth={2}
fillOpacity={1}
fill="url(#costGradient)"
/>
</LazyAreaChart>
</Suspense>
</ResponsiveContainer>
</Box>
<MultiSeriesCostChart
trends={trends}
monthlyLimit={projections.cost_limit || 0}
/>
</Box>
{/* API Calls Trend Chart */}
@@ -263,7 +327,7 @@ const UsageTrends: React.FC<UsageTrendsProps> = ({
fontSize={12}
tickFormatter={(value) => formatNumber(value)}
/>
<Tooltip content={<CustomTooltip />} />
<RechartsTooltip content={<CustomTooltip />} />
<Line
type="monotone"
dataKey="calls"
@@ -271,6 +335,8 @@ const UsageTrends: React.FC<UsageTrendsProps> = ({
strokeWidth={2}
dot={{ fill: '#764ba2', strokeWidth: 2, r: 4 }}
activeDot={{ r: 6, stroke: '#764ba2', strokeWidth: 2 }}
animationDuration={1000}
animationBegin={200}
/>
</LazyLineChart>
</Suspense>
@@ -278,62 +344,167 @@ const UsageTrends: React.FC<UsageTrendsProps> = ({
</Box>
</Box>
{/* Projections */}
{/* Enhanced Projections */}
<Box
sx={{
p: 2,
p: 2.5,
backgroundColor: 'rgba(255,255,255,0.05)',
borderRadius: 2,
border: '1px solid rgba(255,255,255,0.1)'
}}
>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 'bold', display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 'bold', display: 'flex', alignItems: 'center', gap: 1, color: '#ffffff' }}>
<CalendarIcon fontSize="small" />
Monthly Projections
Monthly Projections & Forecast
</Typography>
<Grid container spacing={2}>
<Grid item xs={6}>
<Box sx={{ textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary" sx={{ mb: 0.5 }}>
Projected Cost
</Typography>
<Typography variant="h6" sx={{ fontWeight: 'bold', color: 'primary.main' }}>
{formatCurrency(projections.projected_monthly_cost)}
</Typography>
</Box>
<Grid container spacing={2} sx={{ mb: 2 }}>
<Grid item xs={6} sm={3}>
<MuiTooltip
title={
<React.Fragment>
<Typography variant="subtitle2" sx={{ fontWeight: 'bold', mb: 0.5, display: 'block' }}>
Projected Monthly Cost
</Typography>
<Typography variant="body2" sx={{ display: 'block' }}>
Based on current spending rate: {formatCurrency(avgDailyCost)}/day
</Typography>
<Typography variant="caption" sx={{ mt: 1, display: 'block', opacity: 0.8 }}>
{daysRemaining} days remaining in month
</Typography>
</React.Fragment>
}
arrow
placement="top"
>
<Box component="span" sx={{ textAlign: 'center', cursor: 'help' }}>
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.7)', mb: 0.5 }}>
Projected Cost
</Typography>
<Typography variant="h6" sx={{ fontWeight: 'bold', color: '#667eea' }}>
{formatCurrency(Math.max(projectedMonthlyCost, projections.projected_monthly_cost))}
</Typography>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.6)', display: 'block', mt: 0.5 }}>
{projectedMonthlyCost > projections.projected_monthly_cost ? '(velocity-based)' : '(trend-based)'}
</Typography>
</Box>
</MuiTooltip>
</Grid>
<Grid item xs={6}>
<Box sx={{ textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary" sx={{ mb: 0.5 }}>
Usage %
</Typography>
<Typography
variant="h6"
sx={{
fontWeight: 'bold',
color: projections.projected_usage_percentage > 80 ? 'error.main' :
projections.projected_usage_percentage > 60 ? 'warning.main' : 'success.main'
}}
>
{formatPercentage(projections.projected_usage_percentage)}
</Typography>
</Box>
<Grid item xs={6} sm={3}>
<MuiTooltip
title={
<React.Fragment>
<Typography variant="subtitle2" sx={{ fontWeight: 'bold', mb: 0.5, display: 'block' }}>
Projected Usage Percentage
</Typography>
<Typography variant="body2" sx={{ display: 'block' }}>
Based on projected cost vs monthly limit
</Typography>
</React.Fragment>
}
arrow
placement="top"
>
<Box component="span" sx={{ textAlign: 'center', cursor: 'help' }}>
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.7)', mb: 0.5 }}>
Usage %
</Typography>
<Typography
variant="h6"
sx={{
fontWeight: 'bold',
color: projections.projected_usage_percentage > 80 ? '#ef4444' :
projections.projected_usage_percentage > 60 ? '#f59e0b' : '#22c55e'
}}
>
{formatPercentage(projections.projected_usage_percentage)}
</Typography>
</Box>
</MuiTooltip>
</Grid>
<Grid item xs={6} sm={3}>
<MuiTooltip
title={
<React.Fragment>
<Typography variant="subtitle2" sx={{ fontWeight: 'bold', mb: 0.5, display: 'block' }}>
Estimated Days Until Budget Exhaustion
</Typography>
<Typography variant="body2" sx={{ display: 'block' }}>
Based on current daily spending rate
</Typography>
</React.Fragment>
}
arrow
placement="top"
>
<Box component="span" sx={{ textAlign: 'center', cursor: 'help' }}>
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.7)', mb: 0.5 }}>
Days Remaining
</Typography>
<Typography
variant="h6"
sx={{
fontWeight: 'bold',
color: avgDailyCost > 0 && projections.cost_limit > 0
? (projections.cost_limit / avgDailyCost <= daysRemaining ? '#ef4444' : '#22c55e')
: '#ffffff'
}}
>
{avgDailyCost > 0 && projections.cost_limit > 0
? Math.min(Math.ceil((projections.cost_limit - currentMonthCost) / avgDailyCost), daysRemaining)
: daysRemaining
}
</Typography>
</Box>
</MuiTooltip>
</Grid>
<Grid item xs={6} sm={3}>
<MuiTooltip
title={
<React.Fragment>
<Typography variant="subtitle2" sx={{ fontWeight: 'bold', mb: 0.5, display: 'block' }}>
Monthly Budget Limit
</Typography>
<Typography variant="body2" sx={{ display: 'block' }}>
Maximum spending allowed this month
</Typography>
</React.Fragment>
}
arrow
placement="top"
>
<Box component="span" sx={{ textAlign: 'center', cursor: 'help' }}>
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.7)', mb: 0.5 }}>
Budget Limit
</Typography>
<Typography variant="h6" sx={{ fontWeight: 'bold', color: '#ffffff' }}>
{formatCurrency(projections.cost_limit)}
</Typography>
</Box>
</MuiTooltip>
</Grid>
</Grid>
<Box sx={{ mt: 2, textAlign: 'center' }}>
<Chip
label={`Limit: ${formatCurrency(projections.cost_limit)}`}
size="small"
sx={{
backgroundColor: 'rgba(255,255,255,0.1)',
color: 'text.secondary',
fontWeight: 'bold'
{/* Cost Velocity Warning */}
{avgDailyCost > 0 && projectedMonthlyCost > projections.cost_limit && (
<Box
sx={{
mt: 2,
p: 1.5,
backgroundColor: 'rgba(239, 68, 68, 0.1)',
borderRadius: 1,
border: '1px solid rgba(239, 68, 68, 0.3)'
}}
/>
</Box>
>
<Typography variant="caption" sx={{ color: '#ef4444', fontWeight: 500 }}>
⚠️ At current spending rate ({formatCurrency(avgDailyCost)}/day), you'll exceed your monthly budget
in ~{Math.ceil((projections.cost_limit - currentMonthCost) / avgDailyCost)} days.
</Typography>
</Box>
)}
</Box>
</CardContent>
@@ -362,6 +533,15 @@ const UsageTrends: React.FC<UsageTrendsProps> = ({
pointerEvents: 'none'
}}
/>
{/* Cost Velocity Chart */}
<Box sx={{ mt: 3 }}>
<CostVelocityChart
trends={trends}
projections={projections}
monthlyLimit={projections.cost_limit || 0}
/>
</Box>
</Card>
</motion.div>
);

View File

@@ -0,0 +1,50 @@
import React, { useEffect } from 'react';
import { motion, useMotionValue, useSpring, useTransform } from 'framer-motion';
interface AnimatedNumberProps {
value: number;
format?: (n: number) => string;
duration?: number;
decimals?: number;
prefix?: string;
suffix?: string;
}
/**
* AnimatedNumber - Smoothly animates number changes
*
* Usage:
* <AnimatedNumber value={1234.56} format={(n) => `$${n.toFixed(2)}`} />
* <AnimatedNumber value={1000} prefix="$" decimals={2} />
*/
export const AnimatedNumber: React.FC<AnimatedNumberProps> = ({
value,
format,
duration = 1,
decimals = 0,
prefix = '',
suffix = ''
}) => {
const motionValue = useMotionValue(0);
const spring = useSpring(motionValue, {
stiffness: 50,
damping: 30,
duration: duration * 1000
});
const display = useTransform(spring, (latest) => {
const rounded = decimals > 0 ? Number(latest.toFixed(decimals)) : Math.round(latest);
if (format) {
return format(rounded);
}
return `${prefix}${rounded.toLocaleString()}${suffix}`;
});
useEffect(() => {
motionValue.set(value);
}, [value, motionValue]);
return <motion.span>{display}</motion.span>;
};
export default AnimatedNumber;

View File

@@ -0,0 +1,106 @@
import React from 'react';
import { Box, LinearProgress, Typography } from '@mui/material';
import { motion } from 'framer-motion';
interface AnimatedProgressBarProps {
value: number; // 0-100
maxValue?: number; // Optional max value for display
label?: string;
color?: string;
height?: number;
showLabel?: boolean;
showPercentage?: boolean;
variant?: 'determinate' | 'indeterminate' | 'buffer' | 'query';
}
/**
* AnimatedProgressBar - Progress bar with smooth fill animation
*
* Usage:
* <AnimatedProgressBar value={75} label="Usage" showPercentage />
* <AnimatedProgressBar value={50} color="#4ade80" height={8} />
*/
export const AnimatedProgressBar: React.FC<AnimatedProgressBarProps> = ({
value,
maxValue,
label,
color,
height = 8,
showLabel = false,
showPercentage = false,
variant = 'determinate'
}) => {
// Clamp value between 0 and 100
const clampedValue = Math.min(Math.max(value, 0), 100);
// Get color based on value if not provided
const getColor = () => {
if (color) return color;
if (clampedValue >= 90) return '#ef4444'; // Red
if (clampedValue >= 75) return '#f59e0b'; // Orange
if (clampedValue >= 50) return '#eab308'; // Yellow
return '#22c55e'; // Green
};
const displayValue = maxValue
? `${Math.round(clampedValue)} / ${maxValue}`
: `${Math.round(clampedValue)}%`;
return (
<Box sx={{ width: '100%' }}>
{(showLabel || showPercentage) && (
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
{showLabel && label && (
<Typography variant="caption" sx={{ fontSize: '0.75rem', opacity: 0.8 }}>
{label}
</Typography>
)}
{showPercentage && (
<Typography variant="caption" sx={{ fontSize: '0.75rem', fontWeight: 'bold' }}>
{displayValue}
</Typography>
)}
</Box>
)}
<Box sx={{ position: 'relative', width: '100%', height }}>
<LinearProgress
variant={variant}
value={clampedValue}
sx={{
height,
borderRadius: height / 2,
backgroundColor: 'rgba(255,255,255,0.1)',
'& .MuiLinearProgress-bar': {
borderRadius: height / 2,
backgroundColor: getColor(),
}
}}
/>
{/* Animated overlay for smoother animation */}
<motion.div
initial={{ scaleX: 0 }}
animate={{ scaleX: clampedValue / 100 }}
transition={{
duration: 1,
ease: "easeOut",
delay: 0.2
}}
style={{
position: 'absolute',
top: 0,
left: 0,
height: '100%',
width: '100%',
transformOrigin: 'left',
backgroundColor: getColor(),
borderRadius: height / 2,
opacity: 0.3,
pointerEvents: 'none'
}}
/>
</Box>
</Box>
);
};
export default AnimatedProgressBar;

View File

@@ -0,0 +1,148 @@
import React, { Suspense } from 'react';
import { Box, Typography } from '@mui/material';
import {
LazyLineChart,
Line,
XAxis,
YAxis,
Tooltip as RechartsTooltip,
ResponsiveContainer,
ChartLoadingFallback
} from '../../utils/lazyRecharts';
interface MiniSparklineProps {
data: Array<{ date: string; value: number }>;
color: string;
height?: number;
showArea?: boolean;
formatValue?: (value: number) => string;
label?: string;
}
/**
* MiniSparkline - Enhanced trend line chart for metric cards with axes and tooltips
*
* Usage:
* <MiniSparkline
* data={last7DaysData}
* color="#4ade80"
* height={60}
* formatValue={(v) => `$${v.toFixed(2)}`}
* label="Cost"
* />
*/
export const MiniSparkline: React.FC<MiniSparklineProps> = ({
data,
color,
height = 60,
showArea = false,
formatValue = (v) => v.toLocaleString(),
label = 'Value'
}) => {
// Ensure we have data
if (!data || data.length === 0) {
return (
<Box sx={{ height, width: '100%', mt: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)', fontSize: '0.7rem' }}>
No data available
</Typography>
</Box>
);
}
// If only one data point, duplicate it for visual consistency
const chartData = data.length === 1
? [data[0], { ...data[0], value: data[0].value }]
: data;
// Calculate min/max for Y-axis domain
const values = chartData.map(d => d.value);
const minValue = Math.min(...values);
const maxValue = Math.max(...values);
const padding = (maxValue - minValue) * 0.1 || 0.1;
// Format date for X-axis
const formatDate = (dateStr: string) => {
try {
const date = new Date(dateStr);
if (isNaN(date.getTime())) return dateStr;
// Show day of month for daily data
return date.getDate().toString();
} catch {
return dateStr;
}
};
// Custom tooltip
const CustomTooltip = ({ active, payload }: any) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
return (
<Box
sx={{
backgroundColor: 'rgba(0, 0, 0, 0.9)',
color: 'white',
padding: 1,
borderRadius: 1,
border: `1px solid ${color}`,
fontSize: '0.75rem'
}}
>
<Typography variant="caption" sx={{ display: 'block', fontWeight: 'bold', mb: 0.5 }}>
{new Date(data.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
</Typography>
<Typography variant="caption" sx={{ color: color }}>
{label}: {formatValue(data.value)}
</Typography>
</Box>
);
}
return null;
};
return (
<Box sx={{ height, width: '100%', mt: 1 }}>
<Suspense fallback={<ChartLoadingFallback />}>
<ResponsiveContainer width="100%" height="100%">
<LazyLineChart
data={chartData}
margin={{ top: 5, right: 5, bottom: 20, left: 5 }}
>
<XAxis
dataKey="date"
tickFormatter={formatDate}
stroke="rgba(255,255,255,0.5)"
tick={{ fill: 'rgba(255,255,255,0.5)', fontSize: 10 }}
height={20}
/>
<YAxis
domain={[minValue - padding, maxValue + padding]}
tickFormatter={(value) => {
if (value >= 1000) return `${(value / 1000).toFixed(1)}k`;
if (value >= 1) return value.toFixed(0);
return value.toFixed(2);
}}
stroke="rgba(255,255,255,0.5)"
tick={{ fill: 'rgba(255,255,255,0.5)', fontSize: 10 }}
width={40}
/>
<RechartsTooltip content={<CustomTooltip />} />
<Line
type="monotone"
dataKey="value"
stroke={color}
strokeWidth={2}
dot={{ fill: color, r: 3 }}
activeDot={{ r: 5, fill: color }}
isAnimationActive={true}
animationDuration={1000}
animationBegin={0}
/>
</LazyLineChart>
</ResponsiveContainer>
</Suspense>
</Box>
);
};
export default MiniSparkline;

View File

@@ -0,0 +1,166 @@
/**
* Priority 2 Alert Banner Component
*
* Displays Priority 2 alerts (cost trends, pricing changes, OSS recommendations)
* in a prominent banner format for the main dashboard.
*/
import React from 'react';
import {
Alert,
AlertTitle,
Box,
Button,
IconButton,
Collapse,
Stack,
Chip,
Typography,
} from '@mui/material';
import {
AlertTriangle,
Info,
XCircle,
TrendingUp,
DollarSign,
X,
Lightbulb,
} from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { Priority2Alert } from '../../hooks/usePriority2Alerts';
interface Priority2AlertBannerProps {
alerts: Priority2Alert[];
onDismiss: (alertId: string) => void;
maxAlerts?: number;
}
const Priority2AlertBanner: React.FC<Priority2AlertBannerProps> = ({
alerts,
onDismiss,
maxAlerts = 3
}) => {
const [expanded, setExpanded] = React.useState(true);
const [dismissedIds, setDismissedIds] = React.useState<Set<string>>(new Set());
// Filter out dismissed alerts
const visibleAlerts = alerts.filter(alert => !dismissedIds.has(alert.id));
const displayAlerts = visibleAlerts.slice(0, maxAlerts);
const remainingCount = visibleAlerts.length - maxAlerts;
const getSeverityIcon = (severity: string, type: string) => {
if (type === 'cost_trend') return <TrendingUp size={20} />;
if (type === 'oss_recommendation') return <Lightbulb size={20} />;
if (type === 'pricing_change') return <DollarSign size={20} />;
switch (severity) {
case 'error':
return <XCircle size={20} />;
case 'warning':
return <AlertTriangle size={20} />;
default:
return <Info size={20} />;
}
};
const getSeverityColor = (severity: string) => {
switch (severity) {
case 'error':
return 'error';
case 'warning':
return 'warning';
default:
return 'info';
}
};
const handleDismiss = (alertId: string) => {
setDismissedIds(prev => new Set([...prev, alertId]));
onDismiss(alertId);
};
if (displayAlerts.length === 0) {
return null;
}
return (
<Box sx={{ mb: 3 }}>
<AnimatePresence>
{displayAlerts.map((alert, index) => (
<motion.div
key={alert.id}
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3, delay: index * 0.1 }}
>
<Alert
severity={getSeverityColor(alert.severity)}
icon={getSeverityIcon(alert.severity, alert.type)}
action={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{alert.action && (
<Button
size="small"
onClick={alert.action.onClick}
sx={{ textTransform: 'none' }}
>
{alert.action.label}
</Button>
)}
{alert.dismissible && (
<IconButton
size="small"
onClick={() => handleDismiss(alert.id)}
sx={{ ml: 1 }}
>
<X size={16} />
</IconButton>
)}
</Box>
}
sx={{
mb: 2,
'& .MuiAlert-icon': {
alignItems: 'center'
}
}}
>
<AlertTitle sx={{ fontWeight: 'bold', mb: 0.5 }}>
{alert.title}
</AlertTitle>
{alert.message}
{alert.type && (
<Chip
label={alert.type.replace('_', ' ')}
size="small"
sx={{ mt: 1, ml: 1 }}
/>
)}
</Alert>
</motion.div>
))}
</AnimatePresence>
{remainingCount > 0 && (
<Collapse in={expanded}>
<Alert severity="info" sx={{ mt: 1 }}>
<AlertTitle>
{remainingCount} more alert{remainingCount > 1 ? 's' : ''} available
</AlertTitle>
View all alerts in the{' '}
<Button
size="small"
onClick={() => window.location.href = '/billing'}
sx={{ textTransform: 'none' }}
>
Billing Dashboard
</Button>
</Alert>
</Collapse>
)}
</Box>
);
};
export default Priority2AlertBanner;

View File

@@ -111,7 +111,6 @@ const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
}
// All checks passed - render protected component
console.log('ProtectedRoute: Access granted (context/local), rendering component');
return <>{children}</>;
};

View File

@@ -23,6 +23,8 @@ import {
} from '@mui/icons-material';
import { apiClient } from '../../api/client';
import { useSubscription } from '../../contexts/SubscriptionContext';
import { usePriority2Alerts } from '../../hooks/usePriority2Alerts';
import Priority2AlertBanner from './Priority2AlertBanner';
interface UsageStats {
total_calls: number;
@@ -84,6 +86,13 @@ const UsageDashboard: React.FC<UsageDashboardProps> = ({
const userId = localStorage.getItem('user_id');
// Priority 2 Alerts - automatically appears in all tool headers
const { alerts: priority2Alerts, dismissAlert: dismissPriority2Alert } = usePriority2Alerts({
userId: userId || undefined,
enabled: !!userId && subscription?.active,
checkInterval: 120000, // Check every 2 minutes
});
const fetchUsageData = async () => {
if (!userId) return;
@@ -175,19 +184,34 @@ const UsageDashboard: React.FC<UsageDashboardProps> = ({
);
}
return null;
return <Box />; // Return empty box instead of null
}
if (compact) {
// Compact view - show key metrics as chips
const totalCalls = dashboardData.summary.total_api_calls_this_month;
const totalCost = dashboardData.summary.total_cost_this_month;
// Use current_usage for accurate cost (properly coerced from provider breakdown)
// Fallback to summary if current_usage is not available
const totalCalls = dashboardData.current_usage?.total_calls ?? dashboardData.summary.total_api_calls_this_month;
const totalCost = dashboardData.current_usage?.total_cost ?? dashboardData.summary.total_cost_this_month ?? 0;
const monthlyLimit = dashboardData.limits.limits.monthly_cost;
const usagePercentage = (totalCost / monthlyLimit) * 100;
const usagePercentage = monthlyLimit > 0 ? (totalCost / monthlyLimit) * 100 : 0;
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{/* Total API Calls */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{/* Priority 2 Alerts - Shows cost trends, OSS recommendations, spending velocity */}
{priority2Alerts.length > 0 && (
<Box sx={{ mb: 0.5 }}>
<Priority2AlertBanner
alerts={priority2Alerts}
onDismiss={dismissPriority2Alert}
maxAlerts={1} // Show only 1 alert in compact view
/>
</Box>
)}
{/* Usage Statistics */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{/* Total API Calls */}
<Tooltip title={`${totalCalls.toLocaleString()} API calls this month`}>
<Chip
icon={getUsageStatusIcon(dashboardData.summary.usage_status)}
@@ -298,6 +322,7 @@ const UsageDashboard: React.FC<UsageDashboardProps> = ({
</Box>
)}
</Menu>
</Box>
</Box>
);
}
@@ -316,7 +341,7 @@ const UsageDashboard: React.FC<UsageDashboardProps> = ({
Total API Calls
</Typography>
<Typography variant="h4" color="primary">
{dashboardData.summary.total_api_calls_this_month.toLocaleString()}
{(dashboardData.current_usage?.total_calls ?? dashboardData.summary.total_api_calls_this_month).toLocaleString()}
</Typography>
</Box>
@@ -326,7 +351,7 @@ const UsageDashboard: React.FC<UsageDashboardProps> = ({
Monthly Cost
</Typography>
<Typography variant="h4" color="secondary">
${dashboardData.summary.total_cost_this_month.toFixed(2)}
${(dashboardData.current_usage?.total_cost ?? dashboardData.summary.total_cost_this_month ?? 0).toFixed(2)}
</Typography>
<Typography variant="caption" color="text.secondary">
of ${dashboardData.limits.limits.monthly_cost} limit

View File

@@ -0,0 +1,131 @@
import React, { Suspense } from 'react';
import { Box, Typography } from '@mui/material';
import { LazyPieChart, Pie, Cell, ResponsiveContainer, ChartLoadingFallback } from '../../utils/lazyRecharts';
import { motion } from 'framer-motion';
interface UsageLimitRingProps {
used: number;
limit: number;
label: string;
color: string;
size?: number;
showValue?: boolean;
terminalTheme?: boolean;
terminalColors?: any;
}
/**
* UsageLimitRing - Circular progress ring showing usage vs limit
*
* Usage:
* <UsageLimitRing
* used={75}
* limit={100}
* label="Calls"
* color="#4ade80"
* />
*/
export const UsageLimitRing: React.FC<UsageLimitRingProps> = ({
used,
limit,
label,
color,
size = 120,
showValue = true,
terminalTheme = false,
terminalColors
}) => {
const percentage = limit > 0 ? Math.min((used / limit) * 100, 100) : 0;
const remaining = Math.max(0, limit - used);
const data = [
{ name: 'Used', value: used },
{ name: 'Remaining', value: remaining }
];
// Determine color based on percentage
const getRingColor = () => {
if (percentage >= 90) return '#ef4444'; // Red
if (percentage >= 75) return '#f59e0b'; // Orange
if (percentage >= 50) return '#eab308'; // Yellow
return color || '#22c55e'; // Green or provided color
};
const ringColor = getRingColor();
return (
<Box sx={{ position: 'relative', width: size, height: size }}>
<Suspense fallback={<ChartLoadingFallback />}>
<ResponsiveContainer width="100%" height="100%">
<LazyPieChart>
<Pie
data={data}
cx="50%"
cy="50%"
innerRadius={size * 0.35}
outerRadius={size * 0.45}
startAngle={90}
endAngle={-270}
dataKey="value"
animationBegin={0}
animationDuration={1000}
>
<Cell fill={ringColor} />
<Cell fill={terminalTheme
? (terminalColors?.backgroundLight || 'rgba(255,255,255,0.1)')
: 'rgba(255,255,255,0.1)'}
/>
</Pie>
</LazyPieChart>
</ResponsiveContainer>
</Suspense>
<Box sx={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
textAlign: 'center',
pointerEvents: 'none'
}}>
{showValue && (
<>
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.3, duration: 0.4 }}
>
<Typography
variant="h6"
sx={{
fontWeight: 'bold',
color: terminalTheme
? (terminalColors?.text || '#ffffff')
: '#ffffff',
fontSize: size * 0.15,
lineHeight: 1.2
}}
>
{Math.round(percentage)}%
</Typography>
</motion.div>
<Typography
variant="caption"
sx={{
fontSize: size * 0.08,
color: terminalTheme
? (terminalColors?.textSecondary || 'rgba(255,255,255,0.7)')
: 'rgba(255,255,255,0.7)',
display: 'block',
mt: 0.5
}}
>
{label}
</Typography>
</>
)}
</Box>
</Box>
);
};
export default UsageLimitRing;