Use external agent SDK in playground

This commit is contained in:
hand-dot
2026-05-20 10:08:21 +09:00
parent 9c12be967d
commit bfa94c2452
8 changed files with 155 additions and 1187 deletions

View File

@@ -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 (
<div className="min-h-screen flex flex-col">
<Header />

View File

@@ -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> | void;
templateName?: string | null;
templatePath?: string | null;
workspaceRootName?: string | null;
};
type WidgetLog = {
id: string;
text: string;
};
const readFileAsDataUrl = (file: File) =>
new Promise<string>((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<AgentArtifact[]>([]);
const [available, setAvailable] = useState(false);
const [changedFiles, setChangedFiles] = useState<ChangedFile[]>([]);
const [enabled, setEnabled] = useState(isPdfmeAgentEnabled);
const [expanded, setExpanded] = useState(true);
const [health, setHealth] = useState<BridgeHealth | null>(null);
const [input, setInput] = useState('');
const [logs, setLogs] = useState<WidgetLog[]>([]);
const [messages, setMessages] = useState<AgentSessionMessage[]>([]);
const [pairingToken, setPairingToken] = useState('');
const [session, setSession] = useState<AgentSession | null>(null);
const [skills, setSkills] = useState<SkillSummary[]>([]);
const [submitting, setSubmitting] = useState(false);
const [validation, setValidation] = useState<TemplateValidationResult | null>(null);
const [working, setWorking] = useState(false);
const [workspace, setWorkspace] = useState<WorkspaceSummary | null>(null);
const eventSourceRef = useRef<EventSource | null>(null);
const logCounterRef = useRef(0);
const workspaceRef = useRef<WorkspaceSummary | null>(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<HTMLFormElement>) => {
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<HTMLFormElement>) => {
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<HTMLInputElement>) => {
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 (
<button
type="button"
className="fixed bottom-4 right-4 z-40 inline-flex items-center gap-2 rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm font-semibold text-gray-800 shadow-lg transition hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-green-500"
onClick={() => setExpanded(true)}
>
<Bot className="size-4 text-green-600" />
pdfme Agent
<ChevronUp className="size-4 text-gray-500" />
</button>
);
}
const requiresPairing = Boolean(health?.requiresPairing && !health.paired);
const codexReviewAvailable = health?.agentAdapter === 'codex';
const adapterLabel = health?.agentAdapter ?? 'unknown adapter';
return (
<aside className="fixed bottom-4 right-4 z-40 flex max-h-[calc(100vh-2rem)] w-[min(24rem,calc(100vw-2rem))] flex-col rounded-lg border border-gray-200 bg-white shadow-xl">
<header className="flex items-center justify-between border-b border-gray-200 px-3 py-2">
<div className="flex min-w-0 items-center gap-2">
<Bot className="size-4 shrink-0 text-green-600" />
<div className="min-w-0">
<h2 className="truncate text-sm font-semibold text-gray-900">pdfme Agent</h2>
<p className="truncate text-xs capitalize text-gray-500">
{bridgeUnavailable ? 'Bridge unavailable' : statusLabel(health, session)}
{skills.length > 0 ? ` · ${skills.length} skills` : ''}
{health ? ` · ${adapterLabel}` : ''}
</p>
</div>
</div>
<div className="flex items-center gap-1">
<button
type="button"
className="rounded p-1 text-gray-500 hover:bg-gray-100 hover:text-gray-700"
onClick={() => setExpanded(false)}
aria-label="Collapse pdfme Agent"
>
<ChevronDown className="size-4" />
</button>
<button
type="button"
className="rounded p-1 text-gray-500 hover:bg-gray-100 hover:text-gray-700"
onClick={onResetPairing}
aria-label="Reset pdfme Agent pairing"
>
<X className="size-4" />
</button>
</div>
</header>
{bridgeUnavailable ? (
<div className="space-y-3 p-3">
<div className="rounded border border-yellow-200 bg-yellow-50 px-2 py-1.5 text-xs text-yellow-900">
Local bridge is unavailable.
</div>
</div>
) : requiresPairing ? (
<form className="space-y-3 p-3" onSubmit={onPair}>
<div className="flex items-center gap-2 text-sm font-medium text-gray-800">
<KeyRound className="size-4 text-gray-500" />
Pair local bridge
</div>
<input
className="w-full rounded border border-gray-300 px-2 py-1.5 text-sm text-gray-900 focus:border-green-500 focus:outline-none focus:ring-1 focus:ring-green-500"
value={pairingToken}
onChange={(event) => setPairingToken(event.target.value)}
placeholder="Pairing token"
type="password"
/>
<PlaygroundButton disabled={submitting} fullWidth type="submit" variant="primary">
{submitting ? <LoaderCircle className="size-4 animate-spin" /> : <KeyRound className="size-4" />}
Pair
</PlaygroundButton>
</form>
) : (
<>
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto p-3">
{workspaceRootName && (
<div className="flex items-start gap-2 rounded border border-gray-200 bg-gray-50 px-2 py-1.5 text-xs text-gray-600">
<Server className="mt-0.5 size-3.5 shrink-0" />
<span className="min-w-0 truncate">
{workspaceRootName}
{templatePath ? ` / ${templatePath}` : ''}
</span>
</div>
)}
{workspace && workspace.status === 'unmapped' && (
<div className="rounded border border-yellow-200 bg-yellow-50 px-2 py-1.5 text-xs text-yellow-900">
Workspace root is not mapped in the bridge.
</div>
)}
{!codexReviewAvailable && (
<div className="rounded border border-yellow-200 bg-yellow-50 px-2 py-1.5 text-xs text-yellow-900">
Codex review is not active in the bridge.
</div>
)}
<div className="space-y-2">
{messages.length === 0 ? (
<p className="rounded border border-dashed border-gray-300 px-3 py-6 text-center text-sm text-gray-500">
Ask for a review or a template change.
</p>
) : (
messages.map((message) => (
<div
key={message.id}
className={`rounded px-3 py-2 text-sm ${
message.role === 'user'
? 'ml-8 bg-green-50 text-green-950'
: 'mr-8 bg-gray-100 text-gray-800'
}`}
>
{message.content}
</div>
))
)}
</div>
{validation && (
<div
className={`rounded border px-2 py-1.5 text-xs ${
validation.ok
? 'border-green-200 bg-green-50 text-green-900'
: 'border-red-200 bg-red-50 text-red-900'
}`}
>
<div className="font-medium">{validation.summary}</div>
<div className="mt-1 space-y-0.5">
{validation.checks.map((check) => (
<div key={check.id} className="truncate">
{check.status}: {check.label}
</div>
))}
</div>
</div>
)}
{(artifacts.length > 0 || logs.length > 0 || changedFiles.length > 0) && (
<details className="rounded border border-gray-200 px-2 py-1.5 text-xs text-gray-600">
<summary className="cursor-pointer font-medium text-gray-700">Details</summary>
{artifacts.length > 0 && (
<div className="mt-2 space-y-1">
{artifacts.map((artifact) => (
<div key={`${artifact.kind}:${artifact.path}`} className="truncate">
artifact: {artifact.label} · {artifact.path}
</div>
))}
</div>
)}
{changedFiles.length > 0 && (
<div className="mt-2 space-y-1 border-t border-gray-100 pt-2">
{changedFiles.map((file) => (
<div key={`${file.status}:${file.path}`} className="truncate">
{file.status}: {file.path}
</div>
))}
</div>
)}
{logs.length > 0 && (
<div className="mt-2 space-y-1 border-t border-gray-100 pt-2">
{logs.map((log) => (
<div key={log.id} className="truncate">
{log.text}
</div>
))}
</div>
)}
</details>
)}
</div>
<div className="border-t border-gray-200 p-3">
{onRefreshTemplate && (
<PlaygroundButton
className="mb-2"
disabled={working}
fullWidth
onClick={() => void onRefresh()}
variant="secondary"
>
<RefreshCw className="size-4" />
Refresh template
</PlaygroundButton>
)}
<PlaygroundButton
className="mb-2"
disabled={working || !codexReviewAvailable}
fullWidth
onClick={() => void onReviewCurrentTemplate()}
variant="primary"
>
{working ? (
<LoaderCircle className="size-4 animate-spin" />
) : (
<Bot className="size-4" />
)}
Review current template
</PlaygroundButton>
<PlaygroundButton
className="mb-2"
disabled={working}
fullWidth
onClick={() => void onValidate()}
variant="secondary"
>
Validate template
</PlaygroundButton>
<label
aria-disabled={working}
className={`mb-2 inline-flex w-full min-w-0 items-center justify-center gap-1 whitespace-nowrap rounded border border-gray-300 bg-white px-2 py-1.5 text-sm font-medium text-gray-700 transition ${
working ? 'cursor-not-allowed opacity-50' : 'cursor-pointer hover:bg-gray-50'
}`}
>
<Upload className="size-4" />
Create from PDF
<input
disabled={working}
type="file"
accept="application/pdf"
className="sr-only"
onChange={(event) => void onCreateFromPdf(event)}
/>
</label>
<form className="flex gap-2" onSubmit={onSendMessage}>
<input
className="min-w-0 flex-1 rounded border border-gray-300 px-2 py-1.5 text-sm text-gray-900 focus:border-green-500 focus:outline-none focus:ring-1 focus:ring-green-500"
disabled={working}
value={input}
onChange={(event) => setInput(event.target.value)}
placeholder="Message"
/>
<PlaygroundButton disabled={working || !input.trim()} type="submit" variant="primary">
{working ? <LoaderCircle className="size-4 animate-spin" /> : <Send className="size-4" />}
</PlaygroundButton>
</form>
</div>
</>
)}
</aside>
);
}

View File

@@ -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({

View File

@@ -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<string, unknown>;
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<string, unknown>;
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<BridgeHealth>('/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<T>(
path: string,
init: (RequestInit & { skipToken?: boolean }) = {},
): Promise<T> {
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();

View File

@@ -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;
};

View File

@@ -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> | void;
};
declare global {
interface Window {
pdfmeAgent?: {
isEnabled?: () => boolean;
setEnabled?: (enabled: boolean) => void;
start?: (host?: PdfmeAgentHost) => void;
stop?: () => void;
version?: string;
};
pdfmeAgentHost?: PdfmeAgentHost;
}
}

View File

@@ -0,0 +1,83 @@
const DEFAULT_BRIDGE_URL = 'http://127.0.0.1:4128';
const ENABLED_KEY = 'pdfme-agent.enabled';
let loadPromise: Promise<void> | 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<void>((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.
});
};

View File

@@ -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}
/>
<PdfmeAgentWidget
getCurrentTemplate={() => designer.current?.getTemplate() ?? null}
getCurrentTemplateTitle={() => projectTitleRef.current}
onRefreshTemplate={fileWorkspaceEntry ? onRefreshAgentTemplate : undefined}
templateName={fileWorkspaceEntry?.name ?? null}
templatePath={fileWorkspaceEntry?.path ?? null}
workspaceRootName={fileWorkspaceCollectionRef.current?.rootName ?? null}
/>
</>
);
}