openvibers/openvibe.wiki Commits
Rich wiki articles: blocks, image suggestions and the content.wiki writer (#265)
Changes
@@@ -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.@@@ -19,7 +19,7 @@ Media attachments and Community discussion. AI proposes revisions; a person apprpublishes. 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 ownsits 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 ofThe 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 releasedcapability 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, PostgreSQLevents 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 tPart 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 -->@@@ -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",@@@ -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"@@@ -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"},@@@ -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; }++}@@@ -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@@@ -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: {@@@ -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, viberouter.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, viberouter.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, vibeconst 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 proposalsrouter.post('/proposals', guard('wiki.revision.propose'), B('wiki.revision.propose'), R(async (req) => {const b = req.body || {};@@@ -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()) } },@@@ -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, viconst 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, vifunction 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, viconst 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, vilet 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));@@@ -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 = nulif (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 = nulconst 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),};}nnew file mode 100644@@@ -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 };@@@ -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'),});}@@@ -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>`;}nnew file mode 100644@@@ -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 };@@@ -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'; },nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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); });
@@ -2,6 +2,9 @@
@@ -2,6 +2,9 @@
## Unreleased
## 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`.
- 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).
- 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.
- **Deleted:** the person's own user spaces (through `deleteSpace`), roles, watches and drafts.
@@ -19,7 +19,7 @@ Media attachments and Community discussion. AI proposes revisions; a person appr
@@ -19,7 +19,7 @@ Media attachments and Community discussion. AI proposes revisions; a person appr
publishes. Search indexes only published, public state.
publishes. Search indexes only published, public state.
It is the first full consumer of the shared publishing packages
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,
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
media references, discussion references and the Search index hooks all come from there. Wiki owns
its publication state; the packages supply the mechanics.
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
@@ -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
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,
`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
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
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).
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
@@ -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
- 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-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.
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.Network (SSO, JWKS, service principal `wiki`), OpenVibe.Events, OpenVibe.Community,
OpenVibe.Sources, OpenVibe.Media, OpenVibe.VIP (VIP spaces and pages), OpenVibe.Search (consumer of
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
@@ -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).
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
<!-- versions:start -->
<!-- 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-shared: v4.179.0
-- openvibe-publishing: v1.3.0
+- openvibe-publishing: v1.7.0
<!-- versions:end -->
<!-- versions:end -->
@@ -7,13 +7,13 @@
@@ -7,13 +7,13 @@
"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"port": 4800,
"port": 4800,
"domain": "openvibe.wiki",
"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",
"shared": "openvibe-shared v4.179.0",
"packages": [
"packages": [
- "openvibe-publishing v1.3.0",
+ "openvibe-publishing v1.7.0",
"openvibe-shared v4.179.0",
"openvibe-shared v4.179.0",
- "openvibe-sdk v0.125.0"
+ "openvibe-sdk v0.132.0"
],
],
"capabilities": [
"capabilities": [
"wiki.space.create",
"wiki.space.create",
@@ -14,9 +14,9 @@
@@ -14,9 +14,9 @@
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"iovalkey": "^0.4.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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.179.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
@@ -1134,9 +1134,9 @@
@@ -1134,9 +1134,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"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",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -1150,9 +1150,9 @@
@@ -1150,9 +1150,9 @@
}
}
},
},
"node_modules/openvibe-publishing": {
"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",
"license": "MIT",
"engines": {
"engines": {
"node": ">=20"
"node": ">=20"
@@ -1168,9 +1168,9 @@
@@ -1168,9 +1168,9 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"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",
"license": "MIT",
"bin": {
"bin": {
"openvibe-download": "bin/openvibe-download.js"
"openvibe-download": "bin/openvibe-download.js"
@@ -25,9 +25,9 @@
@@ -25,9 +25,9 @@
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"iovalkey": "^0.4.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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.179.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
@@ -238,3 +238,14 @@ main .wk-h-group h3 { display: flex; flex-wrap: wrap; align-items: baseline; gap
@@ -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--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); }
.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; } }
@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; }
+}
@@ -136,6 +136,11 @@ function createApp({ config, telemetry = null, svc, viewers: baseViewers, platfo
@@ -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).
// 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.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, {
app.use(express.static(PUBLIC_DIR, {
index: false, redirect: false,
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
// Immutable only when ?v= is the hash of the bytes served: an older page's URL after a deploy (or a
@@ -71,6 +71,8 @@ function load(env = process.env) {
@@ -71,6 +71,8 @@ function load(env = process.env) {
mediaPublicUrl: trim(env.OV_MEDIA_URL || 'https://openvibe.media'),
mediaPublicUrl: trim(env.OV_MEDIA_URL || 'https://openvibe.media'),
// Media namespace (app) Wiki's attachments live in, checked with media.object.read.
// Media namespace (app) Wiki's attachments live in, checked with media.object.read.
mediaApp: env.WIKI_MEDIA_APP || 'wiki',
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.
// "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 || ''),
aiInternalUrl: trim(env.OV_AI_INTERNAL_URL || ''),
vibe: {
vibe: {
@@ -11,14 +11,16 @@
@@ -11,14 +11,16 @@
* PATCH /spaces/:space wiki.space.create { name?, description?, visibility?, slug? } owner
* PATCH /spaces/:space wiki.space.create { name?, description?, visibility?, slug? } owner
* GET /spaces/:space/roles owner
* GET /spaces/:space/roles owner
* PUT /spaces/:space/roles/:subject wiki.space.create { role: owner|editor|viewer|null } 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)
* 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=)
* 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? }
* PATCH /pages/:id wiki.page.create { slug?, parent_id?, visibility?, noindex? }
* DELETE /pages/:id wiki.page.create owner
* DELETE /pages/:id wiki.page.create owner
* GET /pages/:id/revisions wiki.page.read history (?before=&limit=)
* 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
* 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/diff?from=&to=&mode= wiki.page.read
* GET /pages/:id/revisions/:n/citations 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)
* POST /pages/:id/revisions/:n/citations wiki.citation.attach { citations: [...] } (unpublished head only)
@@ -44,6 +46,10 @@ const express = require('express');
@@ -44,6 +46,10 @@ const express = require('express');
const contracts = require('openvibe-contracts');
const contracts = require('openvibe-contracts');
const { actorMiddleware, guard, guardReview, run, resolveCitations, resolveBundleCitations } = require('./common');
const { actorMiddleware, guard, guardReview, run, resolveCitations, resolveBundleCitations } = require('./common');
const importer = require('../wiki/import');
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;
const { http } = contracts;
@@ -83,6 +89,23 @@ function serializeTree(nodes, svc, space) {
@@ -83,6 +89,23 @@ function serializeTree(nodes, svc, space) {
function int(v) { const n = Number(v); return Number.isInteger(n) ? n : undefined; }
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 }) {
function createApi({ svc, viewers, platform, config, log = console, limits, vibes = null }) {
const router = express.Router();
const router = express.Router();
const ownOrigin = new URL(config.baseUrl).origin;
const ownOrigin = new URL(config.baseUrl).origin;
@@ -173,9 +196,9 @@ function createApi({ svc, viewers, platform, config, log = console, limits, vibe
@@ -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) => {
router.post('/spaces/:space/pages', guard('wiki.page.create'), B('wiki.page.create'), R(async (req) => {
const b = req.body || {};
const b = req.body || {};
const cites = await resolveCitations(b.citations, platform);
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);
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));
}, 201));
router.post('/spaces/:space/import', guard('wiki.page.create'), B('wiki.page.import'), R(async (req) => {
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
@@ -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) => {
router.get('/pages/:id', guard('wiki.page.read'), R(async (req) => {
const { page, space } = await readable(req, req.params.id);
const { page, space } = await readable(req, req.params.id);
const v = await svc.view(space, page, req.actor, { revision: int(req.query.revision) });
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 {
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,
citations: v.citations.map(serializeCitation), infobox: v.infobox,
attachments: v.attachments.map(serializeAttachment),
attachments: v.attachments.map(serializeAttachment),
links: v.links.map((l) => ({ space: l.target_space, slug: l.target_slug, label: l.label })),
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
@@ -223,16 +249,16 @@ function createApi({ svc, viewers, platform, config, log = console, limits, vibe
const b = req.body || {};
const b = req.body || {};
const cites = await resolveCitations(b.citations, platform);
const cites = await resolveCitations(b.citations, platform);
const out = await svc.editPage(req.params.id, {
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,
citations: cites, keepCitations: b.keep_citations === undefined ? 'all' : b.keep_citations, message: b.message,
}, req.actor);
}, req.actor);
const space = await svc.spaceById(out.page.space_id);
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)));
}, (out) => (out.created ? 201 : 200)));
router.get('/pages/:id/revisions/:n', guard('wiki.page.read'), R(async (req) => {
router.get('/pages/:id/revisions/:n', guard('wiki.page.read'), R(async (req) => {
const { page, space } = await readable(req, req.params.id);
const { page, space } = await readable(req, req.params.id);
const v = await svc.view(space, page, req.actor, { revision: int(req.params.n) });
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) => {
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);
const { page, space } = await readable(req, req.params.id);
@@ -284,6 +310,15 @@ function createApi({ svc, viewers, platform, config, log = console, limits, vibe
@@ -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)));
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
// AI proposals
router.post('/proposals', guard('wiki.revision.propose'), B('wiki.revision.propose'), R(async (req) => {
router.post('/proposals', guard('wiki.revision.propose'), B('wiki.revision.propose'), R(async (req) => {
const b = req.body || {};
const b = req.body || {};
@@ -110,8 +110,9 @@ route('put', '/spaces/{space}/roles/{subject}', {
@@ -110,8 +110,9 @@ route('put', '/spaces/{space}/roles/{subject}', {
route('post', '/spaces/{space}/pages', {
route('post', '/spaces/{space}/pages', {
operationId: 'createPage', summary: 'Create a draft page in a space', tags: ['pages'], security: PERSON, parameters: [SPACE],
operationId: 'createPage', summary: 'Create a draft page in a space', tags: ['pages'], security: PERSON, parameters: [SPACE],
description: 'wiki.page.create.',
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'] },
infobox, parent_id: nullable('string'), visibility: { type: 'string', enum: ['public', 'members', 'vip', 'private'] },
citations: arrayOf(citation), message: nullable('string'),
citations: arrayOf(citation), message: nullable('string'),
} }),
} }),
@@ -151,7 +152,7 @@ route('post', '/pages/{id}/revisions', {
@@ -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],
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.',
description: 'wiki.page.create. expected_revision makes the write conditional: a stale number answers 409.',
requestBody: body({ type: 'object', required: ['expected_revision'], properties: {
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),
summary: str({ maxLength: 300 }), infobox, citations: arrayOf(citation),
keep_citations: { type: 'string', description: 'all (default), none, or the numbers to carry' }, message: nullable('string'),
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', {
@@ -224,6 +225,29 @@ route('post', '/pages/{id}/media/verify', {
description: 'wiki.page.create. Editors only.',
description: 'wiki.page.create. Editors only.',
responses: { 200: json('One result per attachment', { type: 'object', properties: { results: arrayOf({ type: 'object' }) } }), ...errors() },
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', {
route('put', '/pages/{id}/watch', {
operationId: 'watchPage', summary: 'Watch (or stop watching) a page', tags: ['pages'], security: PERSON, parameters: [PAGE],
operationId: 'watchPage', summary: 'Watch (or stop watching) a page', tags: ['pages'], security: PERSON, parameters: [PAGE],
description: 'People only: a service token cannot watch.',
description: 'People only: a service token cannot watch.',
@@ -319,6 +343,9 @@ function openapi() {
@@ -319,6 +343,9 @@ function openapi() {
body: { type: 'null', description: 'Never sent in a history listing' }, published: { type: 'boolean' },
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' }),
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: {
Citation: { type: 'object', properties: {
id: str(), revision: { type: 'integer' }, url: nullable('string'), title: nullable('string'),
id: str(), revision: { type: 'integer' }, url: nullable('string'), title: nullable('string'),
source_item_id: nullable('string'), retrieved_at: nullable('string', { format: 'date-time' }),
source_item_id: nullable('string'), retrieved_at: nullable('string', { format: 'date-time' }),
@@ -331,10 +358,10 @@ function openapi() {
@@ -331,10 +358,10 @@ function openapi() {
checked_at: nullable('string', { format: 'date-time' }), attached_by: nullable('string'),
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' }) } },
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: {
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')),
attachments: arrayOf(ref('Attachment')),
links: arrayOf({ type: 'object', properties: { space: str(), slug: str(), label: nullable('string') } }),
links: arrayOf({ type: 'object', properties: { space: str(), slug: str(), label: nullable('string') } }),
indexability: { type: 'object', properties: { indexable: { type: 'boolean' }, reasons: arrayOf(str()) } },
indexability: { type: 'object', properties: { indexable: { type: 'boolean' }, reasons: arrayOf(str()) } },
@@ -35,6 +35,7 @@ const seo = require('openvibe-publishing/seo');
@@ -35,6 +35,7 @@ const seo = require('openvibe-publishing/seo');
const ssr = require('openvibe-publishing/ssr');
const ssr = require('openvibe-publishing/ssr');
const { renderPage, AI_SUMMARY, asset } = require('../render/layout');
const { renderPage, AI_SUMMARY, asset } = require('../render/layout');
const views = require('../render/views');
const views = require('../render/views');
+const blockRender = require('../render/blocks');
const { foldBlock } = require('../render/short');
const { foldBlock } = require('../render/short');
const frame = require('openvibe-shared/frame');
const frame = require('openvibe-shared/frame');
const showcase = require('openvibe-shared/showcase');
const showcase = require('openvibe-shared/showcase');
@@ -285,7 +286,7 @@ function createPages({ svc, viewers, platform, config, log = console, limits, vi
@@ -285,7 +286,7 @@ function createPages({ svc, viewers, platform, config, log = console, limits, vi
const b = req.body || {};
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 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 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 {
try {
const infobox = content.parseInfoboxText(b.infobox);
const infobox = content.parseInfoboxText(b.infobox);
if (b.op === 'preview') return again(200, null, await svc.renderRevision(space, { content: String(b.body || '') }, req.actor));
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
@@ -389,7 +390,7 @@ function createPages({ svc, viewers, platform, config, log = console, limits, vi
function articleData(v, space, page) {
function articleData(v, space, page) {
return {
return {
id: page.id, space: space.slug, slug: page.slug, url: svc.pageUrl(space, page), state: page.state, visibility: svc.access.effectiveVisibility(space, page),
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,
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_published_at: page.revision_published_at ? new Date(page.revision_published_at).toISOString() : null,
revision_created_at: v.revision.createdAt,
revision_created_at: v.revision.createdAt,
@@ -443,18 +444,18 @@ function createPages({ svc, viewers, platform, config, log = console, limits, vi
@@ -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 }];
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.
// 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 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 ? [
const jsonLd = open && v.isPublishedRevision ? [
seo.structuredData.article({
seo.structuredData.article({
headline: v.revision.fields.title, url, description,
headline: v.revision.fields.title, url, description,
datePublished: page.published_at, dateModified: page.revision_published_at,
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),
seo.structuredData.breadcrumbs(crumbsLd),
].filter(Boolean) : null;
].filter(Boolean) : null;
const shareMedia = v.attachments.find((a) => a.mediaId && !a.brokenReason);
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 }), {
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
@@ -539,7 +540,7 @@ function createPages({ svc, viewers, platform, config, log = console, limits, vi
let baseView = null;
let baseView = null;
try { baseView = Number.isInteger(base) && base > 0 ? await svc.view(space, page, req.actor, { revision: base }) : null; } catch { 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 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 {
try {
const infobox = content.parseInfoboxText(b.infobox);
const infobox = content.parseInfoboxText(b.infobox);
if (b.op === 'preview') return again(200, null, await svc.renderRevision(space, { content: String(b.body || '') }, req.actor));
if (b.op === 'preview') return again(200, null, await svc.renderRevision(space, { content: String(b.body || '') }, req.actor));
@@ -223,9 +223,10 @@ function createPlatform({ config, db, fetchImpl = globalThis.fetch, tokens = nul
@@ -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
* 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.
* 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)');
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 } : {}) });
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) => {
const call = async (url, init) => {
let res;
let res;
try { res = await fetchImpl(url, { ...init, signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(waitMs + 15000)]) : AbortSignal.timeout(waitMs + 15000) }); } catch (err) {
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
@@ -235,22 +236,50 @@ function createPlatform({ config, db, fetchImpl = globalThis.fetch, tokens = nul
if (res.status === 401) forAudience('openvibe.ai').invalidate();
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 === 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 (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;
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) });
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;
const until = Date.now() + totalMs;
while (run && !['succeeded', 'cached', 'failed', 'cancelled'].includes(run.status)) {
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));
await new Promise((r) => setTimeout(r, 3000));
run = await call(`${config.aiInternalUrl}/api/v1/runs/${encodeURIComponent(run.id)}`, { method: 'GET', headers: await headers() });
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) throw new IntegrationError(503, 'ai.unavailable', 'OpenVibe.AI answered without a run');
if (run.status === 'failed' || run.status === 'cancelled') {
if (run.status === 'failed' || run.status === 'cancelled') {
const e = run.error || {};
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
@@ -258,7 +287,7 @@ function createPlatform({ config, db, fetchImpl = globalThis.fetch, tokens = nul
const vip = require('./vip').createVip({ config, tokenClient, fetchImpl, now, log });
const vip = require('./vip').createVip({ config, tokenClient, fetchImpl, now, log });
return {
return {
- tokenClient, sdk, events, outbox, community, sources, media, ai, vip,
+ tokenClient, sdk, events, outbox, community, sources, media, pics, ai, vip,
eventsConfigured: !!(tokenClient && config.eventsUrl),
eventsConfigured: !!(tokenClient && config.eventsUrl),
};
};
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,67 @@
@@ -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 };
@@ -13,6 +13,7 @@ const path = require('path');
@@ -13,6 +13,7 @@ const path = require('path');
const layout = require('openvibe-publishing/layout');
const layout = require('openvibe-publishing/layout');
const { escapeHtml: esc } = require('openvibe-publishing/ssr');
const { escapeHtml: esc } = require('openvibe-publishing/ssr');
const seo = require('openvibe-shared/seo');
const seo = require('openvibe-shared/seo');
+const serve = require('openvibe-shared/serve');
const SITE_NAME = 'OpenVibe.Wiki';
const SITE_NAME = 'OpenVibe.Wiki';
const NETWORK_URL = 'https://openvibe.network';
const NETWORK_URL = 'https://openvibe.network';
@@ -27,12 +28,13 @@ const NAV_LINKS = [
@@ -27,12 +28,13 @@ const NAV_LINKS = [
];
];
const FOOTER = { service: 'wiki', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME, updates: '/updates' };
const FOOTER = { service: 'wiki', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME, updates: '/updates' };
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
+const BLOCKS_CSS = require.resolve('openvibe-publishing/blocks.css');
const hashes = new Map();
const hashes = new Map();
function asset(rel) {
function asset(rel) {
if (!hashes.has(rel)) {
if (!hashes.has(rel)) {
let v = 'dev';
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);
hashes.set(rel, v);
}
}
return `/${rel}?v=${hashes.get(rel)}`;
return `/${rel}?v=${hashes.get(rel)}`;
@@ -109,14 +111,14 @@ function renderPage(o) {
@@ -109,14 +111,14 @@ function renderPage(o) {
navLinks: NAV_LINKS,
navLinks: NAV_LINKS,
home: '/',
home: '/',
iconSite: 'network',
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,
release: RELEASE,
mainClass: 'wk-main',
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>`,
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,
body: o.body,
// The page names its Markdown twin (openvibe-shared/agent-layer) and the site's llms.txt.
// 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'),
});
});
}
}
@@ -333,7 +333,7 @@ function infoboxHtml(entries, resolve, rel = 'nofollow ugc noopener', { media =
@@ -333,7 +333,7 @@ function infoboxHtml(entries, resolve, rel = 'nofollow ugc noopener', { media =
function citationsHtml(cites, inspectHref, rel = 'nofollow ugc noopener') {
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>` : '';
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>`;
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>`;
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,187 @@
@@ -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 };
@@ -33,10 +33,11 @@ function firstSentence(text) {
@@ -33,10 +33,11 @@ function firstSentence(text) {
if (m) return m[1];
if (m) return m[1];
return t.length <= 160 ? t : `${t.slice(0, 160).replace(/\s+\S*$/, '')}…`;
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 { ulid } = require('openvibe-contracts').ids;
const { checkCapability, CAPS } = require('../auth/capabilities');
const { checkCapability, CAPS } = require('../auth/capabilities');
const content = require('./content');
const content = require('./content');
+const blockRender = require('../render/blocks');
const importer = require('./import');
const importer = require('./import');
const { createAccess, VISIBILITIES } = require('./access');
const { createAccess, VISIBILITIES } = require('./access');
@@ -1164,13 +1165,16 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
@@ -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.
// 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 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 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'; },
linkRel(space) { return space && space.kind === 'official' ? 'noopener' : 'nofollow ugc noopener'; },
new file mode 100644
new file mode 100644
@@ -0,0 +1,147 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,133 @@
@@ -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); });