Skip to content

Commit 4b86b72

Browse files
authored
feat(P060): rename REMOVE_FIELDS->DELETE_FIELDS and DELETE_PAGE->DELETE_PAGES (array) (#33)
## Background Cleanup pass on the copilot bridge + LLM-tool adapter. Pairs with the matching top-level repo PR ([SimplePDF/simple-pdf#196](SimplePDF/simple-pdf#196)) which carries the editor / e2e changes. The bridge now owns the iframe contract end-to-end (Zod schemas + descriptions + parsing). The adapter is a one-line-per-tool router. ## Changes **Bridge owns the contract** - New `embed-bridge/schemas.ts` — single source of truth: one Zod schema per iframe operation, each with `.describe()`. Snake_case keys throughout (matches the wire). - `IframeBridge` methods accept `unknown` and validate internally via `parseAndSend(Schema, 'EVENT', args)`. Bad input → `bad_input` without a postMessage round-trip. - `BridgeResult.error.code` is now a typed union with autocomplete for bridge-owned codes. **Adapter is thin** - `dispatch.ts`, `safeDispatch`, `client-tools/schemas.ts`, and the redundant `isClientToolName` runtime narrow are gone. - New `tools.ts` enumerates LLM tool names and pulls descriptions verbatim from the bridge schemas (no duplicated text). - `factory.ts` switch terminus is pure routing — every arm is one line; `satisfies never` keeps it exhaustive. - `chat.ts` and `transport.ts` drop ~50 lines each: tool registration is now `tools: withFinalisationTool(LLM_STATIC_TOOLS)`. **Dead code pruned** - `bridge.createField` + `CreateFieldArgs` removed (no LLM tool, no other consumer). - `chat.toolInvocation.names.create_field` i18n key dropped from all 23 locales. - `createLlmFieldBaselineMiddleware` + `markFieldAsKnown` plumbing removed (the `create_field` tool was never registered). **Iframe contract rename** (also part of this PR) - `REMOVE_FIELDS` → `DELETE_FIELDS` (response `removed_count` → `deleted_count`). - `DELETE_PAGE { page }` → `DELETE_PAGES { pages: number[] }`. **React SDK (BREAKING)** - `actions.removeFields` → `actions.deleteFields`; `removed_count` → `deleted_count`. Major-version changeset added. Net: `+388 / -674` lines on `copilot/`. Adding a new bridge operation is now: schema in `schemas.ts`, method on `IframeBridge`, `parseAndSend` line in `bridge.ts`. Exposing it to the LLM: one entry in `LLM_STATIC_TOOLS`, one switch arm in `factory.ts`. The `satisfies never` exhaustiveness catches drift at compile time.
1 parent 6a2ab0a commit 4b86b72

46 files changed

Lines changed: 470 additions & 717 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
"@simplepdf/react-embed-pdf": major
3+
---
4+
5+
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.
6+
7+
If you are not using `actions.removeFields(...)` or `sendEvent("REMOVE_FIELDS", ...)`, you can safely update to this new major version.
8+
9+
```ts
10+
// Before
11+
const result = await actions.removeFields({ page: 1 });
12+
if (result.success) {
13+
console.log(result.data.removed_count);
14+
}
15+
16+
// After
17+
const result = await actions.deleteFields({ page: 1 });
18+
if (result.success) {
19+
console.log(result.data.deleted_count);
20+
}
21+
```

copilot/src/components/chat/chat_pane.tsx

Lines changed: 7 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -324,25 +324,6 @@ const createToolbarSyncMiddleware =
324324
return result
325325
}
326326

327-
// When the LLM itself creates a field (via `create_field`), the iframe's
328-
// field set grows by one. If we did nothing, the post-stream getFields
329-
// would diff that field as "user-added" and nudge the LLM about a field
330-
// it just created itself. This middleware extracts the new field id from
331-
// the bridge result and forwards it to the host so the field-detection
332-
// hook can pre-mark it as known.
333-
const createLlmFieldBaselineMiddleware =
334-
({ onLlmCreatedField }: { onLlmCreatedField: (fieldId: string) => void }): ToolMiddleware =>
335-
async ({ toolName }, next) => {
336-
const result = await next()
337-
if (toolName === 'create_field' && result.success) {
338-
const data = result.data
339-
if (data !== null && typeof data === 'object' && 'field_id' in data && typeof data.field_id === 'string') {
340-
onLlmCreatedField(data.field_id)
341-
}
342-
}
343-
return result
344-
}
345-
346327
const toUnexpectedToolResult = (error: unknown): BridgeResult<null> => {
347328
const errorMessage = error instanceof Error ? `${error.name}: ${error.message}` : String(error)
348329
return {
@@ -550,7 +531,7 @@ export const ChatPane = ({
550531
if (activeBridge === null) {
551532
return
552533
}
553-
void activeBridge.submit({ downloadCopy: false })
534+
void activeBridge.submit({ download_copy: false })
554535
}, [])
555536

556537
const handleDownloadRequested = useCallback((): void => {
@@ -640,13 +621,13 @@ export const ChatPane = ({
640621
}, [])
641622

642623
// Refs-not-props for isStreaming + onFieldAdded: useDetectUserAddedField
643-
// must be called BEFORE `tools` useMemo (which needs markFieldAsKnown),
644-
// but both of those pieces of information come from useChat which runs
645-
// AFTER `tools`. Refs break the cycle; they are synced once useChat's
646-
// output is in scope (a bit further down in this component).
624+
// must be called BEFORE `tools` useMemo, but both of those pieces of
625+
// information come from useChat which runs AFTER `tools`. Refs break the
626+
// cycle; they are synced once useChat's output is in scope (a bit further
627+
// down in this component).
647628
const isStreamingRef = useRef(false)
648629
const onFieldAddedRef = useRef<(event: { tools: SupportedFieldType[]; delta: number }) => void>(() => {})
649-
const { markFieldAsKnown: markFieldDetectionAsKnown } = useDetectUserAddedField({
630+
useDetectUserAddedField({
650631
bridge,
651632
isReady,
652633
toolbarTool,
@@ -665,11 +646,6 @@ export const ChatPane = ({
665646
}
666647
const sharedMiddleware: ToolMiddleware[] = [
667648
createToolbarSyncMiddleware({ onChange: setToolbarTool }),
668-
createLlmFieldBaselineMiddleware({
669-
// When the LLM creates a field, mark its id as known so the next
670-
// user-placed-field diff does not attribute it to the user.
671-
onLlmCreatedField: (fieldId) => markFieldDetectionAsKnown(fieldId),
672-
}),
673649
createCompactionMiddleware({ getByokActive: () => byokConfigRef.current !== null }),
674650
]
675651
// Demo-only middleware lives at the head of the chain so it
@@ -684,7 +660,7 @@ export const ChatPane = ({
684660
systemPrompt: SYSTEM_PROMPT,
685661
middleware,
686662
})
687-
}, [bridge, handleDownloadRequested, markFieldDetectionAsKnown])
663+
}, [bridge, handleDownloadRequested])
688664

689665
const { messages, status, error, sendMessage, stop, addToolOutput, setMessages } = useChat({
690666
transport,

copilot/src/components/chat/hooks/use_detect_user_added_field.ts

Lines changed: 2 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
1+
import { type MutableRefObject, useEffect, useRef } from 'react'
22
import type { IframeBridge, SupportedFieldType } from '../../../lib/embed-bridge'
33

44
// WORKAROUND: the SimplePDF editor does not currently emit an outbound
@@ -30,11 +30,6 @@ import type { IframeBridge, SupportedFieldType } from '../../../lib/embed-bridge
3030
// the UI can show one icon per unique type when the user mixed (e.g.
3131
// TEXT + SIGNATURE in the same batch).
3232
//
33-
// LLM-created fields bypass this nudge via `markFieldAsKnown(fieldId)`,
34-
// called from the create_field middleware once the iframe has confirmed
35-
// the new field id. The id goes straight into the seen set; the next
36-
// poll's diff sees no user-added fields.
37-
//
3833
// Refs-not-props for the streaming flag and the fire callback let the
3934
// hook be called BEFORE useChat in the consumer (useChat produces the
4035
// status + sendMessage used downstream). The consumer syncs the refs
@@ -53,31 +48,17 @@ type UseDetectUserAddedFieldArgs = {
5348
onFieldAddedRef: MutableRefObject<(event: FieldAddedEvent) => void>
5449
}
5550

56-
type UseDetectUserAddedFieldReturn = {
57-
// Consumers call this when they know a field was added by something
58-
// other than the user (e.g. the LLM's `create_field` tool returned a
59-
// field id). The id is added to the seen set so the next poll does
60-
// NOT attribute that field to the user.
61-
markFieldAsKnown: (fieldId: string) => void
62-
}
63-
6451
export const useDetectUserAddedField = ({
6552
bridge,
6653
isReady,
6754
toolbarTool,
6855
isCursorOverEditor,
6956
isStreamingRef,
7057
onFieldAddedRef,
71-
}: UseDetectUserAddedFieldArgs): UseDetectUserAddedFieldReturn => {
58+
}: UseDetectUserAddedFieldArgs): void => {
7259
const seenIdsRef = useRef<Set<string> | null>(null)
7360
const lastBridgeRef = useRef<IframeBridge | null>(null)
7461

75-
const markFieldAsKnown = useCallback((fieldId: string): void => {
76-
if (seenIdsRef.current !== null) {
77-
seenIdsRef.current.add(fieldId)
78-
}
79-
}, [])
80-
8162
useEffect(() => {
8263
// Bridge swap is the only event that invalidates the seen set; the
8364
// ids belong to a different document context. Tool changes, cursor
@@ -145,6 +126,4 @@ export const useDetectUserAddedField = ({
145126
}
146127
}
147128
}, [bridge, isReady, toolbarTool, isCursorOverEditor, isStreamingRef, onFieldAddedRef])
148-
149-
return { markFieldAsKnown }
150129
}

copilot/src/lib/byok/transport.ts

Lines changed: 2 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,8 @@
11
import { convertToModelMessages, streamText, type UIMessage } from 'ai'
22
import { buildSystemPrompt } from '../../server/tools'
33
import {
4-
DeletePageInput,
5-
DetectFieldsInput,
64
FINALISATION_ACTION,
7-
FocusFieldInput,
8-
GetDocumentContentInput,
9-
GetFieldsInput,
10-
GoToPageInput,
11-
MovePageInput,
12-
RemoveFieldsInput,
13-
RotatePageInput,
14-
SelectToolInput,
15-
SetFieldValueInput,
5+
LLM_STATIC_TOOLS,
166
withFinalisationTool,
177
} from '../embed-bridge-adapters/client-tools'
188
import { formatStreamError } from '../error-classifier'
@@ -93,58 +83,7 @@ export const runByokStream = async ({ config, init }: RunByokStreamArgs): Promis
9383
abortSignal: init?.signal ?? undefined,
9484
maxRetries: 0,
9585
maxOutputTokens: MAX_OUTPUT_TOKENS,
96-
tools: withFinalisationTool({
97-
get_fields: {
98-
description: 'Lists every fillable field currently on the document.',
99-
inputSchema: GetFieldsInput,
100-
},
101-
get_document_content: {
102-
description: 'Extracts the textual content of the document page by page.',
103-
inputSchema: GetDocumentContentInput,
104-
},
105-
detect_fields: {
106-
description:
107-
'Asks the editor to auto-detect and create missing fields. Call this when get_fields returned 0 fields.',
108-
inputSchema: DetectFieldsInput,
109-
},
110-
remove_fields: {
111-
description:
112-
'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.',
113-
inputSchema: RemoveFieldsInput,
114-
},
115-
select_tool: {
116-
description:
117-
'Switches the editor tool (TEXT, BOXED_TEXT, CHECKBOX, SIGNATURE, PICTURE, or null for cursor).',
118-
inputSchema: SelectToolInput,
119-
},
120-
set_field_value: {
121-
description: 'Writes a value into a single field. Always focus_field first.',
122-
inputSchema: SetFieldValueInput,
123-
},
124-
focus_field: {
125-
description: 'Scrolls to and visually highlights a field.',
126-
inputSchema: FocusFieldInput,
127-
},
128-
go_to_page: {
129-
description: 'Scrolls the editor to a given 1-based page.',
130-
inputSchema: GoToPageInput,
131-
},
132-
move_page: {
133-
description:
134-
'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.',
135-
inputSchema: MovePageInput,
136-
},
137-
delete_page: {
138-
description:
139-
'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.',
140-
inputSchema: DeletePageInput,
141-
},
142-
rotate_page: {
143-
description:
144-
'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.',
145-
inputSchema: RotatePageInput,
146-
},
147-
}),
86+
tools: withFinalisationTool(LLM_STATIC_TOOLS),
14887
onError: ({ error }) => {
14988
monitoring.error('byok.stream_error', { detail: normalizeError(error) })
15089
},

copilot/src/lib/embed-bridge-adapters/client-tools/dispatch.ts

Lines changed: 0 additions & 155 deletions
This file was deleted.

0 commit comments

Comments
 (0)