/** * ActionEngine — Unified execution layer for all agent actions. * * Replaces the 28 Vercel AI SDK tools in ai-tools.ts with a single engine * that both online (streaming) and offline (playback) paths share. * * Two execution modes: * - Fire-and-forget: spotlight, laser — dispatch and return immediately * - Synchronous: speech, whiteboard, discussion — await completion */ import type { StageStore } from '@/lib/api/stage-api'; import { createStageAPI } from '@/lib/api/stage-api'; import { useCanvasStore } from '@/lib/store/canvas'; import { useWhiteboardHistoryStore } from '@/lib/store/whiteboard-history'; import { useMediaGenerationStore, type MediaTask } from '@/lib/store/media-generation'; import type { AudioPlayer } from '@/lib/utils/audio-player'; import type { LegacySpeechAction } from '@/lib/types/action'; import type { Action, SpotlightAction, LaserAction, SpeechAction, PlayVideoAction, WbDrawTextAction, WbDrawShapeAction, WbDrawChartAction, WbDrawLatexAction, WbDrawTableAction, WbDeleteAction, WbDrawLineAction, WbDrawCodeAction, WbEditCodeAction, WidgetHighlightAction, WidgetSetStateAction, WidgetAnnotationAction, WidgetRevealAction, } from '@/lib/types/action'; import type { CodeLine, PPTVideoElement } from '@openmaic/dsl'; import { resolveVideoMediaForElement, type VideoMediaTaskResolution, } from '@/lib/media/media-task-resolution'; import { EFFECT_AUTO_CLEAR_MS, MAX_VIDEO_WAIT_MS, WB_OPEN_MS, WB_DRAW_MS, WB_EDIT_MS, WB_DELETE_MS, WB_CLOSE_MS, WIDGET_MS, wbDrawCodeMs, wbClearMs, } from '@/lib/choreography'; import katex from 'katex'; import { createLogger } from '@/lib/logger'; const log = createLogger('ActionEngine'); // ==================== SVG Paths for Shapes ==================== const SHAPE_PATHS: Record = { rectangle: 'M 0 0 L 1000 0 L 1000 1000 L 0 1000 Z', circle: 'M 500 0 A 500 500 0 1 1 499 0 Z', triangle: 'M 500 0 L 1000 1000 L 0 1000 Z', }; // ==================== Helpers ==================== function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } const COMMON_LATEX_COMMAND = /\\(?:alpha|beta|cdot|delta|dfrac|frac|gamma|infty|int|lambda|left|lim|mu|neq|omega|pi|pm|prod|rightarrow|right|sigma|sqrt|sum|text|tfrac|theta|times)\b/; function getDelimitedLatex(content: string): string | null { const trimmed = content.trim(); if (trimmed.length > 4 && trimmed.startsWith('$$') && trimmed.endsWith('$$')) { return trimmed.slice(2, -2).trim(); } if ( trimmed.length > 2 && trimmed.startsWith('$') && trimmed.endsWith('$') && !trimmed.startsWith('$$') && !trimmed.endsWith('$$') ) { return trimmed.slice(1, -1).trim(); } return null; } function getLikelyLatexMath(content: string): string | null { const trimmed = content.trim(); if (!trimmed || trimmed.startsWith('<')) return null; const delimitedLatex = getDelimitedLatex(trimmed); if (delimitedLatex !== null) return delimitedLatex; if (/^[A-Za-z]:\\/.test(trimmed)) return null; if (COMMON_LATEX_COMMAND.test(trimmed) || /[_^]\{/.test(trimmed)) return trimmed; const commands = trimmed.match(/\\[A-Za-z]+/g) ?? []; if (commands.length === 0) return null; if (commands.length === 1) { return /\\[A-Za-z]+\s*\{[^{}]*\}/.test(trimmed) ? trimmed : null; } if (!/[=+\-*/^_{}]/.test(trimmed)) return null; const commandCharacters = commands.reduce((total, command) => total + command.length, 0); return commandCharacters / trimmed.length >= 0.15 ? trimmed : null; } /** Convert raw code string to CodeLine array with unique IDs */ function codeToLines(code: string): CodeLine[] { return code.split('\n').map((content, i) => ({ id: `L${i + 1}`, content, })); } let lineIdCounter = 0; /** Generate unique line IDs for newly inserted lines */ function generateLineIds(count: number): string[] { return Array.from({ length: count }, () => `L_${++lineIdCounter}_${Date.now().toString(36)}`); } /** Resolve the video element and its renderer-equivalent media binding for an action. */ export function resolveActionVideoMedia( stageStore: StageStore, tasks: Readonly>, elementId: string, ): VideoMediaTaskResolution | undefined { const { stage, scenes, currentSceneId } = stageStore.getState(); const orderedScenes = currentSceneId ? [ scenes.find((scene) => scene.id === currentSceneId), ...scenes.filter((scene) => scene.id !== currentSceneId), ] : scenes; for (const scene of orderedScenes) { if (!scene || scene.content.type !== 'slide') continue; const element = scene.content.canvas.elements.find( (candidate): candidate is PPTVideoElement => candidate.id === elementId && candidate.type === 'video', ); if (element) return resolveVideoMediaForElement(tasks, element, stage?.id); } return undefined; } /** * Renderer-aligned playability: a task is playable only once its bytes exist * (`done` + objectUrl). A deferred restore is `done` without an objectUrl and * the renderer still treats it as pending (`resolveMediaRef` maps it to * 'pending'), so starting playback in that state would set * playingVideoElementId while no