import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { flushSync } from 'react-dom'; import Snackbar from '@mui/material/Snackbar'; import Alert from '@mui/material/Alert'; import { FaCog, FaPaperPlane, FaStop, FaPlus, FaGoogle, FaMicrosoft, FaSlack } from 'react-icons/fa'; import IntialSetting from './IntialSetting'; import AddContentDropdown from './AiComponents/Dropdowns/AddContentDropdown'; import AddFilesDialog from './AiComponents/Dropdowns/AddFilesDialog'; import ChatWindow from './AiComponents/ChatWindow'; import RightSidebar from './AiComponents/Sidebars/RightSidebar'; import Notification from '../Components/AiComponents/Notifications/Notification'; import { useNotification } from '../Components/AiComponents/Notifications/useNotification'; import './AiPage.css'; function AiPage() { // Sidebar and other states const [isRightSidebarOpen, setRightSidebarOpen] = useState( localStorage.getItem("rightSidebarState") === "true" ); const [rightSidebarWidth, setRightSidebarWidth] = useState(300); const [sidebarContent, setSidebarContent] = useState("default"); const [searchText, setSearchText] = useState(""); const textAreaRef = useRef(null); const [showSettingsModal, setShowSettingsModal] = useState(false); const [showChatWindow, setShowChatWindow] = useState(false); const [chatBlocks, setChatBlocks] = useState([]); const [selectedChatBlockId, setSelectedChatBlockId] = useState(null); const addBtnRef = useRef(null); const chatAddBtnRef = useRef(null); const [isAddContentOpen, setAddContentOpen] = useState(false); const [isTooltipSuppressed, setIsTooltipSuppressed] = useState(false); const [isAddFilesDialogOpen, setIsAddFilesDialogOpen] = useState(false); const [defaultChatHeight, setDefaultChatHeight] = useState(null); const [chatBottomPadding, setChatBottomPadding] = useState("60px"); const [sessionContent, setSessionContent] = useState({ files: [], links: [] }); // States/refs for streaming const [isProcessing, setIsProcessing] = useState(false); const [activeBlockId, setActiveBlockId] = useState(null); const activeEventSourceRef = useRef(null); // State to track if we should auto-scroll to the bottom const [autoScrollEnabled, setAutoScrollEnabled] = useState(false); // Snackbar state const [snackbar, setSnackbar] = useState({ open: false, message: "", severity: "success", }); // State for tracking selected services const [selectedServices, setSelectedServices] = useState({ google: [], microsoft: [], slack: false }); // Notifications const { notifications, addNotification, removeNotification, updateNotification } = useNotification(); // Token management const tokenExpiryTimersRef = useRef({}); const notificationIdsRef = useRef({}); // Function to check if we are near the bottom of the page const checkIfNearBottom = (threshold = 400) => { const scrollTop = window.pageYOffset || document.documentElement.scrollTop; const scrollHeight = document.documentElement.scrollHeight; const clientHeight = window.innerHeight; const distanceFromBottom = scrollHeight - (scrollTop + clientHeight); return distanceFromBottom <= threshold; }; // Helper to scroll to bottom const scrollToBottom = (smooth = true) => { window.scrollTo({ top: document.documentElement.scrollHeight, behavior: smooth ? 'smooth' : 'auto' }); }; // Function to open the snackbar const openSnackbar = useCallback((message, severity = "success", duration) => { let finalDuration; if (duration !== undefined) { // If a specific duration is provided (e.g., 5000 or null), use it. finalDuration = duration; } else { // Otherwise, use the default logic. finalDuration = severity === 'success' ? 3000 : null; // Success auto-hides, others are persistent by default. } setSnackbar({ open: true, message, severity, duration: finalDuration }); }, []); // Function to close the snackbar const closeSnackbar = (event, reason) => { if (reason === 'clickaway') return; setSnackbar(prev => ({ ...prev, open: false, duration: null })); }; useEffect(() => { localStorage.setItem("rightSidebarState", isRightSidebarOpen); }, [isRightSidebarOpen]); // Add cleanup handler for when the user closes the tab/browser useEffect(() => { const handleCleanup = () => { navigator.sendBeacon('/cleanup'); }; window.addEventListener('beforeunload', handleCleanup); return () => window.removeEventListener('beforeunload', handleCleanup); }, []); useEffect(() => { document.documentElement.style.setProperty('--right-sidebar-width', rightSidebarWidth + 'px'); }, [rightSidebarWidth]); // Dynamically increase height of chat input field based on newlines entered useEffect(() => { if (textAreaRef.current) { if (!defaultChatHeight) { setDefaultChatHeight(textAreaRef.current.scrollHeight); } textAreaRef.current.style.height = "auto"; textAreaRef.current.style.overflowY = "hidden"; const newHeight = textAreaRef.current.scrollHeight; let finalHeight = newHeight; if (newHeight > 200) { finalHeight = 200; textAreaRef.current.style.overflowY = "auto"; } textAreaRef.current.style.height = `${finalHeight}px`; const minPaddingPx = 0; const maxPaddingPx = 59; let newPaddingPx = minPaddingPx; if (defaultChatHeight && finalHeight > defaultChatHeight) { newPaddingPx = minPaddingPx + ((finalHeight - defaultChatHeight) / (200 - defaultChatHeight)) * (maxPaddingPx - minPaddingPx); if (newPaddingPx > maxPaddingPx) newPaddingPx = maxPaddingPx; } setChatBottomPadding(`${newPaddingPx}px`); } }, [searchText, defaultChatHeight]); // Update backend whenever selected services change useEffect(() => { const updateSelectedServices = async () => { try { await fetch('/api/selected-services', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ services: selectedServices }) }); } catch (error) { console.error('Failed to update selected services:', error); } }; updateSelectedServices(); }, [selectedServices]); // Clear all tokens on page load useEffect(() => { // Clear all provider tokens on new tab/page load ['google', 'microsoft', 'slack'].forEach(provider => { sessionStorage.removeItem(`${provider}_token`); sessionStorage.removeItem(`${provider}_token_expiry`); }); // Clear any existing timers Object.values(tokenExpiryTimersRef.current).forEach(timer => clearTimeout(timer)); tokenExpiryTimersRef.current = {}; console.log('Cleared all tokens for new session'); }, []); const handleOpenRightSidebar = (content, chatBlockId = null) => { flushSync(() => { if (chatBlockId) { setSelectedChatBlockId(chatBlockId); } setSidebarContent(content ? content : "default"); setRightSidebarOpen(true); }); }; const handleEvaluationError = useCallback((blockId, errorMsg) => { setChatBlocks(prev => prev.map(block => block.id === blockId ? { ...block, isError: true, errorMessage: errorMsg } : block ) ); }, []); // Function to store token with expiry const storeTokenWithExpiry = (provider, token) => { const expiryTime = Date.now() + (60 * 60 * 1000); // 1 hour from now sessionStorage.setItem(`${provider}_token`, token); sessionStorage.setItem(`${provider}_token_expiry`, expiryTime.toString()); // Set up expiry timer setupTokenExpiryTimer(provider, expiryTime); }; // Function to check if token is valid const isTokenValid = (provider) => { const token = sessionStorage.getItem(`${provider}_token`); const expiry = sessionStorage.getItem(`${provider}_token_expiry`); if (!token || !expiry) return false; const expiryTime = parseInt(expiry); return Date.now() < expiryTime; }; // Function to get provider icon const getProviderIcon = useCallback((provider) => { switch (provider.toLowerCase()) { case 'google': return ; case 'microsoft': return ; case 'slack': return ; default: return null; } }, []); // Function to get provider color const getProviderColor = useCallback((provider) => { switch (provider.toLowerCase()) { case 'google': return '#4285F4'; case 'microsoft': return '#00A4EF'; case 'slack': return '#4A154B'; default: return '#666'; } }, []); // Function to set up timer for token expiry notification const setupTokenExpiryTimer = useCallback((provider, expiryTime) => { // Clear existing timer if any if (tokenExpiryTimersRef.current[provider]) { clearTimeout(tokenExpiryTimersRef.current[provider]); } // Remove any existing notification for this provider if (notificationIdsRef.current[provider]) { removeNotification(notificationIdsRef.current[provider]); delete notificationIdsRef.current[provider]; } const timeUntilExpiry = expiryTime - Date.now(); if (timeUntilExpiry > 0) { tokenExpiryTimersRef.current[provider] = setTimeout(() => { const providerName = provider.charAt(0).toUpperCase() + provider.slice(1); const providerColor = getProviderColor(provider); // Add notification const notificationId = addNotification({ type: 'warning', title: `${providerName} Authentication Expired`, message: `Your ${providerName} authentication has expired. Please reconnect to continue using ${providerName} services.`, icon: getProviderIcon(provider), dismissible: true, autoDismiss: false, actions: [ { id: 'reconnect', label: `Reconnect ${providerName}`, style: { background: providerColor, color: 'white', border: 'none' }, data: { provider } } ], style: { borderLeftColor: providerColor } }); // Store notification ID notificationIdsRef.current[provider] = notificationId; // Clear token data sessionStorage.removeItem(`${provider}_token`); sessionStorage.removeItem(`${provider}_token_expiry`); // Update selected services to reflect disconnection if (provider === 'slack') { setSelectedServices(prev => ({ ...prev, slack: false })); } else { setSelectedServices(prev => ({ ...prev, [provider]: [] })); } }, timeUntilExpiry); } }, [addNotification, getProviderColor, getProviderIcon, removeNotification, setSelectedServices]); // Check existing tokens on component mount and set up timers useEffect(() => { ['google', 'microsoft', 'slack'].forEach(provider => { const expiry = sessionStorage.getItem(`${provider}_token_expiry`); if (expiry) { const expiryTime = parseInt(expiry); if (Date.now() < expiryTime) { setupTokenExpiryTimer(provider, expiryTime); } else { // Token already expired, clear it sessionStorage.removeItem(`${provider}_token`); sessionStorage.removeItem(`${provider}_token_expiry`); } } }); // Cleanup timers on unmount return () => { Object.values(tokenExpiryTimersRef.current).forEach(timer => clearTimeout(timer)); }; }, [setupTokenExpiryTimer]); // Initiate the SSE const initiateSSE = (query, blockId) => { const startTime = Date.now(); const sseUrl = `/message-sse?user_message=${encodeURIComponent(query)}`; const eventSource = new EventSource(sseUrl); activeEventSourceRef.current = eventSource; eventSource.addEventListener("token", (e) => { const { chunk, index } = JSON.parse(e.data); console.log("[SSE token chunk]", JSON.stringify(chunk)); console.log("[SSE token index]", JSON.stringify(index)); setChatBlocks(prevBlocks => { return prevBlocks.map(block => { if (block.id === blockId) { const newTokenArray = block.tokenChunks ? [...block.tokenChunks] : []; newTokenArray[index] = chunk; return { ...block, tokenChunks: newTokenArray }; } return block; }); }); }); eventSource.addEventListener("final_message", (e) => { console.log("[SSE final message]", e.data); const endTime = Date.now(); const thinkingTime = ((endTime - startTime) / 1000).toFixed(1); // Only update thinkingTime so the streaming flag turns false and the cursor disappears setChatBlocks(prev => prev.map(block => block.id === blockId ? { ...block, thinkingTime } : block )); }); // Listen for the "final_sources" event to update sources in AI answer of this chat block. eventSource.addEventListener("final_sources", (e) => { try { const sources = JSON.parse(e.data); console.log("Final sources received:", sources); setChatBlocks(prev => prev.map(block => block.id === blockId ? { ...block, finalSources: sources } : block )); } catch (err) { console.error("Error parsing final_sources event:", err); } }); // Listen for the "complete" event to know when to close the connection. eventSource.addEventListener("complete", (e) => { console.log("Complete event received:", e.data); eventSource.close(); activeEventSourceRef.current = null; setIsProcessing(false); setActiveBlockId(null); }); // Update actions for only this chat block. eventSource.addEventListener("action", (e) => { try { const actionData = JSON.parse(e.data); console.log("Action event received:", actionData); setChatBlocks(prev => prev.map(block => { if (block.id === blockId) { let updatedBlock = { ...block, actions: [...(block.actions || []), actionData] }; if (actionData.name === "sources") { updatedBlock.sources = actionData.payload; } if (actionData.name === "graph") { updatedBlock.graph = actionData.payload; } return updatedBlock; } return block; })); } catch (err) { console.error("Error parsing action event:", err); } }); // Update the error for this chat block. eventSource.addEventListener("error", (e) => { console.error("Error from SSE:", e.data); setChatBlocks(prev => prev.map(block => block.id === blockId ? { ...block, isError: true, errorMessage: e.data, aiAnswer: "", tasks: [] } : block )); eventSource.close(); activeEventSourceRef.current = null; setIsProcessing(false); setActiveBlockId(null); }); eventSource.addEventListener("step", (e) => { console.log("Step event received:", e.data); setChatBlocks(prev => prev.map(block => block.id === blockId ? { ...block, thoughtLabel: e.data } : block )); }); eventSource.addEventListener("sources_read", (e) => { console.log("Sources read event received:", e.data); try { const parsed = JSON.parse(e.data); let count; if (typeof parsed === 'number') { count = parsed; } else if (parsed && typeof parsed.count === 'number') { count = parsed.count; } if (typeof count === 'number') { setChatBlocks(prev => prev.map(block => block.id === blockId ? { ...block, sourcesRead: count, sources: parsed.sources || [] } : block )); } } catch(err) { if (e.data.trim() !== "") { setChatBlocks(prev => prev.map(block => block.id === blockId ? { ...block, sourcesRead: e.data } : block )); } } }); eventSource.addEventListener("task", (e) => { console.log("Task event received:", e.data); try { const taskData = JSON.parse(e.data); setChatBlocks(prev => prev.map(block => { if (block.id === blockId) { const existingTaskIndex = (block.tasks || []).findIndex(t => t.task === taskData.task); if (existingTaskIndex !== -1) { const updatedTasks = [...block.tasks]; updatedTasks[existingTaskIndex] = { ...updatedTasks[existingTaskIndex], status: taskData.status }; return { ...block, tasks: updatedTasks }; } else { return { ...block, tasks: [...(block.tasks || []), taskData] }; } } return block; })); } catch (error) { console.error("Error parsing task event:", error); } }); }; // Create a new chat block and initiate the SSE const handleSend = () => { if (!searchText.trim()) return; // Check if user is near bottom before adding new block const shouldScroll = checkIfNearBottom(1000); // 1000px threshold setAutoScrollEnabled(shouldScroll); const blockId = new Date().getTime(); setActiveBlockId(blockId); setIsProcessing(true); setChatBlocks(prev => [ ...prev, { id: blockId, userMessage: searchText, tokenChunks: [], aiAnswer: "", thinkingTime: null, thoughtLabel: "", sourcesRead: "", tasks: [], sources: [], actions: [] } ]); setShowChatWindow(true); const query = searchText; setSearchText(""); initiateSSE(query, blockId); }; const handleKeyDown = (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); if (!isProcessing) { handleSend(); } } }; // Auto-scroll when chat block is added useEffect(() => { if (autoScrollEnabled && isProcessing) { scrollToBottom(); } }, [isProcessing, autoScrollEnabled]); // Stop the user request and close the active SSE connection const handleStop = async () => { // Close the active SSE connection if it exists if (activeEventSourceRef.current) { activeEventSourceRef.current.close(); activeEventSourceRef.current = null; } // Send POST request to /stop and update the chat block with the returned message try { const response = await fetch('/stop', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) }); const data = await response.json(); if (activeBlockId) { setChatBlocks(prev => prev.map(block => block.id === activeBlockId ? { ...block, aiAnswer: data.message, thinkingTime: 0, tasks: [] } : block )); } } catch (error) { console.error("Error stopping the request:", error); if (activeBlockId) { setChatBlocks(prev => prev.map(block => block.id === activeBlockId ? { ...block, aiAnswer: "Error stopping task", thinkingTime: 0, tasks: [] } : block )); } } setIsProcessing(false); setActiveBlockId(null); }; const handleSendButtonClick = () => { if (searchText.trim()) handleSend(); }; // Toggle the Add Content dropdown const handleToggleAddContent = (event) => { event.stopPropagation(); // Prevents the click from closing the menu immediately // If we are about to close the dropdown, suppress the tooltip. if (isAddContentOpen) { setIsTooltipSuppressed(true); } setAddContentOpen(prev => !prev); }; // Handle mouse enter on the Add Content button to suppress tooltip const handleMouseLeaveAddBtn = () => { setIsTooltipSuppressed(false); }; // Close the Add Content dropdown const closeAddContentDropdown = () => { setAddContentOpen(false); }; // Open the Add Files dialog const handleOpenAddFilesDialog = () => { setAddContentOpen(false); // Close the dropdown when opening the dialog setIsAddFilesDialogOpen(true); }; // Fetch excerpts for a specific block const handleFetchExcerpts = useCallback(async (blockId) => { let blockIndex = -1; let currentBlock = null; // Find the block to check its current state setChatBlocks(prev => { blockIndex = prev.findIndex(b => b.id === blockId); if (blockIndex !== -1) { currentBlock = prev[blockIndex]; } // No state change here, just reading the state return prev; }); // Prevent fetching if already loaded or currently loading if (blockIndex === -1 || !currentBlock || currentBlock.excerptsData || currentBlock.isLoadingExcerpts) return; // Set loading state for the specific block setChatBlocks(prev => prev.map(b => b.id === blockId ? { ...b, isLoadingExcerpts: true } : b )); try { // Call the backend endpoint to get excerpts const response = await fetch('/action/excerpts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ blockId: blockId }) }); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.detail || `HTTP error! status: ${response.status}`); } const data = await response.json(); console.log("Fetched excerpts data from backend:", data.result); // Update the specific block with the fetched excerptsData setChatBlocks(prev => prev.map(b => b.id === blockId ? { ...b, excerptsData: data.result, // Store the fetched data isLoadingExcerpts: false, // Turn off loading } : b )); openSnackbar("Excerpts loaded successfully!", "success"); } catch (error) { console.error("Error requesting excerpts:", error); // Reset loading state on error setChatBlocks(prev => prev.map(b => b.id === blockId ? { ...b, isLoadingExcerpts: false } : b )); openSnackbar(`Failed to load excerpts`, "error"); } }, [openSnackbar]); // Function to handle notification actions const handleNotificationAction = (notificationId, actionId, actionData) => { console.log('Notification action triggered:', { notificationId, actionId, actionData }); // Handle both 'reconnect' and 'connect' actions if ((actionId === 'reconnect' || actionId === 'connect') && actionData?.provider) { // Remove the notification removeNotification(notificationId); // Clean up stored notification ID if it exists if (notificationIdsRef.current[actionData.provider] === notificationId) { delete notificationIdsRef.current[actionData.provider]; } // Trigger authentication initiateOAuth(actionData.provider); } }; // Function to initiate OAuth const initiateOAuth = (provider) => { const authUrls = { google: `https://accounts.google.com/o/oauth2/v2/auth?` + `client_id=${process.env.REACT_APP_GOOGLE_CLIENT_ID}&` + `response_type=token&` + `scope=email profile https://www.googleapis.com/auth/drive.readonly https://www.googleapis.com/auth/gmail.readonly https://www.googleapis.com/auth/calendar.readonly https://www.googleapis.com/auth/tasks.readonly&` + `redirect_uri=${window.location.origin}/auth-receiver.html&` + `prompt=select_account`, microsoft: `https://login.microsoftonline.com/common/oauth2/v2.0/authorize?` + `client_id=${process.env.REACT_APP_MICROSOFT_CLIENT_ID}&` + `response_type=token&` + `scope=openid profile email Files.Read.All Mail.Read Calendars.Read Tasks.Read Notes.Read&` + `redirect_uri=${window.location.origin}/auth-receiver.html&` + `response_mode=fragment&` + `prompt=select_account`, slack: `https://slack.com/oauth/v2/authorize?` + `client_id=${process.env.REACT_APP_SLACK_CLIENT_ID}&` + `scope=channels:read,channels:history,files:read,groups:read,im:read,mpim:read,search:read,users:read&` + `redirect_uri=${window.location.origin}/auth-receiver.html` }; const authWindow = window.open( authUrls[provider], 'Connect Account', 'width=600,height=700,left=200,top=100' ); // Show connecting notification const connectingNotificationId = addNotification({ type: 'info', title: `Connecting to ${provider.charAt(0).toUpperCase() + provider.slice(1)}`, message: 'Please complete the authentication in the popup window...', icon: getProviderIcon(provider), dismissible: false, autoDismiss: false }); // Set up message listener const messageHandler = async (event) => { if (event.origin !== window.location.origin) return; if (event.data.type === 'auth-success') { const { token } = event.data; // Remove connecting notification removeNotification(connectingNotificationId); // Store token with expiry storeTokenWithExpiry(provider, token); // Send token to backend try { const response = await fetch('/api/session-token', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ provider, token }) }); if (response.ok) { // Show success notification addNotification({ type: 'success', title: 'Connected Successfully', message: `Successfully connected to ${provider.charAt(0).toUpperCase() + provider.slice(1)}!`, icon: getProviderIcon(provider), autoDismiss: true, duration: 3000, showProgress: true }); } } catch (error) { console.error(`Failed to connect to ${provider}:`, error); addNotification({ type: 'error', title: 'Connection Failed', message: `Failed to connect to ${provider.charAt(0).toUpperCase() + provider.slice(1)}. Please try again.`, autoDismiss: true, duration: 5000 }); } window.removeEventListener('message', messageHandler); } else if (event.data.type === 'auth-failed') { // Remove connecting notification removeNotification(connectingNotificationId); // Show error notification addNotification({ type: 'error', title: 'Authentication Failed', message: `Failed to authenticate with ${provider.charAt(0).toUpperCase() + provider.slice(1)}. Please try again.`, autoDismiss: true, duration: 5000 }); window.removeEventListener('message', messageHandler); } }; window.addEventListener('message', messageHandler); // Handle if user closes the popup without authenticating const checkInterval = setInterval(() => { if (authWindow.closed) { clearInterval(checkInterval); removeNotification(connectingNotificationId); window.removeEventListener('message', messageHandler); } }, 1000); }; // Handle service selection from dropdown const handleServiceClick = useCallback((provider, service) => { // Toggle selection if (provider === 'slack') { setSelectedServices(prev => ({ ...prev, slack: !prev.slack })); } else { setSelectedServices(prev => ({ ...prev, [provider]: prev[provider].includes(service) ? prev[provider].filter(s => s !== service) : [...prev[provider], service] })); } // Check if token is valid if (!isTokenValid(provider)) { // Show notification prompting to authenticate const notificationId = addNotification({ type: 'info', title: 'Authentication Required', message: `Please connect your ${provider.charAt(0).toUpperCase() + provider.slice(1)} account to use this service.`, icon: getProviderIcon(provider), actions: [ { id: 'connect', label: `Connect ${provider.charAt(0).toUpperCase() + provider.slice(1)}`, style: { background: getProviderColor(provider), color: 'white', border: 'none' }, data: { provider } } ], autoDismiss: true, duration: 5000, showProgress: true }); } }, [addNotification, getProviderIcon, getProviderColor]); // Get the chat block whose details should be shown in the sidebar. const selectedBlock = chatBlocks.find(block => block.id === selectedChatBlockId); const evaluateAction = selectedBlock && selectedBlock.actions ? selectedBlock.actions.find(a => a.name === "evaluate") : null; // Memoized evaluation object const evaluation = useMemo(() => { if (!evaluateAction) return null; return { ...evaluateAction.payload, blockId: selectedBlock?.id, onError: handleEvaluationError, }; }, [evaluateAction, selectedBlock?.id, handleEvaluationError]); return (
{showChatWindow && selectedBlock && (sidebarContent !== "default" || (selectedBlock.tasks && selectedBlock.tasks.length > 0) || (selectedBlock.sources && selectedBlock.sources.length > 0)) && (
setRightSidebarOpen(!isRightSidebarOpen)} sidebarContent={sidebarContent} tasks={selectedBlock.tasks || []} tasksLoading={false} sources={selectedBlock.sources || []} sourcesLoading={false} onSourceClick={(source) => { if (!source || !source.link) return; window.open(source.link, '_blank'); }} evaluation={evaluation} />
)}
{showChatWindow ? ( <>
{chatBlocks.map((block) => ( { /* if needed */ }} isError={block.isError} errorMessage={block.errorMessage} /> ))}