@webship.co/workspace 0.0.0-stage → 1.0.0-alpha1

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 (311) hide show
  1. package/PLUGINS.md +249 -0
  2. package/README.md +379 -2
  3. package/WORKSPACES.md +69 -0
  4. package/actions/ai-items.js +293 -0
  5. package/actions/audio.js +48 -0
  6. package/actions/build-order.js +79 -0
  7. package/actions/checks.js +95 -0
  8. package/actions/claude-session.js +104 -0
  9. package/actions/commands-center.js +258 -0
  10. package/actions/composer.js +267 -0
  11. package/actions/ddev-addons.js +90 -0
  12. package/actions/ddev-mail.js +80 -0
  13. package/actions/ddev.js +237 -0
  14. package/actions/folders.js +165 -0
  15. package/actions/graphs.js +107 -0
  16. package/actions/images-svg.js +33 -0
  17. package/actions/images.js +145 -0
  18. package/actions/navigation.js +190 -0
  19. package/actions/node.js +222 -0
  20. package/actions/notes.js +55 -0
  21. package/actions/obsidian.js +59 -0
  22. package/actions/plugins.js +36 -0
  23. package/actions/project-make.js +28 -0
  24. package/actions/rag.js +202 -0
  25. package/actions/row-actions.js +262 -0
  26. package/actions/screens.js +270 -0
  27. package/actions/testing.js +121 -0
  28. package/actions/video-edit.js +54 -0
  29. package/ai-inventory.js +182 -0
  30. package/ai-items.js +202 -0
  31. package/assistant-cast.js +176 -0
  32. package/assistant-suggestions.js +91 -0
  33. package/assistant.js +484 -0
  34. package/audio-voice.js +528 -0
  35. package/auth-google.js +260 -0
  36. package/build-ai.js +52 -0
  37. package/build-order-page.js +191 -0
  38. package/build-order.js +313 -0
  39. package/builder-args.js +117 -0
  40. package/changelog.js +302 -0
  41. package/chat-threads.js +134 -0
  42. package/checks.js +105 -0
  43. package/claude-args.js +282 -0
  44. package/claude-sessions.js +129 -0
  45. package/command-author.js +117 -0
  46. package/command-block.js +20 -0
  47. package/commands/defaults.yml +159 -0
  48. package/commands-page.js +460 -0
  49. package/commands.js +568 -0
  50. package/cucumber.js +43 -0
  51. package/ddev-addons-page.js +124 -0
  52. package/ddev-addons.js +154 -0
  53. package/ddev-custom-commands.js +96 -0
  54. package/ddev-domains.js +84 -0
  55. package/ddev-import.js +93 -0
  56. package/ddev-mail-page.js +167 -0
  57. package/ddev-mail.js +110 -0
  58. package/ddev-options.js +145 -0
  59. package/ddev-snapshots.js +126 -0
  60. package/ddev.js +145 -0
  61. package/docs/DEVELOPER-GUIDE.md +224 -0
  62. package/docs/README.md +252 -0
  63. package/docs/REFERENCE.md +684 -0
  64. package/docs/USER-GUIDE.md +697 -0
  65. package/docs/screenshots/assistant/avatar-board.png +0 -0
  66. package/docs/screenshots/assistant/avatar-coffee.png +0 -0
  67. package/docs/screenshots/assistant/avatar-colours.png +0 -0
  68. package/docs/screenshots/assistant/avatar-icons.png +0 -0
  69. package/docs/screenshots/assistant/avatar-moves.png +0 -0
  70. package/docs/screenshots/assistant/avatar-pick-cookieman.png +0 -0
  71. package/docs/screenshots/assistant/avatar-settings-voices.png +0 -0
  72. package/docs/screenshots/assistant/avatar-status.png +0 -0
  73. package/docs/screenshots/create-card/create-card.png +0 -0
  74. package/docs/screenshots/create-card/start-ai.png +0 -0
  75. package/docs/screenshots/guide-clone-address.png +0 -0
  76. package/docs/screenshots/guide-clone-worktree-off.png +0 -0
  77. package/docs/screenshots/guide-clone-worktree-on.png +0 -0
  78. package/docs/screenshots/guide-ddev-menu.png +0 -0
  79. package/docs/screenshots/guide-domains.png +0 -0
  80. package/docs/screenshots/guide-home.png +0 -0
  81. package/docs/screenshots/guide-ide-menu.png +0 -0
  82. package/docs/screenshots/guide-remove-refusal.png +0 -0
  83. package/docs/screenshots/guide-rows-worktree.png +0 -0
  84. package/docs/screenshots/guide-screens.png +0 -0
  85. package/docs/screenshots/guide-settings.png +0 -0
  86. package/docs/screenshots/guide-workspace-page.png +0 -0
  87. package/docs/screenshots/home/card-settings.png +0 -0
  88. package/docs/screenshots/home/drag-reorder.png +0 -0
  89. package/docs/screenshots/home/edit-card.png +0 -0
  90. package/docs/screenshots/images/claude-code-image.png +0 -0
  91. package/docs/screenshots/images/edit-with-ai.png +0 -0
  92. package/docs/screenshots/images/gallery.png +0 -0
  93. package/docs/screenshots/images/generate-description.png +0 -0
  94. package/docs/screenshots/images/generate-plan.png +0 -0
  95. package/docs/screenshots/images/providers.png +0 -0
  96. package/docs/screenshots/images/svg-form.png +0 -0
  97. package/docs/screenshots/images/svg-result.png +0 -0
  98. package/docs/screenshots/images/upload.png +0 -0
  99. package/docs/screenshots/new-folder/2-form.png +0 -0
  100. package/docs/screenshots/new-folder/8-ddev-options.png +0 -0
  101. package/docs/screenshots/new-folder/files-folder.png +0 -0
  102. package/docs/screenshots/projects/ddev-custom-commands.png +0 -0
  103. package/docs/screenshots/projects/state-chip-stop.png +0 -0
  104. package/docs/screenshots/readme/dashboard-dark.png +0 -0
  105. package/docs/screenshots/readme/dashboard-light.png +0 -0
  106. package/docs/screenshots/readme/dev-dark.png +0 -0
  107. package/docs/screenshots/readme/dev-light.png +0 -0
  108. package/docs/screenshots/readme/docs-dark.png +0 -0
  109. package/docs/screenshots/readme/docs-light.png +0 -0
  110. package/docs/screenshots/readme/icons/ai.svg +4 -0
  111. package/docs/screenshots/readme/icons/code.svg +4 -0
  112. package/docs/screenshots/readme/icons/docs.svg +4 -0
  113. package/docs/screenshots/readme/icons/services.svg +4 -0
  114. package/docs/screenshots/readme/icons/sites.svg +4 -0
  115. package/docs/screenshots/readme/test-report-dark.png +0 -0
  116. package/docs/screenshots/readme/test-report-light.png +0 -0
  117. package/docs/screenshots/readme/test-scenarios.png +0 -0
  118. package/docs/screenshots/readme/test-steps.png +0 -0
  119. package/docs/screenshots/readme/videos-dark.png +0 -0
  120. package/docs/screenshots/readme/videos-light.png +0 -0
  121. package/docs/screenshots/screens-menu.png +0 -0
  122. package/docs/screenshots/screens-new.png +0 -0
  123. package/docs/screenshots/settings/auth-google-lists.png +0 -0
  124. package/docs/screenshots/settings/delete-1-row.png +0 -0
  125. package/docs/screenshots/settings/delete-2-confirm.png +0 -0
  126. package/docs/screenshots/settings/delete-3-type-name.png +0 -0
  127. package/docs/screenshots/settings/delete-4-type-yes.png +0 -0
  128. package/event-log.js +59 -0
  129. package/files-browser.js +247 -0
  130. package/folders.js +121 -0
  131. package/format.js +73 -0
  132. package/git-info.js +81 -0
  133. package/github-auth.js +62 -0
  134. package/graphs.js +104 -0
  135. package/html.js +10 -0
  136. package/icons.js +83 -0
  137. package/images-ai.js +447 -0
  138. package/images-create.js +226 -0
  139. package/images-page.js +417 -0
  140. package/images-records.js +39 -0
  141. package/images-render.js +36 -0
  142. package/images-svg-core.js +214 -0
  143. package/images-svg-sanitize.js +188 -0
  144. package/images-svg.js +241 -0
  145. package/images.js +270 -0
  146. package/jobs.js +430 -0
  147. package/linger.js +21 -0
  148. package/lists.js +292 -0
  149. package/media-info.js +136 -0
  150. package/navigation-page.js +264 -0
  151. package/navigation.js +394 -0
  152. package/notes.js +0 -0
  153. package/obsidian.js +405 -0
  154. package/package.json +47 -4
  155. package/palette-index.js +46 -0
  156. package/playwright.config.js +23 -0
  157. package/plugin-install.js +239 -0
  158. package/plugin-loader.js +448 -0
  159. package/plugin-manifest.js +83 -0
  160. package/plugin-settings.js +44 -0
  161. package/plugins/workspace-notes/index.js +59 -0
  162. package/plugins-page.js +103 -0
  163. package/project-make.js +241 -0
  164. package/project-name.js +210 -0
  165. package/public/assistant-avatar-moves.js +646 -0
  166. package/public/assistant-avatar-ui.js +1223 -0
  167. package/public/assistant-avatar.js +530 -0
  168. package/public/avatar/druplicon.vrm +0 -0
  169. package/public/css/base.css +260 -0
  170. package/public/css/components/ai-assistant.css +263 -0
  171. package/public/css/components/assistant-avatar.css +279 -0
  172. package/public/css/components/assistant-views.css +198 -0
  173. package/public/css/components/build-order.css +74 -0
  174. package/public/css/components/clone-as-a-git-worktree.css +30 -0
  175. package/public/css/components/command-palette.css +61 -0
  176. package/public/css/components/commands-center.css +139 -0
  177. package/public/css/components/confirm-dialog.css +40 -0
  178. package/public/css/components/create-card.css +8 -0
  179. package/public/css/components/ddev-menu.css +23 -0
  180. package/public/css/components/deep-chat.css +17 -0
  181. package/public/css/components/density.css +11 -0
  182. package/public/css/components/floating-assistant-workspace-pages.css +55 -0
  183. package/public/css/components/folders.css +5 -0
  184. package/public/css/components/full-height-left-assistant-sidebar-all-pages.css +39 -0
  185. package/public/css/components/full-height-right-actions-rail-pages-that-have-actions.css +267 -0
  186. package/public/css/components/grid-and-list.css +203 -0
  187. package/public/css/components/help-page.css +36 -0
  188. package/public/css/components/hero.css +16 -0
  189. package/public/css/components/images.css +70 -0
  190. package/public/css/components/layout-rhythm-and-spacing-polish.css +139 -0
  191. package/public/css/components/mic-on-any-field-that-wants-typing.css +416 -0
  192. package/public/css/components/modal-fit.css +113 -0
  193. package/public/css/components/page-width.css +23 -0
  194. package/public/css/components/plugins-page.css +9 -0
  195. package/public/css/components/project-cards.css +256 -0
  196. package/public/css/components/project-rows.css +390 -0
  197. package/public/css/components/reordering-the-workspace-cards.css +85 -0
  198. package/public/css/components/responsive-home-and-the-workspace-pages.css +457 -0
  199. package/public/css/components/running-commands-as-toasts.css +327 -0
  200. package/public/css/components/settings-editor-core-config-yml.css +828 -0
  201. package/public/css/components/short-results-as-a-toast-at-the-top.css +203 -0
  202. package/public/css/components/sound-player.css +84 -0
  203. package/public/css/components/suggest-agents-skills-commands.css +112 -0
  204. package/public/css/components/tab-modal.css +142 -0
  205. package/public/css/components/terminals.css +244 -0
  206. package/public/css/components/test-report-media-one-file-per-row-paged.css +144 -0
  207. package/public/css/components/top-toolbar-logo-breadcrumb.css +107 -0
  208. package/public/css/components/video-edit.css +6 -0
  209. package/public/css/components/web-ide-open-with.css +23 -0
  210. package/public/css/components/working-indicators.css +158 -0
  211. package/public/css/components/workspace-cards.css +79 -0
  212. package/public/favicon.ico +0 -0
  213. package/public/favicon.png +0 -0
  214. package/public/htmx.min.js +1 -0
  215. package/public/logo.png +0 -0
  216. package/public/media-player.js +215 -0
  217. package/public/mic.js +255 -0
  218. package/public/narration.js +110 -0
  219. package/public/palette.js +278 -0
  220. package/public/sortable.js +251 -0
  221. package/public/suggest.js +337 -0
  222. package/public/terminal.js +903 -0
  223. package/public/themes/default/theme.css +267 -0
  224. package/public/themes/ember/theme.css +180 -0
  225. package/public/themes/press/theme.css +290 -0
  226. package/public/themes/slate/theme.css +182 -0
  227. package/public/touch-icon.png +0 -0
  228. package/public/ui.js +2721 -0
  229. package/public/vendor/ace/LICENSE.txt +24 -0
  230. package/public/vendor/ace/ace.js +23 -0
  231. package/public/vendor/ace/ext-language_tools.js +8 -0
  232. package/public/vendor/ace/ext-searchbox.js +8 -0
  233. package/public/vendor/ace/mode-css.js +8 -0
  234. package/public/vendor/ace/mode-html.js +8 -0
  235. package/public/vendor/ace/mode-javascript.js +8 -0
  236. package/public/vendor/ace/mode-markdown.js +8 -0
  237. package/public/vendor/ace/mode-sh.js +8 -0
  238. package/public/vendor/ace/mode-yaml.js +8 -0
  239. package/public/vendor/ace/theme-textmate.js +8 -0
  240. package/public/vendor/ace/theme-tomorrow_night.js +8 -0
  241. package/public/vendor/ace/worker-yaml.js +1 -0
  242. package/public/vendor/deep-chat.LICENSE +21 -0
  243. package/public/vendor/deep-chat.bundle.js +1 -0
  244. package/public/vendor/fonts/dm-serif-text-LICENSE.txt +93 -0
  245. package/public/vendor/fonts/dm-serif-text-latin-400-italic.woff2 +0 -0
  246. package/public/vendor/fonts/dm-serif-text-latin-400-normal.woff2 +0 -0
  247. package/public/vendor/fonts/figtree-LICENSE.txt +93 -0
  248. package/public/vendor/fonts/figtree-latin-400-italic.woff2 +0 -0
  249. package/public/vendor/fonts/figtree-latin-400-normal.woff2 +0 -0
  250. package/public/vendor/fonts/figtree-latin-600-italic.woff2 +0 -0
  251. package/public/vendor/fonts/figtree-latin-600-normal.woff2 +0 -0
  252. package/public/vendor/fonts/jetbrains-mono-LICENSE.txt +93 -0
  253. package/public/vendor/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
  254. package/public/vendor/fonts/ubuntu-mono-LICENSE.txt +98 -0
  255. package/public/vendor/fonts/ubuntu-mono-latin-wght-normal.woff2 +0 -0
  256. package/public/vendor/tabler-LICENSE.txt +21 -0
  257. package/public/vendor/tabler-sprite.svg +1 -0
  258. package/public/vendor/three/LICENSE +21 -0
  259. package/public/vendor/three/addons/loaders/GLTFLoader.js +4860 -0
  260. package/public/vendor/three/addons/utils/BufferGeometryUtils.js +1501 -0
  261. package/public/vendor/three/addons/utils/SkeletonUtils.js +496 -0
  262. package/public/vendor/three/three.core.min.js +6 -0
  263. package/public/vendor/three/three.module.min.js +6 -0
  264. package/public/vendor/three-vrm/LICENSE +22 -0
  265. package/public/vendor/three-vrm/three-vrm.module.min.js +983 -0
  266. package/public/vendor/uikit-icons.min.js +1 -0
  267. package/public/vendor/uikit.min.css +1 -0
  268. package/public/vendor/uikit.min.js +1 -0
  269. package/public/vendor/webship-icons/ui/bookmark-outlined.svg +1 -0
  270. package/public/vendor/webship-icons/ui/headset.svg +1 -0
  271. package/public/vendor/webship-icons/ui/menu.svg +1 -0
  272. package/public/vendor/webship-icons/ui/pause.svg +1 -0
  273. package/public/vendor/webship-icons/ui/play.svg +1 -0
  274. package/public/vendor/webship-icons/ui/share.svg +1 -0
  275. package/public/vendor/webship-icons/ui/sound-off.svg +1 -0
  276. package/public/vendor/webship-icons/ui/sound-on.svg +1 -0
  277. package/public/vendor/webship-icons/ui/stop.svg +1 -0
  278. package/public/vendor/xterm/addon-fit.js +2 -0
  279. package/public/vendor/xterm/addon-unicode11.js +2 -0
  280. package/public/vendor/xterm/addon-web-links.js +2 -0
  281. package/public/vendor/xterm/xterm.css +218 -0
  282. package/public/vendor/xterm/xterm.js +2 -0
  283. package/rag.js +169 -0
  284. package/reader.js +132 -0
  285. package/repo-blobs.js +66 -0
  286. package/report-pdf.js +337 -0
  287. package/row-layout.js +1440 -0
  288. package/screenrc +37 -0
  289. package/screens.js +1063 -0
  290. package/scripts/code-server-start.sh +190 -0
  291. package/scripts/images-claude-finish.js +107 -0
  292. package/scripts/screen-host.sh +114 -0
  293. package/scripts/screen-runner.js +362 -0
  294. package/scripts/screen-then.js +47 -0
  295. package/scripts/update.sh +350 -0
  296. package/server.js +8140 -0
  297. package/settings-file.js +423 -0
  298. package/speech.js +297 -0
  299. package/tab-modal.js +75 -0
  300. package/test-reports.js +260 -0
  301. package/themes.js +381 -0
  302. package/transcripts.js +168 -0
  303. package/urls.js +13 -0
  304. package/validate.js +18 -0
  305. package/version-check.js +94 -0
  306. package/video-edit.js +588 -0
  307. package/waiting.js +85 -0
  308. package/workspace-card-tools.js +41 -0
  309. package/workspace-delete.js +139 -0
  310. package/workspace-types.js +85 -0
  311. package/workspaces.js +557 -0
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Building a project's knowledge graph, and what you do with it afterwards.
3
+ *
4
+ * One module per area of the dashboard, each exporting the handlers it owns keyed by the path that
5
+ * reaches them. server.js merges the maps and dispatches; nothing here knows about routing, and
6
+ * adding an action is adding an entry rather than another branch in a 1,250-line function.
7
+ *
8
+ * Every handler takes the same context: the parsed form, the response, a `send` that writes an HTML
9
+ * fragment, and the workspace the request named.
10
+ */
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+
15
+ const { esc } = require('../html');
16
+ const { humanSize, humanTime } = require('../format');
17
+ const { ROOT, loadWorkspaces, workspaceDir } = require('../workspaces');
18
+ const {
19
+ run, jobFragment, runningJobKey, startJob, ddevProjectName, listProjects,
20
+ startInScreen, screenStartedHtml,
21
+ } = require('../jobs');
22
+ const { graphStoreDir } = require('../graphs');
23
+ const {
24
+ milvusProjectName, ragInstances, ragBindings, ragInstanceFor, ragWriteBinding,
25
+ ragCollectionName, milvusPost, ragCollections, ragInvalidate,
26
+ } = require('../rag');
27
+ const {
28
+ findTestReports, testReportFor, invalidateTestReports, testMediaHtml, testingStackOf,
29
+ } = require('../test-reports');
30
+ const { NAME_RE, SCRIPT_RE } = require('../validate');
31
+ const { commandBlock } = require('../command-block');
32
+ const { wsUrl } = require('../urls');
33
+ const { findGraphifyScript, findRagifyScript } = require('../workspaces');
34
+ const { DDEV_ACTIONS } = require('../ddev');
35
+
36
+ async function graphify({ form, res, send, workspace, pathname }) {
37
+ const dir = workspaceDir(workspace);
38
+ const script = findGraphifyScript(dir);
39
+ const projectName = String(form.projectName || '');
40
+ if (!script) return send('<div class="msg error">No graphify script in this workspace.</div>', 400);
41
+ if (!NAME_RE.test(projectName) || !listProjects(dir).includes(projectName)) {
42
+ return send('<div class="msg error">Unknown project.</div>', 400);
43
+ }
44
+ // graphify has no cross-process lock: two runs against one graphify-out/
45
+ // race on manifest.json, graph.json and cache/ast, and the loser's atomic
46
+ // replace silently wins, corrupting the incremental gate.
47
+ const jobKey = `graphify:${workspace}/${projectName}`;
48
+ if (runningJobKey(jobKey)) {
49
+ return send('<div class="msg error">A graph is already being built for this project.</div>', 409);
50
+ }
51
+ // Local AST extraction only: no API key, nothing leaves the machine. Big
52
+ // codebases take a while, so it gets the long build timeout.
53
+ const started = await startInScreen({ workspace, project: projectName, projectDir: dir, ownerDir: path.join(dir, projectName), verb: 'graphify',
54
+ cmdLine: `bash ${JSON.stringify(script)} ${JSON.stringify(projectName)}`, label: 'graphify' });
55
+ return send(screenStartedHtml(started, 'graphify', projectName), started.ok ? 200 : (started.already ? 409 : 500));
56
+ }
57
+
58
+ // Commands the dashboard SHOWS rather than runs, with a copy button each.
59
+ //
60
+ // Two kinds end up here. `claude mcp add` writes ~/.claude.json — the CLI configuration of the
61
+ // person at the keyboard, not of this web request. The Upsun pulls need cloud credentials this
62
+ // container does not have and replace local data. Both are worth reading before they happen.
63
+ async function graph_mcp_command({ form, res, send, workspace, pathname }) {
64
+ const projectName = String(form.projectName || '');
65
+ if (!NAME_RE.test(projectName)) return send('<div class="msg error">Unknown project.</div>', 400);
66
+ const graph = path.join(graphStoreDir(workspace, projectName), 'graph.json');
67
+ if (!fs.existsSync(graph)) return send('<div class="msg error">No graph yet — build it first.</div>', 409);
68
+ return send(commandBlock(
69
+ `Serve the ${workspace}/${projectName} graph to the Claude Code CLI`,
70
+ [`cd ${ROOT}/graphs && bash cmd-tools-graphs.sh ${workspace}/${projectName} -a mcp-register`,
71
+ `claude mcp add --scope user graph-${workspace}-${projectName} -- graphify-mcp ${graph.replace(ROOT, '~/workspace')}`],
72
+ 'Run either one <strong>on the host</strong>, in a terminal. The first is the workspace command; '
73
+ + 'the second is what it runs. stdio, so there is no port and no daemon — the CLI starts the server '
74
+ + 'when a session needs it. Open a NEW session to see the tools; rebuilding the graph needs no '
75
+ + 're-registration.'));
76
+ }
77
+
78
+ // Delete a project's graph. Bounded to graphs/<workspace>/<project> — the whole point is that a
79
+ // graph is regenerable, so this is the one cheap thing to throw away. The project itself is
80
+ // never touched: nothing of the graph lives in it.
81
+ async function graph_remove({ form, res, send, workspace, pathname }) {
82
+ if (form.confirm !== 'yes') return send('<div class="msg error">Deleting requires confirmation.</div>', 400);
83
+ const projectName = String(form.projectName || '');
84
+ // The project may be gone — a graph outlives it, and that is exactly a graph worth deleting.
85
+ if (!NAME_RE.test(projectName)) {
86
+ return send('<div class="msg error">Unknown project.</div>', 400);
87
+ }
88
+ if (runningJobKey(`graphify:${workspace}/${projectName}`)) {
89
+ return send('<div class="msg error">A graph run is in progress for this project — wait for it to finish.</div>', 409);
90
+ }
91
+ const out = graphStoreDir(workspace, projectName);
92
+ let real;
93
+ try {
94
+ real = fs.realpathSync(out);
95
+ const base = fs.realpathSync(path.join(ROOT, 'graphs'));
96
+ if (!real.startsWith(base + path.sep)) throw new Error('outside');
97
+ } catch (_) { return send('<div class="msg assistant">No graph to delete.</div>'); }
98
+ fs.rmSync(real, { recursive: true, force: true });
99
+ res.setHeader('HX-Trigger', 'refresh-projects');
100
+ return send(`<div class="msg assistant">🗑️ Deleted the graph for <strong>${esc(projectName)}</strong>. Rebuild it any time from the Graph menu.</div>`);
101
+ }
102
+
103
+ module.exports = {
104
+ '/actions/graphify': graphify,
105
+ '/actions/graph-mcp-command': graph_mcp_command,
106
+ '/actions/graph-remove': graph_remove,
107
+ };
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Generate SVG: without confirm=yes this answers with the plan; the request goes out only with the confirm dialog's Yes.
3
+ */
4
+
5
+ const { esc } = require('../html');
6
+ const images = require('../images');
7
+
8
+ async function image_svg({ form, send, res }) {
9
+ const svg = require('../images-svg');
10
+ if (form.confirm !== 'yes') {
11
+ const p = svg.plan(form);
12
+ if (!p.ok) return send(`<div class="msg error">${esc(p.error)}</div>${svg.formHtml()}`, 400);
13
+ const prov = require('../images-providers').get(p.provider);
14
+ const a = prov.available();
15
+ if (!a.ok) return send(`<div class="msg error">${prov.keyName ? `Set <code>ai_providers.${esc(prov.keyName)}.api_key</code> in <code>core/config/settings.yml</code> first.` : `${esc(prov.label)}: ${esc(a.why)}.`}</div>`, 503);
16
+ return send(svg.planHtml(p));
17
+ }
18
+ const r = await svg.generateSvg(form);
19
+ if (!r.ok) {
20
+ const spent = r.cost ? ` It cost about $${r.cost.toFixed(4)}.` : '';
21
+ return send(`<div class="msg error">${esc(r.error)}${spent}</div>`, r.status);
22
+ }
23
+ if (r.async) {
24
+ return send(`<div class="msg assistant">Claude Code is drawing it in the screen session <strong>${esc(r.session)}</strong>: <a href="/screens">watch it on Screens</a>. The SVG and its records appear in <strong>images/svg/</strong> when it is done.</div>`);
25
+ }
26
+ images.forgetScan();
27
+ try { res.setHeader('HX-Trigger', 'refresh-projects'); } catch (_) { /* headers already sent */ }
28
+ const viewer = require('../images-page').viewerHtml({ q: '', sort: 'newest', kind: '', scope: 'here' }, 'svg', 'images', r.rel) || '';
29
+ return send(`<div class="msg assistant">Saved <strong>images/${esc(r.rel)}</strong> after ${r.attempts} attempt${r.attempts === 1 ? '' : 's'}${
30
+ r.cost === null ? '' : `, for about $${r.cost.toFixed(4)}`}.</div>${viewer}`);
31
+ }
32
+
33
+ module.exports = { '/actions/image-svg': image_svg };
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Renaming and deleting an image in the images workspace. Images shown from other workspaces are
3
+ * read-only here, so both refuse any source but images/.
4
+ */
5
+
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+
9
+ const { esc } = require('../html');
10
+ const { workspaceDir, isValidWorkspace } = require('../workspaces');
11
+ const images = require('../images');
12
+
13
+ function ownImage(form) {
14
+ if (!isValidWorkspace('images')) return null;
15
+ const where = images.safeIn(workspaceDir('images'), String(form.file || ''));
16
+ if (!where || !images.IMAGE_RE.test(where.abs)) return null;
17
+ try { if (!fs.statSync(where.abs).isFile()) return null; } catch (_) { return null; }
18
+ return where;
19
+ }
20
+
21
+ function refreshed(res) {
22
+ images.forgetScan();
23
+ try { res.setHeader('HX-Trigger', 'refresh-projects'); } catch (_) { /* headers already sent */ }
24
+ }
25
+
26
+ async function image_rename({ form, send, res }) {
27
+ const where = ownImage(form);
28
+ if (!where) return send('<div class="msg error">That image is not in the images workspace.</div>', 404);
29
+ const ext = path.extname(where.abs).toLowerCase();
30
+ let name = images.safeName(form.name);
31
+ if (!name) return send('<div class="msg error">Give it a name: letters, digits, dot, dash and underscore.</div>', 400);
32
+ // The extension is the file's own; a rename never changes what kind of image it is.
33
+ if (path.extname(name).toLowerCase() !== ext) name = `${name.replace(/\.[^.]*$/, '')}${ext}`;
34
+ const dest = path.join(path.dirname(where.abs), name);
35
+ if (dest === where.abs) return send('<div class="msg assistant">The name is unchanged.</div>');
36
+ if (fs.existsSync(dest)) return send(`<div class="msg error"><code>${esc(name)}</code> already exists in that folder.</div>`, 409);
37
+ fs.renameSync(where.abs, dest);
38
+ // A sidecar travels with its image.
39
+ if (fs.existsSync(`${where.abs}.json`)) fs.renameSync(`${where.abs}.json`, `${dest}.json`);
40
+ refreshed(res);
41
+ const rel = path.relative(fs.realpathSync(workspaceDir('images')), dest).split(path.sep).join('/');
42
+ return send(`<div class="msg assistant">Renamed to <strong>${esc(name)}</strong>.</div>${
43
+ require('../images-page').viewerHtml({ q: '', sort: 'newest', kind: '', scope: 'here' }, path.posix.dirname(rel) === '.' ? '' : path.posix.dirname(rel), 'images', rel) || ''}`);
44
+ }
45
+
46
+ async function image_delete({ form, send, res }) {
47
+ if (form.confirm !== 'yes') return send('<div class="msg error">Deleting needs confirmation.</div>', 400);
48
+ const where = ownImage(form);
49
+ if (!where) return send('<div class="msg error">That image is not in the images workspace.</div>', 404);
50
+ fs.unlinkSync(where.abs);
51
+ try { fs.unlinkSync(`${where.abs}.json`); } catch (_) { /* no sidecar */ }
52
+ refreshed(res);
53
+ return send(`<div class="msg assistant">Deleted <strong>${esc(where.rel)}</strong>.</div>`);
54
+ }
55
+
56
+ const viewerOf = (rel) => require('../images-page').viewerHtml({ q: '', sort: 'newest', kind: '', scope: 'here' },
57
+ path.posix.dirname(rel) === '.' ? '' : path.posix.dirname(rel), 'images', rel) || '';
58
+
59
+ async function image_screenshot({ form, send, res }) {
60
+ const r = await require('../images-create').screenshot(form);
61
+ if (!r.ok) return send(`<div class="msg error">${esc(r.error)}</div>`, r.status);
62
+ refreshed(res);
63
+ return send(`<div class="msg assistant">Saved <strong>${esc(r.rel)}</strong>.</div>${viewerOf(r.rel)}`);
64
+ }
65
+
66
+ async function image_edit({ form, send, res }) {
67
+ const r = await require('../images-create').edit(form);
68
+ if (!r.ok) return send(`<div class="msg error">${esc(r.error)}</div>`, r.status);
69
+ refreshed(res);
70
+ return send(`<div class="msg assistant">Saved as <strong>${esc(r.name)}</strong>; the original is unchanged.</div>${viewerOf(r.rel)}`);
71
+ }
72
+
73
+ /** Why a provider cannot run here, as HTML, or '' when it can. */
74
+ function notReady(id) {
75
+ const prov = require('../images-providers').get(id);
76
+ if (!prov) return 'Unknown provider.';
77
+ const a = prov.available();
78
+ if (a.ok) return '';
79
+ return prov.keyName ? `Set <code>ai_providers.${esc(prov.keyName)}.api_key</code> in <code>core/config/settings.yml</code> first.` : `${esc(prov.label)}: ${esc(a.why)}.`;
80
+ }
81
+
82
+ /** A Claude Code session has started: where to watch it and where the result lands. */
83
+ function startedHtml(session, where) {
84
+ return `<div class="msg assistant">Claude Code is drawing it in the screen session <strong>${esc(session)}</strong>: <a href="/screens">watch it on Screens</a>. The SVG, its PNG and the records appear in <strong>${where}</strong> when it is done.</div>`;
85
+ }
86
+
87
+ // Without confirm=yes this only answers with the plan; the paid request needs the confirm dialog's Yes.
88
+ async function image_generate({ form, send, res }) {
89
+ const ai = require('../images-ai');
90
+ if (form.confirm !== 'yes') {
91
+ const p = ai.plan(form);
92
+ if (!p.ok) return send(`<div class="msg error">${esc(p.error)}</div>${ai.formHtml()}`, 400);
93
+ const why = notReady(p.provider);
94
+ if (why) return send(`<div class="msg error">${why}</div>`, 503);
95
+ return send(ai.planHtml(p));
96
+ }
97
+ const r = await ai.generate(form);
98
+ if (!r.ok) return send(`<div class="msg error">${esc(r.error)}</div>`, r.status);
99
+ if (r.async) return send(startedHtml(r.session, 'images/generated/'));
100
+ refreshed(res);
101
+ const page = require('../images-page');
102
+ const thumbs = r.made.map((rel) => `<button type="button" class="img-thumb img-made-thumb"
103
+ hx-get="/fragments/images/view?src=images&file=${encodeURIComponent(rel)}&path=generated" hx-target="#editor-modal-body" hx-swap="innerHTML">
104
+ <img src="${esc(page.fileUrl('images', rel))}" alt="${esc(rel)}"></button>`).join('');
105
+ const missing = (r.undescribed || []).length
106
+ ? `<div class="msg error">The description was not written for ${r.undescribed.map((n) => `<code>${esc(n)}</code>`).join(', ')}; open it and use <strong>Describe it</strong>.</div>` : '';
107
+ return send(`<div class="msg assistant">Made ${r.made.length} image${r.made.length === 1 ? '' : 's'} in <strong>images/generated/</strong>, each with its prompt, a description and an alt text${
108
+ r.cost === null ? '' : `, for about $${r.cost.toFixed(4)} in all`}. Open one to see it full size.</div>${missing}<div class="img-made-grid">${thumbs}</div>`);
109
+ }
110
+
111
+ // Edit with AI: a plan first, and the paid request only with the confirm dialog's Yes.
112
+ async function image_ai_edit({ form, send, res }) {
113
+ const ai = require('../images-ai');
114
+ if (form.confirm !== 'yes') {
115
+ const p = ai.editPlan(form);
116
+ if (!p.ok) return send(`<div class="msg error">${esc(p.error)}</div>`, 400);
117
+ const why = notReady(p.provider);
118
+ if (why) return send(`<div class="msg error">${why}</div>`, 503);
119
+ return send(ai.editPlanHtml(p));
120
+ }
121
+ const r = await ai.editImage(form);
122
+ if (!r.ok) return send(`<div class="msg error">${esc(r.error)}</div>`, r.status);
123
+ if (r.async) return send(startedHtml(r.session, `the source's folder as ${esc(r.name)}`));
124
+ refreshed(res);
125
+ return send(`<div class="msg assistant">Saved as <strong>images/${esc(r.rel)}</strong>; the source is unchanged${
126
+ r.cost === null ? '' : `. It cost about $${r.cost.toFixed(4)}`}.${r.described ? '' : ' The description was not written: use <strong>Describe it</strong>.'}</div>${viewerOf(r.rel)}`);
127
+ }
128
+
129
+ // Describe an image: a small paid call, so only with the confirm dialog's Yes.
130
+ async function image_describe({ form, send, res }) {
131
+ const r = await require('../images-ai').describeExisting(form);
132
+ if (!r.ok) return send(`<div class="msg error">${esc(r.error)}</div>`, r.status);
133
+ refreshed(res);
134
+ return send(`<div class="msg assistant">Described, for about $${r.cost.toFixed(4)}.</div>${viewerOf(r.rel)}`);
135
+ }
136
+
137
+ module.exports = {
138
+ '/actions/image-generate': image_generate,
139
+ '/actions/image-ai-edit': image_ai_edit,
140
+ '/actions/image-describe': image_describe,
141
+ '/actions/image-screenshot': image_screenshot,
142
+ '/actions/image-edit': image_edit,
143
+ '/actions/image-rename': image_rename,
144
+ '/actions/image-delete': image_delete,
145
+ };
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Writing the rail's layout: what is hidden, in what order, and the entries someone added.
3
+ *
4
+ * One form carries the whole arrangement (`#nav-form`), so a drag that has not been saved is posted with
5
+ * anything else that is done meanwhile — adding an entry never loses a reorder. Every write carries the
6
+ * file's mtime and is refused with 409 when it changed since, the way every other writer here works.
7
+ */
8
+
9
+ const rail = require('../navigation');
10
+ const navPage = require('../navigation-page');
11
+ const cc = require('../commands');
12
+ const { startJob, jobFragment } = require('../jobs');
13
+
14
+ const ok = (msg) => `<div class="msg assistant">✅ ${msg}</div>`;
15
+ const bad = (msg) => `<div class="msg error">${msg}</div>`;
16
+
17
+ const asArray = (v) => (v === undefined ? [] : (Array.isArray(v) ? v : [v]));
18
+
19
+ /** The arrangement as posted: group order, and each entry under the group it was dropped in. */
20
+ function arranged(form) {
21
+ // Deduplicated, and the reserved lists are not sidebar groups: the group order is a set, and a stray
22
+ // repeat would make the same group render twice.
23
+ const groups = [...new Set(asArray(form.group).map(String).filter(rail.isId))]
24
+ .filter((g) => !rail.RESERVED.has(g));
25
+ const order = {};
26
+ const hidden = [];
27
+ const topbar = rail.emptyTopbar();
28
+ // How much of each entry is shown: `<id>:icon`, `<id>:text`, or `<id>:both`. Both is the default and
29
+ // is not written down, which is what keeps the file a record of what somebody changed — and posting
30
+ // it explicitly is what makes going back to both work.
31
+ const ships = shippedShow(scopeOf(form));
32
+ const show = {};
33
+ for (const raw of asArray(form.show).map(String)) {
34
+ const at = raw.lastIndexOf(':');
35
+ if (at < 0) continue;
36
+ const id = raw.slice(0, at);
37
+ const how = raw.slice(at + 1);
38
+ if (!rail.isId(id)) continue;
39
+ if (how === 'icon' || how === 'text') { show[id] = how; continue; }
40
+ // Both is what an entry is unless somebody says otherwise, so it is not written down — EXCEPT when
41
+ // it is overriding an entry the dashboard ships one way. Without that there would be no way to put
42
+ // the assistant's label back: leaving the key out would just let its own default win again.
43
+ if (how === 'both' && ships[id]) show[id] = 'both';
44
+ }
45
+ for (const raw of asArray(form.item).map(String)) {
46
+ const at = raw.indexOf(':');
47
+ if (at < 0) continue;
48
+ const group = raw.slice(0, at);
49
+ const id = raw.slice(at + 1);
50
+ if (!rail.isId(id)) continue;
51
+ if (group === 'hidden') { hidden.push(id); continue; }
52
+ const region = rail.regionOf(group);
53
+ if (region) { topbar[region].push(id); continue; }
54
+ if (!rail.isId(group)) continue;
55
+ (order[group] || (order[group] = [])).push(id);
56
+ }
57
+ return { groups, order, hidden, topbar, show };
58
+ }
59
+
60
+ /** How each entry SHIPS, from what the navigation has drawn — a default an explicit answer overrides. */
61
+ function shippedShow(scope) {
62
+ const out = {};
63
+ for (const g of Object.values(rail.seenFor(scope))) {
64
+ for (const [id, was] of Object.entries((g || {}).items || {})) {
65
+ const how = rail.seenShips(was);
66
+ if (how) out[id] = how;
67
+ }
68
+ }
69
+ return out;
70
+ }
71
+
72
+ function scopeOf(form) {
73
+ const scope = String(form.scope || 'global');
74
+ return scope === 'global' || rail.isId(scope) ? scope : 'global';
75
+ }
76
+
77
+ /** Save one scope, keeping the entries it already had — they are edited by their own actions. */
78
+ function write(form, change) {
79
+ const { raw, mtime } = rail.rawLayout();
80
+ if (String(form.mtime || '') !== String(mtime)) {
81
+ return { conflict: true };
82
+ }
83
+ const scope = scopeOf(form);
84
+ const current = rail.scopeLayout(raw, scope);
85
+ const next = change({ ...current });
86
+ const out = rail.withScope(raw, scope, next);
87
+ return { mtime: rail.saveLayout(out) };
88
+ }
89
+
90
+ const editor = (send, form, message, status = 200) =>
91
+ send(navPage.navEditorHtml(scopeOf(form), message), status);
92
+
93
+ async function nav_save({ form, send }) {
94
+ const { groups, order, hidden, topbar, show } = arranged(form);
95
+ const res = write(form, (current) => ({ ...current, groups, order, hidden, topbar, show }));
96
+ if (res.conflict) {
97
+ return editor(send, form, bad('The navigation changed somewhere else while this was open — nothing was written. This is how it is now.'), 409);
98
+ }
99
+ return editor(send, form, ok('Navigation saved. It applies on the next page you open.'));
100
+ }
101
+
102
+ async function nav_add({ form, send }) {
103
+ const label = String(form.new_label || '').trim().slice(0, 40);
104
+ if (!label) return editor(send, form, bad('An entry needs a label.'), 400);
105
+ const href = String(form.new_href || '').trim();
106
+ const use = String(form.new_use || '').trim();
107
+ if (!href && !use) return editor(send, form, bad('An entry needs somewhere to go, or a command to run.'), 400);
108
+ if (href && use) return editor(send, form, bad('An entry is a link or a command, not both.'), 400);
109
+ if (use && !cc.loadCatalog().commands.some((c) => c.id === use)) {
110
+ return editor(send, form, bad('No such command.'), 400);
111
+ }
112
+ // A link goes in an href attribute and is followed by a click: only http(s) and a path of this
113
+ // dashboard, so nothing can put javascript: or data: into the rail.
114
+ if (href && !/^(https?:\/\/|\/)[^\s"'<>]*$/i.test(href)) {
115
+ return editor(send, form, bad('A link is an https:// address or a path of this dashboard.'), 400);
116
+ }
117
+
118
+ const base = rail.slugId(label);
119
+ const { raw } = rail.rawLayout();
120
+ const taken = new Set(rail.scopeLayout(raw, scopeOf(form)).items.map((i) => i.id));
121
+ let id = base;
122
+ for (let n = 2; taken.has(id); n += 1) id = `${base}-${n}`.slice(0, 48);
123
+
124
+ const item = { id, label };
125
+ const iconValue = String(form.new_icon || '').trim();
126
+ if (iconValue) item.icon = iconValue.slice(0, 40);
127
+ const group = String(form.new_group || '').trim();
128
+ // A region of the top bar is a legal destination, and slugId leaves it alone (it is already a slug).
129
+ if (group) item.group = rail.slugId(group);
130
+ if (href) item.href = href; else item.use = use;
131
+ if (form.new_confirm) item.confirm = true;
132
+
133
+ const { groups, order, hidden, topbar, show } = arranged(form);
134
+ const res = write(form, (current) => ({ ...current, groups, order, hidden, topbar, show, items: [...current.items, item] }));
135
+ if (res.conflict) {
136
+ return editor(send, form, bad('The navigation changed somewhere else while this was open — nothing was written.'), 409);
137
+ }
138
+ return editor(send, form, ok(`Added <strong>${label}</strong> to the navigation.`));
139
+ }
140
+
141
+ async function nav_remove({ form, send }) {
142
+ const id = String(form.id || '');
143
+ const { groups, order, hidden, topbar, show } = arranged(form);
144
+ const res = write(form, (current) => ({
145
+ ...current,
146
+ groups,
147
+ show: Object.fromEntries(Object.entries(show).filter(([x]) => x !== id)),
148
+ order: Object.fromEntries(Object.entries(order).map(([g, ids]) => [g, ids.filter((x) => x !== id)])),
149
+ hidden: hidden.filter((x) => x !== id),
150
+ topbar: Object.fromEntries(Object.entries(topbar).map(([r, ids]) => [r, ids.filter((x) => x !== id)])),
151
+ items: current.items.filter((it) => it.id !== id),
152
+ }));
153
+ if (res.conflict) return editor(send, form, bad('The navigation changed somewhere else while this was open.'), 409);
154
+ return editor(send, form, ok('Removed.'));
155
+ }
156
+
157
+ async function nav_reset({ form, send }) {
158
+ const { raw, mtime } = rail.rawLayout();
159
+ if (String(form.mtime || '') !== String(mtime)) {
160
+ return editor(send, form, bad('The navigation changed somewhere else while this was open.'), 409);
161
+ }
162
+ rail.saveLayout(rail.withScope(raw, scopeOf(form), {}));
163
+ return editor(send, form, ok('Back to the navigation this scope ships with.'));
164
+ }
165
+
166
+ /**
167
+ * Run a command an entry carries. The same runner a row action uses, so there is one history and one
168
+ * screen session per run rather than a second way of doing it.
169
+ */
170
+ async function nav_run({ form, send }) {
171
+ const use = String(form.use || '');
172
+ const command = cc.loadCatalog().commands.find((c) => c.id === use);
173
+ if (!command) return send(bad('No such command.'), 400);
174
+ let line;
175
+ try {
176
+ line = cc.commandLine(command, { workspace: command.workspace });
177
+ } catch (err) {
178
+ return send(bad(`That command cannot run from the rail: ${err.message}`), 400);
179
+ }
180
+ const id = startJob(`rail:${use}`, line, { cwd: command.dir });
181
+ return send(jobFragment(id).html);
182
+ }
183
+
184
+ module.exports = {
185
+ '/actions/nav-save': nav_save,
186
+ '/actions/nav-add': nav_add,
187
+ '/actions/nav-remove': nav_remove,
188
+ '/actions/nav-reset': nav_reset,
189
+ '/actions/nav-run': nav_run,
190
+ };