mulmoclaude 0.3.0 → 0.5.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 (312) hide show
  1. package/bin/mulmoclaude.js +7 -24
  2. package/client/assets/html2canvas-Cx501zZr-DiKaqnKs.js +5 -0
  3. package/client/assets/{index-eHWB79u5.js → index-C94GcmNa.js} +203 -198
  4. package/client/assets/index-CY-WpQUm.css +2 -0
  5. package/client/assets/{index.es-D4YyL_Dg-BfRHLTZV.js → index.es-D4YyL_Dg-5ipqh8Pe.js} +5 -5
  6. package/client/assets/material-symbols-outlined-NzYEeyps.woff2 +0 -0
  7. package/client/index.html +2 -4
  8. package/package.json +17 -15
  9. package/server/agent/attachmentConverter.ts +2 -2
  10. package/server/agent/backend/claude-code.ts +170 -0
  11. package/server/agent/backend/index.ts +14 -0
  12. package/server/agent/backend/types.ts +65 -0
  13. package/server/agent/index.ts +31 -159
  14. package/server/agent/mcp-server.ts +88 -10
  15. package/server/agent/mcp-tools/index.ts +8 -7
  16. package/server/agent/mcp-tools/notify.ts +76 -0
  17. package/server/agent/mcp-tools/x.ts +12 -2
  18. package/server/agent/plugin-names.ts +10 -4
  19. package/server/agent/prompt.ts +187 -26
  20. package/server/agent/resumeFailover.ts +5 -5
  21. package/server/agent/sandboxMounts.ts +3 -3
  22. package/server/api/auth/bearerAuth.ts +3 -3
  23. package/server/api/auth/token.ts +2 -2
  24. package/server/api/routes/agent.ts +99 -4
  25. package/server/api/routes/chart.ts +13 -0
  26. package/server/api/routes/chat-index.ts +2 -1
  27. package/server/api/routes/config.ts +35 -8
  28. package/server/api/routes/files.ts +75 -24
  29. package/server/api/routes/html.ts +15 -2
  30. package/server/api/routes/image.ts +75 -20
  31. package/server/api/routes/mulmo-script.ts +33 -31
  32. package/server/api/routes/news.ts +146 -0
  33. package/server/api/routes/notifications.ts +58 -2
  34. package/server/api/routes/pdf.ts +2 -2
  35. package/server/api/routes/plugins.ts +73 -91
  36. package/server/api/routes/presentHtml.ts +9 -0
  37. package/server/api/routes/roles.ts +12 -2
  38. package/server/api/routes/scheduler.ts +20 -11
  39. package/server/api/routes/schedulerTasks.ts +58 -21
  40. package/server/api/routes/sessions.ts +15 -4
  41. package/server/api/routes/sessionsCursor.ts +4 -4
  42. package/server/api/routes/skills.ts +26 -5
  43. package/server/api/routes/sources.ts +8 -7
  44. package/server/api/routes/todos.ts +30 -0
  45. package/server/api/routes/todosColumnsHandlers.ts +13 -27
  46. package/server/api/routes/todosHandlers.ts +1 -1
  47. package/server/api/routes/todosItemsHandlers.ts +14 -14
  48. package/server/api/routes/wiki/frontmatter.ts +86 -0
  49. package/server/api/routes/wiki.ts +335 -75
  50. package/server/api/sandboxStatus.ts +1 -1
  51. package/server/events/notifications.ts +32 -8
  52. package/server/events/pub-sub/index.ts +3 -3
  53. package/server/events/relay-client.ts +26 -16
  54. package/server/events/resolveRelayBridgeOptions.ts +125 -0
  55. package/server/index.ts +72 -49
  56. package/server/system/config.ts +5 -5
  57. package/server/system/credentials.ts +7 -5
  58. package/server/system/env.ts +15 -5
  59. package/server/system/macosNotify.ts +152 -0
  60. package/server/utils/errors.ts +11 -2
  61. package/server/utils/fetch.ts +54 -0
  62. package/server/utils/files/atomic.ts +18 -17
  63. package/server/utils/files/image-store.ts +19 -13
  64. package/server/utils/files/journal-io.ts +2 -2
  65. package/server/utils/files/json.ts +5 -5
  66. package/server/utils/files/markdown-image-fill.ts +131 -0
  67. package/server/utils/files/markdown-store.ts +22 -6
  68. package/server/utils/files/naming.ts +20 -10
  69. package/server/utils/files/reference-dirs-io.ts +3 -3
  70. package/server/utils/files/roles-io.ts +4 -4
  71. package/server/utils/files/safe.ts +14 -14
  72. package/server/utils/files/scheduler-overrides-io.ts +2 -2
  73. package/server/utils/files/spreadsheet-store.ts +15 -10
  74. package/server/utils/files/workspace-io.ts +12 -12
  75. package/server/utils/gemini.ts +30 -4
  76. package/server/utils/gitignore.ts +9 -9
  77. package/server/utils/id.ts +40 -8
  78. package/server/utils/json.ts +5 -5
  79. package/server/utils/logBackgroundError.ts +12 -3
  80. package/server/utils/logPreview.ts +24 -0
  81. package/server/utils/markdown.ts +5 -5
  82. package/server/utils/port.d.mts +6 -0
  83. package/server/utils/port.mjs +48 -0
  84. package/server/utils/promptMeta.ts +32 -0
  85. package/server/utils/request.ts +12 -6
  86. package/server/utils/slug.ts +65 -4
  87. package/server/utils/spawn.ts +1 -1
  88. package/server/utils/types.ts +2 -2
  89. package/server/workspace/chat-index/index.ts +1 -1
  90. package/server/workspace/chat-index/summarizer.ts +5 -5
  91. package/server/workspace/custom-dirs.ts +5 -5
  92. package/server/workspace/helps/gemini.md +57 -0
  93. package/server/workspace/helps/index.md +2 -1
  94. package/server/workspace/helps/sources.md +42 -0
  95. package/server/workspace/helps/wiki.md +40 -5
  96. package/server/workspace/journal/archivist-cli.ts +121 -0
  97. package/server/workspace/journal/{archivist.ts → archivist-schemas.ts} +12 -120
  98. package/server/workspace/journal/dailyPass.ts +78 -38
  99. package/server/workspace/journal/diff.ts +2 -2
  100. package/server/workspace/journal/index.ts +56 -5
  101. package/server/workspace/journal/memoryExtractor.ts +1 -1
  102. package/server/workspace/journal/optimizationPass.ts +4 -5
  103. package/server/workspace/journal/paths.ts +8 -24
  104. package/server/workspace/journal/state.ts +18 -8
  105. package/server/workspace/news/reader.ts +248 -0
  106. package/server/workspace/paths.ts +4 -3
  107. package/server/workspace/reference-dirs.ts +3 -3
  108. package/server/workspace/skills/parser.ts +6 -6
  109. package/server/workspace/skills/scheduler.ts +5 -4
  110. package/server/workspace/skills/user-tasks.ts +3 -2
  111. package/server/workspace/skills/writer.ts +3 -3
  112. package/server/workspace/sources/arxivDiscovery.ts +2 -2
  113. package/server/workspace/sources/classifier.ts +1 -1
  114. package/server/workspace/sources/fetchers/rss.ts +5 -5
  115. package/server/workspace/sources/fetchers/rssParser.ts +4 -4
  116. package/server/workspace/sources/interests.ts +3 -3
  117. package/server/workspace/sources/paths.ts +6 -6
  118. package/server/workspace/sources/pipeline/fetch.ts +59 -13
  119. package/server/workspace/sources/pipeline/index.ts +59 -7
  120. package/server/workspace/sources/pipeline/notify.ts +13 -5
  121. package/server/workspace/sources/pipeline/plan.ts +11 -9
  122. package/server/workspace/sources/pipeline/summarize.ts +1 -1
  123. package/server/workspace/sources/pipeline/write.ts +5 -5
  124. package/server/workspace/sources/rateLimiter.ts +1 -1
  125. package/server/workspace/sources/sourceState.ts +9 -4
  126. package/server/workspace/sources/types.ts +9 -0
  127. package/server/workspace/sources/urls.ts +1 -1
  128. package/server/workspace/tool-trace/classify.ts +4 -4
  129. package/server/workspace/workspace.ts +7 -7
  130. package/src/App.vue +477 -251
  131. package/src/components/CanvasViewToggle.vue +12 -10
  132. package/src/components/ChatInput.vue +112 -105
  133. package/src/components/FileContentHeader.vue +10 -7
  134. package/src/components/FileContentRenderer.vue +37 -10
  135. package/src/components/FileTree.vue +34 -4
  136. package/src/components/FileTreePane.vue +32 -27
  137. package/src/components/FilesView.vue +5 -3
  138. package/src/components/FilterChip.vue +22 -0
  139. package/src/components/LockStatusPopup.vue +19 -13
  140. package/src/components/NewsView.vue +252 -0
  141. package/src/components/NotificationBell.vue +35 -9
  142. package/src/components/NotificationToast.vue +4 -1
  143. package/src/components/PageChatComposer.vue +101 -0
  144. package/src/components/PluginLauncher.vue +36 -62
  145. package/src/components/RightSidebar.vue +13 -10
  146. package/src/components/RoleSelector.vue +3 -2
  147. package/src/components/SessionHeaderControls.vue +63 -0
  148. package/src/components/SessionHistoryExpandButton.vue +30 -0
  149. package/src/components/SessionHistoryPanel.vue +64 -93
  150. package/src/components/SessionHistoryToggleButton.vue +40 -0
  151. package/src/components/SessionRoleIcon.vue +72 -0
  152. package/src/components/SessionSidebar.vue +96 -0
  153. package/src/components/SessionTabBar.vue +44 -51
  154. package/src/components/SettingsMcpTab.vue +361 -52
  155. package/src/components/SettingsModal.vue +203 -72
  156. package/src/components/SettingsReferenceDirsTab.vue +72 -51
  157. package/src/components/SettingsWorkspaceDirsTab.vue +74 -51
  158. package/src/components/SidebarHeader.vue +50 -16
  159. package/src/components/SourcesManager.vue +900 -0
  160. package/src/components/SourcesView.vue +45 -0
  161. package/src/components/StackView.vue +84 -48
  162. package/src/components/SuggestionsPanel.vue +25 -36
  163. package/src/components/SystemFileBanner.vue +106 -0
  164. package/src/components/ThinkingIndicator.vue +41 -0
  165. package/src/components/TodoExplorer.vue +72 -22
  166. package/src/components/todo/TodoAddDialog.vue +17 -12
  167. package/src/components/todo/TodoEditDialog.vue +7 -2
  168. package/src/components/todo/TodoEditPanel.vue +15 -10
  169. package/src/components/todo/TodoKanbanView.vue +16 -6
  170. package/src/components/todo/TodoListView.vue +14 -3
  171. package/src/components/todo/TodoTableView.vue +36 -5
  172. package/src/composables/favicon/conditions.ts +76 -0
  173. package/src/composables/favicon/resolveColor.ts +93 -0
  174. package/src/composables/favicon/types.ts +61 -0
  175. package/src/composables/useAppApi.ts +23 -0
  176. package/src/composables/useChatScroll.ts +5 -5
  177. package/src/composables/useCurrentRole.ts +32 -0
  178. package/src/composables/useDynamicFavicon.ts +174 -58
  179. package/src/composables/useEventListeners.ts +7 -12
  180. package/src/composables/useFaviconState.ts +93 -12
  181. package/src/composables/useFileSelection.ts +25 -6
  182. package/src/composables/useHealth.ts +76 -7
  183. package/src/composables/useLayoutMode.ts +27 -0
  184. package/src/composables/useNewsItems.ts +38 -0
  185. package/src/composables/useNewsReadState.ts +75 -0
  186. package/src/composables/useNotifications.ts +76 -13
  187. package/src/composables/usePendingCalls.ts +11 -1
  188. package/src/composables/useRoles.ts +6 -10
  189. package/src/composables/useRunElapsed.ts +80 -0
  190. package/src/composables/useSessionDerived.ts +21 -5
  191. package/src/composables/useSessionHistory.ts +7 -17
  192. package/src/composables/useSidePanelVisible.ts +25 -0
  193. package/src/composables/useViewLayout.ts +16 -37
  194. package/src/config/apiRoutes.ts +19 -6
  195. package/src/config/historyFilters.ts +30 -0
  196. package/src/config/mcpCatalog.ts +285 -0
  197. package/src/config/mcpTypes.ts +26 -0
  198. package/src/config/roles.ts +19 -51
  199. package/src/config/systemFileDescriptors.ts +170 -0
  200. package/src/config/toolNames.ts +6 -1
  201. package/src/config/workspacePaths.ts +1 -0
  202. package/src/index.css +14 -0
  203. package/src/lang/de.ts +706 -0
  204. package/src/lang/en.ts +726 -0
  205. package/src/lang/es.ts +712 -0
  206. package/src/lang/fr.ts +704 -0
  207. package/src/lang/ja.ts +707 -0
  208. package/src/lang/ko.ts +709 -0
  209. package/src/lang/pt-BR.ts +702 -0
  210. package/src/lang/zh.ts +705 -0
  211. package/src/lib/vue-i18n.ts +97 -0
  212. package/src/main.ts +3 -0
  213. package/src/plugins/canvas/View.vue +104 -186
  214. package/src/plugins/canvas/definition.ts +0 -8
  215. package/src/plugins/canvas/index.ts +3 -2
  216. package/src/plugins/chart/Preview.vue +1 -1
  217. package/src/plugins/chart/View.vue +9 -4
  218. package/src/plugins/chart/index.ts +3 -2
  219. package/src/plugins/editImage/index.ts +3 -2
  220. package/src/plugins/generateImage/index.ts +3 -2
  221. package/src/plugins/manageRoles/Preview.vue +4 -1
  222. package/src/plugins/manageRoles/View.vue +67 -46
  223. package/src/plugins/manageRoles/index.ts +3 -2
  224. package/src/plugins/manageSkills/Preview.vue +8 -3
  225. package/src/plugins/manageSkills/View.vue +39 -34
  226. package/src/plugins/manageSkills/index.ts +3 -2
  227. package/src/plugins/manageSource/Preview.vue +1 -1
  228. package/src/plugins/manageSource/View.vue +3 -687
  229. package/src/plugins/manageSource/index.ts +3 -2
  230. package/src/plugins/markdown/Preview.vue +1 -1
  231. package/src/plugins/markdown/View.vue +164 -73
  232. package/src/plugins/markdown/definition.ts +6 -4
  233. package/src/plugins/markdown/index.ts +3 -2
  234. package/src/plugins/presentForm/Preview.vue +99 -0
  235. package/src/plugins/presentForm/View.vue +675 -0
  236. package/src/plugins/presentForm/definition.ts +127 -0
  237. package/src/plugins/presentForm/index.ts +18 -0
  238. package/src/plugins/presentForm/plugin.ts +94 -0
  239. package/src/plugins/presentForm/types.ts +109 -0
  240. package/src/plugins/presentHtml/Preview.vue +1 -1
  241. package/src/plugins/presentHtml/View.vue +7 -4
  242. package/src/plugins/presentHtml/index.ts +3 -2
  243. package/src/plugins/presentMulmoScript/Preview.vue +1 -1
  244. package/src/plugins/presentMulmoScript/View.vue +36 -26
  245. package/src/plugins/presentMulmoScript/index.ts +3 -2
  246. package/src/plugins/scheduler/AutomationsPreview.vue +37 -0
  247. package/src/plugins/scheduler/AutomationsView.vue +23 -0
  248. package/src/plugins/scheduler/CalendarView.vue +23 -0
  249. package/src/plugins/scheduler/LegacySchedulerView.vue +32 -0
  250. package/src/plugins/scheduler/Preview.vue +7 -4
  251. package/src/plugins/scheduler/TasksTab.vue +119 -28
  252. package/src/plugins/scheduler/View.vue +75 -32
  253. package/src/plugins/scheduler/automationsDefinition.ts +58 -0
  254. package/src/plugins/scheduler/calendarDefinition.ts +46 -0
  255. package/src/plugins/scheduler/formatSchedule.ts +93 -0
  256. package/src/plugins/scheduler/index.ts +68 -14
  257. package/src/plugins/scheduler/legacyShape.ts +34 -0
  258. package/src/plugins/spreadsheet/Preview.vue +9 -5
  259. package/src/plugins/spreadsheet/View.vue +43 -57
  260. package/src/plugins/spreadsheet/engine/responseDecoder.ts +2 -1
  261. package/src/plugins/spreadsheet/index.ts +3 -2
  262. package/src/plugins/textResponse/Preview.vue +15 -58
  263. package/src/plugins/textResponse/View.vue +42 -45
  264. package/src/plugins/textResponse/utils.ts +25 -0
  265. package/src/plugins/todo/Preview.vue +11 -6
  266. package/src/plugins/todo/View.vue +27 -13
  267. package/src/plugins/todo/composables/useTodos.ts +3 -1
  268. package/src/plugins/todo/index.ts +3 -2
  269. package/src/plugins/ui-image/ImagePreview.vue +6 -3
  270. package/src/plugins/ui-image/ImageView.vue +7 -4
  271. package/src/plugins/wiki/Preview.vue +5 -2
  272. package/src/plugins/wiki/View.vue +539 -92
  273. package/src/plugins/wiki/index.ts +5 -2
  274. package/src/plugins/wiki/route.ts +121 -0
  275. package/src/router/guards.ts +43 -24
  276. package/src/router/index.ts +53 -26
  277. package/src/router/pageRoutes.ts +23 -0
  278. package/src/tools/index.ts +12 -5
  279. package/src/tools/legacyPluginNames.ts +13 -0
  280. package/src/types/notification.ts +31 -6
  281. package/src/types/vue-i18n.d.ts +20 -0
  282. package/src/utils/agent/eventDispatch.ts +3 -6
  283. package/src/utils/agent/formatElapsed.ts +37 -0
  284. package/src/utils/agent/request.ts +22 -1
  285. package/src/utils/canvas/layoutMode.ts +26 -0
  286. package/src/utils/canvas/sidePanelVisible.ts +19 -0
  287. package/src/utils/dom/scrollIntoViewByTestId.ts +38 -0
  288. package/src/utils/errors.ts +9 -2
  289. package/src/utils/files/filename.ts +24 -0
  290. package/src/utils/filesPreview/schedulerPreview.ts +9 -3
  291. package/src/utils/id.ts +18 -0
  292. package/src/utils/image/cacheBust.ts +16 -0
  293. package/src/utils/image/resolve.ts +16 -0
  294. package/src/utils/markdown/taskList.ts +175 -0
  295. package/src/utils/mcp/interpolateSpec.ts +97 -0
  296. package/src/utils/notification/dispatch.ts +51 -15
  297. package/src/utils/path/workspaceLinkRouter.ts +99 -0
  298. package/src/utils/session/mergeSessions.ts +5 -0
  299. package/src/utils/sources/filter.ts +69 -0
  300. package/src/vite-env.d.ts +9 -0
  301. package/client/assets/chunk-vKJrgz-R-C_I3GbVV.js +0 -1
  302. package/client/assets/html2canvas-Cx501zZr-BF5dYYkY.js +0 -5
  303. package/client/assets/index-Bm70FDU2.css +0 -1
  304. package/client/assets/typeof-DBp4T-Ny-BC0P-2DM.js +0 -1
  305. package/server/workspace/journal/linkRewrite.ts +0 -4
  306. package/src/components/ToolResultsPanel.vue +0 -77
  307. package/src/composables/useCanvasViewMode.ts +0 -121
  308. package/src/plugins/scheduler/definition.ts +0 -57
  309. package/src/utils/canvas/viewMode.ts +0 -46
  310. package/src/utils/role/plugins.ts +0 -12
  311. package/src/utils/session/seedRoleDefault.ts +0 -35
  312. /package/client/assets/{purify.es-Fx1Nqyry-PeS5RUhs.js → purify.es-Fx1Nqyry-BwJECkqS.js} +0 -0
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Form Tool Definition (Schema)
3
+ */
4
+
5
+ import type { ToolDefinition } from "gui-chat-protocol";
6
+
7
+ export const TOOL_NAME = "presentForm";
8
+
9
+ export const TOOL_DEFINITION: ToolDefinition = {
10
+ type: "function",
11
+ name: TOOL_NAME,
12
+ description:
13
+ "Create a structured form to collect information from the user. Supports various field types including text input, textarea, multiple choice (radio), dropdown menus, checkboxes, date/time pickers, and number inputs. Each field can have validation rules and help text.",
14
+ parameters: {
15
+ type: "object",
16
+ properties: {
17
+ title: {
18
+ type: "string",
19
+ description: "Optional title for the form (e.g., 'User Registration')",
20
+ },
21
+ description: {
22
+ type: "string",
23
+ description: "Optional description explaining the purpose of the form",
24
+ },
25
+ fields: {
26
+ type: "array",
27
+ description: "Array of form fields with various types and configurations",
28
+ items: {
29
+ type: "object",
30
+ properties: {
31
+ id: {
32
+ type: "string",
33
+ description:
34
+ "Unique identifier for the field (e.g., 'email', 'birthdate'). This will be the key in the JSON response. Use descriptive camelCase or snake_case names.",
35
+ },
36
+ type: {
37
+ type: "string",
38
+ enum: ["text", "textarea", "radio", "dropdown", "checkbox", "date", "time", "number"],
39
+ description:
40
+ "Field type: 'text' for short text, 'textarea' for long text, 'radio' for 2-6 choices, 'dropdown' for many choices, 'checkbox' for multiple selections, 'date' for date picker, 'time' for time picker, 'number' for numeric input",
41
+ },
42
+ label: {
43
+ type: "string",
44
+ description: "Field label shown to the user",
45
+ },
46
+ description: {
47
+ type: "string",
48
+ description: "Optional help text explaining the field",
49
+ },
50
+ required: {
51
+ type: "boolean",
52
+ description: "Whether the field is required (default: false)",
53
+ },
54
+ placeholder: {
55
+ type: "string",
56
+ description: "Placeholder text for text/textarea fields",
57
+ },
58
+ validation: {
59
+ type: "string",
60
+ description: "For text fields: 'email', 'url', 'phone', or a regex pattern",
61
+ },
62
+ minLength: {
63
+ type: "number",
64
+ description: "Minimum character length for textarea fields",
65
+ },
66
+ maxLength: {
67
+ type: "number",
68
+ description: "Maximum character length for textarea fields",
69
+ },
70
+ rows: {
71
+ type: "number",
72
+ description: "Number of visible rows for textarea (default: 4)",
73
+ },
74
+ choices: {
75
+ type: "array",
76
+ items: { type: "string" },
77
+ description: "Array of choices for radio/dropdown/checkbox fields. Radio should have 2-6 choices, dropdown for 7+ choices.",
78
+ },
79
+ searchable: {
80
+ type: "boolean",
81
+ description: "Make dropdown searchable (for large lists)",
82
+ },
83
+ minSelections: {
84
+ type: "number",
85
+ description: "Minimum number of selections for checkbox fields",
86
+ },
87
+ maxSelections: {
88
+ type: "number",
89
+ description: "Maximum number of selections for checkbox fields",
90
+ },
91
+ minDate: {
92
+ type: "string",
93
+ description: "Minimum date (ISO format: YYYY-MM-DD)",
94
+ },
95
+ maxDate: {
96
+ type: "string",
97
+ description: "Maximum date (ISO format: YYYY-MM-DD)",
98
+ },
99
+ format: {
100
+ type: "string",
101
+ description: "Format for time fields: '12hr' or '24hr'",
102
+ },
103
+ min: {
104
+ type: "number",
105
+ description: "Minimum value for number fields",
106
+ },
107
+ max: {
108
+ type: "number",
109
+ description: "Maximum value for number fields",
110
+ },
111
+ step: {
112
+ type: "number",
113
+ description: "Step increment for number fields",
114
+ },
115
+ defaultValue: {
116
+ description:
117
+ "Optional default/pre-filled value for the field. Type varies by field: string for text/textarea/radio/dropdown/date/time, number for number fields, array of strings for checkbox fields. For radio/dropdown, must be one of the choices. For checkbox, must be a subset of choices.",
118
+ },
119
+ },
120
+ required: ["id", "type", "label"],
121
+ },
122
+ minItems: 1,
123
+ },
124
+ },
125
+ required: ["fields"],
126
+ },
127
+ };
@@ -0,0 +1,18 @@
1
+ import type { ToolPlugin } from "gui-chat-protocol/vue";
2
+ import type { FormData, FormArgs } from "./types";
3
+ import { TOOL_DEFINITION } from "./definition";
4
+ import { executeForm } from "./plugin";
5
+ import View from "./View.vue";
6
+ import Preview from "./Preview.vue";
7
+
8
+ const presentFormPlugin: ToolPlugin<never, FormData, FormArgs> = {
9
+ toolDefinition: TOOL_DEFINITION,
10
+ execute: executeForm,
11
+ generatingMessage: "Preparing form...",
12
+ isEnabled: () => true,
13
+ viewComponent: View,
14
+ previewComponent: Preview,
15
+ };
16
+
17
+ export default presentFormPlugin;
18
+ export { TOOL_NAME } from "./definition";
@@ -0,0 +1,94 @@
1
+ // Local fork of @mulmochat-plugin/form (originally
2
+ // ~/ss/llm/GUIChatPlugins/MulmoChatPluginForm). Forked so the form
3
+ // submission payload (see View.vue:handleSubmit) renders as readable
4
+ // markdown bullets in the chat history instead of a JSON-wrapped
5
+ // object.
6
+
7
+ import type { ToolContext, ToolResult } from "gui-chat-protocol";
8
+ import type { FormData, FormArgs, FormField } from "./types";
9
+
10
+ export { TOOL_NAME, TOOL_DEFINITION } from "./definition";
11
+
12
+ function validateChoiceField(field: FormField): void {
13
+ if (field.type === "radio") {
14
+ if (!Array.isArray(field.choices) || field.choices.length < 2) {
15
+ throw new Error(`Field '${field.id}': radio fields must have at least 2 choices`);
16
+ }
17
+ return;
18
+ }
19
+ if (field.type === "dropdown" || field.type === "checkbox") {
20
+ if (!Array.isArray(field.choices) || field.choices.length < 1) {
21
+ throw new Error(`Field '${field.id}': ${field.type} fields must have at least 1 choice`);
22
+ }
23
+ }
24
+ }
25
+
26
+ function validateCheckboxRange(field: FormField & { type: "checkbox" }): void {
27
+ const { minSelections, maxSelections, choices, id } = field;
28
+ if (minSelections !== undefined && maxSelections !== undefined && minSelections > maxSelections) {
29
+ throw new Error(`Field '${id}': minSelections cannot be greater than maxSelections`);
30
+ }
31
+ if (maxSelections !== undefined && maxSelections > choices.length) {
32
+ throw new Error(`Field '${id}': maxSelections cannot exceed number of choices`);
33
+ }
34
+ // Without this lower-bound check, a form with minSelections > choices.length
35
+ // would render but be impossible to submit — the user can never satisfy the
36
+ // minimum because there aren't enough options to pick.
37
+ if (minSelections !== undefined && minSelections > choices.length) {
38
+ throw new Error(`Field '${id}': minSelections cannot exceed number of choices`);
39
+ }
40
+ }
41
+
42
+ function validateRangeField(field: FormField): void {
43
+ if (
44
+ (field.type === "text" || field.type === "textarea") &&
45
+ field.minLength !== undefined &&
46
+ field.maxLength !== undefined &&
47
+ field.minLength > field.maxLength
48
+ ) {
49
+ throw new Error(`Field '${field.id}': minLength cannot be greater than maxLength`);
50
+ }
51
+ if (field.type === "number" && field.min !== undefined && field.max !== undefined && field.min > field.max) {
52
+ throw new Error(`Field '${field.id}': min cannot be greater than max`);
53
+ }
54
+ if (field.type === "date" && field.minDate && field.maxDate && field.minDate > field.maxDate) {
55
+ throw new Error(`Field '${field.id}': minDate cannot be after maxDate`);
56
+ }
57
+ if (field.type === "checkbox") validateCheckboxRange(field);
58
+ }
59
+
60
+ function validateField(field: FormField, index: number, seenIds: Set<string>): void {
61
+ if (!field.id || typeof field.id !== "string") throw new Error(`Field ${index + 1} must have a valid 'id' property`);
62
+ if (!field.type || typeof field.type !== "string") throw new Error(`Field ${index + 1} must have a valid 'type' property`);
63
+ if (!field.label || typeof field.label !== "string") throw new Error(`Field ${index + 1} must have a valid 'label' property`);
64
+ if (seenIds.has(field.id)) throw new Error(`Duplicate field ID: '${field.id}'`);
65
+ seenIds.add(field.id);
66
+ validateChoiceField(field);
67
+ validateRangeField(field);
68
+ }
69
+
70
+ export const executeForm = async (_context: ToolContext, args: FormArgs): Promise<ToolResult<never, FormData>> => {
71
+ try {
72
+ const { title, description, fields } = args;
73
+ if (!fields || !Array.isArray(fields) || fields.length === 0) {
74
+ throw new Error("At least one field is required");
75
+ }
76
+ const seen = new Set<string>();
77
+ fields.forEach((field, i) => validateField(field, i, seen));
78
+
79
+ const formData: FormData = { title, description, fields };
80
+ const fieldCount = `${fields.length} field${fields.length > 1 ? "s" : ""}`;
81
+ const titleSuffix = title ? `: ${title}` : "";
82
+ return {
83
+ message: `Form created with ${fieldCount}${titleSuffix}`,
84
+ jsonData: formData,
85
+ instructions:
86
+ "The form has been presented to the user. Wait for the user to fill out and submit it. They will reply with a markdown bullet list of `- {label}: {value}` lines.",
87
+ };
88
+ } catch (error) {
89
+ return {
90
+ message: `Form error: ${error instanceof Error ? error.message : "Unknown error"}`,
91
+ instructions: "Acknowledge that there was an error creating the form and suggest trying again with corrected field definitions.",
92
+ };
93
+ }
94
+ };
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Form Plugin Types
3
+ *
4
+ * Form-specific type definitions only.
5
+ * Common types should be imported directly from gui-chat-protocol.
6
+ */
7
+
8
+ // ============================================================================
9
+ // Form-specific Types
10
+ // ============================================================================
11
+
12
+ /** Field type discriminator */
13
+ export type FieldType = "text" | "textarea" | "radio" | "dropdown" | "checkbox" | "date" | "time" | "number";
14
+
15
+ /** Base field interface */
16
+ export interface BaseField {
17
+ id: string;
18
+ type: FieldType;
19
+ label: string;
20
+ description?: string;
21
+ required?: boolean;
22
+ maxLength?: number;
23
+ }
24
+
25
+ /** Text field */
26
+ export interface TextField extends BaseField {
27
+ type: "text";
28
+ placeholder?: string;
29
+ validation?: "email" | "url" | "phone" | string;
30
+ defaultValue?: string;
31
+ minLength?: number;
32
+ maxLength?: number;
33
+ }
34
+
35
+ /** Textarea field */
36
+ export interface TextareaField extends BaseField {
37
+ type: "textarea";
38
+ placeholder?: string;
39
+ minLength?: number;
40
+ maxLength?: number;
41
+ rows?: number;
42
+ defaultValue?: string;
43
+ }
44
+
45
+ /** Radio field */
46
+ export interface RadioField extends BaseField {
47
+ type: "radio";
48
+ choices: string[];
49
+ defaultValue?: string;
50
+ }
51
+
52
+ /** Dropdown field */
53
+ export interface DropdownField extends BaseField {
54
+ type: "dropdown";
55
+ choices: string[];
56
+ searchable?: boolean;
57
+ defaultValue?: string;
58
+ }
59
+
60
+ /** Checkbox field */
61
+ export interface CheckboxField extends BaseField {
62
+ type: "checkbox";
63
+ choices: string[];
64
+ minSelections?: number;
65
+ maxSelections?: number;
66
+ defaultValue?: string[];
67
+ }
68
+
69
+ /** Date field */
70
+ export interface DateField extends BaseField {
71
+ type: "date";
72
+ minDate?: string;
73
+ maxDate?: string;
74
+ format?: string;
75
+ defaultValue?: string;
76
+ }
77
+
78
+ /** Time field */
79
+ export interface TimeField extends BaseField {
80
+ type: "time";
81
+ format?: "12hr" | "24hr";
82
+ defaultValue?: string;
83
+ }
84
+
85
+ /** Number field */
86
+ export interface NumberField extends BaseField {
87
+ type: "number";
88
+ min?: number;
89
+ max?: number;
90
+ step?: number;
91
+ defaultValue?: number;
92
+ }
93
+
94
+ /** Union type for all fields */
95
+ export type FormField = TextField | TextareaField | RadioField | DropdownField | CheckboxField | DateField | TimeField | NumberField;
96
+
97
+ /** Form data stored in result.jsonData */
98
+ export interface FormData {
99
+ title?: string;
100
+ description?: string;
101
+ fields: FormField[];
102
+ }
103
+
104
+ /** Arguments passed to the form tool */
105
+ export interface FormArgs {
106
+ title?: string;
107
+ description?: string;
108
+ fields: FormField[];
109
+ }
@@ -1,5 +1,5 @@
1
1
  <template>
2
- <div class="text-sm">
2
+ <div class="p-2 text-sm">
3
3
  <div class="font-medium text-gray-700 truncate mb-1">
4
4
  {{ title }}
5
5
  </div>
@@ -1,18 +1,18 @@
1
1
  <template>
2
2
  <div class="h-full flex flex-col overflow-hidden">
3
3
  <div class="px-4 py-2 border-b border-gray-100 shrink-0 flex items-center justify-between">
4
- <span class="text-sm font-medium text-gray-700 truncate">{{ title ?? "HTML Page" }}</span>
4
+ <span class="text-sm font-medium text-gray-700 truncate">{{ title ?? t("pluginPresentHtml.untitled") }}</span>
5
5
  <div class="flex items-center gap-2">
6
6
  <button
7
7
  class="px-2 py-1 text-xs rounded border border-gray-300 text-gray-500 hover:bg-gray-50 shrink-0"
8
- title="Save as PDF (opens print dialog)"
8
+ :title="t('pluginPresentHtml.saveAsPdf')"
9
9
  @click="printToPdf"
10
10
  >
11
11
  <span class="material-icons text-sm align-middle">picture_as_pdf</span>
12
- PDF
12
+ {{ t("pluginPresentHtml.pdf") }}
13
13
  </button>
14
14
  <button class="px-2 py-1 text-xs rounded border border-gray-300 text-gray-500 hover:bg-gray-50 shrink-0" @click="sourceOpen = !sourceOpen">
15
- {{ sourceOpen ? "Hide Source <>" : "Show Source <>" }}
15
+ {{ sourceOpen ? t("pluginPresentHtml.hideSource") : t("pluginPresentHtml.showSource") }}
16
16
  </button>
17
17
  </div>
18
18
  </div>
@@ -25,9 +25,12 @@
25
25
 
26
26
  <script setup lang="ts">
27
27
  import { computed, ref } from "vue";
28
+ import { useI18n } from "vue-i18n";
28
29
  import type { ToolResultComplete } from "gui-chat-protocol/vue";
29
30
  import type { PresentHtmlData } from "./index";
30
31
 
32
+ const { t } = useI18n();
33
+
31
34
  const props = defineProps<{
32
35
  selectedResult: ToolResultComplete<PresentHtmlData>;
33
36
  }>();
@@ -5,6 +5,7 @@ import View from "./View.vue";
5
5
  import Preview from "./Preview.vue";
6
6
  import { apiPost } from "../../utils/api";
7
7
  import { API_ROUTES } from "../../config/apiRoutes";
8
+ import { makeUuid } from "../../utils/id";
8
9
 
9
10
  export interface PresentHtmlData {
10
11
  html: string;
@@ -20,14 +21,14 @@ const presentHtmlPlugin: ToolPlugin<PresentHtmlData> = {
20
21
  if (!result.ok) {
21
22
  return {
22
23
  toolName: TOOL_NAME,
23
- uuid: crypto.randomUUID(),
24
+ uuid: makeUuid(),
24
25
  message: result.error,
25
26
  };
26
27
  }
27
28
  return {
28
29
  ...result.data,
29
30
  toolName: TOOL_NAME,
30
- uuid: crypto.randomUUID(),
31
+ uuid: makeUuid(),
31
32
  };
32
33
  },
33
34
 
@@ -1,5 +1,5 @@
1
1
  <template>
2
- <div class="text-sm">
2
+ <div class="p-2 text-sm">
3
3
  <div class="font-medium text-gray-700 truncate mb-1">
4
4
  {{ title }}
5
5
  </div>
@@ -10,7 +10,7 @@
10
10
  {{ script.description }}
11
11
  </p>
12
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>
13
+ <span>{{ t("pluginMulmoScript.beatCount", beats.length, { named: { count: beats.length } }) }}</span>
14
14
  <span v-if="script.lang">{{ script.lang }}</span>
15
15
  <span v-if="filePath" class="truncate">{{ filePath }}</span>
16
16
  </div>
@@ -24,7 +24,7 @@
24
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
25
  >
26
26
  <span class="material-icons text-sm leading-none">download</span>
27
- <span>Movie</span>
27
+ <span>{{ t("pluginMulmoScript.movie") }}</span>
28
28
  </a>
29
29
  <!-- Generate / Regenerate Movie -->
30
30
  <button
@@ -36,10 +36,10 @@
36
36
  <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
37
37
  <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
38
38
  </svg>
39
- <span v-if="movieGenerating">Generating…</span>
39
+ <span v-if="movieGenerating">{{ t("pluginMulmoScript.generating") }}</span>
40
40
  <template v-else>
41
41
  <span class="material-icons text-sm leading-none">refresh</span>
42
- <span>Movie</span>
42
+ <span>{{ t("pluginMulmoScript.movie") }}</span>
43
43
  </template>
44
44
  </button>
45
45
  </div>
@@ -48,13 +48,13 @@
48
48
  <!-- Characters section -->
49
49
  <div v-if="characterKeys.length > 0" class="border-b border-gray-100 shrink-0 px-4 py-3">
50
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>
51
+ <span class="text-xs font-semibold text-gray-500 uppercase tracking-wide">{{ t("pluginMulmoScript.characters") }}</span>
52
52
  <button
53
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
54
  :disabled="movieGenerating || anyBeatRendering || characterKeys.every((key) => charRenderState[key] === 'rendering')"
55
55
  @click="generateAllCharacters"
56
56
  >
57
- Generate All
57
+ {{ t("pluginMulmoScript.generateAll") }}
58
58
  </button>
59
59
  </div>
60
60
  <div class="flex gap-3 flex-wrap">
@@ -88,11 +88,11 @@
88
88
  </template>
89
89
  <!-- Permanent drop hint -->
90
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
91
+ {{ t("pluginMulmoScript.orDropImage") }}
92
92
  </div>
93
93
  <!-- Drop overlay -->
94
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>
95
+ <span class="text-xs text-blue-500 font-medium">{{ t("pluginMulmoScript.drop") }}</span>
96
96
  </div>
97
97
  <!-- Regenerate button -->
98
98
  <button
@@ -121,7 +121,7 @@
121
121
  <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
122
122
  <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
123
123
  </svg>
124
- <span v-else>Gen</span>
124
+ <span v-else>{{ t("pluginMulmoScript.gen") }}</span>
125
125
  </button>
126
126
  </div>
127
127
  <span class="text-xs text-gray-600 text-center truncate w-full">{{ key }}</span>
@@ -163,7 +163,7 @@
163
163
  <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
164
164
  <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
165
165
  </svg>
166
- <span class="text-xs text-green-500">Rendering…</span>
166
+ <span class="text-xs text-green-500">{{ t("pluginMulmoScript.rendering") }}</span>
167
167
  </template>
168
168
  <template v-else-if="renderState[index] === 'error'">
169
169
  <span class="text-xs text-red-400 text-center">{{ renderErrors[index] }}</span>
@@ -177,13 +177,13 @@
177
177
  </div>
178
178
  <!-- Beat drop hint / overlay -->
179
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>
180
+ <span class="text-xs text-blue-500 font-medium">{{ t("pluginMulmoScript.drop") }}</span>
181
181
  </div>
182
182
  <div
183
183
  v-else-if="!renderedImages[index] && renderState[index] !== 'rendering'"
184
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
185
  >
186
- or drop image
186
+ {{ t("pluginMulmoScript.orDropImage") }}
187
187
  </div>
188
188
  <!-- Generate button for imagePrompt beats -->
189
189
  <button
@@ -191,7 +191,7 @@
191
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
192
  @click="renderBeat(index)"
193
193
  >
194
- Generate
194
+ {{ t("pluginMulmoScript.generate") }}
195
195
  </button>
196
196
  </div>
197
197
 
@@ -213,10 +213,10 @@
213
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
214
  @click="playAudio(index)"
215
215
  >
216
- {{ playingAudio?.index === index ? "■ Stop" : "▶ Play" }}
216
+ {{ playingAudio?.index === index ? t("pluginMulmoScript.stop") : t("pluginMulmoScript.play") }}
217
217
  </button>
218
218
  <template v-else-if="audioErrors[index]">
219
- <span class="text-xs text-red-400" :title="audioErrors[index]">⚠ Error</span>
219
+ <span class="text-xs text-red-400" :title="audioErrors[index]">{{ t("pluginMulmoScript.errPrefix") }}</span>
220
220
  <button
221
221
  v-if="effectiveBeat(index).text"
222
222
  class="text-xs px-2 py-0.5 rounded border border-gray-300 text-gray-500 hover:bg-gray-50 disabled:opacity-50"
@@ -231,7 +231,7 @@
231
231
  class="text-xs px-2 py-0.5 rounded border border-gray-300 text-gray-500 hover:bg-gray-50"
232
232
  @click="generateAudio(index)"
233
233
  >
234
- Generate
234
+ {{ t("pluginMulmoScript.generateAudio") }}
235
235
  </button>
236
236
  </div>
237
237
  <button class="text-gray-400 hover:text-gray-600" :title="sourceOpen[index] ? 'Hide source' : 'Show source'" @click="toggleSource(index)">
@@ -263,7 +263,11 @@
263
263
  spellcheck="false"
264
264
  />
265
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>
266
+ <span v-if="beatSaveErrors[index]" class="text-xs text-red-600" role="alert">{{
267
+ t(beatSaveErrors[index].kind === "invalidJson" ? "pluginMulmoScript.saveErrorInvalidJson" : "pluginMulmoScript.saveErrorSaveFailed", {
268
+ error: beatSaveErrors[index].error,
269
+ })
270
+ }}</span>
267
271
  <button
268
272
  class="px-2 py-1 text-xs rounded border"
269
273
  :class="
@@ -274,19 +278,19 @@
274
278
  :disabled="!isValidBeat(index) || !!beatSaving[index]"
275
279
  @click="updateBeat(index)"
276
280
  >
277
- {{ beatSaving[index] ? "Saving…" : "Update" }}
281
+ {{ beatSaving[index] ? t("pluginMulmoScript.saving") : t("pluginMulmoScript.update") }}
278
282
  </button>
279
283
  </div>
280
284
  </div>
281
285
  </div>
282
286
 
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>
287
+ <div v-if="beats.length === 0" class="flex items-center justify-center h-32 text-gray-400 text-sm">{{ t("pluginMulmoScript.noBeats") }}</div>
284
288
  </div>
285
289
 
286
290
  <!-- Bottom bar: Edit Script Source + Copy -->
287
291
  <div class="bottom-bar-wrapper">
288
292
  <details ref="sourceDetails" class="script-source" @toggle="onSourceToggle(($event.target as HTMLDetailsElement).open)">
289
- <summary>Edit Script Source</summary>
293
+ <summary>{{ t("pluginMulmoScript.editSource") }}</summary>
290
294
  <textarea
291
295
  v-model="editableSource"
292
296
  class="script-editor"
@@ -294,8 +298,8 @@
294
298
  spellcheck="false"
295
299
  ></textarea>
296
300
  <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>
301
+ <button class="apply-btn" :disabled="!sourceChanged || !sourceValid" @click="applySource">{{ t("pluginMulmoScript.applyChanges") }}</button>
302
+ <button class="cancel-btn" @click="cancelSourceEdit">{{ t("common.cancel") }}</button>
299
303
  </div>
300
304
  </details>
301
305
  <button v-show="!editing" class="copy-btn" :title="copied ? 'Copied!' : 'Copy'" @click="copyText">
@@ -328,7 +332,7 @@
328
332
  "
329
333
  @click="playAudio(lightbox.index)"
330
334
  >
331
- {{ playingAudio?.index === lightbox.index ? "■ Stop" : "▶ Play" }}
335
+ {{ playingAudio?.index === lightbox.index ? t("pluginMulmoScript.stop") : t("pluginMulmoScript.play") }}
332
336
  </button>
333
337
  </div>
334
338
  </div>
@@ -347,7 +351,10 @@
347
351
 
348
352
  <script setup lang="ts">
349
353
  import { computed, onMounted, reactive, ref, watch } from "vue";
354
+ import { useI18n } from "vue-i18n";
350
355
  import type { ToolResultComplete } from "gui-chat-protocol/vue";
356
+
357
+ const { t } = useI18n();
351
358
  import type { MulmoScriptData } from "./index";
352
359
  import { mulmoBeatSchema, mulmoScriptSchema } from "@mulmocast/types";
353
360
  import { extractErrorMessage, getMissingCharacterKeys, shouldAutoRenderBeat, streamMovieEvents, validateBeatJSON } from "./helpers";
@@ -407,7 +414,10 @@ const sourceOpen = reactive<Record<number, boolean>>({});
407
414
  const sourceText = reactive<Record<number, string>>({});
408
415
  // Surface POST /api/mulmo-script/update-beat failures inline next to
409
416
  // the Update button. Cleared on next successful save or editor close.
410
- const beatSaveErrors = reactive<Record<number, string>>({});
417
+ // Store raw error + kind tag so the template picks a localized key,
418
+ // instead of pre-composing an English-prefixed string here.
419
+ type BeatSaveError = { kind: "invalidJson" | "saveFailed"; error: string };
420
+ const beatSaveErrors = reactive<Record<number, BeatSaveError>>({});
411
421
  const beatSaving = reactive<Record<number, boolean>>({});
412
422
  const localOverrides = reactive<Record<number, Beat>>({});
413
423
  const movieGenerating = ref(false);
@@ -606,7 +616,7 @@ async function updateBeat(index: number) {
606
616
  try {
607
617
  beat = JSON.parse(sourceText[index]);
608
618
  } catch (err) {
609
- beatSaveErrors[index] = `Invalid JSON: ${errorMessage(err)}`;
619
+ beatSaveErrors[index] = { kind: "invalidJson", error: errorMessage(err) };
610
620
  return;
611
621
  }
612
622
  const prevImage = JSON.stringify(effectiveBeat(index).image);
@@ -620,7 +630,7 @@ async function updateBeat(index: number) {
620
630
  });
621
631
  delete beatSaving[index];
622
632
  if (!response.ok) {
623
- beatSaveErrors[index] = `Save failed: ${response.error}`;
633
+ beatSaveErrors[index] = { kind: "saveFailed", error: response.error };
624
634
  return;
625
635
  }
626
636