@webship.co/workspace 0.0.0-stage → 1.0.0-beta1
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/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/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/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/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 +466 -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 +1439 -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 +7923 -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 +553 -0
package/images-ai.js
ADDED
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generate and edit images with the providers in images-providers/, into images/, each with a sidecar that says
|
|
3
|
+
* how it was made. A paid call first answers with a plan and goes out only with confirm=yes, which only the
|
|
4
|
+
* confirm dialog's Yes sends. Keys are read from settings.yml, sent in headers, and never printed, logged or put
|
|
5
|
+
* in a URL.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const os = require('os');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const crypto = require('crypto');
|
|
12
|
+
const { execFile } = require('child_process');
|
|
13
|
+
|
|
14
|
+
const { esc } = require('./html');
|
|
15
|
+
const { workspaceDir } = require('./workspaces');
|
|
16
|
+
const images = require('./images');
|
|
17
|
+
const { writeRecords, recordPath } = require('./images-records');
|
|
18
|
+
|
|
19
|
+
const providers = require('./images-providers');
|
|
20
|
+
const openai = require('./images-providers/openai');
|
|
21
|
+
const http = require('./images-providers/http');
|
|
22
|
+
|
|
23
|
+
const MODELS = openai.models;
|
|
24
|
+
const SIZES = openai.sizes;
|
|
25
|
+
const QUALITIES = openai.qualities;
|
|
26
|
+
const ESTIMATE = openai.ESTIMATE;
|
|
27
|
+
const CHAT_API = () => process.env.OPENAI_CHAT_API || 'https://api.openai.com/v1/chat/completions';
|
|
28
|
+
// The description: a small vision model, reading the image at low detail. Prices per token, from OpenAI's pricing page.
|
|
29
|
+
const VISION_MODEL = 'gpt-5.4-nano';
|
|
30
|
+
const VISION_IN = 0.20 / 1e6;
|
|
31
|
+
const VISION_OUT = 1.25 / 1e6;
|
|
32
|
+
const DESCRIBE_ESTIMATE = 0.001;
|
|
33
|
+
const SHAPES = { square: 'Square', landscape: 'Landscape', portrait: 'Portrait' };
|
|
34
|
+
const EDIT_SIZES = { same: 'Same shape as the source', square: 'Square', landscape: 'Landscape', portrait: 'Portrait' };
|
|
35
|
+
const EDITABLE = { '.png': 'png', '.jpg': 'jpeg', '.jpeg': 'jpeg', '.webp': 'webp' };
|
|
36
|
+
|
|
37
|
+
/** The OpenAI key, used for the description call, or '' when it is missing or still a placeholder. */
|
|
38
|
+
function apiKey() {
|
|
39
|
+
return http.keyFor('openai');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The request a form asks for, checked against its provider, or why not. */
|
|
43
|
+
function plan(form) {
|
|
44
|
+
const prompt = String(form.prompt || '').trim();
|
|
45
|
+
if (!prompt) return { ok: false, error: 'Say what the image should show.' };
|
|
46
|
+
if (prompt.length > 4000) return { ok: false, error: 'The prompt can be 4000 characters at most.' };
|
|
47
|
+
const prov = providers.pick(form.provider, 'generate');
|
|
48
|
+
const model = prov.models[form.model] ? String(form.model) : prov.defaultModel;
|
|
49
|
+
const shape = prov.sizes[form.shape] ? String(form.shape) : Object.keys(prov.sizes)[0];
|
|
50
|
+
const quality = prov.qualities ? (prov.qualities.includes(form.quality) ? String(form.quality) : prov.qualities[0]) : null;
|
|
51
|
+
const n = Math.min(prov.maxCount || 4, Math.max(1, parseInt(form.count, 10) || 1));
|
|
52
|
+
const name = images.safeName(form.name || '').replace(/\.[^.]*$/, '')
|
|
53
|
+
|| prompt.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'generated';
|
|
54
|
+
const per = prov.estimate({ quality, model });
|
|
55
|
+
const describe = prov.async ? 0 : DESCRIBE_ESTIMATE;
|
|
56
|
+
return { ok: true, provider: prov.id, prompt, model, shape, size: prov.sizes[shape], quality, n, name,
|
|
57
|
+
estimate: per === null ? null : (per + describe) * n };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A temporary copy of an image in another format, made with ImageMagick. */
|
|
61
|
+
function convertTo(abs, ext, extra = []) {
|
|
62
|
+
const out = path.join(os.tmpdir(), `images-${crypto.randomBytes(8).toString('hex')}.${ext}`);
|
|
63
|
+
return new Promise((resolve) => {
|
|
64
|
+
execFile('convert', [`${abs}[0]`, '-auto-orient', ...extra, `${ext}:${out}`], { timeout: 60000 },
|
|
65
|
+
(err) => resolve(!err && fs.existsSync(out) ? out : null));
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const actualCost = openai.actualCost;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* A one or two sentence summary and an alt text for an image, from a small vision model. The image goes as a
|
|
73
|
+
* data URL, never a public one; anything that is not a small PNG, JPEG, WebP or GIF is made a 1024px JPEG first.
|
|
74
|
+
*/
|
|
75
|
+
async function describeImage(abs, opts = {}) {
|
|
76
|
+
const key = opts.key || apiKey();
|
|
77
|
+
if (!key) return { ok: false, error: 'No OpenAI key.' };
|
|
78
|
+
const ext = path.extname(abs).toLowerCase();
|
|
79
|
+
const plain = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.gif': 'image/gif' };
|
|
80
|
+
let file = abs;
|
|
81
|
+
let mime = plain[ext];
|
|
82
|
+
let tmp = null;
|
|
83
|
+
if (!mime || fs.statSync(abs).size > 4 * 1024 * 1024) {
|
|
84
|
+
tmp = await convertTo(abs, 'jpeg', ['-resize', '1024x1024>', '-quality', '85']);
|
|
85
|
+
if (!tmp) return { ok: false, error: 'The image could not be read.' };
|
|
86
|
+
file = tmp;
|
|
87
|
+
mime = 'image/jpeg';
|
|
88
|
+
}
|
|
89
|
+
const url = `data:${mime};base64,${fs.readFileSync(file).toString('base64')}`;
|
|
90
|
+
if (tmp) fs.rmSync(tmp, { force: true });
|
|
91
|
+
let json;
|
|
92
|
+
try {
|
|
93
|
+
json = await http.post(CHAT_API(), { who: 'OpenAI', type: 'application/json', headers: { Authorization: `Bearer ${key}` }, body: JSON.stringify({
|
|
94
|
+
model: VISION_MODEL, reasoning_effort: 'low', max_completion_tokens: 800, response_format: { type: 'json_object' },
|
|
95
|
+
messages: [
|
|
96
|
+
{ role: 'system', content: 'You describe images for a media library. Answer only with JSON: {"summary": "one or two plain sentences on what the image shows", "alt": "alt text, at most 125 characters"}.' },
|
|
97
|
+
{ role: 'user', content: [{ type: 'text', text: 'Describe this image.' }, { type: 'image_url', image_url: { url, detail: 'low' } }] },
|
|
98
|
+
] }) });
|
|
99
|
+
} catch (e) {
|
|
100
|
+
return { ok: false, error: e.message };
|
|
101
|
+
}
|
|
102
|
+
let out = {};
|
|
103
|
+
try { out = JSON.parse(((json.choices || [])[0] || {}).message.content || '{}'); } catch (_) { /* not JSON */ }
|
|
104
|
+
const summary = String(out.summary || '').trim().slice(0, 600);
|
|
105
|
+
const alt = String(out.alt || '').trim().slice(0, 125);
|
|
106
|
+
if (!summary && !alt) return { ok: false, error: 'The description came back empty.' };
|
|
107
|
+
const u = json.usage || {};
|
|
108
|
+
const cost = Number(u.prompt_tokens || 0) * VISION_IN + Number(u.completion_tokens || 0) * VISION_OUT;
|
|
109
|
+
return { ok: true, summary, alt, cost };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Describe an image and add the result to its records, keeping what they already hold. */
|
|
113
|
+
async function describeAndRecord(abs, base, opts = {}) {
|
|
114
|
+
const d = await describeImage(abs, opts);
|
|
115
|
+
const rec = { ...(base || {}), ...(d.ok ? { summary: d.summary, alt: d.alt, described_by: VISION_MODEL,
|
|
116
|
+
description_cost: `about $${d.cost.toFixed(4)}` } : { description: 'not written' }) };
|
|
117
|
+
writeRecords(abs, rec);
|
|
118
|
+
return { ...d, rec };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The runner call that starts host screen sessions, unless a test passes its own. */
|
|
122
|
+
const runnerStart = (opts) => opts.start || require('./jobs').hostRunnerRequest;
|
|
123
|
+
|
|
124
|
+
/** The palette a drawing is asked to keep to: the brand tokens, in words. */
|
|
125
|
+
function paletteWords() {
|
|
126
|
+
try {
|
|
127
|
+
const t = JSON.parse(fs.readFileSync(path.join(require('./workspaces').CONFIG_DIR, 'brand-tokens.json'), 'utf8')).tokens || {};
|
|
128
|
+
return ['brand-primary', 'brand-secondary', 'brand-accent', 'brand-dark', 'brand-light'].filter((k) => t[k]).map((k) => `${k.replace('brand-', '')} ${t[k]}`).join(', ');
|
|
129
|
+
} catch (_) {
|
|
130
|
+
return 'blue #0074e0, navy #2b447c, lavender #e4dcff, black #000000, warm grey #ebe8e5';
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Make the images. Only with confirm=yes. */
|
|
135
|
+
async function generate(form, opts = {}) {
|
|
136
|
+
if (form.confirm !== 'yes') return { ok: false, status: 400, error: 'Generating needs confirming first.' };
|
|
137
|
+
const p = plan(form);
|
|
138
|
+
if (!p.ok) return { ok: false, status: 400, error: p.error };
|
|
139
|
+
const prov = providers.get(p.provider);
|
|
140
|
+
const dir = opts.dir || path.join(workspaceDir('images'), 'generated');
|
|
141
|
+
if (prov.async) {
|
|
142
|
+
if (!prov.available().ok) return { ok: false, status: 503, error: `Claude Code is not available: ${prov.available().why}.` };
|
|
143
|
+
const name = images.freeName(dir, p.name, 'svg').replace(/\.svg$/, '');
|
|
144
|
+
const r = await prov.startSession({ kind: 'generate', name, dir, prompt: p.prompt, pixels: prov.PIXELS[p.shape] || prov.PIXELS.square,
|
|
145
|
+
palette: paletteWords(), provider: prov.id, model: p.model, size: p.size, png: true, created: new Date().toISOString() }, runnerStart(opts));
|
|
146
|
+
if (!r.ok) return { ok: false, status: 503, error: r.error };
|
|
147
|
+
return { ok: true, async: true, session: r.session, made: [], cost: 0, undescribed: [] };
|
|
148
|
+
}
|
|
149
|
+
const key = opts.key || prov.key();
|
|
150
|
+
if (!key) return { ok: false, status: 503, error: `Set ai_providers.${prov.keyName}.api_key in core/config/settings.yml first.` };
|
|
151
|
+
let out;
|
|
152
|
+
try {
|
|
153
|
+
out = await prov.generate(p, key);
|
|
154
|
+
} catch (e) {
|
|
155
|
+
return { ok: false, status: 502, error: `${prov.label} refused it: ${e.message}` };
|
|
156
|
+
}
|
|
157
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
158
|
+
const cost = out.cost;
|
|
159
|
+
const made = [];
|
|
160
|
+
let total = cost || 0;
|
|
161
|
+
const undescribed = [];
|
|
162
|
+
// The description is OpenAI's vision call, whichever provider made the picture.
|
|
163
|
+
const describeKey = prov.id === 'openai' ? key : apiKey();
|
|
164
|
+
for (const item of out.images || []) {
|
|
165
|
+
const name = images.freeName(dir, p.name, item.ext || 'png');
|
|
166
|
+
const abs = path.join(dir, name);
|
|
167
|
+
fs.writeFileSync(abs, item.data);
|
|
168
|
+
const base = { prompt: p.prompt, provider: prov.id === 'openai' ? undefined : prov.label, model: p.model, size: p.size,
|
|
169
|
+
quality: p.quality || undefined, created: new Date().toISOString(), revised_prompt: item.revised || undefined,
|
|
170
|
+
cost: cost === null || cost === undefined ? undefined : `about $${(cost / Math.max(1, out.images.length)).toFixed(4)}` };
|
|
171
|
+
const d = opts.describe === false ? (writeRecords(abs, base), { ok: false }) : await describeAndRecord(abs, base, { key: describeKey });
|
|
172
|
+
if (d.ok) total += d.cost; else if (opts.describe !== false) undescribed.push(name);
|
|
173
|
+
made.push(`generated/${name}`);
|
|
174
|
+
}
|
|
175
|
+
images.forgetScan();
|
|
176
|
+
if (!made.length) return { ok: false, status: 502, error: `${prov.label} answered without an image.` };
|
|
177
|
+
return { ok: true, made, cost: cost === null || cost === undefined ? null : total, undescribed };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// --------------------------------------------------------------- AI edits --
|
|
181
|
+
|
|
182
|
+
/** The nearest supported shape to an image's own. */
|
|
183
|
+
function nearestShape(width, height) {
|
|
184
|
+
if (!width || !height) return 'square';
|
|
185
|
+
const r = width / height;
|
|
186
|
+
return r > 1.2 ? 'landscape' : r < 0.83 ? 'portrait' : 'square';
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** The nearest OpenAI size to an image's own shape. */
|
|
190
|
+
function nearestSize(width, height) {
|
|
191
|
+
return SIZES[nearestShape(width, height)];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** The next free <stem>-ai-v<n>.<ext> in a folder: never the source, never an existing file. */
|
|
195
|
+
function aiVersionName(dir, file, ext) {
|
|
196
|
+
const stem = path.basename(file).replace(/\.[^.]*$/, '').replace(/-ai-v\d+$/, '');
|
|
197
|
+
for (let n = 1; n < 10000; n += 1) {
|
|
198
|
+
const name = `${stem}-ai-v${n}.${ext}`;
|
|
199
|
+
if (!fs.existsSync(path.join(dir, name))) return name;
|
|
200
|
+
}
|
|
201
|
+
return `${stem}-ai-v${Date.now()}.${ext}`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Who may edit a source: Claude Code edits an SVG's code and nothing else; the image models edit pictures. */
|
|
205
|
+
function editProviders(isSvg) {
|
|
206
|
+
return providers.list('edit').filter((p) => (p.id === 'claude_code_cli') === !!isSvg);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** An AI edit a form asks for, checked, or why not. An SVG can be edited only by Claude Code, which edits its code. */
|
|
210
|
+
function editPlan(form) {
|
|
211
|
+
const src = String(form.src || 'images');
|
|
212
|
+
const root = images.sourceRoot(src);
|
|
213
|
+
const where = root ? images.safeIn(root, String(form.file || '')) : null;
|
|
214
|
+
if (!where || !images.IMAGE_RE.test(where.abs) || !fs.existsSync(where.abs)) return { ok: false, error: 'That image is not there any more.' };
|
|
215
|
+
const isSvg = images.typeOf(where.abs) === 'svg';
|
|
216
|
+
if (isSvg && form.provider !== 'claude_code_cli') {
|
|
217
|
+
return { ok: false, error: 'An SVG cannot be edited by an image model: it is a drawing, not a picture. Edit it with Claude Code, which changes its code, or export it to PNG first.' };
|
|
218
|
+
}
|
|
219
|
+
const prov = providers.get(editProviders(isSvg).some((p) => p.id === form.provider) ? form.provider : editProviders(isSvg)[0].id);
|
|
220
|
+
const prompt = String(form.prompt || '').trim();
|
|
221
|
+
if (!prompt) return { ok: false, error: 'Say what to change.' };
|
|
222
|
+
if (prompt.length > 4000) return { ok: false, error: 'The prompt can be 4000 characters at most.' };
|
|
223
|
+
const model = prov.models[form.model] ? String(form.model) : prov.defaultModel;
|
|
224
|
+
const quality = prov.qualities ? (prov.qualities.includes(form.quality) ? String(form.quality) : prov.qualities[0]) : null;
|
|
225
|
+
const dims = images.dimensions(where.abs);
|
|
226
|
+
const shape = form.shape && form.shape !== 'same' && prov.sizes[form.shape] ? String(form.shape) : 'same';
|
|
227
|
+
const own = nearestShape(dims && dims.width, dims && dims.height);
|
|
228
|
+
const size = prov.sizes[shape === 'same' ? own : shape] || Object.values(prov.sizes)[0];
|
|
229
|
+
const ext = isSvg ? 'svg' : EDITABLE[path.extname(where.abs).toLowerCase()] ? path.extname(where.abs).toLowerCase().replace('.', '') : 'png';
|
|
230
|
+
const outExt = ext === 'jpeg' ? 'jpg' : ext;
|
|
231
|
+
const dir = src === 'images' ? path.dirname(where.abs) : path.join(workspaceDir('images'), 'edited');
|
|
232
|
+
const per = prov.editEstimate({ quality, model });
|
|
233
|
+
return { ok: true, provider: prov.id, isSvg, src, rel: where.rel, abs: where.abs, prompt, model, quality, shape, size, outExt, dir,
|
|
234
|
+
name: aiVersionName(dir, where.abs, outExt),
|
|
235
|
+
estimate: per === null ? null : per + (prov.async ? 0 : DESCRIBE_ESTIMATE) };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Edit an image with AI into a new file. Only with confirm=yes. */
|
|
239
|
+
async function editImage(form, opts = {}) {
|
|
240
|
+
if (form.confirm !== 'yes') return { ok: false, status: 400, error: 'Editing with AI needs confirming first.' };
|
|
241
|
+
const p = editPlan(form);
|
|
242
|
+
if (!p.ok) return { ok: false, status: 400, error: p.error };
|
|
243
|
+
const prov = providers.get(p.provider);
|
|
244
|
+
const dir = opts.dir || p.dir;
|
|
245
|
+
if (prov.async) {
|
|
246
|
+
if (!prov.available().ok) return { ok: false, status: 503, error: `Claude Code is not available: ${prov.available().why}.` };
|
|
247
|
+
const dims = images.dimensions(p.abs) || { width: 1024, height: 1024 };
|
|
248
|
+
const name = (opts.dir ? aiVersionName(dir, p.abs, 'svg') : p.name).replace(/\.svg$/, '');
|
|
249
|
+
const r = await prov.startSession({ kind: 'edit', name, dir, prompt: p.prompt, pixels: [dims.width || 1024, dims.height || 1024],
|
|
250
|
+
sourceSvg: fs.readFileSync(p.abs, 'utf8').slice(0, 200000), palette: paletteWords(), provider: prov.id, model: p.model,
|
|
251
|
+
source: { workspace: p.src, path: p.rel }, png: false, created: new Date().toISOString() }, runnerStart(opts));
|
|
252
|
+
if (!r.ok) return { ok: false, status: 503, error: r.error };
|
|
253
|
+
return { ok: true, async: true, session: r.session, name: `${name}.svg`, cost: 0, described: true };
|
|
254
|
+
}
|
|
255
|
+
const key = opts.key || prov.key();
|
|
256
|
+
if (!key) return { ok: false, status: 503, error: `Set ai_providers.${prov.keyName}.api_key in core/config/settings.yml first.` };
|
|
257
|
+
// PNG, JPEG and WebP go as they are; anything else is sent as a temporary PNG.
|
|
258
|
+
let upload = p.abs;
|
|
259
|
+
let tmp = null;
|
|
260
|
+
if (!EDITABLE[path.extname(p.abs).toLowerCase()]) {
|
|
261
|
+
tmp = await convertTo(p.abs, 'png');
|
|
262
|
+
if (!tmp) return { ok: false, status: 400, error: 'The image could not be converted for the edit.' };
|
|
263
|
+
upload = tmp;
|
|
264
|
+
}
|
|
265
|
+
const kind = EDITABLE[path.extname(upload).toLowerCase()] || 'png';
|
|
266
|
+
const file = { name: path.basename(upload), type: `image/${kind}`, data: fs.readFileSync(upload) };
|
|
267
|
+
if (tmp) fs.rmSync(tmp, { force: true });
|
|
268
|
+
let out;
|
|
269
|
+
try {
|
|
270
|
+
out = await prov.edit(p, file, key);
|
|
271
|
+
} catch (e) {
|
|
272
|
+
return { ok: false, status: 502, error: `${prov.label} refused it: ${e.message}` };
|
|
273
|
+
}
|
|
274
|
+
if (!out.image) return { ok: false, status: 502, error: `${prov.label} answered without an image.` };
|
|
275
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
276
|
+
const name = opts.dir ? aiVersionName(dir, p.abs, p.outExt) : p.name;
|
|
277
|
+
const abs = path.join(dir, name);
|
|
278
|
+
fs.writeFileSync(abs, out.image);
|
|
279
|
+
const cost = out.cost;
|
|
280
|
+
const base = { edit_prompt: p.prompt, source: { workspace: p.src, path: p.rel }, provider: prov.id === 'openai' ? undefined : prov.label,
|
|
281
|
+
model: p.model, size: p.size, quality: p.quality || undefined, created: new Date().toISOString(),
|
|
282
|
+
cost: cost === null || cost === undefined ? undefined : `about $${cost.toFixed(4)}` };
|
|
283
|
+
const describeKey = prov.id === 'openai' ? key : apiKey();
|
|
284
|
+
const d = opts.describe === false ? (writeRecords(abs, base), { ok: false }) : await describeAndRecord(abs, base, { key: describeKey });
|
|
285
|
+
images.forgetScan();
|
|
286
|
+
const root = fs.realpathSync(workspaceDir('images'));
|
|
287
|
+
const rel = path.relative(root, fs.realpathSync(abs)).split(path.sep).join('/');
|
|
288
|
+
return { ok: true, rel, name, cost: cost === null || cost === undefined ? null : cost + (d.ok ? d.cost : 0), described: !!d.ok };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Describe an image already in images/ and add it to its records. Only with confirm=yes. */
|
|
292
|
+
async function describeExisting(form, opts = {}) {
|
|
293
|
+
if (form.confirm !== 'yes') return { ok: false, status: 400, error: 'Describing spends a little money, so it needs confirming.' };
|
|
294
|
+
const where = images.safeIn(workspaceDir('images'), String(form.file || ''));
|
|
295
|
+
if (!where || !images.IMAGE_RE.test(where.abs) || !fs.existsSync(where.abs)) return { ok: false, status: 404, error: 'That image is not in the images workspace.' };
|
|
296
|
+
if (!(opts.key || apiKey())) return { ok: false, status: 503, error: 'Set ai_providers.openai.api_key in core/config/settings.yml first.' };
|
|
297
|
+
const base = images.sidecarOf(where.abs) || {};
|
|
298
|
+
delete base.description;
|
|
299
|
+
const d = await describeAndRecord(where.abs, base, opts);
|
|
300
|
+
if (!d.ok) return { ok: false, status: 502, error: `The description was not written: ${d.error}` };
|
|
301
|
+
return { ok: true, rel: where.rel, cost: d.cost };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ------------------------------------------------------------------- forms --
|
|
305
|
+
|
|
306
|
+
const opt = (opts, chosen) => Object.entries(opts).map(([v, l]) => `<option value="${esc(v)}"${v === chosen ? ' selected' : ''}>${esc(l)}</option>`).join('');
|
|
307
|
+
|
|
308
|
+
/** The provider select: those not set up here are listed, disabled, with what to set. */
|
|
309
|
+
function providerSelectHtml(kind, chosen, { only = null } = {}) {
|
|
310
|
+
const list = providers.list(kind).filter((p) => !only || only.includes(p.id));
|
|
311
|
+
const pick = (list.find((p) => p.id === chosen && p.available().ok) || list.find((p) => p.available().ok) || list[0] || {}).id;
|
|
312
|
+
return list.map((p) => {
|
|
313
|
+
const a = p.available();
|
|
314
|
+
return `<option value="${esc(p.id)}"${p.id === pick ? ' selected' : ''}${a.ok ? '' : ' disabled'}>${esc(p.label)}${a.ok ? '' : ` (${esc(a.why)})`}</option>`;
|
|
315
|
+
}).join('');
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** The fields that follow the chosen provider: model, size, quality and how many. */
|
|
319
|
+
function providerFieldsHtml(kind, id, { prefix = 'img-ai', edit = false } = {}) {
|
|
320
|
+
const prov = providers.pick(id, kind);
|
|
321
|
+
const sizes = edit ? { same: 'Same shape as the source', ...Object.fromEntries(Object.keys(prov.sizes).map((k) => [k, SHAPES[k] || k])) } : prov.sizes;
|
|
322
|
+
const sizeLabels = edit ? sizes : Object.fromEntries(Object.entries(prov.sizes).map(([k, v]) => [k, `${SHAPES[k] || k}, ${String(v).replace('x', '×')}`]));
|
|
323
|
+
const quals = prov.qualities ? Object.fromEntries(prov.qualities.map((q) => [q, (prov.qualityLabels && prov.qualityLabels[q]) || q[0].toUpperCase() + q.slice(1)])) : null;
|
|
324
|
+
const counts = Object.fromEntries(Array.from({ length: prov.maxCount || 1 }, (_, i) => [String(i + 1), String(i + 1)]));
|
|
325
|
+
return `
|
|
326
|
+
<div class="uk-grid-small uk-child-width-1-3@s uk-margin-small-top" uk-grid>
|
|
327
|
+
<div><label class="uk-form-label" for="${prefix}-model">Model</label>
|
|
328
|
+
<select class="uk-select uk-form-small" id="${prefix}-model" name="model">${opt(prov.models, prov.defaultModel)}</select></div>
|
|
329
|
+
<div><label class="uk-form-label" for="${prefix}-shape">${edit ? 'Size' : 'Shape'}</label>
|
|
330
|
+
<select class="uk-select uk-form-small" id="${prefix}-shape" name="shape">${opt(sizeLabels, edit ? 'same' : Object.keys(sizes)[0])}</select></div>
|
|
331
|
+
${quals ? `<div><label class="uk-form-label" for="${prefix}-quality">Quality</label>
|
|
332
|
+
<select class="uk-select uk-form-small" id="${prefix}-quality" name="quality">${opt(quals, prov.qualities[0])}</select></div>` : ''}
|
|
333
|
+
${!edit && (prov.maxCount || 1) > 1 ? `<div><label class="uk-form-label" for="${prefix}-count">How many</label>
|
|
334
|
+
<select class="uk-select uk-form-small" id="${prefix}-count" name="count">${opt(counts, '1')}</select></div>` : ''}
|
|
335
|
+
</div>
|
|
336
|
+
${prov.async ? '<p class="uk-text-meta uk-margin-small-top">Claude Code draws it as an SVG and a PNG under your own Claude Code login, with no API charge. It runs as a screen session you can watch on Screens, and the result appears in <code>images/generated/</code> when it is done.</p>' : ''}`;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function editFormHtml(src, rel, isSvg) {
|
|
340
|
+
const list = editProviders(isSvg);
|
|
341
|
+
const only = list.map((p) => p.id);
|
|
342
|
+
const ready = list.some((p) => p.available().ok);
|
|
343
|
+
return `
|
|
344
|
+
<details class="img-edit img-ai-edit">
|
|
345
|
+
<summary>Edit with AI</summary>
|
|
346
|
+
${isSvg ? '<p class="uk-text-meta">An SVG is a drawing, so only Claude Code can edit it: it changes the code into a new version.</p>' : ''}
|
|
347
|
+
<form hx-post="/actions/image-ai-edit" hx-target="#editor-modal-body" hx-swap="innerHTML">
|
|
348
|
+
<input type="hidden" name="workspace" value="images">
|
|
349
|
+
<input type="hidden" name="src" value="${esc(src)}">
|
|
350
|
+
<input type="hidden" name="file" value="${esc(rel)}">
|
|
351
|
+
<label class="uk-form-label" for="img-ai-edit-provider">Provider</label>
|
|
352
|
+
<select class="uk-select uk-form-small" id="img-ai-edit-provider" name="provider"
|
|
353
|
+
hx-get="/fragments/images/provider-fields?kind=edit" hx-trigger="change" hx-target="#img-ai-edit-fields" hx-swap="innerHTML" hx-include="this">${providerSelectHtml('edit', '', { only })}</select>
|
|
354
|
+
<label class="uk-form-label uk-margin-small-top" for="img-ai-edit-prompt">What should change?</label>
|
|
355
|
+
<textarea class="uk-textarea" id="img-ai-edit-prompt" name="prompt" rows="4" maxlength="4000" required data-ace="markdown"></textarea>
|
|
356
|
+
<div id="img-ai-edit-fields">${providerFieldsHtml('edit', (list.find((p) => p.available().ok) || list[0]).id, { prefix: 'img-ai-edit', edit: true })}</div>
|
|
357
|
+
<p class="uk-margin-small-top"><button class="uk-button uk-button-primary uk-button-small" type="submit"${ready ? '' : ' disabled'}>See the plan</button>
|
|
358
|
+
<span class="uk-text-meta">${ready ? 'Saved as a new file; the original stays. Nothing is sent until you confirm the plan.' : 'Set an image provider key in <code>core/config/settings.yml</code> first, for example <code>ai_providers.openai.api_key</code>.'}</span></p>
|
|
359
|
+
</form>
|
|
360
|
+
</details>`;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** The cost line of a plan, per provider. */
|
|
364
|
+
function costLine(prov, estimate, { describe = true } = {}) {
|
|
365
|
+
if (prov.async) return 'No API charge: it uses your own Claude Code login.';
|
|
366
|
+
if (estimate === null || estimate === undefined) return `${esc(prov.label)} publishes no per-image figure on the page this was built from; check its pricing page. The real cost is not reported back.`;
|
|
367
|
+
return `About $${estimate.toFixed(3)}${describe ? ', with the description' : ''}; the real cost is shown after when the provider reports it.`;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function editPlanHtml(p) {
|
|
371
|
+
const prov = providers.get(p.provider);
|
|
372
|
+
const vals = esc(JSON.stringify({ workspace: 'images', src: p.src, file: p.rel, prompt: p.prompt, provider: p.provider, model: p.model,
|
|
373
|
+
shape: p.shape, quality: p.quality || '', confirm: 'yes' }));
|
|
374
|
+
const where = p.src === 'images' ? `images/${path.posix.dirname(p.rel) === '.' ? '' : `${path.posix.dirname(p.rel)}/`}${p.name}` : `images/edited/${p.name}`;
|
|
375
|
+
return `
|
|
376
|
+
<div class="editor-card img-ai-plan">
|
|
377
|
+
<h3 class="uk-margin-small-bottom">The plan: edit with AI</h3>
|
|
378
|
+
<dl class="img-info">
|
|
379
|
+
<dt>Source</dt><dd><code>${esc(`${p.src}/${p.rel}`)}</code>, unchanged</dd>
|
|
380
|
+
<dt>Provider</dt><dd>${esc(prov.label)}, ${esc(prov.models[p.model] || p.model)}</dd>
|
|
381
|
+
<dt>Size</dt><dd>${esc(p.size)}${p.quality ? `, ${esc(p.quality)} quality` : ''}</dd>
|
|
382
|
+
<dt>Saved as</dt><dd><code>${esc(where)}</code></dd>
|
|
383
|
+
<dt>Change</dt><dd>${esc(p.prompt)}</dd>
|
|
384
|
+
<dt>Cost</dt><dd>${costLine(prov, p.estimate)}</dd>
|
|
385
|
+
${prov.async ? '<dt>Where</dt><dd>A screen session: watch it on Screens. The new version appears beside the source when it is done.</dd>' : ''}
|
|
386
|
+
</dl>
|
|
387
|
+
<p>
|
|
388
|
+
<button type="button" class="uk-button uk-button-primary arm-step" title="Edit ${esc(p.rel)} with ${esc(prov.label)} into a new file${prov.async ? ' (starts a Claude Code session)' : ' (paid)'}"
|
|
389
|
+
hx-post="/actions/image-ai-edit" hx-trigger="confirmed-remove" hx-vals="${vals}"
|
|
390
|
+
hx-target="#editor-modal-body" hx-swap="innerHTML">Edit it</button>
|
|
391
|
+
<button type="button" class="uk-button uk-button-default" hx-get="/fragments/images/view?src=${encodeURIComponent(p.src)}&file=${encodeURIComponent(p.rel)}"
|
|
392
|
+
hx-target="#editor-modal-body" hx-swap="innerHTML">Back to the image</button>
|
|
393
|
+
</p>
|
|
394
|
+
</div>`;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function formHtml() {
|
|
398
|
+
const ready = providers.list('generate').some((p) => p.available().ok);
|
|
399
|
+
return `
|
|
400
|
+
<div class="editor-card img-ai">
|
|
401
|
+
<h3 class="uk-margin-small-bottom">Generate with AI</h3>
|
|
402
|
+
${ready ? '' : '<div class="msg error">No image provider is set up on this machine: set an <code>ai_providers.<provider>.api_key</code> in <code>core/config/settings.yml</code>, or install the claude CLI.</div>'}
|
|
403
|
+
<form hx-post="/actions/image-generate" hx-target="#editor-modal-body" hx-swap="innerHTML">
|
|
404
|
+
<input type="hidden" name="workspace" value="images">
|
|
405
|
+
<label class="uk-form-label" for="img-ai-provider">Provider</label>
|
|
406
|
+
<select class="uk-select uk-form-small" id="img-ai-provider" name="provider"
|
|
407
|
+
hx-get="/fragments/images/provider-fields?kind=generate" hx-trigger="change" hx-target="#img-ai-fields" hx-swap="innerHTML" hx-include="this">${providerSelectHtml('generate', '')}</select>
|
|
408
|
+
<label class="uk-form-label uk-margin-small-top" for="img-ai-prompt">What should it show?</label>
|
|
409
|
+
<textarea class="uk-textarea" id="img-ai-prompt" name="prompt" rows="5" maxlength="4000" required data-ace="markdown"></textarea>
|
|
410
|
+
<div id="img-ai-fields">${providerFieldsHtml('generate', (providers.list('generate').find((p) => p.available().ok) || {}).id)}</div>
|
|
411
|
+
<div class="uk-margin-small-top"><label class="uk-form-label" for="img-ai-name">File name</label>
|
|
412
|
+
<input class="uk-input uk-form-small" id="img-ai-name" name="name" maxlength="80"></div>
|
|
413
|
+
<p class="uk-margin-small-top"><button class="uk-button uk-button-primary" type="submit"${ready ? '' : ' disabled'}>See the plan</button>
|
|
414
|
+
<span class="uk-text-meta">Nothing is sent until you confirm the plan.</span></p>
|
|
415
|
+
</form>
|
|
416
|
+
</div>`;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function planHtml(p) {
|
|
420
|
+
const prov = providers.get(p.provider);
|
|
421
|
+
const vals = esc(JSON.stringify({ workspace: 'images', prompt: p.prompt, provider: p.provider, model: p.model, shape: p.shape,
|
|
422
|
+
quality: p.quality || '', count: String(p.n), name: p.name, confirm: 'yes' }));
|
|
423
|
+
const ext = prov.async ? 'svg' : 'png';
|
|
424
|
+
return `
|
|
425
|
+
<div class="editor-card img-ai-plan">
|
|
426
|
+
<h3 class="uk-margin-small-bottom">The plan</h3>
|
|
427
|
+
<dl class="img-info">
|
|
428
|
+
<dt>Provider</dt><dd>${esc(prov.label)}, ${esc(prov.models[p.model] || p.model)}</dd>
|
|
429
|
+
<dt>Size</dt><dd>${esc(p.size)}${p.quality ? `, ${esc(p.quality)} quality` : ''}</dd>
|
|
430
|
+
<dt>Images</dt><dd>${p.n}, saved as <code>images/generated/${esc(p.name)}.${ext}</code>${prov.async ? ' and its PNG' : ''}${p.n > 1 ? ' and the next free names' : ''}</dd>
|
|
431
|
+
<dt>Prompt</dt><dd>${esc(p.prompt)}</dd>
|
|
432
|
+
<dt>Cost</dt><dd>${prov.id === 'openai' && p.estimate !== null ? `About $${(ESTIMATE[p.quality] * p.n).toFixed(3)} (OpenAI's published figure per ${esc(p.quality)} image), + about $${(DESCRIBE_ESTIMATE * p.n).toFixed(3)} for the description; the real cost is shown after.` : costLine(prov, p.estimate)}</dd>
|
|
433
|
+
<dt>Also</dt><dd>${prov.async ? 'Claude writes the summary and the alt text itself, and the prompt is kept in a <code>.md</code> file beside the image. It runs as a screen session: watch it on Screens.'
|
|
434
|
+
: `A short description and an alt text are written by ${esc(VISION_MODEL)}, and the prompt is kept in a <code>.md</code> file beside each image.`}</dd>
|
|
435
|
+
</dl>
|
|
436
|
+
<p>
|
|
437
|
+
<button type="button" class="uk-button uk-button-primary arm-step" title="${prov.async ? 'Start a Claude Code session to draw it' : `Generate ${p.n} image${p.n === 1 ? '' : 's'} with ${esc(prov.label)} (paid)`}"
|
|
438
|
+
hx-post="/actions/image-generate" hx-trigger="confirmed-remove" hx-vals="${vals}"
|
|
439
|
+
hx-target="#editor-modal-body" hx-swap="innerHTML">${prov.async ? 'Start drawing' : 'Generate'}</button>
|
|
440
|
+
<button type="button" class="uk-button uk-button-default" hx-get="/fragments/images/generate-form" hx-target="#editor-modal-body" hx-swap="innerHTML">Change it</button>
|
|
441
|
+
</p>
|
|
442
|
+
</div>`;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
module.exports = { MODELS, SIZES, QUALITIES, ESTIMATE, VISION_MODEL, DESCRIBE_ESTIMATE, EDIT_SIZES, apiKey, plan, generate, actualCost,
|
|
446
|
+
formHtml, planHtml, describeImage, writeRecords, recordPath, nearestSize, nearestShape, aiVersionName, editPlan, editImage,
|
|
447
|
+
describeExisting, editFormHtml, editPlanHtml, providerFieldsHtml, providerSelectHtml, paletteWords, editProviders };
|