@materializecms/server 0.3.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 CHANGED
@@ -4,6 +4,21 @@ 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
+
7
22
  ## 0.3.0 — 2026-11-01
8
23
 
9
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@materializecms/server",
3
- "version": "0.3.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.3.0",
24
- "@materializecms/core": "0.3.0",
25
- "@materializecms/db": "0.3.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
- /** Text in, text out: a rewrite, a translation, an alt text (with an image). */
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
- /** Text in, text out: a rewrite, a translation, an alt text (with an image). */
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({ role: 'user', content: input.text });
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({ role: 'user', content: input.text });
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
- system: input.system,
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
- return await client.beta.messages.create({
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.');
package/src/app.js CHANGED
@@ -1743,11 +1743,29 @@ export function createApp(base) {
1743
1743
  // A form on the site: the message is kept, and sent by email when email is set up.
1744
1744
  // ---------- AI: settings, text actions and the editor's assistant ----------
1745
1745
  const aiSettings = async () => ({ ...EMPTY_AI, ...(await settings.get('ai')) });
1746
+ const thisMonth = () => new Date().toISOString().slice(0, 7);
1747
+ const monthUsage = async () => {
1748
+ const saved = await settings.get('ai-usage');
1749
+ const month = thisMonth();
1750
+ return saved?.month === month ? saved : { month, requests: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
1751
+ };
1752
+ // One write at a time: the steps of a turn and parallel requests all count.
1753
+ let usageWrites = Promise.resolve();
1754
+ const countUsage = (u) => {
1755
+ usageWrites = usageWrites
1756
+ .then(async () => {
1757
+ const total = await monthUsage();
1758
+ for (const k of ['requests', 'input', 'output', 'cacheRead', 'cacheWrite'])
1759
+ total[k] += u[k];
1760
+ await settings.set('ai-usage', total);
1761
+ })
1762
+ .catch((error) => console.error(error));
1763
+ };
1746
1764
  const readyAi = async () => {
1747
1765
  const ai = await aiSettings();
1748
1766
  if (!isReady(ai))
1749
1767
  throw new ApiError(400, 'ai_not_configured', 'L’AI non è configurata: Impostazioni → AI e MCP');
1750
- return ai;
1768
+ return { ...ai, onUsage: countUsage };
1751
1769
  };
1752
1770
  const aiFailed = (error) => {
1753
1771
  if (error instanceof AiError)
@@ -1757,7 +1775,7 @@ export function createApp(base) {
1757
1775
  const allowAi = createLimiter(40, 60_000);
1758
1776
  app.get('/api/ai', requireUser, async (c) => {
1759
1777
  const ai = await aiSettings();
1760
- return c.json(c.get('user').role === 'admin' ? { ai: publicAi(ai) } : { ai: { ready: isReady(ai) } });
1778
+ return c.json(c.get('user').role === 'admin' ? { ai: publicAi(ai), usage: await monthUsage() } : { ai: { ready: isReady(ai) } });
1761
1779
  });
1762
1780
  app.put('/api/ai', requireUser, requireSession, requireAdmin, async (c) => {
1763
1781
  const input = await body(c, z.object({
@@ -1767,6 +1785,7 @@ export function createApp(base) {
1767
1785
  apiKey: z.string().max(500).nullable().optional(),
1768
1786
  baseUrl: z.union([z.url().max(500), z.literal('')]),
1769
1787
  model: z.string().trim().max(120),
1788
+ fastModel: z.string().trim().max(120).default(''),
1770
1789
  }),
1771
1790
  }));
1772
1791
  const before = await aiSettings();
@@ -1778,13 +1797,16 @@ export function createApp(base) {
1778
1797
  });
1779
1798
  app.post('/api/ai/test', requireUser, requireSession, requireAdmin, async (c) => {
1780
1799
  const ai = await readyAi();
1781
- const reply = await complete(ai, { system: 'Answer with one short word.', prompt: 'Say: ok' }).catch(aiFailed);
1800
+ const probe = { system: 'Answer with one short word.', prompt: 'Say: ok' };
1801
+ // Both models: a wrong name for the quick actions shows up here, not at the first ✨.
1802
+ const reply = await complete(ai, probe).catch(aiFailed);
1803
+ await complete(ai, { ...probe, fast: true }).catch(aiFailed);
1782
1804
  return c.json({ ok: true, reply });
1783
1805
  });
1784
1806
  const TEXT_ACTIONS = {
1785
1807
  rewrite: 'Rewrite it so it reads better: same meaning, similar length.',
1786
- shorten: 'Make it shorter, keeping what matters.',
1787
- expand: 'Make it a little longer and richer, without inventing facts.',
1808
+ shorten: 'Make it shorter, keeping what matters most.',
1809
+ expand: 'Make it longer and richer, without inventing facts.',
1788
1810
  fix: 'Fix spelling, grammar and punctuation only; change nothing else.',
1789
1811
  formal: 'Make the tone more formal and professional.',
1790
1812
  friendly: 'Make the tone warmer and more conversational.',
@@ -1801,6 +1823,10 @@ export function createApp(base) {
1801
1823
  src: z.string().max(1000).optional(),
1802
1824
  /** The site's language, for texts written from scratch. */
1803
1825
  locale: z.string().max(20).optional(),
1826
+ /** rich: the text is rich text as lines of markup (richTextMarkup), and so is the reply. */
1827
+ format: z.enum(['plain', 'rich']).default('plain'),
1828
+ /** The most characters the field takes. */
1829
+ max: z.number().int().positive().max(100_000).optional(),
1804
1830
  }));
1805
1831
  if (!allowAi(c.get('user').id))
1806
1832
  throw new ApiError(429, 'too_many_requests', 'Troppe richieste: aspetta un minuto');
@@ -1813,9 +1839,14 @@ export function createApp(base) {
1813
1839
  if (!file)
1814
1840
  throw new ApiError(400, 'no_image', 'Scegli prima un’immagine della libreria');
1815
1841
  const data = (await readFile(file.path)).toString('base64');
1842
+ // The text already there may credit the photographer or the source: that part stays.
1843
+ const current = input.text.trim()
1844
+ ? `\n\nThe current alternative text is:\n<text>\n${input.text}\n</text>\nWrite in its language. If it credits a photographer, an author or a source (such as "photo by … on Unsplash"), keep that credit at the end, in the same form.`
1845
+ : '';
1816
1846
  const text = await complete(ai, {
1817
1847
  system,
1818
- prompt: `Write the alternative text (alt) for this image on a website, in the language with code "${lang}": one sentence, at most 125 characters, describing what matters for someone who cannot see it. No "image of".`,
1848
+ fast: true,
1849
+ prompt: `Write the alternative text (alt) for this image on a website, in the language with code "${lang}"${current ? ' unless said otherwise below' : ''}: one sentence, at most 125 characters (besides any credit), describing what matters for someone who cannot see it. No "image of".${current}`,
1819
1850
  image: { data, mime: 'image/jpeg' },
1820
1851
  }).catch(aiFailed);
1821
1852
  return c.json({ text });
@@ -1827,7 +1858,37 @@ export function createApp(base) {
1827
1858
  : input.action === 'custom'
1828
1859
  ? (input.instruction ?? 'Improve it.')
1829
1860
  : TEXT_ACTIONS[input.action];
1830
- const text = await complete(ai, { system, prompt: `${task}\n\n<text>\n${input.text}\n</text>` }).catch(aiFailed);
1861
+ const rich = input.format === 'rich'
1862
+ ? '\n\nThe text is markup, one block per line: <p>, <h2>, <h3>, <h4>, <blockquote>, <li>, with <b>, <i> and numbered links (<a1>…</a1>) inside, and <img1>, <img2>… for images. Reply in the same markup: you may change, merge, split or add blocks; keep each link around the matching words and every image line.'
1863
+ : '';
1864
+ // Shorter and longer by how much: as words, small models barely touch a short text or
1865
+ // multiply it. A length in characters (the words alone, without markup) they follow.
1866
+ const length = input.text.replace(/<[^>]+>/g, '').length;
1867
+ let max = input.max;
1868
+ let target = '';
1869
+ if (input.action === 'shorten') {
1870
+ target = `\n\nThe text has ${length} characters: aim for about ${Math.round(length * 0.65)}.`;
1871
+ max = Math.min(max ?? Number.POSITIVE_INFINITY, Math.round(length * 0.8));
1872
+ }
1873
+ else if (input.action === 'expand')
1874
+ target = `\n\nThe text has ${length} characters: aim for about ${Math.round(length * 1.5)}, never more than ${length * 2}.`;
1875
+ const limit = max ? `\n\nThe result must be at most ${max} characters, spaces included.` : '';
1876
+ let text = await complete(ai, {
1877
+ system,
1878
+ fast: true,
1879
+ prompt: `${task}${target}${limit}${rich}\n\n<text>\n${input.text}\n</text>`,
1880
+ }).catch(aiFailed);
1881
+ // Models count characters loosely: another pass when the result is too long, then one on the
1882
+ // main model when the quick one still does not get there (a small model may return the text as is).
1883
+ for (const fast of [true, false]) {
1884
+ if (!max || input.format !== 'plain' || text.length <= max)
1885
+ break;
1886
+ text = await complete(ai, {
1887
+ system,
1888
+ fast,
1889
+ prompt: `Shorten this text to at most ${max} characters, spaces included (it has ${text.length}), keeping its meaning and tone.\n\n<text>\n${text}\n</text>`,
1890
+ }).catch(aiFailed);
1891
+ }
1831
1892
  return c.json({ text });
1832
1893
  });
1833
1894
  /** Conversations with the assistant, per person and chat, kept for two hours in memory. */
@@ -1844,6 +1905,14 @@ export function createApp(base) {
1844
1905
  url: z.string().max(500).nullish(),
1845
1906
  }),
1846
1907
  selected: z.array(z.string().max(100)).max(50).optional(),
1908
+ /** Reference pictures (a screenshot, a mockup, a sketch), already made smaller by the panel. */
1909
+ images: z
1910
+ .array(z.object({
1911
+ mime: z.enum(['image/png', 'image/jpeg', 'image/webp']),
1912
+ data: z.base64().max(4_000_000),
1913
+ }))
1914
+ .max(4)
1915
+ .default([]),
1847
1916
  }));
1848
1917
  const user = c.get('user');
1849
1918
  if (!allowAi(user.id))
@@ -1869,17 +1938,24 @@ export function createApp(base) {
1869
1938
  auth.cookie = cookie;
1870
1939
  const tools = assistantTools(apiCaller(app.fetch, auth), page, ctx.config().site.name);
1871
1940
  const limited = await contentOnly(user);
1872
- const system = `You are the assistant inside Materialize, the visual editor of the website "${ctx.config().site.name}". The person (${user.name}) is editing the page "${input.page.title}" (collection ${input.page.collection}${input.page.url ? `, address ${input.page.url}` : ''}).
1941
+ // The instructions stay the same for the whole conversation: the model's earlier reasoning is
1942
+ // tied to them (a change is refused) and they are read from the prompt cache. What changes
1943
+ // from turn to turn, the page and what is selected, goes with each message instead.
1944
+ 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.
1873
1945
  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.
1874
1946
  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.
1875
- ${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' : ''}When done, say in one or two short sentences what you changed. Reply in the person's language. If something cannot be done with the tools, say so.
1876
- ${input.selected?.length ? `Selected on the canvas: ${input.selected.join(', ')} (what "this" refers to).` : ''}`;
1947
+ ${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.
1948
+ When done, say in one or two short sentences what you changed. Reply in the person's language. If something cannot be done with the tools, say so.`;
1949
+ const context = `<context>Page "${input.page.title}" (collection ${input.page.collection}${input.page.url ? `, address ${input.page.url}` : ''}). ${input.selected?.length
1950
+ ? `Selected on the canvas: ${input.selected.join(', ')} (what "this" refers to).`
1951
+ : 'Nothing selected on the canvas.'}</context>`;
1877
1952
  return streamSSE(c, async (stream) => {
1878
1953
  const send = (data) => stream.writeSSE({ data: JSON.stringify(data) });
1879
1954
  try {
1880
1955
  const reply = await runTurn(ai, conv, {
1881
1956
  system,
1882
- text: input.message,
1957
+ text: `${context}\n\n${input.message}`,
1958
+ images: input.images,
1883
1959
  tools,
1884
1960
  onEvent: (e) => void send(e),
1885
1961
  });