slotdeck 0.1.0 → 0.1.2

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.
Files changed (51) hide show
  1. package/README.md +16 -1
  2. package/dist/deck/validate.js +12 -5
  3. package/dist/deck/validate.js.map +1 -1
  4. package/dist/index.d.ts +1 -0
  5. package/dist/index.js +1 -0
  6. package/dist/index.js.map +1 -1
  7. package/dist/llm/gemini.d.ts +4 -0
  8. package/dist/llm/gemini.js +43 -7
  9. package/dist/llm/gemini.js.map +1 -1
  10. package/dist/llm/provider.d.ts +12 -0
  11. package/dist/plan/catalog.d.ts +11 -1
  12. package/dist/plan/catalog.js +20 -7
  13. package/dist/plan/catalog.js.map +1 -1
  14. package/dist/plan/edit.js +9 -5
  15. package/dist/plan/edit.js.map +1 -1
  16. package/dist/plan/plan.d.ts +16 -2
  17. package/dist/plan/plan.js +50 -17
  18. package/dist/plan/plan.js.map +1 -1
  19. package/dist/plan/write.d.ts +8 -1
  20. package/dist/plan/write.js +29 -3
  21. package/dist/plan/write.js.map +1 -1
  22. package/dist/render/render.js +4 -4
  23. package/dist/render/render.js.map +1 -1
  24. package/dist/template/change.d.ts +1 -0
  25. package/dist/template/change.js +12 -0
  26. package/dist/template/change.js.map +1 -1
  27. package/dist/template/instruct.js +4 -2
  28. package/dist/template/instruct.js.map +1 -1
  29. package/dist/template/manifest.d.ts +16 -0
  30. package/dist/template/manifest.js +26 -0
  31. package/dist/template/manifest.js.map +1 -1
  32. package/dist/template/sample.js +4 -3
  33. package/dist/template/sample.js.map +1 -1
  34. package/dist/tools.js +6 -0
  35. package/dist/tools.js.map +1 -1
  36. package/docs/README.md +77 -0
  37. package/docs/api-reference.md +680 -0
  38. package/docs/changelog.md +107 -0
  39. package/docs/chat-tools.md +257 -0
  40. package/docs/concepts.md +126 -0
  41. package/docs/deck-format.md +225 -0
  42. package/docs/generating.md +254 -0
  43. package/docs/getting-started.md +139 -0
  44. package/docs/languages.md +83 -0
  45. package/docs/layout-freedom.md +136 -0
  46. package/docs/manifest-format.md +208 -0
  47. package/docs/providers.md +281 -0
  48. package/docs/rendering.md +172 -0
  49. package/docs/template-studio.md +379 -0
  50. package/docs/troubleshooting.md +174 -0
  51. package/package.json +3 -2
@@ -0,0 +1,281 @@
1
+ # Model providers
2
+
3
+ slotdeck does not know any model service. It defines two small interfaces, and you pass objects that implement
4
+ them. This is how it works with a public API, a cloud account, or a gateway inside a network with no internet
5
+ access.
6
+
7
+ ```ts
8
+ import type { LlmProvider, JsonRequest, JsonSchema, ImageProvider, ImageRequest } from 'slotdeck';
9
+ ```
10
+
11
+ ## `LlmProvider`: the text model
12
+
13
+ ```ts
14
+ interface LlmProvider {
15
+ /** Resolves with the parsed JSON value. */
16
+ generateJson(request: JsonRequest): Promise<unknown>;
17
+ }
18
+
19
+ interface JsonRequest {
20
+ system: string; // the system prompt: the role and the rules
21
+ prompt: string; // the user message: the brief, the manifest's patterns, what to answer
22
+ schema: JsonSchema; // the JSON Schema the answer must follow
23
+ maxOutputTokens?: number; // a ceiling on the answer's size in tokens, reasoning not counted; see "Runaway answers"
24
+ effort?: 'low'; // set on short structural requests: use light reasoning if your model has a setting for it
25
+ images?: Array<{ mimeType: string; data: Uint8Array }>; // pictures to look at; only draftManifest sends them
26
+ signal?: AbortSignal; // pass it to your HTTP call so work can be cancelled
27
+ }
28
+ ```
29
+
30
+ The contract:
31
+
32
+ - Resolve with the **parsed** value (an object), not a string of JSON.
33
+ - The value should follow `schema`. slotdeck checks what it gets back against the template's manifest and asks
34
+ again when content breaks a limit, but it expects the overall shape the schema describes.
35
+ - Reject (throw) when the call fails. The planner does not retry failed calls; retries belong in the provider,
36
+ which knows which failures pass by themselves (see "Runaway answers" and the Gemini adapter below).
37
+ - One call is one request and one answer. There is no conversation state: every request carries everything it
38
+ needs.
39
+
40
+ ### What the schemas contain
41
+
42
+ Only a small, widely supported subset of JSON Schema:
43
+
44
+ - `type`: `object`, `array`, `string`, `integer`, `number`, `boolean`
45
+ - `properties`, `required`, `items`, `enum`, `description`
46
+ - in a few places `minItems`, `maxItems`, `minimum`, `maximum`
47
+
48
+ There is no `anyOf`, `oneOf`, `$ref`, `pattern` or `format`. If your backend rejects the length or range
49
+ keywords, your provider can delete them from the schema before sending it: the result is checked by code in any
50
+ case.
51
+
52
+ ### A provider for a backend with structured output
53
+
54
+ Most hosted models and gateways accept a JSON Schema and return JSON that follows it. The adapter is then a
55
+ single HTTP call. The request and response fields below are placeholders: use the ones your gateway documents.
56
+
57
+ ```ts
58
+ import type { LlmProvider } from 'slotdeck';
59
+
60
+ export function gatewayModel(options: { url: string; model: string; token: () => Promise<string> }): LlmProvider {
61
+ return {
62
+ async generateJson({ system, prompt, schema, signal }) {
63
+ const response = await fetch(options.url, {
64
+ method: 'POST',
65
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${await options.token()}` },
66
+ body: JSON.stringify({
67
+ model: options.model,
68
+ messages: [
69
+ { role: 'system', content: system },
70
+ { role: 'user', content: prompt },
71
+ ],
72
+ // However your gateway asks for JSON that follows a schema:
73
+ response_format: { type: 'json_schema', json_schema: { name: 'answer', schema } },
74
+ }),
75
+ signal,
76
+ });
77
+ if (!response.ok) throw new Error(`Model gateway answered ${response.status}: ${(await response.text()).slice(0, 300)}`);
78
+ const body = await response.json();
79
+ const text = body.choices[0].message.content; // wherever your gateway puts the answer text
80
+ return JSON.parse(text);
81
+ },
82
+ };
83
+ }
84
+ ```
85
+
86
+ ### A provider for a model without structured output
87
+
88
+ Put the schema into the prompt and parse what comes back. Models often wrap JSON in a code fence or add a
89
+ sentence around it, so extract the JSON before parsing.
90
+
91
+ ```ts
92
+ export function plainTextModel(complete: (system: string, prompt: string, signal?: AbortSignal) => Promise<string>): LlmProvider {
93
+ return {
94
+ async generateJson({ system, prompt, schema, signal }) {
95
+ const instruction =
96
+ `${prompt}\n\nAnswer with one JSON value that follows this JSON Schema, and nothing else:\n${JSON.stringify(schema)}`;
97
+ const text = await complete(system, instruction, signal);
98
+ const start = text.indexOf('{');
99
+ const end = text.lastIndexOf('}');
100
+ if (start < 0 || end <= start) throw new Error(`The model did not answer with JSON: ${text.slice(0, 200)}`);
101
+ return JSON.parse(text.slice(start, end + 1));
102
+ },
103
+ };
104
+ }
105
+ ```
106
+
107
+ Every top-level answer slotdeck asks for is a JSON object, so looking for the outermost braces is safe.
108
+
109
+ ### Choosing a model
110
+
111
+ - The requests are small. Measured on the demo template (15 patterns): the outline request is a system prompt
112
+ of about 600 characters, a prompt of about 3,600 characters and a schema of about 700 characters. The writing
113
+ request for a 15-slide deck is a prompt of about 1,500 characters and a schema of about 9,800 characters. Any
114
+ current general-purpose model handles this.
115
+ - Writing a deck is a single call that returns all slides, so the model needs enough output room for the whole
116
+ deck. The JSON of a nine-slide deck is about 6 KB.
117
+ - `editManifest` sends the whole manifest with the measured shapes of every pattern's slide: about 58,000
118
+ characters for a template with 24 patterns.
119
+ - `draftManifest` can send a picture of each slide (`images`). A provider for a model that cannot see may ignore
120
+ them; the draft then relies on measurements alone.
121
+
122
+ ### Runaway answers: always set an output limit
123
+
124
+ Now and then a model runs away inside one text field of its answer: it keeps adding sentence fragments to a
125
+ single string until something stops it. This was seen with a planning request whose proper answer is about 200
126
+ tokens: the model wrote more than 27,000 tokens into one field. Without a limit such a call takes minutes, costs
127
+ tens of thousands of output tokens, and ends in a timeout. It is a matter of chance: the same request usually
128
+ works the next time.
129
+
130
+ It was tied to reasoning. With the model's default reasoning, a request for a text-only plan ran away in 6 of
131
+ 24 calls. With light reasoning it did not run away in 18 calls, and each took 4 seconds instead of about 35.
132
+ That is why planning requests carry `effort: 'low'`.
133
+
134
+ So a provider should do three things:
135
+
136
+ 1. **Use light reasoning when a request says `effort: 'low'`**, if your model has such a setting.
137
+ 2. **Limit the output of every call.** The planner passes `maxOutputTokens` with its requests: a generous
138
+ ceiling for that answer (for an outline of six slides, 1,360 tokens). Pass it to your model as its output
139
+ limit. If your model counts reasoning tokens against the same limit, add room for them. When a request
140
+ carries no estimate, use a default of your own rather than no limit.
141
+ 3. **Ask once more when an answer is cut off at the limit.** A cut-off answer is not valid JSON. Retry the same
142
+ request once, and throw if it is cut off again.
143
+
144
+ ```ts
145
+ async generateJson({ system, prompt, schema, maxOutputTokens, effort, signal }) {
146
+ const limit = (maxOutputTokens ?? 16_000) + 8_000; // room for reasoning, if your model counts it here
147
+ const reasoning = effort === 'low' ? 'low' : undefined; // whatever your model calls its light setting
148
+ for (let attempt = 1; attempt <= 2; attempt++) {
149
+ const answer = await callModel({ system, prompt, schema, limit, reasoning, signal });
150
+ if (!answer.cutOffAtLimit) return JSON.parse(answer.text);
151
+ }
152
+ throw new Error(`The model's answer was cut off at ${limit} tokens twice`);
153
+ }
154
+ ```
155
+
156
+ The included Gemini adapter does all three. Its limit is the request's estimate plus 8,192 tokens for reasoning
157
+ (24,576 when there is no estimate, or its own `maxOutputTokens` option when set). It sends a cut-off request
158
+ once more. It uses thinking level `low` for requests with `effort: 'low'`, unless `thinkingLevel` is set on the
159
+ adapter, which then applies to every request. It also sends a request once more after a server error (500 to
160
+ 504) or when the service's recitation filter blocks an ordinary answer, which happens at random; a bad key, a
161
+ quota or a bad request is not repeated.
162
+
163
+ ### Timeouts and cancellation
164
+
165
+ slotdeck passes `signal` through when the caller gave one. Your provider should pass it to its HTTP call, and
166
+ should have a time limit of its own: a model call can hang without failing.
167
+
168
+ ```ts
169
+ const limit = AbortSignal.timeout(180_000);
170
+ const combined = signal ? AbortSignal.any([signal, limit]) : limit;
171
+ await fetch(url, { ...init, signal: combined });
172
+ ```
173
+
174
+ ### Logging and auditing
175
+
176
+ A provider is the one place every model call passes through, so it is where to count calls, log prompts or
177
+ enforce a budget:
178
+
179
+ ```ts
180
+ const counted: LlmProvider = {
181
+ async generateJson(request) {
182
+ const started = Date.now();
183
+ try {
184
+ return await inner.generateJson(request);
185
+ } finally {
186
+ log({ promptChars: request.prompt.length, ms: Date.now() - started });
187
+ }
188
+ },
189
+ };
190
+ ```
191
+
192
+ ## `ImageProvider`: the picture source
193
+
194
+ ```ts
195
+ interface ImageProvider {
196
+ /** Resolves with PNG, JPEG or GIF bytes. */
197
+ generateImage(request: ImageRequest): Promise<Uint8Array>;
198
+ }
199
+
200
+ interface ImageRequest {
201
+ prompt: string; // a description of the picture, written by the planning model
202
+ kind: 'photo' | 'icon' | 'graphic'; // what the slot holds; generated pictures are photos
203
+ aspectRatio: number; // width divided by height of the frame the picture goes into
204
+ signal?: AbortSignal;
205
+ }
206
+ ```
207
+
208
+ It does not have to be an image model. A stock library, an internal picture search or a fixed set of brand
209
+ photos all fit: return the bytes of the picture that best matches `prompt`.
210
+
211
+ ```ts
212
+ const brandPhotos: ImageProvider = {
213
+ async generateImage({ prompt, aspectRatio }) {
214
+ const hit = await photoLibrary.search(prompt, { orientation: aspectRatio >= 1 ? 'landscape' : 'portrait' });
215
+ return new Uint8Array(await hit.download());
216
+ },
217
+ };
218
+ ```
219
+
220
+ The picture is cropped to the frame when the slot's fit is `cover`, so any proportions work; matching
221
+ `aspectRatio` just loses less of the picture.
222
+
223
+ Without an `ImageProvider`, leave `images` out. `generateDeck` and the chat tools then plan decks with no
224
+ generated photos (patterns that need a photo are not offered), unless you supply photos as `assets`.
225
+
226
+ ## The included Gemini adapters
227
+
228
+ ```ts
229
+ import { gemini, geminiImages, vertexEndpoint, GeminiError, GEMINI_DEVELOPER_ENDPOINT } from 'slotdeck';
230
+
231
+ const llm = gemini({ model: 'gemini-3.8-flash', apiKey });
232
+ const images = geminiImages({ model: 'gemini-3.1-flash-image', apiKey });
233
+ ```
234
+
235
+ They call Google's Interactions API with plain `fetch`, so they need network access to that API. Inside a
236
+ network without it, write a provider for your own gateway as shown above.
237
+
238
+ Options of both:
239
+
240
+ | Option | Meaning |
241
+ | --- | --- |
242
+ | `model` | Model id. Required. |
243
+ | `apiKey` | Gemini Developer API key, sent as the `x-goog-api-key` header. |
244
+ | `endpoint` | Interactions endpoint. Defaults to `GEMINI_DEVELOPER_ENDPOINT`. Use `vertexEndpoint(project, location?)` for Vertex AI, or the address of a proxy. |
245
+ | `headers` | Extra headers, or a function returning them (may be async), for example a fresh `Authorization: Bearer` token. |
246
+ | `fetch` | A custom `fetch`, for proxies and tests. |
247
+ | `timeoutMs` | Gives up on a request after this long. Default 180000 (3 minutes). `0` waits for ever. |
248
+
249
+ Only for `gemini`: `thinkingLevel` (`minimal`, `low`, `medium`, `high`) and `maxOutputTokens`, the output limit
250
+ of a request with reasoning included. By default the limit is the request's own estimate plus 8,192 tokens, or
251
+ 24,576 when the request has none.
252
+
253
+ Only for `geminiImages`: `imageSize` (`512`, `1K`, `2K`, `4K`; default `1K`) and `style`, a sentence appended to
254
+ every prompt, such as a house photo style.
255
+
256
+ Failures throw `GeminiError`, which has the HTTP `status` when there was one.
257
+
258
+ Vertex AI:
259
+
260
+ ```ts
261
+ const llm = gemini({
262
+ model: 'gemini-3.8-flash',
263
+ endpoint: vertexEndpoint('my-project'), // location defaults to 'global'
264
+ headers: async () => ({ Authorization: `Bearer ${await getAccessToken()}` }),
265
+ });
266
+ ```
267
+
268
+ The adapters were run live against the Gemini Developer API. The Vertex endpoint is supported by the adapter
269
+ but has not been run live.
270
+
271
+ ## Testing without a model
272
+
273
+ A scripted provider makes tests deterministic and free:
274
+
275
+ ```ts
276
+ const scripted: LlmProvider = {
277
+ async generateJson({ prompt, schema }) {
278
+ return cannedAnswers.shift(); // objects shaped like the schema
279
+ },
280
+ };
281
+ ```
@@ -0,0 +1,172 @@
1
+ # Rendering
2
+
3
+ Rendering turns a deck (JSON) into a `.pptx` using a template. It uses no model and no network, and it is
4
+ deterministic: the same template, deck and assets give the same bytes.
5
+
6
+ ```ts
7
+ import { loadTemplate, renderDeck, validateDeck, extractAssets, DeckValidationError } from 'slotdeck';
8
+ ```
9
+
10
+ ## `loadTemplate(pptx, manifest)`
11
+
12
+ ```ts
13
+ function loadTemplate(pptx: Uint8Array, manifest: TemplateManifest): Template;
14
+ ```
15
+
16
+ Unzips the template once. The result is `{ pkg, manifest }`. Keep it and reuse it for every render; rendering
17
+ does not change it. Loading reads the whole file, so do it once at start-up rather than per request. A render
18
+ is fast: a 15-slide deck from the demo template takes about 100 ms.
19
+
20
+ It does not verify the manifest. Call `checkManifest(template)` when a template or manifest is loaded if you
21
+ want to catch a manifest that no longer matches its file (see [template-studio.md](template-studio.md)).
22
+
23
+ ## `renderDeck(template, deck, options?)`
24
+
25
+ ```ts
26
+ function renderDeck(template: Template, deck: DeckSpec, options?: RenderOptions): RenderResult;
27
+
28
+ interface RenderOptions {
29
+ assets?: Record<string, Uint8Array>; // picture bytes by asset id
30
+ layout?: 'strict' | 'adaptive'; // default 'strict'
31
+ }
32
+
33
+ interface RenderResult {
34
+ pptx: Uint8Array;
35
+ warnings: Issue[]; // non-blocking findings, such as text over a slot's budget
36
+ }
37
+ ```
38
+
39
+ What it does, for each slide of the deck:
40
+
41
+ 1. Clones the template slide of the slide's pattern, with its layout, background and every shape.
42
+ 2. For a pattern with a row or grid of items, removes or copies items to match `items` and spaces them out.
43
+ 3. Fills each slot: text into text shapes, pictures into picture frames, rows into tables, a native chart in
44
+ place of a chart slot's sample picture.
45
+ 4. Removes shapes whose slot is left out (optional slots in `adaptive` layout, and slots with `empty: "drop"`).
46
+ 5. Sets the page number where the template has one.
47
+
48
+ Then it writes a new package that contains only what the new slides use: template slides, pictures and other
49
+ parts that are not referenced any more are left out, so the output is not bloated by the template's sample
50
+ content. Speaker notes, named sections and revision history of the template are not carried over, because they
51
+ describe the template's own slides.
52
+
53
+ ### Assets
54
+
55
+ Every `ImageRef` in the deck names an asset id. `options.assets` must have bytes under that id, or the deck is
56
+ invalid (`asset "x" was not provided`). The same bytes may be used by several slides; they are stored in the
57
+ file once.
58
+
59
+ ```ts
60
+ renderDeck(template, deck, {
61
+ assets: {
62
+ clock: clockPngBytes,
63
+ 'photo-commute': photoJpegBytes,
64
+ 'gen-68db256a51fa6c83': generatedPhotoBytes,
65
+ },
66
+ });
67
+ ```
68
+
69
+ When a deck was made by the planner, `resolveAssets` or `generateDeck` builds this map for you
70
+ (see [generating.md](generating.md)).
71
+
72
+ ### Errors
73
+
74
+ `renderDeck` validates first. If the deck has any error-level issue it throws:
75
+
76
+ ```ts
77
+ try {
78
+ renderDeck(template, deck, { assets });
79
+ } catch (error) {
80
+ if (error instanceof DeckValidationError) {
81
+ for (const issue of error.issues) console.log(issue.severity, issue.slide, issue.slot, issue.message);
82
+ } else {
83
+ throw error; // the manifest does not match the template, or an image format is not supported
84
+ }
85
+ }
86
+ ```
87
+
88
+ Other exceptions mean the manifest and the pptx disagree (for example
89
+ `Pattern "x": slot "y" points at shape #12, which is not on template slide 3`). `checkManifest` finds these
90
+ ahead of time.
91
+
92
+ ## `validateDeck(manifest, deck, options?)`
93
+
94
+ ```ts
95
+ function validateDeck(manifest: TemplateManifest, deck: DeckSpec, options?: ValidateOptions): Issue[];
96
+
97
+ interface ValidateOptions {
98
+ layout?: 'strict' | 'adaptive';
99
+ assets?: Iterable<string>; // ids that will be available at render time; when given, image references are checked
100
+ }
101
+
102
+ interface Issue {
103
+ severity: 'error' | 'warning';
104
+ slide?: number; // 1-based; absent for deck-level issues
105
+ slot?: string;
106
+ message: string;
107
+ }
108
+ ```
109
+
110
+ It takes the manifest, not the loaded template, so it can run where the pptx is not available (in a browser,
111
+ or before a job is queued). It uses exactly the same rules as the renderer.
112
+
113
+ - **Errors** block rendering: unknown pattern, a required slot without a value, a value of the wrong kind, a
114
+ table whose rows do not match its header, a chart whose series do not match its categories, an item count
115
+ outside the pattern's range, an asset that was not provided.
116
+ - **Warnings** do not: text, tables or charts over a slot's soft limits; a slot name the pattern does not have;
117
+ `items` on a pattern with a fixed number of items; an unknown chart `format`.
118
+
119
+ All messages are listed in [troubleshooting.md](troubleshooting.md).
120
+
121
+ ## `extractAssets(template, deck, pptx)`
122
+
123
+ ```ts
124
+ function extractAssets(template: Template, deck: DeckSpec, pptx: Uint8Array): Record<string, Uint8Array>;
125
+ ```
126
+
127
+ Reads the pictures of a deck back out of a pptx that was rendered from that deck, keyed by asset id. With it,
128
+ the deck JSON and its pptx are all you have to store between edits: generated photos are read back instead of
129
+ being generated, and paid for, again.
130
+
131
+ ```ts
132
+ const cache = new Map(Object.entries(extractAssets(template, deck, previousPptx)));
133
+ const edited = await editDeck(template, deck, 'Shorten slide 3', { llm, images, cache });
134
+ ```
135
+
136
+ Only slides that still match the deck are used. If the file was changed by hand (slides added, removed or
137
+ reordered, or a title retyped), those slides are skipped.
138
+
139
+ ## Layout modes in practice
140
+
141
+ ```ts
142
+ // The deck leaves out the optional "subtitle" slot.
143
+ renderDeck(template, deck, { layout: 'strict' }); // throws: leaving this slot empty needs layout: 'adaptive'
144
+ renderDeck(template, deck, { layout: 'adaptive' }); // renders; the subtitle shape is removed
145
+ ```
146
+
147
+ Use `adaptive` unless you need a guarantee that every slide looks exactly like the template slide with
148
+ different words.
149
+
150
+ ## What the output is
151
+
152
+ - A `.pptx` that PowerPoint opens without a repair prompt. Output was checked by rendering in PowerPoint on
153
+ Windows; other programs were not tested.
154
+ - Charts are native charts with their data in an embedded workbook.
155
+ - Text keeps the template's formatting. Text boxes are not resized: text that is longer than its box overflows
156
+ it, which is why budgets exist.
157
+ - The output has no dependency on slotdeck. It is an ordinary file.
158
+
159
+ ## Serving a file from a web server
160
+
161
+ ```ts
162
+ app.post('/decks', async (request, response) => {
163
+ const deck = request.body as DeckSpec;
164
+ const issues = validateDeck(template.manifest, deck, { layout: 'adaptive', assets: Object.keys(assets) });
165
+ if (issues.some((issue) => issue.severity === 'error')) return response.status(400).json({ issues });
166
+ const { pptx } = renderDeck(template, deck, { assets, layout: 'adaptive' });
167
+ response
168
+ .setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.presentationml.presentation')
169
+ .setHeader('Content-Disposition', 'attachment; filename="deck.pptx"')
170
+ .send(Buffer.from(pptx));
171
+ });
172
+ ```