Merge pull request #921 from getmaxun/airobot
feat: AI powered robot generation
This commit is contained in:
@@ -15,6 +15,7 @@ import { encrypt, decrypt } from '../utils/auth';
|
||||
import { WorkflowFile } from 'maxun-core';
|
||||
import { cancelScheduledWorkflow, scheduleWorkflow } from '../storage/schedule';
|
||||
import { pgBossClient } from '../storage/pgboss';
|
||||
import { WorkflowEnricher } from '../sdk/workflowEnricher';
|
||||
|
||||
export const router = Router();
|
||||
|
||||
@@ -518,6 +519,92 @@ router.post('/recordings/scrape', requireSignIn, async (req: AuthenticatedReques
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST endpoint for creating an LLM-powered extraction robot
|
||||
*/
|
||||
router.post('/recordings/llm', requireSignIn, async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const { url, prompt, llmProvider, llmModel, llmApiKey, llmBaseUrl, robotName } = req.body;
|
||||
|
||||
if (!url || !prompt) {
|
||||
return res.status(400).json({ error: 'Both "url" and "prompt" fields are required.' });
|
||||
}
|
||||
|
||||
if (!req.user) {
|
||||
return res.status(401).send({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
try {
|
||||
new URL(url);
|
||||
} catch (err) {
|
||||
return res.status(400).json({ error: 'Invalid URL format' });
|
||||
}
|
||||
|
||||
logger.log('info', `Starting LLM workflow generation for URL: ${url}`);
|
||||
|
||||
const workflowResult = await WorkflowEnricher.generateWorkflowFromPrompt(url, prompt, req.user.id, {
|
||||
provider: llmProvider || 'ollama',
|
||||
model: llmModel,
|
||||
apiKey: llmApiKey,
|
||||
baseUrl: llmBaseUrl
|
||||
});
|
||||
|
||||
if (!workflowResult.success || !workflowResult.workflow) {
|
||||
logger.log('error', `Failed to generate workflow: ${JSON.stringify(workflowResult.errors)}`);
|
||||
return res.status(400).json({
|
||||
error: 'Failed to generate workflow from prompt',
|
||||
details: workflowResult.errors
|
||||
});
|
||||
}
|
||||
|
||||
const robotId = uuid();
|
||||
const currentTimestamp = new Date().toISOString();
|
||||
const finalRobotName = robotName || `LLM Extract: ${prompt.substring(0, 50)}`;
|
||||
|
||||
const newRobot = await Robot.create({
|
||||
id: uuid(),
|
||||
userId: req.user.id,
|
||||
recording_meta: {
|
||||
name: finalRobotName,
|
||||
id: robotId,
|
||||
createdAt: currentTimestamp,
|
||||
updatedAt: currentTimestamp,
|
||||
pairs: workflowResult.workflow.length,
|
||||
params: [],
|
||||
type: 'extract',
|
||||
url: workflowResult.url || url,
|
||||
},
|
||||
recording: { workflow: workflowResult.workflow },
|
||||
google_sheet_email: null,
|
||||
google_sheet_name: null,
|
||||
google_sheet_id: null,
|
||||
google_access_token: null,
|
||||
google_refresh_token: null,
|
||||
schedule: null,
|
||||
});
|
||||
|
||||
logger.log('info', `LLM robot created with id: ${newRobot.id}`);
|
||||
capture('maxun-oss-robot-created', {
|
||||
robot_meta: newRobot.recording_meta,
|
||||
recording: newRobot.recording,
|
||||
llm_provider: llmProvider || 'ollama',
|
||||
});
|
||||
|
||||
return res.status(201).json({
|
||||
message: 'LLM robot created successfully.',
|
||||
robot: newRobot,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
logger.log('error', `Error creating LLM robot: ${error.message}`);
|
||||
return res.status(500).json({ error: error.message });
|
||||
} else {
|
||||
logger.log('error', 'Unknown error creating LLM robot');
|
||||
return res.status(500).json({ error: 'An unknown error occurred.' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE endpoint for deleting a recording from the storage.
|
||||
*/
|
||||
|
||||
@@ -58,12 +58,50 @@ export const createScrapeRobot = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const createLLMRobot = async (
|
||||
url: string,
|
||||
prompt: string,
|
||||
llmProvider?: 'anthropic' | 'openai' | 'ollama',
|
||||
llmModel?: string,
|
||||
llmApiKey?: string,
|
||||
llmBaseUrl?: string,
|
||||
robotName?: string
|
||||
): Promise<any> => {
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${apiUrl}/storage/recordings/llm`,
|
||||
{
|
||||
url,
|
||||
prompt,
|
||||
llmProvider,
|
||||
llmModel,
|
||||
llmApiKey,
|
||||
llmBaseUrl,
|
||||
robotName,
|
||||
},
|
||||
{
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
withCredentials: true,
|
||||
timeout: 300000,
|
||||
}
|
||||
);
|
||||
|
||||
if (response.status === 201) {
|
||||
return response.data;
|
||||
} else {
|
||||
throw new Error('Failed to create LLM robot');
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Error creating LLM robot:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const updateRecording = async (id: string, data: {
|
||||
name?: string;
|
||||
limits?: Array<{pairIndex: number, actionIndex: number, argIndex: number, limit: number}>;
|
||||
credentials?: Credentials;
|
||||
targetUrl?: string;
|
||||
// optional full workflow replacement (useful for action renames)
|
||||
workflow?: any[];
|
||||
}): Promise<boolean> => {
|
||||
try {
|
||||
|
||||
@@ -81,8 +81,46 @@ interface RecordingsTableProps {
|
||||
handleDuplicateRobot: (id: string, name: string, params: string[]) => void;
|
||||
}
|
||||
|
||||
const LoadingRobotRow = memo(({ row, columns }: any) => {
|
||||
return (
|
||||
<TableRow hover role="checkbox" tabIndex={-1} sx={{ backgroundColor: 'action.hover' }}>
|
||||
{columns.map((column: Column) => {
|
||||
if (column.id === 'name') {
|
||||
return (
|
||||
<MemoizedTableCell key={column.id} align={column.align}>
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
<CircularProgress size={20} />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{row.name} (Creating...)
|
||||
</Typography>
|
||||
</Box>
|
||||
</MemoizedTableCell>
|
||||
);
|
||||
} else if (column.id === 'interpret') {
|
||||
return (
|
||||
<MemoizedTableCell key={column.id} align={column.align}>
|
||||
<CircularProgress size={20} />
|
||||
</MemoizedTableCell>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<MemoizedTableCell key={column.id} align={column.align}>
|
||||
<Box sx={{ opacity: 0.3 }}>-</Box>
|
||||
</MemoizedTableCell>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</TableRow>
|
||||
);
|
||||
});
|
||||
|
||||
// Virtualized row component for efficient rendering
|
||||
const TableRowMemoized = memo(({ row, columns, handlers }: any) => {
|
||||
// If robot is loading, show loading row
|
||||
if (row.isLoading) {
|
||||
return <LoadingRobotRow row={row} columns={columns} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<TableRow hover role="checkbox" tabIndex={-1}>
|
||||
{columns.map((column: Column) => {
|
||||
@@ -261,7 +299,9 @@ export const RecordingsTable = ({
|
||||
id: index,
|
||||
...recording.recording_meta,
|
||||
content: recording.recording,
|
||||
parsedDate
|
||||
parsedDate,
|
||||
isLoading: recording.isLoading || false,
|
||||
isOptimistic: recording.isOptimistic || false
|
||||
};
|
||||
}
|
||||
return null;
|
||||
@@ -552,7 +592,7 @@ export const RecordingsTable = ({
|
||||
<>
|
||||
<TableContainer component={Paper} sx={{ width: '100%', overflow: 'hidden', marginTop: '15px' }}>
|
||||
<Table stickyHeader aria-label="sticky table">
|
||||
{/* <TableHead> */}
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
{columns.map((column) => (
|
||||
<MemoizedTableCell
|
||||
@@ -563,7 +603,7 @@ export const RecordingsTable = ({
|
||||
</MemoizedTableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
{/* </TableHead> */}
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{visibleRows.map((row) => (
|
||||
<TableRowMemoized
|
||||
|
||||
@@ -6,10 +6,8 @@ import {
|
||||
Typography,
|
||||
TextField,
|
||||
Button,
|
||||
FormControlLabel,
|
||||
Checkbox,
|
||||
IconButton,
|
||||
Grid,
|
||||
Card,
|
||||
CircularProgress,
|
||||
Container,
|
||||
@@ -21,10 +19,10 @@ import {
|
||||
MenuItem,
|
||||
InputLabel
|
||||
} from '@mui/material';
|
||||
import { ArrowBack, PlayCircleOutline, Article, Code, Description } from '@mui/icons-material';
|
||||
import { useGlobalInfoStore } from '../../../context/globalInfo';
|
||||
import { ArrowBack, AutoAwesome, HighlightAlt } from '@mui/icons-material';
|
||||
import { useGlobalInfoStore, useCacheInvalidation } from '../../../context/globalInfo';
|
||||
import { canCreateBrowserInState, getActiveBrowserId, stopRecording } from '../../../api/recording';
|
||||
import { createScrapeRobot } from "../../../api/storage";
|
||||
import { createScrapeRobot, createLLMRobot, createAndRunRecording } from "../../../api/storage";
|
||||
import { AuthContext } from '../../../context/auth';
|
||||
import { GenericModal } from '../../ui/GenericModal';
|
||||
|
||||
@@ -59,14 +57,24 @@ const RobotCreate: React.FC = () => {
|
||||
const [tabValue, setTabValue] = useState(0);
|
||||
const [url, setUrl] = useState('');
|
||||
const [scrapeRobotName, setScrapeRobotName] = useState('');
|
||||
const [extractRobotName, setExtractRobotName] = useState('');
|
||||
const [needsLogin, setNeedsLogin] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isWarningModalOpen, setWarningModalOpen] = useState(false);
|
||||
const [activeBrowserId, setActiveBrowserId] = useState('');
|
||||
const [outputFormats, setOutputFormats] = useState<string[]>([]);
|
||||
const [generationMode, setGenerationMode] = useState<'agent' | 'recorder' | null>(null);
|
||||
|
||||
const [aiPrompt, setAiPrompt] = useState('');
|
||||
const [llmProvider, setLlmProvider] = useState<'anthropic' | 'openai' | 'ollama'>('ollama');
|
||||
const [llmModel, setLlmModel] = useState('default');
|
||||
const [llmApiKey, setLlmApiKey] = useState('');
|
||||
const [llmBaseUrl, setLlmBaseUrl] = useState('');
|
||||
const [aiRobotName, setAiRobotName] = useState('');
|
||||
|
||||
const { state } = React.useContext(AuthContext);
|
||||
const { user } = state;
|
||||
const { addOptimisticRobot, removeOptimisticRobot, invalidateRecordings, invalidateRuns, addOptimisticRun } = useCacheInvalidation();
|
||||
|
||||
const handleTabChange = (event: React.SyntheticEvent, newValue: number) => {
|
||||
setTabValue(newValue);
|
||||
@@ -147,11 +155,6 @@ const RobotCreate: React.FC = () => {
|
||||
navigate('/robots');
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<Container maxWidth="md" sx={{ py: 4 }}>
|
||||
<Box>
|
||||
@@ -210,11 +213,9 @@ const RobotCreate: React.FC = () => {
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
|
||||
<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">
|
||||
{/* Logo (kept as original) */}
|
||||
<img
|
||||
src="https://ik.imagekit.io/ys1blv5kv/maxunlogo.png"
|
||||
width={73}
|
||||
@@ -227,138 +228,339 @@ const RobotCreate: React.FC = () => {
|
||||
/>
|
||||
|
||||
<Typography variant="body2" color="text.secondary" mb={3}>
|
||||
Extract structured data from websites in a few clicks.
|
||||
Extract structured data from websites using AI or record your own extraction workflow.
|
||||
</Typography>
|
||||
|
||||
{/* Origin URL Input */}
|
||||
<Box sx={{ width: '100%', maxWidth: 700, mb: 2 }}>
|
||||
<Box sx={{ width: '100%', maxWidth: 700, mb: 3 }}>
|
||||
<TextField
|
||||
placeholder="Example: https://www.ycombinator.com/companies/"
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
label="Website URL"
|
||||
/>
|
||||
</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>
|
||||
<Box sx={{ width: '100%', maxWidth: 700, mb: 3 }}>
|
||||
<Typography variant="subtitle1" gutterBottom sx={{ mb: 2 }} color="text.secondary">
|
||||
Choose How to Build
|
||||
</Typography>
|
||||
|
||||
{/* 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>
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<Card
|
||||
onClick={() => setGenerationMode('recorder')}
|
||||
sx={{
|
||||
flex: 1,
|
||||
cursor: 'pointer',
|
||||
border: '2px solid',
|
||||
borderColor: generationMode === 'recorder' ? '#ff00c3' : 'divider',
|
||||
transition: 'all 0.2s',
|
||||
'&:hover': {
|
||||
borderColor: '#ff00c3',
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ textAlign: 'center', py: 3, color:"text.secondary" }}>
|
||||
<HighlightAlt sx={{ fontSize: 32, mb: 1 }} />
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Recorder Mode
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
Record your actions into a workflow.
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
onClick={() => setGenerationMode('agent')}
|
||||
sx={{
|
||||
flex: 1,
|
||||
cursor: 'pointer',
|
||||
border: '2px solid',
|
||||
borderColor: generationMode === 'agent' ? '#ff00c3' : 'divider',
|
||||
transition: 'all 0.2s',
|
||||
'&:hover': {
|
||||
borderColor: '#ff00c3',
|
||||
},
|
||||
position: 'relative'
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
background: '#ff00c3',
|
||||
color: '#fff',
|
||||
px: 1,
|
||||
py: 0.3,
|
||||
borderRadius: '10px',
|
||||
fontSize: '0.7rem',
|
||||
}}
|
||||
>
|
||||
Beta
|
||||
</Box>
|
||||
|
||||
<CardContent sx={{ textAlign: 'center', py: 3, color:"text.secondary" }}>
|
||||
<AutoAwesome sx={{ fontSize: 32, mb: 1 }} />
|
||||
<Typography variant="h6" gutterBottom>
|
||||
AI Mode
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
Describe the task. It builds it for you.
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
</Box>
|
||||
{generationMode === 'agent' && (
|
||||
<Box sx={{ width: '100%', maxWidth: 700 }}>
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<TextField
|
||||
placeholder="Robot Name"
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
value={extractRobotName}
|
||||
onChange={(e) => setExtractRobotName(e.target.value)}
|
||||
label="Robot Name"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<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>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, mb: 3 }}>
|
||||
<FormControl sx={{ flex: 1 }}>
|
||||
<InputLabel>LLM Provider</InputLabel>
|
||||
<Select
|
||||
value={llmProvider}
|
||||
label="LLM Provider"
|
||||
onChange={(e) => {
|
||||
const provider = e.target.value as 'anthropic' | 'openai' | 'ollama';
|
||||
setLlmProvider(provider);
|
||||
setLlmModel('default');
|
||||
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>Model</InputLabel>
|
||||
<Select
|
||||
value={llmModel}
|
||||
label="Model"
|
||||
onChange={(e) => setLlmModel(e.target.value)}
|
||||
>
|
||||
{llmProvider === 'ollama' ? (
|
||||
[
|
||||
<MenuItem key="default" value="default">Default (llama3.2-vision)</MenuItem>,
|
||||
<MenuItem key="llama3.2-vision" value="llama3.2-vision">llama3.2-vision</MenuItem>,
|
||||
<MenuItem key="llama3.2" value="llama3.2">llama3.2</MenuItem>
|
||||
]
|
||||
) : llmProvider === 'anthropic' ? (
|
||||
[
|
||||
<MenuItem key="default" value="default">Default (claude-3-5-sonnet)</MenuItem>,
|
||||
<MenuItem key="claude-3-5-sonnet-20241022" value="claude-3-5-sonnet-20241022">claude-3-5-sonnet-20241022</MenuItem>,
|
||||
<MenuItem key="claude-3-opus-20240229" value="claude-3-opus-20240229">claude-3-opus-20240229</MenuItem>
|
||||
]
|
||||
) : (
|
||||
[
|
||||
<MenuItem key="default" value="default">Default (gpt-4-vision-preview)</MenuItem>,
|
||||
<MenuItem key="gpt-4-vision-preview" value="gpt-4-vision-preview">gpt-4-vision-preview</MenuItem>,
|
||||
<MenuItem key="gpt-4o" value="gpt-4o">gpt-4o</MenuItem>
|
||||
]
|
||||
)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
|
||||
{/* API Key for non-Ollama providers */}
|
||||
{llmProvider !== 'ollama' && (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
<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;
|
||||
}
|
||||
|
||||
const tempRobotId = `temp-${Date.now()}`;
|
||||
const robotDisplayName = extractRobotName;
|
||||
|
||||
const optimisticRobot = {
|
||||
id: tempRobotId,
|
||||
recording_meta: {
|
||||
id: tempRobotId,
|
||||
name: robotDisplayName,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
pairs: 0,
|
||||
params: [],
|
||||
type: 'extract',
|
||||
url: url,
|
||||
},
|
||||
recording: { workflow: [] },
|
||||
isLoading: true,
|
||||
isOptimistic: true
|
||||
};
|
||||
|
||||
addOptimisticRobot(optimisticRobot);
|
||||
|
||||
notify('info', `Robot ${robotDisplayName} creation started`);
|
||||
navigate('/robots');
|
||||
|
||||
try {
|
||||
const result = await createLLMRobot(
|
||||
url,
|
||||
aiPrompt,
|
||||
llmProvider,
|
||||
llmModel === 'default' ? undefined : llmModel,
|
||||
llmApiKey || undefined,
|
||||
llmBaseUrl || undefined,
|
||||
extractRobotName
|
||||
);
|
||||
|
||||
removeOptimisticRobot(tempRobotId);
|
||||
|
||||
if (!result || !result.robot) {
|
||||
notify('error', 'Failed to create AI robot. Please check your LLM configuration.');
|
||||
invalidateRecordings();
|
||||
return;
|
||||
}
|
||||
|
||||
const robotMetaId = result.robot.recording_meta.id;
|
||||
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>
|
||||
</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}>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 Paper from '@mui/material/Paper';
|
||||
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 SearchIcon from '@mui/icons-material/Search';
|
||||
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 { CollapsibleRow } from "./ColapsibleRow";
|
||||
import { ArrowDownward, ArrowUpward, UnfoldMore } from '@mui/icons-material';
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
import { apiUrl } from '../../apiConfig';
|
||||
|
||||
export const columns: readonly Column[] = [
|
||||
{ id: 'runStatus', label: 'Status', minWidth: 80 },
|
||||
@@ -133,6 +135,9 @@ export const RunsTable: React.FC<RunsTableProps> = ({
|
||||
|
||||
const { notify, rerenderRuns, setRerenderRuns } = useGlobalInfoStore();
|
||||
const { data: rows = [], isLoading: isFetching, error, refetch } = useCachedRuns();
|
||||
const { invalidateRuns } = useCacheInvalidation();
|
||||
|
||||
const activeSocketsRef = useRef<Map<string, Socket>>(new Map());
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [paginationStates, setPaginationStates] = useState<PaginationState>({});
|
||||
@@ -285,6 +290,98 @@ export const RunsTable: React.FC<RunsTableProps> = ({
|
||||
}
|
||||
}, [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(() => {
|
||||
notify('success', t('runstable.notifications.delete_success'));
|
||||
refetch();
|
||||
|
||||
@@ -220,6 +220,20 @@ export const useCacheInvalidation = () => {
|
||||
});
|
||||
};
|
||||
|
||||
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 = () => {
|
||||
invalidateRuns();
|
||||
invalidateRecordings();
|
||||
@@ -229,6 +243,8 @@ export const useCacheInvalidation = () => {
|
||||
invalidateRuns,
|
||||
invalidateRecordings,
|
||||
addOptimisticRun,
|
||||
addOptimisticRobot,
|
||||
removeOptimisticRobot,
|
||||
invalidateAllCache
|
||||
};
|
||||
};
|
||||
|
||||
@@ -53,6 +53,10 @@ export const MainPage = ({ handleEditRecording, initialContent }: MainPageProps)
|
||||
const { invalidateRuns, addOptimisticRun } = useCacheInvalidation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
React.useEffect(() => {
|
||||
setContent(initialContent);
|
||||
}, [initialContent]);
|
||||
|
||||
const { state } = useContext(AuthContext);
|
||||
const { user } = state;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user