openvibers/openvibe.wiki Commits

Rich wiki articles: blocks, image suggestions and the content.wiki writer (#265)

OP OpenVibers committed 3 days ago · dbe7546ba1 · 19 files, +719 −66

* Rich wiki articles: blocks, image suggestions and the content.wiki writer

* Image suggestions name Commons and Openverse explicitly, one engine per call

* A date of birth in an infobox marks a real person too

* Merge origin/main (openvibe-shared 4.175.0) into w1/wiki-rich-blocks

* Send the writer only source fields the AI schema takes

* Merge origin/main and take openvibe-shared 4.177.0

* Merge origin/main; pin openvibe-contracts v0.379.0 and openvibe-sdk v0.132.0

* Merge origin/main and take openvibe-shared 4.179.0

* Blocks in an article: where they sit in a wiki's text

The facts beside the lead where there is room, the other blocks the full width, and a picture in a gallery in its grid.

* Serve shared block styles in rich wiki pages

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 1fe6b07..e4cb7df 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -2,6 +2,9 @@
## Unreleased
++- Rich wiki articles (rich-content plan W1): a body is Markdown plus `openvibe-publishing/blocks` (infobox, figure, gallery, timeline, quote, callout, stat, …), rendered server-side by `server/render/blocks.js` in the article, the editor's preview, the `.json` body and the Markdown twin; a body with no blocks renders as before. The API takes `blocks` on create and revision writes, serializes them to the Markdown a revision keeps, and returns `blocks` next to `body_markdown` (`?format=blocks` for the tree alone). Pins `openvibe-publishing` v1.6.1. `test/wiki-rich.test.js`.
++- The image task: `POST /api/v1/pages/:id/images/suggest` asks `wiki.article.images` for the lead picture and figures — Commons and Openverse first with the licence and author kept, then OpenVibe.Pics and the page's Media; an AI illustration only when nothing real exists and never for an article about a real person ("Illustration made with <model>"). `server/wiki/images.js`.
++- The wiki writer: `POST /api/v1/pages/:id/draft` runs OpenVibe.AI's `content.wiki` workflow (web search plus the article's own sources, a citation on every sentence, the infobox from structured facts) and files the revision as a pending proposal. It never publishes. `server/wiki/writer.js`.
- Share: every article carries the network's one Share button (`data-ov-share-sheet`) in its page actions, with the article's canonical address, title and summary; `openvibe-shared`'s navbar loads the sheet on the first press. `test/share-sheet.test.js`.
- Account export and deletion (ADR-033): `POST /internal/events` (loopback, `WIKI_EVENTS_SECRET`) answers network.account.export_requested and network.account.deleted through `server/wiki/account-data.js` over `openvibe-sdk/account-data` (SDK v0.37.0, was v0.35.0).
- **Deleted:** the person's own user spaces (through `deleteSpace`), roles, watches and drafts.
ddiff --git a/README.md b/README.md
iindex 514059b..7290498 100644
---- a/README.md
++++ b/README.md
@@@ -19,7 +19,7 @@ Media attachments and Community discussion. AI proposes revisions; a person appr
publishes. Search indexes only published, public state.
It is the first full consumer of the shared publishing packages
--([openvibe-publishing](https://github.com/OpenVibers/OpenVibe.Publishing) v0.2.1, ADR-019): revisions,
++([openvibe-publishing](https://github.com/OpenVibers/OpenVibe.Publishing) v1.7.0, ADR-019): revisions,
citations, redirects, the indexability gate, feeds, sitemaps, JSON-LD, authorship, scheduling,
media references, discussion references and the Search index hooks all come from there. Wiki owns
its publication state; the packages supply the mechanics.
@@@ -278,7 +278,7 @@ applies). Errors are RFC 9457 problem+json. The full route list is at the top of
The ids were proposed in [docs/capabilities-proposal/](docs/capabilities-proposal/) (the plan's
`wiki.search` becomes the three-segment `wiki.search.query`) and are released in openvibe-contracts,
which owns the wiki capability manifests, the service manifest and the `wiki.*` event payload schemas
--(this repo pins v0.107.0) ([docs/service-manifest-proposal.json](docs/service-manifest-proposal.json)).
++(this repo pins v0.379.0) ([docs/service-manifest-proposal.json](docs/service-manifest-proposal.json)).
The proposal for `wiki.revision.publish` also lists the revision review route, which the released
capability names since v0.32.0 (as `wiki.page.create` does the space import route).
@@@ -315,9 +315,9 @@ Production: `/opt/openvibe.wiki`, env `/etc/openvibe/wiki.env`, unit
- PostgreSQL 18 and Valkey 9 (OpenVibe.Host `roles/data/`, ADR-035): every read and write is async through
`openvibe-sdk/db`; Valkey holds the per-actor limit counters (optional: without `VALKEY_URL` they count per process).
--- `openvibe-publishing` v1.3.0 (async PostgreSQL stores, ingest and publication chassis), `openvibe-contracts`
-- v0.107.0, `openvibe-shared` v4.179.0
-- (Frame, release, metrics, readiness, SEO helpers, legal pages), `openvibe-sdk` v0.125.0 (db, auth, PostgreSQL
++- `openvibe-publishing` v1.7.0 (async PostgreSQL stores, ingest and publication chassis), `openvibe-contracts`
++ v0.379.0, `openvibe-shared` v4.179.0
++ (Frame, release, metrics, readiness, SEO helpers, legal pages), `openvibe-sdk` v0.132.0 (db, auth, PostgreSQL
events outbox, per-actor limits, account export and deletion, testing) — pinned release tarballs.
- OpenVibe.Network (SSO, JWKS, service principal `wiki`), OpenVibe.Events, OpenVibe.Community,
OpenVibe.Sources, OpenVibe.Media, OpenVibe.VIP (VIP spaces and pages), OpenVibe.Search (consumer of
@@@ -444,8 +444,8 @@ implemented service. Still open: a person's review of the 42 seed pages (until t
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
<!-- versions:start -->
--- openvibe-contracts: v0.373.0
--- openvibe-sdk: v0.125.0
++- openvibe-contracts: v0.379.0
++- openvibe-sdk: v0.132.0
- openvibe-shared: v4.179.0
--- openvibe-publishing: v1.3.0
++- openvibe-publishing: v1.7.0
<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex de399bd..07acc2d 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -7,13 +7,13 @@
"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"port": 4800,
"domain": "openvibe.wiki",
-- "contracts": "openvibe-contracts v0.373.0",
-- "sdk": "openvibe-sdk v0.125.0",
++ "contracts": "openvibe-contracts v0.379.0",
++ "sdk": "openvibe-sdk v0.132.0",
"shared": "openvibe-shared v4.179.0",
"packages": [
-- "openvibe-publishing v1.3.0",
++ "openvibe-publishing v1.7.0",
"openvibe-shared v4.179.0",
-- "openvibe-sdk v0.125.0"
++ "openvibe-sdk v0.132.0"
],
"capabilities": [
"wiki.space.create",
ddiff --git a/package-lock.json b/package-lock.json
iindex 5a6180e..565510f 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -14,9 +14,9 @@
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.373.0",
-- "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.3.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.125.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.379.0",
++ "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.7.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.132.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.179.0",
"pg": "^8.23.0"
},
@@@ -1134,9 +1134,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.373.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.373.0",
-- "integrity": "sha512-ddW6yG5deFxRgS/MQLgV6pyYx8vco49Nt83DrE+m9XAPwuHxFSeSZgvZ5CznK0cjul3CMmB5NaVXA6jdEbXAfQ==",
++ "version": "0.379.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.379.0",
++ "integrity": "sha512-svsHkIGui4y0K1WGnkM2X2gFr1wdHAqH+7vnefYdtybnX0Ze8j8Ue0inI1yYPVqWMPFAggTyVcKV9grLeIO2BA==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
@@@ -1150,9 +1150,9 @@
}
},
"node_modules/openvibe-publishing": {
-- "version": "1.3.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.3.0",
-- "integrity": "sha512-dIXLK5JpFqUEztywlWgSupUa9NEkovVczR2qeIvpst5evOyKZlwGzQktArvWUQn1ly6CoLL5mn/FnKiqoPvZFA==",
++ "version": "1.7.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.7.0",
++ "integrity": "sha512-65DspLWSuy6KO1uzR88aLIVfRhEpC4J3+xxK34rcj/VoEaCSV6dTBjDqlSwM/e3Cj2SPoVj4QT/qsUkx7VDt6w==",
"license": "MIT",
"engines": {
"node": ">=20"
@@@ -1168,9 +1168,9 @@
}
},
"node_modules/openvibe-sdk": {
-- "version": "0.125.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.125.0",
-- "integrity": "sha512-hGYmiaYLk/QrZC7w0opFAlEQi9fgDct0qcwc4cVBNBzCPm8HSRZlibYBe5x+NVshM3kqgQ6LZihqvFj1LP51bg==",
++ "version": "0.132.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.132.0",
++ "integrity": "sha512-LdA66ia2KJP1vsLr+vVFxEUyIWtF+F8c95PqM/MvECLp1xUHMxAXmEcJHOapjqlRs1hlNLAM59L0hEWNFQuK9g==",
"license": "MIT",
"bin": {
"openvibe-download": "bin/openvibe-download.js"
ddiff --git a/package.json b/package.json
iindex 48df663..fe8467d 100644
---- a/package.json
++++ b/package.json
@@@ -25,9 +25,9 @@
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.373.0",
-- "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.3.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.125.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.379.0",
++ "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.7.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.132.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.179.0",
"pg": "^8.23.0"
},
ddiff --git a/public/css/wiki.css b/public/css/wiki.css
iindex d5cc283..10457e4 100644
---- a/public/css/wiki.css
++++ b/public/css/wiki.css
@@@ -238,3 +238,14 @@ main .wk-h-group h3 { display: flex; flex-wrap: wrap; align-items: baseline; gap
.wk-review--ok { color: var(--success-text, var(--success, #22c55e)); background: color-mix(in srgb, var(--success, #22c55e) 12%, transparent); }
.wk-review--no { color: var(--warning-text, var(--warning, #f59e0b)); background: color-mix(in srgb, var(--warning, #f59e0b) 12%, transparent); }
@media (max-width: 600px) { .wk-review { margin-left: 0; margin-top: 4px; } }
++
++/* ── Blocks in an article. openvibe-publishing/blocks.css draws them (one look on every site); these rules only place
++ them in a wiki's text: the facts beside the lead where there is room, everything else the full width of the text,
++ and a picture inside a gallery or a compare in its grid, not floated like a picture in running text. ── */
++.wk-content .ov-timeline, .wk-content .ov-callout, .wk-content .ov-stat, .wk-content .ov-quote, .wk-content .ov-gallery, .wk-content .ov-compare, .wk-content .ov-clip, .wk-content .ov-video, .wk-content .ov-embed, .wk-content .ov-patchnotes { clear: both; }
++.wk-content .ov-gallery .ov-media, .wk-content .ov-compare .ov-media { float: none; width: auto; margin: 0; }
++.wk-content .ov-infobox { margin-top: 4px; }
++.wk-content .ov-infobox-title, .wk-content .ov-timeline-title { border-bottom: 0; padding-bottom: 0; }
++@media (min-width: 760px) {
++ .wk-content .ov-infobox { float: right; clear: right; width: min(320px, 42%); margin: 4px 0 16px 24px; }
++}
ddiff --git a/server/app.js b/server/app.js
iindex 4176777..62b3203 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -136,6 +136,11 @@ function createApp({ config, telemetry = null, svc, viewers: baseViewers, platfo
// This site's own pinned copy of the OpenVibe Frame's browser files (openvibe-shared/serve).
app.use('/shared', require('openvibe-shared/serve').handler());
++ app.get('/css/blocks.css', (req, res) => {
++ const v = req.query.v;
++ res.type('css').set('Cache-Control', cache.assetHeaders('css/blocks.css', { hashed: !!v && v === require('./render/layout').assetVersion('css/blocks.css') }))
++ .sendFile(require.resolve('openvibe-publishing/blocks.css'));
++ });
app.use(express.static(PUBLIC_DIR, {
index: false, redirect: false,
// Immutable only when ?v= is the hash of the bytes served: an older page's URL after a deploy (or a
ddiff --git a/server/config.js b/server/config.js
iindex bc889b2..ed1f577 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -71,6 +71,8 @@ function load(env = process.env) {
mediaPublicUrl: trim(env.OV_MEDIA_URL || 'https://openvibe.media'),
// Media namespace (app) Wiki's attachments live in, checked with media.object.read.
mediaApp: env.WIKI_MEDIA_APP || 'wiki',
++ // OpenVibe.Pics' public gallery: the licensed pictures the wiki's image task tries after Commons and Openverse.
++ picsUrl: trim(env.OV_PICS_URL || 'https://openvibe.pics'),
// "Vibe a wiki" (server/wiki/vibe.js): OpenVibe.AI researches and writes; these bound how many and how big.
aiInternalUrl: trim(env.OV_AI_INTERNAL_URL || ''),
vibe: {
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 102be39..a797bf3 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -11,14 +11,16 @@
* PATCH /spaces/:space wiki.space.create { name?, description?, visibility?, slug? } owner
* GET /spaces/:space/roles owner
* PUT /spaces/:space/roles/:subject wiki.space.create { role: owner|editor|viewer|null } owner
-- * POST /spaces/:space/pages wiki.page.create { title, body, summary?, infobox?, parent_id?, visibility?, citations?, message? }
++ * POST /spaces/:space/pages wiki.page.create { title, body?, blocks?, summary?, infobox?, parent_id?, visibility?, citations?, message? }
* POST /spaces/:space/import wiki.page.create { pages: [...], publish?, on_existing?, source?, original_author?, ai_assisted? } owner; all or nothing (server/wiki/import.js)
* GET /pages/:id wiki.page.read page + the revision the caller may read (?revision=)
* PATCH /pages/:id wiki.page.create { slug?, parent_id?, visibility?, noindex? }
* DELETE /pages/:id wiki.page.create owner
* GET /pages/:id/revisions wiki.page.read history (?before=&limit=)
-- * POST /pages/:id/revisions wiki.page.create { expected_revision, body?, title?, summary?, infobox?, citations?, keep_citations?, message? }
++ * POST /pages/:id/revisions wiki.page.create { expected_revision, body?, blocks?, title?, summary?, infobox?, citations?, keep_citations?, message? }
* GET /pages/:id/revisions/:n wiki.page.read
++ * POST /pages/:id/images/suggest wiki.page.create { query?, section?, count?, allow_ai?, choice? } the wiki.article.images task
++ * POST /pages/:id/draft wiki.page.create { instruction?, choice? } content.wiki → a pending proposal
* GET /pages/:id/diff?from=&to=&mode= wiki.page.read
* GET /pages/:id/revisions/:n/citations wiki.page.read
* POST /pages/:id/revisions/:n/citations wiki.citation.attach { citations: [...] } (unpublished head only)
@@@ -44,6 +46,10 @@ const express = require('express');
const contracts = require('openvibe-contracts');
const { actorMiddleware, guard, guardReview, run, resolveCitations, resolveBundleCitations } = require('./common');
const importer = require('../wiki/import');
++const blocks = require('openvibe-publishing/blocks');
++const blockRender = require('../render/blocks');
++const images = require('../wiki/images');
++const writer = require('../wiki/writer');
const { http } = contracts;
@@@ -83,6 +89,23 @@ function serializeTree(nodes, svc, space) {
function int(v) { const n = Number(v); return Number.isInteger(n) ? n : undefined; }
++/**
++ * The body of a write: the Markdown given, or the Markdown a block tree serializes to. Blocks are the
++ * additive form — the revision still keeps (and diffs) Markdown, so body and blocks are alternatives.
++ */
++function bodyOf(b = {}) {
++ if (b.blocks == null) return b.body;
++ const tree = Array.isArray(b.blocks) ? { type: 'root', children: b.blocks } : b.blocks;
++ const check = blocks.validate(tree);
++ if (!check.ok) {
++ throw Object.assign(new Error(`The blocks are not valid: ${check.errors.slice(0, 3).map((e) => `${e.path} ${e.message}`).join('; ')}`), { status: 422, code: 'page.invalid_blocks' });
++ }
++ return blocks.serialize(tree);
++}
++
++/** A revision's blocks, next to the Markdown the revision keeps. */
++function blocksOf(rev) { return rev ? blockRender.bodyBlocks(rev.content || rev.body || '') : { type: 'root', children: [] }; }
++
function createApi({ svc, viewers, platform, config, log = console, limits, vibes = null }) {
const router = express.Router();
const ownOrigin = new URL(config.baseUrl).origin;
@@@ -173,9 +196,9 @@ function createApi({ svc, viewers, platform, config, log = console, limits, vibe
router.post('/spaces/:space/pages', guard('wiki.page.create'), B('wiki.page.create'), R(async (req) => {
const b = req.body || {};
const cites = await resolveCitations(b.citations, platform);
-- const out = await svc.createPage(req.params.space, { title: b.title, body: b.body, summary: b.summary, infobox: b.infobox, parentId: b.parent_id || null, visibility: b.visibility, citations: cites, message: b.message }, req.actor);
++ const out = await svc.createPage(req.params.space, { title: b.title, body: bodyOf(b), summary: b.summary, infobox: b.infobox, parentId: b.parent_id || null, visibility: b.visibility, citations: cites, message: b.message }, req.actor);
const space = await svc.spaceById(out.page.space_id);
-- return { page: serializePage(out.page, svc, space), revision: serializeRevision(out.revision), citations: (await svc.citationsOf(out.page, out.revision.number)).map(serializeCitation) };
++ return { page: serializePage(out.page, svc, space), revision: serializeRevision(out.revision), blocks: blocksOf(out.revision), citations: (await svc.citationsOf(out.page, out.revision.number)).map(serializeCitation) };
}, 201));
router.post('/spaces/:space/import', guard('wiki.page.create'), B('wiki.page.import'), R(async (req) => {
@@@ -192,8 +215,11 @@ function createApi({ svc, viewers, platform, config, log = console, limits, vibe
router.get('/pages/:id', guard('wiki.page.read'), R(async (req) => {
const { page, space } = await readable(req, req.params.id);
const v = await svc.view(space, page, req.actor, { revision: int(req.query.revision) });
++ const tree = blocksOf(v.revision);
++ // ?format=blocks: the body as blocks alone, the Markdown staying the source of truth.
++ if (String(req.query.format || '').toLowerCase() === 'blocks') return { blocks: tree };
return {
-- page: serializePage(page, svc, space), revision: serializeRevision(v.revision),
++ page: serializePage(page, svc, space), revision: serializeRevision(v.revision), blocks: tree,
citations: v.citations.map(serializeCitation), infobox: v.infobox,
attachments: v.attachments.map(serializeAttachment),
links: v.links.map((l) => ({ space: l.target_space, slug: l.target_slug, label: l.label })),
@@@ -223,16 +249,16 @@ function createApi({ svc, viewers, platform, config, log = console, limits, vibe
const b = req.body || {};
const cites = await resolveCitations(b.citations, platform);
const out = await svc.editPage(req.params.id, {
-- expectedRevision: int(b.expected_revision), title: b.title, body: b.body, summary: b.summary, infobox: b.infobox,
++ expectedRevision: int(b.expected_revision), title: b.title, body: bodyOf(b), summary: b.summary, infobox: b.infobox,
citations: cites, keepCitations: b.keep_citations === undefined ? 'all' : b.keep_citations, message: b.message,
}, req.actor);
const space = await svc.spaceById(out.page.space_id);
-- return { page: serializePage(out.page, svc, space), revision: serializeRevision(out.revision), created: out.created, citations: (await svc.citationsOf(out.page, out.revision.number)).map(serializeCitation) };
++ return { page: serializePage(out.page, svc, space), revision: serializeRevision(out.revision), blocks: blocksOf(out.revision), created: out.created, citations: (await svc.citationsOf(out.page, out.revision.number)).map(serializeCitation) };
}, (out) => (out.created ? 201 : 200)));
router.get('/pages/:id/revisions/:n', guard('wiki.page.read'), R(async (req) => {
const { page, space } = await readable(req, req.params.id);
const v = await svc.view(space, page, req.actor, { revision: int(req.params.n) });
-- return { revision: serializeRevision(v.revision), citations: v.citations.map(serializeCitation), infobox: v.infobox };
++ return { revision: serializeRevision(v.revision), blocks: blocksOf(v.revision), citations: v.citations.map(serializeCitation), infobox: v.infobox };
}));
router.get('/pages/:id/diff', guard('wiki.page.read'), B('wiki.page.diff'), R(async (req) => {
const { page, space } = await readable(req, req.params.id);
@@@ -284,6 +310,15 @@ function createApi({ svc, viewers, platform, config, log = console, limits, vibe
}));
router.put('/pages/:id/watch', B('wiki.page.watch'), R((req) => svc.watch(req.params.id, req.actor, (req.body || {}).watching !== false)));
++ // Pictures and the writer (the AI choices task and the content.wiki workflow)
++ router.post('/pages/:id/images/suggest', guard('wiki.page.create'), B('wiki.page.edit'), R(async (req) => (
++ images.suggest({ svc, platform, config, log }, req.params.id, req.body || {}, req.actor)
++ )));
++ router.post('/pages/:id/draft', guard('wiki.page.create'), B('wiki.revision.propose'), R(async (req) => {
++ const out = await writer.draft({ svc, platform, config, log }, req.params.id, req.body || {}, req.actor);
++ return { ...out, revision: serializeRevision(out.revision) };
++ }, 201));
++
// AI proposals
router.post('/proposals', guard('wiki.revision.propose'), B('wiki.revision.propose'), R(async (req) => {
const b = req.body || {};
ddiff --git a/server/http/openapi.js b/server/http/openapi.js
iindex 56c3038..37ce844 100644
---- a/server/http/openapi.js
++++ b/server/http/openapi.js
@@@ -110,8 +110,9 @@ route('put', '/spaces/{space}/roles/{subject}', {
route('post', '/spaces/{space}/pages', {
operationId: 'createPage', summary: 'Create a draft page in a space', tags: ['pages'], security: PERSON, parameters: [SPACE],
description: 'wiki.page.create.',
-- requestBody: body({ type: 'object', required: ['title', 'body'], properties: {
-- title: str({ maxLength: 200 }), body: str({ maxLength: 200000, description: 'Markdown' }), summary: str({ maxLength: 300 }),
++ requestBody: body({ type: 'object', required: ['title'], properties: {
++ title: str({ maxLength: 200 }), body: str({ maxLength: 200000, description: 'Markdown' }), blocks: ref('Block'),
++ summary: str({ maxLength: 300 }),
infobox, parent_id: nullable('string'), visibility: { type: 'string', enum: ['public', 'members', 'vip', 'private'] },
citations: arrayOf(citation), message: nullable('string'),
} }),
@@@ -151,7 +152,7 @@ route('post', '/pages/{id}/revisions', {
operationId: 'editPage', summary: 'Write a new revision of a page', tags: ['revisions'], security: PERSON, parameters: [PAGE],
description: 'wiki.page.create. expected_revision makes the write conditional: a stale number answers 409.',
requestBody: body({ type: 'object', required: ['expected_revision'], properties: {
-- expected_revision: { type: 'integer', minimum: 0 }, title: str({ maxLength: 200 }), body: str({ maxLength: 200000 }),
++ expected_revision: { type: 'integer', minimum: 0 }, title: str({ maxLength: 200 }), body: str({ maxLength: 200000 }), blocks: ref('Block'),
summary: str({ maxLength: 300 }), infobox, citations: arrayOf(citation),
keep_citations: { type: 'string', description: 'all (default), none, or the numbers to carry' }, message: nullable('string'),
} }),
@@@ -224,6 +225,29 @@ route('post', '/pages/{id}/media/verify', {
description: 'wiki.page.create. Editors only.',
responses: { 200: json('One result per attachment', { type: 'object', properties: { results: arrayOf({ type: 'object' }) } }), ...errors() },
});
++route('post', '/pages/{id}/images/suggest', {
++ operationId: 'suggestImages', summary: 'Pictures for an article, from the wiki.article.images task', tags: ['media'], security: PERSON, parameters: [PAGE],
++ description: 'wiki.page.create. Wiki editors only. Commons and Openverse first, with licence and author; OpenVibe.Pics and the page\'s Media next; an AI illustration only when nothing real exists and the article is not about a real person.',
++ requestBody: body({ type: 'object', properties: {
++ query: nullable('string', { maxLength: 300 }), section: nullable('string', { maxLength: 200 }),
++ count: { type: 'integer', minimum: 1, maximum: 12 }, allow_ai: { type: 'boolean', default: true },
++ choice: { type: 'object', properties: { model: str(), source: { type: 'string', enum: ['openvibe', 'keys', 'machines'] }, machine: str() } },
++ } }, false),
++ responses: { 200: json('Picture suggestions', { type: 'object', properties: {
++ article_id: str(), task: str(), query: str(), subject_is_person: { type: 'boolean' }, run_id: nullable('string'), note: nullable('string'), suggestions: arrayOf({ type: 'object' }),
++ } }), ...errors() },
++});
++route('post', '/pages/{id}/draft', {
++ operationId: 'draftArticle', summary: 'Run the wiki writer and file a pending proposal', tags: ['proposals'], security: PERSON, parameters: [PAGE],
++ description: 'wiki.page.create. Runs OpenVibe.AI\'s content.wiki workflow (web search plus the sources the article cites, a citation on every sentence, an infobox from facts) and files the revision as a pending proposal. Never publishes.',
++ requestBody: body({ type: 'object', properties: {
++ instruction: nullable('string', { maxLength: 4000 }),
++ choice: { type: 'object', properties: { model: str(), source: { type: 'string', enum: ['openvibe', 'keys', 'machines'] }, machine: str() } },
++ } }, false),
++ responses: { 201: json('The pending proposal and its revision', { type: 'object', properties: {
++ proposal: { type: 'object' }, revision: ref('Revision'), run_id: nullable('string'), model: nullable('string'), provider: nullable('string'), sources: arrayOf({ type: 'object' }),
++ } }), ...errors() },
++});
route('put', '/pages/{id}/watch', {
operationId: 'watchPage', summary: 'Watch (or stop watching) a page', tags: ['pages'], security: PERSON, parameters: [PAGE],
description: 'People only: a service token cannot watch.',
@@@ -319,6 +343,9 @@ function openapi() {
body: { type: 'null', description: 'Never sent in a history listing' }, published: { type: 'boolean' },
citation_count: { type: 'integer' }, proposal: nullable('object', { description: '{ id, status } when the revision came from a proposal' }),
} }] },
++ Block: { type: 'object', description: 'A publishing.block@1 tree (type: root, with text and the block children); the Markdown is what the revision keeps', required: ['type'], properties: {
++ type: str(), attrs: { type: 'object' }, value: str(), children: arrayOf({ type: 'object' }),
++ } },
Citation: { type: 'object', properties: {
id: str(), revision: { type: 'integer' }, url: nullable('string'), title: nullable('string'),
source_item_id: nullable('string'), retrieved_at: nullable('string', { format: 'date-time' }),
@@@ -331,10 +358,10 @@ function openapi() {
checked_at: nullable('string', { format: 'date-time' }), attached_by: nullable('string'),
} },
Role: { type: 'object', properties: { subject: str(), role: { type: 'string', enum: ['owner', 'editor', 'viewer'] }, granted_by: nullable('string'), granted_at: str({ format: 'date-time' }) } },
-- CreatedPage: { type: 'object', properties: { page: ref('Page'), revision: ref('Revision'), created: { type: 'boolean' }, citations: arrayOf(ref('Citation')) } },
-- RevisionView: { type: 'object', properties: { revision: ref('Revision'), citations: arrayOf(ref('Citation')), infobox } },
++ CreatedPage: { type: 'object', properties: { page: ref('Page'), revision: ref('Revision'), blocks: ref('Block'), created: { type: 'boolean' }, citations: arrayOf(ref('Citation')) } },
++ RevisionView: { type: 'object', properties: { revision: ref('Revision'), blocks: ref('Block'), citations: arrayOf(ref('Citation')), infobox } },
PageView: { type: 'object', properties: {
-- page: ref('Page'), revision: ref('Revision'), citations: arrayOf(ref('Citation')), infobox,
++ page: ref('Page'), revision: ref('Revision'), blocks: ref('Block'), citations: arrayOf(ref('Citation')), infobox,
attachments: arrayOf(ref('Attachment')),
links: arrayOf({ type: 'object', properties: { space: str(), slug: str(), label: nullable('string') } }),
indexability: { type: 'object', properties: { indexable: { type: 'boolean' }, reasons: arrayOf(str()) } },
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 57b4440..b0fa9f3 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -35,6 +35,7 @@ const seo = require('openvibe-publishing/seo');
const ssr = require('openvibe-publishing/ssr');
const { renderPage, AI_SUMMARY, asset } = require('../render/layout');
const views = require('../render/views');
++const blockRender = require('../render/blocks');
const { foldBlock } = require('../render/short');
const frame = require('openvibe-shared/frame');
const showcase = require('openvibe-shared/showcase');
@@@ -285,7 +286,7 @@ function createPages({ svc, viewers, platform, config, log = console, limits, vi
const b = req.body || {};
const values = { title: b.title, body: b.body, summary: b.summary, infobox: b.infobox, parent_id: b.parent_id, visibility: b.visibility, message: b.message };
const parents = await parentsOf(space, req.actor);
-- const again = (status, error, preview = null) => send(req, res, status, views.editPage({ space, values, error, preview, parents }), { title: `New page in ${space.name}`, robots: 'noindex, nofollow' });
++ const again = (status, error, preview = null) => send(req, res, status, views.editPage({ space, values, error, preview, parents }), { title: `New page in ${space.name}`, robots: 'noindex, nofollow', blocksCss: !!preview });
try {
const infobox = content.parseInfoboxText(b.infobox);
if (b.op === 'preview') return again(200, null, await svc.renderRevision(space, { content: String(b.body || '') }, req.actor));
@@@ -389,7 +390,7 @@ function createPages({ svc, viewers, platform, config, log = console, limits, vi
function articleData(v, space, page) {
return {
id: page.id, space: space.slug, slug: page.slug, url: svc.pageUrl(space, page), state: page.state, visibility: svc.access.effectiveVisibility(space, page),
-- revision: v.revision.number, title: v.revision.fields.title, summary: v.revision.fields.summary || null, body_markdown: v.revision.content,
++ revision: v.revision.number, title: v.revision.fields.title, summary: v.revision.fields.summary || null, body_markdown: v.revision.content, blocks: blockRender.bodyBlocks(v.revision.content),
published_at: page.published_at ? new Date(page.published_at).toISOString() : null,
revision_published_at: page.revision_published_at ? new Date(page.revision_published_at).toISOString() : null,
revision_created_at: v.revision.createdAt,
@@@ -443,18 +444,18 @@ function createPages({ svc, viewers, platform, config, log = console, limits, vi
const crumbsLd = [{ name: 'Wiki', url: `${config.baseUrl}/` }, { name: space.name, url: `${config.baseUrl}${svc.spacePath(space)}` }, { name: v.revision.fields.title, url }];
// The gate decides robots for the published revision; any other revision is noindex.
const decision = v.isPublishedRevision ? v.decision : { ...v.decision, indexable: false, robots: 'noindex, nofollow', canonical: url };
-- const description = v.revision.fields.summary || ssr.markdownToText(v.revision.content, 160);
++ const description = v.revision.fields.summary || blockRender.bodyText(v.revision.content).slice(0, 160);
const jsonLd = open && v.isPublishedRevision ? [
seo.structuredData.article({
headline: v.revision.fields.title, url, description,
datePublished: page.published_at, dateModified: page.revision_published_at,
-- citations: v.citations, wordCount: ssr.wordCount(ssr.markdownToText(v.revision.content)),
++ citations: v.citations, wordCount: ssr.wordCount(blockRender.bodyText(v.revision.content)),
}),
seo.structuredData.breadcrumbs(crumbsLd),
].filter(Boolean) : null;
const shareMedia = v.attachments.find((a) => a.mediaId && !a.brokenReason);
send(req, res, 200, views.articlePage(v, { html: bodyHtml, discussion, actor: req.actor, mediaUrl: (id) => platform.media.publicUrl(id), resolve: (title) => infoboxLinks.get(title) || { href: null, exists: false }, flash: req.query.saved ? 'Saved.' : null, shareUrl: url, shareText: description, shareImage: shareMedia ? platform.media.publicUrl(shareMedia.mediaId) : null }), {
-- decision, title: v.revision.fields.title, description, ogType: 'article', jsonLd, path: svc.pagePath(space, page), cache: publicCache, styles: ['kit.css'],
++ decision, title: v.revision.fields.title, description, ogType: 'article', jsonLd, path: svc.pagePath(space, page), cache: publicCache, styles: ['kit.css'], blocksCss: true,
});
}));
@@@ -539,7 +540,7 @@ function createPages({ svc, viewers, platform, config, log = console, limits, vi
let baseView = null;
try { baseView = Number.isInteger(base) && base > 0 ? await svc.view(space, page, req.actor, { revision: base }) : null; } catch { baseView = null; }
const values = { title: b.title, body: b.body, summary: b.summary, infobox: b.infobox, message: b.message, keep };
-- const again = (status, error, preview = null) => send(req, res, status, views.editPage({ space, page, values, error, preview, citations: baseView ? baseView.citations : [], baseRevision: base, expectedRevision: expected }), { title: `Editing ${page.title}`, robots: 'noindex, nofollow' });
++ const again = (status, error, preview = null) => send(req, res, status, views.editPage({ space, page, values, error, preview, citations: baseView ? baseView.citations : [], baseRevision: base, expectedRevision: expected }), { title: `Editing ${page.title}`, robots: 'noindex, nofollow', blocksCss: !!preview });
try {
const infobox = content.parseInfoboxText(b.infobox);
if (b.op === 'preview') return again(200, null, await svc.renderRevision(space, { content: String(b.body || '') }, req.actor));
ddiff --git a/server/integrations/platform.js b/server/integrations/platform.js
iindex 7987d96..9b3d350 100644
---- a/server/integrations/platform.js
++++ b/server/integrations/platform.js
@@@ -223,9 +223,10 @@ function createPlatform({ config, db, fetchImpl = globalThis.fetch, tokens = nul
* Run a workflow and wait for it: → the run's output. Long runs are polled. Throws IntegrationError with the run's
* own code when it failed, 429 with retryAfter (seconds) when AI's quota or queue is full.
*/
-- async run(workflow, input, { subject = null, idempotencyKey = null, waitMs = 55000, totalMs = 300000, signal = null } = {}) {
++ async run(workflow, input, { subject = null, idempotencyKey = null, waitMs = 55000, totalMs = 300000, signal = null, task = null, choice = null } = {}) {
if (!ai.configured) throw new IntegrationError(503, 'ai.unavailable', 'OpenVibe.AI is not configured (OV_AI_INTERNAL_URL)');
const headers = async () => ({ ...(await forAudience('openvibe.ai').authHeaders()), 'Content-Type': 'application/json', Accept: 'application/json', ...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}) });
++ let citations = [];
const call = async (url, init) => {
let res;
try { res = await fetchImpl(url, { ...init, signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(waitMs + 15000)]) : AbortSignal.timeout(waitMs + 15000) }); } catch (err) {
@@@ -235,22 +236,50 @@ function createPlatform({ config, db, fetchImpl = globalThis.fetch, tokens = nul
if (res.status === 401) forAudience('openvibe.ai').invalidate();
if (res.status === 429) throw Object.assign(new IntegrationError(429, (body && body.code) || 'ai.busy', (body && (body.detail || body.title)) || 'OpenVibe.AI is busy'), { retryAfter: Number(res.headers.get('retry-after')) || 60 });
if (res.status >= 400) throw new IntegrationError(res.status >= 500 ? 503 : res.status, (body && body.code) || 'ai.refused', (body && (body.detail || body.title)) || `OpenVibe.AI answered ${res.status}`);
++ if (body && Array.isArray(body.citations)) citations = body.citations;
return body && body.run;
};
-- const body = { workflow, input, ...(subject ? { on_behalf_of: { type: 'user', id: subject }, attribution: { service: 'wiki', type: 'user', id: subject } } : {}) };
++ const subjectFields = subject ? { on_behalf_of: { type: 'user', id: subject }, attribution: { service: 'wiki', type: 'user', id: subject } } : {};
++ const body = task
++ ? { task, input, ...(choice ? { choice } : {}), ...subjectFields }
++ : { workflow, input, ...(choice ? { choice } : {}), ...subjectFields };
let run = await call(`${config.aiInternalUrl}/api/v1/runs?wait=${waitMs}`, { method: 'POST', headers: await headers(), body: JSON.stringify(body) });
const until = Date.now() + totalMs;
while (run && !['succeeded', 'cached', 'failed', 'cancelled'].includes(run.status)) {
-- if (Date.now() > until) throw new IntegrationError(504, 'ai.timeout', `${workflow} did not finish in ${Math.round(totalMs / 1000)} s`);
++ if (Date.now() > until) throw new IntegrationError(504, 'ai.timeout', `${task || workflow} did not finish in ${Math.round(totalMs / 1000)} s`);
await new Promise((r) => setTimeout(r, 3000));
run = await call(`${config.aiInternalUrl}/api/v1/runs/${encodeURIComponent(run.id)}`, { method: 'GET', headers: await headers() });
}
if (!run) throw new IntegrationError(503, 'ai.unavailable', 'OpenVibe.AI answered without a run');
if (run.status === 'failed' || run.status === 'cancelled') {
const e = run.error || {};
-- throw new IntegrationError(e.code === 'quota.exceeded' || e.code === 'govern.exceeded' ? 429 : 502, e.code || 'ai.run_failed', e.detail || e.message || `${workflow} ${run.status}`);
++ throw new IntegrationError(e.code === 'quota.exceeded' || e.code === 'govern.exceeded' ? 429 : 502, e.code || 'ai.run_failed', e.detail || e.message || `${task || workflow} ${run.status}`);
}
-- return { output: run.output, runId: run.id, synthetic: Boolean(run.synthetic), provenance: run.provenance || null };
++ return {
++ output: run.output, runId: run.id, citations, model: run.model || null, provider: run.provider || null,
++ choice: run.choice || null, synthetic: Boolean(run.synthetic), provenance: run.provenance || null,
++ };
++ },
++ /** Run a registered AI task by name; the task's own workflow and choice resolve inside OpenVibe.AI. */
++ runTask(task, input, opts = {}) { return ai.run(task, input, { ...opts, task }); },
++ };
++
++ // ── Pics: the public gallery's pictures (licence and author come from OpenVibe.Pics) ──
++ const pics = {
++ configured: !!config.picsUrl,
++ /** Pictures matching q in the public gallery → [{ id, slug, title, description, alt, width, height, author, links }]. */
++ async search(q, { limit = 8, signal = null } = {}) {
++ if (!pics.configured) return [];
++ const u = new URL('/api/v1/gallery', config.picsUrl);
++ u.searchParams.set('q', String(q || '').slice(0, 120));
++ u.searchParams.set('limit', String(Math.max(1, Math.min(30, limit))));
++ let res;
++ try { res = await fetchImpl(u.toString(), { headers: { Accept: 'application/json' }, signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(8000)]) : AbortSignal.timeout(8000) }); } catch (err) {
++ throw new IntegrationError(503, 'pics.unavailable', `OpenVibe.Pics did not answer: ${err && err.name === 'TimeoutError' ? 'timeout' : err && err.message}`);
++ }
++ if (res.status !== 200) throw new IntegrationError(503, 'pics.unavailable', `OpenVibe.Pics answered ${res.status}`);
++ const body = await res.json().catch(() => null);
++ return (body && Array.isArray(body.pictures) ? body.pictures : []).slice(0, limit);
},
};
@@@ -258,7 +287,7 @@ function createPlatform({ config, db, fetchImpl = globalThis.fetch, tokens = nul
const vip = require('./vip').createVip({ config, tokenClient, fetchImpl, now, log });
return {
-- tokenClient, sdk, events, outbox, community, sources, media, ai, vip,
++ tokenClient, sdk, events, outbox, community, sources, media, pics, ai, vip,
eventsConfigured: !!(tokenClient && config.eventsUrl),
};
}
ddiff --git a/server/render/blocks.js b/server/render/blocks.js
nnew file mode 100644
iindex 0000000..21e40f4
---- /dev/null
++++ b/server/render/blocks.js
@@@ -0,0 +1,67 @@
++'use strict';
++/**
++ * A wiki page body rendered as Markdown plus blocks (openvibe-publishing/blocks).
++ *
++ * The Markdown stays the source of truth: a revision keeps it, revisions diff it, and a body with no
++ * blocks renders exactly as it did before. A body with blocks — infobox, figure, gallery, timeline,
++ * quote, callout, stat and the rest — renders server-side to plain HTML, so a reader without
++ * JavaScript sees the same page and a crawler reads it.
++ *
++ * Wiki resolves the things a block names the way it always has: [[Page title]] links (including red
++ * links), [[media:med_…]] figures, and the attachments the page owns. A picture that is gone is an
++ * explicit placeholder, never a guessed URL.
++ */
++const blocks = require('openvibe-publishing/blocks');
++const { figureHtml } = require('openvibe-publishing/media');
++const content = require('../wiki/content');
++
++const UNAVAILABLE = 'This picture is no longer available.';
++
++/**
++ * Render a revision's body.
++ *
++ * renderBody(source, {
++ * currentSpace, resolve, rel, mediaUrl,
++ * attachments: [{ mediaId, width, height, alt, caption, broken, state }]
++ * })
++ * → { tree, html }
++ */
++function renderBody(source, { currentSpace, resolve, rel = 'noopener', mediaUrl = null, attachments = [] } = {}) {
++ const byId = new Map((attachments || []).map((a) => [a.mediaId, a]));
++ const figure = (mediaId, caption) => {
++ const a = byId.get(mediaId);
++ if (!a || !mediaUrl) return null;
++ return figureHtml({ ...a, caption: caption || a.caption }, { urlFor: mediaUrl, unavailableText: UNAVAILABLE });
++ };
++ const media = (mediaId) => {
++ const a = byId.get(mediaId);
++ if (!a) return null;
++ if (a.broken || a.state === 'broken') return { broken: true };
++ return { width: a.width || null, height: a.height || null, alt: a.alt || '', caption: a.caption || null };
++ };
++ const tree = blocks.parse(source);
++ const html = blocks.render(tree, {
++ rel,
++ // The page owns its h1 (the title): a section's "## Heading" stays an h2.
++ headingShift: 1,
++ media,
++ urlFor: mediaUrl,
++ renderText: (value, opts) => content.renderContent(value, {
++ currentSpace, resolve, rel: opts.rel,
++ media: (mediaId, caption) => figure(mediaId, caption),
++ }),
++ });
++ return { tree, html };
++}
++
++/** The blocks of a body, for the API's `blocks` next to `body_markdown`. */
++function bodyBlocks(source) {
++ return blocks.parse(source);
++}
++
++/** The plain text of a body: what a summary, a feed and an AI reader get. */
++function bodyText(source) {
++ return blocks.toText(blocks.parse(source));
++}
++
++module.exports = { renderBody, bodyBlocks, bodyText };
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex bbd7ce8..c0f08c8 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -13,6 +13,7 @@ const path = require('path');
const layout = require('openvibe-publishing/layout');
const { escapeHtml: esc } = require('openvibe-publishing/ssr');
const seo = require('openvibe-shared/seo');
++const serve = require('openvibe-shared/serve');
const SITE_NAME = 'OpenVibe.Wiki';
const NETWORK_URL = 'https://openvibe.network';
@@@ -27,12 +28,13 @@ const NAV_LINKS = [
];
const FOOTER = { service: 'wiki', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME, updates: '/updates' };
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
++const BLOCKS_CSS = require.resolve('openvibe-publishing/blocks.css');
const hashes = new Map();
function asset(rel) {
if (!hashes.has(rel)) {
let v = 'dev';
-- try { v = crypto.createHash('sha256').update(fs.readFileSync(path.join(PUBLIC_DIR, rel))).digest('hex').slice(0, 10); } catch { /* missing asset */ }
++ try { v = crypto.createHash('sha256').update(fs.readFileSync(rel === 'css/blocks.css' ? BLOCKS_CSS : path.join(PUBLIC_DIR, rel))).digest('hex').slice(0, 10); } catch { /* missing asset */ }
hashes.set(rel, v);
}
return `/${rel}?v=${hashes.get(rel)}`;
@@@ -109,14 +111,14 @@ function renderPage(o) {
navLinks: NAV_LINKS,
home: '/',
iconSite: 'network',
-- css: asset('css/wiki.css'),
-- styles: o.styles,
++ css: o.blocksCss ? null : asset('css/wiki.css'),
++ styles: o.blocksCss ? [] : o.styles,
release: RELEASE,
mainClass: 'wk-main',
header: `<header class="wk-bar"><a class="wk-brand" href="/">${SITE_NAME}</a><form class="wk-search" action="/search" method="get" role="search"><label for="wk-q" class="wk-sr">Search the wiki</label><input id="wk-q" name="q" type="search" placeholder="Search the wiki" value="${esc(o.query || '')}"><button type="submit">Search</button></form><noscript><span class="wk-account">${who}</span></noscript></header>`,
body: o.body,
// The page names its Markdown twin (openvibe-shared/agent-layer) and the site's llms.txt.
-- head: [hasTwin ? seo.linkHeaderTags(canonical, `${config.baseUrl}/llms.txt`).head : '', o.head].filter(Boolean).join('\n'),
++ head: [o.blocksCss ? `<link rel="stylesheet" href="${asset('css/blocks.css')}">\n<link rel="stylesheet" href="${asset('css/wiki.css')}">` : '', ...(o.blocksCss ? (o.styles || []).map((name) => `<link rel="stylesheet" href="${esc(serve.url(name))}">`) : []), hasTwin ? seo.linkHeaderTags(canonical, `${config.baseUrl}/llms.txt`).head : '', o.head].filter(Boolean).join('\n'),
});
}
ddiff --git a/server/render/views.js b/server/render/views.js
iindex 603c0dc..2d6ec38 100644
---- a/server/render/views.js
++++ b/server/render/views.js
@@@ -333,7 +333,7 @@ function infoboxHtml(entries, resolve, rel = 'nofollow ugc noopener', { media =
function citationsHtml(cites, inspectHref, rel = 'nofollow ugc noopener') {
const inspect = inspectHref ? html`<p class="wk-muted"><a href="${inspectHref}">Inspect these sources</a> (retrieval times, where each was first cited, what changed between revisions)</p>` : '';
-- if (!cites.length) return html`<section class="wk-sources"><h2>Sources</h2><p class="wk-muted">This revision cites no sources.</p>${inspect}</section>`;
++ if (!cites.length) return html`<section class="wk-sources"><h2>Sources</h2><p class="wk-muted">This revision cites no sources.</p></section>`;
return html`<section class="wk-sources"><h2>Sources</h2><ol>${cites.map((c) => html`<li id="cite-${String(c.id)}">${c.url ? html`<a href="${c.url}" rel="${rel}">${c.title || c.url}</a>` : html`${c.title || 'Sources item'}`}${c.sourceItemId ? html` <span class="wk-muted">(OpenVibe.Sources item <code>${c.sourceItemId}</code>)</span>` : ''}${c.retrievedAt ? html`, retrieved ${t(c.retrievedAt)}` : html`, <span class="wk-muted">retrieval time unknown</span>`}${c.licenseNote ? html` (${c.licenseNote})` : ''}${c.quote && c.quote.text ? html`<blockquote>${c.quote.text}</blockquote>` : ''}</li>`)}</ol>${inspect}</section>`;
}
ddiff --git a/server/wiki/images.js b/server/wiki/images.js
nnew file mode 100644
iindex 0000000..5254d01
---- /dev/null
++++ b/server/wiki/images.js
@@@ -0,0 +1,187 @@
++'use strict';
++/**
++ * wiki.images.suggest — the article's lead picture and figures, from the wiki.article.images AI task.
++ *
++ * The order is the plan's: Wikimedia Commons and Openverse first, with the licence and the author kept;
++ * OpenVibe.Pics' public gallery next; the page's own Media after that. An AI illustration is made only
++ * when nothing real exists, is labelled "Illustration made with <model>", and is never made for an
++ * article about a real person. A suggestion is a candidate, not an attachment: the editor picks one,
++ * it goes to Media, and only then does it become an attachment.
++ */
++const { IntegrationError } = require('../integrations/platform');
++
++const TASK = 'wiki.article.images';
++const PROVIDERS = ['commons', 'openverse'];
++const PERSON_KEYS = /^(born|died|death|birth|date_of_birth|place_of_birth|birth_?(date|place|name)|occupation|nationality|citizenship|spouse|partner|children|parents|mother|father|alma_mater|education|known_for|height|position|office|party|voiced_by|portrayed_by|author|director|actor|actress|singer|musician|writer|artist|politician|person)$/;
++const PERSON_TEXT = /\b(was born|is an? (actor|actress|singer|musician|writer|politician|artist|director|author|person|human)|birth name|date of birth|grew up in)\b/i;
++
++const oneLine = (s, n) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim().slice(0, n);
++const creditOf = (img) => [oneLine(img.author, 120), oneLine(img.license, 80)].filter(Boolean).join(', ');
++
++/** True when the page is about a real person: then no AI illustration is ever suggested. */
++function aboutPerson(v) {
++ const rows = Array.isArray(v.infobox) ? v.infobox : [];
++ if (rows.some((r) => PERSON_KEYS.test(String(r.key || r.label || '').toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '')))) return true;
++ const text = `${v.revision.fields.title || ''}\n${v.revision.fields.summary || ''}\n${v.revision.content || ''}`;
++ return PERSON_TEXT.test(text);
++}
++
++function fromResearch(images, limit) {
++ const out = [];
++ const seen = new Set();
++ for (const img of Array.isArray(images) ? images : []) {
++ if (!img || !img.url || img.free === false) continue;
++ const provider = String(img.provider || '').toLowerCase() === 'openverse' ? 'openverse' : 'commons';
++ if (!PROVIDERS.includes(provider)) continue;
++ const key = img.source_url || img.url;
++ if (seen.has(key)) continue;
++ seen.add(key);
++ out.push({
++ provider, role: out.length ? 'figure' : 'lead',
++ url: img.url, page_url: img.source_url || null,
++ title: oneLine(img.title, 300) || null, author: oneLine(img.author, 200) || null,
++ license: oneLine(img.license, 200) || null, license_url: img.license_url || null,
++ width: Number.isInteger(img.width) ? img.width : null, height: Number.isInteger(img.height) ? img.height : null,
++ alt: oneLine(img.description || img.title, 400) || null,
++ caption: oneLine(img.description || img.title, 400) || null,
++ credit: creditOf(img) || null, ai: false,
++ });
++ if (out.length >= limit) break;
++ }
++ return out;
++}
++
++function fromPics(pictures, limit) {
++ return (Array.isArray(pictures) ? pictures : []).slice(0, limit).map((p, i) => ({
++ provider: 'pics', role: i === 0 ? 'lead' : 'figure',
++ url: (p.links && (p.links.original || p.links.direct)) || null,
++ page_url: (p.links && p.links.page) || null,
++ title: oneLine(p.title, 300) || null, author: oneLine(p.author, 200) || null,
++ license: null, license_url: null,
++ width: Number.isInteger(Number(p.width)) ? Number(p.width) : null, height: Number.isInteger(Number(p.height)) ? Number(p.height) : null,
++ alt: oneLine(p.alt || p.description || p.title, 400) || null,
++ caption: oneLine(p.description || p.title, 400) || null,
++ credit: ['OpenVibe.Pics', oneLine(p.author, 120)].filter(Boolean).join(', '), ai: false,
++ })).filter((p) => p.url);
++}
++
++function fromAttachments(v, mediaUrl) {
++ return (v.attachments || []).filter((a) => !a.broken && a.mediaId).map((a, i) => ({
++ provider: 'media', role: i === 0 ? 'lead' : 'figure', media_id: a.mediaId,
++ url: mediaUrl ? mediaUrl(a.mediaId) : null, page_url: mediaUrl ? mediaUrl(a.mediaId) : null,
++ title: oneLine(a.caption, 300) || null, author: null, license: null, license_url: null,
++ width: null, height: null, alt: oneLine(a.alt, 400) || null, caption: oneLine(a.caption, 400) || null,
++ credit: 'OpenVibe.Media', ai: false,
++ }));
++}
++
++/** One run of the image task; an AI without the task yet answers 404 and the research workflow is used instead. */
++async function runTask(ctx, query, { choice = null, subject = null, limit = 8, provider = null } = {}) {
++ const input = { query, images: true, max_images: Math.max(4, limit), content_chars: 800 };
++ if (provider) {
++ // The task's choice names one engine; the plan asks Commons and Openverse, so each call names its own.
++ input.providers = [provider];
++ choice = { model: `search/${provider}`, source: 'openvibe' };
++ }
++ try {
++ return { ...(await ctx.platform.ai.runTask(TASK, input, { subject, choice })), task: TASK };
++ } catch (err) {
++ if (!(err instanceof IntegrationError) || !(err.status === 404 || err.status === 422 || err.code === 'task.not_found')) throw err;
++ // Before the AI choices system, the same task is the research workflow with both free picture sources.
++ const run = await ctx.platform.ai.run('research.gather', { ...input, providers: PROVIDERS }, { subject });
++ return { ...run, task: TASK };
++ }
++}
++
++async function suggest(ctx, pageId, body = {}, actor) {
++ const page = await ctx.svc.pageById(pageId);
++ if (!page || page.state === 'deleted') throw Object.assign(new Error('No such page'), { status: 404, code: 'page.not_found' });
++ const space = await ctx.svc.spaceById(page.space_id);
++ if (!space) throw Object.assign(new Error('No such space'), { status: 404, code: 'space.not_found' });
++ await ctx.svc.access.loadRoles(actor, { fresh: true });
++ if (!ctx.svc.access.canReadPage(space, page, actor)) throw Object.assign(new Error('No such page'), { status: 404, code: 'page.not_found' });
++ if (!ctx.svc.access.canEdit(space, actor)) throw Object.assign(new Error('Only editors of this space can suggest pictures'), { status: 403, code: 'page.forbidden' });
++
++ const v = await ctx.svc.view(space, page, actor);
++ const limit = Math.max(1, Math.min(12, Number(body.count) || 8));
++ const query = oneLine(body.query || [v.revision.fields.title, v.revision.fields.summary].filter(Boolean).join(' '), 300) || page.title;
++ const person = aboutPerson(v);
++ const suggestions = [];
++ let runId = null;
++ let note = null;
++ let model = null;
++
++ if (ctx.platform.ai.configured) {
++ const found = [];
++ // Commons and Openverse, both: the task's choice names one engine, so ask each in turn.
++ for (const provider of PROVIDERS) {
++ try {
++ const run = await runTask(ctx, query, { subject: actor && actor.subject, choice: body.choice, limit, provider: provider === 'openverse' ? 'openverse' : 'commons' });
++ runId = run.runId || runId;
++ found.push(...fromResearch((run.output && run.output.images) || [], limit));
++ } catch (err) {
++ ctx.log && ctx.log.warn && ctx.log.warn(`[Wiki] images ${pageId}: ${provider}: ${err && err.message}`);
++ }
++ }
++ suggestions.push(...found);
++ } else {
++ note = 'OpenVibe.AI is not configured here: only Pics and the page\'s own Media are suggested.';
++ }
++
++ const seen = new Set(suggestions.map((s) => s.page_url || s.url));
++ if (ctx.platform.pics.configured && suggestions.length < limit) {
++ try {
++ const pictures = await ctx.platform.pics.search(query, { limit: limit - suggestions.length });
++ for (const p of fromPics(pictures, limit - suggestions.length)) {
++ if (seen.has(p.page_url || p.url)) continue;
++ seen.add(p.page_url || p.url);
++ suggestions.push(p);
++ }
++ } catch (err) {
++ ctx.log && ctx.log.warn && ctx.log.warn(`[Wiki] images ${pageId}: Pics: ${err && err.message}`);
++ }
++ }
++ for (const a of fromAttachments(v, ctx.platform.media.publicUrl)) {
++ if (seen.has(a.page_url || a.url)) continue;
++ seen.add(a.page_url || a.url);
++ suggestions.push(a);
++ }
++
++ // An AI illustration only when nothing real exists, and never of a real person.
++ if (!suggestions.length && person) {
++ note = 'This article is about a real person: no AI illustration is suggested.';
++ } else if (!suggestions.length && body.allow_ai !== false && ctx.platform.ai.configured) {
++ try {
++ const run = await ctx.platform.ai.run('ai.image', {
++ prompt: `A plain editorial illustration for a wiki article titled "${oneLine(v.revision.fields.title, 200)}". ${oneLine(v.revision.fields.summary, 300)} No people, no faces, no real person, no text.`,
++ n: 1,
++ }, { subject: actor && actor.subject });
++ model = run.model || null;
++ const image = run.output && Array.isArray(run.output.images) ? run.output.images[0] : null;
++ if (image) {
++ const label = model || 'AI';
++ suggestions.push({
++ provider: 'ai', role: 'lead', url: image.url || null, page_url: null,
++ title: `Illustration for ${oneLine(v.revision.fields.title, 200)}`, author: null,
++ license: null, license_url: null, width: null, height: null,
++ alt: `An illustration for the article ${oneLine(v.revision.fields.title, 200)}`,
++ caption: `Illustration for ${oneLine(v.revision.fields.title, 200)}`,
++ credit: `Illustration made with ${label}`, ai: true, model,
++ data_url: image.b64 ? `data:image/png;base64,${image.b64}` : null,
++ });
++ runId = run.runId || runId;
++ }
++ } catch (err) {
++ note = `No illustration could be made: ${oneLine(err && err.message, 200)}`;
++ }
++ } else if (!suggestions.length) {
++ note = note || 'No licensed picture was found.';
++ }
++
++ return {
++ article_id: page.id, task: TASK, query, subject_is_person: person, run_id: runId, note,
++ suggestions: suggestions.slice(0, limit).map((s, i) => ({ ...s, role: i === 0 ? 'lead' : 'figure' })),
++ };
++}
++
++module.exports = { suggest, aboutPerson, TASK, PROVIDERS };
ddiff --git a/server/wiki/service.js b/server/wiki/service.js
iindex f5763cd..9a4231d 100644
---- a/server/wiki/service.js
++++ b/server/wiki/service.js
@@@ -33,10 +33,11 @@ function firstSentence(text) {
if (m) return m[1];
return t.length <= 160 ? t : `${t.slice(0, 160).replace(/\s+\S*$/, '')}…`;
}
--const { isMediaId, figureHtml } = require('openvibe-publishing/media');
++const { isMediaId } = require('openvibe-publishing/media');
const { ulid } = require('openvibe-contracts').ids;
const { checkCapability, CAPS } = require('../auth/capabilities');
const content = require('./content');
++const blockRender = require('../render/blocks');
const importer = require('./import');
const { createAccess, VISIBILITIES } = require('./access');
@@@ -1164,13 +1165,16 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
// in series made /w/:space/:slug wait on every link before it rendered.
const unique = new Map(targets.map((t) => [`${t.space}\u0000${t.slug}`, t]));
const resolved = new Map(await Promise.all([...unique].map(async ([key, t]) => [key, await svc.resolveLink(t, actor)])));
-- const byMedia = new Map((attachments || []).map((a) => [a.mediaId, a]));
-- const media = mediaUrl ? (id, caption) => {
-- const a = byMedia.get(id);
-- if (!a) return null;
-- return figureHtml({ ...a, caption: caption || a.caption }, { urlFor: mediaUrl, unavailableText: 'This picture is no longer available.' });
-- } : null;
-- return content.renderContent(rev.content, { currentSpace: space.slug, resolve: (t) => resolved.get(`${t.space}\u0000${t.slug}`) || { href: null, exists: false }, rel: svc.linkRel(space), media });
++ // Markdown plus blocks (openvibe-publishing/blocks): a body with no blocks renders as it always did.
++ // A preview has no revision row (and so no page): it has no attachments to resolve.
++ const pageId = rev.entityId || rev.entity_id || rev.pageId || rev.page_id || null;
++ return blockRender.renderBody(rev.content, {
++ currentSpace: space.slug,
++ resolve: (t) => resolved.get(`${t.space}\u0000${t.slug}`) || { href: null, exists: false },
++ rel: svc.linkRel(space),
++ mediaUrl,
++ attachments: attachments || (pageId ? await svc.attachmentsOf(pageId) : []),
++ }).html;
},
linkRel(space) { return space && space.kind === 'official' ? 'noopener' : 'nofollow ugc noopener'; },
ddiff --git a/server/wiki/writer.js b/server/wiki/writer.js
nnew file mode 100644
iindex 0000000..15d393f
---- /dev/null
++++ b/server/wiki/writer.js
@@@ -0,0 +1,147 @@
++'use strict';
++/**
++ * The wiki writer — OpenVibe.AI's content.wiki workflow, filed as a draft revision for a person.
++ *
++ * The workflow researches with web search plus the sources the article already cites, writes a citation
++ * on every sentence and fills the infobox from structured facts. This module only turns that answer into
++ * the Markdown a revision keeps: it composes the sentences, maps the cited ordinals back to their sources,
++ * and files the revision through wiki.revision.propose. Nothing here publishes: the proposal waits in the
++ * review queue for a person.
++ */
++const crypto = require('crypto');
++const content = require('./content');
++
++const WORKFLOW = 'content.wiki';
++const MAX_BODY = 200000;
++
++const oneLine = (s, n) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim().slice(0, n);
++
++/** One sentence must cite at least one source: the writer's own promise, checked before the draft is filed. */
++function citedSentences(list, where) {
++ const out = [];
++ for (const [i, s] of (Array.isArray(list) ? list : []).entries()) {
++ const text = oneLine(s && s.text, 2000);
++ if (!text) continue;
++ if (!s || !Array.isArray(s.citations) || !s.citations.length) {
++ throw Object.assign(new Error(`${where} sentence ${i + 1} cites no source`), { status: 502, code: 'wiki.citations_missing' });
++ }
++ out.push({ text, citations: [...new Set(s.citations.filter((n) => Number.isInteger(n) && n >= 0))] });
++ }
++ return out;
++}
++
++/** The Markdown of the writer's answer: the lead, then each section's cited sentences as one paragraph. */
++function composeBody(out, title = '') {
++ const parts = [];
++ const leadRows = Array.isArray(out.lead) ? out.lead.slice() : [];
++ if (leadRows.length && leadRows[0] && typeof leadRows[0].text === 'string') {
++ const first = leadRows[0].text.replace(/^\s*#{1,6}[ \t]+([^\r\n]+)\r?\n+/, (match, heading) =>
++ oneLine(heading, 160).toLowerCase() === oneLine(title, 160).toLowerCase() ? '' : match);
++ leadRows[0] = { ...leadRows[0], text: first };
++ }
++ const lead = citedSentences(leadRows, 'lead').map((s) => s.text).join(' ');
++ if (lead) parts.push(lead);
++ for (const section of Array.isArray(out.sections) ? out.sections : []) {
++ const heading = oneLine(section && section.heading, 160).replace(/^#+\s*/, '');
++ const sentences = citedSentences(section && section.sentences, `section "${heading}"`).map((s) => s.text).join(' ');
++ if (heading && sentences) parts.push(parts.length === 0 && heading.toLowerCase() === oneLine(title, 160).toLowerCase() ? sentences : `## ${heading}\n\n${sentences}`);
++ }
++ return parts.join('\n\n').replace(/\n{3,}/g, '\n\n').trim();
++}
++
++/** The infobox rows, typed as the wiki stores them; a value that does not fit its type becomes text. */
++function composeInfobox(rows) {
++ const out = [];
++ for (const row of Array.isArray(rows) ? rows : []) {
++ const label = oneLine(row && row.label, 80);
++ const value = row && row.value;
++ if (!label || value == null || oneLine(value, 500) === '') continue;
++ const wanted = ['text', 'number', 'date', 'url', 'boolean', 'page', 'media'].includes(row.type) ? row.type : 'text';
++ for (const type of [wanted, 'text']) {
++ try {
++ const [entry] = content.normalizeInfobox([{ label, type, value }]);
++ if (entry) { out.push(entry); break; }
++ } catch { /* try the next type */ }
++ }
++ if (out.length >= 14) break;
++ }
++ // One row per key: the first the writer gave wins.
++ return out.filter((row, i) => out.findIndex((x) => x.key === row.key) === i);
++}
++
++/** Every citation ordinal the writer used, mapped to the run's sources. */
++function citedSources(out, citations) {
++ const used = new Set();
++ const walk = (v) => {
++ if (Array.isArray(v)) return v.forEach(walk);
++ if (!v || typeof v !== 'object') return;
++ for (const [k, x] of Object.entries(v)) {
++ if (k === 'citations' && Array.isArray(x)) x.forEach((n) => { if (Number.isInteger(n) && n >= 0) used.add(n); });
++ else walk(x);
++ }
++ };
++ walk(out);
++ return [...used].sort((a, b) => a - b)
++ .map((n) => (Array.isArray(citations) ? citations.find((c) => c && c.ordinal === n) : null))
++ .filter((c) => c && c.url)
++ .map((c) => ({
++ url: c.url,
++ title: oneLine(c.title, 300) || c.url,
++ retrievedAt: c.retrieved_at || new Date().toISOString(),
++ licenseNote: oneLine((c.provenance && (c.provenance.license || c.provenance.licence)) || '', 300) || null,
++ }));
++}
++
++async function draft(ctx, pageId, body = {}, actor) {
++ const page = await ctx.svc.pageById(pageId);
++ if (!page || page.state === 'deleted') throw Object.assign(new Error('No such page'), { status: 404, code: 'page.not_found' });
++ const space = await ctx.svc.spaceById(page.space_id);
++ if (!space) throw Object.assign(new Error('No such space'), { status: 404, code: 'space.not_found' });
++ await ctx.svc.access.loadRoles(actor, { fresh: true });
++ if (!ctx.svc.access.canReadPage(space, page, actor)) throw Object.assign(new Error('No such page'), { status: 404, code: 'page.not_found' });
++ if (!ctx.svc.access.canEdit(space, actor)) throw Object.assign(new Error('Only editors of this space can write this article'), { status: 403, code: 'page.forbidden' });
++ if (!ctx.platform.ai.configured) throw Object.assign(new Error('The wiki writer needs OpenVibe.AI (OV_AI_INTERNAL_URL)'), { status: 503, code: 'ai.unavailable' });
++
++ const v = await ctx.svc.view(space, page, actor);
++ const title = v.revision.fields.title || page.title;
++ const instruction = oneLine(body.instruction, 4000) || 'Write or improve this article from its sources.';
++ // The sources the article already cites go in with the research, so nothing it says before is lost.
++ const sources = v.citations.filter((c) => c.url).slice(0, 40).map((c) => {
++ // The AI source schema takes strings, never null: leave an unknown field out.
++ const source = { source_type: 'wiki', url: c.url, title: c.title || c.url };
++ if (c.retrievedAt) source.retrieved_at = c.retrievedAt;
++ if (c.quote && c.quote.text) source.content = c.quote.text;
++ return source;
++ });
++ const key = crypto.createHash('sha256').update(JSON.stringify({ page: page.id, head: v.revision.number, instruction })).digest('hex').slice(0, 24);
++ const run = await ctx.platform.ai.run(WORKFLOW, {
++ title, topic: title, space: space.name, instruction: `${instruction} The page title is already shown above the body. Do not start the body with a heading that repeats the title.`, sources,
++ query: title, outline: [], pages: [], lang: 'en',
++ }, {
++ subject: actor && actor.subject,
++ choice: body.choice || null,
++ idempotencyKey: `wiki-draft:${page.id}:${key}`,
++ totalMs: 300000,
++ });
++ const out = run.output || {};
++ const text = composeBody(out, title);
++ if (text.length < 200) throw Object.assign(new Error('The sources did not say enough to write this article'), { status: 422, code: 'wiki.page_too_thin' });
++ if (text.length > MAX_BODY) throw Object.assign(new Error('The draft is too long'), { status: 422, code: 'wiki.page_too_long' });
++ const citations = citedSources(out, run.citations);
++ if (!citations.length) throw Object.assign(new Error('The writer cited no source with a URL'), { status: 502, code: 'wiki.citations_missing' });
++ const infobox = composeInfobox(out.infobox);
++ const summary = oneLine(out.summary, 300) || null;
++ const filed = await ctx.svc.propose({
++ space: space.id, pageId: page.id, title, body: text, summary, infobox,
++ citations: citations.slice(0, 50),
++ workflow: { id: WORKFLOW, runId: run.runId, model: run.model || null },
++ expectedRevision: v.revision.number,
++ note: `Written by AI from ${citations.length} source${citations.length === 1 ? '' : 's'}`,
++ }, { kind: 'system', service: 'svc:wiki', subject: null });
++ return {
++ proposal: filed.proposal, revision: filed.revision, run_id: run.runId, model: run.model || null,
++ provider: run.provider || null, sources: citations.slice(0, 50),
++ };
++}
++
++module.exports = { draft, composeBody, composeInfobox, citedSources, WORKFLOW };
ddiff --git a/test/wiki-rich.test.js b/test/wiki-rich.test.js
nnew file mode 100644
iindex 0000000..371aa39
---- /dev/null
++++ b/test/wiki-rich.test.js
@@@ -0,0 +1,133 @@
++'use strict';
++/**
++ * Rich wiki articles (W1): bodies are Markdown plus blocks (infobox, figure, gallery, timeline, quote,
++ * callout, stat), the API takes and returns blocks, and the two AI features — the wiki.article.images
++ * suggestions and the content.wiki writer — answer through their tasks and file a pending proposal.
++ */
++const assert = require('assert');
++const H = require('./helpers');
++require('../server/http/pages'); // the article route this test fetches (the blocks are rendered there)
++const { aboutPerson } = require('../server/wiki/images');
++const { composeBody } = require('../server/wiki/writer');
++
++const run = (output, extra = {}) => ({ run: { id: 'run_ai_1', status: 'succeeded', output, model: 'stub-model', provider: 'stub', ...extra } });
++
++(async () => {
++ const h = await H.boot({
++ env: { OV_AI_INTERNAL_URL: 'https://ai.test', OV_PICS_URL: 'https://pics.test' },
++ fetch: async (url, init = {}) => {
++ const u = new URL(String(url));
++ if (u.host === 'ai.test' && u.pathname === '/api/v1/runs') {
++ const body = JSON.parse(init.body || '{}');
++ if (body.task === 'wiki.article.images') {
++ const provider = (body.input && body.input.providers && body.input.providers[0]) || 'commons';
++ return new Response(JSON.stringify(run({
++ images: [{ url: `https://pictures.test/${provider}.jpg`, source_url: `https://${provider}.test/File:Machine`, title: 'A machine', description: 'A machine in a workshop', author: 'Ada Example', license: 'CC BY-SA 4.0', license_url: 'https://creativecommons.org/licenses/by-sa/4.0/', free: true, provider, width: 800, height: 600 }],
++ })), { status: 200, headers: { 'Content-Type': 'application/json' } });
++ }
++ if (body.workflow === 'content.wiki') {
++ return new Response(JSON.stringify({
++ ...run({
++ title: 'Machines', summary: 'A page about machines.',
++ lead: [{ text: `# Machines\n\nMachines do work. ${H.LONG}`, citations: [0] }],
++ sections: [{ heading: 'History', sentences: [{ text: 'They began with simple tools and grew from there.', citations: [1] }] }],
++ infobox: [{ label: 'Maker', type: 'text', value: 'OpenVibe' }],
++ citations: [0, 1], gaps: [],
++ }),
++ citations: [
++ { ordinal: 0, url: 'https://sources.test/a', title: 'A source', retrieved_at: '2026-11-09T09:00:00.000Z' },
++ { ordinal: 1, url: 'https://sources.test/b', title: 'B source', retrieved_at: '2026-11-09T09:00:00.000Z' },
++ ],
++ }), { status: 200, headers: { 'Content-Type': 'application/json' } });
++ }
++ }
++ if (u.host === 'pics.test' && u.pathname === '/api/v1/gallery') {
++ return new Response(JSON.stringify({ pictures: [{ id: 'pic_1', slug: 'lathe', title: 'A lathe', description: 'A lathe in use', alt: 'A lathe', width: 1200, height: 800, author: 'Bo', links: { page: 'https://pics.test/i/lathe', direct: 'https://pics.test/i/lathe.jpg' } }] }), { status: 200, headers: { 'Content-Type': 'application/json' } });
++ }
++ throw new Error(`unexpected outbound fetch ${url}`);
++ },
++ });
++ const alice = { kind: 'user', subject: H.subject(), staff: false };
++ const cookie = H.cookieFor(H.userToken({ subject: alice.subject }));
++ try {
++ const space = await h.svc.createSpace({ name: 'Machines', slug: 'machines' }, alice);
++ const body = ['# Machines', H.LONG,
++ ':::infobox{title="Quick facts"}', 'Released: 2026', 'Maker: OpenVibe', ':::',
++ ':::callout{tone="tip"}', 'A tip for readers.', ':::',
++ ':::timeline{title="History"}', '2025: The first one.', '2026: The second one.', ':::',
++ ':::quote{by="Ada"}', 'It works.', ':::',
++ ':::stat{value="3" label="machines"}', ':::',
++ ':::gallery{items="med_01JAB2C3D4E5F6G7H8J9K0MNPQ"}', ':::'].join('\n\n');
++ const made = await h.svc.createPage(space.id, { title: 'Machines', body }, alice);
++ const html = await h.svc.renderRevision(space, made.revision, alice);
++ for (const cls of ['ov-infobox', 'ov-callout', 'ov-timeline', 'ov-quote', 'ov-stat', 'ov-gallery']) {
++ assert.ok(html.includes(`class="${cls}`) || html.includes(`class="ov-media ${cls}`), `${cls} rendered: ${html.slice(0, 200)}`);
++ }
++ assert.ok(html.includes('data-media-id="med_01JAB2C3D4E5F6G7H8J9K0MNPQ"'), 'a figure names the Media object it wants');
++
++ // The page itself (server/http/pages.js) renders the blocks, and its .json carries the same tree.
++ await h.svc.publish(made.page.id, { revision: made.revision.number }, alice);
++ const page = await H.req(h, 'GET', `/w/${space.slug}/${made.page.slug}`, { cookie });
++ assert.strictEqual(page.status, 200, page.text.slice(0, 300));
++ assert.ok(page.text.includes('class="ov-infobox"'), 'the page renders the infobox block');
++ assert.ok(page.text.includes('class="ov-stat"'), 'the page renders the stat block');
++ const blocksLink = page.text.match(/<link rel="stylesheet" href="(\/css\/blocks\.css\?v=[0-9a-f]{10})">/);
++ assert.ok(blocksLink, 'the article links the hashed blocks sheet');
++ assert.ok(page.text.indexOf(blocksLink[0]) < page.text.indexOf('<link rel="stylesheet" href="/css/wiki.css?'), 'blocks CSS loads before wiki placement rules');
++ const sheet = await fetch(`${h.base}${blocksLink[1]}`);
++ assert.strictEqual(sheet.status, 200);
++ assert.match(sheet.headers.get('content-type'), /^text\/css/);
++ assert.strictEqual(sheet.headers.get('cache-control'), 'public, max-age=31536000, immutable');
++ assert.ok((await sheet.text()).includes('.ov-infobox'), 'the served sheet comes from the library');
++ assert.ok(page.text.includes('This revision cites no sources.'));
++ assert.ok(!page.text.includes('Inspect these sources'), 'an empty list has no inspect link');
++ const pageJson = await H.req(h, 'GET', `/w/${space.slug}/${made.page.slug}.json`, { cookie });
++ assert.strictEqual(pageJson.status, 200);
++ assert.strictEqual(pageJson.json.blocks.type, 'root');
++ assert.ok(Array.isArray(pageJson.json.blocks.children) && pageJson.json.blocks.children.length > 1);
++
++ // The API takes a block tree and keeps the Markdown it serializes to.
++ const tree = { type: 'root', children: [{ type: 'text', value: `# Blocks\n\n${H.LONG}\n\n` }, { type: 'stat', attrs: { value: '2', label: 'blocks' } }] };
++ const created = await H.req(h, 'POST', `/api/v1/spaces/${space.slug}/pages`, { cookie, body: { title: 'Blocks', blocks: tree } });
++ assert.strictEqual(created.status, 201, created.text);
++ assert.ok(created.json.revision.body.includes(':::stat{value="2" label="blocks"}'), created.json.revision.body);
++ assert.strictEqual(created.json.blocks.type, 'root');
++ const blocksOnly = await H.req(h, 'GET', `/api/v1/pages/${created.json.page.id}?format=blocks`, { cookie });
++ assert.strictEqual(blocksOnly.status, 200, blocksOnly.text);
++ assert.strictEqual(blocksOnly.json.blocks.type, 'root');
++ const bad = await H.req(h, 'POST', `/api/v1/spaces/${space.slug}/pages`, { cookie, body: { title: 'Bad', blocks: { type: 'nope' } } });
++ assert.strictEqual(bad.status, 422);
++ assert.strictEqual(bad.json.code, 'page.invalid_blocks');
++
++ // A real person never gets an AI illustration: the infobox says so before any image is drawn.
++ assert.strictEqual(aboutPerson({ infobox: [{ key: 'born', label: 'Born', type: 'text', value: '1970' }], revision: { fields: { title: 'Ada' }, content: '' } }), true);
++ assert.strictEqual(aboutPerson({ infobox: [], revision: { fields: { title: 'A lathe' }, content: 'A lathe turns wood.' } }), false);
++
++ // wiki.article.images: Commons and Openverse first, keeping licence and author; then Pics.
++ const suggested = await H.req(h, 'POST', `/api/v1/pages/${made.page.id}/images/suggest`, { cookie, body: { count: 8 } });
++ assert.strictEqual(suggested.status, 200, suggested.text);
++ assert.strictEqual(suggested.json.task, 'wiki.article.images');
++ assert.strictEqual(suggested.json.subject_is_person, false);
++ assert.deepStrictEqual([...new Set(suggested.json.suggestions.map((s) => s.provider))].sort(), ['commons', 'openverse', 'pics']);
++ const lead = suggested.json.suggestions.find((s) => s.role === 'lead');
++ assert.strictEqual(lead.license, 'CC BY-SA 4.0');
++ assert.strictEqual(lead.author, 'Ada Example');
++ assert.ok(lead.credit.includes('CC BY-SA 4.0'));
++
++ // content.wiki: a draft proposal, pending, with a citation per sentence and the infobox filled.
++ const drafted = await H.req(h, 'POST', `/api/v1/pages/${made.page.id}/draft`, { cookie, body: { instruction: 'Add the history' } });
++ assert.strictEqual(drafted.status, 201, drafted.text);
++ assert.strictEqual(drafted.json.proposal.status, 'pending');
++ assert.strictEqual(drafted.json.model, 'stub-model');
++ assert.ok(drafted.json.revision.body.startsWith('Machines do work.'), drafted.json.revision.body);
++ assert.ok(drafted.json.revision.body.includes('## History'));
++ assert.ok(!drafted.json.revision.body.startsWith('# Machines'), 'the writer drops a repeated leading title');
++ assert.strictEqual(composeBody({ sections: [{ heading: 'Machines', sentences: [{ text: 'A cited sentence.', citations: [0] }] }] }, 'Machines'), 'A cited sentence.');
++ assert.deepStrictEqual(drafted.json.revision.infobox.map((r) => [r.label, r.value]), [['Maker', 'OpenVibe']]);
++ assert.deepStrictEqual(drafted.json.sources.map((s) => s.url), ['https://sources.test/a', 'https://sources.test/b']);
++
++ console.log('wiki rich ok');
++ } finally {
++ await h.stop();
++ }
++})().catch((err) => { console.error(err); process.exit(1); });