AI Analysis and Content Strategy fixes. Enhanced Strategy Routes refactoring.
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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)
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)}
|
||||
|
||||
310
frontend/src/components/ImageStudio/CreateStudioCostAlerts.tsx
Normal file
310
frontend/src/components/ImageStudio/CreateStudioCostAlerts.tsx
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
252
frontend/src/components/ProductMarketing/CampaignPreview.tsx
Normal file
252
frontend/src/components/ProductMarketing/CampaignPreview.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>>({});
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
export { default as ProductAnimationStudio } from './ProductAnimationStudio';
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
export { default as ProductAvatarStudio } from './ProductAvatarStudio';
|
||||
220
frontend/src/components/ProductMarketing/ProductImagePreview.tsx
Normal file
220
frontend/src/components/ProductMarketing/ProductImagePreview.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
export { default as ProductVideoStudio } from './ProductVideoStudio';
|
||||
@@ -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}>
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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>) => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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[] = [
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.",
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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[];
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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"
|
||||
];
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ResearchMode } from '../../../../services/blogWriterApi';
|
||||
import { ResearchMode } from '../../../../services/researchApi';
|
||||
|
||||
/**
|
||||
* Smart mode suggestion based on query complexity
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
590
frontend/src/components/billing/AdvancedCostAnalytics.tsx
Normal file
590
frontend/src/components/billing/AdvancedCostAnalytics.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
308
frontend/src/components/billing/CostEstimationModal.tsx
Normal file
308
frontend/src/components/billing/CostEstimationModal.tsx
Normal 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;
|
||||
@@ -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;
|
||||
243
frontend/src/components/billing/CostVelocityChart.tsx
Normal file
243
frontend/src/components/billing/CostVelocityChart.tsx
Normal 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;
|
||||
231
frontend/src/components/billing/DailyCostHeatmap.tsx
Normal file
231
frontend/src/components/billing/DailyCostHeatmap.tsx
Normal 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;
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
244
frontend/src/components/billing/ErrorRateGauge.tsx
Normal file
244
frontend/src/components/billing/ErrorRateGauge.tsx
Normal 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;
|
||||
154
frontend/src/components/billing/LiveCostCounter.tsx
Normal file
154
frontend/src/components/billing/LiveCostCounter.tsx
Normal 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;
|
||||
210
frontend/src/components/billing/MultiSeriesCostChart.tsx
Normal file
210
frontend/src/components/billing/MultiSeriesCostChart.tsx
Normal 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;
|
||||
171
frontend/src/components/billing/ProviderCostComparison.tsx
Normal file
171
frontend/src/components/billing/ProviderCostComparison.tsx
Normal 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;
|
||||
397
frontend/src/components/billing/ToolCostBreakdown.tsx
Normal file
397
frontend/src/components/billing/ToolCostBreakdown.tsx
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
50
frontend/src/components/shared/AnimatedNumber.tsx
Normal file
50
frontend/src/components/shared/AnimatedNumber.tsx
Normal 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;
|
||||
106
frontend/src/components/shared/AnimatedProgressBar.tsx
Normal file
106
frontend/src/components/shared/AnimatedProgressBar.tsx
Normal 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;
|
||||
148
frontend/src/components/shared/MiniSparkline.tsx
Normal file
148
frontend/src/components/shared/MiniSparkline.tsx
Normal 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;
|
||||
166
frontend/src/components/shared/Priority2AlertBanner.tsx
Normal file
166
frontend/src/components/shared/Priority2AlertBanner.tsx
Normal 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;
|
||||
@@ -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}</>;
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
131
frontend/src/components/shared/UsageLimitRing.tsx
Normal file
131
frontend/src/components/shared/UsageLimitRing.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user