mulmoclaude 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (408) hide show
  1. package/README.md +44 -0
  2. package/bin/mulmoclaude.js +202 -0
  3. package/bin/prepare-dist.js +93 -0
  4. package/client/assets/chunk-vKJrgz-R-C_I3GbVV.js +1 -0
  5. package/client/assets/html2canvas-Cx501zZr-BF5dYYkY.js +5 -0
  6. package/client/assets/index-D8rhwXLq.js +4906 -0
  7. package/client/assets/index-KNLBjwuh.css +1 -0
  8. package/client/assets/index.es-D4YyL_Dg-BfRHLTZV.js +5 -0
  9. package/client/assets/material-icons-Dr0goTwe.woff +0 -0
  10. package/client/assets/material-icons-kAwBdRge.woff2 +0 -0
  11. package/client/assets/material-icons-outlined-BpWbwl2n.woff +0 -0
  12. package/client/assets/material-icons-outlined-DZhiGvEA.woff2 +0 -0
  13. package/client/assets/material-icons-round-BDlwx-sv.woff +0 -0
  14. package/client/assets/material-icons-round-DrirKXBx.woff2 +0 -0
  15. package/client/assets/material-icons-sharp-CH1KkVu7.woff +0 -0
  16. package/client/assets/material-icons-sharp-gidztirS.woff2 +0 -0
  17. package/client/assets/material-icons-two-tone-B7wz7mED.woff +0 -0
  18. package/client/assets/material-icons-two-tone-DuNIpaEj.woff2 +0 -0
  19. package/client/assets/mulmo_bw-ERmkSv0a.png +0 -0
  20. package/client/assets/purify.es-Fx1Nqyry-PeS5RUhs.js +2 -0
  21. package/client/assets/typeof-DBp4T-Ny-BC0P-2DM.js +1 -0
  22. package/client/index.html +28 -0
  23. package/package.json +66 -0
  24. package/server/agent/attachmentConverter.ts +270 -0
  25. package/server/agent/config.ts +414 -0
  26. package/server/agent/index.ts +260 -0
  27. package/server/agent/mcp-server.ts +412 -0
  28. package/server/agent/mcp-tools/index.ts +63 -0
  29. package/server/agent/mcp-tools/x.ts +188 -0
  30. package/server/agent/plugin-names.ts +75 -0
  31. package/server/agent/prompt.ts +349 -0
  32. package/server/agent/resumeFailover.ts +129 -0
  33. package/server/agent/sandboxMounts.ts +329 -0
  34. package/server/agent/stream.ts +194 -0
  35. package/server/api/auth/bearerAuth.ts +61 -0
  36. package/server/api/auth/token.ts +98 -0
  37. package/server/api/csrfGuard.ts +85 -0
  38. package/server/api/routes/agent.ts +478 -0
  39. package/server/api/routes/chart.ts +98 -0
  40. package/server/api/routes/chat-index.ts +46 -0
  41. package/server/api/routes/config.ts +258 -0
  42. package/server/api/routes/dispatchResponse.ts +79 -0
  43. package/server/api/routes/files.ts +812 -0
  44. package/server/api/routes/html.ts +101 -0
  45. package/server/api/routes/image.ts +169 -0
  46. package/server/api/routes/mulmo-script.ts +712 -0
  47. package/server/api/routes/mulmoScriptValidate.ts +101 -0
  48. package/server/api/routes/notifications.ts +69 -0
  49. package/server/api/routes/pdf.ts +163 -0
  50. package/server/api/routes/plugins.ts +276 -0
  51. package/server/api/routes/presentHtml.ts +48 -0
  52. package/server/api/routes/roles.ts +125 -0
  53. package/server/api/routes/scheduler.ts +153 -0
  54. package/server/api/routes/schedulerHandlers.ts +151 -0
  55. package/server/api/routes/schedulerTasks.ts +163 -0
  56. package/server/api/routes/sessions.ts +294 -0
  57. package/server/api/routes/sessionsCursor.ts +59 -0
  58. package/server/api/routes/skills.ts +195 -0
  59. package/server/api/routes/sources.ts +540 -0
  60. package/server/api/routes/todos.ts +263 -0
  61. package/server/api/routes/todosColumnsHandlers.ts +347 -0
  62. package/server/api/routes/todosHandlers.ts +274 -0
  63. package/server/api/routes/todosItemsHandlers.ts +386 -0
  64. package/server/api/routes/wiki/pageIndex.ts +53 -0
  65. package/server/api/routes/wiki.ts +363 -0
  66. package/server/api/sandboxStatus.ts +64 -0
  67. package/server/events/notifications.ts +160 -0
  68. package/server/events/pub-sub/index.ts +45 -0
  69. package/server/events/relay-client.ts +288 -0
  70. package/server/events/scheduler-adapter.ts +302 -0
  71. package/server/events/session-store/index.ts +492 -0
  72. package/server/events/task-manager/index.ts +181 -0
  73. package/server/index.ts +572 -0
  74. package/server/system/config.ts +243 -0
  75. package/server/system/credentials.ts +220 -0
  76. package/server/system/docker.ts +97 -0
  77. package/server/system/env.ts +109 -0
  78. package/server/system/logger/config.ts +112 -0
  79. package/server/system/logger/formatters.ts +40 -0
  80. package/server/system/logger/index.ts +53 -0
  81. package/server/system/logger/rotation.ts +37 -0
  82. package/server/system/logger/sinks.ts +101 -0
  83. package/server/system/logger/types.ts +29 -0
  84. package/server/utils/date.ts +57 -0
  85. package/server/utils/errors.ts +7 -0
  86. package/server/utils/fetch.ts +27 -0
  87. package/server/utils/files/atomic.ts +125 -0
  88. package/server/utils/files/html-io.ts +20 -0
  89. package/server/utils/files/image-store.ts +66 -0
  90. package/server/utils/files/index.ts +45 -0
  91. package/server/utils/files/journal-io.ts +213 -0
  92. package/server/utils/files/json.ts +69 -0
  93. package/server/utils/files/markdown-store.ts +33 -0
  94. package/server/utils/files/naming.ts +50 -0
  95. package/server/utils/files/reference-dirs-io.ts +45 -0
  96. package/server/utils/files/roles-io.ts +45 -0
  97. package/server/utils/files/safe.ts +106 -0
  98. package/server/utils/files/scheduler-io.ts +20 -0
  99. package/server/utils/files/scheduler-overrides-io.ts +64 -0
  100. package/server/utils/files/session-io.ts +136 -0
  101. package/server/utils/files/spreadsheet-store.ts +63 -0
  102. package/server/utils/files/todos-io.ts +29 -0
  103. package/server/utils/files/user-tasks-io.ts +25 -0
  104. package/server/utils/files/workspace-io.ts +221 -0
  105. package/server/utils/gemini.ts +59 -0
  106. package/server/utils/gitignore.ts +69 -0
  107. package/server/utils/http.ts +15 -0
  108. package/server/utils/httpError.ts +61 -0
  109. package/server/utils/id.ts +16 -0
  110. package/server/utils/json.ts +83 -0
  111. package/server/utils/logBackgroundError.ts +22 -0
  112. package/server/utils/markdown.ts +82 -0
  113. package/server/utils/request.ts +29 -0
  114. package/server/utils/slug.ts +50 -0
  115. package/server/utils/spawn.ts +62 -0
  116. package/server/utils/time.ts +34 -0
  117. package/server/utils/types.ts +47 -0
  118. package/server/workspace/chat-index/index.ts +153 -0
  119. package/server/workspace/chat-index/indexer.ts +209 -0
  120. package/server/workspace/chat-index/paths.ts +34 -0
  121. package/server/workspace/chat-index/summarizer.ts +247 -0
  122. package/server/workspace/chat-index/types.ts +38 -0
  123. package/server/workspace/custom-dirs.ts +220 -0
  124. package/server/workspace/helps/business.md +104 -0
  125. package/server/workspace/helps/github.md +23 -0
  126. package/server/workspace/helps/index.md +60 -0
  127. package/server/workspace/helps/mulmoscript.md +249 -0
  128. package/server/workspace/helps/sandbox.md +90 -0
  129. package/server/workspace/helps/spreadsheet.md +43 -0
  130. package/server/workspace/helps/telegram.md +135 -0
  131. package/server/workspace/helps/wiki.md +131 -0
  132. package/server/workspace/journal/archivist.ts +386 -0
  133. package/server/workspace/journal/dailyPass.ts +743 -0
  134. package/server/workspace/journal/diff.ts +71 -0
  135. package/server/workspace/journal/index.ts +185 -0
  136. package/server/workspace/journal/indexFile.ts +136 -0
  137. package/server/workspace/journal/linkRewrite.ts +4 -0
  138. package/server/workspace/journal/memoryExtractor.ts +130 -0
  139. package/server/workspace/journal/optimizationPass.ts +160 -0
  140. package/server/workspace/journal/paths.ts +76 -0
  141. package/server/workspace/journal/state.ts +125 -0
  142. package/server/workspace/paths.ts +158 -0
  143. package/server/workspace/reference-dirs.ts +252 -0
  144. package/server/workspace/roles.ts +37 -0
  145. package/server/workspace/skills/discovery.ts +125 -0
  146. package/server/workspace/skills/index.ts +10 -0
  147. package/server/workspace/skills/parser.ts +144 -0
  148. package/server/workspace/skills/paths.ts +41 -0
  149. package/server/workspace/skills/scheduler.ts +149 -0
  150. package/server/workspace/skills/types.ts +30 -0
  151. package/server/workspace/skills/user-tasks.ts +257 -0
  152. package/server/workspace/skills/writer.ts +189 -0
  153. package/server/workspace/sources/arxivDiscovery.ts +182 -0
  154. package/server/workspace/sources/classifier.ts +268 -0
  155. package/server/workspace/sources/fetchers/arxiv.ts +170 -0
  156. package/server/workspace/sources/fetchers/github.ts +106 -0
  157. package/server/workspace/sources/fetchers/githubIssues.ts +208 -0
  158. package/server/workspace/sources/fetchers/githubReleases.ts +186 -0
  159. package/server/workspace/sources/fetchers/index.ts +71 -0
  160. package/server/workspace/sources/fetchers/registerAll.ts +15 -0
  161. package/server/workspace/sources/fetchers/rss.ts +141 -0
  162. package/server/workspace/sources/fetchers/rssParser.ts +295 -0
  163. package/server/workspace/sources/httpFetcher.ts +230 -0
  164. package/server/workspace/sources/interests.ts +120 -0
  165. package/server/workspace/sources/paths.ts +110 -0
  166. package/server/workspace/sources/pipeline/dedup.ts +60 -0
  167. package/server/workspace/sources/pipeline/fetch.ts +136 -0
  168. package/server/workspace/sources/pipeline/index.ts +249 -0
  169. package/server/workspace/sources/pipeline/notify.ts +72 -0
  170. package/server/workspace/sources/pipeline/plan.ts +66 -0
  171. package/server/workspace/sources/pipeline/summarize.ts +189 -0
  172. package/server/workspace/sources/pipeline/write.ts +185 -0
  173. package/server/workspace/sources/rateLimiter.ts +148 -0
  174. package/server/workspace/sources/registry.ts +326 -0
  175. package/server/workspace/sources/robots.ts +271 -0
  176. package/server/workspace/sources/sourceState.ts +135 -0
  177. package/server/workspace/sources/taxonomy.ts +74 -0
  178. package/server/workspace/sources/types.ts +144 -0
  179. package/server/workspace/sources/urls.ts +112 -0
  180. package/server/workspace/tool-trace/classify.ts +114 -0
  181. package/server/workspace/tool-trace/index.ts +250 -0
  182. package/server/workspace/tool-trace/writeSearch.ts +98 -0
  183. package/server/workspace/wiki-backlinks/index.ts +107 -0
  184. package/server/workspace/wiki-backlinks/sessionBacklinks.ts +144 -0
  185. package/server/workspace/workspace.ts +66 -0
  186. package/src/App.vue +720 -0
  187. package/src/assets/mulmo_bw.png +0 -0
  188. package/src/components/CanvasViewToggle.vue +27 -0
  189. package/src/components/ChatAttachmentPreview.vue +45 -0
  190. package/src/components/ChatImagePreview.vue +17 -0
  191. package/src/components/ChatInput.vue +208 -0
  192. package/src/components/FileContentHeader.vue +49 -0
  193. package/src/components/FileContentRenderer.vue +162 -0
  194. package/src/components/FileTree.vue +115 -0
  195. package/src/components/FileTreePane.vue +85 -0
  196. package/src/components/FilesView.vue +206 -0
  197. package/src/components/LockStatusPopup.vue +111 -0
  198. package/src/components/NotificationBell.vue +131 -0
  199. package/src/components/NotificationToast.vue +72 -0
  200. package/src/components/PluginLauncher.vue +138 -0
  201. package/src/components/RightSidebar.vue +113 -0
  202. package/src/components/RoleSelector.vue +64 -0
  203. package/src/components/SessionHistoryPanel.vue +176 -0
  204. package/src/components/SessionTabBar.vue +81 -0
  205. package/src/components/SettingsMcpTab.vue +350 -0
  206. package/src/components/SettingsModal.vue +275 -0
  207. package/src/components/SettingsReferenceDirsTab.vue +173 -0
  208. package/src/components/SettingsWorkspaceDirsTab.vue +174 -0
  209. package/src/components/SidebarHeader.vue +69 -0
  210. package/src/components/StackView.vue +360 -0
  211. package/src/components/SuggestionsPanel.vue +65 -0
  212. package/src/components/TodoExplorer.vue +358 -0
  213. package/src/components/ToolResultsPanel.vue +77 -0
  214. package/src/components/todo/TodoAddDialog.vue +131 -0
  215. package/src/components/todo/TodoEditDialog.vue +47 -0
  216. package/src/components/todo/TodoEditPanel.vue +113 -0
  217. package/src/components/todo/TodoKanbanView.vue +249 -0
  218. package/src/components/todo/TodoListView.vue +79 -0
  219. package/src/components/todo/TodoTableView.vue +177 -0
  220. package/src/composables/useActiveSession.ts +40 -0
  221. package/src/composables/useAppApi.ts +45 -0
  222. package/src/composables/useCanvasViewMode.ts +121 -0
  223. package/src/composables/useChatScroll.ts +47 -0
  224. package/src/composables/useClickOutside.ts +26 -0
  225. package/src/composables/useClipboardCopy.ts +44 -0
  226. package/src/composables/useContentDisplay.ts +52 -0
  227. package/src/composables/useDebugBeat.ts +23 -0
  228. package/src/composables/useDynamicFavicon.ts +115 -0
  229. package/src/composables/useEventListeners.ts +42 -0
  230. package/src/composables/useExpandedDirs.ts +64 -0
  231. package/src/composables/useFaviconState.ts +30 -0
  232. package/src/composables/useFileSelection.ts +115 -0
  233. package/src/composables/useFileSortMode.ts +24 -0
  234. package/src/composables/useFileTree.ts +85 -0
  235. package/src/composables/useFreshPluginData.ts +89 -0
  236. package/src/composables/useHealth.ts +38 -0
  237. package/src/composables/useImeAwareEnter.ts +57 -0
  238. package/src/composables/useKeyNavigation.ts +60 -0
  239. package/src/composables/useMarkdownLinkHandler.ts +46 -0
  240. package/src/composables/useMarkdownMode.ts +17 -0
  241. package/src/composables/useMcpTools.ts +71 -0
  242. package/src/composables/useMergedSessions.ts +27 -0
  243. package/src/composables/useNotifications.ts +90 -0
  244. package/src/composables/usePdfDownload.ts +60 -0
  245. package/src/composables/usePendingCalls.ts +77 -0
  246. package/src/composables/usePubSub.ts +85 -0
  247. package/src/composables/useRightSidebar.ts +23 -0
  248. package/src/composables/useRoles.ts +34 -0
  249. package/src/composables/useSandboxStatus.ts +67 -0
  250. package/src/composables/useSelectedResult.ts +49 -0
  251. package/src/composables/useSessionDerived.ts +51 -0
  252. package/src/composables/useSessionHistory.ts +81 -0
  253. package/src/composables/useSessionSync.ts +57 -0
  254. package/src/composables/useViewLayout.ts +55 -0
  255. package/src/config/apiRoutes.ts +173 -0
  256. package/src/config/pubsubChannels.ts +45 -0
  257. package/src/config/roles.ts +335 -0
  258. package/src/config/schedulerActions.ts +25 -0
  259. package/src/config/toolNames.ts +71 -0
  260. package/src/config/workspacePaths.ts +24 -0
  261. package/src/index.css +107 -0
  262. package/src/main.ts +25 -0
  263. package/src/plugins/canvas/Preview.vue +13 -0
  264. package/src/plugins/canvas/View.vue +333 -0
  265. package/src/plugins/canvas/definition.ts +38 -0
  266. package/src/plugins/canvas/index.ts +36 -0
  267. package/src/plugins/chart/Preview.vue +49 -0
  268. package/src/plugins/chart/View.vue +143 -0
  269. package/src/plugins/chart/definition.ts +58 -0
  270. package/src/plugins/chart/index.ts +52 -0
  271. package/src/plugins/editImage/Preview.vue +13 -0
  272. package/src/plugins/editImage/View.vue +13 -0
  273. package/src/plugins/editImage/definition.ts +27 -0
  274. package/src/plugins/editImage/index.ts +36 -0
  275. package/src/plugins/generateImage/Preview.vue +13 -0
  276. package/src/plugins/generateImage/View.vue +33 -0
  277. package/src/plugins/generateImage/definition.ts +32 -0
  278. package/src/plugins/generateImage/index.ts +56 -0
  279. package/src/plugins/manageRoles/Preview.vue +49 -0
  280. package/src/plugins/manageRoles/View.vue +525 -0
  281. package/src/plugins/manageRoles/definition.ts +43 -0
  282. package/src/plugins/manageRoles/index.ts +47 -0
  283. package/src/plugins/manageSkills/Preview.vue +21 -0
  284. package/src/plugins/manageSkills/View.vue +321 -0
  285. package/src/plugins/manageSkills/definition.ts +49 -0
  286. package/src/plugins/manageSkills/index.ts +49 -0
  287. package/src/plugins/manageSource/Preview.vue +33 -0
  288. package/src/plugins/manageSource/View.vue +697 -0
  289. package/src/plugins/manageSource/definition.ts +63 -0
  290. package/src/plugins/manageSource/index.ts +66 -0
  291. package/src/plugins/markdown/Preview.vue +77 -0
  292. package/src/plugins/markdown/View.vue +476 -0
  293. package/src/plugins/markdown/definition.ts +50 -0
  294. package/src/plugins/markdown/index.ts +36 -0
  295. package/src/plugins/presentHtml/Preview.vue +25 -0
  296. package/src/plugins/presentHtml/View.vue +52 -0
  297. package/src/plugins/presentHtml/definition.ts +27 -0
  298. package/src/plugins/presentHtml/helpers.ts +72 -0
  299. package/src/plugins/presentHtml/index.ts +41 -0
  300. package/src/plugins/presentMulmoScript/Preview.vue +23 -0
  301. package/src/plugins/presentMulmoScript/View.vue +1166 -0
  302. package/src/plugins/presentMulmoScript/definition.ts +95 -0
  303. package/src/plugins/presentMulmoScript/helpers.ts +162 -0
  304. package/src/plugins/presentMulmoScript/index.ts +40 -0
  305. package/src/plugins/scheduler/Preview.vue +67 -0
  306. package/src/plugins/scheduler/TasksTab.vue +205 -0
  307. package/src/plugins/scheduler/View.vue +565 -0
  308. package/src/plugins/scheduler/definition.ts +57 -0
  309. package/src/plugins/scheduler/index.ts +45 -0
  310. package/src/plugins/scheduler/viewModes.ts +26 -0
  311. package/src/plugins/spreadsheet/Preview.vue +29 -0
  312. package/src/plugins/spreadsheet/View.vue +997 -0
  313. package/src/plugins/spreadsheet/cellHighlights.ts +79 -0
  314. package/src/plugins/spreadsheet/definition.ts +121 -0
  315. package/src/plugins/spreadsheet/engine/calculator.ts +459 -0
  316. package/src/plugins/spreadsheet/engine/cellBuilder.ts +81 -0
  317. package/src/plugins/spreadsheet/engine/date-parser.ts +220 -0
  318. package/src/plugins/spreadsheet/engine/date-utils.ts +56 -0
  319. package/src/plugins/spreadsheet/engine/engine.ts +176 -0
  320. package/src/plugins/spreadsheet/engine/evaluator.ts +390 -0
  321. package/src/plugins/spreadsheet/engine/formatter.ts +172 -0
  322. package/src/plugins/spreadsheet/engine/formulaRefs.ts +101 -0
  323. package/src/plugins/spreadsheet/engine/functions/date.ts +299 -0
  324. package/src/plugins/spreadsheet/engine/functions/financial.ts +387 -0
  325. package/src/plugins/spreadsheet/engine/functions/index.ts +16 -0
  326. package/src/plugins/spreadsheet/engine/functions/logical.ts +262 -0
  327. package/src/plugins/spreadsheet/engine/functions/lookup.ts +400 -0
  328. package/src/plugins/spreadsheet/engine/functions/mathematical.ts +297 -0
  329. package/src/plugins/spreadsheet/engine/functions/statistical.ts +338 -0
  330. package/src/plugins/spreadsheet/engine/functions/text.ts +389 -0
  331. package/src/plugins/spreadsheet/engine/index.ts +27 -0
  332. package/src/plugins/spreadsheet/engine/jsonCellLocator.ts +111 -0
  333. package/src/plugins/spreadsheet/engine/parser.ts +143 -0
  334. package/src/plugins/spreadsheet/engine/registry.ts +150 -0
  335. package/src/plugins/spreadsheet/engine/responseDecoder.ts +67 -0
  336. package/src/plugins/spreadsheet/engine/types.ts +64 -0
  337. package/src/plugins/spreadsheet/index.ts +36 -0
  338. package/src/plugins/textResponse/Preview.vue +94 -0
  339. package/src/plugins/textResponse/View.vue +503 -0
  340. package/src/plugins/textResponse/definition.ts +34 -0
  341. package/src/plugins/textResponse/index.ts +27 -0
  342. package/src/plugins/textResponse/plugin.ts +29 -0
  343. package/src/plugins/textResponse/samples.ts +97 -0
  344. package/src/plugins/textResponse/types.ts +11 -0
  345. package/src/plugins/todo/Preview.vue +63 -0
  346. package/src/plugins/todo/View.vue +364 -0
  347. package/src/plugins/todo/composables/useTodos.ts +177 -0
  348. package/src/plugins/todo/definition.ts +45 -0
  349. package/src/plugins/todo/index.ts +61 -0
  350. package/src/plugins/todo/labels.ts +163 -0
  351. package/src/plugins/todo/priority.ts +98 -0
  352. package/src/plugins/todo/viewModes.ts +19 -0
  353. package/src/plugins/ui-image/ImagePreview.vue +23 -0
  354. package/src/plugins/ui-image/ImageView.vue +34 -0
  355. package/src/plugins/ui-image/index.ts +3 -0
  356. package/src/plugins/ui-image/types.ts +4 -0
  357. package/src/plugins/wiki/Preview.vue +65 -0
  358. package/src/plugins/wiki/View.vue +342 -0
  359. package/src/plugins/wiki/definition.ts +25 -0
  360. package/src/plugins/wiki/helpers.ts +59 -0
  361. package/src/plugins/wiki/index.ts +52 -0
  362. package/src/router/guards.ts +61 -0
  363. package/src/router/index.ts +50 -0
  364. package/src/tools/index.ts +52 -0
  365. package/src/tools/types.ts +27 -0
  366. package/src/types/events.ts +16 -0
  367. package/src/types/fileTree.ts +13 -0
  368. package/src/types/notification.ts +67 -0
  369. package/src/types/session.ts +116 -0
  370. package/src/types/sse.ts +90 -0
  371. package/src/types/toolCallHistory.ts +13 -0
  372. package/src/utils/agent/eventDispatch.ts +74 -0
  373. package/src/utils/agent/request.ts +55 -0
  374. package/src/utils/agent/toolCalls.ts +62 -0
  375. package/src/utils/api.ts +218 -0
  376. package/src/utils/canvas/viewMode.ts +46 -0
  377. package/src/utils/dom/authTokenMeta.ts +20 -0
  378. package/src/utils/dom/clickOutside.ts +11 -0
  379. package/src/utils/dom/externalLink.ts +57 -0
  380. package/src/utils/dom/scrollable.ts +24 -0
  381. package/src/utils/errors.ts +11 -0
  382. package/src/utils/files/expandedDirs.ts +25 -0
  383. package/src/utils/files/filename.ts +12 -0
  384. package/src/utils/files/sortChildren.ts +20 -0
  385. package/src/utils/filesPreview/schedulerPreview.ts +38 -0
  386. package/src/utils/filesPreview/todoPreview.ts +40 -0
  387. package/src/utils/format/date.ts +85 -0
  388. package/src/utils/format/frontmatter.ts +80 -0
  389. package/src/utils/format/jsonSyntax.ts +109 -0
  390. package/src/utils/html/previewCsp.ts +65 -0
  391. package/src/utils/image/resolve.ts +8 -0
  392. package/src/utils/image/rewriteMarkdownImageRefs.ts +182 -0
  393. package/src/utils/markdown/extractFirstH1.ts +39 -0
  394. package/src/utils/notification/dispatch.ts +22 -0
  395. package/src/utils/path/relativeLink.ts +130 -0
  396. package/src/utils/role/icon.ts +20 -0
  397. package/src/utils/role/merge.ts +10 -0
  398. package/src/utils/role/plugins.ts +12 -0
  399. package/src/utils/session/mergeSessions.ts +103 -0
  400. package/src/utils/session/seedRoleDefault.ts +35 -0
  401. package/src/utils/session/sessionEntries.ts +121 -0
  402. package/src/utils/session/sessionFactory.ts +22 -0
  403. package/src/utils/session/sessionHelpers.ts +99 -0
  404. package/src/utils/tools/dedup.ts +17 -0
  405. package/src/utils/tools/mcp.ts +33 -0
  406. package/src/utils/tools/pendingCalls.ts +16 -0
  407. package/src/utils/tools/result.ts +40 -0
  408. package/src/utils/types.ts +44 -0
@@ -0,0 +1,1166 @@
1
+ <template>
2
+ <div class="h-full bg-white flex flex-col overflow-hidden">
3
+ <!-- Header -->
4
+ <div class="flex items-start justify-between px-6 py-4 border-b border-gray-100 shrink-0">
5
+ <div class="min-w-0 flex-1">
6
+ <h2 class="text-lg font-semibold text-gray-800 truncate">
7
+ {{ script.title || "Untitled Script" }}
8
+ </h2>
9
+ <p v-if="script.description" class="text-sm text-gray-500 mt-0.5 truncate">
10
+ {{ script.description }}
11
+ </p>
12
+ <div class="flex items-center gap-3 mt-1 text-xs text-gray-400">
13
+ <span>{{ beats.length }} beat{{ beats.length !== 1 ? "s" : "" }}</span>
14
+ <span v-if="script.lang">{{ script.lang }}</span>
15
+ <span v-if="filePath" class="truncate">{{ filePath }}</span>
16
+ </div>
17
+ </div>
18
+ <div class="ml-4 shrink-0 flex gap-2">
19
+ <!-- Download Movie -->
20
+ <a
21
+ v-if="moviePath && !movieGenerating"
22
+ :href="`${downloadMovieBase}?moviePath=${encodeURIComponent(moviePath)}`"
23
+ download
24
+ class="px-3 py-1 text-xs rounded-full border transition-colors border-gray-200 text-gray-500 hover:bg-gray-50 flex items-center justify-center gap-1"
25
+ >
26
+ <span class="material-icons text-sm leading-none">download</span>
27
+ <span>Movie</span>
28
+ </a>
29
+ <!-- Generate / Regenerate Movie -->
30
+ <button
31
+ class="px-3 py-1 text-xs rounded-full border transition-colors border-gray-200 text-gray-500 hover:bg-gray-50 disabled:opacity-40 flex items-center justify-center gap-1"
32
+ :disabled="movieGenerating"
33
+ @click="generateMovie"
34
+ >
35
+ <svg v-if="movieGenerating" class="animate-spin w-3 h-3 shrink-0" viewBox="0 0 24 24" fill="none">
36
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
37
+ <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
38
+ </svg>
39
+ <span v-if="movieGenerating">Generating…</span>
40
+ <template v-else>
41
+ <span class="material-icons text-sm leading-none">refresh</span>
42
+ <span>Movie</span>
43
+ </template>
44
+ </button>
45
+ </div>
46
+ </div>
47
+
48
+ <!-- Characters section -->
49
+ <div v-if="characterKeys.length > 0" class="border-b border-gray-100 shrink-0 px-4 py-3">
50
+ <div class="flex items-center justify-between mb-2">
51
+ <span class="text-xs font-semibold text-gray-500 uppercase tracking-wide">Characters</span>
52
+ <button
53
+ class="px-2 py-0.5 text-xs rounded border border-gray-300 text-gray-500 hover:bg-gray-50 disabled:opacity-50"
54
+ :disabled="movieGenerating || anyBeatRendering || characterKeys.every((key) => charRenderState[key] === 'rendering')"
55
+ @click="generateAllCharacters"
56
+ >
57
+ Generate All
58
+ </button>
59
+ </div>
60
+ <div class="flex gap-3 flex-wrap">
61
+ <div v-for="key in characterKeys" :key="key" class="flex flex-col items-center gap-1 w-36">
62
+ <!-- Character thumbnail -->
63
+ <div
64
+ class="relative w-36 h-36 rounded-lg border overflow-hidden bg-gray-50 flex items-center justify-center transition-colors"
65
+ :class="charDragOver[key] ? 'border-blue-400 bg-blue-50' : 'border-gray-200'"
66
+ @dragover="onCharDragOver($event, key)"
67
+ @dragleave="onCharDragLeave(key)"
68
+ @drop="onCharDrop($event, key)"
69
+ >
70
+ <img
71
+ v-if="charImages[key]"
72
+ :src="charImages[key]"
73
+ class="w-full h-full object-cover cursor-zoom-in"
74
+ :alt="key"
75
+ @click="openCharacterLightbox(key)"
76
+ />
77
+ <template v-else-if="charRenderState[key] === 'rendering'">
78
+ <svg class="animate-spin w-4 h-4 text-green-400" viewBox="0 0 24 24" fill="none">
79
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
80
+ <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
81
+ </svg>
82
+ </template>
83
+ <template v-else-if="charRenderState[key] === 'error'">
84
+ <span class="text-xs text-red-400 text-center px-1">{{ charErrors[key] }}</span>
85
+ </template>
86
+ <template v-else>
87
+ <span class="text-xs text-gray-300 text-center px-1 leading-tight">{{ characterPrompt(key) }}</span>
88
+ </template>
89
+ <!-- Permanent drop hint -->
90
+ <div v-if="!charDragOver[key]" class="absolute bottom-0 inset-x-0 text-center text-xs text-gray-400 bg-white/70 py-0.5 pointer-events-none">
91
+ or drop image
92
+ </div>
93
+ <!-- Drop overlay -->
94
+ <div v-if="charDragOver[key]" class="absolute inset-0 flex items-center justify-center bg-blue-50/80 pointer-events-none">
95
+ <span class="text-xs text-blue-500 font-medium">Drop</span>
96
+ </div>
97
+ <!-- Regenerate button -->
98
+ <button
99
+ v-if="charImages[key] && charRenderState[key] !== 'rendering'"
100
+ class="absolute top-0.5 right-0.5 px-1 py-0.5 text-xs rounded border bg-white"
101
+ :class="
102
+ movieGenerating || anyBeatRendering ? 'border-yellow-400 text-yellow-500 cursor-not-allowed' : 'border-gray-400 text-gray-600 hover:bg-gray-50'
103
+ "
104
+ :disabled="movieGenerating || anyBeatRendering"
105
+ @click.stop="renderCharacter(key, true)"
106
+ >
107
+ <span v-if="movieGenerating || anyBeatRendering" class="inline-block animate-spin">↺</span>
108
+ <span v-else>↺</span>
109
+ </button>
110
+ <!-- Generate button -->
111
+ <button
112
+ v-else-if="!charImages[key] && charRenderState[key] !== 'rendering'"
113
+ class="absolute top-0.5 right-0.5 px-1 py-0.5 text-xs rounded border bg-white"
114
+ :class="
115
+ movieGenerating || anyBeatRendering ? 'border-yellow-400 text-yellow-500 cursor-not-allowed' : 'border-blue-400 text-blue-600 hover:bg-blue-50'
116
+ "
117
+ :disabled="movieGenerating || anyBeatRendering"
118
+ @click.stop="renderCharacter(key, false)"
119
+ >
120
+ <svg v-if="movieGenerating || anyBeatRendering" class="animate-spin w-3 h-3" viewBox="0 0 24 24" fill="none">
121
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
122
+ <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
123
+ </svg>
124
+ <span v-else>Gen</span>
125
+ </button>
126
+ </div>
127
+ <span class="text-xs text-gray-600 text-center truncate w-full">{{ key }}</span>
128
+ </div>
129
+ </div>
130
+ </div>
131
+
132
+ <!-- Beat list -->
133
+ <div ref="beatListEl" class="flex-1 overflow-y-auto p-2 space-y-1.5">
134
+ <div v-for="(beat, index) in beats" :key="index" class="rounded-lg border border-gray-200 overflow-hidden">
135
+ <!-- Beat body: thumbnail + narration side by side -->
136
+ <div class="flex gap-3 items-stretch">
137
+ <!-- Thumbnail -->
138
+ <div
139
+ class="relative shrink-0 w-[45%] overflow-hidden bg-gray-50 transition-colors"
140
+ :class="beatDragOver[index] ? 'bg-blue-50' : ''"
141
+ @dragover="onBeatDragOver($event, index)"
142
+ @dragleave="onBeatDragLeave(index)"
143
+ @drop="onBeatDrop($event, index)"
144
+ >
145
+ <img
146
+ v-if="renderedImages[index]"
147
+ :src="renderedImages[index]"
148
+ class="w-full object-contain cursor-zoom-in"
149
+ :alt="`Beat ${index + 1}`"
150
+ @click="openLightbox(index)"
151
+ />
152
+ <button
153
+ v-if="renderedImages[index] && renderState[index] !== 'rendering'"
154
+ class="absolute top-1.5 right-1.5 flex items-center gap-1 px-2 py-0.5 text-xs rounded border border-gray-400 text-gray-600 bg-white hover:bg-gray-50 disabled:opacity-60 disabled:cursor-not-allowed"
155
+ :disabled="movieGenerating"
156
+ @click.stop="regenerateBeat(index)"
157
+ >
158
+
159
+ </button>
160
+ <div v-else-if="!renderedImages[index]" class="w-full aspect-video flex flex-col items-center justify-center gap-1 p-2">
161
+ <template v-if="renderState[index] === 'rendering' || (movieGenerating && !renderedImages[index] && effectiveBeat(index).imagePrompt)">
162
+ <svg class="animate-spin w-4 h-4 text-green-400" viewBox="0 0 24 24" fill="none">
163
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
164
+ <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
165
+ </svg>
166
+ <span class="text-xs text-green-500">Rendering…</span>
167
+ </template>
168
+ <template v-else-if="renderState[index] === 'error'">
169
+ <span class="text-xs text-red-400 text-center">{{ renderErrors[index] }}</span>
170
+ </template>
171
+ <template v-else>
172
+ <span v-if="effectiveBeat(index).imagePrompt" class="text-xs text-gray-400 text-center italic leading-relaxed px-1">{{
173
+ effectiveBeat(index).imagePrompt
174
+ }}</span>
175
+ <span v-else class="text-xs text-gray-300">{{ beat.image?.type ?? "—" }}</span>
176
+ </template>
177
+ </div>
178
+ <!-- Beat drop hint / overlay -->
179
+ <div v-if="beatDragOver[index]" class="absolute inset-0 flex items-center justify-center bg-blue-50/80 pointer-events-none">
180
+ <span class="text-xs text-blue-500 font-medium">Drop</span>
181
+ </div>
182
+ <div
183
+ v-else-if="!renderedImages[index] && renderState[index] !== 'rendering'"
184
+ class="absolute bottom-0 inset-x-0 text-center text-xs text-gray-400 bg-white/70 py-0.5 pointer-events-none"
185
+ >
186
+ or drop image
187
+ </div>
188
+ <!-- Generate button for imagePrompt beats -->
189
+ <button
190
+ v-if="effectiveBeat(index).imagePrompt && !renderedImages[index] && renderState[index] !== 'rendering' && !movieGenerating"
191
+ class="absolute top-1.5 right-1.5 flex items-center gap-1 px-2 py-0.5 text-xs rounded border border-blue-400 text-blue-600 bg-white hover:bg-blue-50"
192
+ @click="renderBeat(index)"
193
+ >
194
+ Generate
195
+ </button>
196
+ </div>
197
+
198
+ <!-- Narration text -->
199
+ <div class="flex flex-col flex-1 min-w-0 px-2 py-1.5">
200
+ <span class="text-sm text-gray-800 leading-relaxed">{{ effectiveBeat(index).text }}</span>
201
+ <div class="flex justify-between mt-auto pt-1">
202
+ <!-- Audio controls -->
203
+ <div class="flex items-center gap-1">
204
+ <template v-if="audioState[index] === 'generating' || (movieGenerating && !beatAudios[index] && effectiveBeat(index).text)">
205
+ <svg class="animate-spin w-3 h-3 text-green-400" viewBox="0 0 24 24" fill="none">
206
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
207
+ <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
208
+ </svg>
209
+ </template>
210
+ <button
211
+ v-else-if="beatAudios[index]"
212
+ class="text-xs px-2 py-0.5 rounded border"
213
+ :class="playingAudio?.index === index ? 'border-red-400 text-red-600 hover:bg-red-50' : 'border-green-400 text-green-600 hover:bg-green-50'"
214
+ @click="playAudio(index)"
215
+ >
216
+ {{ playingAudio?.index === index ? "■ Stop" : "▶ Play" }}
217
+ </button>
218
+ <template v-else-if="audioErrors[index]">
219
+ <span class="text-xs text-red-400" :title="audioErrors[index]">⚠ Error</span>
220
+ <button
221
+ v-if="effectiveBeat(index).text"
222
+ class="text-xs px-2 py-0.5 rounded border border-gray-300 text-gray-500 hover:bg-gray-50 disabled:opacity-50"
223
+ :disabled="movieGenerating"
224
+ @click="generateAudio(index)"
225
+ >
226
+
227
+ </button>
228
+ </template>
229
+ <button
230
+ v-else-if="effectiveBeat(index).text"
231
+ class="text-xs px-2 py-0.5 rounded border border-gray-300 text-gray-500 hover:bg-gray-50"
232
+ @click="generateAudio(index)"
233
+ >
234
+ ♪ Generate
235
+ </button>
236
+ </div>
237
+ <button class="text-gray-400 hover:text-gray-600" :title="sourceOpen[index] ? 'Hide source' : 'Show source'" @click="toggleSource(index)">
238
+ <svg
239
+ xmlns="http://www.w3.org/2000/svg"
240
+ class="w-3.5 h-3.5"
241
+ viewBox="0 0 24 24"
242
+ fill="none"
243
+ stroke="currentColor"
244
+ stroke-width="2"
245
+ stroke-linecap="round"
246
+ stroke-linejoin="round"
247
+ >
248
+ <polyline points="16 18 22 12 16 6" />
249
+ <polyline points="8 6 2 12 8 18" />
250
+ </svg>
251
+ </button>
252
+ </div>
253
+ </div>
254
+ </div>
255
+
256
+ <!-- Source editor -->
257
+ <div v-if="sourceOpen[index]" class="border-t border-gray-100">
258
+ <textarea
259
+ v-model="sourceText[index]"
260
+ class="w-full text-xs text-gray-600 bg-gray-50 p-2 font-mono resize-none"
261
+ :class="isValidBeat(index) ? 'outline-none' : 'outline outline-2 outline-red-400'"
262
+ rows="8"
263
+ spellcheck="false"
264
+ />
265
+ <div class="flex items-center justify-end gap-2 px-2 pb-2">
266
+ <span v-if="beatSaveErrors[index]" class="text-xs text-red-600" role="alert">⚠ {{ beatSaveErrors[index] }}</span>
267
+ <button
268
+ class="px-2 py-1 text-xs rounded border"
269
+ :class="
270
+ isValidBeat(index) && !beatSaving[index]
271
+ ? 'border-blue-400 text-blue-600 hover:bg-blue-50 cursor-pointer'
272
+ : 'border-gray-200 text-gray-300 cursor-not-allowed'
273
+ "
274
+ :disabled="!isValidBeat(index) || !!beatSaving[index]"
275
+ @click="updateBeat(index)"
276
+ >
277
+ {{ beatSaving[index] ? "Saving…" : "Update" }}
278
+ </button>
279
+ </div>
280
+ </div>
281
+ </div>
282
+
283
+ <div v-if="beats.length === 0" class="flex items-center justify-center h-32 text-gray-400 text-sm">No beats found in script</div>
284
+ </div>
285
+
286
+ <!-- Bottom bar: Edit Script Source + Copy -->
287
+ <div class="bottom-bar-wrapper">
288
+ <details ref="sourceDetails" class="script-source" @toggle="onSourceToggle(($event.target as HTMLDetailsElement).open)">
289
+ <summary>Edit Script Source</summary>
290
+ <textarea
291
+ v-model="editableSource"
292
+ class="script-editor"
293
+ :class="{ 'script-editor-invalid': sourceChanged && !sourceValid }"
294
+ spellcheck="false"
295
+ ></textarea>
296
+ <div class="editor-actions">
297
+ <button class="apply-btn" :disabled="!sourceChanged || !sourceValid" @click="applySource">Apply Changes</button>
298
+ <button class="cancel-btn" @click="cancelSourceEdit">Cancel</button>
299
+ </div>
300
+ </details>
301
+ <button v-show="!editing" class="copy-btn" :title="copied ? 'Copied!' : 'Copy'" @click="copyText">
302
+ <span class="material-icons">{{ copied ? "check" : "content_copy" }}</span>
303
+ </button>
304
+ </div>
305
+
306
+ <!-- Lightbox -->
307
+ <div v-if="lightbox" class="fixed inset-0 z-50 flex items-center justify-center bg-black/80" @click="lightbox = null">
308
+ <div class="flex items-center gap-4" @click.stop>
309
+ <button
310
+ v-if="!lightbox.isCharacter"
311
+ class="text-white/60 hover:text-white disabled:opacity-20 text-4xl leading-none"
312
+ :disabled="!hasPrev"
313
+ @click="lightboxMove(-1)"
314
+ >
315
+
316
+ </button>
317
+ <div class="flex flex-col items-center gap-3">
318
+ <img :src="lightbox.src" class="max-w-[80vw] max-h-[80vh] object-contain rounded shadow-2xl" />
319
+ <div class="flex items-center gap-4">
320
+ <p v-if="lightbox.text" class="max-w-[80vw] text-center text-white text-2xl leading-relaxed">
321
+ {{ lightbox.text }}
322
+ </p>
323
+ <button
324
+ v-if="beatAudios[lightbox.index]"
325
+ class="shrink-0 text-sm px-3 py-1 rounded border"
326
+ :class="
327
+ playingAudio?.index === lightbox.index ? 'border-red-400 text-red-400 hover:bg-red-400/20' : 'border-white/60 text-white/60 hover:bg-white/20'
328
+ "
329
+ @click="playAudio(lightbox.index)"
330
+ >
331
+ {{ playingAudio?.index === lightbox.index ? "■ Stop" : "▶ Play" }}
332
+ </button>
333
+ </div>
334
+ </div>
335
+ <button
336
+ v-if="!lightbox.isCharacter"
337
+ class="text-white/60 hover:text-white disabled:opacity-20 text-4xl leading-none"
338
+ :disabled="!hasNext"
339
+ @click="lightboxMove(1)"
340
+ >
341
+
342
+ </button>
343
+ </div>
344
+ </div>
345
+ </div>
346
+ </template>
347
+
348
+ <script setup lang="ts">
349
+ import { computed, onMounted, reactive, ref, watch } from "vue";
350
+ import type { ToolResultComplete } from "gui-chat-protocol/vue";
351
+ import type { MulmoScriptData } from "./index";
352
+ import { mulmoBeatSchema, mulmoScriptSchema } from "@mulmocast/types";
353
+ import { extractErrorMessage, getMissingCharacterKeys, shouldAutoRenderBeat, streamMovieEvents, validateBeatJSON } from "./helpers";
354
+ import { apiGet, apiPost, apiFetchRaw } from "../../utils/api";
355
+ import { API_ROUTES } from "../../config/apiRoutes";
356
+ import { errorMessage } from "../../utils/errors";
357
+ import { useClipboardCopy } from "../../composables/useClipboardCopy";
358
+ import { useActiveSession } from "../../composables/useActiveSession";
359
+ import { GENERATION_KINDS, type PendingGeneration } from "../../types/events";
360
+
361
+ interface Beat {
362
+ speaker?: string;
363
+ text?: string;
364
+ id?: string;
365
+ imagePrompt?: string;
366
+ image?: { type: string; [key: string]: unknown };
367
+ }
368
+
369
+ interface ImageEntry {
370
+ type: string;
371
+ prompt?: string;
372
+ [key: string]: unknown;
373
+ }
374
+
375
+ interface MulmoScript {
376
+ title?: string;
377
+ description?: string;
378
+ lang?: string;
379
+ beats?: Beat[];
380
+ imageParams?: {
381
+ images?: Record<string, ImageEntry>;
382
+ [key: string]: unknown;
383
+ };
384
+ [key: string]: unknown;
385
+ }
386
+
387
+ const props = defineProps<{
388
+ selectedResult: ToolResultComplete<MulmoScriptData>;
389
+ }>();
390
+ const emit = defineEmits<{ updateResult: [result: ToolResultComplete] }>();
391
+
392
+ const data = computed(() => props.selectedResult.data);
393
+ const script = computed<MulmoScript>(() => data.value?.script ?? {});
394
+ const filePath = computed(() => data.value?.filePath ?? "");
395
+ const beats = computed<Beat[]>(() => script.value.beats ?? []);
396
+
397
+ // Exposed to the template so the `<a :href="...">` download button
398
+ // can compose a query-string URL without inlining the API path.
399
+ const downloadMovieBase = API_ROUTES.mulmoScript.downloadMovie;
400
+
401
+ // Per-beat render state
402
+ type RenderState = "idle" | "rendering" | "done" | "error";
403
+ const renderState = reactive<Record<number, RenderState>>({});
404
+ const renderedImages = reactive<Record<number, string>>({});
405
+ const renderErrors = reactive<Record<number, string>>({});
406
+ const sourceOpen = reactive<Record<number, boolean>>({});
407
+ const sourceText = reactive<Record<number, string>>({});
408
+ // Surface POST /api/mulmo-script/update-beat failures inline next to
409
+ // the Update button. Cleared on next successful save or editor close.
410
+ const beatSaveErrors = reactive<Record<number, string>>({});
411
+ const beatSaving = reactive<Record<number, boolean>>({});
412
+ const localOverrides = reactive<Record<number, Beat>>({});
413
+ const movieGenerating = ref(false);
414
+ const moviePath = ref<string | null>(null);
415
+ const beatAudios = reactive<Record<number, string>>({});
416
+ const audioState = reactive<Record<number, "generating" | "done" | "error">>({});
417
+ const audioErrors = reactive<Record<number, string>>({});
418
+ const playingAudio = ref<{ index: number; audio: HTMLAudioElement } | null>(null);
419
+ const beatListEl = ref<HTMLElement | null>(null);
420
+ const lightbox = ref<{
421
+ src: string;
422
+ text?: string;
423
+ index: number;
424
+ isCharacter?: boolean;
425
+ } | null>(null);
426
+ // Character (imageParams.images) state
427
+ type CharRenderState = "idle" | "rendering" | "done" | "error";
428
+ const charRenderState = reactive<Record<string, CharRenderState>>({});
429
+ const charImages = reactive<Record<string, string>>({});
430
+ const charErrors = reactive<Record<string, string>>({});
431
+ const charDragOver = reactive<Record<string, boolean>>({});
432
+ const beatDragOver = reactive<Record<number, boolean>>({});
433
+
434
+ const anyBeatRendering = computed(() => Object.values(renderState).some((s) => s === "rendering"));
435
+
436
+ const characterKeys = computed(() => {
437
+ const imgs = script.value.imageParams?.images ?? {};
438
+ return Object.keys(imgs).filter((key) => imgs[key]?.type === "imagePrompt");
439
+ });
440
+
441
+ // Session-scoped pending generations — lets spinners survive view
442
+ // unmount/remount and tags new generations on the correct session
443
+ // channel so the cross-session sidebar indicator stays lit.
444
+ const activeSessionRef = useActiveSession();
445
+ const chatSessionId = computed(() => activeSessionRef?.value?.id);
446
+
447
+ const pendingForThisScript = computed(() => {
448
+ const out: Record<string, PendingGeneration> = {};
449
+ const pending = activeSessionRef?.value?.pendingGenerations ?? {};
450
+ const fp = filePath.value;
451
+ if (!fp) return out;
452
+ for (const [mapKey, entry] of Object.entries(pending)) {
453
+ if (entry.filePath === fp) out[mapKey] = entry;
454
+ }
455
+ return out;
456
+ });
457
+
458
+ // Local renderState / charRenderState / audioState / movieGenerating
459
+ // are kept in sync with `pendingForThisScript` by the watcher below
460
+ // and by `initializeScript`, so the template continues to read them
461
+ // without needing per-kind predicates here.
462
+
463
+ function characterPrompt(key: string): string {
464
+ return (script.value.imageParams?.images?.[key]?.prompt as string) ?? "";
465
+ }
466
+
467
+ function openLightbox(index: number) {
468
+ if (playingAudio.value) {
469
+ playingAudio.value.audio.pause();
470
+ playingAudio.value = null;
471
+ }
472
+ lightbox.value = {
473
+ src: renderedImages[index],
474
+ text: effectiveBeat(index).text,
475
+ index,
476
+ };
477
+ }
478
+
479
+ const hasPrev = computed(() => {
480
+ if (!lightbox.value) return false;
481
+ for (let i = lightbox.value.index - 1; i >= 0; i--) {
482
+ if (renderedImages[i]) return true;
483
+ }
484
+ return false;
485
+ });
486
+
487
+ const hasNext = computed(() => {
488
+ if (!lightbox.value) return false;
489
+ for (let i = lightbox.value.index + 1; i < beats.value.length; i++) {
490
+ if (renderedImages[i]) return true;
491
+ }
492
+ return false;
493
+ });
494
+
495
+ function lightboxMove(delta: number) {
496
+ if (!lightbox.value) return;
497
+ const total = beats.value.length;
498
+ let i = lightbox.value.index + delta;
499
+ while (i >= 0 && i < total) {
500
+ if (renderedImages[i]) {
501
+ openLightbox(i);
502
+ return;
503
+ }
504
+ i += delta;
505
+ }
506
+ }
507
+ const sourceDetails = ref<HTMLDetailsElement>();
508
+ const editing = ref(false);
509
+ const editableSource = ref("");
510
+ const { copied, copy } = useClipboardCopy();
511
+
512
+ // Beats may be edited in-place via `updateBeat()` and rendered through
513
+ // `effectiveBeat()`, so the Copy / source-view text must read the merged
514
+ // shape — otherwise the clipboard returns the original prop snapshot
515
+ // until the full result is reloaded.
516
+ const effectiveScript = computed<MulmoScript>(() => ({
517
+ ...script.value,
518
+ beats: beats.value.map((beat, i) => localOverrides[i] ?? beat),
519
+ }));
520
+ const scriptSourceText = computed(() => JSON.stringify(effectiveScript.value, null, 2));
521
+ const loadedSource = ref("");
522
+ const sourceChanged = computed(() => editableSource.value !== loadedSource.value);
523
+ const sourceValid = computed(() => {
524
+ try {
525
+ const parsed = JSON.parse(editableSource.value);
526
+ return mulmoScriptSchema.safeParse(parsed).success;
527
+ } catch {
528
+ return false;
529
+ }
530
+ });
531
+
532
+ async function onSourceToggle(open: boolean) {
533
+ editing.value = open;
534
+ if (open) {
535
+ let text = scriptSourceText.value;
536
+ // Read the current file from disk so beat-level edits are reflected
537
+ if (filePath.value) {
538
+ const response = await apiGet<{ content?: string }>(API_ROUTES.files.content, { path: filePath.value });
539
+ if (response.ok && response.data.content) {
540
+ text = response.data.content;
541
+ }
542
+ // fall through to in-memory script on failure
543
+ }
544
+ editableSource.value = text;
545
+ loadedSource.value = text;
546
+ }
547
+ }
548
+
549
+ function cancelSourceEdit() {
550
+ if (sourceDetails.value) sourceDetails.value.open = false;
551
+ }
552
+
553
+ async function applySource() {
554
+ let parsed: MulmoScript;
555
+ try {
556
+ parsed = JSON.parse(editableSource.value);
557
+ } catch (err) {
558
+ alert(extractErrorMessage(err));
559
+ return;
560
+ }
561
+ const response = await apiPost<unknown>(API_ROUTES.mulmoScript.updateScript, {
562
+ filePath: filePath.value,
563
+ script: parsed,
564
+ });
565
+ if (!response.ok) {
566
+ alert(response.error || "Update failed");
567
+ return;
568
+ }
569
+
570
+ // Update the UI with the new script.
571
+ // Note: the parent's handleUpdateResult uses Object.assign (in-place
572
+ // mutation), so the watcher on props.selectedResult won't fire.
573
+ // We emit first so the parent data is updated, then manually
574
+ // re-initialize the view.
575
+ emit("updateResult", {
576
+ ...props.selectedResult,
577
+ data: { ...props.selectedResult.data, script: parsed },
578
+ });
579
+
580
+ if (sourceDetails.value) sourceDetails.value.open = false;
581
+ await initializeScript();
582
+ }
583
+
584
+ async function copyText() {
585
+ await copy(scriptSourceText.value);
586
+ }
587
+
588
+ function effectiveBeat(index: number): Beat {
589
+ return localOverrides[index] ?? beats.value[index] ?? {};
590
+ }
591
+
592
+ function toggleSource(index: number) {
593
+ if (!sourceOpen[index]) {
594
+ sourceText[index] = JSON.stringify(effectiveBeat(index), null, 2);
595
+ delete beatSaveErrors[index];
596
+ }
597
+ sourceOpen[index] = !sourceOpen[index];
598
+ }
599
+
600
+ function isValidBeat(index: number): boolean {
601
+ return validateBeatJSON(sourceText[index] ?? "", mulmoBeatSchema);
602
+ }
603
+
604
+ async function updateBeat(index: number) {
605
+ let beat: Beat;
606
+ try {
607
+ beat = JSON.parse(sourceText[index]);
608
+ } catch (err) {
609
+ beatSaveErrors[index] = `Invalid JSON: ${errorMessage(err)}`;
610
+ return;
611
+ }
612
+ const prevImage = JSON.stringify(effectiveBeat(index).image);
613
+
614
+ delete beatSaveErrors[index];
615
+ beatSaving[index] = true;
616
+ const response = await apiPost<unknown>(API_ROUTES.mulmoScript.updateBeat, {
617
+ filePath: filePath.value,
618
+ beatIndex: index,
619
+ beat,
620
+ });
621
+ delete beatSaving[index];
622
+ if (!response.ok) {
623
+ beatSaveErrors[index] = `Save failed: ${response.error}`;
624
+ return;
625
+ }
626
+
627
+ localOverrides[index] = beat;
628
+ sourceOpen[index] = false;
629
+
630
+ if (JSON.stringify(beat.image) !== prevImage) {
631
+ delete renderedImages[index];
632
+ renderBeat(index);
633
+ }
634
+ }
635
+
636
+ async function renderBeat(index: number) {
637
+ renderState[index] = "rendering";
638
+ const response = await apiPost<{ image?: string; error?: string }>(API_ROUTES.mulmoScript.renderBeat, {
639
+ filePath: filePath.value,
640
+ beatIndex: index,
641
+ chatSessionId: chatSessionId.value,
642
+ });
643
+ if (!response.ok) {
644
+ renderErrors[index] = response.error || "Render failed";
645
+ renderState[index] = "error";
646
+ return;
647
+ }
648
+ if (response.data.error) {
649
+ renderErrors[index] = response.data.error;
650
+ renderState[index] = "error";
651
+ return;
652
+ }
653
+ renderedImages[index] = response.data.image ?? "";
654
+ renderState[index] = "done";
655
+ refreshMissingCharacterImages();
656
+ }
657
+
658
+ async function regenerateBeat(index: number) {
659
+ delete renderedImages[index];
660
+ renderState[index] = "rendering";
661
+ const response = await apiPost<{ image?: string; error?: string }>(API_ROUTES.mulmoScript.renderBeat, {
662
+ filePath: filePath.value,
663
+ beatIndex: index,
664
+ force: true,
665
+ chatSessionId: chatSessionId.value,
666
+ });
667
+ if (!response.ok) {
668
+ renderErrors[index] = response.error || "Render failed";
669
+ renderState[index] = "error";
670
+ return;
671
+ }
672
+ if (response.data.error) {
673
+ renderErrors[index] = response.data.error;
674
+ renderState[index] = "error";
675
+ return;
676
+ }
677
+ renderedImages[index] = response.data.image ?? "";
678
+ renderState[index] = "done";
679
+ }
680
+
681
+ async function loadExistingBeatImage(index: number) {
682
+ const response = await apiGet<{ image?: string }>(API_ROUTES.mulmoScript.beatImage, { filePath: filePath.value, beatIndex: String(index) });
683
+ // silently ignore errors — image simply hasn't been generated yet
684
+ if (response.ok && response.data.image) {
685
+ renderedImages[index] = response.data.image;
686
+ renderState[index] = "done";
687
+ }
688
+ }
689
+
690
+ async function loadExistingBeatAudio(index: number) {
691
+ const response = await apiGet<{ audio?: string }>(API_ROUTES.mulmoScript.beatAudio, { filePath: filePath.value, beatIndex: String(index) });
692
+ // silently ignore errors
693
+ if (response.ok && response.data.audio) {
694
+ beatAudios[index] = response.data.audio;
695
+ audioState[index] = "done";
696
+ }
697
+ }
698
+
699
+ async function generateAudio(index: number) {
700
+ audioState[index] = "generating";
701
+ delete audioErrors[index];
702
+ const response = await apiPost<{ audio?: string; error?: string }>(API_ROUTES.mulmoScript.generateBeatAudio, {
703
+ filePath: filePath.value,
704
+ beatIndex: index,
705
+ chatSessionId: chatSessionId.value,
706
+ });
707
+ if (!response.ok) {
708
+ audioErrors[index] = response.error || "Audio generation failed";
709
+ audioState[index] = "error";
710
+ return;
711
+ }
712
+ if (response.data.error) {
713
+ audioErrors[index] = response.data.error;
714
+ audioState[index] = "error";
715
+ return;
716
+ }
717
+ beatAudios[index] = response.data.audio ?? "";
718
+ audioState[index] = "done";
719
+ }
720
+
721
+ function playAudio(index: number) {
722
+ if (playingAudio.value) {
723
+ playingAudio.value.audio.pause();
724
+ const wasIndex = playingAudio.value.index;
725
+ playingAudio.value = null;
726
+ if (wasIndex === index) return;
727
+ }
728
+ const src = beatAudios[index];
729
+ if (!src) return;
730
+ const audio = new Audio(src);
731
+ playingAudio.value = { index, audio };
732
+ audio.addEventListener("ended", () => {
733
+ if (playingAudio.value?.index !== index) return;
734
+ playingAudio.value = null;
735
+ if (lightbox.value?.index === index) {
736
+ lightboxMove(1);
737
+ const nextIndex = lightbox.value?.index;
738
+ if (nextIndex !== undefined && nextIndex !== index && beatAudios[nextIndex]) {
739
+ playAudio(nextIndex);
740
+ }
741
+ }
742
+ });
743
+ audio.play();
744
+ }
745
+
746
+ function onBeatDragOver(event: DragEvent, index: number) {
747
+ if (!event.dataTransfer?.types.includes("Files")) return;
748
+ event.preventDefault();
749
+ beatDragOver[index] = true;
750
+ }
751
+
752
+ function onBeatDragLeave(index: number) {
753
+ beatDragOver[index] = false;
754
+ }
755
+
756
+ async function onBeatDrop(event: DragEvent, index: number) {
757
+ event.preventDefault();
758
+ beatDragOver[index] = false;
759
+ const file = event.dataTransfer?.files[0];
760
+ if (!file || !file.type.startsWith("image/")) return;
761
+
762
+ renderState[index] = "rendering";
763
+ delete renderErrors[index];
764
+ let imageData: string;
765
+ try {
766
+ imageData = await new Promise<string>((resolve, reject) => {
767
+ const reader = new FileReader();
768
+ reader.onload = () => resolve(reader.result as string);
769
+ reader.onerror = reject;
770
+ reader.readAsDataURL(file);
771
+ });
772
+ } catch (err) {
773
+ renderErrors[index] = errorMessage(err);
774
+ renderState[index] = "error";
775
+ return;
776
+ }
777
+ const response = await apiPost<{ image?: string; error?: string }>(API_ROUTES.mulmoScript.uploadBeatImage, {
778
+ filePath: filePath.value,
779
+ beatIndex: index,
780
+ imageData,
781
+ });
782
+ if (!response.ok) {
783
+ renderErrors[index] = response.error || "Upload failed";
784
+ renderState[index] = "error";
785
+ return;
786
+ }
787
+ if (response.data.error) {
788
+ renderErrors[index] = response.data.error;
789
+ renderState[index] = "error";
790
+ return;
791
+ }
792
+ renderedImages[index] = response.data.image ?? "";
793
+ renderState[index] = "done";
794
+ }
795
+
796
+ function onCharDragOver(event: DragEvent, key: string) {
797
+ if (!event.dataTransfer?.types.includes("Files")) return;
798
+ event.preventDefault();
799
+ charDragOver[key] = true;
800
+ }
801
+
802
+ function onCharDragLeave(key: string) {
803
+ charDragOver[key] = false;
804
+ }
805
+
806
+ async function onCharDrop(event: DragEvent, key: string) {
807
+ event.preventDefault();
808
+ charDragOver[key] = false;
809
+ const file = event.dataTransfer?.files[0];
810
+ if (!file || !file.type.startsWith("image/")) return;
811
+
812
+ charRenderState[key] = "rendering";
813
+ delete charErrors[key];
814
+ let imageData: string;
815
+ try {
816
+ imageData = await new Promise<string>((resolve, reject) => {
817
+ const reader = new FileReader();
818
+ reader.onload = () => resolve(reader.result as string);
819
+ reader.onerror = reject;
820
+ reader.readAsDataURL(file);
821
+ });
822
+ } catch (err) {
823
+ charErrors[key] = errorMessage(err);
824
+ charRenderState[key] = "error";
825
+ return;
826
+ }
827
+ const response = await apiPost<{ image?: string; error?: string }>(API_ROUTES.mulmoScript.uploadCharacterImage, { filePath: filePath.value, key, imageData });
828
+ if (!response.ok) {
829
+ charErrors[key] = response.error || "Upload failed";
830
+ charRenderState[key] = "error";
831
+ return;
832
+ }
833
+ if (response.data.error) {
834
+ charErrors[key] = response.data.error;
835
+ charRenderState[key] = "error";
836
+ return;
837
+ }
838
+ charImages[key] = response.data.image ?? "";
839
+ charRenderState[key] = "done";
840
+ }
841
+
842
+ function openCharacterLightbox(key: string) {
843
+ if (playingAudio.value) {
844
+ playingAudio.value.audio.pause();
845
+ playingAudio.value = null;
846
+ }
847
+ lightbox.value = {
848
+ src: charImages[key],
849
+ text: key,
850
+ index: -1,
851
+ isCharacter: true,
852
+ };
853
+ }
854
+
855
+ async function loadExistingCharacterImage(key: string) {
856
+ const response = await apiGet<{ image?: string }>(API_ROUTES.mulmoScript.characterImage, { filePath: filePath.value, key });
857
+ // silently ignore errors
858
+ if (response.ok && response.data.image) {
859
+ charImages[key] = response.data.image;
860
+ charRenderState[key] = "done";
861
+ }
862
+ }
863
+
864
+ function refreshMissingCharacterImages() {
865
+ getMissingCharacterKeys(characterKeys.value, charImages, charRenderState).forEach((key) => loadExistingCharacterImage(key));
866
+ }
867
+
868
+ async function renderCharacter(key: string, force: boolean) {
869
+ charRenderState[key] = "rendering";
870
+ delete charErrors[key];
871
+ const response = await apiPost<{ image?: string; error?: string }>(API_ROUTES.mulmoScript.renderCharacter, {
872
+ filePath: filePath.value,
873
+ key,
874
+ force,
875
+ chatSessionId: chatSessionId.value,
876
+ });
877
+ if (!response.ok) {
878
+ charErrors[key] = response.error || "Render failed";
879
+ charRenderState[key] = "error";
880
+ return;
881
+ }
882
+ if (response.data.error) {
883
+ charErrors[key] = response.data.error;
884
+ charRenderState[key] = "error";
885
+ return;
886
+ }
887
+ charImages[key] = response.data.image ?? "";
888
+ charRenderState[key] = "done";
889
+ }
890
+
891
+ async function generateAllCharacters() {
892
+ await Promise.all(characterKeys.value.filter((key) => charRenderState[key] !== "rendering").map((key) => renderCharacter(key, false)));
893
+ }
894
+
895
+ async function initializeScript() {
896
+ // Reset scroll position so new results start at the top
897
+ if (beatListEl.value) beatListEl.value.scrollTop = 0;
898
+ // Reset per-script state
899
+ Object.keys(renderState).forEach((key) => delete renderState[+key]);
900
+ Object.keys(renderedImages).forEach((key) => delete renderedImages[+key]);
901
+ Object.keys(renderErrors).forEach((key) => delete renderErrors[+key]);
902
+ Object.keys(sourceOpen).forEach((key) => delete sourceOpen[+key]);
903
+ Object.keys(sourceText).forEach((key) => delete sourceText[+key]);
904
+ Object.keys(beatSaveErrors).forEach((key) => delete beatSaveErrors[+key]);
905
+ Object.keys(beatSaving).forEach((key) => delete beatSaving[+key]);
906
+ Object.keys(localOverrides).forEach((key) => delete localOverrides[+key]);
907
+ Object.keys(beatAudios).forEach((key) => delete beatAudios[+key]);
908
+ Object.keys(audioState).forEach((key) => delete audioState[+key]);
909
+ Object.keys(audioErrors).forEach((key) => delete audioErrors[+key]);
910
+ Object.keys(charRenderState).forEach((key) => delete charRenderState[key]);
911
+ Object.keys(charImages).forEach((key) => delete charImages[key]);
912
+ Object.keys(charErrors).forEach((key) => delete charErrors[key]);
913
+ Object.keys(beatDragOver).forEach((key) => delete beatDragOver[+key]);
914
+ moviePath.value = null;
915
+ if (sourceDetails.value) sourceDetails.value.open = false;
916
+
917
+ const AUTO_RENDER_TYPES = ["textSlide", "markdown", "chart", "mermaid", "html_tailwind"] as const;
918
+ const hasCharacters = characterKeys.value.length > 0;
919
+ beats.value.forEach((beat, index) => {
920
+ if (shouldAutoRenderBeat(beat, hasCharacters, AUTO_RENDER_TYPES)) {
921
+ renderBeat(index);
922
+ } else if (beat.imagePrompt) {
923
+ loadExistingBeatImage(index);
924
+ }
925
+ if (beat.text) loadExistingBeatAudio(index);
926
+ });
927
+
928
+ characterKeys.value.forEach((key) => loadExistingCharacterImage(key));
929
+
930
+ if (filePath.value) {
931
+ const response = await apiGet<{ moviePath?: string }>(API_ROUTES.mulmoScript.movieStatus, { filePath: filePath.value });
932
+ if (response.ok && response.data.moviePath) {
933
+ moviePath.value = response.data.moviePath;
934
+ }
935
+ // ignore errors
936
+ }
937
+
938
+ // Reflect any generations that were already in flight when we
939
+ // mounted (user switched away mid-generation and came back).
940
+ for (const entry of Object.values(pendingForThisScript.value)) {
941
+ reflectGenerationStart(entry);
942
+ }
943
+ }
944
+
945
+ onMounted(initializeScript);
946
+ watch(() => props.selectedResult, initializeScript);
947
+
948
+ // Keep the view in sync with generations that started from a different
949
+ // view mount or a parallel tab. When a generation for this script
950
+ // disappears from session.pendingGenerations we reload the relevant
951
+ // artifact off disk; when one appears we mirror it into the local
952
+ // "rendering" state so spinners show even after a remount.
953
+ watch(pendingForThisScript, (now, prev = {}) => {
954
+ for (const [mapKey, entry] of Object.entries(now)) {
955
+ if (!(mapKey in prev)) reflectGenerationStart(entry);
956
+ }
957
+ for (const [mapKey, entry] of Object.entries(prev)) {
958
+ if (!(mapKey in now)) {
959
+ // Fire-and-forget: the watcher callback must stay sync so Vue
960
+ // can batch multiple pendingGenerations updates. Swallow + log
961
+ // so a failed reload doesn't surface as an unhandled rejection.
962
+ reflectGenerationFinish(entry).catch((err) => {
963
+ console.error("[presentMulmoScript] reload on finish failed:", err);
964
+ });
965
+ }
966
+ }
967
+ });
968
+
969
+ function reflectGenerationStart(entry: PendingGeneration): void {
970
+ if (entry.kind === GENERATION_KINDS.beatImage) {
971
+ const idx = Number(entry.key);
972
+ if (!renderedImages[idx]) renderState[idx] = "rendering";
973
+ } else if (entry.kind === GENERATION_KINDS.beatAudio) {
974
+ const idx = Number(entry.key);
975
+ if (!beatAudios[idx]) audioState[idx] = "generating";
976
+ } else if (entry.kind === GENERATION_KINDS.characterImage) {
977
+ if (!charImages[entry.key]) charRenderState[entry.key] = "rendering";
978
+ } else if (entry.kind === GENERATION_KINDS.movie) {
979
+ movieGenerating.value = true;
980
+ }
981
+ }
982
+
983
+ async function reflectGenerationFinish(entry: PendingGeneration): Promise<void> {
984
+ if (entry.kind === GENERATION_KINDS.beatImage) {
985
+ const idx = Number(entry.key);
986
+ await loadExistingBeatImage(idx);
987
+ if (renderState[idx] === "rendering") delete renderState[idx];
988
+ } else if (entry.kind === GENERATION_KINDS.beatAudio) {
989
+ const idx = Number(entry.key);
990
+ await loadExistingBeatAudio(idx);
991
+ if (audioState[idx] === "generating") delete audioState[idx];
992
+ } else if (entry.kind === GENERATION_KINDS.characterImage) {
993
+ await loadExistingCharacterImage(entry.key);
994
+ if (charRenderState[entry.key] === "rendering") {
995
+ delete charRenderState[entry.key];
996
+ }
997
+ } else if (entry.kind === GENERATION_KINDS.movie) {
998
+ movieGenerating.value = false;
999
+ await refreshMoviePath();
1000
+ }
1001
+ }
1002
+
1003
+ async function refreshMoviePath(): Promise<void> {
1004
+ if (!filePath.value) return;
1005
+ const response = await apiGet<{ moviePath?: string }>(API_ROUTES.mulmoScript.movieStatus, { filePath: filePath.value });
1006
+ if (response.ok && response.data.moviePath) {
1007
+ moviePath.value = response.data.moviePath;
1008
+ }
1009
+ }
1010
+
1011
+ async function generateMovie() {
1012
+ movieGenerating.value = true;
1013
+ try {
1014
+ const res = await apiFetchRaw(API_ROUTES.mulmoScript.generateMovie, {
1015
+ method: "POST",
1016
+ body: JSON.stringify({
1017
+ filePath: filePath.value,
1018
+ chatSessionId: chatSessionId.value,
1019
+ }),
1020
+ headers: { "Content-Type": "application/json" },
1021
+ });
1022
+ if (!res.ok || !res.body) throw new Error("Generation failed");
1023
+ await streamMovieEvents(res.body, {
1024
+ onBeatImageDone: (beatIndex) => {
1025
+ loadExistingBeatImage(beatIndex);
1026
+ refreshMissingCharacterImages();
1027
+ },
1028
+ onBeatAudioDone: (beatIndex) => loadExistingBeatAudio(beatIndex),
1029
+ onDone: (path) => {
1030
+ moviePath.value = path;
1031
+ },
1032
+ });
1033
+ } catch (err) {
1034
+ alert(extractErrorMessage(err));
1035
+ } finally {
1036
+ movieGenerating.value = false;
1037
+ }
1038
+ }
1039
+ </script>
1040
+
1041
+ <style scoped>
1042
+ .bottom-bar-wrapper {
1043
+ position: relative;
1044
+ flex-shrink: 0;
1045
+ }
1046
+
1047
+ .script-source {
1048
+ padding: 0.5rem;
1049
+ background: #f5f5f5;
1050
+ border-top: 1px solid #e0e0e0;
1051
+ font-family: monospace;
1052
+ font-size: 0.85rem;
1053
+ }
1054
+
1055
+ .script-source summary {
1056
+ cursor: pointer;
1057
+ user-select: none;
1058
+ padding: 0.5rem;
1059
+ background: #e8e8e8;
1060
+ border-radius: 4px;
1061
+ font-weight: 500;
1062
+ color: #333;
1063
+ }
1064
+
1065
+ .script-source[open] summary {
1066
+ margin-bottom: 0.5rem;
1067
+ }
1068
+
1069
+ .script-source summary:hover {
1070
+ background: #d8d8d8;
1071
+ }
1072
+
1073
+ .script-editor {
1074
+ width: 100%;
1075
+ height: 40vh;
1076
+ padding: 1rem;
1077
+ background: #ffffff;
1078
+ border: 1px solid #ccc;
1079
+ border-radius: 4px;
1080
+ color: #333;
1081
+ font-family: "Courier New", monospace;
1082
+ font-size: 0.9rem;
1083
+ resize: vertical;
1084
+ margin-bottom: 0.5rem;
1085
+ line-height: 1.5;
1086
+ }
1087
+
1088
+ .script-editor:focus {
1089
+ outline: none;
1090
+ border-color: #4caf50;
1091
+ box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.1);
1092
+ }
1093
+
1094
+ .script-editor-invalid {
1095
+ border-color: #ef4444;
1096
+ }
1097
+
1098
+ .script-editor-invalid:focus {
1099
+ border-color: #ef4444;
1100
+ box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.1);
1101
+ }
1102
+
1103
+ .editor-actions {
1104
+ display: flex;
1105
+ justify-content: space-between;
1106
+ }
1107
+
1108
+ .apply-btn {
1109
+ padding: 0.5rem 1rem;
1110
+ background: #4caf50;
1111
+ color: white;
1112
+ border: none;
1113
+ border-radius: 4px;
1114
+ cursor: pointer;
1115
+ font-size: 0.9rem;
1116
+ transition: background 0.2s;
1117
+ font-weight: 500;
1118
+ }
1119
+
1120
+ .apply-btn:hover {
1121
+ background: #45a049;
1122
+ }
1123
+
1124
+ .apply-btn:disabled {
1125
+ background: #cccccc;
1126
+ color: #666666;
1127
+ cursor: not-allowed;
1128
+ opacity: 0.6;
1129
+ }
1130
+
1131
+ .cancel-btn {
1132
+ padding: 0.5rem 1rem;
1133
+ background: #e0e0e0;
1134
+ color: #333;
1135
+ border: none;
1136
+ border-radius: 4px;
1137
+ cursor: pointer;
1138
+ font-size: 0.9rem;
1139
+ transition: background 0.2s;
1140
+ font-weight: 500;
1141
+ }
1142
+
1143
+ .cancel-btn:hover {
1144
+ background: #d0d0d0;
1145
+ }
1146
+
1147
+ .copy-btn {
1148
+ position: absolute;
1149
+ bottom: 0.3rem;
1150
+ right: 0.65rem;
1151
+ padding: 0.4rem;
1152
+ background: none;
1153
+ border: none;
1154
+ color: #333;
1155
+ cursor: pointer;
1156
+ z-index: 1;
1157
+ }
1158
+
1159
+ .copy-btn:hover {
1160
+ color: #000;
1161
+ }
1162
+
1163
+ .copy-btn .material-icons {
1164
+ font-size: 1.15rem;
1165
+ }
1166
+ </style>