@materializecms/server 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/package.json +4 -4
- package/src/ai.d.ts +6 -1
- package/src/ai.js +11 -3
- package/src/app.js +672 -21
- package/src/brief.d.ts +43 -0
- package/src/brief.js +172 -0
- package/src/studio.d.ts +13 -0
- package/src/studio.js +75 -0
- package/src/tools.d.ts +1 -0
- package/src/tools.js +57 -2
- package/src/wordpress.d.ts +2 -0
- package/src/wordpress.js +22 -5
- package/src/wpimport.d.ts +19 -1
- package/src/wpimport.js +112 -3
package/src/app.js
CHANGED
|
@@ -13,7 +13,8 @@ import { expandComponents, expandGlobals, formDefinition, normalizeTheme, sameDe
|
|
|
13
13
|
import { addMove, normalizePath } from '@materializecms/core/seo';
|
|
14
14
|
import { TRACKING_PATTERNS, trackingBlocks } from '@materializecms/core/tracking';
|
|
15
15
|
import { DEFAULT_UPDATES, isPatch, } from '@materializecms/core/updates';
|
|
16
|
-
import {
|
|
16
|
+
import { VOICE_TONES, voicePrompt } from '@materializecms/core/voice';
|
|
17
|
+
import { createActivityStore, createAuthStore, createCommentStore, createContentStore, createMediaStore, createPreviewLinkStore, createReleaseStore, createScheduleStore, createSettingsStore, createSubmissionStore, EmailTakenError, SlugTakenError, } from '@materializecms/db';
|
|
17
18
|
import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js';
|
|
18
19
|
import { Hono } from 'hono';
|
|
19
20
|
import { bodyLimit } from 'hono/body-limit';
|
|
@@ -24,9 +25,10 @@ import { secureHeaders } from 'hono/secure-headers';
|
|
|
24
25
|
import { streamSSE } from 'hono/streaming';
|
|
25
26
|
import { z } from 'zod';
|
|
26
27
|
import { serveAdmin } from './admin.js';
|
|
27
|
-
import { AiError, complete, EMPTY_AI, isReady, publicAi, runTurn, translateTexts, } from './ai.js';
|
|
28
|
+
import { AiError, complete, EMPTY_AI, isReady, languageName, publicAi, runTurn, translateTexts, } from './ai.js';
|
|
28
29
|
import { folderEntries, tarGz } from './archive.js';
|
|
29
30
|
import { createBackups, DEFAULT_BACKUPS } from './backups.js';
|
|
31
|
+
import { briefSystem, briefTools, plain } from './brief.js';
|
|
30
32
|
import { createFonts, FONT_FILE, FONT_ID } from './fonts.js';
|
|
31
33
|
import { fingerprintOf, readSubmission, removeFiles, saveFiles, sendWebhook, spamReason, } from './forms.js';
|
|
32
34
|
import { checkSenderDomain, explainMailError, isConfigured, publicMail, sendMail, submissionText, } from './mail.js';
|
|
@@ -36,10 +38,11 @@ import { DEFAULT_OFFSITE, newRecoveryKey, runOffsite, testOffsite, } from './off
|
|
|
36
38
|
import { hashPassword, verifyPassword } from './password.js';
|
|
37
39
|
import { fetchPublicFile, RemoteError, remoteFetch } from './remote.js';
|
|
38
40
|
import { statusPage } from './status-page.js';
|
|
39
|
-
import {
|
|
41
|
+
import { studioSystem, studioTools } from './studio.js';
|
|
42
|
+
import { apiCaller, assistantTools, contentTools, pageTools } from './tools.js';
|
|
40
43
|
import { newSecret, otpauthUri, recoveryCodes, verifyCode } from './totp.js';
|
|
41
44
|
import { readRest, readWxr, WordPressError } from './wordpress.js';
|
|
42
|
-
import { fold, optionsOf, runImport, sourcesFor, suggest, } from './wpimport.js';
|
|
45
|
+
import { fold, optionsOf, relayoutProblem, restoreAlts, runImport, sourcesFor, suggest, } from './wpimport.js';
|
|
43
46
|
/** The installed version, from this package's package.json (same place in the sources and in the release). */
|
|
44
47
|
export const VERSION = createRequire(import.meta.url)('../package.json').version;
|
|
45
48
|
const SESSION_COOKIE = 'mz_session';
|
|
@@ -190,6 +193,7 @@ export function createApp(base) {
|
|
|
190
193
|
const comments = createCommentStore(ctx.database.db);
|
|
191
194
|
const activityLog = createActivityStore(ctx.database.db);
|
|
192
195
|
const schedules = createScheduleStore(ctx.database.db);
|
|
196
|
+
const releases = createReleaseStore(ctx.database.db);
|
|
193
197
|
const previewLinks = createPreviewLinkStore(ctx.database.db);
|
|
194
198
|
const backups = createBackups(ctx.database, () => ctx.config().media.dir);
|
|
195
199
|
const backupSettings = async () => ({
|
|
@@ -461,6 +465,12 @@ export function createApp(base) {
|
|
|
461
465
|
issues: check && !check.ok ? check.issues : [],
|
|
462
466
|
/** When it goes on or off the site by itself, and the last scheduled action that failed. */
|
|
463
467
|
schedule: await schedules.forDocument(doc.id),
|
|
468
|
+
/** The release it goes out with, if any. */
|
|
469
|
+
release: await releases
|
|
470
|
+
.forDocument(doc.id)
|
|
471
|
+
.then((r) => r
|
|
472
|
+
? { id: r.id, name: r.name, runAt: r.runAt, action: r.items.find((i) => i.documentId === doc.id)?.action }
|
|
473
|
+
: null),
|
|
464
474
|
};
|
|
465
475
|
};
|
|
466
476
|
/**
|
|
@@ -888,7 +898,7 @@ export function createApp(base) {
|
|
|
888
898
|
const ACTIVITY_GROUPS = {
|
|
889
899
|
access: ['login.', 'logout', 'session.', 'twofactor.', 'token.', 'security.', 'password.'],
|
|
890
900
|
people: ['user.'],
|
|
891
|
-
content: ['document.', 'media.', 'message.'],
|
|
901
|
+
content: ['document.', 'release.', 'site.', 'media.', 'message.'],
|
|
892
902
|
settings: ['settings.', 'backup.', 'update.'],
|
|
893
903
|
};
|
|
894
904
|
app.get('/api/activity', requireUser, requireAdmin, async (c) => {
|
|
@@ -1826,6 +1836,203 @@ export function createApp(base) {
|
|
|
1826
1836
|
await complete(ai, { ...probe, fast: true }).catch(aiFailed);
|
|
1827
1837
|
return c.json({ ok: true, reply });
|
|
1828
1838
|
});
|
|
1839
|
+
// ---------- brand voice ----------
|
|
1840
|
+
/** `style` when the AI changes a text someone wrote, `full` when it writes one (see voicePrompt). */
|
|
1841
|
+
const voiceText = async (use = 'full') => voicePrompt(await settings.get('voice'), use);
|
|
1842
|
+
const voiceSchema = z.object({
|
|
1843
|
+
about: z.string().trim().max(1000).optional(),
|
|
1844
|
+
tones: z.array(z.enum(VOICE_TONES)).max(3).optional(),
|
|
1845
|
+
address: z.enum(['tu', 'voi', 'lei']).nullable().optional(),
|
|
1846
|
+
prefer: z.string().trim().max(1000).optional(),
|
|
1847
|
+
avoid: z.string().trim().max(1000).optional(),
|
|
1848
|
+
rules: z.string().trim().max(1500).optional(),
|
|
1849
|
+
example: z.string().trim().max(3000).optional(),
|
|
1850
|
+
});
|
|
1851
|
+
app.get('/api/voice', requireUser, async (c) => c.json({ voice: (await settings.get('voice')) ?? {} }));
|
|
1852
|
+
app.put('/api/voice', requireUser, requireAdmin, async (c) => {
|
|
1853
|
+
const input = await body(c, z.object({ voice: voiceSchema }));
|
|
1854
|
+
await settings.set('voice', input.voice, c.get('user').id);
|
|
1855
|
+
await record(c, 'settings.voice');
|
|
1856
|
+
return c.json({ voice: input.voice });
|
|
1857
|
+
});
|
|
1858
|
+
/**
|
|
1859
|
+
* A brand voice read from the texts already on the site, to start from: nothing is saved, the
|
|
1860
|
+
* panel puts it in the form to check.
|
|
1861
|
+
*/
|
|
1862
|
+
app.post('/api/voice/suggest', requireUser, requireAdmin, async (c) => {
|
|
1863
|
+
if (!allowAi(c.get('user').id))
|
|
1864
|
+
throw new ApiError(429, 'too_many_requests', 'Troppe richieste: aspetta un minuto');
|
|
1865
|
+
const ai = await readyAi();
|
|
1866
|
+
const config = ctx.config();
|
|
1867
|
+
const texts = [];
|
|
1868
|
+
let size = 0;
|
|
1869
|
+
for (const def of config.collections) {
|
|
1870
|
+
for (const doc of await content.published(def.name, config.locales.default)) {
|
|
1871
|
+
for (const slot of documentSlots(def.fields, doc.publishedData ?? {})) {
|
|
1872
|
+
const t = slot.text.replace(/<[^>]+>/g, '').trim();
|
|
1873
|
+
if (t.length < 20 || size > 14_000)
|
|
1874
|
+
continue;
|
|
1875
|
+
texts.push(t);
|
|
1876
|
+
size += t.length;
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
if (size < 300)
|
|
1881
|
+
throw new ApiError(400, 'too_little_text', 'Il sito ha ancora pochi testi pubblicati: scrivi la voce a mano');
|
|
1882
|
+
const reply = await complete(ai, {
|
|
1883
|
+
system: 'You describe the voice of a brand from the texts of its website, for a style guide the brand will reuse. Reply with JSON only, no code fences.',
|
|
1884
|
+
prompt: `Read these texts of the website "${config.site.name}" and describe how it speaks, writing in the language of the texts.
|
|
1885
|
+
Reply with this JSON object:
|
|
1886
|
+
{"about": "who the brand is and who it writes for, one or two sentences",
|
|
1887
|
+
"tones": [up to three of ${JSON.stringify(VOICE_TONES)}],
|
|
1888
|
+
"address": "tu" | "voi" | "lei" | null (how readers are addressed),
|
|
1889
|
+
"prefer": "words and phrases typical of these texts, comma separated",
|
|
1890
|
+
"avoid": "words that would sound wrong for this brand, comma separated",
|
|
1891
|
+
"rules": "two or three rules of style seen in the texts, as short sentences",
|
|
1892
|
+
"example": "a short paragraph of new text in this voice, about the brand"}
|
|
1893
|
+
|
|
1894
|
+
<texts>
|
|
1895
|
+
${texts.join('\n---\n')}
|
|
1896
|
+
</texts>`,
|
|
1897
|
+
}).catch(aiFailed);
|
|
1898
|
+
let raw = null;
|
|
1899
|
+
try {
|
|
1900
|
+
raw = JSON.parse(reply.replace(/^\s*```(?:json)?|```\s*$/g, '').trim());
|
|
1901
|
+
}
|
|
1902
|
+
catch { }
|
|
1903
|
+
const parsed = voiceSchema.safeParse(raw && {
|
|
1904
|
+
...raw,
|
|
1905
|
+
tones: Array.isArray(raw.tones)
|
|
1906
|
+
? raw.tones.filter((t) => VOICE_TONES.includes(t)).slice(0, 3)
|
|
1907
|
+
: [],
|
|
1908
|
+
address: ['tu', 'voi', 'lei'].includes(raw.address) ? raw.address : null,
|
|
1909
|
+
});
|
|
1910
|
+
if (!parsed.success)
|
|
1911
|
+
throw new ApiError(422, 'ai_failed', 'L’AI non ha risposto come doveva: riprova');
|
|
1912
|
+
return c.json({ voice: parsed.data, texts: texts.length });
|
|
1913
|
+
});
|
|
1914
|
+
// ---------- AI review before publishing ----------
|
|
1915
|
+
const REVIEW_KINDS = ['spelling', 'grammar', 'voice', 'consistency', 'link', 'clarity'];
|
|
1916
|
+
/** The site's pages by address, with their titles: what a link on the page points to. */
|
|
1917
|
+
const pageTitles = async () => {
|
|
1918
|
+
const out = new Map();
|
|
1919
|
+
const config = ctx.config();
|
|
1920
|
+
for (const def of config.collections) {
|
|
1921
|
+
if (!def.url)
|
|
1922
|
+
continue;
|
|
1923
|
+
for (const doc of await content.published(def.name)) {
|
|
1924
|
+
const url = publicUrl(def, doc.locale, doc.publishedData ?? {});
|
|
1925
|
+
if (url && out.size < 200)
|
|
1926
|
+
out.set(url, doc.title);
|
|
1927
|
+
}
|
|
1928
|
+
}
|
|
1929
|
+
return out;
|
|
1930
|
+
};
|
|
1931
|
+
/**
|
|
1932
|
+
* The AI reads the draft as it is now (saved or not) and lists what to fix before publishing:
|
|
1933
|
+
* typos, grammar, sentences out of the brand voice, facts that contradict each other, links whose
|
|
1934
|
+
* words promise something else than where they go, unclear sentences. Each with the exact words
|
|
1935
|
+
* and a replacement. Nothing is changed.
|
|
1936
|
+
*/
|
|
1937
|
+
app.post('/api/documents/:id/review', requireUser, async (c) => {
|
|
1938
|
+
const existing = await loadDocument(c.req.param('id'));
|
|
1939
|
+
const input = await body(c, z.object({ data: z.record(z.string(), z.unknown()).optional() }));
|
|
1940
|
+
if (!allowAi(c.get('user').id))
|
|
1941
|
+
throw new ApiError(429, 'too_many_requests', 'Troppe richieste: aspetta un minuto');
|
|
1942
|
+
const ai = await readyAi();
|
|
1943
|
+
const def = collection(existing.collection);
|
|
1944
|
+
const slots = documentSlots(def.fields, structuredClone(input.data ?? existing.data));
|
|
1945
|
+
if (!slots.length)
|
|
1946
|
+
throw new ApiError(400, 'empty_text', 'Non c’è testo da rivedere');
|
|
1947
|
+
const titles = await pageTitles();
|
|
1948
|
+
const linkTo = (href) => {
|
|
1949
|
+
const path = href.split('#')[0] || href;
|
|
1950
|
+
const title = titles.get(path.replace(/\/+$/, '') || '/');
|
|
1951
|
+
return title ? `${href} (page "${title}")` : href;
|
|
1952
|
+
};
|
|
1953
|
+
let size = 0;
|
|
1954
|
+
const listed = slots.flatMap((s, i) => {
|
|
1955
|
+
if (size > 30_000)
|
|
1956
|
+
return [];
|
|
1957
|
+
size += s.text.length;
|
|
1958
|
+
const links = s.links?.length ? `\n links: ${s.links.map((l, n) => `${n + 1} → ${linkTo(l)}`).join('; ')}` : '';
|
|
1959
|
+
return [`[${i}] ${s.text}${links}`];
|
|
1960
|
+
});
|
|
1961
|
+
const voice = await voiceText('full');
|
|
1962
|
+
const reply = await complete(ai, {
|
|
1963
|
+
system: `${voice ? `${voice}\n\n` : ''}You review a web page before it is published, as a careful editor. Reply with JSON only, no code fences.`,
|
|
1964
|
+
prompt: `These are the texts of the page "${existing.title}", numbered. Tags like <b>, <i> and <a1>…</a1> mark formatting and links; "links" says where each numbered link goes.
|
|
1965
|
+
|
|
1966
|
+
Find what really needs fixing before publishing, and nothing else:
|
|
1967
|
+
- spelling: typos and misspelled words
|
|
1968
|
+
- grammar: grammar, agreement, punctuation
|
|
1969
|
+
- voice: sentences clearly out of the brand voice above (skip if there is none)
|
|
1970
|
+
- consistency: facts on the page that contradict each other (prices, dates, names, numbers)
|
|
1971
|
+
- link: link words that promise something different from the page they go to
|
|
1972
|
+
- clarity: a sentence a reader would not understand
|
|
1973
|
+
|
|
1974
|
+
Reply with {"issues": [{"slot": number, "kind": one of ${JSON.stringify(REVIEW_KINDS)}, "find": "the exact words to change, copied from that text with its tags", "replace": "the corrected words, or \\"\\" when only a person can fix it", "note": "why, in one short sentence, in the language of the page"}]}.
|
|
1975
|
+
At most 25 issues, the most important first. An empty list is a fine answer when the page is good. Do not report style preferences, the length of texts, or things that are fine.
|
|
1976
|
+
|
|
1977
|
+
<texts>
|
|
1978
|
+
${listed.join('\n')}
|
|
1979
|
+
</texts>`,
|
|
1980
|
+
}).catch(aiFailed);
|
|
1981
|
+
let raw;
|
|
1982
|
+
try {
|
|
1983
|
+
raw = JSON.parse(reply.replace(/^\s*```(?:json)?|```\s*$/g, '').trim());
|
|
1984
|
+
}
|
|
1985
|
+
catch {
|
|
1986
|
+
throw new ApiError(422, 'ai_failed', 'L’AI non ha risposto come doveva: riprova');
|
|
1987
|
+
}
|
|
1988
|
+
const issue = z.object({
|
|
1989
|
+
slot: z.number().int().min(0),
|
|
1990
|
+
kind: z.enum(REVIEW_KINDS),
|
|
1991
|
+
find: z.string().min(1).max(2000),
|
|
1992
|
+
replace: z.string().max(4000),
|
|
1993
|
+
note: z.string().max(500),
|
|
1994
|
+
});
|
|
1995
|
+
const items = Array.isArray(raw?.issues) ? raw.issues : [];
|
|
1996
|
+
const issues = items.flatMap((it) => {
|
|
1997
|
+
const parsed = issue.safeParse(it);
|
|
1998
|
+
if (!parsed.success)
|
|
1999
|
+
return [];
|
|
2000
|
+
const slot = slots[parsed.data.slot];
|
|
2001
|
+
// Only what is really in the text: a quote the model made up cannot be applied.
|
|
2002
|
+
if (!slot?.text.includes(parsed.data.find))
|
|
2003
|
+
return [];
|
|
2004
|
+
return [{ ...parsed.data, node: slot.node ?? null, context: slot.text.replace(/<[^>]+>/g, '').slice(0, 160) }];
|
|
2005
|
+
});
|
|
2006
|
+
await record(c, 'document.reviewed', existing.title, {
|
|
2007
|
+
collection: existing.collection,
|
|
2008
|
+
id: existing.id,
|
|
2009
|
+
issues: issues.length,
|
|
2010
|
+
});
|
|
2011
|
+
return c.json({ issues: issues.slice(0, 25) });
|
|
2012
|
+
});
|
|
2013
|
+
/**
|
|
2014
|
+
* Applies one suggestion of the review to the draft as the editor holds it, and gives it back:
|
|
2015
|
+
* the editor takes it as one change, which ⌘Z undoes. Nothing is saved here.
|
|
2016
|
+
*/
|
|
2017
|
+
app.post('/api/documents/:id/review/apply', requireUser, async (c) => {
|
|
2018
|
+
const existing = await loadDocument(c.req.param('id'));
|
|
2019
|
+
const input = await body(c, z.object({
|
|
2020
|
+
data: z.record(z.string(), z.unknown()),
|
|
2021
|
+
slot: z.number().int().min(0),
|
|
2022
|
+
find: z.string().min(1).max(2000),
|
|
2023
|
+
replace: z.string().max(4000),
|
|
2024
|
+
}));
|
|
2025
|
+
const data = structuredClone(input.data);
|
|
2026
|
+
const slots = documentSlots(collection(existing.collection).fields, data);
|
|
2027
|
+
// The same text, or the first one that still has those words (the page may have changed since).
|
|
2028
|
+
const slot = slots[input.slot]?.text.includes(input.find)
|
|
2029
|
+
? slots[input.slot]
|
|
2030
|
+
: slots.find((s) => s.text.includes(input.find));
|
|
2031
|
+
if (!slot)
|
|
2032
|
+
throw new ApiError(409, 'changed', 'Quel testo è cambiato: rifai la revisione');
|
|
2033
|
+
slot.set(slot.text.replace(input.find, input.replace));
|
|
2034
|
+
return c.json({ data });
|
|
2035
|
+
});
|
|
1829
2036
|
const TEXT_ACTIONS = {
|
|
1830
2037
|
rewrite: 'Rewrite it so it reads better: same meaning, similar length.',
|
|
1831
2038
|
shorten: 'Make it shorter, keeping what matters most.',
|
|
@@ -1854,8 +2061,12 @@ export function createApp(base) {
|
|
|
1854
2061
|
if (!allowAi(c.get('user').id))
|
|
1855
2062
|
throw new ApiError(429, 'too_many_requests', 'Troppe richieste: aspetta un minuto');
|
|
1856
2063
|
const ai = await readyAi();
|
|
2064
|
+
// Writing from scratch takes the whole voice; changing a text, only its style.
|
|
2065
|
+
const voice = await voiceText(['seo_title', 'seo_description', 'custom'].includes(input.action) ? 'full' : 'style');
|
|
1857
2066
|
const lang = input.locale ?? ctx.config().locales.default;
|
|
1858
|
-
const system = `You edit texts for a website. Reply with the resulting text only: no quotes, no preamble, no notes. Keep the language of the text${input.action === 'translate' ? ' unless asked to translate' : ''}; when writing from scratch, write in the language with code "${lang}". Keep line breaks where they make sense; never add Markdown
|
|
2067
|
+
const system = `You edit texts for a website. Reply with the resulting text only: no quotes, no preamble, no notes. Keep the language of the text${input.action === 'translate' ? ' unless asked to translate' : ''}; when writing from scratch, write in the language with code "${lang}". Keep line breaks where they make sense; never add Markdown.${
|
|
2068
|
+
// Spelling fixes and alternative texts describe: they do not speak with the brand's voice.
|
|
2069
|
+
input.action === 'fix' || input.action === 'alt' || !voice ? '' : `\n\n${voice}`}`;
|
|
1859
2070
|
if (input.action === 'alt') {
|
|
1860
2071
|
const key = /^\/media\/([^/?#@]+)/.exec(input.src ?? '')?.[1];
|
|
1861
2072
|
const file = key ? await files.open(decodeURIComponent(key), { width: 1024, format: 'jpg' }) : null;
|
|
@@ -1948,8 +2159,12 @@ export function createApp(base) {
|
|
|
1948
2159
|
const chatId = input.chatId ?? crypto.randomUUID();
|
|
1949
2160
|
const key = `${user.id}:${chatId}`;
|
|
1950
2161
|
const entry = chats.get(key);
|
|
1951
|
-
const
|
|
1952
|
-
|
|
2162
|
+
const same = !!entry && entry.conv.provider === ai.provider;
|
|
2163
|
+
const conv = same && entry ? entry.conv : { provider: ai.provider, messages: [] };
|
|
2164
|
+
// Fixed when the conversation starts (its instructions may not change): a voice changed
|
|
2165
|
+
// meanwhile applies to the next conversation.
|
|
2166
|
+
const voice = same && entry?.voice !== undefined ? entry.voice : await voiceText();
|
|
2167
|
+
chats.set(key, { conv, at: now, voice });
|
|
1953
2168
|
const before = input.nodes;
|
|
1954
2169
|
const page = { nodes: before };
|
|
1955
2170
|
const auth = {};
|
|
@@ -1964,7 +2179,7 @@ export function createApp(base) {
|
|
|
1964
2179
|
// The instructions stay the same for the whole conversation: the model's earlier reasoning is
|
|
1965
2180
|
// tied to them (a change is refused) and they are read from the prompt cache. What changes
|
|
1966
2181
|
// from turn to turn, the page and what is selected, goes with each message instead.
|
|
1967
|
-
const system = `You are the assistant inside Materialize, the visual editor of the website "${ctx.config().site.name}". The person (${user.name}) is editing a page; each of their messages starts with a <context> note on the page and on what is selected on the canvas.
|
|
2182
|
+
const system = `${voice ? `${voice}\n\n` : ''}You are the assistant inside Materialize, the visual editor of the website "${ctx.config().site.name}". The person (${user.name}) is editing a page; each of their messages starts with a <context> note on the page and on what is selected on the canvas.
|
|
1968
2183
|
Use the tools to look at the page (get_page, get_node) and change it (edit_page). Your changes go into the editor, where the person sees them at once and can undo them; they are not published.
|
|
1969
2184
|
Read the page before changing it. Make the changes asked, consistent with the design already there (theme colors and text styles, spacing, the look of nearby sections); prefer the section library for new sections. Keep ids of existing nodes.
|
|
1970
2185
|
${limited ? 'This person may change only contents (texts, links, images, alt texts), not the layout or the design: do not add, remove or move nodes, and do not change styles.\n' : ''}Pictures the person attaches are references (a site they like, a mockup, a sketch): take their layout, hierarchy and mood, and build it with this site's sections, theme and texts; never copy their brand, logos or texts, and do not put the pictures on the page.
|
|
@@ -1982,7 +2197,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
1982
2197
|
tools,
|
|
1983
2198
|
onEvent: (e) => void send(e),
|
|
1984
2199
|
});
|
|
1985
|
-
chats.set(key, { conv, at: Date.now() });
|
|
2200
|
+
chats.set(key, { conv, at: Date.now(), voice });
|
|
1986
2201
|
if (page.nodes !== before) {
|
|
1987
2202
|
if (limited && !sameDesign(before, page.nodes))
|
|
1988
2203
|
await send({
|
|
@@ -2005,6 +2220,195 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
2005
2220
|
}
|
|
2006
2221
|
});
|
|
2007
2222
|
});
|
|
2223
|
+
// A first version of the site from a few lines about the business: new drafts only, theme and
|
|
2224
|
+
// voice proposed. Admins, since the proposal touches the design of the whole site.
|
|
2225
|
+
/** Runs of the site builder, per person, kept two hours: the person may answer the AI and go on. */
|
|
2226
|
+
const briefRuns = new Map();
|
|
2227
|
+
app.post('/api/ai/site', requireUser, requireSession, requireAdmin, async (c) => {
|
|
2228
|
+
const input = await body(c, z
|
|
2229
|
+
.object({
|
|
2230
|
+
brief: z.string().trim().min(20, 'Scrivi qualche riga in più sull’attività').max(6000).optional(),
|
|
2231
|
+
/** A reply in a run already started, with its id. */
|
|
2232
|
+
runId: z.string().max(64).optional(),
|
|
2233
|
+
message: z.string().trim().min(1).max(4000).optional(),
|
|
2234
|
+
theme: z.boolean().default(true),
|
|
2235
|
+
images: z
|
|
2236
|
+
.array(z.object({
|
|
2237
|
+
mime: z.enum(['image/png', 'image/jpeg', 'image/webp']),
|
|
2238
|
+
data: z.base64().max(4_000_000),
|
|
2239
|
+
}))
|
|
2240
|
+
.max(2)
|
|
2241
|
+
.default([]),
|
|
2242
|
+
})
|
|
2243
|
+
.refine((v) => (v.runId ? !!v.message : !!v.brief), { message: 'Scrivi il brief' }));
|
|
2244
|
+
const user = c.get('user');
|
|
2245
|
+
if (!allowAi(user.id))
|
|
2246
|
+
throw new ApiError(429, 'too_many_requests', 'Troppe richieste: aspetta un minuto');
|
|
2247
|
+
const ai = await readyAi();
|
|
2248
|
+
const now = Date.now();
|
|
2249
|
+
for (const [k, v] of briefRuns)
|
|
2250
|
+
if (now - v.at > 2 * 60 * 60 * 1000)
|
|
2251
|
+
briefRuns.delete(k);
|
|
2252
|
+
const runId = input.runId ?? crypto.randomUUID();
|
|
2253
|
+
const key = `${user.id}:${runId}`;
|
|
2254
|
+
let run = input.runId ? briefRuns.get(key) : undefined;
|
|
2255
|
+
if (input.runId && (!run || run.conv.provider !== ai.provider))
|
|
2256
|
+
throw new ApiError(409, 'run_expired', 'Questa conversazione non c’è più: parti da un nuovo brief');
|
|
2257
|
+
if (!run) {
|
|
2258
|
+
const config = ctx.config();
|
|
2259
|
+
const pageCollections = config.collections.filter((d) => !d.singleton && Object.values(d.fields).some((f) => f.kind === 'page'));
|
|
2260
|
+
if (!pageCollections.length)
|
|
2261
|
+
throw new ApiError(400, 'no_pages', 'Il sito non ha una raccolta di pagine costruite sulla tela');
|
|
2262
|
+
const locale = config.locales.default;
|
|
2263
|
+
const existing = [];
|
|
2264
|
+
for (const d of config.collections.filter((x) => !x.singleton)) {
|
|
2265
|
+
const { items } = await content.list(d.name, { locale, limit: 100 });
|
|
2266
|
+
for (const i of items)
|
|
2267
|
+
existing.push(`${d.name}: "${i.title}"${i.slug ? ` (slug ${i.slug})` : ''}`);
|
|
2268
|
+
}
|
|
2269
|
+
// Fixed for the whole run, like the assistant's: the model's reasoning is tied to them.
|
|
2270
|
+
const voice = await voiceText();
|
|
2271
|
+
const askVoice = !voice;
|
|
2272
|
+
run = {
|
|
2273
|
+
conv: { provider: ai.provider, messages: [] },
|
|
2274
|
+
system: briefSystem({
|
|
2275
|
+
site: config.site.name,
|
|
2276
|
+
language: languageName(locale),
|
|
2277
|
+
collections: pageCollections.map((d) => `${d.name} (${d.label ?? d.name})`).join(', '),
|
|
2278
|
+
existing: existing.length ? existing.join('; ') : 'none: the site is empty',
|
|
2279
|
+
voice,
|
|
2280
|
+
askVoice,
|
|
2281
|
+
theme: input.theme,
|
|
2282
|
+
}),
|
|
2283
|
+
result: { created: [], theme: null, voice: null },
|
|
2284
|
+
askVoice,
|
|
2285
|
+
theme: input.theme,
|
|
2286
|
+
at: now,
|
|
2287
|
+
};
|
|
2288
|
+
}
|
|
2289
|
+
const current = run;
|
|
2290
|
+
current.at = now;
|
|
2291
|
+
briefRuns.set(key, current);
|
|
2292
|
+
const auth = {};
|
|
2293
|
+
const authorization = c.req.header('authorization');
|
|
2294
|
+
const cookie = c.req.header('cookie');
|
|
2295
|
+
if (authorization)
|
|
2296
|
+
auth.authorization = authorization;
|
|
2297
|
+
else if (cookie)
|
|
2298
|
+
auth.cookie = cookie;
|
|
2299
|
+
return streamSSE(c, async (stream) => {
|
|
2300
|
+
const send = (data) => stream.writeSSE({ data: JSON.stringify(data) });
|
|
2301
|
+
const stop = new AbortController();
|
|
2302
|
+
stream.onAbort(() => stop.abort());
|
|
2303
|
+
const { tools, result } = briefTools(apiCaller(app.fetch, auth), {
|
|
2304
|
+
askVoice: current.askVoice,
|
|
2305
|
+
result: current.result,
|
|
2306
|
+
onCreated: (doc) => void send({ type: 'created', doc }),
|
|
2307
|
+
});
|
|
2308
|
+
const before = result.created.length;
|
|
2309
|
+
try {
|
|
2310
|
+
const reply = await runTurn(ai, current.conv, {
|
|
2311
|
+
system: current.system,
|
|
2312
|
+
text: input.runId ? (input.message ?? '') : `<brief>\n${input.brief}\n</brief>`,
|
|
2313
|
+
images: input.images,
|
|
2314
|
+
tools: current.theme ? tools : tools.filter((t) => t.name !== 'propose_theme'),
|
|
2315
|
+
onEvent: (e) => void send(e),
|
|
2316
|
+
maxSteps: 80,
|
|
2317
|
+
signal: stop.signal,
|
|
2318
|
+
});
|
|
2319
|
+
if (result.created.length > before)
|
|
2320
|
+
await record(c, 'site.generated', '', { pages: result.created.length - before });
|
|
2321
|
+
await send({ type: 'result', ...result });
|
|
2322
|
+
await send({ type: 'text', text: reply });
|
|
2323
|
+
await send({ type: 'done', runId });
|
|
2324
|
+
}
|
|
2325
|
+
catch (error) {
|
|
2326
|
+
if (!(error instanceof AiError))
|
|
2327
|
+
console.error(error);
|
|
2328
|
+
// What was made before the failure stays, as drafts: the panel lists it. The run ends.
|
|
2329
|
+
briefRuns.delete(key);
|
|
2330
|
+
await send({ type: 'result', ...result });
|
|
2331
|
+
await send({ type: 'error', message: error instanceof Error ? error.message : 'Errore' });
|
|
2332
|
+
await send({ type: 'done' });
|
|
2333
|
+
}
|
|
2334
|
+
});
|
|
2335
|
+
});
|
|
2336
|
+
/** Requests from the dashboard, per person, kept two hours so the person can answer and go on. */
|
|
2337
|
+
const studioRuns = new Map();
|
|
2338
|
+
app.post('/api/ai/studio', requireUser, async (c) => {
|
|
2339
|
+
const input = await body(c, z.object({
|
|
2340
|
+
runId: z.string().max(64).optional(),
|
|
2341
|
+
message: z.string().trim().min(1).max(6000),
|
|
2342
|
+
images: z
|
|
2343
|
+
.array(z.object({
|
|
2344
|
+
mime: z.enum(['image/png', 'image/jpeg', 'image/webp']),
|
|
2345
|
+
data: z.base64().max(4_000_000),
|
|
2346
|
+
}))
|
|
2347
|
+
.max(4)
|
|
2348
|
+
.default([]),
|
|
2349
|
+
}));
|
|
2350
|
+
const user = c.get('user');
|
|
2351
|
+
if (!allowAi(user.id))
|
|
2352
|
+
throw new ApiError(429, 'too_many_requests', 'Troppe richieste: aspetta un minuto');
|
|
2353
|
+
const ai = await readyAi();
|
|
2354
|
+
const now = Date.now();
|
|
2355
|
+
for (const [k, v] of studioRuns)
|
|
2356
|
+
if (now - v.at > 2 * 60 * 60 * 1000)
|
|
2357
|
+
studioRuns.delete(k);
|
|
2358
|
+
const runId = input.runId ?? crypto.randomUUID();
|
|
2359
|
+
const key = `${user.id}:${runId}`;
|
|
2360
|
+
const found = input.runId ? studioRuns.get(key) : undefined;
|
|
2361
|
+
const run = found && found.conv.provider === ai.provider
|
|
2362
|
+
? found
|
|
2363
|
+
: {
|
|
2364
|
+
conv: { provider: ai.provider, messages: [] },
|
|
2365
|
+
// Fixed for the whole conversation: the model's reasoning is tied to it.
|
|
2366
|
+
system: studioSystem({
|
|
2367
|
+
site: ctx.config().site.name,
|
|
2368
|
+
user: user.name,
|
|
2369
|
+
voice: await voiceText(),
|
|
2370
|
+
limited: await contentOnly(user),
|
|
2371
|
+
}),
|
|
2372
|
+
at: now,
|
|
2373
|
+
};
|
|
2374
|
+
run.at = now;
|
|
2375
|
+
studioRuns.set(key, run);
|
|
2376
|
+
const credentials = credentialsOf(c);
|
|
2377
|
+
return streamSSE(c, async (stream) => {
|
|
2378
|
+
const send = (data) => stream.writeSSE({ data: JSON.stringify(data) });
|
|
2379
|
+
const stop = new AbortController();
|
|
2380
|
+
stream.onAbort(() => stop.abort());
|
|
2381
|
+
const seen = new Set();
|
|
2382
|
+
const tools = studioTools(apiCaller(app.fetch, credentials), (doc) => {
|
|
2383
|
+
if (seen.has(doc.id))
|
|
2384
|
+
return;
|
|
2385
|
+
seen.add(doc.id);
|
|
2386
|
+
void send({ type: 'touched', doc });
|
|
2387
|
+
});
|
|
2388
|
+
try {
|
|
2389
|
+
const reply = await runTurn(ai, run.conv, {
|
|
2390
|
+
system: run.system,
|
|
2391
|
+
text: input.message,
|
|
2392
|
+
images: input.images,
|
|
2393
|
+
tools,
|
|
2394
|
+
onEvent: (e) => void send(e),
|
|
2395
|
+
maxSteps: 40,
|
|
2396
|
+
signal: stop.signal,
|
|
2397
|
+
});
|
|
2398
|
+
if (seen.size)
|
|
2399
|
+
await record(c, 'site.ai_request', '', { documents: seen.size });
|
|
2400
|
+
await send({ type: 'text', text: reply });
|
|
2401
|
+
await send({ type: 'done', runId });
|
|
2402
|
+
}
|
|
2403
|
+
catch (error) {
|
|
2404
|
+
if (!(error instanceof AiError))
|
|
2405
|
+
console.error(error);
|
|
2406
|
+
studioRuns.delete(key);
|
|
2407
|
+
await send({ type: 'error', message: error instanceof Error ? error.message : 'Errore' });
|
|
2408
|
+
await send({ type: 'done' });
|
|
2409
|
+
}
|
|
2410
|
+
});
|
|
2411
|
+
});
|
|
2008
2412
|
// ---------- forms ----------
|
|
2009
2413
|
/**
|
|
2010
2414
|
* Every form on the published site, by id, as the server checks it: canvas pages with their
|
|
@@ -2563,6 +2967,17 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
2563
2967
|
});
|
|
2564
2968
|
return c.json(await toJson(existing));
|
|
2565
2969
|
});
|
|
2970
|
+
/** Why a document's draft cannot go on the site now, in words; null when it can. */
|
|
2971
|
+
const publishProblem = (doc) => {
|
|
2972
|
+
const def = ctx.config().collections.find((x) => x.name === doc.collection);
|
|
2973
|
+
if (!def)
|
|
2974
|
+
return 'La raccolta non esiste più';
|
|
2975
|
+
const check = validate(def, prepare(def, doc.data));
|
|
2976
|
+
if (check.ok)
|
|
2977
|
+
return null;
|
|
2978
|
+
const fields = [...new Set(check.issues.map((i) => def.fields[i.path.split('.')[0] ?? '']?.label ?? i.path))];
|
|
2979
|
+
return `Alcuni campi vanno completati${fields.length ? `: ${fields.join(', ')}` : ''}`;
|
|
2980
|
+
};
|
|
2566
2981
|
/**
|
|
2567
2982
|
* Runs the actions whose time has come, as the person who set them. A server that was off at the
|
|
2568
2983
|
* time catches up when it starts. A publication whose fields are no longer complete fails with a
|
|
@@ -2593,14 +3008,9 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
2593
3008
|
}
|
|
2594
3009
|
try {
|
|
2595
3010
|
if (item.action === 'publish') {
|
|
2596
|
-
const
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
const fields = def && check && !check.ok
|
|
2600
|
-
? [...new Set(check.issues.map((i) => def.fields[i.path.split('.')[0] ?? '']?.label ?? i.path))].join(', ')
|
|
2601
|
-
: '';
|
|
2602
|
-
throw new Error(def ? `Alcuni campi vanno completati${fields ? `: ${fields}` : ''}` : 'La raccolta non esiste più');
|
|
2603
|
-
}
|
|
3011
|
+
const problem = publishProblem(existing);
|
|
3012
|
+
if (problem)
|
|
3013
|
+
throw new Error(problem);
|
|
2604
3014
|
const { doc, url } = await publishDocument(existing, item.createdBy ?? undefined);
|
|
2605
3015
|
await schedules.finish(item.id, 'done');
|
|
2606
3016
|
await note('document.published', doc.title, { collection: doc.collection, id: doc.id, url });
|
|
@@ -2623,6 +3033,11 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
2623
3033
|
});
|
|
2624
3034
|
}
|
|
2625
3035
|
}
|
|
3036
|
+
for (const release of await releases.due(now)) {
|
|
3037
|
+
const by = release.scheduledBy ? { id: release.scheduledBy, name: release.scheduledByName } : null;
|
|
3038
|
+
if (await releaseOut(release, by))
|
|
3039
|
+
ran++;
|
|
3040
|
+
}
|
|
2626
3041
|
}
|
|
2627
3042
|
catch (error) {
|
|
2628
3043
|
console.error('Materialize: scheduled publishing failed', error);
|
|
@@ -2648,6 +3063,178 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
2648
3063
|
}),
|
|
2649
3064
|
});
|
|
2650
3065
|
});
|
|
3066
|
+
/** What stops a release from going out: every document must be ready, or none goes. */
|
|
3067
|
+
const releaseProblems = async (release) => {
|
|
3068
|
+
const out = [];
|
|
3069
|
+
for (const item of release.items) {
|
|
3070
|
+
const doc = await content.get(item.documentId);
|
|
3071
|
+
if (!doc)
|
|
3072
|
+
out.push({ documentId: item.documentId, title: '', message: 'La pagina non esiste più' });
|
|
3073
|
+
else if (item.action === 'publish') {
|
|
3074
|
+
const message = publishProblem(doc);
|
|
3075
|
+
if (message)
|
|
3076
|
+
out.push({ documentId: doc.id, title: doc.title, message });
|
|
3077
|
+
}
|
|
3078
|
+
}
|
|
3079
|
+
if (!release.items.length)
|
|
3080
|
+
out.push({ documentId: '', title: '', message: 'Il rilascio è vuoto' });
|
|
3081
|
+
return out;
|
|
3082
|
+
};
|
|
3083
|
+
/**
|
|
3084
|
+
* Puts a release on the site: checked first, all or nothing. Returns whether it went out; when
|
|
3085
|
+
* it did not, the release is marked failed with the reason, and the panel shows it.
|
|
3086
|
+
*/
|
|
3087
|
+
const releaseOut = async (release, by) => {
|
|
3088
|
+
const note = (action, target, detail) => activityLog
|
|
3089
|
+
.record({ userId: by?.id ?? null, userName: by?.name ?? '', action, target, detail, ip: null })
|
|
3090
|
+
.catch((error) => console.error('Materialize: activity not recorded', error));
|
|
3091
|
+
const problems = await releaseProblems(release);
|
|
3092
|
+
if (problems.length) {
|
|
3093
|
+
const message = problems.map((p) => (p.title ? `${p.title}: ${p.message}` : p.message)).join('; ');
|
|
3094
|
+
await releases.finish(release.id, 'failed', message);
|
|
3095
|
+
await note('release.failed', release.name, { id: release.id, message });
|
|
3096
|
+
return false;
|
|
3097
|
+
}
|
|
3098
|
+
const done = [];
|
|
3099
|
+
try {
|
|
3100
|
+
for (const item of release.items) {
|
|
3101
|
+
const doc = await content.get(item.documentId);
|
|
3102
|
+
if (!doc)
|
|
3103
|
+
continue;
|
|
3104
|
+
if (item.action === 'publish') {
|
|
3105
|
+
await publishDocument(doc, by?.id);
|
|
3106
|
+
await schedules.cancel(doc.id, 'publish');
|
|
3107
|
+
}
|
|
3108
|
+
else if (doc.publishedAt) {
|
|
3109
|
+
await unpublishDocument(doc, by?.id);
|
|
3110
|
+
await schedules.cancel(doc.id, 'unpublish');
|
|
3111
|
+
}
|
|
3112
|
+
done.push(doc.title);
|
|
3113
|
+
}
|
|
3114
|
+
}
|
|
3115
|
+
catch (error) {
|
|
3116
|
+
const why = error instanceof Error && error.message ? error.message : 'Errore imprevisto';
|
|
3117
|
+
const message = `${why}${done.length ? ` (già uscite: ${done.join(', ')})` : ''}`;
|
|
3118
|
+
await releases.finish(release.id, 'failed', message);
|
|
3119
|
+
await note('release.failed', release.name, { id: release.id, message });
|
|
3120
|
+
return false;
|
|
3121
|
+
}
|
|
3122
|
+
await releases.finish(release.id, 'published');
|
|
3123
|
+
await note('release.published', release.name, { id: release.id, items: release.items.length });
|
|
3124
|
+
return true;
|
|
3125
|
+
};
|
|
3126
|
+
/** A release as the panel shows it: each document with its title, state and what stops it. */
|
|
3127
|
+
const releaseJson = async (release) => {
|
|
3128
|
+
const problems = release.status === 'published' ? [] : await releaseProblems(release);
|
|
3129
|
+
const items = [];
|
|
3130
|
+
for (const item of release.items) {
|
|
3131
|
+
const doc = await content.get(item.documentId);
|
|
3132
|
+
if (!doc)
|
|
3133
|
+
continue;
|
|
3134
|
+
items.push({
|
|
3135
|
+
...item,
|
|
3136
|
+
title: doc.title,
|
|
3137
|
+
collection: doc.collection,
|
|
3138
|
+
locale: doc.locale,
|
|
3139
|
+
status: doc.status,
|
|
3140
|
+
problem: problems.find((p) => p.documentId === doc.id)?.message ?? null,
|
|
3141
|
+
});
|
|
3142
|
+
}
|
|
3143
|
+
return { ...release, items, ready: problems.length === 0 };
|
|
3144
|
+
};
|
|
3145
|
+
const loadRelease = async (id) => {
|
|
3146
|
+
const release = await releases.get(id);
|
|
3147
|
+
if (!release)
|
|
3148
|
+
throw new ApiError(404, 'not_found', 'Questo rilascio non esiste');
|
|
3149
|
+
return release;
|
|
3150
|
+
};
|
|
3151
|
+
const openRelease = async (id) => {
|
|
3152
|
+
const release = await loadRelease(id);
|
|
3153
|
+
if (release.status === 'published' || release.status === 'cancelled')
|
|
3154
|
+
throw new ApiError(409, 'release_closed', 'Questo rilascio è già uscito o è stato annullato');
|
|
3155
|
+
return release;
|
|
3156
|
+
};
|
|
3157
|
+
app.get('/api/releases', requireUser, async (c) => {
|
|
3158
|
+
const { active, done } = await releases.list();
|
|
3159
|
+
return c.json({
|
|
3160
|
+
releases: await Promise.all(active.map(releaseJson)),
|
|
3161
|
+
published: await Promise.all(done.map(releaseJson)),
|
|
3162
|
+
});
|
|
3163
|
+
});
|
|
3164
|
+
app.post('/api/releases', requireUser, async (c) => {
|
|
3165
|
+
const input = await body(c, z.object({ name: z.string().trim().min(1).max(120) }));
|
|
3166
|
+
const release = await releases.create(input.name, c.get('user'));
|
|
3167
|
+
await record(c, 'release.created', release.name, { id: release.id });
|
|
3168
|
+
return c.json(await releaseJson(release), 201);
|
|
3169
|
+
});
|
|
3170
|
+
app.get('/api/releases/:id', requireUser, async (c) => c.json(await releaseJson(await loadRelease(c.req.param('id')))));
|
|
3171
|
+
app.patch('/api/releases/:id', requireUser, async (c) => {
|
|
3172
|
+
const release = await openRelease(c.req.param('id'));
|
|
3173
|
+
const input = await body(c, z.object({ name: z.string().trim().min(1).max(120) }));
|
|
3174
|
+
await releases.rename(release.id, input.name);
|
|
3175
|
+
return c.json(await releaseJson(await loadRelease(release.id)));
|
|
3176
|
+
});
|
|
3177
|
+
/** Cancels a release: its documents stay as they are. */
|
|
3178
|
+
app.delete('/api/releases/:id', requireUser, async (c) => {
|
|
3179
|
+
const release = await openRelease(c.req.param('id'));
|
|
3180
|
+
await releases.finish(release.id, 'cancelled');
|
|
3181
|
+
await record(c, 'release.cancelled', release.name, { id: release.id });
|
|
3182
|
+
return c.body(null, 204);
|
|
3183
|
+
});
|
|
3184
|
+
/** Adds a document (or changes what happens to it): it leaves any other release. */
|
|
3185
|
+
app.put('/api/releases/:id/items/:documentId', requireUser, async (c) => {
|
|
3186
|
+
const release = await openRelease(c.req.param('id'));
|
|
3187
|
+
const doc = await loadDocument(c.req.param('documentId'));
|
|
3188
|
+
const input = await body(c, z.object({ action: z.enum(['publish', 'unpublish']).default('publish') }));
|
|
3189
|
+
if (input.action === 'unpublish' && !doc.publishedAt)
|
|
3190
|
+
throw new ApiError(409, 'not_published', 'La pagina non è sul sito: non c’è niente da togliere');
|
|
3191
|
+
await releases.put(release.id, doc.id, input.action);
|
|
3192
|
+
await record(c, 'release.item_added', doc.title, { id: release.id, release: release.name, action: input.action });
|
|
3193
|
+
return c.json(await releaseJson(await loadRelease(release.id)));
|
|
3194
|
+
});
|
|
3195
|
+
app.delete('/api/releases/:id/items/:documentId', requireUser, async (c) => {
|
|
3196
|
+
const release = await openRelease(c.req.param('id'));
|
|
3197
|
+
await releases.remove(release.id, c.req.param('documentId'));
|
|
3198
|
+
return c.json(await releaseJson(await loadRelease(release.id)));
|
|
3199
|
+
});
|
|
3200
|
+
/** Everything in the release goes on the site now, or nothing does (the reasons come back). */
|
|
3201
|
+
app.post('/api/releases/:id/publish', requireUser, async (c) => {
|
|
3202
|
+
const release = await openRelease(c.req.param('id'));
|
|
3203
|
+
const problems = await releaseProblems(release);
|
|
3204
|
+
if (problems.length)
|
|
3205
|
+
return c.json({
|
|
3206
|
+
error: {
|
|
3207
|
+
code: 'release_not_ready',
|
|
3208
|
+
message: 'Alcune pagine non sono pronte: non è uscito niente',
|
|
3209
|
+
problems,
|
|
3210
|
+
},
|
|
3211
|
+
}, 422);
|
|
3212
|
+
const user = c.get('user');
|
|
3213
|
+
const ok = await releaseOut(release, user);
|
|
3214
|
+
const after = await loadRelease(release.id);
|
|
3215
|
+
if (!ok)
|
|
3216
|
+
throw new ApiError(422, 'release_failed', after.error ?? 'Il rilascio non è uscito');
|
|
3217
|
+
return c.json(await releaseJson(after));
|
|
3218
|
+
});
|
|
3219
|
+
/** When the release goes out by itself; at null: only by hand. Checked now, and again at that moment. */
|
|
3220
|
+
app.put('/api/releases/:id/schedule', requireUser, async (c) => {
|
|
3221
|
+
const release = await openRelease(c.req.param('id'));
|
|
3222
|
+
const input = await body(c, z.object({ at: z.iso.datetime({ offset: true }).nullable() }));
|
|
3223
|
+
const user = c.get('user');
|
|
3224
|
+
if (input.at === null) {
|
|
3225
|
+
await releases.schedule(release.id, null, null);
|
|
3226
|
+
await record(c, 'release.schedule_cancelled', release.name, { id: release.id });
|
|
3227
|
+
return c.json(await releaseJson(await loadRelease(release.id)));
|
|
3228
|
+
}
|
|
3229
|
+
const at = new Date(input.at);
|
|
3230
|
+
if (at.getTime() <= Date.now() + 30_000)
|
|
3231
|
+
throw new ApiError(400, 'past_time', 'Scegli un momento nel futuro, almeno tra un minuto');
|
|
3232
|
+
if (!release.items.length)
|
|
3233
|
+
throw new ApiError(400, 'empty_release', 'Aggiungi prima qualcosa al rilascio');
|
|
3234
|
+
await releases.schedule(release.id, at, user);
|
|
3235
|
+
await record(c, 'release.scheduled', release.name, { id: release.id, at: at.toISOString() });
|
|
3236
|
+
return c.json(await releaseJson(await loadRelease(release.id)));
|
|
3237
|
+
});
|
|
2651
3238
|
// ---------- shared preview links ----------
|
|
2652
3239
|
const previewJson = (c, link) => {
|
|
2653
3240
|
const path = `/_materialize/share/${link.token}`;
|
|
@@ -2692,6 +3279,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
2692
3279
|
const existing = await loadDocument(c.req.param('id'));
|
|
2693
3280
|
await content.remove(existing.id);
|
|
2694
3281
|
await schedules.removeForDocument(existing.id);
|
|
3282
|
+
await releases.removeDocument(existing.id);
|
|
2695
3283
|
await previewLinks.removeForDocument(existing.id);
|
|
2696
3284
|
if (existing.publishedAt)
|
|
2697
3285
|
sitePublished();
|
|
@@ -2761,7 +3349,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
2761
3349
|
slots = only(slots);
|
|
2762
3350
|
if (!slots.length)
|
|
2763
3351
|
return;
|
|
2764
|
-
const translated = await translateTexts(ai, slots.map((s) => s.text), from, to).catch(aiFailed);
|
|
3352
|
+
const translated = await translateTexts(ai, slots.map((s) => s.text), from, to, undefined, await voiceText('style')).catch(aiFailed);
|
|
2765
3353
|
for (const [i, slot] of slots.entries())
|
|
2766
3354
|
slot.set(translated[i]);
|
|
2767
3355
|
};
|
|
@@ -2838,7 +3426,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
2838
3426
|
const own = nodeSlots(walk(data[name]));
|
|
2839
3427
|
if (!own.length)
|
|
2840
3428
|
continue;
|
|
2841
|
-
const translated = await translateTexts(ai, own.map((s) => s.text), original.locale, doc.locale).catch(aiFailed);
|
|
3429
|
+
const translated = await translateTexts(ai, own.map((s) => s.text), original.locale, doc.locale, undefined, await voiceText('style')).catch(aiFailed);
|
|
2842
3430
|
for (const [i, slot] of own.entries())
|
|
2843
3431
|
slot.set(translated[i]);
|
|
2844
3432
|
}
|
|
@@ -2944,6 +3532,60 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
2944
3532
|
])),
|
|
2945
3533
|
});
|
|
2946
3534
|
});
|
|
3535
|
+
/** The caller's credentials, for tools that go through the API as them. */
|
|
3536
|
+
const credentialsOf = (c) => {
|
|
3537
|
+
const authorization = c.req.header('authorization');
|
|
3538
|
+
const cookie = c.req.header('cookie');
|
|
3539
|
+
return authorization ? { authorization } : cookie ? { cookie } : {};
|
|
3540
|
+
};
|
|
3541
|
+
/**
|
|
3542
|
+
* An imported page laid out again by the AI with the section library, in its draft. The texts
|
|
3543
|
+
* and images must stay the same (checked after): otherwise the page is left as it was.
|
|
3544
|
+
*/
|
|
3545
|
+
const relayoutPage = async (ai, id, user, credentials) => {
|
|
3546
|
+
const doc = await content.get(id);
|
|
3547
|
+
if (!doc)
|
|
3548
|
+
return 'la pagina non c’è più';
|
|
3549
|
+
const def = collection(doc.collection);
|
|
3550
|
+
const field = Object.entries(def.fields).find(([, f]) => f.kind === 'page')?.[0];
|
|
3551
|
+
const before = field && Array.isArray(doc.data[field]) ? doc.data[field] : null;
|
|
3552
|
+
if (!field || !before?.length)
|
|
3553
|
+
return 'la pagina è vuota';
|
|
3554
|
+
const page = { nodes: before };
|
|
3555
|
+
const call = apiCaller(app.fetch, credentials);
|
|
3556
|
+
const getSchema = () => call('GET', '/api/schema');
|
|
3557
|
+
const tools = [
|
|
3558
|
+
...contentTools(call, getSchema).filter((t) => ['list_library', 'get_theme'].includes(t.name)),
|
|
3559
|
+
...pageTools({
|
|
3560
|
+
withId: false,
|
|
3561
|
+
brand: async () => ctx.config().site.name,
|
|
3562
|
+
source: async () => ({
|
|
3563
|
+
load: async () => page.nodes,
|
|
3564
|
+
save: async (nodes) => {
|
|
3565
|
+
page.nodes = nodes;
|
|
3566
|
+
},
|
|
3567
|
+
}),
|
|
3568
|
+
}),
|
|
3569
|
+
];
|
|
3570
|
+
const system = `You redesign one page of the website "${ctx.config().site.name}", just imported from WordPress into Materialize. Its content arrived as a plain column of texts, images and buttons, one section per heading. Rebuild it so it looks designed, with the section library.
|
|
3571
|
+
|
|
3572
|
+
How: read the page (get_page) and the library (list_library); replace the plain sections with library sections (edit_page: add_section, then fill them; remove what you replace and the library sample nodes you do not use); read the page again at the end and fix what is left.
|
|
3573
|
+
|
|
3574
|
+
Rules:
|
|
3575
|
+
- Keep every text word for word: you may split it across sections, change its style (title, body, eyebrow) or turn a list into cards, but do not rewrite, shorten or translate it.
|
|
3576
|
+
- Keep every image (same src and alt) and every button with its link.
|
|
3577
|
+
- Add no text of your own: no new headings, slogans, labels or facts. Library sample texts and pictures you do not replace with the page's own content must be removed.
|
|
3578
|
+
- Do not add a menu or a footer: the site has its own.
|
|
3579
|
+
- Keep it simple: a clear opening, then sections that follow the page's order.
|
|
3580
|
+
When done, reply with one short sentence.`;
|
|
3581
|
+
await runTurn(ai, { provider: ai.provider, messages: [] }, { system, text: `Lay out the page "${doc.title}".`, tools, onEvent: () => { }, maxSteps: 30 });
|
|
3582
|
+
const problem = relayoutProblem(before, page.nodes);
|
|
3583
|
+
if (problem)
|
|
3584
|
+
return problem;
|
|
3585
|
+
const draft = draftData(def, { ...doc.data, [field]: restoreAlts(before, page.nodes).map(plain) });
|
|
3586
|
+
await content.update(doc.id, { ...draft, userId: user.id });
|
|
3587
|
+
return null;
|
|
3588
|
+
};
|
|
2947
3589
|
app.post('/api/import/wordpress/run', requireUser, requireSession, requireAdmin, async (c) => {
|
|
2948
3590
|
const target = z.object({ collection: z.string(), fields: z.record(z.string(), z.string()) }).nullable();
|
|
2949
3591
|
const input = await body(c, z.object({
|
|
@@ -2956,9 +3598,11 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
2956
3598
|
locale: z.string().optional(),
|
|
2957
3599
|
categories: z.record(z.string(), z.string()).optional(),
|
|
2958
3600
|
fallbackCategory: z.string().optional(),
|
|
3601
|
+
aiLayout: z.boolean().optional(),
|
|
2959
3602
|
}),
|
|
2960
3603
|
}));
|
|
2961
3604
|
const held = wpSites.get(input.token);
|
|
3605
|
+
const ai = input.plan.aiLayout ? await readyAi() : null;
|
|
2962
3606
|
if (!held)
|
|
2963
3607
|
throw new ApiError(409, 'import_expired', 'Read the site again');
|
|
2964
3608
|
const lang = locale(input.plan.locale);
|
|
@@ -2977,6 +3621,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
2977
3621
|
problems: [],
|
|
2978
3622
|
media: { downloaded: 0, failed: 0, reasons: {} },
|
|
2979
3623
|
redirects: 0,
|
|
3624
|
+
relaid: 0,
|
|
2980
3625
|
at: Date.now(),
|
|
2981
3626
|
};
|
|
2982
3627
|
wpJobs.set(id, job);
|
|
@@ -3067,6 +3712,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
3067
3712
|
return list.length - before;
|
|
3068
3713
|
},
|
|
3069
3714
|
onProgress: (p) => Object.assign(job, p),
|
|
3715
|
+
relayout: ai ? (doc) => relayoutPage(ai, doc.id, user, credentialsOf(c)) : undefined,
|
|
3070
3716
|
};
|
|
3071
3717
|
void runImport(held.site, plan, deps)
|
|
3072
3718
|
.then(async (result) => {
|
|
@@ -3078,7 +3724,12 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
3078
3724
|
userName: user.name,
|
|
3079
3725
|
action: 'import.wordpress',
|
|
3080
3726
|
target: held.site.name,
|
|
3081
|
-
detail: {
|
|
3727
|
+
detail: {
|
|
3728
|
+
created: result.created.length,
|
|
3729
|
+
media: result.media.downloaded,
|
|
3730
|
+
redirects: result.redirects,
|
|
3731
|
+
...(result.relaid ? { relaid: result.relaid } : {}),
|
|
3732
|
+
},
|
|
3082
3733
|
ip: null,
|
|
3083
3734
|
});
|
|
3084
3735
|
})
|