mirror of
https://github.com/web-infra-dev/midscene.git
synced 2025-12-28 07:30:02 +00:00
feat(record): implement caching for element descriptions and screenshots to enhance performance
This commit is contained in:
parent
4de7fd81f7
commit
091ad4f564
@ -33,7 +33,7 @@ import {
|
||||
useRecordStore,
|
||||
useRecordingSessionStore,
|
||||
} from '../store';
|
||||
import { optimizeEvent } from '../utils/eventOptimizer';
|
||||
import { clearDescriptionCache, optimizeEvent } from '../utils/eventOptimizer';
|
||||
import './record.less';
|
||||
|
||||
// Generate default session name with current time
|
||||
@ -146,11 +146,11 @@ const safeChromeAPI = {
|
||||
// Mock port for non-extension environment
|
||||
return {
|
||||
onMessage: {
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
addListener: () => { },
|
||||
removeListener: () => { },
|
||||
},
|
||||
disconnect: () => {},
|
||||
postMessage: () => {},
|
||||
disconnect: () => { },
|
||||
postMessage: () => { },
|
||||
};
|
||||
}
|
||||
},
|
||||
@ -234,242 +234,242 @@ const RecordList: React.FC<{
|
||||
currentTab,
|
||||
startRecording,
|
||||
}) => {
|
||||
return (
|
||||
<div className="record-list-view">
|
||||
{!isExtensionMode && (
|
||||
<Alert
|
||||
message="Limited Functionality"
|
||||
description="Recording features require Chrome extension environment. Only session management and event viewing are available."
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: '16px' }}
|
||||
/>
|
||||
)}
|
||||
return (
|
||||
<div className="record-list-view">
|
||||
{!isExtensionMode && (
|
||||
<Alert
|
||||
message="Limited Functionality"
|
||||
description="Recording features require Chrome extension environment. Only session management and event viewing are available."
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: '16px' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '16px',
|
||||
}}
|
||||
>
|
||||
<Title level={3} style={{ margin: 0 }}>
|
||||
Recording Sessions
|
||||
</Title>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
// Create session with default name and start recording immediately
|
||||
const sessionName = generateDefaultSessionName();
|
||||
const newSession: RecordingSession = {
|
||||
id: `session-${Date.now()}`,
|
||||
name: sessionName,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
events: [],
|
||||
status: 'idle',
|
||||
url: currentTab?.url,
|
||||
};
|
||||
|
||||
addSession(newSession);
|
||||
setCurrentSession(newSession.id);
|
||||
clearEvents();
|
||||
message.success(`Session "${sessionName}" created successfully`);
|
||||
|
||||
// Switch to detail view for the new session
|
||||
setSelectedSession(newSession);
|
||||
setViewMode('detail');
|
||||
|
||||
// Automatically start recording if in extension mode
|
||||
if (isExtensionMode && currentTab?.id) {
|
||||
setTimeout(() => {
|
||||
startRecording();
|
||||
}, 100);
|
||||
}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '16px',
|
||||
}}
|
||||
>
|
||||
New Session
|
||||
</Button>
|
||||
</div>
|
||||
<Title level={3} style={{ margin: 0 }}>
|
||||
Recording Sessions
|
||||
</Title>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
// Create session with default name and start recording immediately
|
||||
const sessionName = generateDefaultSessionName();
|
||||
const newSession: RecordingSession = {
|
||||
id: `session-${Date.now()}`,
|
||||
name: sessionName,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
events: [],
|
||||
status: 'idle',
|
||||
url: currentTab?.url,
|
||||
};
|
||||
|
||||
{sessions.length === 0 ? (
|
||||
<div className="session-empty">
|
||||
<Empty
|
||||
description="No recording sessions yet"
|
||||
style={{ margin: '40px 0' }}
|
||||
addSession(newSession);
|
||||
setCurrentSession(newSession.id);
|
||||
clearEvents();
|
||||
message.success(`Session "${sessionName}" created successfully`);
|
||||
|
||||
// Switch to detail view for the new session
|
||||
setSelectedSession(newSession);
|
||||
setViewMode('detail');
|
||||
|
||||
// Automatically start recording if in extension mode
|
||||
if (isExtensionMode && currentTab?.id) {
|
||||
setTimeout(() => {
|
||||
startRecording();
|
||||
}, 100);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
// Create session with default name and start recording immediately
|
||||
const sessionName = generateDefaultSessionName();
|
||||
const newSession: RecordingSession = {
|
||||
id: `session-${Date.now()}`,
|
||||
name: sessionName,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
events: [],
|
||||
status: 'idle',
|
||||
url: currentTab?.url,
|
||||
};
|
||||
|
||||
addSession(newSession);
|
||||
setCurrentSession(newSession.id);
|
||||
clearEvents();
|
||||
message.success(
|
||||
`Session "${sessionName}" created successfully`,
|
||||
);
|
||||
|
||||
// Switch to detail view for the new session
|
||||
setSelectedSession(newSession);
|
||||
setViewMode('detail');
|
||||
|
||||
// Automatically start recording if in extension mode
|
||||
if (isExtensionMode && currentTab?.id) {
|
||||
setTimeout(() => {
|
||||
startRecording();
|
||||
}, 100);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Create First Session
|
||||
</Button>
|
||||
</Empty>
|
||||
New Session
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<List
|
||||
className="session-list"
|
||||
grid={{ gutter: 16, column: 1 }}
|
||||
dataSource={sessions}
|
||||
renderItem={(session) => (
|
||||
<List.Item>
|
||||
<Card
|
||||
size="small"
|
||||
className={
|
||||
session.id === currentSessionId ? 'selected-session' : ''
|
||||
}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
border:
|
||||
session.id === currentSessionId
|
||||
? '2px solid #1890ff'
|
||||
: '1px solid #d9d9d9',
|
||||
|
||||
{sessions.length === 0 ? (
|
||||
<div className="session-empty">
|
||||
<Empty
|
||||
description="No recording sessions yet"
|
||||
style={{ margin: '40px 0' }}
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
// Create session with default name and start recording immediately
|
||||
const sessionName = generateDefaultSessionName();
|
||||
const newSession: RecordingSession = {
|
||||
id: `session-${Date.now()}`,
|
||||
name: sessionName,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
events: [],
|
||||
status: 'idle',
|
||||
url: currentTab?.url,
|
||||
};
|
||||
|
||||
addSession(newSession);
|
||||
setCurrentSession(newSession.id);
|
||||
clearEvents();
|
||||
message.success(
|
||||
`Session "${sessionName}" created successfully`,
|
||||
);
|
||||
|
||||
// Switch to detail view for the new session
|
||||
setSelectedSession(newSession);
|
||||
setViewMode('detail');
|
||||
|
||||
// Automatically start recording if in extension mode
|
||||
if (isExtensionMode && currentTab?.id) {
|
||||
setTimeout(() => {
|
||||
startRecording();
|
||||
}, 100);
|
||||
}
|
||||
}}
|
||||
onClick={() => onViewDetail(session)}
|
||||
actions={[
|
||||
<Button
|
||||
key="select"
|
||||
type="text"
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelectSession(session);
|
||||
}}
|
||||
style={{
|
||||
color:
|
||||
session.id === currentSessionId ? '#1890ff' : undefined,
|
||||
}}
|
||||
>
|
||||
{session.id === currentSessionId ? 'Selected' : 'Select'}
|
||||
</Button>,
|
||||
<Button
|
||||
key="edit"
|
||||
type="text"
|
||||
icon={<EditOutlined />}
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEditSession(session);
|
||||
}}
|
||||
/>,
|
||||
<Button
|
||||
key="download"
|
||||
type="text"
|
||||
icon={<DownloadOutlined />}
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onExportSession(session);
|
||||
}}
|
||||
disabled={session.events.length === 0}
|
||||
/>,
|
||||
<Popconfirm
|
||||
key="delete"
|
||||
title="Delete session"
|
||||
description="Are you sure you want to delete this session?"
|
||||
onConfirm={(e) => {
|
||||
e?.stopPropagation();
|
||||
onDeleteSession(session.id);
|
||||
}}
|
||||
onCancel={(e) => e?.stopPropagation()}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
size="small"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</Popconfirm>,
|
||||
]}
|
||||
>
|
||||
<Card.Meta
|
||||
title={
|
||||
<div
|
||||
Create First Session
|
||||
</Button>
|
||||
</Empty>
|
||||
</div>
|
||||
) : (
|
||||
<List
|
||||
className="session-list"
|
||||
grid={{ gutter: 16, column: 1 }}
|
||||
dataSource={sessions}
|
||||
renderItem={(session) => (
|
||||
<List.Item>
|
||||
<Card
|
||||
size="small"
|
||||
className={
|
||||
session.id === currentSessionId ? 'selected-session' : ''
|
||||
}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
border:
|
||||
session.id === currentSessionId
|
||||
? '2px solid #1890ff'
|
||||
: '1px solid #d9d9d9',
|
||||
}}
|
||||
onClick={() => onViewDetail(session)}
|
||||
actions={[
|
||||
<Button
|
||||
key="select"
|
||||
type="text"
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelectSession(session);
|
||||
}}
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
color:
|
||||
session.id === currentSessionId ? '#1890ff' : undefined,
|
||||
}}
|
||||
>
|
||||
<span>{session.name}</span>
|
||||
<Space>
|
||||
<Tag
|
||||
color={
|
||||
session.status === 'recording'
|
||||
? 'red'
|
||||
: session.status === 'completed'
|
||||
? 'green'
|
||||
: 'default'
|
||||
}
|
||||
>
|
||||
{session.status}
|
||||
</Tag>
|
||||
{session.id === currentSessionId && (
|
||||
<Tag color="blue">Current</Tag>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
}
|
||||
description={
|
||||
<div className="session-meta">
|
||||
<div className="session-details">
|
||||
Events: {session.events.length} | Created:{' '}
|
||||
{new Date(session.createdAt).toLocaleString()} |
|
||||
{session.duration &&
|
||||
` Duration: ${(session.duration / 1000).toFixed(1)}s |`}
|
||||
{session.url &&
|
||||
` URL: ${session.url.slice(0, 50)}${session.url.length > 50 ? '...' : ''}`}
|
||||
{session.id === currentSessionId ? 'Selected' : 'Select'}
|
||||
</Button>,
|
||||
<Button
|
||||
key="edit"
|
||||
type="text"
|
||||
icon={<EditOutlined />}
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEditSession(session);
|
||||
}}
|
||||
/>,
|
||||
<Button
|
||||
key="download"
|
||||
type="text"
|
||||
icon={<DownloadOutlined />}
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onExportSession(session);
|
||||
}}
|
||||
disabled={session.events.length === 0}
|
||||
/>,
|
||||
<Popconfirm
|
||||
key="delete"
|
||||
title="Delete session"
|
||||
description="Are you sure you want to delete this session?"
|
||||
onConfirm={(e) => {
|
||||
e?.stopPropagation();
|
||||
onDeleteSession(session.id);
|
||||
}}
|
||||
onCancel={(e) => e?.stopPropagation()}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
size="small"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</Popconfirm>,
|
||||
]}
|
||||
>
|
||||
<Card.Meta
|
||||
title={
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<span>{session.name}</span>
|
||||
<Space>
|
||||
<Tag
|
||||
color={
|
||||
session.status === 'recording'
|
||||
? 'red'
|
||||
: session.status === 'completed'
|
||||
? 'green'
|
||||
: 'default'
|
||||
}
|
||||
>
|
||||
{session.status}
|
||||
</Tag>
|
||||
{session.id === currentSessionId && (
|
||||
<Tag color="blue">Current</Tag>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
{session.description && (
|
||||
<div style={{ marginTop: '4px', fontStyle: 'italic' }}>
|
||||
{session.description}
|
||||
}
|
||||
description={
|
||||
<div className="session-meta">
|
||||
<div className="session-details">
|
||||
Events: {session.events.length} | Created:{' '}
|
||||
{new Date(session.createdAt).toLocaleString()} |
|
||||
{session.duration &&
|
||||
` Duration: ${(session.duration / 1000).toFixed(1)}s |`}
|
||||
{session.url &&
|
||||
` URL: ${session.url.slice(0, 50)}${session.url.length > 50 ? '...' : ''}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
{session.description && (
|
||||
<div style={{ marginTop: '4px', fontStyle: 'italic' }}>
|
||||
{session.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Record Detail Component
|
||||
const RecordDetail: React.FC<{
|
||||
@ -495,156 +495,156 @@ const RecordDetail: React.FC<{
|
||||
onExportEvents,
|
||||
isExtensionMode,
|
||||
}) => {
|
||||
return (
|
||||
<div className="record-detail-view">
|
||||
{!isExtensionMode && (
|
||||
<Alert
|
||||
message="Recording Disabled"
|
||||
description="Recording functionality is not available outside Chrome extension environment."
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: '16px' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Header with back button and session info */}
|
||||
<div className="detail-header">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={onBack}
|
||||
className="back-button"
|
||||
>
|
||||
Back to Sessions
|
||||
</Button>
|
||||
<div className="session-title">
|
||||
<Title level={4}>{session.name}</Title>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recording Status Indicator */}
|
||||
<div className={`recording-status ${isRecording ? 'recording' : 'idle'}`}>
|
||||
{isRecording ? (
|
||||
<span>🔴 Recording in progress</span>
|
||||
) : (
|
||||
<span>✅ Ready to record</span>
|
||||
return (
|
||||
<div className="record-detail-view">
|
||||
{!isExtensionMode && (
|
||||
<Alert
|
||||
message="Recording Disabled"
|
||||
description="Recording functionality is not available outside Chrome extension environment."
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: '16px' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Session Details */}
|
||||
<Card size="small" className="session-info-card">
|
||||
<div className="session-info">
|
||||
<Space direction="vertical" size="small" style={{ width: '100%' }}>
|
||||
<div>
|
||||
<Text strong>Status: </Text>
|
||||
<Tag
|
||||
color={
|
||||
session.status === 'recording'
|
||||
? 'red'
|
||||
: session.status === 'completed'
|
||||
? 'green'
|
||||
: 'default'
|
||||
}
|
||||
{/* Header with back button and session info */}
|
||||
<div className="detail-header">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={onBack}
|
||||
className="back-button"
|
||||
>
|
||||
Back to Sessions
|
||||
</Button>
|
||||
<div className="session-title">
|
||||
<Title level={4}>{session.name}</Title>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recording Status Indicator */}
|
||||
<div className={`recording-status ${isRecording ? 'recording' : 'idle'}`}>
|
||||
{isRecording ? (
|
||||
<span>🔴 Recording in progress</span>
|
||||
) : (
|
||||
<span>✅ Ready to record</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Session Details */}
|
||||
<Card size="small" className="session-info-card">
|
||||
<div className="session-info">
|
||||
<Space direction="vertical" size="small" style={{ width: '100%' }}>
|
||||
<div>
|
||||
<Text strong>Status: </Text>
|
||||
<Tag
|
||||
color={
|
||||
session.status === 'recording'
|
||||
? 'red'
|
||||
: session.status === 'completed'
|
||||
? 'green'
|
||||
: 'default'
|
||||
}
|
||||
>
|
||||
{session.status}
|
||||
</Tag>
|
||||
</div>
|
||||
<div>
|
||||
<Text strong>Events: </Text>
|
||||
<Text>{session.events.length}</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text strong>Created: </Text>
|
||||
<Text>{new Date(session.createdAt).toLocaleString()}</Text>
|
||||
</div>
|
||||
{session.duration && (
|
||||
<div>
|
||||
<Text strong>Duration: </Text>
|
||||
<Text>{(session.duration / 1000).toFixed(1)}s</Text>
|
||||
</div>
|
||||
)}
|
||||
{session.url && (
|
||||
<div>
|
||||
<Text strong>URL: </Text>
|
||||
<Text>{session.url}</Text>
|
||||
</div>
|
||||
)}
|
||||
{session.description && (
|
||||
<div>
|
||||
<Text strong>Description: </Text>
|
||||
<Text>{session.description}</Text>
|
||||
</div>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Recording Controls */}
|
||||
<div className="controls-section">
|
||||
<div className="current-tab-info">
|
||||
<Text strong>Current Tab:</Text>{' '}
|
||||
{currentTab?.title || 'No tab selected'}
|
||||
{!isExtensionMode && <Text type="secondary"> (Mock)</Text>}
|
||||
</div>
|
||||
<Space className="record-controls">
|
||||
{!isRecording ? (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlayCircleOutlined />}
|
||||
onClick={onStartRecording}
|
||||
disabled={!currentTab || !isExtensionMode}
|
||||
>
|
||||
{session.status}
|
||||
</Tag>
|
||||
</div>
|
||||
<div>
|
||||
<Text strong>Events: </Text>
|
||||
<Text>{session.events.length}</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text strong>Created: </Text>
|
||||
<Text>{new Date(session.createdAt).toLocaleString()}</Text>
|
||||
</div>
|
||||
{session.duration && (
|
||||
<div>
|
||||
<Text strong>Duration: </Text>
|
||||
<Text>{(session.duration / 1000).toFixed(1)}s</Text>
|
||||
</div>
|
||||
)}
|
||||
{session.url && (
|
||||
<div>
|
||||
<Text strong>URL: </Text>
|
||||
<Text>{session.url}</Text>
|
||||
</div>
|
||||
)}
|
||||
{session.description && (
|
||||
<div>
|
||||
<Text strong>Description: </Text>
|
||||
<Text>{session.description}</Text>
|
||||
</div>
|
||||
Start Recording
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
danger
|
||||
icon={<StopOutlined />}
|
||||
onClick={onStopRecording}
|
||||
disabled={!isExtensionMode}
|
||||
>
|
||||
Stop Recording
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={onClearEvents}
|
||||
disabled={events.length === 0 || isRecording}
|
||||
>
|
||||
Clear Events
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={onExportEvents}
|
||||
disabled={events.length === 0}
|
||||
>
|
||||
Export Events
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Recording Controls */}
|
||||
<div className="controls-section">
|
||||
<div className="current-tab-info">
|
||||
<Text strong>Current Tab:</Text>{' '}
|
||||
{currentTab?.title || 'No tab selected'}
|
||||
{!isExtensionMode && <Text type="secondary"> (Mock)</Text>}
|
||||
</div>
|
||||
<Space className="record-controls">
|
||||
{!isRecording ? (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlayCircleOutlined />}
|
||||
onClick={onStartRecording}
|
||||
disabled={!currentTab || !isExtensionMode}
|
||||
>
|
||||
Start Recording
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
danger
|
||||
icon={<StopOutlined />}
|
||||
onClick={onStopRecording}
|
||||
disabled={!isExtensionMode}
|
||||
>
|
||||
Stop Recording
|
||||
</Button>
|
||||
)}
|
||||
<Divider />
|
||||
|
||||
<Button
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={onClearEvents}
|
||||
disabled={events.length === 0 || isRecording}
|
||||
{/* Events Display */}
|
||||
<div className="events-section">
|
||||
<div className="events-header">
|
||||
<Title level={5}>Recorded Events ({events.length})</Title>
|
||||
</div>
|
||||
<div
|
||||
className={`events-container ${events.length === 0 ? 'empty' : ''}`}
|
||||
>
|
||||
Clear Events
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={onExportEvents}
|
||||
disabled={events.length === 0}
|
||||
>
|
||||
Export Events
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Events Display */}
|
||||
<div className="events-section">
|
||||
<div className="events-header">
|
||||
<Title level={5}>Recorded Events ({events.length})</Title>
|
||||
</div>
|
||||
<div
|
||||
className={`events-container ${events.length === 0 ? 'empty' : ''}`}
|
||||
>
|
||||
{events.length === 0 ? (
|
||||
<Empty description="No events recorded yet" />
|
||||
) : (
|
||||
<RecordTimeline events={events} />
|
||||
)}
|
||||
{events.length === 0 ? (
|
||||
<Empty description="No events recorded yet" />
|
||||
) : (
|
||||
<RecordTimeline events={events} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
);
|
||||
};
|
||||
|
||||
export default function Record() {
|
||||
const {
|
||||
@ -1036,11 +1036,13 @@ export default function Record() {
|
||||
}
|
||||
|
||||
// Update session status to recording
|
||||
updateSession(currentSessionId, {
|
||||
status: 'recording',
|
||||
url: currentTab?.url,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
if (currentSessionId) {
|
||||
updateSession(currentSessionId, {
|
||||
status: 'recording',
|
||||
url: currentTab?.url,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
if (!currentTab?.id) {
|
||||
message.error('No active tab found');
|
||||
@ -1051,6 +1053,9 @@ export default function Record() {
|
||||
await ensureScriptInjected();
|
||||
|
||||
try {
|
||||
// Clear the AI description cache to avoid using old descriptions
|
||||
clearDescriptionCache();
|
||||
|
||||
// Send message to content script to start recording
|
||||
await safeChromeAPI.tabs.sendMessage(currentTab.id, { action: 'start' });
|
||||
setIsRecording(true);
|
||||
|
||||
@ -3,6 +3,79 @@ import type { BaseElement, UIContext } from '@midscene/core';
|
||||
import { compositeElementInfoImg } from '@midscene/shared/img';
|
||||
import type { RecordedEvent } from '../store';
|
||||
|
||||
// Caches for element descriptions and boxed screenshots to improve performance
|
||||
// Using LRU-like behavior by tracking keys in insertion order and limiting size
|
||||
const MAX_CACHE_SIZE = 100; // Maximum number of items to keep in each cache
|
||||
const descriptionCache = new Map<string, string>();
|
||||
const boxedScreenshotCache = new Map<string, string>();
|
||||
const cacheKeyOrder: string[] = []; // Track keys in order of insertion for LRU behavior
|
||||
|
||||
// Add an item to cache with size limiting
|
||||
const addToCache = (
|
||||
cache: Map<string, string>,
|
||||
key: string,
|
||||
value: string,
|
||||
): void => {
|
||||
// If key already exists, remove it from the order array to add it at the end (most recently used)
|
||||
const existingIndex = cacheKeyOrder.indexOf(key);
|
||||
if (existingIndex >= 0) {
|
||||
cacheKeyOrder.splice(existingIndex, 1);
|
||||
}
|
||||
|
||||
// If cache is at max size, remove oldest item (LRU)
|
||||
if (cache.size >= MAX_CACHE_SIZE && cacheKeyOrder.length > 0) {
|
||||
const oldestKey = cacheKeyOrder.shift();
|
||||
if (oldestKey) {
|
||||
// Remove from both caches to ensure consistency
|
||||
descriptionCache.delete(oldestKey);
|
||||
boxedScreenshotCache.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
// Add new key to cache and track it
|
||||
cache.set(key, value);
|
||||
cacheKeyOrder.push(key);
|
||||
};
|
||||
|
||||
// Generate a cache key based on element properties
|
||||
const generateElementCacheKey = (event: RecordedEvent): string => {
|
||||
const elementProps = [
|
||||
event.targetTagName || '',
|
||||
event.targetId || '',
|
||||
event.targetClassName || '',
|
||||
event.viewportX || 0,
|
||||
event.viewportY || 0,
|
||||
event.width || 0,
|
||||
event.height || 0,
|
||||
];
|
||||
|
||||
return elementProps.join('|');
|
||||
};
|
||||
|
||||
// Check if two events reference the same DOM element
|
||||
const isSameElement = (
|
||||
event1: RecordedEvent,
|
||||
event2: RecordedEvent,
|
||||
): boolean => {
|
||||
return (
|
||||
event1.targetId === event2.targetId &&
|
||||
event1.targetTagName === event2.targetTagName &&
|
||||
event1.targetClassName === event2.targetClassName &&
|
||||
Math.abs((event1.viewportX || 0) - (event2.viewportX || 0)) < 5 &&
|
||||
Math.abs((event1.viewportY || 0) - (event2.viewportY || 0)) < 5 &&
|
||||
Math.abs((event1.width || 0) - (event2.width || 0)) < 5 &&
|
||||
Math.abs((event1.height || 0) - (event2.height || 0)) < 5
|
||||
);
|
||||
};
|
||||
|
||||
// Clear all caches
|
||||
export const clearDescriptionCache = (): void => {
|
||||
descriptionCache.clear();
|
||||
boxedScreenshotCache.clear();
|
||||
cacheKeyOrder.length = 0; // Clear the key order array
|
||||
console.log('Description and screenshot caches cleared');
|
||||
};
|
||||
|
||||
// Generate fallback description for events when AI fails
|
||||
export const generateFallbackDescription = (event: RecordedEvent): string => {
|
||||
const elementType = event.targetTagName?.toLowerCase() || 'element';
|
||||
@ -21,7 +94,7 @@ export const generateFallbackDescription = (event: RecordedEvent): string => {
|
||||
}
|
||||
};
|
||||
|
||||
// Generate AI description asynchronously
|
||||
// Generate AI description asynchronously with caching
|
||||
const generateAIDescription = async (
|
||||
event: RecordedEvent,
|
||||
boxedImageBase64: string,
|
||||
@ -29,6 +102,23 @@ const generateAIDescription = async (
|
||||
updateCallback: (updatedEvent: RecordedEvent) => void,
|
||||
) => {
|
||||
try {
|
||||
// Generate a cache key for this element
|
||||
const cacheKey = generateElementCacheKey(event);
|
||||
|
||||
// Check if we have a cached description for this element
|
||||
if (descriptionCache.has(cacheKey)) {
|
||||
const cachedDescription = descriptionCache.get(cacheKey);
|
||||
console.log('Using cached description for element');
|
||||
|
||||
updateCallback({
|
||||
...eventWithBoxedImage,
|
||||
elementDescription: cachedDescription,
|
||||
descriptionLoading: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// No cached description, generate a new one
|
||||
const mockContext: UIContext<BaseElement> = {
|
||||
screenshotBase64: boxedImageBase64,
|
||||
size: { width: event.pageWidth, height: event.pageHeight },
|
||||
@ -39,6 +129,9 @@ const generateAIDescription = async (
|
||||
const insight = new Insight(mockContext);
|
||||
const { description } = await insight.describe([event.x!, event.y!]);
|
||||
|
||||
// Cache the description for future use
|
||||
addToCache(descriptionCache, cacheKey, description);
|
||||
|
||||
updateCallback({
|
||||
...eventWithBoxedImage,
|
||||
elementDescription: description,
|
||||
@ -94,31 +187,50 @@ export const optimizeEvent = async (
|
||||
} as any);
|
||||
}
|
||||
|
||||
// Generate the boxed image
|
||||
const boxedImageBase64 = await compositeElementInfoImg({
|
||||
inputImgBase64: event.screenshotBefore,
|
||||
size: { width: event.pageWidth, height: event.pageHeight },
|
||||
elementsPositionInfo,
|
||||
borderThickness: 3,
|
||||
annotationPadding: 2,
|
||||
});
|
||||
// Check for cached description and boxed screenshot by element properties
|
||||
const cacheKey = generateElementCacheKey(event);
|
||||
const cachedDescription = descriptionCache.get(cacheKey);
|
||||
let boxedImageBase64;
|
||||
|
||||
// Return event with boxed image and loading state
|
||||
// Check if we have a cached boxed screenshot
|
||||
if (boxedScreenshotCache.has(cacheKey)) {
|
||||
boxedImageBase64 = boxedScreenshotCache.get(cacheKey);
|
||||
console.log('Using cached boxed screenshot for element');
|
||||
} else {
|
||||
// Generate the boxed image and cache it
|
||||
boxedImageBase64 = await compositeElementInfoImg({
|
||||
inputImgBase64: event.screenshotBefore,
|
||||
size: { width: event.pageWidth, height: event.pageHeight },
|
||||
elementsPositionInfo,
|
||||
borderThickness: 3,
|
||||
annotationPadding: 2,
|
||||
});
|
||||
|
||||
// Only cache the boxed image if it's for a significant element (with dimensions)
|
||||
if (event.width && event.height && event.width > 0 && event.height > 0) {
|
||||
addToCache(boxedScreenshotCache, cacheKey, boxedImageBase64);
|
||||
}
|
||||
}
|
||||
|
||||
// Return event with boxed image and loading state or cached description
|
||||
const eventWithBoxedImage: RecordedEvent = {
|
||||
...event,
|
||||
screenshotWithBox: boxedImageBase64,
|
||||
elementDescription: 'AI 正在分析元素...',
|
||||
descriptionLoading: true,
|
||||
elementDescription: cachedDescription || 'AI 正在分析元素...',
|
||||
descriptionLoading: !cachedDescription,
|
||||
};
|
||||
|
||||
// Generate AI description asynchronously if coordinates are available
|
||||
// Generate AI description asynchronously if coordinates are available and no cached description
|
||||
if (event.x !== undefined && event.y !== undefined && updateCallback) {
|
||||
generateAIDescription(
|
||||
event,
|
||||
boxedImageBase64,
|
||||
eventWithBoxedImage,
|
||||
updateCallback,
|
||||
);
|
||||
// Skip AI description generation if we already have a cached description
|
||||
if (!cachedDescription && boxedImageBase64) {
|
||||
generateAIDescription(
|
||||
event,
|
||||
boxedImageBase64,
|
||||
eventWithBoxedImage,
|
||||
updateCallback,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
eventWithBoxedImage.elementDescription = 'No description available';
|
||||
eventWithBoxedImage.descriptionLoading = false;
|
||||
|
||||
@ -16,7 +16,7 @@
|
||||
"dependencies": {
|
||||
"querystring": "0.2.1",
|
||||
"react": "18.3.1",
|
||||
"react-dom": ">=19.1.0"
|
||||
"react-dom": "18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rspress/plugin-llms": "2.0.0-beta.4",
|
||||
|
||||
54
migrations.json
Normal file
54
migrations.json
Normal file
@ -0,0 +1,54 @@
|
||||
{
|
||||
"migrations": [
|
||||
{
|
||||
"version": "20.0.0-beta.7",
|
||||
"description": "Migration for v20.0.0-beta.7",
|
||||
"implementation": "./src/migrations/update-20-0-0/move-use-daemon-process",
|
||||
"package": "nx",
|
||||
"name": "move-use-daemon-process"
|
||||
},
|
||||
{
|
||||
"version": "20.0.1",
|
||||
"description": "Set `useLegacyCache` to true for migrating workspaces",
|
||||
"implementation": "./src/migrations/update-20-0-1/use-legacy-cache",
|
||||
"x-repair-skip": true,
|
||||
"package": "nx",
|
||||
"name": "use-legacy-cache"
|
||||
},
|
||||
{
|
||||
"version": "21.0.0-beta.8",
|
||||
"description": "Removes the legacy cache configuration from nx.json",
|
||||
"implementation": "./src/migrations/update-21-0-0/remove-legacy-cache",
|
||||
"package": "nx",
|
||||
"name": "remove-legacy-cache"
|
||||
},
|
||||
{
|
||||
"version": "21.0.0-beta.8",
|
||||
"description": "Removes the legacy cache configuration from nx.json",
|
||||
"implementation": "./src/migrations/update-21-0-0/remove-custom-tasks-runner",
|
||||
"package": "nx",
|
||||
"name": "remove-custom-tasks-runner"
|
||||
},
|
||||
{
|
||||
"version": "21.0.0-beta.11",
|
||||
"description": "Updates release version config based on the breaking changes in Nx v21",
|
||||
"implementation": "./src/migrations/update-21-0-0/release-version-config-changes",
|
||||
"package": "nx",
|
||||
"name": "release-version-config-changes"
|
||||
},
|
||||
{
|
||||
"version": "21.0.0-beta.11",
|
||||
"description": "Updates release changelog config based on the breaking changes in Nx v21",
|
||||
"implementation": "./src/migrations/update-21-0-0/release-changelog-config-changes",
|
||||
"package": "nx",
|
||||
"name": "release-changelog-config-changes"
|
||||
},
|
||||
{
|
||||
"version": "21.1.0-beta.2",
|
||||
"description": "Adds **/nx-rules.mdc and **/nx.instructions.md to .gitignore if not present",
|
||||
"implementation": "./src/migrations/update-21-1-0/add-gitignore-entry",
|
||||
"package": "nx",
|
||||
"name": "21-1-0-add-ignore-entries-for-nx-rule-files"
|
||||
}
|
||||
]
|
||||
}
|
||||
3
nx.json
3
nx.json
@ -20,5 +20,6 @@
|
||||
"dependsOn": ["^build"]
|
||||
}
|
||||
},
|
||||
"defaultBase": "main"
|
||||
"defaultBase": "main",
|
||||
"nxCloudId": "683c52f96ac2ea619b350be9"
|
||||
}
|
||||
|
||||
@ -53,7 +53,7 @@
|
||||
"husky": "9.1.7",
|
||||
"minimist": "1.2.5",
|
||||
"nano-staged": "^0.8.0",
|
||||
"nx": "^19.8.14",
|
||||
"nx": "21.1.2",
|
||||
"prettier": "^3.5.3",
|
||||
"pretty-quick": "3.1.3",
|
||||
"semver": "7.5.2",
|
||||
|
||||
@ -25,11 +25,11 @@
|
||||
"@ant-design/icons": "^5.3.1",
|
||||
"antd": "^5.21.6",
|
||||
"dayjs": "^1.11.11",
|
||||
"react-dom": ">=19.1.0",
|
||||
"react-dom": "18.3.1",
|
||||
"@midscene/shared": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "18.3.1",
|
||||
"react-dom": ">=19.1.0"
|
||||
"react-dom": "18.3.1"
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user