@materializecms/server 0.2.0 → 0.4.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 +23 -0
- package/LICENSE +1 -1
- package/package.json +4 -4
- package/src/ai.d.ts +35 -1
- package/src/ai.js +64 -6
- package/src/app.js +286 -17
- package/src/index.d.ts +1 -0
- package/src/index.js +1 -0
- package/src/mail.d.ts +27 -0
- package/src/mail.js +170 -0
- package/src/offsite.d.ts +52 -0
- package/src/offsite.js +230 -0
- package/src/remote-storage.d.ts +47 -0
- package/src/remote-storage.js +264 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,29 @@ Ogni versione, dalla più recente. Le novità in arrivo si scrivono sotto «Pros
|
|
|
4
4
|
|
|
5
5
|
## Prossima versione
|
|
6
6
|
|
|
7
|
+
## 0.4.0 — 2026-11-03
|
|
8
|
+
|
|
9
|
+
- **Immagini di riferimento nell'assistente**: allega, incolla (⌘V) o trascina nella chat lo screenshot di un sito che ti piace, un mockup o uno schizzo, e l'assistente ne riprende impaginazione e atmosfera con le sezioni e lo stile del tuo sito. Fino a 4 immagini per messaggio; non vanno nella libreria e non finiscono sulla pagina.
|
|
10
|
+
- **Conversazioni con l'assistente più solide**: un secondo messaggio dopo aver cambiato la selezione sulla tela non fallisce più, e ogni nuovo messaggio rilegge dalla cache tutta la conversazione precedente, così costa una frazione.
|
|
11
|
+
- **L'assistente costa meno**: con Claude usa la cache dei prompt, così istruzioni, strumenti e pagina, rimandati a ogni passaggio, si pagano a una frazione del prezzo dopo il primo.
|
|
12
|
+
- **Azioni rapide più economiche**: ✨ sui testi, SEO e testo alternativo usano Claude Haiku 5.5, molto più economico di Opus; l'assistente e le traduzioni delle pagine restano sul modello principale. Si può scegliere un altro modello in Impostazioni → AI e MCP.
|
|
13
|
+
- **Uso dell'AI del mese** in Impostazioni → AI e MCP: richieste, token letti (e quanti dalla cache) e scritti, per tenere d'occhio la spesa.
|
|
14
|
+
- **AI sul testo formattato**: il pulsante ✨ c'è anche nel corpo degli articoli, e riscrive, accorcia o traduce mantenendo titoli, elenchi, grassetti, link e immagini.
|
|
15
|
+
- **✨ sulla tela mantiene grassetti, corsivi e link** di un testo, nell'Inspector e in modalità Contenuto.
|
|
16
|
+
- **Accorcia e Allunga più affidabili**: chiedono una lunghezza precisa (circa un terzo in meno, circa la metà in più), così un testo breve non torna quasi uguale e uno da allungare non diventa sette volte più lungo.
|
|
17
|
+
- **L'AI rispetta la lunghezza dei campi**: un testo riscritto non supera più il massimo del campo (prima, per esempio, un sommario di 220 caratteri poteva arrivare a 234 e bloccare la pubblicazione).
|
|
18
|
+
- **Descrivi con l'AI conserva i crediti**: il testo alternativo nuovo mantiene il credito della foto già scritto, come «foto di … su Unsplash», e la sua lingua.
|
|
19
|
+
- **Nastri con i bordi sfumati**: le lettere che scendono sotto la riga (g, p, y) non vengono più tagliate in basso.
|
|
20
|
+
- **Indirizzi del pannello scritti a mano**: `/admin/media`, `/admin/persone` e `/admin/impostazioni` portano alla sezione giusta, se nessuna raccolta ha quel nome.
|
|
21
|
+
|
|
22
|
+
## 0.3.0 — 2026-11-01
|
|
23
|
+
|
|
24
|
+
- **Backup fuori dal server**, ogni notte e cifrati, su uno spazio S3 compatibile (Cloudflare R2, Backblaze B2, Hetzner, Amazon S3…) o in una cartella WebDAV (Nextcloud, NAS Synology e QNAP…). Si attiva in Impostazioni → Backup; `materialize decrypt` rende di nuovo leggibili i file scaricati.
|
|
25
|
+
- **Email più facile da configurare**: si sceglie il fornitore (Brevo, Postmark, Mailgun, Amazon SES, Gmail…) e il server si compila da solo; se la prova non riesce, il pannello spiega perché (porta bloccata, password rifiutata, mittente non verificato…); **Controlla il dominio** verifica SPF, DKIM e DMARC.
|
|
26
|
+
- **Pannello più chiaro**: Impostazioni → Protezione divisa in Sicurezza, Backup e Registro attività; barra dell'editor a gruppi, con gli elementi meno usati in **⋯**; pannello sinistro dell'editor allargabile; menu a tendina, intestazioni e testi d'aiuto uniformi e più leggibili; pagina dedicata per gli indirizzi del pannello che non esistono; in Persone, chi entra con la sola password si riconosce a colpo d'occhio; accesso e installazione con l'aspetto del pannello.
|
|
27
|
+
- **Chi ha fatto ogni versione**: la cronologia mostra il nome dell'autore accanto alla data.
|
|
28
|
+
- **Informazioni su Materialize** in Impostazioni: versione, autore, licenza, novità, guida e licenze di terzi.
|
|
29
|
+
|
|
7
30
|
## 0.2.0 — 2026-11-01
|
|
8
31
|
|
|
9
32
|
- **Password dimenticata?** nella pagina di accesso: un link per email, valido un'ora. Senza email configurata, il comando `materialize reset-password` stampa il link sul server, anche per l'unico amministratore.
|
package/LICENSE
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@materializecms/server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Il server di Materialize: API, accessi, media, backup, aggiornamenti dal pannello e server MCP.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -20,9 +20,9 @@
|
|
|
20
20
|
"dependencies": {
|
|
21
21
|
"@anthropic-ai/sdk": "1.3.0",
|
|
22
22
|
"@hono/node-server": "2.1.5",
|
|
23
|
-
"@materializecms/admin": "0.
|
|
24
|
-
"@materializecms/core": "0.
|
|
25
|
-
"@materializecms/db": "0.
|
|
23
|
+
"@materializecms/admin": "0.4.0",
|
|
24
|
+
"@materializecms/core": "0.4.0",
|
|
25
|
+
"@materializecms/db": "0.4.0",
|
|
26
26
|
"@modelcontextprotocol/sdk": "1.32.2",
|
|
27
27
|
"hono": "4.13.14",
|
|
28
28
|
"htmlparser2": "12.0.0",
|
package/src/ai.d.ts
CHANGED
|
@@ -11,9 +11,29 @@ export interface AiSettings {
|
|
|
11
11
|
baseUrl: string;
|
|
12
12
|
/** Empty: the default (Claude Opus 5.5 for Anthropic). */
|
|
13
13
|
model: string;
|
|
14
|
+
/**
|
|
15
|
+
* For the quick actions (✨ on a text, SEO, alternative text). Empty: Claude Haiku 5.5 for
|
|
16
|
+
* Anthropic, the main model for a compatible server.
|
|
17
|
+
*/
|
|
18
|
+
fastModel?: string;
|
|
19
|
+
/** Told the tokens of every request (never saved). */
|
|
20
|
+
onUsage?: (usage: AiUsage) => void;
|
|
21
|
+
}
|
|
22
|
+
/** The tokens of one or more requests: what the provider bills. */
|
|
23
|
+
export interface AiUsage {
|
|
24
|
+
requests: number;
|
|
25
|
+
/** Read at full price. */
|
|
26
|
+
input: number;
|
|
27
|
+
output: number;
|
|
28
|
+
/** Read from the prompt cache, at a fraction of the price. */
|
|
29
|
+
cacheRead: number;
|
|
30
|
+
/** Written to the prompt cache, at a little more than the full price. */
|
|
31
|
+
cacheWrite: number;
|
|
14
32
|
}
|
|
15
33
|
export declare const EMPTY_AI: AiSettings;
|
|
16
34
|
export declare const DEFAULT_CLAUDE = "claude-opus-5-5";
|
|
35
|
+
/** Short, one-shot texts: a fraction of Opus's price, quality enough for them. */
|
|
36
|
+
export declare const DEFAULT_CLAUDE_FAST = "claude-haiku-5-5";
|
|
17
37
|
/** Raised when the AI cannot answer: not set up, refused, or the provider failed. */
|
|
18
38
|
export declare class AiError extends Error {
|
|
19
39
|
}
|
|
@@ -22,12 +42,19 @@ export declare function publicAi(s: AiSettings | null): {
|
|
|
22
42
|
provider: 'anthropic' | 'openai';
|
|
23
43
|
baseUrl: string;
|
|
24
44
|
model: string;
|
|
45
|
+
fastModel?: string;
|
|
46
|
+
onUsage?: (usage: AiUsage) => void;
|
|
25
47
|
hasKey: boolean;
|
|
26
48
|
envKey: boolean;
|
|
27
49
|
ready: boolean;
|
|
28
50
|
};
|
|
29
51
|
/** Claude needs a key (saved, or ANTHROPIC_API_KEY); a compatible server needs an address and a model. */
|
|
30
52
|
export declare function isReady(s: AiSettings | null): s is AiSettings;
|
|
53
|
+
/** A picture sent to the model: base64, in one of the formats every provider reads. */
|
|
54
|
+
export interface AiImage {
|
|
55
|
+
mime: 'image/png' | 'image/jpeg' | 'image/webp';
|
|
56
|
+
data: string;
|
|
57
|
+
}
|
|
31
58
|
/** Progress of an assistant turn, sent to the panel as it happens. */
|
|
32
59
|
export type AiEvent = {
|
|
33
60
|
type: 'status';
|
|
@@ -45,7 +72,10 @@ export interface Conversation {
|
|
|
45
72
|
provider: AiSettings['provider'];
|
|
46
73
|
messages: unknown[];
|
|
47
74
|
}
|
|
48
|
-
/**
|
|
75
|
+
/**
|
|
76
|
+
* Text in, text out: a rewrite, a translation, an alt text (with an image). `fast`: on the model
|
|
77
|
+
* for the quick actions.
|
|
78
|
+
*/
|
|
49
79
|
export declare function complete(s: AiSettings, input: {
|
|
50
80
|
system: string;
|
|
51
81
|
prompt: string;
|
|
@@ -53,6 +83,7 @@ export declare function complete(s: AiSettings, input: {
|
|
|
53
83
|
data: string;
|
|
54
84
|
mime: string;
|
|
55
85
|
};
|
|
86
|
+
fast?: boolean;
|
|
56
87
|
}): Promise<string>;
|
|
57
88
|
/**
|
|
58
89
|
* One turn of the assistant: the person's message, then as many tool calls as the model needs
|
|
@@ -61,10 +92,13 @@ export declare function complete(s: AiSettings, input: {
|
|
|
61
92
|
export declare function runTurn(s: AiSettings, conv: Conversation, input: {
|
|
62
93
|
system: string;
|
|
63
94
|
text: string;
|
|
95
|
+
/** Pictures attached to the message: references to look at, not files for the site. */
|
|
96
|
+
images?: AiImage[];
|
|
64
97
|
tools: SiteTool[];
|
|
65
98
|
onEvent: (e: AiEvent) => void;
|
|
66
99
|
maxSteps?: number;
|
|
67
100
|
}): Promise<string>;
|
|
101
|
+
export declare const fastModelOf: (s: AiSettings) => string;
|
|
68
102
|
/**
|
|
69
103
|
* Translates a page's texts in batches, keeping their order: each batch goes out as a JSON list
|
|
70
104
|
* and must come back as a list of the same length. Marks like <b>, <i> and <a1> stay in place.
|
package/src/ai.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import Anthropic from '@anthropic-ai/sdk';
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { ToolError } from './tools.js';
|
|
4
|
-
export const EMPTY_AI = { provider: 'anthropic', apiKey: '', baseUrl: '', model: '' };
|
|
4
|
+
export const EMPTY_AI = { provider: 'anthropic', apiKey: '', baseUrl: '', model: '', fastModel: '' };
|
|
5
5
|
export const DEFAULT_CLAUDE = 'claude-opus-5-5';
|
|
6
|
+
/** Short, one-shot texts: a fraction of Opus's price, quality enough for them. */
|
|
7
|
+
export const DEFAULT_CLAUDE_FAST = 'claude-haiku-5-5';
|
|
6
8
|
/** Raised when the AI cannot answer: not set up, refused, or the provider failed. */
|
|
7
9
|
export class AiError extends Error {
|
|
8
10
|
}
|
|
@@ -18,8 +20,13 @@ export function isReady(s) {
|
|
|
18
20
|
return !!process.env.ANTHROPIC_API_KEY;
|
|
19
21
|
return s.provider === 'anthropic' ? !!(s.apiKey || process.env.ANTHROPIC_API_KEY) : !!(s.baseUrl && s.model);
|
|
20
22
|
}
|
|
21
|
-
/**
|
|
23
|
+
/**
|
|
24
|
+
* Text in, text out: a rewrite, a translation, an alt text (with an image). `fast`: on the model
|
|
25
|
+
* for the quick actions.
|
|
26
|
+
*/
|
|
22
27
|
export async function complete(s, input) {
|
|
28
|
+
if (input.fast)
|
|
29
|
+
s = { ...s, model: fastModelOf(s) };
|
|
23
30
|
if (s.provider === 'openai') {
|
|
24
31
|
const content = input.image
|
|
25
32
|
? [
|
|
@@ -54,6 +61,8 @@ export async function complete(s, input) {
|
|
|
54
61
|
},
|
|
55
62
|
],
|
|
56
63
|
});
|
|
64
|
+
if (message.stop_reason === 'refusal')
|
|
65
|
+
throw new AiError('La richiesta è stata rifiutata dal modello.');
|
|
57
66
|
return textOf(message).trim();
|
|
58
67
|
}
|
|
59
68
|
/**
|
|
@@ -61,6 +70,7 @@ export async function complete(s, input) {
|
|
|
61
70
|
* (at most `maxSteps`), then its answer. The conversation keeps everything for the next turn.
|
|
62
71
|
*/
|
|
63
72
|
export async function runTurn(s, conv, input) {
|
|
73
|
+
const images = input.images ?? [];
|
|
64
74
|
const byName = new Map(input.tools.map((t) => [t.name, t]));
|
|
65
75
|
const runTool = async (name, raw) => {
|
|
66
76
|
const t = byName.get(name);
|
|
@@ -87,7 +97,15 @@ export async function runTurn(s, conv, input) {
|
|
|
87
97
|
function: { name: t.name, description: t.description, parameters: schemaOf(t) },
|
|
88
98
|
}));
|
|
89
99
|
const messages = conv.messages;
|
|
90
|
-
messages.push({
|
|
100
|
+
messages.push({
|
|
101
|
+
role: 'user',
|
|
102
|
+
content: images.length
|
|
103
|
+
? [
|
|
104
|
+
...images.map((i) => ({ type: 'image_url', image_url: { url: `data:${i.mime};base64,${i.data}` } })),
|
|
105
|
+
{ type: 'text', text: input.text },
|
|
106
|
+
]
|
|
107
|
+
: input.text,
|
|
108
|
+
});
|
|
91
109
|
for (let step = 0; step < maxSteps; step++) {
|
|
92
110
|
const reply = await openaiChat(s, { messages: [{ role: 'system', content: input.system }, ...messages], tools });
|
|
93
111
|
messages.push(reply);
|
|
@@ -117,14 +135,31 @@ export async function runTurn(s, conv, input) {
|
|
|
117
135
|
input_schema: schemaOf(t),
|
|
118
136
|
}));
|
|
119
137
|
const messages = conv.messages;
|
|
120
|
-
messages.push({
|
|
138
|
+
messages.push({
|
|
139
|
+
role: 'user',
|
|
140
|
+
content: images.length
|
|
141
|
+
? [
|
|
142
|
+
...images.map((i) => ({
|
|
143
|
+
type: 'image',
|
|
144
|
+
source: { type: 'base64', media_type: i.mime, data: i.data },
|
|
145
|
+
})),
|
|
146
|
+
{ type: 'text', text: input.text },
|
|
147
|
+
]
|
|
148
|
+
: input.text,
|
|
149
|
+
});
|
|
150
|
+
// Each step sends the instructions, the tools and the whole conversation again (the page read
|
|
151
|
+
// with get_page included): from the cache they cost a fraction. A fixed point after the
|
|
152
|
+
// instructions and tools, and the automatic one at the end of the conversation, which moves
|
|
153
|
+
// forward step by step.
|
|
154
|
+
const system = [{ type: 'text', text: input.system, cache_control: { type: 'ephemeral' } }];
|
|
121
155
|
for (let step = 0; step < maxSteps; step++) {
|
|
122
156
|
const message = await claude(s, {
|
|
123
157
|
max_tokens: 16000,
|
|
124
158
|
output_config: { effort: 'medium' },
|
|
125
159
|
// Progress notes between tool calls, so the panel can say what is happening.
|
|
126
160
|
...(updatesSupported(s) ? { thinking: { type: 'adaptive', display: 'updates' } } : {}),
|
|
127
|
-
|
|
161
|
+
cache_control: { type: 'ephemeral' },
|
|
162
|
+
system,
|
|
128
163
|
tools,
|
|
129
164
|
messages,
|
|
130
165
|
});
|
|
@@ -151,6 +186,7 @@ export async function runTurn(s, conv, input) {
|
|
|
151
186
|
/** Models that take the server-side fallback on a refusal and progress notes between tool calls. */
|
|
152
187
|
const CURRENT = /^claude-(opus-5-5|opus-5|sonnet-5-5|fable-5-1)$/;
|
|
153
188
|
const modelOf = (s) => s.model.trim() || DEFAULT_CLAUDE;
|
|
189
|
+
export const fastModelOf = (s) => s.fastModel?.trim() || (s.provider === 'anthropic' ? DEFAULT_CLAUDE_FAST : s.model.trim());
|
|
154
190
|
const updatesSupported = (s) => /^claude-(opus-5-5|sonnet-5-5|fable-5-1|fable-5)$/.test(modelOf(s));
|
|
155
191
|
async function claude(s, params) {
|
|
156
192
|
const client = new Anthropic({ apiKey: s.apiKey || undefined, baseURL: s.baseUrl || undefined });
|
|
@@ -163,12 +199,20 @@ async function claude(s, params) {
|
|
|
163
199
|
if ('thinking' in params && params.thinking?.display === 'updates')
|
|
164
200
|
betas.push('thinking-display-updates-2026-08-18');
|
|
165
201
|
try {
|
|
166
|
-
|
|
202
|
+
const message = await client.beta.messages.create({
|
|
167
203
|
...params,
|
|
168
204
|
model,
|
|
169
205
|
...(betas.length ? { betas } : {}),
|
|
170
206
|
...(fallback ? { fallbacks: 'default' } : {}),
|
|
171
207
|
});
|
|
208
|
+
s.onUsage?.({
|
|
209
|
+
requests: 1,
|
|
210
|
+
input: message.usage.input_tokens,
|
|
211
|
+
output: message.usage.output_tokens,
|
|
212
|
+
cacheRead: message.usage.cache_read_input_tokens ?? 0,
|
|
213
|
+
cacheWrite: message.usage.cache_creation_input_tokens ?? 0,
|
|
214
|
+
});
|
|
215
|
+
return message;
|
|
172
216
|
}
|
|
173
217
|
catch (error) {
|
|
174
218
|
if (error instanceof Anthropic.AuthenticationError)
|
|
@@ -179,6 +223,9 @@ async function claude(s, params) {
|
|
|
179
223
|
throw new AiError(`Il modello “${model}” non esiste.`);
|
|
180
224
|
if (error instanceof Anthropic.RateLimitError)
|
|
181
225
|
throw new AiError('Troppe richieste ad Anthropic: riprova tra poco.');
|
|
226
|
+
// The reasoning of earlier turns no longer fits the conversation (it was changed underneath).
|
|
227
|
+
if (error instanceof Anthropic.BadRequestError && /thinking/i.test(error.message))
|
|
228
|
+
throw new AiError('Questa conversazione non può continuare: iniziane una nuova con ↺ in alto.');
|
|
182
229
|
if (error instanceof Anthropic.BadRequestError)
|
|
183
230
|
throw new AiError(`Richiesta non valida: ${error.message}`);
|
|
184
231
|
if (error instanceof Anthropic.APIError)
|
|
@@ -214,6 +261,17 @@ async function openaiChat(s, body) {
|
|
|
214
261
|
const json = (await res.json().catch(() => null));
|
|
215
262
|
if (!res.ok)
|
|
216
263
|
throw new AiError(json?.error?.message ?? `Il server AI ha risposto ${res.status}.`);
|
|
264
|
+
if (json?.usage) {
|
|
265
|
+
// Cached tokens are part of the prompt tokens here: the rest is the full-price part.
|
|
266
|
+
const cached = json.usage.prompt_tokens_details?.cached_tokens ?? 0;
|
|
267
|
+
s.onUsage?.({
|
|
268
|
+
requests: 1,
|
|
269
|
+
input: (json.usage.prompt_tokens ?? 0) - cached,
|
|
270
|
+
output: json.usage.completion_tokens ?? 0,
|
|
271
|
+
cacheRead: cached,
|
|
272
|
+
cacheWrite: 0,
|
|
273
|
+
});
|
|
274
|
+
}
|
|
217
275
|
const message = json?.choices?.[0]?.message;
|
|
218
276
|
if (!message)
|
|
219
277
|
throw new AiError('Il server AI ha dato una risposta vuota.');
|