feat: Running tasks and steps UI (#165)

This commit is contained in:
Salih Altun
2024-04-07 21:52:59 +03:00
committed by GitHub
parent 112b44e41a
commit 533ed32d9c
32 changed files with 1523 additions and 225 deletions

View File

@@ -1,9 +1,12 @@
import express from "express";
import fs from "fs";
import cors from "cors";
const app = express();
app.get("/artifact", (req, res) => {
app.use(cors());
app.get("/artifact/recording", (req, res) => {
const range = req.headers.range;
const path = req.query.path;
const videoSize = fs.statSync(path).size;
@@ -25,4 +28,26 @@ app.get("/artifact", (req, res) => {
stream.pipe(res);
});
app.get("/artifact/image", (req, res) => {
const path = req.query.path;
res.sendFile(path);
});
app.get("/artifact/json", (req, res) => {
const path = req.query.path;
const contents = fs.readFileSync(path);
try {
const data = JSON.parse(contents);
res.json(data);
} catch (err) {
res.status(500).send(err);
}
});
app.get("/artifact/text", (req, res) => {
const path = req.query.path;
const contents = fs.readFileSync(path);
res.send(contents);
});
app.listen(9090);

View File

@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/png" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Skyvern</title>
</head>

View File

@@ -10,22 +10,28 @@
"dependencies": {
"@hookform/resolvers": "^3.3.4",
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-aspect-ratio": "^1.0.3",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-radio-group": "^1.1.3",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toast": "^1.1.5",
"@radix-ui/react-tooltip": "^1.0.7",
"@tanstack/react-query": "^5.28.6",
"axios": "^1.6.8",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"cors": "^2.8.5",
"embla-carousel-react": "^8.0.0",
"express": "^4.19.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.51.1",
"react-medium-image-zoom": "^5.1.11",
"react-router-dom": "^6.22.3",
"tailwind-merge": "^2.2.2",
"tailwindcss-animate": "^1.0.7",
@@ -883,6 +889,29 @@
}
}
},
"node_modules/@radix-ui/react-aspect-ratio": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.0.3.tgz",
"integrity": "sha512-fXR5kbMan9oQqMuacfzlGG/SQMcmMlZ4wrvpckv8SgUulD0MMpspxJrxg/Gp/ISV3JfV1AeSWTYK9GvxA4ySwA==",
"dependencies": {
"@babel/runtime": "^7.13.10",
"@radix-ui/react-primitive": "1.0.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0",
"react-dom": "^16.8 || ^17.0 || ^18.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-collapsible": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.0.3.tgz",
@@ -1053,6 +1082,35 @@
}
}
},
"node_modules/@radix-ui/react-dropdown-menu": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.0.6.tgz",
"integrity": "sha512-i6TuFOoWmLWq+M/eCLGd/bQ2HfAX1RJgvrBQ6AQLmzfvsLdefxbWu8G9zczcPFfcSPehz9GcpF6K9QYreFV8hA==",
"dependencies": {
"@babel/runtime": "^7.13.10",
"@radix-ui/primitive": "1.0.1",
"@radix-ui/react-compose-refs": "1.0.1",
"@radix-ui/react-context": "1.0.1",
"@radix-ui/react-id": "1.0.1",
"@radix-ui/react-menu": "2.0.6",
"@radix-ui/react-primitive": "1.0.3",
"@radix-ui/react-use-controllable-state": "1.0.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0",
"react-dom": "^16.8 || ^17.0 || ^18.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-focus-guards": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.0.1.tgz",
@@ -1144,6 +1202,46 @@
}
}
},
"node_modules/@radix-ui/react-menu": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.0.6.tgz",
"integrity": "sha512-BVkFLS+bUC8HcImkRKPSiVumA1VPOOEC5WBMiT+QAVsPzW1FJzI9KnqgGxVDPBcql5xXrHkD3JOVoXWEXD8SYA==",
"dependencies": {
"@babel/runtime": "^7.13.10",
"@radix-ui/primitive": "1.0.1",
"@radix-ui/react-collection": "1.0.3",
"@radix-ui/react-compose-refs": "1.0.1",
"@radix-ui/react-context": "1.0.1",
"@radix-ui/react-direction": "1.0.1",
"@radix-ui/react-dismissable-layer": "1.0.5",
"@radix-ui/react-focus-guards": "1.0.1",
"@radix-ui/react-focus-scope": "1.0.4",
"@radix-ui/react-id": "1.0.1",
"@radix-ui/react-popper": "1.1.3",
"@radix-ui/react-portal": "1.0.4",
"@radix-ui/react-presence": "1.0.1",
"@radix-ui/react-primitive": "1.0.3",
"@radix-ui/react-roving-focus": "1.0.4",
"@radix-ui/react-slot": "1.0.2",
"@radix-ui/react-use-callback-ref": "1.0.1",
"aria-hidden": "^1.1.1",
"react-remove-scroll": "2.5.5"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0",
"react-dom": "^16.8 || ^17.0 || ^18.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popper": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.1.3.tgz",
@@ -1370,6 +1468,36 @@
}
}
},
"node_modules/@radix-ui/react-tabs": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.0.4.tgz",
"integrity": "sha512-egZfYY/+wRNCflXNHx+dePvnz9FbmssDTJBtgRfDY7e8SE5oIo3Py2eCB1ckAbh1Q7cQ/6yJZThJ++sgbxibog==",
"dependencies": {
"@babel/runtime": "^7.13.10",
"@radix-ui/primitive": "1.0.1",
"@radix-ui/react-context": "1.0.1",
"@radix-ui/react-direction": "1.0.1",
"@radix-ui/react-id": "1.0.1",
"@radix-ui/react-presence": "1.0.1",
"@radix-ui/react-primitive": "1.0.3",
"@radix-ui/react-roving-focus": "1.0.4",
"@radix-ui/react-use-controllable-state": "1.0.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0",
"react-dom": "^16.8 || ^17.0 || ^18.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-toast": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.1.5.tgz",
@@ -2661,6 +2789,18 @@
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ=="
},
"node_modules/cors": {
"version": "2.8.5",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
"integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==",
"dependencies": {
"object-assign": "^4",
"vary": "^1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/cross-spawn": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
@@ -2810,6 +2950,31 @@
"integrity": "sha512-ncfPC8UnGIyGFrPE03J5Xn6yTZ6R+clkcZbuG1PJbjAaZBFS4Kn3UKfzu8eilzru6SfC8TPsHuwv0p0eYVu+ww==",
"dev": true
},
"node_modules/embla-carousel": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.0.0.tgz",
"integrity": "sha512-ecixcyqS6oKD2nh5Nj5MObcgoSILWNI/GtBxkidn5ytFaCCmwVHo2SecksaQZHcARMMpIR2dWOlSIdA1LkZFUA=="
},
"node_modules/embla-carousel-react": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/embla-carousel-react/-/embla-carousel-react-8.0.0.tgz",
"integrity": "sha512-qT0dii8ZwoCtEIBE6ogjqU2+5IwnGfdt2teKjCzW88JRErflhlCpz8KjWnW8xoRZOP8g0clRtsMEFoAgS/elfA==",
"dependencies": {
"embla-carousel": "8.0.0",
"embla-carousel-reactive-utils": "8.0.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.1 || ^18.0.0"
}
},
"node_modules/embla-carousel-reactive-utils": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-8.0.0.tgz",
"integrity": "sha512-JCw0CqCXI7tbHDRogBb9PoeMLyjEC1vpN0lDOzUjmlfVgtfF+ffLaOK8bVtXVUEbNs/3guGe3NSzA5J5aYzLzw==",
"peerDependencies": {
"embla-carousel": "8.0.0"
}
},
"node_modules/emoji-regex": {
"version": "10.3.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.3.0.tgz",
@@ -4900,6 +5065,21 @@
"react": "^16.8.0 || ^17 || ^18"
}
},
"node_modules/react-medium-image-zoom": {
"version": "5.1.11",
"resolved": "https://registry.npmjs.org/react-medium-image-zoom/-/react-medium-image-zoom-5.1.11.tgz",
"integrity": "sha512-7rHECk0nRY+Uwmd02HKPt0L0Lxv2/km24ztcxgUaGR8TD0ikK+OOJyGZAMg/SeQzJDzmNv0yW6U1K8jqnta5MQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/rpearce"
}
],
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/react-remove-scroll": {
"version": "2.5.5",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.5.5.tgz",

View File

@@ -4,34 +4,40 @@
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite & npm run run:server",
"dev": "vite & npm run run-artifact-server",
"build": "tsc --noEmit && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"format": "prettier --write .",
"preview": "vite preview",
"prepare": "cd .. && husky skyvern-frontend/.husky",
"precommit": "lint-staged",
"run:server": "node server.js"
"run-artifact-server": "node artifactServer.js"
},
"dependencies": {
"@hookform/resolvers": "^3.3.4",
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-aspect-ratio": "^1.0.3",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-radio-group": "^1.1.3",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toast": "^1.1.5",
"@radix-ui/react-tooltip": "^1.0.7",
"@tanstack/react-query": "^5.28.6",
"axios": "^1.6.8",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"cors": "^2.8.5",
"embla-carousel-react": "^8.0.0",
"express": "^4.19.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.51.1",
"react-medium-image-zoom": "^5.1.11",
"react-router-dom": "^6.22.3",
"tailwind-merge": "^2.2.2",
"tailwindcss-animate": "^1.0.7",

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

View File

@@ -1,4 +1,4 @@
import { apiBaseUrl, credential } from "@/util/env";
import { apiBaseUrl, artifactApiBaseUrl, credential } from "@/util/env";
import axios from "axios";
const client = axios.create({
@@ -9,4 +9,8 @@ const client = axios.create({
},
});
export { client };
const artifactApiClient = axios.create({
baseURL: artifactApiBaseUrl,
});
export { client, artifactApiClient };

View File

@@ -1,6 +1,14 @@
export const ArtifactType = {
Recording: "recording",
ActionScreenshot: "screenshot_action",
LLMScreenshot: "screenshot_llm",
LLMResponseRaw: "llm_response",
LLMResponseParsed: "llm_response_parsed",
VisibleElementsTree: "visible_elements_tree",
VisibleElementsTreeTrimmed: "visible_elements_tree_trimmed",
LLMPrompt: "llm_prompt",
LLMRequest: "llm_request",
HTMLScrape: "html_scrape",
} as const;
export const Status = {

View File

@@ -0,0 +1,6 @@
function Logo() {
const src = "/logo.png";
return <img src={src} />;
}
export { Logo };

View File

@@ -5,7 +5,7 @@ type Props = {
status: Status;
};
function TaskStatusBadge({ status }: Props) {
function StatusBadge({ status }: Props) {
let variant: "default" | "success" | "destructive" | "warning" = "default";
if (status === "completed") {
variant = "success";
@@ -18,4 +18,4 @@ function TaskStatusBadge({ status }: Props) {
return <Badge variant={variant}>{status}</Badge>;
}
export { TaskStatusBadge };
export { StatusBadge };

View File

@@ -0,0 +1,39 @@
import { MoonIcon, SunIcon } from "@radix-ui/react-icons";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useTheme } from "@/components/useTheme";
function ThemeToggle() {
const { setTheme } = useTheme();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<SunIcon className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<MoonIcon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
export { ThemeToggle };

View File

@@ -0,0 +1,16 @@
import Zoom from "react-medium-image-zoom";
import { AspectRatio } from "@/components/ui/aspect-ratio";
type HTMLImageElementProps = React.ComponentProps<"img">;
function ZoomableImage(props: HTMLImageElementProps) {
return (
<Zoom>
<AspectRatio ratio={16 / 9}>
<img {...props} />
</AspectRatio>
</Zoom>
);
}
export { ZoomableImage };

View File

@@ -0,0 +1,5 @@
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio";
const AspectRatio = AspectRatioPrimitive.Root;
export { AspectRatio };

View File

@@ -0,0 +1,260 @@
import * as React from "react";
import { ArrowLeftIcon, ArrowRightIcon } from "@radix-ui/react-icons";
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react";
import { cn } from "@/util/utils";
import { Button } from "@/components/ui/button";
type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
type CarouselOptions = UseCarouselParameters[0];
type CarouselPlugin = UseCarouselParameters[1];
type CarouselProps = {
opts?: CarouselOptions;
plugins?: CarouselPlugin;
orientation?: "horizontal" | "vertical";
setApi?: (api: CarouselApi) => void;
};
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
api: ReturnType<typeof useEmblaCarousel>[1];
scrollPrev: () => void;
scrollNext: () => void;
canScrollPrev: boolean;
canScrollNext: boolean;
} & CarouselProps;
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
function useCarousel() {
const context = React.useContext(CarouselContext);
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />");
}
return context;
}
const Carousel = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & CarouselProps
>(
(
{
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
},
ref,
) => {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins,
);
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
const [canScrollNext, setCanScrollNext] = React.useState(false);
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) {
return;
}
setCanScrollPrev(api.canScrollPrev());
setCanScrollNext(api.canScrollNext());
}, []);
const scrollPrev = React.useCallback(() => {
api?.scrollPrev();
}, [api]);
const scrollNext = React.useCallback(() => {
api?.scrollNext();
}, [api]);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault();
scrollPrev();
} else if (event.key === "ArrowRight") {
event.preventDefault();
scrollNext();
}
},
[scrollPrev, scrollNext],
);
React.useEffect(() => {
if (!api || !setApi) {
return;
}
setApi(api);
}, [api, setApi]);
React.useEffect(() => {
if (!api) {
return;
}
onSelect(api);
api.on("reInit", onSelect);
api.on("select", onSelect);
return () => {
api?.off("select", onSelect);
};
}, [api, onSelect]);
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
ref={ref}
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
);
},
);
Carousel.displayName = "Carousel";
const CarouselContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { carouselRef, orientation } = useCarousel();
return (
<div ref={carouselRef} className="overflow-hidden">
<div
ref={ref}
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className,
)}
{...props}
/>
</div>
);
});
CarouselContent.displayName = "CarouselContent";
const CarouselItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { orientation } = useCarousel();
return (
<div
ref={ref}
role="group"
aria-roledescription="slide"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className,
)}
{...props}
/>
);
});
CarouselItem.displayName = "CarouselItem";
const CarouselPrevious = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-left-12 top-1/2 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeftIcon className="h-4 w-4" />
<span className="sr-only">Previous slide</span>
</Button>
);
});
CarouselPrevious.displayName = "CarouselPrevious";
const CarouselNext = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollNext, canScrollNext } = useCarousel();
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-right-12 top-1/2 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRightIcon className="h-4 w-4" />
<span className="sr-only">Next slide</span>
</Button>
);
});
CarouselNext.displayName = "CarouselNext";
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
};

View File

@@ -0,0 +1,203 @@
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import {
CheckIcon,
ChevronRightIcon,
DotFilledIcon,
} from "@radix-ui/react-icons";
import { cn } from "@/util/utils";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
inset && "pl-8",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
));
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className,
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<DotFilledIcon className="h-4 w-4 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className,
)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
);
};
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};

View File

@@ -0,0 +1,53 @@
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "@/util/utils";
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
className,
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
className,
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className,
)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };

View File

@@ -73,6 +73,104 @@
body {
@apply bg-background text-foreground;
}
[data-rmiz] {
position: relative;
}
[data-rmiz-ghost] {
position: absolute;
pointer-events: none;
}
[data-rmiz-btn-zoom],
[data-rmiz-btn-unzoom] {
background-color: rgba(0, 0, 0, 0.7);
border-radius: 50%;
border: none;
box-shadow: 0 0 1px rgba(255, 255, 255, 0.5);
color: #fff;
height: 40px;
margin: 0;
outline-offset: 2px;
padding: 9px;
touch-action: manipulation;
width: 40px;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
}
[data-rmiz-btn-zoom]:not(:focus):not(:active) {
position: absolute;
clip: rect(0 0 0 0);
clip-path: inset(50%);
height: 1px;
overflow: hidden;
pointer-events: none;
white-space: nowrap;
width: 1px;
}
[data-rmiz-btn-zoom] {
position: absolute;
inset: 10px 10px auto auto;
cursor: zoom-in;
}
[data-rmiz-btn-unzoom] {
position: absolute;
inset: 20px 20px auto auto;
cursor: zoom-out;
z-index: 1;
}
[data-rmiz-content="found"] img,
[data-rmiz-content="found"] svg,
[data-rmiz-content="found"] [role="img"],
[data-rmiz-content="found"] [data-zoom] {
cursor: zoom-in;
}
[data-rmiz-modal]::backdrop {
display: none;
}
[data-rmiz-modal][open] {
position: fixed;
width: 100vw;
width: 100dvw;
height: 100vh;
height: 100dvh;
max-width: none;
max-height: none;
margin: 0;
padding: 0;
border: 0;
background: transparent;
overflow: hidden;
}
[data-rmiz-modal-overlay] {
position: absolute;
inset: 0;
transition: background-color 0.3s;
}
[data-rmiz-modal-overlay="hidden"] {
background-color: rgba(255, 255, 255, 0);
}
[data-rmiz-modal-overlay="visible"] {
@apply bg-background;
}
[data-rmiz-modal-content] {
position: relative;
width: 100%;
height: 100%;
}
[data-rmiz-modal-img] {
position: absolute;
cursor: zoom-out;
image-rendering: high-quality;
transform-origin: top left;
transition: transform 0.3s;
}
@media (prefers-reduced-motion: reduce) {
[data-rmiz-modal-overlay],
[data-rmiz-modal-img] {
transition-duration: 0.01ms !important;
}
}
}
body,

View File

@@ -2,20 +2,21 @@ import { Link, Outlet } from "react-router-dom";
import { Toaster } from "@/components/ui/toaster";
import { SideNav } from "./SideNav";
import { DiscordLogoIcon, GitHubLogoIcon } from "@radix-ui/react-icons";
import { Logo } from "@/components/Logo";
import { ThemeToggle } from "@/components/ThemeSwitch";
function RootLayout() {
return (
<>
<div className="w-full h-full px-4 max-w-screen-2xl mx-auto">
<div className="w-full h-full px-4">
<aside className="fixed w-72 px-6 shrink-0 min-h-screen">
<Link
to="https://skyvern.com"
target="_blank"
rel="noopener noreferrer"
>
<div className="h-24 flex items-center justify-center">
<img src="/skyvern-logo.png" width={48} height={48} />
<img src="/skyvern-logo-text.png" height={48} width={192} />
<div className="h-24">
<Logo />
</div>
</Link>
<SideNav />
@@ -26,20 +27,20 @@ function RootLayout() {
target="_blank"
rel="noopener noreferrer"
>
<DiscordLogoIcon className="w-6 h-6 text-gray-400 hover:text-white" />
<DiscordLogoIcon className="w-6 h-6" />
</Link>
<Link
to="https://github.com/Skyvern-AI/skyvern"
target="_blank"
rel="noopener noreferrer"
>
<GitHubLogoIcon className="w-6 h-6 text-gray-400 hover:text-white" />
<GitHubLogoIcon className="w-6 h-6" />
</Link>
<ThemeToggle />
</div>
<main className="pl-72">
<Outlet />
</main>
<aside className="w-72 shrink-0"></aside>
</div>
<Toaster />
</>

View File

@@ -0,0 +1,40 @@
import { artifactApiClient } from "@/api/AxiosClient";
import { Skeleton } from "@/components/ui/skeleton";
import { Textarea } from "@/components/ui/textarea";
import { useQuery } from "@tanstack/react-query";
type Props = {
uri: string;
};
function JSONArtifact({ uri }: Props) {
const { data, isFetching, isError, error } = useQuery<
Record<string, unknown>
>({
queryKey: ["artifact", uri],
queryFn: async () => {
return artifactApiClient
.get(`/artifact/json`, {
params: {
path: uri.slice(7),
},
})
.then((response) => response.data);
},
});
if (isFetching) {
return <Skeleton className="w-full h-48" />;
}
return (
<Textarea
className="w-full"
rows={15}
value={isError ? JSON.stringify(error) : JSON.stringify(data, null, 2)}
readOnly
/>
);
}
export { JSONArtifact };

View File

@@ -0,0 +1,205 @@
import { client } from "@/api/AxiosClient";
import {
ArtifactApiResponse,
ArtifactType,
StepApiResponse,
} from "@/api/types";
import { StatusBadge } from "@/components/StatusBadge";
import { Label } from "@/components/ui/label";
import { useQuery } from "@tanstack/react-query";
import { useParams } from "react-router-dom";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { artifactApiBaseUrl } from "@/util/env";
import { ZoomableImage } from "@/components/ZoomableImage";
import { Skeleton } from "@/components/ui/skeleton";
import { JSONArtifact } from "./JSONArtifact";
import { TextArtifact } from "./TextArtifact";
type Props = {
id: string;
stepProps: StepApiResponse;
};
function StepArtifacts({ id, stepProps }: Props) {
const { taskId } = useParams();
const {
data: artifacts,
isFetching,
isError,
error,
} = useQuery<Array<ArtifactApiResponse>>({
queryKey: ["task", taskId, "steps", id, "artifacts"],
queryFn: async () => {
return client
.get(`/tasks/${taskId}/steps/${id}/artifacts`)
.then((response) => response.data);
},
});
if (isError) {
return <div>Error: {error?.message}</div>;
}
const llmScreenshotUris = artifacts
?.filter(
(artifact) => artifact.artifact_type === ArtifactType.LLMScreenshot,
)
.map((artifact) => artifact.uri);
const actionScreenshotUris = artifacts
?.filter(
(artifact) => artifact.artifact_type === ArtifactType.ActionScreenshot,
)
.map((artifact) => artifact.uri);
const visibleElementsTreeUri = artifacts?.find(
(artifact) => artifact.artifact_type === ArtifactType.VisibleElementsTree,
)?.uri;
const visibleElementsTreeTrimmedUri = artifacts?.find(
(artifact) =>
artifact.artifact_type === ArtifactType.VisibleElementsTreeTrimmed,
)?.uri;
const llmPromptUri = artifacts?.find(
(artifact) => artifact.artifact_type === ArtifactType.LLMPrompt,
)?.uri;
const llmRequestUri = artifacts?.find(
(artifact) => artifact.artifact_type === ArtifactType.LLMRequest,
)?.uri;
const llmResponseRawUri = artifacts?.find(
(artifact) => artifact.artifact_type === ArtifactType.LLMResponseRaw,
)?.uri;
const llmResponseParsedUri = artifacts?.find(
(artifact) => artifact.artifact_type === ArtifactType.LLMResponseParsed,
)?.uri;
const htmlRawUri = artifacts?.find(
(artifact) => artifact.artifact_type === ArtifactType.HTMLScrape,
)?.uri;
return (
<Tabs defaultValue="info" className="w-full">
<TabsList className="grid w-full h-16 grid-cols-5">
<TabsTrigger value="info">Info</TabsTrigger>
<TabsTrigger value="screenshot_llm">LLM Screenshots</TabsTrigger>
<TabsTrigger value="screenshot_action">Action Screenshots</TabsTrigger>
<TabsTrigger value="element_tree">Element Tree</TabsTrigger>
<TabsTrigger value="element_tree_trimmed">
Element Tree (Trimmed)
</TabsTrigger>
<TabsTrigger value="llm_prompt">LLM Prompt</TabsTrigger>
<TabsTrigger value="llm_request">LLM Request</TabsTrigger>
<TabsTrigger value="llm_response_raw">LLM Response (Raw)</TabsTrigger>
<TabsTrigger value="llm_response_parsed">
LLM Response (Parsed)
</TabsTrigger>
<TabsTrigger value="html_raw">HTML (Raw)</TabsTrigger>
</TabsList>
<TabsContent value="info">
<div className="flex flex-col gap-4 p-4">
<div className="flex items-center">
<Label className="w-24">Step ID:</Label>
{isFetching ? (
<Skeleton className="h-4 w-40" />
) : (
<span>{stepProps?.step_id}</span>
)}
</div>
<div className="flex items-center">
<Label className="w-24">Status:</Label>
{isFetching ? (
<Skeleton className="h-4 w-40" />
) : stepProps ? (
<StatusBadge status={stepProps.status} />
) : null}
</div>
<div className="flex items-center">
<Label className="w-24">Created At:</Label>
{isFetching ? (
<Skeleton className="h-4 w-40" />
) : stepProps ? (
<span>{stepProps.created_at}</span>
) : null}
</div>
</div>
</TabsContent>
<TabsContent value="screenshot_llm">
{llmScreenshotUris && llmScreenshotUris.length > 0 ? (
<div className="grid grid-cols-3 gap-4 p-4">
{llmScreenshotUris.map((uri, index) => (
<ZoomableImage
key={index}
src={`${artifactApiBaseUrl}/artifact/image?path=${uri.slice(7)}`}
className="object-cover w-full h-full"
alt="action-screenshot"
/>
))}
</div>
) : isFetching ? (
<div className="grid grid-cols-3 gap-4 p-4">
<Skeleton className="w-full h-full" />
<Skeleton className="w-full h-full" />
<Skeleton className="w-full h-full" />
</div>
) : (
<div>No screenshots found</div>
)}
</TabsContent>
<TabsContent value="screenshot_action">
{actionScreenshotUris && actionScreenshotUris.length > 0 ? (
<div className="grid grid-cols-3 gap-4 p-4">
{actionScreenshotUris.map((uri, index) => (
<ZoomableImage
key={index}
src={`${artifactApiBaseUrl}/artifact/image?path=${uri.slice(7)}`}
className="object-cover w-full h-full"
alt="action-screenshot"
/>
))}
</div>
) : isFetching ? (
<div className="grid grid-cols-3 gap-4 p-4">
<Skeleton className="w-full h-full" />
<Skeleton className="w-full h-full" />
<Skeleton className="w-full h-full" />
</div>
) : (
<div>No screenshots found</div>
)}
</TabsContent>
<TabsContent value="element_tree">
{visibleElementsTreeUri ? (
<JSONArtifact uri={visibleElementsTreeUri} />
) : null}
</TabsContent>
<TabsContent value="element_tree_trimmed">
{visibleElementsTreeTrimmedUri ? (
<JSONArtifact uri={visibleElementsTreeTrimmedUri} />
) : null}
</TabsContent>
<TabsContent value="llm_prompt">
{llmPromptUri ? <TextArtifact uri={llmPromptUri} /> : null}
</TabsContent>
<TabsContent value="llm_request">
{llmRequestUri ? <JSONArtifact uri={llmRequestUri} /> : null}
</TabsContent>
<TabsContent value="llm_response_raw">
{llmResponseRawUri ? <JSONArtifact uri={llmResponseRawUri} /> : null}
</TabsContent>
<TabsContent value="llm_response_parsed">
{llmResponseParsedUri ? (
<JSONArtifact uri={llmResponseParsedUri} />
) : null}
</TabsContent>
<TabsContent value="html_raw">
{htmlRawUri ? <TextArtifact uri={htmlRawUri} /> : null}
</TabsContent>
</Tabs>
);
}
export { StepArtifacts };

View File

@@ -0,0 +1,58 @@
import { useState } from "react";
import { StepNavigation } from "./StepNavigation";
import { StepArtifacts } from "./StepArtifacts";
import { useQuery } from "@tanstack/react-query";
import { StepApiResponse } from "@/api/types";
import { useParams } from "react-router-dom";
import { client } from "@/api/AxiosClient";
function StepArtifactsLayout() {
const [activeIndex, setActiveIndex] = useState(0);
const { taskId } = useParams();
const {
data: steps,
isFetching,
isError,
error,
} = useQuery<Array<StepApiResponse>>({
queryKey: ["task", taskId, "steps"],
queryFn: async () => {
return client
.get(`/tasks/${taskId}/steps`)
.then((response) => response.data);
},
});
if (isFetching) {
return <div>Loading...</div>;
}
if (isError) {
return <div>Error: {error?.message}</div>;
}
if (!steps) {
return <div>No steps found</div>;
}
const activeStep = steps[activeIndex];
return (
<div className="px-4 flex">
<aside className="w-64 shrink-0">
<StepNavigation
activeIndex={activeIndex}
onActiveIndexChange={setActiveIndex}
/>
</aside>
<main className="px-4 w-full">
{activeStep ? (
<StepArtifacts id={activeStep.step_id} stepProps={activeStep} />
) : null}
</main>
</div>
);
}
export { StepArtifactsLayout };

View File

@@ -0,0 +1,42 @@
import { StepApiResponse } from "@/api/types";
import { StatusBadge } from "@/components/StatusBadge";
import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
type Props = {
isFetching: boolean;
stepProps?: StepApiResponse;
};
function StepInfo({ isFetching, stepProps }: Props) {
return (
<div className="flex flex-col gap-4 p-4">
<div className="flex items-center">
<Label className="w-24">Step ID:</Label>
{isFetching ? (
<Skeleton className="h-4 w-40" />
) : (
<span>{stepProps?.step_id}</span>
)}
</div>
<div className="flex items-center">
<Label className="w-24">Status:</Label>
{isFetching ? (
<Skeleton className="h-4 w-40" />
) : stepProps ? (
<StatusBadge status={stepProps.status} />
) : null}
</div>
<div className="flex items-center">
<Label className="w-24">Created At:</Label>
{isFetching ? (
<Skeleton className="h-4 w-40" />
) : stepProps ? (
<span>{stepProps.created_at}</span>
) : null}
</div>
</div>
);
}
export { StepInfo };

View File

@@ -1,123 +0,0 @@
import { client } from "@/api/AxiosClient";
import { StepApiResponse } from "@/api/types";
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { useQuery } from "@tanstack/react-query";
import { useParams, useSearchParams } from "react-router-dom";
import { StepListSkeleton } from "./StepListSkeleton";
import { TaskStatusBadge } from "@/components/TaskStatusBadge";
import { basicTimeFormat } from "@/util/timeFormat";
function StepList() {
const { taskId } = useParams();
const [searchParams, setSearchParams] = useSearchParams();
const page = searchParams.get("page") ? Number(searchParams.get("page")) : 1;
const {
data: steps,
isFetching,
isError,
error,
} = useQuery<Array<StepApiResponse>>({
queryKey: ["task", taskId, "steps", page],
queryFn: async () => {
return client
.get(`/tasks/${taskId}/steps`, {
params: {
page,
},
})
.then((response) => response.data);
},
});
if (isFetching) {
return <StepListSkeleton />;
}
if (isError) {
return <div>Error: {error?.message}</div>;
}
if (!steps) {
return <div>No steps found</div>;
}
return (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-1/3">Order</TableHead>
<TableHead className="w-1/3">Status</TableHead>
<TableHead className="w-1/3">Created At</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{steps.length === 0 ? (
<TableRow>
<TableCell colSpan={3}>No tasks found</TableCell>
</TableRow>
) : (
steps.map((step) => {
return (
<TableRow key={step.step_id} className="cursor-pointer w-4">
<TableCell className="w-1/3">{step.order}</TableCell>
<TableCell className="w-1/3">
<TaskStatusBadge status={step.status} />
</TableCell>
<TableCell className="w-1/3">
{basicTimeFormat(step.created_at)}
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href="#"
onClick={() => {
const params = new URLSearchParams();
params.set("page", String(Math.max(1, page - 1)));
setSearchParams(params);
}}
/>
</PaginationItem>
<PaginationItem>
<PaginationLink href="#">{page}</PaginationLink>
</PaginationItem>
<PaginationItem>
<PaginationNext
href="#"
onClick={() => {
const params = new URLSearchParams();
params.set("page", String(page + 1));
setSearchParams(params);
}}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</>
);
}
export { StepList };

View File

@@ -1,46 +0,0 @@
import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
const pageSizeArray = new Array(15).fill(null); // doesn't matter the value
function StepListSkeleton() {
return (
<div className="flex flex-col gap-2">
<Table>
<TableHeader>
<TableRow>
<TableHead>Order</TableHead>
<TableHead>Status</TableHead>
<TableHead>Created At</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pageSizeArray.map((_, index) => {
return (
<TableRow key={index}>
<TableCell>
<Skeleton className="h-4 w-24" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-24" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-24" />
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
);
}
export { StepListSkeleton };

View File

@@ -0,0 +1,78 @@
import { client } from "@/api/AxiosClient";
import { StepApiResponse } from "@/api/types";
import { cn } from "@/util/utils";
import { useQuery } from "@tanstack/react-query";
import { useParams, useSearchParams } from "react-router-dom";
import { PAGE_SIZE } from "../constants";
type Props = {
activeIndex: number;
onActiveIndexChange: (index: number) => void;
};
function StepNavigation({ activeIndex, onActiveIndexChange }: Props) {
const { taskId } = useParams();
const [searchParams] = useSearchParams();
const page = searchParams.get("page") ? Number(searchParams.get("page")) : 1;
const {
data: steps,
isFetching,
isError,
error,
} = useQuery<Array<StepApiResponse>>({
queryKey: ["task", taskId, "steps", page],
queryFn: async () => {
return client
.get(`/tasks/${taskId}/steps`, {
params: {
page,
page_size: PAGE_SIZE,
},
})
.then((response) => response.data);
},
});
if (isFetching) {
return <div>Loading...</div>;
}
if (isError) {
return <div>Error: {error?.message}</div>;
}
if (!steps) {
return <div>No steps found</div>;
}
return (
<nav className="flex flex-col gap-4">
{steps.map((step, index) => {
const isActive = activeIndex === index;
return (
<div
className={cn(
"flex justify-center items-center px-6 py-2 hover:bg-primary-foreground rounded-2xl cursor-pointer",
{
"bg-primary-foreground": isActive,
},
)}
key={step.step_id}
onClick={() => {
onActiveIndexChange(index);
}}
>
<span>
{step.retry_index > 0
? `Step ${step.order + 1} ( Retry ${step.retry_index} )`
: `Step ${step.order + 1}`}
</span>
</div>
);
})}
</nav>
);
}
export { StepNavigation };

View File

@@ -2,20 +2,22 @@ import { client } from "@/api/AxiosClient";
import { Status, TaskApiResponse } from "@/api/types";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { useParams } from "react-router-dom";
import { StepList } from "./StepList";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { TaskStatusBadge } from "@/components/TaskStatusBadge";
import { StatusBadge } from "@/components/StatusBadge";
import { artifactApiBaseUrl } from "@/util/env";
import { Button } from "@/components/ui/button";
import { ReloadIcon } from "@radix-ui/react-icons";
import { basicTimeFormat } from "@/util/timeFormat";
import { StepArtifactsLayout } from "./StepArtifactsLayout";
import Zoom from "react-medium-image-zoom";
import { AspectRatio } from "@/components/ui/aspect-ratio";
function TaskDetails() {
const { taskId } = useParams();
@@ -27,11 +29,10 @@ function TaskDetails() {
error: taskError,
refetch,
} = useQuery<TaskApiResponse>({
queryKey: ["task", taskId],
queryKey: ["task", taskId, "details"],
queryFn: async () => {
return client.get(`/tasks/${taskId}`).then((response) => response.data);
},
placeholderData: keepPreviousData,
});
if (isTaskError) {
@@ -63,14 +64,14 @@ function TaskDetails() {
<div className="flex">
<Label className="w-32">Recording</Label>
<video
src={`${artifactApiBaseUrl}/artifact?path=${task.recording_url.slice(7)}`}
src={`${artifactApiBaseUrl}/artifact/recording?path=${task.recording_url.slice(7)}`}
controls
/>
</div>
) : null}
<div className="flex items-center">
<Label className="w-32">Status</Label>
<TaskStatusBadge status={task.status} />
<StatusBadge status={task.status} />
</div>
{task.status === Status.Completed ? (
<div className="flex items-center">
@@ -93,8 +94,8 @@ function TaskDetails() {
</div>
) : null}
</div>
<Accordion type="single" collapsible>
<AccordionItem value="task-details">
<Accordion type="multiple">
<AccordionItem value="task-parameters">
<AccordionTrigger>
<h1>Task Parameters</h1>
</AccordionTrigger>
@@ -102,7 +103,9 @@ function TaskDetails() {
<div>
<p className="py-2">Task ID: {taskId}</p>
<p className="py-2">URL: {task.request.url}</p>
<p className="py-2">{basicTimeFormat(task.created_at)}</p>
<p className="py-2">
Created: {basicTimeFormat(task.created_at)}
</p>
<div className="py-2">
<Label>Navigation Goal</Label>
<Textarea
@@ -130,11 +133,36 @@ function TaskDetails() {
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem value="task-artifacts">
<AccordionTrigger>
<h1>Screenshot</h1>
</AccordionTrigger>
<AccordionContent>
<div className="max-w-sm mx-auto">
{task.screenshot_url ? (
<Zoom zoomMargin={16}>
<AspectRatio ratio={16 / 9}>
<img
src={`${artifactApiBaseUrl}/artifact/image?path=${task.screenshot_url.slice(7)}`}
alt="screenshot"
/>
</AspectRatio>
</Zoom>
) : (
<p>No screenshot</p>
)}
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem value="task-steps">
<AccordionTrigger>
<h1>Task Steps</h1>
</AccordionTrigger>
<AccordionContent>
<StepArtifactsLayout />
</AccordionContent>
</AccordionItem>
</Accordion>
<div className="py-2">
<h1>Task Steps</h1>
<StepList />
</div>
</div>
);
}

View File

@@ -0,0 +1,38 @@
import { artifactApiClient } from "@/api/AxiosClient";
import { Skeleton } from "@/components/ui/skeleton";
import { Textarea } from "@/components/ui/textarea";
import { useQuery } from "@tanstack/react-query";
type Props = {
uri: string;
};
function TextArtifact({ uri }: Props) {
const { data, isFetching, isError, error } = useQuery<string>({
queryKey: ["artifact", uri],
queryFn: async () => {
return artifactApiClient
.get(`/artifact/text`, {
params: {
path: uri.slice(7),
},
})
.then((response) => response.data);
},
});
if (isFetching) {
return <Skeleton className="w-full h-48" />;
}
return (
<Textarea
className="w-full"
rows={15}
value={isError ? JSON.stringify(error) : data}
readOnly
/>
);
}
export { TextArtifact };

View File

@@ -22,7 +22,7 @@ import { TaskListSkeleton } from "./TaskListSkeleton";
import { RunningTasks } from "../running/RunningTasks";
import { cn } from "@/util/utils";
import { PAGE_SIZE } from "../constants";
import { TaskStatusBadge } from "@/components/TaskStatusBadge";
import { StatusBadge } from "@/components/StatusBadge";
import { basicTimeFormat } from "@/util/timeFormat";
function TaskList() {
@@ -102,7 +102,7 @@ function TaskList() {
>
<TableCell className="w-1/3">{task.request.url}</TableCell>
<TableCell className="w-1/3">
<TaskStatusBadge status={task.status} />
<StatusBadge status={task.status} />
</TableCell>
<TableCell className="w-1/3">
{basicTimeFormat(task.created_at)}

View File

@@ -0,0 +1,84 @@
import { client } from "@/api/AxiosClient";
import {
ArtifactApiResponse,
ArtifactType,
StepApiResponse,
} from "@/api/types";
import { Skeleton } from "@/components/ui/skeleton";
import { artifactApiBaseUrl } from "@/util/env";
import { useQuery } from "@tanstack/react-query";
type Props = {
id: string;
};
function LatestScreenshot({ id }: Props) {
const {
data: screenshotUri,
isFetching,
isError,
} = useQuery<string | undefined>({
queryKey: ["task", id, "latestScreenshot"],
queryFn: async () => {
const steps: StepApiResponse[] = await client
.get(`/tasks/${id}/steps`)
.then((response) => response.data);
if (steps.length === 0) {
return;
}
const latestStep = steps[steps.length - 1];
if (!latestStep) {
return;
}
const artifacts: ArtifactApiResponse[] = await client
.get(`/tasks/${id}/steps/${latestStep.step_id}/artifacts`)
.then((response) => response.data);
const actionScreenshotUris = artifacts
?.filter(
(artifact) =>
artifact.artifact_type === ArtifactType.ActionScreenshot,
)
.map((artifact) => artifact.uri);
if (actionScreenshotUris.length > 0) {
return actionScreenshotUris[0];
}
const llmScreenshotUris = artifacts
?.filter(
(artifact) => artifact.artifact_type === ArtifactType.LLMScreenshot,
)
.map((artifact) => artifact.uri);
if (llmScreenshotUris.length > 0) {
return llmScreenshotUris[0];
}
return Promise.reject("No screenshots found");
},
refetchInterval: 2000,
});
if (isFetching) {
return <Skeleton className="w-full h-full" />;
}
if (isError || !screenshotUri || typeof screenshotUri !== "string") {
return null;
}
return (
<img
src={`${artifactApiBaseUrl}/artifact/image?path=${screenshotUri.slice(7)}`}
className="w-full h-full object-contain"
alt="Latest screenshot"
/>
);
}
export { LatestScreenshot };

View File

@@ -11,20 +11,15 @@ import {
CardTitle,
} from "@/components/ui/card";
import { PAGE_SIZE } from "../constants";
import { RunningTaskSkeleton } from "./RunningTaskSkeleton";
import { basicTimeFormat } from "@/util/timeFormat";
import { LatestScreenshot } from "./LatestScreenshot";
function RunningTasks() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const page = searchParams.get("page") ? Number(searchParams.get("page")) : 1;
const {
data: tasks,
isPending,
isError,
error,
} = useQuery<Array<TaskApiResponse>>({
const { data: tasks } = useQuery<Array<TaskApiResponse>>({
queryKey: ["tasks", page],
queryFn: async () => {
return client
@@ -40,25 +35,13 @@ function RunningTasks() {
placeholderData: keepPreviousData,
});
if (isPending) {
return <RunningTaskSkeleton />;
}
const runningTasks = tasks?.filter((task) => task.status === Status.Running);
if (isError) {
return <div>Error: {error?.message}</div>;
}
if (!tasks) {
return null;
}
const runningTasks = tasks.filter((task) => task.status === Status.Running);
if (runningTasks.length === 0) {
if (runningTasks?.length === 0) {
return <div>No running tasks</div>;
}
return runningTasks.map((task) => {
return runningTasks?.map((task) => {
return (
<Card
key={task.task_id}
@@ -68,10 +51,17 @@ function RunningTasks() {
}}
>
<CardHeader>
<CardTitle>{task.request.url}</CardTitle>
<CardDescription></CardDescription>
<CardTitle>{task.task_id}</CardTitle>
<CardDescription className="whitespace-nowrap overflow-hidden text-ellipsis">
{task.request.url}
</CardDescription>
</CardHeader>
<CardContent>Goal: {task.request.navigation_goal}</CardContent>
<CardContent>
Latest screenshot:
<div className="w-40 h-40 border-2">
<LatestScreenshot id={task.task_id} />
</div>
</CardContent>
<CardFooter>Created: {basicTimeFormat(task.created_at)}</CardFooter>
</Card>
);