web-doc 0.7.0 → 0.9.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/THIRD_PARTY_NOTICES.md +10 -3
- package/dist/contracts.d.ts +6 -0
- package/dist/edit/ai/outline.d.ts +47 -0
- package/dist/edit/ai/outline.js +338 -0
- package/dist/edit/ai/targets.d.ts +16 -0
- package/dist/edit/ai/targets.js +309 -0
- package/dist/edit/ai/tools.d.ts +28 -0
- package/dist/edit/ai/tools.js +607 -0
- package/dist/edit/ai/types.d.ts +175 -0
- package/dist/edit/ai/types.js +1 -0
- package/dist/edit/docx/elements.js +19 -1
- package/dist/edit/docx/engine.d.ts +10 -4
- package/dist/edit/docx/engine.js +80 -6
- package/dist/edit/docx/model.d.ts +2 -0
- package/dist/edit/docx/model.js +11 -0
- package/dist/edit/docx/operations.d.ts +10 -0
- package/dist/edit/docx/provider.d.ts +4 -1
- package/dist/edit/docx/provider.js +3 -0
- package/dist/edit/docx/session.d.ts +12 -1
- package/dist/edit/docx/session.js +41 -0
- package/dist/edit/docx/structure-ops.js +39 -4
- package/dist/edit/docx/table-ops.js +26 -4
- package/dist/edit/docx/text-ops.d.ts +22 -0
- package/dist/edit/docx/text-ops.js +49 -16
- package/dist/edit/docx/text.js +21 -0
- package/dist/edit/docx/tracked.d.ts +52 -0
- package/dist/edit/docx/tracked.js +347 -0
- package/dist/edit/docx/types.d.ts +24 -1
- package/dist/edit/docx/write.d.ts +19 -5
- package/dist/edit/docx/write.js +31 -8
- package/dist/edit/engine.d.ts +14 -3
- package/dist/edit/history.d.ts +27 -3
- package/dist/edit/history.js +29 -7
- package/dist/edit/pdf/engine/document.js +48 -3
- package/dist/edit/pdf/engine/elements.d.ts +6 -0
- package/dist/edit/pdf/engine/fonts.js +13 -10
- package/dist/edit/pdf/engine/pages.js +9 -1
- package/dist/edit/pdf/range-map.d.ts +4 -2
- package/dist/edit/pdf/schemas.js +4 -1
- package/dist/edit/pdf/session.d.ts +10 -0
- package/dist/edit/pdf/session.js +39 -0
- package/dist/edit/pdf/types.d.ts +15 -3
- package/dist/edit/pptx/handler.js +10 -2
- package/dist/edit/pptx/session.d.ts +10 -0
- package/dist/edit/pptx/session.js +31 -0
- package/dist/edit/session.d.ts +11 -0
- package/dist/edit/session.js +269 -27
- package/dist/edit/types.d.ts +17 -3
- package/dist/edit/worker-engine.d.ts +2 -2
- package/dist/edit/worker-engine.js +2 -2
- package/dist/fonts/THIRD_PARTY_NOTICES.md +6 -3
- package/dist/fonts/manifest.json +4 -4
- package/dist/fonts/noto-sans-latin-cyrillic.ttf +0 -0
- package/dist/fuzzy-alignment.d.ts +11 -4
- package/dist/fuzzy-alignment.js +3 -9
- package/dist/headless.d.ts +1 -1
- package/dist/headless.js +4 -1
- package/dist/index.d.ts +5 -2
- package/dist/index.js +11 -2
- package/dist/limits.js +3 -0
- package/dist/worker-protocol.d.ts +1 -1
- package/dist/workers/fuzzy-search-worker.js +1 -1
- package/dist/workers/ooxml-edit-worker.js +1415 -860
- package/dist/workers/pdf-edit-worker.js +64 -16
- package/package.json +1 -1
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
import { ViewerError } from "../../errors.js";
|
|
2
|
+
import { binaryFields, isAssetReference } from "../assets.js";
|
|
3
|
+
import { validateSchema } from "../schema.js";
|
|
4
|
+
import { foldText } from "./outline.js";
|
|
5
|
+
/*
|
|
6
|
+
* The tool set is the editing API as a model provider lists tools: plain
|
|
7
|
+
* JSON Schema a host maps to its provider's format, and one executor that
|
|
8
|
+
* validates a call, runs it on the session and answers with JSON for the
|
|
9
|
+
* model and a sentence for the chat. A model's mistake never throws: it
|
|
10
|
+
* comes back as `ok: false` with the issues `apply()` would report.
|
|
11
|
+
*/
|
|
12
|
+
export const TOOL_SET_VERSION = 1;
|
|
13
|
+
const ASSET_PATTERN = "^asset:[0-9a-f]{64}$";
|
|
14
|
+
const ID = {
|
|
15
|
+
type: "string",
|
|
16
|
+
minLength: 1,
|
|
17
|
+
maxLength: 200,
|
|
18
|
+
description: "An element id as the outline lists it; copy it verbatim.",
|
|
19
|
+
};
|
|
20
|
+
const PAGE_RANGE = {
|
|
21
|
+
type: "array",
|
|
22
|
+
items: { type: "integer", minimum: 0 },
|
|
23
|
+
minItems: 2,
|
|
24
|
+
maxItems: 2,
|
|
25
|
+
description: "Inclusive 0-based [first, last] page range.",
|
|
26
|
+
};
|
|
27
|
+
const KINDS = {
|
|
28
|
+
type: "array",
|
|
29
|
+
items: { type: "string", minLength: 1, maxLength: 40 },
|
|
30
|
+
maxItems: 20,
|
|
31
|
+
description: "Element kinds to keep.",
|
|
32
|
+
};
|
|
33
|
+
const MAX_TEXT_CHARS = {
|
|
34
|
+
type: "integer",
|
|
35
|
+
minimum: 0,
|
|
36
|
+
maximum: 20000,
|
|
37
|
+
description: "Characters of text per element; default 160.",
|
|
38
|
+
};
|
|
39
|
+
function noun(format) {
|
|
40
|
+
return format === "pptx" ? "slide" : "page";
|
|
41
|
+
}
|
|
42
|
+
/** The tool definitions of a format, built from its operation schemas. */
|
|
43
|
+
export function buildToolSet(format, schemas) {
|
|
44
|
+
const pages = noun(format);
|
|
45
|
+
const tracked = format === "docx"
|
|
46
|
+
? 'With changeMode "tracked" (set by the host) the batch is written as Word tracked changes for a person to accept or reject; replaceText, insertParagraph, deleteElement of a paragraph, setTableCell, setTextStyle and setParagraphStyle have a tracked form.'
|
|
47
|
+
: `${format.toUpperCase()} has no tracked changes: changes apply directly; the host reviews them with document_checkpoint.`;
|
|
48
|
+
const definitions = [
|
|
49
|
+
{
|
|
50
|
+
name: "document_describe",
|
|
51
|
+
description: `The document as text, one line per element: [id] ${pages} N kind "label": text. Call it first; copy ids from the brackets. Budget in characters.`,
|
|
52
|
+
inputSchema: object({
|
|
53
|
+
pageRange: PAGE_RANGE,
|
|
54
|
+
kinds: KINDS,
|
|
55
|
+
maxTextChars: MAX_TEXT_CHARS,
|
|
56
|
+
maxChars: {
|
|
57
|
+
type: "integer",
|
|
58
|
+
minimum: 1,
|
|
59
|
+
description: "Characters the description may take; default 50 000.",
|
|
60
|
+
},
|
|
61
|
+
}),
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
name: "document_outline",
|
|
65
|
+
description: `The elements as JSON in reading order, nested where the format nests (table cells, group members), with ids, ${pages}s, text, labels and the operations each accepts.`,
|
|
66
|
+
inputSchema: object({
|
|
67
|
+
pageRange: PAGE_RANGE,
|
|
68
|
+
kinds: KINDS,
|
|
69
|
+
maxTextChars: MAX_TEXT_CHARS,
|
|
70
|
+
}),
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
name: "document_find",
|
|
74
|
+
description: "Elements and text ranges a quoted text, a citation or a kind names, best first with a score (1 exact, 0.9 folded whitespace and case, below 0.85 fuzzy, 0.5 by kind only). Use the range of a candidate in replaceText and setTextStyle.",
|
|
75
|
+
inputSchema: object({
|
|
76
|
+
text: {
|
|
77
|
+
type: "string",
|
|
78
|
+
minLength: 1,
|
|
79
|
+
maxLength: 2000,
|
|
80
|
+
description: "Text to find; whitespace and case do not matter.",
|
|
81
|
+
},
|
|
82
|
+
citation: {
|
|
83
|
+
type: "object",
|
|
84
|
+
required: ["text"],
|
|
85
|
+
additionalProperties: false,
|
|
86
|
+
properties: {
|
|
87
|
+
text: { type: "string", minLength: 1, maxLength: 2000 },
|
|
88
|
+
pageNumber: {
|
|
89
|
+
type: "integer",
|
|
90
|
+
minimum: 1,
|
|
91
|
+
description: `The 1-based ${pages} the passage is expected on; a hint.`,
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
pageIndex: {
|
|
96
|
+
type: "integer",
|
|
97
|
+
minimum: 0,
|
|
98
|
+
description: `Only this 0-based ${pages}.`,
|
|
99
|
+
},
|
|
100
|
+
kinds: KINDS,
|
|
101
|
+
within: {
|
|
102
|
+
...ID,
|
|
103
|
+
description: "Only this element and its descendants.",
|
|
104
|
+
},
|
|
105
|
+
maxResults: { type: "integer", minimum: 1, maximum: 50 },
|
|
106
|
+
}),
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
name: "document_inspect",
|
|
110
|
+
description: "One element with its text, style, table rows and the operations it accepts; geometry left out.",
|
|
111
|
+
inputSchema: object({ id: ID }, ["id"]),
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
name: "document_preview",
|
|
115
|
+
description: "Validates a batch and reports what applying it would do (pages changed, elements created) without changing anything. Recommended before document_apply for batches above one operation.",
|
|
116
|
+
inputSchema: batchSchema(schemas, false),
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
name: "document_apply",
|
|
120
|
+
description: `Applies a batch of operations as one undo step and returns the receipt. Address elements by the ids document_describe lists; the same batch is validated like document_preview. ${tracked}`,
|
|
121
|
+
inputSchema: batchSchema(schemas, true),
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
name: "document_undo",
|
|
125
|
+
description: "Undoes the last change (a batch or a checkpoint restore).",
|
|
126
|
+
inputSchema: object({}),
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
name: "document_checkpoint",
|
|
130
|
+
description: "Named checkpoints: create one before a series of changes, list them, or restore one to drop everything since it (one undo step).",
|
|
131
|
+
inputSchema: object({
|
|
132
|
+
action: { enum: ["create", "restore", "list"] },
|
|
133
|
+
id: {
|
|
134
|
+
type: "string",
|
|
135
|
+
minLength: 1,
|
|
136
|
+
maxLength: 40,
|
|
137
|
+
description: "The checkpoint to restore.",
|
|
138
|
+
},
|
|
139
|
+
label: { type: "string", maxLength: 200 },
|
|
140
|
+
}, ["action"]),
|
|
141
|
+
},
|
|
142
|
+
];
|
|
143
|
+
return Object.freeze({
|
|
144
|
+
version: TOOL_SET_VERSION,
|
|
145
|
+
format,
|
|
146
|
+
definitions: Object.freeze(definitions.map((definition) => Object.freeze(definition))),
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
function object(properties, required = []) {
|
|
150
|
+
return {
|
|
151
|
+
type: "object",
|
|
152
|
+
...(required.length > 0 ? { required } : {}),
|
|
153
|
+
additionalProperties: false,
|
|
154
|
+
properties,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* The batch argument: every operation of the format as one `oneOf`, with
|
|
159
|
+
* the formats' shared definitions hoisted to the root and binary fields
|
|
160
|
+
* narrowed to asset references, which a host registers with `addAsset()`.
|
|
161
|
+
*/
|
|
162
|
+
function batchSchema(schemas, withLabel) {
|
|
163
|
+
const defs = {};
|
|
164
|
+
const branches = Object.values(schemas.operations).map((schema) => {
|
|
165
|
+
const { $defs, ...rest } = schema;
|
|
166
|
+
if ($defs && typeof $defs === "object")
|
|
167
|
+
Object.assign(defs, $defs);
|
|
168
|
+
return assetsByReference(rest);
|
|
169
|
+
});
|
|
170
|
+
return {
|
|
171
|
+
type: "object",
|
|
172
|
+
required: ["operations"],
|
|
173
|
+
additionalProperties: false,
|
|
174
|
+
properties: {
|
|
175
|
+
operations: {
|
|
176
|
+
type: "array",
|
|
177
|
+
minItems: 1,
|
|
178
|
+
items: { oneOf: branches },
|
|
179
|
+
description: `Operations of the ${schemas.format} session, applied in order; "$<n>" as a target names the element operation n created.`,
|
|
180
|
+
},
|
|
181
|
+
...(withLabel
|
|
182
|
+
? {
|
|
183
|
+
label: {
|
|
184
|
+
type: "string",
|
|
185
|
+
maxLength: 200,
|
|
186
|
+
description: "What the change is, for the host's history.",
|
|
187
|
+
},
|
|
188
|
+
}
|
|
189
|
+
: {}),
|
|
190
|
+
},
|
|
191
|
+
...(Object.keys(defs).length > 0 ? { $defs: defs } : {}),
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
function assetsByReference(schema) {
|
|
195
|
+
const out = {};
|
|
196
|
+
for (const [key, value] of Object.entries(schema)) {
|
|
197
|
+
if (key === "x-binary")
|
|
198
|
+
continue;
|
|
199
|
+
out[key] = isRecord(value)
|
|
200
|
+
? value["x-binary"] === true
|
|
201
|
+
? {
|
|
202
|
+
type: "string",
|
|
203
|
+
pattern: ASSET_PATTERN,
|
|
204
|
+
description: "An asset reference (asset:<sha-256>) the host registered with addAsset(); never raw bytes or base64.",
|
|
205
|
+
}
|
|
206
|
+
: assetsByReference(value)
|
|
207
|
+
: Array.isArray(value)
|
|
208
|
+
? value.map((item) => (isRecord(item) ? assetsByReference(item) : item))
|
|
209
|
+
: value;
|
|
210
|
+
}
|
|
211
|
+
return out;
|
|
212
|
+
}
|
|
213
|
+
function isRecord(value) {
|
|
214
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
215
|
+
}
|
|
216
|
+
/** Runs one tool call on a session. */
|
|
217
|
+
export async function callTool(session, tools, call, options = {}) {
|
|
218
|
+
const definition = tools.definitions.find((tool) => tool.name === call.name);
|
|
219
|
+
if (!definition) {
|
|
220
|
+
const name = String(call.name).slice(0, 80);
|
|
221
|
+
return refused([
|
|
222
|
+
{
|
|
223
|
+
operationIndex: -1,
|
|
224
|
+
path: "/name",
|
|
225
|
+
code: "unknown-tool",
|
|
226
|
+
message: `Unknown tool ${name}`,
|
|
227
|
+
},
|
|
228
|
+
], `No tool named ${name}; the tools are ${tools.definitions.map((tool) => tool.name).join(", ")}.`);
|
|
229
|
+
}
|
|
230
|
+
const args = call.arguments ?? {};
|
|
231
|
+
const schema = definition.name === "document_apply"
|
|
232
|
+
? APPLY_ARGUMENTS
|
|
233
|
+
: definition.name === "document_preview"
|
|
234
|
+
? PREVIEW_ARGUMENTS
|
|
235
|
+
: definition.inputSchema;
|
|
236
|
+
const issues = validateSchema(args, schema, -1);
|
|
237
|
+
if (issues.length > 0)
|
|
238
|
+
return refused(issues, `Invalid arguments for ${definition.name}: ${issues
|
|
239
|
+
.map((issue) => `${issue.path || "/"} ${issue.message}`)
|
|
240
|
+
.join("; ")}.`);
|
|
241
|
+
const read = options.signal ? { signal: options.signal } : {};
|
|
242
|
+
try {
|
|
243
|
+
switch (definition.name) {
|
|
244
|
+
case "document_describe":
|
|
245
|
+
return await describeTool(session, args, read);
|
|
246
|
+
case "document_outline":
|
|
247
|
+
return await outlineTool(session, args, read);
|
|
248
|
+
case "document_find":
|
|
249
|
+
return await findTool(session, args, read);
|
|
250
|
+
case "document_inspect":
|
|
251
|
+
return await inspectTool(session, args.id, read);
|
|
252
|
+
case "document_preview":
|
|
253
|
+
return await batchTool(session, args, options, true);
|
|
254
|
+
case "document_apply":
|
|
255
|
+
return await batchTool(session, args, options, false);
|
|
256
|
+
case "document_undo":
|
|
257
|
+
return await undoTool(session, options);
|
|
258
|
+
default:
|
|
259
|
+
return await checkpointTool(session, args, options);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
catch (error) {
|
|
263
|
+
return refusal(session, error);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
/** The loose shape of a preview call; the operations themselves are validated by `apply()`. */
|
|
267
|
+
const PREVIEW_ARGUMENTS = {
|
|
268
|
+
type: "object",
|
|
269
|
+
required: ["operations"],
|
|
270
|
+
additionalProperties: false,
|
|
271
|
+
properties: {
|
|
272
|
+
operations: {
|
|
273
|
+
type: "array",
|
|
274
|
+
minItems: 1,
|
|
275
|
+
items: { type: "object" },
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
/** The loose shape of an apply call: a preview plus the history label. */
|
|
280
|
+
const APPLY_ARGUMENTS = {
|
|
281
|
+
...PREVIEW_ARGUMENTS,
|
|
282
|
+
properties: {
|
|
283
|
+
...PREVIEW_ARGUMENTS.properties,
|
|
284
|
+
label: { type: "string", maxLength: 200 },
|
|
285
|
+
},
|
|
286
|
+
};
|
|
287
|
+
function refused(issues, text) {
|
|
288
|
+
const frozen = Object.freeze(issues.map((issue) => Object.freeze({ ...issue })));
|
|
289
|
+
return Object.freeze({
|
|
290
|
+
ok: false,
|
|
291
|
+
content: { issues: frozen },
|
|
292
|
+
text,
|
|
293
|
+
issues: frozen,
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
function ok(content, text) {
|
|
297
|
+
return Object.freeze({ ok: true, content, text });
|
|
298
|
+
}
|
|
299
|
+
/** A session error a model can act on becomes a refusal; the session's own errors propagate. */
|
|
300
|
+
function refusal(session, error) {
|
|
301
|
+
if (!(error instanceof ViewerError))
|
|
302
|
+
throw error;
|
|
303
|
+
if (error.code === "lifecycle-error" || error.code === "aborted")
|
|
304
|
+
throw error;
|
|
305
|
+
// A session that could not recover answers nothing a model can act on.
|
|
306
|
+
if (error.code === "worker-crashed" ||
|
|
307
|
+
(error.code === "edit-failed" && error.details?.recovered === false))
|
|
308
|
+
throw error;
|
|
309
|
+
if (error.code === "invalid-operation") {
|
|
310
|
+
const reported = error.details?.issues;
|
|
311
|
+
const issues = reported && reported.length > 0
|
|
312
|
+
? reported
|
|
313
|
+
: [
|
|
314
|
+
{
|
|
315
|
+
operationIndex: -1,
|
|
316
|
+
path: "",
|
|
317
|
+
code: "invalid-operation",
|
|
318
|
+
message: error.message,
|
|
319
|
+
},
|
|
320
|
+
];
|
|
321
|
+
const first = issues[0];
|
|
322
|
+
return refused(issues, first
|
|
323
|
+
? `Refused: operation ${first.operationIndex < 0 ? "batch" : first.operationIndex} at ${first.path || "/"}: ${first.message}. Fix the operation and send the batch again.`
|
|
324
|
+
: `Refused: ${error.message}.`);
|
|
325
|
+
}
|
|
326
|
+
if (error.code === "edit-conflict")
|
|
327
|
+
return refused([
|
|
328
|
+
{
|
|
329
|
+
operationIndex: -1,
|
|
330
|
+
path: "",
|
|
331
|
+
code: "edit-conflict",
|
|
332
|
+
message: error.message,
|
|
333
|
+
},
|
|
334
|
+
], `The document changed since it was read (now revision ${session.state.revision}); call document_describe again and retry with the new revision.`);
|
|
335
|
+
return refused([
|
|
336
|
+
{
|
|
337
|
+
operationIndex: -1,
|
|
338
|
+
path: "",
|
|
339
|
+
code: error.code,
|
|
340
|
+
message: error.message,
|
|
341
|
+
},
|
|
342
|
+
], `${error.message}. The document is unchanged.`);
|
|
343
|
+
}
|
|
344
|
+
async function describeTool(session, args, read) {
|
|
345
|
+
const result = await session.describe({ ...args, ...read });
|
|
346
|
+
const item = result.item;
|
|
347
|
+
return ok({ revision: result.revision, ...item }, `Described ${count(item.elementCount, "element")} on ${count(item.pageCount, noun(session.format))} at revision ${result.revision}${item.truncated ? " (cut to the budget)" : ""}.`);
|
|
348
|
+
}
|
|
349
|
+
async function outlineTool(session, args, read) {
|
|
350
|
+
const result = await session.getOutline({ ...args, ...read });
|
|
351
|
+
return ok({
|
|
352
|
+
revision: result.revision,
|
|
353
|
+
nodeCount: result.nodeCount,
|
|
354
|
+
truncated: result.truncated,
|
|
355
|
+
nodes: result.items,
|
|
356
|
+
}, `Outlined ${count(result.nodeCount, "element")} at revision ${result.revision}${result.truncated ? " (cut at the node limit)" : ""}.`);
|
|
357
|
+
}
|
|
358
|
+
async function findTool(session, query, read) {
|
|
359
|
+
const result = await session.resolveTargets(query, read);
|
|
360
|
+
const best = result.items[0];
|
|
361
|
+
return ok({ revision: result.revision, candidates: result.items }, best
|
|
362
|
+
? `Found ${count(result.items.length, "candidate")}; best: ${best.reason} match in ${best.elementId}${best.pageIndex >= 0 ? ` on ${noun(session.format)} ${best.pageIndex + 1}` : ""} (score ${best.score}).`
|
|
363
|
+
: "Nothing matched; try a shorter quote, a different page, or document_describe to see the text.");
|
|
364
|
+
}
|
|
365
|
+
async function inspectTool(session, id, read) {
|
|
366
|
+
const result = await session.getElement(id, read);
|
|
367
|
+
const element = result.item;
|
|
368
|
+
if (!element)
|
|
369
|
+
return refused([
|
|
370
|
+
{
|
|
371
|
+
operationIndex: -1,
|
|
372
|
+
path: "/id",
|
|
373
|
+
code: "unknown-target",
|
|
374
|
+
message: `No element ${id}`,
|
|
375
|
+
},
|
|
376
|
+
], `No element ${id}; ids come from document_describe at the current revision.`);
|
|
377
|
+
const { bounds: _bounds, frame: _frame, fragments: _fragments, rotation: _rotation, ...rest } = element;
|
|
378
|
+
const text = element.text;
|
|
379
|
+
return ok({ revision: result.revision, ...rest }, `${element.kind} ${element.id}${element.pageIndex >= 0 ? ` on ${noun(session.format)} ${element.pageIndex + 1}` : ""}${text
|
|
380
|
+
? `: ${foldText(text.slice(0, 80))}${text.length > 80 ? "…" : ""}`
|
|
381
|
+
: ""}. Operations: ${element.operations.join(", ") || "none"}.`);
|
|
382
|
+
}
|
|
383
|
+
async function batchTool(session, args, options, dryRun) {
|
|
384
|
+
const assetIssues = checkAssets(session.schemas, args.operations);
|
|
385
|
+
if (assetIssues.length > 0)
|
|
386
|
+
return refused(assetIssues, "Binary payloads travel as asset references: the host registers the bytes with addAsset() and passes the asset:<sha-256> reference; never send base64 in a tool call.");
|
|
387
|
+
const receipt = await session.applyJson(args.operations, {
|
|
388
|
+
...(options.expectedRevision === undefined
|
|
389
|
+
? {}
|
|
390
|
+
: { expectedRevision: options.expectedRevision }),
|
|
391
|
+
...(options.changeMode === undefined
|
|
392
|
+
? {}
|
|
393
|
+
: { changeMode: options.changeMode }),
|
|
394
|
+
...(options.author === undefined ? {} : { author: options.author }),
|
|
395
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
396
|
+
...(args.label === undefined ? {} : { label: args.label }),
|
|
397
|
+
dryRun,
|
|
398
|
+
});
|
|
399
|
+
return ok(receipt, describeReceipt(session.format, args.operations, receipt));
|
|
400
|
+
}
|
|
401
|
+
/** Binary fields of a batch must be asset references before `apply()` sees them. */
|
|
402
|
+
function checkAssets(schemas, operations) {
|
|
403
|
+
const issues = [];
|
|
404
|
+
operations.forEach((operation, operationIndex) => {
|
|
405
|
+
if (!isRecord(operation) || typeof operation.op !== "string")
|
|
406
|
+
return;
|
|
407
|
+
for (const field of binaryFields(schemas.operations[operation.op])) {
|
|
408
|
+
const value = operation[field];
|
|
409
|
+
if (value === undefined || isAssetReference(value))
|
|
410
|
+
continue;
|
|
411
|
+
issues.push({
|
|
412
|
+
operationIndex,
|
|
413
|
+
path: `/${field}`,
|
|
414
|
+
code: "unknown-asset",
|
|
415
|
+
message: "Expected an asset reference registered with addAsset()",
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
});
|
|
419
|
+
return issues;
|
|
420
|
+
}
|
|
421
|
+
async function undoTool(session, options) {
|
|
422
|
+
const receipt = await session.undo({
|
|
423
|
+
...(options.expectedRevision === undefined
|
|
424
|
+
? {}
|
|
425
|
+
: { expectedRevision: options.expectedRevision }),
|
|
426
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
427
|
+
});
|
|
428
|
+
return ok(receipt, receipt.operationCount === 0 && receipt.changedPages.length === 0
|
|
429
|
+
? session.state.canUndo
|
|
430
|
+
? `Undid the last change; revision ${receipt.revision}.`
|
|
431
|
+
: "Nothing to undo."
|
|
432
|
+
: `Undid the last change${receipt.removedIds.length > 0 ? `, removing ${count(receipt.removedIds.length, "element")}` : ""}; revision ${receipt.revision}.`);
|
|
433
|
+
}
|
|
434
|
+
async function checkpointTool(session, args, options) {
|
|
435
|
+
switch (args.action) {
|
|
436
|
+
case "create": {
|
|
437
|
+
const checkpoint = await session.createCheckpoint(args.label);
|
|
438
|
+
return ok(checkpoint, `Checkpoint ${checkpoint.id} created at revision ${checkpoint.revision}${checkpoint.label ? ` ("${checkpoint.label}")` : ""}.`);
|
|
439
|
+
}
|
|
440
|
+
case "list": {
|
|
441
|
+
const checkpoints = session.listCheckpoints();
|
|
442
|
+
return ok({ checkpoints }, checkpoints.length === 0
|
|
443
|
+
? "No checkpoints."
|
|
444
|
+
: `${count(checkpoints.length, "checkpoint")}: ${checkpoints
|
|
445
|
+
.map((checkpoint) => `${checkpoint.id} (revision ${checkpoint.revision}${checkpoint.label ? `, "${checkpoint.label}"` : ""})`)
|
|
446
|
+
.join(", ")}.`);
|
|
447
|
+
}
|
|
448
|
+
default: {
|
|
449
|
+
if (args.id === undefined)
|
|
450
|
+
return refused([
|
|
451
|
+
{
|
|
452
|
+
operationIndex: -1,
|
|
453
|
+
path: "/id",
|
|
454
|
+
code: "required",
|
|
455
|
+
message: "restore needs the checkpoint id",
|
|
456
|
+
},
|
|
457
|
+
], 'Pass the id of the checkpoint to restore; list them with action "list".');
|
|
458
|
+
if (!session.listCheckpoints().some((item) => item.id === args.id))
|
|
459
|
+
return refused([
|
|
460
|
+
{
|
|
461
|
+
operationIndex: -1,
|
|
462
|
+
path: "/id",
|
|
463
|
+
code: "unknown-checkpoint",
|
|
464
|
+
message: `No checkpoint ${args.id}`,
|
|
465
|
+
},
|
|
466
|
+
], `No checkpoint ${args.id}; list them with action "list".`);
|
|
467
|
+
const receipt = await session.restoreCheckpoint(args.id, {
|
|
468
|
+
...(options.expectedRevision === undefined
|
|
469
|
+
? {}
|
|
470
|
+
: { expectedRevision: options.expectedRevision }),
|
|
471
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
472
|
+
});
|
|
473
|
+
return ok(receipt, receipt.changedPages.length === 0
|
|
474
|
+
? `Already at checkpoint ${args.id}; revision ${receipt.revision}.`
|
|
475
|
+
: `Restored checkpoint ${args.id}${receipt.removedIds.length > 0 ? `, removing ${count(receipt.removedIds.length, "element")}` : ""}${receipt.createdIds.length > 0 ? `, bringing back ${count(receipt.createdIds.length, "element")}` : ""}; revision ${receipt.revision}.`);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
480
|
+
* What a batch did, in the format's words: one sentence per operation with
|
|
481
|
+
* the id it created when the receipt names one per creating operation,
|
|
482
|
+
* then what else the receipt reports.
|
|
483
|
+
*/
|
|
484
|
+
export function describeReceipt(format, operations, receipt) {
|
|
485
|
+
const pages = noun(format);
|
|
486
|
+
const creating = operations.filter((operation) => CREATING.has(operation.op));
|
|
487
|
+
const idPer = creating.length > 0 && receipt.createdIds.length === creating.length;
|
|
488
|
+
let created = 0;
|
|
489
|
+
const sentences = operations.map((operation) => {
|
|
490
|
+
const id = idPer && CREATING.has(operation.op)
|
|
491
|
+
? receipt.createdIds[created++]
|
|
492
|
+
: undefined;
|
|
493
|
+
return describeOperation(format, pages, operation, id);
|
|
494
|
+
});
|
|
495
|
+
if (!idPer && receipt.createdIds.length > 0)
|
|
496
|
+
sentences.push(`Created ${receipt.createdIds.join(", ")}`);
|
|
497
|
+
if (receipt.removedIds.length > 0)
|
|
498
|
+
sentences.push(receipt.removedIds.length <= 5
|
|
499
|
+
? `Removed ${receipt.removedIds.join(", ")}`
|
|
500
|
+
: `Removed ${count(receipt.removedIds.length, "element")}`);
|
|
501
|
+
if (receipt.remappedIds && Object.keys(receipt.remappedIds).length > 0)
|
|
502
|
+
sentences.push(`Renamed ${Object.entries(receipt.remappedIds)
|
|
503
|
+
.map(([from, to]) => `${from} → ${to}`)
|
|
504
|
+
.join(", ")}`);
|
|
505
|
+
for (const warning of receipt.warnings)
|
|
506
|
+
sentences.push(`Warning (${warning.code}): ${warning.message}`);
|
|
507
|
+
const body = sentences.map((sentence) => `${sentence}.`).join(" ");
|
|
508
|
+
if (receipt.dryRun)
|
|
509
|
+
return `Preview, nothing changed: ${body} Would repaint ${pageList(receipt.changedPages, pages)}; ${count(receipt.pageCount, pages)} after.`;
|
|
510
|
+
return `${body} Revision ${receipt.revision}; ${receipt.changedPages.length > 0 ? `changed ${pageList(receipt.changedPages, pages)}` : `no ${pages} changed`}; ${count(receipt.pageCount, pages)}.`;
|
|
511
|
+
}
|
|
512
|
+
const CREATING = new Set([
|
|
513
|
+
"insertTextBox",
|
|
514
|
+
"insertParagraph",
|
|
515
|
+
"insertImage",
|
|
516
|
+
"insertShape",
|
|
517
|
+
"insertTable",
|
|
518
|
+
"insertPage",
|
|
519
|
+
"insertSlide",
|
|
520
|
+
"duplicateSlide",
|
|
521
|
+
]);
|
|
522
|
+
function describeOperation(format, pages, operation, id) {
|
|
523
|
+
const fields = operation;
|
|
524
|
+
const at = () => typeof fields.pageIndex === "number"
|
|
525
|
+
? ` on ${pages} ${fields.pageIndex + 1}`
|
|
526
|
+
: "";
|
|
527
|
+
const next = () => typeof fields.before === "string"
|
|
528
|
+
? ` before ${fields.before}`
|
|
529
|
+
: typeof fields.after === "string"
|
|
530
|
+
? ` after ${fields.after}`
|
|
531
|
+
: "";
|
|
532
|
+
const tag = id ? ` (${id})` : "";
|
|
533
|
+
const target = targetName(fields.target);
|
|
534
|
+
const ranged = fields.range ? " a range of" : "";
|
|
535
|
+
const rows = () => {
|
|
536
|
+
const grid = fields.rows;
|
|
537
|
+
if (!Array.isArray(grid) || grid.length === 0)
|
|
538
|
+
return "";
|
|
539
|
+
const columns = Array.isArray(grid[0]) ? grid[0].length : 0;
|
|
540
|
+
return ` ${grid.length}×${columns}`;
|
|
541
|
+
};
|
|
542
|
+
switch (operation.op) {
|
|
543
|
+
case "insertTextBox":
|
|
544
|
+
return format === "docx"
|
|
545
|
+
? `Added a paragraph${next()}${tag}`
|
|
546
|
+
: `Added a text box${at()}${tag}`;
|
|
547
|
+
case "insertParagraph":
|
|
548
|
+
return `Added a paragraph${next()}${tag}`;
|
|
549
|
+
case "insertImage":
|
|
550
|
+
return `Added a picture${at()}${next()}${tag}`;
|
|
551
|
+
case "insertShape":
|
|
552
|
+
return `Added a ${typeof fields.shape === "string" ? fields.shape : "shape"}${at()}${tag}`;
|
|
553
|
+
case "insertTable":
|
|
554
|
+
return `Added a${rows()} table${at()}${next()}${tag}`;
|
|
555
|
+
case "replaceText":
|
|
556
|
+
return `Replaced${ranged} the text of ${target}`;
|
|
557
|
+
case "setTextStyle":
|
|
558
|
+
return `Changed the text style of${ranged} ${target}`;
|
|
559
|
+
case "setParagraphStyle":
|
|
560
|
+
return `Changed the paragraph style of ${target}`;
|
|
561
|
+
case "setShapeStyle":
|
|
562
|
+
return `Changed the shape style of ${target}`;
|
|
563
|
+
case "setTableCell":
|
|
564
|
+
return `Set cell ${String(fields.row)},${String(fields.column)} of ${target}`;
|
|
565
|
+
case "moveElement":
|
|
566
|
+
return `Moved ${target}${next()}`;
|
|
567
|
+
case "resizeElement":
|
|
568
|
+
return `Resized ${target}`;
|
|
569
|
+
case "deleteElement":
|
|
570
|
+
return `Deleted ${target}`;
|
|
571
|
+
case "insertPage":
|
|
572
|
+
case "insertSlide":
|
|
573
|
+
return `Added ${pages} ${Number(fields.index) + 1}${tag}`;
|
|
574
|
+
case "duplicateSlide":
|
|
575
|
+
return `Duplicated ${pages} ${Number(fields.pageIndex) + 1}${tag}`;
|
|
576
|
+
case "deletePage":
|
|
577
|
+
case "deleteSlide":
|
|
578
|
+
return `Deleted ${pages} ${Number(fields.pageIndex) + 1}`;
|
|
579
|
+
case "movePage":
|
|
580
|
+
case "moveSlide":
|
|
581
|
+
return `Moved ${pages} ${Number(fields.from) + 1} to ${Number(fields.to) + 1}`;
|
|
582
|
+
case "rotatePage":
|
|
583
|
+
return fields.by === undefined
|
|
584
|
+
? `Rotated ${pages} ${Number(fields.pageIndex) + 1} to ${String(fields.rotation)}°`
|
|
585
|
+
: `Turned ${pages} ${Number(fields.pageIndex) + 1} by ${String(fields.by)}°`;
|
|
586
|
+
default:
|
|
587
|
+
return `Applied ${operation.op}${target === "the element" ? "" : ` to ${target}`}${tag}`;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
function targetName(target) {
|
|
591
|
+
if (typeof target !== "string")
|
|
592
|
+
return "the element";
|
|
593
|
+
const reference = /^\$(\d+)$/.exec(target);
|
|
594
|
+
return reference
|
|
595
|
+
? `the element created by operation ${Number(reference[1]) + 1}`
|
|
596
|
+
: target;
|
|
597
|
+
}
|
|
598
|
+
function pageList(pages, pageNoun) {
|
|
599
|
+
if (pages.length === 0)
|
|
600
|
+
return `no ${pageNoun}`;
|
|
601
|
+
if (pages.length > 6)
|
|
602
|
+
return `${count(pages.length, pageNoun)} (${pages[0] + 1}–${pages.at(-1) + 1})`;
|
|
603
|
+
return `${pageNoun}${pages.length === 1 ? "" : "s"} ${pages.map((page) => page + 1).join(", ")}`;
|
|
604
|
+
}
|
|
605
|
+
function count(value, word) {
|
|
606
|
+
return `${value} ${word}${value === 1 ? "" : "s"}`;
|
|
607
|
+
}
|