@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.
- package/PLUGINS.md +249 -0
- package/README.md +379 -2
- package/WORKSPACES.md +69 -0
- package/actions/ai-items.js +293 -0
- package/actions/audio.js +48 -0
- package/actions/build-order.js +79 -0
- package/actions/checks.js +95 -0
- package/actions/claude-session.js +104 -0
- package/actions/commands-center.js +258 -0
- package/actions/composer.js +267 -0
- package/actions/ddev-addons.js +90 -0
- package/actions/ddev-mail.js +80 -0
- package/actions/ddev.js +237 -0
- package/actions/folders.js +165 -0
- package/actions/graphs.js +107 -0
- package/actions/images-svg.js +33 -0
- package/actions/images.js +145 -0
- package/actions/navigation.js +190 -0
- package/actions/node.js +222 -0
- package/actions/notes.js +55 -0
- package/actions/obsidian.js +59 -0
- package/actions/plugins.js +36 -0
- package/actions/project-make.js +28 -0
- package/actions/rag.js +202 -0
- package/actions/row-actions.js +262 -0
- package/actions/screens.js +270 -0
- package/actions/testing.js +121 -0
- package/actions/video-edit.js +54 -0
- package/ai-inventory.js +182 -0
- package/ai-items.js +202 -0
- package/assistant-cast.js +176 -0
- package/assistant-suggestions.js +91 -0
- package/assistant.js +484 -0
- package/audio-voice.js +528 -0
- package/auth-google.js +260 -0
- package/build-ai.js +52 -0
- package/build-order-page.js +191 -0
- package/build-order.js +313 -0
- package/builder-args.js +117 -0
- package/changelog.js +302 -0
- package/chat-threads.js +134 -0
- package/checks.js +105 -0
- package/claude-args.js +282 -0
- package/claude-sessions.js +129 -0
- package/command-author.js +117 -0
- package/command-block.js +20 -0
- package/commands/defaults.yml +159 -0
- package/commands-page.js +460 -0
- package/commands.js +568 -0
- package/cucumber.js +43 -0
- package/ddev-addons-page.js +124 -0
- package/ddev-addons.js +154 -0
- package/ddev-custom-commands.js +96 -0
- package/ddev-domains.js +84 -0
- package/ddev-import.js +93 -0
- package/ddev-mail-page.js +167 -0
- package/ddev-mail.js +110 -0
- package/ddev-options.js +145 -0
- package/ddev-snapshots.js +126 -0
- package/ddev.js +145 -0
- package/docs/DEVELOPER-GUIDE.md +224 -0
- package/docs/README.md +252 -0
- package/docs/REFERENCE.md +684 -0
- package/docs/USER-GUIDE.md +697 -0
- package/docs/screenshots/assistant/avatar-board.png +0 -0
- package/docs/screenshots/assistant/avatar-coffee.png +0 -0
- package/docs/screenshots/assistant/avatar-colours.png +0 -0
- package/docs/screenshots/assistant/avatar-icons.png +0 -0
- package/docs/screenshots/assistant/avatar-moves.png +0 -0
- package/docs/screenshots/assistant/avatar-pick-cookieman.png +0 -0
- package/docs/screenshots/assistant/avatar-settings-voices.png +0 -0
- package/docs/screenshots/assistant/avatar-status.png +0 -0
- package/docs/screenshots/create-card/create-card.png +0 -0
- package/docs/screenshots/create-card/start-ai.png +0 -0
- package/docs/screenshots/guide-clone-address.png +0 -0
- package/docs/screenshots/guide-clone-worktree-off.png +0 -0
- package/docs/screenshots/guide-clone-worktree-on.png +0 -0
- package/docs/screenshots/guide-ddev-menu.png +0 -0
- package/docs/screenshots/guide-domains.png +0 -0
- package/docs/screenshots/guide-home.png +0 -0
- package/docs/screenshots/guide-ide-menu.png +0 -0
- package/docs/screenshots/guide-remove-refusal.png +0 -0
- package/docs/screenshots/guide-rows-worktree.png +0 -0
- package/docs/screenshots/guide-screens.png +0 -0
- package/docs/screenshots/guide-settings.png +0 -0
- package/docs/screenshots/guide-workspace-page.png +0 -0
- package/docs/screenshots/home/card-settings.png +0 -0
- package/docs/screenshots/home/drag-reorder.png +0 -0
- package/docs/screenshots/home/edit-card.png +0 -0
- package/docs/screenshots/images/claude-code-image.png +0 -0
- package/docs/screenshots/images/edit-with-ai.png +0 -0
- package/docs/screenshots/images/gallery.png +0 -0
- package/docs/screenshots/images/generate-description.png +0 -0
- package/docs/screenshots/images/generate-plan.png +0 -0
- package/docs/screenshots/images/providers.png +0 -0
- package/docs/screenshots/images/svg-form.png +0 -0
- package/docs/screenshots/images/svg-result.png +0 -0
- package/docs/screenshots/images/upload.png +0 -0
- package/docs/screenshots/new-folder/2-form.png +0 -0
- package/docs/screenshots/new-folder/8-ddev-options.png +0 -0
- package/docs/screenshots/new-folder/files-folder.png +0 -0
- package/docs/screenshots/projects/ddev-custom-commands.png +0 -0
- package/docs/screenshots/projects/state-chip-stop.png +0 -0
- package/docs/screenshots/readme/dashboard-dark.png +0 -0
- package/docs/screenshots/readme/dashboard-light.png +0 -0
- package/docs/screenshots/readme/dev-dark.png +0 -0
- package/docs/screenshots/readme/dev-light.png +0 -0
- package/docs/screenshots/readme/docs-dark.png +0 -0
- package/docs/screenshots/readme/docs-light.png +0 -0
- package/docs/screenshots/readme/icons/ai.svg +4 -0
- package/docs/screenshots/readme/icons/code.svg +4 -0
- package/docs/screenshots/readme/icons/docs.svg +4 -0
- package/docs/screenshots/readme/icons/services.svg +4 -0
- package/docs/screenshots/readme/icons/sites.svg +4 -0
- package/docs/screenshots/readme/test-report-dark.png +0 -0
- package/docs/screenshots/readme/test-report-light.png +0 -0
- package/docs/screenshots/readme/test-scenarios.png +0 -0
- package/docs/screenshots/readme/test-steps.png +0 -0
- package/docs/screenshots/readme/videos-dark.png +0 -0
- package/docs/screenshots/readme/videos-light.png +0 -0
- package/docs/screenshots/screens-menu.png +0 -0
- package/docs/screenshots/screens-new.png +0 -0
- package/docs/screenshots/settings/auth-google-lists.png +0 -0
- package/docs/screenshots/settings/delete-1-row.png +0 -0
- package/docs/screenshots/settings/delete-2-confirm.png +0 -0
- package/docs/screenshots/settings/delete-3-type-name.png +0 -0
- package/docs/screenshots/settings/delete-4-type-yes.png +0 -0
- package/event-log.js +59 -0
- package/files-browser.js +247 -0
- package/folders.js +121 -0
- package/format.js +73 -0
- package/git-info.js +81 -0
- package/github-auth.js +62 -0
- package/graphs.js +104 -0
- package/html.js +10 -0
- package/icons.js +83 -0
- package/images-ai.js +447 -0
- package/images-create.js +226 -0
- package/images-page.js +417 -0
- package/images-records.js +39 -0
- package/images-render.js +36 -0
- package/images-svg-core.js +214 -0
- package/images-svg-sanitize.js +188 -0
- package/images-svg.js +241 -0
- package/images.js +270 -0
- package/jobs.js +430 -0
- package/linger.js +21 -0
- package/lists.js +292 -0
- package/media-info.js +136 -0
- package/navigation-page.js +264 -0
- package/navigation.js +394 -0
- package/notes.js +0 -0
- package/obsidian.js +405 -0
- package/package.json +47 -4
- package/palette-index.js +46 -0
- package/playwright.config.js +23 -0
- package/plugin-install.js +239 -0
- package/plugin-loader.js +448 -0
- package/plugin-manifest.js +83 -0
- package/plugin-settings.js +44 -0
- package/plugins/workspace-notes/index.js +59 -0
- package/plugins-page.js +103 -0
- package/project-make.js +241 -0
- package/project-name.js +210 -0
- package/public/assistant-avatar-moves.js +646 -0
- package/public/assistant-avatar-ui.js +1223 -0
- package/public/assistant-avatar.js +530 -0
- package/public/avatar/druplicon.vrm +0 -0
- package/public/css/base.css +260 -0
- package/public/css/components/ai-assistant.css +263 -0
- package/public/css/components/assistant-avatar.css +279 -0
- package/public/css/components/assistant-views.css +198 -0
- package/public/css/components/build-order.css +74 -0
- package/public/css/components/clone-as-a-git-worktree.css +30 -0
- package/public/css/components/command-palette.css +61 -0
- package/public/css/components/commands-center.css +139 -0
- package/public/css/components/confirm-dialog.css +40 -0
- package/public/css/components/create-card.css +8 -0
- package/public/css/components/ddev-menu.css +23 -0
- package/public/css/components/deep-chat.css +17 -0
- package/public/css/components/density.css +11 -0
- package/public/css/components/floating-assistant-workspace-pages.css +55 -0
- package/public/css/components/folders.css +5 -0
- package/public/css/components/full-height-left-assistant-sidebar-all-pages.css +39 -0
- package/public/css/components/full-height-right-actions-rail-pages-that-have-actions.css +267 -0
- package/public/css/components/grid-and-list.css +203 -0
- package/public/css/components/help-page.css +36 -0
- package/public/css/components/hero.css +16 -0
- package/public/css/components/images.css +70 -0
- package/public/css/components/layout-rhythm-and-spacing-polish.css +139 -0
- package/public/css/components/mic-on-any-field-that-wants-typing.css +416 -0
- package/public/css/components/modal-fit.css +113 -0
- package/public/css/components/page-width.css +23 -0
- package/public/css/components/plugins-page.css +9 -0
- package/public/css/components/project-cards.css +256 -0
- package/public/css/components/project-rows.css +390 -0
- package/public/css/components/reordering-the-workspace-cards.css +85 -0
- package/public/css/components/responsive-home-and-the-workspace-pages.css +457 -0
- package/public/css/components/running-commands-as-toasts.css +327 -0
- package/public/css/components/settings-editor-core-config-yml.css +828 -0
- package/public/css/components/short-results-as-a-toast-at-the-top.css +203 -0
- package/public/css/components/sound-player.css +84 -0
- package/public/css/components/suggest-agents-skills-commands.css +112 -0
- package/public/css/components/tab-modal.css +142 -0
- package/public/css/components/terminals.css +244 -0
- package/public/css/components/test-report-media-one-file-per-row-paged.css +144 -0
- package/public/css/components/top-toolbar-logo-breadcrumb.css +107 -0
- package/public/css/components/video-edit.css +6 -0
- package/public/css/components/web-ide-open-with.css +23 -0
- package/public/css/components/working-indicators.css +158 -0
- package/public/css/components/workspace-cards.css +79 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.png +0 -0
- package/public/htmx.min.js +1 -0
- package/public/logo.png +0 -0
- package/public/media-player.js +215 -0
- package/public/mic.js +255 -0
- package/public/narration.js +110 -0
- package/public/palette.js +278 -0
- package/public/sortable.js +251 -0
- package/public/suggest.js +337 -0
- package/public/terminal.js +903 -0
- package/public/themes/default/theme.css +267 -0
- package/public/themes/ember/theme.css +180 -0
- package/public/themes/press/theme.css +290 -0
- package/public/themes/slate/theme.css +182 -0
- package/public/touch-icon.png +0 -0
- package/public/ui.js +2721 -0
- package/public/vendor/ace/LICENSE.txt +24 -0
- package/public/vendor/ace/ace.js +23 -0
- package/public/vendor/ace/ext-language_tools.js +8 -0
- package/public/vendor/ace/ext-searchbox.js +8 -0
- package/public/vendor/ace/mode-css.js +8 -0
- package/public/vendor/ace/mode-html.js +8 -0
- package/public/vendor/ace/mode-javascript.js +8 -0
- package/public/vendor/ace/mode-markdown.js +8 -0
- package/public/vendor/ace/mode-sh.js +8 -0
- package/public/vendor/ace/mode-yaml.js +8 -0
- package/public/vendor/ace/theme-textmate.js +8 -0
- package/public/vendor/ace/theme-tomorrow_night.js +8 -0
- package/public/vendor/ace/worker-yaml.js +1 -0
- package/public/vendor/deep-chat.LICENSE +21 -0
- package/public/vendor/deep-chat.bundle.js +1 -0
- package/public/vendor/fonts/dm-serif-text-LICENSE.txt +93 -0
- package/public/vendor/fonts/dm-serif-text-latin-400-italic.woff2 +0 -0
- package/public/vendor/fonts/dm-serif-text-latin-400-normal.woff2 +0 -0
- package/public/vendor/fonts/figtree-LICENSE.txt +93 -0
- package/public/vendor/fonts/figtree-latin-400-italic.woff2 +0 -0
- package/public/vendor/fonts/figtree-latin-400-normal.woff2 +0 -0
- package/public/vendor/fonts/figtree-latin-600-italic.woff2 +0 -0
- package/public/vendor/fonts/figtree-latin-600-normal.woff2 +0 -0
- package/public/vendor/fonts/jetbrains-mono-LICENSE.txt +93 -0
- package/public/vendor/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
- package/public/vendor/fonts/ubuntu-mono-LICENSE.txt +98 -0
- package/public/vendor/fonts/ubuntu-mono-latin-wght-normal.woff2 +0 -0
- package/public/vendor/tabler-LICENSE.txt +21 -0
- package/public/vendor/tabler-sprite.svg +1 -0
- package/public/vendor/three/LICENSE +21 -0
- package/public/vendor/three/addons/loaders/GLTFLoader.js +4860 -0
- package/public/vendor/three/addons/utils/BufferGeometryUtils.js +1501 -0
- package/public/vendor/three/addons/utils/SkeletonUtils.js +496 -0
- package/public/vendor/three/three.core.min.js +6 -0
- package/public/vendor/three/three.module.min.js +6 -0
- package/public/vendor/three-vrm/LICENSE +22 -0
- package/public/vendor/three-vrm/three-vrm.module.min.js +983 -0
- package/public/vendor/uikit-icons.min.js +1 -0
- package/public/vendor/uikit.min.css +1 -0
- package/public/vendor/uikit.min.js +1 -0
- package/public/vendor/webship-icons/ui/bookmark-outlined.svg +1 -0
- package/public/vendor/webship-icons/ui/headset.svg +1 -0
- package/public/vendor/webship-icons/ui/menu.svg +1 -0
- package/public/vendor/webship-icons/ui/pause.svg +1 -0
- package/public/vendor/webship-icons/ui/play.svg +1 -0
- package/public/vendor/webship-icons/ui/share.svg +1 -0
- package/public/vendor/webship-icons/ui/sound-off.svg +1 -0
- package/public/vendor/webship-icons/ui/sound-on.svg +1 -0
- package/public/vendor/webship-icons/ui/stop.svg +1 -0
- package/public/vendor/xterm/addon-fit.js +2 -0
- package/public/vendor/xterm/addon-unicode11.js +2 -0
- package/public/vendor/xterm/addon-web-links.js +2 -0
- package/public/vendor/xterm/xterm.css +218 -0
- package/public/vendor/xterm/xterm.js +2 -0
- package/rag.js +169 -0
- package/reader.js +132 -0
- package/repo-blobs.js +66 -0
- package/report-pdf.js +337 -0
- package/row-layout.js +1440 -0
- package/screenrc +37 -0
- package/screens.js +1063 -0
- package/scripts/code-server-start.sh +190 -0
- package/scripts/images-claude-finish.js +107 -0
- package/scripts/screen-host.sh +114 -0
- package/scripts/screen-runner.js +362 -0
- package/scripts/screen-then.js +47 -0
- package/scripts/update.sh +350 -0
- package/server.js +8140 -0
- package/settings-file.js +423 -0
- package/speech.js +297 -0
- package/tab-modal.js +75 -0
- package/test-reports.js +260 -0
- package/themes.js +381 -0
- package/transcripts.js +168 -0
- package/urls.js +13 -0
- package/validate.js +18 -0
- package/version-check.js +94 -0
- package/video-edit.js +588 -0
- package/waiting.js +85 -0
- package/workspace-card-tools.js +41 -0
- package/workspace-delete.js +139 -0
- package/workspace-types.js +85 -0
- package/workspaces.js +557 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Edit a video into a new version: the tabs of the Edit modal post here.
|
|
3
|
+
*
|
|
4
|
+
* The form is `video-edit.js`; the work is `videos/cmd-tools-video-edit.sh`, run on the host in a screen
|
|
5
|
+
* session like every other long action. Only a new section spoken with ElevenLabs spends credits, and that
|
|
6
|
+
* needs `confirm=yes`, which only the confirm dialog's button sends.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
|
|
12
|
+
const { esc } = require('../html');
|
|
13
|
+
const { workspaceDir } = require('../workspaces');
|
|
14
|
+
const { startInScreen, screenStartedHtml } = require('../jobs');
|
|
15
|
+
const voice = require('../audio-voice');
|
|
16
|
+
const videoEdit = require('../video-edit');
|
|
17
|
+
|
|
18
|
+
async function video_edit({ form, send, workspace }) {
|
|
19
|
+
if (workspace !== 'videos') return send('<div class="msg error">Videos are edited in the videos workspace.</div>', 400);
|
|
20
|
+
const found = form.mode === 'sections' ? await voice.loadEngines() : { engines: [] };
|
|
21
|
+
const p = videoEdit.plan(form, found.engines);
|
|
22
|
+
if (p.error) return send(`<div class="msg error">${esc(p.error)}</div>`, 400);
|
|
23
|
+
if (p.spends && form.confirm !== 'yes') return send(videoEdit.spendHtml(p));
|
|
24
|
+
const stringsFile = Object.keys(p.strings).length ? videoEdit.writeStrings(p.strings) : null;
|
|
25
|
+
const opts = { workspace, project: path.basename(p.out, '.mp4'), projectDir: workspaceDir('videos'), verb: 'edit',
|
|
26
|
+
cmdLine: videoEdit.commandLine(p, stringsFile, true), label: `video edit ${p.out}` };
|
|
27
|
+
const started = await startInScreen(opts);
|
|
28
|
+
if (!started.ok) {
|
|
29
|
+
if (stringsFile) fs.rmSync(path.dirname(stringsFile), { recursive: true, force: true });
|
|
30
|
+
return send(screenStartedHtml(started, opts.label, opts.project), started.already ? 409 : 500);
|
|
31
|
+
}
|
|
32
|
+
return send(voice.progressHtml({ kind: 'video', file: p.out, session: started.session, t0: Date.now(), engine: '', edit: '1' }, null));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Ask the AI to make the change: a Claude Code screen in the videos folder, briefed with the video, its script,
|
|
36
|
+
// the tools, the rules, the settings already chosen on the tab, and the person's own words.
|
|
37
|
+
async function video_edit_ai({ form, send, res }) {
|
|
38
|
+
const brief = videoEdit.askBrief(form);
|
|
39
|
+
if (brief.error) return send(`<div class="msg error">${esc(brief.error)}</div>`, 400);
|
|
40
|
+
let out = null;
|
|
41
|
+
await require('./screens')['/actions/screen-new']({
|
|
42
|
+
form: { target: brief.target, kind: 'claude', session: brief.session, command: brief.prompt },
|
|
43
|
+
send: (html, status = 200) => { out = { html, status }; },
|
|
44
|
+
res,
|
|
45
|
+
});
|
|
46
|
+
if (!out || out.status >= 400) return send((out && out.html) || '<div class="msg error">The session did not start.</div>', (out && out.status) || 500);
|
|
47
|
+
const sessionName = `${brief.target.startsWith('p:') ? brief.target.slice(2).replace(':', '--') : 'videos'}--${brief.session}`.toLowerCase();
|
|
48
|
+
return send(`<div class="msg assistant">
|
|
49
|
+
<p>The AI (Claude Code) is working on <strong>${esc(brief.file)}</strong> in the session <code>${esc(sessionName)}</code>.</p>
|
|
50
|
+
<p class="uk-text-meta">Watch it, answer it and approve its steps on <a href="/screens">Screens</a>, or <code>screen -r ${esc(sessionName)}</code> in your own terminal.
|
|
51
|
+
The new version appears in the list when it is done; the original is not touched.</p></div>`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
module.exports = { '/actions/video-edit': video_edit, '/actions/video-edit-ai': video_edit_ai };
|
package/ai-inventory.js
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What "@" and "/" can offer, and what the AI Assistant is told it has.
|
|
3
|
+
*
|
|
4
|
+
* ONE inventory, read by three callers that used to disagree or not know at all:
|
|
5
|
+
*
|
|
6
|
+
* - the suggestion popup in any prompt field (@ for agents and skills, / for commands),
|
|
7
|
+
* - the Ace completer on the New screen form,
|
|
8
|
+
* - the assistant's own system prompt, which never listed these at all — so it could
|
|
9
|
+
* not use a skill it had, and would improvise instead of invoking one.
|
|
10
|
+
*
|
|
11
|
+
* The DESCRIPTION is why this is not just a directory listing. Claude Code chooses an
|
|
12
|
+
* agent or a skill by its frontmatter `description`, and a picker that shows only names
|
|
13
|
+
* makes the reader guess between `webship-workspace-build` and `webship-workspace-backups`.
|
|
14
|
+
* The same line is what lets the assistant pick correctly, so both read it from the file
|
|
15
|
+
* rather than from a hand-kept table that would drift.
|
|
16
|
+
*
|
|
17
|
+
* Three places hold items, and precedence runs the same way Claude Code resolves them:
|
|
18
|
+
* a project's own .claude/ wins over ~/.claude, which wins over the workspace draft
|
|
19
|
+
* folders (ai-agents/, ai-skills/, ai-commands/, ai-prompts/). `where` says which, so
|
|
20
|
+
* the list can show it and the assistant knows whether an item is actually installed.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const fs = require('fs');
|
|
24
|
+
const path = require('path');
|
|
25
|
+
|
|
26
|
+
const { ROOT } = require('./workspaces');
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The workspace tooling itself: `webship-workspace` and its skills (-build, -backups,
|
|
30
|
+
* -graphify, -dashboard, -authoring, -site-templates, -upsun, -linux-install).
|
|
31
|
+
*
|
|
32
|
+
* These are BUILT IN to the AI Assistant — it already operates this workspace, and its
|
|
33
|
+
* system prompt carries these rules whether anybody names them or not. So offering them
|
|
34
|
+
* in the picker is noise at best and misleading at worst: it implies that building a
|
|
35
|
+
* project needs you to remember `@webship-workspace-build` first, when asking for a build
|
|
36
|
+
* is enough. Nine rows out of 135, and they crowd the top of the list because they share
|
|
37
|
+
* a prefix with the product's own name.
|
|
38
|
+
*
|
|
39
|
+
* They stay in the inventory — the assistant is still told it has them, so it can say
|
|
40
|
+
* which one it is following — and are filtered out of the SUGGESTIONS only.
|
|
41
|
+
*/
|
|
42
|
+
const BUILT_IN = /^webship-workspace(-|$)/;
|
|
43
|
+
|
|
44
|
+
// Kind -> which trigger character offers it.
|
|
45
|
+
//
|
|
46
|
+
// This mirrors the CLI rather than inventing a scheme: in Claude Code a slash command is
|
|
47
|
+
// typed as `/name`, and an agent is named in prose. Skills appear under BOTH here on
|
|
48
|
+
// purpose — a skill is a capability you name (@) and also the thing you most often want
|
|
49
|
+
// to invoke directly (/) — and prompts under neither, because a prompt is a whole
|
|
50
|
+
// `claude …` launch command for a NEW session, so it cannot be dropped into a message
|
|
51
|
+
// that is already running. The New screen form offers those in its own template picker.
|
|
52
|
+
const TRIGGERS = {
|
|
53
|
+
agent: ['@'],
|
|
54
|
+
skill: ['@', '/'],
|
|
55
|
+
command: ['/'],
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// A frontmatter description, as one line.
|
|
59
|
+
//
|
|
60
|
+
// Read from the head of the file only: a skill can be hundreds of lines and none of the
|
|
61
|
+
// rest is frontmatter. The value may be folded over several lines in YAML, so a
|
|
62
|
+
// continuation (indented, no `key:` of its own) is joined back on.
|
|
63
|
+
const HEAD_BYTES = 4096;
|
|
64
|
+
function descriptionOf(file) {
|
|
65
|
+
let head = '';
|
|
66
|
+
try {
|
|
67
|
+
const fd = fs.openSync(file, 'r');
|
|
68
|
+
const buf = Buffer.alloc(HEAD_BYTES);
|
|
69
|
+
const read = fs.readSync(fd, buf, 0, HEAD_BYTES, 0);
|
|
70
|
+
fs.closeSync(fd);
|
|
71
|
+
head = buf.slice(0, read).toString('utf8');
|
|
72
|
+
} catch (_) { return ''; }
|
|
73
|
+
if (!/^---\s*$/m.test(head.split('\n')[0] || '')) return '';
|
|
74
|
+
const lines = head.split('\n').slice(1);
|
|
75
|
+
let out = '';
|
|
76
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
77
|
+
const line = lines[i];
|
|
78
|
+
if (/^---\s*$/.test(line)) break;
|
|
79
|
+
const m = line.match(/^description:\s*(.*)$/);
|
|
80
|
+
if (!m) continue;
|
|
81
|
+
out = m[1].trim().replace(/^["']|["']$/g, '');
|
|
82
|
+
// A folded value: following lines that are indented and are not themselves a key.
|
|
83
|
+
for (let j = i + 1; j < lines.length; j += 1) {
|
|
84
|
+
if (/^---\s*$/.test(lines[j])) break;
|
|
85
|
+
if (/^\s+\S/.test(lines[j]) && !/^\s*[a-zA-Z_-]+:/.test(lines[j])) {
|
|
86
|
+
out += ` ${lines[j].trim()}`;
|
|
87
|
+
} else break;
|
|
88
|
+
}
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
return out.replace(/\s+/g, ' ').trim();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Items of one kind in one directory. Skills are directories with a SKILL.md. */
|
|
95
|
+
function readKind(dir, kind, where, out) {
|
|
96
|
+
let entries = [];
|
|
97
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { return; }
|
|
98
|
+
for (const e of entries) {
|
|
99
|
+
let name = '';
|
|
100
|
+
let file = '';
|
|
101
|
+
if (kind === 'skill') {
|
|
102
|
+
if (!e.isDirectory()) continue;
|
|
103
|
+
file = path.join(dir, e.name, 'SKILL.md');
|
|
104
|
+
if (!fs.existsSync(file)) continue;
|
|
105
|
+
name = e.name;
|
|
106
|
+
} else {
|
|
107
|
+
if (!e.isFile() || !e.name.endsWith('.md')) continue;
|
|
108
|
+
// A folder's own documentation is not an item in it.
|
|
109
|
+
if (['README.md', 'AGENTS.md', 'CLAUDE.md'].includes(e.name)) continue;
|
|
110
|
+
name = e.name.replace(/\.md$/, '');
|
|
111
|
+
file = path.join(dir, e.name);
|
|
112
|
+
}
|
|
113
|
+
const key = `${kind}:${name}`;
|
|
114
|
+
// First writer wins, and callers add in precedence order: a project's own copy of an
|
|
115
|
+
// item is the one that would actually run there, so it must not be overwritten by
|
|
116
|
+
// the global one found later.
|
|
117
|
+
if (out.has(key)) continue;
|
|
118
|
+
out.set(key, { name, kind, where, file, description: descriptionOf(file),
|
|
119
|
+
triggers: TRIGGERS[kind] || [], builtIn: BUILT_IN.test(name) });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// The filesystem is walked per request otherwise, and a keystroke in the composer asks
|
|
124
|
+
// for this list. Short enough that installing a skill shows up while you are looking.
|
|
125
|
+
const TTL = 5000;
|
|
126
|
+
const _cache = new Map();
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Everything "@" and "/" can offer here.
|
|
130
|
+
* @param {string} [workspace] the workspace key, when a page is scoped to one
|
|
131
|
+
* @param {string} [project] a project inside it, whose own .claude/ takes precedence
|
|
132
|
+
*/
|
|
133
|
+
function aiInventory(workspace, project) {
|
|
134
|
+
const ck = `${workspace || ''}/${project || ''}`;
|
|
135
|
+
const hit = _cache.get(ck);
|
|
136
|
+
if (hit && Date.now() - hit.at < TTL) return hit.items;
|
|
137
|
+
|
|
138
|
+
const home = process.env.HOME || '';
|
|
139
|
+
const items = new Map();
|
|
140
|
+
|
|
141
|
+
// Precedence, highest first.
|
|
142
|
+
if (workspace && project) {
|
|
143
|
+
const pDir = path.join(ROOT, workspace, project, '.claude');
|
|
144
|
+
readKind(path.join(pDir, 'agents'), 'agent', 'this project', items);
|
|
145
|
+
readKind(path.join(pDir, 'skills'), 'skill', 'this project', items);
|
|
146
|
+
readKind(path.join(pDir, 'commands'), 'command', 'this project', items);
|
|
147
|
+
}
|
|
148
|
+
readKind(path.join(home, '.claude', 'agents'), 'agent', 'installed', items);
|
|
149
|
+
readKind(path.join(home, '.claude', 'skills'), 'skill', 'installed', items);
|
|
150
|
+
readKind(path.join(home, '.claude', 'commands'), 'command', 'installed', items);
|
|
151
|
+
// The workspace drafts. Anything only here is NOT loaded by a session yet, which the
|
|
152
|
+
// label has to say — otherwise a draft reads as available and silently does nothing.
|
|
153
|
+
readKind(path.join(ROOT, 'ai-agents'), 'agent', 'workspace draft', items);
|
|
154
|
+
readKind(path.join(ROOT, 'ai-skills'), 'skill', 'workspace draft', items);
|
|
155
|
+
readKind(path.join(ROOT, 'ai-commands'), 'command', 'workspace draft', items);
|
|
156
|
+
|
|
157
|
+
const list = [...items.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
158
|
+
_cache.set(ck, { at: Date.now(), items: list });
|
|
159
|
+
return list;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Just the items a trigger character offers, optionally filtered by what is typed. */
|
|
163
|
+
function suggestFor(trigger, typed, workspace, project) {
|
|
164
|
+
const q = String(typed || '').toLowerCase();
|
|
165
|
+
return aiInventory(workspace, project)
|
|
166
|
+
// The assistant's own tooling is not something to pick — see BUILT_IN above.
|
|
167
|
+
.filter((i) => !i.builtIn)
|
|
168
|
+
.filter((i) => i.triggers.includes(trigger))
|
|
169
|
+
.filter((i) => !q || i.name.toLowerCase().includes(q)
|
|
170
|
+
|| (i.description || '').toLowerCase().includes(q))
|
|
171
|
+
.sort((a, b) => {
|
|
172
|
+
// What the user is typing, first: a name that STARTS with it beats one that
|
|
173
|
+
// merely contains it, and a description match comes last.
|
|
174
|
+
const rank = (i) => (i.name.toLowerCase().startsWith(q) ? 0
|
|
175
|
+
: i.name.toLowerCase().includes(q) ? 1 : 2)
|
|
176
|
+
// then by how close to hand it is
|
|
177
|
+
+ (i.where === 'this project' ? 0 : i.where === 'installed' ? 0.1 : 0.2);
|
|
178
|
+
return rank(a) - rank(b) || a.name.localeCompare(b.name);
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
module.exports = { aiInventory, suggestFor, descriptionOf, TRIGGERS, BUILT_IN };
|
package/ai-items.js
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The AI-items domain: agents, skills and prompts, in their three places —
|
|
3
|
+
* this workspace's folders (drafts), ~/.claude (what a session loads) and the
|
|
4
|
+
* shared repo (what the team has) — plus the git-blob comparison that says
|
|
5
|
+
* when the repo's copy differs from the folder's. The handlers live in
|
|
6
|
+
* actions/ai-items.js; the rendering in server.js imports the same primitives,
|
|
7
|
+
* so the buttons and the CLI can never disagree about where things are.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
|
|
13
|
+
const { loadWorkspaces, workspaceDir, sourceOf } = require('./workspaces');
|
|
14
|
+
const { repoTree, invalidateRepoTree, gitBlobSha } = require('./repo-blobs');
|
|
15
|
+
|
|
16
|
+
/* ---------------- file-item workspaces (agents, skills, prompts, docs) --- */
|
|
17
|
+
|
|
18
|
+
// Where each editable kind installs into the live Claude Code CLI setup.
|
|
19
|
+
const INSTALL_TARGETS = {
|
|
20
|
+
'ai-agents': path.join(process.env.HOME, '.claude', 'agents'),
|
|
21
|
+
'ai-skills': path.join(process.env.HOME, '.claude', 'skills'),
|
|
22
|
+
// Commands and prompts are two kinds that share one destination: a command is a
|
|
23
|
+
// $ARGUMENTS spec typed mid-session, a prompt is a whole `claude …` launch command
|
|
24
|
+
// for a new session. Installing one writes over the other where names collide.
|
|
25
|
+
'ai-commands': path.join(process.env.HOME, '.claude', 'commands'),
|
|
26
|
+
'ai-prompts': path.join(process.env.HOME, '.claude', 'commands'),
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// Agents, skills and prompts exist in three places: this workspace folder
|
|
30
|
+
// (drafts), ~/.claude (what a session loads) and webship/ai-agents (what
|
|
31
|
+
// the team has). The item lists show all three, merged by name, so it is
|
|
32
|
+
// obvious what is only local, only installed, or only in the repo.
|
|
33
|
+
const AI_ITEMS_REPO = () => process.env.AI_ITEMS_REPO || sourceOf('ai_items', 'repo', 'webship/ai-agents');
|
|
34
|
+
const AI_ITEMS_REF = () => process.env.AI_ITEMS_REF || sourceOf('ai_items', 'ref', 'main');
|
|
35
|
+
// A prompt is the whole `claude …` launch command for a NEW session, so the only way
|
|
36
|
+
// to use one is to start a session with it — the dashboard's New screen form, or the
|
|
37
|
+
// CLI by hand. It is not a slash command, so nothing of a prompt's syncs into ~/.claude
|
|
38
|
+
// or a project's .claude/. It keeps its INSTALL_TARGETS entry only so listClaudeItems
|
|
39
|
+
// can still SHOW what a machine already has there.
|
|
40
|
+
// AI Commands is the kind that DOES install: same destination, different shape.
|
|
41
|
+
const NO_CLAUDE_SYNC = new Set(['ai-prompts']);
|
|
42
|
+
|
|
43
|
+
// Where each kind sits IN THE REPO. Prompts are the one kind whose directory name
|
|
44
|
+
// differs from the CLI's: webship/ai-agents keeps them at the top level, in
|
|
45
|
+
// prompts/, beside the agents and skills they invoke.
|
|
46
|
+
const AI_ITEMS_REPO_PATHS = { 'ai-agents': '.claude/agents', 'ai-skills': '.claude/skills',
|
|
47
|
+
'ai-commands': '.claude/commands', 'ai-prompts': 'prompts' };
|
|
48
|
+
// Where each kind installs INSIDE A PROJECT — always the project's own .claude/,
|
|
49
|
+
// which is what Claude Code reads there. Not the same map as above: a project's
|
|
50
|
+
// prompts go to .claude/commands, never to a prompts/ directory of its own.
|
|
51
|
+
const AI_ITEMS_PROJECT_PATHS = { 'ai-agents': '.claude/agents', 'ai-skills': '.claude/skills',
|
|
52
|
+
'ai-commands': '.claude/commands', 'ai-prompts': '.claude/commands' };
|
|
53
|
+
|
|
54
|
+
// What ~/.claude currently has, by kind.
|
|
55
|
+
function listClaudeItems(key) {
|
|
56
|
+
const dir = INSTALL_TARGETS[key];
|
|
57
|
+
if (!dir) return new Set();
|
|
58
|
+
try {
|
|
59
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
60
|
+
if (key === 'ai-skills') {
|
|
61
|
+
return new Set(entries.filter((e) => e.isDirectory()
|
|
62
|
+
&& fs.existsSync(path.join(dir, e.name, 'SKILL.md'))).map((e) => e.name));
|
|
63
|
+
}
|
|
64
|
+
return new Set(entries.filter((e) => e.isFile() && e.name.endsWith('.md')
|
|
65
|
+
&& !['README.md', 'AGENTS.md', 'CLAUDE.md'].includes(e.name))
|
|
66
|
+
.map((e) => e.name.replace(/\.md$/, '')));
|
|
67
|
+
} catch (_) { return new Set(); }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Blob shas for every shared item, grouped by name, from the ONE cached recursive
|
|
71
|
+
// Trees call repo-blobs makes per repo@ref — the same identity git itself uses, so
|
|
72
|
+
// comparing them against locally computed blob shas answers "does the repo's copy
|
|
73
|
+
// differ from this folder's?" without fetching a single file body. `null` means
|
|
74
|
+
// the repo was unreachable (an unauthenticated container), which the UI states
|
|
75
|
+
// rather than implying the repo has nothing.
|
|
76
|
+
async function repoItemBlobs(key) {
|
|
77
|
+
if (!AI_ITEMS_REPO_PATHS[key]) return null;
|
|
78
|
+
const tree = await repoTree(AI_ITEMS_REPO(), AI_ITEMS_REF());
|
|
79
|
+
if (!tree) return null;
|
|
80
|
+
const prefix = `${AI_ITEMS_REPO_PATHS[key]}/`;
|
|
81
|
+
const map = new Map(); // item name -> Map<path-inside-item, blob sha>
|
|
82
|
+
for (const [p, sha] of tree) {
|
|
83
|
+
if (!p.startsWith(prefix)) continue;
|
|
84
|
+
const rest = p.slice(prefix.length);
|
|
85
|
+
let name; let inner;
|
|
86
|
+
if (key === 'ai-skills') {
|
|
87
|
+
const slash = rest.indexOf('/');
|
|
88
|
+
if (slash < 1) continue;
|
|
89
|
+
name = rest.slice(0, slash);
|
|
90
|
+
inner = rest.slice(slash + 1);
|
|
91
|
+
} else {
|
|
92
|
+
if (!rest.endsWith('.md') || rest.includes('/')) continue;
|
|
93
|
+
name = rest.slice(0, -3);
|
|
94
|
+
inner = '';
|
|
95
|
+
}
|
|
96
|
+
if (['README', 'AGENTS', 'CLAUDE', 'RULES'].includes(name)) continue;
|
|
97
|
+
if (!map.has(name)) map.set(name, new Map());
|
|
98
|
+
map.get(name).set(inner, sha);
|
|
99
|
+
}
|
|
100
|
+
return map;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// The names alone — the same tree, so a page listing names AND comparing blobs
|
|
104
|
+
// still costs one network call, not two.
|
|
105
|
+
async function listRepoItems(key) {
|
|
106
|
+
const blobs = await repoItemBlobs(key);
|
|
107
|
+
return blobs === null ? null : new Set(blobs.keys());
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Does the repo's copy of one item differ from this folder's? null = not comparable
|
|
111
|
+
// (not in the repo, or the tree was unreachable).
|
|
112
|
+
function repoDiffers(key, name, repoBlobs) {
|
|
113
|
+
if (!repoBlobs || !repoBlobs.has(name)) return null;
|
|
114
|
+
const theirs = repoBlobs.get(name);
|
|
115
|
+
const dir = workspaceDir(key);
|
|
116
|
+
try {
|
|
117
|
+
if (key !== 'ai-skills') {
|
|
118
|
+
return theirs.get('') !== gitBlobSha(path.join(dir, `${name}.md`));
|
|
119
|
+
}
|
|
120
|
+
// A skill is a directory: same files with the same blob shas, or it differs.
|
|
121
|
+
const base = path.join(dir, name);
|
|
122
|
+
const mine = new Map();
|
|
123
|
+
const walk = (d, rel) => {
|
|
124
|
+
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
|
|
125
|
+
if (e.name === '.git') continue;
|
|
126
|
+
const full = path.join(d, e.name);
|
|
127
|
+
const r = rel ? `${rel}/${e.name}` : e.name;
|
|
128
|
+
if (e.isDirectory()) walk(full, r);
|
|
129
|
+
else if (e.isFile()) mine.set(r, gitBlobSha(full));
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
walk(base, '');
|
|
133
|
+
if (mine.size !== theirs.size) return true;
|
|
134
|
+
for (const [rel, sha] of theirs) if (mine.get(rel) !== sha) return true;
|
|
135
|
+
return false;
|
|
136
|
+
} catch (_) {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const ITEM_TEMPLATES = {
|
|
142
|
+
agents: (name) => `---
|
|
143
|
+
name: ${name}
|
|
144
|
+
description: Use this agent to <when to invoke it>.
|
|
145
|
+
tools:
|
|
146
|
+
- Bash
|
|
147
|
+
- Read
|
|
148
|
+
- Glob
|
|
149
|
+
- Grep
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
# ${name}
|
|
153
|
+
|
|
154
|
+
You are the specialist agent for <what this agent does>.
|
|
155
|
+
|
|
156
|
+
## Instructions
|
|
157
|
+
|
|
158
|
+
- <how to work>
|
|
159
|
+
`,
|
|
160
|
+
skills: (name) => `---
|
|
161
|
+
name: ${name}
|
|
162
|
+
description: <what this skill does and when to use it>
|
|
163
|
+
---
|
|
164
|
+
|
|
165
|
+
# ${name}
|
|
166
|
+
|
|
167
|
+
## Instructions
|
|
168
|
+
|
|
169
|
+
- <steps the skill follows>
|
|
170
|
+
`,
|
|
171
|
+
prompts: (name) => `<Write the reusable prompt here — installing it makes it available in Claude Code as /${name}>
|
|
172
|
+
`,
|
|
173
|
+
docs: (name) => `# ${name}
|
|
174
|
+
|
|
175
|
+
<Write the document here — Markdown; use "Make PDF" to render a PDF next to it.>
|
|
176
|
+
`,
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
// The file that holds an item's content, given its display name.
|
|
180
|
+
function itemFile(key, name) {
|
|
181
|
+
const dir = workspaceDir(key);
|
|
182
|
+
if (key === 'ai-skills') return path.join(dir, name, 'SKILL.md');
|
|
183
|
+
return path.join(dir, `${name}.md`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Where an install can land besides the global ~/.claude: any real project, so a session started
|
|
187
|
+
// under it (and only there) sees the item. Site workspaces only — a "project" in `docs/` or
|
|
188
|
+
// `agents/` itself would be another item, not something Claude Code scopes to.
|
|
189
|
+
function installTargetWorkspaces() {
|
|
190
|
+
return Object.values(loadWorkspaces()).filter((w) => w.kind !== 'files');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Every write toward the repo or the folders makes the cached tree stale.
|
|
194
|
+
function invalidateRepoCaches() {
|
|
195
|
+
invalidateRepoTree(AI_ITEMS_REPO());
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
module.exports = {
|
|
199
|
+
INSTALL_TARGETS, NO_CLAUDE_SYNC, AI_ITEMS_REPO, AI_ITEMS_REF, AI_ITEMS_REPO_PATHS, AI_ITEMS_PROJECT_PATHS,
|
|
200
|
+
listClaudeItems, listRepoItems, repoItemBlobs, gitBlobSha, repoDiffers,
|
|
201
|
+
ITEM_TEMPLATES, itemFile, installTargetWorkspaces, invalidateRepoCaches,
|
|
202
|
+
};
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who the assistant's avatar can be, and which voices it can speak in.
|
|
3
|
+
*
|
|
4
|
+
* Avatars. Only the ones named in settings.yml `assistant.avatars:` are offered, so a machine shows
|
|
5
|
+
* what its owner chose and nothing else. `druplicon` ships with the dashboard; any other name is looked
|
|
6
|
+
* up in the avatars workspace (`<root>/avatars/<project>/`): a character file there
|
|
7
|
+
* (`characters/<name>.json` or `characters/osa-<name>.json`) gives its model, display name and credit,
|
|
8
|
+
* and a bare model (`public/models/<name>.vrm`) is enough on its own. The models stay where they are and
|
|
9
|
+
* are served from there, never copied into this repository: their licences are their own, and some
|
|
10
|
+
* (fan-made ones) are for personal use only.
|
|
11
|
+
*
|
|
12
|
+
* Voices. Besides the browser's own voices, the ElevenLabs voices of the account in settings.yml
|
|
13
|
+
* `elevenlabs:`, once a real key is there. Listing them is free; speaking with one spends credits, which
|
|
14
|
+
* is why it happens only when someone picks an ElevenLabs voice. The key never leaves this server: the
|
|
15
|
+
* page posts the words and gets the audio back. What was spoken is kept in `.voice-cache/`, so a
|
|
16
|
+
* sentence heard again costs nothing.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const crypto = require('crypto');
|
|
20
|
+
const fs = require('fs');
|
|
21
|
+
const path = require('path');
|
|
22
|
+
|
|
23
|
+
const DEFAULT_MODEL = 'eleven_multilingual_v2';
|
|
24
|
+
const CACHE_DIR = path.join(__dirname, '.voice-cache');
|
|
25
|
+
const CACHE_MAX = 400;
|
|
26
|
+
const MAX_TEXT = 600;
|
|
27
|
+
const BASES = {
|
|
28
|
+
us: 'https://api.us.elevenlabs.io',
|
|
29
|
+
eu: 'https://api.eu.residency.elevenlabs.io',
|
|
30
|
+
in: 'https://api.in.residency.elevenlabs.io',
|
|
31
|
+
sg: 'https://api.sg.residency.elevenlabs.io',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const NAME_RE = /^[a-z0-9][a-z0-9_-]{0,62}$/;
|
|
35
|
+
// The avatars that ship with the dashboard, in public/avatar/.
|
|
36
|
+
const BUILT_IN = {
|
|
37
|
+
druplicon: { name: 'Druplicon', model: '/avatar/druplicon.vrm',
|
|
38
|
+
credit: 'Druplicon, the Drupal drop, built in code. Druplicon is licensed under the GPL; Drupal is a registered trademark of Dries Buytaert.' },
|
|
39
|
+
};
|
|
40
|
+
const titleOf = (id) => id.replace(/^osa-/, '').split(/[-_]/).filter(Boolean).map((w) => w[0].toUpperCase() + w.slice(1)).join(' ');
|
|
41
|
+
|
|
42
|
+
/** The set of moves an avatar does beyond the shared ones (public/assistant-avatar-moves.js), or ''. */
|
|
43
|
+
const moveSetOf = (id) => (/^labiba/.test(String(id || '')) ? 'labiba' : '');
|
|
44
|
+
|
|
45
|
+
/** The hair colour an avatar starts with (settings.yml assistant.avatar_hair.<id>), as #rrggbb, or ''. */
|
|
46
|
+
function hairOf(settings, id) {
|
|
47
|
+
const v = String(((settings.assistant || {}).avatar_hair || {})[id] || '').trim().replace(/^['"]|['"]$/g, '');
|
|
48
|
+
return /^#[0-9a-f]{6}$/i.test(v) ? v.toLowerCase() : '';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The Claude Code agent an avatar brings (settings.yml assistant.avatar_agents.<id>), when it is
|
|
53
|
+
* installed for this user; '' keeps the assistant's own agent.
|
|
54
|
+
*/
|
|
55
|
+
function agentOf(settings, id, home = process.env.HOME || '') {
|
|
56
|
+
const name = String(((settings.assistant || {}).avatar_agents || {})[id] || '').trim();
|
|
57
|
+
return /^[a-z0-9][a-z0-9-]{0,62}$/.test(name) && fs.existsSync(path.join(home, '.claude', 'agents', `${name}.md`)) ? name : '';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The avatars settings.yml names, with what the page needs to show each one. Unknown names are left out. */
|
|
61
|
+
function avatarCast(settings = {}, root = '') {
|
|
62
|
+
const a = settings.assistant || {};
|
|
63
|
+
const named = Array.isArray(a.avatars) && a.avatars.length ? a.avatars : ['druplicon'];
|
|
64
|
+
const out = [];
|
|
65
|
+
for (const raw of named) {
|
|
66
|
+
const id = String(raw || '').trim().toLowerCase();
|
|
67
|
+
if (!NAME_RE.test(id) || id === 'none' || out.some((x) => x.id === id)) continue;
|
|
68
|
+
const more = { moves: moveSetOf(id), hair: hairOf(settings, id) };
|
|
69
|
+
// The model's link carries its file's time, so a rebuilt model is fetched at once rather than after
|
|
70
|
+
// the hour a browser may keep it.
|
|
71
|
+
if (BUILT_IN[id]) { out.push({ id, ...BUILT_IN[id], model: `${BUILT_IN[id].model}?v=${stamp(path.join(__dirname, 'public', BUILT_IN[id].model))}`, ...more }); continue; }
|
|
72
|
+
const found = findAvatar(id, root);
|
|
73
|
+
if (found) out.push({ id, name: found.name, model: `/avatar/model/${id}.vrm?v=${stamp(found.file)}`, credit: found.credit, ...more });
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const stamp = (file) => { try { return Math.round(fs.statSync(file).mtimeMs).toString(36); } catch (_) { return '0'; } };
|
|
79
|
+
|
|
80
|
+
/** The model file of an avatar, inside `<root>/avatars`, or null. */
|
|
81
|
+
function findAvatar(id, root) {
|
|
82
|
+
if (!NAME_RE.test(id) || !root) return null;
|
|
83
|
+
const base = path.join(root, 'avatars');
|
|
84
|
+
let projects = [];
|
|
85
|
+
try { projects = fs.readdirSync(base, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith('.')).map((e) => e.name).sort(); } catch (_) { return null; }
|
|
86
|
+
const inside = (file) => {
|
|
87
|
+
try {
|
|
88
|
+
const real = fs.realpathSync(file);
|
|
89
|
+
return real.startsWith(fs.realpathSync(base) + path.sep) && /\.vrm$/i.test(real) && fs.statSync(real).isFile() ? real : null;
|
|
90
|
+
} catch (_) { return null; }
|
|
91
|
+
};
|
|
92
|
+
for (const p of projects) {
|
|
93
|
+
for (const stem of [id, `osa-${id}`]) {
|
|
94
|
+
let c = null;
|
|
95
|
+
try { c = JSON.parse(fs.readFileSync(path.join(base, p, 'characters', `${stem}.json`), 'utf8')); } catch (_) { continue; }
|
|
96
|
+
const rel = String((c && c.model) || '').replace(/^\/+/, '');
|
|
97
|
+
if (!/^models\/[\w.-]+\.vrm$/i.test(rel)) continue;
|
|
98
|
+
const file = inside(path.join(base, p, 'public', rel));
|
|
99
|
+
if (file) return { file, name: String(c.name || titleOf(id)).slice(0, 40), credit: String(c.credit || '').slice(0, 400) };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
for (const p of projects) {
|
|
103
|
+
for (const stem of [id, `osa-${id}`]) {
|
|
104
|
+
const file = inside(path.join(base, p, 'public', 'models', `${stem}.vrm`));
|
|
105
|
+
if (file) return { file, name: titleOf(id), credit: `From the ${p} avatar project; its README states the model's licence.` };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** GET /avatar/model/<id>.vrm: only a model of an avatar settings.yml names. */
|
|
112
|
+
function serveAvatarModel(res, id, settings, root) {
|
|
113
|
+
const listed = avatarCast(settings, root).some((a) => a.id === id && !BUILT_IN[a.id]);
|
|
114
|
+
const found = listed ? findAvatar(id, root) : null;
|
|
115
|
+
if (!found) { res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); return res.end('No such avatar.'); }
|
|
116
|
+
res.writeHead(200, { 'Content-Type': 'model/gltf-binary', 'Cache-Control': 'private, max-age=3600', 'X-Content-Type-Options': 'nosniff' });
|
|
117
|
+
fs.createReadStream(found.file).pipe(res);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---- ElevenLabs ----------------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
/** The account's key from settings.yml, or '' while it is a placeholder or missing. */
|
|
123
|
+
function elevenKey(settings = {}) {
|
|
124
|
+
const k = String((settings.elevenlabs && settings.elevenlabs.api_key) || '').trim();
|
|
125
|
+
return /^[A-Za-z0-9_.-]{20,}$/.test(k) && !/change/i.test(k) ? k : '';
|
|
126
|
+
}
|
|
127
|
+
const elevenBase = (settings = {}) => process.env.ELEVENLABS_API_BASE
|
|
128
|
+
|| BASES[String((settings.elevenlabs && settings.elevenlabs.region) || '').toLowerCase()] || 'https://api.elevenlabs.io';
|
|
129
|
+
|
|
130
|
+
let voicesMemo = { at: 0, sig: '', list: null };
|
|
131
|
+
|
|
132
|
+
/** The account's voices, `{ id, name, label }`, kept ten minutes. Free: it lists, it does not speak. */
|
|
133
|
+
async function elevenVoices(settings = {}, { fetchImpl = fetch } = {}) {
|
|
134
|
+
const key = elevenKey(settings);
|
|
135
|
+
if (!key) return { connected: false, voices: [] };
|
|
136
|
+
const sig = crypto.createHash('sha256').update(key + elevenBase(settings)).digest('hex');
|
|
137
|
+
if (voicesMemo.sig === sig && voicesMemo.list && Date.now() - voicesMemo.at < 600000) return { connected: true, voices: voicesMemo.list };
|
|
138
|
+
const r = await fetchImpl(`${elevenBase(settings)}/v2/voices?page_size=100`, { headers: { 'xi-api-key': key }, signal: AbortSignal.timeout(10000) });
|
|
139
|
+
if (!r.ok) return { connected: true, voices: [], error: r.status === 401 ? 'ElevenLabs rejected the key.' : `ElevenLabs answered ${r.status}.` };
|
|
140
|
+
const body = await r.json();
|
|
141
|
+
const list = (body.voices || []).filter((v) => /^[A-Za-z0-9]{16,32}$/.test(String(v.voice_id || ''))).map((v) => {
|
|
142
|
+
const l = v.labels || {};
|
|
143
|
+
return { id: v.voice_id, name: String(v.name || v.voice_id).slice(0, 60), label: [l.accent, l.gender, l.age].filter(Boolean).join(', ').slice(0, 60) };
|
|
144
|
+
});
|
|
145
|
+
voicesMemo = { at: Date.now(), sig, list };
|
|
146
|
+
return { connected: true, voices: list };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The audio of `text` in an ElevenLabs voice, as an mp3 Buffer; from the cache when it was spoken before. */
|
|
150
|
+
async function elevenSpeak(settings = {}, { voice, text }, { fetchImpl = fetch, cacheDir = CACHE_DIR } = {}) {
|
|
151
|
+
const key = elevenKey(settings);
|
|
152
|
+
if (!key) throw Object.assign(new Error('ElevenLabs is not connected.'), { status: 409 });
|
|
153
|
+
const id = String(voice || '');
|
|
154
|
+
const words = String(text || '').replace(/\s+/g, ' ').trim().slice(0, MAX_TEXT);
|
|
155
|
+
if (!/^[A-Za-z0-9]{16,32}$/.test(id) || !words) throw Object.assign(new Error('A voice and some words, please.'), { status: 400 });
|
|
156
|
+
const model = String((settings.elevenlabs && (settings.elevenlabs.model_id || settings.elevenlabs.model)) || DEFAULT_MODEL).replace(/[^\w.-]/g, '') || DEFAULT_MODEL;
|
|
157
|
+
const file = path.join(cacheDir, `${crypto.createHash('sha256').update(`${id}\n${model}\n${words}`).digest('hex')}.mp3`);
|
|
158
|
+
try { return fs.readFileSync(file); } catch (_) { /* not spoken yet */ }
|
|
159
|
+
const r = await fetchImpl(`${elevenBase(settings)}/v1/text-to-speech/${id}?output_format=mp3_44100_128`, {
|
|
160
|
+
method: 'POST', signal: AbortSignal.timeout(30000),
|
|
161
|
+
headers: { 'xi-api-key': key, 'Content-Type': 'application/json', Accept: 'audio/mpeg' },
|
|
162
|
+
body: JSON.stringify({ text: words, model_id: model }),
|
|
163
|
+
});
|
|
164
|
+
if (!r.ok) throw Object.assign(new Error(r.status === 401 ? 'ElevenLabs rejected the key.' : `ElevenLabs answered ${r.status}.`), { status: 502 });
|
|
165
|
+
const audio = Buffer.from(await r.arrayBuffer());
|
|
166
|
+
try {
|
|
167
|
+
fs.mkdirSync(cacheDir, { recursive: true, mode: 0o700 });
|
|
168
|
+
fs.writeFileSync(file, audio, { mode: 0o600 });
|
|
169
|
+
const kept = fs.readdirSync(cacheDir).filter((f) => f.endsWith('.mp3'))
|
|
170
|
+
.map((f) => ({ f, t: fs.statSync(path.join(cacheDir, f)).mtimeMs })).sort((a, b) => b.t - a.t);
|
|
171
|
+
for (const old of kept.slice(CACHE_MAX)) fs.unlinkSync(path.join(cacheDir, old.f));
|
|
172
|
+
} catch (_) { /* spoken, just not kept */ }
|
|
173
|
+
return audio;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
module.exports = { BUILT_IN, avatarCast, moveSetOf, hairOf, agentOf, findAvatar, serveAvatarModel, elevenKey, elevenVoices, elevenSpeak };
|