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
Changes
@@@ -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"@@@ -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"},@@@ -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');@@@ -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 || '',nnew file mode 100644@@@ -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 };@@@ -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));@@@ -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) => {@@@ -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 };nnew file mode 100644@@@ -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();++})();
@@ -16,7 +16,7 @@
@@ -16,7 +16,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.33.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-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"
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.5.1"
},
},
@@ -1031,9 +1031,9 @@
@@ -1031,9 +1031,9 @@
}
}
},
},
"node_modules/openvibe-publishing": {
"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",
"license": "MIT",
"dependencies": {
"dependencies": {
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.5.1"
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.5.1"
@@ -22,7 +22,7 @@
@@ -22,7 +22,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.33.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-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"
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.5.1"
},
},
@@ -67,7 +67,8 @@ function createApp(opts = {}) {
@@ -67,7 +67,8 @@ function createApp(opts = {}) {
const worker = createWorker({ config, store, posts, media, outbox, log });
const worker = createWorker({ config, store, posts, media, outbox, log });
blogs.ensureOfficial();
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();
const app = express();
app.disable('x-powered-by');
app.disable('x-powered-by');
@@ -34,6 +34,8 @@ function load(env = process.env) {
@@ -34,6 +34,8 @@ function load(env = process.env) {
// OpenVibe.Network: SSO (OAuth2 authorization server), JWKS, client-credentials tokens.
// OpenVibe.Network: SSO (OAuth2 authorization server), JWKS, client-credentials tokens.
networkUrl: trim(env.OV_NETWORK_URL || 'https://openvibe.network'),
networkUrl: trim(env.OV_NETWORK_URL || 'https://openvibe.network'),
networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),
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: {
oauth: {
clientId: env.OV_OAUTH_CLIENT_ID || 'blog',
clientId: env.OV_OAUTH_CLIENT_ID || 'blog',
clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',
clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',
new file mode 100644
new file mode 100644
@@ -0,0 +1,66 @@
@@ -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 };
@@ -182,6 +182,15 @@ function createApi(ctx) {
@@ -182,6 +182,15 @@ function createApi(ctx) {
return { total, posts: rows.map((p) => postDto(p)) };
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) => {
router.post('/blogs/:handle/posts', guard('blog.post.create'), jsonBody, run((req) => {
const blog = mustBlog(req);
const blog = mustBlog(req);
const { post, revision } = posts.create(req.viewer, blog, req.body || {}, tp(req));
const { post, revision } = posts.create(req.viewer, blog, req.body || {}, tp(req));
@@ -8,6 +8,7 @@
@@ -8,6 +8,7 @@
* POST /write/start create your member blog (first use)
* POST /write/start create your member blog (first use)
* GET /write/@:handle the blog's posts (drafts included)
* GET /write/@:handle the blog's posts (drafts included)
* GET /write/@:handle/new, POST …/new a new draft
* 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)
* 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/{publish,schedule,unschedule,unpublish,delete,review,revert}
* POST /write/posts/:id/media, …/media/:aid/remove
* POST /write/posts/:id/media, …/media/:aid/remove
@@ -116,7 +117,18 @@ function createEditorRoutes(ctx) {
@@ -116,7 +117,18 @@ function createEditorRoutes(ctx) {
router.get('/@:handle/new', wrap(async (req, res) => {
router.get('/@:handle/new', wrap(async (req, res) => {
const blog = blogFor(req);
const blog = blogFor(req);
if (!blog || !access.canWrite(store, req.viewer, blog, 'create')) return notFound(req, res);
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) => {
router.post('/@:handle/new', wrap(async (req, res) => {
@@ -46,6 +46,19 @@ function select(name, options, current) {
@@ -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>`;
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). */
/** New post and edit form (the same fields). */
function postForm({ blog, post, head, terms, series, csrf, message, action }) {
function postForm({ blog, post, head, terms, series, csrf, message, action }) {
const f = head ? head.fields : {};
const f = head ? head.fields : {};
@@ -150,4 +163,4 @@ ${m.role !== 'owner' || blog.kind === 'official' ? h`<form method="post" action=
@@ -150,4 +163,4 @@ ${m.role !== 'owner' || blog.kind === 'official' ? h`<form method="post" action=
<button type="submit">Add member</button></form></section>`);
<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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,90 @@
@@ -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();
+})();