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} - /> ); }