mulmoclaude 0.5.2 → 0.6.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 (486) hide show
  1. package/Dockerfile.sandbox +100 -0
  2. package/README.md +17 -4
  3. package/bin/mulmoclaude.js +46 -15
  4. package/bin/prepare-dist.js +18 -2
  5. package/client/assets/chunk-CernVdwh.js +1 -0
  6. package/client/assets/chunk-D8eiyYIV-C1eAZMzz.js +1 -0
  7. package/client/assets/html2canvas-CDGcmOD3-BbPeutDg.js +5 -0
  8. package/client/assets/index-BbgSjFQ8.js +4968 -0
  9. package/client/assets/index-ECD0lgIv.css +2 -0
  10. package/client/assets/{index.es-D4YyL_Dg-BgT6a3Nd.js → index.es-DqtpmBm8-DJdTPdnc.js} +5 -5
  11. package/client/assets/material-symbols-outlined-BLDfUw-_.woff2 +0 -0
  12. package/client/assets/runtime-protocol-vue-6WYa8hAs.js +1 -0
  13. package/client/assets/runtime-vue-BVUzgYGA.js +1 -0
  14. package/client/assets/typeof-DBp4T-Ny-C2xoZtcz.js +1 -0
  15. package/client/assets/vue-1e_vz2LW.js +1 -0
  16. package/client/assets/vue.runtime.esm-bundler-DQ8Kjjui.js +4 -0
  17. package/client/index.html +33 -2
  18. package/package.json +20 -18
  19. package/sandbox-entrypoint.sh +106 -0
  20. package/server/accounting/accountNormalize.ts +32 -0
  21. package/server/accounting/defaultAccounts.ts +87 -0
  22. package/server/accounting/eventPublisher.ts +51 -0
  23. package/server/accounting/journal.ts +252 -0
  24. package/server/accounting/openingBalances.ts +114 -0
  25. package/server/accounting/report.ts +237 -0
  26. package/server/accounting/service.ts +718 -0
  27. package/server/accounting/snapshotCache.ts +333 -0
  28. package/server/accounting/timeSeries.ts +265 -0
  29. package/server/accounting/types.ts +148 -0
  30. package/server/agent/activeTools.ts +128 -0
  31. package/server/agent/attachmentConverter.ts +10 -5
  32. package/server/agent/backend/claude-code.ts +8 -2
  33. package/server/agent/backend/types.ts +1 -1
  34. package/server/agent/config.ts +101 -31
  35. package/server/agent/index.ts +45 -33
  36. package/server/agent/mcp-server.ts +146 -69
  37. package/server/agent/mcp-tools/index.ts +1 -5
  38. package/server/agent/mcp-tools/notify.ts +2 -22
  39. package/server/agent/mcp-tools/x.ts +0 -4
  40. package/server/agent/mcpHealth.ts +168 -0
  41. package/server/agent/plugin-names.ts +20 -77
  42. package/server/agent/prompt.ts +259 -51
  43. package/server/agent/resumeFailover.ts +1 -1
  44. package/server/agent/stream.ts +0 -1
  45. package/server/api/auth/bearerAuth.ts +5 -5
  46. package/server/api/csrfGuard.ts +1 -1
  47. package/server/api/routes/accounting.ts +366 -0
  48. package/server/api/routes/agent.ts +509 -46
  49. package/server/api/routes/attachment.ts +104 -0
  50. package/server/api/routes/chart.ts +2 -1
  51. package/server/api/routes/config.ts +12 -12
  52. package/server/api/routes/files.ts +105 -48
  53. package/server/api/routes/image.ts +70 -25
  54. package/server/api/routes/journal.ts +35 -0
  55. package/server/api/routes/mulmo-script.ts +358 -118
  56. package/server/api/routes/mulmoScriptValidate.ts +1 -1
  57. package/server/api/routes/news.ts +1 -1
  58. package/server/api/routes/notifications.ts +92 -22
  59. package/server/api/routes/notifier.ts +98 -0
  60. package/server/api/routes/pdf.ts +188 -48
  61. package/server/api/routes/plugins.ts +34 -14
  62. package/server/api/routes/presentHtml.ts +58 -3
  63. package/server/api/routes/roles.ts +1 -8
  64. package/server/api/routes/runtime-plugin.ts +224 -0
  65. package/server/api/routes/scheduler.ts +7 -5
  66. package/server/api/routes/schedulerHandlers.ts +1 -1
  67. package/server/api/routes/schedulerTasks.ts +8 -7
  68. package/server/api/routes/sessions.ts +234 -121
  69. package/server/api/routes/skills.ts +56 -51
  70. package/server/api/routes/sources.ts +52 -45
  71. package/server/api/routes/translation.ts +44 -0
  72. package/server/api/routes/wiki/frontmatter.ts +13 -65
  73. package/server/api/routes/wiki/history.ts +261 -0
  74. package/server/api/routes/wiki/pageIndex.ts +1 -1
  75. package/server/api/routes/wiki.ts +50 -26
  76. package/server/events/file-change.ts +83 -0
  77. package/server/events/notifications.ts +247 -91
  78. package/server/events/pub-sub/index.ts +1 -1
  79. package/server/events/relay-client.ts +5 -5
  80. package/server/events/scheduler-adapter.ts +2 -2
  81. package/server/events/session-store/index.ts +110 -22
  82. package/server/events/task-manager/index.ts +10 -9
  83. package/server/index.ts +509 -33
  84. package/server/notifier/engine.ts +419 -0
  85. package/server/notifier/legacy-adapters.ts +76 -0
  86. package/server/notifier/runtime-api.ts +74 -0
  87. package/server/notifier/store.ts +70 -0
  88. package/server/notifier/types.ts +121 -0
  89. package/server/plugins/dev-loader.ts +171 -0
  90. package/server/plugins/dev-watcher.ts +150 -0
  91. package/server/plugins/diagnostics.ts +188 -0
  92. package/server/plugins/preset-list.ts +52 -0
  93. package/server/plugins/preset-loader.ts +112 -0
  94. package/server/plugins/runtime-chat-api.ts +38 -0
  95. package/server/plugins/runtime-loader.ts +430 -0
  96. package/server/plugins/runtime-registry.ts +112 -0
  97. package/server/plugins/runtime-tasks-api.ts +50 -0
  98. package/server/plugins/runtime.ts +378 -0
  99. package/server/services/translation/cache.ts +72 -0
  100. package/server/services/translation/index.ts +106 -0
  101. package/server/services/translation/llm.ts +140 -0
  102. package/server/services/translation/types.ts +35 -0
  103. package/server/system/credentials.ts +13 -2
  104. package/server/system/env.ts +6 -1
  105. package/server/system/logger/formatters.ts +46 -4
  106. package/server/system/logger/index.ts +4 -4
  107. package/server/system/logger/sinks.ts +26 -5
  108. package/server/system/logger/types.ts +2 -2
  109. package/server/utils/dev-plugin-args.d.mts +11 -0
  110. package/server/utils/dev-plugin-args.mjs +43 -0
  111. package/server/utils/errors.ts +13 -4
  112. package/server/utils/files/accounting-io.ts +295 -0
  113. package/server/utils/files/atomic.ts +17 -49
  114. package/server/utils/files/attachment-store.ts +182 -0
  115. package/server/utils/files/html-io.ts +1 -7
  116. package/server/utils/files/html-store.ts +19 -0
  117. package/server/utils/files/image-store.ts +20 -22
  118. package/server/utils/files/index.ts +5 -15
  119. package/server/utils/files/journal-io.ts +7 -35
  120. package/server/utils/files/json.ts +2 -29
  121. package/server/utils/files/markdown-image-fill.ts +6 -37
  122. package/server/utils/files/markdown-store.ts +6 -21
  123. package/server/utils/files/naming.ts +3 -39
  124. package/server/utils/files/plugins-io.ts +100 -0
  125. package/server/utils/files/reference-dirs-io.ts +1 -9
  126. package/server/utils/files/roles-io.ts +2 -10
  127. package/server/utils/files/safe.ts +17 -19
  128. package/server/utils/files/scheduler-io.ts +1 -7
  129. package/server/utils/files/scheduler-overrides-io.ts +3 -12
  130. package/server/utils/files/session-io.ts +21 -30
  131. package/server/utils/files/spreadsheet-store.ts +9 -22
  132. package/server/utils/files/translation-io.ts +46 -0
  133. package/server/utils/files/user-tasks-io.ts +1 -7
  134. package/server/utils/files/workspace-io.ts +3 -79
  135. package/server/utils/gemini.ts +33 -11
  136. package/server/utils/html/htmlArtifactSplicer.ts +41 -0
  137. package/server/utils/markdown/frontmatter.ts +112 -0
  138. package/server/utils/regex.ts +56 -0
  139. package/server/utils/router.ts +41 -0
  140. package/server/utils/slug.ts +5 -3
  141. package/server/utils/time.ts +12 -0
  142. package/server/workspace/chat-index/indexer.ts +15 -2
  143. package/server/workspace/chat-index/summarizer.ts +1 -1
  144. package/server/workspace/custom-dirs.ts +1 -1
  145. package/server/workspace/helps/gemini.md +1 -1
  146. package/server/workspace/helps/guide.md +61 -0
  147. package/server/workspace/helps/index.md +4 -0
  148. package/server/workspace/helps/presenthtml.md +80 -0
  149. package/server/workspace/helps/sandbox.md +7 -0
  150. package/server/workspace/helps/storyteller.md +101 -0
  151. package/server/workspace/helps/telegram.md +1 -0
  152. package/server/workspace/helps/wiki.md +9 -7
  153. package/server/workspace/journal/archivist-cli.ts +7 -33
  154. package/server/workspace/journal/archivist-schemas.ts +5 -43
  155. package/server/workspace/journal/dailyPass.ts +34 -187
  156. package/server/workspace/journal/diff.ts +3 -28
  157. package/server/workspace/journal/index.ts +10 -81
  158. package/server/workspace/journal/indexFile.ts +3 -24
  159. package/server/workspace/journal/latestDaily.ts +51 -0
  160. package/server/workspace/journal/memoryExtractor.ts +4 -20
  161. package/server/workspace/journal/optimizationPass.ts +4 -21
  162. package/server/workspace/journal/paths.ts +4 -23
  163. package/server/workspace/journal/state.ts +6 -29
  164. package/server/workspace/memory/io.ts +213 -0
  165. package/server/workspace/memory/llm-classifier.ts +158 -0
  166. package/server/workspace/memory/migrate.ts +263 -0
  167. package/server/workspace/memory/run.ts +84 -0
  168. package/server/workspace/memory/topic-cluster.ts +218 -0
  169. package/server/workspace/memory/topic-detect.ts +67 -0
  170. package/server/workspace/memory/topic-index-hook.ts +128 -0
  171. package/server/workspace/memory/topic-io.ts +180 -0
  172. package/server/workspace/memory/topic-migrate.ts +248 -0
  173. package/server/workspace/memory/topic-run.ts +172 -0
  174. package/server/workspace/memory/topic-swap.ts +135 -0
  175. package/server/workspace/memory/topic-types.ts +142 -0
  176. package/server/workspace/memory/types.ts +83 -0
  177. package/server/workspace/news/reader.ts +4 -5
  178. package/server/workspace/paths.ts +124 -47
  179. package/server/workspace/roles.ts +2 -11
  180. package/server/workspace/skills/parser.ts +38 -55
  181. package/server/workspace/skills/user-tasks.ts +1 -2
  182. package/server/workspace/skills-preset/mc-library/SKILL.md +188 -0
  183. package/server/workspace/skills-preset.ts +196 -0
  184. package/server/workspace/sources/fetchers/githubIssues.ts +13 -11
  185. package/server/workspace/sources/fetchers/index.ts +1 -1
  186. package/server/workspace/sources/fetchers/rssParser.ts +1 -1
  187. package/server/workspace/sources/pipeline/index.ts +2 -2
  188. package/server/workspace/sources/pipeline/notify.ts +3 -3
  189. package/server/workspace/sources/pipeline/write.ts +2 -2
  190. package/server/workspace/sources/registry.ts +39 -61
  191. package/server/workspace/sources/robots.ts +1 -1
  192. package/server/workspace/tool-trace/classify.ts +2 -1
  193. package/server/workspace/tool-trace/index.ts +1 -1
  194. package/server/workspace/tool-trace/writeSearch.ts +6 -1
  195. package/server/workspace/wiki-backlinks/index.ts +19 -7
  196. package/server/workspace/wiki-backlinks/sessionBacklinks.ts +1 -0
  197. package/server/workspace/wiki-history/hook/snapshot.mjs +98 -0
  198. package/server/workspace/wiki-history/hook/snapshot.ts +135 -0
  199. package/server/workspace/wiki-history/provision.ts +181 -0
  200. package/server/workspace/wiki-pages/io.ts +217 -0
  201. package/server/workspace/wiki-pages/snapshot.ts +380 -0
  202. package/server/workspace/workspace.ts +75 -13
  203. package/src/App.vue +115 -40
  204. package/src/_runtime/protocol-vue.ts +21 -0
  205. package/src/_runtime/vue.ts +22 -0
  206. package/src/components/ChatInput.vue +14 -10
  207. package/src/components/CopyChatButton.vue +76 -0
  208. package/src/components/FileContentRenderer.vue +67 -14
  209. package/src/components/FileTree.vue +2 -2
  210. package/src/components/FilesView.vue +17 -1
  211. package/src/components/NewsView.vue +16 -2
  212. package/src/components/NotificationBell.vue +320 -93
  213. package/src/components/PageChatComposer.vue +5 -4
  214. package/src/components/PluginLauncher.vue +42 -6
  215. package/src/components/PluginScopedRoot.vue +87 -0
  216. package/src/components/RoleSelector.vue +12 -1
  217. package/src/components/RolesView.vue +562 -0
  218. package/src/components/SentAttachmentChip.vue +102 -0
  219. package/src/components/SessionHistoryPanel.vue +109 -20
  220. package/src/components/SessionRoleIcon.vue +7 -4
  221. package/src/components/SessionSidebar.vue +20 -7
  222. package/src/components/SessionTabBar.vue +1 -1
  223. package/src/components/SettingsMcpTab.vue +4 -4
  224. package/src/components/SettingsModal.vue +2 -0
  225. package/src/components/SidebarHeader.vue +16 -5
  226. package/src/components/SourcesManager.vue +23 -9
  227. package/src/components/SourcesView.vue +1 -1
  228. package/src/components/StackView.vue +102 -6
  229. package/src/components/SuggestionsPanel.vue +105 -16
  230. package/src/components/SystemFileBanner.vue +1 -1
  231. package/src/components/TodoExplorer.vue +4 -5
  232. package/src/components/todo/TodoAddDialog.vue +2 -3
  233. package/src/components/todo/TodoEditDialog.vue +1 -2
  234. package/src/components/todo/TodoEditPanel.vue +2 -3
  235. package/src/components/todo/TodoKanbanView.vue +8 -5
  236. package/src/components/todo/TodoListView.vue +3 -5
  237. package/src/components/todo/TodoTableView.vue +7 -5
  238. package/src/composables/useAccountingChannel.ts +58 -0
  239. package/src/composables/useActiveSession.ts +4 -25
  240. package/src/composables/useAppApi.ts +6 -44
  241. package/src/composables/useClipboardCopy.ts +3 -20
  242. package/src/composables/useContentDisplay.ts +33 -2
  243. package/src/composables/useDevPluginReload.ts +23 -0
  244. package/src/composables/useDynamicFavicon.ts +5 -31
  245. package/src/composables/useEventListeners.ts +0 -20
  246. package/src/composables/useExpandedDirs.ts +4 -15
  247. package/src/composables/useFaviconState.ts +12 -46
  248. package/src/composables/useFileChange.ts +53 -0
  249. package/src/composables/useFreshPluginData.ts +6 -43
  250. package/src/composables/useHealth.ts +14 -43
  251. package/src/composables/useImageErrorRepair.ts +104 -0
  252. package/src/composables/useLatestDaily.ts +40 -0
  253. package/src/composables/useMarkdownDoc.ts +39 -0
  254. package/src/composables/useMarkdownLinkHandler.ts +1 -1
  255. package/src/composables/useMcpTools.ts +3 -16
  256. package/src/composables/useNotifications.ts +138 -112
  257. package/src/composables/usePdfDownload.ts +17 -3
  258. package/src/composables/usePendingCalls.ts +8 -26
  259. package/src/composables/usePluginErrorBoundary.ts +68 -0
  260. package/src/composables/usePubSub.ts +9 -17
  261. package/src/composables/useRunElapsed.ts +5 -22
  262. package/src/composables/useSandboxStatus.ts +4 -20
  263. package/src/composables/useSessionDerived.ts +7 -15
  264. package/src/composables/useSessionHistory.ts +70 -29
  265. package/src/composables/useSessionSync.ts +25 -3
  266. package/src/composables/useSkillsList.ts +59 -0
  267. package/src/composables/useTranslatedQueries.ts +109 -0
  268. package/src/config/apiRoutes.ts +181 -80
  269. package/src/config/historyFilters.ts +5 -3
  270. package/src/config/hostEvents.ts +17 -0
  271. package/src/config/mcpCatalog.ts +277 -5
  272. package/src/config/pubsubChannels.ts +134 -12
  273. package/src/config/roles.ts +212 -147
  274. package/src/config/systemFileDescriptors.ts +5 -5
  275. package/src/config/toolNames.ts +52 -30
  276. package/src/config/workspacePaths.ts +26 -2
  277. package/src/lang/de.ts +483 -27
  278. package/src/lang/en.ts +448 -27
  279. package/src/lang/es.ts +474 -27
  280. package/src/lang/fr.ts +476 -27
  281. package/src/lang/ja.ts +465 -27
  282. package/src/lang/ko.ts +466 -27
  283. package/src/lang/pt-BR.ts +473 -27
  284. package/src/lang/zh.ts +463 -27
  285. package/src/lib/vue-i18n.ts +1 -1
  286. package/src/lib/wiki-page/slug.ts +66 -0
  287. package/src/main.ts +85 -0
  288. package/src/plugins/_extras.ts +58 -0
  289. package/src/plugins/_generated/metas.ts +42 -0
  290. package/src/plugins/_generated/registrations.ts +44 -0
  291. package/src/plugins/_generated/server-bindings.ts +47 -0
  292. package/src/plugins/accounting/Preview.vue +106 -0
  293. package/src/plugins/accounting/View.vue +632 -0
  294. package/src/plugins/accounting/actions.ts +34 -0
  295. package/src/plugins/accounting/api.ts +301 -0
  296. package/src/plugins/accounting/components/AccountEditor.vue +250 -0
  297. package/src/plugins/accounting/components/AccountRow.vue +50 -0
  298. package/src/plugins/accounting/components/AccountsList.vue +102 -0
  299. package/src/plugins/accounting/components/AccountsModal.vue +300 -0
  300. package/src/plugins/accounting/components/BalanceSheet.vue +186 -0
  301. package/src/plugins/accounting/components/BookSettings.vue +284 -0
  302. package/src/plugins/accounting/components/BookSwitcher.vue +78 -0
  303. package/src/plugins/accounting/components/DateRangePicker.vue +140 -0
  304. package/src/plugins/accounting/components/JournalEntryForm.vue +504 -0
  305. package/src/plugins/accounting/components/JournalList.vue +553 -0
  306. package/src/plugins/accounting/components/Ledger.vue +206 -0
  307. package/src/plugins/accounting/components/NewBookForm.vue +211 -0
  308. package/src/plugins/accounting/components/OpeningBalancesForm.vue +271 -0
  309. package/src/plugins/accounting/components/ProfitLoss.vue +160 -0
  310. package/src/plugins/accounting/components/accountDraft.ts +13 -0
  311. package/src/plugins/accounting/components/accountNumbering.ts +103 -0
  312. package/src/plugins/accounting/components/accountValidation.ts +75 -0
  313. package/src/plugins/accounting/components/useLatestRequest.ts +44 -0
  314. package/src/plugins/accounting/countries.ts +158 -0
  315. package/src/plugins/accounting/currencies.ts +64 -0
  316. package/src/plugins/accounting/dates.ts +51 -0
  317. package/src/plugins/accounting/definition.ts +199 -0
  318. package/src/plugins/accounting/fiscalYear.ts +136 -0
  319. package/src/plugins/accounting/index.ts +49 -0
  320. package/src/plugins/accounting/meta.ts +91 -0
  321. package/src/plugins/accounting/timeSeriesEnums.ts +16 -0
  322. package/src/plugins/api.ts +125 -0
  323. package/src/plugins/canvas/View.vue +38 -28
  324. package/src/plugins/canvas/definition.ts +10 -8
  325. package/src/plugins/canvas/index.ts +15 -8
  326. package/src/plugins/canvas/meta.ts +12 -0
  327. package/src/plugins/chart/Preview.vue +1 -1
  328. package/src/plugins/chart/View.vue +2 -2
  329. package/src/plugins/chart/definition.ts +12 -2
  330. package/src/plugins/chart/index.ts +15 -7
  331. package/src/plugins/chart/meta.ts +18 -0
  332. package/src/plugins/editImages/definition.ts +44 -0
  333. package/src/plugins/editImages/index.ts +43 -0
  334. package/src/plugins/editImages/meta.ts +5 -0
  335. package/src/plugins/generateImage/View.vue +3 -1
  336. package/src/plugins/generateImage/definition.ts +2 -0
  337. package/src/plugins/generateImage/index.ts +13 -5
  338. package/src/plugins/generateImage/meta.ts +5 -0
  339. package/src/plugins/index.ts +35 -0
  340. package/src/plugins/manageRoles/Preview.vue +7 -4
  341. package/src/plugins/manageRoles/View.vue +12 -8
  342. package/src/plugins/manageRoles/definition.ts +6 -0
  343. package/src/plugins/manageRoles/index.ts +7 -6
  344. package/src/plugins/manageSkills/View.vue +11 -7
  345. package/src/plugins/manageSkills/definition.ts +4 -1
  346. package/src/plugins/manageSkills/index.ts +14 -7
  347. package/src/plugins/manageSkills/meta.ts +21 -0
  348. package/src/plugins/manageSource/definition.ts +4 -1
  349. package/src/plugins/manageSource/index.ts +15 -7
  350. package/src/plugins/manageSource/meta.ts +21 -0
  351. package/src/plugins/markdown/Preview.vue +10 -8
  352. package/src/plugins/markdown/View.vue +84 -17
  353. package/src/plugins/markdown/definition.ts +7 -1
  354. package/src/plugins/markdown/index.ts +15 -8
  355. package/src/plugins/markdown/meta.ts +16 -0
  356. package/src/plugins/meta-types.ts +97 -0
  357. package/src/plugins/metas.ts +224 -0
  358. package/src/plugins/presentForm/Preview.vue +4 -15
  359. package/src/plugins/presentForm/View.vue +35 -78
  360. package/src/plugins/presentForm/definition.ts +7 -6
  361. package/src/plugins/presentForm/index.ts +12 -5
  362. package/src/plugins/presentForm/meta.ts +11 -0
  363. package/src/plugins/presentForm/plugin.ts +8 -9
  364. package/src/plugins/presentForm/types.ts +0 -24
  365. package/src/plugins/presentHtml/Preview.vue +1 -8
  366. package/src/plugins/presentHtml/View.vue +401 -30
  367. package/src/plugins/presentHtml/definition.ts +8 -5
  368. package/src/plugins/presentHtml/index.ts +15 -8
  369. package/src/plugins/presentHtml/meta.ts +14 -0
  370. package/src/plugins/presentMulmoScript/View.vue +327 -107
  371. package/src/plugins/presentMulmoScript/definition.ts +34 -7
  372. package/src/plugins/presentMulmoScript/helpers.ts +4 -5
  373. package/src/plugins/presentMulmoScript/index.ts +20 -7
  374. package/src/plugins/presentMulmoScript/meta.ts +52 -0
  375. package/src/plugins/scheduler/AutomationsPreview.vue +2 -8
  376. package/src/plugins/scheduler/Preview.vue +5 -2
  377. package/src/plugins/scheduler/TasksTab.vue +16 -36
  378. package/src/plugins/scheduler/View.vue +22 -54
  379. package/src/plugins/scheduler/automationsDefinition.ts +14 -9
  380. package/src/plugins/scheduler/automationsMeta.ts +5 -0
  381. package/src/plugins/scheduler/calendarDefinition.ts +4 -7
  382. package/src/plugins/scheduler/calendarMeta.ts +28 -0
  383. package/src/plugins/scheduler/formatSchedule.ts +6 -24
  384. package/src/plugins/scheduler/index.ts +26 -52
  385. package/src/plugins/scope.ts +57 -0
  386. package/src/plugins/server-bindings-types.ts +38 -0
  387. package/src/plugins/server.ts +32 -0
  388. package/src/plugins/skill/Preview.vue +25 -0
  389. package/src/plugins/skill/View.vue +125 -0
  390. package/src/plugins/skill/definition.ts +23 -0
  391. package/src/plugins/skill/index.ts +36 -0
  392. package/src/plugins/skill/plugin.ts +31 -0
  393. package/src/plugins/skill/types.ts +21 -0
  394. package/src/plugins/spreadsheet/Preview.vue +1 -3
  395. package/src/plugins/spreadsheet/View.vue +29 -49
  396. package/src/plugins/spreadsheet/cellHighlights.ts +2 -3
  397. package/src/plugins/spreadsheet/definition.ts +5 -2
  398. package/src/plugins/spreadsheet/index.ts +15 -8
  399. package/src/plugins/spreadsheet/keyboardNav.ts +38 -0
  400. package/src/plugins/spreadsheet/meta.ts +14 -0
  401. package/src/plugins/textResponse/Preview.vue +9 -1
  402. package/src/plugins/textResponse/View.vue +59 -8
  403. package/src/plugins/textResponse/index.ts +11 -3
  404. package/src/plugins/textResponse/plugin.ts +8 -10
  405. package/src/plugins/textResponse/types.ts +28 -0
  406. package/src/plugins/wiki/Preview.vue +6 -4
  407. package/src/plugins/wiki/View.vue +463 -254
  408. package/src/plugins/wiki/components/WikiPageBody.vue +159 -0
  409. package/src/plugins/wiki/helpers.ts +17 -0
  410. package/src/plugins/wiki/history/HistoryDetail.vue +325 -0
  411. package/src/plugins/wiki/history/HistoryTab.vue +167 -0
  412. package/src/plugins/wiki/history/RestoreConfirm.vue +63 -0
  413. package/src/plugins/wiki/history/api.ts +52 -0
  414. package/src/plugins/wiki/history/diff.ts +145 -0
  415. package/src/plugins/wiki/index.ts +42 -32
  416. package/src/plugins/wiki/meta.ts +10 -0
  417. package/src/plugins/wiki/pageEditLoader.ts +53 -0
  418. package/src/plugins/wiki/route.ts +8 -0
  419. package/src/router/guards.ts +2 -1
  420. package/src/router/index.ts +19 -0
  421. package/src/router/pageRoutes.ts +1 -0
  422. package/src/tools/index.ts +50 -51
  423. package/src/tools/runtimeLoader.ts +141 -0
  424. package/src/tools/types.ts +44 -1
  425. package/src/types/notification.ts +23 -0
  426. package/src/types/pastedFile.ts +10 -0
  427. package/src/types/session.ts +61 -3
  428. package/src/types/sse.ts +21 -6
  429. package/src/utils/agent/eventDispatch.ts +12 -9
  430. package/src/utils/agent/pastedAttachment.ts +35 -0
  431. package/src/utils/agent/request.ts +32 -3
  432. package/src/utils/agent/toolCalls.ts +7 -1
  433. package/src/utils/api.ts +1 -1
  434. package/src/utils/chat/exportMarkdown.ts +243 -0
  435. package/src/utils/errors.ts +10 -2
  436. package/src/utils/files/expandedDirs.ts +1 -1
  437. package/src/utils/filesPreview/todoPreview.ts +13 -2
  438. package/src/utils/format/date.ts +1 -3
  439. package/src/utils/format/jsonSyntax.ts +5 -0
  440. package/src/utils/html/iframeHeightReporterScript.ts +62 -0
  441. package/src/utils/html/previewCsp.ts +29 -2
  442. package/src/utils/image/htmlSrcAttrs.ts +122 -0
  443. package/src/utils/image/imageRepairInlineScript.ts +115 -0
  444. package/src/utils/image/resolve.ts +17 -3
  445. package/src/utils/image/rewriteMarkdownImageRefs.ts +62 -9
  446. package/src/utils/markdown/frontmatter.ts +125 -0
  447. package/src/utils/markdown/taskList.ts +7 -2
  448. package/src/utils/plugin/runtime.ts +132 -0
  449. package/src/utils/session/mergeSessions.ts +40 -37
  450. package/src/utils/session/sessionEntries.ts +74 -18
  451. package/src/utils/session/sessionHelpers.ts +54 -10
  452. package/src/utils/tools/result.ts +76 -14
  453. package/src/vite-env.d.ts +6 -0
  454. package/client/assets/html2canvas-Cx501zZr-Bug0qRNv.js +0 -5
  455. package/client/assets/index-CY-WpQUm.css +0 -2
  456. package/client/assets/index-DbTz2Mfs.js +0 -4911
  457. package/client/assets/material-symbols-outlined-NzYEeyps.woff2 +0 -0
  458. package/server/api/routes/html.ts +0 -114
  459. package/server/api/routes/todos.ts +0 -293
  460. package/server/api/routes/todosColumnsHandlers.ts +0 -333
  461. package/server/api/routes/todosHandlers.ts +0 -274
  462. package/server/api/routes/todosItemsHandlers.ts +0 -386
  463. package/server/utils/files/todos-io.ts +0 -29
  464. package/src/components/NotificationToast.vue +0 -75
  465. package/src/plugins/editImage/definition.ts +0 -27
  466. package/src/plugins/editImage/index.ts +0 -37
  467. package/src/plugins/presentHtml/helpers.ts +0 -72
  468. package/src/plugins/scheduler/LegacySchedulerView.vue +0 -32
  469. package/src/plugins/scheduler/legacyShape.ts +0 -34
  470. package/src/plugins/todo/Preview.vue +0 -68
  471. package/src/plugins/todo/View.vue +0 -378
  472. package/src/plugins/todo/composables/useTodos.ts +0 -179
  473. package/src/plugins/todo/definition.ts +0 -45
  474. package/src/plugins/todo/index.ts +0 -62
  475. package/src/plugins/todo/labels.ts +0 -163
  476. package/src/plugins/todo/priority.ts +0 -98
  477. package/src/plugins/todo/viewModes.ts +0 -19
  478. package/src/plugins/wiki/definition.ts +0 -25
  479. package/src/tools/legacyPluginNames.ts +0 -13
  480. package/src/utils/format/frontmatter.ts +0 -80
  481. package/src/utils/image/rewriteHtmlImageRefs.ts +0 -50
  482. package/src/utils/notification/dispatch.ts +0 -58
  483. /package/client/assets/{purify.es-Fx1Nqyry-BwJECkqS.js → purify.es-Fx1Nqyry-BSVNht6S.js} +0 -0
  484. /package/src/plugins/{editImage → editImages}/Preview.vue +0 -0
  485. /package/src/plugins/{editImage → editImages}/View.vue +0 -0
  486. /package/src/{config/schedulerActions.ts → plugins/scheduler/actions.ts} +0 -0
@@ -15,30 +15,74 @@
15
15
  <span v-if="filePath" class="truncate">{{ filePath }}</span>
16
16
  </div>
17
17
  </div>
18
- <div class="ml-4 shrink-0 flex gap-2">
19
- <!-- Download Movie -->
20
- <a
18
+ <div class="ml-4 shrink-0 flex items-center gap-2">
19
+ <!-- Play presentation: opens the lightbox at beat 0 and starts
20
+ audio. Same gating as Download Movie — only when a movie has
21
+ been generated, which is our proxy for "every beat has both
22
+ an image and audio on disk". Green outline + green icon
23
+ share the visual idiom with the (filled) Download button so
24
+ both completed-artifact actions read as the same family.
25
+ `isPlayReady` ensures we don't open the lightbox before the
26
+ first beat's image (and audio, if it has text) finish their
27
+ async load — moviePath can be set while loadExistingBeatImage
28
+ is still in flight. -->
29
+ <button
30
+ v-if="moviePath && !movieGenerating"
31
+ class="h-8 w-8 flex items-center justify-center rounded border border-green-600 text-green-600 hover:bg-green-50 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
32
+ :disabled="!isPlayReady"
33
+ :title="t('pluginMulmoScript.playPresentation')"
34
+ :aria-label="t('pluginMulmoScript.playPresentation')"
35
+ @click="playPresentation"
36
+ >
37
+ <span class="material-icons text-base">play_arrow</span>
38
+ </button>
39
+ <!-- Download Movie: bearer-authenticated blob fetch, then a
40
+ synthetic <a download> click. The natural <a href download>
41
+ approach can't attach the Authorization header, which would
42
+ have forced a bearer-auth exemption on the route — the
43
+ reviewer's P1 was that any sibling process could then read
44
+ a caller-controlled movie path. Going through apiFetchRaw
45
+ (auto-attaches bearer) keeps the auth boundary intact. -->
46
+ <button
21
47
  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"
48
+ class="h-8 px-2.5 flex items-center gap-1 rounded bg-green-600 hover:bg-green-700 text-white text-sm disabled:opacity-60 disabled:cursor-not-allowed transition-colors"
49
+ :disabled="movieDownloading"
50
+ data-testid="mulmo-script-download-movie-button"
51
+ @click="downloadMovie"
25
52
  >
26
- <span class="material-icons text-sm leading-none">download</span>
53
+ <span class="material-icons text-base">download</span>
27
54
  <span>{{ t("pluginMulmoScript.movie") }}</span>
28
- </a>
29
- <!-- Generate / Regenerate Movie -->
55
+ </button>
56
+ <!-- Regenerate Movie (icon-only): collapses to a square once a
57
+ movie exists — the adjacent Download / Play already make
58
+ the subject clear, so the "Movie" label only adds noise. -->
59
+ <button
60
+ v-if="moviePath && !movieGenerating"
61
+ class="h-8 w-8 flex items-center justify-center rounded border border-gray-200 text-gray-600 hover:bg-gray-100 transition-colors"
62
+ :title="t('pluginMulmoScript.regenerateMovie')"
63
+ :aria-label="t('pluginMulmoScript.regenerateMovie')"
64
+ data-testid="mulmo-script-regenerate-movie-button"
65
+ @click="generateMovie"
66
+ >
67
+ <span class="material-icons text-base">refresh</span>
68
+ </button>
69
+ <!-- Generate Movie (pill): no movie yet, or one is currently
70
+ generating. Keeps the label so first-time users know what
71
+ they're triggering. -->
30
72
  <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"
73
+ v-else
74
+ class="h-8 px-2.5 flex items-center gap-1 text-sm rounded border border-gray-200 text-gray-600 hover:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
32
75
  :disabled="movieGenerating"
76
+ data-testid="mulmo-script-generate-movie-button"
33
77
  @click="generateMovie"
34
78
  >
35
- <svg v-if="movieGenerating" class="animate-spin w-3 h-3 shrink-0" viewBox="0 0 24 24" fill="none">
79
+ <svg v-if="movieGenerating" class="animate-spin w-4 h-4 shrink-0" viewBox="0 0 24 24" fill="none">
36
80
  <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
37
81
  <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
38
82
  </svg>
39
83
  <span v-if="movieGenerating">{{ t("pluginMulmoScript.generating") }}</span>
40
84
  <template v-else>
41
- <span class="material-icons text-sm leading-none">refresh</span>
85
+ <span class="material-icons text-sm">refresh</span>
42
86
  <span>{{ t("pluginMulmoScript.movie") }}</span>
43
87
  </template>
44
88
  </button>
@@ -216,7 +260,9 @@
216
260
  {{ playingAudio?.index === index ? t("pluginMulmoScript.stop") : t("pluginMulmoScript.play") }}
217
261
  </button>
218
262
  <template v-else-if="audioErrors[index]">
219
- <span class="text-xs text-red-400" :title="audioErrors[index]">{{ t("pluginMulmoScript.errPrefix") }}</span>
263
+ <span class="text-xs text-red-400 truncate min-w-0 max-w-[20rem]" :title="audioErrors[index]">
264
+ {{ t("pluginMulmoScript.errPrefix") }} {{ audioErrors[index] }}
265
+ </span>
220
266
  <button
221
267
  v-if="effectiveBeat(index).text"
222
268
  class="text-xs px-2 py-0.5 rounded border border-gray-300 text-gray-500 hover:bg-gray-50 disabled:opacity-50"
@@ -234,7 +280,12 @@
234
280
  {{ t("pluginMulmoScript.generateAudio") }}
235
281
  </button>
236
282
  </div>
237
- <button class="text-gray-400 hover:text-gray-600" :title="sourceOpen[index] ? 'Hide source' : 'Show source'" @click="toggleSource(index)">
283
+ <button
284
+ class="text-gray-400 hover:text-gray-600"
285
+ :title="sourceOpen[index] ? 'Hide source' : 'Show source'"
286
+ :data-testid="`mulmo-script-beat-source-toggle-${index}`"
287
+ @click="toggleSource(index)"
288
+ >
238
289
  <svg
239
290
  xmlns="http://www.w3.org/2000/svg"
240
291
  class="w-3.5 h-3.5"
@@ -261,6 +312,7 @@
261
312
  :class="isValidBeat(index) ? 'outline-none' : 'outline outline-2 outline-red-400'"
262
313
  rows="8"
263
314
  spellcheck="false"
315
+ :data-testid="`mulmo-script-beat-source-textarea-${index}`"
264
316
  />
265
317
  <div class="flex items-center justify-end gap-2 px-2 pb-2">
266
318
  <span v-if="beatSaveErrors[index]" class="text-xs text-red-600" role="alert">{{
@@ -276,6 +328,7 @@
276
328
  : 'border-gray-200 text-gray-300 cursor-not-allowed'
277
329
  "
278
330
  :disabled="!isValidBeat(index) || !!beatSaving[index]"
331
+ :data-testid="`mulmo-script-beat-update-button-${index}`"
279
332
  @click="updateBeat(index)"
280
333
  >
281
334
  {{ beatSaving[index] ? t("pluginMulmoScript.saving") : t("pluginMulmoScript.update") }}
@@ -308,42 +361,74 @@
308
361
  </div>
309
362
 
310
363
  <!-- Lightbox -->
311
- <div v-if="lightbox" class="fixed inset-0 z-50 flex items-center justify-center bg-black/80" @click="lightbox = null">
312
- <div class="flex items-center gap-4" @click.stop>
313
- <button
314
- v-if="!lightbox.isCharacter"
315
- class="text-white/60 hover:text-white disabled:opacity-20 text-4xl leading-none"
316
- :disabled="!hasPrev"
317
- @click="lightboxMove(-1)"
318
- >
319
-
320
- </button>
321
- <div class="flex flex-col items-center gap-3">
322
- <img :src="lightbox.src" class="max-w-[80vw] max-h-[80vh] object-contain rounded shadow-2xl" />
323
- <div class="flex items-center gap-4">
324
- <p v-if="lightbox.text" class="max-w-[80vw] text-center text-white text-2xl leading-relaxed">
325
- {{ lightbox.text }}
326
- </p>
327
- <button
328
- v-if="beatAudios[lightbox.index]"
329
- class="shrink-0 text-sm px-3 py-1 rounded border"
330
- :class="
331
- 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'
332
- "
333
- @click="playAudio(lightbox.index)"
334
- >
335
- {{ playingAudio?.index === lightbox.index ? t("pluginMulmoScript.stop") : t("pluginMulmoScript.play") }}
336
- </button>
364
+ <div v-if="lightbox" class="fixed inset-0 z-50 bg-black/80 overflow-y-auto" @click="closeLightbox">
365
+ <button class="fixed top-2 right-4 z-10 text-white/60 hover:text-white text-3xl leading-none" :title="t('common.close')" @click.stop="closeLightbox">
366
+
367
+ </button>
368
+ <div class="flex flex-col items-center gap-4 pt-4 pb-8" @click.stop>
369
+ <div class="flex items-center gap-4">
370
+ <button
371
+ v-if="!lightbox.isCharacter"
372
+ class="text-white/60 hover:text-white disabled:opacity-20 text-5xl leading-none"
373
+ :disabled="!hasPrev"
374
+ @click="lightboxMove(-1)"
375
+ >
376
+
377
+ </button>
378
+ <div class="flex flex-col items-center">
379
+ <img :src="lightbox.src" class="max-w-[80vw] max-h-[85vh] object-contain rounded shadow-2xl" />
380
+ <div v-if="!lightbox.isCharacter && beats.length > 1" class="relative w-full h-1">
381
+ <div class="flex gap-1 h-full">
382
+ <div
383
+ v-for="i in beats.length"
384
+ :key="i - 1"
385
+ class="group flex-1 cursor-pointer relative transition-colors"
386
+ :class="
387
+ i - 1 === lightbox.index
388
+ ? 'bg-white/80 hover:bg-white'
389
+ : i - 1 < lightbox.index
390
+ ? 'bg-white/40 hover:bg-white/60'
391
+ : 'bg-white/20 hover:bg-white/40'
392
+ "
393
+ @click="jumpToBeat(i - 1)"
394
+ >
395
+ <span class="absolute -inset-y-3 inset-x-0" />
396
+ <div
397
+ v-if="beatTooltip(i - 1)"
398
+ class="absolute bottom-full mb-2 left-1/2 -translate-x-1/2 z-20 px-2 py-1 rounded bg-black/90 text-white text-xs leading-tight w-48 max-h-[53px] overflow-hidden opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity"
399
+ >
400
+ {{ beatTooltip(i - 1) }}
401
+ </div>
402
+ </div>
403
+ </div>
404
+ <div
405
+ v-if="playingAudio && playingAudio.index === lightbox.index"
406
+ class="absolute top-1/2 w-3.5 h-3.5 rounded-full bg-white shadow ring-2 ring-black/30 -translate-y-1/2 -translate-x-1/2 pointer-events-none"
407
+ :style="{ left: `${((lightbox.index + audioProgress) / beats.length) * 100}%` }"
408
+ />
409
+ </div>
337
410
  </div>
411
+ <button
412
+ v-if="!lightbox.isCharacter"
413
+ class="text-white/60 hover:text-white disabled:opacity-20 text-5xl leading-none"
414
+ :disabled="!hasNext"
415
+ @click="lightboxMove(1)"
416
+ >
417
+
418
+ </button>
419
+ </div>
420
+ <div v-if="lightbox.text || beatAudios[lightbox.index]" class="relative w-screen flex justify-center px-16">
421
+ <p v-if="lightbox.text" class="max-w-[80vw] text-center text-white leading-relaxed text-[clamp(0.8rem,1.76vw,1.6rem)]">
422
+ {{ lightbox.text }}
423
+ </p>
424
+ <button
425
+ v-if="beatAudios[lightbox.index]"
426
+ class="absolute top-0 right-4 text-sm px-3 py-1 rounded border border-white/60 text-white/60 hover:bg-white/20"
427
+ @click="playAudio(lightbox.index)"
428
+ >
429
+ {{ playingAudio?.index === lightbox.index ? t("pluginMulmoScript.stop") : t("pluginMulmoScript.play") }}
430
+ </button>
338
431
  </div>
339
- <button
340
- v-if="!lightbox.isCharacter"
341
- class="text-white/60 hover:text-white disabled:opacity-20 text-4xl leading-none"
342
- :disabled="!hasNext"
343
- @click="lightboxMove(1)"
344
- >
345
-
346
- </button>
347
432
  </div>
348
433
  </div>
349
434
  </div>
@@ -353,18 +438,22 @@
353
438
  import { computed, onMounted, reactive, ref, watch } from "vue";
354
439
  import { useI18n } from "vue-i18n";
355
440
  import type { ToolResultComplete } from "gui-chat-protocol/vue";
356
-
357
- const { t } = useI18n();
358
441
  import type { MulmoScriptData } from "./index";
359
442
  import { mulmoBeatSchema, mulmoScriptSchema } from "@mulmocast/types";
360
443
  import { extractErrorMessage, getMissingCharacterKeys, shouldAutoRenderBeat, streamMovieEvents, validateBeatJSON } from "./helpers";
361
444
  import { apiGet, apiPost, apiFetchRaw } from "../../utils/api";
362
- import { API_ROUTES } from "../../config/apiRoutes";
445
+ import { pluginEndpoints } from "../api";
446
+ import type { MulmoScriptEndpoints } from "./definition";
363
447
  import { errorMessage } from "../../utils/errors";
364
448
  import { useClipboardCopy } from "../../composables/useClipboardCopy";
365
449
  import { useActiveSession } from "../../composables/useActiveSession";
366
450
  import { GENERATION_KINDS, type PendingGeneration } from "../../types/events";
367
451
 
452
+ const endpoints = pluginEndpoints<MulmoScriptEndpoints>("mulmoScript");
453
+ const filesEndpoints = pluginEndpoints<{ content: string }>("files");
454
+
455
+ const { t } = useI18n();
456
+
368
457
  interface Beat {
369
458
  speaker?: string;
370
459
  text?: string;
@@ -401,10 +490,6 @@ const script = computed<MulmoScript>(() => data.value?.script ?? {});
401
490
  const filePath = computed(() => data.value?.filePath ?? "");
402
491
  const beats = computed<Beat[]>(() => script.value.beats ?? []);
403
492
 
404
- // Exposed to the template so the `<a :href="...">` download button
405
- // can compose a query-string URL without inlining the API path.
406
- const downloadMovieBase = API_ROUTES.mulmoScript.downloadMovie;
407
-
408
493
  // Per-beat render state
409
494
  type RenderState = "idle" | "rendering" | "done" | "error";
410
495
  const renderState = reactive<Record<number, RenderState>>({});
@@ -416,16 +501,21 @@ const sourceText = reactive<Record<number, string>>({});
416
501
  // the Update button. Cleared on next successful save or editor close.
417
502
  // Store raw error + kind tag so the template picks a localized key,
418
503
  // instead of pre-composing an English-prefixed string here.
419
- type BeatSaveError = { kind: "invalidJson" | "saveFailed"; error: string };
504
+ interface BeatSaveError {
505
+ kind: "invalidJson" | "saveFailed";
506
+ error: string;
507
+ }
420
508
  const beatSaveErrors = reactive<Record<number, BeatSaveError>>({});
421
509
  const beatSaving = reactive<Record<number, boolean>>({});
422
510
  const localOverrides = reactive<Record<number, Beat>>({});
423
511
  const movieGenerating = ref(false);
512
+ const movieDownloading = ref(false);
424
513
  const moviePath = ref<string | null>(null);
425
514
  const beatAudios = reactive<Record<number, string>>({});
426
515
  const audioState = reactive<Record<number, "generating" | "done" | "error">>({});
427
516
  const audioErrors = reactive<Record<number, string>>({});
428
517
  const playingAudio = ref<{ index: number; audio: HTMLAudioElement } | null>(null);
518
+ const audioProgress = ref(0);
429
519
  const beatListEl = ref<HTMLElement | null>(null);
430
520
  const lightbox = ref<{
431
521
  src: string;
@@ -474,11 +564,15 @@ function characterPrompt(key: string): string {
474
564
  return (script.value.imageParams?.images?.[key]?.prompt as string) ?? "";
475
565
  }
476
566
 
567
+ function stopPlayingAudio() {
568
+ if (!playingAudio.value) return;
569
+ playingAudio.value.audio.pause();
570
+ playingAudio.value = null;
571
+ audioProgress.value = 0;
572
+ }
573
+
477
574
  function openLightbox(index: number) {
478
- if (playingAudio.value) {
479
- playingAudio.value.audio.pause();
480
- playingAudio.value = null;
481
- }
575
+ stopPlayingAudio();
482
576
  lightbox.value = {
483
577
  src: renderedImages[index],
484
578
  text: effectiveBeat(index).text,
@@ -486,6 +580,44 @@ function openLightbox(index: number) {
486
580
  };
487
581
  }
488
582
 
583
+ // Backdrop click handler. Stops any in-flight narration so the audio
584
+ // doesn't keep playing after the lightbox is dismissed — without this,
585
+ // the HTMLAudioElement created by playAudio() outlives the modal and
586
+ // the user hears disembodied narration with no UI to stop it.
587
+ function closeLightbox() {
588
+ stopPlayingAudio();
589
+ lightbox.value = null;
590
+ }
591
+
592
+ // "Play presentation" toolbar action. Opens the lightbox at beat 0 and
593
+ // kicks off its narration audio; the existing on-ended hook then chains
594
+ // through the rest of the deck (lightboxMove(1) → playAudio if the next
595
+ // beat has audio), so one click runs the whole presentation. Only wired
596
+ // to the toolbar button when moviePath is set, which is our proxy for
597
+ // "every beat has both image and audio on disk".
598
+ //
599
+ // `moviePath` arrives synchronously from /movie-status, but the per-beat
600
+ // image and audio data URIs are populated asynchronously by
601
+ // loadExistingBeatImage / loadExistingBeatAudio in initializeScript().
602
+ // The Play button can therefore become visible before beat 0's assets
603
+ // hydrate — `isPlayReady` gates the click so the lightbox never opens
604
+ // with an undefined src or silent narration on a beat that does have
605
+ // text.
606
+ const isPlayReady = computed<boolean>(() => {
607
+ if (beats.value.length === 0) return false;
608
+ if (!renderedImages[0]) return false;
609
+ // Audio is only required when the beat has text (the source of TTS).
610
+ // Beats without text are valid; they just play silently.
611
+ if (effectiveBeat(0).text && !beatAudios[0]) return false;
612
+ return true;
613
+ });
614
+
615
+ function playPresentation() {
616
+ if (!isPlayReady.value) return;
617
+ openLightbox(0);
618
+ if (beatAudios[0]) playAudio(0);
619
+ }
620
+
489
621
  const hasPrev = computed(() => {
490
622
  if (!lightbox.value) return false;
491
623
  for (let i = lightbox.value.index - 1; i >= 0; i--) {
@@ -502,13 +634,39 @@ const hasNext = computed(() => {
502
634
  return false;
503
635
  });
504
636
 
637
+ function jumpToBeat(index: number) {
638
+ if (!lightbox.value) return;
639
+ if (index === lightbox.value.index) return;
640
+ if (!renderedImages[index]) return;
641
+ const wasPlaying = playingAudio.value !== null;
642
+ openLightbox(index);
643
+ if (wasPlaying && beatAudios[index]) {
644
+ playAudio(index);
645
+ }
646
+ }
647
+
648
+ function beatTooltip(index: number): string {
649
+ const text = effectiveBeat(index).text ?? "";
650
+ return text.length > 80 ? `${text.slice(0, 80)}…` : text;
651
+ }
652
+
505
653
  function lightboxMove(delta: number) {
506
654
  if (!lightbox.value) return;
507
655
  const total = beats.value.length;
656
+ // If audio was playing when the user clicked the arrow, carry the
657
+ // playback over to the next beat that has audio. openLightbox()
658
+ // unconditionally stops any active audio, so we capture the flag
659
+ // BEFORE that and replay AFTER. The on-ended auto-advance path
660
+ // already nulls playingAudio before calling lightboxMove, so this
661
+ // branch won't double-fire there.
662
+ const wasPlaying = playingAudio.value !== null;
508
663
  let i = lightbox.value.index + delta;
509
664
  while (i >= 0 && i < total) {
510
665
  if (renderedImages[i]) {
511
666
  openLightbox(i);
667
+ if (wasPlaying && beatAudios[i]) {
668
+ playAudio(i);
669
+ }
512
670
  return;
513
671
  }
514
672
  i += delta;
@@ -545,7 +703,7 @@ async function onSourceToggle(open: boolean) {
545
703
  let text = scriptSourceText.value;
546
704
  // Read the current file from disk so beat-level edits are reflected
547
705
  if (filePath.value) {
548
- const response = await apiGet<{ content?: string }>(API_ROUTES.files.content, { path: filePath.value });
706
+ const response = await apiGet<{ content?: string }>(filesEndpoints.content, { path: filePath.value });
549
707
  if (response.ok && response.data.content) {
550
708
  text = response.data.content;
551
709
  }
@@ -568,7 +726,7 @@ async function applySource() {
568
726
  alert(extractErrorMessage(err));
569
727
  return;
570
728
  }
571
- const response = await apiPost<unknown>(API_ROUTES.mulmoScript.updateScript, {
729
+ const response = await apiPost<unknown>(endpoints.updateScript.url, {
572
730
  filePath: filePath.value,
573
731
  script: parsed,
574
732
  });
@@ -602,7 +760,7 @@ function effectiveBeat(index: number): Beat {
602
760
  function toggleSource(index: number) {
603
761
  if (!sourceOpen[index]) {
604
762
  sourceText[index] = JSON.stringify(effectiveBeat(index), null, 2);
605
- delete beatSaveErrors[index];
763
+ Reflect.deleteProperty(beatSaveErrors, index);
606
764
  }
607
765
  sourceOpen[index] = !sourceOpen[index];
608
766
  }
@@ -621,14 +779,14 @@ async function updateBeat(index: number) {
621
779
  }
622
780
  const prevImage = JSON.stringify(effectiveBeat(index).image);
623
781
 
624
- delete beatSaveErrors[index];
782
+ Reflect.deleteProperty(beatSaveErrors, index);
625
783
  beatSaving[index] = true;
626
- const response = await apiPost<unknown>(API_ROUTES.mulmoScript.updateBeat, {
784
+ const response = await apiPost<unknown>(endpoints.updateBeat.url, {
627
785
  filePath: filePath.value,
628
786
  beatIndex: index,
629
787
  beat,
630
788
  });
631
- delete beatSaving[index];
789
+ Reflect.deleteProperty(beatSaving, index);
632
790
  if (!response.ok) {
633
791
  beatSaveErrors[index] = { kind: "saveFailed", error: response.error };
634
792
  return;
@@ -638,14 +796,14 @@ async function updateBeat(index: number) {
638
796
  sourceOpen[index] = false;
639
797
 
640
798
  if (JSON.stringify(beat.image) !== prevImage) {
641
- delete renderedImages[index];
799
+ Reflect.deleteProperty(renderedImages, index);
642
800
  renderBeat(index);
643
801
  }
644
802
  }
645
803
 
646
804
  async function renderBeat(index: number) {
647
805
  renderState[index] = "rendering";
648
- const response = await apiPost<{ image?: string; error?: string }>(API_ROUTES.mulmoScript.renderBeat, {
806
+ const response = await apiPost<{ image?: string; error?: string }>(endpoints.renderBeat.url, {
649
807
  filePath: filePath.value,
650
808
  beatIndex: index,
651
809
  chatSessionId: chatSessionId.value,
@@ -666,9 +824,9 @@ async function renderBeat(index: number) {
666
824
  }
667
825
 
668
826
  async function regenerateBeat(index: number) {
669
- delete renderedImages[index];
827
+ Reflect.deleteProperty(renderedImages, index);
670
828
  renderState[index] = "rendering";
671
- const response = await apiPost<{ image?: string; error?: string }>(API_ROUTES.mulmoScript.renderBeat, {
829
+ const response = await apiPost<{ image?: string; error?: string }>(endpoints.renderBeat.url, {
672
830
  filePath: filePath.value,
673
831
  beatIndex: index,
674
832
  force: true,
@@ -689,7 +847,7 @@ async function regenerateBeat(index: number) {
689
847
  }
690
848
 
691
849
  async function loadExistingBeatImage(index: number) {
692
- const response = await apiGet<{ image?: string }>(API_ROUTES.mulmoScript.beatImage, { filePath: filePath.value, beatIndex: String(index) });
850
+ const response = await apiGet<{ image?: string }>(endpoints.beatImage.url, { filePath: filePath.value, beatIndex: String(index) });
693
851
  // silently ignore errors — image simply hasn't been generated yet
694
852
  if (response.ok && response.data.image) {
695
853
  renderedImages[index] = response.data.image;
@@ -698,7 +856,7 @@ async function loadExistingBeatImage(index: number) {
698
856
  }
699
857
 
700
858
  async function loadExistingBeatAudio(index: number) {
701
- const response = await apiGet<{ audio?: string }>(API_ROUTES.mulmoScript.beatAudio, { filePath: filePath.value, beatIndex: String(index) });
859
+ const response = await apiGet<{ audio?: string }>(endpoints.beatAudio.url, { filePath: filePath.value, beatIndex: String(index) });
702
860
  // silently ignore errors
703
861
  if (response.ok && response.data.audio) {
704
862
  beatAudios[index] = response.data.audio;
@@ -708,8 +866,8 @@ async function loadExistingBeatAudio(index: number) {
708
866
 
709
867
  async function generateAudio(index: number) {
710
868
  audioState[index] = "generating";
711
- delete audioErrors[index];
712
- const response = await apiPost<{ audio?: string; error?: string }>(API_ROUTES.mulmoScript.generateBeatAudio, {
869
+ Reflect.deleteProperty(audioErrors, index);
870
+ const response = await apiPost<{ audio?: string; error?: string }>(endpoints.generateBeatAudio.url, {
713
871
  filePath: filePath.value,
714
872
  beatIndex: index,
715
873
  chatSessionId: chatSessionId.value,
@@ -739,9 +897,15 @@ function playAudio(index: number) {
739
897
  if (!src) return;
740
898
  const audio = new Audio(src);
741
899
  playingAudio.value = { index, audio };
900
+ audioProgress.value = 0;
901
+ audio.addEventListener("timeupdate", () => {
902
+ if (playingAudio.value?.index !== index) return;
903
+ if (audio.duration > 0) audioProgress.value = audio.currentTime / audio.duration;
904
+ });
742
905
  audio.addEventListener("ended", () => {
743
906
  if (playingAudio.value?.index !== index) return;
744
907
  playingAudio.value = null;
908
+ audioProgress.value = 0;
745
909
  if (lightbox.value?.index === index) {
746
910
  lightboxMove(1);
747
911
  const nextIndex = lightbox.value?.index;
@@ -770,7 +934,7 @@ async function onBeatDrop(event: DragEvent, index: number) {
770
934
  if (!file || !file.type.startsWith("image/")) return;
771
935
 
772
936
  renderState[index] = "rendering";
773
- delete renderErrors[index];
937
+ Reflect.deleteProperty(renderErrors, index);
774
938
  let imageData: string;
775
939
  try {
776
940
  imageData = await new Promise<string>((resolve, reject) => {
@@ -784,7 +948,7 @@ async function onBeatDrop(event: DragEvent, index: number) {
784
948
  renderState[index] = "error";
785
949
  return;
786
950
  }
787
- const response = await apiPost<{ image?: string; error?: string }>(API_ROUTES.mulmoScript.uploadBeatImage, {
951
+ const response = await apiPost<{ image?: string; error?: string }>(endpoints.uploadBeatImage.url, {
788
952
  filePath: filePath.value,
789
953
  beatIndex: index,
790
954
  imageData,
@@ -820,7 +984,7 @@ async function onCharDrop(event: DragEvent, key: string) {
820
984
  if (!file || !file.type.startsWith("image/")) return;
821
985
 
822
986
  charRenderState[key] = "rendering";
823
- delete charErrors[key];
987
+ Reflect.deleteProperty(charErrors, key);
824
988
  let imageData: string;
825
989
  try {
826
990
  imageData = await new Promise<string>((resolve, reject) => {
@@ -834,7 +998,7 @@ async function onCharDrop(event: DragEvent, key: string) {
834
998
  charRenderState[key] = "error";
835
999
  return;
836
1000
  }
837
- const response = await apiPost<{ image?: string; error?: string }>(API_ROUTES.mulmoScript.uploadCharacterImage, { filePath: filePath.value, key, imageData });
1001
+ const response = await apiPost<{ image?: string; error?: string }>(endpoints.uploadCharacterImage.url, { filePath: filePath.value, key, imageData });
838
1002
  if (!response.ok) {
839
1003
  charErrors[key] = response.error || "Upload failed";
840
1004
  charRenderState[key] = "error";
@@ -863,7 +1027,7 @@ function openCharacterLightbox(key: string) {
863
1027
  }
864
1028
 
865
1029
  async function loadExistingCharacterImage(key: string) {
866
- const response = await apiGet<{ image?: string }>(API_ROUTES.mulmoScript.characterImage, { filePath: filePath.value, key });
1030
+ const response = await apiGet<{ image?: string }>(endpoints.characterImage.url, { filePath: filePath.value, key });
867
1031
  // silently ignore errors
868
1032
  if (response.ok && response.data.image) {
869
1033
  charImages[key] = response.data.image;
@@ -877,8 +1041,8 @@ function refreshMissingCharacterImages() {
877
1041
 
878
1042
  async function renderCharacter(key: string, force: boolean) {
879
1043
  charRenderState[key] = "rendering";
880
- delete charErrors[key];
881
- const response = await apiPost<{ image?: string; error?: string }>(API_ROUTES.mulmoScript.renderCharacter, {
1044
+ Reflect.deleteProperty(charErrors, key);
1045
+ const response = await apiPost<{ image?: string; error?: string }>(endpoints.renderCharacter.url, {
882
1046
  filePath: filePath.value,
883
1047
  key,
884
1048
  force,
@@ -902,43 +1066,65 @@ async function generateAllCharacters() {
902
1066
  await Promise.all(characterKeys.value.filter((key) => charRenderState[key] !== "rendering").map((key) => renderCharacter(key, false)));
903
1067
  }
904
1068
 
1069
+ // Probe the server for an existing beat PNG before triggering any
1070
+ // generation. Only auto-renders when the disk is empty AND the beat
1071
+ // is a deterministic type — imagePrompt beats are left empty so the
1072
+ // user clicks Generate explicitly (avoids surprise paid text2image
1073
+ // calls on every page refresh).
1074
+ async function hydrateBeatImage(beat: Beat, index: number, hasCharacters: boolean, autoRenderTypes: readonly string[]): Promise<void> {
1075
+ await loadExistingBeatImage(index);
1076
+ if (renderedImages[index]) return;
1077
+ if (shouldAutoRenderBeat(beat, hasCharacters, autoRenderTypes)) {
1078
+ await renderBeat(index);
1079
+ }
1080
+ }
1081
+
905
1082
  async function initializeScript() {
906
1083
  // Reset scroll position so new results start at the top
907
1084
  if (beatListEl.value) beatListEl.value.scrollTop = 0;
908
1085
  // Reset per-script state
909
- Object.keys(renderState).forEach((key) => delete renderState[+key]);
910
- Object.keys(renderedImages).forEach((key) => delete renderedImages[+key]);
911
- Object.keys(renderErrors).forEach((key) => delete renderErrors[+key]);
912
- Object.keys(sourceOpen).forEach((key) => delete sourceOpen[+key]);
913
- Object.keys(sourceText).forEach((key) => delete sourceText[+key]);
914
- Object.keys(beatSaveErrors).forEach((key) => delete beatSaveErrors[+key]);
915
- Object.keys(beatSaving).forEach((key) => delete beatSaving[+key]);
916
- Object.keys(localOverrides).forEach((key) => delete localOverrides[+key]);
917
- Object.keys(beatAudios).forEach((key) => delete beatAudios[+key]);
918
- Object.keys(audioState).forEach((key) => delete audioState[+key]);
919
- Object.keys(audioErrors).forEach((key) => delete audioErrors[+key]);
920
- Object.keys(charRenderState).forEach((key) => delete charRenderState[key]);
921
- Object.keys(charImages).forEach((key) => delete charImages[key]);
922
- Object.keys(charErrors).forEach((key) => delete charErrors[key]);
923
- Object.keys(beatDragOver).forEach((key) => delete beatDragOver[+key]);
1086
+ Object.keys(renderState).forEach((key) => Reflect.deleteProperty(renderState, key));
1087
+ Object.keys(renderedImages).forEach((key) => Reflect.deleteProperty(renderedImages, key));
1088
+ Object.keys(renderErrors).forEach((key) => Reflect.deleteProperty(renderErrors, key));
1089
+ Object.keys(sourceOpen).forEach((key) => Reflect.deleteProperty(sourceOpen, key));
1090
+ Object.keys(sourceText).forEach((key) => Reflect.deleteProperty(sourceText, key));
1091
+ Object.keys(beatSaveErrors).forEach((key) => Reflect.deleteProperty(beatSaveErrors, key));
1092
+ Object.keys(beatSaving).forEach((key) => Reflect.deleteProperty(beatSaving, key));
1093
+ Object.keys(localOverrides).forEach((key) => Reflect.deleteProperty(localOverrides, key));
1094
+ Object.keys(beatAudios).forEach((key) => Reflect.deleteProperty(beatAudios, key));
1095
+ Object.keys(audioState).forEach((key) => Reflect.deleteProperty(audioState, key));
1096
+ Object.keys(audioErrors).forEach((key) => Reflect.deleteProperty(audioErrors, key));
1097
+ Object.keys(charRenderState).forEach((key) => Reflect.deleteProperty(charRenderState, key));
1098
+ Object.keys(charImages).forEach((key) => Reflect.deleteProperty(charImages, key));
1099
+ Object.keys(charErrors).forEach((key) => Reflect.deleteProperty(charErrors, key));
1100
+ Object.keys(beatDragOver).forEach((key) => Reflect.deleteProperty(beatDragOver, key));
924
1101
  moviePath.value = null;
925
1102
  if (sourceDetails.value) sourceDetails.value.open = false;
926
1103
 
1104
+ // Mount-time policy: prefer the existing PNG on the server. Every
1105
+ // beat — deterministic AND imagePrompt — first probes /beat-image,
1106
+ // and we only fall through to renderBeat() when the disk has nothing
1107
+ // yet AND the type is safe to auto-render (deterministic content,
1108
+ // no characters waiting). Without this probe a refresh would re-fire
1109
+ // generateBeatImage for every beat, and for imagePrompt beats that
1110
+ // means a paid text2image call against an image we already have.
1111
+ //
1112
+ // Stale-after-edit: if the user edits the script source the on-disk
1113
+ // PNG is no longer in sync with the new content, but we don't try to
1114
+ // detect that here — the per-beat ↺ button is one click away and a
1115
+ // page refresh re-runs this same probe, so the user can opt back into
1116
+ // a fresh render whenever they need to.
927
1117
  const AUTO_RENDER_TYPES = ["textSlide", "markdown", "chart", "mermaid", "html_tailwind"] as const;
928
1118
  const hasCharacters = characterKeys.value.length > 0;
929
1119
  beats.value.forEach((beat, index) => {
930
- if (shouldAutoRenderBeat(beat, hasCharacters, AUTO_RENDER_TYPES)) {
931
- renderBeat(index);
932
- } else if (beat.imagePrompt) {
933
- loadExistingBeatImage(index);
934
- }
1120
+ void hydrateBeatImage(beat, index, hasCharacters, AUTO_RENDER_TYPES);
935
1121
  if (beat.text) loadExistingBeatAudio(index);
936
1122
  });
937
1123
 
938
1124
  characterKeys.value.forEach((key) => loadExistingCharacterImage(key));
939
1125
 
940
1126
  if (filePath.value) {
941
- const response = await apiGet<{ moviePath?: string }>(API_ROUTES.mulmoScript.movieStatus, { filePath: filePath.value });
1127
+ const response = await apiGet<{ moviePath?: string }>(endpoints.movieStatus.url, { filePath: filePath.value });
942
1128
  if (response.ok && response.data.moviePath) {
943
1129
  moviePath.value = response.data.moviePath;
944
1130
  }
@@ -994,15 +1180,15 @@ async function reflectGenerationFinish(entry: PendingGeneration): Promise<void>
994
1180
  if (entry.kind === GENERATION_KINDS.beatImage) {
995
1181
  const idx = Number(entry.key);
996
1182
  await loadExistingBeatImage(idx);
997
- if (renderState[idx] === "rendering") delete renderState[idx];
1183
+ if (renderState[idx] === "rendering") Reflect.deleteProperty(renderState, idx);
998
1184
  } else if (entry.kind === GENERATION_KINDS.beatAudio) {
999
1185
  const idx = Number(entry.key);
1000
1186
  await loadExistingBeatAudio(idx);
1001
- if (audioState[idx] === "generating") delete audioState[idx];
1187
+ if (audioState[idx] === "generating") Reflect.deleteProperty(audioState, idx);
1002
1188
  } else if (entry.kind === GENERATION_KINDS.characterImage) {
1003
1189
  await loadExistingCharacterImage(entry.key);
1004
1190
  if (charRenderState[entry.key] === "rendering") {
1005
- delete charRenderState[entry.key];
1191
+ Reflect.deleteProperty(charRenderState, entry.key);
1006
1192
  }
1007
1193
  } else if (entry.kind === GENERATION_KINDS.movie) {
1008
1194
  movieGenerating.value = false;
@@ -1012,7 +1198,7 @@ async function reflectGenerationFinish(entry: PendingGeneration): Promise<void>
1012
1198
 
1013
1199
  async function refreshMoviePath(): Promise<void> {
1014
1200
  if (!filePath.value) return;
1015
- const response = await apiGet<{ moviePath?: string }>(API_ROUTES.mulmoScript.movieStatus, { filePath: filePath.value });
1201
+ const response = await apiGet<{ moviePath?: string }>(endpoints.movieStatus.url, { filePath: filePath.value });
1016
1202
  if (response.ok && response.data.moviePath) {
1017
1203
  moviePath.value = response.data.moviePath;
1018
1204
  }
@@ -1021,7 +1207,7 @@ async function refreshMoviePath(): Promise<void> {
1021
1207
  async function generateMovie() {
1022
1208
  movieGenerating.value = true;
1023
1209
  try {
1024
- const res = await apiFetchRaw(API_ROUTES.mulmoScript.generateMovie, {
1210
+ const res = await apiFetchRaw(endpoints.generateMovie.url, {
1025
1211
  method: "POST",
1026
1212
  body: JSON.stringify({
1027
1213
  filePath: filePath.value,
@@ -1046,6 +1232,40 @@ async function generateMovie() {
1046
1232
  movieGenerating.value = false;
1047
1233
  }
1048
1234
  }
1235
+
1236
+ // Bearer-authenticated movie download. apiFetchRaw auto-attaches the
1237
+ // Authorization header (which a plain `<a href download>` cannot), so
1238
+ // the route stays behind the standard /api/* bearer guard. The blob
1239
+ // is hooked to a synthetic anchor whose `download` attribute carries
1240
+ // the filename — the browser still surfaces a native save dialog.
1241
+ async function downloadMovie() {
1242
+ if (!moviePath.value || movieDownloading.value) return;
1243
+ movieDownloading.value = true;
1244
+ let objectUrl: string | null = null;
1245
+ try {
1246
+ const res = await apiFetchRaw(endpoints.downloadMovie.url, {
1247
+ method: "GET",
1248
+ query: { moviePath: moviePath.value },
1249
+ });
1250
+ if (!res.ok) {
1251
+ throw new Error(`HTTP ${res.status}`);
1252
+ }
1253
+ const blob = await res.blob();
1254
+ objectUrl = URL.createObjectURL(blob);
1255
+ const filename = moviePath.value.split("/").pop() ?? "movie.mp4";
1256
+ const anchor = document.createElement("a");
1257
+ anchor.href = objectUrl;
1258
+ anchor.download = filename;
1259
+ document.body.appendChild(anchor);
1260
+ anchor.click();
1261
+ anchor.remove();
1262
+ } catch (err) {
1263
+ alert(extractErrorMessage(err));
1264
+ } finally {
1265
+ if (objectUrl) URL.revokeObjectURL(objectUrl);
1266
+ movieDownloading.value = false;
1267
+ }
1268
+ }
1049
1269
  </script>
1050
1270
 
1051
1271
  <style scoped>