Add "Print PDF" Block (#4452)
This commit is contained in:
@@ -14,6 +14,7 @@ export const ArtifactType = {
|
|||||||
HTMLScrape: "html_scrape",
|
HTMLScrape: "html_scrape",
|
||||||
SkyvernLog: "skyvern_log",
|
SkyvernLog: "skyvern_log",
|
||||||
SkyvernLogRaw: "skyvern_log_raw",
|
SkyvernLogRaw: "skyvern_log_raw",
|
||||||
|
PDF: "pdf",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type ArtifactType = (typeof ArtifactType)[keyof typeof ArtifactType];
|
export type ArtifactType = (typeof ArtifactType)[keyof typeof ArtifactType];
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { HelpTooltip } from "@/components/HelpTooltip";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Handle, NodeProps, Position } from "@xyflow/react";
|
||||||
|
import type { PrintPageNode } from "./types";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionContent,
|
||||||
|
AccordionItem,
|
||||||
|
AccordionTrigger,
|
||||||
|
} from "@/components/ui/accordion";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { cn } from "@/util/utils";
|
||||||
|
import { NodeHeader } from "../components/NodeHeader";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import { statusIsRunningOrQueued } from "@/routes/tasks/types";
|
||||||
|
import { useWorkflowRunQuery } from "@/routes/workflows/hooks/useWorkflowRunQuery";
|
||||||
|
import { useUpdate } from "@/routes/workflows/editor/useUpdate";
|
||||||
|
|
||||||
|
function PrintPageNode({ id, data }: NodeProps<PrintPageNode>) {
|
||||||
|
const { editable, label } = data;
|
||||||
|
const { blockLabel: urlBlockLabel } = useParams();
|
||||||
|
const { data: workflowRun } = useWorkflowRunQuery();
|
||||||
|
const workflowRunIsRunningOrQueued =
|
||||||
|
workflowRun && statusIsRunningOrQueued(workflowRun);
|
||||||
|
const thisBlockIsTargetted =
|
||||||
|
urlBlockLabel !== undefined && urlBlockLabel === label;
|
||||||
|
const thisBlockIsPlaying =
|
||||||
|
workflowRunIsRunningOrQueued && thisBlockIsTargetted;
|
||||||
|
|
||||||
|
const update = useUpdate<PrintPageNode["data"]>({ id, editable });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={Position.Bottom}
|
||||||
|
id="a"
|
||||||
|
className="opacity-0"
|
||||||
|
/>
|
||||||
|
<Handle
|
||||||
|
type="target"
|
||||||
|
position={Position.Top}
|
||||||
|
id="b"
|
||||||
|
className="opacity-0"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"w-[30rem] space-y-4 rounded-lg bg-slate-elevation3 px-6 py-4 transition-all",
|
||||||
|
{
|
||||||
|
"pointer-events-none": thisBlockIsPlaying,
|
||||||
|
"bg-slate-950 outline outline-2 outline-slate-300":
|
||||||
|
thisBlockIsTargetted,
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<NodeHeader
|
||||||
|
blockLabel={label}
|
||||||
|
editable={editable}
|
||||||
|
nodeId={id}
|
||||||
|
totpIdentifier={null}
|
||||||
|
totpUrl={null}
|
||||||
|
type="print_page"
|
||||||
|
/>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs text-slate-300">Page Format</Label>
|
||||||
|
<Select
|
||||||
|
value={data.format}
|
||||||
|
onValueChange={(value) => update({ format: value })}
|
||||||
|
disabled={!editable}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="nopan w-36 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="A4">A4</SelectItem>
|
||||||
|
<SelectItem value="Letter">Letter</SelectItem>
|
||||||
|
<SelectItem value="Legal">Legal</SelectItem>
|
||||||
|
<SelectItem value="Tabloid">Tabloid</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Label className="text-xs text-slate-300">Print Background</Label>
|
||||||
|
<HelpTooltip content="Include CSS background colors and images in the PDF" />
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={data.printBackground}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
update({ printBackground: checked })
|
||||||
|
}
|
||||||
|
disabled={!editable}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Label className="text-xs font-normal text-slate-300">
|
||||||
|
Headers & Footers
|
||||||
|
</Label>
|
||||||
|
<HelpTooltip content="Adds date, title, URL, and page numbers to the PDF" />
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={data.includeTimestamp}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
update({ includeTimestamp: checked })
|
||||||
|
}
|
||||||
|
disabled={!editable}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<Accordion type="single" collapsible>
|
||||||
|
<AccordionItem value="advanced" className="border-b-0">
|
||||||
|
<AccordionTrigger className="py-0">
|
||||||
|
Advanced Settings
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="pl-6 pr-1 pt-1">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs text-slate-300">
|
||||||
|
Custom Filename
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={data.customFilename}
|
||||||
|
onChange={(e) => update({ customFilename: e.target.value })}
|
||||||
|
placeholder="my_report"
|
||||||
|
disabled={!editable}
|
||||||
|
className="nopan text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs font-normal text-slate-300">
|
||||||
|
Landscape
|
||||||
|
</Label>
|
||||||
|
<Switch
|
||||||
|
checked={data.landscape}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
update({ landscape: checked })
|
||||||
|
}
|
||||||
|
disabled={!editable}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { PrintPageNode };
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type { Node } from "@xyflow/react";
|
||||||
|
import { debuggableWorkflowBlockTypes } from "@/routes/workflows/types/workflowTypes";
|
||||||
|
import { NodeBaseData } from "../types";
|
||||||
|
|
||||||
|
export type PrintPageNodeData = NodeBaseData & {
|
||||||
|
includeTimestamp: boolean;
|
||||||
|
customFilename: string;
|
||||||
|
format: string;
|
||||||
|
landscape: boolean;
|
||||||
|
printBackground: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PrintPageNode = Node<PrintPageNodeData, "printPage">;
|
||||||
|
|
||||||
|
export const printPageNodeDefaultData: PrintPageNodeData = {
|
||||||
|
debuggable: debuggableWorkflowBlockTypes.has("print_page"),
|
||||||
|
label: "",
|
||||||
|
continueOnFailure: false,
|
||||||
|
editable: true,
|
||||||
|
model: null,
|
||||||
|
includeTimestamp: true,
|
||||||
|
customFilename: "",
|
||||||
|
format: "A4",
|
||||||
|
landscape: false,
|
||||||
|
printBackground: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isPrintPageNode(node: Node): node is PrintPageNode {
|
||||||
|
return node.type === "printPage";
|
||||||
|
}
|
||||||
@@ -91,6 +91,9 @@ function WorkflowBlockIcon({ workflowBlockType, className }: Props) {
|
|||||||
case "http_request": {
|
case "http_request": {
|
||||||
return <GlobeIcon className={className} />;
|
return <GlobeIcon className={className} />;
|
||||||
}
|
}
|
||||||
|
case "print_page": {
|
||||||
|
return <FileTextIcon className={className} />;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ import { HttpRequestNode } from "./HttpRequestNode/types";
|
|||||||
import { HttpRequestNode as HttpRequestNodeComponent } from "./HttpRequestNode/HttpRequestNode";
|
import { HttpRequestNode as HttpRequestNodeComponent } from "./HttpRequestNode/HttpRequestNode";
|
||||||
import { HumanInteractionNode } from "./HumanInteractionNode/types";
|
import { HumanInteractionNode } from "./HumanInteractionNode/types";
|
||||||
import { HumanInteractionNode as HumanInteractionNodeComponent } from "./HumanInteractionNode/HumanInteractionNode";
|
import { HumanInteractionNode as HumanInteractionNodeComponent } from "./HumanInteractionNode/HumanInteractionNode";
|
||||||
|
import { PrintPageNode } from "./PrintPageNode/types";
|
||||||
|
import { PrintPageNode as PrintPageNodeComponent } from "./PrintPageNode/PrintPageNode";
|
||||||
|
|
||||||
export type UtilityNode = StartNode | NodeAdderNode;
|
export type UtilityNode = StartNode | NodeAdderNode;
|
||||||
|
|
||||||
@@ -72,7 +74,8 @@ export type WorkflowBlockNode =
|
|||||||
| PDFParserNode
|
| PDFParserNode
|
||||||
| Taskv2Node
|
| Taskv2Node
|
||||||
| URLNode
|
| URLNode
|
||||||
| HttpRequestNode;
|
| HttpRequestNode
|
||||||
|
| PrintPageNode;
|
||||||
|
|
||||||
export function isUtilityNode(node: AppNode): node is UtilityNode {
|
export function isUtilityNode(node: AppNode): node is UtilityNode {
|
||||||
return node.type === "nodeAdder" || node.type === "start";
|
return node.type === "nodeAdder" || node.type === "start";
|
||||||
@@ -109,4 +112,5 @@ export const nodeTypes = {
|
|||||||
taskv2: memo(Taskv2NodeComponent),
|
taskv2: memo(Taskv2NodeComponent),
|
||||||
url: memo(URLNodeComponent),
|
url: memo(URLNodeComponent),
|
||||||
http_request: memo(HttpRequestNodeComponent),
|
http_request: memo(HttpRequestNodeComponent),
|
||||||
|
printPage: memo(PrintPageNodeComponent),
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -66,4 +66,5 @@ export const workflowBlockTitle: {
|
|||||||
task_v2: "Browser Task v2",
|
task_v2: "Browser Task v2",
|
||||||
goto_url: "Go to URL",
|
goto_url: "Go to URL",
|
||||||
http_request: "HTTP Request",
|
http_request: "HTTP Request",
|
||||||
|
print_page: "Print Page",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -266,6 +266,17 @@ const nodeLibraryItems: Array<{
|
|||||||
title: "HTTP Request Block",
|
title: "HTTP Request Block",
|
||||||
description: "Make HTTP API calls",
|
description: "Make HTTP API calls",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
nodeType: "printPage",
|
||||||
|
icon: (
|
||||||
|
<WorkflowBlockIcon
|
||||||
|
workflowBlockType={WorkflowBlockTypes.PrintPage}
|
||||||
|
className="size-6"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
title: "Print Page Block",
|
||||||
|
description: "Print current page to PDF",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import {
|
|||||||
URLBlockYAML,
|
URLBlockYAML,
|
||||||
FileUploadBlockYAML,
|
FileUploadBlockYAML,
|
||||||
HttpRequestBlockYAML,
|
HttpRequestBlockYAML,
|
||||||
|
PrintPageBlockYAML,
|
||||||
} from "../types/workflowYamlTypes";
|
} from "../types/workflowYamlTypes";
|
||||||
import {
|
import {
|
||||||
EMAIL_BLOCK_SENDER,
|
EMAIL_BLOCK_SENDER,
|
||||||
@@ -122,6 +123,7 @@ import { taskv2NodeDefaultData } from "./nodes/Taskv2Node/types";
|
|||||||
import { urlNodeDefaultData } from "./nodes/URLNode/types";
|
import { urlNodeDefaultData } from "./nodes/URLNode/types";
|
||||||
import { fileUploadNodeDefaultData } from "./nodes/FileUploadNode/types";
|
import { fileUploadNodeDefaultData } from "./nodes/FileUploadNode/types";
|
||||||
import { httpRequestNodeDefaultData } from "./nodes/HttpRequestNode/types";
|
import { httpRequestNodeDefaultData } from "./nodes/HttpRequestNode/types";
|
||||||
|
import { printPageNodeDefaultData } from "./nodes/PrintPageNode/types";
|
||||||
|
|
||||||
export const NEW_NODE_LABEL_PREFIX = "block_";
|
export const NEW_NODE_LABEL_PREFIX = "block_";
|
||||||
|
|
||||||
@@ -839,6 +841,21 @@ function convertToNode(
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
case "print_page": {
|
||||||
|
return {
|
||||||
|
...identifiers,
|
||||||
|
...common,
|
||||||
|
type: "printPage",
|
||||||
|
data: {
|
||||||
|
...commonData,
|
||||||
|
includeTimestamp: block.include_timestamp ?? false,
|
||||||
|
customFilename: block.custom_filename ?? "",
|
||||||
|
format: block.format ?? "A4",
|
||||||
|
landscape: block.landscape ?? false,
|
||||||
|
printBackground: block.print_background ?? true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1877,6 +1894,17 @@ function createNode(
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
case "printPage": {
|
||||||
|
return {
|
||||||
|
...identifiers,
|
||||||
|
...common,
|
||||||
|
type: "printPage",
|
||||||
|
data: {
|
||||||
|
...printPageNodeDefaultData,
|
||||||
|
label,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
case "conditional": {
|
case "conditional": {
|
||||||
const branches = createDefaultBranchConditions();
|
const branches = createDefaultBranchConditions();
|
||||||
return {
|
return {
|
||||||
@@ -2332,6 +2360,17 @@ function getWorkflowBlock(
|
|||||||
save_response_as_file: node.data.saveResponseAsFile,
|
save_response_as_file: node.data.saveResponseAsFile,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
case "printPage": {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
block_type: "print_page",
|
||||||
|
include_timestamp: node.data.includeTimestamp,
|
||||||
|
custom_filename: node.data.customFilename || null,
|
||||||
|
format: node.data.format,
|
||||||
|
landscape: node.data.landscape,
|
||||||
|
print_background: node.data.printBackground,
|
||||||
|
};
|
||||||
|
}
|
||||||
case "conditional": {
|
case "conditional": {
|
||||||
return serializeConditionalBlock(node as ConditionalNode, nodes, edges);
|
return serializeConditionalBlock(node as ConditionalNode, nodes, edges);
|
||||||
}
|
}
|
||||||
@@ -3338,6 +3377,18 @@ function convertBlocksToBlockYAML(
|
|||||||
};
|
};
|
||||||
return blockYaml;
|
return blockYaml;
|
||||||
}
|
}
|
||||||
|
case "print_page": {
|
||||||
|
const blockYaml: PrintPageBlockYAML = {
|
||||||
|
...base,
|
||||||
|
block_type: "print_page",
|
||||||
|
include_timestamp: block.include_timestamp,
|
||||||
|
custom_filename: block.custom_filename,
|
||||||
|
format: block.format,
|
||||||
|
landscape: block.landscape,
|
||||||
|
print_background: block.print_background,
|
||||||
|
};
|
||||||
|
return blockYaml;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -211,7 +211,8 @@ export type WorkflowBlock =
|
|||||||
| PDFParserBlock
|
| PDFParserBlock
|
||||||
| Taskv2Block
|
| Taskv2Block
|
||||||
| URLBlock
|
| URLBlock
|
||||||
| HttpRequestBlock;
|
| HttpRequestBlock
|
||||||
|
| PrintPageBlock;
|
||||||
|
|
||||||
export const WorkflowBlockTypes = {
|
export const WorkflowBlockTypes = {
|
||||||
Task: "task",
|
Task: "task",
|
||||||
@@ -236,6 +237,7 @@ export const WorkflowBlockTypes = {
|
|||||||
Taskv2: "task_v2",
|
Taskv2: "task_v2",
|
||||||
URL: "goto_url",
|
URL: "goto_url",
|
||||||
HttpRequest: "http_request",
|
HttpRequest: "http_request",
|
||||||
|
PrintPage: "print_page",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// all of them
|
// all of them
|
||||||
@@ -554,6 +556,15 @@ export type HttpRequestBlock = WorkflowBlockBase & {
|
|||||||
save_response_as_file: boolean;
|
save_response_as_file: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PrintPageBlock = WorkflowBlockBase & {
|
||||||
|
block_type: "print_page";
|
||||||
|
include_timestamp: boolean;
|
||||||
|
custom_filename: string | null;
|
||||||
|
format: string;
|
||||||
|
landscape: boolean;
|
||||||
|
print_background: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type WorkflowDefinition = {
|
export type WorkflowDefinition = {
|
||||||
version?: number | null;
|
version?: number | null;
|
||||||
parameters: Array<Parameter>;
|
parameters: Array<Parameter>;
|
||||||
|
|||||||
@@ -141,7 +141,8 @@ export type BlockYAML =
|
|||||||
| PDFParserBlockYAML
|
| PDFParserBlockYAML
|
||||||
| Taskv2BlockYAML
|
| Taskv2BlockYAML
|
||||||
| URLBlockYAML
|
| URLBlockYAML
|
||||||
| HttpRequestBlockYAML;
|
| HttpRequestBlockYAML
|
||||||
|
| PrintPageBlockYAML;
|
||||||
|
|
||||||
export type BlockYAMLBase = {
|
export type BlockYAMLBase = {
|
||||||
block_type: WorkflowBlockType;
|
block_type: WorkflowBlockType;
|
||||||
@@ -404,3 +405,12 @@ export type HttpRequestBlockYAML = BlockYAMLBase & {
|
|||||||
download_filename?: string | null;
|
download_filename?: string | null;
|
||||||
save_response_as_file?: boolean;
|
save_response_as_file?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PrintPageBlockYAML = BlockYAMLBase & {
|
||||||
|
block_type: "print_page";
|
||||||
|
include_timestamp: boolean;
|
||||||
|
custom_filename: string | null;
|
||||||
|
format: string;
|
||||||
|
landscape: boolean;
|
||||||
|
print_background: boolean;
|
||||||
|
};
|
||||||
|
|||||||
@@ -279,13 +279,13 @@ class ArtifactManager:
|
|||||||
path=path,
|
path=path,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def create_workflow_run_block_artifact(
|
async def _create_workflow_run_block_artifact_internal(
|
||||||
self,
|
self,
|
||||||
workflow_run_block: WorkflowRunBlock,
|
workflow_run_block: WorkflowRunBlock,
|
||||||
artifact_type: ArtifactType,
|
artifact_type: ArtifactType,
|
||||||
data: bytes | None = None,
|
data: bytes | None = None,
|
||||||
path: str | None = None,
|
path: str | None = None,
|
||||||
) -> str:
|
) -> tuple[str, str]:
|
||||||
artifact_id = generate_artifact_id()
|
artifact_id = generate_artifact_id()
|
||||||
uri = app.STORAGE.build_workflow_run_block_uri(
|
uri = app.STORAGE.build_workflow_run_block_uri(
|
||||||
organization_id=workflow_run_block.organization_id,
|
organization_id=workflow_run_block.organization_id,
|
||||||
@@ -293,7 +293,7 @@ class ArtifactManager:
|
|||||||
workflow_run_block=workflow_run_block,
|
workflow_run_block=workflow_run_block,
|
||||||
artifact_type=artifact_type,
|
artifact_type=artifact_type,
|
||||||
)
|
)
|
||||||
return await self._create_artifact(
|
await self._create_artifact(
|
||||||
aio_task_primary_key=workflow_run_block.workflow_run_block_id,
|
aio_task_primary_key=workflow_run_block.workflow_run_block_id,
|
||||||
artifact_id=artifact_id,
|
artifact_id=artifact_id,
|
||||||
artifact_type=artifact_type,
|
artifact_type=artifact_type,
|
||||||
@@ -304,6 +304,36 @@ class ArtifactManager:
|
|||||||
data=data,
|
data=data,
|
||||||
path=path,
|
path=path,
|
||||||
)
|
)
|
||||||
|
return artifact_id, uri
|
||||||
|
|
||||||
|
async def create_workflow_run_block_artifact(
|
||||||
|
self,
|
||||||
|
workflow_run_block: WorkflowRunBlock,
|
||||||
|
artifact_type: ArtifactType,
|
||||||
|
data: bytes | None = None,
|
||||||
|
path: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
artifact_id, _ = await self._create_workflow_run_block_artifact_internal(
|
||||||
|
workflow_run_block=workflow_run_block,
|
||||||
|
artifact_type=artifact_type,
|
||||||
|
data=data,
|
||||||
|
path=path,
|
||||||
|
)
|
||||||
|
return artifact_id
|
||||||
|
|
||||||
|
async def create_workflow_run_block_artifact_with_uri(
|
||||||
|
self,
|
||||||
|
workflow_run_block: WorkflowRunBlock,
|
||||||
|
artifact_type: ArtifactType,
|
||||||
|
data: bytes | None = None,
|
||||||
|
path: str | None = None,
|
||||||
|
) -> tuple[str, str]:
|
||||||
|
return await self._create_workflow_run_block_artifact_internal(
|
||||||
|
workflow_run_block=workflow_run_block,
|
||||||
|
artifact_type=artifact_type,
|
||||||
|
data=data,
|
||||||
|
path=path,
|
||||||
|
)
|
||||||
|
|
||||||
async def create_workflow_run_block_artifacts(
|
async def create_workflow_run_block_artifacts(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -52,6 +52,9 @@ class ArtifactType(StrEnum):
|
|||||||
# Script files
|
# Script files
|
||||||
SCRIPT_FILE = "script_file"
|
SCRIPT_FILE = "script_file"
|
||||||
|
|
||||||
|
# PDF files
|
||||||
|
PDF = "pdf"
|
||||||
|
|
||||||
|
|
||||||
class Artifact(BaseModel):
|
class Artifact(BaseModel):
|
||||||
created_at: datetime = Field(
|
created_at: datetime = Field(
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ FILE_EXTENTSION_MAP: dict[ArtifactType, str] = {
|
|||||||
ArtifactType.HASHED_HREF_MAP: "json",
|
ArtifactType.HASHED_HREF_MAP: "json",
|
||||||
# DEPRECATED: we're using CSS selector map now
|
# DEPRECATED: we're using CSS selector map now
|
||||||
ArtifactType.VISIBLE_ELEMENTS_ID_XPATH_MAP: "json",
|
ArtifactType.VISIBLE_ELEMENTS_ID_XPATH_MAP: "json",
|
||||||
|
ArtifactType.PDF: "pdf",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -11,13 +11,14 @@ import smtplib
|
|||||||
import textwrap
|
import textwrap
|
||||||
import uuid
|
import uuid
|
||||||
from collections import defaultdict, deque
|
from collections import defaultdict, deque
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from email.message import EmailMessage
|
from email.message import EmailMessage
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from typing import Annotated, Any, Awaitable, Callable, ClassVar, Literal, Union, cast
|
from typing import Annotated, Any, Awaitable, Callable, ClassVar, Literal, Union, cast
|
||||||
from urllib.parse import quote, urlparse
|
from urllib.parse import quote, urlparse
|
||||||
|
|
||||||
|
import aiofiles
|
||||||
import aiohttp
|
import aiohttp
|
||||||
import filetype
|
import filetype
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
@@ -66,6 +67,7 @@ from skyvern.forge.sdk.artifact.models import ArtifactType
|
|||||||
from skyvern.forge.sdk.core import skyvern_context
|
from skyvern.forge.sdk.core import skyvern_context
|
||||||
from skyvern.forge.sdk.core.aiohttp_helper import aiohttp_request
|
from skyvern.forge.sdk.core.aiohttp_helper import aiohttp_request
|
||||||
from skyvern.forge.sdk.db.enums import TaskType
|
from skyvern.forge.sdk.db.enums import TaskType
|
||||||
|
from skyvern.forge.sdk.db.exceptions import NotFoundError
|
||||||
from skyvern.forge.sdk.experimentation.llm_prompt_config import get_llm_handler_for_prompt_type
|
from skyvern.forge.sdk.experimentation.llm_prompt_config import get_llm_handler_for_prompt_type
|
||||||
from skyvern.forge.sdk.schemas.files import FileInfo
|
from skyvern.forge.sdk.schemas.files import FileInfo
|
||||||
from skyvern.forge.sdk.schemas.task_v2 import TaskV2Status
|
from skyvern.forge.sdk.schemas.task_v2 import TaskV2Status
|
||||||
@@ -4474,6 +4476,189 @@ class HttpRequestBlock(Block):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PrintPageBlock(Block):
|
||||||
|
block_type: Literal[BlockType.PRINT_PAGE] = BlockType.PRINT_PAGE # type: ignore
|
||||||
|
|
||||||
|
include_timestamp: bool = True
|
||||||
|
custom_filename: str | None = None
|
||||||
|
format: str = "A4"
|
||||||
|
landscape: bool = False
|
||||||
|
print_background: bool = True
|
||||||
|
|
||||||
|
VALID_FORMATS: ClassVar[set[str]] = {"A4", "Letter", "Legal", "Tabloid"}
|
||||||
|
|
||||||
|
def get_all_parameters(self, workflow_run_id: str) -> list[PARAMETER_TYPE]:
|
||||||
|
return []
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _sanitize_filename(filename: str) -> str:
|
||||||
|
sanitized = re.sub(r'[<>:"/\\|?*]', "_", filename)
|
||||||
|
sanitized = sanitized.strip(". ")
|
||||||
|
return sanitized[:200] if sanitized else "document"
|
||||||
|
|
||||||
|
def _build_pdf_options(self) -> dict[str, Any]:
|
||||||
|
pdf_format = self.format if self.format in self.VALID_FORMATS else "A4"
|
||||||
|
pdf_options: dict[str, Any] = {
|
||||||
|
"format": pdf_format,
|
||||||
|
"landscape": self.landscape,
|
||||||
|
"print_background": self.print_background,
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.include_timestamp:
|
||||||
|
pdf_options["display_header_footer"] = True
|
||||||
|
pdf_options["header_template"] = (
|
||||||
|
'<div style="font-size:10px;width:100%;display:flex;justify-content:space-between;padding:0 10px;">'
|
||||||
|
'<span class="date"></span><span class="title"></span><span></span></div>'
|
||||||
|
)
|
||||||
|
pdf_options["footer_template"] = (
|
||||||
|
'<div style="font-size:10px;width:100%;display:flex;justify-content:space-between;padding:0 10px;">'
|
||||||
|
'<span class="url"></span><span></span><span><span class="pageNumber"></span>/<span class="totalPages"></span></span></div>'
|
||||||
|
)
|
||||||
|
pdf_options["margin"] = {"top": "40px", "bottom": "40px"}
|
||||||
|
|
||||||
|
return pdf_options
|
||||||
|
|
||||||
|
async def _upload_pdf_artifact(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
pdf_bytes: bytes,
|
||||||
|
workflow_run_id: str,
|
||||||
|
workflow_run_block_id: str,
|
||||||
|
workflow_run_context: WorkflowRunContext,
|
||||||
|
organization_id: str | None,
|
||||||
|
) -> str | None:
|
||||||
|
artifact_org_id = organization_id or workflow_run_context.organization_id
|
||||||
|
if not artifact_org_id:
|
||||||
|
LOG.warning(
|
||||||
|
"PrintPageBlock: Missing organization_id, skipping artifact upload",
|
||||||
|
workflow_run_id=workflow_run_id,
|
||||||
|
workflow_run_block_id=workflow_run_block_id,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
workflow_run_block = await app.DATABASE.get_workflow_run_block(
|
||||||
|
workflow_run_block_id,
|
||||||
|
organization_id=artifact_org_id,
|
||||||
|
)
|
||||||
|
except NotFoundError:
|
||||||
|
LOG.warning(
|
||||||
|
"PrintPageBlock: Workflow run block not found, skipping artifact upload",
|
||||||
|
workflow_run_id=workflow_run_id,
|
||||||
|
workflow_run_block_id=workflow_run_block_id,
|
||||||
|
organization_id=artifact_org_id,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
_, artifact_uri = await app.ARTIFACT_MANAGER.create_workflow_run_block_artifact_with_uri(
|
||||||
|
workflow_run_block=workflow_run_block,
|
||||||
|
artifact_type=ArtifactType.PDF,
|
||||||
|
data=pdf_bytes,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await app.ARTIFACT_MANAGER.wait_for_upload_aiotasks([workflow_run_block.workflow_run_block_id])
|
||||||
|
except Exception:
|
||||||
|
LOG.warning(
|
||||||
|
"PrintPageBlock: Failed to upload PDF artifact",
|
||||||
|
workflow_run_id=workflow_run_id,
|
||||||
|
workflow_run_block_id=workflow_run_block.workflow_run_block_id,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
return artifact_uri
|
||||||
|
|
||||||
|
async def execute(
|
||||||
|
self,
|
||||||
|
workflow_run_id: str,
|
||||||
|
workflow_run_block_id: str,
|
||||||
|
organization_id: str | None = None,
|
||||||
|
browser_session_id: str | None = None,
|
||||||
|
**kwargs: dict,
|
||||||
|
) -> BlockResult:
|
||||||
|
workflow_run_context = self.get_workflow_run_context(workflow_run_id)
|
||||||
|
browser_state = app.BROWSER_MANAGER.get_for_workflow_run(workflow_run_id)
|
||||||
|
|
||||||
|
if not browser_state:
|
||||||
|
return await self.build_block_result(
|
||||||
|
success=False,
|
||||||
|
failure_reason="No browser state available",
|
||||||
|
status=BlockStatus.failed,
|
||||||
|
workflow_run_block_id=workflow_run_block_id,
|
||||||
|
organization_id=organization_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
page = await browser_state.get_working_page()
|
||||||
|
if not page:
|
||||||
|
return await self.build_block_result(
|
||||||
|
success=False,
|
||||||
|
failure_reason="No page available",
|
||||||
|
status=BlockStatus.failed,
|
||||||
|
workflow_run_block_id=workflow_run_block_id,
|
||||||
|
organization_id=organization_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
pdf_options = self._build_pdf_options()
|
||||||
|
|
||||||
|
try:
|
||||||
|
pdf_bytes = await page.pdf(**pdf_options)
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = str(e)
|
||||||
|
if "pdf" in error_msg.lower() and ("not supported" in error_msg.lower() or "chromium" in error_msg.lower()):
|
||||||
|
error_msg = "PDF generation requires Chromium browser. Current browser does not support page.pdf()."
|
||||||
|
LOG.warning("PrintPageBlock: Failed to generate PDF", error=error_msg, workflow_run_id=workflow_run_id)
|
||||||
|
return await self.build_block_result(
|
||||||
|
success=False,
|
||||||
|
failure_reason=f"Failed to generate PDF: {error_msg}",
|
||||||
|
status=BlockStatus.failed,
|
||||||
|
workflow_run_block_id=workflow_run_block_id,
|
||||||
|
organization_id=organization_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
timestamp_str = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
|
||||||
|
if self.custom_filename:
|
||||||
|
filename = self.format_block_parameter_template_from_workflow_run_context(
|
||||||
|
self.custom_filename, workflow_run_context
|
||||||
|
)
|
||||||
|
filename = self._sanitize_filename(filename)
|
||||||
|
if not filename.endswith(".pdf"):
|
||||||
|
filename += ".pdf"
|
||||||
|
else:
|
||||||
|
filename = f"page_{timestamp_str}.pdf"
|
||||||
|
|
||||||
|
# Save PDF to download directory so it appears in runs UI
|
||||||
|
download_dir = get_download_dir(workflow_run_id)
|
||||||
|
file_path = os.path.join(download_dir, filename)
|
||||||
|
async with aiofiles.open(file_path, "wb") as f:
|
||||||
|
await f.write(pdf_bytes)
|
||||||
|
|
||||||
|
# Upload to artifact storage for downstream block access (e.g., File Extraction Block)
|
||||||
|
artifact_uri = await self._upload_pdf_artifact(
|
||||||
|
pdf_bytes=pdf_bytes,
|
||||||
|
workflow_run_id=workflow_run_id,
|
||||||
|
workflow_run_block_id=workflow_run_block_id,
|
||||||
|
workflow_run_context=workflow_run_context,
|
||||||
|
organization_id=organization_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
output = {
|
||||||
|
"filename": filename,
|
||||||
|
"file_path": file_path,
|
||||||
|
"size_bytes": len(pdf_bytes),
|
||||||
|
"artifact_uri": artifact_uri,
|
||||||
|
}
|
||||||
|
await self.record_output_parameter_value(workflow_run_context, workflow_run_id, output)
|
||||||
|
|
||||||
|
return await self.build_block_result(
|
||||||
|
success=True,
|
||||||
|
failure_reason=None,
|
||||||
|
output_parameter_value=output,
|
||||||
|
status=BlockStatus.completed,
|
||||||
|
workflow_run_block_id=workflow_run_block_id,
|
||||||
|
organization_id=organization_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class BranchEvaluationContext:
|
class BranchEvaluationContext:
|
||||||
"""Collection of runtime data that BranchCriteria evaluators can consume."""
|
"""Collection of runtime data that BranchCriteria evaluators can consume."""
|
||||||
|
|
||||||
@@ -5246,6 +5431,7 @@ BlockSubclasses = Union[
|
|||||||
TaskV2Block,
|
TaskV2Block,
|
||||||
FileUploadBlock,
|
FileUploadBlock,
|
||||||
HttpRequestBlock,
|
HttpRequestBlock,
|
||||||
|
PrintPageBlock,
|
||||||
]
|
]
|
||||||
BlockTypeVar = Annotated[BlockSubclasses, Field(discriminator="block_type")]
|
BlockTypeVar = Annotated[BlockSubclasses, Field(discriminator="block_type")]
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ from skyvern.forge.sdk.workflow.models.block import (
|
|||||||
LoginBlock,
|
LoginBlock,
|
||||||
NavigationBlock,
|
NavigationBlock,
|
||||||
PDFParserBlock,
|
PDFParserBlock,
|
||||||
|
PrintPageBlock,
|
||||||
PromptBranchCriteria,
|
PromptBranchCriteria,
|
||||||
SendEmailBlock,
|
SendEmailBlock,
|
||||||
TaskBlock,
|
TaskBlock,
|
||||||
@@ -729,6 +730,15 @@ def block_yaml_to_block(
|
|||||||
url=block_yaml.url,
|
url=block_yaml.url,
|
||||||
complete_verification=False,
|
complete_verification=False,
|
||||||
)
|
)
|
||||||
|
elif block_yaml.block_type == BlockType.PRINT_PAGE:
|
||||||
|
return PrintPageBlock(
|
||||||
|
**base_kwargs,
|
||||||
|
include_timestamp=block_yaml.include_timestamp,
|
||||||
|
custom_filename=block_yaml.custom_filename,
|
||||||
|
format=block_yaml.format,
|
||||||
|
landscape=block_yaml.landscape,
|
||||||
|
print_background=block_yaml.print_background,
|
||||||
|
)
|
||||||
|
|
||||||
raise ValueError(f"Invalid block type {block_yaml.block_type}")
|
raise ValueError(f"Invalid block type {block_yaml.block_type}")
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ class BlockType(StrEnum):
|
|||||||
PDF_PARSER = "pdf_parser"
|
PDF_PARSER = "pdf_parser"
|
||||||
HTTP_REQUEST = "http_request"
|
HTTP_REQUEST = "http_request"
|
||||||
HUMAN_INTERACTION = "human_interaction"
|
HUMAN_INTERACTION = "human_interaction"
|
||||||
|
PRINT_PAGE = "print_page"
|
||||||
|
|
||||||
|
|
||||||
class BlockStatus(StrEnum):
|
class BlockStatus(StrEnum):
|
||||||
@@ -68,6 +69,13 @@ class FileType(StrEnum):
|
|||||||
PDF = "pdf"
|
PDF = "pdf"
|
||||||
|
|
||||||
|
|
||||||
|
class PDFFormat(StrEnum):
|
||||||
|
A4 = "A4"
|
||||||
|
LETTER = "Letter"
|
||||||
|
LEGAL = "Legal"
|
||||||
|
TABLOID = "Tabloid"
|
||||||
|
|
||||||
|
|
||||||
class FileStorageType(StrEnum):
|
class FileStorageType(StrEnum):
|
||||||
S3 = "s3"
|
S3 = "s3"
|
||||||
AZURE = "azure"
|
AZURE = "azure"
|
||||||
@@ -546,6 +554,15 @@ class HttpRequestBlockYAML(BlockYAML):
|
|||||||
parameter_keys: list[str] | None = None
|
parameter_keys: list[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PrintPageBlockYAML(BlockYAML):
|
||||||
|
block_type: Literal[BlockType.PRINT_PAGE] = BlockType.PRINT_PAGE # type: ignore
|
||||||
|
include_timestamp: bool = True
|
||||||
|
custom_filename: str | None = None
|
||||||
|
format: PDFFormat = PDFFormat.A4
|
||||||
|
landscape: bool = False
|
||||||
|
print_background: bool = True
|
||||||
|
|
||||||
|
|
||||||
PARAMETER_YAML_SUBCLASSES = (
|
PARAMETER_YAML_SUBCLASSES = (
|
||||||
AWSSecretParameterYAML
|
AWSSecretParameterYAML
|
||||||
| BitwardenLoginCredentialParameterYAML
|
| BitwardenLoginCredentialParameterYAML
|
||||||
@@ -583,6 +600,7 @@ BLOCK_YAML_SUBCLASSES = (
|
|||||||
| TaskV2BlockYAML
|
| TaskV2BlockYAML
|
||||||
| HttpRequestBlockYAML
|
| HttpRequestBlockYAML
|
||||||
| ConditionalBlockYAML
|
| ConditionalBlockYAML
|
||||||
|
| PrintPageBlockYAML
|
||||||
)
|
)
|
||||||
BLOCK_YAML_TYPES = Annotated[BLOCK_YAML_SUBCLASSES, Field(discriminator="block_type")]
|
BLOCK_YAML_TYPES = Annotated[BLOCK_YAML_SUBCLASSES, Field(discriminator="block_type")]
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user