Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/rename-remove-fields-to-delete-fields.md
Original file line number Diff line number Diff line change
@@ -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);
}
```
38 changes: 7 additions & 31 deletions copilot/src/components/chat/chat_pane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<null> => {
const errorMessage = error instanceof Error ? `${error.name}: ${error.message}` : String(error)
return {
Expand Down Expand Up @@ -550,7 +531,7 @@ export const ChatPane = ({
if (activeBridge === null) {
return
}
void activeBridge.submit({ downloadCopy: false })
void activeBridge.submit({ download_copy: false })
}, [])

const handleDownloadRequested = useCallback((): void => {
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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,
Expand Down
25 changes: 2 additions & 23 deletions copilot/src/components/chat/hooks/use_detect_user_added_field.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand All @@ -53,31 +48,17 @@ 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,
toolbarTool,
isCursorOverEditor,
isStreamingRef,
onFieldAddedRef,
}: UseDetectUserAddedFieldArgs): UseDetectUserAddedFieldReturn => {
}: UseDetectUserAddedFieldArgs): void => {
const seenIdsRef = useRef<Set<string> | null>(null)
const lastBridgeRef = useRef<IframeBridge | null>(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
Expand Down Expand Up @@ -145,6 +126,4 @@ export const useDetectUserAddedField = ({
}
}
}, [bridge, isReady, toolbarTool, isCursorOverEditor, isStreamingRef, onFieldAddedRef])

return { markFieldAsKnown }
}
65 changes: 2 additions & 63 deletions copilot/src/lib/byok/transport.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,8 @@
import { convertToModelMessages, streamText, type UIMessage } from 'ai'
import { buildSystemPrompt } from '../../server/tools'
import {
DeletePageInput,
DetectFieldsInput,
FINALISATION_ACTION,
FocusFieldInput,
GetDocumentContentInput,
GetFieldsInput,
GoToPageInput,
MovePageInput,
RemoveFieldsInput,
RotatePageInput,
SelectToolInput,
SetFieldValueInput,
LLM_STATIC_TOOLS,
withFinalisationTool,
} from '../embed-bridge-adapters/client-tools'
import { formatStreamError } from '../error-classifier'
Expand Down Expand Up @@ -93,58 +83,7 @@ export const runByokStream = async ({ config, init }: RunByokStreamArgs): Promis
abortSignal: init?.signal ?? undefined,
maxRetries: 0,
maxOutputTokens: MAX_OUTPUT_TOKENS,
tools: withFinalisationTool({
get_fields: {
description: 'Lists every fillable field currently on the document.',
inputSchema: GetFieldsInput,
},
get_document_content: {
description: 'Extracts the textual content of the document page by page.',
inputSchema: GetDocumentContentInput,
},
detect_fields: {
description:
'Asks the editor to auto-detect and create missing fields. Call this when get_fields returned 0 fields.',
inputSchema: DetectFieldsInput,
},
remove_fields: {
description:
'Removes fields from the document. field_ids targets specific fields by id; page targets a single page (1-indexed); both omitted clears all fields. Destructive — only call when the user explicitly asks to remove fields.',
inputSchema: RemoveFieldsInput,
},
select_tool: {
description:
'Switches the editor tool (TEXT, BOXED_TEXT, CHECKBOX, SIGNATURE, PICTURE, or null for cursor).',
inputSchema: SelectToolInput,
},
set_field_value: {
description: 'Writes a value into a single field. Always focus_field first.',
inputSchema: SetFieldValueInput,
},
focus_field: {
description: 'Scrolls to and visually highlights a field.',
inputSchema: FocusFieldInput,
},
go_to_page: {
description: 'Scrolls the editor to a given 1-based page.',
inputSchema: GoToPageInput,
},
move_page: {
description:
'Reorders pages: from_page and to_page are 1-indexed visible page positions. Destructive — only call when the user explicitly asks to reorder a page.',
inputSchema: MovePageInput,
},
delete_page: {
description:
'Permanently removes a visible page (1-indexed) and any fields placed on it. The last remaining page cannot be deleted. Destructive — only call when the user explicitly asks to delete a page.',
inputSchema: DeletePageInput,
},
rotate_page: {
description:
'Rotates a visible page (1-indexed) 90° clockwise per call (repeat for 180° / 270°). Destructive — only call when the user explicitly asks to rotate a page.',
inputSchema: RotatePageInput,
},
}),
tools: withFinalisationTool(LLM_STATIC_TOOLS),
onError: ({ error }) => {
monitoring.error('byok.stream_error', { detail: normalizeError(error) })
},
Expand Down
155 changes: 0 additions & 155 deletions copilot/src/lib/embed-bridge-adapters/client-tools/dispatch.ts

This file was deleted.

Loading
Loading