From d9caf1689e0743e3e6ba069b9c250d23887e01d6 Mon Sep 17 00:00:00 2001 From: ben Date: Mon, 27 Apr 2026 17:33:22 +0200 Subject: [PATCH 01/11] feat: rename REMOVE_FIELDS->DELETE_FIELDS, DELETE_PAGE->DELETE_PAGES (array) Iframe contract: - REMOVE_FIELDS -> DELETE_FIELDS; response field removed_count -> deleted_count - DELETE_PAGE { page } -> DELETE_PAGES { pages: number[] } (non-empty) Validation: empty -> invalid_page; pages.length >= visible -> event_not_allowed (last-page guard upfront); per-element invalid_page / page_out_of_range. Visible-page positions resolved to absolute page numbers BEFORE deletion so multi-page batches stay consistent across mid-loop index shifts. Copilot: - Tool registry: remove_fields -> delete_fields, delete_page -> delete_pages - Bridge: removeFields -> deleteFields, deletePage -> deletePages - 23 locales: rename keys + plural-aware "Deleting pages" copy - Drop dead `chat.toolInvocation.names.create_field` key (LLM never calls create_field; the matching createLlmFieldBaselineMiddleware was unreachable and is removed) - System prompt updated to encourage batched delete_pages calls React SDK (BREAKING): - actions.removeFields -> actions.deleteFields - RemoveFieldsResult.removed_count -> DeleteFieldsResult.deleted_count - Internal postMessage type literal updated; changeset added (major bump) embed/dev: panel rows updated; DELETE_PAGES accepts comma-separated input. documentation/IFRAME.md: section + payload + response shape rewritten. --- .../rename-remove-fields-to-delete-fields.md | 21 +++++++++ copilot/src/components/chat/chat_pane.tsx | 36 +++------------ .../chat/hooks/use_detect_user_added_field.ts | 25 +---------- copilot/src/lib/byok/transport.ts | 16 +++---- .../client-tools/dispatch.ts | 18 +++++--- .../client-tools/index.ts | 4 +- .../client-tools/schemas.ts | 25 ++++++----- copilot/src/lib/embed-bridge/bridge.ts | 12 ++--- copilot/src/lib/embed-bridge/index.ts | 2 +- copilot/src/lib/embed-bridge/types.ts | 10 ++--- copilot/src/locales/ar.json | 5 +-- copilot/src/locales/cs.json | 5 +-- copilot/src/locales/da.json | 5 +-- copilot/src/locales/de.json | 5 +-- copilot/src/locales/el.json | 5 +-- copilot/src/locales/en.json | 5 +-- copilot/src/locales/es.json | 5 +-- copilot/src/locales/et.json | 5 +-- copilot/src/locales/fi.json | 5 +-- copilot/src/locales/fr.json | 5 +-- copilot/src/locales/he.json | 5 +-- copilot/src/locales/hi.json | 5 +-- copilot/src/locales/it.json | 5 +-- copilot/src/locales/nl.json | 5 +-- copilot/src/locales/no.json | 5 +-- copilot/src/locales/pl.json | 5 +-- copilot/src/locales/pt.json | 5 +-- copilot/src/locales/ro.json | 5 +-- copilot/src/locales/sv.json | 5 +-- copilot/src/locales/tr.json | 5 +-- copilot/src/locales/uk.json | 5 +-- copilot/src/locales/vi.json | 5 +-- copilot/src/locales/zh.json | 5 +-- copilot/src/routes/api/chat.ts | 16 +++---- copilot/src/server/tools.ts | 6 +-- documentation/IFRAME.md | 45 +++++++++++-------- react/README.md | 2 +- react/src/hook.test.ts | 20 ++++----- react/src/hook.tsx | 14 +++--- react/src/index.test.tsx | 4 +- react/src/index.tsx | 6 +-- 41 files changed, 183 insertions(+), 214 deletions(-) create mode 100644 .changeset/rename-remove-fields-to-delete-fields.md 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..56c1298 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 { @@ -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..ccf7dad 100644 --- a/copilot/src/lib/byok/transport.ts +++ b/copilot/src/lib/byok/transport.ts @@ -1,7 +1,8 @@ import { convertToModelMessages, streamText, type UIMessage } from 'ai' import { buildSystemPrompt } from '../../server/tools' import { - DeletePageInput, + DeleteFieldsInput, + DeletePagesInput, DetectFieldsInput, FINALISATION_ACTION, FocusFieldInput, @@ -9,7 +10,6 @@ import { GetFieldsInput, GoToPageInput, MovePageInput, - RemoveFieldsInput, RotatePageInput, SelectToolInput, SetFieldValueInput, @@ -107,10 +107,10 @@ export const runByokStream = async ({ config, init }: RunByokStreamArgs): Promis 'Asks the editor to auto-detect and create missing fields. Call this when get_fields returned 0 fields.', inputSchema: DetectFieldsInput, }, - remove_fields: { + delete_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, + 'Deletes 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 delete fields.', + inputSchema: DeleteFieldsInput, }, select_tool: { description: @@ -134,10 +134,10 @@ export const runByokStream = async ({ config, init }: RunByokStreamArgs): Promis '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: { + delete_pages: { 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, + 'Permanently removes one or more visible pages (1-indexed) and any fields placed on them. Pass pages as a non-empty array. At least one visible page must remain — passing every visible page returns event_not_allowed. Destructive — only call when the user explicitly asks to delete pages.', + inputSchema: DeletePagesInput, }, rotate_page: { description: diff --git a/copilot/src/lib/embed-bridge-adapters/client-tools/dispatch.ts b/copilot/src/lib/embed-bridge-adapters/client-tools/dispatch.ts index 5056330..097dae3 100644 --- a/copilot/src/lib/embed-bridge-adapters/client-tools/dispatch.ts +++ b/copilot/src/lib/embed-bridge-adapters/client-tools/dispatch.ts @@ -31,7 +31,7 @@ export const dispatch = async ( } case 'detect_fields': return bridge.detectFields() - case 'remove_fields': { + case 'delete_fields': { const rawIds = input.field_ids const fieldIds = ((): string[] | null | 'invalid' => { if (rawIds === undefined || rawIds === null) { @@ -49,7 +49,7 @@ export const dispatch = async ( } } const page = typeof input.page === 'number' ? input.page : null - return bridge.removeFields({ fieldIds, page }) + return bridge.deleteFields({ fieldIds, page }) } case 'select_tool': { const rawTool = input.tool @@ -104,15 +104,19 @@ export const dispatch = async ( } return bridge.movePage({ fromPage, toPage }) } - case 'delete_page': { - const page = typeof input.page === 'number' ? input.page : null - if (page === null) { + case 'delete_pages': { + const rawPages = input.pages + if ( + !Array.isArray(rawPages) || + rawPages.length === 0 || + !rawPages.every((page): page is number => typeof page === 'number' && Number.isInteger(page) && page > 0) + ) { return { success: false, - error: { code: 'bad_input', message: 'page must be a number' }, + error: { code: 'bad_input', message: 'pages must be a non-empty array of positive integers' }, } } - return bridge.deletePage({ page }) + return bridge.deletePages({ pages: rawPages }) } case 'rotate_page': { const page = typeof input.page === 'number' ? input.page : null 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..8866318 100644 --- a/copilot/src/lib/embed-bridge-adapters/client-tools/index.ts +++ b/copilot/src/lib/embed-bridge-adapters/client-tools/index.ts @@ -9,7 +9,8 @@ export { composeMiddleware } from './middleware' export type { ClientToolName } from './schemas' export { CLIENT_TOOL_SCHEMAS, - DeletePageInput, + DeleteFieldsInput, + DeletePagesInput, DetectFieldsInput, DownloadInput, FocusFieldInput, @@ -18,7 +19,6 @@ export { GoToPageInput, isClientToolName, MovePageInput, - RemoveFieldsInput, RotatePageInput, SelectToolInput, SetFieldValueInput, diff --git a/copilot/src/lib/embed-bridge-adapters/client-tools/schemas.ts b/copilot/src/lib/embed-bridge-adapters/client-tools/schemas.ts index 293002d..83f9225 100644 --- a/copilot/src/lib/embed-bridge-adapters/client-tools/schemas.ts +++ b/copilot/src/lib/embed-bridge-adapters/client-tools/schemas.ts @@ -18,13 +18,13 @@ export const DetectFieldsInput = z '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 +export const DeleteFieldsInput = z .object({ - field_ids: z.array(z.string()).optional().describe('Specific field identifiers to remove (omit to target by page or all)'), + 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( - '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.', + '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.', ) // Aligned with the bridge's SupportedFieldType. The LLM may pick any of @@ -70,10 +70,15 @@ export const MovePageInput = z '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)') }) +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 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.', + '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 @@ -98,13 +103,13 @@ export const CLIENT_TOOL_NAMES = [ 'get_fields', 'get_document_content', 'detect_fields', - 'remove_fields', + 'delete_fields', 'select_tool', 'set_field_value', 'focus_field', 'go_to_page', 'move_page', - 'delete_page', + 'delete_pages', 'rotate_page', 'submit', 'download', @@ -122,13 +127,13 @@ export const CLIENT_TOOL_SCHEMAS = { get_fields: GetFieldsInput, get_document_content: GetDocumentContentInput, detect_fields: DetectFieldsInput, - remove_fields: RemoveFieldsInput, + delete_fields: DeleteFieldsInput, select_tool: SelectToolInput, set_field_value: SetFieldValueInput, focus_field: FocusFieldInput, go_to_page: GoToPageInput, move_page: MovePageInput, - delete_page: DeletePageInput, + delete_pages: DeletePagesInput, rotate_page: RotatePageInput, submit: SubmitInput, download: DownloadInput, diff --git a/copilot/src/lib/embed-bridge/bridge.ts b/copilot/src/lib/embed-bridge/bridge.ts index 77ec61b..8653426 100644 --- a/copilot/src/lib/embed-bridge/bridge.ts +++ b/copilot/src/lib/embed-bridge/bridge.ts @@ -9,7 +9,7 @@ import { type IframeBridge, isBridgeResultLike, type LoadDocumentArgs, - type RemoveFieldsArgs, + type DeleteFieldsArgs, } from './types' type PendingRequest = { @@ -35,14 +35,14 @@ const getRequestTimeoutMs = (requestType: BridgeRequestType): number => { 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': @@ -379,8 +379,8 @@ export const createBridge = ({ 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', { + deleteFields: (args?: DeleteFieldsArgs) => + sendRequest('DELETE_FIELDS', { field_ids: args?.fieldIds ?? null, page: args?.page ?? null, }), @@ -402,7 +402,7 @@ export const createBridge = ({ submit: ({ downloadCopy }) => sendRequest('SUBMIT', { download_copy: downloadCopy }), download: () => sendRequest('DOWNLOAD', {}), movePage: ({ fromPage, toPage }) => sendRequest('MOVE_PAGE', { from_page: fromPage, to_page: toPage }), - deletePage: ({ page }) => sendRequest('DELETE_PAGE', { page }), + deletePages: ({ pages }) => sendRequest('DELETE_PAGES', { pages }), rotatePage: ({ page }) => sendRequest('ROTATE_PAGE', { page }), } diff --git a/copilot/src/lib/embed-bridge/index.ts b/copilot/src/lib/embed-bridge/index.ts index 73fdd2e..6e90506 100644 --- a/copilot/src/lib/embed-bridge/index.ts +++ b/copilot/src/lib/embed-bridge/index.ts @@ -7,12 +7,12 @@ export type { BridgeResult, BridgeState, CreateFieldArgs, + DeleteFieldsArgs, DocumentContentPage, DocumentContentResult, FieldRecord, IframeBridge, LoadDocumentArgs, - RemoveFieldsArgs, SupportedFieldType, } from './types' export { isBridgeResultLike } from './types' diff --git a/copilot/src/lib/embed-bridge/types.ts b/copilot/src/lib/embed-bridge/types.ts index 1f84c0c..fb5eff1 100644 --- a/copilot/src/lib/embed-bridge/types.ts +++ b/copilot/src/lib/embed-bridge/types.ts @@ -78,7 +78,7 @@ export type CreateFieldArgs = { value?: string | null } -export type RemoveFieldsArgs = { +export type DeleteFieldsArgs = { fieldIds?: string[] | null page?: number | null } @@ -99,7 +99,7 @@ export type BridgeRequestType = | 'GO_TO' | 'SELECT_TOOL' | 'DETECT_FIELDS' - | 'REMOVE_FIELDS' + | 'DELETE_FIELDS' | 'GET_DOCUMENT_CONTENT' | 'GET_FIELDS' | 'SET_FIELD_VALUE' @@ -108,7 +108,7 @@ export type BridgeRequestType = | 'SUBMIT' | 'DOWNLOAD' | 'MOVE_PAGE' - | 'DELETE_PAGE' + | 'DELETE_PAGES' | 'ROTATE_PAGE' export type IframeBridge = { @@ -117,7 +117,7 @@ export type IframeBridge = { goTo: (args: { page: number }) => Promise selectTool: (args: { tool: SupportedFieldType | null }) => Promise detectFields: (args?: { debugMode?: boolean }) => Promise> - removeFields: (args?: RemoveFieldsArgs) => Promise> + deleteFields: (args?: DeleteFieldsArgs) => Promise> getDocumentContent: (args: { extractionMode: 'auto' | 'ocr' }) => Promise> @@ -130,6 +130,6 @@ export type IframeBridge = { submit: (args: { downloadCopy: boolean }) => Promise download: () => Promise movePage: (args: { fromPage: number; toPage: number }) => Promise - deletePage: (args: { page: number }) => Promise + deletePages: (args: { pages: 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..0343f44 100644 --- a/copilot/src/routes/api/chat.ts +++ b/copilot/src/routes/api/chat.ts @@ -3,7 +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, + DeleteFieldsInput, + DeletePagesInput, DetectFieldsInput, FINALISATION_ACTION, FocusFieldInput, @@ -11,7 +12,6 @@ import { GetFieldsInput, GoToPageInput, MovePageInput, - RemoveFieldsInput, RotatePageInput, SelectToolInput, SetFieldValueInput, @@ -182,10 +182,10 @@ export const Route = createFileRoute('/api/chat')({ 'Asks the editor to auto-detect and create missing fields. Call this when get_fields returned 0 fields.', inputSchema: DetectFieldsInput, }, - remove_fields: { + delete_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, + 'Deletes 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 delete fields.', + inputSchema: DeleteFieldsInput, }, select_tool: { description: @@ -209,10 +209,10 @@ export const Route = createFileRoute('/api/chat')({ '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: { + delete_pages: { 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, + 'Permanently removes one or more visible pages (1-indexed) and any fields placed on them. Pass pages as a non-empty array. At least one visible page must remain — passing every visible page returns event_not_allowed. Destructive — only call when the user explicitly asks to delete pages.', + inputSchema: DeletePagesInput, }, rotate_page: { description: 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, })); From 577aa650fb3902bb3871e9d0ecd58ad08cace686 Mon Sep 17 00:00:00 2001 From: ben Date: Mon, 27 Apr 2026 17:51:05 +0200 Subject: [PATCH 02/11] refactor(P060): thin dispatcher to a pure router; iframe owns validation Drops dispatcher-side narrowing for LLM-driven bridge calls. The Zod tool schemas validate shape at the AI SDK boundary, and the iframe handler in client/lib/iframe/handlers.ts is the canonical runtime validator (it owns range checks + visiblePageCount). A third validation layer in the dispatcher just duplicates one of those and would drift over time. - IframeBridge: goTo/setFieldValue/focusField/movePage/rotatePage/ deletePages/deleteFields now accept `unknown` for LLM-supplied values. No non-dispatcher consumers exist for these methods. - Dispatcher: 7 cases collapse to one-liners (set_field_value, focus_field, go_to_page, move_page, rotate_page, delete_pages, delete_fields). - Header comment rewritten to call out the new contract. --- .../client-tools/dispatch.ts | 110 ++++-------------- copilot/src/lib/embed-bridge/types.ts | 21 ++-- 2 files changed, 34 insertions(+), 97 deletions(-) diff --git a/copilot/src/lib/embed-bridge-adapters/client-tools/dispatch.ts b/copilot/src/lib/embed-bridge-adapters/client-tools/dispatch.ts index 097dae3..aa88ceb 100644 --- a/copilot/src/lib/embed-bridge-adapters/client-tools/dispatch.ts +++ b/copilot/src/lib/embed-bridge-adapters/client-tools/dispatch.ts @@ -14,9 +14,13 @@ 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. +// produced, route to the matching bridge method. The dispatcher does NO +// input validation — it is a pure router. The Zod tool schemas catch +// shape violations at the AI SDK boundary BEFORE this runs, and the +// iframe handler in `client/lib/iframe/handlers.ts` is the canonical +// runtime validator (it owns range rules + visiblePageCount). Layering +// a third validation step here would just duplicate one of those and +// drift over time. export const dispatch = async ( bridge: IframeBridge, toolName: ClientToolName, @@ -31,26 +35,8 @@ export const dispatch = async ( } case 'detect_fields': return bridge.detectFields() - case 'delete_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.deleteFields({ fieldIds, page }) - } + case 'delete_fields': + return bridge.deleteFields({ fieldIds: input.field_ids, page: input.page }) case 'select_tool': { const rawTool = input.tool if (rawTool !== undefined && !isSelectableTool(rawTool)) { @@ -62,72 +48,18 @@ export const dispatch = async ( 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_pages': { - const rawPages = input.pages - if ( - !Array.isArray(rawPages) || - rawPages.length === 0 || - !rawPages.every((page): page is number => typeof page === 'number' && Number.isInteger(page) && page > 0) - ) { - return { - success: false, - error: { code: 'bad_input', message: 'pages must be a non-empty array of positive integers' }, - } - } - return bridge.deletePages({ pages: rawPages }) - } - 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 'set_field_value': + return bridge.setFieldValue({ fieldId: input.field_id, value: input.value }) + case 'focus_field': + return bridge.focusField({ fieldId: input.field_id }) + case 'go_to_page': + return bridge.goTo({ page: input.page }) + case 'move_page': + return bridge.movePage({ fromPage: input.from_page, toPage: input.to_page }) + case 'delete_pages': + return bridge.deletePages({ pages: input.pages }) + case 'rotate_page': + return bridge.rotatePage({ page: input.page }) case 'submit': return bridge.submit({ downloadCopy: false }) case 'download': diff --git a/copilot/src/lib/embed-bridge/types.ts b/copilot/src/lib/embed-bridge/types.ts index fb5eff1..f008870 100644 --- a/copilot/src/lib/embed-bridge/types.ts +++ b/copilot/src/lib/embed-bridge/types.ts @@ -79,8 +79,8 @@ export type CreateFieldArgs = { } export type DeleteFieldsArgs = { - fieldIds?: string[] | null - page?: number | null + fieldIds?: unknown + page?: unknown } // State machine. Transitions are strictly forward (booting -> editor_ready -> @@ -111,10 +111,15 @@ export type BridgeRequestType = | 'DELETE_PAGES' | 'ROTATE_PAGE' +// LLM-driven bridge methods accept `unknown` for raw LLM-supplied values. +// The iframe handler in `client/lib/iframe/handlers.ts` is the canonical +// validator (it owns visiblePageCount + per-field shape rules), so the +// dispatcher routes the LLM input through unmodified rather than rebuilding +// a parallel validation layer that would drift from the iframe's rules. export type IframeBridge = { getState: () => BridgeState loadDocument: (args: LoadDocumentArgs) => Promise - goTo: (args: { page: number }) => Promise + goTo: (args: { page: unknown }) => Promise selectTool: (args: { tool: SupportedFieldType | null }) => Promise detectFields: (args?: { debugMode?: boolean }) => Promise> deleteFields: (args?: DeleteFieldsArgs) => Promise> @@ -122,14 +127,14 @@ export type IframeBridge = { extractionMode: 'auto' | 'ocr' }) => Promise> getFields: () => Promise> - setFieldValue: (args: { fieldId: string; value: string | null }) => Promise + setFieldValue: (args: { fieldId: unknown; value: unknown }) => Promise focusField: (args: { - fieldId: string + fieldId: unknown }) => Promise> createField: (args: CreateFieldArgs) => Promise> submit: (args: { downloadCopy: boolean }) => Promise download: () => Promise - movePage: (args: { fromPage: number; toPage: number }) => Promise - deletePages: (args: { pages: number[] }) => Promise - rotatePage: (args: { page: number }) => Promise + movePage: (args: { fromPage: unknown; toPage: unknown }) => Promise + deletePages: (args: { pages: unknown }) => Promise + rotatePage: (args: { page: unknown }) => Promise } From 4a8f389d333868c9b30c7692007b84d0dda10985 Mon Sep 17 00:00:00 2001 From: ben Date: Mon, 27 Apr 2026 17:56:26 +0200 Subject: [PATCH 03/11] refactor(P060): drop createField from the copilot bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the bridge.createField method, CreateFieldArgs type, the CREATE_FIELD BridgeRequestType variant, and the matching timeout-bucket arm. The copilot demo never invoked it: create_field is not a registered LLM tool (CLIENT_TOOL_NAMES omits it) and there are no direct non-dispatcher consumers. Cleanup of dead surface — the iframe contract itself still exposes CREATE_FIELD for SDK consumers, this only trims the copilot's internal bridge. --- copilot/src/lib/embed-bridge/bridge.ts | 12 ------------ copilot/src/lib/embed-bridge/index.ts | 1 - copilot/src/lib/embed-bridge/types.ts | 12 ------------ 3 files changed, 25 deletions(-) diff --git a/copilot/src/lib/embed-bridge/bridge.ts b/copilot/src/lib/embed-bridge/bridge.ts index 8653426..6ce59af 100644 --- a/copilot/src/lib/embed-bridge/bridge.ts +++ b/copilot/src/lib/embed-bridge/bridge.ts @@ -3,7 +3,6 @@ import { type BridgeRequestType, type BridgeResult, type BridgeState, - type CreateFieldArgs, type DocumentContentResult, type FieldRecord, type IframeBridge, @@ -34,7 +33,6 @@ const getRequestTimeoutMs = (requestType: BridgeRequestType): number => { case 'DETECT_FIELDS': case 'GET_DOCUMENT_CONTENT': return HEAVY_REQUEST_TIMEOUT_MS - case 'CREATE_FIELD': case 'DELETE_FIELDS': case 'DELETE_PAGES': case 'FOCUS_FIELD': @@ -389,16 +387,6 @@ export const createBridge = ({ 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 }), download: () => sendRequest('DOWNLOAD', {}), movePage: ({ fromPage, toPage }) => sendRequest('MOVE_PAGE', { from_page: fromPage, to_page: toPage }), diff --git a/copilot/src/lib/embed-bridge/index.ts b/copilot/src/lib/embed-bridge/index.ts index 6e90506..1a2c32f 100644 --- a/copilot/src/lib/embed-bridge/index.ts +++ b/copilot/src/lib/embed-bridge/index.ts @@ -6,7 +6,6 @@ export type { BridgeRequestType, BridgeResult, BridgeState, - CreateFieldArgs, DeleteFieldsArgs, DocumentContentPage, DocumentContentResult, diff --git a/copilot/src/lib/embed-bridge/types.ts b/copilot/src/lib/embed-bridge/types.ts index f008870..2afd6c7 100644 --- a/copilot/src/lib/embed-bridge/types.ts +++ b/copilot/src/lib/embed-bridge/types.ts @@ -68,16 +68,6 @@ export type LoadDocumentArgs = { initialPage?: number } -export type CreateFieldArgs = { - type: SupportedFieldType - x: number - y: number - width: number - height: number - page: number - value?: string | null -} - export type DeleteFieldsArgs = { fieldIds?: unknown page?: unknown @@ -104,7 +94,6 @@ export type BridgeRequestType = | 'GET_FIELDS' | 'SET_FIELD_VALUE' | 'FOCUS_FIELD' - | 'CREATE_FIELD' | 'SUBMIT' | 'DOWNLOAD' | 'MOVE_PAGE' @@ -131,7 +120,6 @@ export type IframeBridge = { focusField: (args: { fieldId: unknown }) => Promise> - createField: (args: CreateFieldArgs) => Promise> submit: (args: { downloadCopy: boolean }) => Promise download: () => Promise movePage: (args: { fromPage: unknown; toPage: unknown }) => Promise From c5038650efbc57fa6e102c2f75df4240fd1dfacc Mon Sep 17 00:00:00 2001 From: ben Date: Mon, 27 Apr 2026 17:59:02 +0200 Subject: [PATCH 04/11] refactor(P060): drop safeDispatch wrapper, inline narrowing in factory safeDispatch's "unknown tool name" path is unreachable through the standard Vercel AI SDK pathway: the SDK validates LLM tool calls against the registered tool list before the dispatcher is invoked. Removing the wrapper collapses the indirection; the factory now narrows toolName via isClientToolName inline. The dispatcher's `default` arm + `satisfies never` keeps the compile-time exhaustiveness over ClientToolName intact. --- .../client-tools/dispatch.ts | 19 +----------------- .../client-tools/factory.ts | 20 +++++++++++++------ .../client-tools/index.ts | 2 +- 3 files changed, 16 insertions(+), 25 deletions(-) diff --git a/copilot/src/lib/embed-bridge-adapters/client-tools/dispatch.ts b/copilot/src/lib/embed-bridge-adapters/client-tools/dispatch.ts index aa88ceb..1adcfba 100644 --- a/copilot/src/lib/embed-bridge-adapters/client-tools/dispatch.ts +++ b/copilot/src/lib/embed-bridge-adapters/client-tools/dispatch.ts @@ -1,5 +1,5 @@ import type { BridgeResult, IframeBridge, SupportedFieldType } from '../../embed-bridge' -import { type ClientToolName, isClientToolName } from './schemas' +import type { ClientToolName } from './schemas' export type ToolInput = Record @@ -72,20 +72,3 @@ export const dispatch = async ( } } } - -// 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..6964c90 100644 --- a/copilot/src/lib/embed-bridge-adapters/client-tools/factory.ts +++ b/copilot/src/lib/embed-bridge-adapters/client-tools/factory.ts @@ -1,5 +1,5 @@ import type { BridgeResult, IframeBridge } from '../../embed-bridge' -import { safeDispatch, type ToolInput } from './dispatch' +import { dispatch, type ToolInput } from './dispatch' import { composeMiddleware, type ToolMiddleware } from './middleware' import { CLIENT_TOOL_SCHEMAS, isClientToolName } from './schemas' @@ -27,8 +27,10 @@ export type ClientTools = { // 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. + // run the middleware stack and dispatch to the bridge. Tool names that + // aren't in the registry come back as `unknown_tool`. In practice the + // Vercel AI SDK validates tool calls against the registered tool list + // before this fires, so the unknown branch is defensive only. execute: (toolName: string, input: ToolInput) => Promise> // Type guard re-export so the consumer can branch on tool names without // importing `schemas.ts` separately. @@ -40,9 +42,15 @@ export const createClientTools = ({ systemPrompt, middleware = [], }: CreateClientToolsArgs): ClientTools => { - const composed = composeMiddleware(middleware, ({ toolName, input }) => - safeDispatch(bridge, toolName, input), - ) + const composed = composeMiddleware(middleware, async ({ toolName, input }) => { + if (!isClientToolName(toolName)) { + return { + success: false, + error: { code: 'unknown_tool', message: `Unknown tool: ${toolName}` }, + } + } + return dispatch(bridge, toolName, input) + }) return { schemas: CLIENT_TOOL_SCHEMAS, systemPrompt, 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 8866318..f6ebf5a 100644 --- a/copilot/src/lib/embed-bridge-adapters/client-tools/index.ts +++ b/copilot/src/lib/embed-bridge-adapters/client-tools/index.ts @@ -1,5 +1,5 @@ export type { ToolInput } from './dispatch' -export { dispatch, safeDispatch } from './dispatch' +export { dispatch } from './dispatch' export type { ClientTools, CreateClientToolsArgs } from './factory' export { createClientTools } from './factory' export type { FinalisationAction, FinalisationToolMap } from './finalisation' From c772bbce1622f70d9a2f3ce4f3832161a08e5c82 Mon Sep 17 00:00:00 2001 From: ben Date: Mon, 27 Apr 2026 18:00:37 +0200 Subject: [PATCH 05/11] refactor(P060): tighten execute(toolName: ClientToolName), drop runtime narrow The factory's runtime isClientToolName check was redundant: the chat_pane.tsx caller already narrows toolName via isClientToolName at the boundary (rejecting unknowns with output-error before execute fires). Tightens execute's signature to ClientToolName so the type system enforces what the caller already guarantees, and the factory's middleware terminus collapses to a one-line dispatch call. MiddlewareContext.toolName follows suit. Net: one runtime narrow at the consumer boundary; everything downstream is typed. --- .../client-tools/factory.ts | 25 +++++++------------ .../client-tools/middleware.ts | 3 ++- 2 files changed, 11 insertions(+), 17 deletions(-) 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 6964c90..1aadc35 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,7 @@ import type { BridgeResult, IframeBridge } from '../../embed-bridge' import { dispatch, type ToolInput } from './dispatch' import { composeMiddleware, type ToolMiddleware } from './middleware' -import { CLIENT_TOOL_SCHEMAS, isClientToolName } from './schemas' +import { type ClientToolName, CLIENT_TOOL_SCHEMAS, isClientToolName } from './schemas' export type CreateClientToolsArgs = { // The iframe bridge the dispatcher will drive. Usually comes from the React @@ -26,12 +26,13 @@ export type ClientTools = { // 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. Tool names that - // aren't in the registry come back as `unknown_tool`. In practice the - // Vercel AI SDK validates tool calls against the registered tool list - // before this fires, so the unknown branch is defensive only. - execute: (toolName: string, input: ToolInput) => Promise> + // Main entry: middleware stack + bridge dispatch. The caller is expected + // to narrow toolName via `isClientToolName` BEFORE calling execute (the + // Vercel AI SDK guarantees the LLM only fires registered tools, so the + // narrow is a one-line type assertion at the consumer). Pushing the + // narrow up means there is no redundant runtime check here, and the + // dispatcher stays a pure router with `satisfies never` exhaustiveness. + execute: (toolName: ClientToolName, input: ToolInput) => Promise> // Type guard re-export so the consumer can branch on tool names without // importing `schemas.ts` separately. isClientToolName: typeof isClientToolName @@ -42,15 +43,7 @@ export const createClientTools = ({ systemPrompt, middleware = [], }: CreateClientToolsArgs): ClientTools => { - const composed = composeMiddleware(middleware, async ({ toolName, input }) => { - if (!isClientToolName(toolName)) { - return { - success: false, - error: { code: 'unknown_tool', message: `Unknown tool: ${toolName}` }, - } - } - return dispatch(bridge, toolName, input) - }) + const composed = composeMiddleware(middleware, ({ toolName, input }) => dispatch(bridge, toolName, input)) return { schemas: CLIENT_TOOL_SCHEMAS, systemPrompt, 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..ebf122a 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 { ClientToolName } from './schemas' // 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 } From 2a58fd4ba6c98a8f94798137f8867ab6d984882e Mon Sep 17 00:00:00 2001 From: ben Date: Mon, 27 Apr 2026 18:18:50 +0200 Subject: [PATCH 06/11] refactor(P060): bridge owns the schemas; client-tools is a thin adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bridge is the single source of truth for the iframe contract: each operation has a Zod schema (with description) in embed-bridge/schemas.ts, and IframeBridge methods take z.infer directly. The bridge implementation is a one-line postMessage pass- through per method — no key conversion (input shapes mirror the wire's snake_case payloads). The client-tools adapter is now just two files: - schemas.ts: enumerates the LLM tool names exposed to the model. - tools.ts: maps each tool name to { description, inputSchema } pulled verbatim from the bridge schema's `.describe()`. No duplicated text. Consumers (routes/api/chat.ts and lib/byok/transport.ts) drop ~50 lines of inline tool registration each; they now just spread LLM_STATIC_TOOLS into withFinalisationTool. Adding a new LLM-exposed bridge operation: - one schema in embed-bridge/schemas.ts - one method on IframeBridge + one impl line in bridge.ts - one tool entry in client-tools/tools.ts - one switch arm in client-tools/factory.ts dispatch.ts is gone (the switch lives in factory.ts directly). --- copilot/src/components/chat/chat_pane.tsx | 2 +- copilot/src/lib/byok/transport.ts | 65 +-------- .../client-tools/dispatch.ts | 74 ----------- .../client-tools/factory.ts | 74 +++++++++-- .../client-tools/finalisation.ts | 19 +-- .../client-tools/index.ts | 23 +--- .../client-tools/middleware.ts | 2 +- .../client-tools/schemas.ts | 125 +----------------- .../client-tools/tools.ts | 38 ++++++ copilot/src/lib/embed-bridge/bridge.ts | 35 +++-- copilot/src/lib/embed-bridge/index.ts | 21 ++- copilot/src/lib/embed-bridge/schemas.ts | 108 +++++++++++++++ copilot/src/lib/embed-bridge/types.ts | 65 ++++----- copilot/src/routes/api/chat.ts | 65 +-------- 14 files changed, 292 insertions(+), 424 deletions(-) delete mode 100644 copilot/src/lib/embed-bridge-adapters/client-tools/dispatch.ts create mode 100644 copilot/src/lib/embed-bridge-adapters/client-tools/tools.ts create mode 100644 copilot/src/lib/embed-bridge/schemas.ts diff --git a/copilot/src/components/chat/chat_pane.tsx b/copilot/src/components/chat/chat_pane.tsx index 56c1298..dcbc331 100644 --- a/copilot/src/components/chat/chat_pane.tsx +++ b/copilot/src/components/chat/chat_pane.tsx @@ -531,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 => { diff --git a/copilot/src/lib/byok/transport.ts b/copilot/src/lib/byok/transport.ts index ccf7dad..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 { - DeleteFieldsInput, - DeletePagesInput, - DetectFieldsInput, FINALISATION_ACTION, - FocusFieldInput, - GetDocumentContentInput, - GetFieldsInput, - GoToPageInput, - MovePageInput, - 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, - }, - delete_fields: { - description: - 'Deletes 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 delete fields.', - inputSchema: DeleteFieldsInput, - }, - 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_pages: { - description: - 'Permanently removes one or more visible pages (1-indexed) and any fields placed on them. Pass pages as a non-empty array. At least one visible page must remain — passing every visible page returns event_not_allowed. Destructive — only call when the user explicitly asks to delete pages.', - inputSchema: DeletePagesInput, - }, - 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 1adcfba..0000000 --- a/copilot/src/lib/embed-bridge-adapters/client-tools/dispatch.ts +++ /dev/null @@ -1,74 +0,0 @@ -import type { BridgeResult, IframeBridge, SupportedFieldType } from '../../embed-bridge' -import type { ClientToolName } 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. The dispatcher does NO -// input validation — it is a pure router. The Zod tool schemas catch -// shape violations at the AI SDK boundary BEFORE this runs, and the -// iframe handler in `client/lib/iframe/handlers.ts` is the canonical -// runtime validator (it owns range rules + visiblePageCount). Layering -// a third validation step here would just duplicate one of those and -// drift over time. -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 'delete_fields': - return bridge.deleteFields({ fieldIds: input.field_ids, page: input.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': - return bridge.setFieldValue({ fieldId: input.field_id, value: input.value }) - case 'focus_field': - return bridge.focusField({ fieldId: input.field_id }) - case 'go_to_page': - return bridge.goTo({ page: input.page }) - case 'move_page': - return bridge.movePage({ fromPage: input.from_page, toPage: input.to_page }) - case 'delete_pages': - return bridge.deletePages({ pages: input.pages }) - case 'rotate_page': - return bridge.rotatePage({ page: input.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)}` }, - } - } -} 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 1aadc35..9b2e481 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,20 @@ -import type { BridgeResult, IframeBridge } from '../../embed-bridge' -import { dispatch, type ToolInput } from './dispatch' +import { + type BridgeResult, + DeleteFieldsInput, + DeletePagesInput, + FocusFieldInput, + GetDocumentContentInput, + GoToInput, + type IframeBridge, + MovePageInput, + RotatePageInput, + SelectToolInput, + SetFieldValueInput, +} from '../../embed-bridge' import { composeMiddleware, type ToolMiddleware } from './middleware' -import { type ClientToolName, CLIENT_TOOL_SCHEMAS, isClientToolName } from './schemas' +import { type ClientToolName, isClientToolName } from './schemas' + +export type ToolInput = Record export type CreateClientToolsArgs = { // The iframe bridge the dispatcher will drive. Usually comes from the React @@ -20,18 +33,12 @@ 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: middleware stack + bridge dispatch. The caller is expected - // to narrow toolName via `isClientToolName` BEFORE calling execute (the - // Vercel AI SDK guarantees the LLM only fires registered tools, so the - // narrow is a one-line type assertion at the consumer). Pushing the - // narrow up means there is no redundant runtime check here, and the - // dispatcher stays a pure router with `satisfies never` exhaustiveness. + // 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 tool names without // importing `schemas.ts` separately. @@ -43,9 +50,48 @@ export const createClientTools = ({ systemPrompt, middleware = [], }: CreateClientToolsArgs): ClientTools => { - const composed = composeMiddleware(middleware, ({ toolName, input }) => dispatch(bridge, toolName, input)) + // One arm per tool. Each arm parses the LLM-supplied input via the + // bridge schema (single source of truth, lives in + // embed-bridge/schemas.ts) and forwards the typed payload to the matching + // bridge method. `satisfies never` keeps the switch exhaustive over + // ClientToolName at compile time. + const composed = composeMiddleware(middleware, async ({ toolName, input }) => { + switch (toolName) { + case 'get_fields': + return bridge.getFields() + case 'get_document_content': + return bridge.getDocumentContent(GetDocumentContentInput.parse(input)) + case 'detect_fields': + return bridge.detectFields() + case 'delete_fields': + return bridge.deleteFields(DeleteFieldsInput.parse(input)) + case 'select_tool': + return bridge.selectTool(SelectToolInput.parse(input)) + case 'set_field_value': + return bridge.setFieldValue(SetFieldValueInput.parse(input)) + case 'focus_field': + return bridge.focusField(FocusFieldInput.parse(input)) + case 'go_to_page': + return bridge.goTo(GoToInput.parse(input)) + case 'move_page': + return bridge.movePage(MovePageInput.parse(input)) + case 'delete_pages': + return bridge.deletePages(DeletePagesInput.parse(input)) + case 'rotate_page': + return bridge.rotatePage(RotatePageInput.parse(input)) + case 'submit': + return bridge.submit({ download_copy: false }) + case 'download': + return bridge.download() + default: + toolName satisfies never + return { + 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 f6ebf5a..050b930 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,9 @@ -export type { ToolInput } from './dispatch' -export { dispatch } 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, - DeleteFieldsInput, - DeletePagesInput, - DetectFieldsInput, - DownloadInput, - FocusFieldInput, - GetDocumentContentInput, - GetFieldsInput, - GoToPageInput, - isClientToolName, - MovePageInput, - RotatePageInput, - SelectToolInput, - SetFieldValueInput, - SubmitInput, -} from './schemas' +export { CLIENT_TOOL_NAMES, isClientToolName } from './schemas' +export { 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 ebf122a..a3004ce 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,5 @@ import type { BridgeResult } from '../../embed-bridge' -import type { ToolInput } from './dispatch' +import type { ToolInput } from './factory' import type { ClientToolName } from './schemas' // Onion-style middleware. Each layer receives a context (tool name + input) diff --git a/copilot/src/lib/embed-bridge-adapters/client-tools/schemas.ts b/copilot/src/lib/embed-bridge-adapters/client-tools/schemas.ts index 83f9225..9c176dc 100644 --- a/copilot/src/lib/embed-bridge-adapters/client-tools/schemas.ts +++ b/copilot/src/lib/embed-bridge-adapters/client-tools/schemas.ts @@ -1,103 +1,9 @@ -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 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.', - ) - -// 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 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({}) - .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.', - ) +// LLM-tool-name registry. The bridge owns the schemas (with descriptions) +// in embed-bridge/schemas.ts; this file just enumerates which bridge +// operations are exposed to the LLM and under which snake_case tool name. +// Adding a new LLM tool = add the entry here. Removing one = drop the +// entry. The factory's switch is exhaustive over this union, so any +// addition forces a matching switch arm at compile time. export const CLIENT_TOOL_NAMES = [ 'get_fields', @@ -119,22 +25,3 @@ 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, - delete_fields: DeleteFieldsInput, - select_tool: SelectToolInput, - set_field_value: SetFieldValueInput, - focus_field: FocusFieldInput, - go_to_page: GoToPageInput, - move_page: MovePageInput, - delete_pages: DeletePagesInput, - 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..e64544f --- /dev/null +++ b/copilot/src/lib/embed-bridge-adapters/client-tools/tools.ts @@ -0,0 +1,38 @@ +import type { z } from 'zod' +import { + DeleteFieldsInput, + DeletePagesInput, + DetectFieldsInput, + FocusFieldInput, + GetDocumentContentInput, + GetFieldsInput, + GoToInput, + MovePageInput, + RotatePageInput, + SelectToolInput, + SetFieldValueInput, +} from '../../embed-bridge' + +// LLM tool registration map. Each entry pulls its description verbatim +// from the bridge schema's `.describe()` — no duplication. Spread into +// streamText({ tools: withFinalisationTool(LLM_STATIC_TOOLS) }) on both +// the /api/chat and BYOK paths. Adding a new LLM tool is one line here + +// one switch arm in `factory.ts`. +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 diff --git a/copilot/src/lib/embed-bridge/bridge.ts b/copilot/src/lib/embed-bridge/bridge.ts index 6ce59af..ab5b6d8 100644 --- a/copilot/src/lib/embed-bridge/bridge.ts +++ b/copilot/src/lib/embed-bridge/bridge.ts @@ -7,8 +7,6 @@ import { type FieldRecord, type IframeBridge, isBridgeResultLike, - type LoadDocumentArgs, - type DeleteFieldsArgs, } from './types' type PendingRequest = { @@ -370,28 +368,25 @@ export const createBridge = ({ window.addEventListener('message', onMessage) + // Each method is a one-line pass-through to sendRequest. The args shape + // already matches the iframe's snake_case payload (see schemas.ts), so + // no key conversion happens at this layer. 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 }), - deleteFields: (args?: DeleteFieldsArgs) => - sendRequest('DELETE_FIELDS', { - field_ids: args?.fieldIds ?? null, - page: args?.page ?? null, - }), - getDocumentContent: ({ extractionMode }) => - sendRequest('GET_DOCUMENT_CONTENT', { extraction_mode: extractionMode }), + loadDocument: (args) => sendRequest('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 }), - submit: ({ downloadCopy }) => sendRequest('SUBMIT', { download_copy: downloadCopy }), + getDocumentContent: (args) => sendRequest('GET_DOCUMENT_CONTENT', args), + detectFields: () => sendRequest('DETECT_FIELDS', {}), + deleteFields: (args) => sendRequest('DELETE_FIELDS', args), + selectTool: (args) => sendRequest('SELECT_TOOL', args), + setFieldValue: (args) => sendRequest('SET_FIELD_VALUE', args), + focusField: (args) => sendRequest('FOCUS_FIELD', args), + goTo: (args) => sendRequest('GO_TO', args), + movePage: (args) => sendRequest('MOVE_PAGE', args), + deletePages: (args) => sendRequest('DELETE_PAGES', args), + rotatePage: (args) => sendRequest('ROTATE_PAGE', args), + submit: (args) => sendRequest('SUBMIT', args), download: () => sendRequest('DOWNLOAD', {}), - movePage: ({ fromPage, toPage }) => sendRequest('MOVE_PAGE', { from_page: fromPage, to_page: toPage }), - deletePages: ({ pages }) => sendRequest('DELETE_PAGES', { pages }), - 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 1a2c32f..e9cca3e 100644 --- a/copilot/src/lib/embed-bridge/index.ts +++ b/copilot/src/lib/embed-bridge/index.ts @@ -2,16 +2,33 @@ 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 { BridgeRequestType, BridgeResult, BridgeState, - DeleteFieldsArgs, DocumentContentPage, DocumentContentResult, FieldRecord, + FocusFieldResult, IframeBridge, - LoadDocumentArgs, 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..8c0604f --- /dev/null +++ b/copilot/src/lib/embed-bridge/schemas.ts @@ -0,0 +1,108 @@ +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.', +) + +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 2afd6c7..7785df0 100644 --- a/copilot/src/lib/embed-bridge/types.ts +++ b/copilot/src/lib/embed-bridge/types.ts @@ -1,6 +1,21 @@ // Shared types for the SimplePDF embed bridge. Pure TypeScript, no // framework dependencies. +import type { z } from 'zod' +import type { + DeleteFieldsInput, + DeletePagesInput, + FocusFieldInput, + GetDocumentContentInput, + GoToInput, + LoadDocumentInput, + MovePageInput, + RotatePageInput, + SelectToolInput, + SetFieldValueInput, + SubmitInput, +} from './schemas' + export type BridgeResult = | { success: true; data: TData } | { success: false; error: { code: string; message: string } } @@ -62,17 +77,6 @@ export type DocumentContentResult = { pages: DocumentContentPage[] } -export type LoadDocumentArgs = { - dataUrl: string - name?: string - initialPage?: number -} - -export type DeleteFieldsArgs = { - fieldIds?: unknown - page?: unknown -} - // 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 @@ -100,29 +104,26 @@ export type BridgeRequestType = | 'DELETE_PAGES' | 'ROTATE_PAGE' -// LLM-driven bridge methods accept `unknown` for raw LLM-supplied values. -// The iframe handler in `client/lib/iframe/handlers.ts` is the canonical -// validator (it owns visiblePageCount + per-field shape rules), so the -// dispatcher routes the LLM input through unmodified rather than rebuilding -// a parallel validation layer that would drift from the iframe's rules. +// Each method's input is typed via z.infer from its bridge schema in +// schemas.ts — that file is the single source of truth for the iframe +// contract shape. Output types (BridgeResult<...>) stay explicit since +// they describe the iframe response, not the request input. +export type FocusFieldResult = { hint: { type: 'user_action_expected'; message: string } } | null + export type IframeBridge = { getState: () => BridgeState - loadDocument: (args: LoadDocumentArgs) => Promise - goTo: (args: { page: unknown }) => Promise - selectTool: (args: { tool: SupportedFieldType | null }) => Promise - detectFields: (args?: { debugMode?: boolean }) => Promise> - deleteFields: (args?: DeleteFieldsArgs) => Promise> - getDocumentContent: (args: { - extractionMode: 'auto' | 'ocr' - }) => Promise> + loadDocument: (args: z.infer) => Promise getFields: () => Promise> - setFieldValue: (args: { fieldId: unknown; value: unknown }) => Promise - focusField: (args: { - fieldId: unknown - }) => Promise> - submit: (args: { downloadCopy: boolean }) => Promise + getDocumentContent: (args: z.infer) => Promise> + detectFields: () => Promise> + deleteFields: (args: z.infer) => Promise> + selectTool: (args: z.infer) => Promise + setFieldValue: (args: z.infer) => Promise + focusField: (args: z.infer) => Promise> + goTo: (args: z.infer) => Promise + movePage: (args: z.infer) => Promise + deletePages: (args: z.infer) => Promise + rotatePage: (args: z.infer) => Promise + submit: (args: z.infer) => Promise download: () => Promise - movePage: (args: { fromPage: unknown; toPage: unknown }) => Promise - deletePages: (args: { pages: unknown }) => Promise - rotatePage: (args: { page: unknown }) => Promise } diff --git a/copilot/src/routes/api/chat.ts b/copilot/src/routes/api/chat.ts index 0343f44..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 { - DeleteFieldsInput, - DeletePagesInput, - DetectFieldsInput, FINALISATION_ACTION, - FocusFieldInput, - GetDocumentContentInput, - GetFieldsInput, - GoToPageInput, - MovePageInput, - 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, - }, - delete_fields: { - description: - 'Deletes 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 delete fields.', - inputSchema: DeleteFieldsInput, - }, - 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_pages: { - description: - 'Permanently removes one or more visible pages (1-indexed) and any fields placed on them. Pass pages as a non-empty array. At least one visible page must remain — passing every visible page returns event_not_allowed. Destructive — only call when the user explicitly asks to delete pages.', - inputSchema: DeletePagesInput, - }, - 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', { From 2eb9a27e2731a7a060850a94060dbddeede53d60 Mon Sep 17 00:00:00 2001 From: ben Date: Mon, 27 Apr 2026 18:20:04 +0200 Subject: [PATCH 07/11] refactor(P060): collapse client-tools/schemas.ts into tools.ts The tool-name registry (CLIENT_TOOL_NAMES, ClientToolName, isClientToolName) lived next to the LLM_STATIC_TOOLS map but in a separate file for no real reason. Merging both into tools.ts puts every LLM-tool decision (which schema, which name, the runtime guard) in one place. Adding a tool now touches one file in the adapter (tools.ts) + one switch arm in factory.ts. --- .../client-tools/factory.ts | 2 +- .../client-tools/index.ts | 5 +-- .../client-tools/middleware.ts | 2 +- .../client-tools/schemas.ts | 27 -------------- .../client-tools/tools.ts | 37 ++++++++++++++++--- 5 files changed, 36 insertions(+), 37 deletions(-) delete mode 100644 copilot/src/lib/embed-bridge-adapters/client-tools/schemas.ts 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 9b2e481..d4e9d03 100644 --- a/copilot/src/lib/embed-bridge-adapters/client-tools/factory.ts +++ b/copilot/src/lib/embed-bridge-adapters/client-tools/factory.ts @@ -12,7 +12,7 @@ import { SetFieldValueInput, } from '../../embed-bridge' import { composeMiddleware, type ToolMiddleware } from './middleware' -import { type ClientToolName, isClientToolName } from './schemas' +import { type ClientToolName, isClientToolName } from './tools' export type ToolInput = Record 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 050b930..5e9dccb 100644 --- a/copilot/src/lib/embed-bridge-adapters/client-tools/index.ts +++ b/copilot/src/lib/embed-bridge-adapters/client-tools/index.ts @@ -4,6 +4,5 @@ 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_NAMES, isClientToolName } from './schemas' -export { LLM_STATIC_TOOLS } from './tools' +export type { ClientToolName } from './tools' +export { CLIENT_TOOL_NAMES, 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 a3004ce..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,6 +1,6 @@ import type { BridgeResult } from '../../embed-bridge' import type { ToolInput } from './factory' -import type { ClientToolName } from './schemas' +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 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 9c176dc..0000000 --- a/copilot/src/lib/embed-bridge-adapters/client-tools/schemas.ts +++ /dev/null @@ -1,27 +0,0 @@ -// LLM-tool-name registry. The bridge owns the schemas (with descriptions) -// in embed-bridge/schemas.ts; this file just enumerates which bridge -// operations are exposed to the LLM and under which snake_case tool name. -// Adding a new LLM tool = add the entry here. Removing one = drop the -// entry. The factory's switch is exhaustive over this union, so any -// addition forces a matching switch arm at compile time. - -export const CLIENT_TOOL_NAMES = [ - 'get_fields', - 'get_document_content', - 'detect_fields', - 'delete_fields', - 'select_tool', - 'set_field_value', - 'focus_field', - 'go_to_page', - 'move_page', - 'delete_pages', - '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) diff --git a/copilot/src/lib/embed-bridge-adapters/client-tools/tools.ts b/copilot/src/lib/embed-bridge-adapters/client-tools/tools.ts index e64544f..9d9f286 100644 --- a/copilot/src/lib/embed-bridge-adapters/client-tools/tools.ts +++ b/copilot/src/lib/embed-bridge-adapters/client-tools/tools.ts @@ -13,11 +13,17 @@ import { SetFieldValueInput, } from '../../embed-bridge' -// LLM tool registration map. Each entry pulls its description verbatim -// from the bridge schema's `.describe()` — no duplication. Spread into -// streamText({ tools: withFinalisationTool(LLM_STATIC_TOOLS) }) on both -// the /api/chat and BYOK paths. Adding a new LLM tool is one line here + -// one switch arm in `factory.ts`. +// 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 a new LLM tool: add an entry to LLM_STATIC_TOOLS, add the snake +// name to CLIENT_TOOL_NAMES, 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, @@ -36,3 +42,24 @@ export const LLM_STATIC_TOOLS = { delete_pages: tool(DeletePagesInput), rotate_page: tool(RotatePageInput), } as const + +export const CLIENT_TOOL_NAMES = [ + 'get_fields', + 'get_document_content', + 'detect_fields', + 'delete_fields', + 'select_tool', + 'set_field_value', + 'focus_field', + 'go_to_page', + 'move_page', + 'delete_pages', + '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) From 0d5964e15a8b12b3e82c3003c2fbbaeef07ed249 Mon Sep 17 00:00:00 2001 From: ben Date: Mon, 27 Apr 2026 18:25:14 +0200 Subject: [PATCH 08/11] =?UTF-8?q?refactor(P060):=20bridge=20owns=20the=20p?= =?UTF-8?q?arsing=20=E2=80=94=20adapter=20is=20a=20pure=20router?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each bridge method now safeParses its `unknown` input via the matching Zod schema before posting to the iframe. Bad input surfaces as `{ success: false, error: { code: 'bad_input', message } }` without a postMessage round-trip. The adapter (createClientTools switch terminus) collapses to one-line cases that just hand the LLM input to the bridge. - IframeBridge methods take `args: unknown` (uniform external surface). - bridge.ts: parseAndSend helper centralises the parse + sendRequest pair. - factory.ts: switch is pure routing, no schemas imported. - sendRequest's `data` parameter loosened to `unknown` (it JSON.stringifies and doesn't care about the shape). Adding a new bridge operation is now: schema in schemas.ts, method on IframeBridge, parseAndSend line in bridge.ts, switch arm in factory.ts. The schema is the single source of truth. --- .../client-tools/factory.ts | 48 +++++-------- copilot/src/lib/embed-bridge/bridge.ts | 70 +++++++++++++++---- copilot/src/lib/embed-bridge/types.ts | 47 +++++-------- 3 files changed, 89 insertions(+), 76 deletions(-) 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 d4e9d03..198449f 100644 --- a/copilot/src/lib/embed-bridge-adapters/client-tools/factory.ts +++ b/copilot/src/lib/embed-bridge-adapters/client-tools/factory.ts @@ -1,16 +1,4 @@ -import { - type BridgeResult, - DeleteFieldsInput, - DeletePagesInput, - FocusFieldInput, - GetDocumentContentInput, - GoToInput, - type IframeBridge, - MovePageInput, - RotatePageInput, - SelectToolInput, - SetFieldValueInput, -} from '../../embed-bridge' +import type { BridgeResult, IframeBridge } from '../../embed-bridge' import { composeMiddleware, type ToolMiddleware } from './middleware' import { type ClientToolName, isClientToolName } from './tools' @@ -41,7 +29,7 @@ export type ClientTools = { // registered tools. execute: (toolName: ClientToolName, input: ToolInput) => Promise> // Type guard re-export so the consumer can branch on tool names without - // importing `schemas.ts` separately. + // importing `tools.ts` separately. isClientToolName: typeof isClientToolName } @@ -50,45 +38,43 @@ export const createClientTools = ({ systemPrompt, middleware = [], }: CreateClientToolsArgs): ClientTools => { - // One arm per tool. Each arm parses the LLM-supplied input via the - // bridge schema (single source of truth, lives in - // embed-bridge/schemas.ts) and forwards the typed payload to the matching - // bridge method. `satisfies never` keeps the switch exhaustive over - // ClientToolName at compile time. - const composed = composeMiddleware(middleware, async ({ 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(GetDocumentContentInput.parse(input)) + return bridge.getDocumentContent(input) case 'detect_fields': return bridge.detectFields() case 'delete_fields': - return bridge.deleteFields(DeleteFieldsInput.parse(input)) + return bridge.deleteFields(input) case 'select_tool': - return bridge.selectTool(SelectToolInput.parse(input)) + return bridge.selectTool(input) case 'set_field_value': - return bridge.setFieldValue(SetFieldValueInput.parse(input)) + return bridge.setFieldValue(input) case 'focus_field': - return bridge.focusField(FocusFieldInput.parse(input)) + return bridge.focusField(input) case 'go_to_page': - return bridge.goTo(GoToInput.parse(input)) + return bridge.goTo(input) case 'move_page': - return bridge.movePage(MovePageInput.parse(input)) + return bridge.movePage(input) case 'delete_pages': - return bridge.deletePages(DeletePagesInput.parse(input)) + return bridge.deletePages(input) case 'rotate_page': - return bridge.rotatePage(RotatePageInput.parse(input)) + return bridge.rotatePage(input) case 'submit': return bridge.submit({ download_copy: false }) case 'download': return bridge.download() default: toolName satisfies never - return { + return Promise.resolve({ success: false, error: { code: 'unknown_tool', message: `Unknown tool: ${String(toolName)}` }, - } + }) } }) return { diff --git a/copilot/src/lib/embed-bridge/bridge.ts b/copilot/src/lib/embed-bridge/bridge.ts index ab5b6d8..b2494fb 100644 --- a/copilot/src/lib/embed-bridge/bridge.ts +++ b/copilot/src/lib/embed-bridge/bridge.ts @@ -1,10 +1,25 @@ +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 DocumentContentResult, type FieldRecord, + type FocusFieldResult, type IframeBridge, isBridgeResultLike, } from './types' @@ -109,7 +124,7 @@ export const createBridge = ({ const sendRequest = ( type: BridgeRequestType, - data: Record, + data: unknown, ): Promise> => new Promise((resolve) => { const iframe = getIframe() @@ -368,24 +383,49 @@ export const createBridge = ({ window.addEventListener('message', onMessage) - // Each method is a one-line pass-through to sendRequest. The args shape - // already matches the iframe's snake_case payload (see schemas.ts), so - // no key conversion happens at this layer. + // 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 dispatcher, 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: (args) => sendRequest('LOAD_DOCUMENT', args), + loadDocument: (args) => parseAndSend(LoadDocumentInput, 'LOAD_DOCUMENT', args), getFields: () => sendRequest<{ fields: FieldRecord[] }>('GET_FIELDS', {}), - getDocumentContent: (args) => sendRequest('GET_DOCUMENT_CONTENT', args), + getDocumentContent: (args) => parseAndSend( + GetDocumentContentInput, + 'GET_DOCUMENT_CONTENT', + args, + ), detectFields: () => sendRequest('DETECT_FIELDS', {}), - deleteFields: (args) => sendRequest('DELETE_FIELDS', args), - selectTool: (args) => sendRequest('SELECT_TOOL', args), - setFieldValue: (args) => sendRequest('SET_FIELD_VALUE', args), - focusField: (args) => sendRequest('FOCUS_FIELD', args), - goTo: (args) => sendRequest('GO_TO', args), - movePage: (args) => sendRequest('MOVE_PAGE', args), - deletePages: (args) => sendRequest('DELETE_PAGES', args), - rotatePage: (args) => sendRequest('ROTATE_PAGE', args), - submit: (args) => sendRequest('SUBMIT', args), + 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', {}), } diff --git a/copilot/src/lib/embed-bridge/types.ts b/copilot/src/lib/embed-bridge/types.ts index 7785df0..930a81e 100644 --- a/copilot/src/lib/embed-bridge/types.ts +++ b/copilot/src/lib/embed-bridge/types.ts @@ -1,21 +1,6 @@ // Shared types for the SimplePDF embed bridge. Pure TypeScript, no // framework dependencies. -import type { z } from 'zod' -import type { - DeleteFieldsInput, - DeletePagesInput, - FocusFieldInput, - GetDocumentContentInput, - GoToInput, - LoadDocumentInput, - MovePageInput, - RotatePageInput, - SelectToolInput, - SetFieldValueInput, - SubmitInput, -} from './schemas' - export type BridgeResult = | { success: true; data: TData } | { success: false; error: { code: string; message: string } } @@ -104,26 +89,28 @@ export type BridgeRequestType = | 'DELETE_PAGES' | 'ROTATE_PAGE' -// Each method's input is typed via z.infer from its bridge schema in -// schemas.ts — that file is the single source of truth for the iframe -// contract shape. Output types (BridgeResult<...>) stay explicit since -// they describe the iframe response, not the request input. export type FocusFieldResult = { hint: { type: 'user_action_expected'; message: string } } | null +// The bridge owns the contract. Each method takes `unknown` (raw, from any +// caller — the LLM dispatcher, direct UI code, etc.) 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 do not +// re-validate. export type IframeBridge = { getState: () => BridgeState - loadDocument: (args: z.infer) => Promise + loadDocument: (args: unknown) => Promise getFields: () => Promise> - getDocumentContent: (args: z.infer) => Promise> + getDocumentContent: (args: unknown) => Promise> detectFields: () => Promise> - deleteFields: (args: z.infer) => Promise> - selectTool: (args: z.infer) => Promise - setFieldValue: (args: z.infer) => Promise - focusField: (args: z.infer) => Promise> - goTo: (args: z.infer) => Promise - movePage: (args: z.infer) => Promise - deletePages: (args: z.infer) => Promise - rotatePage: (args: z.infer) => Promise - submit: (args: z.infer) => 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 } From aa32048828dd873466cc85c2cd752e4b0d0ba0f4 Mon Sep 17 00:00:00 2001 From: ben Date: Mon, 27 Apr 2026 18:31:15 +0200 Subject: [PATCH 09/11] refactor(P060): drop dead loadDocument, derive ClientToolName, clean comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove unused bridge.loadDocument + LoadDocumentInput + 'LOAD_DOCUMENT' from BridgeRequestType + matching arm in getRequestTimeoutMs. The copilot demo loads documents via URL params, never via postMessage — the method had no callers. - Derive ClientToolName from `keyof typeof LLM_STATIC_TOOLS | 'submit' | 'download'`. Drop the redundant CLIENT_TOOL_NAMES array. isClientToolName uses a derived ReadonlySet for the runtime guard. CLIENT_TOOL_NAMES is no longer exported from the barrel (it was internal only). - Stale "LLM dispatcher" comment wording → "LLM tool registry" (dispatch.ts has been gone for a while). --- .../client-tools/factory.ts | 4 +-- .../client-tools/index.ts | 2 +- .../client-tools/tools.ts | 33 ++++++++----------- copilot/src/lib/embed-bridge/bridge.ts | 5 +-- copilot/src/lib/embed-bridge/index.ts | 1 - copilot/src/lib/embed-bridge/schemas.ts | 8 ----- copilot/src/lib/embed-bridge/types.ts | 10 +++--- 7 files changed, 21 insertions(+), 42 deletions(-) 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 198449f..4bf36a0 100644 --- a/copilot/src/lib/embed-bridge-adapters/client-tools/factory.ts +++ b/copilot/src/lib/embed-bridge-adapters/client-tools/factory.ts @@ -28,8 +28,8 @@ export type ClientTools = { // 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 tool names without - // importing `tools.ts` separately. + // Type guard re-export so the consumer can branch on LLM tool names + // without importing `tools.ts` separately. isClientToolName: typeof isClientToolName } 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 5e9dccb..872bcb6 100644 --- a/copilot/src/lib/embed-bridge-adapters/client-tools/index.ts +++ b/copilot/src/lib/embed-bridge-adapters/client-tools/index.ts @@ -5,4 +5,4 @@ export { FINALISATION_ACTION, FINALISATION_TOOL, withFinalisationTool } from './ export type { MiddlewareContext, ToolMiddleware } from './middleware' export { composeMiddleware } from './middleware' export type { ClientToolName } from './tools' -export { CLIENT_TOOL_NAMES, isClientToolName, LLM_STATIC_TOOLS } from './tools' +export { isClientToolName, LLM_STATIC_TOOLS } from './tools' diff --git a/copilot/src/lib/embed-bridge-adapters/client-tools/tools.ts b/copilot/src/lib/embed-bridge-adapters/client-tools/tools.ts index 9d9f286..d150242 100644 --- a/copilot/src/lib/embed-bridge-adapters/client-tools/tools.ts +++ b/copilot/src/lib/embed-bridge-adapters/client-tools/tools.ts @@ -19,10 +19,9 @@ import { // name, and pulls each description verbatim from the bridge schema's // `.describe()` — no duplicated text. // -// Adding a new LLM tool: add an entry to LLM_STATIC_TOOLS, add the snake -// name to CLIENT_TOOL_NAMES, add a switch arm in factory.ts. The switch -// is exhaustive over ClientToolName, so any addition forces a matching -// arm at compile time. +// 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 ?? '', @@ -43,23 +42,17 @@ export const LLM_STATIC_TOOLS = { rotate_page: tool(RotatePageInput), } as const -export const CLIENT_TOOL_NAMES = [ - 'get_fields', - 'get_document_content', - 'detect_fields', - 'delete_fields', - 'select_tool', - 'set_field_value', - 'focus_field', - 'go_to_page', - 'move_page', - 'delete_pages', - 'rotate_page', +// 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', -] 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) + 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 b2494fb..eb4537d 100644 --- a/copilot/src/lib/embed-bridge/bridge.ts +++ b/copilot/src/lib/embed-bridge/bridge.ts @@ -6,7 +6,6 @@ import { FocusFieldInput, GetDocumentContentInput, GoToInput, - LoadDocumentInput, MovePageInput, RotatePageInput, SelectToolInput, @@ -52,7 +51,6 @@ const getRequestTimeoutMs = (requestType: BridgeRequestType): number => { case 'GET_FIELDS': case 'DOWNLOAD': case 'GO_TO': - case 'LOAD_DOCUMENT': case 'MOVE_PAGE': case 'ROTATE_PAGE': case 'SELECT_TOOL': @@ -385,7 +383,7 @@ export const createBridge = ({ // 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 dispatcher, React SDK, etc.) is therefore a pure + // 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. @@ -405,7 +403,6 @@ export const createBridge = ({ } const bridge: IframeBridge = { getState: () => state, - loadDocument: (args) => parseAndSend(LoadDocumentInput, 'LOAD_DOCUMENT', args), getFields: () => sendRequest<{ fields: FieldRecord[] }>('GET_FIELDS', {}), getDocumentContent: (args) => parseAndSend( GetDocumentContentInput, diff --git a/copilot/src/lib/embed-bridge/index.ts b/copilot/src/lib/embed-bridge/index.ts index e9cca3e..a7886b5 100644 --- a/copilot/src/lib/embed-bridge/index.ts +++ b/copilot/src/lib/embed-bridge/index.ts @@ -11,7 +11,6 @@ export { GetDocumentContentInput, GetFieldsInput, GoToInput, - LoadDocumentInput, MovePageInput, NoInput, RotatePageInput, diff --git a/copilot/src/lib/embed-bridge/schemas.ts b/copilot/src/lib/embed-bridge/schemas.ts index 8c0604f..b64dbd6 100644 --- a/copilot/src/lib/embed-bridge/schemas.ts +++ b/copilot/src/lib/embed-bridge/schemas.ts @@ -98,11 +98,3 @@ export const SubmitInput = z 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.', ) - -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 930a81e..30d970d 100644 --- a/copilot/src/lib/embed-bridge/types.ts +++ b/copilot/src/lib/embed-bridge/types.ts @@ -74,7 +74,6 @@ export type BridgeState = // Request type union. Every postMessage the bridge sends carries one of these // as its `type` field. The editor honours each. export type BridgeRequestType = - | 'LOAD_DOCUMENT' | 'GO_TO' | 'SELECT_TOOL' | 'DETECT_FIELDS' @@ -92,14 +91,13 @@ export type BridgeRequestType = export type FocusFieldResult = { hint: { type: 'user_action_expected'; message: string } } | null // The bridge owns the contract. Each method takes `unknown` (raw, from any -// caller — the LLM dispatcher, direct UI code, etc.) 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 do not +// 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: unknown) => Promise getFields: () => Promise> getDocumentContent: (args: unknown) => Promise> detectFields: () => Promise> From 735945aa0b9ce96790a46521dd46c3c96266f3ea Mon Sep 17 00:00:00 2001 From: ben Date: Mon, 27 Apr 2026 18:32:02 +0200 Subject: [PATCH 10/11] refactor(P060): keep loadDocument on the bridge (not exposed as LLM tool) Restores `bridge.loadDocument` + `LoadDocumentInput`. The method stays available for direct host-app consumers / SDK adapters; it is NOT in LLM_STATIC_TOOLS or ClientToolName, so the LLM cannot invoke it via the copilot tool registry. This separates the bridge surface (full iframe contract) from the LLM-tool surface (a curated subset). --- copilot/src/lib/embed-bridge/bridge.ts | 3 +++ copilot/src/lib/embed-bridge/index.ts | 1 + copilot/src/lib/embed-bridge/schemas.ts | 11 +++++++++++ copilot/src/lib/embed-bridge/types.ts | 2 ++ 4 files changed, 17 insertions(+) diff --git a/copilot/src/lib/embed-bridge/bridge.ts b/copilot/src/lib/embed-bridge/bridge.ts index eb4537d..a471cc8 100644 --- a/copilot/src/lib/embed-bridge/bridge.ts +++ b/copilot/src/lib/embed-bridge/bridge.ts @@ -6,6 +6,7 @@ import { FocusFieldInput, GetDocumentContentInput, GoToInput, + LoadDocumentInput, MovePageInput, RotatePageInput, SelectToolInput, @@ -51,6 +52,7 @@ const getRequestTimeoutMs = (requestType: BridgeRequestType): number => { case 'GET_FIELDS': case 'DOWNLOAD': case 'GO_TO': + case 'LOAD_DOCUMENT': case 'MOVE_PAGE': case 'ROTATE_PAGE': case 'SELECT_TOOL': @@ -403,6 +405,7 @@ export const createBridge = ({ } const bridge: IframeBridge = { getState: () => state, + loadDocument: (args) => parseAndSend(LoadDocumentInput, 'LOAD_DOCUMENT', args), getFields: () => sendRequest<{ fields: FieldRecord[] }>('GET_FIELDS', {}), getDocumentContent: (args) => parseAndSend( GetDocumentContentInput, diff --git a/copilot/src/lib/embed-bridge/index.ts b/copilot/src/lib/embed-bridge/index.ts index a7886b5..e9cca3e 100644 --- a/copilot/src/lib/embed-bridge/index.ts +++ b/copilot/src/lib/embed-bridge/index.ts @@ -11,6 +11,7 @@ export { GetDocumentContentInput, GetFieldsInput, GoToInput, + LoadDocumentInput, MovePageInput, NoInput, RotatePageInput, diff --git a/copilot/src/lib/embed-bridge/schemas.ts b/copilot/src/lib/embed-bridge/schemas.ts index b64dbd6..a05b99d 100644 --- a/copilot/src/lib/embed-bridge/schemas.ts +++ b/copilot/src/lib/embed-bridge/schemas.ts @@ -98,3 +98,14 @@ export const SubmitInput = z 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 30d970d..bbf477e 100644 --- a/copilot/src/lib/embed-bridge/types.ts +++ b/copilot/src/lib/embed-bridge/types.ts @@ -74,6 +74,7 @@ export type BridgeState = // Request type union. Every postMessage the bridge sends carries one of these // as its `type` field. The editor honours each. export type BridgeRequestType = + | 'LOAD_DOCUMENT' | 'GO_TO' | 'SELECT_TOOL' | 'DETECT_FIELDS' @@ -98,6 +99,7 @@ export type FocusFieldResult = { hint: { type: 'user_action_expected'; message: // re-validate. export type IframeBridge = { getState: () => BridgeState + loadDocument: (args: unknown) => Promise getFields: () => Promise> getDocumentContent: (args: unknown) => Promise> detectFields: () => Promise> From 7667906b6f6a8cf2bb85e9cbe6c94cf424767399 Mon Sep 17 00:00:00 2001 From: ben Date: Mon, 27 Apr 2026 18:43:31 +0200 Subject: [PATCH 11/11] refactor(P060): tighten BridgeResult.error.code to a typed union MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bridge-owned codes (bad_input, bridge_disposed, iframe_not_ready, missing_result, timeout) are now literal types; iframe-forwarded codes (bad_request:*, forbidden:*, etc.) pass through via `string & {}` so arbitrary strings still compile. The `& {}` idiom preserves IDE autocomplete for the bridge-owned literals — typos like `code: 'iframe_not_redy'` show up in suggestions; bridge-emitted code is now self-documenting at the type level. Narrowing on a specific iframe code stays the consumer's responsibility. Exports BridgeErrorCode from the embed-bridge barrel so future adapter / consumer code can reference the type if it ever needs to. --- copilot/src/lib/embed-bridge/index.ts | 1 + copilot/src/lib/embed-bridge/types.ts | 12 +++++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/copilot/src/lib/embed-bridge/index.ts b/copilot/src/lib/embed-bridge/index.ts index e9cca3e..17d6d58 100644 --- a/copilot/src/lib/embed-bridge/index.ts +++ b/copilot/src/lib/embed-bridge/index.ts @@ -21,6 +21,7 @@ export { SupportedFieldTypeSchema, } from './schemas' export type { + BridgeErrorCode, BridgeRequestType, BridgeResult, BridgeState, diff --git a/copilot/src/lib/embed-bridge/types.ts b/copilot/src/lib/embed-bridge/types.ts index bbf477e..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