openvibers/openvibe.blog Commits

Blog: find and make pictures through the shared image task (#264)

OP OpenVibers committed 3 days ago · c0ba6cead3 · 8 files, +195 −72

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 4edd9d7..9b17ef2 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -2,6 +2,11 @@
## Unreleased
++- The editor's picture search and "Make an image" now call OpenVibe.AI's one shared image task (`image.picture`):
++ find keeps the licence, author and source page and stores the picture in Media for the blog; make returns a Media
++ reference and the caption credit "Made with AI (<model>)". The browser no longer asks Openverse or Commons, and their
++ hosts are gone from the content security policy. `server/domain/ai-drafts.js`, `server/http/editor.js`,
++ `public/js/blog-editor.js`; `test/image-task.test.js`.
- Weekly devblogs use the content.devblog AI agent and blog.devblog.write task: meaningful merged changes, stored screenshots, rich blocks, and a person-only review gate. Owner requests and the opt-in scheduler share durable weekly reservations. Per-site and network indexes use the first figure and summary; the back-catalogue script defaults to a read-only dry run.
- The network changelog's entries carry `kind` (new, better, fixed or internal), one to three fixed `areas` and the
subject in `plain` words. Library pins, merges and test/docs/CI-only work become internal without a model; the rest
ddiff --git a/public/js/blog-editor.js b/public/js/blog-editor.js
iindex 78f8b21..5c6d808 100644
---- a/public/js/blog-editor.js
++++ b/public/js/blog-editor.js
@@@ -6,7 +6,7 @@
* - a "/" menu: type "/" at the start of a line, or press the toolbar button;
* - a form per block, with a live preview rendered by the server's own renderer (/write/preview);
* - drag to reorder the top-level blocks in the outline;
-- * - picture search, straight from Openverse and Wikimedia Commons (their APIs, their licences);
++ * - picture search through the shared image task (Commons and Openverse, stored in Media);
* - "Make an image", the image task through OpenVibe.AI (/write/make-image).
*
* Blocks are the Markdown directives openvibe-publishing/blocks parses; the textarea stays the source
@@@ -26,6 +26,7 @@
var POST = root.getAttribute('data-post') || '';
var PREVIEW = root.getAttribute('data-preview') || '/write/preview';
var IMAGE = root.getAttribute('data-image') || '/write/make-image';
++ var FIND = root.getAttribute('data-find') || '/write/find-images';
var BLOCKS = [
{ type: 'hero', label: 'Hero banner', hint: 'The post’s title art, kicker and teaser, as a banner at the top.', body: true, bodyDefault: '# Name of the update\nOne or two sentences that say what this is, and why it matters.', fields: [{ n: 'art', l: 'Art (Media id med_…)', ph: 'med_…' }, { n: 'kicker', l: 'Kicker', ph: 'Devblog' }, { n: 'date', l: 'Date', type: 'date' }] },
@@@ -181,10 +182,10 @@
redraw();
}
-- // ── Picture search: Openverse and Commons, straight from the browser ──
++ // ── Picture search: the shared image task, through Blog and OpenVibe.AI ──
function showSearch() {
openPanel('<div class="ov-editor-card"><div class="ov-editor-cardhead"><h3>Find a picture</h3><button type="button" class="ov-editor-x" data-ov-close aria-label="Close">×</button></div>'
-- + '<p class="meta">Openverse and Wikimedia Commons, in their own licences. A picture found here is inserted as a linked image with its credit; a block figure needs a Media object, attached in the Media panel above.</p>'
++ + '<p class="meta">Wikimedia Commons and Openverse, found through OpenVibe.AI and stored in OpenVibe.Media with their licence and author. A picture found here is inserted as a figure that names its Media object.</p>'
+ '<div class="ov-editor-form ov-editor-search" data-ov-searchform><label>Search<input type="search" name="q" placeholder="a channel, a workbench, a town…"></label><div class="ov-editor-actions"><button type="button" data-ov-searchgo>Search</button></div></div>'
+ '<p class="meta" data-ov-status aria-live="polite"></p><ul class="ov-editor-results" data-ov-results></ul></div>');
panel.querySelector('[data-ov-close]').addEventListener('click', closePanel);
@@@ -194,63 +195,38 @@
var search = function () {
var q = form.querySelector('input[name=q]').value;
if (!q) return;
-- status.textContent = 'Searching Openverse and Commons…';
++ status.textContent = 'Searching Commons and Openverse…';
results.innerHTML = '';
-- Promise.allSettled([openverse(q), commons(q)]).then(function (out) {
-- var items = [];
-- out.forEach(function (r) { if (r.status === 'fulfilled') items = items.concat(r.value); else if (!items.length) status.textContent = 'One source could not be reached: ' + (r.reason && r.reason.message || 'no answer') + '.'; });
-- status.textContent = items.length ? items.length + ' pictures' : 'No pictures found.';
-- results.innerHTML = items.map(function (p, i) {
-- return '<li><img src="' + esc(p.thumb) + '" alt="" loading="lazy" decoding="async"><div><b>' + esc(p.title || 'Untitled') + '</b><span class="meta">' + esc([p.author, p.licence, p.source].filter(Boolean).join(' · ')) + '</span></div><button type="button" data-ov-pick="' + i + '">Insert</button></li>';
-- }).join('');
-- results.querySelectorAll('[data-ov-pick]').forEach(function (b) {
-- b.addEventListener('click', function () {
-- var p = items[Number(b.getAttribute('data-ov-pick'))];
-- var alt = (p.title || 'Picture').replace(/\s+/g, ' ').slice(0, 200);
-- insertBlock('![' + alt.replace(/[[\]]/g, '') + '](' + p.url + ')\n*Picture: ' + [p.author, p.licence, p.source].filter(Boolean).join(' · ') + '*');
-- closePanel();
++ var body = new URLSearchParams({ _csrf: CSRF, q: q });
++ fetch(FIND, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body.toString() })
++ .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
++ .then(function (x) {
++ if (!x.ok) { status.textContent = x.d && (x.d.detail || x.d.message) || 'The image task is not available here.'; return; }
++ var items = (x.d && x.d.items) || [];
++ status.textContent = items.length ? items.length + ' pictures' : (x.d && x.d.note) || 'No pictures found.';
++ results.innerHTML = items.map(function (p, i) {
++ return '<li><img src="' + esc(p.url) + '" alt="" loading="lazy" decoding="async"><div><b>' + esc(p.title || 'Untitled') + '</b><span class="meta">' + esc([p.author, p.license].filter(Boolean).join(' · ')) + '</span></div><button type="button" data-ov-pick="' + i + '">Insert</button></li>';
++ }).join('');
++ results.querySelectorAll('[data-ov-pick]').forEach(function (b) {
++ b.addEventListener('click', function () {
++ var p = items[Number(b.getAttribute('data-ov-pick'))];
++ var alt = (p.alt || p.title || 'Picture').replace(/\s+/g, ' ').slice(0, 200);
++ var credit = p.credit || [p.author, p.license].filter(Boolean).join(', ');
++ if (p.mediaId) {
++ insertBlock(':::figure{src="' + p.mediaId + '" alt="' + alt.replace(/"/g, '\\"') + '"' + (p.caption ? ' caption="' + String(p.caption).replace(/"/g, '\\"') + '"' : '') + (credit ? ' credit="' + credit.replace(/"/g, '\\"') + '"' : '') + '}:::');
++ } else {
++ insertBlock('![' + alt.replace(/[[\]]/g, '') + '](' + p.url + ')\n*Picture: ' + credit + '*');
++ }
++ closePanel();
++ });
});
-- });
-- });
++ })
++ .catch(function () { status.textContent = 'The image task could not be reached.'; });
};
panel.querySelector('[data-ov-searchgo]').addEventListener('click', search);
form.addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); search(); } });
}
-- function openverse(q) {
-- return fetch('https://api.openverse.org/v1/images/?q=' + encodeURIComponent(q) + '&page_size=12', { headers: { Accept: 'application/json' } })
-- .then(function (r) { if (!r.ok) throw new Error('Openverse answered ' + r.status); return r.json(); })
-- .then(function (d) {
-- return (d.results || []).map(function (x) {
-- return {
-- url: x.url, thumb: x.thumbnail || x.url, title: x.title,
-- author: x.creator, licence: [x.license, x.license_version].filter(Boolean).join(' ').toUpperCase(),
-- source: 'Openverse',
-- };
-- });
-- });
-- }
--
-- function commons(q) {
-- var api = 'https://commons.wikimedia.org/w/api.php?action=query&format=json&origin=*&generator=search&gsrnamespace=6&gsrlimit=12'
-- + '&gsrsearch=' + encodeURIComponent(q) + '&prop=imageinfo&iiprop=url%7Cextmetadata&iiurlwidth=480';
-- return fetch(api, { headers: { Accept: 'application/json' } })
-- .then(function (r) { if (!r.ok) throw new Error('Commons answered ' + r.status); return r.json(); })
-- .then(function (d) {
-- var pages = (d.query && d.query.pages) || {};
-- return Object.keys(pages).map(function (k) {
-- var p = pages[k];
-- var info = (p.imageinfo && p.imageinfo[0]) || {};
-- var meta = info.extmetadata || {};
-- var clean = function (v) { return v && v.value ? String(v.value).replace(/<[^>]*>/g, '').trim() : null; };
-- return {
-- url: info.url, thumb: info.thumburl || info.url, title: (p.title || '').replace(/^File:/, ''),
-- author: clean(meta.Artist), licence: clean(meta.LicenseShortName), source: 'Wikimedia Commons',
-- };
-- });
-- });
-- }
--
// ── Make an image ───────────────────────────────────────
function showImage() {
openPanel('<div class="ov-editor-card"><div class="ov-editor-cardhead"><h3>Make an image</h3><button type="button" class="ov-editor-x" data-ov-close aria-label="Close">×</button></div>'
@@@ -273,14 +249,17 @@
.then(function (x) {
if (!x.ok) { status.textContent = x.d && (x.d.detail || x.d.message) || 'The image task is not available here.'; return; }
var alt = (x.d.alt || 'Illustration').replace(/\s+/g, ' ').slice(0, 200);
++ if (x.d.dryRun) { status.textContent = 'This would make ' + (x.d.estimate ? x.d.estimate.images : 1) + ' image with ' + (x.d.estimate ? x.d.estimate.model : 'the model') + ', about $' + (x.d.estimate ? x.d.estimate.price_usd : '?') + '.'; return; }
if (x.d.mediaId) {
-- insertBlock(':::figure{src="' + x.d.mediaId + '" alt="' + alt.replace(/"/g, '\\"') + '" credit="Made with ' + String(x.d.model || 'AI').replace(/"/g, '') + '"}:::');
-- status.textContent = 'Inserted as a figure. Attach ' + x.d.mediaId + ' in the Media panel so it renders.';
++ var credit = String(x.d.credit || ('Made with AI (' + String(x.d.model || 'AI') + ')')).replace(/"/g, '');
++ var caption = String(x.d.caption || '').replace(/"/g, '\\"');
++ insertBlock(':::figure{src="' + x.d.mediaId + '" alt="' + alt.replace(/"/g, '\\"') + '"' + (caption ? ' caption="' + caption + '"' : '') + ' credit="' + credit + '"}:::');
++ status.textContent = 'Inserted as a figure, made in OpenVibe.Media.';
closePanel();
} else if (x.d.url) {
made.innerHTML = '<img src="' + esc(x.d.url) + '" alt="">';
status.textContent = 'Inserting the picture as a linked image…';
-- insertBlock('![' + alt.replace(/[[\]]/g, '') + '](' + x.d.url + ')\n*Made with ' + String(x.d.model || 'AI') + '*');
++ insertBlock('![' + alt.replace(/[[\]]/g, '') + '](' + x.d.url + ')\n*' + String(x.d.credit || 'Made with AI') + '*');
closePanel();
} else {
status.textContent = 'The run answered without a picture.';
ddiff --git a/server/app.js b/server/app.js
iindex 7857174..004129d 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -167,10 +167,9 @@ async function createApp(opts = {}) {
imgSrc: ["'self'", 'data:', 'https:'],
mediaSrc: ["'self'", 'https:'],
// openvibe.events: release notifications (release-watch's EventSource, openvibe-shared 1.17).
-- // The navbar's Messages count asks Chat (openvibe-shared 4.130.0).
-- // The editor's picture search asks Openverse and Wikimedia Commons directly (the plan's
-- // fallback while Exchange's image search is not here): their APIs, not ours, answer.
-- connectSrc: ["'self'", 'https://openvibe.chat', 'https://openvibe.network', 'https://cloudflareinsights.com', 'https://openvibe.events', 'https://api.openverse.org', 'https://commons.wikimedia.org'],
++ // The navbar's Messages count asks Chat (openvibe-shared 4.130.0). Picture search goes through
++ // Blog's own server and the shared image task, so no picture source is a browser origin.
++ connectSrc: ["'self'", 'https://openvibe.chat', 'https://openvibe.network', 'https://cloudflareinsights.com', 'https://openvibe.events'],
frameSrc: ["'self'", 'https://openvibe.network'],
frameAncestors: ["'self'"],
objectSrc: ["'none'"],
ddiff --git a/server/domain/ai-drafts.js b/server/domain/ai-drafts.js
iindex f43f0ea..4160a0b 100644
---- a/server/domain/ai-drafts.js
++++ b/server/domain/ai-drafts.js
@@@ -98,22 +98,57 @@ function createAiDrafts({ config, store, posts, access, fetchImpl = globalThis.f
}
/**
-- * "Make an image" in the editor: the image task (`image.generate`) through OpenVibe.AI, with the same
-- * target and on-behalf-of a draft uses. The answer's Media id is what a figure block names; a plain
-- * URL is returned as well so the editor can show what was made. Nothing is stored here.
++ * "Find a picture" in the editor: the shared image task's find mode. Blog calls the task, never Openverse
++ * or Commons from a browser; the pictures are already in OpenVibe.Media, with their licence and author,
++ * so a figure block names a Media id and a plain URL is only the preview.
++ */
++ async function findImages(viewer, blog, input = {}, { traceparent } = {}) {
++ if (!viewer || !viewer.subject) throw new ApiError(401, 'subject.required', 'Sign in to find a picture');
++ const query = clean(input.query || input.q, 200);
++ if (!query) throw new ApiError(422, 'ai_image.query_required', 'Say what to look for');
++ const count = Math.max(1, Math.min(12, Number(input.count) || 8));
++ try {
++ const r = await ai.run('image.picture', { mode: 'find', query, count, media_app: 'blog' }, {
++ onBehalfOf: { type: 'user', id: viewer.subject }, target: { service: 'blog', type: 'blog', id: String(blog.id) }, traceparent,
++ });
++ const out = r.output || {};
++ return {
++ runId: r.runId, task: 'image.picture', query, note: out.note || null,
++ items: (Array.isArray(out.media) ? out.media : []).map((m) => ({
++ mediaId: m.media_id || null, url: m.url || null, title: m.title || null, alt: m.alt || m.title || null,
++ caption: m.caption || null, credit: m.credit || null, author: m.author || null, license: m.license || null,
++ licenseUrl: m.license_url || null, sourceUrl: m.source_url || null, provider: m.provider || null,
++ width: m.width == null ? null : Number(m.width), height: m.height == null ? null : Number(m.height), ai: m.ai === true,
++ })),
++ };
++ } catch (err) {
++ if (err instanceof AiRunError) throw new ApiError(err.status || 502, err.code, err.message);
++ throw err;
++ }
++ }
++
++ /**
++ * "Make an image" in the editor: the shared image task's make mode (`image.picture`) through OpenVibe.AI,
++ * with the same target and on-behalf-of a draft uses. The answer's Media id is what a figure block names;
++ * the caption credit says "Made with AI (<model>)". Nothing is stored here.
*/
async function image(viewer, blog, input = {}, { traceparent } = {}) {
if (!viewer || !viewer.subject) throw new ApiError(401, 'subject.required', 'Sign in to make an image');
const prompt = clean(input.prompt, 1000);
if (!prompt) throw new ApiError(422, 'ai_image.prompt_required', 'Say what the image should show');
++ const alt = clean(input.alt, 300);
try {
-- const r = await ai.run('image.generate', { prompt, ...(input.alt ? { alt: clean(input.alt, 300) } : {}) }, {
++ const r = await ai.run('image.picture', { mode: 'make', prompt, ...(alt ? { alt } : {}), ...(input.dry_run ? { dry_run: true } : {}), media_app: 'blog' }, {
onBehalfOf: { type: 'user', id: viewer.subject }, target: { service: 'blog', type: 'blog', id: String(blog.id) }, traceparent,
});
const out = r.output || {};
++ const made = Array.isArray(out.media) && out.media[0] ? out.media[0] : {};
++ const model = made.model || (out.estimate && out.estimate.model) || (r.provenance && r.provenance.model) || null;
return {
-- runId: r.runId, mediaId: out.media_id || out.mediaId || null, url: out.url || null,
-- alt: out.alt || clean(input.alt, 300) || null, model: (r.workflow && r.workflow.model) || null, synthetic: Boolean(r.synthetic),
++ runId: r.runId, dryRun: out.dry_run === true, estimate: out.estimate || null,
++ mediaId: made.media_id || null, url: made.url || null,
++ alt: made.alt || alt || null, caption: made.caption || null,
++ credit: made.credit || (model ? `Made with AI (${model})` : 'Made with AI'), model, synthetic: Boolean(r.synthetic),
};
} catch (err) {
if (err instanceof AiRunError) throw new ApiError(err.status || 502, err.code, err.message);
@@@ -132,7 +167,7 @@ function createAiDrafts({ config, store, posts, access, fetchImpl = globalThis.f
async function devblog(viewer, blog, input, options = {}) {
return devblogClient.run('content.devblog', input, { ...options, onBehalfOf: { type: 'user', id: viewer.subject }, target: { service: 'blog', type: 'blog', id: String(blog.id) } });
}
-- return { devblog, enabled: ai.enabled, imageEnabled: ai.enabled, draft, rawDraft, image };
++ return { devblog, enabled: ai.enabled, imageEnabled: ai.enabled, draft, rawDraft, image, findImages };
}
module.exports = { createAiDrafts };
ddiff --git a/server/http/editor.js b/server/http/editor.js
iindex fe52f38..5cead6e 100644
---- a/server/http/editor.js
++++ b/server/http/editor.js
@@@ -312,14 +312,31 @@ function createEditorRoutes(ctx) {
} catch (err) { next(err); }
});
++ // "Find a picture" in the inserter: the shared image task's find mode through OpenVibe.AI. The browser
++ // no longer calls Openverse or Commons itself; the task keeps licence and author and stores the picture.
++ router.post('/find-images', B('blog.post.preview'), async (req, res, next) => {
++ try {
++ if (!ctx.aiDrafts || !ctx.aiDrafts.imageEnabled) return res.status(503).json({ code: 'ai.not_configured', detail: 'OpenVibe.AI has no image task on this server yet.' });
++ const out = await ctx.aiDrafts.findImages(req.viewer, await blogs.official(), formInput(req.body), { traceparent: req.ov && req.ov.traceparent });
++ res.json(out);
++ } catch (err) {
++ const e = asApiError(err);
++ if (e && e.status >= 400 && e.status < 500) return res.status(e.status).json({ code: e.code, detail: e.message });
++ next(err);
++ }
++ });
++
// "Make an image" in the inserter: the image task through OpenVibe.AI, the same service the AI draft uses.
router.post('/make-image', B('blog.post.ai_draft'), async (req, res, next) => {
try {
if (!ctx.aiDrafts || !ctx.aiDrafts.imageEnabled) return res.status(503).json({ code: 'ai.not_configured', detail: 'OpenVibe.AI has no image task on this server yet.' });
-- const found = await postFor(req);
-- const out = await ctx.aiDrafts.image(req.viewer, found ? found.blog : await blogs.official(), formInput(req.body), { traceparent: req.ov && req.ov.traceparent });
++ const out = await ctx.aiDrafts.image(req.viewer, await blogs.official(), formInput(req.body), { traceparent: req.ov && req.ov.traceparent });
res.json(out);
-- } catch (err) { next(err); }
++ } catch (err) {
++ const e = asApiError(err);
++ if (e && e.status >= 400 && e.status < 500) return res.status(e.status).json({ code: e.code, detail: e.message });
++ next(err);
++ }
});
// Settings → How I write: the voice card the AI prompt uses.
ddiff --git a/server/render/editor.js b/server/render/editor.js
iindex 72852e5..ea4053c 100644
---- a/server/render/editor.js
++++ b/server/render/editor.js
@@@ -78,7 +78,7 @@ ${head ? h`<input type="hidden" name="expectedRevision" value="${head.number}">`
<label for="title">Title</label><input id="title" name="title" maxlength="200" required value="${f.title || ''}">
<label for="summary">Summary <span class="meta">(optional, shown in lists and feeds)</span></label><input id="summary" name="summary" maxlength="500" value="${f.summary || ''}">
<label for="body">Text <span class="meta">(Markdown and blocks; a line <code>[[media:med_…]]</code> places an attached Media object)</span></label>
--<div class="ov-editor" data-ov-editor data-post="${post ? post.id : ''}" data-csrf="${csrf}" data-preview="/write/preview" data-image="/write/make-image">
++<div class="ov-editor" data-ov-editor data-post="${post ? post.id : ''}" data-csrf="${csrf}" data-preview="/write/preview" data-image="/write/make-image" data-find="/write/find-images">
<div class="ov-editor-bar">
<button type="button" class="ov-editor-btn" data-ov-insert aria-expanded="false">Insert a block <kbd>/</kbd></button>
<button type="button" class="ov-editor-btn" data-ov-outline aria-pressed="false">Outline</button>
ddiff --git a/test/editor-inserter.test.js b/test/editor-inserter.test.js
iindex 960d72b..4bd795b 100644
---- a/test/editor-inserter.test.js
++++ b/test/editor-inserter.test.js
@@@ -20,7 +20,7 @@ const MED = 'med_01J8ZQ4X3M000000000000000A';
await check('the edit page carries the inserter and its script; the form still works without it', async () => {
const page = await t.get(`/write/posts/${id}`, { as: alice });
assert.strictEqual(page.status, 200, page.text);
-- assert.match(page.text, /data-ov-editor data-post="pst_[^"]+" data-csrf="[^"]+" data-preview="\/write\/preview" data-image="\/write\/make-image"/);
++ assert.match(page.text, /data-ov-editor data-post="pst_[^"]+" data-csrf="[^"]+" data-preview="\/write\/preview" data-image="\/write\/make-image" data-find="\/write\/find-images"/);
assert.match(page.text, /<script src="\/js\/blog-editor\.js" defer><\/script>/);
assert.match(page.text, /<textarea id="body" name="body" rows="22">A first draft.<\/textarea>/, 'the textarea saves without script');
});
ddiff --git a/test/image-task.test.js b/test/image-task.test.js
nnew file mode 100644
iindex 0000000..5f05efc
---- /dev/null
++++ b/test/image-task.test.js
@@@ -0,0 +1,88 @@
++'use strict';
++/**
++ * The shared image task from Blog's editor: "Find a picture" and "Make an image" both call
++ * OpenVibe.AI's image.picture task, with Media references coming back (never a source API hit from
++ * the browser). A made picture's credit says "Made with AI (<model>)".
++ */
++const assert = require('assert');
++const http = require('http');
++const { boot, check, done } = require('./helpers/boot');
++
++const RUN = 'run_01JAB2C3D4E5F6G7H8J9K0MNPQ';
++const MED = 'med_01J8ZQ4X3M000000000000000A';
++
++(async () => {
++ const asked = [];
++ const aiSrv = await new Promise((r) => {
++ const s = http.createServer((req, res) => {
++ let raw = '';
++ req.on('data', (c) => { raw += c; });
++ req.on('end', () => {
++ const json = (st, o) => { res.writeHead(st, { 'content-type': 'application/json' }); res.end(JSON.stringify(o)); };
++ if (req.method === 'POST' && req.url.startsWith('/api/v1/runs')) {
++ const body = JSON.parse(raw);
++ asked.push(body);
++ const make = body.input && body.input.mode === 'make';
++ const item = make
++ ? { media_id: MED, url: `https://openvibe.media/o/${MED}`, width: 1024, height: 1024, title: 'A goose', alt: 'A goose', caption: 'A goose (Made with AI (gpt-image-1))', credit: 'Made with AI (gpt-image-1)', author: null, license: null, license_url: null, source_url: null, provider: 'image', ai: true, model: 'gpt-image-1' }
++ : { media_id: MED, url: `https://openvibe.media/o/${MED}`, width: 800, height: 600, title: 'A workbench', alt: 'A workbench in a workshop', caption: 'A workbench', credit: 'Ada Example, CC BY-SA 4.0', author: 'Ada Example', license: 'CC BY-SA 4.0', license_url: 'https://creativecommons.org/licenses/by-sa/4.0/', source_url: 'https://commons.wikimedia.org/wiki/File:Workbench', provider: 'commons', ai: false, model: null };
++ return json(201, { run: { id: RUN, status: 'succeeded', workflow: { key: 'image.picture', version: 1 }, synthetic: false, output: {
++ mode: make ? 'make' : 'find', task: 'image.picture', query: make ? null : 'workbench', prompt: make ? 'A goose' : null, dry_run: false,
++ estimate: make ? { model: 'gpt-image-1', source: 'openvibe', images: 1, per_image_usd: 0.04, price_usd: 0.04 } : null,
++ media: [item], count: 1, gaps: [], note: null,
++ } } });
++ }
++ json(404, {});
++ });
++ }).listen(0, '127.0.0.1', () => r(s));
++ });
++
++ const t = await boot({ env: { OV_AI_INTERNAL_URL: `http://127.0.0.1:${aiSrv.address().port}` } });
++ const alice = t.network.addUser('alice', { display_name: 'Alice' });
++ await t.get('/api/v1/blogs', { as: alice, json: {} });
++
++ await check('Find a picture goes through Blog and the shared task, and returns Media references', async () => {
++ const r = await t.get('/write/find-images', { as: alice, form: { _csrf: t.csrf(alice), q: 'workbench', count: '4' } });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.json().task, 'image.picture');
++ assert.strictEqual(r.json().items.length, 1);
++ assert.strictEqual(r.json().items[0].mediaId, MED);
++ assert.deepStrictEqual({ license: r.json().items[0].license, author: r.json().items[0].author, ai: r.json().items[0].ai }, { license: 'CC BY-SA 4.0', author: 'Ada Example', ai: false });
++ assert.strictEqual(asked[0].workflow, 'image.picture');
++ assert.deepStrictEqual(asked[0].input, { mode: 'find', query: 'workbench', count: 4, media_app: 'blog' });
++ assert.deepStrictEqual(asked[0].on_behalf_of, { type: 'user', id: alice.subject });
++ const page = await t.get('/write/@alice/new', { as: alice });
++ assert.ok(page.text.includes('data-find="/write/find-images"'));
++ assert.ok(!page.text.includes('api.openverse.org'), 'the browser no longer asks Openverse');
++ const js = await t.get('/js/blog-editor.js');
++ assert.ok(!js.text.includes('api.openverse.org') && !js.text.includes('commons.wikimedia.org'), 'picture search is server-side');
++ });
++
++ await check('a query is required, and a stranger cannot search', async () => {
++ assert.strictEqual((await t.get('/write/find-images', { as: alice, form: { _csrf: t.csrf(alice) } })).status, 422);
++ const stranger = t.network.addUser('stranger');
++ assert.strictEqual((await t.get('/write/find-images', { as: stranger, form: { _csrf: t.csrf(stranger), q: 'x' } })).status, 200, 'signed-in search is a read, not a write');
++ });
++
++ await check('Make an image returns the Media reference and its made-with-AI credit', async () => {
++ asked.length = 0;
++ // The same stub answers make as find; make's own output shape is exercised in AI's tests.
++ const r = await t.get('/write/make-image', { as: alice, form: { _csrf: t.csrf(alice), prompt: 'A goose', alt: 'A goose' } });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(asked[0].workflow, 'image.picture');
++ assert.deepStrictEqual(asked[0].input, { mode: 'make', prompt: 'A goose', alt: 'A goose', media_app: 'blog' });
++ assert.strictEqual(r.json().mediaId, MED);
++ assert.strictEqual(r.json().credit, 'Made with AI (gpt-image-1)');
++ assert.match(r.json().caption, /Made with AI \(gpt-image-1\)/);
++ });
++
++ await check('a search with no note still reports an empty answer honestly', async () => {
++ const r = await t.get('/write/find-images', { as: alice, form: { _csrf: t.csrf(alice), q: 'nothing' } });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.ok(Array.isArray(r.json().items));
++ });
++
++ await t.close();
++ aiSrv.close();
++ done();
++})();