Change website layout (#18)

* Add basic experiments page

* Isolate experiment components

* Fix grid on small screens

* Change nav bar

* Add padding to logo

* Fix linking

* Remove right margin on ExperimentCard flask

* Change favicon

* Use humanize in formatTimePast

* Add TODO
This commit is contained in:
arcticfly
2023-07-07 14:47:54 -07:00
committed by GitHub
parent 918f209227
commit db4476d1cb
25 changed files with 301 additions and 196 deletions

View File

@@ -14,6 +14,7 @@ declare module "nextjs-routes" {
| DynamicRoute<"/api/auth/[...nextauth]", { "nextauth": string[] }>
| DynamicRoute<"/api/trpc/[trpc]", { "trpc": string }>
| DynamicRoute<"/experiments/[id]", { "id": string }>
| StaticRoute<"/experiments">
| StaticRoute<"/">;
interface StaticRoute<Pathname> {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 738 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 828 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="512.000000pt" height="512.000000pt" viewBox="0 0 512.000000 512.000000"
preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.14, written by Peter Selinger 2001-2017
</metadata>
<g transform="translate(0.000000,512.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M7 5113 c-4 -3 -7 -352 -7 -774 0 -579 3 -770 12 -775 6 -4 226 -8
487 -8 l476 -1 1 -1760 c1 -968 2 -1768 3 -1778 1 -16 81 -17 1584 -15 l1582
3 0 1775 0 1775 460 2 c253 1 472 2 488 2 l27 1 -2 778 -3 777 -2551 3 c-1403
1 -2554 -1 -2557 -5z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 770 B

View File

@@ -1,100 +1,8 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Generator: Adobe Illustrator 24.1.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg
version="1.1"
id="Layer_1"
x="0px"
y="0px"
viewBox="0 0 512 512"
style="enable-background:new 0 0 512 512;"
xml:space="preserve"
sodipodi:docname="logo.svg"
inkscape:version="1.2.2 (b0a8486, 2022-12-01)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><defs
id="defs144" /><sodipodi:namedview
id="namedview142"
pagecolor="#505050"
bordercolor="#eeeeee"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#505050"
showgrid="false"
inkscape:zoom="0.4609375"
inkscape:cx="304.81356"
inkscape:cy="-29.288136"
inkscape:window-width="1440"
inkscape:window-height="847"
inkscape:window-x="1080"
inkscape:window-y="1105"
inkscape:window-maximized="0"
inkscape:current-layer="g429" />
<style
type="text/css"
id="style132">
.st0{fill:#F39C12;}
.st1{fill:#F1C40F;}
</style>
<g
id="g429"
transform="rotate(-31.952357,237.34973,219.54994)"><g
id="XMLID_1_"
transform="matrix(0.75514475,0.02046743,-0.01995659,0.75644772,46.808592,36.430117)">
<path
id="XMLID_5_"
class="st0"
d="M 463.2,68.4 C 399.4,4.6 297.8,3 231.6,68.4 c -63.8,63.8 -63.8,167.8 0,231.6 63.8,63.8 167.8,63.8 231.6,0 66.2,-65.4 63.9,-167.8 0,-231.6 z M 424.6,107 c 21.3,21.3 21.3,55.9 0,77.2 -21.3,21.3 -55.9,21.3 -77.2,0 -21.3,-21.3 -21.3,-55.9 0,-77.2 21.3,-21.3 56,-21.3 77.2,0 z" />
<path
id="XMLID_8_"
class="st1"
d="m 462.5,48.7 c -63.8,-63.8 -167.8,-63.8 -231.6,0 -63.8,63.8 -63.8,167.8 0,231.6 63.8,63.8 167.8,63.8 231.6,0 63.8,-64.6 63.8,-167.8 0,-231.6 z m -38.6,38.6 c 21.3,21.3 21.3,55.9 0,77.2 -21.3,21.3 -55.9,21.3 -77.2,0 -21.3,-21.3 -21.3,-55.9 0,-77.2 22,-21.3 55.9,-21.3 77.2,0 z" />
<rect
id="XMLID_9_"
x="204.10001"
y="197.7"
transform="matrix(0.7071,-0.7071,0.7071,0.7071,-130.0056,245.4256)"
class="st1"
width="54.400002"
height="163.89999" />
<polygon
id="XMLID_10_"
class="st1"
points="193,395.3 250.5,337.8 173.3,260.6 0,434 0,511.2 77.2,511.2 96.1,492.3 77.2,434 134.7,453.6 154.4,434 154.4,395.3 " />
<path
id="XMLID_14_"
class="st0"
d="m 231.6,318.1 -77.2,77.2 H 193 l 57.5,-57.5 z m -77.2,77.2 -48.1,48.1 29.1,9.5 18.9,-18.9 c 0.1,0 0.1,-38.7 0.1,-38.7 z m -67.7,67.8 -48.1,48.1 h 38.6 l 18.9,-18.9 z" />
<polygon
id="XMLID_15_"
class="st0"
points="193,279.5 0,472.6 0,511.2 77.2,434 211.9,299.2 " />
</g></g>
<text
xml:space="preserve"
style="font-style:normal;font-weight:normal;font-size:424.372px;line-height:1.25;font-family:sans-serif;fill:#1a202c;fill-opacity:1;stroke:none;stroke-width:1.06093"
x="-51.142395"
y="363.65485"
id="text475"
transform="scale(0.8254529,1.2114562)"><tspan
sodipodi:role="line"
id="tspan473"
x="-51.142395"
y="363.65485"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:424.372px;font-family:'Fira Code';-inkscape-font-specification:'Fira Code';stroke-width:1.06093;fill:#1a202c;fill-opacity:1">{</tspan></text><text
xml:space="preserve"
style="font-style:normal;font-weight:normal;font-size:424.373px;line-height:1.25;font-family:sans-serif;fill:#1a202c;fill-opacity:1;stroke:none;stroke-width:1.06093"
x="-671.40875"
y="363.655"
id="text475-6"
transform="scale(-0.82545186,1.2114577)"><tspan
sodipodi:role="line"
id="tspan473-2"
x="-671.40875"
y="363.655"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:424.373px;font-family:'Fira Code';-inkscape-font-specification:'Fira Code';stroke-width:1.06093;fill:#1a202c;fill-opacity:1">{</tspan></text></svg>
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M97.5238 147.526H414.476V510C414.476 511.105 413.581 512 412.476 512H99.5238C98.4192 512 97.5238 511.105 97.5238 510V147.526Z" fill="black"/>
<rect width="512" height="156.203" rx="2" fill="black"/>
<rect x="109.714" y="156.203" width="292.571" height="347.119" fill="#FF5733"/>
<rect x="12.1905" y="8.67792" width="487.619" height="138.847" fill="#FF5733"/>
<rect x="164.571" y="156.203" width="48.7619" height="347.119" fill="white"/>
<rect x="128" y="8.67792" width="48.7619" height="138.847" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 627 B

View File

@@ -0,0 +1,76 @@
import {
Card,
CardBody,
HStack,
Icon,
VStack,
Text,
CardHeader,
Divider,
Box,
} from "@chakra-ui/react";
import { RiFlaskLine } from "react-icons/ri";
import { formatTimePast } from "~/utils/dayjs";
import { useRouter } from "next/router";
type ExperimentData = {
testScenarioCount: number;
promptVariantCount: number;
id: string;
label: string;
sortIndex: number;
createdAt: Date;
updatedAt: Date;
};
export const ExperimentCard = ({ exp }: { exp: ExperimentData }) => {
const router = useRouter();
return (
<Box
as={Card}
variant="elevated"
bg="gray.50"
_hover={{ bg: "gray.100" }}
transition="background 0.2s"
cursor="pointer"
onClick={(e) => {
e.preventDefault();
void router.push({ pathname: "/experiments/[id]", query: { id: exp.id } }, undefined, {
shallow: true,
});
}}
>
<CardHeader>
<HStack w="full" color="gray.700">
<Icon as={RiFlaskLine} boxSize={4} />
<Text fontWeight="bold">{exp.label}</Text>
</HStack>
</CardHeader>
<CardBody>
<HStack w="full" mb={8} spacing={4}>
<CountLabel label="Variants" count={exp.promptVariantCount} />
<Divider h={12} orientation="vertical" />
<CountLabel label="Scenarios" count={exp.testScenarioCount} />
</HStack>
<HStack w="full" color="gray.500" fontSize="xs">
<Text>Created {formatTimePast(exp.createdAt)}</Text>
<Divider h={4} orientation="vertical" />
<Text>Updated {formatTimePast(exp.updatedAt)}</Text>
</HStack>
</CardBody>
</Box>
);
};
const CountLabel = ({ label, count }: { label: string; count: number }) => {
return (
<VStack alignItems="flex-start">
<Text color="gray.500" fontWeight="bold">
{label}
</Text>
<Text fontSize="sm" color="gray.500">
{count}
</Text>
</VStack>
);
};

View File

@@ -0,0 +1,29 @@
import { Icon, Button, Spinner, type ButtonProps } from "@chakra-ui/react";
import { api } from "~/utils/api";
import { useRouter } from "next/router";
import { BsPlusSquare } from "react-icons/bs";
import { useHandledAsyncCallback } from "~/utils/hooks";
export const NewExperimentButton = (props: ButtonProps) => {
const router = useRouter();
const utils = api.useContext();
const createMutation = api.experiments.create.useMutation();
const [createExperiment, isLoading] = useHandledAsyncCallback(async () => {
const newExperiment = await createMutation.mutateAsync({ label: "New Experiment" });
await utils.experiments.list.invalidate();
await router.push({ pathname: "/experiments/[id]", query: { id: newExperiment.id } });
}, [createMutation, router]);
return (
<Button
onClick={createExperiment}
display="flex"
alignItems="center"
variant="ghost"
{...props}
>
<Icon as={isLoading ? Spinner : BsPlusSquare} boxSize={4} mr={2} />
New Experiment
</Button>
);
};

View File

@@ -1,119 +1,82 @@
import {
Box,
Heading,
VStack,
Icon,
HStack,
type BoxProps,
forwardRef,
Image,
Link,
Grid,
GridItem,
Divider,
Text,
Box,
type BoxProps,
type LinkProps,
Link,
} from "@chakra-ui/react";
import Head from "next/head";
import { api } from "~/utils/api";
import { BsGithub, BsPlusSquare, BsTwitter } from "react-icons/bs";
import { RiFlaskLine } from "react-icons/ri";
import { BsGithub, BsTwitter } from "react-icons/bs";
import { useRouter } from "next/router";
import NextLink from "next/link";
import { useHandledAsyncCallback } from "~/utils/hooks";
import PublicPlaygroundWarning from "../PublicPlaygroundWarning";
import { type IconType } from "react-icons";
import { RiFlaskLine } from "react-icons/ri";
const ExperimentLink = forwardRef<BoxProps & { active: boolean | undefined }, "a">(
({ children, active, ...props }, ref) => (
type IconLinkProps = BoxProps & LinkProps & { label: string; icon: IconType; href: string };
const IconLink = ({ icon, label, href, target, ...props }: IconLinkProps) => {
const isActive = useRouter().pathname.startsWith(href);
return (
<Box
ref={ref}
bgColor={active ? "gray.300" : "transparent"}
as={Link}
href={href}
target={target}
w="full"
bgColor={isActive ? "gray.300" : "transparent"}
_hover={{ bgColor: "gray.300" }}
borderRadius={4}
px={4}
py={2}
py={4}
justifyContent="start"
cursor="pointer"
{...props}
>
{children}
<HStack w="full" px={4} color="gray.700">
<Icon as={icon} boxSize={6} mr={2} />
<Text fontWeight="bold">{label}</Text>
</HStack>
</Box>
)
);
ExperimentLink.displayName = "ExperimentLink";
const Separator = () => <Box h="1px" bgColor="gray.300" />;
};
const NavSidebar = () => {
const experiments = api.experiments.list.useQuery();
const router = useRouter();
const utils = api.useContext();
const currentId = router.query.id as string | undefined;
const createMutation = api.experiments.create.useMutation();
const [createExperiment] = useHandledAsyncCallback(async () => {
const newExperiment = await createMutation.mutateAsync({ label: "New Experiment" });
await utils.experiments.list.invalidate();
await router.push({ pathname: "/experiments/[id]", query: { id: newExperiment.id } });
}, [createMutation, router]);
return (
<VStack align="stretch" bgColor="gray.100" p={2} pb={0} height="100%">
<HStack spacing={0}>
<VStack align="stretch" bgColor="gray.100" py={2} pb={0} height="100%">
<HStack spacing={0} pl="4">
<Image src="/logo.svg" alt="" w={6} h={6} />
<Heading size="md" p={2}>
OpenPipe
</Heading>
</HStack>
<Separator />
<Divider />
<VStack spacing={0} align="flex-start" overflowY="auto" flex={1}>
<Heading size="xs" textAlign="center" p={2}>
Experiments
</Heading>
<VStack spacing={1} align="stretch" flex={1}>
<ExperimentLink
onClick={createExperiment}
active={false}
display="flex"
alignItems="center"
>
<Icon as={BsPlusSquare} boxSize={4} mr={2} />
New Experiment
</ExperimentLink>
{experiments?.data?.map((exp) => (
<ExperimentLink
key={exp.id}
as={NextLink}
active={exp.id === currentId}
href={{ pathname: "/experiments/[id]", query: { id: exp.id } }}
display="flex"
alignItems="center"
>
<Icon as={RiFlaskLine} boxSize={4} mr={2} />
{exp.label}
</ExperimentLink>
))}
<IconLink icon={RiFlaskLine} label="Experiments" href="/experiments" />
</VStack>
</VStack>
<Separator />
<HStack align="center" justify="center" spacing={4} p={2}>
<Link
<Divider />
<VStack align="center" spacing={4} p={2}>
<IconLink
icon={BsGithub}
label="GitHub"
href="https://github.com/corbt/openpipe"
target="_blank"
color="gray.500"
_hover={{ color: "gray.800" }}
>
<Icon as={BsGithub} boxSize={8} />
</Link>
<Link
/>
<IconLink
icon={BsTwitter}
label="Twitter"
href="https://twitter.com/corbtt"
target="_blank"
color="gray.500"
_hover={{ color: "gray.800" }}
>
<Icon as={BsTwitter} boxSize={8} />
</Link>
</HStack>
/>
</VStack>
</VStack>
);
};

View File

@@ -15,6 +15,7 @@ import {
AlertDialogContent,
AlertDialogOverlay,
useDisclosure,
BreadcrumbLink,
} from "@chakra-ui/react";
import { useRouter } from "next/router";
@@ -120,9 +121,11 @@ export default function Experiment() {
<HStack px={4} py={2}>
<Breadcrumb flex={1}>
<BreadcrumbItem>
<BreadcrumbLink href="/experiments">
<Flex alignItems="center">
<Icon as={RiFlaskLine} boxSize={4} mr={2} /> Experiments
</Flex>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbItem isCurrentPage>
<Input

View File

@@ -0,0 +1,40 @@
import {
SimpleGrid,
HStack,
Icon,
VStack,
Breadcrumb,
BreadcrumbItem,
Flex,
} from "@chakra-ui/react";
import { RiFlaskLine } from "react-icons/ri";
import AppShell from "~/components/nav/AppShell";
import { api } from "~/utils/api";
import { NewExperimentButton } from "~/components/experiments/NewExperimentButton";
import { ExperimentCard } from "~/components/experiments/ExperimentCard";
export default function ExperimentsPage() {
const experiments = api.experiments.list.useQuery();
return (
<AppShell>
<VStack alignItems={"flex-start"} m={4}>
<HStack w="full" justifyContent="space-between" mb={4}>
<Breadcrumb flex={1}>
<BreadcrumbItem>
<Flex alignItems="center">
<Icon as={RiFlaskLine} boxSize={4} mr={2} /> Experiments
</Flex>
</BreadcrumbItem>
</Breadcrumb>
<NewExperimentButton mr={4} borderRadius={8} />
</HStack>
<SimpleGrid w="full" columns={{ sm: 1, md: 2, lg: 3, xl: 4 }} spacing={8} p="4">
{experiments?.data?.map((exp) => (
<ExperimentCard key={exp.id} exp={exp} />
))}
</SimpleGrid>
</VStack>
</AppShell>
);
}

View File

@@ -1,12 +1,15 @@
import { Center } from "@chakra-ui/react";
import AppShell from "~/components/nav/AppShell";
import { type GetServerSideProps } from 'next';
// eslint-disable-next-line @typescript-eslint/require-await
export const getServerSideProps: GetServerSideProps = async (context) => {
return {
redirect: {
destination: '/experiments',
permanent: false,
},
}
}
export default function Home() {
return (
<AppShell>
<Center h="100%">
<div>Select an experiment from the sidebar to get started!</div>
</Center>
</AppShell>
);
return null;
}

View File

@@ -4,11 +4,38 @@ import { prisma } from "~/server/db";
export const experimentsRouter = createTRPCRouter({
list: publicProcedure.query(async () => {
return await prisma.experiment.findMany({
const experiments = await prisma.experiment.findMany({
orderBy: {
sortIndex: "asc",
},
});
// TODO: look for cleaner way to do this. Maybe aggregate?
const experimentsWithCounts = await Promise.all(
experiments.map(async (experiment) => {
const visibleTestScenarioCount = await prisma.testScenario.count({
where: {
experimentId: experiment.id,
visible: true,
},
});
const visiblePromptVariantCount = await prisma.promptVariant.count({
where: {
experimentId: experiment.id,
visible: true,
},
});
return {
...experiment,
testScenarioCount: visibleTestScenarioCount,
promptVariantCount: visiblePromptVariantCount,
};
}),
);
return experimentsWithCounts;
}),
get: publicProcedure.input(z.object({ id: z.string() })).query(async ({ input }) => {

View File

@@ -76,7 +76,7 @@ export const promptVariantsRouter = createTRPCRouter({
})
)
.mutation(async ({ input }) => {
const lastScenario = await prisma.promptVariant.findFirst({
const lastVariant = await prisma.promptVariant.findFirst({
where: {
experimentId: input.experimentId,
visible: true,
@@ -98,16 +98,25 @@ export const promptVariantsRouter = createTRPCRouter({
})
)._max?.sortIndex ?? 0;
const newScenario = await prisma.promptVariant.create({
const newVariant = await prisma.promptVariant.create({
data: {
experimentId: input.experimentId,
label: `Prompt Variant ${largestSortIndex + 2}`,
sortIndex: (lastScenario?.sortIndex ?? 0) + 1,
config: lastScenario?.config ?? {},
sortIndex: (lastVariant?.sortIndex ?? 0) + 1,
config: lastVariant?.config ?? {},
},
});
return newScenario;
await prisma.experiment.update({
where: {
id: input.experimentId,
},
data: {
updatedAt: new Date(),
},
});
return newVariant;
}),
update: publicProcedure

View File

@@ -45,6 +45,15 @@ export const scenariosRouter = createTRPCRouter({
: {},
},
});
await prisma.experiment.update({
where: {
id: input.experimentId,
},
data: {
updatedAt: new Date(),
},
});
}),
hide: publicProcedure.input(z.object({ id: z.string() })).mutation(async ({ input }) => {

20
src/utils/dayjs.ts Normal file
View File

@@ -0,0 +1,20 @@
import dayjs from "dayjs";
import duration from "dayjs/plugin/duration";
import relativeTime from "dayjs/plugin/relativeTime";
dayjs.extend(duration);
dayjs.extend(relativeTime);
export const formatTimePast = (date: Date) => {
const now = dayjs();
const dayDiff = Math.floor(now.diff(date, "day"));
if (dayDiff > 0) return dayjs.duration(-dayDiff, "days").humanize(true);
const hourDiff = Math.floor(now.diff(date, "hour"));
if (hourDiff > 0) return dayjs.duration(-hourDiff, "hours").humanize(true);
const minuteDiff = Math.floor(now.diff(date, "minute"));
if (minuteDiff > 0) return dayjs.duration(-minuteDiff, "minutes").humanize(true);
return 'a few seconds ago'
};