openvibers/openvibe.blog Commits
Blog: find and make pictures through the shared image task (#264)
Changes
@@@ -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 thesubject in `plain` words. Library pins, merges and test/docs/CI-only work become internal without a model; the rest@@@ -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.';@@@ -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'"],@@@ -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.fasync 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 };@@@ -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.@@@ -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>@@@ -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');});nnew file mode 100644@@@ -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();++})();
@@ -2,6 +2,11 @@
@@ -2,6 +2,11 @@
## Unreleased
## 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.
- 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
- 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
subject in `plain` words. Library pins, merges and test/docs/CI-only work become internal without a model; the rest
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
* - a "/" menu: type "/" at the start of a line, or press the toolbar button;
* - 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);
* - 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;
* - 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).
* - "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
* Blocks are the Markdown directives openvibe-publishing/blocks parses; the textarea stays the source
@@ -26,6 +26,7 @@
@@ -26,6 +26,7 @@
var POST = root.getAttribute('data-post') || '';
var POST = root.getAttribute('data-post') || '';
var PREVIEW = root.getAttribute('data-preview') || '/write/preview';
var PREVIEW = root.getAttribute('data-preview') || '/write/preview';
var IMAGE = root.getAttribute('data-image') || '/write/make-image';
var IMAGE = root.getAttribute('data-image') || '/write/make-image';
+ var FIND = root.getAttribute('data-find') || '/write/find-images';
var BLOCKS = [
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' }] },
{ 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 @@
@@ -181,10 +182,10 @@
redraw();
redraw();
}
}
- // ── Picture search: Openverse and Commons, straight from the browser ──
+ // ── Picture search: the shared image task, through Blog and OpenVibe.AI ──
function showSearch() {
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>'
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>'
+ '<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>');
+ '<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);
panel.querySelector('[data-ov-close]').addEventListener('click', closePanel);
@@ -194,63 +195,38 @@
@@ -194,63 +195,38 @@
var search = function () {
var search = function () {
var q = form.querySelector('input[name=q]').value;
var q = form.querySelector('input[name=q]').value;
if (!q) return;
if (!q) return;
- status.textContent = 'Searching Openverse and Commons…';
+ status.textContent = 'Searching Commons and Openverse…';
results.innerHTML = '';
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);
panel.querySelector('[data-ov-searchgo]').addEventListener('click', search);
form.addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); 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 ───────────────────────────────────────
// ── Make an image ───────────────────────────────────────
function showImage() {
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>'
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 @@
@@ -273,14 +249,17 @@
.then(function (x) {
.then(function (x) {
if (!x.ok) { status.textContent = x.d && (x.d.detail || x.d.message) || 'The image task is not available here.'; return; }
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);
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) {
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();
closePanel();
} else if (x.d.url) {
} else if (x.d.url) {
made.innerHTML = '<img src="' + esc(x.d.url) + '" alt="">';
made.innerHTML = '<img src="' + esc(x.d.url) + '" alt="">';
status.textContent = 'Inserting the picture as a linked image…';
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();
closePanel();
} else {
} else {
status.textContent = 'The run answered without a picture.';
status.textContent = 'The run answered without a picture.';
@@ -167,10 +167,9 @@ async function createApp(opts = {}) {
@@ -167,10 +167,9 @@ async function createApp(opts = {}) {
imgSrc: ["'self'", 'data:', 'https:'],
imgSrc: ["'self'", 'data:', 'https:'],
mediaSrc: ["'self'", 'https:'],
mediaSrc: ["'self'", 'https:'],
// openvibe.events: release notifications (release-watch's EventSource, openvibe-shared 1.17).
// 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'],
frameSrc: ["'self'", 'https://openvibe.network'],
frameAncestors: ["'self'"],
frameAncestors: ["'self'"],
objectSrc: ["'none'"],
objectSrc: ["'none'"],
@@ -98,22 +98,57 @@ function createAiDrafts({ config, store, posts, access, fetchImpl = globalThis.f
@@ -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 } = {}) {
async function image(viewer, blog, input = {}, { traceparent } = {}) {
if (!viewer || !viewer.subject) throw new ApiError(401, 'subject.required', 'Sign in to make an image');
if (!viewer || !viewer.subject) throw new ApiError(401, 'subject.required', 'Sign in to make an image');
const prompt = clean(input.prompt, 1000);
const prompt = clean(input.prompt, 1000);
if (!prompt) throw new ApiError(422, 'ai_image.prompt_required', 'Say what the image should show');
if (!prompt) throw new ApiError(422, 'ai_image.prompt_required', 'Say what the image should show');
+ const alt = clean(input.alt, 300);
try {
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,
onBehalfOf: { type: 'user', id: viewer.subject }, target: { service: 'blog', type: 'blog', id: String(blog.id) }, traceparent,
});
});
const out = r.output || {};
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 {
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) {
} catch (err) {
if (err instanceof AiRunError) throw new ApiError(err.status || 502, err.code, err.message);
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
@@ -132,7 +167,7 @@ function createAiDrafts({ config, store, posts, access, fetchImpl = globalThis.f
async function devblog(viewer, blog, input, options = {}) {
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 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 };
module.exports = { createAiDrafts };
@@ -312,14 +312,31 @@ function createEditorRoutes(ctx) {
@@ -312,14 +312,31 @@ function createEditorRoutes(ctx) {
} catch (err) { next(err); }
} 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.
// "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) => {
router.post('/make-image', B('blog.post.ai_draft'), async (req, res, next) => {
try {
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.' });
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);
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.
// Settings → How I write: the voice card the AI prompt uses.
@@ -78,7 +78,7 @@ ${head ? h`<input type="hidden" name="expectedRevision" value="${head.number}">`
@@ -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="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="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>
<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">
<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-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>
<button type="button" class="ov-editor-btn" data-ov-outline aria-pressed="false">Outline</button>
@@ -20,7 +20,7 @@ const MED = 'med_01J8ZQ4X3M000000000000000A';
@@ -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 () => {
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 });
const page = await t.get(`/write/posts/${id}`, { as: alice });
assert.strictEqual(page.status, 200, page.text);
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, /<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');
assert.match(page.text, /<textarea id="body" name="body" rows="22">A first draft.<\/textarea>/, 'the textarea saves without script');
});
});
new file mode 100644
new file mode 100644
@@ -0,0 +1,88 @@
@@ -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();
+})();