diff --git a/.changeset/rename-remove-fields-to-delete-fields.md b/.changeset/rename-remove-fields-to-delete-fields.md new file mode 100644 index 0000000..72909e0 --- /dev/null +++ b/.changeset/rename-remove-fields-to-delete-fields.md @@ -0,0 +1,21 @@ +--- +"@simplepdf/react-embed-pdf": major +--- + +Renames `actions.removeFields` to `actions.deleteFields` and the corresponding iframe event from `REMOVE_FIELDS` to `DELETE_FIELDS`. The result payload field is renamed from `removed_count` to `deleted_count`. Aligns naming with the new `DELETE_PAGES` event so all destructive operations use `delete_*` consistently. + +If you are not using `actions.removeFields(...)` or `sendEvent("REMOVE_FIELDS", ...)`, you can safely update to this new major version. + +```ts +// Before +const result = await actions.removeFields({ page: 1 }); +if (result.success) { + console.log(result.data.removed_count); +} + +// After +const result = await actions.deleteFields({ page: 1 }); +if (result.success) { + console.log(result.data.deleted_count); +} +``` diff --git a/copilot/src/components/chat/chat_pane.tsx b/copilot/src/components/chat/chat_pane.tsx index 9f20d61..dcbc331 100644 --- a/copilot/src/components/chat/chat_pane.tsx +++ b/copilot/src/components/chat/chat_pane.tsx @@ -324,25 +324,6 @@ const createToolbarSyncMiddleware = return result } -// When the LLM itself creates a field (via `create_field`), the iframe's -// field set grows by one. If we did nothing, the post-stream getFields -// would diff that field as "user-added" and nudge the LLM about a field -// it just created itself. This middleware extracts the new field id from -// the bridge result and forwards it to the host so the field-detection -// hook can pre-mark it as known. -const createLlmFieldBaselineMiddleware = - ({ onLlmCreatedField }: { onLlmCreatedField: (fieldId: string) => void }): ToolMiddleware => - async ({ toolName }, next) => { - const result = await next() - if (toolName === 'create_field' && result.success) { - const data = result.data - if (data !== null && typeof data === 'object' && 'field_id' in data && typeof data.field_id === 'string') { - onLlmCreatedField(data.field_id) - } - } - return result - } - const toUnexpectedToolResult = (error: unknown): BridgeResult => { const errorMessage = error instanceof Error ? `${error.name}: ${error.message}` : String(error) return { @@ -550,7 +531,7 @@ export const ChatPane = ({ if (activeBridge === null) { return } - void activeBridge.submit({ downloadCopy: false }) + void activeBridge.submit({ download_copy: false }) }, []) const handleDownloadRequested = useCallback((): void => { @@ -640,13 +621,13 @@ export const ChatPane = ({ }, []) // Refs-not-props for isStreaming + onFieldAdded: useDetectUserAddedField - // must be called BEFORE `tools` useMemo (which needs markFieldAsKnown), - // but both of those pieces of information come from useChat which runs - // AFTER `tools`. Refs break the cycle; they are synced once useChat's - // output is in scope (a bit further down in this component). + // must be called BEFORE `tools` useMemo, but both of those pieces of + // information come from useChat which runs AFTER `tools`. Refs break the + // cycle; they are synced once useChat's output is in scope (a bit further + // down in this component). const isStreamingRef = useRef(false) const onFieldAddedRef = useRef<(event: { tools: SupportedFieldType[]; delta: number }) => void>(() => {}) - const { markFieldAsKnown: markFieldDetectionAsKnown } = useDetectUserAddedField({ + useDetectUserAddedField({ bridge, isReady, toolbarTool, @@ -665,11 +646,6 @@ export const ChatPane = ({ } const sharedMiddleware: ToolMiddleware[] = [ createToolbarSyncMiddleware({ onChange: setToolbarTool }), - createLlmFieldBaselineMiddleware({ - // When the LLM creates a field, mark its id as known so the next - // user-placed-field diff does not attribute it to the user. - onLlmCreatedField: (fieldId) => markFieldDetectionAsKnown(fieldId), - }), createCompactionMiddleware({ getByokActive: () => byokConfigRef.current !== null }), ] // Demo-only middleware lives at the head of the chain so it @@ -684,7 +660,7 @@ export const ChatPane = ({ systemPrompt: SYSTEM_PROMPT, middleware, }) - }, [bridge, handleDownloadRequested, markFieldDetectionAsKnown]) + }, [bridge, handleDownloadRequested]) const { messages, status, error, sendMessage, stop, addToolOutput, setMessages } = useChat({ transport, diff --git a/copilot/src/components/chat/hooks/use_detect_user_added_field.ts b/copilot/src/components/chat/hooks/use_detect_user_added_field.ts index def2f14..eace2ba 100644 --- a/copilot/src/components/chat/hooks/use_detect_user_added_field.ts +++ b/copilot/src/components/chat/hooks/use_detect_user_added_field.ts @@ -1,4 +1,4 @@ -import { type MutableRefObject, useCallback, useEffect, useRef } from 'react' +import { type MutableRefObject, useEffect, useRef } from 'react' import type { IframeBridge, SupportedFieldType } from '../../../lib/embed-bridge' // WORKAROUND: the SimplePDF editor does not currently emit an outbound @@ -30,11 +30,6 @@ import type { IframeBridge, SupportedFieldType } from '../../../lib/embed-bridge // the UI can show one icon per unique type when the user mixed (e.g. // TEXT + SIGNATURE in the same batch). // -// LLM-created fields bypass this nudge via `markFieldAsKnown(fieldId)`, -// called from the create_field middleware once the iframe has confirmed -// the new field id. The id goes straight into the seen set; the next -// poll's diff sees no user-added fields. -// // Refs-not-props for the streaming flag and the fire callback let the // hook be called BEFORE useChat in the consumer (useChat produces the // status + sendMessage used downstream). The consumer syncs the refs @@ -53,14 +48,6 @@ type UseDetectUserAddedFieldArgs = { onFieldAddedRef: MutableRefObject<(event: FieldAddedEvent) => void> } -type UseDetectUserAddedFieldReturn = { - // Consumers call this when they know a field was added by something - // other than the user (e.g. the LLM's `create_field` tool returned a - // field id). The id is added to the seen set so the next poll does - // NOT attribute that field to the user. - markFieldAsKnown: (fieldId: string) => void -} - export const useDetectUserAddedField = ({ bridge, isReady, @@ -68,16 +55,10 @@ export const useDetectUserAddedField = ({ isCursorOverEditor, isStreamingRef, onFieldAddedRef, -}: UseDetectUserAddedFieldArgs): UseDetectUserAddedFieldReturn => { +}: UseDetectUserAddedFieldArgs): void => { const seenIdsRef = useRef | null>(null) const lastBridgeRef = useRef(null) - const markFieldAsKnown = useCallback((fieldId: string): void => { - if (seenIdsRef.current !== null) { - seenIdsRef.current.add(fieldId) - } - }, []) - useEffect(() => { // Bridge swap is the only event that invalidates the seen set; the // ids belong to a different document context. Tool changes, cursor @@ -145,6 +126,4 @@ export const useDetectUserAddedField = ({ } } }, [bridge, isReady, toolbarTool, isCursorOverEditor, isStreamingRef, onFieldAddedRef]) - - return { markFieldAsKnown } } diff --git a/copilot/src/lib/byok/transport.ts b/copilot/src/lib/byok/transport.ts index 9db9a31..153cf11 100644 --- a/copilot/src/lib/byok/transport.ts +++ b/copilot/src/lib/byok/transport.ts @@ -1,18 +1,8 @@ import { convertToModelMessages, streamText, type UIMessage } from 'ai' import { buildSystemPrompt } from '../../server/tools' import { - DeletePageInput, - DetectFieldsInput, FINALISATION_ACTION, - FocusFieldInput, - GetDocumentContentInput, - GetFieldsInput, - GoToPageInput, - MovePageInput, - RemoveFieldsInput, - RotatePageInput, - SelectToolInput, - SetFieldValueInput, + LLM_STATIC_TOOLS, withFinalisationTool, } from '../embed-bridge-adapters/client-tools' import { formatStreamError } from '../error-classifier' @@ -93,58 +83,7 @@ export const runByokStream = async ({ config, init }: RunByokStreamArgs): Promis abortSignal: init?.signal ?? undefined, maxRetries: 0, maxOutputTokens: MAX_OUTPUT_TOKENS, - tools: withFinalisationTool({ - get_fields: { - description: 'Lists every fillable field currently on the document.', - inputSchema: GetFieldsInput, - }, - get_document_content: { - description: 'Extracts the textual content of the document page by page.', - inputSchema: GetDocumentContentInput, - }, - detect_fields: { - description: - 'Asks the editor to auto-detect and create missing fields. Call this when get_fields returned 0 fields.', - inputSchema: DetectFieldsInput, - }, - remove_fields: { - description: - 'Removes fields from the document. field_ids targets specific fields by id; page targets a single page (1-indexed); both omitted clears all fields. Destructive — only call when the user explicitly asks to remove fields.', - inputSchema: RemoveFieldsInput, - }, - select_tool: { - description: - 'Switches the editor tool (TEXT, BOXED_TEXT, CHECKBOX, SIGNATURE, PICTURE, or null for cursor).', - inputSchema: SelectToolInput, - }, - set_field_value: { - description: 'Writes a value into a single field. Always focus_field first.', - inputSchema: SetFieldValueInput, - }, - focus_field: { - description: 'Scrolls to and visually highlights a field.', - inputSchema: FocusFieldInput, - }, - go_to_page: { - description: 'Scrolls the editor to a given 1-based page.', - inputSchema: GoToPageInput, - }, - move_page: { - description: - 'Reorders pages: from_page and to_page are 1-indexed visible page positions. Destructive — only call when the user explicitly asks to reorder a page.', - inputSchema: MovePageInput, - }, - delete_page: { - description: - 'Permanently removes a visible page (1-indexed) and any fields placed on it. The last remaining page cannot be deleted. Destructive — only call when the user explicitly asks to delete a page.', - inputSchema: DeletePageInput, - }, - rotate_page: { - description: - 'Rotates a visible page (1-indexed) 90° clockwise per call (repeat for 180° / 270°). Destructive — only call when the user explicitly asks to rotate a page.', - inputSchema: RotatePageInput, - }, - }), + tools: withFinalisationTool(LLM_STATIC_TOOLS), onError: ({ error }) => { monitoring.error('byok.stream_error', { detail: normalizeError(error) }) }, diff --git a/copilot/src/lib/embed-bridge-adapters/client-tools/dispatch.ts b/copilot/src/lib/embed-bridge-adapters/client-tools/dispatch.ts deleted file mode 100644 index 5056330..0000000 --- a/copilot/src/lib/embed-bridge-adapters/client-tools/dispatch.ts +++ /dev/null @@ -1,155 +0,0 @@ -import type { BridgeResult, IframeBridge, SupportedFieldType } from '../../embed-bridge' -import { type ClientToolName, isClientToolName } from './schemas' - -export type ToolInput = Record - -const isSupportedFieldType = (value: unknown): value is SupportedFieldType => - value === 'TEXT' || - value === 'BOXED_TEXT' || - value === 'CHECKBOX' || - value === 'PICTURE' || - value === 'SIGNATURE' - -const isSelectableTool = (value: unknown): value is SupportedFieldType | null => - value === null || isSupportedFieldType(value) - -// Core dispatcher. Given a tool name and the raw input object the LLM -// produced, route to the matching bridge method. Input-shape violations -// surface as typed `bad_input` BridgeResult failures; the dispatcher never -// throws. -export const dispatch = async ( - bridge: IframeBridge, - toolName: ClientToolName, - input: ToolInput, -): Promise> => { - switch (toolName) { - case 'get_fields': - return bridge.getFields() - case 'get_document_content': { - const extractionMode: 'auto' | 'ocr' = input.extraction_mode === 'ocr' ? 'ocr' : 'auto' - return bridge.getDocumentContent({ extractionMode }) - } - case 'detect_fields': - return bridge.detectFields() - case 'remove_fields': { - const rawIds = input.field_ids - const fieldIds = ((): string[] | null | 'invalid' => { - if (rawIds === undefined || rawIds === null) { - return null - } - if (Array.isArray(rawIds) && rawIds.every((id): id is string => typeof id === 'string')) { - return rawIds - } - return 'invalid' - })() - if (fieldIds === 'invalid') { - return { - success: false, - error: { code: 'bad_input', message: 'field_ids must be an array of strings' }, - } - } - const page = typeof input.page === 'number' ? input.page : null - return bridge.removeFields({ fieldIds, page }) - } - case 'select_tool': { - const rawTool = input.tool - if (rawTool !== undefined && !isSelectableTool(rawTool)) { - return { - success: false, - error: { code: 'bad_input', message: `Unsupported tool: ${String(rawTool)}` }, - } - } - const tool: SupportedFieldType | null = rawTool ?? null - return bridge.selectTool({ tool }) - } - case 'set_field_value': { - const fieldId = typeof input.field_id === 'string' ? input.field_id : null - const value = typeof input.value === 'string' ? input.value : null - if (fieldId === null) { - return { - success: false, - error: { code: 'bad_input', message: 'field_id is required' }, - } - } - return bridge.setFieldValue({ fieldId, value }) - } - case 'focus_field': { - const fieldId = typeof input.field_id === 'string' ? input.field_id : null - if (fieldId === null) { - return { - success: false, - error: { code: 'bad_input', message: 'field_id is required' }, - } - } - return bridge.focusField({ fieldId }) - } - case 'go_to_page': { - const page = typeof input.page === 'number' ? input.page : null - if (page === null) { - return { - success: false, - error: { code: 'bad_input', message: 'page must be a number' }, - } - } - return bridge.goTo({ page }) - } - case 'move_page': { - const fromPage = typeof input.from_page === 'number' ? input.from_page : null - const toPage = typeof input.to_page === 'number' ? input.to_page : null - if (fromPage === null || toPage === null) { - return { - success: false, - error: { code: 'bad_input', message: 'from_page and to_page must be numbers' }, - } - } - return bridge.movePage({ fromPage, toPage }) - } - case 'delete_page': { - const page = typeof input.page === 'number' ? input.page : null - if (page === null) { - return { - success: false, - error: { code: 'bad_input', message: 'page must be a number' }, - } - } - return bridge.deletePage({ page }) - } - case 'rotate_page': { - const page = typeof input.page === 'number' ? input.page : null - if (page === null) { - return { - success: false, - error: { code: 'bad_input', message: 'page must be a number' }, - } - } - return bridge.rotatePage({ page }) - } - case 'submit': - return bridge.submit({ downloadCopy: false }) - case 'download': - return bridge.download() - default: - toolName satisfies never - return { - success: false, - error: { code: 'unknown_tool', message: `Unknown tool: ${String(toolName)}` }, - } - } -} - -// Optional safety wrapper around `dispatch` that accepts an arbitrary tool -// name (e.g. coming from an LLM tool call where the type isn't narrowed yet) -// and rejects unknown names with a typed error. -export const safeDispatch = async ( - bridge: IframeBridge, - toolName: string, - input: ToolInput, -): Promise> => { - if (!isClientToolName(toolName)) { - return { - success: false, - error: { code: 'unknown_tool', message: `Unknown tool: ${toolName}` }, - } - } - return dispatch(bridge, toolName, input) -} diff --git a/copilot/src/lib/embed-bridge-adapters/client-tools/factory.ts b/copilot/src/lib/embed-bridge-adapters/client-tools/factory.ts index 24f6ee5..4bf36a0 100644 --- a/copilot/src/lib/embed-bridge-adapters/client-tools/factory.ts +++ b/copilot/src/lib/embed-bridge-adapters/client-tools/factory.ts @@ -1,7 +1,8 @@ import type { BridgeResult, IframeBridge } from '../../embed-bridge' -import { safeDispatch, type ToolInput } from './dispatch' import { composeMiddleware, type ToolMiddleware } from './middleware' -import { CLIENT_TOOL_SCHEMAS, isClientToolName } from './schemas' +import { type ClientToolName, isClientToolName } from './tools' + +export type ToolInput = Record export type CreateClientToolsArgs = { // The iframe bridge the dispatcher will drive. Usually comes from the React @@ -20,18 +21,15 @@ export type CreateClientToolsArgs = { } export type ClientTools = { - // Zod input schemas keyed by tool name. Spread into streamText({ tools }) - // alongside descriptions. - schemas: typeof CLIENT_TOOL_SCHEMAS // System prompt passed into createClientTools, re-exported verbatim for // the consumer to pass to their LLM. systemPrompt: string - // Main entry: given a raw tool name + input (e.g. from an LLM tool call), - // run the middleware stack and dispatch to the bridge. Unknown tool names - // come back as a typed `unknown_tool` failure. - execute: (toolName: string, input: ToolInput) => Promise> - // Type guard re-export so the consumer can branch on tool names without - // importing `schemas.ts` separately. + // Main entry. The caller narrows toolName via `isClientToolName` once at + // the consumer boundary; the Vercel AI SDK guarantees the LLM only fires + // registered tools. + execute: (toolName: ClientToolName, input: ToolInput) => Promise> + // Type guard re-export so the consumer can branch on LLM tool names + // without importing `tools.ts` separately. isClientToolName: typeof isClientToolName } @@ -40,11 +38,46 @@ export const createClientTools = ({ systemPrompt, middleware = [], }: CreateClientToolsArgs): ClientTools => { - const composed = composeMiddleware(middleware, ({ toolName, input }) => - safeDispatch(bridge, toolName, input), - ) + // Pure router. Each arm just hands the LLM input to the matching bridge + // method; the bridge owns parsing + validation. `satisfies never` keeps + // the switch exhaustive over ClientToolName at compile time. + const composed = composeMiddleware(middleware, ({ toolName, input }) => { + switch (toolName) { + case 'get_fields': + return bridge.getFields() + case 'get_document_content': + return bridge.getDocumentContent(input) + case 'detect_fields': + return bridge.detectFields() + case 'delete_fields': + return bridge.deleteFields(input) + case 'select_tool': + return bridge.selectTool(input) + case 'set_field_value': + return bridge.setFieldValue(input) + case 'focus_field': + return bridge.focusField(input) + case 'go_to_page': + return bridge.goTo(input) + case 'move_page': + return bridge.movePage(input) + case 'delete_pages': + return bridge.deletePages(input) + case 'rotate_page': + return bridge.rotatePage(input) + case 'submit': + return bridge.submit({ download_copy: false }) + case 'download': + return bridge.download() + default: + toolName satisfies never + return Promise.resolve({ + success: false, + error: { code: 'unknown_tool', message: `Unknown tool: ${String(toolName)}` }, + }) + } + }) return { - schemas: CLIENT_TOOL_SCHEMAS, systemPrompt, execute: (toolName, input) => composed({ toolName, input }), isClientToolName, diff --git a/copilot/src/lib/embed-bridge-adapters/client-tools/finalisation.ts b/copilot/src/lib/embed-bridge-adapters/client-tools/finalisation.ts index 61a746f..0dd5272 100644 --- a/copilot/src/lib/embed-bridge-adapters/client-tools/finalisation.ts +++ b/copilot/src/lib/embed-bridge-adapters/client-tools/finalisation.ts @@ -1,30 +1,19 @@ +import { DownloadInput, SubmitInput } from '../../embed-bridge' import { IS_DEMO_MODE } from '../../mode' -import { DownloadInput, SubmitInput } from './schemas' // The single AI SDK tool that finalises the filled PDF. Demo mode (the // SimplePDF-hosted copilot.simplepdf.com) exposes only `download`, which // short-circuits through the host's upsell-aware handler. SimplePDF customer // forks expose only `submit`, which fires the SimplePDF SUBMIT iframe event // so the filled PDF lands in the customer's BYOS storage + webhook stack. +// Both descriptions live with the bridge schemas. export type FinalisationToolMap = | { submit: { description: string; inputSchema: typeof SubmitInput } } | { download: { description: string; inputSchema: typeof DownloadInput } } export const FINALISATION_TOOL: FinalisationToolMap = IS_DEMO_MODE - ? { - download: { - description: - 'Finalizes the filled PDF and triggers an in-browser download for the user. Use only when the user asks to download.', - inputSchema: DownloadInput, - }, - } - : { - submit: { - description: - 'Finalizes the filled PDF and submits it to the host application (storage, webhook, etc.). Use only when the user asks to submit or finalize.', - inputSchema: SubmitInput, - }, - } + ? { download: { description: DownloadInput.description ?? '', inputSchema: DownloadInput } } + : { submit: { description: SubmitInput.description ?? '', inputSchema: SubmitInput } } // Merges the mode-appropriate finalisation tool into the caller's static // tool map. The constraint `T & { submit?: never; download?: never }` diff --git a/copilot/src/lib/embed-bridge-adapters/client-tools/index.ts b/copilot/src/lib/embed-bridge-adapters/client-tools/index.ts index b5cb366..872bcb6 100644 --- a/copilot/src/lib/embed-bridge-adapters/client-tools/index.ts +++ b/copilot/src/lib/embed-bridge-adapters/client-tools/index.ts @@ -1,26 +1,8 @@ -export type { ToolInput } from './dispatch' -export { dispatch, safeDispatch } from './dispatch' -export type { ClientTools, CreateClientToolsArgs } from './factory' +export type { ClientTools, CreateClientToolsArgs, ToolInput } from './factory' export { createClientTools } from './factory' export type { FinalisationAction, FinalisationToolMap } from './finalisation' export { FINALISATION_ACTION, FINALISATION_TOOL, withFinalisationTool } from './finalisation' export type { MiddlewareContext, ToolMiddleware } from './middleware' export { composeMiddleware } from './middleware' -export type { ClientToolName } from './schemas' -export { - CLIENT_TOOL_SCHEMAS, - DeletePageInput, - DetectFieldsInput, - DownloadInput, - FocusFieldInput, - GetDocumentContentInput, - GetFieldsInput, - GoToPageInput, - isClientToolName, - MovePageInput, - RemoveFieldsInput, - RotatePageInput, - SelectToolInput, - SetFieldValueInput, - SubmitInput, -} from './schemas' +export type { ClientToolName } from './tools' +export { isClientToolName, LLM_STATIC_TOOLS } from './tools' diff --git a/copilot/src/lib/embed-bridge-adapters/client-tools/middleware.ts b/copilot/src/lib/embed-bridge-adapters/client-tools/middleware.ts index 230b943..a7c2aec 100644 --- a/copilot/src/lib/embed-bridge-adapters/client-tools/middleware.ts +++ b/copilot/src/lib/embed-bridge-adapters/client-tools/middleware.ts @@ -1,5 +1,6 @@ import type { BridgeResult } from '../../embed-bridge' -import type { ToolInput } from './dispatch' +import type { ToolInput } from './factory' +import type { ClientToolName } from './tools' // Onion-style middleware. Each layer receives a context (tool name + input) // and `next()` which triggers the inner dispatcher. Layers can short-circuit @@ -14,7 +15,7 @@ import type { ToolInput } from './dispatch' // data }` envelope for prompt-injection hardening. export type MiddlewareContext = { - toolName: string + toolName: ClientToolName input: ToolInput } diff --git a/copilot/src/lib/embed-bridge-adapters/client-tools/schemas.ts b/copilot/src/lib/embed-bridge-adapters/client-tools/schemas.ts deleted file mode 100644 index 293002d..0000000 --- a/copilot/src/lib/embed-bridge-adapters/client-tools/schemas.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { z } from 'zod' - -// Zod schemas for the 11 iframe tools. Consumers register these with their -// LLM framework (Vercel AI SDK's `tools`, LangChain, etc.). Each schema -// matches the bridge method signature; the dispatcher routes by tool name. - -export const GetFieldsInput = z.object({}).describe('Lists every fillable field currently on the document') - -export const GetDocumentContentInput = z - .object({ - extraction_mode: z.enum(['auto', 'ocr']).default('auto'), - }) - .describe('Returns extracted text per page. Use "ocr" for scanned documents, otherwise "auto"') - -export const DetectFieldsInput = z - .object({}) - .describe( - 'Asks the editor to auto-detect and create fields on the document. Use when get_fields returned 0 fields before asking the user to add fields manually.', - ) - -export const RemoveFieldsInput = z - .object({ - field_ids: z.array(z.string()).optional().describe('Specific field identifiers to remove (omit to target by page or all)'), - page: z.number().int().positive().optional().describe('1-indexed visible page to clear (omit to target specific ids or all)'), - }) - .describe( - 'Removes fields from the document. Pass field_ids to remove specific fields, page to clear a single page, or both omitted to remove every field. Destructive: only call when the user explicitly asks.', - ) - -// Aligned with the bridge's SupportedFieldType. The LLM may pick any of -// the five tool variants + null (cursor); the host UI mirrors the same -// five in its toolbar. -export const SelectToolInput = z - .object({ - tool: z - .enum(['TEXT', 'BOXED_TEXT', 'CHECKBOX', 'PICTURE', 'SIGNATURE']) - .nullable() - .describe('Editor tool to activate. Pass null to return to the cursor.'), - }) - .describe( - 'Switches the active editor tool. Use tool="TEXT" for free-form text, "BOXED_TEXT" for box-per-character fields (e.g. IBAN), or any of the other field types to let the user drop fields on a document without native AcroFields.', - ) - -export const SetFieldValueInput = z - .object({ - field_id: z.string().describe('Field identifier from get_fields'), - value: z - .string() - .nullable() - .describe( - 'Value to write. TEXT/BOXED_TEXT: any string. CHECKBOX: "checked" ticks, null un-ticks (never "true"/"false"). Do not use this tool for SIGNATURE or PICTURE fields.', - ), - }) - .describe('Writes a value into a single field in the PDF') - -export const FocusFieldInput = z - .object({ field_id: z.string().describe('Field identifier from get_fields') }) - .describe('Scrolls to and visually highlights a field so the user can see what will be filled next') - -export const GoToPageInput = z - .object({ page: z.number().int().positive().describe('1-based page number') }) - .describe('Scrolls the editor to a given page') - -export const MovePageInput = z - .object({ - from_page: z.number().int().positive().describe('Visible page to move (1-indexed)'), - to_page: z.number().int().positive().describe('Target visible position (1-indexed)'), - }) - .describe( - 'Reorders pages in the document. Destructive: only call when the user explicitly asks to reorder a page.', - ) - -export const DeletePageInput = z - .object({ page: z.number().int().positive().describe('Visible page to delete (1-indexed)') }) - .describe( - 'Permanently removes a page (and any fields on it) from the document. Destructive: only call when the user explicitly asks to delete a page. The last remaining visible page cannot be deleted.', - ) - -export const RotatePageInput = z - .object({ page: z.number().int().positive().describe('Visible page to rotate (1-indexed)') }) - .describe( - 'Rotates a page 90° clockwise. Destructive: only call when the user explicitly asks to rotate a page. Repeat to reach 180° / 270°.', - ) - -export const SubmitInput = z - .object({}) - .describe( - 'Finalizes the filled PDF and submits it to the host application (storage, webhook, etc.). Use only when the user asks to submit or finalize.', - ) - -export const DownloadInput = z - .object({}) - .describe( - 'Finalizes the filled PDF and triggers an in-browser download for the user. Use only when the user asks to download.', - ) - -export const CLIENT_TOOL_NAMES = [ - 'get_fields', - 'get_document_content', - 'detect_fields', - 'remove_fields', - 'select_tool', - 'set_field_value', - 'focus_field', - 'go_to_page', - 'move_page', - 'delete_page', - 'rotate_page', - 'submit', - 'download', -] as const - -export type ClientToolName = (typeof CLIENT_TOOL_NAMES)[number] - -export const isClientToolName = (value: unknown): value is ClientToolName => - typeof value === 'string' && CLIENT_TOOL_NAMES.some((candidate) => candidate === value) - -// Map of tool name → input schema. Consumers typically spread this into -// `streamText({ tools })` (for the ai-sdk path) after calling `.describe` -// on each to add per-call descriptions. -export const CLIENT_TOOL_SCHEMAS = { - get_fields: GetFieldsInput, - get_document_content: GetDocumentContentInput, - detect_fields: DetectFieldsInput, - remove_fields: RemoveFieldsInput, - select_tool: SelectToolInput, - set_field_value: SetFieldValueInput, - focus_field: FocusFieldInput, - go_to_page: GoToPageInput, - move_page: MovePageInput, - delete_page: DeletePageInput, - rotate_page: RotatePageInput, - submit: SubmitInput, - download: DownloadInput, -} as const diff --git a/copilot/src/lib/embed-bridge-adapters/client-tools/tools.ts b/copilot/src/lib/embed-bridge-adapters/client-tools/tools.ts new file mode 100644 index 0000000..d150242 --- /dev/null +++ b/copilot/src/lib/embed-bridge-adapters/client-tools/tools.ts @@ -0,0 +1,58 @@ +import type { z } from 'zod' +import { + DeleteFieldsInput, + DeletePagesInput, + DetectFieldsInput, + FocusFieldInput, + GetDocumentContentInput, + GetFieldsInput, + GoToInput, + MovePageInput, + RotatePageInput, + SelectToolInput, + SetFieldValueInput, +} from '../../embed-bridge' + +// LLM-tool adapter for the bridge. The bridge owns the schemas (with +// descriptions) in embed-bridge/schemas.ts; this file enumerates which +// bridge operations are exposed to the LLM, under which snake_case tool +// name, and pulls each description verbatim from the bridge schema's +// `.describe()` — no duplicated text. +// +// Adding an LLM tool: add an entry to LLM_STATIC_TOOLS, add a switch arm +// in factory.ts. The switch is exhaustive over ClientToolName, so any +// addition forces a matching arm at compile time. + +const tool = (inputSchema: TSchema): { description: string; inputSchema: TSchema } => ({ + description: inputSchema.description ?? '', + inputSchema, +}) + +export const LLM_STATIC_TOOLS = { + get_fields: tool(GetFieldsInput), + get_document_content: tool(GetDocumentContentInput), + detect_fields: tool(DetectFieldsInput), + delete_fields: tool(DeleteFieldsInput), + select_tool: tool(SelectToolInput), + set_field_value: tool(SetFieldValueInput), + focus_field: tool(FocusFieldInput), + go_to_page: tool(GoToInput), + move_page: tool(MovePageInput), + delete_pages: tool(DeletePagesInput), + rotate_page: tool(RotatePageInput), +} as const + +// Finalisation tools (submit / download) are mode-gated and merged in via +// `withFinalisationTool` rather than living in LLM_STATIC_TOOLS, so the +// type union covers both. +type FinalisationToolName = 'submit' | 'download' +export type ClientToolName = keyof typeof LLM_STATIC_TOOLS | FinalisationToolName + +const ALL_CLIENT_TOOL_NAMES: ReadonlySet = new Set([ + ...(Object.keys(LLM_STATIC_TOOLS) as Array), + 'submit', + 'download', +]) + +export const isClientToolName = (value: unknown): value is ClientToolName => + typeof value === 'string' && ALL_CLIENT_TOOL_NAMES.has(value) diff --git a/copilot/src/lib/embed-bridge/bridge.ts b/copilot/src/lib/embed-bridge/bridge.ts index 77ec61b..a471cc8 100644 --- a/copilot/src/lib/embed-bridge/bridge.ts +++ b/copilot/src/lib/embed-bridge/bridge.ts @@ -1,15 +1,27 @@ +import type { z } from 'zod' import { type BridgeLogger, NOOP_LOGGER } from './logger' +import { + DeleteFieldsInput, + DeletePagesInput, + FocusFieldInput, + GetDocumentContentInput, + GoToInput, + LoadDocumentInput, + MovePageInput, + RotatePageInput, + SelectToolInput, + SetFieldValueInput, + SubmitInput, +} from './schemas' import { type BridgeRequestType, type BridgeResult, type BridgeState, - type CreateFieldArgs, type DocumentContentResult, type FieldRecord, + type FocusFieldResult, type IframeBridge, isBridgeResultLike, - type LoadDocumentArgs, - type RemoveFieldsArgs, } from './types' type PendingRequest = { @@ -34,15 +46,14 @@ const getRequestTimeoutMs = (requestType: BridgeRequestType): number => { case 'DETECT_FIELDS': case 'GET_DOCUMENT_CONTENT': return HEAVY_REQUEST_TIMEOUT_MS - case 'CREATE_FIELD': - case 'DELETE_PAGE': + case 'DELETE_FIELDS': + case 'DELETE_PAGES': case 'FOCUS_FIELD': case 'GET_FIELDS': case 'DOWNLOAD': case 'GO_TO': case 'LOAD_DOCUMENT': case 'MOVE_PAGE': - case 'REMOVE_FIELDS': case 'ROTATE_PAGE': case 'SELECT_TOOL': case 'SET_FIELD_VALUE': @@ -113,7 +124,7 @@ export const createBridge = ({ const sendRequest = ( type: BridgeRequestType, - data: Record, + data: unknown, ): Promise> => new Promise((resolve) => { const iframe = getIframe() @@ -372,38 +383,50 @@ export const createBridge = ({ window.addEventListener('message', onMessage) + // The bridge OWNS validation: each method validates its `unknown` input + // against the schema in schemas.ts before posting to the iframe. The + // adapter layer (LLM tool registry, React SDK, etc.) is therefore a pure + // router — no parse, no narrowing. Adding a new method = add a schema in + // schemas.ts, add a method on IframeBridge, and add a `parseAndSend` line + // here. + const parseAndSend = ( + schema: TSchema, + type: BridgeRequestType, + args: unknown, + ): Promise> => { + const parsed = schema.safeParse(args) + if (!parsed.success) { + return Promise.resolve({ + success: false, + error: { code: 'bad_input', message: parsed.error.message }, + }) + } + return sendRequest(type, parsed.data) + } const bridge: IframeBridge = { getState: () => state, - loadDocument: ({ dataUrl, name, initialPage }: LoadDocumentArgs) => - sendRequest('LOAD_DOCUMENT', { data_url: dataUrl, name, page: initialPage }), - goTo: ({ page }) => sendRequest('GO_TO', { page }), - selectTool: ({ tool }) => sendRequest('SELECT_TOOL', { tool }), - detectFields: (args) => sendRequest('DETECT_FIELDS', { debug_mode: args?.debugMode === true }), - removeFields: (args?: RemoveFieldsArgs) => - sendRequest('REMOVE_FIELDS', { - field_ids: args?.fieldIds ?? null, - page: args?.page ?? null, - }), - getDocumentContent: ({ extractionMode }) => - sendRequest('GET_DOCUMENT_CONTENT', { extraction_mode: extractionMode }), + loadDocument: (args) => parseAndSend(LoadDocumentInput, 'LOAD_DOCUMENT', args), getFields: () => sendRequest<{ fields: FieldRecord[] }>('GET_FIELDS', {}), - setFieldValue: ({ fieldId, value }) => sendRequest('SET_FIELD_VALUE', { field_id: fieldId, value }), - focusField: ({ fieldId }) => sendRequest('FOCUS_FIELD', { field_id: fieldId }), - createField: ({ type, x, y, width, height, page, value }: CreateFieldArgs) => - sendRequest<{ field_id: string }>('CREATE_FIELD', { - type, - x, - y, - width, - height, - page, - value: value ?? null, - }), - submit: ({ downloadCopy }) => sendRequest('SUBMIT', { download_copy: downloadCopy }), + getDocumentContent: (args) => parseAndSend( + GetDocumentContentInput, + 'GET_DOCUMENT_CONTENT', + args, + ), + detectFields: () => sendRequest('DETECT_FIELDS', {}), + deleteFields: (args) => parseAndSend( + DeleteFieldsInput, + 'DELETE_FIELDS', + args, + ), + selectTool: (args) => parseAndSend(SelectToolInput, 'SELECT_TOOL', args), + setFieldValue: (args) => parseAndSend(SetFieldValueInput, 'SET_FIELD_VALUE', args), + focusField: (args) => parseAndSend(FocusFieldInput, 'FOCUS_FIELD', args), + goTo: (args) => parseAndSend(GoToInput, 'GO_TO', args), + movePage: (args) => parseAndSend(MovePageInput, 'MOVE_PAGE', args), + deletePages: (args) => parseAndSend(DeletePagesInput, 'DELETE_PAGES', args), + rotatePage: (args) => parseAndSend(RotatePageInput, 'ROTATE_PAGE', args), + submit: (args) => parseAndSend(SubmitInput, 'SUBMIT', args), download: () => sendRequest('DOWNLOAD', {}), - movePage: ({ fromPage, toPage }) => sendRequest('MOVE_PAGE', { from_page: fromPage, to_page: toPage }), - deletePage: ({ page }) => sendRequest('DELETE_PAGE', { page }), - rotatePage: ({ page }) => sendRequest('ROTATE_PAGE', { page }), } const subscribe = (listener: (nextState: BridgeState) => void): (() => void) => { diff --git a/copilot/src/lib/embed-bridge/index.ts b/copilot/src/lib/embed-bridge/index.ts index 73fdd2e..17d6d58 100644 --- a/copilot/src/lib/embed-bridge/index.ts +++ b/copilot/src/lib/embed-bridge/index.ts @@ -2,17 +2,34 @@ export type { CreateBridgeArgs, EmbedBridge } from './bridge' export { createBridge } from './bridge' export type { BridgeLogger, LogPayload } from './logger' export { NOOP_LOGGER } from './logger' +export { + DeleteFieldsInput, + DeletePagesInput, + DetectFieldsInput, + DownloadInput, + FocusFieldInput, + GetDocumentContentInput, + GetFieldsInput, + GoToInput, + LoadDocumentInput, + MovePageInput, + NoInput, + RotatePageInput, + SelectToolInput, + SetFieldValueInput, + SubmitInput, + SupportedFieldTypeSchema, +} from './schemas' export type { + BridgeErrorCode, BridgeRequestType, BridgeResult, BridgeState, - CreateFieldArgs, DocumentContentPage, DocumentContentResult, FieldRecord, + FocusFieldResult, IframeBridge, - LoadDocumentArgs, - RemoveFieldsArgs, SupportedFieldType, } from './types' export { isBridgeResultLike } from './types' diff --git a/copilot/src/lib/embed-bridge/schemas.ts b/copilot/src/lib/embed-bridge/schemas.ts new file mode 100644 index 0000000..a05b99d --- /dev/null +++ b/copilot/src/lib/embed-bridge/schemas.ts @@ -0,0 +1,111 @@ +import { z } from 'zod' + +// Zod schemas for every iframe operation. The bridge OWNS the contract: +// shape AND description. Adapters (LLM tool calls, React SDK, etc.) +// consume these schemas verbatim — they don't redefine descriptions or +// shapes. Adding a new iframe operation = add a schema here, add the +// matching method to IframeBridge, add the bridge.ts implementation, +// register the LLM tool name in client-tools/schemas.ts. +// +// One file, one schema per operation. The shape is the snake_case payload +// that travels over postMessage; nothing converts keys between layers. + +export const SupportedFieldTypeSchema = z.enum(['TEXT', 'BOXED_TEXT', 'CHECKBOX', 'PICTURE', 'SIGNATURE']) + +export const NoInput = z.object({}) + +export const GetFieldsInput = NoInput.describe('Lists every fillable field currently on the document') + +export const GetDocumentContentInput = z + .object({ + extraction_mode: z.enum(['auto', 'ocr']).default('auto'), + }) + .describe('Returns extracted text per page. Use "ocr" for scanned documents, otherwise "auto"') + +export const DetectFieldsInput = NoInput.describe( + 'Asks the editor to auto-detect and create fields on the document. Use when get_fields returned 0 fields before asking the user to add fields manually.', +) + +export const DeleteFieldsInput = z + .object({ + field_ids: z.array(z.string()).optional().describe('Specific field identifiers to delete (omit to target by page or all)'), + page: z.number().int().positive().optional().describe('1-indexed visible page to clear (omit to target specific ids or all)'), + }) + .describe( + 'Deletes fields from the document. Pass field_ids to delete specific fields, page to clear a single page, or both omitted to delete every field. Destructive: only call when the user explicitly asks.', + ) + +export const SelectToolInput = z + .object({ + tool: SupportedFieldTypeSchema.nullable().describe('Editor tool to activate. Pass null to return to the cursor.'), + }) + .describe( + 'Switches the active editor tool. Use tool="TEXT" for free-form text, "BOXED_TEXT" for box-per-character fields (e.g. IBAN), or any of the other field types to let the user drop fields on a document without native AcroFields.', + ) + +export const SetFieldValueInput = z + .object({ + field_id: z.string().describe('Field identifier from get_fields'), + value: z + .string() + .nullable() + .describe( + 'Value to write. TEXT/BOXED_TEXT: any string. CHECKBOX: "checked" ticks, null un-ticks (never "true"/"false"). Do not use this tool for SIGNATURE or PICTURE fields.', + ), + }) + .describe('Writes a value into a single field in the PDF') + +export const FocusFieldInput = z + .object({ field_id: z.string().describe('Field identifier from get_fields') }) + .describe('Scrolls to and visually highlights a field so the user can see what will be filled next') + +export const GoToInput = z + .object({ page: z.number().int().positive().describe('1-based page number') }) + .describe('Scrolls the editor to a given page') + +export const MovePageInput = z + .object({ + from_page: z.number().int().positive().describe('Visible page to move (1-indexed)'), + to_page: z.number().int().positive().describe('Target visible position (1-indexed)'), + }) + .describe( + 'Reorders pages in the document. Destructive: only call when the user explicitly asks to reorder a page.', + ) + +export const DeletePagesInput = z + .object({ + pages: z + .array(z.number().int().positive()) + .nonempty() + .describe('Visible pages to delete (1-indexed). Must be a non-empty array.'), + }) + .describe( + 'Permanently removes one or more pages (and any fields on them) from the document. Destructive: only call when the user explicitly asks to delete pages. At least one visible page must remain — passing every visible page returns event_not_allowed.', + ) + +export const RotatePageInput = z + .object({ page: z.number().int().positive().describe('Visible page to rotate (1-indexed)') }) + .describe( + 'Rotates a page 90° clockwise. Destructive: only call when the user explicitly asks to rotate a page. Repeat to reach 180° / 270°.', + ) + +export const SubmitInput = z + .object({ download_copy: z.boolean() }) + .describe( + 'Finalizes the filled PDF and submits it to the host application (storage, webhook, etc.). Use only when the user asks to submit or finalize.', + ) + +export const DownloadInput = NoInput.describe( + 'Finalizes the filled PDF and triggers an in-browser download for the user. Use only when the user asks to download.', +) + +// Bridge-only — not exposed as an LLM tool. Direct callers (host apps, +// SDK adapters) load documents via this; the copilot demo itself bootstraps +// from URL params and doesn't currently use it. +export const LoadDocumentInput = z + .object({ + data_url: z.string(), + name: z.string().optional(), + page: z.number().int().positive().optional(), + }) + .describe('Loads a PDF document into the editor by URL or data-URL.') diff --git a/copilot/src/lib/embed-bridge/types.ts b/copilot/src/lib/embed-bridge/types.ts index 1f84c0c..cd3b96c 100644 --- a/copilot/src/lib/embed-bridge/types.ts +++ b/copilot/src/lib/embed-bridge/types.ts @@ -1,9 +1,19 @@ // Shared types for the SimplePDF embed bridge. Pure TypeScript, no // framework dependencies. +// Codes the bridge itself emits. Anything else (`bad_request:invalid_page`, +// `forbidden:editing_not_allowed`, etc.) is forwarded verbatim from the +// iframe handler and flows through as a plain string. The `(string & {})` +// in the union preserves IDE autocomplete for the bridge-owned literals +// while still accepting arbitrary forwarded codes — narrowing on a +// specific iframe code stays the consumer's responsibility. +type BridgeOwnedErrorCode = 'bad_input' | 'bridge_disposed' | 'iframe_not_ready' | 'missing_result' | 'timeout' + +export type BridgeErrorCode = BridgeOwnedErrorCode | (string & {}) + export type BridgeResult = | { success: true; data: TData } - | { success: false; error: { code: string; message: string } } + | { success: false; error: { code: BridgeErrorCode; message: string } } // Runtime guard for BridgeResult shapes received from the iframe. The // postMessage payload is JSON parsed from an untrusted source — same-origin @@ -62,27 +72,6 @@ export type DocumentContentResult = { pages: DocumentContentPage[] } -export type LoadDocumentArgs = { - dataUrl: string - name?: string - initialPage?: number -} - -export type CreateFieldArgs = { - type: SupportedFieldType - x: number - y: number - width: number - height: number - page: number - value?: string | null -} - -export type RemoveFieldsArgs = { - fieldIds?: string[] | null - page?: number | null -} - // State machine. Transitions are strictly forward (booting -> editor_ready -> // document_loaded) except for `editor_ready` -> `editor_ready` on EDITOR_READY // re-emission (fresh iframe, no doc yet). Impossible states like @@ -99,37 +88,39 @@ export type BridgeRequestType = | 'GO_TO' | 'SELECT_TOOL' | 'DETECT_FIELDS' - | 'REMOVE_FIELDS' + | 'DELETE_FIELDS' | 'GET_DOCUMENT_CONTENT' | 'GET_FIELDS' | 'SET_FIELD_VALUE' | 'FOCUS_FIELD' - | 'CREATE_FIELD' | 'SUBMIT' | 'DOWNLOAD' | 'MOVE_PAGE' - | 'DELETE_PAGE' + | 'DELETE_PAGES' | 'ROTATE_PAGE' +export type FocusFieldResult = { hint: { type: 'user_action_expected'; message: string } } | null + +// The bridge owns the contract. Each method takes `unknown` (raw, from any +// caller) and validates it internally against the matching Zod schema in +// schemas.ts before posting to the iframe. Bad input surfaces as +// `{ success: false, error: { code: 'bad_input', ... } }` without a +// postMessage round-trip. Adapters (LLM tool registry, etc.) do not +// re-validate. export type IframeBridge = { getState: () => BridgeState - loadDocument: (args: LoadDocumentArgs) => Promise - goTo: (args: { page: number }) => Promise - selectTool: (args: { tool: SupportedFieldType | null }) => Promise - detectFields: (args?: { debugMode?: boolean }) => Promise> - removeFields: (args?: RemoveFieldsArgs) => Promise> - getDocumentContent: (args: { - extractionMode: 'auto' | 'ocr' - }) => Promise> + loadDocument: (args: unknown) => Promise getFields: () => Promise> - setFieldValue: (args: { fieldId: string; value: string | null }) => Promise - focusField: (args: { - fieldId: string - }) => Promise> - createField: (args: CreateFieldArgs) => Promise> - submit: (args: { downloadCopy: boolean }) => Promise + getDocumentContent: (args: unknown) => Promise> + detectFields: () => Promise> + deleteFields: (args: unknown) => Promise> + selectTool: (args: unknown) => Promise + setFieldValue: (args: unknown) => Promise + focusField: (args: unknown) => Promise> + goTo: (args: unknown) => Promise + movePage: (args: unknown) => Promise + deletePages: (args: unknown) => Promise + rotatePage: (args: unknown) => Promise + submit: (args: unknown) => Promise download: () => Promise - movePage: (args: { fromPage: number; toPage: number }) => Promise - deletePage: (args: { page: number }) => Promise - rotatePage: (args: { page: number }) => Promise } diff --git a/copilot/src/locales/ar.json b/copilot/src/locales/ar.json index ebff4cb..69e0e33 100644 --- a/copilot/src/locales/ar.json +++ b/copilot/src/locales/ar.json @@ -207,10 +207,9 @@ "go_to_page": "جارٍ الانتقال إلى الصفحة", "set_field_value": "جارٍ تعبئة الحقل", "select_tool": "جارٍ تبديل الأداة", - "create_field": "جارٍ إنشاء حقل", - "remove_fields": "جارٍ إزالة الحقول", + "delete_fields": "جارٍ حذف الحقول", "move_page": "جارٍ نقل الصفحة", - "delete_page": "جارٍ حذف الصفحة", + "delete_pages": "جارٍ حذف الصفحات", "rotate_page": "جارٍ تدوير الصفحة", "submit": "جارٍ إرسال النموذج", "download": "جارٍ تحضير التنزيل" diff --git a/copilot/src/locales/cs.json b/copilot/src/locales/cs.json index cb999d9..b0fa61d 100644 --- a/copilot/src/locales/cs.json +++ b/copilot/src/locales/cs.json @@ -198,10 +198,9 @@ "go_to_page": "Přecházím na stránku", "set_field_value": "Vyplňuji pole", "select_tool": "Přepínám nástroj", - "create_field": "Vytvářím pole", - "remove_fields": "Odstraňuji pole", + "delete_fields": "Mažu pole", "move_page": "Přesouvám stránku", - "delete_page": "Mažu stránku", + "delete_pages": "Mažu stránky", "rotate_page": "Otáčím stránku", "submit": "Odesílám formulář", "download": "Připravuji ke stažení" diff --git a/copilot/src/locales/da.json b/copilot/src/locales/da.json index 817bbb5..4190143 100644 --- a/copilot/src/locales/da.json +++ b/copilot/src/locales/da.json @@ -195,10 +195,9 @@ "go_to_page": "Går til siden", "set_field_value": "Udfylder feltet", "select_tool": "Skifter værktøj", - "create_field": "Opretter et felt", - "remove_fields": "Fjerner felter", + "delete_fields": "Sletter felter", "move_page": "Flytter siden", - "delete_page": "Sletter siden", + "delete_pages": "Sletter sider", "rotate_page": "Roterer siden", "submit": "Indsender formularen", "download": "Forbereder download" diff --git a/copilot/src/locales/de.json b/copilot/src/locales/de.json index 0f41bc5..2cd55e3 100644 --- a/copilot/src/locales/de.json +++ b/copilot/src/locales/de.json @@ -195,10 +195,9 @@ "go_to_page": "Wechsel zur Seite", "set_field_value": "Feld wird ausgefüllt", "select_tool": "Werkzeug wird gewechselt", - "create_field": "Feld wird erstellt", - "remove_fields": "Felder werden entfernt", + "delete_fields": "Felder werden gelöscht", "move_page": "Seite wird verschoben", - "delete_page": "Seite wird gelöscht", + "delete_pages": "Seiten werden gelöscht", "rotate_page": "Seite wird gedreht", "submit": "Formular wird gesendet", "download": "Download wird vorbereitet" diff --git a/copilot/src/locales/el.json b/copilot/src/locales/el.json index 5f53c48..43fb8f8 100644 --- a/copilot/src/locales/el.json +++ b/copilot/src/locales/el.json @@ -195,10 +195,9 @@ "go_to_page": "Μετάβαση στη σελίδα", "set_field_value": "Συμπλήρωση πεδίου", "select_tool": "Αλλαγή εργαλείου", - "create_field": "Δημιουργία πεδίου", - "remove_fields": "Αφαίρεση πεδίων", + "delete_fields": "Διαγραφή πεδίων", "move_page": "Μετακίνηση σελίδας", - "delete_page": "Διαγραφή σελίδας", + "delete_pages": "Διαγραφή σελίδων", "rotate_page": "Περιστροφή σελίδας", "submit": "Υποβολή φόρμας", "download": "Προετοιμασία λήψης" diff --git a/copilot/src/locales/en.json b/copilot/src/locales/en.json index 219d109..30677ec 100644 --- a/copilot/src/locales/en.json +++ b/copilot/src/locales/en.json @@ -213,10 +213,9 @@ "go_to_page": "Going to the page", "set_field_value": "Filling the field", "select_tool": "Switching tool", - "create_field": "Creating a field", - "remove_fields": "Removing fields", + "delete_fields": "Deleting fields", "move_page": "Moving the page", - "delete_page": "Deleting the page", + "delete_pages": "Deleting pages", "rotate_page": "Rotating the page", "submit": "Submitting the form", "download": "Preparing the download" diff --git a/copilot/src/locales/es.json b/copilot/src/locales/es.json index 763c0f0..c06fb5d 100644 --- a/copilot/src/locales/es.json +++ b/copilot/src/locales/es.json @@ -195,10 +195,9 @@ "go_to_page": "Yendo a la página", "set_field_value": "Completando el campo", "select_tool": "Cambiando de herramienta", - "create_field": "Creando un campo", - "remove_fields": "Eliminando campos", + "delete_fields": "Eliminando campos", "move_page": "Moviendo la página", - "delete_page": "Eliminando la página", + "delete_pages": "Eliminando páginas", "rotate_page": "Rotando la página", "submit": "Enviando el formulario", "download": "Preparando la descarga" diff --git a/copilot/src/locales/et.json b/copilot/src/locales/et.json index f235a23..13768e9 100644 --- a/copilot/src/locales/et.json +++ b/copilot/src/locales/et.json @@ -195,10 +195,9 @@ "go_to_page": "Lehele minemine", "set_field_value": "Välja täitmine", "select_tool": "Tööriista vahetamine", - "create_field": "Välja loomine", - "remove_fields": "Väljade eemaldamine", + "delete_fields": "Väljade kustutamine", "move_page": "Lehe teisaldamine", - "delete_page": "Lehe kustutamine", + "delete_pages": "Lehtede kustutamine", "rotate_page": "Lehe pööramine", "submit": "Vormi esitamine", "download": "Allalaadimise ettevalmistamine" diff --git a/copilot/src/locales/fi.json b/copilot/src/locales/fi.json index 10a3021..a402e09 100644 --- a/copilot/src/locales/fi.json +++ b/copilot/src/locales/fi.json @@ -195,10 +195,9 @@ "go_to_page": "Siirrytään sivulle", "set_field_value": "Täytetään kenttää", "select_tool": "Vaihdetaan työkalua", - "create_field": "Luodaan kenttä", - "remove_fields": "Poistetaan kenttiä", + "delete_fields": "Poistetaan kenttiä", "move_page": "Siirretään sivua", - "delete_page": "Poistetaan sivua", + "delete_pages": "Poistetaan sivuja", "rotate_page": "Käännetään sivua", "submit": "Lähetetään lomaketta", "download": "Valmistellaan latausta" diff --git a/copilot/src/locales/fr.json b/copilot/src/locales/fr.json index 7301e7d..6c40aff 100644 --- a/copilot/src/locales/fr.json +++ b/copilot/src/locales/fr.json @@ -195,10 +195,9 @@ "go_to_page": "Passage à la page", "set_field_value": "Remplissage du champ", "select_tool": "Changement d’outil", - "create_field": "Création d’un champ", - "remove_fields": "Suppression des champs", + "delete_fields": "Suppression des champs", "move_page": "Déplacement de la page", - "delete_page": "Suppression de la page", + "delete_pages": "Suppression des pages", "rotate_page": "Rotation de la page", "submit": "Envoi du formulaire", "download": "Préparation du téléchargement" diff --git a/copilot/src/locales/he.json b/copilot/src/locales/he.json index db9becc..42efeaf 100644 --- a/copilot/src/locales/he.json +++ b/copilot/src/locales/he.json @@ -198,10 +198,9 @@ "go_to_page": "מעבר לעמוד", "set_field_value": "מילוי שדה", "select_tool": "החלפת כלי", - "create_field": "יצירת שדה", - "remove_fields": "הסרת שדות", + "delete_fields": "מחיקת שדות", "move_page": "העברת העמוד", - "delete_page": "מחיקת העמוד", + "delete_pages": "מחיקת עמודים", "rotate_page": "סיבוב העמוד", "submit": "שליחת הטופס", "download": "הכנה להורדה" diff --git a/copilot/src/locales/hi.json b/copilot/src/locales/hi.json index b4a0c37..1bfec11 100644 --- a/copilot/src/locales/hi.json +++ b/copilot/src/locales/hi.json @@ -195,10 +195,9 @@ "go_to_page": "पृष्ठ पर जा रहा है", "set_field_value": "फ़ील्ड भर रहा है", "select_tool": "टूल बदल रहा है", - "create_field": "फ़ील्ड बना रहा है", - "remove_fields": "फ़ील्ड हटा रहा है", + "delete_fields": "फ़ील्ड हटा रहा है", "move_page": "पृष्ठ स्थानांतरित कर रहा है", - "delete_page": "पृष्ठ हटा रहा है", + "delete_pages": "पृष्ठों को हटा रहा है", "rotate_page": "पृष्ठ घुमा रहा है", "submit": "फ़ॉर्म सबमिट कर रहा है", "download": "डाउनलोड की तैयारी कर रहा है" diff --git a/copilot/src/locales/it.json b/copilot/src/locales/it.json index 6d434cb..963918f 100644 --- a/copilot/src/locales/it.json +++ b/copilot/src/locales/it.json @@ -195,10 +195,9 @@ "go_to_page": "Passaggio alla pagina", "set_field_value": "Compilazione del campo", "select_tool": "Cambio strumento", - "create_field": "Creazione di un campo", - "remove_fields": "Rimozione dei campi", + "delete_fields": "Eliminazione dei campi", "move_page": "Spostamento della pagina", - "delete_page": "Eliminazione della pagina", + "delete_pages": "Eliminazione delle pagine", "rotate_page": "Rotazione della pagina", "submit": "Invio del modulo", "download": "Preparazione del download" diff --git a/copilot/src/locales/nl.json b/copilot/src/locales/nl.json index 2f17a10..079c628 100644 --- a/copilot/src/locales/nl.json +++ b/copilot/src/locales/nl.json @@ -195,10 +195,9 @@ "go_to_page": "Naar de pagina gaan", "set_field_value": "Veld invullen", "select_tool": "Tool wisselen", - "create_field": "Een veld aanmaken", - "remove_fields": "Velden verwijderen", + "delete_fields": "Velden verwijderen", "move_page": "Pagina verplaatsen", - "delete_page": "Pagina verwijderen", + "delete_pages": "Pagina's verwijderen", "rotate_page": "Pagina draaien", "submit": "Formulier verzenden", "download": "Download voorbereiden" diff --git a/copilot/src/locales/no.json b/copilot/src/locales/no.json index a59aa22..07f4dd8 100644 --- a/copilot/src/locales/no.json +++ b/copilot/src/locales/no.json @@ -195,10 +195,9 @@ "go_to_page": "Går til siden", "set_field_value": "Fyller ut feltet", "select_tool": "Bytter verktøy", - "create_field": "Oppretter et felt", - "remove_fields": "Fjerner felt", + "delete_fields": "Sletter felt", "move_page": "Flytter siden", - "delete_page": "Sletter siden", + "delete_pages": "Sletter sider", "rotate_page": "Roterer siden", "submit": "Sender inn skjemaet", "download": "Forbereder nedlasting" diff --git a/copilot/src/locales/pl.json b/copilot/src/locales/pl.json index 135b5da..8c69ace 100644 --- a/copilot/src/locales/pl.json +++ b/copilot/src/locales/pl.json @@ -201,10 +201,9 @@ "go_to_page": "Przechodzenie do strony", "set_field_value": "Wypełnianie pola", "select_tool": "Zmiana narzędzia", - "create_field": "Tworzenie pola", - "remove_fields": "Usuwanie pól", + "delete_fields": "Usuwanie pól", "move_page": "Przenoszenie strony", - "delete_page": "Usuwanie strony", + "delete_pages": "Usuwanie stron", "rotate_page": "Obracanie strony", "submit": "Wysyłanie formularza", "download": "Przygotowywanie pobrania" diff --git a/copilot/src/locales/pt.json b/copilot/src/locales/pt.json index 2064eca..185f2e6 100644 --- a/copilot/src/locales/pt.json +++ b/copilot/src/locales/pt.json @@ -195,10 +195,9 @@ "go_to_page": "A ir para a página", "set_field_value": "A preencher o campo", "select_tool": "A mudar de ferramenta", - "create_field": "A criar um campo", - "remove_fields": "A remover campos", + "delete_fields": "A eliminar campos", "move_page": "A mover a página", - "delete_page": "A eliminar a página", + "delete_pages": "A eliminar as páginas", "rotate_page": "A rodar a página", "submit": "A submeter o formulário", "download": "A preparar a transferência" diff --git a/copilot/src/locales/ro.json b/copilot/src/locales/ro.json index bbb3627..b97cc5a 100644 --- a/copilot/src/locales/ro.json +++ b/copilot/src/locales/ro.json @@ -198,10 +198,9 @@ "go_to_page": "Se trece la pagină", "set_field_value": "Se completează câmpul", "select_tool": "Se schimbă instrumentul", - "create_field": "Se creează un câmp", - "remove_fields": "Se elimină câmpurile", + "delete_fields": "Se șterg câmpurile", "move_page": "Se mută pagina", - "delete_page": "Se șterge pagina", + "delete_pages": "Se șterg paginile", "rotate_page": "Se roteşte pagina", "submit": "Se trimite formularul", "download": "Se pregătește descărcarea" diff --git a/copilot/src/locales/sv.json b/copilot/src/locales/sv.json index a956d2c..6be7aab 100644 --- a/copilot/src/locales/sv.json +++ b/copilot/src/locales/sv.json @@ -195,10 +195,9 @@ "go_to_page": "Går till sidan", "set_field_value": "Fyller i fältet", "select_tool": "Byter verktyg", - "create_field": "Skapar ett fält", - "remove_fields": "Tar bort fält", + "delete_fields": "Tar bort fält", "move_page": "Flyttar sidan", - "delete_page": "Tar bort sidan", + "delete_pages": "Tar bort sidor", "rotate_page": "Roterar sidan", "submit": "Skickar in formuläret", "download": "Förbereder nedladdning" diff --git a/copilot/src/locales/tr.json b/copilot/src/locales/tr.json index 7839559..1b34f68 100644 --- a/copilot/src/locales/tr.json +++ b/copilot/src/locales/tr.json @@ -195,10 +195,9 @@ "go_to_page": "Sayfaya gidiliyor", "set_field_value": "Alan dolduruluyor", "select_tool": "Araç değiştiriliyor", - "create_field": "Alan oluşturuluyor", - "remove_fields": "Alanlar kaldırılıyor", + "delete_fields": "Alanlar siliniyor", "move_page": "Sayfa taşınıyor", - "delete_page": "Sayfa siliniyor", + "delete_pages": "Sayfalar siliniyor", "rotate_page": "Sayfa döndürülüyor", "submit": "Form gönderiliyor", "download": "İndirme hazırlanıyor" diff --git a/copilot/src/locales/uk.json b/copilot/src/locales/uk.json index 49b7004..b14cd9b 100644 --- a/copilot/src/locales/uk.json +++ b/copilot/src/locales/uk.json @@ -201,10 +201,9 @@ "go_to_page": "Перехід на сторінку", "set_field_value": "Заповнення поля", "select_tool": "Зміна інструмента", - "create_field": "Створення поля", - "remove_fields": "Видалення полів", + "delete_fields": "Видалення полів", "move_page": "Переміщення сторінки", - "delete_page": "Видалення сторінки", + "delete_pages": "Видалення сторінок", "rotate_page": "Обертання сторінки", "submit": "Надсилання форми", "download": "Підготовка завантаження" diff --git a/copilot/src/locales/vi.json b/copilot/src/locales/vi.json index 8ca0ce6..a46d6f6 100644 --- a/copilot/src/locales/vi.json +++ b/copilot/src/locales/vi.json @@ -193,10 +193,9 @@ "go_to_page": "Đang chuyển đến trang", "set_field_value": "Đang điền trường", "select_tool": "Đang chuyển công cụ", - "create_field": "Đang tạo trường", - "remove_fields": "Đang xóa các trường", + "delete_fields": "Đang xóa các trường", "move_page": "Đang di chuyển trang", - "delete_page": "Đang xóa trang", + "delete_pages": "Đang xóa các trang", "rotate_page": "Đang xoay trang", "submit": "Đang gửi biểu mẫu", "download": "Đang chuẩn bị tải xuống" diff --git a/copilot/src/locales/zh.json b/copilot/src/locales/zh.json index 1f3d333..fc0e3ce 100644 --- a/copilot/src/locales/zh.json +++ b/copilot/src/locales/zh.json @@ -193,10 +193,9 @@ "go_to_page": "正在跳转页面", "set_field_value": "正在填写字段", "select_tool": "正在切换工具", - "create_field": "正在创建字段", - "remove_fields": "正在删除字段", + "delete_fields": "正在删除字段", "move_page": "正在移动页面", - "delete_page": "正在删除页面", + "delete_pages": "正在删除页面", "rotate_page": "正在旋转页面", "submit": "正在提交表单", "download": "正在准备下载" diff --git a/copilot/src/routes/api/chat.ts b/copilot/src/routes/api/chat.ts index e262a03..40bca85 100644 --- a/copilot/src/routes/api/chat.ts +++ b/copilot/src/routes/api/chat.ts @@ -3,18 +3,8 @@ import { convertToModelMessages, streamText, type UIMessage } from 'ai' import type { ServerErrorBody } from '../../lib/api_envelope' import { DEMO_MODELS } from '../../lib/demo/demo_model' import { - DeletePageInput, - DetectFieldsInput, FINALISATION_ACTION, - FocusFieldInput, - GetDocumentContentInput, - GetFieldsInput, - GoToPageInput, - MovePageInput, - RemoveFieldsInput, - RotatePageInput, - SelectToolInput, - SetFieldValueInput, + LLM_STATIC_TOOLS, withFinalisationTool, } from '../../lib/embed-bridge-adapters/client-tools' import { monitoring, normalizeError } from '../../lib/monitoring' @@ -168,58 +158,7 @@ export const Route = createFileRoute('/api/chat')({ ], maxRetries: 0, maxOutputTokens: 500, - tools: withFinalisationTool({ - get_fields: { - description: 'Lists every fillable field currently on the document.', - inputSchema: GetFieldsInput, - }, - get_document_content: { - description: 'Extracts the textual content of the document page by page.', - inputSchema: GetDocumentContentInput, - }, - detect_fields: { - description: - 'Asks the editor to auto-detect and create missing fields. Call this when get_fields returned 0 fields.', - inputSchema: DetectFieldsInput, - }, - remove_fields: { - description: - 'Removes fields from the document. field_ids targets specific fields by id; page targets a single page (1-indexed); both omitted clears all fields. Destructive — only call when the user explicitly asks to remove fields.', - inputSchema: RemoveFieldsInput, - }, - select_tool: { - description: - 'Switches the editor tool (TEXT, BOXED_TEXT, CHECKBOX, SIGNATURE, PICTURE, or null for cursor). Use TEXT to invite the user to drop fields on a scanned document that has no native fields.', - inputSchema: SelectToolInput, - }, - set_field_value: { - description: 'Writes a value into a single field. Always focus_field first.', - inputSchema: SetFieldValueInput, - }, - focus_field: { - description: 'Scrolls to and visually highlights a field.', - inputSchema: FocusFieldInput, - }, - go_to_page: { - description: 'Scrolls the editor to a given 1-based page.', - inputSchema: GoToPageInput, - }, - move_page: { - description: - 'Reorders pages: from_page and to_page are 1-indexed visible page positions. Destructive — only call when the user explicitly asks to reorder a page.', - inputSchema: MovePageInput, - }, - delete_page: { - description: - 'Permanently removes a visible page (1-indexed) and any fields placed on it. The last remaining page cannot be deleted. Destructive — only call when the user explicitly asks to delete a page.', - inputSchema: DeletePageInput, - }, - rotate_page: { - description: - 'Rotates a visible page (1-indexed) 90° clockwise per call (repeat for 180° / 270°). Destructive — only call when the user explicitly asks to rotate a page.', - inputSchema: RotatePageInput, - }, - }), + tools: withFinalisationTool(LLM_STATIC_TOOLS), abortSignal: AbortSignal.timeout(MAX_DURATION_MS), onFinish: ({ usage }) => { monitoring.info('chat.finished', { diff --git a/copilot/src/server/tools.ts b/copilot/src/server/tools.ts index f40cd00..80525cb 100644 --- a/copilot/src/server/tools.ts +++ b/copilot/src/server/tools.ts @@ -137,9 +137,9 @@ Handling tool errors: - For a failed ${action.toolName}: ask the user to try again in a moment, or to press the editor's save button directly. - Never expose raw error codes, stack traces, or schema details to the user — surface only the human-level alternative. -Page actions (move_page, delete_page, rotate_page) — NEVER unsolicited: -- These tools mutate the document structure. Only call them when the user explicitly asks ("delete page 3", "rotate page 2", "move page 1 to the end", "swap these two pages"). -- delete_page is irreversible: any fields on the deleted page disappear with it. The last remaining visible page cannot be deleted; the editor will refuse and return event_not_allowed. +Page actions (move_page, delete_pages, rotate_page) — NEVER unsolicited: +- These tools mutate the document structure. Only call them when the user explicitly asks ("delete page 3", "delete pages 2 and 4", "rotate page 2", "move page 1 to the end", "swap these two pages"). +- delete_pages is irreversible: any fields on the deleted pages disappear with them. Pass pages as a non-empty array of 1-indexed visible positions. At least one visible page must remain — passing every visible page returns event_not_allowed. Batch a single multi-page delete into one call ("delete pages 2 and 4" → delete_pages with pages=[2, 4]) rather than calling the tool per page. - rotate_page rotates 90° clockwise per call; if the user asks for 180° or 270°, repeat the call. - move_page accepts visible page positions (1-indexed). "Move page 1 to position 4" → from_page=1, to_page=4. - If you are unsure whether the user is asking for a page mutation or a navigation, ask one short clarifying question — do NOT mutate to be helpful. diff --git a/documentation/IFRAME.md b/documentation/IFRAME.md index 6f69ba9..4fc13af 100644 --- a/documentation/IFRAME.md +++ b/documentation/IFRAME.md @@ -188,12 +188,12 @@ await sendEvent("SELECT_TOOL", { tool: "TEXT" }); // or "CHECKBOX", "SIGNATURE", // Detect fields in the document await sendEvent("DETECT_FIELDS", {}); -// Remove all fields (or specific ones) -await sendEvent("REMOVE_FIELDS", {}); // Remove all -await sendEvent("REMOVE_FIELDS", { page: 1 }); // Remove page 1 only -await sendEvent("REMOVE_FIELDS", { +// Delete all fields (or specific ones) +await sendEvent("DELETE_FIELDS", {}); // Delete all +await sendEvent("DELETE_FIELDS", { page: 1 }); // Delete page 1 only +await sendEvent("DELETE_FIELDS", { field_ids: ["f_kj8n2hd9x3m1p", "f_q7v5c4b6a0wyz"], -}); // Remove specific fields +}); // Delete specific fields // Extract document content const content = await sendEvent("GET_DOCUMENT_CONTENT", { @@ -208,8 +208,9 @@ await sendEvent("SUBMIT", { download_copy: true }); // Move a visible page (1-indexed) to a new position await sendEvent("MOVE_PAGE", { from_page: 2, to_page: 5 }); -// Delete a visible page (1-indexed). The last remaining visible page cannot be deleted -await sendEvent("DELETE_PAGE", { page: 3 }); +// Delete one or more visible pages (1-indexed). At least one visible page must remain +await sendEvent("DELETE_PAGES", { pages: [3] }); +await sendEvent("DELETE_PAGES", { pages: [2, 4, 6] }); // Rotate a visible page (1-indexed) 90° clockwise await sendEvent("ROTATE_PAGE", { page: 1 }); @@ -297,20 +298,20 @@ Automatically detect form fields in the document. _No data fields required._ -#### REMOVE_FIELDS +#### DELETE_FIELDS -Remove fields from the document. +Delete fields from the document. -| Field | Type | Required | Description | -| ----------- | ---------- | -------- | ------------------------------------------------ | -| `field_ids` | `string[]` | No | Specific field IDs to remove (omit to remove all) | -| `page` | `number` | No | Only remove fields on this page | +| Field | Type | Required | Description | +| ----------- | ---------- | -------- | ------------------------------------------------- | +| `field_ids` | `string[]` | No | Specific field IDs to delete (omit to delete all) | +| `page` | `number` | No | Only delete fields on this page | **Response data:** ```json { - "removed_count": 5 + "deleted_count": 5 } ``` @@ -353,13 +354,19 @@ Reorder a visible page. Both positions are 1-indexed visible-page numbers (match | `from_page` | `number` | Yes | Visible page to move (1-indexed) | | `to_page` | `number` | Yes | Target visible position (1-indexed) | -#### DELETE_PAGE +#### DELETE_PAGES -Delete a visible page and any fields placed on it. The last remaining visible page cannot be deleted; doing so returns `bad_request:event_not_allowed`. +Delete one or more visible pages and any fields placed on them. Visible-page positions are resolved to absolute page numbers before any deletion runs, so passing multiple pages in one call is safe regardless of intermediate index shifts. -| Field | Type | Required | Description | -| ------ | -------- | -------- | ------------------------------------ | -| `page` | `number` | Yes | Visible page to delete (1-indexed) | +| Field | Type | Required | Description | +| ------- | ---------- | -------- | ---------------------------------------------------- | +| `pages` | `number[]` | Yes | Non-empty array of visible pages to delete (1-indexed) | + +Validation: + +- Empty `pages` returns `bad_request:invalid_page`. +- Any out-of-range or non-integer value returns `bad_request:page_out_of_range` / `bad_request:invalid_page`. +- `pages.length >= total_visible_pages` returns `bad_request:event_not_allowed` — at least one visible page must remain. #### ROTATE_PAGE diff --git a/react/README.md b/react/README.md index 82a524f..1e2b5d7 100644 --- a/react/README.md +++ b/react/README.md @@ -142,7 +142,7 @@ Use `const { embedRef, actions } = useEmbed();` to programmatically control the | `actions.goTo({ page })` | Navigate to a specific page | | `actions.selectTool(toolType)` | Select a tool: `'TEXT'`, `'BOXED_TEXT'`, `'CHECKBOX'`, `'PICTURE'`, `'SIGNATURE'`, or `null` to deselect (`CURSOR`) | | `actions.detectFields()` | Automatically detect form fields in the document | -| `actions.removeFields(options?)` | Remove fields by `fieldIds` or `page`, or all fields if no options | +| `actions.deleteFields(options?)` | Delete fields by `fieldIds` or `page`, or all fields if no options | | `actions.getDocumentContent({ extractionMode })` | Extract document content (`extractionMode: 'auto'` or `'ocr'`) | | `actions.submit({ downloadCopyOnDevice })` | Submit the document | diff --git a/react/src/hook.test.ts b/react/src/hook.test.ts index 36d7226..ec43ef8 100644 --- a/react/src/hook.test.ts +++ b/react/src/hook.test.ts @@ -253,9 +253,9 @@ describe('useEmbed', () => { expect(actionResult).toEqual(expectedError); }); - it('removeFields returns error when embedRef not attached', async () => { + it('deleteFields returns error when embedRef not attached', async () => { const { result } = renderHook(() => useEmbed()); - const actionResult = await result.current.actions.removeFields({}); + const actionResult = await result.current.actions.deleteFields({}); expect(actionResult).toEqual(expectedError); }); @@ -281,7 +281,7 @@ describe('useEmbed', () => { goTo: vi.fn().mockResolvedValue({ success: true }), selectTool: vi.fn().mockResolvedValue({ success: true }), detectFields: vi.fn().mockResolvedValue({ success: true }), - removeFields: vi.fn().mockResolvedValue({ success: true }), + deleteFields: vi.fn().mockResolvedValue({ success: true }), getDocumentContent: vi.fn().mockResolvedValue({ success: true }), submit: vi.fn().mockResolvedValue({ success: true }), }; @@ -325,14 +325,14 @@ describe('useEmbed', () => { expect(actionResult).toEqual({ success: true }); }); - it('removeFields delegates to ref.removeFields', async () => { + it('deleteFields delegates to ref.deleteFields', async () => { const { result } = renderHook(() => useEmbed()); const { ref, spies } = createMockEmbedRef(); (result.current.embedRef as React.MutableRefObject).current = ref; - const actionResult = await result.current.actions.removeFields({}); + const actionResult = await result.current.actions.deleteFields({}); - expect(spies.removeFields).toHaveBeenCalledWith({}); + expect(spies.deleteFields).toHaveBeenCalledWith({}); expect(actionResult).toEqual({ success: true }); }); @@ -403,12 +403,12 @@ describe('Type assertions', () => { expectTypeOf().returns.resolves.toExtend(); }); - it('removeFields accepts optional { fieldIds?, page? } and returns ActionResult with removed_count', () => { - expectTypeOf() + it('deleteFields accepts optional { fieldIds?, page? } and returns ActionResult with deleted_count', () => { + expectTypeOf() .parameter(0) .toEqualTypeOf<{ fieldIds?: string[]; page?: number } | undefined>(); - expectTypeOf().returns.resolves.toExtend< - ExpectedActionResult<{ removed_count: number }> + expectTypeOf().returns.resolves.toExtend< + ExpectedActionResult<{ deleted_count: number }> >(); }); diff --git a/react/src/hook.tsx b/react/src/hook.tsx index e005dbe..84709e0 100644 --- a/react/src/hook.tsx +++ b/react/src/hook.tsx @@ -28,8 +28,8 @@ type DocumentContentResult = { pages: DocumentContentPage[]; }; -type RemoveFieldsResult = { - removed_count: number; +type DeleteFieldsResult = { + deleted_count: number; }; export type EmbedActions = { @@ -39,7 +39,7 @@ export type EmbedActions = { detectFields: () => Promise; - removeFields: (options?: { fieldIds?: string[]; page?: number }) => Promise>; + deleteFields: (options?: { fieldIds?: string[]; page?: number }) => Promise>; getDocumentContent: (options: { extractionMode: ExtractionMode }) => Promise>; @@ -150,9 +150,9 @@ export const useEmbed = (): { embedRef: React.RefObject; ac [], ); - const handleRemoveFields = React.useCallback( - createAction<[{ fieldIds?: string[]; page?: number }?], RemoveFieldsResult>(async (ref, options) => { - return ref.removeFields(options); + const handleDeleteFields = React.useCallback( + createAction<[{ fieldIds?: string[]; page?: number }?], DeleteFieldsResult>(async (ref, options) => { + return ref.deleteFields(options); }), [], ); @@ -177,7 +177,7 @@ export const useEmbed = (): { embedRef: React.RefObject; ac goTo: handleGoTo, selectTool: handleSelectTool, detectFields: handleDetectFields, - removeFields: handleRemoveFields, + deleteFields: handleDeleteFields, getDocumentContent: handleGetDocumentContent, submit: handleSubmit, }, diff --git a/react/src/index.test.tsx b/react/src/index.test.tsx index 087bf96..c7542ad 100644 --- a/react/src/index.test.tsx +++ b/react/src/index.test.tsx @@ -312,7 +312,7 @@ describe('EmbedPDF', () => { expect(typeof ref.current?.goTo).toBe('function'); expect(typeof ref.current?.selectTool).toBe('function'); expect(typeof ref.current?.detectFields).toBe('function'); - expect(typeof ref.current?.removeFields).toBe('function'); + expect(typeof ref.current?.deleteFields).toBe('function'); expect(typeof ref.current?.getDocumentContent).toBe('function'); expect(typeof ref.current?.submit).toBe('function'); }); @@ -322,7 +322,7 @@ describe('EmbedPDF', () => { { action: 'goTo' as const, args: { page: 1 } }, { action: 'selectTool' as const, args: 'TEXT' as const }, { action: 'detectFields' as const, args: undefined }, - { action: 'removeFields' as const, args: {} }, + { action: 'deleteFields' as const, args: {} }, { action: 'getDocumentContent' as const, args: {} }, { action: 'submit' as const, args: { downloadCopyOnDevice: false } }, ])('$action returns error when iframe not available (modal not opened)', async ({ action, args }) => { diff --git a/react/src/index.tsx b/react/src/index.tsx index bb27596..f8e53b7 100644 --- a/react/src/index.tsx +++ b/react/src/index.tsx @@ -189,13 +189,13 @@ export const EmbedPDF = React.forwardRef((props, ref) => { }); }, []); - const removeFields: EmbedActions['removeFields'] = React.useCallback(async (options) => { + const deleteFields: EmbedActions['deleteFields'] = React.useCallback(async (options) => { if (!iframeRef.current) { return { success: false, error: { code: 'unexpected:iframe_not_available', message: 'Iframe not available' } }; } await ensureEditorReady(); return sendEvent(iframeRef.current, { - type: 'REMOVE_FIELDS', + type: 'DELETE_FIELDS', data: { field_ids: options?.fieldIds, page: options?.page }, }); }, []); @@ -227,7 +227,7 @@ export const EmbedPDF = React.forwardRef((props, ref) => { goTo, selectTool, detectFields, - removeFields, + deleteFields, getDocumentContent, submit, }));