diff --git a/playground/src/App.tsx b/playground/src/App.tsx
index c4d00941..0dae7c4f 100644
--- a/playground/src/App.tsx
+++ b/playground/src/App.tsx
@@ -1,23 +1,15 @@
-import { Suspense, lazy, useEffect } from 'react';
-import { Routes, Route, useSearchParams } from 'react-router-dom';
+import { Suspense, lazy } from 'react';
+import { Routes, Route } from 'react-router-dom';
import { ToastContainer } from 'react-toastify';
import Designer from './routes/Designer';
import FormAndViewer from './routes/FormAndViewer';
import Templates from './routes/Templates';
import Header from './components/Header';
-import { consumePdfmeAgentSearchParam } from './lib/pdfmeAgentFeature';
const JsxPlayground = lazy(() => import('./routes/JsxPlayground'));
const Md2Pdf = lazy(() => import('./routes/Md2Pdf'));
export default function App() {
- const [searchParams, setSearchParams] = useSearchParams();
-
- useEffect(() => {
- const nextSearchParams = consumePdfmeAgentSearchParam(searchParams);
- if (nextSearchParams) setSearchParams(nextSearchParams, { replace: true });
- }, [searchParams, setSearchParams]);
-
return (
diff --git a/playground/src/components/PdfmeAgentWidget.tsx b/playground/src/components/PdfmeAgentWidget.tsx
deleted file mode 100644
index e9bd4d6d..00000000
--- a/playground/src/components/PdfmeAgentWidget.tsx
+++ /dev/null
@@ -1,796 +0,0 @@
-import { ChangeEvent, FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
-import {
- Bot,
- ChevronDown,
- ChevronUp,
- KeyRound,
- LoaderCircle,
- RefreshCw,
- Send,
- Server,
- Upload,
- X,
-} from 'lucide-react';
-import {
- clearStoredPairingToken,
- createPdfmeAgentBridgeClient,
- type AgentArtifact,
- type AgentSession,
- type AgentSessionMessage,
- type AgentSessionStatus,
- type BridgeHealth,
- type BridgeSessionEvent,
- type ChangedFile,
- type SkillSummary,
- type TemplateValidationResult,
- type WorkspaceSummary,
-} from '../lib/pdfmeAgentBridge';
-import { PDFME_AGENT_FEATURE_EVENT, isPdfmeAgentEnabled } from '../lib/pdfmeAgentFeature';
-import PlaygroundButton from './PlaygroundButton';
-
-type PdfmeAgentWidgetProps = {
- getCurrentTemplate?: () => unknown | null;
- getCurrentTemplateTitle?: () => string | null;
- onRefreshTemplate?: () => Promise
| void;
- templateName?: string | null;
- templatePath?: string | null;
- workspaceRootName?: string | null;
-};
-
-type WidgetLog = {
- id: string;
- text: string;
-};
-
-const readFileAsDataUrl = (file: File) =>
- new Promise((resolve, reject) => {
- const reader = new FileReader();
- reader.addEventListener('load', () => resolve(String(reader.result)));
- reader.addEventListener('error', () => reject(reader.error));
- reader.readAsDataURL(file);
- });
-
-const addUniqueMessages = (
- currentMessages: AgentSessionMessage[],
- nextMessages: AgentSessionMessage[],
-) => {
- const existingIds = new Set(currentMessages.map((message) => message.id));
- return [
- ...currentMessages,
- ...nextMessages.filter((message) => !existingIds.has(message.id)),
- ];
-};
-
-const getEventMessage = (event: BridgeSessionEvent): AgentSessionMessage | null => {
- if (
- typeof event.data !== 'object' ||
- event.data === null ||
- !('message' in event.data) ||
- typeof event.data.message !== 'object' ||
- event.data.message === null
- ) {
- return null;
- }
-
- return event.data.message as AgentSessionMessage;
-};
-
-const getEventLogMessage = (event: BridgeSessionEvent): string | null => {
- if (
- event.type !== 'agent.log' ||
- typeof event.data !== 'object' ||
- event.data === null ||
- !('message' in event.data) ||
- typeof event.data.message !== 'string'
- ) {
- return null;
- }
-
- return event.data.message;
-};
-
-const getEventStatus = (event: BridgeSessionEvent): AgentSessionStatus | null => {
- if (
- event.type !== 'session.status' ||
- typeof event.data !== 'object' ||
- event.data === null ||
- !('status' in event.data) ||
- typeof event.data.status !== 'string'
- ) {
- return null;
- }
-
- return event.data.status as AgentSessionStatus;
-};
-
-const getEventChangedFiles = (event: BridgeSessionEvent): ChangedFile[] | null => {
- if (
- event.type !== 'changed-files.updated' ||
- typeof event.data !== 'object' ||
- event.data === null ||
- !('changedFiles' in event.data) ||
- !Array.isArray(event.data.changedFiles)
- ) {
- return null;
- }
-
- return event.data.changedFiles as ChangedFile[];
-};
-
-const getEventArtifacts = (event: BridgeSessionEvent): AgentArtifact[] | null => {
- if (
- event.type !== 'artifacts.updated' ||
- typeof event.data !== 'object' ||
- event.data === null ||
- !('artifacts' in event.data) ||
- !Array.isArray(event.data.artifacts)
- ) {
- return null;
- }
-
- return event.data.artifacts as AgentArtifact[];
-};
-
-const getEventValidation = (event: BridgeSessionEvent): TemplateValidationResult | null => {
- if (
- event.type !== 'validation.result' ||
- typeof event.data !== 'object' ||
- event.data === null ||
- !('checks' in event.data) ||
- !Array.isArray(event.data.checks)
- ) {
- return null;
- }
-
- return event.data as TemplateValidationResult;
-};
-
-const statusLabel = (health: BridgeHealth | null, session: AgentSession | null) => {
- if (!health) return 'Checking';
- if (health.requiresPairing && !health.paired) return 'Pairing';
- if (session) return session.status.replace(/_/g, ' ');
- return 'Ready';
-};
-
-const getSessionIdFromUrl = () =>
- typeof window === 'undefined'
- ? null
- : new URLSearchParams(window.location.search).get('agentSession');
-
-const REVIEW_CURRENT_TEMPLATE_MESSAGE =
- 'Review the current pdfme template. This is a read-only review request; do not edit files.';
-
-export default function PdfmeAgentWidget({
- getCurrentTemplate,
- getCurrentTemplateTitle,
- onRefreshTemplate,
- templateName,
- templatePath,
- workspaceRootName,
-}: PdfmeAgentWidgetProps) {
- const client = useMemo(() => createPdfmeAgentBridgeClient(), []);
- const [artifacts, setArtifacts] = useState([]);
- const [available, setAvailable] = useState(false);
- const [changedFiles, setChangedFiles] = useState([]);
- const [enabled, setEnabled] = useState(isPdfmeAgentEnabled);
- const [expanded, setExpanded] = useState(true);
- const [health, setHealth] = useState(null);
- const [input, setInput] = useState('');
- const [logs, setLogs] = useState([]);
- const [messages, setMessages] = useState([]);
- const [pairingToken, setPairingToken] = useState('');
- const [session, setSession] = useState(null);
- const [skills, setSkills] = useState([]);
- const [submitting, setSubmitting] = useState(false);
- const [validation, setValidation] = useState(null);
- const [working, setWorking] = useState(false);
- const [workspace, setWorkspace] = useState(null);
- const eventSourceRef = useRef(null);
- const logCounterRef = useRef(0);
- const workspaceRef = useRef(null);
-
- useEffect(() => {
- const syncEnabled = () => setEnabled(isPdfmeAgentEnabled());
- syncEnabled();
- window.addEventListener(PDFME_AGENT_FEATURE_EVENT, syncEnabled);
- window.addEventListener('storage', syncEnabled);
- return () => {
- window.removeEventListener(PDFME_AGENT_FEATURE_EVENT, syncEnabled);
- window.removeEventListener('storage', syncEnabled);
- };
- }, []);
-
- const appendLog = useCallback((text: string) => {
- logCounterRef.current += 1;
- const id = `${Date.now()}:${logCounterRef.current}`;
- setLogs((currentLogs) => [
- ...currentLogs.slice(-24),
- { id, text },
- ]);
- }, []);
-
- const refreshHealth = useCallback(async () => {
- if (!enabled) return;
-
- try {
- const nextHealth = await client.health();
- setHealth(nextHealth);
- setAvailable(nextHealth.ok);
- } catch {
- setAvailable(false);
- setHealth(null);
- }
- }, [client, enabled]);
-
- useEffect(() => {
- if (!enabled) return undefined;
-
- void refreshHealth();
- const intervalId = window.setInterval(() => void refreshHealth(), 5000);
- return () => window.clearInterval(intervalId);
- }, [enabled, refreshHealth]);
-
- useEffect(() => {
- if (!enabled || !available || !health || (health.requiresPairing && !health.paired)) return;
-
- void client
- .listSkills()
- .then(setSkills)
- .catch(() => setSkills([]));
- }, [available, client, enabled, health]);
-
- useEffect(
- () => () => {
- eventSourceRef.current?.close();
- },
- [],
- );
-
- const connectEvents = useCallback(
- (sessionId: string) => {
- eventSourceRef.current?.close();
- const eventSource = client.streamEvents(sessionId);
- eventSourceRef.current = eventSource;
-
- const onBridgeEvent = (event: MessageEvent) => {
- const bridgeEvent = JSON.parse(event.data) as BridgeSessionEvent;
- appendLog(bridgeEvent.type);
- const eventStatus = getEventStatus(bridgeEvent);
- if (eventStatus) {
- setSession((currentSession) =>
- currentSession
- ? {
- ...currentSession,
- status: eventStatus,
- updatedAt: bridgeEvent.createdAt,
- }
- : currentSession,
- );
- setWorking(eventStatus === 'running');
- }
- const logMessage = getEventLogMessage(bridgeEvent);
- if (logMessage) appendLog(logMessage);
- const eventArtifacts = getEventArtifacts(bridgeEvent);
- if (eventArtifacts) setArtifacts(eventArtifacts);
- const eventChangedFiles = getEventChangedFiles(bridgeEvent);
- if (eventChangedFiles) setChangedFiles(eventChangedFiles);
- const eventValidation = getEventValidation(bridgeEvent);
- if (eventValidation) setValidation(eventValidation);
- const message = getEventMessage(bridgeEvent);
- if (message) {
- setMessages((currentMessages) => addUniqueMessages(currentMessages, [message]));
- }
- };
-
- eventSource.addEventListener('session.created', onBridgeEvent);
- eventSource.addEventListener('session.status', onBridgeEvent);
- eventSource.addEventListener('user.message', onBridgeEvent);
- eventSource.addEventListener('agent.message', onBridgeEvent);
- eventSource.addEventListener('agent.log', onBridgeEvent);
- eventSource.addEventListener('artifacts.updated', onBridgeEvent);
- eventSource.addEventListener('changed-files.updated', onBridgeEvent);
- eventSource.addEventListener('validation.result', onBridgeEvent);
- eventSource.addEventListener('error', () => {
- appendLog('event stream disconnected');
- });
- },
- [appendLog, client],
- );
-
- useEffect(() => {
- if (!enabled || !available || !health || (health.requiresPairing && !health.paired) || session) {
- return;
- }
-
- const sessionId = getSessionIdFromUrl();
- if (!sessionId) return;
-
- void (async () => {
- try {
- const nextSession = await client.getSession(sessionId);
- setSession(nextSession);
- setMessages(nextSession.messages);
- setChangedFiles(await client.getChangedFiles(sessionId));
- setArtifacts(await client.getArtifacts(sessionId));
- connectEvents(sessionId);
- appendLog(`resumed ${sessionId}`);
- } catch (error) {
- appendLog(error instanceof Error ? error.message : 'session resume failed');
- }
- })();
- }, [appendLog, available, client, connectEvents, enabled, health, session]);
-
- const ensureSession = useCallback(async () => {
- if (session) return session;
-
- const workspace = await client.registerWorkspace({
- label: workspaceRootName ?? templateName ?? 'Playground workspace',
- metadata: { source: 'playground-designer-poc' },
- rootName: workspaceRootName ?? undefined,
- selectedTemplateName: templateName ?? undefined,
- templatePath: templatePath ?? undefined,
- });
- workspaceRef.current = workspace;
- setWorkspace(workspace);
- const nextSession = await client.createSession({
- mode: 'designer-review',
- templateName: templateName ?? undefined,
- title: templateName ? `Review ${templateName}` : 'Designer review',
- workspaceId: workspace.id,
- });
-
- setSession(nextSession);
- setMessages(nextSession.messages);
- appendLog(
- workspace.status === 'mapped'
- ? `registered ${workspace.label} at ${workspace.rootPath}`
- : `registered ${workspace.label}; workspace root is not mapped`,
- );
- for (const diagnostic of workspace.diagnostics) appendLog(diagnostic);
- connectEvents(nextSession.id);
- return nextSession;
- }, [
- appendLog,
- client,
- connectEvents,
- session,
- templateName,
- templatePath,
- workspaceRootName,
- ]);
-
- const onPair = async (event: FormEvent) => {
- event.preventDefault();
- if (!pairingToken.trim()) return;
-
- setSubmitting(true);
- try {
- await client.pair(pairingToken.trim());
- appendLog('paired with local bridge');
- setPairingToken('');
- await refreshHealth();
- } catch (error) {
- appendLog(error instanceof Error ? error.message : 'pairing failed');
- } finally {
- setSubmitting(false);
- }
- };
-
- const onResetPairing = async () => {
- clearStoredPairingToken();
- setSession(null);
- setArtifacts([]);
- setMessages([]);
- setChangedFiles([]);
- setValidation(null);
- workspaceRef.current = null;
- setWorkspace(null);
- eventSourceRef.current?.close();
- appendLog('pairing token cleared');
- await refreshHealth();
- };
-
- const onSendMessage = async (event: FormEvent) => {
- event.preventDefault();
- const message = input.trim();
- if (!message) return;
-
- setWorking(true);
- setInput('');
- try {
- const activeSession = await ensureSession();
- const currentTemplate = getCurrentTemplate?.() ?? null;
- const activeTemplateName =
- templateName ??
- workspaceRef.current?.selectedTemplateName ??
- activeSession.templateName ??
- null;
- if (!activeTemplateName) {
- throw new Error('Open a mounted template before asking the agent to edit it');
- }
-
- appendLog('requested template edit');
- const nextSession = await client.sendMessage(activeSession.id, message, {
- action: 'edit-template',
- ...(currentTemplate ? { currentTemplate } : {}),
- templateName: activeTemplateName,
- title: getCurrentTemplateTitle?.() ?? activeSession.title,
- });
- setSession(nextSession);
- setMessages((currentMessages) => addUniqueMessages(currentMessages, nextSession.messages));
- setChangedFiles(await client.getChangedFiles(activeSession.id));
- setArtifacts(await client.getArtifacts(activeSession.id));
- } catch (error) {
- appendLog(error instanceof Error ? error.message : 'message failed');
- } finally {
- setWorking(false);
- }
- };
-
- const onReviewCurrentTemplate = async () => {
- setWorking(true);
- try {
- const activeSession = await ensureSession();
- const currentTemplate = getCurrentTemplate?.() ?? null;
- const activeTemplateName =
- templateName ??
- workspaceRef.current?.selectedTemplateName ??
- activeSession.templateName ??
- null;
- if (!currentTemplate && !activeTemplateName) {
- throw new Error('No current template is available to review');
- }
-
- appendLog('requested current template review');
- const nextSession = await client.sendMessage(
- activeSession.id,
- REVIEW_CURRENT_TEMPLATE_MESSAGE,
- {
- action: 'review-current-template',
- ...(currentTemplate ? { currentTemplate } : {}),
- templateName: activeTemplateName ?? 'current-designer-template',
- title: getCurrentTemplateTitle?.() ?? activeSession.title,
- },
- );
- setSession(nextSession);
- setMessages((currentMessages) => addUniqueMessages(currentMessages, nextSession.messages));
- setChangedFiles(await client.getChangedFiles(activeSession.id));
- setArtifacts(await client.getArtifacts(activeSession.id));
- } catch (error) {
- appendLog(error instanceof Error ? error.message : 'review failed');
- } finally {
- setWorking(false);
- }
- };
-
- const onRefresh = async () => {
- setWorking(true);
- try {
- await onRefreshTemplate?.();
- appendLog('template refreshed');
- } catch (error) {
- appendLog(error instanceof Error ? error.message : 'refresh failed');
- } finally {
- setWorking(false);
- }
- };
-
- const onCreateFromPdf = async (event: ChangeEvent) => {
- const file = event.target.files?.[0];
- event.target.value = '';
- if (!file) return;
-
- setWorking(true);
- try {
- const activeSession = await ensureSession();
- const activeWorkspace = workspaceRef.current;
- if (!activeWorkspace) throw new Error('Workspace is not registered');
-
- const title =
- window.prompt('Template title', file.name.replace(/\.pdf$/i, '')) ??
- file.name.replace(/\.pdf$/i, '');
- if (!title.trim()) return;
-
- const result = await client.createTemplateFromPdf({
- dataUrl: await readFileAsDataUrl(file),
- fileName: file.name,
- sessionId: activeSession.id,
- title,
- workspaceId: activeWorkspace.id,
- });
- setValidation(result.validation);
- setArtifacts(result.template.artifacts);
- setChangedFiles(
- result.template.files.map((path) => ({
- path,
- status: 'created',
- })),
- );
- appendLog(
- `created ${result.template.path} (${result.template.pageCount} pages, ${result.template.detectionSource}, ${result.template.acroFormFieldCount + result.template.visualFieldCount} fields)`,
- );
- const nextUrl = new URL('/designer', window.location.origin);
- nextUrl.searchParams.set('workspace', result.template.name);
- nextUrl.searchParams.set('agentSession', activeSession.id);
- window.location.href = `${nextUrl.pathname}${nextUrl.search}`;
- } catch (error) {
- appendLog(error instanceof Error ? error.message : 'PDF template creation failed');
- } finally {
- setWorking(false);
- }
- };
-
- const onValidate = async () => {
- setWorking(true);
- try {
- const activeSession = await ensureSession();
- const activeWorkspace = workspaceRef.current;
- const activeTemplateName =
- templateName ??
- workspaceRef.current?.selectedTemplateName ??
- activeSession.templateName ??
- null;
- const nextValidation =
- activeWorkspace && activeTemplateName
- ? await client.validateTemplate(activeWorkspace.id, activeTemplateName)
- : await client.validateCurrentTemplate({
- template: getCurrentTemplate?.() ?? null,
- templateName: activeTemplateName ?? 'current-designer-template',
- title: getCurrentTemplateTitle?.() ?? activeSession.title,
- });
- setValidation(nextValidation);
- appendLog(nextValidation.summary);
- } catch (error) {
- appendLog(error instanceof Error ? error.message : 'validation failed');
- } finally {
- setWorking(false);
- }
- };
-
- if (!enabled) return null;
-
- const bridgeUnavailable = !available || !health;
-
- if (!expanded) {
- return (
-
- );
- }
-
- const requiresPairing = Boolean(health?.requiresPairing && !health.paired);
- const codexReviewAvailable = health?.agentAdapter === 'codex';
- const adapterLabel = health?.agentAdapter ?? 'unknown adapter';
-
- return (
-
- );
-}
diff --git a/playground/src/index.tsx b/playground/src/index.tsx
index a8176b73..217c6a41 100644
--- a/playground/src/index.tsx
+++ b/playground/src/index.tsx
@@ -4,6 +4,9 @@ import { BrowserRouter } from 'react-router-dom';
import * as Sentry from '@sentry/react';
import './index.css';
import App from './App';
+import { initPdfmeAgentLoader } from './lib/pdfmeAgentLoader';
+
+initPdfmeAgentLoader();
// Initialize Sentry
Sentry.init({
diff --git a/playground/src/lib/pdfmeAgentBridge.ts b/playground/src/lib/pdfmeAgentBridge.ts
deleted file mode 100644
index 2d423cc3..00000000
--- a/playground/src/lib/pdfmeAgentBridge.ts
+++ /dev/null
@@ -1,326 +0,0 @@
-const DEFAULT_BRIDGE_URL = 'http://127.0.0.1:4128';
-const PAIRING_TOKEN_KEY = 'pdfme-agent.pairing-token';
-let runtimePairingToken: string | null = null;
-
-export type BridgeHealth = {
- agentAdapter?: 'codex' | 'poc';
- bridgeApiVersion: number;
- name: string;
- ok: boolean;
- paired: boolean;
- requiresPairing: boolean;
- version: string;
- workspaceRootCount?: number;
-};
-
-export type SkillSummary = {
- description: string;
- id: string;
- title: string;
-};
-
-export type WorkspaceSummary = {
- createdAt: string;
- id: string;
- label: string;
- metadata: Record;
- diagnostics: string[];
- rootPath: string | null;
- rootName: string | null;
- selectedTemplateName: string | null;
- status: 'mapped' | 'unmapped';
- templatePath: string | null;
- updatedAt: string;
- writeScopePath: string | null;
-};
-
-export type RegisterWorkspaceInput = {
- label?: string;
- metadata?: Record;
- rootPath?: string;
- rootName?: string;
- selectedTemplateName?: string;
- templatePath?: string;
-};
-
-export type AgentSessionStatus = 'idle' | 'running' | 'waiting_for_user' | 'completed' | 'failed';
-
-export type AgentSessionMessage = {
- content: string;
- createdAt: string;
- id: string;
- role: 'agent' | 'system' | 'user';
-};
-
-export type ChangedFile = {
- path: string;
- status: 'created' | 'deleted' | 'modified' | 'unknown';
-};
-
-export type AgentArtifact = {
- createdAt: string;
- kind: 'image' | 'json' | 'pdf' | 'text' | 'unknown';
- label: string;
- mimeType: string | null;
- path: string;
- templateName: string | null;
-};
-
-export type AgentSession = {
- artifacts: AgentArtifact[];
- changedFiles: ChangedFile[];
- createdAt: string;
- id: string;
- messages: AgentSessionMessage[];
- mode: string;
- status: AgentSessionStatus;
- templateName: string | null;
- title: string;
- updatedAt: string;
- workspaceId: string | null;
-};
-
-export type CreateSessionInput = {
- initialMessage?: string;
- mode?: string;
- templateName?: string;
- title?: string;
- workspaceId?: string;
-};
-
-export type SendMessageContext = {
- action?: 'edit-template' | 'message' | 'review-current-template';
- currentTemplate?: unknown;
- templateName?: string | null;
- title?: string | null;
-};
-
-export type BridgeSessionEvent = {
- createdAt: string;
- data: unknown;
- id: string;
- sessionId: string;
- type: string;
-};
-
-export type ValidationCheck = {
- id: string;
- label: string;
- message: string;
- status: 'error' | 'ok' | 'warning';
-};
-
-export type TemplateValidationResult = {
- checks: ValidationCheck[];
- ok: boolean;
- summary: string;
- template?: {
- fieldCount: number;
- pageCount: number;
- sourceKind: string | null;
- templateName: string;
- title: string | null;
- };
-};
-
-export type CreatedTemplateSummary = {
- acroFormFieldCount: number;
- artifacts: AgentArtifact[];
- detectionSource: 'acroform' | 'blank' | 'visual';
- files: string[];
- name: string;
- pageCount: number;
- path: string;
- title: string;
- visualFieldCount: number;
-};
-
-class BridgeRequestError extends Error {
- constructor(
- readonly status: number,
- message: string,
- ) {
- super(message);
- this.name = 'BridgeRequestError';
- }
-}
-
-const getStorage = () => {
- try {
- return typeof window === 'undefined' ? null : window.localStorage;
- } catch {
- return null;
- }
-};
-
-const getStoredPairingToken = () => getStorage()?.getItem(PAIRING_TOKEN_KEY) ?? runtimePairingToken;
-
-export const clearStoredPairingToken = () => {
- runtimePairingToken = null;
- getStorage()?.removeItem(PAIRING_TOKEN_KEY);
-};
-
-const setStoredPairingToken = (token: string) => {
- runtimePairingToken = token;
- getStorage()?.setItem(PAIRING_TOKEN_KEY, token);
-};
-
-const resolveBridgeUrl = () => {
- const configuredUrl = import.meta.env.VITE_PDFME_AGENT_BRIDGE_URL;
- return (configuredUrl || DEFAULT_BRIDGE_URL).replace(/\/+$/, '');
-};
-
-export class PdfmeAgentBridgeClient {
- constructor(private readonly baseUrl = resolveBridgeUrl()) {}
-
- get hasPairingToken() {
- return Boolean(getStoredPairingToken());
- }
-
- async health() {
- return this.fetchJson('/health');
- }
-
- async listSkills() {
- const response = await this.fetchJson<{ skills: SkillSummary[] }>('/skills');
- return response.skills;
- }
-
- async pair(token: string) {
- const response = await this.fetchJson<{ paired: boolean; requiresPairing: boolean }>('/pair', {
- body: JSON.stringify({ token }),
- method: 'POST',
- skipToken: true,
- });
- if (response.paired) setStoredPairingToken(token);
- return response;
- }
-
- async registerWorkspace(input: RegisterWorkspaceInput) {
- const response = await this.fetchJson<{ workspace: WorkspaceSummary }>('/workspaces', {
- body: JSON.stringify(input),
- method: 'POST',
- });
- return response.workspace;
- }
-
- async createSession(input: CreateSessionInput) {
- const response = await this.fetchJson<{ session: AgentSession }>('/sessions', {
- body: JSON.stringify(input),
- method: 'POST',
- });
- return response.session;
- }
-
- async getSession(sessionId: string) {
- const response = await this.fetchJson<{ session: AgentSession }>(
- `/sessions/${encodeURIComponent(sessionId)}`,
- );
- return response.session;
- }
-
- async sendMessage(sessionId: string, message: string, context?: SendMessageContext) {
- const response = await this.fetchJson<{ session: AgentSession }>(
- `/sessions/${encodeURIComponent(sessionId)}/messages`,
- {
- body: JSON.stringify({ context, message }),
- method: 'POST',
- },
- );
- return response.session;
- }
-
- async getChangedFiles(sessionId: string) {
- const response = await this.fetchJson<{ changedFiles: ChangedFile[] }>(
- `/sessions/${encodeURIComponent(sessionId)}/changed-files`,
- );
- return response.changedFiles;
- }
-
- async getArtifacts(sessionId: string) {
- const response = await this.fetchJson<{ artifacts: AgentArtifact[] }>(
- `/sessions/${encodeURIComponent(sessionId)}/artifacts`,
- );
- return response.artifacts;
- }
-
- async validateTemplate(workspaceId: string, templateName?: string | null) {
- const response = await this.fetchJson<{ validation: TemplateValidationResult }>(
- '/templates/validate',
- {
- body: JSON.stringify({ templateName, workspaceId }),
- method: 'POST',
- },
- );
- return response.validation;
- }
-
- async validateCurrentTemplate(input: {
- template: unknown;
- templateName?: string | null;
- title?: string | null;
- }) {
- const response = await this.fetchJson<{ validation: TemplateValidationResult }>(
- '/templates/validate',
- {
- body: JSON.stringify(input),
- method: 'POST',
- },
- );
- return response.validation;
- }
-
- async createTemplateFromPdf(input: {
- dataUrl: string;
- fileName: string;
- sessionId?: string;
- title?: string;
- workspaceId: string;
- }) {
- const response = await this.fetchJson<{
- template: CreatedTemplateSummary;
- validation: TemplateValidationResult;
- }>('/templates/from-pdf', {
- body: JSON.stringify(input),
- method: 'POST',
- });
- return response;
- }
-
- streamEvents(sessionId: string) {
- const token = getStoredPairingToken();
- const url = new URL(`${this.baseUrl}/sessions/${encodeURIComponent(sessionId)}/events`);
- if (token) url.searchParams.set('token', token);
- return new EventSource(url);
- }
-
- private async fetchJson(
- path: string,
- init: (RequestInit & { skipToken?: boolean }) = {},
- ): Promise {
- const headers = new Headers(init.headers);
- headers.set('Accept', 'application/json');
- if (init.body) headers.set('Content-Type', 'application/json');
-
- const token = getStoredPairingToken();
- if (token && !init.skipToken) headers.set('X-Pdfme-Agent-Token', token);
-
- const response = await fetch(`${this.baseUrl}${path}`, {
- ...init,
- headers,
- });
-
- const payload = await response.json().catch(() => null);
- if (!response.ok) {
- const message =
- typeof payload?.error?.message === 'string'
- ? payload.error.message
- : `Bridge request failed with ${response.status}`;
- throw new BridgeRequestError(response.status, message);
- }
-
- return payload as T;
- }
-}
-
-export const createPdfmeAgentBridgeClient = () => new PdfmeAgentBridgeClient();
diff --git a/playground/src/lib/pdfmeAgentFeature.ts b/playground/src/lib/pdfmeAgentFeature.ts
deleted file mode 100644
index 31c27134..00000000
--- a/playground/src/lib/pdfmeAgentFeature.ts
+++ /dev/null
@@ -1,46 +0,0 @@
-const PDFME_AGENT_ENABLED_KEY = 'pdfme-agent.enabled';
-export const PDFME_AGENT_FEATURE_EVENT = 'pdfme-agent-feature-change';
-
-let runtimePdfmeAgentEnabled = false;
-
-const getStorage = () => {
- try {
- return typeof window === 'undefined' ? null : window.localStorage;
- } catch {
- return null;
- }
-};
-
-const getSearchParamValue = () =>
- typeof window === 'undefined' ? null : new URLSearchParams(window.location.search).get('agent');
-
-export const isPdfmeAgentEnabled = () => {
- const searchValue = getSearchParamValue();
- if (searchValue === '1') return true;
- if (searchValue === '0') return false;
- return runtimePdfmeAgentEnabled || getStorage()?.getItem(PDFME_AGENT_ENABLED_KEY) === '1';
-};
-
-export const setPdfmeAgentEnabled = (enabled: boolean) => {
- runtimePdfmeAgentEnabled = enabled;
- const storage = getStorage();
- if (storage) {
- if (enabled) {
- storage.setItem(PDFME_AGENT_ENABLED_KEY, '1');
- } else {
- storage.removeItem(PDFME_AGENT_ENABLED_KEY);
- }
- }
- if (typeof window !== 'undefined') window.dispatchEvent(new Event(PDFME_AGENT_FEATURE_EVENT));
-};
-
-export const consumePdfmeAgentSearchParam = (searchParams: URLSearchParams) => {
- const value = searchParams.get('agent');
- if (value !== '1' && value !== '0') return null;
-
- setPdfmeAgentEnabled(value === '1');
-
- const nextSearchParams = new URLSearchParams(searchParams);
- nextSearchParams.delete('agent');
- return nextSearchParams;
-};
diff --git a/playground/src/lib/pdfmeAgentHost.ts b/playground/src/lib/pdfmeAgentHost.ts
new file mode 100644
index 00000000..cce48e6a
--- /dev/null
+++ b/playground/src/lib/pdfmeAgentHost.ts
@@ -0,0 +1,29 @@
+export const PDFME_AGENT_HOST_READY_EVENT = 'pdfme-agent-host-ready';
+export const PDFME_AGENT_HOST_DESTROYED_EVENT = 'pdfme-agent-host-destroyed';
+
+export type PdfmeAgentWorkspaceContext = {
+ templateName?: string | null;
+ templatePath?: string | null;
+ workspaceRootName?: string | null;
+};
+
+export type PdfmeAgentHost = {
+ getCurrentTemplate?: () => unknown | null;
+ getCurrentTemplateTitle?: () => string | null;
+ getWorkspaceContext?: () => PdfmeAgentWorkspaceContext;
+ navigateToGeneratedTemplate?: (input: { sessionId: string; templateName: string }) => void;
+ refreshTemplate?: () => Promise | void;
+};
+
+declare global {
+ interface Window {
+ pdfmeAgent?: {
+ isEnabled?: () => boolean;
+ setEnabled?: (enabled: boolean) => void;
+ start?: (host?: PdfmeAgentHost) => void;
+ stop?: () => void;
+ version?: string;
+ };
+ pdfmeAgentHost?: PdfmeAgentHost;
+ }
+}
diff --git a/playground/src/lib/pdfmeAgentLoader.ts b/playground/src/lib/pdfmeAgentLoader.ts
new file mode 100644
index 00000000..1a8aa0b1
--- /dev/null
+++ b/playground/src/lib/pdfmeAgentLoader.ts
@@ -0,0 +1,83 @@
+const DEFAULT_BRIDGE_URL = 'http://127.0.0.1:4128';
+const ENABLED_KEY = 'pdfme-agent.enabled';
+
+let loadPromise: Promise | null = null;
+
+const getStorage = () => {
+ try {
+ return typeof window === 'undefined' ? null : window.localStorage;
+ } catch {
+ return null;
+ }
+};
+
+const setEnabled = (enabled: boolean) => {
+ const storage = getStorage();
+ if (!storage) return;
+ if (enabled) {
+ storage.setItem(ENABLED_KEY, '1');
+ } else {
+ storage.removeItem(ENABLED_KEY);
+ }
+};
+
+const isEnabled = () => getStorage()?.getItem(ENABLED_KEY) === '1';
+
+const consumeSearchParam = () => {
+ const url = new URL(window.location.href);
+ const value = url.searchParams.get('agent');
+ if (value !== '1' && value !== '0') return null;
+
+ setEnabled(value === '1');
+ url.searchParams.delete('agent');
+ window.history.replaceState(window.history.state, '', `${url.pathname}${url.search}${url.hash}`);
+ return value === '1';
+};
+
+const getBridgeUrl = () =>
+ (import.meta.env.VITE_PDFME_AGENT_BRIDGE_URL || DEFAULT_BRIDGE_URL).replace(/\/+$/, '');
+
+export const loadPdfmeAgentSdk = () => {
+ if (typeof document === 'undefined') return Promise.resolve();
+ if (window.pdfmeAgent) {
+ return Promise.resolve();
+ }
+ if (loadPromise) return loadPromise;
+ if (document.querySelector('script[data-pdfme-agent-sdk="true"]')) {
+ return Promise.resolve();
+ }
+
+ loadPromise = new Promise((resolve, reject) => {
+ const bridgeUrl = getBridgeUrl();
+ const script = document.createElement('script');
+ script.async = true;
+ script.dataset.bridgeUrl = bridgeUrl;
+ script.dataset.pdfmeAgentSdk = 'true';
+ script.src = `${bridgeUrl}/sdk/pdfme-agent.js`;
+ script.addEventListener('load', () => resolve(), { once: true });
+ script.addEventListener(
+ 'error',
+ () => {
+ loadPromise = null;
+ script.remove();
+ reject(new Error('pdfme Agent SDK failed to load'));
+ },
+ { once: true },
+ );
+ document.head.appendChild(script);
+ });
+
+ return loadPromise;
+};
+
+export const initPdfmeAgentLoader = () => {
+ const explicitEnabled = consumeSearchParam();
+ if (explicitEnabled === false) window.pdfmeAgent?.setEnabled?.(false);
+ if (!isEnabled()) {
+ window.pdfmeAgent?.stop?.();
+ return;
+ }
+ void loadPdfmeAgentSdk().catch(() => {
+ // The hidden SDK is optional and only used with a local bridge.
+ });
+};
diff --git a/playground/src/routes/Designer.tsx b/playground/src/routes/Designer.tsx
index 7c7430de..a2d1d316 100644
--- a/playground/src/routes/Designer.tsx
+++ b/playground/src/routes/Designer.tsx
@@ -18,10 +18,14 @@ import {
} from '../helper';
import { getPlugins } from '../plugins';
import { NavBar, NavItem } from '../components/NavBar';
-import PdfmeAgentWidget from '../components/PdfmeAgentWidget';
import PlaygroundButton from '../components/PlaygroundButton';
import ProjectSavedToast from '../components/ProjectSavedToast';
import TemplateJsonDialog from '../components/TemplateJsonDialog';
+import {
+ PDFME_AGENT_HOST_DESTROYED_EVENT,
+ PDFME_AGENT_HOST_READY_EVENT,
+ type PdfmeAgentHost,
+} from '../lib/pdfmeAgentHost';
import {
clearActivePlaygroundProject,
getActivePlaygroundProject,
@@ -580,6 +584,39 @@ function DesignerApp() {
});
}, [applyTemplateFromDisk]);
+ useEffect(() => {
+ const host: PdfmeAgentHost = {
+ getCurrentTemplate: () => designer.current?.getTemplate() ?? null,
+ getCurrentTemplateTitle: () => projectTitleRef.current,
+ getWorkspaceContext: () => {
+ const currentEntry = fileWorkspaceEntryRef.current;
+ return {
+ templateName: currentEntry?.name ?? null,
+ templatePath: currentEntry?.path ?? null,
+ workspaceRootName: fileWorkspaceCollectionRef.current?.rootName ?? null,
+ };
+ },
+ navigateToGeneratedTemplate: ({ sessionId, templateName }) => {
+ const nextUrl = new URL('/designer', window.location.origin);
+ nextUrl.searchParams.set('workspace', templateName);
+ nextUrl.searchParams.set('agentSession', sessionId);
+ window.location.href = `${nextUrl.pathname}${nextUrl.search}`;
+ },
+ refreshTemplate: onRefreshAgentTemplate,
+ };
+
+ window.pdfmeAgentHost = host;
+ window.dispatchEvent(new CustomEvent(PDFME_AGENT_HOST_READY_EVENT));
+ window.pdfmeAgent?.start?.(host);
+
+ return () => {
+ if (window.pdfmeAgentHost !== host) return;
+ window.dispatchEvent(new CustomEvent(PDFME_AGENT_HOST_DESTROYED_EVENT));
+ window.pdfmeAgent?.stop?.();
+ delete window.pdfmeAgentHost;
+ };
+ }, [onRefreshAgentTemplate]);
+
useEffect(() => {
if (!fileWorkspaceConflict) return;
@@ -892,14 +929,6 @@ function DesignerApp() {
}}
onCommit={onCommitTemplateJson}
/>
- designer.current?.getTemplate() ?? null}
- getCurrentTemplateTitle={() => projectTitleRef.current}
- onRefreshTemplate={fileWorkspaceEntry ? onRefreshAgentTemplate : undefined}
- templateName={fileWorkspaceEntry?.name ?? null}
- templatePath={fileWorkspaceEntry?.path ?? null}
- workspaceRootName={fileWorkspaceCollectionRef.current?.rootName ?? null}
- />
>
);
}