Skip to content

Commit 9dcb37f

Browse files
committed
pdf-server: add interact tool to allow model to modify existing view
1 parent 37533ff commit 9dcb37f

2 files changed

Lines changed: 399 additions & 3 deletions

File tree

examples/pdf-server/server.ts

Lines changed: 205 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,64 @@ const DIST_DIR = import.meta.filename.endsWith(".ts")
5555
? path.join(import.meta.dirname, "dist")
5656
: import.meta.dirname;
5757

58+
// =============================================================================
59+
// Command Queue (shared across stateless server instances)
60+
// =============================================================================
61+
62+
/** Commands expire after this many ms if never polled */
63+
const COMMAND_TTL_MS = 60_000; // 60 seconds
64+
65+
/** Periodic sweep interval to drop stale queues */
66+
const SWEEP_INTERVAL_MS = 30_000; // 30 seconds
67+
68+
/** Fixed batch window: when commands are present, wait this long before returning to let more accumulate */
69+
const POLL_BATCH_WAIT_MS = 200;
70+
71+
export type PdfCommand =
72+
| { type: "navigate"; page: number }
73+
| { type: "search"; query: string }
74+
| { type: "find"; query: string }
75+
| { type: "search_navigate"; matchIndex: number }
76+
| { type: "zoom"; scale: number };
77+
78+
interface QueueEntry {
79+
commands: PdfCommand[];
80+
/** Timestamp of the most recent enqueue or dequeue */
81+
lastActivity: number;
82+
}
83+
84+
const commandQueues = new Map<string, QueueEntry>();
85+
86+
function pruneStaleQueues(): void {
87+
const now = Date.now();
88+
for (const [uuid, entry] of commandQueues) {
89+
if (now - entry.lastActivity > COMMAND_TTL_MS) {
90+
commandQueues.delete(uuid);
91+
}
92+
}
93+
}
94+
95+
// Periodic sweep so abandoned queues don't leak
96+
setInterval(pruneStaleQueues, SWEEP_INTERVAL_MS).unref();
97+
98+
function enqueueCommand(viewUUID: string, command: PdfCommand): void {
99+
let entry = commandQueues.get(viewUUID);
100+
if (!entry) {
101+
entry = { commands: [], lastActivity: Date.now() };
102+
commandQueues.set(viewUUID, entry);
103+
}
104+
entry.commands.push(command);
105+
entry.lastActivity = Date.now();
106+
}
107+
108+
function dequeueCommands(viewUUID: string): PdfCommand[] {
109+
const entry = commandQueues.get(viewUUID);
110+
if (!entry) return [];
111+
const commands = entry.commands;
112+
commandQueues.delete(viewUUID);
113+
return commands;
114+
}
115+
58116
// =============================================================================
59117
// URL Validation & Normalization
60118
// =============================================================================
@@ -616,21 +674,166 @@ Accepts:
616674

617675
// Probe file size so the client can set up range transport without an extra fetch
618676
const { totalBytes } = await readPdfRange(normalized, 0, 1);
677+
const uuid = randomUUID();
619678

620679
return {
621-
content: [{ type: "text", text: `Displaying PDF: ${normalized}` }],
680+
content: [
681+
{
682+
type: "text",
683+
text: `Displaying PDF (viewUUID: ${uuid}): ${normalized}. Use the interact tool with this viewUUID to navigate, search, zoom, etc.`,
684+
},
685+
],
622686
structuredContent: {
623687
url: normalized,
624688
initialPage: page,
625689
totalBytes,
626690
},
627691
_meta: {
628-
viewUUID: randomUUID(),
692+
viewUUID: uuid,
629693
},
630694
};
631695
},
632696
);
633697

698+
// Tool: interact - Interact with an existing PDF viewer
699+
server.registerTool(
700+
"interact",
701+
{
702+
title: "Interact with PDF",
703+
description: `Send an action to an existing PDF viewer. Actions are queued and batched.
704+
705+
Actions:
706+
- navigate: Go to a page. Requires \`page\`.
707+
- search: Search text and highlight matches in UI. Requires \`query\`. Results (with excerpts, pages, offsets) appear in model context.
708+
- find: Search text silently (no UI change). Requires \`query\`. Results appear in model context only.
709+
- search_navigate: Jump to a search match. Requires \`matchIndex\` (from search/find results).
710+
- zoom: Set zoom level. Requires \`scale\` (0.5–3.0).`,
711+
inputSchema: {
712+
viewUUID: z
713+
.string()
714+
.describe("The viewUUID of the PDF viewer (from display_pdf result)"),
715+
action: z
716+
.enum(["navigate", "search", "find", "search_navigate", "zoom"])
717+
.describe("Action to perform"),
718+
page: z
719+
.number()
720+
.min(1)
721+
.optional()
722+
.describe("Page number (for navigate)"),
723+
query: z
724+
.string()
725+
.optional()
726+
.describe("Search text (for search / find)"),
727+
matchIndex: z
728+
.number()
729+
.min(0)
730+
.optional()
731+
.describe("Match index (for search_navigate)"),
732+
scale: z
733+
.number()
734+
.min(0.5)
735+
.max(3.0)
736+
.optional()
737+
.describe("Zoom scale, 1.0 = 100% (for zoom)"),
738+
},
739+
},
740+
async ({
741+
viewUUID: uuid,
742+
action,
743+
page,
744+
query,
745+
matchIndex,
746+
scale,
747+
}): Promise<CallToolResult> => {
748+
let description: string;
749+
switch (action) {
750+
case "navigate":
751+
if (page == null)
752+
return {
753+
content: [{ type: "text", text: "navigate requires `page`" }],
754+
isError: true,
755+
};
756+
enqueueCommand(uuid, { type: "navigate", page });
757+
description = `navigate to page ${page}`;
758+
break;
759+
case "search":
760+
if (!query)
761+
return {
762+
content: [{ type: "text", text: "search requires `query`" }],
763+
isError: true,
764+
};
765+
enqueueCommand(uuid, { type: "search", query });
766+
description = `search for "${query}"`;
767+
break;
768+
case "find":
769+
if (!query)
770+
return {
771+
content: [{ type: "text", text: "find requires `query`" }],
772+
isError: true,
773+
};
774+
enqueueCommand(uuid, { type: "find", query });
775+
description = `find "${query}" (silent)`;
776+
break;
777+
case "search_navigate":
778+
if (matchIndex == null)
779+
return {
780+
content: [
781+
{
782+
type: "text",
783+
text: "search_navigate requires `matchIndex`",
784+
},
785+
],
786+
isError: true,
787+
};
788+
enqueueCommand(uuid, { type: "search_navigate", matchIndex });
789+
description = `go to match #${matchIndex}`;
790+
break;
791+
case "zoom":
792+
if (scale == null)
793+
return {
794+
content: [{ type: "text", text: "zoom requires `scale`" }],
795+
isError: true,
796+
};
797+
enqueueCommand(uuid, { type: "zoom", scale });
798+
description = `zoom to ${Math.round(scale * 100)}%`;
799+
break;
800+
default:
801+
return {
802+
content: [{ type: "text", text: `Unknown action: ${action}` }],
803+
isError: true,
804+
};
805+
}
806+
return {
807+
content: [{ type: "text", text: `Queued: ${description}` }],
808+
};
809+
},
810+
);
811+
812+
// Tool: poll_pdf_commands (app-only) - Poll for pending commands
813+
registerAppTool(
814+
server,
815+
"poll_pdf_commands",
816+
{
817+
title: "Poll PDF Commands",
818+
description: "Poll for pending commands for a PDF viewer",
819+
inputSchema: {
820+
viewUUID: z.string().describe("The viewUUID of the PDF viewer"),
821+
},
822+
_meta: { ui: { visibility: ["app"] } },
823+
},
824+
async ({ viewUUID: uuid }): Promise<CallToolResult> => {
825+
// If commands are queued, wait a fixed window to let more accumulate
826+
if (commandQueues.has(uuid)) {
827+
await new Promise((r) => setTimeout(r, POLL_BATCH_WAIT_MS));
828+
}
829+
const commands = dequeueCommands(uuid);
830+
return {
831+
content: [{ type: "text", text: `${commands.length} command(s)` }],
832+
structuredContent: { commands },
833+
};
834+
},
835+
);
836+
634837
// Resource: UI HTML
635838
registerAppResource(
636839
server,

0 commit comments

Comments
 (0)