diff --git a/.changeset/tanstack-ai-adapter.md b/.changeset/tanstack-ai-adapter.md new file mode 100644 index 0000000..699d697 --- /dev/null +++ b/.changeset/tanstack-ai-adapter.md @@ -0,0 +1,15 @@ +--- +"@simplepdf/embed": minor +"@simplepdf/react-embed-pdf": minor +--- + +Add a TanStack AI adapter (the `/tanstack-ai` subpath) for client-side tool calling, alongside the existing Vercel AI SDK (`/ai-sdk`) adapter. Both wrap the same generated tool registry + bridge router, so the editor is drivable from either SDK with no duplicated logic. + +- `@simplepdf/embed/tanstack-ai`: `simplePDFToolDefinitions()` (server, for `chat({ tools })`) and `createSimplePDFTools({ embed })` (browser `.client()` tools for `clientTools(...)` then `useChat({ tools })`). +- `@simplepdf/react-embed-pdf/tanstack-ai`: `useEmbedTools(embedRef)`, the editor-bound client tools. Server definitions stay in the React-free core `@simplepdf/embed/tanstack-ai`, so a server route never pulls React in. +- `@tanstack/ai` is a new optional peer, pulled only by the `/tanstack-ai` subpath; the package roots stay free of it (and of `zod`). +- **Public exports trimmed to the strict minimum.** These are breaking removals, but the only consumer is copilot (migrated in lockstep), so they ship as a minor rather than a major: + - `@simplepdf/embed` root drops the internal helpers `buildEditorDomain`, `encodeContext`, `isBridgeResultLike`. + - `@simplepdf/embed/protocol` drops the internal `INTERNAL_PROTOCOL` / `InternalProtocolType` (used only by the bridge). + - `@simplepdf/react-embed-pdf` root no longer re-exports the whole `@simplepdf/embed` core or the wire-protocol vocabulary; import those from `@simplepdf/embed` / `@simplepdf/embed/protocol` directly. + - `@simplepdf/react-embed-pdf/ai-sdk` no longer re-exports `simplePDFToolDefinitions` (import it from `@simplepdf/embed/ai-sdk`); the browser-side `createSimplePDFExecutor` stays. Server tool-definitions now live only in the React-free core, so a server route never pulls React in. diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..19601eb --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +# API Extractor emits CRLF; keep the committed API-surface reports LF so they don't trip +# git's whitespace gate or churn across platforms. api-extractor's report comparison is +# line-ending agnostic, so this does not affect `check:api`. +*.api.md text eol=lf diff --git a/.github/workflows/embed.yaml b/.github/workflows/embed.yaml index 7a952d3..547b3fa 100644 --- a/.github/workflows/embed.yaml +++ b/.github/workflows/embed.yaml @@ -6,12 +6,18 @@ on: - main paths: - embed/** + - scripts/** + - package.json + - package-lock.json - .github/workflows/embed.yaml pull_request: branches: - main paths: - embed/** + - scripts/** + - package.json + - package-lock.json - .github/workflows/embed.yaml jobs: @@ -39,3 +45,9 @@ jobs: - name: Bundle size budgets run: npm run --workspace @simplepdf/embed check:size + + - name: Export load + run: npm run --workspace @simplepdf/embed check:exports + + - name: API surface + run: npm run --workspace @simplepdf/embed check:api diff --git a/.github/workflows/react.yaml b/.github/workflows/react.yaml index dc38c95..1fe188b 100644 --- a/.github/workflows/react.yaml +++ b/.github/workflows/react.yaml @@ -7,6 +7,9 @@ on: paths: - react/** - embed/** + - scripts/** + - package.json + - package-lock.json - .github/workflows/react.yaml pull_request: branches: @@ -14,6 +17,9 @@ on: paths: - react/** - embed/** + - scripts/** + - package.json + - package-lock.json - .github/workflows/react.yaml jobs: @@ -38,6 +44,9 @@ jobs: - name: Build the core run: npm run --workspace @simplepdf/embed build + - name: Build react-embed-pdf + run: npm run --workspace @simplepdf/react-embed-pdf build + - name: Formatting run: npm run --workspace @simplepdf/react-embed-pdf test:format @@ -46,3 +55,9 @@ jobs: - name: Tests run: npm run --workspace @simplepdf/react-embed-pdf test + + - name: Export load + run: npm run --workspace @simplepdf/react-embed-pdf check:exports + + - name: API surface + run: npm run --workspace @simplepdf/react-embed-pdf check:api diff --git a/.gitignore b/.gitignore index 3bed324..8a87439 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,7 @@ chrome-extension/release.zip .claude/ .wrangler/ -coverage \ No newline at end of file +coverage + +# api-extractor working files (committed API reports live in /etc/) +tmp/ \ No newline at end of file diff --git a/copilot/src/components/chat/chat_pane.tsx b/copilot/src/components/chat/chat_pane.tsx index a43e7c7..1a35e3e 100644 --- a/copilot/src/components/chat/chat_pane.tsx +++ b/copilot/src/components/chat/chat_pane.tsx @@ -1,13 +1,8 @@ import { useChat } from '@ai-sdk/react' -import { - OVERLAY_TOOL_TYPES, - type BridgeResult, - type DocumentContentPage, - type DocumentContentResult, - type EmbedActions, - type FieldRecord, -} from '@simplepdf/react-embed-pdf' -import { type EmbedTools, type SimplePDFToolName } from '@simplepdf/react-embed-pdf/ai-sdk' +import type { BridgeResult, DocumentContentPage, DocumentContentResult, FieldRecord } from '@simplepdf/embed' +import { OVERLAY_TOOL_TYPES } from '@simplepdf/embed/protocol' +import type { EmbedActions } from '@simplepdf/react-embed-pdf' +import type { EmbedTools, SimplePDFToolName } from '@simplepdf/react-embed-pdf/ai-sdk' import { getRouteApi } from '@tanstack/react-router' import { DefaultChatTransport, lastAssistantMessageIsCompleteWithToolCalls, type UIMessage } from 'ai' import { ArrowUp, Mic, X } from 'lucide-react' diff --git a/copilot/src/lib/tools/definitions.ts b/copilot/src/lib/tools/definitions.ts index ce2c0f4..82006dc 100644 --- a/copilot/src/lib/tools/definitions.ts +++ b/copilot/src/lib/tools/definitions.ts @@ -1,4 +1,4 @@ -import { simplePDFToolDefinitions, type SimplePDFToolName } from '@simplepdf/react-embed-pdf/ai-sdk' +import { type SimplePDFToolName, simplePDFToolDefinitions } from '@simplepdf/embed/ai-sdk' import type { FinalisationAction } from '../../server/tools' import { IS_DEMO_MODE } from '../mode' diff --git a/embed/README.md b/embed/README.md index cf56398..44afa3c 100644 --- a/embed/README.md +++ b/embed/README.md @@ -49,13 +49,26 @@ const execute = createSimplePDFExecutor({ embed }) `@simplepdf/embed/tools` exposes the same registry SDK-agnostically (`routeToolCall`, `isSimplePDFToolName`). In React, `@simplepdf/react-embed-pdf/ai-sdk`'s `useEmbedTools(embedRef)` is the same registry pre-bound to the live editor. +For TanStack AI, the same registry is exposed via `@simplepdf/embed/tanstack-ai`: + +```ts +// server: execute-less definitions so the model is aware of the tools +import { simplePDFToolDefinitions } from '@simplepdf/embed/tanstack-ai' +chat({ adapter, messages, tools: simplePDFToolDefinitions() }) + +// browser: the same definitions bound to the live editor via .client() +import { clientTools } from '@tanstack/ai-react' +import { createSimplePDFTools } from '@simplepdf/embed/tanstack-ai' +useChat({ connection, tools: clientTools(...createSimplePDFTools({ embed })) }) +``` + ## Install ```bash npm install @simplepdf/embed ``` -Zero runtime dependencies at the root. `zod` is an optional peer, needed only by the `/schemas`, `/tools`, and `/ai-sdk` subpaths. `/ai-sdk` produces values for the Vercel AI SDK but never imports `ai`; bring your own. +Zero runtime dependencies at the root. `zod` is an optional peer, needed by the `/schemas`, `/tools`, `/ai-sdk`, and `/tanstack-ai` subpaths. `/ai-sdk` produces values for the Vercel AI SDK without importing `ai` (bring your own); `/tanstack-ai` uses `@tanstack/ai`'s `toolDefinition` (also an optional peer, pulled only by that subpath). ## Subpaths @@ -66,6 +79,7 @@ Zero runtime dependencies at the root. `zod` is an optional peer, needed only by | `@simplepdf/embed/schemas` | zod schema for every operation input | `zod` | | `@simplepdf/embed/tools` | SDK-agnostic agentic tool registry + `routeToolCall` + `isSimplePDFToolName` | `zod` | | `@simplepdf/embed/ai-sdk` | `simplePDFToolDefinitions()` (server) + `createSimplePDFExecutor({ embed })` (browser) for the Vercel AI SDK | `zod` | +| `@simplepdf/embed/tanstack-ai` | `simplePDFToolDefinitions()` (server) + `createSimplePDFTools({ embed })` (browser) for TanStack AI | `zod`, `@tanstack/ai` | ## Where the editor goes diff --git a/embed/etc/ai-sdk.api.md b/embed/etc/ai-sdk.api.md new file mode 100644 index 0000000..1c565a7 --- /dev/null +++ b/embed/etc/ai-sdk.api.md @@ -0,0 +1,31 @@ +## API Report File for "@simplepdf/embed" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import * as zod from 'zod'; +import * as zod_v4_core from 'zod/v4/core'; + +// Warning: (ae-forgotten-export) The symbol "BridgeResult" needs to be exported by the entry point ai-sdk.d.ts +// +// @public (undocumented) +export const createSimplePDFExecutor: (input: { + embed: Embed; +}) => ((toolName: string, input: unknown) => Promise>); + +// Warning: (ae-forgotten-export) The symbol "TOOL_DEFINITIONS" needs to be exported by the entry point ai-sdk.d.ts +// +// @public (undocumented) +export const simplePDFToolDefinitions: () => typeof TOOL_DEFINITIONS; + +// @public (undocumented) +export type SimplePDFToolName = keyof typeof TOOL_DEFINITIONS; + +// Warnings were encountered during analysis: +// +// dist/ai-sdk.d.ts:10:5 - (ae-forgotten-export) The symbol "Embed" needs to be exported by the entry point ai-sdk.d.ts + +// (No @packageDocumentation comment for this package) + +``` diff --git a/embed/etc/index.api.md b/embed/etc/index.api.md new file mode 100644 index 0000000..5941bf4 --- /dev/null +++ b/embed/etc/index.api.md @@ -0,0 +1,336 @@ +## API Report File for "@simplepdf/embed" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +// @public (undocumented) +export type BridgeError = { + code: 'bad_request:missing_required_fields'; + message: string; + details: MissingRequiredFieldsDetails; +} | { + code: Exclude; + message: string; +}; + +// @public (undocumented) +export type BridgeErrorCode = BridgeOwnedErrorCode | EditorErrorCode; + +// @public (undocumented) +export type BridgeLogger = { + debug: (event: string, payload: LogPayload) => void; + info: (event: string, payload: LogPayload) => void; + warn: (event: string, payload: LogPayload) => void; + error: (event: string, payload: LogPayload) => void; +}; + +// @public (undocumented) +export type BridgeOwnedErrorCode = 'bad_request:invalid_input' | 'unexpected:timeout' | 'unexpected:iframe_not_mounted' | 'unexpected:bridge_disposed' | 'unexpected:malformed_result' | 'unexpected:unknown'; + +// @public (undocumented) +export type BridgeResult = { + success: true; + data: TData; +} | { + success: false; + error: BridgeError; +}; + +// @public (undocumented) +export type BridgeState = { + kind: 'booting'; +} | { + kind: 'editorReady'; +} | { + kind: 'documentLoaded'; + documentId: string | null; +}; + +// @public (undocumented) +export class BridgeUnwrapError extends Error { + constructor(error: BridgeError); + // (undocumented) + readonly error: BridgeError; +} + +// @public (undocumented) +export const createEmbed: (args: CreateEmbedArgs) => Embed; + +// @public (undocumented) +export type CreateEmbedArgs = { + target: string | HTMLElement; + companyIdentifier: string; + baseDomain?: string; + document?: EmbedDocument; + locale?: Locale; + context?: Record; + iframeAttrs?: { + title?: string; + allow?: string; + sandbox?: string; + className?: string; + style?: Partial; + }; + logger?: BridgeLogger; +}; + +// @public (undocumented) +export type CreateFieldInput = { + type: OverlayToolType; + x: number; + y: number; + width: number; + height: number; + page: number; + value?: string; +}; + +// @public (undocumented) +export type CreateFieldOutput = { + fieldId: string; +}; + +// @public (undocumented) +export type DeleteFieldsInput = { + fieldIds?: string[]; + page?: number; +}; + +// @public (undocumented) +export type DeleteFieldsOutput = { + deletedCount: number; +}; + +// @public (undocumented) +export type DeletePagesInput = { + pages: number[]; +}; + +// @public (undocumented) +export type DetectFieldsOutput = { + detectedCount: number; +}; + +// @public (undocumented) +export type DocumentContentPage = GetDocumentContentOutput['pages'][number]; + +// @public (undocumented) +export type DocumentContentResult = GetDocumentContentOutput; + +// Warning: (ae-forgotten-export) The symbol "EDITOR_ERROR_CODES" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +export type EditorErrorCode = (typeof EDITOR_ERROR_CODES)[number]; + +// @public (undocumented) +export type EditorEvent = { + type: 'EDITOR_READY'; + data: Record; +} | { + type: 'DOCUMENT_LOADED'; + data: { + document_id: string; + }; +} | { + type: 'PAGE_FOCUSED'; + data: PageFocusedPayload; +} | { + type: 'SUBMISSION_SENT'; + data: SubmissionSentPayload; +}; + +// @public (undocumented) +export type EditorEventMap = { + [TEvent in EditorEvent as TEvent['type']]: TEvent['data']; +}; + +// @public (undocumented) +export type Embed = { + actions: IframeActions; + events: EmbedEvents; + lifecycle: EmbedLifecycle; +}; + +// @public (undocumented) +export class EmbedConfigError extends Error { + constructor(code: 'invalid_config' | 'invalid_document' | 'invalid_target' | 'invalid_company_identifier', message: string); + // (undocumented) + readonly code: 'invalid_config' | 'invalid_document' | 'invalid_target' | 'invalid_company_identifier'; +} + +// @public (undocumented) +export type EmbedDocument = { + url: string; + name?: string; + page?: number; +} | { + dataUrl: string; + name?: string; + page?: number; +} | { + file: File | Blob; + name?: string; + page?: number; +}; + +// @public (undocumented) +export type EmbedEvents = { + on: (type: TEventType, handler: (data: EditorEventMap[TEventType]) => void) => () => void; +}; + +// @public (undocumented) +export type EmbedLifecycle = { + dispose: () => void; +}; + +// Warning: (ae-forgotten-export) The symbol "EXTRACTION_MODES" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +export type ExtractionMode = (typeof EXTRACTION_MODES)[number]; + +// @public (undocumented) +export type FieldRecord = GetFieldsOutput['fields'][number]; + +// Warning: (ae-forgotten-export) The symbol "FIELD_TYPES" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +export type FieldType = (typeof FIELD_TYPES)[number]; + +// @public (undocumented) +export type FocusFieldInput = { + fieldId: string; +}; + +// @public (undocumented) +export type FocusFieldOutput = { + hint: { + type: "user_action_expected"; + message: string; + }; +}; + +// @public (undocumented) +export type GetDocumentContentInput = { + extractionMode?: ExtractionMode; +}; + +// @public (undocumented) +export type GetDocumentContentOutput = { + name: string; + pages: Array<{ + page: number; + content: string; + }>; +}; + +// @public (undocumented) +export type GetFieldsOutput = { + fields: Array<{ + fieldId: string; + name: string | null; + type: FieldType; + page: number; + value: string | null; + options: string[] | null; + }>; +}; + +// @public (undocumented) +export type GoToInput = { + page: number; +}; + +// @public (undocumented) +export type IframeActions = { + createField: (input: CreateFieldInput) => Promise>; + deleteFields: (input?: DeleteFieldsInput) => Promise>; + deletePages: (input: DeletePagesInput) => Promise; + detectFields: () => Promise>; + download: () => Promise; + focusField: (input: FocusFieldInput) => Promise>; + getDocumentContent: (input?: GetDocumentContentInput) => Promise>; + getFields: () => Promise>; + goTo: (input: GoToInput) => Promise; + loadDocument: (input: LoadDocumentInput) => Promise; + movePage: (input: MovePageInput) => Promise; + rotatePage: (input: RotatePageInput) => Promise; + selectTool: (input: SelectToolInput) => Promise; + setFieldValue: (input: SetFieldValueInput) => Promise; + submit: (input: SubmitInput) => Promise; +}; + +// @public (undocumented) +export type LoadDocumentInput = { + dataUrl: string; + name?: string; + page?: number; +}; + +// Warning: (ae-forgotten-export) The symbol "LOCALES" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +export type Locale = (typeof LOCALES)[number]; + +// @public (undocumented) +export type LogPayload = Record; + +// @public (undocumented) +export type MissingRequiredFieldsDetails = { + unfilledRequiredFieldsCount: number; +}; + +// @public (undocumented) +export type MovePageInput = { + fromPage: number; + toPage: number; +}; + +// @public (undocumented) +export const NOOP_LOGGER: BridgeLogger; + +// Warning: (ae-forgotten-export) The symbol "OVERLAY_TOOL_TYPES" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +export type OverlayToolType = (typeof OVERLAY_TOOL_TYPES)[number]; + +// @public (undocumented) +export type PageFocusedPayload = { + previous_page: number | null; + current_page: number; + total_pages: number; +}; + +// @public (undocumented) +export type RotatePageInput = { + page: number; +}; + +// @public (undocumented) +export type SelectToolInput = { + tool: OverlayToolType | null; +}; + +// @public (undocumented) +export type SetFieldValueInput = { + fieldId: string; + value: string | null; +}; + +// @public (undocumented) +export type SubmissionSentPayload = { + document_id: string; + submission_id: string; +}; + +// @public (undocumented) +export type SubmitInput = { + downloadCopy: boolean; +}; + +// @public (undocumented) +export const unwrap: (result: BridgeResult) => TData; + +// (No @packageDocumentation comment for this package) + +``` diff --git a/embed/etc/protocol.api.md b/embed/etc/protocol.api.md new file mode 100644 index 0000000..385a9c9 --- /dev/null +++ b/embed/etc/protocol.api.md @@ -0,0 +1,186 @@ +## API Report File for "@simplepdf/embed" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +// @public (undocumented) +export const EDITOR_ERROR_CODES: readonly ["bad_request:editor_not_ready", "bad_request:event_not_allowed", "bad_request:field_not_found", "bad_request:invalid_dimensions", "bad_request:invalid_event_type", "bad_request:invalid_field_ids", "bad_request:invalid_field_type", "bad_request:invalid_page", "bad_request:invalid_signature_url", "bad_request:invalid_tool", "bad_request:invalid_value", "bad_request:missing_required_fields", "bad_request:no_document_loaded", "bad_request:page_not_found", "bad_request:page_out_of_range", "bad_request:plan_upgrade_required", "bad_request:read_only", "bad_request:signup_required", "forbidden:editing_not_allowed", "forbidden:origin_not_whitelisted", "forbidden:whitelist_required", "unexpected:internal_error"]; + +// @public (undocumented) +export const EXTRACTION_MODES: readonly ["auto", "ocr"]; + +// @public (undocumented) +export type ExtractionMode = (typeof EXTRACTION_MODES)[number]; + +// @public (undocumented) +export const FIELD_TYPES: readonly ["TEXT", "SIGNATURE", "PICTURE", "CHECKBOX", "COMB_TEXT", "DROPDOWN", "RADIO"]; + +// @public (undocumented) +export type FieldType = (typeof FIELD_TYPES)[number]; + +// @public (undocumented) +export type Locale = (typeof LOCALES)[number]; + +// @public (undocumented) +export const LOCALES: readonly ["fr", "en", "it", "de", "pt", "es", "ja", "nl"]; + +// @public (undocumented) +export const OPERATIONS: readonly [{ + readonly request_type: "CREATE_FIELD"; + readonly wire_type: "CREATE_FIELD"; + readonly method: "createField"; + readonly description: "Create a new overlay field of the given type at an (x, y) position and size (in PDF points) on a 1-based page. Returns { field_id } for the created field. Requires editing to be enabled."; + readonly error_codes: readonly ["forbidden:editing_not_allowed", "bad_request:invalid_page", "bad_request:invalid_dimensions", "bad_request:invalid_value", "bad_request:page_out_of_range", "bad_request:page_not_found", "bad_request:invalid_field_type", "bad_request:invalid_signature_url"]; + readonly is_agentic_tool: true; + readonly has_output: true; +}, { + readonly request_type: "DELETE_FIELDS"; + readonly wire_type: "DELETE_FIELDS"; + readonly method: "deleteFields"; + readonly description: "Delete overlay fields by id; omit field_ids to delete every field on the given 1-based page, or omit both field_ids and page to delete every overlay field in the document. Returns { deleted_count }. Destructive; requires editing to be enabled."; + readonly error_codes: readonly ["forbidden:editing_not_allowed", "bad_request:invalid_field_ids", "bad_request:invalid_page", "bad_request:page_out_of_range", "bad_request:page_not_found"]; + readonly is_agentic_tool: true; + readonly has_output: true; +}, { + readonly request_type: "DELETE_PAGES"; + readonly wire_type: "DELETE_PAGES"; + readonly method: "deletePages"; + readonly description: "Delete one or more 1-based pages from the document (it cannot delete every visible page). Returns no data. Destructive; requires editing to be enabled."; + readonly error_codes: readonly ["forbidden:editing_not_allowed", "bad_request:invalid_page", "bad_request:page_out_of_range", "bad_request:no_document_loaded", "bad_request:page_not_found"]; + readonly is_agentic_tool: true; + readonly has_output: false; +}, { + readonly request_type: "DETECT_FIELDS"; + readonly wire_type: "DETECT_FIELDS"; + readonly method: "detectFields"; + readonly description: "Automatically detect fillable fields in the loaded document and add them as editable fields. Returns { detected_count }. Requires editing to be enabled."; + readonly error_codes: readonly ["forbidden:editing_not_allowed", "bad_request:no_document_loaded"]; + readonly is_agentic_tool: true; + readonly has_output: true; +}, { + readonly request_type: "DOWNLOAD"; + readonly wire_type: "DOWNLOAD"; + readonly method: "download"; + readonly description: "Generate and download the current document as a PDF. Returns no data."; + readonly error_codes: readonly ["bad_request:no_document_loaded", "bad_request:missing_required_fields"]; + readonly is_agentic_tool: true; + readonly has_output: false; +}, { + readonly request_type: "FOCUS_FIELD"; + readonly wire_type: "FOCUS_FIELD"; + readonly method: "focusField"; + readonly description: "Scroll an existing field into view and focus it, addressed by its id (from get_fields). Returns a hint describing the user action expected next."; + readonly error_codes: readonly ["bad_request:invalid_value", "bad_request:no_document_loaded", "bad_request:field_not_found"]; + readonly is_agentic_tool: true; + readonly has_output: true; +}, { + readonly request_type: "GET_DOCUMENT_CONTENT"; + readonly wire_type: "GET_DOCUMENT_CONTENT"; + readonly method: "getDocumentContent"; + readonly description: "Extract the document's text content page by page (pass extraction_mode 'ocr' to force optical recognition). Use it to read what the document says. Returns { name, pages: [{ page, content }] }."; + readonly error_codes: readonly ["bad_request:invalid_value", "bad_request:no_document_loaded"]; + readonly is_agentic_tool: true; + readonly has_output: true; +}, { + readonly request_type: "GET_FIELDS"; + readonly wire_type: "GET_FIELDS"; + readonly method: "getFields"; + readonly description: "List every fillable field in the loaded document, including native dropdown and radio AcroFields. Each field reports its id, name, type, page, and current value. Call this first to discover field ids before reading or setting values. Returns { fields }."; + readonly error_codes: readonly ["bad_request:no_document_loaded"]; + readonly is_agentic_tool: true; + readonly has_output: true; +}, { + readonly request_type: "GO_TO"; + readonly wire_type: "GO_TO"; + readonly method: "goTo"; + readonly description: "Scroll the editor to a specific 1-based page. Returns no data."; + readonly error_codes: readonly ["bad_request:invalid_page", "bad_request:page_out_of_range"]; + readonly is_agentic_tool: true; + readonly has_output: false; +}, { + readonly request_type: "LOAD_DOCUMENT"; + readonly wire_type: "LOAD_DOCUMENT"; + readonly method: "loadDocument"; + readonly description: "Load a document into the editor from a base64 data URL. This is a host/setup action (no agentic tool); it returns no data."; + readonly error_codes: readonly ["bad_request:invalid_value", "bad_request:invalid_page"]; + readonly is_agentic_tool: false; + readonly has_output: false; +}, { + readonly request_type: "MOVE_PAGE"; + readonly wire_type: "MOVE_PAGE"; + readonly method: "movePage"; + readonly description: "Move a page from one 1-based position to another, reordering the document. Returns no data. Destructive; requires editing to be enabled."; + readonly error_codes: readonly ["forbidden:editing_not_allowed", "bad_request:invalid_page", "bad_request:page_out_of_range", "bad_request:no_document_loaded", "bad_request:page_not_found"]; + readonly is_agentic_tool: true; + readonly has_output: false; +}, { + readonly request_type: "ROTATE_PAGE"; + readonly wire_type: "ROTATE_PAGE"; + readonly method: "rotatePage"; + readonly description: "Rotate a 1-based page 90 degrees clockwise. Returns no data. Destructive; requires editing to be enabled."; + readonly error_codes: readonly ["forbidden:editing_not_allowed", "bad_request:invalid_page", "bad_request:page_out_of_range", "bad_request:no_document_loaded", "bad_request:page_not_found"]; + readonly is_agentic_tool: true; + readonly has_output: false; +}, { + readonly request_type: "SELECT_TOOL"; + readonly wire_type: "SELECT_TOOL"; + readonly method: "selectTool"; + readonly description: "Activate a field-placement tool in the editor toolbar so the user can draw that field type, or pass null to clear the active tool. Returns no data."; + readonly error_codes: readonly ["bad_request:invalid_tool"]; + readonly is_agentic_tool: true; + readonly has_output: false; +}, { + readonly request_type: "SET_FIELD_VALUE"; + readonly wire_type: "SET_FIELD_VALUE"; + readonly method: "setFieldValue"; + readonly description: "Set the value of an existing field addressed by its id (from get_fields), or clear it with null. If the field has options (see get_fields), value must be one of them; otherwise value is a string (text or checkbox value) or a data URL (signature, picture). Returns no data."; + readonly error_codes: readonly ["bad_request:invalid_value", "bad_request:invalid_signature_url", "bad_request:no_document_loaded", "bad_request:read_only", "bad_request:field_not_found"]; + readonly is_agentic_tool: true; + readonly has_output: false; +}, { + readonly request_type: "SUBMIT"; + readonly wire_type: "SUBMIT"; + readonly method: "submit"; + readonly description: "Submit the completed document through the editor's finalization flow. This is irreversible. When download_copy is true the signer also gets a downloaded copy. Fails with missing_required_fields when required fields are unfilled. Returns no data."; + readonly error_codes: readonly ["bad_request:invalid_value", "bad_request:missing_required_fields"]; + readonly is_agentic_tool: true; + readonly has_output: false; +}]; + +// @public (undocumented) +export const OUTBOUND_EVENT_TYPES: ("PAGE_FOCUSED" | "SUBMISSION_SENT")[]; + +// @public (undocumented) +export const OUTBOUND_EVENTS: readonly [{ + readonly event_type: "PAGE_FOCUSED"; + readonly description: "Pushed when the focused page changes (the user scrolls to a new page, or a GO_TO completes). The payload reports the current page."; +}, { + readonly event_type: "SUBMISSION_SENT"; + readonly description: "Pushed after a SUBMIT completes successfully. This is how you confirm a submission landed: the SUBMIT operation itself resolves with data: null, so listen for this event to get the resulting document_id and submission_id."; +}]; + +// @public (undocumented) +export type OutboundEventType = (typeof OUTBOUND_EVENTS)[number]["event_type"]; + +// @public (undocumented) +export const OVERLAY_TOOL_TYPES: readonly ["TEXT", "SIGNATURE", "PICTURE", "CHECKBOX", "COMB_TEXT"]; + +// @public (undocumented) +export type OverlayToolType = (typeof OVERLAY_TOOL_TYPES)[number]; + +// @public (undocumented) +export const REQUEST_TYPES: ("CREATE_FIELD" | "DELETE_FIELDS" | "DELETE_PAGES" | "DETECT_FIELDS" | "DOWNLOAD" | "FOCUS_FIELD" | "GET_DOCUMENT_CONTENT" | "GET_FIELDS" | "GO_TO" | "LOAD_DOCUMENT" | "MOVE_PAGE" | "ROTATE_PAGE" | "SELECT_TOOL" | "SET_FIELD_VALUE" | "SUBMIT")[]; + +// @public (undocumented) +export type RequestType = (typeof OPERATIONS)[number]["request_type"]; + +// @public (undocumented) +export const WIRE_TYPES: ("CREATE_FIELD" | "DELETE_FIELDS" | "DELETE_PAGES" | "DETECT_FIELDS" | "DOWNLOAD" | "FOCUS_FIELD" | "GET_DOCUMENT_CONTENT" | "GET_FIELDS" | "GO_TO" | "LOAD_DOCUMENT" | "MOVE_PAGE" | "ROTATE_PAGE" | "SELECT_TOOL" | "SET_FIELD_VALUE" | "SUBMIT")[]; + +// @public (undocumented) +export type WireType = (typeof OPERATIONS)[number]["wire_type"]; + +// (No @packageDocumentation comment for this package) + +``` diff --git a/embed/etc/schemas.api.md b/embed/etc/schemas.api.md new file mode 100644 index 0000000..6bb4202 --- /dev/null +++ b/embed/etc/schemas.api.md @@ -0,0 +1,151 @@ +## API Report File for "@simplepdf/embed" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { z } from 'zod'; + +// @public (undocumented) +export const CreateFieldInput: z.ZodObject<{ + type: z.ZodEnum<{ + TEXT: "TEXT"; + SIGNATURE: "SIGNATURE"; + PICTURE: "PICTURE"; + CHECKBOX: "CHECKBOX"; + COMB_TEXT: "COMB_TEXT"; + }>; + x: z.ZodNumber; + y: z.ZodNumber; + width: z.ZodNumber; + height: z.ZodNumber; + page: z.ZodNumber; + value: z.ZodOptional; +}, z.core.$strip>; + +// @public (undocumented) +export type CreateFieldInput = z.infer; + +// @public (undocumented) +export const DeleteFieldsInput: z.ZodObject<{ + fieldIds: z.ZodOptional>; + page: z.ZodOptional; +}, z.core.$strip>; + +// @public (undocumented) +export type DeleteFieldsInput = z.infer; + +// @public (undocumented) +export const DeletePagesInput: z.ZodObject<{ + pages: z.ZodArray; +}, z.core.$strip>; + +// @public (undocumented) +export type DeletePagesInput = z.infer; + +// @public (undocumented) +export const DetectFieldsInput: z.ZodObject<{}, z.core.$strip>; + +// @public (undocumented) +export type DetectFieldsInput = z.infer; + +// @public (undocumented) +export const DownloadInput: z.ZodObject<{}, z.core.$strip>; + +// @public (undocumented) +export type DownloadInput = z.infer; + +// @public (undocumented) +export const FocusFieldInput: z.ZodObject<{ + fieldId: z.ZodString; +}, z.core.$strip>; + +// @public (undocumented) +export type FocusFieldInput = z.infer; + +// @public (undocumented) +export const GetDocumentContentInput: z.ZodObject<{ + extractionMode: z.ZodOptional>; +}, z.core.$strip>; + +// @public (undocumented) +export type GetDocumentContentInput = z.infer; + +// @public (undocumented) +export const GetFieldsInput: z.ZodObject<{}, z.core.$strip>; + +// @public (undocumented) +export type GetFieldsInput = z.infer; + +// @public (undocumented) +export const GoToInput: z.ZodObject<{ + page: z.ZodNumber; +}, z.core.$strip>; + +// @public (undocumented) +export type GoToInput = z.infer; + +// @public (undocumented) +export const LoadDocumentInput: z.ZodObject<{ + dataUrl: z.ZodString; + name: z.ZodOptional; + page: z.ZodOptional; +}, z.core.$strip>; + +// @public (undocumented) +export type LoadDocumentInput = z.infer; + +// @public (undocumented) +export const MovePageInput: z.ZodObject<{ + fromPage: z.ZodNumber; + toPage: z.ZodNumber; +}, z.core.$strip>; + +// @public (undocumented) +export type MovePageInput = z.infer; + +// @public (undocumented) +export const RotatePageInput: z.ZodObject<{ + page: z.ZodNumber; +}, z.core.$strip>; + +// @public (undocumented) +export type RotatePageInput = z.infer; + +// @public (undocumented) +export const SelectToolInput: z.ZodObject<{ + tool: z.ZodNullable>; +}, z.core.$strip>; + +// @public (undocumented) +export type SelectToolInput = z.infer; + +// @public (undocumented) +export const SetFieldValueInput: z.ZodObject<{ + fieldId: z.ZodString; + value: z.ZodNullable; +}, z.core.$strip>; + +// @public (undocumented) +export type SetFieldValueInput = z.infer; + +// @public (undocumented) +export const SubmitInput: z.ZodObject<{ + downloadCopy: z.ZodBoolean; +}, z.core.$strip>; + +// @public (undocumented) +export type SubmitInput = z.infer; + +// (No @packageDocumentation comment for this package) + +``` diff --git a/embed/etc/tanstack-ai.api.md b/embed/etc/tanstack-ai.api.md new file mode 100644 index 0000000..1a9c847 --- /dev/null +++ b/embed/etc/tanstack-ai.api.md @@ -0,0 +1,33 @@ +## API Report File for "@simplepdf/embed" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { AnyClientTool } from '@tanstack/ai'; +import * as _tanstack_ai from '@tanstack/ai'; +import * as zod from 'zod'; +import * as zod_v4_core from 'zod/v4/core'; + +// @public (undocumented) +export const createSimplePDFTools: (input: { + embed: Embed; +}) => AnyClientTool[]; + +// Warning: (ae-forgotten-export) The symbol "define" needs to be exported by the entry point tanstack-ai.d.ts +// +// @public (undocumented) +export const simplePDFToolDefinitions: () => ReturnType[]; + +// Warning: (ae-forgotten-export) The symbol "TOOL_DEFINITIONS" needs to be exported by the entry point tanstack-ai.d.ts +// +// @public (undocumented) +export type SimplePDFToolName = keyof typeof TOOL_DEFINITIONS; + +// Warnings were encountered during analysis: +// +// dist/tanstack-ai.d.ts:58:5 - (ae-forgotten-export) The symbol "Embed" needs to be exported by the entry point tanstack-ai.d.ts + +// (No @packageDocumentation comment for this package) + +``` diff --git a/embed/etc/tools.api.md b/embed/etc/tools.api.md new file mode 100644 index 0000000..6b9a0e0 --- /dev/null +++ b/embed/etc/tools.api.md @@ -0,0 +1,130 @@ +## API Report File for "@simplepdf/embed" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import * as zod from 'zod'; +import * as zod_v4_core from 'zod/v4/core'; + +// @public (undocumented) +export const isSimplePDFToolName: (value: unknown) => value is SimplePDFToolName; + +// Warning: (ae-forgotten-export) The symbol "IframeActions" needs to be exported by the entry point tools.d.ts +// Warning: (ae-forgotten-export) The symbol "BridgeResult" needs to be exported by the entry point tools.d.ts +// +// @public (undocumented) +export const routeToolCall: (actions: IframeActions, toolName: SimplePDFToolName, input: unknown) => Promise>; + +// @public (undocumented) +export const SIMPLEPDF_TOOLS: { + readonly createField: { + readonly description: "Create a new overlay field of the given type at an (x, y) position and size (in PDF points) on a 1-based page. Returns { field_id } for the created field. Requires editing to be enabled."; + readonly inputSchema: zod.ZodObject<{ + type: zod.ZodEnum<{ + TEXT: "TEXT"; + SIGNATURE: "SIGNATURE"; + PICTURE: "PICTURE"; + CHECKBOX: "CHECKBOX"; + COMB_TEXT: "COMB_TEXT"; + }>; + x: zod.ZodNumber; + y: zod.ZodNumber; + width: zod.ZodNumber; + height: zod.ZodNumber; + page: zod.ZodNumber; + value: zod.ZodOptional; + }, zod_v4_core.$strip>; + }; + readonly deleteFields: { + readonly description: "Delete overlay fields by id; omit field_ids to delete every field on the given 1-based page, or omit both field_ids and page to delete every overlay field in the document. Returns { deleted_count }. Destructive; requires editing to be enabled."; + readonly inputSchema: zod.ZodObject<{ + fieldIds: zod.ZodOptional>; + page: zod.ZodOptional; + }, zod_v4_core.$strip>; + }; + readonly deletePages: { + readonly description: "Delete one or more 1-based pages from the document (it cannot delete every visible page). Returns no data. Destructive; requires editing to be enabled."; + readonly inputSchema: zod.ZodObject<{ + pages: zod.ZodArray; + }, zod_v4_core.$strip>; + }; + readonly detectFields: { + readonly description: "Automatically detect fillable fields in the loaded document and add them as editable fields. Returns { detected_count }. Requires editing to be enabled."; + readonly inputSchema: zod.ZodObject<{}, zod_v4_core.$strip>; + }; + readonly download: { + readonly description: "Generate and download the current document as a PDF. Returns no data."; + readonly inputSchema: zod.ZodObject<{}, zod_v4_core.$strip>; + }; + readonly focusField: { + readonly description: "Scroll an existing field into view and focus it, addressed by its id (from get_fields). Returns a hint describing the user action expected next."; + readonly inputSchema: zod.ZodObject<{ + fieldId: zod.ZodString; + }, zod_v4_core.$strip>; + }; + readonly getDocumentContent: { + readonly description: "Extract the document's text content page by page (pass extraction_mode 'ocr' to force optical recognition). Use it to read what the document says. Returns { name, pages: [{ page, content }] }."; + readonly inputSchema: zod.ZodObject<{ + extractionMode: zod.ZodOptional>; + }, zod_v4_core.$strip>; + }; + readonly getFields: { + readonly description: "List every fillable field in the loaded document, including native dropdown and radio AcroFields. Each field reports its id, name, type, page, and current value. Call this first to discover field ids before reading or setting values. Returns { fields }."; + readonly inputSchema: zod.ZodObject<{}, zod_v4_core.$strip>; + }; + readonly goTo: { + readonly description: "Scroll the editor to a specific 1-based page. Returns no data."; + readonly inputSchema: zod.ZodObject<{ + page: zod.ZodNumber; + }, zod_v4_core.$strip>; + }; + readonly movePage: { + readonly description: "Move a page from one 1-based position to another, reordering the document. Returns no data. Destructive; requires editing to be enabled."; + readonly inputSchema: zod.ZodObject<{ + fromPage: zod.ZodNumber; + toPage: zod.ZodNumber; + }, zod_v4_core.$strip>; + }; + readonly rotatePage: { + readonly description: "Rotate a 1-based page 90 degrees clockwise. Returns no data. Destructive; requires editing to be enabled."; + readonly inputSchema: zod.ZodObject<{ + page: zod.ZodNumber; + }, zod_v4_core.$strip>; + }; + readonly selectTool: { + readonly description: "Activate a field-placement tool in the editor toolbar so the user can draw that field type, or pass null to clear the active tool. Returns no data."; + readonly inputSchema: zod.ZodObject<{ + tool: zod.ZodNullable>; + }, zod_v4_core.$strip>; + }; + readonly setFieldValue: { + readonly description: "Set the value of an existing field addressed by its id (from get_fields), or clear it with null. If the field has options (see get_fields), value must be one of them; otherwise value is a string (text or checkbox value) or a data URL (signature, picture). Returns no data."; + readonly inputSchema: zod.ZodObject<{ + fieldId: zod.ZodString; + value: zod.ZodNullable; + }, zod_v4_core.$strip>; + }; + readonly submit: { + readonly description: "Submit the completed document through the editor's finalization flow. This is irreversible. When download_copy is true the signer also gets a downloaded copy. Fails with missing_required_fields when required fields are unfilled. Returns no data."; + readonly inputSchema: zod.ZodObject<{ + downloadCopy: zod.ZodBoolean; + }, zod_v4_core.$strip>; + }; +}; + +// @public (undocumented) +export type SimplePDFToolName = keyof typeof SIMPLEPDF_TOOLS; + +// (No @packageDocumentation comment for this package) + +``` diff --git a/embed/package.json b/embed/package.json index 7999cc0..e097fdb 100644 --- a/embed/package.json +++ b/embed/package.json @@ -47,6 +47,10 @@ "types": "./dist/ai-sdk.d.ts", "import": "./dist/ai-sdk.js", "require": "./dist/ai-sdk.cjs" + }, + "./tanstack-ai": { + "types": "./dist/tanstack-ai.d.ts", + "import": "./dist/tanstack-ai.js" } }, "scripts": { @@ -59,17 +63,24 @@ "pretest": "npm run generate", "test": "vitest run", "test:watch": "vitest", - "check:size": "npm run build && node scripts/check-bundle-size.mjs" + "check:size": "npm run build && node scripts/check-bundle-size.mjs", + "check:exports": "node ../scripts/check-exports.mjs .", + "check:api": "node ../scripts/check-api.mjs ." }, "peerDependencies": { + "@tanstack/ai": "^0.38.0", "zod": "^4.0.0" }, "peerDependenciesMeta": { + "@tanstack/ai": { + "optional": true + }, "zod": { "optional": true } }, "devDependencies": { + "@tanstack/ai": "^0.38.0", "jsdom": "^26.1.0", "tsup": "^8.5.1", "typescript": "^5.9.3", diff --git a/embed/scripts/check-bundle-size.mjs b/embed/scripts/check-bundle-size.mjs index b1042e8..50fb238 100644 --- a/embed/scripts/check-bundle-size.mjs +++ b/embed/scripts/check-bundle-size.mjs @@ -1,6 +1,7 @@ -// Bundle-size budget guard. Gzips each public entry's full LOCAL closure (the -// entry file plus the dist chunks it imports — peer deps are external and never -// counted) and fails if any entry exceeds its budget. Run after `npm run build`. +// Bundle-size budget guard, run after `npm run build`. Gzips each public entry's local +// closure (the entry file plus the dist chunks it imports; peer deps are external and +// never counted) and fails if any entry exceeds its budget. Export loadability is guarded +// separately by ../../scripts/check-exports.mjs (the `check:exports` script). import { existsSync, readFileSync } from 'node:fs' import { dirname, join } from 'node:path' @@ -19,6 +20,7 @@ const BUDGETS = { 'schemas.js': 3 * 1024, 'tools.js': 5 * 1024, 'ai-sdk.js': 5.5 * 1024, + 'tanstack-ai.js': 5.5 * 1024, } const localImports = (file) => { diff --git a/embed/src/agentic.test-d.ts b/embed/src/agentic.test-d.ts new file mode 100644 index 0000000..f8b74c6 --- /dev/null +++ b/embed/src/agentic.test-d.ts @@ -0,0 +1,16 @@ +// Type-stability assertions for the agentic adapters. No runtime: this file is picked up +// by `test:types` (tsc --noEmit includes `src`) but NOT by the build (explicit tsup entries) +// nor the vitest runner (which globs `*.test.ts`, not `*.test-d.ts`). A change to a public +// agentic type fails CI here, forcing a deliberate review. +import { expectTypeOf } from 'vitest' +import type { AnyClientTool } from '@tanstack/ai' +import { createSimplePDFTools, simplePDFToolDefinitions, type SimplePDFToolName } from './tanstack-ai' + +// `loadDocument` is not part of the agentic tool set (it is excluded from TOOL_DEFINITIONS). +expectTypeOf().not.toEqualTypeOf() + +// Server definitions stay name-strict: each element's `name` is the literal union, not `string`. +expectTypeOf[number]['name']>().toEqualTypeOf() + +// Browser tools are the documented `AnyClientTool[]` (the clientTools-ready shape). +expectTypeOf>().toEqualTypeOf() diff --git a/embed/src/index.ts b/embed/src/index.ts index 1cbcbb9..fc6a9e5 100644 --- a/embed/src/index.ts +++ b/embed/src/index.ts @@ -1,11 +1,10 @@ // Root entry: the zero-runtime-dependency mount path + bridge + error model. -// Action methods are typed; zod/ai live only in the /schemas, /tools, and -// /ai-sdk subpaths. The React layer lives in @simplepdf/react-embed-pdf. +// Action methods are typed; zod (and @tanstack/ai) live only in the /schemas, +// /tools, /ai-sdk, and /tanstack-ai subpaths. The React layer lives in @simplepdf/react-embed-pdf. -export { buildEditorDomain, createEmbed, EmbedConfigError, encodeContext } from './mount' +export { createEmbed, EmbedConfigError } from './mount' export type { CreateEmbedArgs, EmbedDocument } from './mount' export { NOOP_LOGGER } from './logger' export type { BridgeLogger, LogPayload } from './logger' -export { isBridgeResultLike } from './result' export { BridgeUnwrapError, unwrap } from './unwrap' export type * from './types' diff --git a/embed/src/protocol.ts b/embed/src/protocol.ts index d009b4d..83be142 100644 --- a/embed/src/protocol.ts +++ b/embed/src/protocol.ts @@ -26,10 +26,6 @@ export { LOCALES, OVERLAY_TOOL_TYPES, } from './generated/contract' -// The internal protocol constants live in their own module (so the bridge / root -// entry never pulls the OPERATIONS table); the public /protocol surface re-exports them. -export { INTERNAL_PROTOCOL } from './internal-protocol' -export type { InternalProtocolType } from './internal-protocol' // Convenience derived constants (the wire type each op posts; the request_type, the // editor's operation identifier — the camelCase SDK method/tool name is `method`). diff --git a/embed/src/tanstack-ai.ts b/embed/src/tanstack-ai.ts new file mode 100644 index 0000000..0696337 --- /dev/null +++ b/embed/src/tanstack-ai.ts @@ -0,0 +1,35 @@ +// TanStack AI adapter. The same generated registry + bridge router as the Vercel +// `/ai-sdk` adapter, in TanStack's isomorphic tool shape: server-registerable +// definitions for `chat({ tools })`, and browser `.client()` tools bound to the live +// editor for `clientTools(...)` -> `useChat({ tools })`. The zod input schemas drop +// in directly (TanStack accepts any Standard Schema); `@tanstack/ai` is the only +// added peer, pulled solely by this subpath. + +import { type AnyClientTool, toolDefinition } from '@tanstack/ai' +import { TOOL_DEFINITIONS, type SimplePDFToolName } from './generated/tools' +import { isSimplePDFToolName, routeToolCall } from './tools' +import type { Embed } from './types' + +export type { SimplePDFToolName } from './generated/tools' + +const TOOL_NAMES: readonly SimplePDFToolName[] = Object.keys(TOOL_DEFINITIONS).filter(isSimplePDFToolName) + +// One shared definition per tool (name + description + zod input schema): the unit +// `chat({ tools })` registers and `.client()` / `.server()` instantiate from. +const define = (name: SimplePDFToolName) => + toolDefinition({ + name, + description: TOOL_DEFINITIONS[name].description, + inputSchema: TOOL_DEFINITIONS[name].inputSchema, + }) + +// Server: execute-less definitions for `chat({ tools })`, so the model is aware of +// the tools. A fresh array each call so the host can pick/omit (e.g. gate submit XOR +// download) without mutating shared state. +export const simplePDFToolDefinitions = (): ReturnType[] => TOOL_NAMES.map(define) + +// Browser: the same definitions bound to the live editor via `.client()`, for +// `clientTools(...)` -> `useChat({ tools })`. Each call validates input against the +// tool schema and dispatches to the matching editor action, resolving to a BridgeResult. +export const createSimplePDFTools = ({ embed }: { embed: Embed }): AnyClientTool[] => + TOOL_NAMES.map((name) => define(name).client((input) => routeToolCall(embed.actions, name, input))) diff --git a/embed/test/helpers.ts b/embed/test/helpers.ts new file mode 100644 index 0000000..7c0454e --- /dev/null +++ b/embed/test/helpers.ts @@ -0,0 +1,35 @@ +import { vi } from 'vitest' +import type { BridgeResult, Embed, IframeActions } from '../src/types' + +const okResult: BridgeResult = { success: true, data: null } + +// A fully-stubbed actions group: every editor operation is a vi.fn resolving to a +// success Result. Shared by the tools + adapter tests. +export const makeActionsStub = (): IframeActions => { + const method = (): Promise> => Promise.resolve(okResult) + return { + createField: vi.fn(method), + deleteFields: vi.fn(method), + deletePages: vi.fn(method), + detectFields: vi.fn(method), + download: vi.fn(method), + focusField: vi.fn(method), + getDocumentContent: vi.fn(method), + getFields: vi.fn(method), + goTo: vi.fn(method), + loadDocument: vi.fn(method), + movePage: vi.fn(method), + rotatePage: vi.fn(method), + selectTool: vi.fn(method), + setFieldValue: vi.fn(method), + submit: vi.fn(method), + } +} + +// A minimal Embed handle wrapping stubbed actions (events + lifecycle are no-ops); +// enough for adapters that only dispatch through embed.actions. +export const makeEmbedStub = (): Embed => ({ + actions: makeActionsStub(), + events: { on: () => () => {} }, + lifecycle: { dispose: () => {} }, +}) diff --git a/embed/test/tanstack-ai.test.ts b/embed/test/tanstack-ai.test.ts new file mode 100644 index 0000000..6469fd8 --- /dev/null +++ b/embed/test/tanstack-ai.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' +import { createSimplePDFTools, simplePDFToolDefinitions } from '../src/tanstack-ai' +import type { BridgeResult } from '../src/types' +import { makeEmbedStub } from './helpers' + +describe('simplePDFToolDefinitions', () => { + it('returns the 14 agentic operations as execute-less definitions (loadDocument excluded)', () => { + const definitions = simplePDFToolDefinitions() + expect(definitions).toHaveLength(14) + expect(definitions.map((definition) => definition.name)).not.toContain('loadDocument') + for (const definition of definitions) { + expect(typeof definition.description).toBe('string') + expect(definition.inputSchema).toBeDefined() + } + }) +}) + +describe('createSimplePDFTools', () => { + it('produces a client tool for each of the 14 agentic operations', () => { + const tools = createSimplePDFTools({ embed: makeEmbedStub() }) + expect(tools).toHaveLength(14) + expect(tools.every((tool) => typeof tool.execute === 'function')).toBe(true) + }) + + it('binds each tool to the editor: a client call validates input + dispatches to the matching action', async () => { + const embed = makeEmbedStub() + const goTo = createSimplePDFTools({ embed }).find((tool) => tool.name === 'goTo') + if (goTo === undefined || goTo.execute === undefined) { + throw new Error('expected a goTo client tool with an execute') + } + await goTo.execute({ page: 2 }) + expect(embed.actions.goTo).toHaveBeenCalledWith({ page: 2 }) + }) + + it('returns bad_request:invalid_input on schema-invalid input without dispatching', async () => { + const embed = makeEmbedStub() + const goTo = createSimplePDFTools({ embed }).find((tool) => tool.name === 'goTo') + if (goTo === undefined || goTo.execute === undefined) { + throw new Error('expected a goTo client tool with an execute') + } + const result: BridgeResult = await goTo.execute({ page: 'not-a-number' }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.code).toBe('bad_request:invalid_input') + } + expect(embed.actions.goTo).not.toHaveBeenCalled() + }) +}) diff --git a/embed/test/tools.test.ts b/embed/test/tools.test.ts index 4dcf08f..cf05963 100644 --- a/embed/test/tools.test.ts +++ b/embed/test/tools.test.ts @@ -1,29 +1,6 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { isSimplePDFToolName, routeToolCall, SIMPLEPDF_TOOLS } from '../src/tools' -import type { BridgeResult, IframeActions } from '../src/types' - -const okResult: BridgeResult = { success: true, data: null } - -const makeActionsStub = (): IframeActions => { - const method = (): Promise> => Promise.resolve(okResult) - return { - createField: vi.fn(method), - deleteFields: vi.fn(method), - deletePages: vi.fn(method), - detectFields: vi.fn(method), - download: vi.fn(method), - focusField: vi.fn(method), - getDocumentContent: vi.fn(method), - getFields: vi.fn(method), - goTo: vi.fn(method), - loadDocument: vi.fn(method), - movePage: vi.fn(method), - rotatePage: vi.fn(method), - selectTool: vi.fn(method), - setFieldValue: vi.fn(method), - submit: vi.fn(method), - } -} +import { makeActionsStub } from './helpers' describe(isSimplePDFToolName.name, () => { it('accepts agentic tool names', () => { diff --git a/embed/tsup.config.ts b/embed/tsup.config.ts index 7ee74cb..656dedd 100644 --- a/embed/tsup.config.ts +++ b/embed/tsup.config.ts @@ -13,6 +13,7 @@ export default defineConfig({ schemas: 'src/schemas.ts', tools: 'src/tools.ts', 'ai-sdk': 'src/ai-sdk.ts', + 'tanstack-ai': 'src/tanstack-ai.ts', }, format: ['esm', 'cjs'], dts: true, @@ -20,5 +21,5 @@ export default defineConfig({ splitting: true, sourcemap: true, clean: true, - external: ['zod'], + external: ['zod', '@tanstack/ai'], }) diff --git a/package-lock.json b/package-lock.json index dc8918a..124b3b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,14 +11,16 @@ "web" ], "devDependencies": { - "@changesets/cli": "^2.29.8" + "@changesets/cli": "^2.29.8", + "@microsoft/api-extractor": "^7.58.9" } }, "embed": { "name": "@simplepdf/embed", - "version": "0.4.0", + "version": "0.5.0", "license": "MIT", "devDependencies": { + "@tanstack/ai": "^0.38.0", "jsdom": "^26.1.0", "tsup": "^8.5.1", "typescript": "^5.9.3", @@ -29,9 +31,13 @@ "node": ">=18" }, "peerDependencies": { + "@tanstack/ai": "^0.38.0", "zod": "^4.0.0" }, "peerDependenciesMeta": { + "@tanstack/ai": { + "optional": true + }, "zod": { "optional": true } @@ -314,6 +320,25 @@ "dev": true, "license": "MIT" }, + "node_modules/@ag-ui/core": { + "version": "0.0.52", + "resolved": "https://registry.npmjs.org/@ag-ui/core/-/core-0.0.52.tgz", + "integrity": "sha512-Xo0bUaNV56EqylzcrAuhUkQX7et7+SZIrqZZtEByGwEq/I1EHny6ZMkWHLkKR7UNi0FJZwJyhKYmKJS3B2SEgA==", + "dev": true, + "dependencies": { + "zod": "^3.22.4" + } + }, + "node_modules/@ag-ui/core/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@asamuzakjp/css-color": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", @@ -1242,29 +1267,6 @@ } } }, - "node_modules/@isaacs/balanced-match": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", - "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@isaacs/brace-expansion": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", - "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@isaacs/balanced-match": "^4.0.1" - }, - "engines": { - "node": "20 || >=22" - } - }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1387,6 +1389,63 @@ "node": ">=6 <7 || >=8" } }, + "node_modules/@microsoft/api-extractor": { + "version": "7.58.9", + "resolved": "https://registry.npmjs.org/@microsoft/api-extractor/-/api-extractor-7.58.9.tgz", + "integrity": "sha512-S2UF4yza5GoxCmf7hJQNxJNZN9ltOVuOQv8Dy+Z21aol5ERoBNMdWcQHm4MJMPPItW4H/4rZD906iaf4mUojJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@microsoft/api-extractor-model": "7.33.8", + "@microsoft/tsdoc": "~0.16.0", + "@microsoft/tsdoc-config": "~0.18.1", + "@rushstack/node-core-library": "5.23.1", + "@rushstack/rig-package": "0.7.3", + "@rushstack/terminal": "0.24.0", + "@rushstack/ts-command-line": "5.3.10", + "diff": "~8.0.2", + "minimatch": "10.2.3", + "resolve": "~1.22.1", + "semver": "~7.7.4", + "source-map": "~0.6.1", + "typescript": "5.9.3" + }, + "bin": { + "api-extractor": "bin/api-extractor" + } + }, + "node_modules/@microsoft/api-extractor-model": { + "version": "7.33.8", + "resolved": "https://registry.npmjs.org/@microsoft/api-extractor-model/-/api-extractor-model-7.33.8.tgz", + "integrity": "sha512-aIcoQggPyer3B6Ze3usz0YWC/oBwUHfRH5ETUsr+oT2BRA6SfTJl7IKPcPZkX4UR+PohowzW4uMxsvjrn8vm+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@microsoft/tsdoc": "~0.16.0", + "@microsoft/tsdoc-config": "~0.18.1", + "@rushstack/node-core-library": "5.23.1" + } + }, + "node_modules/@microsoft/tsdoc": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.16.0.tgz", + "integrity": "sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@microsoft/tsdoc-config": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/@microsoft/tsdoc-config/-/tsdoc-config-0.18.1.tgz", + "integrity": "sha512-9brPoVdfN9k9g0dcWkFeA7IH9bbcttzDJlXvkf8b2OBzd5MueR1V2wkKBL0abn0otvmkHJC6aapBOTJDDeMCZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@microsoft/tsdoc": "0.16.0", + "ajv": "~8.18.0", + "jju": "~1.4.0", + "resolve": "~1.22.2" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1851,6 +1910,154 @@ "win32" ] }, + "node_modules/@rushstack/node-core-library": { + "version": "5.23.1", + "resolved": "https://registry.npmjs.org/@rushstack/node-core-library/-/node-core-library-5.23.1.tgz", + "integrity": "sha512-wlKmIKIYCKuCASbITvOxLZXepPbwXvrv7S6ig6XNWFchSyhL/E2txmVXspHY49Wu2dzf7nI27a2k/yV5BA3EiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "~8.18.0", + "ajv-draft-04": "~1.0.0", + "ajv-formats": "~3.0.1", + "fs-extra": "~11.3.0", + "import-lazy": "~4.0.0", + "jju": "~1.4.0", + "resolve": "~1.22.1", + "semver": "~7.7.4" + }, + "peerDependencies": { + "@types/node": "*" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@rushstack/node-core-library/node_modules/fs-extra": { + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz", + "integrity": "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@rushstack/node-core-library/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@rushstack/node-core-library/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@rushstack/problem-matcher": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@rushstack/problem-matcher/-/problem-matcher-0.2.1.tgz", + "integrity": "sha512-gulfhBs6n+I5b7DvjKRfhMGyUejtSgOHTclF/eONr8hcgF1APEDjhxIsfdUYYMzC3rvLwGluqLjbwCFZ8nxrog==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/node": "*" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@rushstack/rig-package": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@rushstack/rig-package/-/rig-package-0.7.3.tgz", + "integrity": "sha512-aAA518n6wxxjCfnTAOjQnm7ngNE0FVHxHAw2pxKlIhxrMn0XQjGcXKF0oKWpjBgJOmsaJpVob/v+zr3zxgPWuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jju": "~1.4.0", + "resolve": "~1.22.1" + } + }, + "node_modules/@rushstack/terminal": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@rushstack/terminal/-/terminal-0.24.0.tgz", + "integrity": "sha512-8ZQS4MMaGsv27EXCBiH7WMPkRZrffeDoIevs6z9TM5dzqiY6+Hn4evfK/G+gvgBTjfvfkHIZPQQmalmI2sM4TQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rushstack/node-core-library": "5.23.1", + "@rushstack/problem-matcher": "0.2.1", + "supports-color": "~8.1.1" + }, + "peerDependencies": { + "@types/node": "*" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@rushstack/terminal/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/@rushstack/ts-command-line": { + "version": "5.3.10", + "resolved": "https://registry.npmjs.org/@rushstack/ts-command-line/-/ts-command-line-5.3.10.tgz", + "integrity": "sha512-fwI076HYknC0IrMXdY6UmjDv+PH7NHhNJX3/pY2UblSE5XrXgndXZPiOe/6ZtuFpn6DvVDVNhtkIzQ+Qu/MhVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rushstack/terminal": "0.24.0", + "@types/argparse": "1.0.38", + "argparse": "~1.0.9", + "string-argv": "~0.3.1" + } + }, + "node_modules/@rushstack/ts-command-line/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, "node_modules/@simplepdf/embed": { "resolved": "embed", "link": true @@ -1870,6 +2077,77 @@ "dev": true, "license": "MIT" }, + "node_modules/@tanstack/ai": { + "version": "0.38.0", + "resolved": "https://registry.npmjs.org/@tanstack/ai/-/ai-0.38.0.tgz", + "integrity": "sha512-fAX8pmaLXRx8jB8v4CyYqCxCAagvHzvmU7l4J89XkKlzuun69F/pumcKFUOGxZyQswiz1+pzfmNwscv7K8AJmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ag-ui/core": "^0.0.52", + "@standard-schema/spec": "^1.1.0", + "@tanstack/ai-event-client": "0.6.8", + "@tanstack/ai-utils": "0.3.1", + "partial-json": "^0.1.7" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.9.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + } + } + }, + "node_modules/@tanstack/ai-event-client": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/@tanstack/ai-event-client/-/ai-event-client-0.6.8.tgz", + "integrity": "sha512-h7/HLz9u2LF9ba6uKFMnSZkFlkzQONJ5u3Hi+4qGxpsHyvcGRtW6v186jaFLoklndhEwyC49ytkC4eafi7Qq8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tanstack/devtools-event-client": "^0.4.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/ai-utils": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@tanstack/ai-utils/-/ai-utils-0.3.1.tgz", + "integrity": "sha512-fgbjd5DohL3k5rTWr/KInauLVYMiHVm0cnmTsNrzL3cr4wWhEld5vmFSrjiwUWACAuONjZa+uBXuS+ZSO8fwDQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/devtools-event-client": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/@tanstack/devtools-event-client/-/devtools-event-client-0.4.4.tgz", + "integrity": "sha512-6T5Yop/793YI+H+5J8Hsyj4kCih9sl4t3ElLgKioW5hk3ocn+ZdSJ94tT7vL7uabxSugWYBZlOTMPzEw2puvQw==", + "dev": true, + "license": "MIT", + "bin": { + "intent": "bin/intent.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -1959,6 +2237,13 @@ "@testing-library/dom": ">=7.21.4" } }, + "node_modules/@types/argparse": { + "version": "1.0.38", + "resolved": "https://registry.npmjs.org/@types/argparse/-/argparse-1.0.38.tgz", + "integrity": "sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -2188,6 +2473,56 @@ "node": ">= 14" } }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-draft-04": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz", + "integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^8.5.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/ansi-colors": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", @@ -2579,6 +2914,16 @@ "node": ">=8" } }, + "node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -2626,6 +2971,16 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -2713,6 +3068,13 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -2730,6 +3092,23 @@ "node": ">=8.6.0" } }, + "node_modules/fast-uri": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", + "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -2827,6 +3206,16 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/glob": { "version": "13.0.0", "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.0.tgz", @@ -2896,6 +3285,19 @@ "node": ">=8" } }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/html-encoding-sniffer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", @@ -2981,6 +3383,16 @@ "node": ">= 4" } }, + "node_modules/import-lazy": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/import-lazy/-/import-lazy-4.0.0.tgz", + "integrity": "sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/indent-string": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", @@ -2991,6 +3403,22 @@ "node": ">=8" } }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -3116,6 +3544,13 @@ "node": ">=8" } }, + "node_modules/jju": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/jju/-/jju-1.4.0.tgz", + "integrity": "sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==", + "dev": true, + "license": "MIT" + }, "node_modules/joycon": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", @@ -3186,6 +3621,13 @@ } } }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, "node_modules/jsonfile": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", @@ -3373,21 +3815,44 @@ } }, "node_modules/minimatch": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", - "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", + "version": "10.2.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.3.tgz", + "integrity": "sha512-Rwi3pnapEqirPSbWbrZaa6N3nmqq4Xer/2XooiOKyV3q12ML06f7MOuc5DVH8ONZIFhwIYQ3yzPH4nt7iWHaTg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "@isaacs/brace-expansion": "^5.0.0" + "brace-expansion": "^5.0.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minimatch/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/minimatch/node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/minipass": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", @@ -3586,6 +4051,13 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/partial-json": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", + "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==", + "dev": true, + "license": "MIT" + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -3606,6 +4078,13 @@ "node": ">=8" } }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, "node_modules/path-scurry": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz", @@ -3988,6 +4467,38 @@ "node": ">=8" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/resolve-from": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", @@ -4213,9 +4724,9 @@ } }, "node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "dev": true, "license": "ISC", "bin": { @@ -4358,6 +4869,16 @@ "dev": true, "license": "MIT" }, + "node_modules/string-argv": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", + "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.19" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -4453,6 +4974,19 @@ "node": ">=8" } }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -5164,13 +5698,14 @@ }, "react": { "name": "@simplepdf/react-embed-pdf", - "version": "1.10.0", + "version": "1.11.0", "license": "MIT", "dependencies": { - "@simplepdf/embed": "^0.4.0" + "@simplepdf/embed": "^0.5.0" }, "devDependencies": { "@rollup/plugin-terser": "^0.4.4", + "@tanstack/ai": "^0.38.0", "@testing-library/dom": "10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "16.3.1", @@ -5197,11 +5732,15 @@ "node": ">=18" }, "peerDependencies": { + "@tanstack/ai": "^0.38.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "zod": "^4.0.0" }, "peerDependenciesMeta": { + "@tanstack/ai": { + "optional": true + }, "zod": { "optional": true } diff --git a/package.json b/package.json index d3563cb..5708f46 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "publish": "changeset publish" }, "devDependencies": { - "@changesets/cli": "^2.29.8" + "@changesets/cli": "^2.29.8", + "@microsoft/api-extractor": "^7.58.9" } } diff --git a/react/.prettierignore b/react/.prettierignore index 8c6ec28..3394fcb 100644 --- a/react/.prettierignore +++ b/react/.prettierignore @@ -1,6 +1,8 @@ dist/ dev/ coverage/ +etc/ +tmp/ package-lock.json diff --git a/react/README.md b/react/README.md index d12b067..e58dea0 100644 --- a/react/README.md +++ b/react/README.md @@ -223,7 +223,26 @@ const CopilotEditor = () => { }; ``` -For server-side tool definitions (execute-less, for `streamText`), import `simplePDFToolDefinitions` from `@simplepdf/react-embed-pdf/ai-sdk`. `embedRef.current` is the flat editor-actions handle, every camelCase operation, with the deprecated `selectTool` / `submit` overloads; subscribe to editor events via the `onEmbedEvent` prop. (The framework-free `@simplepdf/embed` core exposes the grouped `embed.actions` / `embed.events` / `embed.lifecycle` handle for non-React use.) +For server-side tool definitions (execute-less, for `streamText`), import `simplePDFToolDefinitions` from the React-free core `@simplepdf/embed/ai-sdk` (it is intentionally not re-exported from this React subpath, which imports React). `embedRef.current` is the flat editor-actions handle, every camelCase operation, with the deprecated `selectTool` / `submit` overloads; subscribe to editor events via the `onEmbedEvent` prop. (The framework-free `@simplepdf/embed` core exposes the grouped `embed.actions` / `embed.events` / `embed.lifecycle` handle for non-React use.) + +#### Agentic: `useEmbedTools` (TanStack AI) + +The TanStack mirror lives in the opt-in `@simplepdf/react-embed-pdf/tanstack-ai` subpath (importing it pulls `@tanstack/ai`). `useEmbedTools(embedRef)` returns the editor-bound client tools; pass them to `clientTools(...)`, then `useChat`: + +```jsx +import { useChat, clientTools } from '@tanstack/ai-react'; +import { EmbedPDF, useEmbed } from '@simplepdf/react-embed-pdf'; +import { useEmbedTools } from '@simplepdf/react-embed-pdf/tanstack-ai'; + +const CopilotEditor = () => { + const { embedRef } = useEmbed(); + const tools = clientTools(...useEmbedTools(embedRef)); + useChat({ connection, tools }); // the model's tool calls run against the live editor + return ; +}; +``` + +On your server `chat({ tools })` route, register `simplePDFToolDefinitions()` imported from the React-free core `@simplepdf/embed/tanstack-ai` (not from this React subpath, which would pull React into your server) so the model is aware of the tools. See [Retrieving PDF Data](../README.md#retrieving-pdf-data) for text extraction, downloading, and server-side storage options. diff --git a/react/etc/ai-sdk.api.md b/react/etc/ai-sdk.api.md new file mode 100644 index 0000000..a2b8de9 --- /dev/null +++ b/react/etc/ai-sdk.api.md @@ -0,0 +1,44 @@ +## API Report File for "@simplepdf/react-embed-pdf" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import type { BridgeResult } from '@simplepdf/embed'; +import { createSimplePDFExecutor } from '@simplepdf/embed/ai-sdk'; +import type { IframeActions } from '@simplepdf/embed'; +import { isSimplePDFToolName } from '@simplepdf/embed/tools'; +import type { RefObject } from 'react'; +import type { SelectToolInput } from '@simplepdf/embed'; +import { SIMPLEPDF_TOOLS } from '@simplepdf/embed/tools'; +import { SimplePDFToolName } from '@simplepdf/embed/tools'; +import type { SubmitInput } from '@simplepdf/embed'; + +export { createSimplePDFExecutor } + +// @public (undocumented) +export type EmbedTool = { + description: string; + inputSchema: ToolInputSchema; + execute: (input: unknown) => Promise>; +}; + +// @public (undocumented) +export type EmbedTools = Record; + +export { isSimplePDFToolName } + +export { SimplePDFToolName } + +// Warning: (ae-forgotten-export) The symbol "EmbedActions" needs to be exported by the entry point ai-sdk.d.ts +// +// @public (undocumented) +export const useEmbedTools: (embedRef: RefObject) => EmbedTools; + +// Warnings were encountered during analysis: +// +// dist/ai-sdk.d.ts:11:5 - (ae-forgotten-export) The symbol "ToolInputSchema" needs to be exported by the entry point ai-sdk.d.ts + +// (No @packageDocumentation comment for this package) + +``` diff --git a/react/etc/index.api.md b/react/etc/index.api.md new file mode 100644 index 0000000..9a4bb05 --- /dev/null +++ b/react/etc/index.api.md @@ -0,0 +1,53 @@ +## API Report File for "@simplepdf/react-embed-pdf" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import type { BridgeLogger } from '@simplepdf/embed'; +import type { BridgeResult } from '@simplepdf/embed'; +import type { EditorEvent } from '@simplepdf/embed'; +import { EmbedDocument } from '@simplepdf/embed'; +import { FieldType } from '@simplepdf/embed'; +import type { IframeActions } from '@simplepdf/embed'; +import type { Locale } from '@simplepdf/embed'; +import { OverlayToolType } from '@simplepdf/embed'; +import * as React_2 from 'react'; +import type { SelectToolInput } from '@simplepdf/embed'; +import type { SubmitInput } from '@simplepdf/embed'; + +// @public (undocumented) +export type EmbedActions = Omit & { + selectTool: (input: SelectToolInput | SelectToolInput['tool']) => Promise; + submit: (input: SubmitInput | { + downloadCopyOnDevice: boolean; + }) => Promise; +}; + +export { EmbedDocument } + +// @public (undocumented) +export type EmbedEvent = EditorEvent; + +// @public (undocumented) +export const EmbedPDF: React_2.ForwardRefExoticComponent>; + +// Warning: (ae-forgotten-export) The symbol "InlineEmbedPDFProps" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "ModalEmbedPDFProps" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +export type EmbedPDFProps = InlineEmbedPDFProps | ModalEmbedPDFProps; + +export { FieldType } + +export { OverlayToolType } + +// @public (undocumented) +export const useEmbed: () => { + embedRef: React_2.RefObject; + actions: EmbedActions; +}; + +// (No @packageDocumentation comment for this package) + +``` diff --git a/react/etc/tanstack-ai.api.md b/react/etc/tanstack-ai.api.md new file mode 100644 index 0000000..e398c3c --- /dev/null +++ b/react/etc/tanstack-ai.api.md @@ -0,0 +1,24 @@ +## API Report File for "@simplepdf/react-embed-pdf" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import type { AnyClientTool } from '@tanstack/ai'; +import type { BridgeResult } from '@simplepdf/embed'; +import type { IframeActions } from '@simplepdf/embed'; +import type { RefObject } from 'react'; +import type { SelectToolInput } from '@simplepdf/embed'; +import { SimplePDFToolName } from '@simplepdf/embed/tanstack-ai'; +import type { SubmitInput } from '@simplepdf/embed'; + +export { SimplePDFToolName } + +// Warning: (ae-forgotten-export) The symbol "EmbedActions" needs to be exported by the entry point tanstack-ai.d.ts +// +// @public (undocumented) +export const useEmbedTools: (embedRef: RefObject) => AnyClientTool[]; + +// (No @packageDocumentation comment for this package) + +``` diff --git a/react/package.json b/react/package.json index de292a5..abbd9ec 100644 --- a/react/package.json +++ b/react/package.json @@ -21,6 +21,10 @@ "types": "./dist/ai-sdk.d.ts", "import": "./dist/ai-sdk.esm.js", "require": "./dist/ai-sdk.cjs" + }, + "./tanstack-ai": { + "types": "./dist/tanstack-ai.d.ts", + "import": "./dist/tanstack-ai.esm.js" } }, "author": "bendersej", @@ -40,23 +44,30 @@ "format": "npm run prettier -- --write", "prepublishOnly": "rimraf dist && npm run build", "build": "rollup -c", - "start": "rollup -c -w" + "start": "rollup -c -w", + "check:exports": "node ../scripts/check-exports.mjs .", + "check:api": "node ../scripts/check-api.mjs ." }, "dependencies": { "@simplepdf/embed": "^0.5.0" }, "peerDependencies": { + "@tanstack/ai": "^0.38.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "zod": "^4.0.0" }, "peerDependenciesMeta": { + "@tanstack/ai": { + "optional": true + }, "zod": { "optional": true } }, "devDependencies": { "@rollup/plugin-terser": "^0.4.4", + "@tanstack/ai": "^0.38.0", "@testing-library/dom": "10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "16.3.1", diff --git a/react/rollup.config.js b/react/rollup.config.js index 21a471f..c2b37d1 100644 --- a/react/rollup.config.js +++ b/react/rollup.config.js @@ -11,13 +11,14 @@ const isExternal = (id) => id === 'react' || id === 'react-dom' || id === 'zod' || + id === '@tanstack/ai' || id === '@simplepdf/embed' || id.startsWith('@simplepdf/embed/'); export default { - // Two entries mirroring the core: the zod-free root, and the opt-in agentic /ai-sdk - // (which pulls zod). A consumer importing only the root never loads zod. - input: { index: 'src/index.tsx', 'ai-sdk': 'src/ai-sdk.tsx' }, + // Three entries mirroring the core: the zod-free root, plus the opt-in agentic /ai-sdk + // (pulls zod) and /tanstack-ai (pulls zod + @tanstack/ai). The root loads neither. + input: { index: 'src/index.tsx', 'ai-sdk': 'src/ai-sdk.tsx', 'tanstack-ai': 'src/tanstack-ai.tsx' }, output: [ { dir: 'dist', @@ -42,7 +43,13 @@ export default { outputStyle: 'compressed', insert: true, }), - typescript(), + // Build excludes test files so type-only `*.test-d.ts` assertions (still type-checked by + // test:types) never emit declarations into dist / ship to npm. + typescript({ + tsconfigOverride: { + exclude: ['node_modules', 'src/**/*.test.ts', 'src/**/*.test.tsx', 'src/**/*.test-d.ts', 'vitest.setup.ts'], + }, + }), terser({ format: { comments: false, diff --git a/react/src/agentic.test-d.ts b/react/src/agentic.test-d.ts new file mode 100644 index 0000000..7a1cbcb --- /dev/null +++ b/react/src/agentic.test-d.ts @@ -0,0 +1,17 @@ +// Type-stability assertions for the React agentic adapters. No runtime: checked by +// `test:types` (tsc), ignored by the build (explicit rollup inputs) and the vitest runner +// (`*.test.{ts,tsx}` glob). Locks the Vercel-vs-TanStack shape convention and the +// cross-package tool-name type. +import { expectTypeOf } from 'vitest'; +import type { AnyClientTool } from '@tanstack/ai'; +import type { SimplePDFToolName as CoreToolName } from '@simplepdf/embed/tanstack-ai'; +import { useEmbedTools as useVercelTools, type EmbedTools } from './ai-sdk'; +import { useEmbedTools as useTanstackTools, type SimplePDFToolName as ReactToolName } from './tanstack-ai'; + +// Same hook name, intentionally different shape (the import path picks the SDK): +// Vercel yields the AI-SDK `EmbedTools` record; TanStack yields the client-tool array. +expectTypeOf>().toEqualTypeOf(); +expectTypeOf>().toEqualTypeOf(); + +// The re-exported tool-name type must not drift from the embed core's. +expectTypeOf().toEqualTypeOf(); diff --git a/react/src/ai-sdk.tsx b/react/src/ai-sdk.tsx index cb323e3..48675db 100644 --- a/react/src/ai-sdk.tsx +++ b/react/src/ai-sdk.tsx @@ -1,4 +1,4 @@ -// The agentic surface for @simplepdf/react-embed-pdf — the React mirror of +// The agentic surface for @simplepdf/react-embed-pdf: the React mirror of // @simplepdf/embed's /tools + /ai-sdk subpaths. Importing THIS module (not the package // root) is what pulls in `zod`, so a -only app never loads it — the same // pay-for-use contract the core has. Pair it with useEmbed(): @@ -14,10 +14,11 @@ import { isSimplePDFToolName, routeToolCall, SIMPLEPDF_TOOLS, type SimplePDFTool import type { EmbedActions } from './embed-pdf'; import { notMounted } from './not-mounted'; -// Re-export the core agentic surface so React consumers get everything from one subpath: -// server-side tool definitions (execute-less, for streamText), the browser executor, and -// the tool-name guard — mirroring @simplepdf/embed/ai-sdk + /tools. -export { createSimplePDFExecutor, simplePDFToolDefinitions } from '@simplepdf/embed/ai-sdk'; +// Re-export the browser-side executor + the tool-name guard for React consumers. The +// server-side `simplePDFToolDefinitions` is deliberately NOT re-exported: this module +// imports React (for the hook), so re-exporting the defs would pull React into a server +// `streamText` route. Import those from the React-free core `@simplepdf/embed/ai-sdk`. +export { createSimplePDFExecutor } from '@simplepdf/embed/ai-sdk'; export { isSimplePDFToolName }; export type { SimplePDFToolName }; diff --git a/react/src/index.tsx b/react/src/index.tsx index d3b664e..49a86cf 100644 --- a/react/src/index.tsx +++ b/react/src/index.tsx @@ -1,28 +1,14 @@ -// @simplepdf/react-embed-pdf — the React home for embedding the SimplePDF editor, -// built on the framework-free @simplepdf/embed core. This root entry is zod-free (like -// the core's main entry); the agentic tools (useEmbedTools) live in the opt-in -// `@simplepdf/react-embed-pdf/ai-sdk` subpath, which pulls zod — mirroring the core's -// main-vs-/tools+/ai-sdk split so both packages share the same requirements. +// @simplepdf/react-embed-pdf: the React home for embedding the SimplePDF editor, built on +// the framework-free @simplepdf/embed core. This root entry is zod-free (like the core's +// main entry); the agentic tools live in the opt-in `/ai-sdk` (Vercel AI SDK) and +// `/tanstack-ai` (TanStack AI) subpaths, both exposing `useEmbedTools`, which pull zod (and +// `@tanstack/ai` for `/tanstack-ai`), so a non-agentic app never loads them. export { EmbedPDF, useEmbed } from './embed-pdf'; export type { EmbedActions, EmbedEvent, EmbedPDFProps } from './embed-pdf'; -// Framework-free core: the full typed surface (Embed, BridgeResult / BridgeError, -// the per-op Input/Output types, FieldRecord, FieldType, OverlayToolType, Locale, -// …) plus createEmbed + helpers for non-React / imperative use. -export * from '@simplepdf/embed'; - -export { - EDITOR_ERROR_CODES, - EXTRACTION_MODES, - FIELD_TYPES, - INTERNAL_PROTOCOL, - LOCALES, - OPERATIONS, - OUTBOUND_EVENT_TYPES, - OUTBOUND_EVENTS, - OVERLAY_TOOL_TYPES, - REQUEST_TYPES, - WIRE_TYPES, -} from '@simplepdf/embed/protocol'; -export type { InternalProtocolType, OutboundEventType, RequestType, WireType } from '@simplepdf/embed/protocol'; +// Core types a React consumer names directly (the document source, the field + tool enums). +// The imperative core (createEmbed, the bridge helpers) and the wire-protocol vocabulary stay +// in @simplepdf/embed: a React app uses / useEmbed, so they are intentionally not +// re-exported here. Import them from @simplepdf/embed directly if a non-React path needs them. +export type { EmbedDocument, FieldType, OverlayToolType } from '@simplepdf/embed'; diff --git a/react/src/tanstack-ai.test.tsx b/react/src/tanstack-ai.test.tsx new file mode 100644 index 0000000..9fb0b68 --- /dev/null +++ b/react/src/tanstack-ai.test.tsx @@ -0,0 +1,36 @@ +/// + +import * as React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { render } from '@testing-library/react'; +import type { BridgeResult } from '@simplepdf/embed'; +import { useEmbed } from './index'; +import { useEmbedTools } from './tanstack-ai'; + +// useEmbed pulls in , which imports scss (a build concern, irrelevant here). +vi.mock('./styles.scss', () => ({})); + +describe('useEmbedTools', () => { + it('returns TanStack client tools, each execute null-safe before mounts', async () => { + const captured: ReturnType[] = []; + const Probe = (): null => { + const { embedRef } = useEmbed(); + captured.push(useEmbedTools(embedRef)); + return null; + }; + render(); + const tools = captured[0]; + if (tools === undefined) { + throw new Error('expected useEmbedTools to have rendered'); + } + const goTo = tools.find((tool) => tool.name === 'goTo'); + if (goTo === undefined || goTo.execute === undefined) { + throw new Error('expected a goTo client tool with an execute'); + } + const result: BridgeResult = await goTo.execute({ page: 1 }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.code).toBe('unexpected:iframe_not_mounted'); + } + }); +}); diff --git a/react/src/tanstack-ai.tsx b/react/src/tanstack-ai.tsx new file mode 100644 index 0000000..73aa1e0 --- /dev/null +++ b/react/src/tanstack-ai.tsx @@ -0,0 +1,36 @@ +// The TanStack AI agentic surface for @simplepdf/react-embed-pdf: the React mirror +// of @simplepdf/embed's /tanstack-ai subpath. Importing THIS module is what pulls +// @tanstack/ai (the only added peer), so a non-agentic app never loads it. Pair it +// with useEmbed(): +// +// const { embedRef } = useEmbed() +// const tools = clientTools(...useEmbedTools(embedRef)) // from @tanstack/ai-react +// useChat({ connection, tools }) + +import * as React from 'react'; +import type { RefObject } from 'react'; +import type { AnyClientTool } from '@tanstack/ai'; +import { routeToolCall } from '@simplepdf/embed/tools'; +import { simplePDFToolDefinitions } from '@simplepdf/embed/tanstack-ai'; +import type { EmbedActions } from './embed-pdf'; +import { notMounted } from './not-mounted'; + +// The server-side tool definitions are NOT re-exported here on purpose: this module +// imports React (for the hook), so re-exporting them would drag React into a server +// route that only needs the defs. Import those from the React-free core instead +// (`@simplepdf/embed/tanstack-ai`). Only the tool-name type (erased at build) is re-exported. +export type { SimplePDFToolName } from '@simplepdf/embed/tanstack-ai'; + +// The agentic tools bound to the live editor via useEmbed().embedRef. Stable and +// null-safe before the editor mounts (each .client() reads embedRef.current at call +// time). Pass to clientTools(...) -> useChat({ tools }). +export const useEmbedTools = (embedRef: RefObject): AnyClientTool[] => + React.useMemo( + () => + simplePDFToolDefinitions().map((definition) => + definition.client((input) => + embedRef.current === null ? notMounted() : routeToolCall(embedRef.current, definition.name, input), + ), + ), + [embedRef], + ); diff --git a/react/tsconfig.json b/react/tsconfig.json index 0b44243..2ff760f 100644 --- a/react/tsconfig.json +++ b/react/tsconfig.json @@ -9,6 +9,7 @@ "jsx": "react", "declaration": true, "strict": true, + "skipLibCheck": true, "moduleResolution": "bundler", "forceConsistentCasingInFileNames": true, "noImplicitReturns": true, diff --git a/scripts/check-api.mjs b/scripts/check-api.mjs new file mode 100644 index 0000000..e324e96 --- /dev/null +++ b/scripts/check-api.mjs @@ -0,0 +1,91 @@ +// Customer-facing API drift gate, shared by the workspace packages. For every public export +// subpath (those with a `types` entry), api-extractor rolls the chunked .d.ts up into a stable, +// reviewable etc/.api.md report; the committed report is diffed on every CI run. A change +// to any customer-facing type fails CI; an intentional change is re-blessed with +// UPDATE_API_SNAPSHOT=1. +// +// One run per entry (not a single namespaced barrel): api-extractor does not support wrapping a +// star-re-exporting module in a namespace, and a per-subpath report pinpoints what changed. +// Reused for every typed package, with no per-package config: a temporary api-extractor config +// is generated per entry from the package's own `exports`. +// +// node ../scripts/check-api.mjs . (check, from a package dir) +// UPDATE_API_SNAPSHOT=1 node ../scripts/check-api.mjs . (re-bless an intentional change) + +import { execFileSync } from 'node:child_process' +import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { basename, dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const packageDir = resolve(process.argv[2] ?? '.') +const pkg = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')) +const reBless = process.env.UPDATE_API_SNAPSHOT === '1' +const apiExtractor = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'node_modules/.bin/api-extractor') + +// Only the public type surface matters; api-extractor's doc-tag policing is silenced. +const MESSAGES = { + compilerMessageReporting: { default: { logLevel: 'warning' } }, + extractorMessageReporting: { + default: { logLevel: 'warning' }, + 'ae-missing-release-tag': { logLevel: 'none' }, + 'ae-forgotten-export': { logLevel: 'none' }, + 'ae-internal-missing-underscore': { logLevel: 'none' }, + 'ae-unresolved-link': { logLevel: 'none' }, + }, + tsdocMessageReporting: { default: { logLevel: 'none' } }, +} + +const entries = Object.entries(pkg.exports) + .filter(([, conditions]) => conditions.types !== undefined) + .map(([subpath, conditions]) => ({ + subpath, + name: basename(conditions.types).replace(/\.d\.ts$/, ''), + types: conditions.types, + })) + .sort((a, b) => a.name.localeCompare(b.name)) + +mkdirSync(join(packageDir, 'etc'), { recursive: true }) +mkdirSync(join(packageDir, 'tmp'), { recursive: true }) + +const checkEntry = (entry) => { + const tempConfigPath = join(packageDir, 'tmp', `api-extractor.${entry.name}.json`) + writeFileSync( + tempConfigPath, + JSON.stringify({ + projectFolder: packageDir, + mainEntryPointFilePath: resolve(packageDir, entry.types), + compiler: { tsconfigFilePath: resolve(packageDir, 'tsconfig.json') }, + apiReport: { + enabled: true, + reportFolder: resolve(packageDir, 'etc'), + reportTempFolder: resolve(packageDir, 'tmp'), + reportFileName: `${entry.name}.api.md`, + }, + docModel: { enabled: false }, + dtsRollup: { enabled: false }, + tsdocMetadata: { enabled: false }, + messages: MESSAGES, + }), + ) + return (() => { + try { + execFileSync(apiExtractor, ['run', '-c', tempConfigPath, ...(reBless ? ['--local'] : [])], { stdio: 'inherit' }) + return true + } catch { + console.error(`✗ ${pkg.name} ${entry.subpath}: API drift or extraction error`) + return false + } finally { + rmSync(tempConfigPath, { force: true }) + } + })() +} + +const drifted = entries.map((entry) => ({ entry, ok: checkEntry(entry) })).filter((result) => !result.ok) + +if (drifted.length > 0) { + console.error( + `\n${drifted.length} entr${drifted.length === 1 ? 'y' : 'ies'} drifted or errored. Review the change; re-bless with UPDATE_API_SNAPSHOT=1 if intended.`, + ) + process.exit(1) +} +console.log(`✓ ${pkg.name}: public API surface matches (${entries.length} entries)`) diff --git a/scripts/check-exports.mjs b/scripts/check-exports.mjs new file mode 100644 index 0000000..5eaf2a7 --- /dev/null +++ b/scripts/check-exports.mjs @@ -0,0 +1,35 @@ +// Export-load guard, shared by the workspace packages. Loads every public subpath of a +// built package per its export conditions (require + import), so an entry that resolves +// but throws at load (e.g. a CJS bundle requiring an ESM-only peer) fails CI, not the +// consumer. Run after that package's build. +// +// node ../scripts/check-exports.mjs (defaults to the cwd) + +import { readFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { resolve } from 'node:path' + +const packageDir = resolve(process.argv[2] ?? '.') +const pkg = JSON.parse(readFileSync(resolve(packageDir, 'package.json'), 'utf8')) +const require = createRequire(import.meta.url) +const load = { require: (spec) => require(spec), import: (spec) => import(spec) } + +const results = [] +for (const [subpath, conditions] of Object.entries(pkg.exports)) { + const spec = subpath === '.' ? pkg.name : `${pkg.name}/${subpath.slice(2)}` + for (const condition of ['require', 'import']) { + if (conditions[condition] === undefined) { + continue + } + try { + await load[condition](spec) + console.log(`✓ ${spec} [${condition}]`) + results.push(true) + } catch (error) { + console.error(`✗ ${spec} [${condition}]: ${error.code ?? error.message}`) + results.push(false) + } + } +} + +process.exit(results.every(Boolean) ? 0 : 1)