UNPKG

@handit.ai/onboarding

Version:

Interactive onboarding components and service for AI agents

691 lines (664 loc) 24.7 kB
import React, { useState, useEffect } from 'react'; import { Dialog, DialogTitle, DialogContent, DialogActions, Box, Typography, Button, IconButton, Paper, Divider, Tooltip, Chip, } from '@mui/material'; import { X, Copy, Code, CheckCircle, ArrowRight, Key, XCircle, } from '@phosphor-icons/react'; import CodeRenderer from './CodeRenderer'; // Note: authService should be provided by the consuming application // import { useGetUserQuery } from '../../services/auth/authService'; import Snackbar from '@mui/material/Snackbar'; import Alert from '@mui/material/Alert'; const OnboardingFullGuide = ({ visible, onClose, content = '' }) => { const [isVisible, setIsVisible] = useState(visible); // const { data: userData } = useGetUserQuery(); const userData = null; // Should be provided by consuming application // Get current environment const environment = React.useMemo(() => { if (typeof window !== 'undefined') { return localStorage.getItem('environment') || 'production'; } return 'production'; }, []); // Get the appropriate API token based on environment const apiToken = React.useMemo(() => { if (environment === 'staging') { return userData?.company?.stagingApiToken; } return userData?.company?.apiToken; }, [environment, userData?.company]); const [testLoading, setTestLoading] = useState(false); const [testError, setTestError] = useState(false); const [testSuccess, setTestSuccess] = useState(false); const [showBanner, setShowBanner] = useState(false); useEffect(() => { setIsVisible(visible); }, [visible]); useEffect(() => { const handleShowGuide = (event) => { setIsVisible(true); }; window.addEventListener('onboarding:show-full-guide', handleShowGuide); return () => { window.removeEventListener('onboarding:show-full-guide', handleShowGuide); }; }, []); const copyToClipboard = (text) => { navigator.clipboard.writeText(text); }; const handleTestConnection = async () => { setTestLoading(true); setTestError(false); setTestSuccess(false); try { const token = localStorage.getItem('custom-auth-token'); const headers = { 'Content-Type': 'application/json', }; if (token) { headers['Authorization'] = `Bearer ${token}`; } const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/setup-assistant/test-connection`, { method: 'POST', headers, body: JSON.stringify({}) }); const result = await response.json(); if (result?.connected) { setTestSuccess(true); setShowBanner(true); setTestError(false); // Notify onboarding orchestrator to advance to next step window.dispatchEvent(new CustomEvent('onboarding:connection-success', { detail: { success: true } })); } else { setTestError(true); setTestSuccess(false); } } catch (e) { setTestError(true); setTestSuccess(false); } finally { setTestLoading(false); } }; const handleBannerClose = () => setShowBanner(false); // Helper to render text with **bold** inline function renderInlineBold(text) { // Replace **bold** with <span style={{fontWeight:700}}>bold</span> const parts = []; let lastIndex = 0; const regex = /\*\*(.+?)\*\*/g; let match; let key = 0; while ((match = regex.exec(text)) !== null) { if (match.index > lastIndex) { parts.push(text.slice(lastIndex, match.index)); } parts.push( <span key={`bold-${key++}`} style={{ fontWeight: 700 }}>{match[1]}</span> ); lastIndex = match.index + match[0].length; } if (lastIndex < text.length) { parts.push(text.slice(lastIndex)); } return parts; } const renderMarkdownContent = (markdownContent) => { if (!markdownContent) { return ( <Typography variant="body2" color="text.secondary" sx={{ fontStyle: 'italic' }}> No setup guide available </Typography> ); } const elements = []; let currentSection = []; let inCodeBlock = false; let codeBlockLang = 'text'; let codeBlockLines = []; const lines = markdownContent.split('\n'); lines.forEach((line, index) => { // Detect start/end of code block if (line.trim().startsWith('```')) { if (!inCodeBlock) { // Starting a code block inCodeBlock = true; codeBlockLang = line.trim().slice(3).trim() || 'text'; // Push any previous text section if (currentSection.length > 0) { elements.push( <Typography key={`section-${elements.length}`} variant="body1" sx={{ mb: 2, whiteSpace: 'pre-wrap', color: 'var(--mui-palette-text-secondary)' }}> {currentSection.join('\n')} </Typography> ); currentSection = []; } } else { // Ending a code block inCodeBlock = false; elements.push( <Box key={`code-${elements.length}`} sx={{ mb: 3, position: 'relative' }}> <Paper sx={{ overflow: 'hidden', bgcolor: '#1e1e1e', border: '1px solid rgba(255, 255, 255, 0.1)', boxShadow: 'none', }} > <CodeRenderer code={codeBlockLines.join('\n')} language={codeBlockLang} showLineNumbers={false} /> </Paper> <Tooltip title="Copy code"> <IconButton onClick={() => copyToClipboard(codeBlockLines.join('\n'))} sx={{ position: 'absolute', top: 8, right: 8, bgcolor: 'rgba(0, 0, 0, 0.6)', color: 'white', width: 24, height: 24, '&:hover': { bgcolor: 'rgba(0, 0, 0, 0.8)' } }} > <Copy size={12} /> </IconButton> </Tooltip> </Box> ); codeBlockLines = []; codeBlockLang = 'text'; } return; } if (inCodeBlock) { codeBlockLines.push(line); return; } // Detect indented code block (4 spaces or tab) if (/^( |\t)/.test(line)) { // If previous section exists, push it if (currentSection.length > 0) { elements.push( <Typography key={`section-${elements.length}`} variant="body1" sx={{ mb: 2, whiteSpace: 'pre-wrap', color: 'var(--mui-palette-text-secondary)' }}> {currentSection.join('\n')} </Typography> ); currentSection = []; } // Collect all consecutive indented lines as code let codeLines = [line.replace(/^( |\t)/, '')]; let nextIndex = index + 1; while (nextIndex < lines.length && /^( |\t)/.test(lines[nextIndex])) { codeLines.push(lines[nextIndex].replace(/^( |\t)/, '')); nextIndex++; } elements.push( <Box key={`code-${elements.length}`} sx={{ mb: 3, position: 'relative' }}> <Paper sx={{ overflow: 'hidden', bgcolor: '#1e1e1e', border: '1px solid rgba(255, 255, 255, 0.1)', boxShadow: 'none', }} > <CodeRenderer code={codeLines.join('\n')} language={'text'} showLineNumbers={false} /> </Paper> <Tooltip title="Copy code"> <IconButton onClick={() => copyToClipboard(codeLines.join('\n'))} sx={{ position: 'absolute', top: 8, right: 8, bgcolor: 'rgba(0, 0, 0, 0.6)', color: 'white', width: 24, height: 24, '&:hover': { bgcolor: 'rgba(0, 0, 0, 0.8)' } }} > <Copy size={12} /> </IconButton> </Tooltip> </Box> ); // Skip the lines we just processed for (let i = index + 1; i < nextIndex; i++) lines[i] = ''; return; } // Handle headers (#, ##, ###, ####) const headerMatch = line.match(/^(#{1,4})\s+(.+)$/); if (headerMatch) { if (currentSection.length > 0) { // Render previous section as paragraphs const paragraphs = []; let para = []; for (let i = 0; i < currentSection.length; i++) { const l = currentSection[i]; if (!l.trim()) { if (para.length > 0) { paragraphs.push(para); para = []; } } else { para.push(l); } } if (para.length > 0) paragraphs.push(para); paragraphs.forEach((lines, idx) => { elements.push( <Typography key={`section-${elements.length}-${idx}`} variant="body1" sx={{ mb: 2, whiteSpace: 'pre-wrap', color: 'var(--mui-palette-text-secondary)' }}> {lines.map((l, i) => <React.Fragment key={i}>{renderInlineBold(l)}{i < lines.length - 1 ? <br /> : null}</React.Fragment>)} </Typography> ); }); currentSection = []; } const level = headerMatch[1].length; const text = headerMatch[2]; let variant, styleProps; if (level === 1) { variant = 'h5'; styleProps = { fontWeight: 600, mt: elements.length > 0 ? 3 : 0, mb: 2, color: 'var(--mui-palette-primary-400)' }; } else if (level === 2) { variant = 'h6'; styleProps = { fontWeight: 600, mt: elements.length > 0 ? 3 : 0, mb: 2, color: 'var(--mui-palette-primary-400)' }; } else if (level === 3) { variant = 'subtitle1'; styleProps = { fontWeight: 700, mb: 1, color: 'var(--mui-palette-primary-400)' }; } else if (level === 4) { variant = 'subtitle1'; styleProps = { fontWeight: 700, mb: 1, color: 'var(--mui-palette-text-secondary)' }; } elements.push( <Typography key={`header-${elements.length}`} variant={variant} sx={styleProps} > {text} </Typography> ); return; } // Handle numbered bold step titles (e.g., '1. **Install the SDK:**') const numberedBoldMatch = line.match(/^(\d+\.)\s+\*\*(.+?)\*\*:?\s*(.*)$/); if (numberedBoldMatch) { if (currentSection.length > 0) { elements.push( <Typography key={`section-${elements.length}`} variant="body1" sx={{ mb: 2, whiteSpace: 'pre-wrap', color: 'var(--mui-palette-text-secondary)' }}> {currentSection.join('\n')} </Typography> ); currentSection = []; } // Render the bolded step title elements.push( <Typography key={`step-title-${elements.length}`} variant="subtitle1" sx={{ fontWeight: 700, mb: 1, color: 'white' }}> {numberedBoldMatch[1]} <span style={{ fontWeight: 700 }}>{numberedBoldMatch[2]}</span>{numberedBoldMatch[3] ? ':' : ''} </Typography> ); // If there's additional text after the bold, render it as normal text if (numberedBoldMatch[3]) { elements.push( <Typography key={`step-desc-${elements.length}`} variant="body1" sx={{ mb: 2, color: 'var(--mui-palette-text-secondary)' }}> {renderInlineBold(numberedBoldMatch[3])} </Typography> ); } return; } // Handle dash bold step subtitles (e.g., '- **Initialize Handit.ai Service:**') const dashBoldMatch = line.match(/^\s*-\s+\*\*(.+?)\*\*:?\s*(.*)$/); if (dashBoldMatch) { if (currentSection.length > 0) { elements.push( <Typography key={`section-${elements.length}`} variant="body1" sx={{ mb: 2, whiteSpace: 'pre-wrap', color: 'var(--mui-palette-text-secondary)' }}> {currentSection.map((l, i) => <React.Fragment key={i}>{renderInlineBold(l)}{i < currentSection.length - 1 ? <br /> : null}</React.Fragment>)} </Typography> ); currentSection = []; } elements.push( <Typography key={`dash-title-${elements.length}`} variant="subtitle1" sx={{ fontWeight: 700, mb: 1, color: 'var(--mui-palette-text-secondary)' }}> <span style={{ fontWeight: 700 }}>{dashBoldMatch[1]}</span>{dashBoldMatch[2] ? ':' : ''} </Typography> ); if (dashBoldMatch[2]) { elements.push( <Typography key={`dash-desc-${elements.length}`} variant="body1" sx={{ mb: 2, color: 'var(--mui-palette-text-secondary)' }}> {renderInlineBold(dashBoldMatch[2])} </Typography> ); } return; } // Handle full-line bold (e.g., '**Step 2: Get Your Integration Token**') const fullLineBoldMatch = line.match(/^\*\*(.+)\*\*$/); if (fullLineBoldMatch) { if (currentSection.length > 0) { // Render previous section as paragraphs const paragraphs = []; let para = []; for (let i = 0; i < currentSection.length; i++) { const l = currentSection[i]; if (!l.trim()) { if (para.length > 0) { paragraphs.push(para); para = []; } } else { para.push(l); } } if (para.length > 0) paragraphs.push(para); paragraphs.forEach((lines, idx) => { elements.push( <Typography key={`section-${elements.length}-${idx}`} variant="body1" sx={{ mb: 2, whiteSpace: 'pre-wrap', color: 'var(--mui-palette-text-secondary)' }}> {lines.map((l, i) => <React.Fragment key={i}>{renderInlineBold(l)}{i < lines.length - 1 ? <br /> : null}</React.Fragment>)} </Typography> ); }); currentSection = []; } elements.push( <Typography key={`fullbold-title-${elements.length}`} variant="subtitle1" sx={{ fontWeight: 700, mb: 1, color: 'var(--mui-palette-text-secondary)' }}> {fullLineBoldMatch[1]} </Typography> ); return; } // Regular text or list currentSection.push(line); }); // Add any remaining text section if (currentSection.length > 0) { // Split into paragraphs at blank lines const paragraphs = []; let para = []; for (let i = 0; i < currentSection.length; i++) { const line = currentSection[i]; if (!line.trim()) { if (para.length > 0) { paragraphs.push(para); para = []; } } else { para.push(line); } } if (para.length > 0) paragraphs.push(para); // Render each paragraph as a Typography, separated by margin paragraphs.forEach((lines, idx) => { elements.push( <Typography key={`section-${elements.length}-${idx}`} variant="body1" sx={{ mb: 2, whiteSpace: 'pre-wrap', color: 'var(--mui-palette-text-secondary)' }}> {lines.map((l, i) => <React.Fragment key={i}>{renderInlineBold(l)}{i < lines.length - 1 ? <br /> : null}</React.Fragment>)} </Typography> ); }); } return <Box>{elements}</Box>; }; if (!isVisible) return null; return ( <> {/* Backdrop */} <Box sx={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, zIndex: 100, backgroundColor: 'rgba(0, 0, 0, 0.5)', backdropFilter: 'blur(4px)', }} onClick={onClose} /> {/* Guide Modal */} <Box sx={{ position: 'fixed', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', zIndex: 10000, width: '90%', maxWidth: '800px', maxHeight: '90vh', bgcolor: 'background.paper', borderRadius: 2, boxShadow: '0 8px 32px rgba(0, 0, 0, 0.3)', display: 'flex', flexDirection: 'column', overflow: 'hidden', }} onClick={(e) => e.stopPropagation()} > {/* Header */} <Box sx={{ px: 3, py: 2, borderBottom: '1px solid', borderColor: 'divider', display: 'flex', alignItems: 'center', justifyContent: 'space-between', }} > <Typography variant="h5" fontWeight="600"> Agent Setup Guide </Typography> <IconButton onClick={onClose} size="small"> <X size={20} /> </IconButton> </Box> {/* Content */} <Box sx={{ flex: 1, overflow: 'auto', px: 3, py: 2, pb: 10, // Extra padding for fixed button }} > {/* API Token Section */} <Box sx={{ mb: 4 }}> <Typography variant="h6" sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1, color: 'var(--mui-palette-text-secondary)' }}> <Key size={20} color="var(--mui-palette-primary-400)" /> Your API Token <Chip label={environment === 'staging' ? 'Staging' : 'Production'} size="small" color={environment === 'staging' ? 'warning' : 'primary'} variant="outlined" /> </Typography> <Paper sx={{ p: 2, bgcolor: 'transparent', border: 'none', position: 'relative', boxShadow: 'none', }} > {apiToken ? ( <> <Typography variant="body2" sx={{ mb: 1, color: 'var(--mui-palette-text-secondary)' }}> Use this API token in your agent configuration: </Typography> <Box sx={{ position: 'relative' }}> <CodeRenderer code={apiToken} language="text" showLineNumbers={false} /> <Tooltip title="Copy API token"> <IconButton onClick={() => copyToClipboard(apiToken)} sx={{ position: 'absolute', top: 4, right: 4, bgcolor: 'rgba(0, 0, 0, 0.6)', color: 'white', width: 24, height: 24, '&:hover': { bgcolor: 'rgba(0, 0, 0, 0.8)' } }} > <Copy size={12} /> </IconButton> </Tooltip> </Box> </> ) : ( <Typography variant="body2" sx={{ fontStyle: 'italic', color: 'var(--mui-palette-text-secondary)' }}> API token not available. Please check your account settings. </Typography> )} </Paper> </Box> {/* AI Assistant Guide Section */} <Box sx={{ mb: 4 }}> <Typography variant="h6" sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1, color: 'var(--mui-palette-text-secondary)' }}> <CheckCircle size={20} color="var(--mui-palette-primary-400)" /> Setup Guide </Typography> <Paper sx={{ p: 3, bgcolor: 'transparent', border: 'none', boxShadow: 'none', }} > {renderMarkdownContent(content)} </Paper> </Box> {/* Test Connection Step */} <Box sx={{ mb: 4 }}> <Typography variant="h6" sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1, color: 'var(--mui-palette-text-secondary)' }}> <ArrowRight size={20} color="var(--mui-palette-primary-400)" /> Test Your Connection </Typography> <Paper sx={{ p: 2, bgcolor: 'transparent', border: 'none', boxShadow: 'none' }}> <Typography variant="body1" sx={{ mb: 2, color: 'var(--mui-palette-text-secondary)' }}> Once you've followed the setup guide above and integrated the API token, use the "Test Connection" button below to verify everything is working correctly. </Typography> <Typography variant="body2" sx={{ color: 'var(--mui-palette-text-secondary)' }}> This will check if your agent can successfully communicate with Handit's monitoring system. </Typography> {testError && ( <Typography variant="body2" color="error" sx={{ mt: 2, display: 'flex', alignItems: 'center', gap: 1 }}> <XCircle size={18} color="#e53935" style={{ marginRight: 4 }} /> Connection failed. Please check your setup and try again. </Typography> )} </Paper> </Box> </Box> {/* Fixed Bottom Actions */} <Box sx={{ position: 'absolute', bottom: 0, left: 0, right: 0, p: 2, bgcolor: 'background.paper', borderTop: '1px solid', borderColor: 'divider', display: 'flex', justifyContent: 'space-between', alignItems: 'center', boxShadow: '0 -2px 8px rgba(0, 0, 0, 0.1)', }} > <Button variant="outlined" onClick={onClose} sx={{ minWidth: 100 }} disabled={testLoading} data-testid="guide-close-button" > Close </Button> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> {testError && ( <XCircle size={20} color="#e53935" style={{ marginRight: 4 }} /> )} <Button variant="outlined" onClick={handleTestConnection} data-testid="guide-test-connection-button" sx={{ bgcolor: 'primary.main', color: 'black', '&:hover': { bgcolor: 'var(--mui-palette-secondary-main)' }, minWidth: 140, position: 'relative', pl: testLoading ? 4 : 2, }} disabled={testLoading} > {testLoading ? ( <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <span style={{ marginRight: 8 }}>Testing...</span> <span className="MuiCircularProgress-root MuiCircularProgress-indeterminate" style={{ display: 'inline-block', width: 20, height: 20 }}> <svg viewBox="22 22 44 44" style={{ width: 20, height: 20 }}> <circle cx="44" cy="44" r="20.2" fill="none" strokeWidth="3.6" strokeMiterlimit="20" stroke="currentColor" style={{ color: '#1976d2', opacity: 0.7 }} /> </svg> </span> </Box> ) : ( 'Test Connection' )} </Button> </Box> </Box> {/* Success Banner */} <Snackbar open={showBanner} autoHideDuration={4000} onClose={handleBannerClose} anchorOrigin={{ vertical: 'top', horizontal: 'center' }}> <Alert onClose={handleBannerClose} severity="success" sx={{ width: '100%' }}> Connection successful! Your agent is now connected to Handit. </Alert> </Snackbar> </Box> </> ); }; export default OnboardingFullGuide;