@stll/folio-agents
Version:
Framework-neutral LLM tool layer over folio's ai-edits engine: function-calling tools so an agent can read and mutate .docx documents through @stll/folio-core.
208 lines (207 loc) • 8.1 kB
JavaScript
import { registerDecodedCommentHandlers } from "../bridge.js";
import { decodeCommentId } from "../codecs.js";
import { toAgentChange } from "./shared.js";
import { FOLIO_DOCUMENT_OPERATION_CONTRACT_VERSION, assertSupportedFolioDocumentOperationVersion, getFolioDocumentOperationIssues, getFolioDocumentOperationReceipts } from "@stll/folio-core/server";
import { createReply } from "@stll/folio-core/docx/replyToComment";
//#region src/bridges/editor-ref.ts
const toAgentCommentReply = (reply) => ({
id: String(reply.id),
author: reply.author,
text: replyPlainText(reply)
});
/** A host-state `Comment`'s plain text, its paragraphs joined by newlines (mirrors `FolioDocxReviewer`'s reading). */
const replyPlainText = (comment) => comment.content.map(paragraphPlainText).join("\n");
const paragraphPlainText = (paragraph) => {
const parts = [];
for (const item of paragraph.content ?? []) {
if (item.type !== "run") continue;
for (const runItem of item.content ?? []) if (runItem.type === "text") parts.push(runItem.text);
}
return parts.join("");
};
/**
* Build a {@link FolioAgentBridge} over a live `DocxEditor` ref plus the host
* app's comment state.
*
* Comments live in app-controlled React state (the `DocxEditor` `comments`
* prop), not on the ref, so this factory takes `getComments`/`setComments` to
* read and write that state directly — the same pair the host already passes
* to `DocxEditor`.
*
* KNOWN LIMITATIONS (only apply to a `ref` that predates the read-surface
* additions below; the current `DocxEditorRef` implements all six):
* - `getChanges()` returns `[]` when `ref.getTrackedChanges` is absent, since
* there is then no ref-level way to enumerate tracked changes from
* ProseMirror mark attributes.
* - Comment entries fall back to `blockId: null` / `quote: ""` when
* `ref.getCommentAnchors` is absent, since there is then no ref-level way
* to resolve a comment's anchor against the live ProseMirror document.
* - `read_page` / `read_selection` report an unsupported-capability error
* when `ref.getPageText` / `ref.getSelectionText` are absent: this bridge
* omits the corresponding `getPageText` / `getSelectionText` member
* entirely rather than implementing it as a no-op, which is what tells
* `executeFolioToolCall` to report the tool as unsupported instead of
* throwing.
* - Page-scoped search and `show_in_document` report an unsupported-capability
* error when `ref.getTargetPage` / `ref.showInDocument` are absent.
*/
const createEditorRefBridge = (options) => {
const { ref, author, getComments, setComments } = options;
const undoDocumentOperations = ref.undoDocumentOperations?.bind(ref);
const mode = options.mode ?? "tracked-changes";
const replyToDecodedComment = (commentId, text) => {
const comments = getComments();
const reply = createReply(comments, commentId, {
author,
text
});
if (!reply) return false;
setComments([...comments, reply]);
return true;
};
const resolveDecodedComment = (commentId, resolved) => {
const comments = getComments();
let found = false;
const next = comments.map((comment) => {
if (comment.id !== commentId) return comment;
found = true;
return Object.assign({}, comment, { done: resolved });
});
if (!found) return false;
setComments(next);
return true;
};
const requireSnapshot = () => {
const snapshot = ref.createAIEditSnapshot();
if (!snapshot) throw new Error("The editor view is not mounted; no snapshot is available yet.");
return snapshot;
};
const bridge = {
snapshot: requireSnapshot,
documentOperationMode: mode,
applyDocumentOperations: (batch) => {
assertSupportedFolioDocumentOperationVersion(batch.version);
const snapshot = requireSnapshot();
const versionedBatch = batch.mode === void 0 ? {
...batch,
mode
} : batch;
if (ref.applyDocumentOperations) {
const result = ref.applyDocumentOperations({
snapshot,
batch: versionedBatch,
mode,
author
});
return {
...result,
issues: result.issues ?? getFolioDocumentOperationIssues(versionedBatch.operations, result.skipped),
receipts: result.receipts ?? getFolioDocumentOperationReceipts(versionedBatch.operations, result.applied),
undoHandle: result.undoHandle ?? null
};
}
if (versionedBatch.dryRun === true) {
const skipped = versionedBatch.operations.map(({ id }) => ({
id,
reason: "unsupportedMode"
}));
return {
version: FOLIO_DOCUMENT_OPERATION_CONTRACT_VERSION,
status: "previewed",
applied: [],
skipped,
issues: getFolioDocumentOperationIssues(versionedBatch.operations, skipped),
receipts: [],
undoHandle: null
};
}
if (versionedBatch.atomic === true) {
const skipped = versionedBatch.operations.map(({ id }) => ({
id,
reason: "unsupportedMode"
}));
return {
version: FOLIO_DOCUMENT_OPERATION_CONTRACT_VERSION,
status: "rejected",
applied: [],
skipped,
issues: getFolioDocumentOperationIssues(versionedBatch.operations, skipped),
receipts: [],
undoHandle: null
};
}
const result = ref.applyAIEditOperations({
snapshot,
operations: [...versionedBatch.operations],
author,
...versionedBatch.mode !== void 0 && { mode: versionedBatch.mode }
});
return {
version: FOLIO_DOCUMENT_OPERATION_CONTRACT_VERSION,
status: "committed",
...result,
issues: getFolioDocumentOperationIssues(versionedBatch.operations, result.skipped),
receipts: getFolioDocumentOperationReceipts(versionedBatch.operations, result.applied),
undoHandle: null
};
},
...undoDocumentOperations && { undoDocumentOperations: (undoHandle) => undoDocumentOperations(undoHandle) },
getComments: () => {
const comments = getComments();
const anchors = ref.getCommentAnchors?.();
const anchorByCommentId = /* @__PURE__ */ new Map();
if (anchors) for (const anchor of anchors) anchorByCommentId.set(anchor.commentId, anchor);
const repliesByParent = /* @__PURE__ */ new Map();
const topLevel = [];
for (const comment of comments) {
if (comment.parentId == null) {
topLevel.push(comment);
continue;
}
const siblings = repliesByParent.get(comment.parentId) ?? [];
siblings.push(comment);
repliesByParent.set(comment.parentId, siblings);
}
return topLevel.map((comment) => {
const anchor = anchorByCommentId.get(comment.id);
return {
id: String(comment.id),
author: comment.author,
text: replyPlainText(comment),
resolved: comment.done ?? false,
blockId: anchor?.blockId ?? null,
quote: anchor?.quote ?? "",
replies: (repliesByParent.get(comment.id) ?? []).map(toAgentCommentReply)
};
});
},
getChanges: () => {
const changes = ref.getTrackedChanges?.();
return changes ? changes.map(toAgentChange) : [];
},
replyToComment: (commentId, text) => {
const decoded = decodeCommentId(commentId);
return decoded !== null && replyToDecodedComment(decoded.numeric, text);
},
resolveComment: (commentId, resolved) => {
const decoded = decodeCommentId(commentId);
return decoded !== null && resolveDecodedComment(decoded.numeric, resolved);
},
scrollToBlock: (blockId) => ref.scrollToBlock(blockId),
getPageCount: () => ref.getTotalPages()
};
const getSelectionText = ref.getSelectionText;
if (getSelectionText) bridge.getSelectionText = () => getSelectionText();
const getPageText = ref.getPageText;
if (getPageText) bridge.getPageText = (page) => getPageText(page) ?? "";
const getTargetPage = ref.getTargetPage;
if (getTargetPage) bridge.getTargetPage = (target) => getTargetPage(target);
const showInDocument = ref.showInDocument;
if (showInDocument) bridge.showInDocument = (target) => showInDocument(target);
return registerDecodedCommentHandlers(bridge, {
replyToComment: ({ numeric }, text) => replyToDecodedComment(numeric, text),
resolveComment: ({ numeric }, resolved) => resolveDecodedComment(numeric, resolved)
});
};
//#endregion
export { createEditorRefBridge };