From 86d22793cc59a189da4f2d213e1488a2e0124e09 Mon Sep 17 00:00:00 2001 From: Shubhangi-Microsoft Date: Tue, 24 Feb 2026 18:17:49 +0530 Subject: [PATCH 01/12] refactor(frontend): complete Tasks 1-5 UI refactoring Task 1: Centralize state with Redux Toolkit (4 slices, typed hooks, selectors) Task 2: Centralized HTTP client with interceptors (httpClient singleton) Task 3: Extract 18 reusable components from monolithic files Task 4: Wrap all components with React.memo + displayName, useCallback throughout Task 5: Extract business logic into 7 custom hooks (useChatOrchestrator, etc.) - App.tsx reduced from ~787 to ~85 lines - Zero raw fetch calls (except /.auth/me) - All console.log downgraded to console.debug - Build: 0 TypeScript errors --- content-gen/src/app/frontend/src/App.tsx | 884 ++---------------- .../src/app/frontend/src/api/httpClient.ts | 197 ++++ content-gen/src/app/frontend/src/api/index.ts | 118 +-- .../app/frontend/src/components/AppHeader.tsx | 68 ++ .../frontend/src/components/BriefReview.tsx | 50 +- .../frontend/src/components/ChatHistory.tsx | 457 ++------- .../app/frontend/src/components/ChatInput.tsx | 148 +++ .../app/frontend/src/components/ChatPanel.tsx | 405 ++------ .../src/components/ComplianceSection.tsx | 192 ++++ .../src/components/ConfirmedBriefView.tsx | 6 +- .../src/components/ConversationItem.tsx | 297 ++++++ .../src/components/ImagePreviewCard.tsx | 99 ++ .../src/components/InlineContentPreview.tsx | 437 +-------- .../frontend/src/components/MessageBubble.tsx | 118 +++ .../frontend/src/components/ProductCard.tsx | 136 +++ .../frontend/src/components/ProductReview.tsx | 126 +-- .../src/components/SelectedProductView.tsx | 95 +- .../src/components/SuggestionCard.tsx | 84 ++ .../src/components/TypingIndicator.tsx | 76 ++ .../frontend/src/components/ViolationCard.tsx | 66 ++ .../frontend/src/components/WelcomeCard.tsx | 83 +- .../app/frontend/src/hooks/useAutoScroll.ts | 30 + .../frontend/src/hooks/useChatOrchestrator.ts | 547 +++++++++++ .../src/hooks/useContentGeneration.ts | 170 ++++ .../src/hooks/useConversationActions.ts | 296 ++++++ .../frontend/src/hooks/useCopyToClipboard.ts | 32 + .../src/app/frontend/src/hooks/useDebounce.ts | 29 + .../app/frontend/src/hooks/useWindowSize.ts | 19 + content-gen/src/app/frontend/src/main.tsx | 10 +- .../src/app/frontend/src/store/appSlice.ts | 100 ++ .../frontend/src/store/chatHistorySlice.ts | 138 +++ .../src/app/frontend/src/store/chatSlice.ts | 67 ++ .../app/frontend/src/store/contentSlice.ts | 61 ++ .../src/app/frontend/src/store/hooks.ts | 9 + .../src/app/frontend/src/store/index.ts | 77 ++ .../src/app/frontend/src/store/selectors.ts | 36 + .../src/app/frontend/src/store/store.ts | 21 + .../app/frontend/src/utils/contentErrors.ts | 31 + .../app/frontend/src/utils/downloadImage.ts | 94 ++ 39 files changed, 3613 insertions(+), 2296 deletions(-) create mode 100644 content-gen/src/app/frontend/src/api/httpClient.ts create mode 100644 content-gen/src/app/frontend/src/components/AppHeader.tsx create mode 100644 content-gen/src/app/frontend/src/components/ChatInput.tsx create mode 100644 content-gen/src/app/frontend/src/components/ComplianceSection.tsx create mode 100644 content-gen/src/app/frontend/src/components/ConversationItem.tsx create mode 100644 content-gen/src/app/frontend/src/components/ImagePreviewCard.tsx create mode 100644 content-gen/src/app/frontend/src/components/MessageBubble.tsx create mode 100644 content-gen/src/app/frontend/src/components/ProductCard.tsx create mode 100644 content-gen/src/app/frontend/src/components/SuggestionCard.tsx create mode 100644 content-gen/src/app/frontend/src/components/TypingIndicator.tsx create mode 100644 content-gen/src/app/frontend/src/components/ViolationCard.tsx create mode 100644 content-gen/src/app/frontend/src/hooks/useAutoScroll.ts create mode 100644 content-gen/src/app/frontend/src/hooks/useChatOrchestrator.ts create mode 100644 content-gen/src/app/frontend/src/hooks/useContentGeneration.ts create mode 100644 content-gen/src/app/frontend/src/hooks/useConversationActions.ts create mode 100644 content-gen/src/app/frontend/src/hooks/useCopyToClipboard.ts create mode 100644 content-gen/src/app/frontend/src/hooks/useDebounce.ts create mode 100644 content-gen/src/app/frontend/src/hooks/useWindowSize.ts create mode 100644 content-gen/src/app/frontend/src/store/appSlice.ts create mode 100644 content-gen/src/app/frontend/src/store/chatHistorySlice.ts create mode 100644 content-gen/src/app/frontend/src/store/chatSlice.ts create mode 100644 content-gen/src/app/frontend/src/store/contentSlice.ts create mode 100644 content-gen/src/app/frontend/src/store/hooks.ts create mode 100644 content-gen/src/app/frontend/src/store/index.ts create mode 100644 content-gen/src/app/frontend/src/store/selectors.ts create mode 100644 content-gen/src/app/frontend/src/store/store.ts create mode 100644 content-gen/src/app/frontend/src/utils/contentErrors.ts create mode 100644 content-gen/src/app/frontend/src/utils/downloadImage.ts diff --git a/content-gen/src/app/frontend/src/App.tsx b/content-gen/src/app/frontend/src/App.tsx index 9a769bcca..c0cd14934 100644 --- a/content-gen/src/app/frontend/src/App.tsx +++ b/content-gen/src/app/frontend/src/App.tsx @@ -1,860 +1,80 @@ -import { useState, useCallback, useEffect, useRef } from 'react'; -import { - Text, - Avatar, - Button, - Tooltip, - tokens, -} from '@fluentui/react-components'; -import { - History24Regular, - History24Filled, -} from '@fluentui/react-icons'; -import { v4 as uuidv4 } from 'uuid'; +import { useEffect, useRef } from 'react'; import { ChatPanel } from './components/ChatPanel'; import { ChatHistory } from './components/ChatHistory'; -import type { ChatMessage, CreativeBrief, Product, GeneratedContent } from './types'; -import ContosoLogo from './styles/images/contoso.svg'; +import { AppHeader } from './components/AppHeader'; +import { + useAppDispatch, + useAppSelector, + fetchAppConfig, + fetchUserInfo, + selectUserName, + selectShowChatHistory, +} from './store'; +import { useChatOrchestrator } from './hooks/useChatOrchestrator'; +import { useContentGeneration } from './hooks/useContentGeneration'; +import { useConversationActions } from './hooks/useConversationActions'; function App() { - const [conversationId, setConversationId] = useState(() => uuidv4()); - const [conversationTitle, setConversationTitle] = useState(null); - const [userId, setUserId] = useState(''); - const [userName, setUserName] = useState(''); - const [messages, setMessages] = useState([]); - const [isLoading, setIsLoading] = useState(false); - const [generationStatus, setGenerationStatus] = useState(''); - - // Feature flags from config - const [imageGenerationEnabled, setImageGenerationEnabled] = useState(true); - - // Brief confirmation flow - const [pendingBrief, setPendingBrief] = useState(null); - const [confirmedBrief, setConfirmedBrief] = useState(null); - const [awaitingClarification, setAwaitingClarification] = useState(false); - - // Product selection - const [selectedProducts, setSelectedProducts] = useState([]); - const [availableProducts, setAvailableProducts] = useState([]); - - // Generated content - const [generatedContent, setGeneratedContent] = useState(null); + const dispatch = useAppDispatch(); + const userName = useAppSelector(selectUserName); + const showChatHistory = useAppSelector(selectShowChatHistory); - // Trigger for refreshing chat history - const [historyRefreshTrigger, setHistoryRefreshTrigger] = useState(0); - - // Toggle for showing/hiding chat history panel - const [showChatHistory, setShowChatHistory] = useState(true); - - // Abort controller for cancelling ongoing requests + // Shared abort controller for chat & content-generation const abortControllerRef = useRef(null); - // Fetch app config on mount - useEffect(() => { - const fetchConfig = async () => { - try { - const { getAppConfig } = await import('./api'); - const config = await getAppConfig(); - setImageGenerationEnabled(config.enable_image_generation); - } catch (err) { - console.error('Error fetching config:', err); - // Default to enabled if config fetch fails - setImageGenerationEnabled(true); - } - }; - fetchConfig(); - }, []); - - // Fetch current user on mount - using /.auth/me (Azure App Service built-in auth endpoint) + // Business-logic hooks + const { sendMessage } = useChatOrchestrator(abortControllerRef); + const { generateContent, stopGeneration } = useContentGeneration(abortControllerRef); + const { + selectConversation, + newConversation, + confirmBrief, + cancelBrief, + productsStartOver, + selectProduct, + toggleHistory, + } = useConversationActions(); + + // Fetch app config & current user on mount useEffect(() => { - const fetchUser = async () => { - try { - const response = await fetch('/.auth/me'); - if (response.ok) { - const payload = await response.json(); - - // Extract user ID from objectidentifier claim - const userClaims = payload[0]?.user_claims || []; - const objectIdClaim = userClaims.find( - (claim: { typ: string; val: string }) => - claim.typ === 'http://schemas.microsoft.com/identity/claims/objectidentifier' - ); - setUserId(objectIdClaim?.val || 'anonymous'); - - // Extract display name from 'name' claim - const nameClaim = userClaims.find( - (claim: { typ: string; val: string }) => claim.typ === 'name' - ); - setUserName(nameClaim?.val || ''); - } - } catch (err) { - console.error('Error fetching user:', err); - setUserId('anonymous'); - setUserName(''); - } - }; - fetchUser(); - }, []); - - // Handle selecting a conversation from history - const handleSelectConversation = useCallback(async (selectedConversationId: string) => { - try { - const response = await fetch(`/api/conversations/${selectedConversationId}?user_id=${encodeURIComponent(userId)}`); - if (response.ok) { - const data = await response.json(); - setConversationId(selectedConversationId); - setConversationTitle(null); // Will use title from conversation list - const loadedMessages: ChatMessage[] = (data.messages || []).map((msg: { role: string; content: string; timestamp?: string; agent?: string }, index: number) => ({ - id: `${selectedConversationId}-${index}`, - role: msg.role as 'user' | 'assistant', - content: msg.content, - timestamp: msg.timestamp || new Date().toISOString(), - agent: msg.agent, - })); - setMessages(loadedMessages); - setPendingBrief(null); - setAwaitingClarification(false); - setConfirmedBrief(data.brief || null); - - // Restore availableProducts so product/color name detection works - // when regenerating images in a restored conversation - if (data.brief) { - try { - const productsResponse = await fetch('/api/products'); - if (productsResponse.ok) { - const productsData = await productsResponse.json(); - setAvailableProducts(productsData.products || []); - } - } catch (err) { - console.error('Error loading products for restored conversation:', err); - } - } - - if (data.generated_content) { - const gc = data.generated_content; - let textContent = gc.text_content; - if (typeof textContent === 'string') { - try { - textContent = JSON.parse(textContent); - } catch { - } - } - - let imageUrl: string | undefined = gc.image_url; - if (imageUrl && imageUrl.includes('blob.core.windows.net')) { - const parts = imageUrl.split('/'); - const filename = parts[parts.length - 1]; - const convId = parts[parts.length - 2]; - imageUrl = `/api/images/${convId}/${filename}`; - } - if (!imageUrl && gc.image_base64) { - imageUrl = `data:image/png;base64,${gc.image_base64}`; - } - - const restoredContent: GeneratedContent = { - text_content: typeof textContent === 'object' && textContent ? { - headline: textContent?.headline, - body: textContent?.body, - cta_text: textContent?.cta, - tagline: textContent?.tagline, - } : undefined, - image_content: (imageUrl || gc.image_prompt) ? { - image_url: imageUrl, - prompt_used: gc.image_prompt, - alt_text: gc.image_revised_prompt || 'Generated marketing image', - } : undefined, - violations: gc.violations || [], - requires_modification: gc.requires_modification || false, - error: gc.error, - image_error: gc.image_error, - text_error: gc.text_error, - }; - setGeneratedContent(restoredContent); - - if (gc.selected_products && Array.isArray(gc.selected_products)) { - setSelectedProducts(gc.selected_products); - } else { - setSelectedProducts([]); - } - } else { - setGeneratedContent(null); - setSelectedProducts([]); - } - } - } catch (error) { - console.error('Error loading conversation:', error); - } - }, [userId]); - - // Handle starting a new conversation - const handleNewConversation = useCallback(() => { - setConversationId(uuidv4()); - setConversationTitle(null); - setMessages([]); - setPendingBrief(null); - setAwaitingClarification(false); - setConfirmedBrief(null); - setGeneratedContent(null); - setSelectedProducts([]); - }, []); - - const handleSendMessage = useCallback(async (content: string) => { - const userMessage: ChatMessage = { - id: uuidv4(), - role: 'user', - content, - timestamp: new Date().toISOString(), - }; - - setMessages(prev => [...prev, userMessage]); - setIsLoading(true); - - // Create new abort controller for this request - abortControllerRef.current = new AbortController(); - const signal = abortControllerRef.current.signal; - - try { - // Import dynamically to avoid SSR issues - const { streamChat, parseBrief, selectProducts } = await import('./api'); - - // If we have a pending brief and user is providing feedback, update the brief - if (pendingBrief && !confirmedBrief) { - // User is refining the brief or providing clarification - const refinementKeywords = ['change', 'update', 'modify', 'add', 'remove', 'delete', 'set', 'make', 'should be']; - const isRefinement = refinementKeywords.some(kw => content.toLowerCase().includes(kw)); - - // If awaiting clarification, treat ANY response as a brief update - if (isRefinement || awaitingClarification) { - // Send the refinement request to update the brief - // Combine original brief context with the refinement request - const refinementPrompt = `Current creative brief:\n${JSON.stringify(pendingBrief, null, 2)}\n\nUser requested change: ${content}\n\nPlease update the brief accordingly and return the complete updated brief.`; - - setGenerationStatus('Updating creative brief...'); - const parsed = await parseBrief(refinementPrompt, conversationId, userId, signal); - if (parsed.generated_title && !conversationTitle) { - setConversationTitle(parsed.generated_title); - } - if (parsed.brief) { - setPendingBrief(parsed.brief); - } - - // Check if we still need more clarification - if (parsed.requires_clarification && parsed.clarifying_questions) { - setAwaitingClarification(true); - setGenerationStatus(''); - - const assistantMessage: ChatMessage = { - id: uuidv4(), - role: 'assistant', - content: parsed.clarifying_questions, - agent: 'PlanningAgent', - timestamp: new Date().toISOString(), - }; - setMessages(prev => [...prev, assistantMessage]); - } else { - // Brief is now complete - setAwaitingClarification(false); - setGenerationStatus(''); - - const assistantMessage: ChatMessage = { - id: uuidv4(), - role: 'assistant', - content: "I've updated the brief based on your feedback. Please review the changes above. Let me know if you'd like any other modifications, or click **Confirm Brief** when you're satisfied.", - agent: 'PlanningAgent', - timestamp: new Date().toISOString(), - }; - setMessages(prev => [...prev, assistantMessage]); - } - } else { - // General question or comment while brief is pending - let fullContent = ''; - let currentAgent = ''; - let messageAdded = false; - - setGenerationStatus('Processing your question...'); - for await (const response of streamChat(content, conversationId, userId, signal)) { - if (response.type === 'agent_response') { - fullContent = response.content; - currentAgent = response.agent || ''; - - if ((response.is_final || response.requires_user_input) && !messageAdded) { - const assistantMessage: ChatMessage = { - id: uuidv4(), - role: 'assistant', - content: fullContent, - agent: currentAgent, - timestamp: new Date().toISOString(), - }; - setMessages(prev => [...prev, assistantMessage]); - messageAdded = true; - } - } else if (response.type === 'error') { - const errorMessage: ChatMessage = { - id: uuidv4(), - role: 'assistant', - content: response.content || 'An error occurred while processing your request.', - timestamp: new Date().toISOString(), - }; - setMessages(prev => [...prev, errorMessage]); - messageAdded = true; - } - } - setGenerationStatus(''); - } - } else if (confirmedBrief && !generatedContent) { - // Brief confirmed, in product selection phase - treat messages as product selection requests - setGenerationStatus('Finding products...'); - const result = await selectProducts(content, selectedProducts, conversationId, userId, signal); - - // Update selected products with the result - setSelectedProducts(result.products || []); - setGenerationStatus(''); - - const assistantMessage: ChatMessage = { - id: uuidv4(), - role: 'assistant', - content: result.message || 'Products updated.', - agent: 'ProductAgent', - timestamp: new Date().toISOString(), - }; - setMessages(prev => [...prev, assistantMessage]); - } else if (generatedContent && confirmedBrief) { - // Content has been generated - check if user wants to modify the image - const imageModificationKeywords = [ - 'change', 'modify', 'update', 'replace', 'show', 'display', 'use', - 'instead', 'different', 'another', 'make it', 'make the', - 'kitchen', 'dining', 'living', 'bedroom', 'bathroom', 'outdoor', 'office', - 'room', 'scene', 'setting', 'background', 'style', 'color', 'lighting' - ]; - const isImageModification = imageModificationKeywords.some(kw => content.toLowerCase().includes(kw)); - - if (isImageModification) { - // User wants to modify the image - use regeneration endpoint - const { streamRegenerateImage } = await import('./api'); - - setGenerationStatus('Regenerating image with your changes...'); - - let responseData: GeneratedContent | null = null; - let messageContent = ''; - - // Detect if the user's prompt mentions a different product/color name - // BEFORE the API call so the correct product is sent and persisted - const mentionedProduct = availableProducts.find(p => - content.toLowerCase().includes(p.product_name.toLowerCase()) - ); - const productsForRequest = mentionedProduct ? [mentionedProduct] : selectedProducts; - - // Get previous prompt from image_content if available - const previousPrompt = generatedContent.image_content?.prompt_used; - - for await (const response of streamRegenerateImage( - content, - confirmedBrief, - productsForRequest, - previousPrompt, - conversationId, - userId, - signal - )) { - if (response.type === 'heartbeat') { - setGenerationStatus(response.message || 'Regenerating image...'); - } else if (response.type === 'agent_response' && response.is_final) { - try { - const parsedContent = JSON.parse(response.content); - - // Update generatedContent with new image - if (parsedContent.image_url || parsedContent.image_base64) { - // Replace old color/product name in text_content when switching products - const oldName = selectedProducts[0]?.product_name; - const newName = mentionedProduct?.product_name; - const nameRegex = oldName - ? new RegExp(oldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi') - : undefined; - const swapName = (s?: string) => { - if (!s || !oldName || !newName || oldName === newName || !nameRegex) return s; - return s.replace(nameRegex, () => newName); - }; - const tc = generatedContent.text_content; - - responseData = { - ...generatedContent, - text_content: mentionedProduct ? { ...tc, headline: swapName(tc?.headline), body: swapName(tc?.body), tagline: swapName(tc?.tagline), cta_text: swapName(tc?.cta_text) } : tc, - image_content: { - ...generatedContent.image_content, - image_url: parsedContent.image_url || generatedContent.image_content?.image_url, - image_base64: parsedContent.image_base64, - prompt_used: parsedContent.image_prompt || generatedContent.image_content?.prompt_used, - }, - }; - setGeneratedContent(responseData); - - // Update the selected product/color name now that the new image is ready - if (mentionedProduct) { - setSelectedProducts([mentionedProduct]); - } - - // Update the confirmed brief to include the modification - // This ensures subsequent "Regenerate" clicks use the updated visual guidelines - const updatedBrief = { - ...confirmedBrief, - visual_guidelines: `${confirmedBrief.visual_guidelines}. User modification: ${content}`, - }; - setConfirmedBrief(updatedBrief); - - messageContent = parsedContent.message || 'Image regenerated with your requested changes.'; - } else if (parsedContent.error) { - messageContent = parsedContent.error; - } else { - messageContent = parsedContent.message || 'I processed your request.'; - } - } catch { - messageContent = response.content || 'Image regenerated.'; - } - } else if (response.type === 'error') { - messageContent = response.content || 'An error occurred while regenerating the image.'; - } - } - - setGenerationStatus(''); - - const assistantMessage: ChatMessage = { - id: uuidv4(), - role: 'assistant', - content: messageContent, - agent: 'ImageAgent', - timestamp: new Date().toISOString(), - }; - setMessages(prev => [...prev, assistantMessage]); - } else { - // General question after content generation - use regular chat - let fullContent = ''; - let currentAgent = ''; - let messageAdded = false; - - setGenerationStatus('Processing your request...'); - for await (const response of streamChat(content, conversationId, userId, signal)) { - if (response.type === 'agent_response') { - fullContent = response.content; - currentAgent = response.agent || ''; - - if ((response.is_final || response.requires_user_input) && !messageAdded) { - const assistantMessage: ChatMessage = { - id: uuidv4(), - role: 'assistant', - content: fullContent, - agent: currentAgent, - timestamp: new Date().toISOString(), - }; - setMessages(prev => [...prev, assistantMessage]); - messageAdded = true; - } - } else if (response.type === 'error') { - const errorMessage: ChatMessage = { - id: uuidv4(), - role: 'assistant', - content: response.content || 'An error occurred.', - timestamp: new Date().toISOString(), - }; - setMessages(prev => [...prev, errorMessage]); - messageAdded = true; - } - } - setGenerationStatus(''); - } - } else { - // Check if this looks like a creative brief - const briefKeywords = ['campaign', 'marketing', 'target audience', 'objective', 'deliverable']; - const isBriefLike = briefKeywords.some(kw => content.toLowerCase().includes(kw)); - - if (isBriefLike && !confirmedBrief) { - // Parse as a creative brief - setGenerationStatus('Analyzing creative brief...'); - const parsed = await parseBrief(content, conversationId, userId, signal); - - // Set conversation title from generated title - if (parsed.generated_title && !conversationTitle) { - setConversationTitle(parsed.generated_title); - } - - // Check if request was blocked due to harmful content - if (parsed.rai_blocked) { - // Show the refusal message without any brief UI - setGenerationStatus(''); - - const assistantMessage: ChatMessage = { - id: uuidv4(), - role: 'assistant', - content: parsed.message, - agent: 'ContentSafety', - timestamp: new Date().toISOString(), - }; - setMessages(prev => [...prev, assistantMessage]); - } else if (parsed.requires_clarification && parsed.clarifying_questions) { - // Set partial brief for display but show clarifying questions - if (parsed.brief) { - setPendingBrief(parsed.brief); - } - setAwaitingClarification(true); - setGenerationStatus(''); - - const assistantMessage: ChatMessage = { - id: uuidv4(), - role: 'assistant', - content: parsed.clarifying_questions, - agent: 'PlanningAgent', - timestamp: new Date().toISOString(), - }; - setMessages(prev => [...prev, assistantMessage]); - } else { - // Brief is complete, show for confirmation - if (parsed.brief) { - setPendingBrief(parsed.brief); - } - setAwaitingClarification(false); - setGenerationStatus(''); - - const assistantMessage: ChatMessage = { - id: uuidv4(), - role: 'assistant', - content: "I've parsed your creative brief. Please review the details below and let me know if you'd like to make any changes. You can say things like \"change the target audience to...\" or \"add a call to action...\". When everything looks good, click **Confirm Brief** to proceed.", - agent: 'PlanningAgent', - timestamp: new Date().toISOString(), - }; - setMessages(prev => [...prev, assistantMessage]); - } - } else { - // Stream chat response - let fullContent = ''; - let currentAgent = ''; - let messageAdded = false; - - setGenerationStatus('Processing your request...'); - for await (const response of streamChat(content, conversationId, userId, signal)) { - if (response.type === 'agent_response') { - fullContent = response.content; - currentAgent = response.agent || ''; - - // Add message when final OR when requiring user input (interactive response) - if ((response.is_final || response.requires_user_input) && !messageAdded) { - const assistantMessage: ChatMessage = { - id: uuidv4(), - role: 'assistant', - content: fullContent, - agent: currentAgent, - timestamp: new Date().toISOString(), - }; - setMessages(prev => [...prev, assistantMessage]); - messageAdded = true; - } - } else if (response.type === 'error') { - // Handle error responses - const errorMessage: ChatMessage = { - id: uuidv4(), - role: 'assistant', - content: response.content || 'An error occurred while processing your request.', - timestamp: new Date().toISOString(), - }; - setMessages(prev => [...prev, errorMessage]); - messageAdded = true; - } - } - setGenerationStatus(''); - } - } - } catch (error) { - // Check if this was a user-initiated cancellation - if (error instanceof Error && error.name === 'AbortError') { - console.log('Request cancelled by user'); - const cancelMessage: ChatMessage = { - id: uuidv4(), - role: 'assistant', - content: 'Generation stopped.', - timestamp: new Date().toISOString(), - }; - setMessages(prev => [...prev, cancelMessage]); - } else { - console.error('Error sending message:', error); - const errorMessage: ChatMessage = { - id: uuidv4(), - role: 'assistant', - content: 'Sorry, there was an error processing your request. Please try again.', - timestamp: new Date().toISOString(), - }; - setMessages(prev => [...prev, errorMessage]); - } - } finally { - setIsLoading(false); - setGenerationStatus(''); - abortControllerRef.current = null; - // Trigger refresh of chat history after message is sent - setHistoryRefreshTrigger(prev => prev + 1); - } - }, [conversationId, userId, confirmedBrief, pendingBrief, selectedProducts, generatedContent, availableProducts]); - - const handleBriefConfirm = useCallback(async () => { - if (!pendingBrief) return; - - try { - const { confirmBrief } = await import('./api'); - await confirmBrief(pendingBrief, conversationId, userId); - setConfirmedBrief(pendingBrief); - setPendingBrief(null); - setAwaitingClarification(false); - - const productsResponse = await fetch('/api/products'); - if (productsResponse.ok) { - const productsData = await productsResponse.json(); - setAvailableProducts(productsData.products || []); - } - - const assistantMessage: ChatMessage = { - id: uuidv4(), - role: 'assistant', - content: "Great! Your creative brief has been confirmed. Here are the available products for your campaign. Select the ones you'd like to feature, or tell me what you're looking for.", - agent: 'ProductAgent', - timestamp: new Date().toISOString(), - }; - setMessages(prev => [...prev, assistantMessage]); - } catch (error) { - console.error('Error confirming brief:', error); - } - }, [conversationId, userId, pendingBrief]); - - const handleBriefCancel = useCallback(() => { - setPendingBrief(null); - setAwaitingClarification(false); - const assistantMessage: ChatMessage = { - id: uuidv4(), - role: 'assistant', - content: 'No problem. Please provide your creative brief again or ask me any questions.', - timestamp: new Date().toISOString(), - }; - setMessages(prev => [...prev, assistantMessage]); - }, []); - - const handleProductsStartOver = useCallback(() => { - setSelectedProducts([]); - setConfirmedBrief(null); - const assistantMessage: ChatMessage = { - id: uuidv4(), - role: 'assistant', - content: 'Starting over. Please provide your creative brief to begin a new campaign.', - timestamp: new Date().toISOString(), - }; - setMessages(prev => [...prev, assistantMessage]); - }, []); - - const handleProductSelect = useCallback((product: Product) => { - setSelectedProducts(prev => { - const isSelected = prev.some(p => (p.sku || p.product_name) === (product.sku || product.product_name)); - if (isSelected) { - // Deselect - but user must have at least one selected to proceed - return []; - } else { - // Single selection mode - replace any existing selection - return [product]; - } - }); - }, []); - - const handleStopGeneration = useCallback(() => { - if (abortControllerRef.current) { - abortControllerRef.current.abort(); - } - }, []); - - const handleGenerateContent = useCallback(async () => { - if (!confirmedBrief) return; - - setIsLoading(true); - setGenerationStatus('Starting content generation...'); - - // Create new abort controller for this request - abortControllerRef.current = new AbortController(); - const signal = abortControllerRef.current.signal; - - try { - const { streamGenerateContent } = await import('./api'); - - for await (const response of streamGenerateContent( - confirmedBrief, - selectedProducts, - true, - conversationId, - userId, - signal - )) { - // Handle heartbeat events to show progress - if (response.type === 'heartbeat') { - // Use the message from the heartbeat directly - it contains the stage description - const statusMessage = response.content || 'Generating content...'; - const elapsed = (response as { elapsed?: number }).elapsed || 0; - setGenerationStatus(`${statusMessage} (${elapsed}s)`); - continue; - } - - if (response.is_final && response.type !== 'error') { - setGenerationStatus('Processing results...'); - try { - const rawContent = JSON.parse(response.content); - - // Parse text_content if it's a string (from orchestrator) - let textContent = rawContent.text_content; - if (typeof textContent === 'string') { - try { - textContent = JSON.parse(textContent); - } catch { - // Keep as string if not valid JSON - } - } - - // Build image_url: prefer blob URL, fallback to base64 data URL - let imageUrl: string | undefined; - if (rawContent.image_url) { - imageUrl = rawContent.image_url; - } else if (rawContent.image_base64) { - imageUrl = `data:image/png;base64,${rawContent.image_base64}`; - } - - const content: GeneratedContent = { - text_content: typeof textContent === 'object' ? { - headline: textContent?.headline, - body: textContent?.body, - cta_text: textContent?.cta, - tagline: textContent?.tagline, - } : undefined, - image_content: (imageUrl || rawContent.image_prompt) ? { - image_url: imageUrl, - prompt_used: rawContent.image_prompt, - alt_text: rawContent.image_revised_prompt || 'Generated marketing image', - } : undefined, - violations: rawContent.violations || [], - requires_modification: rawContent.requires_modification || false, - // Capture any generation errors - error: rawContent.error, - image_error: rawContent.image_error, - text_error: rawContent.text_error, - }; - setGeneratedContent(content); - setGenerationStatus(''); - - // Content is displayed via InlineContentPreview - no need for a separate chat message - } catch (parseError) { - console.error('Error parsing generated content:', parseError); - } - } else if (response.type === 'error') { - setGenerationStatus(''); - const errorMessage: ChatMessage = { - id: uuidv4(), - role: 'assistant', - content: `Error generating content: ${response.content}`, - timestamp: new Date().toISOString(), - }; - setMessages(prev => [...prev, errorMessage]); - } - } - } catch (error) { - // Check if this was a user-initiated cancellation - if (error instanceof Error && error.name === 'AbortError') { - console.log('Content generation cancelled by user'); - const cancelMessage: ChatMessage = { - id: uuidv4(), - role: 'assistant', - content: 'Content generation stopped.', - timestamp: new Date().toISOString(), - }; - setMessages(prev => [...prev, cancelMessage]); - } else { - console.error('Error generating content:', error); - const errorMessage: ChatMessage = { - id: uuidv4(), - role: 'assistant', - content: 'Sorry, there was an error generating content. Please try again.', - timestamp: new Date().toISOString(), - }; - setMessages(prev => [...prev, errorMessage]); - } - } finally { - setIsLoading(false); - setGenerationStatus(''); - abortControllerRef.current = null; - } - }, [confirmedBrief, selectedProducts, conversationId]); + dispatch(fetchAppConfig()); + dispatch(fetchUserInfo()); + }, [dispatch]); return (
{/* Header */} -
-
- Contoso - - Contoso - -
-
- -
-
- + + {/* Main Content */}
{/* Chat Panel - main area */}
- + {/* Chat History Sidebar - RIGHT side */} {showChatHistory && (
)} diff --git a/content-gen/src/app/frontend/src/api/httpClient.ts b/content-gen/src/app/frontend/src/api/httpClient.ts new file mode 100644 index 000000000..87b271c18 --- /dev/null +++ b/content-gen/src/app/frontend/src/api/httpClient.ts @@ -0,0 +1,197 @@ +/** + * Centralized HTTP client with interceptors. + * + * - Singleton — use the default `httpClient` export everywhere. + * - Request interceptors automatically attach auth headers + * (X-Ms-Client-Principal-Id) so callers never need to remember. + * - Response interceptors provide uniform error handling. + * - Built-in query-param serialization, configurable timeout, and base URL. + */ + +/* ------------------------------------------------------------------ */ +/* Types */ +/* ------------------------------------------------------------------ */ + +/** Options accepted by every request method. */ +export interface RequestOptions extends Omit { + /** Query parameters – appended to the URL automatically. */ + params?: Record; + /** Per-request timeout in ms (default: client-level `timeout`). */ + timeout?: number; +} + +type RequestInterceptor = (url: string, init: RequestInit) => RequestInit | Promise; +type ResponseInterceptor = (response: Response) => Response | Promise; + +/* ------------------------------------------------------------------ */ +/* HttpClient */ +/* ------------------------------------------------------------------ */ + +export class HttpClient { + private baseUrl: string; + private defaultTimeout: number; + private requestInterceptors: RequestInterceptor[] = []; + private responseInterceptors: ResponseInterceptor[] = []; + + constructor(baseUrl = '', timeout = 60_000) { + this.baseUrl = baseUrl; + this.defaultTimeout = timeout; + } + + /* ---------- interceptor registration ---------- */ + + onRequest(fn: RequestInterceptor): void { + this.requestInterceptors.push(fn); + } + + onResponse(fn: ResponseInterceptor): void { + this.responseInterceptors.push(fn); + } + + /* ---------- public request helpers ---------- */ + + async get(path: string, opts: RequestOptions = {}): Promise { + const res = await this.request(path, { ...opts, method: 'GET' }); + return res.json() as Promise; + } + + async post(path: string, body?: unknown, opts: RequestOptions = {}): Promise { + const res = await this.request(path, { + ...opts, + method: 'POST', + body: body != null ? JSON.stringify(body) : undefined, + headers: { + ...(body != null ? { 'Content-Type': 'application/json' } : {}), + ...opts.headers, + }, + }); + return res.json() as Promise; + } + + async put(path: string, body?: unknown, opts: RequestOptions = {}): Promise { + const res = await this.request(path, { + ...opts, + method: 'PUT', + body: body != null ? JSON.stringify(body) : undefined, + headers: { + ...(body != null ? { 'Content-Type': 'application/json' } : {}), + ...opts.headers, + }, + }); + return res.json() as Promise; + } + + async delete(path: string, opts: RequestOptions = {}): Promise { + const res = await this.request(path, { ...opts, method: 'DELETE' }); + return res.json() as Promise; + } + + /** + * Low-level request that returns the raw `Response`. + * Useful for streaming (SSE) endpoints where the caller needs `response.body`. + */ + async raw(path: string, opts: RequestOptions & { method?: string; body?: BodyInit | null } = {}): Promise { + return this.request(path, opts); + } + + /* ---------- internal plumbing ---------- */ + + private buildUrl(path: string, params?: Record): string { + const url = `${this.baseUrl}${path}`; + if (!params) return url; + + const qs = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value !== undefined) { + qs.set(key, String(value)); + } + } + const queryString = qs.toString(); + return queryString ? `${url}?${queryString}` : url; + } + + private async request(path: string, opts: RequestOptions & { method?: string; body?: BodyInit | null } = {}): Promise { + const { params, timeout, ...fetchOpts } = opts; + const url = this.buildUrl(path, params); + const effectiveTimeout = timeout ?? this.defaultTimeout; + + // Build the init object + let init: RequestInit = { ...fetchOpts }; + + // Run request interceptors + for (const interceptor of this.requestInterceptors) { + init = await interceptor(url, init); + } + + // Timeout via AbortController (merged with caller-supplied signal) + const timeoutCtrl = new AbortController(); + const callerSignal = init.signal; + + // If caller already passed a signal, listen for its abort + if (callerSignal) { + if (callerSignal.aborted) { + timeoutCtrl.abort(callerSignal.reason); + } else { + callerSignal.addEventListener('abort', () => timeoutCtrl.abort(callerSignal.reason), { once: true }); + } + } + + const timer = effectiveTimeout > 0 + ? setTimeout(() => timeoutCtrl.abort(new DOMException('Request timed out', 'TimeoutError')), effectiveTimeout) + : undefined; + + init.signal = timeoutCtrl.signal; + + try { + let response = await fetch(url, init); + + // Run response interceptors + for (const interceptor of this.responseInterceptors) { + response = await interceptor(response); + } + + return response; + } finally { + if (timer !== undefined) clearTimeout(timer); + } + } +} + +/* ------------------------------------------------------------------ */ +/* Singleton instance with default interceptors */ +/* ------------------------------------------------------------------ */ + +const httpClient = new HttpClient('/api'); + +// ---- request interceptor: auth headers ---- +httpClient.onRequest((_url, init) => { + const headers = new Headers(init.headers); + + // Attach userId from Redux store (lazy import to avoid circular deps). + // Falls back to 'anonymous' if store isn't ready yet. + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { store } = require('../store/store'); + const userId: string = store.getState().app.userId || 'anonymous'; + headers.set('X-Ms-Client-Principal-Id', userId); + } catch { + headers.set('X-Ms-Client-Principal-Id', 'anonymous'); + } + + return { ...init, headers }; +}); + +// ---- response interceptor: uniform error handling ---- +httpClient.onResponse((response) => { + if (!response.ok) { + // Don't throw for streaming endpoints — callers handle those manually. + // Clone so the body remains readable for callers that want custom handling. + const cloned = response.clone(); + console.error( + `[httpClient] ${response.status} ${response.statusText} – ${cloned.url}`, + ); + } + return response; +}); + +export default httpClient; diff --git a/content-gen/src/app/frontend/src/api/index.ts b/content-gen/src/app/frontend/src/api/index.ts index 1525bd366..a39c86318 100644 --- a/content-gen/src/app/frontend/src/api/index.ts +++ b/content-gen/src/app/frontend/src/api/index.ts @@ -9,20 +9,13 @@ import type { ParsedBriefResponse, AppConfig, } from '../types'; - -const API_BASE = '/api'; +import httpClient from './httpClient'; /** * Get application configuration including feature flags */ export async function getAppConfig(): Promise { - const response = await fetch(`${API_BASE}/config`); - - if (!response.ok) { - throw new Error(`Failed to get config: ${response.statusText}`); - } - - return response.json(); + return httpClient.get('/config'); } /** @@ -34,22 +27,11 @@ export async function parseBrief( userId?: string, signal?: AbortSignal ): Promise { - const response = await fetch(`${API_BASE}/brief/parse`, { - signal, - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - brief_text: briefText, - conversation_id: conversationId, - user_id: userId || 'anonymous', - }), - }); - - if (!response.ok) { - throw new Error(`Failed to parse brief: ${response.statusText}`); - } - - return response.json(); + return httpClient.post('/brief/parse', { + brief_text: briefText, + conversation_id: conversationId, + user_id: userId || 'anonymous', + }, { signal }); } /** @@ -60,21 +42,11 @@ export async function confirmBrief( conversationId?: string, userId?: string ): Promise<{ status: string; conversation_id: string; brief: CreativeBrief }> { - const response = await fetch(`${API_BASE}/brief/confirm`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - brief, - conversation_id: conversationId, - user_id: userId || 'anonymous', - }), + return httpClient.post('/brief/confirm', { + brief, + conversation_id: conversationId, + user_id: userId || 'anonymous', }); - - if (!response.ok) { - throw new Error(`Failed to confirm brief: ${response.statusText}`); - } - - return response.json(); } /** @@ -87,23 +59,12 @@ export async function selectProducts( userId?: string, signal?: AbortSignal ): Promise<{ products: Product[]; action: string; message: string; conversation_id: string }> { - const response = await fetch(`${API_BASE}/products/select`, { - signal, - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - request, - current_products: currentProducts, - conversation_id: conversationId, - user_id: userId || 'anonymous', - }), - }); - - if (!response.ok) { - throw new Error(`Failed to select products: ${response.statusText}`); - } - - return response.json(); + return httpClient.post('/products/select', { + request, + current_products: currentProducts, + conversation_id: conversationId, + user_id: userId || 'anonymous', + }, { signal }); } /** @@ -115,9 +76,9 @@ export async function* streamChat( userId?: string, signal?: AbortSignal ): AsyncGenerator { - const response = await fetch(`${API_BASE}/chat`, { - signal, + const response = await httpClient.raw('/chat', { method: 'POST', + signal, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message, @@ -174,27 +135,16 @@ export async function* streamGenerateContent( signal?: AbortSignal ): AsyncGenerator { // Use polling-based approach for reliability with long-running tasks - const startResponse = await fetch(`${API_BASE}/generate/start`, { - signal, - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - brief, - products: products || [], - generate_images: generateImages, - conversation_id: conversationId, - user_id: userId || 'anonymous', - }), - }); - - if (!startResponse.ok) { - throw new Error(`Content generation failed to start: ${startResponse.statusText}`); - } - - const startData = await startResponse.json(); + const startData = await httpClient.post<{ task_id: string }>('/generate/start', { + brief, + products: products || [], + generate_images: generateImages, + conversation_id: conversationId, + user_id: userId || 'anonymous', + }, { signal }); const taskId = startData.task_id; - console.log(`Generation started with task ID: ${taskId}`); + console.debug(`Generation started with task ID: ${taskId}`); // Yield initial status yield { @@ -223,12 +173,10 @@ export async function* streamGenerateContent( } try { - const statusResponse = await fetch(`${API_BASE}/generate/status/${taskId}`, { signal }); - if (!statusResponse.ok) { - throw new Error(`Failed to get task status: ${statusResponse.statusText}`); - } - - const statusData = await statusResponse.json(); + const statusData = await httpClient.get<{ status: string; result?: unknown; error?: string }>( + `/generate/status/${taskId}`, + { signal }, + ); if (statusData.status === 'completed') { // Yield the final result @@ -300,9 +248,9 @@ export async function* streamRegenerateImage( userId?: string, signal?: AbortSignal ): AsyncGenerator { - const response = await fetch(`${API_BASE}/regenerate`, { - signal, + const response = await httpClient.raw('/regenerate', { method: 'POST', + signal, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ modification_request: modificationRequest, diff --git a/content-gen/src/app/frontend/src/components/AppHeader.tsx b/content-gen/src/app/frontend/src/components/AppHeader.tsx new file mode 100644 index 000000000..810f0a072 --- /dev/null +++ b/content-gen/src/app/frontend/src/components/AppHeader.tsx @@ -0,0 +1,68 @@ +import { memo } from 'react'; +import { + Text, + Avatar, + Button, + Tooltip, + tokens, +} from '@fluentui/react-components'; +import { + History24Regular, + History24Filled, +} from '@fluentui/react-icons'; +import ContosoLogo from '../styles/images/contoso.svg'; + +export interface AppHeaderProps { + userName?: string | null; + showChatHistory: boolean; + onToggleChatHistory: () => void; +} + +/** + * Top-level application header with logo, title, history toggle and avatar. + */ +export const AppHeader = memo(function AppHeader({ userName, showChatHistory, onToggleChatHistory }: AppHeaderProps) { + return ( +
+
+ Contoso + + Contoso + +
+ +
+ +
+
+ ); +}); +AppHeader.displayName = 'AppHeader'; diff --git a/content-gen/src/app/frontend/src/components/BriefReview.tsx b/content-gen/src/app/frontend/src/components/BriefReview.tsx index 6ea755905..88fbae5c7 100644 --- a/content-gen/src/app/frontend/src/components/BriefReview.tsx +++ b/content-gen/src/app/frontend/src/components/BriefReview.tsx @@ -1,3 +1,4 @@ +import { memo, useMemo } from 'react'; import { Button, Text, @@ -25,34 +26,38 @@ const fieldLabels: Record = { cta: 'Call to Action', }; -export function BriefReview({ +export const BriefReview = memo(function BriefReview({ brief, onConfirm, onStartOver, isAwaitingResponse = false, }: BriefReviewProps) { - const allFields: (keyof CreativeBrief)[] = [ - 'overview', 'objectives', 'target_audience', 'key_message', - 'tone_and_style', 'deliverable', 'timelines', 'visual_guidelines', 'cta' - ]; - const populatedFields = allFields.filter(key => brief[key]?.trim()).length; - const missingFields = allFields.filter(key => !brief[key]?.trim()); + const { populatedFields, missingFields, populatedDisplayFields } = useMemo(() => { + const allFields: (keyof CreativeBrief)[] = [ + 'overview', 'objectives', 'target_audience', 'key_message', + 'tone_and_style', 'deliverable', 'timelines', 'visual_guidelines', 'cta' + ]; + const populated = allFields.filter(key => brief[key]?.trim()).length; + const missing = allFields.filter(key => !brief[key]?.trim()); - // Define the order and labels for display in the card - const displayOrder: { key: keyof CreativeBrief; label: string }[] = [ - { key: 'overview', label: 'Campaign Objective' }, - { key: 'objectives', label: 'Objectives' }, - { key: 'target_audience', label: 'Target Audience' }, - { key: 'key_message', label: 'Key Message' }, - { key: 'tone_and_style', label: 'Tone & Style' }, - { key: 'visual_guidelines', label: 'Visual Guidelines' }, - { key: 'deliverable', label: 'Deliverables' }, - { key: 'timelines', label: 'Timelines' }, - { key: 'cta', label: 'Call to Action' }, - ]; + const displayOrder: { key: keyof CreativeBrief; label: string }[] = [ + { key: 'overview', label: 'Campaign Objective' }, + { key: 'objectives', label: 'Objectives' }, + { key: 'target_audience', label: 'Target Audience' }, + { key: 'key_message', label: 'Key Message' }, + { key: 'tone_and_style', label: 'Tone & Style' }, + { key: 'visual_guidelines', label: 'Visual Guidelines' }, + { key: 'deliverable', label: 'Deliverables' }, + { key: 'timelines', label: 'Timelines' }, + { key: 'cta', label: 'Call to Action' }, + ]; - // Filter to only populated fields - const populatedDisplayFields = displayOrder.filter(({ key }) => brief[key]?.trim()); + return { + populatedFields: populated, + missingFields: missing, + populatedDisplayFields: displayOrder.filter(({ key }) => brief[key]?.trim()), + }; + }, [brief]); return (
); -} +}); +BriefReview.displayName = 'BriefReview'; diff --git a/content-gen/src/app/frontend/src/components/ChatHistory.tsx b/content-gen/src/app/frontend/src/components/ChatHistory.tsx index f258d2a48..37dd7b705 100644 --- a/content-gen/src/app/frontend/src/components/ChatHistory.tsx +++ b/content-gen/src/app/frontend/src/components/ChatHistory.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, useRef } from 'react'; +import { useEffect, useCallback, useMemo, memo } from 'react'; import { Button, Text, @@ -10,7 +10,6 @@ import { MenuPopover, MenuList, MenuItem, - Input, Dialog, DialogSurface, DialogTitle, @@ -22,154 +21,110 @@ import { Chat24Regular, MoreHorizontal20Regular, Compose20Regular, - Delete20Regular, - Edit20Regular, DismissCircle20Regular, } from '@fluentui/react-icons'; - -interface ConversationSummary { - id: string; - title: string; - lastMessage: string; - timestamp: string; - messageCount: number; -} +import { + useAppDispatch, + useAppSelector, + fetchConversations, + deleteConversation, + renameConversation, + clearAllConversations, + setShowAll as setShowAllAction, + setIsClearAllDialogOpen, + selectConversations, + selectIsHistoryLoading, + selectHistoryError, + selectShowAll, + selectIsClearAllDialogOpen, + selectIsClearing, + selectConversationId, + selectConversationTitle, + selectMessages, + selectIsLoading, + selectHistoryRefreshTrigger, +} from '../store'; +import type { ConversationSummary } from '../store'; +import { ConversationItem } from './ConversationItem'; interface ChatHistoryProps { - currentConversationId: string; - currentConversationTitle?: string | null; - currentMessages?: { role: string; content: string }[]; // Current session messages onSelectConversation: (conversationId: string) => void; onNewConversation: () => void; - refreshTrigger?: number; // Increment to trigger refresh - isGenerating?: boolean; // True when content generation is in progress } -export function ChatHistory({ - currentConversationId, - currentConversationTitle, - currentMessages = [], +export const ChatHistory = memo(function ChatHistory({ onSelectConversation, onNewConversation, - refreshTrigger = 0, - isGenerating = false }: ChatHistoryProps) { - const [conversations, setConversations] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - const [showAll, setShowAll] = useState(false); - const [isClearAllDialogOpen, setIsClearAllDialogOpen] = useState(false); - const [isClearing, setIsClearing] = useState(false); + const dispatch = useAppDispatch(); + const conversations = useAppSelector(selectConversations); + const isLoading = useAppSelector(selectIsHistoryLoading); + const error = useAppSelector(selectHistoryError); + const showAll = useAppSelector(selectShowAll); + const isClearAllDialogOpen = useAppSelector(selectIsClearAllDialogOpen); + const isClearing = useAppSelector(selectIsClearing); + const currentConversationId = useAppSelector(selectConversationId); + const currentConversationTitle = useAppSelector(selectConversationTitle); + const currentMessages = useAppSelector(selectMessages); + const isGenerating = useAppSelector(selectIsLoading); + const refreshTrigger = useAppSelector(selectHistoryRefreshTrigger); + const INITIAL_COUNT = 5; const handleClearAllConversations = useCallback(async () => { - setIsClearing(true); try { - const response = await fetch('/api/conversations', { - method: 'DELETE', - }); - if (response.ok) { - setConversations([]); - onNewConversation(); - setIsClearAllDialogOpen(false); - } else { - console.error('Failed to clear all conversations'); - } + await dispatch(clearAllConversations()).unwrap(); + onNewConversation(); } catch (err) { console.error('Error clearing all conversations:', err); - } finally { - setIsClearing(false); } - }, [onNewConversation]); + }, [dispatch, onNewConversation]); const handleDeleteConversation = useCallback(async (conversationId: string) => { try { - const response = await fetch(`/api/conversations/${conversationId}`, { - method: 'DELETE', - }); - if (response.ok) { - setConversations(prev => prev.filter(c => c.id !== conversationId)); - if (conversationId === currentConversationId) { - onNewConversation(); - } - } else { - console.error('Failed to delete conversation'); + await dispatch(deleteConversation(conversationId)).unwrap(); + if (conversationId === currentConversationId) { + onNewConversation(); } } catch (err) { console.error('Error deleting conversation:', err); } - }, [currentConversationId, onNewConversation]); + }, [dispatch, currentConversationId, onNewConversation]); const handleRenameConversation = useCallback(async (conversationId: string, newTitle: string) => { try { - const response = await fetch(`/api/conversations/${conversationId}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ title: newTitle }), - }); - - if (response.ok) { - setConversations(prev => prev.map(c => - c.id === conversationId ? { ...c, title: newTitle } : c - )); - } else { - console.error('Failed to rename conversation'); - } + await dispatch(renameConversation({ conversationId, newTitle })).unwrap(); } catch (err) { console.error('Error renaming conversation:', err); } - }, []); - - const loadConversations = useCallback(async () => { - setIsLoading(true); - setError(null); - try { - // Backend gets user from auth headers, no need to pass user_id - const response = await fetch('/api/conversations'); - if (response.ok) { - const data = await response.json(); - setConversations(data.conversations || []); - } else { - // If no conversations endpoint, use empty list - setConversations([]); - } - } catch (err) { - console.error('Error loading conversations:', err); - setError('Unable to load conversation history'); - setConversations([]); - } finally { - setIsLoading(false); - } - }, []); + }, [dispatch]); useEffect(() => { - loadConversations(); - }, [loadConversations, refreshTrigger]); + dispatch(fetchConversations()); + }, [dispatch, refreshTrigger]); // Reset showAll when conversations change significantly useEffect(() => { - setShowAll(false); - }, [refreshTrigger]); + dispatch(setShowAllAction(false)); + }, [dispatch, refreshTrigger]); // Build the current session conversation summary if it has messages - const currentSessionConversation: ConversationSummary | null = + const currentSessionConversation = useMemo(() => currentMessages.length > 0 && currentConversationTitle ? { id: currentConversationId, title: currentConversationTitle, lastMessage: currentMessages[currentMessages.length - 1]?.content?.substring(0, 100) || '', timestamp: new Date().toISOString(), messageCount: currentMessages.length, - } : null; + } : null, + [currentMessages, currentConversationId, currentConversationTitle], + ); // Merge current session with saved conversations, updating the current one with live data - const displayConversations = (() => { - // Find if current conversation exists in saved list + const displayConversations = useMemo(() => { const existingIndex = conversations.findIndex(c => c.id === currentConversationId); if (existingIndex >= 0 && currentSessionConversation) { - // Update the saved conversation with current session data (live message count) const updated = [...conversations]; updated[existingIndex] = { ...updated[existingIndex], @@ -178,14 +133,23 @@ export function ChatHistory({ }; return updated; } else if (currentSessionConversation) { - // Add current session at the top if it has messages and isn't saved yet return [currentSessionConversation, ...conversations]; } return conversations; - })(); + }, [conversations, currentConversationId, currentSessionConversation, currentMessages]); + + const visibleConversations = useMemo( + () => showAll ? displayConversations : displayConversations.slice(0, INITIAL_COUNT), + [showAll, displayConversations], + ); + const hasMore = useMemo( + () => displayConversations.length > INITIAL_COUNT, + [displayConversations.length], + ); - const visibleConversations = showAll ? displayConversations : displayConversations.slice(0, INITIAL_COUNT); - const hasMore = displayConversations.length > INITIAL_COUNT; + const handleRefreshConversations = useCallback(() => { + dispatch(fetchConversations()); + }, [dispatch]); return (
} - onClick={() => setIsClearAllDialogOpen(true)} + onClick={() => dispatch(setIsClearAllDialogOpen(true))} disabled={displayConversations.length === 0} > Clear all chat history @@ -276,7 +240,7 @@ export function ChatHistory({ }}> {error} Retry @@ -304,7 +268,7 @@ export function ChatHistory({ onSelect={() => onSelectConversation(conversation.id)} onDelete={handleDeleteConversation} onRename={handleRenameConversation} - onRefresh={loadConversations} + onRefresh={handleRefreshConversations} disabled={isGenerating} /> ))} @@ -327,7 +291,7 @@ export function ChatHistory({ }}> {hasMore && ( setShowAll(!showAll)} + onClick={isGenerating ? undefined : () => dispatch(setShowAllAction(!showAll))} style={{ fontSize: '13px', color: isGenerating ? tokens.colorNeutralForegroundDisabled : tokens.colorBrandForeground1, @@ -339,15 +303,15 @@ export function ChatHistory({ )} @@ -357,7 +321,7 @@ export function ChatHistory({
{/* Clear All Confirmation Dialog */} - !isClearing && setIsClearAllDialogOpen(data.open)}> + !isClearing && dispatch(setIsClearAllDialogOpen(data.open))}> Clear all chat history @@ -368,7 +332,7 @@ export function ChatHistory({ -
); -} - -interface ConversationItemProps { - conversation: ConversationSummary; - isActive: boolean; - onSelect: () => void; - onDelete: (conversationId: string) => void; - onRename: (conversationId: string, newTitle: string) => void; - onRefresh: () => void; - disabled?: boolean; -} - -function ConversationItem({ - conversation, - isActive, - onSelect, - onDelete, - onRename, - onRefresh, - disabled = false, -}: ConversationItemProps) { - const [isMenuOpen, setIsMenuOpen] = useState(false); - const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false); - const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); - const [renameValue, setRenameValue] = useState(conversation.title || ''); - const [renameError, setRenameError] = useState(''); - const renameInputRef = useRef(null); - - const handleRenameClick = () => { - setRenameValue(conversation.title || ''); - setRenameError(''); - setIsRenameDialogOpen(true); - setIsMenuOpen(false); - }; - - const handleRenameConfirm = async () => { - const trimmedValue = renameValue.trim(); - - // Validate before API call - if (trimmedValue.length < 5) { - setRenameError('Conversation name must be at least 5 characters'); - return; - } - if (trimmedValue.length > 50) { - setRenameError('Conversation name cannot exceed 50 characters'); - return; - } - if (!/[a-zA-Z0-9]/.test(trimmedValue)) { - setRenameError('Conversation name must contain at least one letter or number'); - return; - } - - if (trimmedValue === conversation.title) { - setIsRenameDialogOpen(false); - setRenameError(''); - return; - } - - await onRename(conversation.id, trimmedValue); - onRefresh(); - setIsRenameDialogOpen(false); - setRenameError(''); - }; - - const handleDeleteClick = () => { - setIsDeleteDialogOpen(true); - setIsMenuOpen(false); - }; - - const handleDeleteConfirm = async () => { - await onDelete(conversation.id); - setIsDeleteDialogOpen(false); - }; - - useEffect(() => { - if (isRenameDialogOpen && renameInputRef.current) { - renameInputRef.current.focus(); - renameInputRef.current.select(); - } - }, [isRenameDialogOpen]); - - return ( - <> -
- - {conversation.title || 'Untitled'} - - - setIsMenuOpen(data.open)}> - - -
+}); - setIsRenameDialogOpen(data.open)}> - - Rename conversation - - - { - const newValue = e.target.value; - setRenameValue(newValue); - if (newValue.trim() === '') { - setRenameError('Conversation name cannot be empty or contain only spaces'); - } else if (newValue.trim().length < 5) { - setRenameError('Conversation name must be at least 5 characters'); - } else if (!/[a-zA-Z0-9]/.test(newValue)) { - setRenameError('Conversation name must contain at least one letter or number'); - } else if (newValue.length > 50) { - setRenameError('Conversation name cannot exceed 50 characters'); - } else { - setRenameError(''); - } - }} - onKeyDown={(e) => { - if (e.key === 'Enter' && renameValue.trim()) { - handleRenameConfirm(); - } else if (e.key === 'Escape') { - setIsRenameDialogOpen(false); - } - }} - placeholder="Enter conversation name" - style={{ width: '100%' }} - /> - - Maximum 50 characters ({renameValue.length}/50) - - {renameError && ( - - {renameError} - - )} - - - - - - - - - - setIsDeleteDialogOpen(data.open)}> - - Delete conversation - - - - Are you sure you want to delete "{conversation.title || 'Untitled'}"? This action cannot be undone. - - - - - - - - - - - ); -} +ChatHistory.displayName = 'ChatHistory'; diff --git a/content-gen/src/app/frontend/src/components/ChatInput.tsx b/content-gen/src/app/frontend/src/components/ChatInput.tsx new file mode 100644 index 000000000..a27f747fd --- /dev/null +++ b/content-gen/src/app/frontend/src/components/ChatInput.tsx @@ -0,0 +1,148 @@ +import { memo, useState, useCallback } from 'react'; +import { + Button, + Text, + Tooltip, + tokens, +} from '@fluentui/react-components'; +import { + Send20Regular, + Add20Regular, +} from '@fluentui/react-icons'; + +export interface ChatInputProps { + /** Called with the trimmed message text when the user submits. */ + onSendMessage: (message: string) => void; + /** Called when the user clicks the "New chat" button. */ + onNewConversation?: () => void; + /** Disables the input and buttons while a request is in flight. */ + disabled?: boolean; + /** Allows the parent to drive the input value (e.g. from WelcomeCard suggestions). */ + value?: string; + /** Notifies the parent when the user types. */ + onChange?: (value: string) => void; +} + +/** + * Chat input bar with send & new-chat buttons, plus an AI disclaimer. + */ +export const ChatInput = memo(function ChatInput({ + onSendMessage, + onNewConversation, + disabled = false, + value: controlledValue, + onChange: controlledOnChange, +}: ChatInputProps) { + const [internalValue, setInternalValue] = useState(''); + + // Support both controlled & uncontrolled modes + const inputValue = controlledValue ?? internalValue; + const setInputValue = (v: string) => { + controlledOnChange?.(v); + if (controlledValue === undefined) setInternalValue(v); + }; + + const handleSubmit = useCallback((e: React.FormEvent) => { + e.preventDefault(); + if (inputValue.trim() && !disabled) { + onSendMessage(inputValue.trim()); + setInputValue(''); + } + }, [inputValue, disabled, onSendMessage, setInputValue]); + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSubmit(e); + } + }, [handleSubmit]); + + return ( +
+ {/* Input Box */} +
+ setInputValue(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Type a message" + disabled={disabled} + style={{ + flex: 1, + border: 'none', + outline: 'none', + backgroundColor: 'transparent', + fontFamily: 'var(--fontFamilyBase)', + fontSize: '14px', + color: tokens.colorNeutralForeground1, + }} + /> + + {/* Icons on the right */} +
+ +
+
+ + {/* Disclaimer */} + + AI generated content may be incorrect + +
+ ); +}); +ChatInput.displayName = 'ChatInput'; diff --git a/content-gen/src/app/frontend/src/components/ChatPanel.tsx b/content-gen/src/app/frontend/src/components/ChatPanel.tsx index bf757acf9..0a6c39208 100644 --- a/content-gen/src/app/frontend/src/components/ChatPanel.tsx +++ b/content-gen/src/app/frontend/src/components/ChatPanel.tsx @@ -1,105 +1,101 @@ -import { useState, useRef, useEffect } from 'react'; -import { - Button, - Text, - Badge, - tokens, - Tooltip, -} from '@fluentui/react-components'; -import { - Send20Regular, - Stop24Regular, - Add20Regular, - Copy20Regular, -} from '@fluentui/react-icons'; -import ReactMarkdown from 'react-markdown'; -import type { ChatMessage, CreativeBrief, Product, GeneratedContent } from '../types'; +import { useState, useMemo, useCallback, memo } from 'react'; +import type { Product } from '../types'; import { BriefReview } from './BriefReview'; import { ConfirmedBriefView } from './ConfirmedBriefView'; import { SelectedProductView } from './SelectedProductView'; import { ProductReview } from './ProductReview'; import { InlineContentPreview } from './InlineContentPreview'; import { WelcomeCard } from './WelcomeCard'; +import { MessageBubble } from './MessageBubble'; +import { TypingIndicator } from './TypingIndicator'; +import { ChatInput } from './ChatInput'; +import { useAutoScroll } from '../hooks/useAutoScroll'; +import { + useAppSelector, + selectMessages, + selectIsLoading, + selectGenerationStatus, + selectPendingBrief, + selectConfirmedBrief, + selectGeneratedContent, + selectSelectedProducts, + selectAvailableProducts, + selectImageGenerationEnabled, +} from '../store'; interface ChatPanelProps { - messages: ChatMessage[]; onSendMessage: (message: string) => void; - isLoading: boolean; - generationStatus?: string; onStopGeneration?: () => void; - // Inline component props - pendingBrief?: CreativeBrief | null; - confirmedBrief?: CreativeBrief | null; - generatedContent?: GeneratedContent | null; - selectedProducts?: Product[]; - availableProducts?: Product[]; onBriefConfirm?: () => void; onBriefCancel?: () => void; onGenerateContent?: () => void; onRegenerateContent?: () => void; onProductsStartOver?: () => void; onProductSelect?: (product: Product) => void; - // Feature flags - imageGenerationEnabled?: boolean; - // New chat onNewConversation?: () => void; } -export function ChatPanel({ - messages, +export const ChatPanel = memo(function ChatPanel({ onSendMessage, - isLoading, - generationStatus, onStopGeneration, - pendingBrief, - confirmedBrief, - generatedContent, - selectedProducts = [], - availableProducts = [], onBriefConfirm, onBriefCancel, onGenerateContent, onRegenerateContent, onProductsStartOver, onProductSelect, - imageGenerationEnabled = true, onNewConversation, }: ChatPanelProps) { - const [inputValue, setInputValue] = useState(''); - const messagesEndRef = useRef(null); - const messagesContainerRef = useRef(null); - const inputContainerRef = useRef(null); + const messages = useAppSelector(selectMessages); + const isLoading = useAppSelector(selectIsLoading); + const generationStatus = useAppSelector(selectGenerationStatus); + const pendingBrief = useAppSelector(selectPendingBrief); + const confirmedBrief = useAppSelector(selectConfirmedBrief); + const generatedContent = useAppSelector(selectGeneratedContent); + const selectedProducts = useAppSelector(selectSelectedProducts); + const availableProducts = useAppSelector(selectAvailableProducts); + const imageGenerationEnabled = useAppSelector(selectImageGenerationEnabled); - // Scroll to bottom when messages change - useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); - }, [messages, pendingBrief, confirmedBrief, generatedContent, isLoading, generationStatus]); + const [inputValue, setInputValue] = useState(''); - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (inputValue.trim() && !isLoading) { - onSendMessage(inputValue.trim()); - setInputValue(''); - } - }; + // Auto-scroll to bottom when messages or state changes + const messagesEndRef = useAutoScroll([ + messages, pendingBrief, confirmedBrief, generatedContent, isLoading, generationStatus, + ]); // Determine if we should show inline components - const showBriefReview = pendingBrief && onBriefConfirm && onBriefCancel; - const showProductReview = confirmedBrief && !generatedContent && onGenerateContent; - const showContentPreview = generatedContent && onRegenerateContent; - const showWelcome = messages.length === 0 && !showBriefReview && !showProductReview && !showContentPreview; + const showBriefReview = useMemo( + () => !!(pendingBrief && onBriefConfirm && onBriefCancel), + [pendingBrief, onBriefConfirm, onBriefCancel], + ); + const showProductReview = useMemo( + () => !!(confirmedBrief && !generatedContent && onGenerateContent), + [confirmedBrief, generatedContent, onGenerateContent], + ); + const showContentPreview = useMemo( + () => !!(generatedContent && onRegenerateContent), + [generatedContent, onRegenerateContent], + ); + const showWelcome = useMemo( + () => messages.length === 0 && !showBriefReview && !showProductReview && !showContentPreview, + [messages.length, showBriefReview, showProductReview, showContentPreview], + ); // Handle suggestion click from welcome card - const handleSuggestionClick = (prompt: string) => { + const handleSuggestionClick = useCallback((prompt: string) => { setInputValue(prompt); - }; + }, []); + + const isInputDisabled = useMemo(() => isLoading, [isLoading]); + + const startOverFallback = useCallback(() => {}, []); + const effectiveProductsStartOver = onProductsStartOver || startOverFallback; return (
{/* Messages Area */}
)} @@ -145,8 +141,8 @@ export function ChatPanel({ {})} + onConfirm={onGenerateContent!} + onStartOver={effectiveProductsStartOver} isAwaitingResponse={isLoading} onProductSelect={onProductSelect} disabled={isLoading} @@ -156,79 +152,20 @@ export function ChatPanel({ {/* Inline Content Preview */} {showContentPreview && ( 0 ? selectedProducts[0] : undefined} imageGenerationEnabled={imageGenerationEnabled} /> )} - {/* Loading/Typing Indicator - Coral Style */} + {/* Loading/Typing Indicator */} {isLoading && ( -
-
- - - - - -
- - {generationStatus || 'Thinking...'} - - {onStopGeneration && ( - - - - )} -
+ )} )} @@ -236,202 +173,16 @@ export function ChatPanel({
- {/* Input Area - Simple single-line like Figma */} -
- {/* Input Box */} -
- setInputValue(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - handleSubmit(e); - } - }} - placeholder="Type a message" - disabled={isLoading} - style={{ - flex: 1, - border: 'none', - outline: 'none', - backgroundColor: 'transparent', - fontFamily: 'var(--fontFamilyBase)', - fontSize: '14px', - color: tokens.colorNeutralForeground1, - }} - /> - - {/* Icons on the right */} -
- -
-
- - {/* Disclaimer - Outside the input box */} - - AI generated content may be incorrect - -
+ {/* Input Area */} +
); -} - -// Copy function for messages -const handleCopy = (text: string) => { - navigator.clipboard.writeText(text).catch((err) => { - console.error('Failed to copy text:', err); - }); -}; - -function MessageBubble({ message }: { message: ChatMessage }) { - const isUser = message.role === 'user'; - const [copied, setCopied] = useState(false); +}); - const onCopy = () => { - handleCopy(message.content); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }; - - return ( -
- {/* Agent badge for assistant messages */} - {!isUser && message.agent && ( - - {message.agent} - - )} - - {/* Message content with markdown */} -
- - {message.content} - - - {/* Footer for assistant messages - Coral style */} - {!isUser && ( -
- - AI-generated content may be incorrect - - -
- -
-
- )} -
-
- ); -} +ChatPanel.displayName = 'ChatPanel'; diff --git a/content-gen/src/app/frontend/src/components/ComplianceSection.tsx b/content-gen/src/app/frontend/src/components/ComplianceSection.tsx new file mode 100644 index 000000000..f593a9f4d --- /dev/null +++ b/content-gen/src/app/frontend/src/components/ComplianceSection.tsx @@ -0,0 +1,192 @@ +import { memo } from 'react'; +import { + Text, + Badge, + Button, + Tooltip, + Accordion, + AccordionItem, + AccordionHeader, + AccordionPanel, + tokens, +} from '@fluentui/react-components'; +import { + ArrowSync20Regular, + CheckmarkCircle20Regular, + Warning20Regular, + Info20Regular, + ErrorCircle20Regular, + Copy20Regular, +} from '@fluentui/react-icons'; +import type { ComplianceViolation } from '../types'; +import { ViolationCard } from './ViolationCard'; + +export interface ComplianceSectionProps { + violations: ComplianceViolation[]; + requiresModification: boolean; + /** Callback to copy generated text. */ + onCopyText: () => void; + /** Callback to regenerate content. */ + onRegenerate: () => void; + /** Whether regeneration is in progress. */ + isLoading?: boolean; + /** Whether the copy-text button shows "Copied!". */ + copied?: boolean; +} + +/** + * Compliance callout (action-needed / review-recommended), status footer + * with badges and actions, and the collapsible violations accordion. + */ +export const ComplianceSection = memo(function ComplianceSection({ + violations, + requiresModification, + onCopyText, + onRegenerate, + isLoading, + copied = false, +}: ComplianceSectionProps) { + return ( + <> + {/* User guidance callout */} + {requiresModification ? ( +
+ + Action needed: This content has compliance issues that must be + addressed before use. Please review the details in the Compliance Guidelines + section below and regenerate with modifications, or manually edit the content to + resolve the flagged items. + +
+ ) : violations.length > 0 ? ( +
+ + Optional review: This content is approved but has minor + suggestions for improvement. You can use it as-is or review the recommendations + in the Compliance Guidelines section below. + +
+ ) : null} + + {/* Footer with actions */} +
+
+ {requiresModification ? ( + }> + Requires Modification + + ) : violations.length > 0 ? ( + }> + Review Recommended + + ) : ( + } + > + Approved + + )} +
+ +
+ +
+
+ + {/* AI disclaimer */} + + AI-generated content may be incorrect + + + {/* Collapsible Compliance Accordion */} + {violations.length > 0 && ( + + + +
+ {requiresModification ? ( + + ) : violations.some((v) => v.severity === 'error') ? ( + + ) : violations.some((v) => v.severity === 'warning') ? ( + + ) : ( + + )} + + Compliance Guidelines ({violations.length}{' '} + {violations.length === 1 ? 'item' : 'items'}) + +
+
+ +
+ {violations.map((violation, index) => ( + + ))} +
+
+
+
+ )} + + ); +}); +ComplianceSection.displayName = 'ComplianceSection'; diff --git a/content-gen/src/app/frontend/src/components/ConfirmedBriefView.tsx b/content-gen/src/app/frontend/src/components/ConfirmedBriefView.tsx index e7feb9416..f0806979b 100644 --- a/content-gen/src/app/frontend/src/components/ConfirmedBriefView.tsx +++ b/content-gen/src/app/frontend/src/components/ConfirmedBriefView.tsx @@ -1,3 +1,4 @@ +import { memo } from 'react'; import { Text, Badge, @@ -24,7 +25,7 @@ const briefFields: { key: keyof CreativeBrief; label: string }[] = [ { key: 'cta', label: 'Call to Action' }, ]; -export function ConfirmedBriefView({ brief }: ConfirmedBriefViewProps) { +export const ConfirmedBriefView = memo(function ConfirmedBriefView({ brief }: ConfirmedBriefViewProps) { return (
); -} +}); +ConfirmedBriefView.displayName = 'ConfirmedBriefView'; diff --git a/content-gen/src/app/frontend/src/components/ConversationItem.tsx b/content-gen/src/app/frontend/src/components/ConversationItem.tsx new file mode 100644 index 000000000..16b5ebc5e --- /dev/null +++ b/content-gen/src/app/frontend/src/components/ConversationItem.tsx @@ -0,0 +1,297 @@ +import { memo, useState, useEffect, useRef, useCallback } from 'react'; +import { + Button, + Text, + tokens, + Menu, + MenuTrigger, + MenuPopover, + MenuList, + MenuItem, + Input, + Dialog, + DialogSurface, + DialogTitle, + DialogBody, + DialogActions, + DialogContent, +} from '@fluentui/react-components'; +import { + MoreHorizontal20Regular, + Delete20Regular, + Edit20Regular, +} from '@fluentui/react-icons'; +import type { ConversationSummary } from '../store'; + +export interface ConversationItemProps { + conversation: ConversationSummary; + isActive: boolean; + onSelect: () => void; + onDelete: (conversationId: string) => void; + onRename: (conversationId: string, newTitle: string) => void; + onRefresh: () => void; + disabled?: boolean; +} + +/** + * A single row in the chat-history sidebar — + * title, context-menu (rename / delete) and confirmation dialogs. + */ +export const ConversationItem = memo(function ConversationItem({ + conversation, + isActive, + onSelect, + onDelete, + onRename, + onRefresh, + disabled = false, +}: ConversationItemProps) { + const [isMenuOpen, setIsMenuOpen] = useState(false); + const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [renameValue, setRenameValue] = useState(conversation.title || ''); + const [renameError, setRenameError] = useState(''); + const renameInputRef = useRef(null); + + const handleRenameClick = useCallback(() => { + setRenameValue(conversation.title || ''); + setRenameError(''); + setIsRenameDialogOpen(true); + setIsMenuOpen(false); + }, [conversation.title]); + + const handleRenameConfirm = useCallback(async () => { + const trimmedValue = renameValue.trim(); + + if (trimmedValue.length < 5) { + setRenameError('Conversation name must be at least 5 characters'); + return; + } + if (trimmedValue.length > 50) { + setRenameError('Conversation name cannot exceed 50 characters'); + return; + } + if (!/[a-zA-Z0-9]/.test(trimmedValue)) { + setRenameError('Conversation name must contain at least one letter or number'); + return; + } + + if (trimmedValue === conversation.title) { + setIsRenameDialogOpen(false); + setRenameError(''); + return; + } + + await onRename(conversation.id, trimmedValue); + onRefresh(); + setIsRenameDialogOpen(false); + setRenameError(''); + }, [renameValue, conversation.id, conversation.title, onRename, onRefresh]); + + const handleDeleteClick = useCallback(() => { + setIsDeleteDialogOpen(true); + setIsMenuOpen(false); + }, []); + + const handleDeleteConfirm = useCallback(async () => { + await onDelete(conversation.id); + setIsDeleteDialogOpen(false); + }, [conversation.id, onDelete]); + + useEffect(() => { + if (isRenameDialogOpen && renameInputRef.current) { + renameInputRef.current.focus(); + renameInputRef.current.select(); + } + }, [isRenameDialogOpen]); + + return ( + <> +
+ + {conversation.title || 'Untitled'} + + + setIsMenuOpen(data.open)}> + + +
+ + {/* Rename dialog */} + setIsRenameDialogOpen(data.open)}> + + Rename conversation + + + { + const newValue = e.target.value; + setRenameValue(newValue); + if (newValue.trim() === '') { + setRenameError('Conversation name cannot be empty or contain only spaces'); + } else if (newValue.trim().length < 5) { + setRenameError('Conversation name must be at least 5 characters'); + } else if (!/[a-zA-Z0-9]/.test(newValue)) { + setRenameError('Conversation name must contain at least one letter or number'); + } else if (newValue.length > 50) { + setRenameError('Conversation name cannot exceed 50 characters'); + } else { + setRenameError(''); + } + }} + onKeyDown={(e) => { + if (e.key === 'Enter' && renameValue.trim()) { + handleRenameConfirm(); + } else if (e.key === 'Escape') { + setIsRenameDialogOpen(false); + } + }} + placeholder="Enter conversation name" + style={{ width: '100%' }} + /> + + Maximum 50 characters ({renameValue.length}/50) + + {renameError && ( + + {renameError} + + )} + + + + + + + + + + {/* Delete dialog */} + setIsDeleteDialogOpen(data.open)}> + + Delete conversation + + + + Are you sure you want to delete "{conversation.title || 'Untitled'}"? This action + cannot be undone. + + + + + + + + + + + ); +}); +ConversationItem.displayName = 'ConversationItem'; diff --git a/content-gen/src/app/frontend/src/components/ImagePreviewCard.tsx b/content-gen/src/app/frontend/src/components/ImagePreviewCard.tsx new file mode 100644 index 000000000..b4e1ee50a --- /dev/null +++ b/content-gen/src/app/frontend/src/components/ImagePreviewCard.tsx @@ -0,0 +1,99 @@ +import { memo } from 'react'; +import { + Button, + Text, + Tooltip, + tokens, +} from '@fluentui/react-components'; +import { ArrowDownload20Regular } from '@fluentui/react-icons'; + +export interface ImagePreviewCardProps { + imageUrl: string; + altText?: string; + productName?: string; + tagline?: string; + isSmall?: boolean; + onDownload: () => void; +} + +/** + * Image preview with download button overlay and a product-name / tagline + * text banner below the image. + */ +export const ImagePreviewCard = memo(function ImagePreviewCard({ + imageUrl, + altText = 'Generated marketing image', + productName = 'Your Product', + tagline, + isSmall = false, + onDownload, +}: ImagePreviewCardProps) { + return ( +
+ {/* Image container */} +
+ {altText} + + +
+ + {/* Text banner below image */} +
+ + {productName} + + {tagline && ( + + {tagline} + + )} +
+
+ ); +}); +ImagePreviewCard.displayName = 'ImagePreviewCard'; diff --git a/content-gen/src/app/frontend/src/components/InlineContentPreview.tsx b/content-gen/src/app/frontend/src/components/InlineContentPreview.tsx index 3ee0eead2..c0f2367c8 100644 --- a/content-gen/src/app/frontend/src/components/InlineContentPreview.tsx +++ b/content-gen/src/app/frontend/src/components/InlineContentPreview.tsx @@ -1,27 +1,17 @@ -import { useState, useEffect } from 'react'; +import { memo, useCallback, useMemo } from 'react'; import { - Button, Text, - Badge, Divider, tokens, - Tooltip, - Accordion, - AccordionItem, - AccordionHeader, - AccordionPanel, } from '@fluentui/react-components'; -import { - ArrowSync20Regular, - CheckmarkCircle20Regular, - Warning20Regular, - Info20Regular, - ErrorCircle20Regular, - Copy20Regular, - ArrowDownload20Regular, - ShieldError20Regular, -} from '@fluentui/react-icons'; -import type { GeneratedContent, ComplianceViolation, Product } from '../types'; +import { ShieldError20Regular } from '@fluentui/react-icons'; +import type { GeneratedContent, Product } from '../types'; +import { useWindowSize } from '../hooks/useWindowSize'; +import { isContentFilterError, getErrorMessage } from '../utils/contentErrors'; +import { downloadImage } from '../utils/downloadImage'; +import { useCopyToClipboard } from '../hooks/useCopyToClipboard'; +import { ImagePreviewCard } from './ImagePreviewCard'; +import { ComplianceSection } from './ComplianceSection'; interface InlineContentPreviewProps { content: GeneratedContent; @@ -31,20 +21,7 @@ interface InlineContentPreviewProps { imageGenerationEnabled?: boolean; } -// Custom hook for responsive breakpoints -function useWindowSize() { - const [windowWidth, setWindowWidth] = useState(typeof window !== 'undefined' ? window.innerWidth : 1200); - - useEffect(() => { - const handleResize = () => setWindowWidth(window.innerWidth); - window.addEventListener('resize', handleResize); - return () => window.removeEventListener('resize', handleResize); - }, []); - - return windowWidth; -} - -export function InlineContentPreview({ +export const InlineContentPreview = memo(function InlineContentPreview({ content, onRegenerate, isLoading, @@ -52,141 +29,36 @@ export function InlineContentPreview({ imageGenerationEnabled = true, }: InlineContentPreviewProps) { const { text_content, image_content, violations, requires_modification, error, image_error, text_error } = content; - const [copied, setCopied] = useState(false); + const { copied, copy } = useCopyToClipboard(); const windowWidth = useWindowSize(); const isSmall = windowWidth < 768; - // Helper to detect content filter errors - const isContentFilterError = (errorMessage?: string): boolean => { - if (!errorMessage) return false; - const filterPatterns = [ - 'content_filter', 'ContentFilter', 'content management policy', - 'ResponsibleAI', 'responsible_ai_policy', 'content filtering', - 'filtered', 'safety system', 'self_harm', 'sexual', 'violence', 'hate', - ]; - return filterPatterns.some(pattern => - errorMessage.toLowerCase().includes(pattern.toLowerCase()) - ); - }; - - const getErrorMessage = (errorMessage?: string): { title: string; description: string } => { - if (isContentFilterError(errorMessage)) { - return { - title: 'Content Filtered', - description: 'Your request was blocked by content safety filters. Please try modifying your creative brief.', - }; - } - return { - title: 'Generation Failed', - description: errorMessage || 'An error occurred. Please try again.', - }; - }; - - const handleCopyText = () => { + const handleCopyText = useCallback(() => { const textToCopy = [ text_content?.headline && `✨ ${text_content.headline} ✨`, text_content?.body, text_content?.tagline, ].filter(Boolean).join('\n\n'); - - navigator.clipboard.writeText(textToCopy); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }; + copy(textToCopy); + }, [text_content, copy]); - const handleDownloadImage = async () => { + const handleDownloadImage = useCallback(() => { if (!image_content?.image_url) return; - - try { - const canvas = document.createElement('canvas'); - const ctx = canvas.getContext('2d'); - if (!ctx) return; - - const img = new Image(); - img.crossOrigin = 'anonymous'; - - img.onload = () => { - // Calculate banner height - const bannerHeight = Math.max(60, img.height * 0.1); - const padding = Math.max(16, img.width * 0.03); - - // Set canvas size to include bottom banner - canvas.width = img.width; - canvas.height = img.height + bannerHeight; - - // Draw the image at the top - ctx.drawImage(img, 0, 0); - - // Draw white banner at the bottom - ctx.fillStyle = '#ffffff'; - ctx.fillRect(0, img.height, img.width, bannerHeight); - - // Draw banner border line - ctx.strokeStyle = '#e5e5e5'; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.moveTo(0, img.height); - ctx.lineTo(img.width, img.height); - ctx.stroke(); - - // Draw text in the banner - const headlineText = selectedProduct?.product_name || text_content?.headline || 'Your Product'; - const headlineFontSize = Math.max(18, Math.min(36, img.width * 0.04)); - const taglineText = text_content?.tagline || ''; - const taglineFontSize = Math.max(12, Math.min(20, img.width * 0.025)); - - // Draw headline - ctx.font = `600 ${headlineFontSize}px Georgia, serif`; - ctx.fillStyle = '#1a1a1a'; - ctx.fillText(headlineText, padding, img.height + padding + headlineFontSize * 0.8, img.width - padding * 2); - - // Draw tagline if available - if (taglineText) { - ctx.font = `400 italic ${taglineFontSize}px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif`; - ctx.fillStyle = '#666666'; - ctx.fillText(taglineText, padding, img.height + padding + headlineFontSize + taglineFontSize * 0.8 + 4, img.width - padding * 2); - } - - canvas.toBlob((blob) => { - if (blob) { - const url = URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.download = 'generated-marketing-image.png'; - link.click(); - URL.revokeObjectURL(url); - } - }, 'image/png'); - }; - - img.onerror = () => { - if (image_content?.image_url) { - const link = document.createElement('a'); - link.href = image_content.image_url; - link.download = 'generated-image.png'; - link.click(); - } - }; - - img.src = image_content.image_url; - } catch { - if (image_content?.image_url) { - const link = document.createElement('a'); - link.href = image_content.image_url; - link.download = 'generated-image.png'; - link.click(); - } - } - }; + downloadImage( + image_content.image_url, + selectedProduct?.product_name || text_content?.headline || 'Your Product', + text_content?.tagline, + ); + }, [image_content, selectedProduct, text_content]); // Get product display name - const getProductDisplayName = () => { + const productDisplayName = useMemo(() => { if (selectedProduct) { return selectedProduct.product_name; } return text_content?.headline || 'Your Content'; - }; + }, [selectedProduct, text_content?.headline]); return (
- ✨ Discover the serene elegance of {getProductDisplayName()}. + ✨ Discover the serene elegance of {productDisplayName}. )} @@ -285,74 +157,16 @@ export function InlineContentPreview({
)} - {/* Image Preview - with bottom banner for text */} + {/* Image Preview */} {imageGenerationEnabled && image_content?.image_url && ( -
- {/* Image container */} -
- {image_content.alt_text - - {/* Download button on image */} - -
- - {/* Text banner below image */} -
- - {selectedProduct?.product_name || text_content?.headline || 'Your Product'} - - {text_content?.tagline && ( - - {text_content.tagline} - - )} -
-
+ )} {/* Image Error State */} @@ -383,179 +197,16 @@ export function InlineContentPreview({ - {/* User guidance callout for compliance status */} - {requires_modification ? ( -
- - Action needed: This content has compliance issues that must be addressed before use. - Please review the details in the Compliance Guidelines section below and regenerate with modifications, - or manually edit the content to resolve the flagged items. - -
- ) : violations.length > 0 ? ( -
- - Optional review: This content is approved but has minor suggestions for improvement. - You can use it as-is or review the recommendations in the Compliance Guidelines section below. - -
- ) : null} - - {/* Footer with actions */} -
-
- {/* Approval Status Badge */} - {requires_modification ? ( - }> - Requires Modification - - ) : violations.length > 0 ? ( - }> - Review Recommended - - ) : ( - }> - Approved - - )} -
- -
- -
-
- - {/* AI disclaimer */} - - AI-generated content may be incorrect - - - {/* Collapsible Compliance Section */} - {violations.length > 0 && ( - - - -
- {requires_modification ? ( - - ) : violations.some(v => v.severity === 'error') ? ( - - ) : violations.some(v => v.severity === 'warning') ? ( - - ) : ( - - )} - - Compliance Guidelines ({violations.length} {violations.length === 1 ? 'item' : 'items'}) - -
-
- -
- {violations.map((violation, index) => ( - - ))} -
-
-
-
- )} -
- ); -} - -function ViolationCard({ violation }: { violation: ComplianceViolation }) { - const getSeverityStyles = () => { - switch (violation.severity) { - case 'error': - return { - icon: , - bg: '#fde7e9', - }; - case 'warning': - return { - icon: , - bg: '#fff4ce', - }; - case 'info': - return { - icon: , - bg: '#deecf9', - }; - } - }; - - const { icon, bg } = getSeverityStyles(); - - return ( -
- {icon} -
- - {violation.message} - - - {violation.suggestion} - -
+ {/* Compliance + Footer + Accordion */} +
); -} +}); +InlineContentPreview.displayName = 'InlineContentPreview'; diff --git a/content-gen/src/app/frontend/src/components/MessageBubble.tsx b/content-gen/src/app/frontend/src/components/MessageBubble.tsx new file mode 100644 index 000000000..58d3cb3c2 --- /dev/null +++ b/content-gen/src/app/frontend/src/components/MessageBubble.tsx @@ -0,0 +1,118 @@ +import { memo } from 'react'; +import { + Text, + Badge, + Button, + Tooltip, + tokens, +} from '@fluentui/react-components'; +import { Copy20Regular } from '@fluentui/react-icons'; +import ReactMarkdown from 'react-markdown'; +import type { ChatMessage } from '../types'; +import { useCopyToClipboard } from '../hooks/useCopyToClipboard'; + +export interface MessageBubbleProps { + message: ChatMessage; +} + +/** + * Renders a single chat message — user or assistant. + * + * - User messages: right-aligned, brand-coloured bubble. + * - Assistant messages: left-aligned, full-width, with optional agent badge, + * markdown rendering, copy button and AI disclaimer. + */ +export const MessageBubble = memo(function MessageBubble({ message }: MessageBubbleProps) { + const isUser = message.role === 'user'; + const { copied, copy } = useCopyToClipboard(); + + return ( +
+ {/* Agent badge for assistant messages */} + {!isUser && message.agent && ( + + {message.agent} + + )} + + {/* Message content with markdown */} +
+ {message.content} + + {/* Footer for assistant messages */} + {!isUser && ( +
+ + AI-generated content may be incorrect + + +
+ +
+
+ )} +
+
+ ); +}); +MessageBubble.displayName = 'MessageBubble'; diff --git a/content-gen/src/app/frontend/src/components/ProductCard.tsx b/content-gen/src/app/frontend/src/components/ProductCard.tsx new file mode 100644 index 000000000..050de19f7 --- /dev/null +++ b/content-gen/src/app/frontend/src/components/ProductCard.tsx @@ -0,0 +1,136 @@ +import { memo, useMemo } from 'react'; +import { + Text, + tokens, +} from '@fluentui/react-components'; +import { Box20Regular } from '@fluentui/react-icons'; +import type { Product } from '../types'; + +export interface ProductCardProps { + product: Product; + /** Visual size variant — "normal" for product review grid, "compact" for selected-product view. */ + size?: 'normal' | 'compact'; + /** Whether the card is currently selected (shows brand border). */ + isSelected?: boolean; + /** Click handler. Omit for read-only cards. */ + onClick?: () => void; + disabled?: boolean; +} + +/** + * Reusable product card with image/placeholder, name, tags and price. + * Used by both ProductReview (selectable) and SelectedProductView (read-only). + */ +export const ProductCard = memo(function ProductCard({ + product, + size = 'normal', + isSelected = false, + onClick, + disabled = false, +}: ProductCardProps) { + const isCompact = size === 'compact'; + const imgSize = isCompact ? 56 : 80; + const isInteractive = useMemo(() => !!onClick && !disabled, [onClick, disabled]); + + return ( +
+ {/* Image or placeholder */} + {product.image_url ? ( + {product.product_name} + ) : ( +
+ +
+ )} + + {/* Product info */} +
+ + {product.product_name} + + + {product.tags || product.description || 'soft white, airy, minimal, fresh'} + + + ${product.price?.toFixed(2) || '59.95'} USD + +
+
+ ); +}); +ProductCard.displayName = 'ProductCard'; diff --git a/content-gen/src/app/frontend/src/components/ProductReview.tsx b/content-gen/src/app/frontend/src/components/ProductReview.tsx index 9c0d9e960..7c8a12ce5 100644 --- a/content-gen/src/app/frontend/src/components/ProductReview.tsx +++ b/content-gen/src/app/frontend/src/components/ProductReview.tsx @@ -1,3 +1,4 @@ +import { memo, useMemo, useCallback } from 'react'; import { Button, Text, @@ -5,9 +6,9 @@ import { } from '@fluentui/react-components'; import { Sparkle20Regular, - Box20Regular, } from '@fluentui/react-icons'; import type { Product } from '../types'; +import { ProductCard } from './ProductCard'; interface ProductReviewProps { products: Product[]; @@ -19,7 +20,7 @@ interface ProductReviewProps { disabled?: boolean; } -export function ProductReview({ +export const ProductReview = memo(function ProductReview({ products, onConfirm, onStartOver: _onStartOver, @@ -28,18 +29,24 @@ export function ProductReview({ onProductSelect, disabled = false, }: ProductReviewProps) { - const displayProducts = availableProducts.length > 0 ? availableProducts : products; - const selectedProductIds = new Set(products.map(p => p.sku || p.product_name)); + const displayProducts = useMemo( + () => availableProducts.length > 0 ? availableProducts : products, + [availableProducts, products], + ); + const selectedProductIds = useMemo( + () => new Set(products.map(p => p.sku || p.product_name)), + [products], + ); - const isProductSelected = (product: Product): boolean => { + const isProductSelected = useCallback((product: Product): boolean => { return selectedProductIds.has(product.sku || product.product_name); - }; + }, [selectedProductIds]); - const handleProductClick = (product: Product) => { + const handleProductClick = useCallback((product: Product) => { if (onProductSelect) { onProductSelect(product); } - }; + }, [onProductSelect]); return (
{displayProducts.map((product, index) => ( -
); -} - -interface ProductCardGridProps { - product: Product; - isSelected: boolean; - onClick: () => void; - disabled?: boolean; -} - -function ProductCardGrid({ product, isSelected, onClick, disabled = false }: ProductCardGridProps) { - return ( -
- {product.image_url ? ( - {product.product_name} - ) : ( -
- -
- )} - -
- - {product.product_name} - - - {product.tags || product.description || 'soft white, airy, minimal, fresh'} - - - ${product.price?.toFixed(2) || '59.95'} USD - -
-
- ); -} +}); +ProductReview.displayName = 'ProductReview'; diff --git a/content-gen/src/app/frontend/src/components/SelectedProductView.tsx b/content-gen/src/app/frontend/src/components/SelectedProductView.tsx index a4c4540f6..743a5f86f 100644 --- a/content-gen/src/app/frontend/src/components/SelectedProductView.tsx +++ b/content-gen/src/app/frontend/src/components/SelectedProductView.tsx @@ -1,19 +1,19 @@ +import { memo } from 'react'; import { - Text, Badge, tokens, } from '@fluentui/react-components'; import { Checkmark20Regular, - Box20Regular, } from '@fluentui/react-icons'; import type { Product } from '../types'; +import { ProductCard } from './ProductCard'; interface SelectedProductViewProps { products: Product[]; } -export function SelectedProductView({ products }: SelectedProductViewProps) { +export const SelectedProductView = memo(function SelectedProductView({ products }: SelectedProductViewProps) { if (products.length === 0) return null; return ( @@ -50,89 +50,14 @@ export function SelectedProductView({ products }: SelectedProductViewProps) { overflowY: 'auto', }}> {products.map((product, index) => ( -
- {product.image_url ? ( - {product.product_name} - ) : ( -
- -
- )} - -
- - {product.product_name} - - - {product.tags || product.description || 'soft white, airy, minimal, fresh'} - - - ${product.price?.toFixed(2) || '59.95'} USD - -
-
+ ))}
); -} +}); +SelectedProductView.displayName = 'SelectedProductView'; diff --git a/content-gen/src/app/frontend/src/components/SuggestionCard.tsx b/content-gen/src/app/frontend/src/components/SuggestionCard.tsx new file mode 100644 index 000000000..d557936a5 --- /dev/null +++ b/content-gen/src/app/frontend/src/components/SuggestionCard.tsx @@ -0,0 +1,84 @@ +import { memo } from 'react'; +import { + Card, + Text, + tokens, +} from '@fluentui/react-components'; + +export interface SuggestionCardProps { + title: string; + prompt: string; + icon: string; + isSelected?: boolean; + onClick: () => void; +} + +/** + * A single suggestion prompt card shown on the WelcomeCard screen. + * Handles its own hover / selected styling. + */ +export const SuggestionCard = memo(function SuggestionCard({ + title, + icon, + isSelected = false, + onClick, +}: SuggestionCardProps) { + return ( + { + if (!isSelected) { + e.currentTarget.style.backgroundColor = tokens.colorBrandBackground2; + } + }} + onMouseLeave={(e) => { + if (!isSelected) { + e.currentTarget.style.backgroundColor = tokens.colorNeutralBackground1; + } + }} + > +
+
+ Prompt icon +
+
+ + {title} + +
+
+
+ ); +}); +SuggestionCard.displayName = 'SuggestionCard'; diff --git a/content-gen/src/app/frontend/src/components/TypingIndicator.tsx b/content-gen/src/app/frontend/src/components/TypingIndicator.tsx new file mode 100644 index 000000000..36fdbbf89 --- /dev/null +++ b/content-gen/src/app/frontend/src/components/TypingIndicator.tsx @@ -0,0 +1,76 @@ +import { memo, useMemo } from 'react'; +import { + Button, + Text, + Tooltip, + tokens, +} from '@fluentui/react-components'; +import { Stop24Regular } from '@fluentui/react-icons'; + +export interface TypingIndicatorProps { + /** Status text shown next to the dots (e.g. "Generating image…"). Falls back to "Thinking…". */ + statusText?: string; + /** Callback wired to the Stop button. If omitted the button is hidden. */ + onStop?: () => void; +} + +/** + * Animated "thinking" indicator with optional status text and a Stop button. + */ +export const TypingIndicator = memo(function TypingIndicator({ statusText, onStop }: TypingIndicatorProps) { + const dotStyle = useMemo(() => (delay: string): React.CSSProperties => ({ + width: '8px', + height: '8px', + borderRadius: '50%', + backgroundColor: tokens.colorBrandBackground, + animation: 'pulse 1.4s infinite ease-in-out', + animationDelay: delay, + }), []); + + return ( +
+
+ + + + + +
+ + + {statusText || 'Thinking...'} + + + {onStop && ( + + + + )} +
+ ); +}); +TypingIndicator.displayName = 'TypingIndicator'; diff --git a/content-gen/src/app/frontend/src/components/ViolationCard.tsx b/content-gen/src/app/frontend/src/components/ViolationCard.tsx new file mode 100644 index 000000000..52914c7a6 --- /dev/null +++ b/content-gen/src/app/frontend/src/components/ViolationCard.tsx @@ -0,0 +1,66 @@ +import { memo } from 'react'; +import { + Text, +} from '@fluentui/react-components'; +import { + ErrorCircle20Regular, + Warning20Regular, + Info20Regular, +} from '@fluentui/react-icons'; +import type { ComplianceViolation } from '../types'; + +export interface ViolationCardProps { + violation: ComplianceViolation; +} + +/** + * A single compliance violation row with severity-coloured icon and background. + */ +export const ViolationCard = memo(function ViolationCard({ violation }: ViolationCardProps) { + const getSeverityStyles = () => { + switch (violation.severity) { + case 'error': + return { + icon: , + bg: '#fde7e9', + }; + case 'warning': + return { + icon: , + bg: '#fff4ce', + }; + case 'info': + return { + icon: , + bg: '#deecf9', + }; + } + }; + + const { icon, bg } = getSeverityStyles(); + + return ( +
+ {icon} +
+ + {violation.message} + + + {violation.suggestion} + +
+
+ ); +}); +ViolationCard.displayName = 'ViolationCard'; diff --git a/content-gen/src/app/frontend/src/components/WelcomeCard.tsx b/content-gen/src/app/frontend/src/components/WelcomeCard.tsx index ab5740708..cf89dca2c 100644 --- a/content-gen/src/app/frontend/src/components/WelcomeCard.tsx +++ b/content-gen/src/app/frontend/src/components/WelcomeCard.tsx @@ -1,8 +1,9 @@ +import { memo, useMemo, useCallback } from 'react'; import { - Card, Text, tokens, } from '@fluentui/react-components'; +import { SuggestionCard } from './SuggestionCard'; import FirstPromptIcon from '../styles/images/firstprompt.png'; import SecondPromptIcon from '../styles/images/secondprompt.png'; @@ -30,8 +31,15 @@ interface WelcomeCardProps { currentInput?: string; } -export function WelcomeCard({ onSuggestionClick, currentInput = '' }: WelcomeCardProps) { - const selectedIndex = suggestions.findIndex(s => s.prompt === currentInput); +export const WelcomeCard = memo(function WelcomeCard({ onSuggestionClick, currentInput = '' }: WelcomeCardProps) { + const selectedIndex = useMemo( + () => suggestions.findIndex(s => s.prompt === currentInput), + [currentInput], + ); + + const handleSuggestionClick = useCallback((prompt: string) => { + onSuggestionClick(prompt); + }, [onSuggestionClick]); return (
{ const isSelected = index === selectedIndex; return ( - onSuggestionClick(suggestion.prompt)} - style={{ - padding: 'clamp(12px, 2vw, 16px)', - cursor: 'pointer', - backgroundColor: isSelected ? tokens.colorBrandBackground2 : tokens.colorNeutralBackground1, - border: 'none', - borderRadius: '16px', - transition: 'all 0.2s ease', - }} - onMouseEnter={(e) => { - if (!isSelected) { - e.currentTarget.style.backgroundColor = tokens.colorBrandBackground2; - } - }} - onMouseLeave={(e) => { - if (!isSelected) { - e.currentTarget.style.backgroundColor = tokens.colorNeutralBackground1; - } - }} - > -
-
- Prompt icon -
-
- - {suggestion.title} - -
-
-
+ title={suggestion.title} + prompt={suggestion.prompt} + icon={suggestion.icon} + isSelected={isSelected} + onClick={() => handleSuggestionClick(suggestion.prompt)} + /> ); })}
); -} +}); +WelcomeCard.displayName = 'WelcomeCard'; diff --git a/content-gen/src/app/frontend/src/hooks/useAutoScroll.ts b/content-gen/src/app/frontend/src/hooks/useAutoScroll.ts new file mode 100644 index 000000000..8b1a2a9d1 --- /dev/null +++ b/content-gen/src/app/frontend/src/hooks/useAutoScroll.ts @@ -0,0 +1,30 @@ +import { useEffect, useRef } from 'react'; + +/** + * Scrolls a sentinel element into view whenever any dependency changes. + * + * @param deps - React dependency list that triggers the scroll. + * @returns A ref to attach to a zero-height element at the bottom of the + * scrollable container (the "scroll anchor"). + * + * @example + * ```tsx + * const endRef = useAutoScroll([messages, isLoading]); + * return ( + *
+ * {messages.map(m => )} + *
+ *
+ * ); + * ``` + */ +export function useAutoScroll(deps: React.DependencyList) { + const endRef = useRef(null); + + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => { + endRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, deps); + + return endRef; +} diff --git a/content-gen/src/app/frontend/src/hooks/useChatOrchestrator.ts b/content-gen/src/app/frontend/src/hooks/useChatOrchestrator.ts new file mode 100644 index 000000000..0c4313587 --- /dev/null +++ b/content-gen/src/app/frontend/src/hooks/useChatOrchestrator.ts @@ -0,0 +1,547 @@ +import { useCallback, type MutableRefObject } from 'react'; +import { v4 as uuidv4 } from 'uuid'; + +import type { ChatMessage, GeneratedContent } from '../types'; +import { + useAppDispatch, + useAppSelector, + selectConversationId, + selectUserId, + selectPendingBrief, + selectConfirmedBrief, + selectAwaitingClarification, + selectSelectedProducts, + selectAvailableProducts, + selectGeneratedContent, + addMessage, + setIsLoading, + setGenerationStatus, + setPendingBrief, + setConfirmedBrief, + setAwaitingClarification, + setSelectedProducts, + setGeneratedContent, + incrementHistoryRefresh, + selectConversationTitle, + setConversationTitle, +} from '../store'; + +/* ------------------------------------------------------------------ */ +/* Helper: create a ChatMessage literal */ +/* ------------------------------------------------------------------ */ +function msg( + role: 'user' | 'assistant', + content: string, + agent?: string, +): ChatMessage { + return { + id: uuidv4(), + role, + content, + agent, + timestamp: new Date().toISOString(), + }; +} + +/* ------------------------------------------------------------------ */ +/* Hook */ +/* ------------------------------------------------------------------ */ + +/** + * Orchestrates the entire "send a message" flow. + * + * Depending on the current conversation phase it will: + * - refine a pending brief (PlanningAgent) + * - answer a general question while a brief is pending (streamChat) + * - forward a product-selection request (ProductAgent) + * - regenerate an image (ImageAgent) + * - parse a new creative brief (PlanningAgent) + * - fall through to generic chat (streamChat) + * + * All Redux reads/writes happen inside the hook so the caller is kept + * thin and declarative. + * + * @param abortControllerRef Shared ref that lets the parent (or sibling + * hooks) cancel the in-flight request. + * @returns `{ sendMessage }` — the callback to wire into `ChatPanel`. + */ +export function useChatOrchestrator( + abortControllerRef: MutableRefObject, +) { + const dispatch = useAppDispatch(); + const conversationId = useAppSelector(selectConversationId); + const userId = useAppSelector(selectUserId); + const pendingBrief = useAppSelector(selectPendingBrief); + const confirmedBrief = useAppSelector(selectConfirmedBrief); + const awaitingClarification = useAppSelector(selectAwaitingClarification); + const selectedProducts = useAppSelector(selectSelectedProducts); + const availableProducts = useAppSelector(selectAvailableProducts); + const generatedContent = useAppSelector(selectGeneratedContent); + const conversationTitle = useAppSelector(selectConversationTitle); + + const sendMessage = useCallback( + async (content: string) => { + dispatch(addMessage(msg('user', content))); + dispatch(setIsLoading(true)); + + // Create new abort controller for this request + abortControllerRef.current = new AbortController(); + const signal = abortControllerRef.current.signal; + + try { + // Dynamic imports to keep the initial bundle lean + const { streamChat, parseBrief, selectProducts } = await import( + '../api' + ); + + /* ---------------------------------------------------------- */ + /* Branch 1 – pending brief, not yet confirmed */ + /* ---------------------------------------------------------- */ + if (pendingBrief && !confirmedBrief) { + const refinementKeywords = [ + 'change', 'update', 'modify', 'add', 'remove', 'delete', + 'set', 'make', 'should be', + ]; + const isRefinement = refinementKeywords.some((kw) => + content.toLowerCase().includes(kw), + ); + + if (isRefinement || awaitingClarification) { + // --- 1-a Refine the brief -------------------------------- + const refinementPrompt = `Current creative brief:\n${JSON.stringify(pendingBrief, null, 2)}\n\nUser requested change: ${content}\n\nPlease update the brief accordingly and return the complete updated brief.`; + + dispatch(setGenerationStatus('Updating creative brief...')); + const parsed = await parseBrief( + refinementPrompt, + conversationId, + userId, + signal, + ); + + if (parsed.generated_title && !conversationTitle) { + dispatch(setConversationTitle(parsed.generated_title)); + } + + if (parsed.brief) { + dispatch(setPendingBrief(parsed.brief)); + } + + if (parsed.requires_clarification && parsed.clarifying_questions) { + dispatch(setAwaitingClarification(true)); + dispatch(setGenerationStatus('')); + dispatch( + addMessage( + msg('assistant', parsed.clarifying_questions, 'PlanningAgent'), + ), + ); + } else { + dispatch(setAwaitingClarification(false)); + dispatch(setGenerationStatus('')); + dispatch( + addMessage( + msg( + 'assistant', + "I've updated the brief based on your feedback. Please review the changes above. Let me know if you'd like any other modifications, or click **Confirm Brief** when you're satisfied.", + 'PlanningAgent', + ), + ), + ); + } + } else { + // --- 1-b General question while brief is pending ----------- + let fullContent = ''; + let currentAgent = ''; + let messageAdded = false; + + dispatch(setGenerationStatus('Processing your question...')); + for await (const response of streamChat( + content, + conversationId, + userId, + signal, + )) { + if (response.type === 'agent_response') { + fullContent = response.content; + currentAgent = response.agent || ''; + if ( + (response.is_final || response.requires_user_input) && + !messageAdded + ) { + dispatch( + addMessage(msg('assistant', fullContent, currentAgent)), + ); + messageAdded = true; + } + } else if (response.type === 'error') { + dispatch( + addMessage( + msg( + 'assistant', + response.content || + 'An error occurred while processing your request.', + ), + ), + ); + messageAdded = true; + } + } + dispatch(setGenerationStatus('')); + } + + /* ---------------------------------------------------------- */ + /* Branch 2 – brief confirmed, in product selection */ + /* ---------------------------------------------------------- */ + } else if (confirmedBrief && !generatedContent) { + dispatch(setGenerationStatus('Finding products...')); + const result = await selectProducts( + content, + selectedProducts, + conversationId, + userId, + signal, + ); + dispatch(setSelectedProducts(result.products || [])); + dispatch(setGenerationStatus('')); + dispatch( + addMessage( + msg( + 'assistant', + result.message || 'Products updated.', + 'ProductAgent', + ), + ), + ); + + /* ---------------------------------------------------------- */ + /* Branch 3 – content generated, post-generation phase */ + /* ---------------------------------------------------------- */ + } else if (generatedContent && confirmedBrief) { + const imageModificationKeywords = [ + 'change', 'modify', 'update', 'replace', 'show', 'display', + 'use', 'instead', 'different', 'another', 'make it', 'make the', + 'kitchen', 'dining', 'living', 'bedroom', 'bathroom', 'outdoor', + 'office', 'room', 'scene', 'setting', 'background', 'style', + 'color', 'lighting', + ]; + const isImageModification = imageModificationKeywords.some((kw) => + content.toLowerCase().includes(kw), + ); + + if (isImageModification) { + // --- 3-a Regenerate image -------------------------------- + const { streamRegenerateImage } = await import('../api'); + dispatch( + setGenerationStatus('Regenerating image with your changes...'), + ); + + let responseData: GeneratedContent | null = null; + let messageContent = ''; + + // Detect if user mentions a different product + const mentionedProduct = availableProducts.find((p) => + content.toLowerCase().includes(p.product_name.toLowerCase()), + ); + const productsForRequest = mentionedProduct + ? [mentionedProduct] + : selectedProducts; + + const previousPrompt = + generatedContent.image_content?.prompt_used; + + for await (const response of streamRegenerateImage( + content, + confirmedBrief, + productsForRequest, + previousPrompt, + conversationId, + userId, + signal, + )) { + if (response.type === 'heartbeat') { + dispatch( + setGenerationStatus( + response.message || 'Regenerating image...', + ), + ); + } else if ( + response.type === 'agent_response' && + response.is_final + ) { + try { + const parsedContent = JSON.parse(response.content); + + if ( + parsedContent.image_url || + parsedContent.image_base64 + ) { + // Replace old product name in text_content when switching + const oldName = selectedProducts[0]?.product_name; + const newName = mentionedProduct?.product_name; + const nameRegex = oldName + ? new RegExp( + oldName.replace( + /[.*+?^${}()|[\]\\]/g, + '\\$&', + ), + 'gi', + ) + : undefined; + const swapName = (s?: string) => { + if ( + !s || + !oldName || + !newName || + oldName === newName || + !nameRegex + ) + return s; + return s.replace(nameRegex, () => newName); + }; + const tc = generatedContent.text_content; + + responseData = { + ...generatedContent, + text_content: mentionedProduct + ? { + ...tc, + headline: swapName(tc?.headline), + body: swapName(tc?.body), + tagline: swapName(tc?.tagline), + cta_text: swapName(tc?.cta_text), + } + : tc, + image_content: { + ...generatedContent.image_content, + image_url: + parsedContent.image_url || + generatedContent.image_content?.image_url, + image_base64: parsedContent.image_base64, + prompt_used: + parsedContent.image_prompt || + generatedContent.image_content?.prompt_used, + }, + }; + dispatch(setGeneratedContent(responseData)); + + if (mentionedProduct) { + dispatch(setSelectedProducts([mentionedProduct])); + } + + // Update confirmed brief to include the modification + const updatedBrief = { + ...confirmedBrief, + visual_guidelines: `${confirmedBrief.visual_guidelines}. User modification: ${content}`, + }; + dispatch(setConfirmedBrief(updatedBrief)); + + messageContent = + parsedContent.message || + 'Image regenerated with your requested changes.'; + } else if (parsedContent.error) { + messageContent = parsedContent.error; + } else { + messageContent = + parsedContent.message || 'I processed your request.'; + } + } catch { + messageContent = + response.content || 'Image regenerated.'; + } + } else if (response.type === 'error') { + messageContent = + response.content || + 'An error occurred while regenerating the image.'; + } + } + + dispatch(setGenerationStatus('')); + dispatch( + addMessage(msg('assistant', messageContent, 'ImageAgent')), + ); + } else { + // --- 3-b General question after content generation -------- + let fullContent = ''; + let currentAgent = ''; + let messageAdded = false; + + dispatch(setGenerationStatus('Processing your request...')); + for await (const response of streamChat( + content, + conversationId, + userId, + signal, + )) { + if (response.type === 'agent_response') { + fullContent = response.content; + currentAgent = response.agent || ''; + if ( + (response.is_final || response.requires_user_input) && + !messageAdded + ) { + dispatch( + addMessage(msg('assistant', fullContent, currentAgent)), + ); + messageAdded = true; + } + } else if (response.type === 'error') { + dispatch( + addMessage( + msg( + 'assistant', + response.content || 'An error occurred.', + ), + ), + ); + messageAdded = true; + } + } + dispatch(setGenerationStatus('')); + } + + /* ---------------------------------------------------------- */ + /* Branch 4 – default: initial flow */ + /* ---------------------------------------------------------- */ + } else { + const briefKeywords = [ + 'campaign', 'marketing', 'target audience', 'objective', + 'deliverable', + ]; + const isBriefLike = briefKeywords.some((kw) => + content.toLowerCase().includes(kw), + ); + + if (isBriefLike && !confirmedBrief) { + // --- 4-a Parse as creative brief -------------------------- + dispatch(setGenerationStatus('Analyzing creative brief...')); + const parsed = await parseBrief( + content, + conversationId, + userId, + signal, + ); + + if (parsed.generated_title && !conversationTitle) { + dispatch(setConversationTitle(parsed.generated_title)); + } + + if (parsed.rai_blocked) { + dispatch(setGenerationStatus('')); + dispatch( + addMessage( + msg('assistant', parsed.message, 'ContentSafety'), + ), + ); + } else if ( + parsed.requires_clarification && + parsed.clarifying_questions + ) { + if (parsed.brief) { + dispatch(setPendingBrief(parsed.brief)); + } + dispatch(setAwaitingClarification(true)); + dispatch(setGenerationStatus('')); + dispatch( + addMessage( + msg( + 'assistant', + parsed.clarifying_questions, + 'PlanningAgent', + ), + ), + ); + } else { + if (parsed.brief) { + dispatch(setPendingBrief(parsed.brief)); + } + dispatch(setAwaitingClarification(false)); + dispatch(setGenerationStatus('')); + dispatch( + addMessage( + msg( + 'assistant', + "I've parsed your creative brief. Please review the details below and let me know if you'd like to make any changes. You can say things like \"change the target audience to...\" or \"add a call to action...\". When everything looks good, click **Confirm Brief** to proceed.", + 'PlanningAgent', + ), + ), + ); + } + } else { + // --- 4-b Generic chat ----------------------------------- + let fullContent = ''; + let currentAgent = ''; + let messageAdded = false; + + dispatch(setGenerationStatus('Processing your request...')); + for await (const response of streamChat( + content, + conversationId, + userId, + signal, + )) { + if (response.type === 'agent_response') { + fullContent = response.content; + currentAgent = response.agent || ''; + if ( + (response.is_final || response.requires_user_input) && + !messageAdded + ) { + dispatch( + addMessage(msg('assistant', fullContent, currentAgent)), + ); + messageAdded = true; + } + } else if (response.type === 'error') { + dispatch( + addMessage( + msg( + 'assistant', + response.content || + 'An error occurred while processing your request.', + ), + ), + ); + messageAdded = true; + } + } + dispatch(setGenerationStatus('')); + } + } + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + console.debug('Request cancelled by user'); + dispatch(addMessage(msg('assistant', 'Generation stopped.'))); + } else { + console.error('Error sending message:', error); + dispatch( + addMessage( + msg( + 'assistant', + 'Sorry, there was an error processing your request. Please try again.', + ), + ), + ); + } + } finally { + dispatch(setIsLoading(false)); + dispatch(setGenerationStatus('')); + abortControllerRef.current = null; + dispatch(incrementHistoryRefresh()); + } + }, + [ + conversationId, + userId, + confirmedBrief, + pendingBrief, + selectedProducts, + generatedContent, + availableProducts, + dispatch, + awaitingClarification, + conversationTitle, + abortControllerRef, + ], + ); + + return { sendMessage }; +} diff --git a/content-gen/src/app/frontend/src/hooks/useContentGeneration.ts b/content-gen/src/app/frontend/src/hooks/useContentGeneration.ts new file mode 100644 index 000000000..53513c2d8 --- /dev/null +++ b/content-gen/src/app/frontend/src/hooks/useContentGeneration.ts @@ -0,0 +1,170 @@ +import { useCallback, type MutableRefObject } from 'react'; +import { v4 as uuidv4 } from 'uuid'; + +import type { ChatMessage, GeneratedContent } from '../types'; +import { + useAppDispatch, + useAppSelector, + selectConfirmedBrief, + selectSelectedProducts, + selectConversationId, + selectUserId, + addMessage, + setIsLoading, + setGenerationStatus, + setGeneratedContent, +} from '../store'; + +/** + * Handles the full content-generation lifecycle (start → poll → result) + * and exposes a way to abort the in-flight request. + * + * @param abortControllerRef Shared ref so the UI can cancel either + * chat-orchestration **or** content-generation with one button. + */ +export function useContentGeneration( + abortControllerRef: MutableRefObject, +) { + const dispatch = useAppDispatch(); + const confirmedBrief = useAppSelector(selectConfirmedBrief); + const selectedProducts = useAppSelector(selectSelectedProducts); + const conversationId = useAppSelector(selectConversationId); + const userId = useAppSelector(selectUserId); + + /** Kick off polling-based content generation. */ + const generateContent = useCallback(async () => { + if (!confirmedBrief) return; + + dispatch(setIsLoading(true)); + dispatch(setGenerationStatus('Starting content generation...')); + + abortControllerRef.current = new AbortController(); + const signal = abortControllerRef.current.signal; + + try { + const { streamGenerateContent } = await import('../api'); + + for await (const response of streamGenerateContent( + confirmedBrief, + selectedProducts, + true, + conversationId, + userId, + signal, + )) { + // Heartbeat → update the status bar + if (response.type === 'heartbeat') { + const statusMessage = response.content || 'Generating content...'; + const elapsed = (response as { elapsed?: number }).elapsed || 0; + dispatch(setGenerationStatus(`${statusMessage} (${elapsed}s)`)); + continue; + } + + if (response.is_final && response.type !== 'error') { + dispatch(setGenerationStatus('Processing results...')); + try { + const rawContent = JSON.parse(response.content); + + // Parse text_content if it's a string (from orchestrator) + let textContent = rawContent.text_content; + if (typeof textContent === 'string') { + try { + textContent = JSON.parse(textContent); + } catch { + // Keep as string if not valid JSON + } + } + + // Build image_url: prefer blob URL, fallback to base64 data URL + let imageUrl: string | undefined; + if (rawContent.image_url) { + imageUrl = rawContent.image_url; + } else if (rawContent.image_base64) { + imageUrl = `data:image/png;base64,${rawContent.image_base64}`; + } + + const genContent: GeneratedContent = { + text_content: + typeof textContent === 'object' + ? { + headline: textContent?.headline, + body: textContent?.body, + cta_text: textContent?.cta, + tagline: textContent?.tagline, + } + : undefined, + image_content: + imageUrl || rawContent.image_prompt + ? { + image_url: imageUrl, + prompt_used: rawContent.image_prompt, + alt_text: + rawContent.image_revised_prompt || + 'Generated marketing image', + } + : undefined, + violations: rawContent.violations || [], + requires_modification: + rawContent.requires_modification || false, + error: rawContent.error, + image_error: rawContent.image_error, + text_error: rawContent.text_error, + }; + dispatch(setGeneratedContent(genContent)); + dispatch(setGenerationStatus('')); + } catch (parseError) { + console.error('Error parsing generated content:', parseError); + } + } else if (response.type === 'error') { + dispatch(setGenerationStatus('')); + const errorMessage: ChatMessage = { + id: uuidv4(), + role: 'assistant', + content: `Error generating content: ${response.content}`, + timestamp: new Date().toISOString(), + }; + dispatch(addMessage(errorMessage)); + } + } + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + console.debug('Content generation cancelled by user'); + const cancelMessage: ChatMessage = { + id: uuidv4(), + role: 'assistant', + content: 'Content generation stopped.', + timestamp: new Date().toISOString(), + }; + dispatch(addMessage(cancelMessage)); + } else { + console.error('Error generating content:', error); + const errorMessage: ChatMessage = { + id: uuidv4(), + role: 'assistant', + content: + 'Sorry, there was an error generating content. Please try again.', + timestamp: new Date().toISOString(), + }; + dispatch(addMessage(errorMessage)); + } + } finally { + dispatch(setIsLoading(false)); + dispatch(setGenerationStatus('')); + abortControllerRef.current = null; + } + }, [ + confirmedBrief, + selectedProducts, + conversationId, + dispatch, + userId, + abortControllerRef, + ]); + + /** Abort whichever request is currently in-flight. */ + const stopGeneration = useCallback(() => { + abortControllerRef.current?.abort(); + }, [abortControllerRef]); + + return { generateContent, stopGeneration }; +} diff --git a/content-gen/src/app/frontend/src/hooks/useConversationActions.ts b/content-gen/src/app/frontend/src/hooks/useConversationActions.ts new file mode 100644 index 000000000..76ab731ec --- /dev/null +++ b/content-gen/src/app/frontend/src/hooks/useConversationActions.ts @@ -0,0 +1,296 @@ +import { useCallback } from 'react'; +import { v4 as uuidv4 } from 'uuid'; + +import type { ChatMessage, Product, CreativeBrief, GeneratedContent } from '../types'; +import httpClient from '../api/httpClient'; +import { + useAppDispatch, + useAppSelector, + selectUserId, + selectConversationId, + selectPendingBrief, + selectSelectedProducts, + resetChat, + resetContent, + setConversationId, + setConversationTitle, + setMessages, + addMessage, + setPendingBrief, + setConfirmedBrief, + setAwaitingClarification, + setSelectedProducts, + setAvailableProducts, + setGeneratedContent, + toggleChatHistory, +} from '../store'; + +/* ------------------------------------------------------------------ */ +/* Helper: create a ChatMessage literal */ +/* ------------------------------------------------------------------ */ +function msg( + role: 'user' | 'assistant', + content: string, + agent?: string, +): ChatMessage { + return { + id: uuidv4(), + role, + content, + agent, + timestamp: new Date().toISOString(), + }; +} + +/* ------------------------------------------------------------------ */ +/* Hook */ +/* ------------------------------------------------------------------ */ + +/** + * Encapsulates every conversation-level user action: + * + * - Loading a saved conversation from history + * - Starting a brand-new conversation + * - Confirming / cancelling a creative brief + * - Starting over with products + * - Toggling a product selection + * - Toggling the chat-history sidebar + * + * All Redux reads/writes are internal so the consumer stays declarative. + */ +export function useConversationActions() { + const dispatch = useAppDispatch(); + const userId = useAppSelector(selectUserId); + const conversationId = useAppSelector(selectConversationId); + const pendingBrief = useAppSelector(selectPendingBrief); + const selectedProducts = useAppSelector(selectSelectedProducts); + + /* ------------------------------------------------------------ */ + /* Select (load) a conversation from history */ + /* ------------------------------------------------------------ */ + const selectConversation = useCallback( + async (selectedConversationId: string) => { + try { + const data = await httpClient.get<{ + messages?: { + role: string; + content: string; + timestamp?: string; + agent?: string; + }[]; + brief?: unknown; + generated_content?: Record; + }>(`/conversations/${selectedConversationId}`, { + params: { user_id: userId }, + }); + + dispatch(setConversationId(selectedConversationId)); + dispatch(setConversationTitle(null)); // Will use title from conversation list + + const loadedMessages: ChatMessage[] = (data.messages || []).map( + (m, index) => ({ + id: `${selectedConversationId}-${index}`, + role: m.role as 'user' | 'assistant', + content: m.content, + timestamp: m.timestamp || new Date().toISOString(), + agent: m.agent, + }), + ); + dispatch(setMessages(loadedMessages)); + dispatch(setPendingBrief(null)); + dispatch(setAwaitingClarification(false)); + dispatch( + setConfirmedBrief( + (data.brief as CreativeBrief) || null, + ), + ); + + // Restore availableProducts so product/color name detection works + // when regenerating images in a restored conversation + if (data.brief) { + try { + const productsData = await httpClient.get<{ + products?: Product[]; + }>('/products'); + dispatch(setAvailableProducts(productsData.products || [])); + } catch (err) { + console.error( + 'Error loading products for restored conversation:', + err, + ); + } + } + + if (data.generated_content) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const gc = data.generated_content as any; + let textContent = gc.text_content; + if (typeof textContent === 'string') { + try { + textContent = JSON.parse(textContent); + } catch { + // keep as-is + } + } + + let imageUrl: string | undefined = gc.image_url; + if (imageUrl && imageUrl.includes('blob.core.windows.net')) { + const parts = imageUrl.split('/'); + const filename = parts[parts.length - 1]; + const convId = parts[parts.length - 2]; + imageUrl = `/api/images/${convId}/${filename}`; + } + if (!imageUrl && gc.image_base64) { + imageUrl = `data:image/png;base64,${gc.image_base64}`; + } + + const restoredContent: GeneratedContent = { + text_content: + typeof textContent === 'object' && textContent + ? { + headline: textContent?.headline, + body: textContent?.body, + cta_text: textContent?.cta, + tagline: textContent?.tagline, + } + : undefined, + image_content: + imageUrl || gc.image_prompt + ? { + image_url: imageUrl, + prompt_used: gc.image_prompt, + alt_text: + gc.image_revised_prompt || + 'Generated marketing image', + } + : undefined, + violations: gc.violations || [], + requires_modification: gc.requires_modification || false, + error: gc.error, + image_error: gc.image_error, + text_error: gc.text_error, + }; + dispatch(setGeneratedContent(restoredContent)); + + if ( + gc.selected_products && + Array.isArray(gc.selected_products) + ) { + dispatch(setSelectedProducts(gc.selected_products)); + } else { + dispatch(setSelectedProducts([])); + } + } else { + dispatch(setGeneratedContent(null)); + dispatch(setSelectedProducts([])); + } + } catch (error) { + console.error('Error loading conversation:', error); + } + }, + [userId, dispatch], + ); + + /* ------------------------------------------------------------ */ + /* Start a new conversation */ + /* ------------------------------------------------------------ */ + const newConversation = useCallback(() => { + dispatch(resetChat()); + dispatch(resetContent()); + }, [dispatch]); + + /* ------------------------------------------------------------ */ + /* Brief lifecycle */ + /* ------------------------------------------------------------ */ + const confirmBrief = useCallback(async () => { + if (!pendingBrief) return; + + try { + const { confirmBrief: confirmBriefApi } = await import('../api'); + await confirmBriefApi(pendingBrief, conversationId, userId); + dispatch(setConfirmedBrief(pendingBrief)); + dispatch(setPendingBrief(null)); + dispatch(setAwaitingClarification(false)); + + const productsData = await httpClient.get<{ products?: Product[] }>( + '/products', + ); + dispatch(setAvailableProducts(productsData.products || [])); + + dispatch( + addMessage( + msg( + 'assistant', + "Great! Your creative brief has been confirmed. Here are the available products for your campaign. Select the ones you'd like to feature, or tell me what you're looking for.", + 'ProductAgent', + ), + ), + ); + } catch (error) { + console.error('Error confirming brief:', error); + } + }, [conversationId, userId, pendingBrief, dispatch]); + + const cancelBrief = useCallback(() => { + dispatch(setPendingBrief(null)); + dispatch(setAwaitingClarification(false)); + dispatch( + addMessage( + msg( + 'assistant', + 'No problem. Please provide your creative brief again or ask me any questions.', + ), + ), + ); + }, [dispatch]); + + /* ------------------------------------------------------------ */ + /* Product actions */ + /* ------------------------------------------------------------ */ + const productsStartOver = useCallback(() => { + dispatch(setSelectedProducts([])); + dispatch(setConfirmedBrief(null)); + dispatch( + addMessage( + msg( + 'assistant', + 'Starting over. Please provide your creative brief to begin a new campaign.', + ), + ), + ); + }, [dispatch]); + + const selectProduct = useCallback( + (product: Product) => { + const isSelected = selectedProducts.some( + (p) => + (p.sku || p.product_name) === + (product.sku || product.product_name), + ); + if (isSelected) { + dispatch(setSelectedProducts([])); + } else { + // Single selection mode — replace any existing selection + dispatch(setSelectedProducts([product])); + } + }, + [selectedProducts, dispatch], + ); + + /* ------------------------------------------------------------ */ + /* Sidebar toggle */ + /* ------------------------------------------------------------ */ + const toggleHistory = useCallback(() => { + dispatch(toggleChatHistory()); + }, [dispatch]); + + return { + selectConversation, + newConversation, + confirmBrief, + cancelBrief, + productsStartOver, + selectProduct, + toggleHistory, + }; +} diff --git a/content-gen/src/app/frontend/src/hooks/useCopyToClipboard.ts b/content-gen/src/app/frontend/src/hooks/useCopyToClipboard.ts new file mode 100644 index 000000000..08d024d8d --- /dev/null +++ b/content-gen/src/app/frontend/src/hooks/useCopyToClipboard.ts @@ -0,0 +1,32 @@ +import { useState, useCallback, useRef, useEffect } from 'react'; + +/** + * Copy text to the clipboard and expose a transient `copied` flag. + * + * @param resetTimeout - Milliseconds before `copied` resets to `false` (default 2 000). + * @returns `{ copied, copy }` — `copy(text)` writes to the clipboard and + * flips `copied` to `true` for `resetTimeout` ms. + */ +export function useCopyToClipboard(resetTimeout = 2000) { + const [copied, setCopied] = useState(false); + const timerRef = useRef>(); + + const copy = useCallback( + (text: string) => { + navigator.clipboard.writeText(text).catch((err) => { + console.error('Failed to copy text:', err); + }); + setCopied(true); + clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => setCopied(false), resetTimeout); + }, + [resetTimeout], + ); + + // Cleanup on unmount + useEffect(() => { + return () => clearTimeout(timerRef.current); + }, []); + + return { copied, copy }; +} diff --git a/content-gen/src/app/frontend/src/hooks/useDebounce.ts b/content-gen/src/app/frontend/src/hooks/useDebounce.ts new file mode 100644 index 000000000..c0c7e5ab1 --- /dev/null +++ b/content-gen/src/app/frontend/src/hooks/useDebounce.ts @@ -0,0 +1,29 @@ +import { useState, useEffect } from 'react'; + +/** + * Returns a debounced copy of `value` that only updates after `delay` ms of + * inactivity. + * + * @param value - The source value to debounce. + * @param delay - Debounce window in milliseconds. + * + * @example + * ```ts + * const [search, setSearch] = useState(''); + * const debouncedSearch = useDebounce(search, 300); + * + * useEffect(() => { + * fetchResults(debouncedSearch); + * }, [debouncedSearch]); + * ``` + */ +export function useDebounce(value: T, delay: number): T { + const [debounced, setDebounced] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => setDebounced(value), delay); + return () => clearTimeout(timer); + }, [value, delay]); + + return debounced; +} diff --git a/content-gen/src/app/frontend/src/hooks/useWindowSize.ts b/content-gen/src/app/frontend/src/hooks/useWindowSize.ts new file mode 100644 index 000000000..da662eb41 --- /dev/null +++ b/content-gen/src/app/frontend/src/hooks/useWindowSize.ts @@ -0,0 +1,19 @@ +import { useState, useEffect } from 'react'; + +/** + * Returns the current window inner-width, updating on resize. + * Falls back to 1200 during SSR. + */ +export function useWindowSize(): number { + const [windowWidth, setWindowWidth] = useState( + typeof window !== 'undefined' ? window.innerWidth : 1200, + ); + + useEffect(() => { + const handleResize = () => setWindowWidth(window.innerWidth); + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, []); + + return windowWidth; +} diff --git a/content-gen/src/app/frontend/src/main.tsx b/content-gen/src/app/frontend/src/main.tsx index e59c93df9..db4a8119d 100644 --- a/content-gen/src/app/frontend/src/main.tsx +++ b/content-gen/src/app/frontend/src/main.tsx @@ -1,13 +1,17 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import { FluentProvider, webLightTheme } from '@fluentui/react-components'; +import { Provider } from 'react-redux'; +import { store } from './store'; import App from './App'; import './styles/global.css'; ReactDOM.createRoot(document.getElementById('root')!).render( - - - + + + + + ); diff --git a/content-gen/src/app/frontend/src/store/appSlice.ts b/content-gen/src/app/frontend/src/store/appSlice.ts new file mode 100644 index 000000000..59876ec03 --- /dev/null +++ b/content-gen/src/app/frontend/src/store/appSlice.ts @@ -0,0 +1,100 @@ +/** + * App slice — application-level state (user info, config, feature flags, UI toggles). + * createSlice + createAsyncThunk replaces manual dispatch + string constants. + * Granular selectors — each component subscribes only to the state it needs. + */ +import { createSlice, createAsyncThunk, type PayloadAction } from '@reduxjs/toolkit'; + +/* ------------------------------------------------------------------ */ +/* Async Thunks */ +/* ------------------------------------------------------------------ */ + +export const fetchAppConfig = createAsyncThunk( + 'app/fetchAppConfig', + async () => { + const { getAppConfig } = await import('../api'); + const config = await getAppConfig(); + return config; + }, +); + +export const fetchUserInfo = createAsyncThunk( + 'app/fetchUserInfo', + async () => { + const response = await fetch('/.auth/me'); + if (!response.ok) return { userId: 'anonymous', userName: '' }; + + const payload = await response.json(); + const claims: { typ: string; val: string }[] = payload[0]?.user_claims || []; + + const objectId = claims.find( + (c) => c.typ === 'http://schemas.microsoft.com/identity/claims/objectidentifier', + )?.val || 'anonymous'; + + const name = claims.find((c) => c.typ === 'name')?.val || ''; + + return { userId: objectId, userName: name }; + }, +); + +/* ------------------------------------------------------------------ */ +/* Slice */ +/* ------------------------------------------------------------------ */ + +interface AppState { + userId: string; + userName: string; + isLoading: boolean; + imageGenerationEnabled: boolean; + showChatHistory: boolean; + generationStatus: string; +} + +const initialState: AppState = { + userId: '', + userName: '', + isLoading: false, + imageGenerationEnabled: true, + showChatHistory: true, + generationStatus: '', +}; + +const appSlice = createSlice({ + name: 'app', + initialState, + reducers: { + setIsLoading(state, action: PayloadAction) { + state.isLoading = action.payload; + }, + setGenerationStatus(state, action: PayloadAction) { + state.generationStatus = action.payload; + }, + toggleChatHistory(state) { + state.showChatHistory = !state.showChatHistory; + }, + setShowChatHistory(state, action: PayloadAction) { + state.showChatHistory = action.payload; + }, + }, + extraReducers: (builder) => { + builder + .addCase(fetchAppConfig.fulfilled, (state, action) => { + state.imageGenerationEnabled = action.payload.enable_image_generation; + }) + .addCase(fetchAppConfig.rejected, (state) => { + state.imageGenerationEnabled = true; // default when fetch fails + }) + .addCase(fetchUserInfo.fulfilled, (state, action) => { + state.userId = action.payload.userId; + state.userName = action.payload.userName; + }) + .addCase(fetchUserInfo.rejected, (state) => { + state.userId = 'anonymous'; + state.userName = ''; + }); + }, +}); + +export const { setIsLoading, setGenerationStatus, toggleChatHistory, setShowChatHistory } = + appSlice.actions; +export default appSlice.reducer; diff --git a/content-gen/src/app/frontend/src/store/chatHistorySlice.ts b/content-gen/src/app/frontend/src/store/chatHistorySlice.ts new file mode 100644 index 000000000..4f088c3a1 --- /dev/null +++ b/content-gen/src/app/frontend/src/store/chatHistorySlice.ts @@ -0,0 +1,138 @@ +/** + * Chat history slice — conversation list CRUD via async thunks. + * createAsyncThunk replaces inline fetch + manual state updates in ChatHistory.tsx. + * Granular selectors for each piece of history state. + */ +import { createSlice, createAsyncThunk, type PayloadAction } from '@reduxjs/toolkit'; +import httpClient from '../api/httpClient'; + +export interface ConversationSummary { + id: string; + title: string; + lastMessage: string; + timestamp: string; + messageCount: number; +} + +interface ChatHistoryState { + conversations: ConversationSummary[]; + isLoading: boolean; + error: string | null; + showAll: boolean; + isClearAllDialogOpen: boolean; + isClearing: boolean; +} + +const initialState: ChatHistoryState = { + conversations: [], + isLoading: true, + error: null, + showAll: false, + isClearAllDialogOpen: false, + isClearing: false, +}; + +/* ------------------------------------------------------------------ */ +/* Async Thunks */ +/* ------------------------------------------------------------------ */ + +export const fetchConversations = createAsyncThunk( + 'chatHistory/fetchConversations', + async () => { + const data = await httpClient.get<{ conversations?: ConversationSummary[] }>('/conversations'); + return (data.conversations || []) as ConversationSummary[]; + }, +); + +export const deleteConversation = createAsyncThunk( + 'chatHistory/deleteConversation', + async (conversationId: string) => { + await httpClient.delete(`/conversations/${conversationId}`); + return conversationId; + }, +); + +export const renameConversation = createAsyncThunk( + 'chatHistory/renameConversation', + async ({ conversationId, newTitle }: { conversationId: string; newTitle: string }) => { + await httpClient.put(`/conversations/${conversationId}`, { title: newTitle }); + return { conversationId, newTitle }; + }, +); + +export const clearAllConversations = createAsyncThunk( + 'chatHistory/clearAllConversations', + async () => { + await httpClient.delete('/conversations'); + }, +); + +/* ------------------------------------------------------------------ */ +/* Slice */ +/* ------------------------------------------------------------------ */ + +const chatHistorySlice = createSlice({ + name: 'chatHistory', + initialState, + reducers: { + setShowAll(state, action: PayloadAction) { + state.showAll = action.payload; + }, + setConversations(state, action: PayloadAction) { + state.conversations = action.payload; + }, + upsertConversation(state, action: PayloadAction) { + const idx = state.conversations.findIndex((c) => c.id === action.payload.id); + if (idx >= 0) { + state.conversations[idx] = action.payload; + } else { + state.conversations.unshift(action.payload); + } + }, + setIsClearAllDialogOpen(state, action: PayloadAction) { + state.isClearAllDialogOpen = action.payload; + }, + }, + extraReducers: (builder) => { + builder + // Fetch + .addCase(fetchConversations.pending, (state) => { + state.isLoading = true; + state.error = null; + }) + .addCase(fetchConversations.fulfilled, (state, action) => { + state.conversations = action.payload; + state.isLoading = false; + }) + .addCase(fetchConversations.rejected, (state) => { + state.error = 'Unable to load conversation history'; + state.conversations = []; + state.isLoading = false; + }) + // Delete single + .addCase(deleteConversation.fulfilled, (state, action) => { + state.conversations = state.conversations.filter((c) => c.id !== action.payload); + }) + // Rename + .addCase(renameConversation.fulfilled, (state, action) => { + const conv = state.conversations.find((c) => c.id === action.payload.conversationId); + if (conv) conv.title = action.payload.newTitle; + }) + // Clear all + .addCase(clearAllConversations.pending, (state) => { + state.isClearing = true; + }) + .addCase(clearAllConversations.fulfilled, (state) => { + state.conversations = []; + state.isClearing = false; + state.isClearAllDialogOpen = false; + }) + .addCase(clearAllConversations.rejected, (state) => { + state.isClearing = false; + }); + }, +}); + +export const { setShowAll, setConversations, upsertConversation, setIsClearAllDialogOpen } = + chatHistorySlice.actions; +export default chatHistorySlice.reducer; diff --git a/content-gen/src/app/frontend/src/store/chatSlice.ts b/content-gen/src/app/frontend/src/store/chatSlice.ts new file mode 100644 index 000000000..71b25330e --- /dev/null +++ b/content-gen/src/app/frontend/src/store/chatSlice.ts @@ -0,0 +1,67 @@ +/** + * Chat slice — conversation state, messages, clarification flow. + * Typed createSlice replaces scattered useState-based state in App.tsx. + * Granular selectors for each piece of chat state. + */ +import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; +import { v4 as uuidv4 } from 'uuid'; +import type { ChatMessage } from '../types'; + +interface ChatState { + conversationId: string; + conversationTitle: string | null; + messages: ChatMessage[]; + awaitingClarification: boolean; + historyRefreshTrigger: number; +} + +const initialState: ChatState = { + conversationId: uuidv4(), + conversationTitle: null, + messages: [], + awaitingClarification: false, + historyRefreshTrigger: 0, +}; + +const chatSlice = createSlice({ + name: 'chat', + initialState, + reducers: { + setConversationId(state, action: PayloadAction) { + state.conversationId = action.payload; + }, + setConversationTitle(state, action: PayloadAction) { + state.conversationTitle = action.payload; + }, + setMessages(state, action: PayloadAction) { + state.messages = action.payload; + }, + addMessage(state, action: PayloadAction) { + state.messages.push(action.payload); + }, + setAwaitingClarification(state, action: PayloadAction) { + state.awaitingClarification = action.payload; + }, + incrementHistoryRefresh(state) { + state.historyRefreshTrigger += 1; + }, + /** Reset chat to a fresh conversation. Optionally provide a new ID. */ + resetChat(state, action: PayloadAction) { + state.conversationId = action.payload ?? uuidv4(); + state.conversationTitle = null; + state.messages = []; + state.awaitingClarification = false; + }, + }, +}); + +export const { + setConversationId, + setConversationTitle, + setMessages, + addMessage, + setAwaitingClarification, + incrementHistoryRefresh, + resetChat, +} = chatSlice.actions; +export default chatSlice.reducer; diff --git a/content-gen/src/app/frontend/src/store/contentSlice.ts b/content-gen/src/app/frontend/src/store/contentSlice.ts new file mode 100644 index 000000000..15736efd5 --- /dev/null +++ b/content-gen/src/app/frontend/src/store/contentSlice.ts @@ -0,0 +1,61 @@ +/** + * Content slice — creative brief, product selection, generated content. + * Typed createSlice with granular selectors. + */ +import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; +import type { CreativeBrief, Product, GeneratedContent } from '../types'; + +interface ContentState { + pendingBrief: CreativeBrief | null; + confirmedBrief: CreativeBrief | null; + selectedProducts: Product[]; + availableProducts: Product[]; + generatedContent: GeneratedContent | null; +} + +const initialState: ContentState = { + pendingBrief: null, + confirmedBrief: null, + selectedProducts: [], + availableProducts: [], + generatedContent: null, +}; + +const contentSlice = createSlice({ + name: 'content', + initialState, + reducers: { + setPendingBrief(state, action: PayloadAction) { + state.pendingBrief = action.payload; + }, + setConfirmedBrief(state, action: PayloadAction) { + state.confirmedBrief = action.payload; + }, + setSelectedProducts(state, action: PayloadAction) { + state.selectedProducts = action.payload; + }, + setAvailableProducts(state, action: PayloadAction) { + state.availableProducts = action.payload; + }, + setGeneratedContent(state, action: PayloadAction) { + state.generatedContent = action.payload; + }, + resetContent(state) { + state.pendingBrief = null; + state.confirmedBrief = null; + state.selectedProducts = []; + state.availableProducts = []; + state.generatedContent = null; + }, + }, +}); + +export const { + setPendingBrief, + setConfirmedBrief, + setSelectedProducts, + setAvailableProducts, + setGeneratedContent, + resetContent, +} = contentSlice.actions; +export default contentSlice.reducer; diff --git a/content-gen/src/app/frontend/src/store/hooks.ts b/content-gen/src/app/frontend/src/store/hooks.ts new file mode 100644 index 000000000..c9c663095 --- /dev/null +++ b/content-gen/src/app/frontend/src/store/hooks.ts @@ -0,0 +1,9 @@ +/** + * Typed Redux hooks for type-safe store access throughout the app. + * Use useAppDispatch and useAppSelector instead of raw useDispatch/useSelector. + */ +import { useDispatch, useSelector } from 'react-redux'; +import type { RootState, AppDispatch } from './store'; + +export const useAppDispatch = useDispatch.withTypes(); +export const useAppSelector = useSelector.withTypes(); diff --git a/content-gen/src/app/frontend/src/store/index.ts b/content-gen/src/app/frontend/src/store/index.ts new file mode 100644 index 000000000..a0b395487 --- /dev/null +++ b/content-gen/src/app/frontend/src/store/index.ts @@ -0,0 +1,77 @@ +/** + * Barrel export for the Redux store. + * Import everything you need from '../store'. + */ +export { store } from './store'; +export type { RootState, AppDispatch } from './store'; +export { useAppDispatch, useAppSelector } from './hooks'; + +// App slice – actions & thunks +export { + fetchAppConfig, + fetchUserInfo, + setIsLoading, + setGenerationStatus, + toggleChatHistory, + setShowChatHistory, +} from './appSlice'; + +// Chat slice – actions +export { + setConversationId, + setConversationTitle, + setMessages, + addMessage, + setAwaitingClarification, + incrementHistoryRefresh, + resetChat, +} from './chatSlice'; + +// Content slice – actions +export { + setPendingBrief, + setConfirmedBrief, + setSelectedProducts, + setAvailableProducts, + setGeneratedContent, + resetContent, +} from './contentSlice'; + +// Chat History slice – actions & thunks +export { + fetchConversations, + deleteConversation, + renameConversation, + clearAllConversations, + setShowAll, + setConversations, + upsertConversation, + setIsClearAllDialogOpen, +} from './chatHistorySlice'; +export type { ConversationSummary } from './chatHistorySlice'; + +// All selectors (centralized to avoid circular store ↔ slice imports) +export { + selectUserId, + selectUserName, + selectIsLoading, + selectGenerationStatus, + selectImageGenerationEnabled, + selectShowChatHistory, + selectConversationId, + selectConversationTitle, + selectMessages, + selectAwaitingClarification, + selectHistoryRefreshTrigger, + selectPendingBrief, + selectConfirmedBrief, + selectSelectedProducts, + selectAvailableProducts, + selectGeneratedContent, + selectConversations, + selectIsHistoryLoading, + selectHistoryError, + selectShowAll, + selectIsClearAllDialogOpen, + selectIsClearing, +} from './selectors'; diff --git a/content-gen/src/app/frontend/src/store/selectors.ts b/content-gen/src/app/frontend/src/store/selectors.ts new file mode 100644 index 000000000..8fb2329c4 --- /dev/null +++ b/content-gen/src/app/frontend/src/store/selectors.ts @@ -0,0 +1,36 @@ +/** + * All Redux selectors in one place. + * Importing RootState here (and ONLY here) avoids the circular dependency + * between store.ts ↔ slice files that confuses VS Code's TypeScript server. + */ +import type { RootState } from './store'; + +/* ---- App selectors ---- */ +export const selectUserId = (state: RootState) => state.app.userId; +export const selectUserName = (state: RootState) => state.app.userName; +export const selectIsLoading = (state: RootState) => state.app.isLoading; +export const selectGenerationStatus = (state: RootState) => state.app.generationStatus; +export const selectImageGenerationEnabled = (state: RootState) => state.app.imageGenerationEnabled; +export const selectShowChatHistory = (state: RootState) => state.app.showChatHistory; + +/* ---- Chat selectors ---- */ +export const selectConversationId = (state: RootState) => state.chat.conversationId; +export const selectConversationTitle = (state: RootState) => state.chat.conversationTitle; +export const selectMessages = (state: RootState) => state.chat.messages; +export const selectAwaitingClarification = (state: RootState) => state.chat.awaitingClarification; +export const selectHistoryRefreshTrigger = (state: RootState) => state.chat.historyRefreshTrigger; + +/* ---- Content selectors ---- */ +export const selectPendingBrief = (state: RootState) => state.content.pendingBrief; +export const selectConfirmedBrief = (state: RootState) => state.content.confirmedBrief; +export const selectSelectedProducts = (state: RootState) => state.content.selectedProducts; +export const selectAvailableProducts = (state: RootState) => state.content.availableProducts; +export const selectGeneratedContent = (state: RootState) => state.content.generatedContent; + +/* ---- Chat History selectors ---- */ +export const selectConversations = (state: RootState) => state.chatHistory.conversations; +export const selectIsHistoryLoading = (state: RootState) => state.chatHistory.isLoading; +export const selectHistoryError = (state: RootState) => state.chatHistory.error; +export const selectShowAll = (state: RootState) => state.chatHistory.showAll; +export const selectIsClearAllDialogOpen = (state: RootState) => state.chatHistory.isClearAllDialogOpen; +export const selectIsClearing = (state: RootState) => state.chatHistory.isClearing; diff --git a/content-gen/src/app/frontend/src/store/store.ts b/content-gen/src/app/frontend/src/store/store.ts new file mode 100644 index 000000000..81e515742 --- /dev/null +++ b/content-gen/src/app/frontend/src/store/store.ts @@ -0,0 +1,21 @@ +/** + * Redux store — central state for the application. + * configureStore combines all domain-specific slices. + */ +import { configureStore } from '@reduxjs/toolkit'; +import appReducer from './appSlice'; +import chatReducer from './chatSlice'; +import contentReducer from './contentSlice'; +import chatHistoryReducer from './chatHistorySlice'; + +export const store = configureStore({ + reducer: { + app: appReducer, + chat: chatReducer, + content: contentReducer, + chatHistory: chatHistoryReducer, + }, +}); + +export type RootState = ReturnType; +export type AppDispatch = typeof store.dispatch; diff --git a/content-gen/src/app/frontend/src/utils/contentErrors.ts b/content-gen/src/app/frontend/src/utils/contentErrors.ts new file mode 100644 index 000000000..dc9ca497a --- /dev/null +++ b/content-gen/src/app/frontend/src/utils/contentErrors.ts @@ -0,0 +1,31 @@ +/** + * Detect whether an error message originates from a content-safety filter. + */ +export function isContentFilterError(errorMessage?: string): boolean { + if (!errorMessage) return false; + const filterPatterns = [ + 'content_filter', 'ContentFilter', 'content management policy', + 'ResponsibleAI', 'responsible_ai_policy', 'content filtering', + 'filtered', 'safety system', 'self_harm', 'sexual', 'violence', 'hate', + ]; + return filterPatterns.some((pattern) => + errorMessage.toLowerCase().includes(pattern.toLowerCase()), + ); +} + +/** + * Return a user-friendly title/description for a generation error. + */ +export function getErrorMessage(errorMessage?: string): { title: string; description: string } { + if (isContentFilterError(errorMessage)) { + return { + title: 'Content Filtered', + description: + 'Your request was blocked by content safety filters. Please try modifying your creative brief.', + }; + } + return { + title: 'Generation Failed', + description: errorMessage || 'An error occurred. Please try again.', + }; +} diff --git a/content-gen/src/app/frontend/src/utils/downloadImage.ts b/content-gen/src/app/frontend/src/utils/downloadImage.ts new file mode 100644 index 000000000..08e752c20 --- /dev/null +++ b/content-gen/src/app/frontend/src/utils/downloadImage.ts @@ -0,0 +1,94 @@ +/** + * Download the generated marketing image with a product-name / tagline + * banner composited at the bottom. + * + * Falls back to a plain download when canvas compositing fails. + */ +export async function downloadImage( + imageUrl: string, + productName?: string, + tagline?: string, +): Promise { + try { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const img = new Image(); + img.crossOrigin = 'anonymous'; + + img.onload = () => { + const bannerHeight = Math.max(60, img.height * 0.1); + const padding = Math.max(16, img.width * 0.03); + + canvas.width = img.width; + canvas.height = img.height + bannerHeight; + + // Draw the image at the top + ctx.drawImage(img, 0, 0); + + // White banner at the bottom + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, img.height, img.width, bannerHeight); + + ctx.strokeStyle = '#e5e5e5'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(0, img.height); + ctx.lineTo(img.width, img.height); + ctx.stroke(); + + // Headline text + const headlineText = productName || 'Your Product'; + const headlineFontSize = Math.max(18, Math.min(36, img.width * 0.04)); + const taglineFontSize = Math.max(12, Math.min(20, img.width * 0.025)); + + ctx.font = `600 ${headlineFontSize}px Georgia, serif`; + ctx.fillStyle = '#1a1a1a'; + ctx.fillText( + headlineText, + padding, + img.height + padding + headlineFontSize * 0.8, + img.width - padding * 2, + ); + + // Tagline + if (tagline) { + ctx.font = `400 italic ${taglineFontSize}px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif`; + ctx.fillStyle = '#666666'; + ctx.fillText( + tagline, + padding, + img.height + padding + headlineFontSize + taglineFontSize * 0.8 + 4, + img.width - padding * 2, + ); + } + + canvas.toBlob((blob) => { + if (blob) { + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = 'generated-marketing-image.png'; + link.click(); + URL.revokeObjectURL(url); + } + }, 'image/png'); + }; + + img.onerror = () => { + plainDownload(imageUrl); + }; + + img.src = imageUrl; + } catch { + plainDownload(imageUrl); + } +} + +function plainDownload(url: string) { + const link = document.createElement('a'); + link.href = url; + link.download = 'generated-image.png'; + link.click(); +} From 653846de0a30b27a1091f218b76a9b2fcf31b98d Mon Sep 17 00:00:00 2001 From: Shubhangi-Microsoft Date: Thu, 26 Feb 2026 14:45:59 +0530 Subject: [PATCH 02/12] =?UTF-8?q?refactor(frontend):=20Task=206=20?= =?UTF-8?q?=E2=80=94=20Modularize=20utility=20functions=20by=20domain?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New utility modules in utils/: - messageUtils.ts: createMessage() factory, formatContentForClipboard() - contentParsing.ts: parseTextContent(), resolveImageUrl(), buildGeneratedContent() - sseParser.ts: parseSSEStream() — eliminates duplicated SSE decode loop - generationStages.ts: getGenerationStage() — pure progress-stage mapper - briefFields.ts: BRIEF_FIELD_LABELS, BRIEF_DISPLAY_ORDER, BRIEF_FIELD_KEYS - stringUtils.ts: escapeRegex(), createNameSwapper(), matchesAnyKeyword() - apiUtils.ts: retryRequest (exponential backoff), RequestCache, throttle - index.ts: barrel export for all utils Deduplicated code: - msg() helper was identical in 2 hooks → createMessage() in messageUtils - SSE stream parser was identical in 2 API functions → parseSSEStream() - GeneratedContent parsing was near-identical in 3 hooks → buildGeneratedContent() - Brief field constants duplicated in 2 components → shared briefFields - Keyword matching pattern repeated 3x → matchesAnyKeyword() Internal helpers marked non-exported: - rewriteBlobUrl() in contentParsing.ts - defaultShouldRetry(), sleep() in apiUtils.ts - plainDownload() in downloadImage.ts (already was) Build: 0 TypeScript errors --- .coverage | Bin 0 -> 53248 bytes .../src/app/frontend/package-lock.json | 120 ++++++++++- content-gen/src/app/frontend/package.json | 2 + content-gen/src/app/frontend/src/api/index.ts | 79 +------ .../frontend/src/components/BriefReview.tsx | 38 +--- .../src/components/ConfirmedBriefView.tsx | 15 +- .../frontend/src/hooks/useChatOrchestrator.ts | 105 +++------ .../src/hooks/useContentGeneration.ts | 81 +------ .../src/hooks/useConversationActions.ts | 74 +------ .../src/app/frontend/src/utils/apiUtils.ts | 202 ++++++++++++++++++ .../src/app/frontend/src/utils/briefFields.ts | 46 ++++ .../app/frontend/src/utils/contentParsing.ts | 108 ++++++++++ .../frontend/src/utils/generationStages.ts | 33 +++ .../src/app/frontend/src/utils/index.ts | 34 +++ .../app/frontend/src/utils/messageUtils.ts | 44 ++++ .../src/app/frontend/src/utils/sseParser.ts | 48 +++++ .../src/app/frontend/src/utils/stringUtils.ts | 44 ++++ 17 files changed, 741 insertions(+), 332 deletions(-) create mode 100644 .coverage create mode 100644 content-gen/src/app/frontend/src/utils/apiUtils.ts create mode 100644 content-gen/src/app/frontend/src/utils/briefFields.ts create mode 100644 content-gen/src/app/frontend/src/utils/contentParsing.ts create mode 100644 content-gen/src/app/frontend/src/utils/generationStages.ts create mode 100644 content-gen/src/app/frontend/src/utils/index.ts create mode 100644 content-gen/src/app/frontend/src/utils/messageUtils.ts create mode 100644 content-gen/src/app/frontend/src/utils/sseParser.ts create mode 100644 content-gen/src/app/frontend/src/utils/stringUtils.ts diff --git a/.coverage b/.coverage new file mode 100644 index 0000000000000000000000000000000000000000..51fea3fcb04caf1151acc6e59af73d9b154294f3 GIT binary patch literal 53248 zcmeI)%WoS+90%~-y7k76Bd3ZYE98*5AjhiX$|@4C0|bafMI|T%#0ht7kL?BTuCtFk z4hXqals|&Mu@VPPT=@NFcD;5ISG`qQzN^^T$IR^fKJ&7>ZNGc^#Eq301)(Qn@yOb? zY}@)&2+OiI=(R`B&|O z%J-YD)2?*r0yYRh00IywjKKV9&1vlI+0TECWq+#TP!3dR-nTyg_T>2VM4TRf_T)sE zbKRMvFndTJW*NdBvM0Ov*;YHTAZ+>^YdQM9a64X zjj4!P=z21|62GXcwoZ`VEmoJYIYEUeckFWtahPUriBOGHsQiJ7Of3%GVXNBu{kN*q z*x9jPDjkyy2lTVK5)GZmLwP)+f(Ai2;}G2muis2YItjaZAQyB!ds4p%6i5DJu^AedoAhYnQzINCQmbusB(UgvE~{1 z)|6S-*-yMak^h`{Gj1HpVQw4;>vFEk8wY8yZ`?Mwv}GReN^@r9+@dB|;=5;wS~PxJ zY!wHNm{oW{X{{qjtA z6z1vm-8fq8R8~68;`ExX!`IjM8y+RAorFixbSi?89x|L;n8u;rygX(eW*9Q$fhK}9 zA=UJ)_BO68)j{;9L4UQc=^hg)8XzOw4{&H5jP}Y*SOrzN)Psu0=vx($&lVp}7 zpCz*~l3t;3j+}Dicz<0uX}VrF-PxEscb&%Wu01y+g6AcE-CGHQ&*Vek+CuQ=)t||D zB5u~5PgBL4_sWfrch^;%tI+H6d-=uo9bM%=OIqKfNuOV(l`8X@yvkpxtD|!{rP*XA z-H?VX&B0~`5S(x*DY5JaNv!)XAL{POYfoNZQ$T*4*;A^PPEN!7n;?o@T2^`4iayHD zisnQ{JPSol(GP;DlD=6RD97W7?&(HC*Wu=>^hsVubj4+i&zrhV+2X>}{3#8(Y<$s< z=ShEo6iphG5e?C6WoCt@S9!+D%J0m`giH10&pR@U zy)M7=w`BhIY|RgBr}5x{Jx|gRY$m5(M4tpanZ=qO-dX8c*DmIr>}#5(=_Ar|=VI_B zLq0Tj;2F_0d*9GysnQQf)fRujx9k5}^n(oo5P$##AOHafKmY;|fB*y_0Dd?6EIgkAr~ zq91G!fB*y_009U<00Izz00bZa0SFXHpjz6mq<;#a|AGH@k%B`k5P$##AOHafKmY;| zfB*y_009VG7vSgr^q>A=g8&2|009U<00Izz00bZa0SG{#zyf&wU*N_?Xb^w^1Rwwb z2tWV=5P$##AOL|>0MGx?6(9fs2tWV=5P$##AOHafKmY>87r^uX;= 8" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -2961,6 +2991,18 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.17", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz", @@ -3069,6 +3111,7 @@ "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -3084,6 +3127,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -3094,6 +3138,7 @@ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -3104,6 +3149,12 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@types/uuid": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", @@ -3151,6 +3202,7 @@ "integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "7.18.0", "@typescript-eslint/types": "7.18.0", @@ -3337,6 +3389,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3484,6 +3537,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -3757,7 +3811,8 @@ "version": "8.6.0", "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/embla-carousel-autoplay": { "version": "8.6.0", @@ -3849,6 +3904,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -4387,6 +4443,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "11.1.4", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", + "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -5613,6 +5679,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -5625,6 +5692,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -5675,6 +5743,30 @@ "react": ">=18" } }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -5685,6 +5777,22 @@ "node": ">=0.10.0" } }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT", + "peer": true + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/remark-parse": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", @@ -5718,6 +5826,12 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -6056,6 +6170,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -6147,6 +6262,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6346,6 +6462,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -6439,6 +6556,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, diff --git a/content-gen/src/app/frontend/package.json b/content-gen/src/app/frontend/package.json index 2479885d7..d7b11ff63 100644 --- a/content-gen/src/app/frontend/package.json +++ b/content-gen/src/app/frontend/package.json @@ -12,9 +12,11 @@ "dependencies": { "@fluentui/react-components": "^9.54.0", "@fluentui/react-icons": "^2.0.245", + "@reduxjs/toolkit": "^2.11.2", "react": "^18.3.1", "react-dom": "^18.3.1", "react-markdown": "^9.0.1", + "react-redux": "^9.2.0", "uuid": "^10.0.0" }, "devDependencies": { diff --git a/content-gen/src/app/frontend/src/api/index.ts b/content-gen/src/app/frontend/src/api/index.ts index a39c86318..ee4bba2e6 100644 --- a/content-gen/src/app/frontend/src/api/index.ts +++ b/content-gen/src/app/frontend/src/api/index.ts @@ -10,6 +10,8 @@ import type { AppConfig, } from '../types'; import httpClient from './httpClient'; +import { parseSSEStream } from '../utils/sseParser'; +import { getGenerationStage } from '../utils/generationStages'; /** * Get application configuration including feature flags @@ -96,31 +98,7 @@ export async function* streamChat( throw new Error('No response body'); } - const decoder = new TextDecoder(); - let buffer = ''; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split('\n\n'); - buffer = lines.pop() || ''; - - for (const line of lines) { - if (line.startsWith('data: ')) { - const data = line.slice(6); - if (data === '[DONE]') { - return; - } - try { - yield JSON.parse(data) as AgentResponse; - } catch { - console.error('Failed to parse SSE data:', data); - } - } - } - } + yield* parseSSEStream(reader); } /** @@ -189,31 +167,8 @@ export async function* streamGenerateContent( } else if (statusData.status === 'failed') { throw new Error(statusData.error || 'Generation failed'); } else if (statusData.status === 'running') { - // Determine progress stage based on elapsed time - // Typical generation: 0-10s briefing, 10-25s copy, 25-45s image, 45-60s compliance const elapsedSeconds = attempts; - let stage: number; - let stageMessage: string; - - if (elapsedSeconds < 10) { - stage = 0; - stageMessage = 'Analyzing creative brief...'; - } else if (elapsedSeconds < 25) { - stage = 1; - stageMessage = 'Generating marketing copy...'; - } else if (elapsedSeconds < 35) { - stage = 2; - stageMessage = 'Creating image prompt...'; - } else if (elapsedSeconds < 55) { - stage = 3; - stageMessage = 'Generating image with AI...'; - } else if (elapsedSeconds < 70) { - stage = 4; - stageMessage = 'Running compliance check...'; - } else { - stage = 5; - stageMessage = 'Finalizing content...'; - } + const { stage, message: stageMessage } = getGenerationStage(elapsedSeconds); // Send status update every second for smoother progress yield { @@ -271,29 +226,5 @@ export async function* streamRegenerateImage( throw new Error('No response body'); } - const decoder = new TextDecoder(); - let buffer = ''; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split('\n\n'); - buffer = lines.pop() || ''; - - for (const line of lines) { - if (line.startsWith('data: ')) { - const data = line.slice(6); - if (data === '[DONE]') { - return; - } - try { - yield JSON.parse(data) as AgentResponse; - } catch { - console.error('Failed to parse SSE data:', data); - } - } - } - } + yield* parseSSEStream(reader); } \ No newline at end of file diff --git a/content-gen/src/app/frontend/src/components/BriefReview.tsx b/content-gen/src/app/frontend/src/components/BriefReview.tsx index 88fbae5c7..627db0df9 100644 --- a/content-gen/src/app/frontend/src/components/BriefReview.tsx +++ b/content-gen/src/app/frontend/src/components/BriefReview.tsx @@ -5,6 +5,7 @@ import { tokens, } from '@fluentui/react-components'; import type { CreativeBrief } from '../types'; +import { BRIEF_FIELD_LABELS, BRIEF_DISPLAY_ORDER, BRIEF_FIELD_KEYS } from '../utils'; interface BriefReviewProps { brief: CreativeBrief; @@ -13,19 +14,6 @@ interface BriefReviewProps { isAwaitingResponse?: boolean; } -// Mapping of field keys to user-friendly labels for the 9 key areas -const fieldLabels: Record = { - overview: 'Overview', - objectives: 'Objectives', - target_audience: 'Target Audience', - key_message: 'Key Message', - tone_and_style: 'Tone and Style', - deliverable: 'Deliverable', - timelines: 'Timelines', - visual_guidelines: 'Visual Guidelines', - cta: 'Call to Action', -}; - export const BriefReview = memo(function BriefReview({ brief, onConfirm, @@ -33,29 +21,13 @@ export const BriefReview = memo(function BriefReview({ isAwaitingResponse = false, }: BriefReviewProps) { const { populatedFields, missingFields, populatedDisplayFields } = useMemo(() => { - const allFields: (keyof CreativeBrief)[] = [ - 'overview', 'objectives', 'target_audience', 'key_message', - 'tone_and_style', 'deliverable', 'timelines', 'visual_guidelines', 'cta' - ]; - const populated = allFields.filter(key => brief[key]?.trim()).length; - const missing = allFields.filter(key => !brief[key]?.trim()); - - const displayOrder: { key: keyof CreativeBrief; label: string }[] = [ - { key: 'overview', label: 'Campaign Objective' }, - { key: 'objectives', label: 'Objectives' }, - { key: 'target_audience', label: 'Target Audience' }, - { key: 'key_message', label: 'Key Message' }, - { key: 'tone_and_style', label: 'Tone & Style' }, - { key: 'visual_guidelines', label: 'Visual Guidelines' }, - { key: 'deliverable', label: 'Deliverables' }, - { key: 'timelines', label: 'Timelines' }, - { key: 'cta', label: 'Call to Action' }, - ]; + const populated = BRIEF_FIELD_KEYS.filter(key => brief[key]?.trim()).length; + const missing = BRIEF_FIELD_KEYS.filter(key => !brief[key]?.trim()); return { populatedFields: populated, missingFields: missing, - populatedDisplayFields: displayOrder.filter(({ key }) => brief[key]?.trim()), + populatedDisplayFields: BRIEF_DISPLAY_ORDER.filter(({ key }) => brief[key]?.trim()), }; }, [brief]); @@ -121,7 +93,7 @@ export const BriefReview = memo(function BriefReview({ {populatedFields < 5 ? ( <> I've captured {populatedFields} of 9 key areas. Would you like to add more details? - You are missing: {missingFields.map(f => fieldLabels[f]).join(', ')}. + You are missing: {missingFields.map(f => BRIEF_FIELD_LABELS[f]).join(', ')}.

You can tell me things like:
    diff --git a/content-gen/src/app/frontend/src/components/ConfirmedBriefView.tsx b/content-gen/src/app/frontend/src/components/ConfirmedBriefView.tsx index f0806979b..e6697151a 100644 --- a/content-gen/src/app/frontend/src/components/ConfirmedBriefView.tsx +++ b/content-gen/src/app/frontend/src/components/ConfirmedBriefView.tsx @@ -8,23 +8,12 @@ import { Checkmark20Regular, } from '@fluentui/react-icons'; import type { CreativeBrief } from '../types'; +import { BRIEF_DISPLAY_ORDER } from '../utils'; interface ConfirmedBriefViewProps { brief: CreativeBrief; } -const briefFields: { key: keyof CreativeBrief; label: string }[] = [ - { key: 'overview', label: 'Overview' }, - { key: 'objectives', label: 'Objectives' }, - { key: 'target_audience', label: 'Target Audience' }, - { key: 'key_message', label: 'Key Message' }, - { key: 'tone_and_style', label: 'Tone & Style' }, - { key: 'deliverable', label: 'Deliverable' }, - { key: 'timelines', label: 'Timelines' }, - { key: 'visual_guidelines', label: 'Visual Guidelines' }, - { key: 'cta', label: 'Call to Action' }, -]; - export const ConfirmedBriefView = memo(function ConfirmedBriefView({ brief }: ConfirmedBriefViewProps) { return (
    - {briefFields.map(({ key, label }) => { + {BRIEF_DISPLAY_ORDER.map(({ key, label }) => { const value = brief[key]; if (!value?.trim()) return null; diff --git a/content-gen/src/app/frontend/src/hooks/useChatOrchestrator.ts b/content-gen/src/app/frontend/src/hooks/useChatOrchestrator.ts index 0c4313587..d88fe834f 100644 --- a/content-gen/src/app/frontend/src/hooks/useChatOrchestrator.ts +++ b/content-gen/src/app/frontend/src/hooks/useChatOrchestrator.ts @@ -1,7 +1,7 @@ import { useCallback, type MutableRefObject } from 'react'; -import { v4 as uuidv4 } from 'uuid'; -import type { ChatMessage, GeneratedContent } from '../types'; +import type { GeneratedContent } from '../types'; +import { createMessage, matchesAnyKeyword, createNameSwapper } from '../utils'; import { useAppDispatch, useAppSelector, @@ -26,23 +26,6 @@ import { setConversationTitle, } from '../store'; -/* ------------------------------------------------------------------ */ -/* Helper: create a ChatMessage literal */ -/* ------------------------------------------------------------------ */ -function msg( - role: 'user' | 'assistant', - content: string, - agent?: string, -): ChatMessage { - return { - id: uuidv4(), - role, - content, - agent, - timestamp: new Date().toISOString(), - }; -} - /* ------------------------------------------------------------------ */ /* Hook */ /* ------------------------------------------------------------------ */ @@ -81,7 +64,7 @@ export function useChatOrchestrator( const sendMessage = useCallback( async (content: string) => { - dispatch(addMessage(msg('user', content))); + dispatch(addMessage(createMessage('user', content))); dispatch(setIsLoading(true)); // Create new abort controller for this request @@ -101,10 +84,8 @@ export function useChatOrchestrator( const refinementKeywords = [ 'change', 'update', 'modify', 'add', 'remove', 'delete', 'set', 'make', 'should be', - ]; - const isRefinement = refinementKeywords.some((kw) => - content.toLowerCase().includes(kw), - ); + ] as const; + const isRefinement = matchesAnyKeyword(content, refinementKeywords); if (isRefinement || awaitingClarification) { // --- 1-a Refine the brief -------------------------------- @@ -131,7 +112,7 @@ export function useChatOrchestrator( dispatch(setGenerationStatus('')); dispatch( addMessage( - msg('assistant', parsed.clarifying_questions, 'PlanningAgent'), + createMessage('assistant', parsed.clarifying_questions, 'PlanningAgent'), ), ); } else { @@ -139,7 +120,7 @@ export function useChatOrchestrator( dispatch(setGenerationStatus('')); dispatch( addMessage( - msg( + createMessage( 'assistant', "I've updated the brief based on your feedback. Please review the changes above. Let me know if you'd like any other modifications, or click **Confirm Brief** when you're satisfied.", 'PlanningAgent', @@ -168,14 +149,14 @@ export function useChatOrchestrator( !messageAdded ) { dispatch( - addMessage(msg('assistant', fullContent, currentAgent)), + addMessage(createMessage('assistant', fullContent, currentAgent)), ); messageAdded = true; } } else if (response.type === 'error') { dispatch( addMessage( - msg( + createMessage( 'assistant', response.content || 'An error occurred while processing your request.', @@ -204,7 +185,7 @@ export function useChatOrchestrator( dispatch(setGenerationStatus('')); dispatch( addMessage( - msg( + createMessage( 'assistant', result.message || 'Products updated.', 'ProductAgent', @@ -222,10 +203,8 @@ export function useChatOrchestrator( 'kitchen', 'dining', 'living', 'bedroom', 'bathroom', 'outdoor', 'office', 'room', 'scene', 'setting', 'background', 'style', 'color', 'lighting', - ]; - const isImageModification = imageModificationKeywords.some((kw) => - content.toLowerCase().includes(kw), - ); + ] as const; + const isImageModification = matchesAnyKeyword(content, imageModificationKeywords); if (isImageModification) { // --- 3-a Regenerate image -------------------------------- @@ -275,28 +254,10 @@ export function useChatOrchestrator( parsedContent.image_base64 ) { // Replace old product name in text_content when switching - const oldName = selectedProducts[0]?.product_name; - const newName = mentionedProduct?.product_name; - const nameRegex = oldName - ? new RegExp( - oldName.replace( - /[.*+?^${}()|[\]\\]/g, - '\\$&', - ), - 'gi', - ) - : undefined; - const swapName = (s?: string) => { - if ( - !s || - !oldName || - !newName || - oldName === newName || - !nameRegex - ) - return s; - return s.replace(nameRegex, () => newName); - }; + const swapName = createNameSwapper( + selectedProducts[0]?.product_name, + mentionedProduct?.product_name, + ); const tc = generatedContent.text_content; responseData = { @@ -304,10 +265,10 @@ export function useChatOrchestrator( text_content: mentionedProduct ? { ...tc, - headline: swapName(tc?.headline), - body: swapName(tc?.body), - tagline: swapName(tc?.tagline), - cta_text: swapName(tc?.cta_text), + headline: swapName?.(tc?.headline) ?? tc?.headline, + body: swapName?.(tc?.body) ?? tc?.body, + tagline: swapName?.(tc?.tagline) ?? tc?.tagline, + cta_text: swapName?.(tc?.cta_text) ?? tc?.cta_text, } : tc, image_content: { @@ -356,7 +317,7 @@ export function useChatOrchestrator( dispatch(setGenerationStatus('')); dispatch( - addMessage(msg('assistant', messageContent, 'ImageAgent')), + addMessage(createMessage('assistant', messageContent, 'ImageAgent')), ); } else { // --- 3-b General question after content generation -------- @@ -379,14 +340,14 @@ export function useChatOrchestrator( !messageAdded ) { dispatch( - addMessage(msg('assistant', fullContent, currentAgent)), + addMessage(createMessage('assistant', fullContent, currentAgent)), ); messageAdded = true; } } else if (response.type === 'error') { dispatch( addMessage( - msg( + createMessage( 'assistant', response.content || 'An error occurred.', ), @@ -405,10 +366,8 @@ export function useChatOrchestrator( const briefKeywords = [ 'campaign', 'marketing', 'target audience', 'objective', 'deliverable', - ]; - const isBriefLike = briefKeywords.some((kw) => - content.toLowerCase().includes(kw), - ); + ] as const; + const isBriefLike = matchesAnyKeyword(content, briefKeywords); if (isBriefLike && !confirmedBrief) { // --- 4-a Parse as creative brief -------------------------- @@ -428,7 +387,7 @@ export function useChatOrchestrator( dispatch(setGenerationStatus('')); dispatch( addMessage( - msg('assistant', parsed.message, 'ContentSafety'), + createMessage('assistant', parsed.message, 'ContentSafety'), ), ); } else if ( @@ -442,7 +401,7 @@ export function useChatOrchestrator( dispatch(setGenerationStatus('')); dispatch( addMessage( - msg( + createMessage( 'assistant', parsed.clarifying_questions, 'PlanningAgent', @@ -457,7 +416,7 @@ export function useChatOrchestrator( dispatch(setGenerationStatus('')); dispatch( addMessage( - msg( + createMessage( 'assistant', "I've parsed your creative brief. Please review the details below and let me know if you'd like to make any changes. You can say things like \"change the target audience to...\" or \"add a call to action...\". When everything looks good, click **Confirm Brief** to proceed.", 'PlanningAgent', @@ -486,14 +445,14 @@ export function useChatOrchestrator( !messageAdded ) { dispatch( - addMessage(msg('assistant', fullContent, currentAgent)), + addMessage(createMessage('assistant', fullContent, currentAgent)), ); messageAdded = true; } } else if (response.type === 'error') { dispatch( addMessage( - msg( + createMessage( 'assistant', response.content || 'An error occurred while processing your request.', @@ -509,12 +468,12 @@ export function useChatOrchestrator( } catch (error) { if (error instanceof Error && error.name === 'AbortError') { console.debug('Request cancelled by user'); - dispatch(addMessage(msg('assistant', 'Generation stopped.'))); + dispatch(addMessage(createMessage('assistant', 'Generation stopped.'))); } else { console.error('Error sending message:', error); dispatch( addMessage( - msg( + createMessage( 'assistant', 'Sorry, there was an error processing your request. Please try again.', ), diff --git a/content-gen/src/app/frontend/src/hooks/useContentGeneration.ts b/content-gen/src/app/frontend/src/hooks/useContentGeneration.ts index 53513c2d8..6b01c54b4 100644 --- a/content-gen/src/app/frontend/src/hooks/useContentGeneration.ts +++ b/content-gen/src/app/frontend/src/hooks/useContentGeneration.ts @@ -1,7 +1,6 @@ import { useCallback, type MutableRefObject } from 'react'; -import { v4 as uuidv4 } from 'uuid'; -import type { ChatMessage, GeneratedContent } from '../types'; +import { createMessage, buildGeneratedContent } from '../utils'; import { useAppDispatch, useAppSelector, @@ -64,52 +63,7 @@ export function useContentGeneration( dispatch(setGenerationStatus('Processing results...')); try { const rawContent = JSON.parse(response.content); - - // Parse text_content if it's a string (from orchestrator) - let textContent = rawContent.text_content; - if (typeof textContent === 'string') { - try { - textContent = JSON.parse(textContent); - } catch { - // Keep as string if not valid JSON - } - } - - // Build image_url: prefer blob URL, fallback to base64 data URL - let imageUrl: string | undefined; - if (rawContent.image_url) { - imageUrl = rawContent.image_url; - } else if (rawContent.image_base64) { - imageUrl = `data:image/png;base64,${rawContent.image_base64}`; - } - - const genContent: GeneratedContent = { - text_content: - typeof textContent === 'object' - ? { - headline: textContent?.headline, - body: textContent?.body, - cta_text: textContent?.cta, - tagline: textContent?.tagline, - } - : undefined, - image_content: - imageUrl || rawContent.image_prompt - ? { - image_url: imageUrl, - prompt_used: rawContent.image_prompt, - alt_text: - rawContent.image_revised_prompt || - 'Generated marketing image', - } - : undefined, - violations: rawContent.violations || [], - requires_modification: - rawContent.requires_modification || false, - error: rawContent.error, - image_error: rawContent.image_error, - text_error: rawContent.text_error, - }; + const genContent = buildGeneratedContent(rawContent); dispatch(setGeneratedContent(genContent)); dispatch(setGenerationStatus('')); } catch (parseError) { @@ -117,35 +71,22 @@ export function useContentGeneration( } } else if (response.type === 'error') { dispatch(setGenerationStatus('')); - const errorMessage: ChatMessage = { - id: uuidv4(), - role: 'assistant', - content: `Error generating content: ${response.content}`, - timestamp: new Date().toISOString(), - }; - dispatch(addMessage(errorMessage)); + dispatch(addMessage(createMessage( + 'assistant', + `Error generating content: ${response.content}`, + ))); } } } catch (error) { if (error instanceof Error && error.name === 'AbortError') { console.debug('Content generation cancelled by user'); - const cancelMessage: ChatMessage = { - id: uuidv4(), - role: 'assistant', - content: 'Content generation stopped.', - timestamp: new Date().toISOString(), - }; - dispatch(addMessage(cancelMessage)); + dispatch(addMessage(createMessage('assistant', 'Content generation stopped.'))); } else { console.error('Error generating content:', error); - const errorMessage: ChatMessage = { - id: uuidv4(), - role: 'assistant', - content: - 'Sorry, there was an error generating content. Please try again.', - timestamp: new Date().toISOString(), - }; - dispatch(addMessage(errorMessage)); + dispatch(addMessage(createMessage( + 'assistant', + 'Sorry, there was an error generating content. Please try again.', + ))); } } finally { dispatch(setIsLoading(false)); diff --git a/content-gen/src/app/frontend/src/hooks/useConversationActions.ts b/content-gen/src/app/frontend/src/hooks/useConversationActions.ts index 76ab731ec..97b3b9f90 100644 --- a/content-gen/src/app/frontend/src/hooks/useConversationActions.ts +++ b/content-gen/src/app/frontend/src/hooks/useConversationActions.ts @@ -1,7 +1,7 @@ import { useCallback } from 'react'; -import { v4 as uuidv4 } from 'uuid'; -import type { ChatMessage, Product, CreativeBrief, GeneratedContent } from '../types'; +import type { ChatMessage, Product, CreativeBrief } from '../types'; +import { createMessage, buildGeneratedContent } from '../utils'; import httpClient from '../api/httpClient'; import { useAppDispatch, @@ -25,23 +25,6 @@ import { toggleChatHistory, } from '../store'; -/* ------------------------------------------------------------------ */ -/* Helper: create a ChatMessage literal */ -/* ------------------------------------------------------------------ */ -function msg( - role: 'user' | 'assistant', - content: string, - agent?: string, -): ChatMessage { - return { - id: uuidv4(), - role, - content, - agent, - timestamp: new Date().toISOString(), - }; -} - /* ------------------------------------------------------------------ */ /* Hook */ /* ------------------------------------------------------------------ */ @@ -124,52 +107,7 @@ export function useConversationActions() { if (data.generated_content) { // eslint-disable-next-line @typescript-eslint/no-explicit-any const gc = data.generated_content as any; - let textContent = gc.text_content; - if (typeof textContent === 'string') { - try { - textContent = JSON.parse(textContent); - } catch { - // keep as-is - } - } - - let imageUrl: string | undefined = gc.image_url; - if (imageUrl && imageUrl.includes('blob.core.windows.net')) { - const parts = imageUrl.split('/'); - const filename = parts[parts.length - 1]; - const convId = parts[parts.length - 2]; - imageUrl = `/api/images/${convId}/${filename}`; - } - if (!imageUrl && gc.image_base64) { - imageUrl = `data:image/png;base64,${gc.image_base64}`; - } - - const restoredContent: GeneratedContent = { - text_content: - typeof textContent === 'object' && textContent - ? { - headline: textContent?.headline, - body: textContent?.body, - cta_text: textContent?.cta, - tagline: textContent?.tagline, - } - : undefined, - image_content: - imageUrl || gc.image_prompt - ? { - image_url: imageUrl, - prompt_used: gc.image_prompt, - alt_text: - gc.image_revised_prompt || - 'Generated marketing image', - } - : undefined, - violations: gc.violations || [], - requires_modification: gc.requires_modification || false, - error: gc.error, - image_error: gc.image_error, - text_error: gc.text_error, - }; + const restoredContent = buildGeneratedContent(gc, true); dispatch(setGeneratedContent(restoredContent)); if ( @@ -219,7 +157,7 @@ export function useConversationActions() { dispatch( addMessage( - msg( + createMessage( 'assistant', "Great! Your creative brief has been confirmed. Here are the available products for your campaign. Select the ones you'd like to feature, or tell me what you're looking for.", 'ProductAgent', @@ -236,7 +174,7 @@ export function useConversationActions() { dispatch(setAwaitingClarification(false)); dispatch( addMessage( - msg( + createMessage( 'assistant', 'No problem. Please provide your creative brief again or ask me any questions.', ), @@ -252,7 +190,7 @@ export function useConversationActions() { dispatch(setConfirmedBrief(null)); dispatch( addMessage( - msg( + createMessage( 'assistant', 'Starting over. Please provide your creative brief to begin a new campaign.', ), diff --git a/content-gen/src/app/frontend/src/utils/apiUtils.ts b/content-gen/src/app/frontend/src/utils/apiUtils.ts new file mode 100644 index 000000000..1f60ff1c9 --- /dev/null +++ b/content-gen/src/app/frontend/src/utils/apiUtils.ts @@ -0,0 +1,202 @@ +/** + * Production-grade API utilities — retry with exponential backoff, + * request deduplication cache, and throttle. + * + * These harden the HTTP layer for unreliable networks and prevent + * accidental duplicate requests (e.g. double-clicks, React strict mode). + */ + +/* ------------------------------------------------------------------ */ +/* retryRequest — exponential backoff wrapper */ +/* ------------------------------------------------------------------ */ + +export interface RetryOptions { + /** Maximum number of attempts (including the first). Default: 3. */ + maxAttempts?: number; + /** Initial delay in ms before the first retry. Default: 1 000. */ + initialDelayMs?: number; + /** Maximum delay cap in ms. Default: 30 000. */ + maxDelayMs?: number; + /** Multiplier applied to the delay after each failure. Default: 2. */ + backoffFactor?: number; + /** Optional predicate — return `true` if the request should be retried. */ + shouldRetry?: (error: unknown, attempt: number) => boolean; + /** Optional `AbortSignal` to cancel outstanding retries. */ + signal?: AbortSignal; +} + +/** + * Execute `fn` with automatic retries and exponential backoff. + * + * ```ts + * const data = await retryRequest(() => httpClient.get('/health'), { + * maxAttempts: 4, + * initialDelayMs: 500, + * }); + * ``` + */ +export async function retryRequest( + fn: () => Promise, + opts: RetryOptions = {}, +): Promise { + const { + maxAttempts = 3, + initialDelayMs = 1_000, + maxDelayMs = 30_000, + backoffFactor = 2, + shouldRetry = defaultShouldRetry, + signal, + } = opts; + + let delay = initialDelayMs; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + return await fn(); + } catch (error) { + if (attempt >= maxAttempts || !shouldRetry(error, attempt)) throw error; + if (signal?.aborted) throw new DOMException('Retry aborted', 'AbortError'); + + await sleep(delay, signal); + delay = Math.min(delay * backoffFactor, maxDelayMs); + } + } + + // Unreachable — the loop always returns or throws. + throw new Error('retryRequest: exhausted all attempts'); +} + +/* ------------------------------------------------------------------ */ +/* RequestCache — deduplication / in-flight coalescing */ +/* ------------------------------------------------------------------ */ + +/** + * Simple in-memory cache that de-duplicates concurrent identical requests. + * + * If a request with the same `key` is already in flight, all callers + * share the same promise. Once settled the entry is automatically + * evicted (or kept for `ttlMs` when configured). + * + * ```ts + * const cache = new RequestCache(); + * const data = await cache.dedupe('config', () => httpClient.get('/config')); + * ``` + */ +export class RequestCache { + private inflight = new Map>(); + private store = new Map(); + private ttlMs: number; + + constructor(ttlMs = 0) { + this.ttlMs = ttlMs; + } + + async dedupe(key: string, fn: () => Promise): Promise { + // Return from TTL cache if still fresh + const cached = this.store.get(key); + if (cached && cached.expiresAt > Date.now()) { + return cached.value as T; + } + + // De-duplicate in-flight requests + const existing = this.inflight.get(key); + if (existing) return existing as Promise; + + const promise = fn().finally(() => { + this.inflight.delete(key); + }); + + this.inflight.set(key, promise); + + if (this.ttlMs > 0) { + promise.then((value) => { + this.store.set(key, { value, expiresAt: Date.now() + this.ttlMs }); + }).catch(() => { /* don't cache failures */ }); + } + + return promise as Promise; + } + + /** Manually evict a cache entry. */ + invalidate(key: string): void { + this.store.delete(key); + this.inflight.delete(key); + } + + /** Evict all cache entries. */ + clear(): void { + this.store.clear(); + this.inflight.clear(); + } +} + +/* ------------------------------------------------------------------ */ +/* throttle — limits invocation frequency */ +/* ------------------------------------------------------------------ */ + +/** + * Classic trailing-edge throttle. + * + * Ensures `fn` is called at most once every `limitMs` milliseconds. + * The first invocation fires immediately; subsequent calls within the + * window are silently dropped and the **last** one fires when the + * window closes. + */ +export function throttle void>( + fn: T, + limitMs: number, +): (...args: Parameters) => void { + let timer: ReturnType | null = null; + let lastArgs: Parameters | null = null; + let lastCallTime = 0; + + return (...args: Parameters) => { + const now = Date.now(); + const remaining = limitMs - (now - lastCallTime); + + if (remaining <= 0) { + // Window has passed — fire immediately + lastCallTime = now; + fn(...args); + } else { + // Inside the window — schedule a trailing call + lastArgs = args; + if (!timer) { + timer = setTimeout(() => { + lastCallTime = Date.now(); + timer = null; + if (lastArgs) { + fn(...lastArgs); + lastArgs = null; + } + }, remaining); + } + } + }; +} + +/* ------------------------------------------------------------------ */ +/* Internal helpers (not exported) */ +/* ------------------------------------------------------------------ */ + +/** Default retry predicate — retry on network errors & 5xx, not on 4xx. */ +function defaultShouldRetry(error: unknown, _attempt: number): boolean { + if (error instanceof DOMException && error.name === 'AbortError') return false; + if (error instanceof Response) return error.status >= 500; + return true; // network errors, timeouts, etc. +} + +/** Abort-aware sleep. */ +function sleep(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new DOMException('Sleep aborted', 'AbortError')); + return; + } + const timer = setTimeout(resolve, ms); + signal?.addEventListener('abort', () => { + clearTimeout(timer); + reject(new DOMException('Sleep aborted', 'AbortError')); + }, { once: true }); + }); +} diff --git a/content-gen/src/app/frontend/src/utils/briefFields.ts b/content-gen/src/app/frontend/src/utils/briefFields.ts new file mode 100644 index 000000000..beb44a88e --- /dev/null +++ b/content-gen/src/app/frontend/src/utils/briefFields.ts @@ -0,0 +1,46 @@ +/** + * Brief-field metadata shared between BriefReview and ConfirmedBriefView. + * + * Eliminates the duplicated field-label arrays. + */ +import type { CreativeBrief } from '../types'; + +/** + * Canonical map from `CreativeBrief` keys to user-friendly labels. + * Used by BriefReview (completeness gauges) and ConfirmedBriefView. + */ +export const BRIEF_FIELD_LABELS: Record = { + overview: 'Overview', + objectives: 'Objectives', + target_audience: 'Target Audience', + key_message: 'Key Message', + tone_and_style: 'Tone & Style', + deliverable: 'Deliverable', + timelines: 'Timelines', + visual_guidelines: 'Visual Guidelines', + cta: 'Call to Action', +}; + +/** + * Display order for brief fields in review UIs. + * + * The first element in each tuple is the `CreativeBrief` key, the second + * is the UI label (which may differ slightly from `BRIEF_FIELD_LABELS` + * for contextual reasons, e.g. "Campaign Objective" vs "Overview"). + */ +export const BRIEF_DISPLAY_ORDER: { key: keyof CreativeBrief; label: string }[] = [ + { key: 'overview', label: 'Campaign Objective' }, + { key: 'objectives', label: 'Objectives' }, + { key: 'target_audience', label: 'Target Audience' }, + { key: 'key_message', label: 'Key Message' }, + { key: 'tone_and_style', label: 'Tone & Style' }, + { key: 'visual_guidelines', label: 'Visual Guidelines' }, + { key: 'deliverable', label: 'Deliverables' }, + { key: 'timelines', label: 'Timelines' }, + { key: 'cta', label: 'Call to Action' }, +]; + +/** + * The canonical list of all nine brief field keys, in display order. + */ +export const BRIEF_FIELD_KEYS: (keyof CreativeBrief)[] = BRIEF_DISPLAY_ORDER.map((f) => f.key); diff --git a/content-gen/src/app/frontend/src/utils/contentParsing.ts b/content-gen/src/app/frontend/src/utils/contentParsing.ts new file mode 100644 index 000000000..629f887da --- /dev/null +++ b/content-gen/src/app/frontend/src/utils/contentParsing.ts @@ -0,0 +1,108 @@ +/** + * Content parsing utilities — raw API response → typed domain objects. + * + * Centralizes the duplicated `textContent` string-to-object parsing, + * image URL resolution (blob rewriting, base64 fallback), and the + * `GeneratedContent` assembly that was copy-pasted across + * useContentGeneration, useConversationActions, and useChatOrchestrator. + */ +import type { GeneratedContent } from '../types'; + +/* ------------------------------------------------------------------ */ +/* Internal helpers (not exported — reduces public API surface) */ +/* ------------------------------------------------------------------ */ + +/** + * Rewrite Azure Blob Storage URLs to the application's proxy endpoint + * so the browser can fetch images without CORS issues. + */ +function rewriteBlobUrl(url: string): string { + if (!url.includes('blob.core.windows.net')) return url; + const parts = url.split('/'); + const filename = parts[parts.length - 1]; + const convId = parts[parts.length - 2]; + return `/api/images/${convId}/${filename}`; +} + +/* ------------------------------------------------------------------ */ +/* Exported utilities */ +/* ------------------------------------------------------------------ */ + +/** + * Parse `text_content` which may arrive as a JSON string or an object. + * Returns an object with known fields, or `undefined` if unusable. + */ +export function parseTextContent( + raw: unknown, +): { headline?: string; body?: string; cta_text?: string; tagline?: string } | undefined { + let textContent = raw; + + if (typeof textContent === 'string') { + try { + textContent = JSON.parse(textContent); + } catch { + // Not valid JSON — treat as unusable + return undefined; + } + } + + if (typeof textContent !== 'object' || textContent === null) return undefined; + + const tc = textContent as Record; + return { + headline: tc.headline as string | undefined, + body: tc.body as string | undefined, + cta_text: (tc.cta_text ?? tc.cta) as string | undefined, + tagline: tc.tagline as string | undefined, + }; +} + +/** + * Resolve the best available image URL from a raw API response. + * + * Priority: explicit `image_url` (with blob rewrite) → base64 data URI. + * Pass `rewriteBlobs: true` (default) when restoring from a saved + * conversation; `false` when the response just came from the live API. + */ +export function resolveImageUrl( + raw: { image_url?: string; image_base64?: string }, + rewriteBlobs = false, +): string | undefined { + let url = raw.image_url; + if (url && rewriteBlobs) { + url = rewriteBlobUrl(url); + } + if (url) return url; + if (raw.image_base64) return `data:image/png;base64,${raw.image_base64}`; + return undefined; +} + +/** + * Build a fully-typed `GeneratedContent` from an arbitrary raw API payload. + * + * @param raw The parsed JSON object from the backend. + * @param rewriteBlobs Pass `true` when restoring from a saved conversation + * so Azure Blob URLs get proxied. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function buildGeneratedContent(raw: any, rewriteBlobs = false): GeneratedContent { + const textContent = parseTextContent(raw.text_content); + const imageUrl = resolveImageUrl(raw, rewriteBlobs); + + return { + text_content: textContent, + image_content: + imageUrl || raw.image_prompt + ? { + image_url: imageUrl, + prompt_used: raw.image_prompt, + alt_text: raw.image_revised_prompt || 'Generated marketing image', + } + : undefined, + violations: raw.violations || [], + requires_modification: raw.requires_modification || false, + error: raw.error, + image_error: raw.image_error, + text_error: raw.text_error, + }; +} diff --git a/content-gen/src/app/frontend/src/utils/generationStages.ts b/content-gen/src/app/frontend/src/utils/generationStages.ts new file mode 100644 index 000000000..03399bc0d --- /dev/null +++ b/content-gen/src/app/frontend/src/utils/generationStages.ts @@ -0,0 +1,33 @@ +/** + * Generation progress stage mapping. + * + * Pure function that converts elapsed seconds into a human-readable + * stage label + ordinal — used by the polling loop in `streamGenerateContent`. + */ + +export interface GenerationStage { + /** Ordinal stage index (0–5) for progress indicators. */ + stage: number; + /** Human-readable status message. */ + message: string; +} + +/** + * Map elapsed seconds to the current generation stage. + * + * Typical generation timeline: + * - 0 – 10 s → Briefing analysis + * - 10 – 25 s → Copy generation + * - 25 – 35 s → Image prompt creation + * - 35 – 55 s → Image generation + * - 55 – 70 s → Compliance check + * - 70 s+ → Finalizing + */ +export function getGenerationStage(elapsedSeconds: number): GenerationStage { + if (elapsedSeconds < 10) return { stage: 0, message: 'Analyzing creative brief...' }; + if (elapsedSeconds < 25) return { stage: 1, message: 'Generating marketing copy...' }; + if (elapsedSeconds < 35) return { stage: 2, message: 'Creating image prompt...' }; + if (elapsedSeconds < 55) return { stage: 3, message: 'Generating image with AI...' }; + if (elapsedSeconds < 70) return { stage: 4, message: 'Running compliance check...' }; + return { stage: 5, message: 'Finalizing content...' }; +} diff --git a/content-gen/src/app/frontend/src/utils/index.ts b/content-gen/src/app/frontend/src/utils/index.ts new file mode 100644 index 000000000..a3240ff6a --- /dev/null +++ b/content-gen/src/app/frontend/src/utils/index.ts @@ -0,0 +1,34 @@ +/** + * Barrel export for all utility modules. + * + * Import everything you need from '../utils'. + */ + +// Message factories & formatting +export { createMessage, formatContentForClipboard } from './messageUtils'; + +// Content parsing (raw API → typed domain objects) +export { parseTextContent, resolveImageUrl, buildGeneratedContent } from './contentParsing'; + +// SSE stream parser +export { parseSSEStream } from './sseParser'; + +// Generation progress stages +export { getGenerationStage } from './generationStages'; +export type { GenerationStage } from './generationStages'; + +// Brief-field metadata +export { BRIEF_FIELD_LABELS, BRIEF_DISPLAY_ORDER, BRIEF_FIELD_KEYS } from './briefFields'; + +// String utilities +export { escapeRegex, createNameSwapper, matchesAnyKeyword } from './stringUtils'; + +// Production API utilities +export { retryRequest, RequestCache, throttle } from './apiUtils'; +export type { RetryOptions } from './apiUtils'; + +// Content error detection +export { isContentFilterError, getErrorMessage } from './contentErrors'; + +// Image download +export { downloadImage } from './downloadImage'; diff --git a/content-gen/src/app/frontend/src/utils/messageUtils.ts b/content-gen/src/app/frontend/src/utils/messageUtils.ts new file mode 100644 index 000000000..5d7530b1f --- /dev/null +++ b/content-gen/src/app/frontend/src/utils/messageUtils.ts @@ -0,0 +1,44 @@ +/** + * Message utilities — ChatMessage factory and formatting helpers. + * + * Replaces duplicated `msg()` helpers in useChatOrchestrator and + * useConversationActions with a single, tested source of truth. + */ +import { v4 as uuidv4 } from 'uuid'; +import type { ChatMessage } from '../types'; + +/** + * Create a `ChatMessage` literal with a fresh UUID and ISO timestamp. + */ +export function createMessage( + role: 'user' | 'assistant', + content: string, + agent?: string, +): ChatMessage { + return { + id: uuidv4(), + role, + content, + agent, + timestamp: new Date().toISOString(), + }; +} + +/** + * Assemble a copyable plain-text string from generated text content. + * + * Used by `InlineContentPreview` to copy headline + body + tagline + * to clipboard. + */ +export function formatContentForClipboard( + textContent?: { headline?: string; body?: string; tagline?: string }, +): string { + if (!textContent) return ''; + return [ + textContent.headline && `✨ ${textContent.headline} ✨`, + textContent.body, + textContent.tagline, + ] + .filter(Boolean) + .join('\n\n'); +} diff --git a/content-gen/src/app/frontend/src/utils/sseParser.ts b/content-gen/src/app/frontend/src/utils/sseParser.ts new file mode 100644 index 000000000..7b11201f0 --- /dev/null +++ b/content-gen/src/app/frontend/src/utils/sseParser.ts @@ -0,0 +1,48 @@ +/** + * SSE (Server-Sent Events) stream parser. + * + * Eliminates the duplicated TextDecoder + buffer + line-split logic + * that was copy-pasted in `streamChat` and `streamRegenerateImage`. + */ +import type { AgentResponse } from '../types'; + +/** + * Parse an SSE stream from a `ReadableStreamDefaultReader` into an + * `AsyncGenerator` of `AgentResponse` objects. + * + * Protocol assumed: + * - Events delimited by `\n\n` + * - Each event starts with `data: ` + * - `data: [DONE]` terminates the stream + * + * @param reader The reader obtained via `response.body.getReader()` + */ +export async function* parseSSEStream( + reader: ReadableStreamDefaultReader, +): AsyncGenerator { + const decoder = new TextDecoder(); + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (line.startsWith('data: ')) { + const data = line.slice(6); + if (data === '[DONE]') { + return; + } + try { + yield JSON.parse(data) as AgentResponse; + } catch { + console.error('Failed to parse SSE data:', data); + } + } + } + } +} diff --git a/content-gen/src/app/frontend/src/utils/stringUtils.ts b/content-gen/src/app/frontend/src/utils/stringUtils.ts new file mode 100644 index 000000000..16c88eed3 --- /dev/null +++ b/content-gen/src/app/frontend/src/utils/stringUtils.ts @@ -0,0 +1,44 @@ +/** + * String utilities — regex escaping, name swapping, keyword matching. + * + * Extracts the duplicated keyword-matching pattern and the regex-escape + + * swapName closure from useChatOrchestrator into reusable, testable functions. + */ + +/** + * Escape a string so it can be safely embedded in a `RegExp` pattern. + */ +export function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Create a function that replaces all case-insensitive occurrences of + * `oldName` with `newName` in a string. + * + * Returns `undefined` if no swap is possible (names are the same, etc.). + */ +export function createNameSwapper( + oldName: string | undefined, + newName: string | undefined, +): ((text?: string) => string | undefined) | undefined { + if (!oldName || !newName || oldName === newName) return undefined; + + const regex = new RegExp(escapeRegex(oldName), 'gi'); + return (text?: string) => { + if (!text) return text; + return text.replace(regex, () => newName); + }; +} + +/** + * Check whether `text` contains **any** of the given keywords + * (case-insensitive substring match). + * + * Used for intent classification (brief detection, refinement detection, + * image modification detection) repeated 3× in useChatOrchestrator. + */ +export function matchesAnyKeyword(text: string, keywords: readonly string[]): boolean { + const lower = text.toLowerCase(); + return keywords.some((kw) => lower.includes(kw)); +} From 8007ba08a580fa42d8cad5c81db4c5b67bdd725b Mon Sep 17 00:00:00 2001 From: Shubhangi-Microsoft Date: Mon, 2 Mar 2026 14:05:20 +0530 Subject: [PATCH 03/12] refactor(frontend): remove dead code, deduplicate error handling, tighten utility exports --- content-gen/src/app/frontend/src/api/index.ts | 2 -- .../frontend/src/components/ChatHistory.tsx | 12 +++++------ .../frontend/src/hooks/useChatOrchestrator.ts | 7 ++---- .../src/hooks/useContentGeneration.ts | 14 +++++------- .../src/hooks/useConversationActions.ts | 15 ++++++------- .../frontend/src/hooks/useCopyToClipboard.ts | 4 ++-- .../frontend/src/store/chatHistorySlice.ts | 13 +----------- .../src/app/frontend/src/store/index.ts | 2 -- .../src/styles/images/SamplePrompt.png | Bin 4938 -> 0 bytes .../src/app/frontend/src/utils/index.ts | 4 ++-- .../app/frontend/src/utils/messageUtils.ts | 20 +++++------------- .../src/app/frontend/src/utils/stringUtils.ts | 3 ++- 12 files changed, 31 insertions(+), 65 deletions(-) delete mode 100644 content-gen/src/app/frontend/src/styles/images/SamplePrompt.png diff --git a/content-gen/src/app/frontend/src/api/index.ts b/content-gen/src/app/frontend/src/api/index.ts index ee4bba2e6..37031126b 100644 --- a/content-gen/src/app/frontend/src/api/index.ts +++ b/content-gen/src/app/frontend/src/api/index.ts @@ -122,8 +122,6 @@ export async function* streamGenerateContent( }, { signal }); const taskId = startData.task_id; - console.debug(`Generation started with task ID: ${taskId}`); - // Yield initial status yield { type: 'status', diff --git a/content-gen/src/app/frontend/src/components/ChatHistory.tsx b/content-gen/src/app/frontend/src/components/ChatHistory.tsx index 37dd7b705..8faa1f24e 100644 --- a/content-gen/src/app/frontend/src/components/ChatHistory.tsx +++ b/content-gen/src/app/frontend/src/components/ChatHistory.tsx @@ -75,8 +75,8 @@ export const ChatHistory = memo(function ChatHistory({ try { await dispatch(clearAllConversations()).unwrap(); onNewConversation(); - } catch (err) { - console.error('Error clearing all conversations:', err); + } catch { + // Error clearing all conversations } }, [dispatch, onNewConversation]); @@ -86,16 +86,16 @@ export const ChatHistory = memo(function ChatHistory({ if (conversationId === currentConversationId) { onNewConversation(); } - } catch (err) { - console.error('Error deleting conversation:', err); + } catch { + // Error deleting conversation } }, [dispatch, currentConversationId, onNewConversation]); const handleRenameConversation = useCallback(async (conversationId: string, newTitle: string) => { try { await dispatch(renameConversation({ conversationId, newTitle })).unwrap(); - } catch (err) { - console.error('Error renaming conversation:', err); + } catch { + // Error renaming conversation } }, [dispatch]); diff --git a/content-gen/src/app/frontend/src/hooks/useChatOrchestrator.ts b/content-gen/src/app/frontend/src/hooks/useChatOrchestrator.ts index d88fe834f..9980e978a 100644 --- a/content-gen/src/app/frontend/src/hooks/useChatOrchestrator.ts +++ b/content-gen/src/app/frontend/src/hooks/useChatOrchestrator.ts @@ -1,7 +1,7 @@ import { useCallback, type MutableRefObject } from 'react'; import type { GeneratedContent } from '../types'; -import { createMessage, matchesAnyKeyword, createNameSwapper } from '../utils'; +import { createMessage, createErrorMessage, matchesAnyKeyword, createNameSwapper } from '../utils'; import { useAppDispatch, useAppSelector, @@ -467,14 +467,11 @@ export function useChatOrchestrator( } } catch (error) { if (error instanceof Error && error.name === 'AbortError') { - console.debug('Request cancelled by user'); dispatch(addMessage(createMessage('assistant', 'Generation stopped.'))); } else { - console.error('Error sending message:', error); dispatch( addMessage( - createMessage( - 'assistant', + createErrorMessage( 'Sorry, there was an error processing your request. Please try again.', ), ), diff --git a/content-gen/src/app/frontend/src/hooks/useContentGeneration.ts b/content-gen/src/app/frontend/src/hooks/useContentGeneration.ts index 6b01c54b4..423d8a561 100644 --- a/content-gen/src/app/frontend/src/hooks/useContentGeneration.ts +++ b/content-gen/src/app/frontend/src/hooks/useContentGeneration.ts @@ -1,6 +1,6 @@ import { useCallback, type MutableRefObject } from 'react'; -import { createMessage, buildGeneratedContent } from '../utils'; +import { createMessage, createErrorMessage, buildGeneratedContent } from '../utils'; import { useAppDispatch, useAppSelector, @@ -66,25 +66,21 @@ export function useContentGeneration( const genContent = buildGeneratedContent(rawContent); dispatch(setGeneratedContent(genContent)); dispatch(setGenerationStatus('')); - } catch (parseError) { - console.error('Error parsing generated content:', parseError); + } catch { + // Content parse failure — non-critical, generation result may be malformed } } else if (response.type === 'error') { dispatch(setGenerationStatus('')); - dispatch(addMessage(createMessage( - 'assistant', + dispatch(addMessage(createErrorMessage( `Error generating content: ${response.content}`, ))); } } } catch (error) { if (error instanceof Error && error.name === 'AbortError') { - console.debug('Content generation cancelled by user'); dispatch(addMessage(createMessage('assistant', 'Content generation stopped.'))); } else { - console.error('Error generating content:', error); - dispatch(addMessage(createMessage( - 'assistant', + dispatch(addMessage(createErrorMessage( 'Sorry, there was an error generating content. Please try again.', ))); } diff --git a/content-gen/src/app/frontend/src/hooks/useConversationActions.ts b/content-gen/src/app/frontend/src/hooks/useConversationActions.ts index 97b3b9f90..a20629a4a 100644 --- a/content-gen/src/app/frontend/src/hooks/useConversationActions.ts +++ b/content-gen/src/app/frontend/src/hooks/useConversationActions.ts @@ -96,11 +96,8 @@ export function useConversationActions() { products?: Product[]; }>('/products'); dispatch(setAvailableProducts(productsData.products || [])); - } catch (err) { - console.error( - 'Error loading products for restored conversation:', - err, - ); + } catch { + // Non-critical — product load failure for restored conversation } } @@ -122,8 +119,8 @@ export function useConversationActions() { dispatch(setGeneratedContent(null)); dispatch(setSelectedProducts([])); } - } catch (error) { - console.error('Error loading conversation:', error); + } catch { + // Error loading conversation — swallowed silently } }, [userId, dispatch], @@ -164,8 +161,8 @@ export function useConversationActions() { ), ), ); - } catch (error) { - console.error('Error confirming brief:', error); + } catch { + // Error confirming brief — swallowed silently } }, [conversationId, userId, pendingBrief, dispatch]); diff --git a/content-gen/src/app/frontend/src/hooks/useCopyToClipboard.ts b/content-gen/src/app/frontend/src/hooks/useCopyToClipboard.ts index 08d024d8d..5887c9532 100644 --- a/content-gen/src/app/frontend/src/hooks/useCopyToClipboard.ts +++ b/content-gen/src/app/frontend/src/hooks/useCopyToClipboard.ts @@ -13,8 +13,8 @@ export function useCopyToClipboard(resetTimeout = 2000) { const copy = useCallback( (text: string) => { - navigator.clipboard.writeText(text).catch((err) => { - console.error('Failed to copy text:', err); + navigator.clipboard.writeText(text).catch(() => { + // Clipboard write failure — non-critical }); setCopied(true); clearTimeout(timerRef.current); diff --git a/content-gen/src/app/frontend/src/store/chatHistorySlice.ts b/content-gen/src/app/frontend/src/store/chatHistorySlice.ts index 4f088c3a1..9874d45bc 100644 --- a/content-gen/src/app/frontend/src/store/chatHistorySlice.ts +++ b/content-gen/src/app/frontend/src/store/chatHistorySlice.ts @@ -78,17 +78,6 @@ const chatHistorySlice = createSlice({ setShowAll(state, action: PayloadAction) { state.showAll = action.payload; }, - setConversations(state, action: PayloadAction) { - state.conversations = action.payload; - }, - upsertConversation(state, action: PayloadAction) { - const idx = state.conversations.findIndex((c) => c.id === action.payload.id); - if (idx >= 0) { - state.conversations[idx] = action.payload; - } else { - state.conversations.unshift(action.payload); - } - }, setIsClearAllDialogOpen(state, action: PayloadAction) { state.isClearAllDialogOpen = action.payload; }, @@ -133,6 +122,6 @@ const chatHistorySlice = createSlice({ }, }); -export const { setShowAll, setConversations, upsertConversation, setIsClearAllDialogOpen } = +export const { setShowAll, setIsClearAllDialogOpen } = chatHistorySlice.actions; export default chatHistorySlice.reducer; diff --git a/content-gen/src/app/frontend/src/store/index.ts b/content-gen/src/app/frontend/src/store/index.ts index a0b395487..6e85accf0 100644 --- a/content-gen/src/app/frontend/src/store/index.ts +++ b/content-gen/src/app/frontend/src/store/index.ts @@ -44,8 +44,6 @@ export { renameConversation, clearAllConversations, setShowAll, - setConversations, - upsertConversation, setIsClearAllDialogOpen, } from './chatHistorySlice'; export type { ConversationSummary } from './chatHistorySlice'; diff --git a/content-gen/src/app/frontend/src/styles/images/SamplePrompt.png b/content-gen/src/app/frontend/src/styles/images/SamplePrompt.png deleted file mode 100644 index 9a57c67965c7e119704e1fcbcb0728dc016d34c6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4938 zcmV-Q6SeG#P)uHZn41`pLiGc!F@vyX@oMK_SaIr|La8j@U7T8%)EK;(!Y>+pQ z1r#*d*|N&(Vg#{?*9M2Gj}F(?%12C zrPlxM?(e+M=_{f;?M}PXew>@4F`N0$+lyLNPpc?;)DCBMz7*lUHo=sds4CD)cg)F@ z<+;3Cf41|`rzxQrN2M^`V%SHqI zz?i(R0Nzl(rD)JoChrmr&_M)K_E>P1=p$@<65!1@-+Wjp_2ghMcocoU-|y4b))w&* zV5uDRC}Gx4%qzYQ+rdLcBlg@_+{;{n2qfT{>w`_A{w66tf(SWXd%d2VbL#-&FRL74g!Of1Fj|am+^7++ z+!{A4{s7q*<3~&|0A>kLIGTPJpXF5H`|tkT;}p%_MQNIHH!K0@`ak%K_^5MgW55GcVv>i48gFnrbW~wu_ zu>i%o$OK{0_u}t~00{IAlQjeEF#Ud}346?jfiS1r?KX8f9pixWoW$LUzVh$NkZui5 z3ZBNT{Urb<{WT|MOa$X5s&$b?k{8EBVToY%@Wi>@#$)pLFm%Fg?tIR~;QQYAIng(6 zeq%pX#u0+=JxpV^QC{FaKZzGTuZxZ=vOr!ebB)!bfHCCm4I~p}J7DgfaQ^$SJ8}1% zColSexO-1i6M?K@++PBCZhNDnEDPk|7s#T|9Airj@ilzpdjKxyArbsIj({={6TpuZ zba}={Z|K7Hc(LD3*nKBJsB7eez;$neWx-zT*;VmT^qUJy&;|FV?dB0mH5yk zATVft!G;@DvnlJ2;|2UiC*=+p$!MerXdCY9am zl*XL=rq&qxAjz(jZnuTDM7eM`ju8VWO;HbXu%}6;L>$?8vXR|p499MxY`_kE_br0h zwn(GFP*ovtVUYL!{52|icT<#<@Mo3Wqit@` zeH@JL!Smn99+lJ+}OZ%XGPSZUbiQgBO=3EsLbbRZnGCXRV9O`qbQlnQmkeQ4-s&cfAZjRF~~pP`Qn9h zqcHBM2N<^`=Ly^zg$Rbz;hTeq@_0fDU?WII+wGHp_nD#pCiGx7tdUudEm`pVB#$QP zea@cCA`G~8#2HA3=88m}HQGEwgK{o{IwsK;#To3#WJd8+YcXp_XY)V4_sJ0p9^nRS z$76NP7bUzE-t6_v`leBx&IAg%rE)1V3cTe9Hi9+Tf;=JSL%&#MbAxT5ZeIwKXUMAMT%*aDZKp55xqt>2q_W&TU6%Rhtw)UbeYXLq7@!$F&jrwb9{>3LKx%z)3Q?P@`B4v80Q*yoa<^ZvT5*t7w z4YnYobN$#4C4k`xgyXKHX_^GYWolu4z>BDHI=UknBCD@8_q-VUVn;xf`kI=PI#jOt zGgwy(F9H0!7iF)7HB`orZAGpcfB+kY;`avBGN@2@T2hkJJuqui&%OOfd-23q`^&o% zz|9?2wLBPY#Pk^-Dm$_Cm7}2$ItX&MTgxFUxh=BWe(q^u=L52ZFaM@WZv^1Ak-ZlF zD$0}*0`w6|-9Q0%(5H0LZo2flineYMt@bBj9Qu1m0C^7h(w?P)5+&0fQ5cLgX0s_+ z(x@a#?Vi02*%|k}3ZJQBoff|Ggi3E-Aj458yDJ8dPb@L;CJSRrDb2-Zm;jh2idMCd z65A29)m6)%`qt`dIPpjVC?Tx&%4a-y0c3pkN86*aVLc5v8A?J(92eT|Adm{Gzucm7 zgBJef2})NlLe!O%UzuD&1P((u2CFPFR0|K_kg6@Es!bd*1wSiFCW3ixJKhmCz$soM z2~Ou72w-D>M#QB{tS0}4M&A?4gVTJ<+8hS^FsKvubE7sJ)zXSZ*MbUGu9C#Z# z$T~#|$V!CRthQniF$H$punmDDdoH!CwkDs466{R?k^!M_!g;Uv%~~@gD`)m{LXib`<{+2&+K8`Ib6)y;pTU;qE70RFOwJ#$|G6Im6IH98Mq6H?d$VLU|M zZAi1|CbWzy_WV5N8&3ghFCDYE^xNnEiSFv% zsFlU4rl~oi$$D!B3*cf18NIRRqGxz~X~I+|@BeyriFRo_2%w$KH-2AJPDx45@8Wrs zgCK$+M3OqOF(U3+t3>oxB9BMaI4y9r*HhUhEo6!gWLtC>t`D@=K#X-O9dQ4g|HqN# zZ5xDq{2TuP@T*p*>d@3!j;lrjj7zr#JP1)Sco;l`!Zhw(LX2|q#MgEQ&n+4)0k(=I z0VWG7PmUD~5bAzr(OGf|azE%}oPr3T2U1XovZ0iB&9NEOMz%OJg|KyMl@_LM(t+t4 zv@mfK@kyUL8G@Arsu4qqpgscrTL8bl+9XNNAG3HH=kAQLZqSN+#BmPrJN>sz1ZaT& z>S(dwQvz&6VZ%%7^R)HEI2J`|{@_05Tbur7nRkcngh|YXj(g<{o;QHtXtG5U(YhGD z$=DuaWi7mEW(4?>9(xVoH&=&zgaE1}m~%Ssvr}gWKn)uWx@#CbV{Q*T(Fj-c=KE_B zy1E!o!yVv#F8UBHNdbU;7M2YyV^A zn|&Na8QaBVcsG4uch)(;(TM1(E+le`k8;mwHboU@kaVf4ZXZ0i(Yb(#zIYp(62QEv z6v;^idkWpxP=#)e%#X7_?Dm$}A4Dc-^1(pyJ?#@AU5%VHOo+HYQ2aSX{r`0$-^hjK zsC9GV0=1~3=d_mu7E-VZB3obUb}9lh~ohuI7rjkfyD5x7Kpa&&f}qQDNA zZ5XQxiQeEVX*Cjp3>c-|U&?)J#D#dK|ArUJjX3zs+ptI{oNPo#LKiyW3*8!;6fkEXu~Gi)M_9U z)8tcRY7#?+YU)?(nxfATYtbefqGjFxq&UCgU$zGZjg25Zh{+6KLy*pD`9%$ELBT&I zL@$x&PamU=-+GbE+%X!rjo2Hk*C}y8#1g?QaR!n7E>dooL$>5~reOIUxyd-> z5MlBAe?eZ{<^3`e@TLzh(}#OS{?6{t5&$JO_P+cRqs~#f_4xA?%^ltSCvIaA!0uSs z)l+B?E!fsT(Z#=CrSt#%K5g6@41d=o(d4=6YV5%?qPf|t^hdvc!CKZ5_Z^1~ikTH^ zdZ=MK;I0SYZ~Vqz63rZOewsc60;n*^Z3+~8k4@dBuWhNfzK*aJ8zgEjrNN+}A}_3t z*2ECEX#*oixjJS?J8|qa333SFiN!kSk`4cYPFe6Evi8A`fH%}#c#z&Zb%x@JS-4{3 zc8sz^?XmYAU?RJaGi3*h#h%3r12$K;M;ToMIM0QsZ zyDDw!pirDBMml!)Ti`Yc5g4pNJzoQSxuxY=M88x*0QLg!e)0>n@iU(_*6nh}qdE4k z3B0cakRhEO`{JmQVhd;cimS^2%#{MWq1i%4s9`2rnYGl~fwm=aJD|}`(S-jwe()^` zaSd}9+-GA~^=!y1h7KI(UGeqV2kHBt_(Ll1{%MLUUyqD!9td|3n-4((#AP*`x;05y z4Z2vwPyA9u-~LKbgE4illXh~}l4^Tsix%~8)9Gd5xPTWG00BzlT%Vz}*(F-J|98yh zfhEO8I1=#I6lp|Z8Pf5IcFSa0I<^(qMFN82o?Sn*p;9@*+q(wk7XdPf)|iBZM* z0RTtKFUi)pj%TSm`IBPZW0xuNk8aW=F=-sD4l>S88y%Z3S;{}22A4f1~-Y1MBlP0ms&{Iyh>~ zP-Jln<*!VFbYrF^BdmWaV?75;4AV@&ds7wp=(!1`g%DCd#n_SC3XGh-`}gWPdh-ib zo3b0TN@KPWMld>y$)5DdaN`wx2IR{%nl}pt6mnZ}7^=30uBPTd%U|9E@ik|^#pa@a za@#S=rjR}Xv&C8YrHF18;kN5tTKzJO*>?C5@bm@avRC7i9&!d=cE}>P)fqL<$V#}j zHAJ$HB@Zy3V2Z1s{y$bhjf_ncYypPHQq&Y(mCZ7Z-!$^;FcN>8Ai#cs_L~5|FrA*m zWY1ZE%1-FAz@S*_1R}`zWStEViC2h%ZUJkT{MP@&%!2i1!|w2Sbytz!0`m;<-}CWV z7{GHhX4}<*zS^x$W2Vz$2b@m(gqUiQcx!LSdjQoG5nOTI)CrLL&m@vuMao651hZ#?ljwe00b0jbY2gQj{pDw07*qo IM6N<$f}h89BLDyZ diff --git a/content-gen/src/app/frontend/src/utils/index.ts b/content-gen/src/app/frontend/src/utils/index.ts index a3240ff6a..2541cc3b7 100644 --- a/content-gen/src/app/frontend/src/utils/index.ts +++ b/content-gen/src/app/frontend/src/utils/index.ts @@ -5,7 +5,7 @@ */ // Message factories & formatting -export { createMessage, formatContentForClipboard } from './messageUtils'; +export { createMessage, createErrorMessage } from './messageUtils'; // Content parsing (raw API → typed domain objects) export { parseTextContent, resolveImageUrl, buildGeneratedContent } from './contentParsing'; @@ -21,7 +21,7 @@ export type { GenerationStage } from './generationStages'; export { BRIEF_FIELD_LABELS, BRIEF_DISPLAY_ORDER, BRIEF_FIELD_KEYS } from './briefFields'; // String utilities -export { escapeRegex, createNameSwapper, matchesAnyKeyword } from './stringUtils'; +export { createNameSwapper, matchesAnyKeyword } from './stringUtils'; // Production API utilities export { retryRequest, RequestCache, throttle } from './apiUtils'; diff --git a/content-gen/src/app/frontend/src/utils/messageUtils.ts b/content-gen/src/app/frontend/src/utils/messageUtils.ts index 5d7530b1f..45a7ea5ac 100644 --- a/content-gen/src/app/frontend/src/utils/messageUtils.ts +++ b/content-gen/src/app/frontend/src/utils/messageUtils.ts @@ -25,20 +25,10 @@ export function createMessage( } /** - * Assemble a copyable plain-text string from generated text content. - * - * Used by `InlineContentPreview` to copy headline + body + tagline - * to clipboard. + * Shorthand for creating an assistant error message. + * Consolidates the repeated `createMessage('assistant', errorText)` pattern + * used in error catch blocks across multiple hooks. */ -export function formatContentForClipboard( - textContent?: { headline?: string; body?: string; tagline?: string }, -): string { - if (!textContent) return ''; - return [ - textContent.headline && `✨ ${textContent.headline} ✨`, - textContent.body, - textContent.tagline, - ] - .filter(Boolean) - .join('\n\n'); +export function createErrorMessage(content: string): ChatMessage { + return createMessage('assistant', content); } diff --git a/content-gen/src/app/frontend/src/utils/stringUtils.ts b/content-gen/src/app/frontend/src/utils/stringUtils.ts index 16c88eed3..387e07ff5 100644 --- a/content-gen/src/app/frontend/src/utils/stringUtils.ts +++ b/content-gen/src/app/frontend/src/utils/stringUtils.ts @@ -7,8 +7,9 @@ /** * Escape a string so it can be safely embedded in a `RegExp` pattern. + * @internal — only used by `createNameSwapper` within this module. */ -export function escapeRegex(str: string): string { +function escapeRegex(str: string): string { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } From 50bac33eb2d6873a960da9ce43c35582294620c1 Mon Sep 17 00:00:00 2001 From: Shubhangi-Microsoft Date: Mon, 2 Mar 2026 16:59:11 +0530 Subject: [PATCH 04/12] refactor(frontend): dedup streamChat, remove dead code, tighten imports and memoization --- content-gen/src/app/frontend/src/App.tsx | 2 - content-gen/src/app/frontend/src/api/index.ts | 4 +- .../app/frontend/src/components/ChatPanel.tsx | 6 - .../src/components/InlineContentPreview.tsx | 3 +- .../frontend/src/components/ProductReview.tsx | 2 - .../frontend/src/components/ViolationCard.tsx | 8 +- .../frontend/src/hooks/useChatOrchestrator.ts | 154 ++++++------------ .../src/hooks/useConversationActions.ts | 14 -- .../src/app/frontend/src/hooks/useDebounce.ts | 29 ---- .../src/app/frontend/src/utils/index.ts | 4 - .../src/app/frontend/src/utils/sseParser.ts | 2 +- 11 files changed, 55 insertions(+), 173 deletions(-) delete mode 100644 content-gen/src/app/frontend/src/hooks/useDebounce.ts diff --git a/content-gen/src/app/frontend/src/App.tsx b/content-gen/src/app/frontend/src/App.tsx index c0cd14934..4bc73f5fc 100644 --- a/content-gen/src/app/frontend/src/App.tsx +++ b/content-gen/src/app/frontend/src/App.tsx @@ -32,7 +32,6 @@ function App() { newConversation, confirmBrief, cancelBrief, - productsStartOver, selectProduct, toggleHistory, } = useConversationActions(); @@ -63,7 +62,6 @@ function App() { onBriefCancel={cancelBrief} onGenerateContent={generateContent} onRegenerateContent={generateContent} - onProductsStartOver={productsStartOver} onProductSelect={selectProduct} onNewConversation={newConversation} /> diff --git a/content-gen/src/app/frontend/src/api/index.ts b/content-gen/src/app/frontend/src/api/index.ts index 37031126b..b139f3e29 100644 --- a/content-gen/src/app/frontend/src/api/index.ts +++ b/content-gen/src/app/frontend/src/api/index.ts @@ -10,8 +10,7 @@ import type { AppConfig, } from '../types'; import httpClient from './httpClient'; -import { parseSSEStream } from '../utils/sseParser'; -import { getGenerationStage } from '../utils/generationStages'; +import { parseSSEStream, getGenerationStage } from '../utils'; /** * Get application configuration including feature flags @@ -178,7 +177,6 @@ export async function* streamGenerateContent( } as AgentResponse; } } catch (error) { - console.error(`Error polling task ${taskId}:`, error); // Continue polling on transient errors if (attempts >= maxAttempts) { throw error; diff --git a/content-gen/src/app/frontend/src/components/ChatPanel.tsx b/content-gen/src/app/frontend/src/components/ChatPanel.tsx index 0a6c39208..d6ac60896 100644 --- a/content-gen/src/app/frontend/src/components/ChatPanel.tsx +++ b/content-gen/src/app/frontend/src/components/ChatPanel.tsx @@ -30,7 +30,6 @@ interface ChatPanelProps { onBriefCancel?: () => void; onGenerateContent?: () => void; onRegenerateContent?: () => void; - onProductsStartOver?: () => void; onProductSelect?: (product: Product) => void; onNewConversation?: () => void; } @@ -42,7 +41,6 @@ export const ChatPanel = memo(function ChatPanel({ onBriefCancel, onGenerateContent, onRegenerateContent, - onProductsStartOver, onProductSelect, onNewConversation, }: ChatPanelProps) { @@ -88,9 +86,6 @@ export const ChatPanel = memo(function ChatPanel({ const isInputDisabled = useMemo(() => isLoading, [isLoading]); - const startOverFallback = useCallback(() => {}, []); - const effectiveProductsStartOver = onProductsStartOver || startOverFallback; - return (
    {/* Messages Area */} @@ -142,7 +137,6 @@ export const ChatPanel = memo(function ChatPanel({ products={selectedProducts} availableProducts={availableProducts} onConfirm={onGenerateContent!} - onStartOver={effectiveProductsStartOver} isAwaitingResponse={isLoading} onProductSelect={onProductSelect} disabled={isLoading} diff --git a/content-gen/src/app/frontend/src/components/InlineContentPreview.tsx b/content-gen/src/app/frontend/src/components/InlineContentPreview.tsx index c0f2367c8..41144f181 100644 --- a/content-gen/src/app/frontend/src/components/InlineContentPreview.tsx +++ b/content-gen/src/app/frontend/src/components/InlineContentPreview.tsx @@ -7,8 +7,7 @@ import { import { ShieldError20Regular } from '@fluentui/react-icons'; import type { GeneratedContent, Product } from '../types'; import { useWindowSize } from '../hooks/useWindowSize'; -import { isContentFilterError, getErrorMessage } from '../utils/contentErrors'; -import { downloadImage } from '../utils/downloadImage'; +import { isContentFilterError, getErrorMessage, downloadImage } from '../utils'; import { useCopyToClipboard } from '../hooks/useCopyToClipboard'; import { ImagePreviewCard } from './ImagePreviewCard'; import { ComplianceSection } from './ComplianceSection'; diff --git a/content-gen/src/app/frontend/src/components/ProductReview.tsx b/content-gen/src/app/frontend/src/components/ProductReview.tsx index 7c8a12ce5..7e3f31dce 100644 --- a/content-gen/src/app/frontend/src/components/ProductReview.tsx +++ b/content-gen/src/app/frontend/src/components/ProductReview.tsx @@ -13,7 +13,6 @@ import { ProductCard } from './ProductCard'; interface ProductReviewProps { products: Product[]; onConfirm: () => void; - onStartOver: () => void; isAwaitingResponse?: boolean; availableProducts?: Product[]; onProductSelect?: (product: Product) => void; @@ -23,7 +22,6 @@ interface ProductReviewProps { export const ProductReview = memo(function ProductReview({ products, onConfirm, - onStartOver: _onStartOver, isAwaitingResponse = false, availableProducts = [], onProductSelect, diff --git a/content-gen/src/app/frontend/src/components/ViolationCard.tsx b/content-gen/src/app/frontend/src/components/ViolationCard.tsx index 52914c7a6..479bbbfd6 100644 --- a/content-gen/src/app/frontend/src/components/ViolationCard.tsx +++ b/content-gen/src/app/frontend/src/components/ViolationCard.tsx @@ -1,4 +1,4 @@ -import { memo } from 'react'; +import { memo, useMemo } from 'react'; import { Text, } from '@fluentui/react-components'; @@ -17,7 +17,7 @@ export interface ViolationCardProps { * A single compliance violation row with severity-coloured icon and background. */ export const ViolationCard = memo(function ViolationCard({ violation }: ViolationCardProps) { - const getSeverityStyles = () => { + const { icon, bg } = useMemo(() => { switch (violation.severity) { case 'error': return { @@ -35,9 +35,7 @@ export const ViolationCard = memo(function ViolationCard({ violation }: Violatio bg: '#deecf9', }; } - }; - - const { icon, bg } = getSeverityStyles(); + }, [violation.severity]); return (
    , + dispatch: AppDispatch, +): Promise { + let fullContent = ''; + let currentAgent = ''; + let messageAdded = false; + + for await (const response of stream) { + if (response.type === 'agent_response') { + fullContent = response.content; + currentAgent = response.agent || ''; + if ((response.is_final || response.requires_user_input) && !messageAdded) { + dispatch(addMessage(createMessage('assistant', fullContent, currentAgent))); + messageAdded = true; + } + } else if (response.type === 'error') { + dispatch( + addMessage( + createMessage( + 'assistant', + response.content || 'An error occurred while processing your request.', + ), + ), + ); + messageAdded = true; + } + } +} /* ------------------------------------------------------------------ */ /* Hook */ @@ -130,42 +166,11 @@ export function useChatOrchestrator( } } else { // --- 1-b General question while brief is pending ----------- - let fullContent = ''; - let currentAgent = ''; - let messageAdded = false; - dispatch(setGenerationStatus('Processing your question...')); - for await (const response of streamChat( - content, - conversationId, - userId, - signal, - )) { - if (response.type === 'agent_response') { - fullContent = response.content; - currentAgent = response.agent || ''; - if ( - (response.is_final || response.requires_user_input) && - !messageAdded - ) { - dispatch( - addMessage(createMessage('assistant', fullContent, currentAgent)), - ); - messageAdded = true; - } - } else if (response.type === 'error') { - dispatch( - addMessage( - createMessage( - 'assistant', - response.content || - 'An error occurred while processing your request.', - ), - ), - ); - messageAdded = true; - } - } + await consumeStreamChat( + streamChat(content, conversationId, userId, signal), + dispatch, + ); dispatch(setGenerationStatus('')); } @@ -321,41 +326,11 @@ export function useChatOrchestrator( ); } else { // --- 3-b General question after content generation -------- - let fullContent = ''; - let currentAgent = ''; - let messageAdded = false; - dispatch(setGenerationStatus('Processing your request...')); - for await (const response of streamChat( - content, - conversationId, - userId, - signal, - )) { - if (response.type === 'agent_response') { - fullContent = response.content; - currentAgent = response.agent || ''; - if ( - (response.is_final || response.requires_user_input) && - !messageAdded - ) { - dispatch( - addMessage(createMessage('assistant', fullContent, currentAgent)), - ); - messageAdded = true; - } - } else if (response.type === 'error') { - dispatch( - addMessage( - createMessage( - 'assistant', - response.content || 'An error occurred.', - ), - ), - ); - messageAdded = true; - } - } + await consumeStreamChat( + streamChat(content, conversationId, userId, signal), + dispatch, + ); dispatch(setGenerationStatus('')); } @@ -426,42 +401,11 @@ export function useChatOrchestrator( } } else { // --- 4-b Generic chat ----------------------------------- - let fullContent = ''; - let currentAgent = ''; - let messageAdded = false; - dispatch(setGenerationStatus('Processing your request...')); - for await (const response of streamChat( - content, - conversationId, - userId, - signal, - )) { - if (response.type === 'agent_response') { - fullContent = response.content; - currentAgent = response.agent || ''; - if ( - (response.is_final || response.requires_user_input) && - !messageAdded - ) { - dispatch( - addMessage(createMessage('assistant', fullContent, currentAgent)), - ); - messageAdded = true; - } - } else if (response.type === 'error') { - dispatch( - addMessage( - createMessage( - 'assistant', - response.content || - 'An error occurred while processing your request.', - ), - ), - ); - messageAdded = true; - } - } + await consumeStreamChat( + streamChat(content, conversationId, userId, signal), + dispatch, + ); dispatch(setGenerationStatus('')); } } diff --git a/content-gen/src/app/frontend/src/hooks/useConversationActions.ts b/content-gen/src/app/frontend/src/hooks/useConversationActions.ts index a20629a4a..6cd0bfd01 100644 --- a/content-gen/src/app/frontend/src/hooks/useConversationActions.ts +++ b/content-gen/src/app/frontend/src/hooks/useConversationActions.ts @@ -182,19 +182,6 @@ export function useConversationActions() { /* ------------------------------------------------------------ */ /* Product actions */ /* ------------------------------------------------------------ */ - const productsStartOver = useCallback(() => { - dispatch(setSelectedProducts([])); - dispatch(setConfirmedBrief(null)); - dispatch( - addMessage( - createMessage( - 'assistant', - 'Starting over. Please provide your creative brief to begin a new campaign.', - ), - ), - ); - }, [dispatch]); - const selectProduct = useCallback( (product: Product) => { const isSelected = selectedProducts.some( @@ -224,7 +211,6 @@ export function useConversationActions() { newConversation, confirmBrief, cancelBrief, - productsStartOver, selectProduct, toggleHistory, }; diff --git a/content-gen/src/app/frontend/src/hooks/useDebounce.ts b/content-gen/src/app/frontend/src/hooks/useDebounce.ts deleted file mode 100644 index c0c7e5ab1..000000000 --- a/content-gen/src/app/frontend/src/hooks/useDebounce.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { useState, useEffect } from 'react'; - -/** - * Returns a debounced copy of `value` that only updates after `delay` ms of - * inactivity. - * - * @param value - The source value to debounce. - * @param delay - Debounce window in milliseconds. - * - * @example - * ```ts - * const [search, setSearch] = useState(''); - * const debouncedSearch = useDebounce(search, 300); - * - * useEffect(() => { - * fetchResults(debouncedSearch); - * }, [debouncedSearch]); - * ``` - */ -export function useDebounce(value: T, delay: number): T { - const [debounced, setDebounced] = useState(value); - - useEffect(() => { - const timer = setTimeout(() => setDebounced(value), delay); - return () => clearTimeout(timer); - }, [value, delay]); - - return debounced; -} diff --git a/content-gen/src/app/frontend/src/utils/index.ts b/content-gen/src/app/frontend/src/utils/index.ts index 2541cc3b7..aaefb147b 100644 --- a/content-gen/src/app/frontend/src/utils/index.ts +++ b/content-gen/src/app/frontend/src/utils/index.ts @@ -23,10 +23,6 @@ export { BRIEF_FIELD_LABELS, BRIEF_DISPLAY_ORDER, BRIEF_FIELD_KEYS } from './bri // String utilities export { createNameSwapper, matchesAnyKeyword } from './stringUtils'; -// Production API utilities -export { retryRequest, RequestCache, throttle } from './apiUtils'; -export type { RetryOptions } from './apiUtils'; - // Content error detection export { isContentFilterError, getErrorMessage } from './contentErrors'; diff --git a/content-gen/src/app/frontend/src/utils/sseParser.ts b/content-gen/src/app/frontend/src/utils/sseParser.ts index 7b11201f0..7767c0b5e 100644 --- a/content-gen/src/app/frontend/src/utils/sseParser.ts +++ b/content-gen/src/app/frontend/src/utils/sseParser.ts @@ -40,7 +40,7 @@ export async function* parseSSEStream( try { yield JSON.parse(data) as AgentResponse; } catch { - console.error('Failed to parse SSE data:', data); + // Malformed SSE frame — skip silently } } } From 3216286ef17c58aa1db1237de0189666b127fb79 Mon Sep 17 00:00:00 2001 From: Shubhangi-Microsoft Date: Mon, 2 Mar 2026 17:31:35 +0530 Subject: [PATCH 05/12] refactor(frontend): GenerationStatus enum, delete dead code, dedup validation, hooks barrel --- content-gen/src/app/frontend/src/App.tsx | 4 +- .../app/frontend/src/components/ChatInput.tsx | 4 +- .../app/frontend/src/components/ChatPanel.tsx | 10 +- .../src/components/ConversationItem.tsx | 55 +++-- .../src/components/InlineContentPreview.tsx | 3 +- .../frontend/src/components/MessageBubble.tsx | 2 +- .../src/components/SuggestionCard.tsx | 1 - .../frontend/src/components/WelcomeCard.tsx | 12 +- .../src/app/frontend/src/hooks/index.ts | 10 + .../frontend/src/hooks/useChatOrchestrator.ts | 44 ++-- .../src/hooks/useContentGeneration.ts | 16 +- .../src/app/frontend/src/store/appSlice.ts | 61 +++++- .../src/app/frontend/src/store/index.ts | 5 +- .../src/app/frontend/src/store/selectors.ts | 1 + .../src/app/frontend/src/types/index.ts | 20 -- .../src/app/frontend/src/utils/apiUtils.ts | 202 ------------------ 16 files changed, 143 insertions(+), 307 deletions(-) create mode 100644 content-gen/src/app/frontend/src/hooks/index.ts delete mode 100644 content-gen/src/app/frontend/src/utils/apiUtils.ts diff --git a/content-gen/src/app/frontend/src/App.tsx b/content-gen/src/app/frontend/src/App.tsx index 4bc73f5fc..28d241c28 100644 --- a/content-gen/src/app/frontend/src/App.tsx +++ b/content-gen/src/app/frontend/src/App.tsx @@ -11,9 +11,7 @@ import { selectUserName, selectShowChatHistory, } from './store'; -import { useChatOrchestrator } from './hooks/useChatOrchestrator'; -import { useContentGeneration } from './hooks/useContentGeneration'; -import { useConversationActions } from './hooks/useConversationActions'; +import { useChatOrchestrator, useContentGeneration, useConversationActions } from './hooks'; function App() { diff --git a/content-gen/src/app/frontend/src/components/ChatInput.tsx b/content-gen/src/app/frontend/src/components/ChatInput.tsx index a27f747fd..778de0e29 100644 --- a/content-gen/src/app/frontend/src/components/ChatInput.tsx +++ b/content-gen/src/app/frontend/src/components/ChatInput.tsx @@ -37,10 +37,10 @@ export const ChatInput = memo(function ChatInput({ // Support both controlled & uncontrolled modes const inputValue = controlledValue ?? internalValue; - const setInputValue = (v: string) => { + const setInputValue = useCallback((v: string) => { controlledOnChange?.(v); if (controlledValue === undefined) setInternalValue(v); - }; + }, [controlledOnChange, controlledValue]); const handleSubmit = useCallback((e: React.FormEvent) => { e.preventDefault(); diff --git a/content-gen/src/app/frontend/src/components/ChatPanel.tsx b/content-gen/src/app/frontend/src/components/ChatPanel.tsx index d6ac60896..c94792432 100644 --- a/content-gen/src/app/frontend/src/components/ChatPanel.tsx +++ b/content-gen/src/app/frontend/src/components/ChatPanel.tsx @@ -9,12 +9,12 @@ import { WelcomeCard } from './WelcomeCard'; import { MessageBubble } from './MessageBubble'; import { TypingIndicator } from './TypingIndicator'; import { ChatInput } from './ChatInput'; -import { useAutoScroll } from '../hooks/useAutoScroll'; +import { useAutoScroll } from '../hooks'; import { useAppSelector, selectMessages, selectIsLoading, - selectGenerationStatus, + selectGenerationStatusLabel, selectPendingBrief, selectConfirmedBrief, selectGeneratedContent, @@ -46,7 +46,7 @@ export const ChatPanel = memo(function ChatPanel({ }: ChatPanelProps) { const messages = useAppSelector(selectMessages); const isLoading = useAppSelector(selectIsLoading); - const generationStatus = useAppSelector(selectGenerationStatus); + const generationStatus = useAppSelector(selectGenerationStatusLabel); const pendingBrief = useAppSelector(selectPendingBrief); const confirmedBrief = useAppSelector(selectConfirmedBrief); const generatedContent = useAppSelector(selectGeneratedContent); @@ -84,8 +84,6 @@ export const ChatPanel = memo(function ChatPanel({ setInputValue(prompt); }, []); - const isInputDisabled = useMemo(() => isLoading, [isLoading]); - return (
    {/* Messages Area */} @@ -171,7 +169,7 @@ export const ChatPanel = memo(function ChatPanel({ diff --git a/content-gen/src/app/frontend/src/components/ConversationItem.tsx b/content-gen/src/app/frontend/src/components/ConversationItem.tsx index 16b5ebc5e..c40d89d1d 100644 --- a/content-gen/src/app/frontend/src/components/ConversationItem.tsx +++ b/content-gen/src/app/frontend/src/components/ConversationItem.tsx @@ -23,6 +23,23 @@ import { } from '@fluentui/react-icons'; import type { ConversationSummary } from '../store'; +/* ------------------------------------------------------------------ */ +/* Validation constants & helper */ +/* ------------------------------------------------------------------ */ + +const NAME_MIN_LENGTH = 5; +const NAME_MAX_LENGTH = 50; + +/** Returns an error message, or `''` when the value is valid. */ +function validateConversationName(value: string): string { + const trimmed = value.trim(); + if (trimmed === '') return 'Conversation name cannot be empty or contain only spaces'; + if (trimmed.length < NAME_MIN_LENGTH) return `Conversation name must be at least ${NAME_MIN_LENGTH} characters`; + if (value.length > NAME_MAX_LENGTH) return `Conversation name cannot exceed ${NAME_MAX_LENGTH} characters`; + if (!/[a-zA-Z0-9]/.test(trimmed)) return 'Conversation name must contain at least one letter or number'; + return ''; +} + export interface ConversationItemProps { conversation: ConversationSummary; isActive: boolean; @@ -61,21 +78,13 @@ export const ConversationItem = memo(function ConversationItem({ }, [conversation.title]); const handleRenameConfirm = useCallback(async () => { - const trimmedValue = renameValue.trim(); - - if (trimmedValue.length < 5) { - setRenameError('Conversation name must be at least 5 characters'); - return; - } - if (trimmedValue.length > 50) { - setRenameError('Conversation name cannot exceed 50 characters'); - return; - } - if (!/[a-zA-Z0-9]/.test(trimmedValue)) { - setRenameError('Conversation name must contain at least one letter or number'); + const error = validateConversationName(renameValue); + if (error) { + setRenameError(error); return; } + const trimmedValue = renameValue.trim(); if (trimmedValue === conversation.title) { setIsRenameDialogOpen(false); setRenameError(''); @@ -194,21 +203,11 @@ export const ConversationItem = memo(function ConversationItem({ { const newValue = e.target.value; setRenameValue(newValue); - if (newValue.trim() === '') { - setRenameError('Conversation name cannot be empty or contain only spaces'); - } else if (newValue.trim().length < 5) { - setRenameError('Conversation name must be at least 5 characters'); - } else if (!/[a-zA-Z0-9]/.test(newValue)) { - setRenameError('Conversation name must contain at least one letter or number'); - } else if (newValue.length > 50) { - setRenameError('Conversation name cannot exceed 50 characters'); - } else { - setRenameError(''); - } + setRenameError(validateConversationName(newValue)); }} onKeyDown={(e) => { if (e.key === 'Enter' && renameValue.trim()) { @@ -228,7 +227,7 @@ export const ConversationItem = memo(function ConversationItem({ display: 'block', }} > - Maximum 50 characters ({renameValue.length}/50) + Maximum {NAME_MAX_LENGTH} characters ({renameValue.length}/{NAME_MAX_LENGTH}) {renameError && ( 50 - } + disabled={!!validateConversationName(renameValue)} > Rename diff --git a/content-gen/src/app/frontend/src/components/InlineContentPreview.tsx b/content-gen/src/app/frontend/src/components/InlineContentPreview.tsx index 41144f181..dc0f27491 100644 --- a/content-gen/src/app/frontend/src/components/InlineContentPreview.tsx +++ b/content-gen/src/app/frontend/src/components/InlineContentPreview.tsx @@ -6,9 +6,8 @@ import { } from '@fluentui/react-components'; import { ShieldError20Regular } from '@fluentui/react-icons'; import type { GeneratedContent, Product } from '../types'; -import { useWindowSize } from '../hooks/useWindowSize'; +import { useWindowSize, useCopyToClipboard } from '../hooks'; import { isContentFilterError, getErrorMessage, downloadImage } from '../utils'; -import { useCopyToClipboard } from '../hooks/useCopyToClipboard'; import { ImagePreviewCard } from './ImagePreviewCard'; import { ComplianceSection } from './ComplianceSection'; diff --git a/content-gen/src/app/frontend/src/components/MessageBubble.tsx b/content-gen/src/app/frontend/src/components/MessageBubble.tsx index 58d3cb3c2..95431a7d9 100644 --- a/content-gen/src/app/frontend/src/components/MessageBubble.tsx +++ b/content-gen/src/app/frontend/src/components/MessageBubble.tsx @@ -9,7 +9,7 @@ import { import { Copy20Regular } from '@fluentui/react-icons'; import ReactMarkdown from 'react-markdown'; import type { ChatMessage } from '../types'; -import { useCopyToClipboard } from '../hooks/useCopyToClipboard'; +import { useCopyToClipboard } from '../hooks'; export interface MessageBubbleProps { message: ChatMessage; diff --git a/content-gen/src/app/frontend/src/components/SuggestionCard.tsx b/content-gen/src/app/frontend/src/components/SuggestionCard.tsx index d557936a5..55e8e1570 100644 --- a/content-gen/src/app/frontend/src/components/SuggestionCard.tsx +++ b/content-gen/src/app/frontend/src/components/SuggestionCard.tsx @@ -7,7 +7,6 @@ import { export interface SuggestionCardProps { title: string; - prompt: string; icon: string; isSelected?: boolean; onClick: () => void; diff --git a/content-gen/src/app/frontend/src/components/WelcomeCard.tsx b/content-gen/src/app/frontend/src/components/WelcomeCard.tsx index cf89dca2c..aa1388324 100644 --- a/content-gen/src/app/frontend/src/components/WelcomeCard.tsx +++ b/content-gen/src/app/frontend/src/components/WelcomeCard.tsx @@ -1,4 +1,4 @@ -import { memo, useMemo, useCallback } from 'react'; +import { memo, useMemo } from 'react'; import { Text, tokens, @@ -7,13 +7,13 @@ import { SuggestionCard } from './SuggestionCard'; import FirstPromptIcon from '../styles/images/firstprompt.png'; import SecondPromptIcon from '../styles/images/secondprompt.png'; -interface SuggestionCard { +interface SuggestionData { title: string; prompt: string; icon: string; } -const suggestions: SuggestionCard[] = [ +const suggestions: SuggestionData[] = [ { title: "I need to create a social media post about paint products for home remodels. The campaign is titled \"Brighten Your Springtime\" and the audience is new homeowners. I need marketing copy plus an image. The image should be an informal living room with tasteful furnishings.", prompt: "I need to create a social media post about paint products for home remodels. The campaign is titled \"Brighten Your Springtime\" and the audience is new homeowners. I need marketing copy plus an image. The image should be an informal living room with tasteful furnishings.", @@ -37,9 +37,6 @@ export const WelcomeCard = memo(function WelcomeCard({ onSuggestionClick, curren [currentInput], ); - const handleSuggestionClick = useCallback((prompt: string) => { - onSuggestionClick(prompt); - }, [onSuggestionClick]); return (
    handleSuggestionClick(suggestion.prompt)} + onClick={() => onSuggestionClick(suggestion.prompt)} /> ); })} diff --git a/content-gen/src/app/frontend/src/hooks/index.ts b/content-gen/src/app/frontend/src/hooks/index.ts new file mode 100644 index 000000000..f23f96430 --- /dev/null +++ b/content-gen/src/app/frontend/src/hooks/index.ts @@ -0,0 +1,10 @@ +/** + * Barrel export for all custom hooks. + * Import hooks from '../hooks' instead of individual files. + */ +export { useAutoScroll } from './useAutoScroll'; +export { useChatOrchestrator } from './useChatOrchestrator'; +export { useContentGeneration } from './useContentGeneration'; +export { useConversationActions } from './useConversationActions'; +export { useCopyToClipboard } from './useCopyToClipboard'; +export { useWindowSize } from './useWindowSize'; diff --git a/content-gen/src/app/frontend/src/hooks/useChatOrchestrator.ts b/content-gen/src/app/frontend/src/hooks/useChatOrchestrator.ts index 6089d088c..f4edc088c 100644 --- a/content-gen/src/app/frontend/src/hooks/useChatOrchestrator.ts +++ b/content-gen/src/app/frontend/src/hooks/useChatOrchestrator.ts @@ -16,6 +16,7 @@ import { addMessage, setIsLoading, setGenerationStatus, + GenerationStatus, setPendingBrief, setConfirmedBrief, setAwaitingClarification, @@ -127,7 +128,7 @@ export function useChatOrchestrator( // --- 1-a Refine the brief -------------------------------- const refinementPrompt = `Current creative brief:\n${JSON.stringify(pendingBrief, null, 2)}\n\nUser requested change: ${content}\n\nPlease update the brief accordingly and return the complete updated brief.`; - dispatch(setGenerationStatus('Updating creative brief...')); + dispatch(setGenerationStatus(GenerationStatus.UPDATING_BRIEF)); const parsed = await parseBrief( refinementPrompt, conversationId, @@ -145,7 +146,7 @@ export function useChatOrchestrator( if (parsed.requires_clarification && parsed.clarifying_questions) { dispatch(setAwaitingClarification(true)); - dispatch(setGenerationStatus('')); + dispatch(setGenerationStatus(GenerationStatus.IDLE)); dispatch( addMessage( createMessage('assistant', parsed.clarifying_questions, 'PlanningAgent'), @@ -153,7 +154,7 @@ export function useChatOrchestrator( ); } else { dispatch(setAwaitingClarification(false)); - dispatch(setGenerationStatus('')); + dispatch(setGenerationStatus(GenerationStatus.IDLE)); dispatch( addMessage( createMessage( @@ -166,19 +167,19 @@ export function useChatOrchestrator( } } else { // --- 1-b General question while brief is pending ----------- - dispatch(setGenerationStatus('Processing your question...')); + dispatch(setGenerationStatus(GenerationStatus.PROCESSING_QUESTION)); await consumeStreamChat( streamChat(content, conversationId, userId, signal), dispatch, ); - dispatch(setGenerationStatus('')); + dispatch(setGenerationStatus(GenerationStatus.IDLE)); } /* ---------------------------------------------------------- */ /* Branch 2 – brief confirmed, in product selection */ /* ---------------------------------------------------------- */ } else if (confirmedBrief && !generatedContent) { - dispatch(setGenerationStatus('Finding products...')); + dispatch(setGenerationStatus(GenerationStatus.FINDING_PRODUCTS)); const result = await selectProducts( content, selectedProducts, @@ -187,7 +188,7 @@ export function useChatOrchestrator( signal, ); dispatch(setSelectedProducts(result.products || [])); - dispatch(setGenerationStatus('')); + dispatch(setGenerationStatus(GenerationStatus.IDLE)); dispatch( addMessage( createMessage( @@ -215,7 +216,7 @@ export function useChatOrchestrator( // --- 3-a Regenerate image -------------------------------- const { streamRegenerateImage } = await import('../api'); dispatch( - setGenerationStatus('Regenerating image with your changes...'), + setGenerationStatus(GenerationStatus.REGENERATING_IMAGE), ); let responseData: GeneratedContent | null = null; @@ -243,9 +244,10 @@ export function useChatOrchestrator( )) { if (response.type === 'heartbeat') { dispatch( - setGenerationStatus( - response.message || 'Regenerating image...', - ), + setGenerationStatus({ + status: GenerationStatus.POLLING, + label: response.message || 'Regenerating image...', + }), ); } else if ( response.type === 'agent_response' && @@ -320,18 +322,18 @@ export function useChatOrchestrator( } } - dispatch(setGenerationStatus('')); + dispatch(setGenerationStatus(GenerationStatus.IDLE)); dispatch( addMessage(createMessage('assistant', messageContent, 'ImageAgent')), ); } else { // --- 3-b General question after content generation -------- - dispatch(setGenerationStatus('Processing your request...')); + dispatch(setGenerationStatus(GenerationStatus.PROCESSING_REQUEST)); await consumeStreamChat( streamChat(content, conversationId, userId, signal), dispatch, ); - dispatch(setGenerationStatus('')); + dispatch(setGenerationStatus(GenerationStatus.IDLE)); } /* ---------------------------------------------------------- */ @@ -346,7 +348,7 @@ export function useChatOrchestrator( if (isBriefLike && !confirmedBrief) { // --- 4-a Parse as creative brief -------------------------- - dispatch(setGenerationStatus('Analyzing creative brief...')); + dispatch(setGenerationStatus(GenerationStatus.ANALYZING_BRIEF)); const parsed = await parseBrief( content, conversationId, @@ -359,7 +361,7 @@ export function useChatOrchestrator( } if (parsed.rai_blocked) { - dispatch(setGenerationStatus('')); + dispatch(setGenerationStatus(GenerationStatus.IDLE)); dispatch( addMessage( createMessage('assistant', parsed.message, 'ContentSafety'), @@ -373,7 +375,7 @@ export function useChatOrchestrator( dispatch(setPendingBrief(parsed.brief)); } dispatch(setAwaitingClarification(true)); - dispatch(setGenerationStatus('')); + dispatch(setGenerationStatus(GenerationStatus.IDLE)); dispatch( addMessage( createMessage( @@ -388,7 +390,7 @@ export function useChatOrchestrator( dispatch(setPendingBrief(parsed.brief)); } dispatch(setAwaitingClarification(false)); - dispatch(setGenerationStatus('')); + dispatch(setGenerationStatus(GenerationStatus.IDLE)); dispatch( addMessage( createMessage( @@ -401,12 +403,12 @@ export function useChatOrchestrator( } } else { // --- 4-b Generic chat ----------------------------------- - dispatch(setGenerationStatus('Processing your request...')); + dispatch(setGenerationStatus(GenerationStatus.PROCESSING_REQUEST)); await consumeStreamChat( streamChat(content, conversationId, userId, signal), dispatch, ); - dispatch(setGenerationStatus('')); + dispatch(setGenerationStatus(GenerationStatus.IDLE)); } } } catch (error) { @@ -423,7 +425,7 @@ export function useChatOrchestrator( } } finally { dispatch(setIsLoading(false)); - dispatch(setGenerationStatus('')); + dispatch(setGenerationStatus(GenerationStatus.IDLE)); abortControllerRef.current = null; dispatch(incrementHistoryRefresh()); } diff --git a/content-gen/src/app/frontend/src/hooks/useContentGeneration.ts b/content-gen/src/app/frontend/src/hooks/useContentGeneration.ts index 423d8a561..895c20025 100644 --- a/content-gen/src/app/frontend/src/hooks/useContentGeneration.ts +++ b/content-gen/src/app/frontend/src/hooks/useContentGeneration.ts @@ -11,6 +11,7 @@ import { addMessage, setIsLoading, setGenerationStatus, + GenerationStatus, setGeneratedContent, } from '../store'; @@ -35,7 +36,7 @@ export function useContentGeneration( if (!confirmedBrief) return; dispatch(setIsLoading(true)); - dispatch(setGenerationStatus('Starting content generation...')); + dispatch(setGenerationStatus(GenerationStatus.STARTING_GENERATION)); abortControllerRef.current = new AbortController(); const signal = abortControllerRef.current.signal; @@ -55,22 +56,25 @@ export function useContentGeneration( if (response.type === 'heartbeat') { const statusMessage = response.content || 'Generating content...'; const elapsed = (response as { elapsed?: number }).elapsed || 0; - dispatch(setGenerationStatus(`${statusMessage} (${elapsed}s)`)); + dispatch(setGenerationStatus({ + status: GenerationStatus.POLLING, + label: `${statusMessage} (${elapsed}s)`, + })); continue; } if (response.is_final && response.type !== 'error') { - dispatch(setGenerationStatus('Processing results...')); + dispatch(setGenerationStatus(GenerationStatus.PROCESSING_RESULTS)); try { const rawContent = JSON.parse(response.content); const genContent = buildGeneratedContent(rawContent); dispatch(setGeneratedContent(genContent)); - dispatch(setGenerationStatus('')); + dispatch(setGenerationStatus(GenerationStatus.IDLE)); } catch { // Content parse failure — non-critical, generation result may be malformed } } else if (response.type === 'error') { - dispatch(setGenerationStatus('')); + dispatch(setGenerationStatus(GenerationStatus.IDLE)); dispatch(addMessage(createErrorMessage( `Error generating content: ${response.content}`, ))); @@ -86,7 +90,7 @@ export function useContentGeneration( } } finally { dispatch(setIsLoading(false)); - dispatch(setGenerationStatus('')); + dispatch(setGenerationStatus(GenerationStatus.IDLE)); abortControllerRef.current = null; } }, [ diff --git a/content-gen/src/app/frontend/src/store/appSlice.ts b/content-gen/src/app/frontend/src/store/appSlice.ts index 59876ec03..265522ebd 100644 --- a/content-gen/src/app/frontend/src/store/appSlice.ts +++ b/content-gen/src/app/frontend/src/store/appSlice.ts @@ -5,6 +5,46 @@ */ import { createSlice, createAsyncThunk, type PayloadAction } from '@reduxjs/toolkit'; +/* ------------------------------------------------------------------ */ +/* Generation-status enum */ +/* ------------------------------------------------------------------ */ + +/** + * Finite set of generation-status values. Components that read + * `generationStatus` can compare against these constants instead of + * relying on magic strings. + * + * `IDLE` means "no status to display". Every other member maps to a + * user-facing label via {@link GENERATION_STATUS_LABELS}. + */ +export enum GenerationStatus { + IDLE = '', + UPDATING_BRIEF = 'UPDATING_BRIEF', + PROCESSING_QUESTION = 'PROCESSING_QUESTION', + FINDING_PRODUCTS = 'FINDING_PRODUCTS', + REGENERATING_IMAGE = 'REGENERATING_IMAGE', + PROCESSING_REQUEST = 'PROCESSING_REQUEST', + ANALYZING_BRIEF = 'ANALYZING_BRIEF', + STARTING_GENERATION = 'STARTING_GENERATION', + PROCESSING_RESULTS = 'PROCESSING_RESULTS', + /** Used for heartbeat polling where the label is dynamic. */ + POLLING = 'POLLING', +} + +/** Display strings shown in the UI for each status. */ +export const GENERATION_STATUS_LABELS: Record = { + [GenerationStatus.IDLE]: '', + [GenerationStatus.UPDATING_BRIEF]: 'Updating creative brief...', + [GenerationStatus.PROCESSING_QUESTION]: 'Processing your question...', + [GenerationStatus.FINDING_PRODUCTS]: 'Finding products...', + [GenerationStatus.REGENERATING_IMAGE]: 'Regenerating image with your changes...', + [GenerationStatus.PROCESSING_REQUEST]: 'Processing your request...', + [GenerationStatus.ANALYZING_BRIEF]: 'Analyzing creative brief...', + [GenerationStatus.STARTING_GENERATION]: 'Starting content generation...', + [GenerationStatus.PROCESSING_RESULTS]: 'Processing results...', + [GenerationStatus.POLLING]: 'Generating content...', +}; + /* ------------------------------------------------------------------ */ /* Async Thunks */ /* ------------------------------------------------------------------ */ @@ -47,7 +87,10 @@ interface AppState { isLoading: boolean; imageGenerationEnabled: boolean; showChatHistory: boolean; - generationStatus: string; + /** Current generation status enum value. */ + generationStatus: GenerationStatus; + /** Dynamic label override (used with GenerationStatus.POLLING). */ + generationStatusLabel: string; } const initialState: AppState = { @@ -56,7 +99,8 @@ const initialState: AppState = { isLoading: false, imageGenerationEnabled: true, showChatHistory: true, - generationStatus: '', + generationStatus: GenerationStatus.IDLE, + generationStatusLabel: '', }; const appSlice = createSlice({ @@ -66,8 +110,17 @@ const appSlice = createSlice({ setIsLoading(state, action: PayloadAction) { state.isLoading = action.payload; }, - setGenerationStatus(state, action: PayloadAction) { - state.generationStatus = action.payload; + setGenerationStatus( + state, + action: PayloadAction, + ) { + if (typeof action.payload === 'string') { + state.generationStatus = action.payload; + state.generationStatusLabel = GENERATION_STATUS_LABELS[action.payload]; + } else { + state.generationStatus = action.payload.status; + state.generationStatusLabel = action.payload.label; + } }, toggleChatHistory(state) { state.showChatHistory = !state.showChatHistory; diff --git a/content-gen/src/app/frontend/src/store/index.ts b/content-gen/src/app/frontend/src/store/index.ts index 6e85accf0..5e28f210c 100644 --- a/content-gen/src/app/frontend/src/store/index.ts +++ b/content-gen/src/app/frontend/src/store/index.ts @@ -6,7 +6,7 @@ export { store } from './store'; export type { RootState, AppDispatch } from './store'; export { useAppDispatch, useAppSelector } from './hooks'; -// App slice – actions & thunks +// App slice – actions, thunks & enums export { fetchAppConfig, fetchUserInfo, @@ -14,6 +14,8 @@ export { setGenerationStatus, toggleChatHistory, setShowChatHistory, + GenerationStatus, + GENERATION_STATUS_LABELS, } from './appSlice'; // Chat slice – actions @@ -54,6 +56,7 @@ export { selectUserName, selectIsLoading, selectGenerationStatus, + selectGenerationStatusLabel, selectImageGenerationEnabled, selectShowChatHistory, selectConversationId, diff --git a/content-gen/src/app/frontend/src/store/selectors.ts b/content-gen/src/app/frontend/src/store/selectors.ts index 8fb2329c4..fe122ee2d 100644 --- a/content-gen/src/app/frontend/src/store/selectors.ts +++ b/content-gen/src/app/frontend/src/store/selectors.ts @@ -10,6 +10,7 @@ export const selectUserId = (state: RootState) => state.app.userId; export const selectUserName = (state: RootState) => state.app.userName; export const selectIsLoading = (state: RootState) => state.app.isLoading; export const selectGenerationStatus = (state: RootState) => state.app.generationStatus; +export const selectGenerationStatusLabel = (state: RootState) => state.app.generationStatusLabel; export const selectImageGenerationEnabled = (state: RootState) => state.app.imageGenerationEnabled; export const selectShowChatHistory = (state: RootState) => state.app.showChatHistory; diff --git a/content-gen/src/app/frontend/src/types/index.ts b/content-gen/src/app/frontend/src/types/index.ts index 91c40c3a3..0a29d07df 100644 --- a/content-gen/src/app/frontend/src/types/index.ts +++ b/content-gen/src/app/frontend/src/types/index.ts @@ -47,14 +47,6 @@ export interface ChatMessage { violations?: ComplianceViolation[]; } -export interface Conversation { - id: string; - user_id: string; - messages: ChatMessage[]; - brief?: CreativeBrief; - updated_at: string; -} - export interface AgentResponse { type: 'agent_response' | 'error' | 'status' | 'heartbeat'; agent?: string; @@ -72,18 +64,6 @@ export interface AgentResponse { }; } -export interface BrandGuidelines { - tone: string; - voice: string; - primary_color: string; - secondary_color: string; - prohibited_words: string[]; - required_disclosures: string[]; - max_headline_length: number; - max_body_length: number; - require_cta: boolean; -} - export interface ParsedBriefResponse { brief?: CreativeBrief; requires_confirmation: boolean; diff --git a/content-gen/src/app/frontend/src/utils/apiUtils.ts b/content-gen/src/app/frontend/src/utils/apiUtils.ts deleted file mode 100644 index 1f60ff1c9..000000000 --- a/content-gen/src/app/frontend/src/utils/apiUtils.ts +++ /dev/null @@ -1,202 +0,0 @@ -/** - * Production-grade API utilities — retry with exponential backoff, - * request deduplication cache, and throttle. - * - * These harden the HTTP layer for unreliable networks and prevent - * accidental duplicate requests (e.g. double-clicks, React strict mode). - */ - -/* ------------------------------------------------------------------ */ -/* retryRequest — exponential backoff wrapper */ -/* ------------------------------------------------------------------ */ - -export interface RetryOptions { - /** Maximum number of attempts (including the first). Default: 3. */ - maxAttempts?: number; - /** Initial delay in ms before the first retry. Default: 1 000. */ - initialDelayMs?: number; - /** Maximum delay cap in ms. Default: 30 000. */ - maxDelayMs?: number; - /** Multiplier applied to the delay after each failure. Default: 2. */ - backoffFactor?: number; - /** Optional predicate — return `true` if the request should be retried. */ - shouldRetry?: (error: unknown, attempt: number) => boolean; - /** Optional `AbortSignal` to cancel outstanding retries. */ - signal?: AbortSignal; -} - -/** - * Execute `fn` with automatic retries and exponential backoff. - * - * ```ts - * const data = await retryRequest(() => httpClient.get('/health'), { - * maxAttempts: 4, - * initialDelayMs: 500, - * }); - * ``` - */ -export async function retryRequest( - fn: () => Promise, - opts: RetryOptions = {}, -): Promise { - const { - maxAttempts = 3, - initialDelayMs = 1_000, - maxDelayMs = 30_000, - backoffFactor = 2, - shouldRetry = defaultShouldRetry, - signal, - } = opts; - - let delay = initialDelayMs; - - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - try { - return await fn(); - } catch (error) { - if (attempt >= maxAttempts || !shouldRetry(error, attempt)) throw error; - if (signal?.aborted) throw new DOMException('Retry aborted', 'AbortError'); - - await sleep(delay, signal); - delay = Math.min(delay * backoffFactor, maxDelayMs); - } - } - - // Unreachable — the loop always returns or throws. - throw new Error('retryRequest: exhausted all attempts'); -} - -/* ------------------------------------------------------------------ */ -/* RequestCache — deduplication / in-flight coalescing */ -/* ------------------------------------------------------------------ */ - -/** - * Simple in-memory cache that de-duplicates concurrent identical requests. - * - * If a request with the same `key` is already in flight, all callers - * share the same promise. Once settled the entry is automatically - * evicted (or kept for `ttlMs` when configured). - * - * ```ts - * const cache = new RequestCache(); - * const data = await cache.dedupe('config', () => httpClient.get('/config')); - * ``` - */ -export class RequestCache { - private inflight = new Map>(); - private store = new Map(); - private ttlMs: number; - - constructor(ttlMs = 0) { - this.ttlMs = ttlMs; - } - - async dedupe(key: string, fn: () => Promise): Promise { - // Return from TTL cache if still fresh - const cached = this.store.get(key); - if (cached && cached.expiresAt > Date.now()) { - return cached.value as T; - } - - // De-duplicate in-flight requests - const existing = this.inflight.get(key); - if (existing) return existing as Promise; - - const promise = fn().finally(() => { - this.inflight.delete(key); - }); - - this.inflight.set(key, promise); - - if (this.ttlMs > 0) { - promise.then((value) => { - this.store.set(key, { value, expiresAt: Date.now() + this.ttlMs }); - }).catch(() => { /* don't cache failures */ }); - } - - return promise as Promise; - } - - /** Manually evict a cache entry. */ - invalidate(key: string): void { - this.store.delete(key); - this.inflight.delete(key); - } - - /** Evict all cache entries. */ - clear(): void { - this.store.clear(); - this.inflight.clear(); - } -} - -/* ------------------------------------------------------------------ */ -/* throttle — limits invocation frequency */ -/* ------------------------------------------------------------------ */ - -/** - * Classic trailing-edge throttle. - * - * Ensures `fn` is called at most once every `limitMs` milliseconds. - * The first invocation fires immediately; subsequent calls within the - * window are silently dropped and the **last** one fires when the - * window closes. - */ -export function throttle void>( - fn: T, - limitMs: number, -): (...args: Parameters) => void { - let timer: ReturnType | null = null; - let lastArgs: Parameters | null = null; - let lastCallTime = 0; - - return (...args: Parameters) => { - const now = Date.now(); - const remaining = limitMs - (now - lastCallTime); - - if (remaining <= 0) { - // Window has passed — fire immediately - lastCallTime = now; - fn(...args); - } else { - // Inside the window — schedule a trailing call - lastArgs = args; - if (!timer) { - timer = setTimeout(() => { - lastCallTime = Date.now(); - timer = null; - if (lastArgs) { - fn(...lastArgs); - lastArgs = null; - } - }, remaining); - } - } - }; -} - -/* ------------------------------------------------------------------ */ -/* Internal helpers (not exported) */ -/* ------------------------------------------------------------------ */ - -/** Default retry predicate — retry on network errors & 5xx, not on 4xx. */ -function defaultShouldRetry(error: unknown, _attempt: number): boolean { - if (error instanceof DOMException && error.name === 'AbortError') return false; - if (error instanceof Response) return error.status >= 500; - return true; // network errors, timeouts, etc. -} - -/** Abort-aware sleep. */ -function sleep(ms: number, signal?: AbortSignal): Promise { - return new Promise((resolve, reject) => { - if (signal?.aborted) { - reject(new DOMException('Sleep aborted', 'AbortError')); - return; - } - const timer = setTimeout(resolve, ms); - signal?.addEventListener('abort', () => { - clearTimeout(timer); - reject(new DOMException('Sleep aborted', 'AbortError')); - }, { once: true }); - }); -} From bfce2efbe4719f849bba36a01291d18c07aef4e4 Mon Sep 17 00:00:00 2001 From: Shubhangi-Microsoft Date: Mon, 2 Mar 2026 18:21:59 +0530 Subject: [PATCH 06/12] refactor: clean up unused barrel exports and standardize httpClient imports --- content-gen/src/app/frontend/src/api/index.ts | 1 + .../src/app/frontend/src/hooks/useConversationActions.ts | 2 +- content-gen/src/app/frontend/src/store/chatHistorySlice.ts | 2 +- content-gen/src/app/frontend/src/store/index.ts | 2 -- content-gen/src/app/frontend/src/utils/index.ts | 1 - 5 files changed, 3 insertions(+), 5 deletions(-) diff --git a/content-gen/src/app/frontend/src/api/index.ts b/content-gen/src/app/frontend/src/api/index.ts index b139f3e29..a7e46207b 100644 --- a/content-gen/src/app/frontend/src/api/index.ts +++ b/content-gen/src/app/frontend/src/api/index.ts @@ -10,6 +10,7 @@ import type { AppConfig, } from '../types'; import httpClient from './httpClient'; +export { default as httpClient } from './httpClient'; import { parseSSEStream, getGenerationStage } from '../utils'; /** diff --git a/content-gen/src/app/frontend/src/hooks/useConversationActions.ts b/content-gen/src/app/frontend/src/hooks/useConversationActions.ts index 6cd0bfd01..48346600d 100644 --- a/content-gen/src/app/frontend/src/hooks/useConversationActions.ts +++ b/content-gen/src/app/frontend/src/hooks/useConversationActions.ts @@ -2,7 +2,7 @@ import { useCallback } from 'react'; import type { ChatMessage, Product, CreativeBrief } from '../types'; import { createMessage, buildGeneratedContent } from '../utils'; -import httpClient from '../api/httpClient'; +import { httpClient } from '../api'; import { useAppDispatch, useAppSelector, diff --git a/content-gen/src/app/frontend/src/store/chatHistorySlice.ts b/content-gen/src/app/frontend/src/store/chatHistorySlice.ts index 9874d45bc..b97b14e31 100644 --- a/content-gen/src/app/frontend/src/store/chatHistorySlice.ts +++ b/content-gen/src/app/frontend/src/store/chatHistorySlice.ts @@ -4,7 +4,7 @@ * Granular selectors for each piece of history state. */ import { createSlice, createAsyncThunk, type PayloadAction } from '@reduxjs/toolkit'; -import httpClient from '../api/httpClient'; +import { httpClient } from '../api'; export interface ConversationSummary { id: string; diff --git a/content-gen/src/app/frontend/src/store/index.ts b/content-gen/src/app/frontend/src/store/index.ts index 5e28f210c..7bbcd99d6 100644 --- a/content-gen/src/app/frontend/src/store/index.ts +++ b/content-gen/src/app/frontend/src/store/index.ts @@ -15,7 +15,6 @@ export { toggleChatHistory, setShowChatHistory, GenerationStatus, - GENERATION_STATUS_LABELS, } from './appSlice'; // Chat slice – actions @@ -55,7 +54,6 @@ export { selectUserId, selectUserName, selectIsLoading, - selectGenerationStatus, selectGenerationStatusLabel, selectImageGenerationEnabled, selectShowChatHistory, diff --git a/content-gen/src/app/frontend/src/utils/index.ts b/content-gen/src/app/frontend/src/utils/index.ts index aaefb147b..66eded22e 100644 --- a/content-gen/src/app/frontend/src/utils/index.ts +++ b/content-gen/src/app/frontend/src/utils/index.ts @@ -15,7 +15,6 @@ export { parseSSEStream } from './sseParser'; // Generation progress stages export { getGenerationStage } from './generationStages'; -export type { GenerationStage } from './generationStages'; // Brief-field metadata export { BRIEF_FIELD_LABELS, BRIEF_DISPLAY_ORDER, BRIEF_FIELD_KEYS } from './briefFields'; From f336f3bd8c312685d93f3fedce61da08ea2d236f Mon Sep 17 00:00:00 2001 From: Shubhangi-Microsoft Date: Tue, 3 Mar 2026 10:09:10 +0530 Subject: [PATCH 07/12] refactor: extract AI_DISCLAIMER constant, remove dead selector and trivial useMemo --- .../frontend/src/components/BriefReview.tsx | 4 ++-- .../frontend/src/components/ChatHistory.tsx | 5 +---- .../app/frontend/src/components/ChatInput.tsx | 3 ++- .../app/frontend/src/components/ChatPanel.tsx | 22 +++++-------------- .../src/components/ComplianceSection.tsx | 3 ++- .../frontend/src/components/MessageBubble.tsx | 3 ++- .../frontend/src/components/ProductCard.tsx | 4 ++-- .../frontend/src/components/ProductReview.tsx | 3 ++- .../src/app/frontend/src/store/selectors.ts | 1 - .../src/app/frontend/src/utils/index.ts | 3 +++ 10 files changed, 21 insertions(+), 30 deletions(-) diff --git a/content-gen/src/app/frontend/src/components/BriefReview.tsx b/content-gen/src/app/frontend/src/components/BriefReview.tsx index 627db0df9..b4a1ce1de 100644 --- a/content-gen/src/app/frontend/src/components/BriefReview.tsx +++ b/content-gen/src/app/frontend/src/components/BriefReview.tsx @@ -5,7 +5,7 @@ import { tokens, } from '@fluentui/react-components'; import type { CreativeBrief } from '../types'; -import { BRIEF_FIELD_LABELS, BRIEF_DISPLAY_ORDER, BRIEF_FIELD_KEYS } from '../utils'; +import { BRIEF_FIELD_LABELS, BRIEF_DISPLAY_ORDER, BRIEF_FIELD_KEYS, AI_DISCLAIMER } from '../utils'; interface BriefReviewProps { brief: CreativeBrief; @@ -154,7 +154,7 @@ export const BriefReview = memo(function BriefReview({ paddingTop: '8px', }}> - AI-generated content may be incorrect + {AI_DISCLAIMER}
    diff --git a/content-gen/src/app/frontend/src/components/ChatHistory.tsx b/content-gen/src/app/frontend/src/components/ChatHistory.tsx index 8faa1f24e..c393fdec9 100644 --- a/content-gen/src/app/frontend/src/components/ChatHistory.tsx +++ b/content-gen/src/app/frontend/src/components/ChatHistory.tsx @@ -142,10 +142,7 @@ export const ChatHistory = memo(function ChatHistory({ () => showAll ? displayConversations : displayConversations.slice(0, INITIAL_COUNT), [showAll, displayConversations], ); - const hasMore = useMemo( - () => displayConversations.length > INITIAL_COUNT, - [displayConversations.length], - ); + const hasMore = displayConversations.length > INITIAL_COUNT; const handleRefreshConversations = useCallback(() => { dispatch(fetchConversations()); diff --git a/content-gen/src/app/frontend/src/components/ChatInput.tsx b/content-gen/src/app/frontend/src/components/ChatInput.tsx index 778de0e29..5a3efb41f 100644 --- a/content-gen/src/app/frontend/src/components/ChatInput.tsx +++ b/content-gen/src/app/frontend/src/components/ChatInput.tsx @@ -5,6 +5,7 @@ import { Tooltip, tokens, } from '@fluentui/react-components'; +import { AI_DISCLAIMER } from '../utils'; import { Send20Regular, Add20Regular, @@ -140,7 +141,7 @@ export const ChatInput = memo(function ChatInput({ fontSize: '12px', }} > - AI generated content may be incorrect + {AI_DISCLAIMER}
    ); diff --git a/content-gen/src/app/frontend/src/components/ChatPanel.tsx b/content-gen/src/app/frontend/src/components/ChatPanel.tsx index c94792432..a10709cae 100644 --- a/content-gen/src/app/frontend/src/components/ChatPanel.tsx +++ b/content-gen/src/app/frontend/src/components/ChatPanel.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo, useCallback, memo } from 'react'; +import { useState, useCallback, memo } from 'react'; import type { Product } from '../types'; import { BriefReview } from './BriefReview'; import { ConfirmedBriefView } from './ConfirmedBriefView'; @@ -62,22 +62,10 @@ export const ChatPanel = memo(function ChatPanel({ ]); // Determine if we should show inline components - const showBriefReview = useMemo( - () => !!(pendingBrief && onBriefConfirm && onBriefCancel), - [pendingBrief, onBriefConfirm, onBriefCancel], - ); - const showProductReview = useMemo( - () => !!(confirmedBrief && !generatedContent && onGenerateContent), - [confirmedBrief, generatedContent, onGenerateContent], - ); - const showContentPreview = useMemo( - () => !!(generatedContent && onRegenerateContent), - [generatedContent, onRegenerateContent], - ); - const showWelcome = useMemo( - () => messages.length === 0 && !showBriefReview && !showProductReview && !showContentPreview, - [messages.length, showBriefReview, showProductReview, showContentPreview], - ); + const showBriefReview = !!(pendingBrief && onBriefConfirm && onBriefCancel); + const showProductReview = !!(confirmedBrief && !generatedContent && onGenerateContent); + const showContentPreview = !!(generatedContent && onRegenerateContent); + const showWelcome = messages.length === 0 && !showBriefReview && !showProductReview && !showContentPreview; // Handle suggestion click from welcome card const handleSuggestionClick = useCallback((prompt: string) => { diff --git a/content-gen/src/app/frontend/src/components/ComplianceSection.tsx b/content-gen/src/app/frontend/src/components/ComplianceSection.tsx index f593a9f4d..216a11f60 100644 --- a/content-gen/src/app/frontend/src/components/ComplianceSection.tsx +++ b/content-gen/src/app/frontend/src/components/ComplianceSection.tsx @@ -1,4 +1,5 @@ import { memo } from 'react'; +import { AI_DISCLAIMER } from '../utils'; import { Text, Badge, @@ -145,7 +146,7 @@ export const ComplianceSection = memo(function ComplianceSection({ marginTop: '8px', }} > - AI-generated content may be incorrect + {AI_DISCLAIMER} {/* Collapsible Compliance Accordion */} diff --git a/content-gen/src/app/frontend/src/components/MessageBubble.tsx b/content-gen/src/app/frontend/src/components/MessageBubble.tsx index 95431a7d9..52340ba50 100644 --- a/content-gen/src/app/frontend/src/components/MessageBubble.tsx +++ b/content-gen/src/app/frontend/src/components/MessageBubble.tsx @@ -10,6 +10,7 @@ import { Copy20Regular } from '@fluentui/react-icons'; import ReactMarkdown from 'react-markdown'; import type { ChatMessage } from '../types'; import { useCopyToClipboard } from '../hooks'; +import { AI_DISCLAIMER } from '../utils'; export interface MessageBubbleProps { message: ChatMessage; @@ -91,7 +92,7 @@ export const MessageBubble = memo(function MessageBubble({ message }: MessageBub fontSize: '11px', }} > - AI-generated content may be incorrect + {AI_DISCLAIMER}
    diff --git a/content-gen/src/app/frontend/src/components/ProductCard.tsx b/content-gen/src/app/frontend/src/components/ProductCard.tsx index 050de19f7..873ae6620 100644 --- a/content-gen/src/app/frontend/src/components/ProductCard.tsx +++ b/content-gen/src/app/frontend/src/components/ProductCard.tsx @@ -1,4 +1,4 @@ -import { memo, useMemo } from 'react'; +import { memo } from 'react'; import { Text, tokens, @@ -30,7 +30,7 @@ export const ProductCard = memo(function ProductCard({ }: ProductCardProps) { const isCompact = size === 'compact'; const imgSize = isCompact ? 56 : 80; - const isInteractive = useMemo(() => !!onClick && !disabled, [onClick, disabled]); + const isInteractive = !!onClick && !disabled; return (
    - AI-generated content may be incorrect + {AI_DISCLAIMER}
    diff --git a/content-gen/src/app/frontend/src/store/selectors.ts b/content-gen/src/app/frontend/src/store/selectors.ts index fe122ee2d..a837844c5 100644 --- a/content-gen/src/app/frontend/src/store/selectors.ts +++ b/content-gen/src/app/frontend/src/store/selectors.ts @@ -9,7 +9,6 @@ import type { RootState } from './store'; export const selectUserId = (state: RootState) => state.app.userId; export const selectUserName = (state: RootState) => state.app.userName; export const selectIsLoading = (state: RootState) => state.app.isLoading; -export const selectGenerationStatus = (state: RootState) => state.app.generationStatus; export const selectGenerationStatusLabel = (state: RootState) => state.app.generationStatusLabel; export const selectImageGenerationEnabled = (state: RootState) => state.app.imageGenerationEnabled; export const selectShowChatHistory = (state: RootState) => state.app.showChatHistory; diff --git a/content-gen/src/app/frontend/src/utils/index.ts b/content-gen/src/app/frontend/src/utils/index.ts index 66eded22e..9b7b9e747 100644 --- a/content-gen/src/app/frontend/src/utils/index.ts +++ b/content-gen/src/app/frontend/src/utils/index.ts @@ -27,3 +27,6 @@ export { isContentFilterError, getErrorMessage } from './contentErrors'; // Image download export { downloadImage } from './downloadImage'; + +// Shared UI constants +export const AI_DISCLAIMER = 'AI-generated content may be incorrect'; From ddf6a0ca52d1ec8514629623e19f7bed1c31b7b7 Mon Sep 17 00:00:00 2001 From: Shubhangi-Microsoft Date: Tue, 3 Mar 2026 12:09:30 +0530 Subject: [PATCH 08/12] chore: remove 3 unused barrel exports (setShowChatHistory, parseTextContent, resolveImageUrl) --- content-gen/src/app/frontend/src/store/appSlice.ts | 5 +---- content-gen/src/app/frontend/src/store/index.ts | 1 - content-gen/src/app/frontend/src/utils/index.ts | 2 +- 3 files changed, 2 insertions(+), 6 deletions(-) diff --git a/content-gen/src/app/frontend/src/store/appSlice.ts b/content-gen/src/app/frontend/src/store/appSlice.ts index 265522ebd..6d94c5833 100644 --- a/content-gen/src/app/frontend/src/store/appSlice.ts +++ b/content-gen/src/app/frontend/src/store/appSlice.ts @@ -125,9 +125,6 @@ const appSlice = createSlice({ toggleChatHistory(state) { state.showChatHistory = !state.showChatHistory; }, - setShowChatHistory(state, action: PayloadAction) { - state.showChatHistory = action.payload; - }, }, extraReducers: (builder) => { builder @@ -148,6 +145,6 @@ const appSlice = createSlice({ }, }); -export const { setIsLoading, setGenerationStatus, toggleChatHistory, setShowChatHistory } = +export const { setIsLoading, setGenerationStatus, toggleChatHistory } = appSlice.actions; export default appSlice.reducer; diff --git a/content-gen/src/app/frontend/src/store/index.ts b/content-gen/src/app/frontend/src/store/index.ts index 7bbcd99d6..1a7a98623 100644 --- a/content-gen/src/app/frontend/src/store/index.ts +++ b/content-gen/src/app/frontend/src/store/index.ts @@ -13,7 +13,6 @@ export { setIsLoading, setGenerationStatus, toggleChatHistory, - setShowChatHistory, GenerationStatus, } from './appSlice'; diff --git a/content-gen/src/app/frontend/src/utils/index.ts b/content-gen/src/app/frontend/src/utils/index.ts index 9b7b9e747..94ba048c3 100644 --- a/content-gen/src/app/frontend/src/utils/index.ts +++ b/content-gen/src/app/frontend/src/utils/index.ts @@ -8,7 +8,7 @@ export { createMessage, createErrorMessage } from './messageUtils'; // Content parsing (raw API → typed domain objects) -export { parseTextContent, resolveImageUrl, buildGeneratedContent } from './contentParsing'; +export { buildGeneratedContent } from './contentParsing'; // SSE stream parser export { parseSSEStream } from './sseParser'; From 4f22f79235a4bf35f8e0e79faba9e6f423b4454f Mon Sep 17 00:00:00 2001 From: Shubhangi-Microsoft Date: Tue, 3 Mar 2026 17:31:45 +0530 Subject: [PATCH 09/12] refactor: eliminate remaining Task 2/6/8 issues - Route fetch('/.auth/me') through platformClient (HttpClient with empty baseUrl) - Un-export parseTextContent/resolveImageUrl (module-internal only) - DRY userId || 'anonymous' x6 into normalizeUserId() helper - Remove redundant duplicate prompt field from WelcomeCard suggestions --- .../src/app/frontend/src/api/httpClient.ts | 6 ++++++ content-gen/src/app/frontend/src/api/index.ts | 17 +++++++++++------ .../app/frontend/src/components/WelcomeCard.tsx | 7 ++----- .../src/app/frontend/src/store/appSlice.ts | 3 ++- .../app/frontend/src/utils/contentParsing.ts | 6 +++--- 5 files changed, 24 insertions(+), 15 deletions(-) diff --git a/content-gen/src/app/frontend/src/api/httpClient.ts b/content-gen/src/app/frontend/src/api/httpClient.ts index 87b271c18..4689a6d98 100644 --- a/content-gen/src/app/frontend/src/api/httpClient.ts +++ b/content-gen/src/app/frontend/src/api/httpClient.ts @@ -163,6 +163,12 @@ export class HttpClient { const httpClient = new HttpClient('/api'); +/** + * Client for Azure platform endpoints (/.auth/me, etc.) — no base URL prefix. + * Shares the same interceptor pattern but targets the host root. + */ +export const platformClient = new HttpClient('', 10_000); + // ---- request interceptor: auth headers ---- httpClient.onRequest((_url, init) => { const headers = new Headers(init.headers); diff --git a/content-gen/src/app/frontend/src/api/index.ts b/content-gen/src/app/frontend/src/api/index.ts index a7e46207b..4a0797318 100644 --- a/content-gen/src/app/frontend/src/api/index.ts +++ b/content-gen/src/app/frontend/src/api/index.ts @@ -13,6 +13,11 @@ import httpClient from './httpClient'; export { default as httpClient } from './httpClient'; import { parseSSEStream, getGenerationStage } from '../utils'; +/** Normalize optional userId to a safe fallback. */ +function normalizeUserId(userId?: string): string { + return userId || 'anonymous'; +} + /** * Get application configuration including feature flags */ @@ -32,7 +37,7 @@ export async function parseBrief( return httpClient.post('/brief/parse', { brief_text: briefText, conversation_id: conversationId, - user_id: userId || 'anonymous', + user_id: normalizeUserId(userId), }, { signal }); } @@ -47,7 +52,7 @@ export async function confirmBrief( return httpClient.post('/brief/confirm', { brief, conversation_id: conversationId, - user_id: userId || 'anonymous', + user_id: normalizeUserId(userId), }); } @@ -65,7 +70,7 @@ export async function selectProducts( request, current_products: currentProducts, conversation_id: conversationId, - user_id: userId || 'anonymous', + user_id: normalizeUserId(userId), }, { signal }); } @@ -85,7 +90,7 @@ export async function* streamChat( body: JSON.stringify({ message, conversation_id: conversationId, - user_id: userId || 'anonymous', + user_id: normalizeUserId(userId), }), }); @@ -118,7 +123,7 @@ export async function* streamGenerateContent( products: products || [], generate_images: generateImages, conversation_id: conversationId, - user_id: userId || 'anonymous', + user_id: normalizeUserId(userId), }, { signal }); const taskId = startData.task_id; @@ -210,7 +215,7 @@ export async function* streamRegenerateImage( products: products || [], previous_image_prompt: previousImagePrompt, conversation_id: conversationId, - user_id: userId || 'anonymous', + user_id: normalizeUserId(userId), }), }); diff --git a/content-gen/src/app/frontend/src/components/WelcomeCard.tsx b/content-gen/src/app/frontend/src/components/WelcomeCard.tsx index aa1388324..b56b781c4 100644 --- a/content-gen/src/app/frontend/src/components/WelcomeCard.tsx +++ b/content-gen/src/app/frontend/src/components/WelcomeCard.tsx @@ -9,19 +9,16 @@ import SecondPromptIcon from '../styles/images/secondprompt.png'; interface SuggestionData { title: string; - prompt: string; icon: string; } const suggestions: SuggestionData[] = [ { title: "I need to create a social media post about paint products for home remodels. The campaign is titled \"Brighten Your Springtime\" and the audience is new homeowners. I need marketing copy plus an image. The image should be an informal living room with tasteful furnishings.", - prompt: "I need to create a social media post about paint products for home remodels. The campaign is titled \"Brighten Your Springtime\" and the audience is new homeowners. I need marketing copy plus an image. The image should be an informal living room with tasteful furnishings.", icon: FirstPromptIcon, }, { title: "Generate a social media campaign with ad copy and an image. This is for \"Back to School\" and the audience is parents of school age children. Tone is playful and humorous. The image must have minimal kids accessories in a children's bedroom. Show the room in a wide view.", - prompt: "Generate a social media campaign with ad copy and an image. This is for \"Back to School\" and the audience is parents of school age children. Tone is playful and humorous. The image must have minimal kids accessories in a children's bedroom. Show the room in a wide view.", icon: SecondPromptIcon, } ]; @@ -33,7 +30,7 @@ interface WelcomeCardProps { export const WelcomeCard = memo(function WelcomeCard({ onSuggestionClick, currentInput = '' }: WelcomeCardProps) { const selectedIndex = useMemo( - () => suggestions.findIndex(s => s.prompt === currentInput), + () => suggestions.findIndex(s => s.title === currentInput), [currentInput], ); @@ -99,7 +96,7 @@ export const WelcomeCard = memo(function WelcomeCard({ onSuggestionClick, curren title={suggestion.title} icon={suggestion.icon} isSelected={isSelected} - onClick={() => onSuggestionClick(suggestion.prompt)} + onClick={() => onSuggestionClick(suggestion.title)} /> ); })} diff --git a/content-gen/src/app/frontend/src/store/appSlice.ts b/content-gen/src/app/frontend/src/store/appSlice.ts index 6d94c5833..0c2eda834 100644 --- a/content-gen/src/app/frontend/src/store/appSlice.ts +++ b/content-gen/src/app/frontend/src/store/appSlice.ts @@ -61,7 +61,8 @@ export const fetchAppConfig = createAsyncThunk( export const fetchUserInfo = createAsyncThunk( 'app/fetchUserInfo', async () => { - const response = await fetch('/.auth/me'); + const { platformClient } = await import('../api/httpClient'); + const response = await platformClient.raw('/.auth/me'); if (!response.ok) return { userId: 'anonymous', userName: '' }; const payload = await response.json(); diff --git a/content-gen/src/app/frontend/src/utils/contentParsing.ts b/content-gen/src/app/frontend/src/utils/contentParsing.ts index 629f887da..e59ac85e3 100644 --- a/content-gen/src/app/frontend/src/utils/contentParsing.ts +++ b/content-gen/src/app/frontend/src/utils/contentParsing.ts @@ -25,14 +25,14 @@ function rewriteBlobUrl(url: string): string { } /* ------------------------------------------------------------------ */ -/* Exported utilities */ +/* Parsing helpers (module-internal — not re-exported) */ /* ------------------------------------------------------------------ */ /** * Parse `text_content` which may arrive as a JSON string or an object. * Returns an object with known fields, or `undefined` if unusable. */ -export function parseTextContent( +function parseTextContent( raw: unknown, ): { headline?: string; body?: string; cta_text?: string; tagline?: string } | undefined { let textContent = raw; @@ -64,7 +64,7 @@ export function parseTextContent( * Pass `rewriteBlobs: true` (default) when restoring from a saved * conversation; `false` when the response just came from the live API. */ -export function resolveImageUrl( +function resolveImageUrl( raw: { image_url?: string; image_base64?: string }, rewriteBlobs = false, ): string | undefined { From 8a7d3b42e0ccd71c76314f0b7737b76029d8679c Mon Sep 17 00:00:00 2001 From: Shubhangi-Microsoft Date: Tue, 3 Mar 2026 17:46:53 +0530 Subject: [PATCH 10/12] refactor: extract readSSEResponse helper, un-export GENERATION_STATUS_LABELS --- content-gen/src/app/frontend/src/api/index.ts | 43 ++++++++++--------- .../src/app/frontend/src/store/appSlice.ts | 2 +- 2 files changed, 24 insertions(+), 21 deletions(-) diff --git a/content-gen/src/app/frontend/src/api/index.ts b/content-gen/src/app/frontend/src/api/index.ts index 4a0797318..5bb5d4017 100644 --- a/content-gen/src/app/frontend/src/api/index.ts +++ b/content-gen/src/app/frontend/src/api/index.ts @@ -18,6 +18,27 @@ function normalizeUserId(userId?: string): string { return userId || 'anonymous'; } +/** + * Validate an SSE response, extract its body reader, and yield parsed events. + * Consolidates the duplicated response → reader → parseSSEStream pipeline + * used by streamChat and streamRegenerateImage. + */ +async function* readSSEResponse( + response: Response, + context: string, +): AsyncGenerator { + if (!response.ok) { + throw new Error(`${context}: ${response.statusText}`); + } + + const reader = response.body?.getReader(); + if (!reader) { + throw new Error('No response body'); + } + + yield* parseSSEStream(reader); +} + /** * Get application configuration including feature flags */ @@ -94,16 +115,7 @@ export async function* streamChat( }), }); - if (!response.ok) { - throw new Error(`Chat request failed: ${response.statusText}`); - } - - const reader = response.body?.getReader(); - if (!reader) { - throw new Error('No response body'); - } - - yield* parseSSEStream(reader); + yield* readSSEResponse(response, 'Chat request failed'); } /** @@ -219,14 +231,5 @@ export async function* streamRegenerateImage( }), }); - if (!response.ok) { - throw new Error(`Regeneration request failed: ${response.statusText}`); - } - - const reader = response.body?.getReader(); - if (!reader) { - throw new Error('No response body'); - } - - yield* parseSSEStream(reader); + yield* readSSEResponse(response, 'Regeneration request failed'); } \ No newline at end of file diff --git a/content-gen/src/app/frontend/src/store/appSlice.ts b/content-gen/src/app/frontend/src/store/appSlice.ts index 0c2eda834..952b6ae1e 100644 --- a/content-gen/src/app/frontend/src/store/appSlice.ts +++ b/content-gen/src/app/frontend/src/store/appSlice.ts @@ -32,7 +32,7 @@ export enum GenerationStatus { } /** Display strings shown in the UI for each status. */ -export const GENERATION_STATUS_LABELS: Record = { +const GENERATION_STATUS_LABELS: Record = { [GenerationStatus.IDLE]: '', [GenerationStatus.UPDATING_BRIEF]: 'Updating creative brief...', [GenerationStatus.PROCESSING_QUESTION]: 'Processing your question...', From 1650f112a4ca029d0ab5ea833b8afa8e02465cbe Mon Sep 17 00:00:00 2001 From: Shubhangi-Microsoft Date: Fri, 13 Mar 2026 13:11:54 +0530 Subject: [PATCH 11/12] fix: wrap MessageBubble copy handler in useCallback --- .../src/app/frontend/src/components/MessageBubble.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/content-gen/src/app/frontend/src/components/MessageBubble.tsx b/content-gen/src/app/frontend/src/components/MessageBubble.tsx index 52340ba50..d4509a0c5 100644 --- a/content-gen/src/app/frontend/src/components/MessageBubble.tsx +++ b/content-gen/src/app/frontend/src/components/MessageBubble.tsx @@ -1,4 +1,4 @@ -import { memo } from 'react'; +import { memo, useCallback } from 'react'; import { Text, Badge, @@ -26,6 +26,7 @@ export interface MessageBubbleProps { export const MessageBubble = memo(function MessageBubble({ message }: MessageBubbleProps) { const isUser = message.role === 'user'; const { copied, copy } = useCopyToClipboard(); + const handleCopy = useCallback(() => copy(message.content), [copy, message.content]); return (
    } size="small" - onClick={() => copy(message.content)} + onClick={handleCopy} style={{ minWidth: '28px', height: '28px', From 10ede7ec41f925e5b680e1f4d2c4beafebd0b21a Mon Sep 17 00:00:00 2001 From: Shubhangi-Microsoft Date: Fri, 13 Mar 2026 17:12:00 +0530 Subject: [PATCH 12/12] fix: resolve 6 Copilot review comments on PR #768 - httpClient: replace require() with ESM dynamic import() for store access - useConversationActions: pass explicit undefined to resetChat() - parseBrief: route to /chat endpoint (no /brief/parse backend route) - streamChat: use httpClient.post + yield instead of SSE (backend returns JSON) - selectProducts: route to /chat endpoint (no /products/select backend route) - streamRegenerateImage: use /chat + polling instead of non-existent /regenerate SSE - remove unused readSSEResponse helper and parseSSEStream import --- .../src/app/frontend/optimization-report.html | 1244 +++++++++++++++++ .../src/app/frontend/src/api/httpClient.ts | 203 +++ content-gen/src/app/frontend/src/api/index.ts | 258 ++++ .../src/hooks/useConversationActions.ts | 2 +- src/app/frontend/package-lock.json | 8 +- src/app/frontend/package.json | 2 +- src/app/frontend/src/api/httpClient.ts | 8 +- src/app/frontend/src/api/index.ts | 121 +- .../src/hooks/useConversationActions.ts | 2 +- 9 files changed, 1788 insertions(+), 60 deletions(-) create mode 100644 content-gen/src/app/frontend/optimization-report.html create mode 100644 content-gen/src/app/frontend/src/api/httpClient.ts create mode 100644 content-gen/src/app/frontend/src/api/index.ts diff --git a/content-gen/src/app/frontend/optimization-report.html b/content-gen/src/app/frontend/optimization-report.html new file mode 100644 index 000000000..710c00e38 --- /dev/null +++ b/content-gen/src/app/frontend/optimization-report.html @@ -0,0 +1,1244 @@ + + + + + + Content Generation Frontend UI Refactorization KPI Report (dev vs psl-ui-refractoring) + + + +
    + +
    +

    🚀 Content Generation Frontend UI Refactorization KPI Report

    +

    Complete Technical Analysis & Implementation Details

    +

    Generated: July 2025 | Project: Content Generation Solution Accelerator

    + +
    +
    + 100% + Original UI Files Impacted +
    +
    + 4 + Redux Toolkit Slices Added +
    +
    + -48.29% + Original Components LOC Reduction +
    +
    + +29.87% + Net UI Delta Rate +
    +
    +
    + + +
    +

    📊 Executive Summary

    +

    Through comparison of origin/dev...psl-ui-refractoring in content-gen/src/app/frontend/src, this report captures measured UI refactorization impact with consistent diff-based KPIs.

    +
      +
    • 48 UI files changed (100% of original dev UI files touched)
    • +
    • 3,740 additions / 2,453 deletions (6,193 total churn)
    • +
    • +1,091 net lines in UI scope (+29.87% vs dev baseline)
    • +
    • Original components reduced by 1,130 lines (-48.29%)
    • +
    • 4 Redux slices added with full store modularization
    • +
    • 100% changed files are TypeScript (48/48 .ts/.tsx)
    • +
    + +
    +
    + Comparison Coverage Complete ✅ +
    +
    +
    + + +
    +

    🔄 Architecture: Before vs After

    + +

    Before: Monolithic Component Pattern

    +
    +
    Tightly Coupled UI Logic
    + +
    Large Monolith Components
    + +
    Mixed API + UI + State
    + +
    Harder Maintenance
    +
    + +

    After: Redux Toolkit + Custom Hooks + Modular Components

    +
    +
    Redux Store
    + +
    4 Typed Slices
    + +
    21 Granular Selectors
    + +
    7 Custom Hooks
    + +
    18 Memoized Components
    +
    + +

    Redux Slice Architecture

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    SliceFileResponsibilityKey Exports
    appSlicestore/appSlice.tsGlobal app-level UI stateTheme, layout, global flags
    chatSlicestore/chatSlice.tsChat state and async chat operationsMessages, streaming, SSE
    contentSlicestore/contentSlice.tsContent preview and generation statePreviews, generation stages
    chatHistorySlicestore/chatHistorySlice.tsConversation history and session dataHistory CRUD, persistence
    +
    + + +
    +

    📈 UI Refactoring Implementation

    + +
    +
    + + + +
    + +
    +

    Phase 1: Component Decomposition

    +

    Monolithic components (App.tsx at 846 lines, ChatHistory at 616 lines) were decomposed into focused, single-responsibility components. 10 new granular components were extracted.

    +
    +
    // Deleted
    +content-gen/src/app/frontend/src/api/index.ts (replaced by httpClient)
    +
    +// Major reductions
    +App.tsx:                846 → 72  lines (-91.5%)
    +ChatHistory.tsx:        616 → 327 lines (-46.9%)
    +InlineContentPreview:   528 → 196 lines (-62.9%)
    +ChatPanel.tsx:          425 → 159 lines (-62.6%)
    +
    +// New granular components (10 files, 1,220 lines)
    +components/AppHeader.tsx
    +components/ChatInput.tsx
    +components/ComplianceSection.tsx
    +components/ConversationItem.tsx
    +components/ImagePreviewCard.tsx
    +components/MessageBubble.tsx
    +components/ProductCard.tsx
    +components/SuggestionCard.tsx
    +components/TypingIndicator.tsx
    +components/ViolationCard.tsx
    +
    +
    + + + + +
    +
    + + +
    +

    📊 KPI 1: Codebase Overview

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    MetricBefore (dev)After (local)Delta
    Total UI source files1347+34 files
    Total source lines3,6524,743+1,091 (+29.87%)
    Files added35Added
    Files modified12Modified
    Files deleted1Legacy API file removed
    Line additions3,740+3,740
    Line deletions2,453−2,453
    +
    + + +
    +

    📊 KPI 2: Component Complexity (Lines of Code)

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    ComponentBefore (lines)After (lines)Reduction
    App.tsx84672−774 (−91.49%)
    ChatHistory.tsx616327−289 (−46.92%)
    InlineContentPreview.tsx528196−332 (−62.88%)
    ChatPanel.tsx425159−266 (−62.59%)
    api/index.ts321222−99 (−30.84%)
    ProductReview.tsx217128−89 (−41.01%)
    BriefReview.tsx177157−20 (−11.30%)
    WelcomeCard.tsx154103−51 (−33.12%)
    SelectedProductView.tsx13560−75 (−55.56%)
    ConfirmedBriefView.tsx8880−8 (−9.09%)
    + +

    Top Reduction Progress

    +
    +
    +

    App.tsx Reduction

    +
    +
    −91.49%
    +
    +
    +
    +

    InlineContentPreview.tsx Reduction

    +
    +
    −62.88%
    +
    +
    +
    +
    +
    +

    ChatPanel.tsx Reduction

    +
    +
    −62.59%
    +
    +
    +
    +

    SelectedProductView.tsx Reduction

    +
    +
    −55.56%
    +
    +
    +
    +
    +
    +

    ChatHistory.tsx Reduction

    +
    +
    −46.92%
    +
    +
    +
    +

    ProductReview.tsx Reduction

    +
    +
    −41.01%
    +
    +
    +
    +
    + + +
    +

    📊 KPI 3: Architecture & Modularity

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    MetricBeforeAfterDelta
    Custom hooks0 files7 files (797 lines)+7 hook modules
    State management files08 files (501 lines)+8 store modules
    Utility modules09 files (423 lines)+9 under utils/
    New components010 files (1,220 lines)+10 granular components
    Typed selectors021+21 centralized in selectors.ts
    + +

    New Files Created

    +
    +
    +

    State Management (8 files)

    +
      +
    • ✅ store/index.ts
    • +
    • ✅ store/store.ts
    • +
    • ✅ store/hooks.ts
    • +
    • ✅ store/selectors.ts
    • +
    • ✅ store/appSlice.ts
    • +
    • ✅ store/chatSlice.ts
    • +
    • ✅ store/contentSlice.ts
    • +
    • ✅ store/chatHistorySlice.ts
    • +
    +
    +
    +

    Custom Hooks (7 files)

    +
      +
    • ✅ hooks/index.ts
    • +
    • ✅ hooks/useAutoScroll.ts
    • +
    • ✅ hooks/useChatOrchestrator.ts
    • +
    • ✅ hooks/useContentGeneration.ts
    • +
    • ✅ hooks/useConversationActions.ts
    • +
    • ✅ hooks/useCopyToClipboard.ts
    • +
    • ✅ hooks/useWindowSize.ts
    • +
    +
    +
    +
    +
    +

    Utility Modules (9 files)

    +
      +
    • ✅ utils/index.ts
    • +
    • ✅ utils/briefFields.ts
    • +
    • ✅ utils/contentErrors.ts
    • +
    • ✅ utils/contentParsing.ts
    • +
    • ✅ utils/downloadImage.ts
    • +
    • ✅ utils/generationStages.ts
    • +
    • ✅ utils/messageUtils.ts
    • +
    • ✅ utils/sseParser.ts
    • +
    • ✅ utils/stringUtils.ts
    • +
    +
    +
    +

    New Components (10 files)

    +
      +
    • ✅ components/AppHeader.tsx (65 lines)
    • +
    • ✅ components/ChatInput.tsx (139 lines)
    • +
    • ✅ components/ComplianceSection.tsx (187 lines)
    • +
    • ✅ components/ConversationItem.tsx (276 lines)
    • +
    • ✅ components/ImagePreviewCard.tsx (95 lines)
    • +
    • ✅ components/MessageBubble.tsx (113 lines)
    • +
    • ✅ components/ProductCard.tsx (132 lines)
    • +
    • ✅ components/SuggestionCard.tsx (81 lines)
    • +
    • ✅ components/TypingIndicator.tsx (71 lines)
    • +
    • ✅ components/ViolationCard.tsx (61 lines)
    • +
    +
    +
    +
    + + +
    +

    📊 KPI 4: Bundle Size (Production Build)

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    ChunkBeforeAfterDelta
    Bundle KPINot measuredNot measuredBuild benchmark required
    Comparison basisgit diff origin/dev...HEAD in content-gen/src/app/frontend/srcDiff-based report
    Measured churn6,193 lines3,740 add / 2,453 del
    Net UI delta+1,091 lines+29.87% vs dev UI baseline
    StatusBundle size not included in this KPI setUse build artifacts for bundle KPI
    + +
    +

    💡 Note: This report is strictly based on branch diff metrics. Bundle-size numbers require production build output from both refs and are intentionally excluded here.

    +
    +
    + + +
    +

    📊 KPI 5: Code Quality Patterns

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    PatternBeforeAfterDelta
    memo( component wrappers018+18 memoized component wrappers
    useCallback(1326+13 callback memoization
    useMemo(09+9 derived value memoization
    createSlice (Redux Toolkit)04+4 typed state modules
    createAsyncThunk06+6 standardized async
    useAppDispatch013+13 typed dispatch hooks
    useAppSelector048+48 typed selector hooks
    displayName018+18 DevTools identifiers
    +
    + + +
    +

    🐛 KPI 6: Structural Risk Reductions

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    #Refactor OutcomeEvidence from DiffStatus
    1Monolith App.tsx decomposedApp.tsx reduced from 846 to 72 lines (−91.5%); logic extracted to hooks, store, and componentsCompleted
    2State flow centralizedAdded 4 Redux slices, 21 selectors, and typed hooks under store/Completed
    3Reusable utility layer introducedAdded 9 utility modules under utils/ (SSE parsing, content parsing, string utils)Completed
    4Chat rendering decomposedChatHistory (616→327), ChatPanel (425→159), extracted MessageBubble, ConversationItem, ChatInputCompleted
    5Content preview modularizedInlineContentPreview (528→196), extracted ComplianceSection, ViolationCard, ImagePreviewCardCompleted
    6API layer refactoredapi/index.ts reduced (321→222), SSE/HTTP client logic extracted to utils/sseParser.tsCompleted
    7Product flow decomposedProductReview (217→128), extracted ProductCard, SuggestionCard sub-componentsCompleted
    8Custom hooks extracted7 domain hooks created: useChatOrchestrator, useContentGeneration, useConversationActions, etc.Completed
    9Performance patterns applied18 memo() wrappers, 18 displayName identifiers, 26 useCallback, 9 useMemoCompleted
    10Type safety enforced0 TypeScript errors; typed Redux hooks (useAppSelector: 48 usages, useAppDispatch: 13 usages)Completed
    +
    + + +
    +

    ⚡ KPI 7: Refactorization and Quality Outcomes

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    OptimizationBeforeAfter
    Changed files in TypeScriptN/A48/48 (.ts/.tsx)
    Memoized components0 memo( patterns18 memo( patterns
    Callback stability13 useCallback(26 useCallback(
    Derived memoization0 useMemo(9 useMemo(
    Typed Redux hooks0 usages61 usages (13 dispatch + 48 selector)
    DevTools traceability0 displayName18 displayName identifiers
    Async state managementManual fetch + state6 createAsyncThunk actions
    + +

    Refactorization Delta Snapshot

    +
    +
    git diff --shortstat origin/dev...HEAD -- content-gen/src/app/frontend/src/
    +48 files changed, 3740 insertions(+), 2453 deletions(-)
    +
    +Change mix:
    +A=35 (72.92%)
    +M=12 (25.00%)
    +D=1  (2.08%)
    +
    +
    + + +
    +

    🎯 Summary Scorecard

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    KPIBeforeAfterVerdict
    UI files impacted13 files48 changed100% of original files
    State managementNo Redux slicesRedux Toolkit (4 slices)Centralized
    Typed selectors021New capability
    Render optimization0 memo( patterns18 memo( patternsProduction-grade
    Net UI delta rate+29.87%Growth from modularization
    Original components LOC2,3401,210-48.29%
    TypeScript changed-file footprint48/48100%
    TypeScript build errors00Clean build
    + +
    +
    + 48 + Files Changed +
    +
    + 35/12/1 + Added / Modified / Deleted +
    +
    + +1,091 + Net UI Lines +
    +
    + 100% + Changed Files in TS/TSX +
    +
    +
    + + +
    +

    ✨ Best Practices Implemented

    + +
    +
    +

    State Management

    +
      +
    • Redux Toolkit (official)
    • +
    • 4 typed slices
    • +
    • 6 createAsyncThunk actions
    • +
    • 21 centralized selectors
    • +
    • Type-safe hooks
    • +
    +
    + +
    +

    Performance

    +
      +
    • 18x memo() wrappers
    • +
    • 26x useCallback()
    • +
    • 9x useMemo()
    • +
    • 48x useAppSelector
    • +
    • Original LOC −48.29%
    • +
    +
    + +
    +

    Code Quality

    +
      +
    • Single Responsibility
    • +
    • DRY Principle
    • +
    • 100% TypeScript
    • +
    • 18 displayName identifiers
    • +
    • 0 build errors
    • +
    +
    + +
    +

    Architecture

    +
      +
    • 7 custom hooks
    • +
    • 9 utility modules
    • +
    • 10 new components
    • +
    • Store/hooks/utils layers
    • +
    • Typed selector & thunk usage
    • +
    +
    +
    +
    + + + +
    + + + + diff --git a/content-gen/src/app/frontend/src/api/httpClient.ts b/content-gen/src/app/frontend/src/api/httpClient.ts new file mode 100644 index 000000000..6aaf83b48 --- /dev/null +++ b/content-gen/src/app/frontend/src/api/httpClient.ts @@ -0,0 +1,203 @@ +/** + * Centralized HTTP client with interceptors. + * + * - Singleton — use the default `httpClient` export everywhere. + * - Request interceptors automatically attach auth headers + * (X-Ms-Client-Principal-Id) so callers never need to remember. + * - Response interceptors provide uniform error handling. + * - Built-in query-param serialization, configurable timeout, and base URL. + */ + +/* ------------------------------------------------------------------ */ +/* Types */ +/* ------------------------------------------------------------------ */ + +/** Options accepted by every request method. */ +export interface RequestOptions extends Omit { + /** Query parameters – appended to the URL automatically. */ + params?: Record; + /** Per-request timeout in ms (default: client-level `timeout`). */ + timeout?: number; +} + +type RequestInterceptor = (url: string, init: RequestInit) => RequestInit | Promise; +type ResponseInterceptor = (response: Response) => Response | Promise; + +/* ------------------------------------------------------------------ */ +/* HttpClient */ +/* ------------------------------------------------------------------ */ + +export class HttpClient { + private baseUrl: string; + private defaultTimeout: number; + private requestInterceptors: RequestInterceptor[] = []; + private responseInterceptors: ResponseInterceptor[] = []; + + constructor(baseUrl = '', timeout = 60_000) { + this.baseUrl = baseUrl; + this.defaultTimeout = timeout; + } + + /* ---------- interceptor registration ---------- */ + + onRequest(fn: RequestInterceptor): void { + this.requestInterceptors.push(fn); + } + + onResponse(fn: ResponseInterceptor): void { + this.responseInterceptors.push(fn); + } + + /* ---------- public request helpers ---------- */ + + async get(path: string, opts: RequestOptions = {}): Promise { + const res = await this.request(path, { ...opts, method: 'GET' }); + return res.json() as Promise; + } + + async post(path: string, body?: unknown, opts: RequestOptions = {}): Promise { + const res = await this.request(path, { + ...opts, + method: 'POST', + body: body != null ? JSON.stringify(body) : undefined, + headers: { + ...(body != null ? { 'Content-Type': 'application/json' } : {}), + ...opts.headers, + }, + }); + return res.json() as Promise; + } + + async put(path: string, body?: unknown, opts: RequestOptions = {}): Promise { + const res = await this.request(path, { + ...opts, + method: 'PUT', + body: body != null ? JSON.stringify(body) : undefined, + headers: { + ...(body != null ? { 'Content-Type': 'application/json' } : {}), + ...opts.headers, + }, + }); + return res.json() as Promise; + } + + async delete(path: string, opts: RequestOptions = {}): Promise { + const res = await this.request(path, { ...opts, method: 'DELETE' }); + return res.json() as Promise; + } + + /** + * Low-level request that returns the raw `Response`. + * Useful for streaming (SSE) endpoints where the caller needs `response.body`. + */ + async raw(path: string, opts: RequestOptions & { method?: string; body?: BodyInit | null } = {}): Promise { + return this.request(path, opts); + } + + /* ---------- internal plumbing ---------- */ + + private buildUrl(path: string, params?: Record): string { + const url = `${this.baseUrl}${path}`; + if (!params) return url; + + const qs = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value !== undefined) { + qs.set(key, String(value)); + } + } + const queryString = qs.toString(); + return queryString ? `${url}?${queryString}` : url; + } + + private async request(path: string, opts: RequestOptions & { method?: string; body?: BodyInit | null } = {}): Promise { + const { params, timeout, ...fetchOpts } = opts; + const url = this.buildUrl(path, params); + const effectiveTimeout = timeout ?? this.defaultTimeout; + + // Build the init object + let init: RequestInit = { ...fetchOpts }; + + // Run request interceptors + for (const interceptor of this.requestInterceptors) { + init = await interceptor(url, init); + } + + // Timeout via AbortController (merged with caller-supplied signal) + const timeoutCtrl = new AbortController(); + const callerSignal = init.signal; + + // If caller already passed a signal, listen for its abort + if (callerSignal) { + if (callerSignal.aborted) { + timeoutCtrl.abort(callerSignal.reason); + } else { + callerSignal.addEventListener('abort', () => timeoutCtrl.abort(callerSignal.reason), { once: true }); + } + } + + const timer = effectiveTimeout > 0 + ? setTimeout(() => timeoutCtrl.abort(new DOMException('Request timed out', 'TimeoutError')), effectiveTimeout) + : undefined; + + init.signal = timeoutCtrl.signal; + + try { + let response = await fetch(url, init); + + // Run response interceptors + for (const interceptor of this.responseInterceptors) { + response = await interceptor(response); + } + + return response; + } finally { + if (timer !== undefined) clearTimeout(timer); + } + } +} + +/* ------------------------------------------------------------------ */ +/* Singleton instance with default interceptors */ +/* ------------------------------------------------------------------ */ + +const httpClient = new HttpClient('/api'); + +/** + * Client for Azure platform endpoints (/.auth/me, etc.) — no base URL prefix. + * Shares the same interceptor pattern but targets the host root. + */ +export const platformClient = new HttpClient('', 10_000); + +// ---- request interceptor: auth headers ---- +httpClient.onRequest(async (_url, init) => { + const headers = new Headers(init.headers); + + // Attach userId from Redux store (lazy import to avoid circular deps). + // Falls back to 'anonymous' if store isn't ready yet. + try { + const { store } = await import('../store/store'); + const state = store?.getState?.(); + const userId: string = state?.app?.userId ?? 'anonymous'; + headers.set('X-Ms-Client-Principal-Id', userId); + } catch { + headers.set('X-Ms-Client-Principal-Id', 'anonymous'); + } + + return { ...init, headers }; +}); + +// ---- response interceptor: uniform error handling ---- +httpClient.onResponse((response) => { + if (!response.ok) { + // Don't throw for streaming endpoints — callers handle those manually. + // Clone so the body remains readable for callers that want custom handling. + const cloned = response.clone(); + console.error( + `[httpClient] ${response.status} ${response.statusText} – ${cloned.url}`, + ); + } + return response; +}); + +export default httpClient; diff --git a/content-gen/src/app/frontend/src/api/index.ts b/content-gen/src/app/frontend/src/api/index.ts new file mode 100644 index 000000000..b0c229ce9 --- /dev/null +++ b/content-gen/src/app/frontend/src/api/index.ts @@ -0,0 +1,258 @@ +/** + * API service for interacting with the Content Generation backend + */ + +import type { + CreativeBrief, + Product, + AgentResponse, + ParsedBriefResponse, + AppConfig, +} from '../types'; +import httpClient from './httpClient'; +export { default as httpClient } from './httpClient'; +import { getGenerationStage } from '../utils'; + +/** Normalize optional userId to a safe fallback. */ +function normalizeUserId(userId?: string): string { + return userId || 'anonymous'; +} + +/** + * Get application configuration including feature flags + */ +export async function getAppConfig(): Promise { + return httpClient.get('/config'); +} + +/** + * Parse a free-text creative brief into structured format + */ +export async function parseBrief( + briefText: string, + conversationId?: string, + userId?: string, + signal?: AbortSignal +): Promise { + return httpClient.post('/chat', { + message: briefText, + conversation_id: conversationId, + user_id: normalizeUserId(userId), + }, { signal }); +} + +/** + * Confirm a parsed creative brief + */ +export async function confirmBrief( + brief: CreativeBrief, + conversationId?: string, + userId?: string +): Promise<{ status: string; conversation_id: string; brief: CreativeBrief }> { + return httpClient.post('/brief/confirm', { + brief, + conversation_id: conversationId, + user_id: normalizeUserId(userId), + }); +} + +/** + * Select or modify products via natural language + */ +export async function selectProducts( + request: string, + currentProducts: Product[], + conversationId?: string, + userId?: string, + signal?: AbortSignal +): Promise<{ products: Product[]; action: string; message: string; conversation_id: string }> { + return httpClient.post('/chat', { + message: request, + current_products: currentProducts, + conversation_id: conversationId, + user_id: normalizeUserId(userId), + }, { signal }); +} + +/** + * Stream chat messages from the agent orchestration. + * + * Note: The /chat endpoint returns JSON (not SSE), so we perform a standard + * POST request and yield the single AgentResponse result. + */ +export async function* streamChat( + message: string, + conversationId?: string, + userId?: string, + signal?: AbortSignal +): AsyncGenerator { + const result = await httpClient.post( + '/chat', + { + message, + conversation_id: conversationId, + user_id: normalizeUserId(userId), + }, + { signal }, + ); + + // Preserve async-iterator interface by yielding the single JSON response. + yield result; +} + +/** + * Generate content from a confirmed brief + */ +export async function* streamGenerateContent( + brief: CreativeBrief, + products?: Product[], + generateImages: boolean = true, + conversationId?: string, + userId?: string, + signal?: AbortSignal +): AsyncGenerator { + // Use polling-based approach for reliability with long-running tasks + const startData = await httpClient.post<{ task_id: string }>('/generate/start', { + brief, + products: products || [], + generate_images: generateImages, + conversation_id: conversationId, + user_id: normalizeUserId(userId), + }, { signal }); + const taskId = startData.task_id; + + // Yield initial status + yield { + type: 'status', + content: 'Generation started...', + is_final: false, + } as AgentResponse; + + // Poll for completion + let attempts = 0; + const maxAttempts = 600; // 10 minutes max with 1-second polling (image generation can take 3-5 min) + const pollInterval = 1000; // 1 second + + while (attempts < maxAttempts) { + // Check if cancelled before waiting + if (signal?.aborted) { + throw new DOMException('Generation cancelled by user', 'AbortError'); + } + + await new Promise(resolve => setTimeout(resolve, pollInterval)); + attempts++; + + // Check if cancelled after waiting + if (signal?.aborted) { + throw new DOMException('Generation cancelled by user', 'AbortError'); + } + + try { + const statusData = await httpClient.get<{ status: string; result?: unknown; error?: string }>( + `/generate/status/${taskId}`, + { signal }, + ); + + if (statusData.status === 'completed') { + // Yield the final result + yield { + type: 'agent_response', + content: JSON.stringify(statusData.result), + is_final: true, + } as AgentResponse; + return; + } else if (statusData.status === 'failed') { + throw new Error(statusData.error || 'Generation failed'); + } else if (statusData.status === 'running') { + const elapsedSeconds = attempts; + const { stage, message: stageMessage } = getGenerationStage(elapsedSeconds); + + // Send status update every second for smoother progress + yield { + type: 'heartbeat', + content: stageMessage, + count: stage, + elapsed: elapsedSeconds, + is_final: false, + } as AgentResponse; + } + } catch (error) { + // Continue polling on transient errors + if (attempts >= maxAttempts) { + throw error; + } + } + } + + throw new Error('Generation timed out after 10 minutes'); +} +/** + * Regenerate image with a modification request + * Used when user wants to change the generated image after initial content generation + */ +export async function* streamRegenerateImage( + modificationRequest: string, + _brief: CreativeBrief, + products?: Product[], + _previousImagePrompt?: string, + conversationId?: string, + userId?: string, + signal?: AbortSignal +): AsyncGenerator { + // Image regeneration uses the unified /chat endpoint with MODIFY_IMAGE intent, + // which returns a task_id for polling via /generate/status. + const startData = await httpClient.post<{ action_type: string; data: { task_id: string; poll_url: string }; conversation_id: string }>( + '/chat', + { + message: modificationRequest, + conversation_id: conversationId, + user_id: normalizeUserId(userId), + selected_products: products || [], + has_generated_content: true, + }, + { signal }, + ); + + const taskId = startData.data?.task_id; + if (!taskId) { + // If no task_id, the response is the final result itself + yield { type: 'agent_response', content: JSON.stringify(startData), is_final: true } as AgentResponse; + return; + } + + yield { type: 'status', content: 'Regeneration started...', is_final: false } as AgentResponse; + + let attempts = 0; + const maxAttempts = 600; + const pollInterval = 1000; + + while (attempts < maxAttempts) { + if (signal?.aborted) { + throw new DOMException('Regeneration cancelled by user', 'AbortError'); + } + + await new Promise(resolve => setTimeout(resolve, pollInterval)); + attempts++; + + if (signal?.aborted) { + throw new DOMException('Regeneration cancelled by user', 'AbortError'); + } + + const statusData = await httpClient.get<{ status: string; result?: unknown; error?: string }>( + `/generate/status/${taskId}`, + { signal }, + ); + + if (statusData.status === 'completed') { + yield { type: 'agent_response', content: JSON.stringify(statusData.result), is_final: true } as AgentResponse; + return; + } else if (statusData.status === 'failed') { + throw new Error(statusData.error || 'Regeneration failed'); + } else { + const { stage, message: stageMessage } = getGenerationStage(attempts); + yield { type: 'heartbeat', content: stageMessage, count: stage, elapsed: attempts, is_final: false } as AgentResponse; + } + } + + throw new Error('Regeneration timed out after 10 minutes'); +} \ No newline at end of file diff --git a/content-gen/src/app/frontend/src/hooks/useConversationActions.ts b/content-gen/src/app/frontend/src/hooks/useConversationActions.ts index 48346600d..5dd8e87b9 100644 --- a/content-gen/src/app/frontend/src/hooks/useConversationActions.ts +++ b/content-gen/src/app/frontend/src/hooks/useConversationActions.ts @@ -130,7 +130,7 @@ export function useConversationActions() { /* Start a new conversation */ /* ------------------------------------------------------------ */ const newConversation = useCallback(() => { - dispatch(resetChat()); + dispatch(resetChat(undefined)); dispatch(resetContent()); }, [dispatch]); diff --git a/src/app/frontend/package-lock.json b/src/app/frontend/package-lock.json index 75cb9c13b..392884c07 100644 --- a/src/app/frontend/package-lock.json +++ b/src/app/frontend/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "dependencies": { "@fluentui/react-components": "^9.54.0", - "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-icons": "^2.0.320", "@reduxjs/toolkit": "^2.11.2", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -1423,9 +1423,9 @@ } }, "node_modules/@fluentui/react-icons": { - "version": "2.0.315", - "resolved": "https://registry.npmjs.org/@fluentui/react-icons/-/react-icons-2.0.315.tgz", - "integrity": "sha512-IITWAQGgU7I32eHPDHi+TUCUF6malP27wZLUV3bqjGVF/x/lfxvTIx8yqv/cxuwF3+ITGFDpl+278ZYJtOI7ww==", + "version": "2.0.320", + "resolved": "https://registry.npmjs.org/@fluentui/react-icons/-/react-icons-2.0.320.tgz", + "integrity": "sha512-NU4gErPeaTD/T6Z9g3Uvp898lIFS6fDLr3++vpT8pcI4Ds0fZqQdrwNi3dF0R/SVws8DXQaRYiGlPHxszo4J4g==", "license": "MIT", "dependencies": { "@griffel/react": "^1.0.0", diff --git a/src/app/frontend/package.json b/src/app/frontend/package.json index d7b11ff63..d70638e23 100644 --- a/src/app/frontend/package.json +++ b/src/app/frontend/package.json @@ -11,7 +11,7 @@ }, "dependencies": { "@fluentui/react-components": "^9.54.0", - "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-icons": "^2.0.320", "@reduxjs/toolkit": "^2.11.2", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/src/app/frontend/src/api/httpClient.ts b/src/app/frontend/src/api/httpClient.ts index 4689a6d98..6aaf83b48 100644 --- a/src/app/frontend/src/api/httpClient.ts +++ b/src/app/frontend/src/api/httpClient.ts @@ -170,15 +170,15 @@ const httpClient = new HttpClient('/api'); export const platformClient = new HttpClient('', 10_000); // ---- request interceptor: auth headers ---- -httpClient.onRequest((_url, init) => { +httpClient.onRequest(async (_url, init) => { const headers = new Headers(init.headers); // Attach userId from Redux store (lazy import to avoid circular deps). // Falls back to 'anonymous' if store isn't ready yet. try { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const { store } = require('../store/store'); - const userId: string = store.getState().app.userId || 'anonymous'; + const { store } = await import('../store/store'); + const state = store?.getState?.(); + const userId: string = state?.app?.userId ?? 'anonymous'; headers.set('X-Ms-Client-Principal-Id', userId); } catch { headers.set('X-Ms-Client-Principal-Id', 'anonymous'); diff --git a/src/app/frontend/src/api/index.ts b/src/app/frontend/src/api/index.ts index 5bb5d4017..b0c229ce9 100644 --- a/src/app/frontend/src/api/index.ts +++ b/src/app/frontend/src/api/index.ts @@ -11,34 +11,13 @@ import type { } from '../types'; import httpClient from './httpClient'; export { default as httpClient } from './httpClient'; -import { parseSSEStream, getGenerationStage } from '../utils'; +import { getGenerationStage } from '../utils'; /** Normalize optional userId to a safe fallback. */ function normalizeUserId(userId?: string): string { return userId || 'anonymous'; } -/** - * Validate an SSE response, extract its body reader, and yield parsed events. - * Consolidates the duplicated response → reader → parseSSEStream pipeline - * used by streamChat and streamRegenerateImage. - */ -async function* readSSEResponse( - response: Response, - context: string, -): AsyncGenerator { - if (!response.ok) { - throw new Error(`${context}: ${response.statusText}`); - } - - const reader = response.body?.getReader(); - if (!reader) { - throw new Error('No response body'); - } - - yield* parseSSEStream(reader); -} - /** * Get application configuration including feature flags */ @@ -55,8 +34,8 @@ export async function parseBrief( userId?: string, signal?: AbortSignal ): Promise { - return httpClient.post('/brief/parse', { - brief_text: briefText, + return httpClient.post('/chat', { + message: briefText, conversation_id: conversationId, user_id: normalizeUserId(userId), }, { signal }); @@ -87,8 +66,8 @@ export async function selectProducts( userId?: string, signal?: AbortSignal ): Promise<{ products: Product[]; action: string; message: string; conversation_id: string }> { - return httpClient.post('/products/select', { - request, + return httpClient.post('/chat', { + message: request, current_products: currentProducts, conversation_id: conversationId, user_id: normalizeUserId(userId), @@ -96,7 +75,10 @@ export async function selectProducts( } /** - * Stream chat messages from the agent orchestration + * Stream chat messages from the agent orchestration. + * + * Note: The /chat endpoint returns JSON (not SSE), so we perform a standard + * POST request and yield the single AgentResponse result. */ export async function* streamChat( message: string, @@ -104,18 +86,18 @@ export async function* streamChat( userId?: string, signal?: AbortSignal ): AsyncGenerator { - const response = await httpClient.raw('/chat', { - method: 'POST', - signal, - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ + const result = await httpClient.post( + '/chat', + { message, conversation_id: conversationId, user_id: normalizeUserId(userId), - }), - }); + }, + { signal }, + ); - yield* readSSEResponse(response, 'Chat request failed'); + // Preserve async-iterator interface by yielding the single JSON response. + yield result; } /** @@ -210,26 +192,67 @@ export async function* streamGenerateContent( */ export async function* streamRegenerateImage( modificationRequest: string, - brief: CreativeBrief, + _brief: CreativeBrief, products?: Product[], - previousImagePrompt?: string, + _previousImagePrompt?: string, conversationId?: string, userId?: string, signal?: AbortSignal ): AsyncGenerator { - const response = await httpClient.raw('/regenerate', { - method: 'POST', - signal, - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - modification_request: modificationRequest, - brief, - products: products || [], - previous_image_prompt: previousImagePrompt, + // Image regeneration uses the unified /chat endpoint with MODIFY_IMAGE intent, + // which returns a task_id for polling via /generate/status. + const startData = await httpClient.post<{ action_type: string; data: { task_id: string; poll_url: string }; conversation_id: string }>( + '/chat', + { + message: modificationRequest, conversation_id: conversationId, user_id: normalizeUserId(userId), - }), - }); + selected_products: products || [], + has_generated_content: true, + }, + { signal }, + ); + + const taskId = startData.data?.task_id; + if (!taskId) { + // If no task_id, the response is the final result itself + yield { type: 'agent_response', content: JSON.stringify(startData), is_final: true } as AgentResponse; + return; + } + + yield { type: 'status', content: 'Regeneration started...', is_final: false } as AgentResponse; + + let attempts = 0; + const maxAttempts = 600; + const pollInterval = 1000; + + while (attempts < maxAttempts) { + if (signal?.aborted) { + throw new DOMException('Regeneration cancelled by user', 'AbortError'); + } + + await new Promise(resolve => setTimeout(resolve, pollInterval)); + attempts++; + + if (signal?.aborted) { + throw new DOMException('Regeneration cancelled by user', 'AbortError'); + } + + const statusData = await httpClient.get<{ status: string; result?: unknown; error?: string }>( + `/generate/status/${taskId}`, + { signal }, + ); + + if (statusData.status === 'completed') { + yield { type: 'agent_response', content: JSON.stringify(statusData.result), is_final: true } as AgentResponse; + return; + } else if (statusData.status === 'failed') { + throw new Error(statusData.error || 'Regeneration failed'); + } else { + const { stage, message: stageMessage } = getGenerationStage(attempts); + yield { type: 'heartbeat', content: stageMessage, count: stage, elapsed: attempts, is_final: false } as AgentResponse; + } + } - yield* readSSEResponse(response, 'Regeneration request failed'); + throw new Error('Regeneration timed out after 10 minutes'); } \ No newline at end of file diff --git a/src/app/frontend/src/hooks/useConversationActions.ts b/src/app/frontend/src/hooks/useConversationActions.ts index 48346600d..5dd8e87b9 100644 --- a/src/app/frontend/src/hooks/useConversationActions.ts +++ b/src/app/frontend/src/hooks/useConversationActions.ts @@ -130,7 +130,7 @@ export function useConversationActions() { /* Start a new conversation */ /* ------------------------------------------------------------ */ const newConversation = useCallback(() => { - dispatch(resetChat()); + dispatch(resetChat(undefined)); dispatch(resetContent()); }, [dispatch]);