Pdf comment agent (#6196)

Co-authored-by: James Brunton <[email protected]>
This commit is contained in:
ConnorYoh
2026-05-01 10:19:38 +01:00
committed by GitHub
co-authored by James Brunton
parent 2dc5276e8b
commit 86774d556e
78 changed files with 5091 additions and 112 deletions
@@ -0,0 +1,47 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon";
import {
SubcategoryId,
ToolCategoryId,
type PrototypeToolRegistry,
} from "@app/data/toolsTaxonomy";
import { pdfCommentAgentOperationConfig } from "@app/hooks/tools/pdfCommentAgent/pdfCommentAgentOperationConfig";
import PdfCommentAgent from "@app/tools/PdfCommentAgent";
import { getSynonyms } from "@app/utils/toolSynonyms";
/**
* Prototype tool registry extension — real implementation.
*
* Overrides the empty stub at {@code core/data/usePrototypeToolRegistry.tsx}
* when the build resolves {@code @app/*} through {@code src/prototypes/*}.
* Experimental AI tools live here until they graduate to core / proprietary.
*/
export function usePrototypeToolRegistry(): PrototypeToolRegistry {
const { t } = useTranslation();
return useMemo(
() =>
({
pdfCommentAgent: {
icon: <LocalIcon icon="add-comment" width="1.5rem" height="1.5rem" />,
name: t("home.pdfCommentAgent.title", "Add AI comments"),
component: PdfCommentAgent,
description: t(
"home.pdfCommentAgent.desc",
"Ask AI to annotate a PDF with sticky-note comments based on your prompt",
),
categoryId: ToolCategoryId.ADVANCED_TOOLS,
subcategoryId: SubcategoryId.DOCUMENT_REVIEW,
maxFiles: 1,
endpoints: ["pdf-comment-agent"],
operationConfig: pdfCommentAgentOperationConfig,
automationSettings: null,
synonyms: getSynonyms(t, "pdfCommentAgent"),
versionStatus: "beta",
},
}) as PrototypeToolRegistry,
[t],
);
}
@@ -0,0 +1,113 @@
import apiClient from "@app/services/apiClient";
import {
ToolType,
CustomToolOperationConfig,
CustomProcessorResult,
} from "@app/hooks/tools/shared/toolOperationTypes";
import {
PdfCommentAgentParameters,
defaultParameters,
} from "@app/hooks/tools/pdfCommentAgent/usePdfCommentAgentParameters";
export const PDF_COMMENT_AGENT_ENDPOINT = "/api/v1/ai/tools/pdf-comment-agent";
/** Build the multipart payload Java expects: fileInput + prompt. */
export const buildPdfCommentAgentFormData = (
parameters: PdfCommentAgentParameters,
file: File,
): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
formData.append("prompt", parameters.prompt);
return formData;
};
/**
* Reject filenames that are blank or contain path separators. The server is
* trusted to supply a sensible value, but guarding here means a hostile or
* buggy backend cannot convince the browser save-dialog to steer the download
* into a parent directory.
*/
const sanitiseFilename = (raw: string): string | null => {
const trimmed = raw.trim();
if (!trimmed) return null;
if (/[\\/]/.test(trimmed)) return null;
return trimmed;
};
/**
* Extract the filename from a Content-Disposition header, falling back to a
* sensible default based on the input file name. Handles both the quoted and
* RFC 5987 (``filename*=UTF-8''encoded``) forms.
*/
const filenameFromContentDisposition = (
header: string | undefined,
inputName: string,
): string => {
const fallback = inputName.replace(/\.pdf$/i, "") + "-commented.pdf";
if (!header) return fallback;
// RFC 5987: filename*=UTF-8''encoded
const extended = /filename\*=[^']*''([^;]+)/i.exec(header);
if (extended?.[1]) {
try {
const decoded = sanitiseFilename(decodeURIComponent(extended[1]));
if (decoded) return decoded;
} catch {
// fall through to plain form
}
}
// Plain: filename="..." or filename=...
const plain = /filename="?([^";]+)"?/i.exec(header);
if (plain?.[1]) {
const sanitised = sanitiseFilename(plain[1]);
if (sanitised) return sanitised;
}
return fallback;
};
/**
* Custom processor: POST the PDF + prompt to the Java AI endpoint, which streams
* the annotated PDF back. Wrap the response Blob as a File so the standard
* review panel (embedPDF viewer) renders it with the sticky-note annotations.
*/
const processPdfCommentAgent = async (
parameters: PdfCommentAgentParameters,
files: File[],
): Promise<CustomProcessorResult> => {
if (files.length === 0) {
return { files: [] };
}
const [inputFile] = files;
const formData = buildPdfCommentAgentFormData(parameters, inputFile);
const response = await apiClient.post<Blob>(
PDF_COMMENT_AGENT_ENDPOINT,
formData,
{ responseType: "blob" },
);
const dispositionHeader =
(response.headers as Record<string, string | undefined>)[
"content-disposition"
] ?? undefined;
const fileName = filenameFromContentDisposition(
dispositionHeader,
inputFile.name,
);
const resultFile = new File([response.data], fileName, {
type: response.data.type || "application/pdf",
});
return { files: [resultFile] };
};
export const pdfCommentAgentOperationConfig = {
toolType: ToolType.custom,
operationType: "pdfCommentAgent",
endpoint: PDF_COMMENT_AGENT_ENDPOINT,
customProcessor: processPdfCommentAgent,
defaultParameters,
} as const satisfies CustomToolOperationConfig<PdfCommentAgentParameters>;
@@ -0,0 +1,15 @@
import { useTranslation } from "react-i18next";
import { useToolOperation } from "@app/hooks/tools/shared/useToolOperation";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import { pdfCommentAgentOperationConfig } from "@app/hooks/tools/pdfCommentAgent/pdfCommentAgentOperationConfig";
export const usePdfCommentAgentOperation = () => {
const { t } = useTranslation();
return useToolOperation({
...pdfCommentAgentOperationConfig,
getErrorMessage: createStandardErrorHandler(
t("pdfCommentAgent.error.failed", "Failed to generate comments"),
),
});
};
@@ -0,0 +1,31 @@
import { BaseParameters } from "@app/types/parameters";
import {
useBaseParameters,
BaseParametersHook,
} from "@app/hooks/tools/shared/useBaseParameters";
export interface PdfCommentAgentParameters extends BaseParameters {
/** Natural-language instructions for what the AI should comment on. */
prompt: string;
}
export const MAX_PROMPT_LENGTH = 4000;
export const defaultParameters: PdfCommentAgentParameters = {
prompt: "",
};
export type PdfCommentAgentParametersHook =
BaseParametersHook<PdfCommentAgentParameters>;
export const usePdfCommentAgentParameters =
(): PdfCommentAgentParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: "pdf-comment-agent",
validateFn: (params) => {
const trimmed = params.prompt.trim();
return trimmed.length > 0 && params.prompt.length <= MAX_PROMPT_LENGTH;
},
});
};
@@ -0,0 +1,100 @@
import type { ChangeEvent } from "react";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Stack, Textarea } from "@mantine/core";
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
import { useBaseTool } from "@app/hooks/tools/shared/useBaseTool";
import type { BaseToolProps } from "@app/types/tool";
import {
MAX_PROMPT_LENGTH,
usePdfCommentAgentParameters,
} from "@app/hooks/tools/pdfCommentAgent/usePdfCommentAgentParameters";
import { usePdfCommentAgentOperation } from "@app/hooks/tools/pdfCommentAgent/usePdfCommentAgentOperation";
const PdfCommentAgent = (props: BaseToolProps) => {
const { t } = useTranslation();
const base = useBaseTool(
"pdf-comment-agent",
usePdfCommentAgentParameters,
usePdfCommentAgentOperation,
props,
);
const handlePromptChange = useCallback(
(event: ChangeEvent<HTMLTextAreaElement>) => {
base.params.updateParameter("prompt", event.currentTarget.value);
},
[base.params],
);
// Inline validation error shown under the Textarea. Only rendered once the
// user has typed something (or over-typed) — we don't want to yell about an
// empty field that the user hasn't interacted with yet.
const prompt = base.params.parameters.prompt;
const trimmedLength = prompt.trim().length;
let promptError: string | null = null;
if (prompt.length > MAX_PROMPT_LENGTH) {
promptError = t("pdfCommentAgent.error.tooLong", {
max: MAX_PROMPT_LENGTH,
defaultValue: `Prompt is too long (maximum ${MAX_PROMPT_LENGTH} characters)`,
});
} else if (prompt.length > 0 && trimmedLength === 0) {
// User typed only whitespace — treat as empty with the empty-prompt message.
promptError = t(
"pdfCommentAgent.error.emptyPrompt",
"Please describe what the AI should comment on",
);
}
return createToolFlow({
files: {
selectedFiles: base.selectedFiles,
isCollapsed: base.hasResults,
},
steps: [
{
title: t("pdfCommentAgent.settings.title", "Comment instructions"),
isCollapsed: false,
content: (
<Stack gap="sm">
<Textarea
label={t(
"pdfCommentAgent.prompt.label",
"What should the AI comment on?",
)}
placeholder={t(
"pdfCommentAgent.prompt.placeholder",
"e.g. Flag any ambiguous dates and suggest clarifications",
)}
value={prompt}
onChange={handlePromptChange}
minRows={4}
autosize
maxLength={MAX_PROMPT_LENGTH}
error={promptError}
/>
</Stack>
),
},
],
executeButton: {
text: t("pdfCommentAgent.submit", "Generate comments"),
isVisible: !base.hasResults,
loadingText: t("loading"),
onClick: base.handleExecute,
endpointEnabled: base.endpointEnabled,
paramsValid: base.params.validateParameters(),
},
review: {
isVisible: base.hasResults,
operation: base.operation,
title: t("pdfCommentAgent.results.title", "Commented PDF"),
onFileClick: base.handleThumbnailClick,
onUndo: base.handleUndo,
},
});
};
export default PdfCommentAgent;
@@ -0,0 +1,31 @@
/**
* Prototype tool ID definitions — real implementation.
*
* Overrides the empty stub at {@code core/types/prototypeToolId.ts} when
* built in prototypes mode. Tools listed here are only reachable from the
* prototypes build (and any build whose {@code @app/*} alias chain reaches
* {@code src/prototypes/*}) — they are invisible to the core / proprietary
* / saas / desktop bundles.
*
* Add an id here when the accompanying tool file lives under
* {@code frontend/src/prototypes/tools/...} and you want it surfaced in the
* prototypes build's tool picker.
*/
export const PROTOTYPE_REGULAR_TOOL_IDS = ["pdfCommentAgent"] as const;
// "ai-workflow" is a generic marker stamped onto files produced by the AI
// orchestrator (which may invoke one or more underlying tools). Lives here as
// a super-tool so ``ToolOperation.toolId`` stays typed; not user-pickable —
// see ChatContext.tsx. The prototype tool registry doesn't include it as an
// entry, which is fine since the registry uses an ``as`` cast.
export const PROTOTYPE_SUPER_TOOL_IDS = ["ai-workflow"] as const;
export const PROTOTYPE_LINK_TOOL_IDS = [] as const;
export type PrototypeRegularToolId =
(typeof PROTOTYPE_REGULAR_TOOL_IDS)[number];
export type PrototypeSuperToolId = (typeof PROTOTYPE_SUPER_TOOL_IDS)[number];
export type PrototypeLinkToolId = (typeof PROTOTYPE_LINK_TOOL_IDS)[number];
export type PrototypeToolId =
| PrototypeRegularToolId
| PrototypeSuperToolId
| PrototypeLinkToolId;