openvibers/openvibe.blog Commits

Draft with AI (W13, proof flow 5): a member who may write on a blog asks OpenVibe.AI's blog.draft_post (openvibe-publishing v0.3.0 ai client, Blog's service token) for a draft from a topic, brief, tone and audience; the answer becomes an AI-authored post (authorship mode ai with the workflow and run), a draft and noindex until a person reviews it, with the run's citations on revision 1. POST /api/v1/blogs/:handle/posts/ai-draft and a no-JS form on the New post page; a failed run makes no post; offered only when OV_AI_INTERNAL_URL is set. test/ai-draft.test.js

OP OpenVibers committed 24 Sept 2026 · 4d522cac3c · 9 files, +201 −8


Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex a2b8425..a42c149 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -16,7 +16,7 @@
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.33.0",
-- "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.2.2",
++ "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.3.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.7.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.5.1"
},
@@@ -1031,9 +1031,9 @@
}
},
"node_modules/openvibe-publishing": {
-- "version": "0.2.2",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.2.2",
-- "integrity": "sha512-+aX5lcXQfvfczCY4Ca89B5uYugCS/uncu4gYI7U/mQoOioXZMHk/mCu2f1/+qxM1Ehi6EA2eB/0TCRv7jbuHbA==",
++ "version": "0.3.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.3.0",
++ "integrity": "sha512-zekpQwHyxkhVW5CZf3ccDPkOniV1hjyw9VlVajA+4bLRCsLUo+jcxamtFIVM2IJ0i8pDlF/MpUfj4wSSv9H/iw==",
"license": "MIT",
"dependencies": {
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.5.1"
ddiff --git a/package.json b/package.json
iindex 6dbd446..e7d9464 100644
---- a/package.json
++++ b/package.json
@@@ -22,7 +22,7 @@
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.33.0",
-- "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.2.2",
++ "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.3.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.7.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.5.1"
},
ddiff --git a/server/app.js b/server/app.js
iindex 55796f4..1a93171 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -67,7 +67,8 @@ function createApp(opts = {}) {
const worker = createWorker({ config, store, posts, media, outbox, log });
blogs.ensureOfficial();
-- const ctx = { config, store, outbox, blogs, publication, posts, people, community, media, reading, effects, entitlements, vip, auth, viewers, access, worker };
++ const aiDrafts = opts.aiDrafts || require('./domain/ai-drafts').createAiDrafts({ config, store, posts, access, fetchImpl });
++ const ctx = { config, store, outbox, blogs, publication, posts, people, community, media, reading, effects, entitlements, vip, auth, viewers, access, worker, aiDrafts };
const app = express();
app.disable('x-powered-by');
ddiff --git a/server/config.js b/server/config.js
iindex 73254ba..a0e33d6 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -34,6 +34,8 @@ function load(env = process.env) {
// OpenVibe.Network: SSO (OAuth2 authorization server), JWKS, client-credentials tokens.
networkUrl: trim(env.OV_NETWORK_URL || 'https://openvibe.network'),
networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),
++ // OpenVibe.AI for "Draft with AI" (server/domain/ai-drafts.js); empty = the button is not offered.
++ aiUrl: trim(env.OV_AI_INTERNAL_URL || ''),
oauth: {
clientId: env.OV_OAUTH_CLIENT_ID || 'blog',
clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',
ddiff --git a/server/domain/ai-drafts.js b/server/domain/ai-drafts.js
nnew file mode 100644
iindex 0000000..468d822
---- /dev/null
++++ b/server/domain/ai-drafts.js
@@@ -0,0 +1,66 @@
++'use strict';
++/**
++ * Draft with AI (roadmap W13, proof flow 5 on Blog): a member who may write on a blog asks
++ * OpenVibe.AI's blog.draft_post workflow for a draft from a topic (and optionally a brief, tone and
++ * audience). The answer becomes a new post that is AI-authored (openvibe-publishing/authorship mode
++ * 'ai', naming the workflow and run), a draft, and noindex until a person reviews it; the run's
++ * citations are attached to its first revision. The member is the acting subject (their blog, their
++ * post); the text is never attributed to them as its writer.
++ *
++ * Uses openvibe-publishing/ai with Blog's service token (audience openvibe.ai, ai.run.create and
++ * ai.run.read in the blog namespace). Off unless OV_AI_INTERNAL_URL and OV_OAUTH_CLIENT_SECRET are set.
++ */
++const { serviceAuth } = require('openvibe-contracts');
++const { createAiClient, AiRunError } = require('openvibe-publishing/ai');
++const { ApiError } = require('../http/errors');
++
++const clean = (v, n) => String(v == null ? '' : v).replace(/\s+/g, ' ').trim().slice(0, n);
++
++function createAiDrafts({ config, store, posts, access, fetchImpl = globalThis.fetch }) {
++ const tokenClient = config.aiUrl && config.oauth.clientSecret ? serviceAuth.createTokenClient({
++ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
++ audience: 'openvibe.ai', scope: 'ai.run.create ai.run.read', fetchImpl,
++ }) : null;
++ const ai = createAiClient({ baseUrl: config.aiUrl, tokenClient, fetch: fetchImpl });
++
++ async function draft(viewer, blog, input = {}, { traceparent } = {}) {
++ if (!viewer || !viewer.subject) throw new ApiError(401, 'subject.required', 'Sign in to ask for an AI draft');
++ if (!access.canWrite(store, viewer, blog, 'create')) throw new ApiError(403, 'blog.forbidden', 'You cannot write on this blog');
++ const topic = clean(input.topic, 300);
++ if (!topic) throw new ApiError(422, 'ai_draft.topic_required', 'Say what the post should be about');
++ const brief = String(input.brief == null ? '' : input.brief).trim().slice(0, 4000);
++ const tone = clean(input.tone, 200);
++ const audience = clean(input.audience, 300);
++ let r;
++ try {
++ r = await ai.run('blog.draft_post', { topic, ...(brief ? { brief } : {}), ...(tone ? { tone } : {}), ...(audience ? { audience } : {}) }, {
++ onBehalfOf: { type: 'user', id: viewer.subject }, target: { service: 'blog', type: 'blog', id: String(blog.id) }, traceparent,
++ });
++ } catch (err) {
++ if (err instanceof AiRunError) throw new ApiError(err.status || 502, err.code, err.message);
++ throw err;
++ }
++ const out = r.output || {};
++ const body = String(out.body_markdown || '').trim();
++ if (!body) throw new ApiError(422, 'ai_draft.empty', `The AI run (${r.runId}) returned no text, so no draft was made`);
++ const citations = (r.citations || []).filter((c) => c && (c.url || c.title)).map((c) => ({
++ url: c.url || null, title: c.title || null, retrievedAt: c.retrieved_at || c.retrievedAt || null,
++ quote: c.snippet ? { text: String(c.snippet).slice(0, 1000) } : null,
++ }));
++ // The member acts (their blog, their draft); the text is the AI's (authorship mode 'ai').
++ const aiViewer = { kind: 'service', service: 'svc:ai', origin: 'ai', subject: viewer.subject };
++ return posts.create(aiViewer, blog, {
++ title: clean(out.title, 200) || topic,
++ summary: clean(out.dek, 400) || null,
++ body,
++ tags: Array.isArray(out.tags) ? out.tags.map((t) => clean(t, 50)).filter(Boolean).slice(0, 5) : undefined,
++ citations,
++ authorship: { workflow: r.workflow, stubProvider: r.synthetic },
++ message: `AI draft (blog.draft_post, run ${r.runId}) requested by ${viewer.subject}`,
++ }, { traceparent });
++ }
++
++ return { enabled: ai.enabled, draft };
++}
++
++module.exports = { createAiDrafts };
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 7bbb54e..18cc1d6 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -182,6 +182,15 @@ function createApi(ctx) {
return { total, posts: rows.map((p) => postDto(p)) };
}));
++ // Draft with AI: OpenVibe.AI's blog.draft_post writes a draft (AI-authored, noindex until reviewed).
++ router.post('/blogs/:handle/posts/ai-draft', guard('blog.post.create'), jsonBody, run(async (req) => {
++ const blog = mustBlog(req);
++ if (!ctx.aiDrafts || !ctx.aiDrafts.enabled) throw new ApiError(503, 'ai.not_configured', 'Drafting with AI is not available on this blog right now');
++ const { post, revision } = await ctx.aiDrafts.draft(req.viewer, blog, req.body || {}, tp(req));
++ after(null, post.id, req);
++ return { post: postDto(post, { full: true }), revision: revision.number };
++ }, 201));
++
router.post('/blogs/:handle/posts', guard('blog.post.create'), jsonBody, run((req) => {
const blog = mustBlog(req);
const { post, revision } = posts.create(req.viewer, blog, req.body || {}, tp(req));
ddiff --git a/server/http/editor.js b/server/http/editor.js
iindex 8fb3238..e7f0f8a 100644
---- a/server/http/editor.js
++++ b/server/http/editor.js
@@@ -8,6 +8,7 @@
* POST /write/start create your member blog (first use)
* GET /write/@:handle the blog's posts (drafts included)
* GET /write/@:handle/new, POST …/new a new draft
++ * POST /write/@:handle/ai-draft a new draft written by OpenVibe.AI (blog.draft_post)
* GET /write/posts/:id, POST … edit (a new revision; 412 when someone saved first)
* POST /write/posts/:id/{publish,schedule,unschedule,unpublish,delete,review,revert}
* POST /write/posts/:id/media, …/media/:aid/remove
@@@ -116,7 +117,18 @@ function createEditorRoutes(ctx) {
router.get('/@:handle/new', wrap(async (req, res) => {
const blog = blogFor(req);
if (!blog || !access.canWrite(store, req.viewer, blog, 'create')) return notFound(req, res);
-- page(req, res, 'New post', `<p><a href="/write/@${esc(blog.handle)}">← ${esc(blog.title)}</a></p><h1>New post</h1>${editor.postForm({ blog, post: null, head: null, terms: {}, series: null, csrf: csrf(req), action: `/write/@${blog.handle}/new` })}`);
++ const aiForm = ctx.aiDrafts && ctx.aiDrafts.enabled ? editor.aiDraftForm({ blog, csrf: csrf(req) }) : '';
++ page(req, res, 'New post', `<p><a href="/write/@${esc(blog.handle)}">← ${esc(blog.title)}</a></p><h1>New post</h1>${aiForm}${editor.postForm({ blog, post: null, head: null, terms: {}, series: null, csrf: csrf(req), action: `/write/@${blog.handle}/new` })}`);
++ }));
++
++ // Draft with AI: the draft is AI-authored and stays a noindex draft until someone reviews it.
++ router.post('/@:handle/ai-draft', wrap(async (req, res) => {
++ const blog = blogFor(req);
++ if (!blog || !ctx.aiDrafts || !ctx.aiDrafts.enabled) return notFound(req, res);
++ const b = formInput(req.body);
++ const { post, revision } = await ctx.aiDrafts.draft(req.viewer, blog, { topic: b.topic, brief: b.brief, tone: b.tone, audience: b.audience }, { traceparent: req.ov && req.ov.traceparent });
++ effects.after(null, post, req.ov);
++ res.redirect(303, `/write/posts/${post.id}?saved=${encodeURIComponent(`AI draft saved (revision ${revision.number}). It stays a draft, marked as AI-written, until you review and publish it.`)}`);
}));
router.post('/@:handle/new', wrap(async (req, res) => {
ddiff --git a/server/render/editor.js b/server/render/editor.js
iindex d01fd78..3360ea2 100644
---- a/server/render/editor.js
++++ b/server/render/editor.js
@@@ -46,6 +46,19 @@ function select(name, options, current) {
return h`<select id="${name}" name="${name}">${options.map(([v, label]) => (v === current ? h`<option value="${v}" selected>${label}</option>` : h`<option value="${v}">${label}</option>`))}</select>`;
}
++/** Draft with AI (only when OpenVibe.AI is configured): a topic, and optionally a brief, tone and audience. */
++function aiDraftForm({ blog, csrf }) {
++ return h`<details class="ai-draft"><summary>Draft with AI</summary>
++<form method="post" action="/write/@${blog.handle}/ai-draft">${csrfField(csrf)}
++<p class="muted">OpenVibe.AI writes a first draft. It is saved as a draft marked as AI-written, and is not published or shown to search engines until you review it.</p>
++<label>Topic <input name="topic" maxlength="300" required placeholder="What should the post be about?"></label>
++<label>Brief <textarea name="brief" maxlength="4000" rows="4" placeholder="Points to cover, links, what to avoid (optional)"></textarea></label>
++<label>Tone <input name="tone" maxlength="200" placeholder="e.g. friendly, plain (optional)"></label>
++<label>Audience <input name="audience" maxlength="300" placeholder="Who is it for? (optional)"></label>
++<button type="submit">Write a draft</button>
++</form></details>`;
++}
++
/** New post and edit form (the same fields). */
function postForm({ blog, post, head, terms, series, csrf, message, action }) {
const f = head ? head.fields : {};
@@@ -150,4 +163,4 @@ ${m.role !== 'owner' || blog.kind === 'official' ? h`<form method="post" action=
<button type="submit">Add member</button></form></section>`);
}
--module.exports = { home, blogDashboard, postForm, editPage, revisionsPage, settingsPage, gateSummary };
++module.exports = { home, blogDashboard, postForm, aiDraftForm, editPage, revisionsPage, settingsPage, gateSummary };
ddiff --git a/test/ai-draft.test.js b/test/ai-draft.test.js
nnew file mode 100644
iindex 0000000..3706926
---- /dev/null
++++ b/test/ai-draft.test.js
@@@ -0,0 +1,90 @@
++'use strict';
++/**
++ * Draft with AI (W13, proof flow 5 on Blog): a member asks OpenVibe.AI's blog.draft_post for a draft;
++ * Blog files the answer as an AI-authored, noindex draft with the run's citations. The API and the
++ * no-JS editor form both work, a failed run makes no post, and without AI configured nothing is offered.
++ */
++const assert = require('assert');
++const http = require('http');
++const { boot, check, done } = require('./helpers/boot');
++
++const RUN = 'run_01JAB2C3D4E5F6G7H8J9K0MNPQ';
++const LONG = Array.from({ length: 120 }, (_, i) => `word${i}`).join(' ');
++
++(async () => {
++ const asked = [];
++ let mode = 'ok';
++ const aiSrv = await new Promise((r) => { const s = http.createServer((req, res) => {
++ let b = ''; req.on('data', (c) => { b += 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')) {
++ asked.push({ auth: req.headers.authorization || null, body: JSON.parse(b) });
++ if (mode === 'fail') return json(201, { run: { id: RUN, status: 'failed', error: { code: 'provider.unavailable', detail: 'down' } } });
++ return json(201, { run: { id: RUN, status: 'succeeded', workflow: { key: 'blog.draft_post', version: 3 }, synthetic: false, provenance: { model: 'm-1' },
++ output: { title: 'Why we self-host', dek: 'Owning the stack', body_markdown: `## Why\n\n${LONG}`, tags: ['hosting', 'open source'], citations: [], gaps: [] } } });
++ }
++ if (req.url === `/api/v1/runs/${RUN}/citations`) return json(200, { citations: [{ url: 'https://example.org/self-hosting', title: 'Self-hosting notes', snippet: 'Costs fell.' }] });
++ 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 fay = t.network.addUser('fay', { display_name: 'Fay' });
++ await t.get('/api/v1/blogs', { as: fay, json: {} });
++ let draft;
++
++ await check('the API writes an AI-authored draft for the member who asked', async () => {
++ const r = await t.get('/api/v1/blogs/fay/posts/ai-draft', { as: fay, json: { topic: 'Why we self-host', tone: 'plain' } });
++ assert.strictEqual(r.status, 201, r.text);
++ draft = r.json().post;
++ assert.strictEqual(draft.state, 'draft');
++ assert.strictEqual(draft.authorship.mode, 'ai');
++ assert.deepStrictEqual({ id: draft.authorship.workflow.id, runId: draft.authorship.workflow.runId }, { id: 'blog.draft_post', runId: RUN });
++ assert.deepStrictEqual(draft.authorship.authors, [], 'the text is never attributed to the person');
++ assert.strictEqual(asked[0].body.workflow, 'blog.draft_post');
++ assert.deepStrictEqual(asked[0].body.input, { topic: 'Why we self-host', tone: 'plain' });
++ assert.deepStrictEqual(asked[0].body.on_behalf_of, { type: 'user', id: fay.subject });
++ assert.strictEqual(asked[0].body.target.service, 'blog');
++ assert.ok(/^Bearer /.test(asked[0].auth), 'Blog\'s service token');
++ const cites = t.ctx.posts.citations(t.ctx.store.db.prepare('SELECT * FROM blog_posts WHERE id = ?').get(draft.id) || { id: draft.id }, 1);
++ assert.ok(cites.some((c) => c.url === 'https://example.org/self-hosting'), 'the run\'s citations are on revision 1');
++ });
++
++ await check('it cannot be published before a person reviews it', async () => {
++ const p = await t.get(`/api/v1/posts/${draft.id}/publish`, { as: fay, json: {} });
++ assert.notStrictEqual(p.status, 200, p.text);
++ });
++
++ await check('the no-JS editor offers the form and files the draft', async () => {
++ const page = await t.get('/write/@fay/new', { as: fay });
++ assert.ok(page.text.includes('Draft with AI') && page.text.includes('/write/@fay/ai-draft'));
++ const r = await t.get('/write/@fay/ai-draft', { as: fay, form: { _csrf: t.csrf(fay), topic: 'A second one' } });
++ assert.strictEqual(r.status, 303, r.text.slice(0, 200));
++ assert.ok(/^\/write\/posts\/[^?]+\?saved=/.test(r.headers.get('location')));
++ });
++
++ await check('a failed run makes no post; a stranger cannot ask', async () => {
++ mode = 'fail';
++ const before = t.ctx.store.db.prepare('SELECT count(*) AS n FROM blog_posts').get().n;
++ const r = await t.get('/api/v1/blogs/fay/posts/ai-draft', { as: fay, json: { topic: 'x' } });
++ assert.strictEqual(r.status, 502);
++ assert.strictEqual(r.json().code, 'ai.run_failed');
++ assert.strictEqual(t.ctx.store.db.prepare('SELECT count(*) AS n FROM blog_posts').get().n, before);
++ mode = 'ok';
++ const stranger = t.network.addUser('stranger');
++ assert.strictEqual((await t.get('/api/v1/blogs/fay/posts/ai-draft', { as: stranger, json: { topic: 'x' } })).status, 403);
++ assert.strictEqual((await t.get('/api/v1/blogs/fay/posts/ai-draft', { as: fay, json: {} })).status, 422, 'a topic is required');
++ });
++
++ await t.close();
++ const t2 = await boot();
++ await check('without OV_AI_INTERNAL_URL nothing is offered', async () => {
++ const eve = t2.network.addUser('eve');
++ await t2.get('/api/v1/blogs', { as: eve, json: {} });
++ assert.ok(!(await t2.get('/write/@eve/new', { as: eve })).text.includes('Draft with AI'));
++ assert.strictEqual((await t2.get('/api/v1/blogs/eve/posts/ai-draft', { as: eve, json: { topic: 'x' } })).status, 503);
++ });
++ await t2.close();
++ aiSrv.close();
++ done();
++})();