mirror of
https://github.com/pdfme/pdfme.git
synced 2026-06-16 02:09:10 -04:00
Use external agent SDK in playground
This commit is contained in:
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
};
|
||||
29
playground/src/lib/pdfmeAgentHost.ts
Normal file
29
playground/src/lib/pdfmeAgentHost.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
83
playground/src/lib/pdfmeAgentLoader.ts
Normal file
83
playground/src/lib/pdfmeAgentLoader.ts
Normal 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.
|
||||
});
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user