feat(pdf-server): get_viewer_state interact action (#590)
* feat(pdf-server): get_viewer_state interact action
New interact action that returns a JSON snapshot of the live viewer:
{currentPage, pageCount, zoom, displayMode, selectedAnnotationIds,
selection: {text, contextBefore, contextAfter, boundingRect} | null}.
The viewer already pushes selection passively via setModelContext as
<pdf-selection> tags, but not all hosts surface model-context. This gives
the model an explicit pull.
selection.boundingRect is a single bbox in PDF points (top-left origin,
y-down) so it can be fed straight back into add_annotations. selection is
null when nothing is selected or the selection is outside the text-layer.
Wiring: new PdfCommand variant -> processCommands case ->
handleGetViewerState -> submit_viewer_state (new app-only tool, mirrors
submit_save_data) -> waitForViewerState -> text content block.
Also fills a gap in the display_pdf description: it listed interact
actions but was missing save_as; added that and get_viewer_state.
e2e: two tests covering selection:null and a programmatic text-layer
selection.
* test(pdf-server): fix get_viewer_state e2e race + assertions
readLastToolResult clicked .last() before the interact result panel
existed (callInteract doesn't block), so it expanded the display_pdf
panel instead. Wait for the expected panel count first.
Also: basic-host renders the full CallToolResult JSON, with the state
double-escaped inside content[0].text. Parse instead of regex-matching.
playwright.config.ts: honor PW_CHANNEL env to use system Chrome locally
when the bundled chromium_headless_shell is broken.
This commit is contained in:
parent
2e55c842ba
commit
4fc95131bf
3 changed files with 281 additions and 1 deletions
|
|
@ -305,6 +305,33 @@ function waitForSaveData(
|
|||
});
|
||||
}
|
||||
|
||||
const pendingStateRequests = new Map<string, (v: string | Error) => void>();
|
||||
|
||||
/**
|
||||
* Wait for the viewer to report its current state (page, zoom, selection, …)
|
||||
* as a JSON string. Same timeout/abort semantics as waitForSaveData.
|
||||
*/
|
||||
function waitForViewerState(
|
||||
requestId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string> {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const settle = (v: string | Error) => {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
pendingStateRequests.delete(requestId);
|
||||
v instanceof Error ? reject(v) : resolve(v);
|
||||
};
|
||||
const onAbort = () => settle(new Error("interact request cancelled"));
|
||||
const timer = setTimeout(
|
||||
() => settle(new Error("Timeout waiting for viewer state")),
|
||||
GET_PAGES_TIMEOUT_MS,
|
||||
);
|
||||
signal?.addEventListener("abort", onAbort);
|
||||
pendingStateRequests.set(requestId, settle);
|
||||
});
|
||||
}
|
||||
|
||||
interface QueueEntry {
|
||||
commands: PdfCommand[];
|
||||
/** Timestamp of the most recent enqueue or dequeue */
|
||||
|
|
@ -1350,7 +1377,8 @@ Returns a viewUUID in structuredContent. Pass it to \`interact\`:
|
|||
- add_annotations, update_annotations, remove_annotations, highlight_text
|
||||
- fill_form (fill PDF form fields)
|
||||
- navigate, search, find, search_navigate, zoom
|
||||
- get_text, get_screenshot (extract content)
|
||||
- get_text, get_screenshot, get_viewer_state (extract content / read selection & current page)
|
||||
- save_as (write annotated PDF to disk)
|
||||
|
||||
Accepts local files (use list_pdfs), client MCP root directories, or any HTTPS URL.
|
||||
Set \`elicit_form_inputs\` to true to prompt the user to fill form fields before display.`,
|
||||
|
|
@ -1650,6 +1678,7 @@ URL: ${normalized}`,
|
|||
"fill_form",
|
||||
"get_text",
|
||||
"get_screenshot",
|
||||
"get_viewer_state",
|
||||
"save_as",
|
||||
])
|
||||
.describe("Action to perform"),
|
||||
|
|
@ -2238,6 +2267,26 @@ URL: ${normalized}`,
|
|||
);
|
||||
}
|
||||
}
|
||||
case "get_viewer_state": {
|
||||
const requestId = randomUUID();
|
||||
enqueueCommand(uuid, { type: "get_viewer_state", requestId });
|
||||
let state: string;
|
||||
try {
|
||||
await ensureViewerIsPolling(uuid);
|
||||
state = await waitForViewerState(requestId, signal);
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Error: ${err instanceof Error ? err.message : String(err)}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
return { content: [{ type: "text", text: state }] };
|
||||
}
|
||||
default:
|
||||
return {
|
||||
content: [{ type: "text", text: `Unknown action: ${action}` }],
|
||||
|
|
@ -2295,6 +2344,7 @@ Example — add a signature image and a stamp, then screenshot to verify:
|
|||
**TEXT/SCREENSHOTS**:
|
||||
• get_text: extract text from pages. Optional \`page\` for single page, or \`intervals\` for ranges [{start?,end?}]. Max 20 pages.
|
||||
• get_screenshot: capture a single page as PNG image. Requires \`page\`.
|
||||
• get_viewer_state: snapshot of the live viewer — JSON {currentPage, pageCount, zoom, displayMode, selectedAnnotationIds, selection:{text,contextBefore,contextAfter,boundingRect}|null}. Use this to read what the user has selected or which page they're on.
|
||||
|
||||
**FORMS** — fill_form: fill fields with \`fields\` array of {name, value}.
|
||||
|
||||
|
|
@ -2320,6 +2370,7 @@ Example — add a signature image and a stamp, then screenshot to verify:
|
|||
"fill_form",
|
||||
"get_text",
|
||||
"get_screenshot",
|
||||
"get_viewer_state",
|
||||
"save_as",
|
||||
])
|
||||
.optional()
|
||||
|
|
@ -2603,6 +2654,48 @@ Example — add a signature image and a stamp, then screenshot to verify:
|
|||
},
|
||||
);
|
||||
|
||||
// Tool: submit_viewer_state (app-only) - Viewer reports its live state
|
||||
registerAppTool(
|
||||
server,
|
||||
"submit_viewer_state",
|
||||
{
|
||||
title: "Submit Viewer State",
|
||||
description:
|
||||
"Submit a viewer-state snapshot for a get_viewer_state request (used by viewer). The model should NOT call this tool directly.",
|
||||
inputSchema: {
|
||||
requestId: z
|
||||
.string()
|
||||
.describe("The request ID from the get_viewer_state command"),
|
||||
state: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("JSON-encoded viewer state snapshot"),
|
||||
error: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Error message if the viewer failed to read state"),
|
||||
},
|
||||
_meta: { ui: { visibility: ["app"] } },
|
||||
},
|
||||
async ({ requestId, state, error }): Promise<CallToolResult> => {
|
||||
const settle = pendingStateRequests.get(requestId);
|
||||
if (!settle) {
|
||||
return {
|
||||
content: [
|
||||
{ type: "text", text: `No pending request for ${requestId}` },
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
if (error || !state) {
|
||||
settle(new Error(error || "Viewer returned no state"));
|
||||
} else {
|
||||
settle(state);
|
||||
}
|
||||
return { content: [{ type: "text", text: "Submitted" }] };
|
||||
},
|
||||
);
|
||||
|
||||
// Tool: poll_pdf_commands (app-only) - Poll for pending commands
|
||||
registerAppTool(
|
||||
server,
|
||||
|
|
|
|||
|
|
@ -66,4 +66,5 @@ export type PdfCommand =
|
|||
getScreenshots: boolean;
|
||||
}
|
||||
| { type: "save_as"; requestId: string }
|
||||
| { type: "get_viewer_state"; requestId: string }
|
||||
| { type: "file_changed"; mtimeMs: number };
|
||||
|
|
|
|||
|
|
@ -2428,6 +2428,84 @@ async function renderPageOffscreen(pageNum: number): Promise<string> {
|
|||
return dataUrl.split(",")[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot the live viewer for `interact({action:"get_viewer_state"})`.
|
||||
*
|
||||
* Selection is read from `window.getSelection()` at call time — no caching;
|
||||
* if the user navigated away or nothing is selected, `selection` is `null`.
|
||||
* `boundingRect` is in model coords (PDF points, origin top-left, y-down) so
|
||||
* it can be fed straight back into `add_annotations`.
|
||||
*/
|
||||
async function handleGetViewerState(requestId: string): Promise<void> {
|
||||
const CONTEXT_CHARS = 200;
|
||||
|
||||
let selection: {
|
||||
text: string;
|
||||
contextBefore: string;
|
||||
contextAfter: string;
|
||||
boundingRect: { x: number; y: number; width: number; height: number };
|
||||
} | null = null;
|
||||
|
||||
const sel = window.getSelection();
|
||||
const selectedText = sel?.toString().replace(/\s+/g, " ").trim();
|
||||
if (sel && selectedText && sel.rangeCount > 0) {
|
||||
// Only treat it as a PDF selection if it lives inside the text layer of
|
||||
// the rendered page (not the toolbar, search box, etc.).
|
||||
const range = sel.getRangeAt(0);
|
||||
const anchor =
|
||||
range.commonAncestorContainer.nodeType === Node.ELEMENT_NODE
|
||||
? (range.commonAncestorContainer as Element)
|
||||
: range.commonAncestorContainer.parentElement;
|
||||
if (anchor && textLayerEl.contains(anchor)) {
|
||||
// Context: locate selection in the page's extracted text and slice
|
||||
// ±CONTEXT_CHARS around it. Falls back to empty strings if fuzzy
|
||||
// match fails (still return text + rect — they're the load-bearing
|
||||
// bits).
|
||||
const pageText = pageTextCache.get(currentPage) ?? "";
|
||||
const loc = findSelectionInText(pageText, selectedText);
|
||||
const contextBefore = loc
|
||||
? pageText.slice(Math.max(0, loc.start - CONTEXT_CHARS), loc.start)
|
||||
: "";
|
||||
const contextAfter = loc
|
||||
? pageText.slice(loc.end, loc.end + CONTEXT_CHARS)
|
||||
: "";
|
||||
|
||||
// Single bounding box, page-relative model coords. getBoundingClientRect
|
||||
// is viewport-relative; subtract the page-wrapper origin then divide by
|
||||
// scale → PDF points (top-left origin, y-down — matches the coord
|
||||
// system documented in the interact tool description).
|
||||
const r = range.getBoundingClientRect();
|
||||
const origin = pageWrapperEl.getBoundingClientRect();
|
||||
const round = (n: number) => Math.round(n * 100) / 100;
|
||||
selection = {
|
||||
text: selectedText,
|
||||
contextBefore,
|
||||
contextAfter,
|
||||
boundingRect: {
|
||||
x: round((r.left - origin.left) / scale),
|
||||
y: round((r.top - origin.top) / scale),
|
||||
width: round(r.width / scale),
|
||||
height: round(r.height / scale),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const state = {
|
||||
currentPage,
|
||||
pageCount: totalPages,
|
||||
zoom: Math.round(scale * 100),
|
||||
displayMode: currentDisplayMode,
|
||||
selectedAnnotationIds: [...selectedAnnotationIds],
|
||||
selection,
|
||||
};
|
||||
|
||||
await app.callServerTool({
|
||||
name: "submit_viewer_state",
|
||||
arguments: { requestId, state: JSON.stringify(state, null, 2) },
|
||||
});
|
||||
}
|
||||
|
||||
async function handleGetPages(cmd: {
|
||||
requestId: string;
|
||||
intervals: Array<{ start?: number; end?: number }>;
|
||||
|
|
@ -4678,6 +4756,23 @@ async function processCommands(commands: PdfCommand[]): Promise<void> {
|
|||
.catch(() => {});
|
||||
}
|
||||
break;
|
||||
case "get_viewer_state":
|
||||
// Same await-before-next-poll discipline as get_pages/save_as.
|
||||
try {
|
||||
await handleGetViewerState(cmd.requestId);
|
||||
} catch (err) {
|
||||
log.error("get_viewer_state failed — submitting error:", err);
|
||||
await app
|
||||
.callServerTool({
|
||||
name: "submit_viewer_state",
|
||||
arguments: {
|
||||
requestId: cmd.requestId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
break;
|
||||
case "file_changed": {
|
||||
// Skip our own save_pdf echo: either save is still in flight, or the
|
||||
// event's mtime matches what save_pdf just returned.
|
||||
|
|
|
|||
Loading…
Reference in a new issue