diff --git a/playground/src/App.tsx b/playground/src/App.tsx
index 0dae7c4f..c4d00941 100644
--- a/playground/src/App.tsx
+++ b/playground/src/App.tsx
@@ -1,15 +1,23 @@
-import { Suspense, lazy } from 'react';
-import { Routes, Route } from 'react-router-dom';
+import { Suspense, lazy, useEffect } from 'react';
+import { Routes, Route, useSearchParams } 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
new file mode 100644
index 00000000..6bd7524a
--- /dev/null
+++ b/playground/src/components/PdfmeAgentWidget.tsx
@@ -0,0 +1,411 @@
+import { FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import {
+ Bot,
+ ChevronDown,
+ ChevronUp,
+ KeyRound,
+ LoaderCircle,
+ RefreshCw,
+ Send,
+ Server,
+ X,
+} from 'lucide-react';
+import {
+ clearStoredPairingToken,
+ createPdfmeAgentBridgeClient,
+ type AgentSession,
+ type AgentSessionMessage,
+ type BridgeHealth,
+ type BridgeSessionEvent,
+ type ChangedFile,
+ type SkillSummary,
+} from '../lib/pdfmeAgentBridge';
+import { isPdfmeAgentEnabled } from '../lib/pdfmeAgentFeature';
+import PlaygroundButton from './PlaygroundButton';
+
+type PdfmeAgentWidgetProps = {
+ onRefreshTemplate?: () => Promise
| void;
+ templateName?: string | null;
+ templatePath?: string | null;
+ workspaceRootName?: string | null;
+};
+
+type WidgetLog = {
+ id: string;
+ text: string;
+};
+
+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 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';
+};
+
+export default function PdfmeAgentWidget({
+ onRefreshTemplate,
+ templateName,
+ templatePath,
+ workspaceRootName,
+}: PdfmeAgentWidgetProps) {
+ const enabled = isPdfmeAgentEnabled();
+ const client = useMemo(() => createPdfmeAgentBridgeClient(), []);
+ const [available, setAvailable] = useState(false);
+ const [changedFiles, setChangedFiles] = useState([]);
+ 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 [working, setWorking] = useState(false);
+ const eventSourceRef = useRef(null);
+
+ const appendLog = useCallback((text: string) => {
+ setLogs((currentLogs) => [
+ ...currentLogs.slice(-24),
+ { id: `${Date.now()}:${currentLogs.length}`, 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 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('error', () => {
+ appendLog('event stream disconnected');
+ });
+ },
+ [appendLog, client],
+ );
+
+ 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,
+ });
+ 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(`registered ${workspace.label}`);
+ 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);
+ setMessages([]);
+ setChangedFiles([]);
+ 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 nextSession = await client.sendMessage(activeSession.id, message);
+ setSession(nextSession);
+ setMessages((currentMessages) => addUniqueMessages(currentMessages, nextSession.messages));
+ setChangedFiles(await client.getChangedFiles(activeSession.id));
+ } catch (error) {
+ appendLog(error instanceof Error ? error.message : 'message 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);
+ }
+ };
+
+ if (!enabled || !available || !health) return null;
+
+ if (!expanded) {
+ return (
+
+ );
+ }
+
+ const requiresPairing = health.requiresPairing && !health.paired;
+
+ return (
+
+ );
+}
diff --git a/playground/src/lib/pdfmeAgentBridge.ts b/playground/src/lib/pdfmeAgentBridge.ts
new file mode 100644
index 00000000..9c2f9465
--- /dev/null
+++ b/playground/src/lib/pdfmeAgentBridge.ts
@@ -0,0 +1,206 @@
+const DEFAULT_BRIDGE_URL = 'http://127.0.0.1:4128';
+const PAIRING_TOKEN_KEY = 'pdfme-agent.pairing-token';
+
+export type BridgeHealth = {
+ bridgeApiVersion: number;
+ name: string;
+ ok: boolean;
+ paired: boolean;
+ requiresPairing: boolean;
+ version: string;
+};
+
+export type SkillSummary = {
+ description: string;
+ id: string;
+ title: string;
+};
+
+export type WorkspaceSummary = {
+ createdAt: string;
+ id: string;
+ label: string;
+ metadata: Record;
+ rootName: string | null;
+ selectedTemplateName: string | null;
+ templatePath: string | null;
+ updatedAt: string;
+};
+
+export type RegisterWorkspaceInput = {
+ label?: string;
+ metadata?: Record;
+ 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 AgentSession = {
+ 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 BridgeSessionEvent = {
+ createdAt: string;
+ data: unknown;
+ id: string;
+ sessionId: string;
+ type: string;
+};
+
+class BridgeRequestError extends Error {
+ constructor(
+ readonly status: number,
+ message: string,
+ ) {
+ super(message);
+ this.name = 'BridgeRequestError';
+ }
+}
+
+const getStoredPairingToken = () =>
+ typeof window === 'undefined' ? null : window.localStorage.getItem(PAIRING_TOKEN_KEY);
+
+export const clearStoredPairingToken = () => {
+ if (typeof window === 'undefined') return;
+ window.localStorage.removeItem(PAIRING_TOKEN_KEY);
+};
+
+const setStoredPairingToken = (token: string) => {
+ if (typeof window === 'undefined') return;
+ window.localStorage.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 sendMessage(sessionId: string, message: string) {
+ const response = await this.fetchJson<{ session: AgentSession }>(
+ `/sessions/${encodeURIComponent(sessionId)}/messages`,
+ {
+ body: JSON.stringify({ 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;
+ }
+
+ 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
new file mode 100644
index 00000000..1b41145c
--- /dev/null
+++ b/playground/src/lib/pdfmeAgentFeature.ts
@@ -0,0 +1,28 @@
+const PDFME_AGENT_ENABLED_KEY = 'pdfme-agent.enabled';
+
+const getStorage = () => (typeof window === 'undefined' ? null : window.localStorage);
+
+export const isPdfmeAgentEnabled = () => getStorage()?.getItem(PDFME_AGENT_ENABLED_KEY) === '1';
+
+export const setPdfmeAgentEnabled = (enabled: boolean) => {
+ const storage = getStorage();
+ if (!storage) return;
+
+ if (enabled) {
+ storage.setItem(PDFME_AGENT_ENABLED_KEY, '1');
+ } else {
+ storage.removeItem(PDFME_AGENT_ENABLED_KEY);
+ }
+};
+
+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/routes/Designer.tsx b/playground/src/routes/Designer.tsx
index 8de70f78..f786e8cc 100644
--- a/playground/src/routes/Designer.tsx
+++ b/playground/src/routes/Designer.tsx
@@ -18,6 +18,7 @@ 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';
@@ -568,6 +569,17 @@ function DesignerApp() {
}
};
+ const onRefreshAgentTemplate = useCallback(async () => {
+ const currentEntry = fileWorkspaceEntryRef.current;
+ if (!currentEntry) return;
+
+ const readResult = await readTemplateEntry(currentEntry);
+ applyTemplateFromDisk(currentEntry, readResult);
+ toast.info(`Reloaded ${currentEntry.path} from disk`, {
+ toastId: `file-workspace-agent-reload:${currentEntry.path}`,
+ });
+ }, [applyTemplateFromDisk]);
+
useEffect(() => {
if (!fileWorkspaceConflict) return;
@@ -880,6 +892,12 @@ function DesignerApp() {
}}
onCommit={onCommitTemplateJson}
/>
+
>
);
}