From e94fb1167a02f6ecd99c3bb46bce13ca84bf54da Mon Sep 17 00:00:00 2001 From: hand-dot Date: Tue, 19 May 2026 14:19:05 +0900 Subject: [PATCH] Add pdfme Agent widget POC --- playground/src/App.tsx | 12 +- .../src/components/PdfmeAgentWidget.tsx | 411 ++++++++++++++++++ playground/src/lib/pdfmeAgentBridge.ts | 206 +++++++++ playground/src/lib/pdfmeAgentFeature.ts | 28 ++ playground/src/routes/Designer.tsx | 18 + 5 files changed, 673 insertions(+), 2 deletions(-) create mode 100644 playground/src/components/PdfmeAgentWidget.tsx create mode 100644 playground/src/lib/pdfmeAgentBridge.ts create mode 100644 playground/src/lib/pdfmeAgentFeature.ts 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} /> + ); }