feat: add step-by-step visual replay and harden process lifecycle
- UI: Implement dual-mode playback in FloatingVideoPlayer supporting video and step-by-step frame replay with scrub controls and auto-fallback - Runtime: Prevent asyncio event loop hangs on Darwin and Linux by avoiding psutil.wait_procs on direct child processes - Task Queue: Add worker exit watchdog and automatic recording recovery for completed or interrupted sessions - Recording: Add ffmpeg ultrafast transcode fallback, shield recording finalization against cancellation, and handle on-the-fly MP4 conversion - Docs: Polish README_CN styling and streamline flash runner agent prompt
This commit is contained in:
1 parent
06643e955b
commit
a36a66ce6d
26 files changed
+1152
-117
No files matched your search
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
getStepPreImageUrl,
|
||||
getStepPostImageUrl
|
||||
getStepPostImageUrl,
|
||||
extractStepReplayFrames
|
||||
} from './action-formatter.util';
|
||||
|
||||
describe('action-formatter.util screenshot chaining', () => {
|
||||
@@ -109,4 +110,100 @@ describe('action-formatter.util screenshot chaining', () => {
|
||||
expect(preUrl).toBe('/images/pre123');
|
||||
expect(postUrl).toBe('/images/post456');
|
||||
});
|
||||
|
||||
it('should resolve post screenshot for primary action when generic_tools contains action traces', () => {
|
||||
const stepData = {
|
||||
step_id: 'step-02',
|
||||
pre_image_name: 'pre_screen_hash',
|
||||
post_image_name: 'post_screen_hash',
|
||||
action_taken: {
|
||||
action: 'click',
|
||||
coordinates: [500, 928],
|
||||
args: { target: [500, 928] },
|
||||
timestamp: 1000
|
||||
},
|
||||
generic_tools: [
|
||||
{
|
||||
trace_id: 'trace-click-internal',
|
||||
type: 'action',
|
||||
name: 'click',
|
||||
timestamp: 1001,
|
||||
payload: {
|
||||
args: { target: '[500, 928]', times: '1', delay_ms: '100' },
|
||||
result: {
|
||||
outcome: 'Clicked successfully.',
|
||||
post_image_name: 'post_screen_hash',
|
||||
status: 'success'
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const preUrl = getStepPreImageUrl(stepData, stepData.action_taken);
|
||||
const postUrl = getStepPostImageUrl(stepData, stepData.action_taken);
|
||||
|
||||
expect(preUrl).toBe('/images/pre_screen_hash');
|
||||
expect(postUrl).toBe('/images/post_screen_hash');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractStepReplayFrames', () => {
|
||||
it('should return an empty array for empty or invalid logs', () => {
|
||||
expect(extractStepReplayFrames([])).toEqual([]);
|
||||
expect(extractStepReplayFrames(null as any)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should extract ordered StepReplayFrames and deduplicate consecutive identical images', () => {
|
||||
const logs = [
|
||||
{
|
||||
type: 'step_updated',
|
||||
data: {
|
||||
step_id: 's-1',
|
||||
step_number: 1,
|
||||
pre_image_name: 'img_screen_1',
|
||||
post_image_name: 'img_screen_2',
|
||||
action_taken: { action: 'tap', coordinates: [500, 1000] },
|
||||
timestamp: 100
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'step_updated',
|
||||
data: {
|
||||
step_id: 's-2',
|
||||
step_number: 2,
|
||||
pre_image_name: 'img_screen_2',
|
||||
post_image_name: 'img_screen_3',
|
||||
action_taken: { action: 'input_text', text: 'hello' },
|
||||
timestamp: 200
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const frames = extractStepReplayFrames(logs);
|
||||
expect(frames.length).toBe(3);
|
||||
expect(frames[0].title).toBe('Step 1 (Before Action)');
|
||||
expect(frames[0].imageUrl).toBe('/images/img_screen_1');
|
||||
expect(frames[1].title).toContain('Step 1');
|
||||
expect(frames[1].imageUrl).toBe('/images/img_screen_2');
|
||||
expect(frames[2].title).toContain('Step 2');
|
||||
expect(frames[2].imageUrl).toBe('/images/img_screen_3');
|
||||
});
|
||||
|
||||
it('should handle terminal step with only pre_image (e.g. cancelled / failed task)', () => {
|
||||
const logs = [
|
||||
{
|
||||
step_number: 1,
|
||||
pre_image_name: 'img_start',
|
||||
post_image_name: null,
|
||||
action_taken: null,
|
||||
timestamp: 100
|
||||
}
|
||||
];
|
||||
|
||||
const frames = extractStepReplayFrames(logs);
|
||||
expect(frames.length).toBe(1);
|
||||
expect(frames[0].imageUrl).toBe('/images/img_start');
|
||||
expect(frames[0].stepNumber).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ActionParam } from '../core/models/stream.model';
|
||||
import { ActionParam, StepReplayFrame } from '../core/models/stream.model';
|
||||
import { extractNumbersFromCoordinateValue, isPureDirectionString, parseSequenceCoordinates } from './image-overlay.util';
|
||||
import { cleanErrorMessage } from './tool-formatter.util';
|
||||
|
||||
@@ -698,7 +698,7 @@ export function resolveStepImageMap(stepData: any): Map<any, ResolvedImageEntry>
|
||||
if (explicitPost) {
|
||||
resolvedPost = explicitPost;
|
||||
hasExplicitPost = true;
|
||||
} else if (e.isPrimary && !hadPrimaryFailure && !hasSubsequentActions) {
|
||||
} else if (e.isPrimary && !hadPrimaryFailure) {
|
||||
resolvedPost = stepData.post_image_name || stepData.post_screenshot || null;
|
||||
} else if (e.item === lastActionItem && !hadPrimaryFailure) {
|
||||
resolvedPost = stepData.post_image_name || stepData.post_screenshot || null;
|
||||
@@ -765,3 +765,86 @@ export function getStepPostImageUrl(stepData: any, actionData?: any): string | n
|
||||
}
|
||||
return postUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract an ordered array of visual StepReplayFrames from session logs or step blocks
|
||||
*/
|
||||
export function extractStepReplayFrames(logsOrSteps: any[]): StepReplayFrame[] {
|
||||
if (!Array.isArray(logsOrSteps) || logsOrSteps.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Extract raw step data objects
|
||||
const rawSteps: any[] = [];
|
||||
for (const item of logsOrSteps) {
|
||||
if (!item) continue;
|
||||
if (item.type === 'step_updated' && item.data) {
|
||||
rawSteps.push(item.data);
|
||||
} else if (item.type === 'step' && item.data) {
|
||||
rawSteps.push(item.data);
|
||||
} else if (item.step_number !== undefined || item.step_id !== undefined) {
|
||||
rawSteps.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate by step_id or step_number, keeping latest
|
||||
const stepMap = new Map<any, any>();
|
||||
for (const step of rawSteps) {
|
||||
const key = step.step_id || step.step_number;
|
||||
if (key !== undefined) {
|
||||
stepMap.set(key, step);
|
||||
}
|
||||
}
|
||||
|
||||
const steps = Array.from(stepMap.values()).sort((a, b) => {
|
||||
const numA = Number(a.step_number ?? 0);
|
||||
const numB = Number(b.step_number ?? 0);
|
||||
if (numA !== numB) return numA - numB;
|
||||
return Number(a.timestamp ?? 0) - Number(b.timestamp ?? 0);
|
||||
});
|
||||
|
||||
const frames: StepReplayFrame[] = [];
|
||||
let lastImageUrl: string | null = null;
|
||||
|
||||
for (const stepData of steps) {
|
||||
const stepNum = Number(stepData.step_number ?? (frames.length + 1));
|
||||
const act = stepData.action_taken;
|
||||
const title = act ? (getActionTitle(act) || 'Action') : 'Step';
|
||||
const coords = act ? getActionCoords(act) : '';
|
||||
const actionDesc = coords ? `${title} (${coords})` : title;
|
||||
|
||||
const preUrl = getStepPreImageUrl(stepData, act);
|
||||
const postUrl = getStepPostImageUrl(stepData, act);
|
||||
|
||||
// If pre-image exists and is new, add it
|
||||
if (preUrl && preUrl !== lastImageUrl) {
|
||||
frames.push({
|
||||
index: frames.length,
|
||||
stepNumber: stepNum,
|
||||
title: `Step ${stepNum} (Before Action)`,
|
||||
imageUrl: preUrl,
|
||||
actionText: `Preparing: ${actionDesc}`,
|
||||
isPost: false,
|
||||
timestamp: stepData.timestamp
|
||||
});
|
||||
lastImageUrl = preUrl;
|
||||
}
|
||||
|
||||
// If post-image exists and is different from current screen, add it
|
||||
if (postUrl && postUrl !== lastImageUrl) {
|
||||
frames.push({
|
||||
index: frames.length,
|
||||
stepNumber: stepNum,
|
||||
title: `Step ${stepNum}: ${actionDesc}`,
|
||||
imageUrl: postUrl,
|
||||
actionText: actionDesc,
|
||||
isPost: true,
|
||||
timestamp: stepData.timestamp
|
||||
});
|
||||
lastImageUrl = postUrl;
|
||||
}
|
||||
}
|
||||
|
||||
return frames;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user