UNPKG

@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
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 };