engine.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534
  1. /**
  2. * ActionEngine — Unified execution layer for all agent actions.
  3. *
  4. * Replaces the 28 Vercel AI SDK tools in ai-tools.ts with a single engine
  5. * that both online (streaming) and offline (playback) paths share.
  6. *
  7. * Two execution modes:
  8. * - Fire-and-forget: spotlight, laser — dispatch and return immediately
  9. * - Synchronous: speech, whiteboard, discussion — await completion
  10. */
  11. import type { StageStore } from '@/lib/api/stage-api';
  12. import { createStageAPI } from '@/lib/api/stage-api';
  13. import { useCanvasStore } from '@/lib/store/canvas';
  14. import { useWhiteboardHistoryStore } from '@/lib/store/whiteboard-history';
  15. import { useMediaGenerationStore, isMediaPlaceholder } from '@/lib/store/media-generation';
  16. import { getClientTranslation } from '@/lib/i18n';
  17. import type { AudioPlayer } from '@/lib/utils/audio-player';
  18. import type {
  19. Action,
  20. SpotlightAction,
  21. LaserAction,
  22. SpeechAction,
  23. PlayVideoAction,
  24. WbDrawTextAction,
  25. WbDrawShapeAction,
  26. WbDrawChartAction,
  27. WbDrawLatexAction,
  28. WbDrawTableAction,
  29. WbDeleteAction,
  30. WbDrawLineAction,
  31. } from '@/lib/types/action';
  32. import katex from 'katex';
  33. import { createLogger } from '@/lib/logger';
  34. const log = createLogger('ActionEngine');
  35. // ==================== SVG Paths for Shapes ====================
  36. const SHAPE_PATHS: Record<string, string> = {
  37. rectangle: 'M 0 0 L 1000 0 L 1000 1000 L 0 1000 Z',
  38. circle: 'M 500 0 A 500 500 0 1 1 499 0 Z',
  39. triangle: 'M 500 0 L 1000 1000 L 0 1000 Z',
  40. };
  41. // ==================== Helpers ====================
  42. function delay(ms: number): Promise<void> {
  43. return new Promise((resolve) => setTimeout(resolve, ms));
  44. }
  45. // ==================== ActionEngine ====================
  46. /** Default duration (ms) before fire-and-forget effects auto-clear */
  47. const EFFECT_AUTO_CLEAR_MS = 5000;
  48. export class ActionEngine {
  49. private stageStore: StageStore;
  50. private stageAPI: ReturnType<typeof createStageAPI>;
  51. private audioPlayer: AudioPlayer | null;
  52. private effectTimer: ReturnType<typeof setTimeout> | null = null;
  53. constructor(stageStore: StageStore, audioPlayer?: AudioPlayer) {
  54. this.stageStore = stageStore;
  55. this.stageAPI = createStageAPI(stageStore);
  56. this.audioPlayer = audioPlayer ?? null;
  57. }
  58. /** Clean up timers when the engine is no longer needed */
  59. dispose(): void {
  60. if (this.effectTimer) {
  61. clearTimeout(this.effectTimer);
  62. this.effectTimer = null;
  63. }
  64. }
  65. /**
  66. * Execute a single action.
  67. * Fire-and-forget actions return immediately.
  68. * Synchronous actions return a Promise that resolves when the action is complete.
  69. */
  70. async execute(action: Action): Promise<void> {
  71. // Auto-open whiteboard if a draw/clear/delete action is attempted while it's closed
  72. if (action.type.startsWith('wb_') && action.type !== 'wb_open' && action.type !== 'wb_close') {
  73. await this.ensureWhiteboardOpen();
  74. }
  75. switch (action.type) {
  76. // Fire-and-forget
  77. case 'spotlight':
  78. this.executeSpotlight(action);
  79. return;
  80. case 'laser':
  81. this.executeLaser(action);
  82. return;
  83. // Synchronous — Video
  84. case 'play_video':
  85. return this.executePlayVideo(action as PlayVideoAction);
  86. // Synchronous
  87. case 'speech':
  88. return this.executeSpeech(action);
  89. case 'wb_open':
  90. return this.executeWbOpen();
  91. case 'wb_draw_text':
  92. return this.executeWbDrawText(action);
  93. case 'wb_draw_shape':
  94. return this.executeWbDrawShape(action);
  95. case 'wb_draw_chart':
  96. return this.executeWbDrawChart(action);
  97. case 'wb_draw_latex':
  98. return this.executeWbDrawLatex(action);
  99. case 'wb_draw_table':
  100. return this.executeWbDrawTable(action);
  101. case 'wb_draw_line':
  102. return this.executeWbDrawLine(action as WbDrawLineAction);
  103. case 'wb_clear':
  104. return this.executeWbClear();
  105. case 'wb_delete':
  106. return this.executeWbDelete(action as WbDeleteAction);
  107. case 'wb_close':
  108. return this.executeWbClose();
  109. case 'discussion':
  110. // Discussion lifecycle is managed externally via engine callbacks
  111. return;
  112. }
  113. }
  114. /** Clear all active visual effects */
  115. clearEffects(): void {
  116. if (this.effectTimer) {
  117. clearTimeout(this.effectTimer);
  118. this.effectTimer = null;
  119. }
  120. useCanvasStore.getState().clearAllEffects();
  121. }
  122. /** Schedule auto-clear for fire-and-forget effects */
  123. private scheduleEffectClear(): void {
  124. if (this.effectTimer) {
  125. clearTimeout(this.effectTimer);
  126. }
  127. this.effectTimer = setTimeout(() => {
  128. useCanvasStore.getState().clearAllEffects();
  129. this.effectTimer = null;
  130. }, EFFECT_AUTO_CLEAR_MS);
  131. }
  132. // ==================== Fire-and-forget ====================
  133. private executeSpotlight(action: SpotlightAction): void {
  134. useCanvasStore.getState().setSpotlight(action.elementId, {
  135. dimness: action.dimOpacity ?? 0.5,
  136. });
  137. this.scheduleEffectClear();
  138. }
  139. private executeLaser(action: LaserAction): void {
  140. useCanvasStore.getState().setLaser(action.elementId, {
  141. color: action.color ?? '#ff0000',
  142. });
  143. this.scheduleEffectClear();
  144. }
  145. // ==================== Synchronous — Speech ====================
  146. private async executeSpeech(action: SpeechAction): Promise<void> {
  147. if (!this.audioPlayer) return;
  148. return new Promise<void>((resolve) => {
  149. this.audioPlayer!.onEnded(() => resolve());
  150. this.audioPlayer!.play(action.audioId || '', action.audioUrl)
  151. .then((audioStarted) => {
  152. if (!audioStarted) resolve();
  153. })
  154. .catch(() => resolve());
  155. });
  156. }
  157. // ==================== Synchronous — Video ====================
  158. private async executePlayVideo(action: PlayVideoAction): Promise<void> {
  159. // Resolve the video element's src to a media placeholder ID (e.g. gen_vid_1).
  160. // action.elementId is the slide element ID (e.g. video_abc123), but the media
  161. // store is keyed by placeholder IDs, so we need to bridge the two.
  162. const placeholderId = this.resolveMediaPlaceholderId(action.elementId);
  163. if (placeholderId) {
  164. const task = useMediaGenerationStore.getState().getTask(placeholderId);
  165. if (task && task.status !== 'done') {
  166. // Wait for media to be ready (or fail)
  167. await new Promise<void>((resolve) => {
  168. const unsubscribe = useMediaGenerationStore.subscribe((state) => {
  169. const t = state.tasks[placeholderId];
  170. if (!t || t.status === 'done' || t.status === 'failed') {
  171. unsubscribe();
  172. resolve();
  173. }
  174. });
  175. // Check again in case it resolved between getState and subscribe
  176. const current = useMediaGenerationStore.getState().tasks[placeholderId];
  177. if (!current || current.status === 'done' || current.status === 'failed') {
  178. unsubscribe();
  179. resolve();
  180. }
  181. });
  182. // If failed, skip playback
  183. if (useMediaGenerationStore.getState().tasks[placeholderId]?.status === 'failed') {
  184. return;
  185. }
  186. }
  187. }
  188. useCanvasStore.getState().playVideo(action.elementId);
  189. // Wait until the video finishes playing, with a safety timeout to prevent
  190. // the playback engine from hanging indefinitely if the video element is
  191. // invalid or the state change is missed.
  192. return new Promise<void>((resolve) => {
  193. const MAX_VIDEO_WAIT_MS = 5 * 60 * 1000; // 5 minutes
  194. const timeout = setTimeout(() => {
  195. unsubscribe();
  196. log.warn(`[playVideo] Timeout waiting for video ${action.elementId} to finish`);
  197. resolve();
  198. }, MAX_VIDEO_WAIT_MS);
  199. const unsubscribe = useCanvasStore.subscribe((state) => {
  200. if (state.playingVideoElementId !== action.elementId) {
  201. clearTimeout(timeout);
  202. unsubscribe();
  203. resolve();
  204. }
  205. });
  206. if (useCanvasStore.getState().playingVideoElementId !== action.elementId) {
  207. clearTimeout(timeout);
  208. unsubscribe();
  209. resolve();
  210. }
  211. });
  212. }
  213. // ==================== Helpers — Media Resolution ====================
  214. /**
  215. * Look up a video/image element's src in the current stage's scenes.
  216. * Returns the src if it's a media placeholder ID (gen_vid_*, gen_img_*), null otherwise.
  217. */
  218. private resolveMediaPlaceholderId(elementId: string): string | null {
  219. const { scenes, currentSceneId } = this.stageStore.getState();
  220. // Search current scene first for efficiency, then remaining scenes
  221. const orderedScenes = currentSceneId
  222. ? [
  223. scenes.find((s) => s.id === currentSceneId),
  224. ...scenes.filter((s) => s.id !== currentSceneId),
  225. ]
  226. : scenes;
  227. for (const scene of orderedScenes) {
  228. if (!scene || scene.type !== 'slide') continue;
  229. const elements = (
  230. scene.content as {
  231. canvas?: { elements?: Array<{ id: string; src?: string }> };
  232. }
  233. )?.canvas?.elements;
  234. if (!Array.isArray(elements)) continue;
  235. const el = elements.find((e: { id: string }) => e.id === elementId);
  236. if (el && 'src' in el && typeof el.src === 'string' && isMediaPlaceholder(el.src)) {
  237. return el.src;
  238. }
  239. }
  240. return null;
  241. }
  242. // ==================== Synchronous — Whiteboard ====================
  243. /** Auto-open the whiteboard if it's not already open */
  244. private async ensureWhiteboardOpen(): Promise<void> {
  245. if (!useCanvasStore.getState().whiteboardOpen) {
  246. await this.executeWbOpen();
  247. }
  248. }
  249. private async executeWbOpen(): Promise<void> {
  250. // Ensure a whiteboard exists
  251. this.stageAPI.whiteboard.get();
  252. useCanvasStore.getState().setWhiteboardOpen(true);
  253. // Wait for open animation to complete (slow spring: stiffness 120, damping 18, mass 1.2)
  254. await delay(2000);
  255. }
  256. private async executeWbDrawText(action: WbDrawTextAction): Promise<void> {
  257. const wb = this.stageAPI.whiteboard.get();
  258. if (!wb.success || !wb.data) return;
  259. const fontSize = action.fontSize ?? 18;
  260. let htmlContent = action.content ?? '';
  261. if (!htmlContent) return; // nothing to draw
  262. if (!htmlContent.startsWith('<')) {
  263. htmlContent = `<p style="font-size: ${fontSize}px;">${htmlContent}</p>`;
  264. }
  265. this.stageAPI.whiteboard.addElement(
  266. {
  267. id: action.elementId || '',
  268. type: 'text',
  269. content: htmlContent,
  270. left: action.x,
  271. top: action.y,
  272. width: action.width ?? 400,
  273. height: action.height ?? 100,
  274. rotate: 0,
  275. defaultFontName: 'Microsoft YaHei',
  276. defaultColor: action.color ?? '#333333',
  277. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  278. } as any,
  279. wb.data.id,
  280. );
  281. // Wait for element fade-in animation
  282. await delay(800);
  283. }
  284. private async executeWbDrawShape(action: WbDrawShapeAction): Promise<void> {
  285. const wb = this.stageAPI.whiteboard.get();
  286. if (!wb.success || !wb.data) return;
  287. this.stageAPI.whiteboard.addElement(
  288. {
  289. id: action.elementId || '',
  290. type: 'shape',
  291. viewBox: [1000, 1000] as [number, number],
  292. path: SHAPE_PATHS[action.shape] ?? SHAPE_PATHS.rectangle,
  293. left: action.x,
  294. top: action.y,
  295. width: action.width,
  296. height: action.height,
  297. rotate: 0,
  298. fill: action.fillColor ?? '#5b9bd5',
  299. fixedRatio: false,
  300. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  301. } as any,
  302. wb.data.id,
  303. );
  304. // Wait for element fade-in animation
  305. await delay(800);
  306. }
  307. private async executeWbDrawChart(action: WbDrawChartAction): Promise<void> {
  308. const wb = this.stageAPI.whiteboard.get();
  309. if (!wb.success || !wb.data) return;
  310. this.stageAPI.whiteboard.addElement(
  311. {
  312. id: action.elementId || '',
  313. type: 'chart',
  314. left: action.x,
  315. top: action.y,
  316. width: action.width,
  317. height: action.height,
  318. rotate: 0,
  319. chartType: action.chartType,
  320. data: action.data,
  321. themeColors: action.themeColors ?? ['#5b9bd5', '#ed7d31', '#a5a5a5', '#ffc000', '#4472c4'],
  322. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  323. } as any,
  324. wb.data.id,
  325. );
  326. await delay(800);
  327. }
  328. private async executeWbDrawLatex(action: WbDrawLatexAction): Promise<void> {
  329. const wb = this.stageAPI.whiteboard.get();
  330. if (!wb.success || !wb.data) return;
  331. try {
  332. const html = katex.renderToString(action.latex, {
  333. throwOnError: false,
  334. displayMode: true,
  335. output: 'html',
  336. });
  337. this.stageAPI.whiteboard.addElement(
  338. {
  339. id: action.elementId || '',
  340. type: 'latex',
  341. left: action.x,
  342. top: action.y,
  343. width: action.width ?? 400,
  344. height: action.height ?? 80,
  345. rotate: 0,
  346. latex: action.latex,
  347. html,
  348. color: action.color ?? '#000000',
  349. fixedRatio: true,
  350. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  351. } as any,
  352. wb.data.id,
  353. );
  354. } catch (err) {
  355. log.warn(`Failed to render latex "${action.latex}":`, err);
  356. return;
  357. }
  358. await delay(800);
  359. }
  360. private async executeWbDrawTable(action: WbDrawTableAction): Promise<void> {
  361. const wb = this.stageAPI.whiteboard.get();
  362. if (!wb.success || !wb.data) return;
  363. const rows = action.data.length;
  364. const cols = rows > 0 ? action.data[0].length : 0;
  365. if (rows === 0 || cols === 0) return;
  366. // Build colWidths: equal distribution
  367. const colWidths = Array(cols).fill(1 / cols);
  368. // Build TableCell[][] from string[][]
  369. let cellId = 0;
  370. const tableData = action.data.map((row) =>
  371. row.map((text) => ({
  372. id: `cell_${cellId++}`,
  373. colspan: 1,
  374. rowspan: 1,
  375. text,
  376. })),
  377. );
  378. this.stageAPI.whiteboard.addElement(
  379. {
  380. id: action.elementId || '',
  381. type: 'table',
  382. left: action.x,
  383. top: action.y,
  384. width: action.width,
  385. height: action.height,
  386. rotate: 0,
  387. colWidths,
  388. cellMinHeight: 36,
  389. data: tableData,
  390. outline: action.outline ?? {
  391. width: 2,
  392. style: 'solid',
  393. color: '#eeece1',
  394. },
  395. theme: action.theme
  396. ? {
  397. color: action.theme.color,
  398. rowHeader: true,
  399. rowFooter: false,
  400. colHeader: false,
  401. colFooter: false,
  402. }
  403. : undefined,
  404. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  405. } as any,
  406. wb.data.id,
  407. );
  408. await delay(800);
  409. }
  410. private async executeWbDrawLine(action: WbDrawLineAction): Promise<void> {
  411. const wb = this.stageAPI.whiteboard.get();
  412. if (!wb.success || !wb.data) return;
  413. // Calculate bounding box — left/top is the minimum of start/end coordinates
  414. const left = Math.min(action.startX, action.endX);
  415. const top = Math.min(action.startY, action.endY);
  416. // Convert absolute coordinates to relative coordinates (relative to left/top)
  417. const start: [number, number] = [action.startX - left, action.startY - top];
  418. const end: [number, number] = [action.endX - left, action.endY - top];
  419. this.stageAPI.whiteboard.addElement(
  420. {
  421. id: action.elementId || '',
  422. type: 'line',
  423. left,
  424. top,
  425. width: action.width ?? 2,
  426. start,
  427. end,
  428. style: action.style ?? 'solid',
  429. color: action.color ?? '#333333',
  430. points: action.points ?? ['', ''],
  431. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  432. } as any,
  433. wb.data.id,
  434. );
  435. // Wait for element fade-in animation
  436. await delay(800);
  437. }
  438. private async executeWbDelete(action: WbDeleteAction): Promise<void> {
  439. const wb = this.stageAPI.whiteboard.get();
  440. if (!wb.success || !wb.data) return;
  441. this.stageAPI.whiteboard.deleteElement(action.elementId, wb.data.id);
  442. await delay(300);
  443. }
  444. private async executeWbClear(): Promise<void> {
  445. const wb = this.stageAPI.whiteboard.get();
  446. if (!wb.success || !wb.data) return;
  447. const elementCount = wb.data.elements?.length || 0;
  448. if (elementCount === 0) return;
  449. // Save snapshot before AI clear (mirrors UI handleClear in index.tsx)
  450. useWhiteboardHistoryStore.getState().pushSnapshot(wb.data.elements!);
  451. // Trigger cascade exit animation
  452. useCanvasStore.getState().setWhiteboardClearing(true);
  453. // Wait for cascade: base 380ms + 55ms per element, capped at 1400ms
  454. const animMs = Math.min(380 + elementCount * 55, 1400);
  455. await delay(animMs);
  456. // Actually remove elements
  457. this.stageAPI.whiteboard.update({ elements: [] }, wb.data.id);
  458. useCanvasStore.getState().setWhiteboardClearing(false);
  459. }
  460. private async executeWbClose(): Promise<void> {
  461. useCanvasStore.getState().setWhiteboardOpen(false);
  462. // Wait for close animation (500ms ease-out tween)
  463. await delay(700);
  464. }
  465. }