| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534 |
- /**
- * 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, isMediaPlaceholder } from '@/lib/store/media-generation';
- import { getClientTranslation } from '@/lib/i18n';
- import type { AudioPlayer } from '@/lib/utils/audio-player';
- import type {
- Action,
- SpotlightAction,
- LaserAction,
- SpeechAction,
- PlayVideoAction,
- WbDrawTextAction,
- WbDrawShapeAction,
- WbDrawChartAction,
- WbDrawLatexAction,
- WbDrawTableAction,
- WbDeleteAction,
- WbDrawLineAction,
- } from '@/lib/types/action';
- import katex from 'katex';
- import { createLogger } from '@/lib/logger';
- const log = createLogger('ActionEngine');
- // ==================== SVG Paths for Shapes ====================
- const SHAPE_PATHS: Record<string, string> = {
- 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<void> {
- return new Promise((resolve) => setTimeout(resolve, ms));
- }
- // ==================== ActionEngine ====================
- /** Default duration (ms) before fire-and-forget effects auto-clear */
- const EFFECT_AUTO_CLEAR_MS = 5000;
- export class ActionEngine {
- private stageStore: StageStore;
- private stageAPI: ReturnType<typeof createStageAPI>;
- private audioPlayer: AudioPlayer | null;
- private effectTimer: ReturnType<typeof setTimeout> | null = null;
- constructor(stageStore: StageStore, audioPlayer?: AudioPlayer) {
- this.stageStore = stageStore;
- this.stageAPI = createStageAPI(stageStore);
- this.audioPlayer = audioPlayer ?? null;
- }
- /** Clean up timers when the engine is no longer needed */
- dispose(): void {
- if (this.effectTimer) {
- clearTimeout(this.effectTimer);
- this.effectTimer = null;
- }
- }
- /**
- * Execute a single action.
- * Fire-and-forget actions return immediately.
- * Synchronous actions return a Promise that resolves when the action is complete.
- */
- async execute(action: Action): Promise<void> {
- // Auto-open whiteboard if a draw/clear/delete action is attempted while it's closed
- if (action.type.startsWith('wb_') && action.type !== 'wb_open' && action.type !== 'wb_close') {
- await this.ensureWhiteboardOpen();
- }
- switch (action.type) {
- // Fire-and-forget
- case 'spotlight':
- this.executeSpotlight(action);
- return;
- case 'laser':
- this.executeLaser(action);
- return;
- // Synchronous — Video
- case 'play_video':
- return this.executePlayVideo(action as PlayVideoAction);
- // Synchronous
- case 'speech':
- return this.executeSpeech(action);
- case 'wb_open':
- return this.executeWbOpen();
- case 'wb_draw_text':
- return this.executeWbDrawText(action);
- case 'wb_draw_shape':
- return this.executeWbDrawShape(action);
- case 'wb_draw_chart':
- return this.executeWbDrawChart(action);
- case 'wb_draw_latex':
- return this.executeWbDrawLatex(action);
- case 'wb_draw_table':
- return this.executeWbDrawTable(action);
- case 'wb_draw_line':
- return this.executeWbDrawLine(action as WbDrawLineAction);
- case 'wb_clear':
- return this.executeWbClear();
- case 'wb_delete':
- return this.executeWbDelete(action as WbDeleteAction);
- case 'wb_close':
- return this.executeWbClose();
- case 'discussion':
- // Discussion lifecycle is managed externally via engine callbacks
- return;
- }
- }
- /** Clear all active visual effects */
- clearEffects(): void {
- if (this.effectTimer) {
- clearTimeout(this.effectTimer);
- this.effectTimer = null;
- }
- useCanvasStore.getState().clearAllEffects();
- }
- /** Schedule auto-clear for fire-and-forget effects */
- private scheduleEffectClear(): void {
- if (this.effectTimer) {
- clearTimeout(this.effectTimer);
- }
- this.effectTimer = setTimeout(() => {
- useCanvasStore.getState().clearAllEffects();
- this.effectTimer = null;
- }, EFFECT_AUTO_CLEAR_MS);
- }
- // ==================== Fire-and-forget ====================
- private executeSpotlight(action: SpotlightAction): void {
- useCanvasStore.getState().setSpotlight(action.elementId, {
- dimness: action.dimOpacity ?? 0.5,
- });
- this.scheduleEffectClear();
- }
- private executeLaser(action: LaserAction): void {
- useCanvasStore.getState().setLaser(action.elementId, {
- color: action.color ?? '#ff0000',
- });
- this.scheduleEffectClear();
- }
- // ==================== Synchronous — Speech ====================
- private async executeSpeech(action: SpeechAction): Promise<void> {
- if (!this.audioPlayer) return;
- return new Promise<void>((resolve) => {
- this.audioPlayer!.onEnded(() => resolve());
- this.audioPlayer!.play(action.audioId || '', action.audioUrl)
- .then((audioStarted) => {
- if (!audioStarted) resolve();
- })
- .catch(() => resolve());
- });
- }
- // ==================== Synchronous — Video ====================
- private async executePlayVideo(action: PlayVideoAction): Promise<void> {
- // Resolve the video element's src to a media placeholder ID (e.g. gen_vid_1).
- // action.elementId is the slide element ID (e.g. video_abc123), but the media
- // store is keyed by placeholder IDs, so we need to bridge the two.
- const placeholderId = this.resolveMediaPlaceholderId(action.elementId);
- if (placeholderId) {
- const task = useMediaGenerationStore.getState().getTask(placeholderId);
- if (task && task.status !== 'done') {
- // Wait for media to be ready (or fail)
- await new Promise<void>((resolve) => {
- const unsubscribe = useMediaGenerationStore.subscribe((state) => {
- const t = state.tasks[placeholderId];
- if (!t || t.status === 'done' || t.status === 'failed') {
- unsubscribe();
- resolve();
- }
- });
- // Check again in case it resolved between getState and subscribe
- const current = useMediaGenerationStore.getState().tasks[placeholderId];
- if (!current || current.status === 'done' || current.status === 'failed') {
- unsubscribe();
- resolve();
- }
- });
- // If failed, skip playback
- if (useMediaGenerationStore.getState().tasks[placeholderId]?.status === 'failed') {
- return;
- }
- }
- }
- useCanvasStore.getState().playVideo(action.elementId);
- // Wait until the video finishes playing, with a safety timeout to prevent
- // the playback engine from hanging indefinitely if the video element is
- // invalid or the state change is missed.
- return new Promise<void>((resolve) => {
- const MAX_VIDEO_WAIT_MS = 5 * 60 * 1000; // 5 minutes
- const timeout = setTimeout(() => {
- unsubscribe();
- log.warn(`[playVideo] Timeout waiting for video ${action.elementId} to finish`);
- resolve();
- }, MAX_VIDEO_WAIT_MS);
- const unsubscribe = useCanvasStore.subscribe((state) => {
- if (state.playingVideoElementId !== action.elementId) {
- clearTimeout(timeout);
- unsubscribe();
- resolve();
- }
- });
- if (useCanvasStore.getState().playingVideoElementId !== action.elementId) {
- clearTimeout(timeout);
- unsubscribe();
- resolve();
- }
- });
- }
- // ==================== Helpers — Media Resolution ====================
- /**
- * Look up a video/image element's src in the current stage's scenes.
- * Returns the src if it's a media placeholder ID (gen_vid_*, gen_img_*), null otherwise.
- */
- private resolveMediaPlaceholderId(elementId: string): string | null {
- const { scenes, currentSceneId } = this.stageStore.getState();
- // Search current scene first for efficiency, then remaining scenes
- const orderedScenes = currentSceneId
- ? [
- scenes.find((s) => s.id === currentSceneId),
- ...scenes.filter((s) => s.id !== currentSceneId),
- ]
- : scenes;
- for (const scene of orderedScenes) {
- if (!scene || scene.type !== 'slide') continue;
- const elements = (
- scene.content as {
- canvas?: { elements?: Array<{ id: string; src?: string }> };
- }
- )?.canvas?.elements;
- if (!Array.isArray(elements)) continue;
- const el = elements.find((e: { id: string }) => e.id === elementId);
- if (el && 'src' in el && typeof el.src === 'string' && isMediaPlaceholder(el.src)) {
- return el.src;
- }
- }
- return null;
- }
- // ==================== Synchronous — Whiteboard ====================
- /** Auto-open the whiteboard if it's not already open */
- private async ensureWhiteboardOpen(): Promise<void> {
- if (!useCanvasStore.getState().whiteboardOpen) {
- await this.executeWbOpen();
- }
- }
- private async executeWbOpen(): Promise<void> {
- // Ensure a whiteboard exists
- this.stageAPI.whiteboard.get();
- useCanvasStore.getState().setWhiteboardOpen(true);
- // Wait for open animation to complete (slow spring: stiffness 120, damping 18, mass 1.2)
- await delay(2000);
- }
- private async executeWbDrawText(action: WbDrawTextAction): Promise<void> {
- const wb = this.stageAPI.whiteboard.get();
- if (!wb.success || !wb.data) return;
- const fontSize = action.fontSize ?? 18;
- let htmlContent = action.content ?? '';
- if (!htmlContent) return; // nothing to draw
- if (!htmlContent.startsWith('<')) {
- htmlContent = `<p style="font-size: ${fontSize}px;">${htmlContent}</p>`;
- }
- this.stageAPI.whiteboard.addElement(
- {
- id: action.elementId || '',
- type: 'text',
- content: htmlContent,
- left: action.x,
- top: action.y,
- width: action.width ?? 400,
- height: action.height ?? 100,
- rotate: 0,
- defaultFontName: 'Microsoft YaHei',
- defaultColor: action.color ?? '#333333',
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- } as any,
- wb.data.id,
- );
- // Wait for element fade-in animation
- await delay(800);
- }
- private async executeWbDrawShape(action: WbDrawShapeAction): Promise<void> {
- const wb = this.stageAPI.whiteboard.get();
- if (!wb.success || !wb.data) return;
- this.stageAPI.whiteboard.addElement(
- {
- id: action.elementId || '',
- type: 'shape',
- viewBox: [1000, 1000] as [number, number],
- path: SHAPE_PATHS[action.shape] ?? SHAPE_PATHS.rectangle,
- left: action.x,
- top: action.y,
- width: action.width,
- height: action.height,
- rotate: 0,
- fill: action.fillColor ?? '#5b9bd5',
- fixedRatio: false,
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- } as any,
- wb.data.id,
- );
- // Wait for element fade-in animation
- await delay(800);
- }
- private async executeWbDrawChart(action: WbDrawChartAction): Promise<void> {
- const wb = this.stageAPI.whiteboard.get();
- if (!wb.success || !wb.data) return;
- this.stageAPI.whiteboard.addElement(
- {
- id: action.elementId || '',
- type: 'chart',
- left: action.x,
- top: action.y,
- width: action.width,
- height: action.height,
- rotate: 0,
- chartType: action.chartType,
- data: action.data,
- themeColors: action.themeColors ?? ['#5b9bd5', '#ed7d31', '#a5a5a5', '#ffc000', '#4472c4'],
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- } as any,
- wb.data.id,
- );
- await delay(800);
- }
- private async executeWbDrawLatex(action: WbDrawLatexAction): Promise<void> {
- const wb = this.stageAPI.whiteboard.get();
- if (!wb.success || !wb.data) return;
- try {
- const html = katex.renderToString(action.latex, {
- throwOnError: false,
- displayMode: true,
- output: 'html',
- });
- this.stageAPI.whiteboard.addElement(
- {
- id: action.elementId || '',
- type: 'latex',
- left: action.x,
- top: action.y,
- width: action.width ?? 400,
- height: action.height ?? 80,
- rotate: 0,
- latex: action.latex,
- html,
- color: action.color ?? '#000000',
- fixedRatio: true,
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- } as any,
- wb.data.id,
- );
- } catch (err) {
- log.warn(`Failed to render latex "${action.latex}":`, err);
- return;
- }
- await delay(800);
- }
- private async executeWbDrawTable(action: WbDrawTableAction): Promise<void> {
- const wb = this.stageAPI.whiteboard.get();
- if (!wb.success || !wb.data) return;
- const rows = action.data.length;
- const cols = rows > 0 ? action.data[0].length : 0;
- if (rows === 0 || cols === 0) return;
- // Build colWidths: equal distribution
- const colWidths = Array(cols).fill(1 / cols);
- // Build TableCell[][] from string[][]
- let cellId = 0;
- const tableData = action.data.map((row) =>
- row.map((text) => ({
- id: `cell_${cellId++}`,
- colspan: 1,
- rowspan: 1,
- text,
- })),
- );
- this.stageAPI.whiteboard.addElement(
- {
- id: action.elementId || '',
- type: 'table',
- left: action.x,
- top: action.y,
- width: action.width,
- height: action.height,
- rotate: 0,
- colWidths,
- cellMinHeight: 36,
- data: tableData,
- outline: action.outline ?? {
- width: 2,
- style: 'solid',
- color: '#eeece1',
- },
- theme: action.theme
- ? {
- color: action.theme.color,
- rowHeader: true,
- rowFooter: false,
- colHeader: false,
- colFooter: false,
- }
- : undefined,
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- } as any,
- wb.data.id,
- );
- await delay(800);
- }
- private async executeWbDrawLine(action: WbDrawLineAction): Promise<void> {
- const wb = this.stageAPI.whiteboard.get();
- if (!wb.success || !wb.data) return;
- // Calculate bounding box — left/top is the minimum of start/end coordinates
- const left = Math.min(action.startX, action.endX);
- const top = Math.min(action.startY, action.endY);
- // Convert absolute coordinates to relative coordinates (relative to left/top)
- const start: [number, number] = [action.startX - left, action.startY - top];
- const end: [number, number] = [action.endX - left, action.endY - top];
- this.stageAPI.whiteboard.addElement(
- {
- id: action.elementId || '',
- type: 'line',
- left,
- top,
- width: action.width ?? 2,
- start,
- end,
- style: action.style ?? 'solid',
- color: action.color ?? '#333333',
- points: action.points ?? ['', ''],
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- } as any,
- wb.data.id,
- );
- // Wait for element fade-in animation
- await delay(800);
- }
- private async executeWbDelete(action: WbDeleteAction): Promise<void> {
- const wb = this.stageAPI.whiteboard.get();
- if (!wb.success || !wb.data) return;
- this.stageAPI.whiteboard.deleteElement(action.elementId, wb.data.id);
- await delay(300);
- }
- private async executeWbClear(): Promise<void> {
- const wb = this.stageAPI.whiteboard.get();
- if (!wb.success || !wb.data) return;
- const elementCount = wb.data.elements?.length || 0;
- if (elementCount === 0) return;
- // Save snapshot before AI clear (mirrors UI handleClear in index.tsx)
- useWhiteboardHistoryStore.getState().pushSnapshot(wb.data.elements!);
- // Trigger cascade exit animation
- useCanvasStore.getState().setWhiteboardClearing(true);
- // Wait for cascade: base 380ms + 55ms per element, capped at 1400ms
- const animMs = Math.min(380 + elementCount * 55, 1400);
- await delay(animMs);
- // Actually remove elements
- this.stageAPI.whiteboard.update({ elements: [] }, wb.data.id);
- useCanvasStore.getState().setWhiteboardClearing(false);
- }
- private async executeWbClose(): Promise<void> {
- useCanvasStore.getState().setWhiteboardOpen(false);
- // Wait for close animation (500ms ease-out tween)
- await delay(700);
- }
- }
|