feat: ui enhancements and fixes
This commit is contained in:
@@ -542,12 +542,12 @@ router.post('/recordings/llm', requireSignIn, async (req: AuthenticatedRequest,
|
|||||||
|
|
||||||
logger.log('info', `Starting LLM workflow generation for URL: ${url}`);
|
logger.log('info', `Starting LLM workflow generation for URL: ${url}`);
|
||||||
|
|
||||||
const workflowResult = await WorkflowEnricher.generateWorkflowFromPrompt(url, prompt, {
|
const workflowResult = await WorkflowEnricher.generateWorkflowFromPrompt(url, prompt, req.user.id, {
|
||||||
provider: llmProvider || 'ollama',
|
provider: llmProvider || 'ollama',
|
||||||
model: llmModel,
|
model: llmModel,
|
||||||
apiKey: llmApiKey,
|
apiKey: llmApiKey,
|
||||||
baseUrl: llmBaseUrl
|
baseUrl: llmBaseUrl
|
||||||
}, req.user.id);
|
});
|
||||||
|
|
||||||
if (!workflowResult.success || !workflowResult.workflow) {
|
if (!workflowResult.success || !workflowResult.workflow) {
|
||||||
logger.log('error', `Failed to generate workflow: ${JSON.stringify(workflowResult.errors)}`);
|
logger.log('error', `Failed to generate workflow: ${JSON.stringify(workflowResult.errors)}`);
|
||||||
@@ -1321,4 +1321,4 @@ export async function recoverOrphanedRuns() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export { processQueuedRuns };
|
export { processQueuedRuns };
|
||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
MenuItem,
|
MenuItem,
|
||||||
InputLabel
|
InputLabel
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { ArrowBack, PlayCircleOutline, Article, Code, Description } from '@mui/icons-material';
|
import { ArrowBack, PlayCircleOutline, Article, Code, Description, SmartToy } from '@mui/icons-material';
|
||||||
import { useGlobalInfoStore, useCacheInvalidation } from '../../../context/globalInfo';
|
import { useGlobalInfoStore, useCacheInvalidation } from '../../../context/globalInfo';
|
||||||
import { canCreateBrowserInState, getActiveBrowserId, stopRecording } from '../../../api/recording';
|
import { canCreateBrowserInState, getActiveBrowserId, stopRecording } from '../../../api/recording';
|
||||||
import { createScrapeRobot, createLLMRobot, createAndRunRecording } from "../../../api/storage";
|
import { createScrapeRobot, createLLMRobot, createAndRunRecording } from "../../../api/storage";
|
||||||
@@ -59,11 +59,13 @@ const RobotCreate: React.FC = () => {
|
|||||||
const [tabValue, setTabValue] = useState(0);
|
const [tabValue, setTabValue] = useState(0);
|
||||||
const [url, setUrl] = useState('');
|
const [url, setUrl] = useState('');
|
||||||
const [scrapeRobotName, setScrapeRobotName] = useState('');
|
const [scrapeRobotName, setScrapeRobotName] = useState('');
|
||||||
|
const [extractRobotName, setExtractRobotName] = useState('');
|
||||||
const [needsLogin, setNeedsLogin] = useState(false);
|
const [needsLogin, setNeedsLogin] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isWarningModalOpen, setWarningModalOpen] = useState(false);
|
const [isWarningModalOpen, setWarningModalOpen] = useState(false);
|
||||||
const [activeBrowserId, setActiveBrowserId] = useState('');
|
const [activeBrowserId, setActiveBrowserId] = useState('');
|
||||||
const [outputFormats, setOutputFormats] = useState<string[]>([]);
|
const [outputFormats, setOutputFormats] = useState<string[]>([]);
|
||||||
|
const [generationMode, setGenerationMode] = useState<'agent' | 'recorder' | null>(null);
|
||||||
|
|
||||||
// AI Extract tab state
|
// AI Extract tab state
|
||||||
const [aiPrompt, setAiPrompt] = useState('');
|
const [aiPrompt, setAiPrompt] = useState('');
|
||||||
@@ -75,7 +77,7 @@ const RobotCreate: React.FC = () => {
|
|||||||
|
|
||||||
const { state } = React.useContext(AuthContext);
|
const { state } = React.useContext(AuthContext);
|
||||||
const { user } = state;
|
const { user } = state;
|
||||||
const { addOptimisticRobot, removeOptimisticRobot, invalidateRecordings } = useCacheInvalidation();
|
const { addOptimisticRobot, removeOptimisticRobot, invalidateRecordings, invalidateRuns, addOptimisticRun } = useCacheInvalidation();
|
||||||
|
|
||||||
const handleTabChange = (event: React.SyntheticEvent, newValue: number) => {
|
const handleTabChange = (event: React.SyntheticEvent, newValue: number) => {
|
||||||
setTabValue(newValue);
|
setTabValue(newValue);
|
||||||
@@ -215,190 +217,16 @@ const RobotCreate: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Tab label="Extract" id="extract-robot" aria-controls="extract-robot" />
|
<Tab label="Extract" id="extract-robot" aria-controls="extract-robot" />
|
||||||
<Tab label="AI Extract" id="ai-extract-robot" aria-controls="ai-extract-robot" />
|
|
||||||
<Tab label="Scrape" id="scrape-robot" aria-controls="scrape-robot" />
|
<Tab label="Scrape" id="scrape-robot" aria-controls="scrape-robot" />
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|
||||||
<TabPanel value={tabValue} index={0}>
|
<TabPanel value={tabValue} index={0}>
|
||||||
<Card sx={{ mb: 4, p: 4, textAlign: 'center' }}>
|
<Card sx={{ mb: 4, p: 4 }}>
|
||||||
<Box display="flex" flexDirection="column" alignItems="center">
|
<Box display="flex" flexDirection="column" alignItems="center">
|
||||||
{/* Logo (kept as original) */}
|
{/* Website URL */}
|
||||||
<img
|
<Box sx={{ width: '100%', maxWidth: 700, mb: 3 }}>
|
||||||
src="https://ik.imagekit.io/ys1blv5kv/maxunlogo.png"
|
|
||||||
width={73}
|
|
||||||
height={65}
|
|
||||||
style={{
|
|
||||||
borderRadius: '5px',
|
|
||||||
marginBottom: '30px'
|
|
||||||
}}
|
|
||||||
alt="Maxun Logo"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Typography variant="body2" color="text.secondary" mb={3}>
|
|
||||||
Extract structured data from websites in a few clicks.
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
{/* Origin URL Input */}
|
|
||||||
<Box sx={{ width: '100%', maxWidth: 700, mb: 2 }}>
|
|
||||||
<TextField
|
|
||||||
placeholder="Example: https://www.ycombinator.com/companies/"
|
|
||||||
variant="outlined"
|
|
||||||
fullWidth
|
|
||||||
value={url}
|
|
||||||
onChange={(e) => setUrl(e.target.value)}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Checkbox */}
|
|
||||||
<Box sx={{ width: '100%', maxWidth: 700, mb: 3, textAlign: 'left' }}>
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
checked={needsLogin}
|
|
||||||
onChange={(e) => setNeedsLogin(e.target.checked)}
|
|
||||||
color="primary"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="This website needs logging in."
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Button */}
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
fullWidth
|
|
||||||
onClick={handleStartRecording}
|
|
||||||
disabled={!url.trim() || isLoading}
|
|
||||||
sx={{
|
|
||||||
bgcolor: '#ff00c3',
|
|
||||||
py: 1.4,
|
|
||||||
fontSize: '1rem',
|
|
||||||
textTransform: 'none',
|
|
||||||
maxWidth: 700,
|
|
||||||
borderRadius: 2
|
|
||||||
}}
|
|
||||||
startIcon={isLoading ? <CircularProgress size={20} color="inherit" /> : null}
|
|
||||||
>
|
|
||||||
{isLoading ? 'Starting...' : 'Start Recording'}
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Box mt={6} textAlign="center">
|
|
||||||
<Typography variant="h6" gutterBottom>
|
|
||||||
First time creating a robot?
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary" mb={3}>
|
|
||||||
Get help and learn how to use Maxun effectively.
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Grid container spacing={3} justifyContent="center">
|
|
||||||
|
|
||||||
{/* YouTube Tutorials */}
|
|
||||||
<Grid item xs={12} sm={6} md={4}>
|
|
||||||
<Card
|
|
||||||
sx={{
|
|
||||||
height: 140,
|
|
||||||
cursor: "pointer",
|
|
||||||
}}
|
|
||||||
onClick={() => window.open("https://www.youtube.com/@MaxunOSS/videos", "_blank")}
|
|
||||||
>
|
|
||||||
<CardContent
|
|
||||||
sx={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center", // center content
|
|
||||||
height: "100%",
|
|
||||||
textAlign: "center",
|
|
||||||
p: 2,
|
|
||||||
color: (theme) =>
|
|
||||||
theme.palette.mode === 'light' ? 'rgba(0, 0, 0, 0.54)' : '',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PlayCircleOutline sx={{ fontSize: "32px", mb: 2 }} />
|
|
||||||
|
|
||||||
<Box sx={{ textAlign: "center" }}>
|
|
||||||
<Typography variant="body1" fontWeight="600" sx={{ lineHeight: 1.2 }}>
|
|
||||||
Video Tutorials
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ lineHeight: 1.4, mt: 1 }}>
|
|
||||||
Watch step-by-step guides
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
{/* Documentation */}
|
|
||||||
<Grid item xs={12} sm={6} md={4}>
|
|
||||||
<Card
|
|
||||||
sx={{
|
|
||||||
height: 140,
|
|
||||||
cursor: "pointer",
|
|
||||||
}}
|
|
||||||
onClick={() => window.open("https://docs.maxun.dev", "_blank")}
|
|
||||||
>
|
|
||||||
<CardContent
|
|
||||||
sx={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center", // center everything
|
|
||||||
height: "100%",
|
|
||||||
textAlign: "center",
|
|
||||||
p: 2,
|
|
||||||
color: (theme) =>
|
|
||||||
theme.palette.mode === 'light' ? 'rgba(0, 0, 0, 0.54)' : '',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Article sx={{ fontSize: "32px", mb: 2 }} />
|
|
||||||
|
|
||||||
<Box sx={{ textAlign: "center" }}>
|
|
||||||
<Typography variant="body1" fontWeight="600" sx={{ lineHeight: 1.2 }}>
|
|
||||||
Documentation
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ lineHeight: 1.4, mt: 1 }}>
|
|
||||||
Explore detailed guides
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
</Box>
|
|
||||||
</TabPanel>
|
|
||||||
|
|
||||||
<TabPanel value={tabValue} index={1}>
|
|
||||||
<Card sx={{ mb: 4, p: 4, textAlign: 'center' }}>
|
|
||||||
<Box display="flex" flexDirection="column" alignItems="center">
|
|
||||||
<img
|
|
||||||
src="https://ik.imagekit.io/ys1blv5kv/maxunlogo.png"
|
|
||||||
width={73}
|
|
||||||
height={65}
|
|
||||||
style={{
|
|
||||||
borderRadius: '5px',
|
|
||||||
marginBottom: '30px'
|
|
||||||
}}
|
|
||||||
alt="Maxun Logo"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Typography variant="body2" color="text.secondary" mb={3}>
|
|
||||||
AI-powered extraction: Describe what you want to extract in natural language.
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Box sx={{ width: '100%', maxWidth: 700, mb: 2 }}>
|
|
||||||
<TextField
|
|
||||||
placeholder="Example: AI Product Extractor"
|
|
||||||
variant="outlined"
|
|
||||||
fullWidth
|
|
||||||
value={aiRobotName}
|
|
||||||
onChange={(e) => setAiRobotName(e.target.value)}
|
|
||||||
sx={{ mb: 2 }}
|
|
||||||
label="Robot Name (Optional)"
|
|
||||||
/>
|
|
||||||
<TextField
|
<TextField
|
||||||
placeholder="Example: https://www.ycombinator.com/companies/"
|
placeholder="Example: https://www.ycombinator.com/companies/"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
@@ -406,207 +234,325 @@ const RobotCreate: React.FC = () => {
|
|||||||
value={url}
|
value={url}
|
||||||
onChange={(e) => setUrl(e.target.value)}
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
label="Website URL"
|
label="Website URL"
|
||||||
sx={{ mb: 2 }}
|
|
||||||
/>
|
/>
|
||||||
<TextField
|
|
||||||
placeholder="Example: Extract first 15 company names, descriptions, and batch information"
|
|
||||||
variant="outlined"
|
|
||||||
fullWidth
|
|
||||||
multiline
|
|
||||||
rows={3}
|
|
||||||
value={aiPrompt}
|
|
||||||
onChange={(e) => setAiPrompt(e.target.value)}
|
|
||||||
label="Extraction Prompt"
|
|
||||||
sx={{ mb: 2 }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Box sx={{ width: '100%', display: 'flex', gap: 2, mb: 2 }}>
|
|
||||||
<FormControl sx={{ flex: 1 }}>
|
|
||||||
<InputLabel id="llm-provider-label">LLM Provider</InputLabel>
|
|
||||||
<Select
|
|
||||||
labelId="llm-provider-label"
|
|
||||||
id="llm-provider"
|
|
||||||
value={llmProvider}
|
|
||||||
label="LLM Provider"
|
|
||||||
onChange={(e) => {
|
|
||||||
const provider = e.target.value as 'anthropic' | 'openai' | 'ollama';
|
|
||||||
setLlmProvider(provider);
|
|
||||||
setLlmModel('');
|
|
||||||
if (provider === 'ollama') {
|
|
||||||
setLlmBaseUrl('http://localhost:11434');
|
|
||||||
} else {
|
|
||||||
setLlmBaseUrl('');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MenuItem value="ollama">Ollama (Local)</MenuItem>
|
|
||||||
<MenuItem value="anthropic">Anthropic (Claude)</MenuItem>
|
|
||||||
<MenuItem value="openai">OpenAI (GPT-4)</MenuItem>
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormControl sx={{ flex: 1 }}>
|
|
||||||
<InputLabel id="llm-model-label">Model (Optional)</InputLabel>
|
|
||||||
<Select
|
|
||||||
labelId="llm-model-label"
|
|
||||||
id="llm-model"
|
|
||||||
value={llmModel}
|
|
||||||
label="Model (Optional)"
|
|
||||||
onChange={(e) => setLlmModel(e.target.value)}
|
|
||||||
>
|
|
||||||
{llmProvider === 'ollama' && (
|
|
||||||
<>
|
|
||||||
<MenuItem value="">Default (llama3.2-vision)</MenuItem>
|
|
||||||
<MenuItem value="llama3.2-vision">llama3.2-vision</MenuItem>
|
|
||||||
<MenuItem value="llama3.2">llama3.2</MenuItem>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{llmProvider === 'anthropic' && (
|
|
||||||
<>
|
|
||||||
<MenuItem value="">Default (claude-3-5-sonnet)</MenuItem>
|
|
||||||
<MenuItem value="claude-3-5-sonnet-20241022">claude-3-5-sonnet-20241022</MenuItem>
|
|
||||||
<MenuItem value="claude-3-opus-20240229">claude-3-opus-20240229</MenuItem>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{llmProvider === 'openai' && (
|
|
||||||
<>
|
|
||||||
<MenuItem value="">Default (gpt-4-vision-preview)</MenuItem>
|
|
||||||
<MenuItem value="gpt-4-vision-preview">gpt-4-vision-preview</MenuItem>
|
|
||||||
<MenuItem value="gpt-4o">gpt-4o</MenuItem>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{llmProvider !== 'ollama' && (
|
|
||||||
<TextField
|
|
||||||
placeholder={`${llmProvider === 'anthropic' ? 'Anthropic' : 'OpenAI'} API Key (or set in .env)`}
|
|
||||||
variant="outlined"
|
|
||||||
fullWidth
|
|
||||||
type="password"
|
|
||||||
value={llmApiKey}
|
|
||||||
onChange={(e) => setLlmApiKey(e.target.value)}
|
|
||||||
label="API Key (Optional if set in .env)"
|
|
||||||
sx={{ mb: 2 }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{llmProvider === 'ollama' && (
|
|
||||||
<TextField
|
|
||||||
placeholder="http://localhost:11434"
|
|
||||||
variant="outlined"
|
|
||||||
fullWidth
|
|
||||||
value={llmBaseUrl}
|
|
||||||
onChange={(e) => setLlmBaseUrl(e.target.value)}
|
|
||||||
label="Ollama Base URL (Optional)"
|
|
||||||
sx={{ mb: 2 }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Button
|
{/* Generation Mode Selection */}
|
||||||
variant="contained"
|
<Box sx={{ width: '100%', maxWidth: 700, mb: 3 }}>
|
||||||
fullWidth
|
<Typography variant="subtitle1" gutterBottom sx={{ fontWeight: 600, mb: 2 }}>
|
||||||
onClick={async () => {
|
Generation Mode
|
||||||
if (!url.trim()) {
|
</Typography>
|
||||||
notify('error', 'Please enter a valid URL');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!aiPrompt.trim()) {
|
|
||||||
notify('error', 'Please enter an extraction prompt');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tempRobotId = `temp-${Date.now()}`;
|
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||||
const robotDisplayName = aiRobotName || `LLM Extract: ${aiPrompt.substring(0, 50)}`;
|
<Card
|
||||||
|
onClick={() => setGenerationMode('agent')}
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
cursor: 'pointer',
|
||||||
|
border: '2px solid',
|
||||||
|
borderColor: generationMode === 'agent' ? '#ff00c3' : 'divider',
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
'&:hover': {
|
||||||
|
borderColor: '#ff00c3',
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CardContent sx={{ textAlign: 'center', py: 3 }}>
|
||||||
|
<SmartToy sx={{ fontSize: 32, mb: 1 }} />
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
AI Powered
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
AI will take care of everything
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
const optimisticRobot = {
|
<Card
|
||||||
id: tempRobotId,
|
onClick={() => setGenerationMode('recorder')}
|
||||||
recording_meta: {
|
sx={{
|
||||||
id: tempRobotId,
|
flex: 1,
|
||||||
name: robotDisplayName,
|
cursor: 'pointer',
|
||||||
createdAt: new Date().toISOString(),
|
border: '2px solid',
|
||||||
updatedAt: new Date().toISOString(),
|
borderColor: generationMode === 'recorder' ? '#ff00c3' : 'divider',
|
||||||
pairs: 0,
|
transition: 'all 0.2s',
|
||||||
params: [],
|
'&:hover': {
|
||||||
type: 'extract',
|
borderColor: '#ff00c3',
|
||||||
url: url,
|
}
|
||||||
},
|
}}
|
||||||
recording: { workflow: [] },
|
>
|
||||||
isLoading: true,
|
<CardContent sx={{ textAlign: 'center', py: 3 }}>
|
||||||
isOptimistic: true
|
<PlayCircleOutline sx={{ fontSize: 32, mb: 1 }} />
|
||||||
};
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Record yourself
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
You control the browser
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
addOptimisticRobot(optimisticRobot);
|
{/* Conditional rendering based on generation mode */}
|
||||||
|
{generationMode === 'agent' && (
|
||||||
|
<Box sx={{ width: '100%', maxWidth: 700 }}>
|
||||||
|
{/* Robot Name */}
|
||||||
|
<Box sx={{ mb: 3 }}>
|
||||||
|
<TextField
|
||||||
|
placeholder="Robot Name"
|
||||||
|
variant="outlined"
|
||||||
|
fullWidth
|
||||||
|
value={extractRobotName}
|
||||||
|
onChange={(e) => setExtractRobotName(e.target.value)}
|
||||||
|
label="Robot Name"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
notify('info', `Robot ${robotDisplayName} creation started (AI Powered)`);
|
{/* Extraction Prompt */}
|
||||||
navigate('/robots');
|
<Box sx={{ mb: 3 }}>
|
||||||
|
<TextField
|
||||||
|
placeholder="Example: Extract first 15 company names, descriptions, and batch information"
|
||||||
|
variant="outlined"
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
rows={3}
|
||||||
|
value={aiPrompt}
|
||||||
|
onChange={(e) => setAiPrompt(e.target.value)}
|
||||||
|
label="Extraction Prompt"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
try {
|
{/* LLM Provider and Model */}
|
||||||
const result = await createLLMRobot(
|
<Box sx={{ display: 'flex', gap: 2, mb: 3 }}>
|
||||||
url,
|
<FormControl sx={{ flex: 1 }}>
|
||||||
aiPrompt,
|
<InputLabel>LLM Provider</InputLabel>
|
||||||
llmProvider,
|
<Select
|
||||||
llmModel || undefined,
|
value={llmProvider}
|
||||||
llmApiKey || undefined,
|
label="LLM Provider"
|
||||||
llmBaseUrl || undefined,
|
onChange={(e) => {
|
||||||
aiRobotName || undefined
|
const provider = e.target.value as 'anthropic' | 'openai' | 'ollama';
|
||||||
);
|
setLlmProvider(provider);
|
||||||
|
setLlmModel('');
|
||||||
|
if (provider === 'ollama') {
|
||||||
|
setLlmBaseUrl('http://localhost:11434');
|
||||||
|
} else {
|
||||||
|
setLlmBaseUrl('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MenuItem value="ollama">Ollama (Local)</MenuItem>
|
||||||
|
<MenuItem value="anthropic">Anthropic (Claude)</MenuItem>
|
||||||
|
<MenuItem value="openai">OpenAI (GPT-4)</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
removeOptimisticRobot(tempRobotId);
|
<FormControl sx={{ flex: 1 }}>
|
||||||
|
<InputLabel>Model</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={llmModel}
|
||||||
|
label="Model"
|
||||||
|
onChange={(e) => setLlmModel(e.target.value)}
|
||||||
|
>
|
||||||
|
{llmProvider === 'ollama' && (
|
||||||
|
<>
|
||||||
|
<MenuItem value="">Default (llama3.2-vision)</MenuItem>
|
||||||
|
<MenuItem value="llama3.2-vision">llama3.2-vision</MenuItem>
|
||||||
|
<MenuItem value="llama3.2">llama3.2</MenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{llmProvider === 'anthropic' && (
|
||||||
|
<>
|
||||||
|
<MenuItem value="">Default (claude-3-5-sonnet)</MenuItem>
|
||||||
|
<MenuItem value="claude-3-5-sonnet-20241022">claude-3-5-sonnet-20241022</MenuItem>
|
||||||
|
<MenuItem value="claude-3-opus-20240229">claude-3-opus-20240229</MenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{llmProvider === 'openai' && (
|
||||||
|
<>
|
||||||
|
<MenuItem value="">Default (gpt-4-vision-preview)</MenuItem>
|
||||||
|
<MenuItem value="gpt-4-vision-preview">gpt-4-vision-preview</MenuItem>
|
||||||
|
<MenuItem value="gpt-4o">gpt-4o</MenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</Box>
|
||||||
|
|
||||||
if (!result || !result.robot) {
|
{/* API Key for non-Ollama providers */}
|
||||||
notify('error', 'Failed to create AI robot. Please check your LLM configuration.');
|
{llmProvider !== 'ollama' && (
|
||||||
invalidateRecordings();
|
<Box sx={{ mb: 3 }}>
|
||||||
return;
|
<TextField
|
||||||
}
|
placeholder={`${llmProvider === 'anthropic' ? 'Anthropic' : 'OpenAI'} API Key`}
|
||||||
|
variant="outlined"
|
||||||
|
fullWidth
|
||||||
|
type="password"
|
||||||
|
value={llmApiKey}
|
||||||
|
onChange={(e) => setLlmApiKey(e.target.value)}
|
||||||
|
label="API Key (Optional if set in .env)"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
const robotMetaId = result.robot.recording_meta.id;
|
{/* Ollama Base URL */}
|
||||||
notify('success', `${result.robot.recording_meta.name} created successfully!`);
|
{llmProvider === 'ollama' && (
|
||||||
|
<Box sx={{ mb: 3 }}>
|
||||||
|
<TextField
|
||||||
|
placeholder="http://localhost:11434"
|
||||||
|
variant="outlined"
|
||||||
|
fullWidth
|
||||||
|
value={llmBaseUrl}
|
||||||
|
onChange={(e) => setLlmBaseUrl(e.target.value)}
|
||||||
|
label="Ollama Base URL (Optional)"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
invalidateRecordings();
|
{/* Create & Run Button */}
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
fullWidth
|
||||||
|
onClick={async () => {
|
||||||
|
if (!url.trim()) {
|
||||||
|
notify('error', 'Please enter a valid URL');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!extractRobotName.trim()) {
|
||||||
|
notify('error', 'Please enter a robot name');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!aiPrompt.trim()) {
|
||||||
|
notify('error', 'Please enter an extraction prompt');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 500));
|
const tempRobotId = `temp-${Date.now()}`;
|
||||||
|
const robotDisplayName = extractRobotName;
|
||||||
|
|
||||||
notify('info', 'Starting robot execution...');
|
const optimisticRobot = {
|
||||||
const runResponse = await createAndRunRecording(robotMetaId, {
|
id: tempRobotId,
|
||||||
maxConcurrency: 1,
|
recording_meta: {
|
||||||
maxRepeats: 1,
|
id: tempRobotId,
|
||||||
debug: true
|
name: robotDisplayName,
|
||||||
});
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
pairs: 0,
|
||||||
|
params: [],
|
||||||
|
type: 'extract',
|
||||||
|
url: url,
|
||||||
|
},
|
||||||
|
recording: { workflow: [] },
|
||||||
|
isLoading: true,
|
||||||
|
isOptimistic: true
|
||||||
|
};
|
||||||
|
|
||||||
if (runResponse && runResponse.runId) {
|
addOptimisticRobot(optimisticRobot);
|
||||||
notify('success', 'Robot is now running!');
|
|
||||||
navigate(`/runs/${robotMetaId}/run/${runResponse.runId}`);
|
notify('info', `Robot ${robotDisplayName} creation started`);
|
||||||
} else {
|
navigate('/robots');
|
||||||
notify('warning', 'Robot created but failed to start execution. You can run it manually from the robots page.');
|
|
||||||
}
|
try {
|
||||||
} catch (error: any) {
|
const result = await createLLMRobot(
|
||||||
console.error('Error in AI robot creation:', error);
|
url,
|
||||||
removeOptimisticRobot(tempRobotId);
|
aiPrompt,
|
||||||
invalidateRecordings();
|
llmProvider,
|
||||||
notify('error', error?.message || 'Failed to create and run AI robot');
|
llmModel || undefined,
|
||||||
}
|
llmApiKey || undefined,
|
||||||
}}
|
llmBaseUrl || undefined,
|
||||||
disabled={!url.trim() || !aiPrompt.trim() || isLoading}
|
extractRobotName
|
||||||
sx={{
|
);
|
||||||
bgcolor: '#ff00c3',
|
|
||||||
py: 1.4,
|
removeOptimisticRobot(tempRobotId);
|
||||||
fontSize: '1rem',
|
|
||||||
textTransform: 'none',
|
if (!result || !result.robot) {
|
||||||
maxWidth: 700,
|
notify('error', 'Failed to create AI robot. Please check your LLM configuration.');
|
||||||
borderRadius: 2
|
invalidateRecordings();
|
||||||
}}
|
return;
|
||||||
startIcon={isLoading ? <CircularProgress size={20} color="inherit" /> : null}
|
}
|
||||||
>
|
|
||||||
{isLoading ? 'Creating & Running...' : 'Create & Run AI Robot'}
|
const robotMetaId = result.robot.recording_meta.id;
|
||||||
</Button>
|
const robotName = result.robot.recording_meta.name;
|
||||||
|
|
||||||
|
invalidateRecordings();
|
||||||
|
notify('success', `${robotName} created successfully!`);
|
||||||
|
|
||||||
|
const optimisticRun = {
|
||||||
|
id: robotMetaId,
|
||||||
|
runId: `temp-${Date.now()}`,
|
||||||
|
status: 'running',
|
||||||
|
name: robotName,
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
finishedAt: '',
|
||||||
|
robotMetaId: robotMetaId,
|
||||||
|
log: 'Starting...',
|
||||||
|
isOptimistic: true
|
||||||
|
};
|
||||||
|
|
||||||
|
addOptimisticRun(optimisticRun);
|
||||||
|
|
||||||
|
const runResponse = await createAndRunRecording(robotMetaId, {
|
||||||
|
maxConcurrency: 1,
|
||||||
|
maxRepeats: 1,
|
||||||
|
debug: false
|
||||||
|
});
|
||||||
|
|
||||||
|
invalidateRuns();
|
||||||
|
|
||||||
|
if (runResponse && runResponse.runId) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 300));
|
||||||
|
navigate(`/runs/${robotMetaId}/run/${runResponse.runId}`);
|
||||||
|
notify('info', `Run started: ${robotName}`);
|
||||||
|
} else {
|
||||||
|
notify('warning', 'Robot created but failed to start execution.');
|
||||||
|
navigate('/robots');
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error in AI robot creation:', error);
|
||||||
|
removeOptimisticRobot(tempRobotId);
|
||||||
|
invalidateRecordings();
|
||||||
|
notify('error', error?.message || 'Failed to create and run AI robot');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={!url.trim() || !extractRobotName.trim() || !aiPrompt.trim() || isLoading}
|
||||||
|
sx={{
|
||||||
|
bgcolor: '#ff00c3',
|
||||||
|
py: 1.4,
|
||||||
|
fontSize: '1rem',
|
||||||
|
textTransform: 'none',
|
||||||
|
borderRadius: 2
|
||||||
|
}}
|
||||||
|
startIcon={isLoading ? <CircularProgress size={20} color="inherit" /> : null}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Creating & Running...' : 'Create & Run Robot'}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{generationMode === 'recorder' && (
|
||||||
|
<Box sx={{ width: '100%', maxWidth: 700 }}>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
fullWidth
|
||||||
|
onClick={handleStartRecording}
|
||||||
|
disabled={!url.trim() || isLoading}
|
||||||
|
sx={{
|
||||||
|
bgcolor: '#ff00c3',
|
||||||
|
py: 1.4,
|
||||||
|
fontSize: '1rem',
|
||||||
|
textTransform: 'none',
|
||||||
|
borderRadius: 2
|
||||||
|
}}
|
||||||
|
startIcon={isLoading ? <CircularProgress size={20} color="inherit" /> : null}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Starting...' : 'Start Recording'}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Card>
|
</Card>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
<TabPanel value={tabValue} index={2}>
|
<TabPanel value={tabValue} index={1}>
|
||||||
<Card sx={{ mb: 4, p: 4, textAlign: 'center' }}>
|
<Card sx={{ mb: 4, p: 4, textAlign: 'center' }}>
|
||||||
<Box display="flex" flexDirection="column" alignItems="center">
|
<Box display="flex" flexDirection="column" alignItems="center">
|
||||||
<img
|
<img
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import Paper from '@mui/material/Paper';
|
import Paper from '@mui/material/Paper';
|
||||||
import Table from '@mui/material/Table';
|
import Table from '@mui/material/Table';
|
||||||
@@ -13,10 +13,12 @@ import { Accordion, AccordionSummary, AccordionDetails, Typography, Box, TextFie
|
|||||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||||
import SearchIcon from '@mui/icons-material/Search';
|
import SearchIcon from '@mui/icons-material/Search';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { useGlobalInfoStore, useCachedRuns } from "../../context/globalInfo";
|
import { useGlobalInfoStore, useCachedRuns, useCacheInvalidation } from "../../context/globalInfo";
|
||||||
import { RunSettings } from "./RunSettings";
|
import { RunSettings } from "./RunSettings";
|
||||||
import { CollapsibleRow } from "./ColapsibleRow";
|
import { CollapsibleRow } from "./ColapsibleRow";
|
||||||
import { ArrowDownward, ArrowUpward, UnfoldMore } from '@mui/icons-material';
|
import { ArrowDownward, ArrowUpward, UnfoldMore } from '@mui/icons-material';
|
||||||
|
import { io, Socket } from 'socket.io-client';
|
||||||
|
import { apiUrl } from '../../apiConfig';
|
||||||
|
|
||||||
export const columns: readonly Column[] = [
|
export const columns: readonly Column[] = [
|
||||||
{ id: 'runStatus', label: 'Status', minWidth: 80 },
|
{ id: 'runStatus', label: 'Status', minWidth: 80 },
|
||||||
@@ -133,6 +135,9 @@ export const RunsTable: React.FC<RunsTableProps> = ({
|
|||||||
|
|
||||||
const { notify, rerenderRuns, setRerenderRuns } = useGlobalInfoStore();
|
const { notify, rerenderRuns, setRerenderRuns } = useGlobalInfoStore();
|
||||||
const { data: rows = [], isLoading: isFetching, error, refetch } = useCachedRuns();
|
const { data: rows = [], isLoading: isFetching, error, refetch } = useCachedRuns();
|
||||||
|
const { invalidateRuns } = useCacheInvalidation();
|
||||||
|
|
||||||
|
const activeSocketsRef = useRef<Map<string, Socket>>(new Map());
|
||||||
|
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [paginationStates, setPaginationStates] = useState<PaginationState>({});
|
const [paginationStates, setPaginationStates] = useState<PaginationState>({});
|
||||||
@@ -285,6 +290,98 @@ export const RunsTable: React.FC<RunsTableProps> = ({
|
|||||||
}
|
}
|
||||||
}, [rerenderRuns, refetch, setRerenderRuns]);
|
}, [rerenderRuns, refetch, setRerenderRuns]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!rows || rows.length === 0) return;
|
||||||
|
|
||||||
|
const activeRuns = rows.filter((row: Data) =>
|
||||||
|
row.status === 'running' && row.browserId && row.browserId.trim() !== ''
|
||||||
|
);
|
||||||
|
|
||||||
|
activeRuns.forEach((run: Data) => {
|
||||||
|
const { browserId, runId: currentRunId, name } = run;
|
||||||
|
|
||||||
|
if (activeSocketsRef.current.has(browserId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[RunsTable] Connecting to browser socket: ${browserId} for run: ${currentRunId}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const socket = io(`${apiUrl}/${browserId}`, {
|
||||||
|
transports: ['websocket'],
|
||||||
|
rejectUnauthorized: false
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('connect', () => {
|
||||||
|
console.log(`[RunsTable] Connected to browser ${browserId}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('debugMessage', (msg: string) => {
|
||||||
|
console.log(`[RunsTable] Debug message for ${browserId}:`, msg);
|
||||||
|
// Optionally update logs in real-time here
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('run-completed', (data: any) => {
|
||||||
|
console.log(`[RunsTable] Run completed for ${browserId}:`, data);
|
||||||
|
|
||||||
|
// Invalidate cache to show updated run status
|
||||||
|
invalidateRuns();
|
||||||
|
setRerenderRuns(true);
|
||||||
|
|
||||||
|
// Show notification
|
||||||
|
if (data.status === 'success') {
|
||||||
|
notify('success', t('main_page.notifications.interpretation_success', { name: data.robotName || name }));
|
||||||
|
} else {
|
||||||
|
notify('error', t('main_page.notifications.interpretation_failed', { name: data.robotName || name }));
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.disconnect();
|
||||||
|
activeSocketsRef.current.delete(browserId);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('urlChanged', (url: string) => {
|
||||||
|
console.log(`[RunsTable] URL changed for ${browserId}:`, url);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('dom-snapshot-loading', () => {
|
||||||
|
console.log(`[RunsTable] DOM snapshot loading for ${browserId}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('connect_error', (error: Error) => {
|
||||||
|
console.error(`[RunsTable] Connection error for browser ${browserId}:`, error.message);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('disconnect', (reason: string) => {
|
||||||
|
console.log(`[RunsTable] Disconnected from browser ${browserId}:`, reason);
|
||||||
|
activeSocketsRef.current.delete(browserId);
|
||||||
|
});
|
||||||
|
|
||||||
|
activeSocketsRef.current.set(browserId, socket);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[RunsTable] Error connecting to browser ${browserId}:`, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Disconnect from sockets for runs that are no longer active
|
||||||
|
const activeBrowserIds = new Set(activeRuns.map((run: Data) => run.browserId));
|
||||||
|
activeSocketsRef.current.forEach((socket, browserId) => {
|
||||||
|
if (!activeBrowserIds.has(browserId)) {
|
||||||
|
console.log(`[RunsTable] Disconnecting from inactive browser: ${browserId}`);
|
||||||
|
socket.disconnect();
|
||||||
|
activeSocketsRef.current.delete(browserId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
return () => {
|
||||||
|
console.log('[RunsTable] Cleaning up all socket connections');
|
||||||
|
activeSocketsRef.current.forEach((socket) => {
|
||||||
|
socket.disconnect();
|
||||||
|
});
|
||||||
|
activeSocketsRef.current.clear();
|
||||||
|
};
|
||||||
|
}, [rows, notify, t, invalidateRuns, setRerenderRuns]);
|
||||||
|
|
||||||
const handleDelete = useCallback(() => {
|
const handleDelete = useCallback(() => {
|
||||||
notify('success', t('runstable.notifications.delete_success'));
|
notify('success', t('runstable.notifications.delete_success'));
|
||||||
refetch();
|
refetch();
|
||||||
|
|||||||
@@ -204,31 +204,47 @@ export const useCachedRuns = () => {
|
|||||||
|
|
||||||
export const useCacheInvalidation = () => {
|
export const useCacheInvalidation = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const invalidateRuns = () => {
|
const invalidateRuns = () => {
|
||||||
queryClient.invalidateQueries({ queryKey: dataCacheKeys.runs });
|
queryClient.invalidateQueries({ queryKey: dataCacheKeys.runs });
|
||||||
};
|
};
|
||||||
|
|
||||||
const invalidateRecordings = () => {
|
const invalidateRecordings = () => {
|
||||||
queryClient.invalidateQueries({ queryKey: dataCacheKeys.recordings });
|
queryClient.invalidateQueries({ queryKey: dataCacheKeys.recordings });
|
||||||
};
|
};
|
||||||
|
|
||||||
const addOptimisticRun = (newRun: any) => {
|
const addOptimisticRun = (newRun: any) => {
|
||||||
queryClient.setQueryData(dataCacheKeys.runs, (oldData: any) => {
|
queryClient.setQueryData(dataCacheKeys.runs, (oldData: any) => {
|
||||||
if (!oldData) return [{ id: 0, ...newRun }];
|
if (!oldData) return [{ id: 0, ...newRun }];
|
||||||
return [{ id: oldData.length, ...newRun }, ...oldData];
|
return [{ id: oldData.length, ...newRun }, ...oldData];
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const addOptimisticRobot = (newRobot: any) => {
|
||||||
|
queryClient.setQueryData(dataCacheKeys.recordings, (oldData: any) => {
|
||||||
|
if (!oldData) return [newRobot];
|
||||||
|
return [newRobot, ...oldData];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeOptimisticRobot = (tempId: string) => {
|
||||||
|
queryClient.setQueryData(dataCacheKeys.recordings, (oldData: any) => {
|
||||||
|
if (!oldData) return [];
|
||||||
|
return oldData.filter((robot: any) => robot.id !== tempId);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const invalidateAllCache = () => {
|
const invalidateAllCache = () => {
|
||||||
invalidateRuns();
|
invalidateRuns();
|
||||||
invalidateRecordings();
|
invalidateRecordings();
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
invalidateRuns,
|
invalidateRuns,
|
||||||
invalidateRecordings,
|
invalidateRecordings,
|
||||||
addOptimisticRun,
|
addOptimisticRun,
|
||||||
|
addOptimisticRobot,
|
||||||
|
removeOptimisticRobot,
|
||||||
invalidateAllCache
|
invalidateAllCache
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -53,6 +53,10 @@ export const MainPage = ({ handleEditRecording, initialContent }: MainPageProps)
|
|||||||
const { invalidateRuns, addOptimisticRun } = useCacheInvalidation();
|
const { invalidateRuns, addOptimisticRun } = useCacheInvalidation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setContent(initialContent);
|
||||||
|
}, [initialContent]);
|
||||||
|
|
||||||
const { state } = useContext(AuthContext);
|
const { state } = useContext(AuthContext);
|
||||||
const { user } = state;
|
const { user } = state;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user