diff --git a/@types/nextjs-routes.d.ts b/@types/nextjs-routes.d.ts index d16d76e..18f5d78 100644 --- a/@types/nextjs-routes.d.ts +++ b/@types/nextjs-routes.d.ts @@ -13,6 +13,7 @@ declare module "nextjs-routes" { export type Route = | StaticRoute<"/account/signin"> | DynamicRoute<"/api/auth/[...nextauth]", { "nextauth": string[] }> + | StaticRoute<"/api/experiments/og-image"> | DynamicRoute<"/api/trpc/[trpc]", { "trpc": string }> | DynamicRoute<"/experiments/[id]", { "id": string }> | StaticRoute<"/experiments"> diff --git a/package.json b/package.json index 1c1eab3..c1a9320 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "@trpc/next": "^10.26.0", "@trpc/react-query": "^10.26.0", "@trpc/server": "^10.26.0", + "@vercel/og": "^0.5.9", "ast-types": "^0.14.2", "chroma-js": "^2.4.2", "concurrently": "^8.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3da9779..d4881b0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,4 +1,4 @@ -lockfileVersion: '6.1' +lockfileVersion: '6.0' settings: autoInstallPeers: true @@ -65,6 +65,9 @@ dependencies: '@trpc/server': specifier: ^10.26.0 version: 10.26.0 + '@vercel/og': + specifier: ^0.5.9 + version: 0.5.9 ast-types: specifier: ^0.14.2 version: 0.14.2 @@ -2650,10 +2653,24 @@ packages: resolution: {integrity: sha512-PDNlhP/1vyTgmNyiucGqGCdXIp7HIkkvKO50si3y3PcceeHvqtiKPaH1iJdz63jCWMVMbj2MElSxXPOeBvEVIQ==} requiresBuild: true + /@resvg/resvg-wasm@2.4.1: + resolution: {integrity: sha512-yi6R0HyHtsoWTRA06Col4WoDs7SvlXU3DLMNP2bdAgs7HK18dTEVl1weXgxRzi8gwLteGUbIg29zulxIB3GSdg==} + engines: {node: '>= 10'} + dev: false + /@rushstack/eslint-patch@1.3.2: resolution: {integrity: sha512-V+MvGwaHH03hYhY+k6Ef/xKd6RYlc4q8WBx+2ANmipHJcKuktNcI/NgEsJgdSUF6Lw32njT6OnrRsKYCdgHjYw==} dev: true + /@shuding/opentype.js@1.4.0-beta.0: + resolution: {integrity: sha512-3NgmNyH3l/Hv6EvsWJbsvpcpUba6R8IREQ83nH83cyakCw7uM1arZKNfHwv1Wz6jgqrF/j4x5ELvR6PnK9nTcA==} + engines: {node: '>= 8.0.0'} + hasBin: true + dependencies: + fflate: 0.7.4 + string.prototype.codepointat: 0.2.1 + dev: false + /@sinclair/typebox@0.27.8: resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} dev: true @@ -3183,6 +3200,15 @@ packages: eslint-visitor-keys: 3.4.1 dev: true + /@vercel/og@0.5.9: + resolution: {integrity: sha512-CtjaV/BVHtNCjRtxGqn8Q6AKFLqcG34Byxr91+mY+4eqyp/09LVe9jEeY9WXjbaKvu8syWPMteTpY+YQUQYzSg==} + engines: {node: '>=16'} + dependencies: + '@resvg/resvg-wasm': 2.4.1 + satori: 0.10.1 + yoga-wasm-web: 0.3.3 + dev: false + /@vitest/expect@0.33.0: resolution: {integrity: sha512-sVNf+Gla3mhTCxNJx+wJLDPp/WcstOe0Ksqz4Vec51MmgMth/ia0MGFEkIZmVGeTL5HtjYR4Wl/ZxBxBXZJTzQ==} dependencies: @@ -3629,6 +3655,11 @@ packages: resolution: {integrity: sha512-Y5gU45svrR5tI2Vt/X9GPd3L0HNIKzGu202EjxrXMpuc2V2CiKgemAbUUsqYmZJvPtCXoUKjNZwBJzsNScUbXA==} dev: false + /base64-js@0.0.8: + resolution: {integrity: sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==} + engines: {node: '>= 0.4'} + dev: false + /base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} dev: false @@ -3750,6 +3781,10 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} + /camelize@1.0.1: + resolution: {integrity: sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==} + dev: false + /caniuse-lite@1.0.30001517: resolution: {integrity: sha512-Vdhm5S11DaFVLlyiKu4hiUTkpZu+y1KA/rZZqVQfOD5YdDT/eQKlkt7NaE0WGOFgX32diqt9MiP9CAiFeRklaA==} @@ -4018,12 +4053,33 @@ packages: resolution: {integrity: sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==} dev: false + /css-background-parser@0.1.0: + resolution: {integrity: sha512-2EZLisiZQ+7m4wwur/qiYJRniHX4K5Tc9w93MT3AS0WS1u5kaZ4FKXlOTBhOjc+CgEgPiGY+fX1yWD8UwpEqUA==} + dev: false + /css-box-model@1.2.1: resolution: {integrity: sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw==} dependencies: tiny-invariant: 1.3.1 dev: false + /css-box-shadow@1.0.0-3: + resolution: {integrity: sha512-9jaqR6e7Ohds+aWwmhe6wILJ99xYQbfmK9QQB9CcMjDbTxPZjwEmUQpU91OG05Xgm8BahT5fW+svbsQGjS/zPg==} + dev: false + + /css-color-keywords@1.0.0: + resolution: {integrity: sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==} + engines: {node: '>=4'} + dev: false + + /css-to-react-native@3.2.0: + resolution: {integrity: sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==} + dependencies: + camelize: 1.0.1 + css-color-keywords: 1.0.0 + postcss-value-parser: 4.2.0 + dev: false + /csstype@2.6.21: resolution: {integrity: sha512-Z1PhmomIfypOpoMjRQB70jfvy/wxT50qW08YXO5lMIJkrdq4yOTR+AW7FqutScmB9NkLwxo+jU+kZLbofZZq/w==} dev: false @@ -4224,6 +4280,10 @@ packages: /electron-to-chromium@1.4.465: resolution: {integrity: sha512-XQcuHvEJRMU97UJ75e170mgcITZoz0lIyiaVjk6R+NMTJ8KBIvUHYd1779swgOppUlzxR+JsLpq59PumaXS1jQ==} + /emoji-regex@10.2.1: + resolution: {integrity: sha512-97g6QgOk8zlDRdgq1WxwgTMgEWGVAQvB5Fdpgc1MkNy56la5SKP9GsMXKDOdqwn90/41a8yPwIGk1Y6WVbeMQA==} + dev: false + /emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} dev: false @@ -4939,6 +4999,10 @@ packages: resolution: {integrity: sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==} dev: false + /fflate@0.7.4: + resolution: {integrity: sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw==} + dev: false + /file-entry-cache@6.0.1: resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} engines: {node: ^10.12.0 || >=12.0.0} @@ -5324,6 +5388,11 @@ packages: space-separated-tokens: 1.1.5 dev: false + /hex-rgb@4.3.0: + resolution: {integrity: sha512-Ox1pJVrDCyGHMG9CFg1tmrRUMRPRsAWYc/PinY0XzJU4K7y7vjNoLKIQ7BR5UJMCxNN8EM1MNDmHWA/B3aZUuw==} + engines: {node: '>=6'} + dev: false + /highlight.js@10.7.3: resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} dev: false @@ -5789,6 +5858,13 @@ packages: type-check: 0.4.0 dev: true + /linebreak@1.1.0: + resolution: {integrity: sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==} + dependencies: + base64-js: 0.0.8 + unicode-trie: 2.0.0 + dev: false + /lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} dev: false @@ -6393,12 +6469,23 @@ packages: resolution: {integrity: sha512-HAKu/fG3HpHFO0AA8WE8q2g+gBJaZ9MG7fcKk+IJPLTGAD6Psw4443l+9DGRbOIh3/aXr7Phy0TjilYivJo5XQ==} dev: false + /pako@0.2.9: + resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==} + dev: false + /parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} dependencies: callsites: 3.1.0 + /parse-css-color@0.2.1: + resolution: {integrity: sha512-bwS/GGIFV3b6KS4uwpzCFj4w297Yl3uqnSgIPsoQkx7GMLROXfMnWvxfNkL0oh8HVhZA4hvJoEoEIqonfJ3BWg==} + dependencies: + color-name: 1.1.4 + hex-rgb: 4.3.0 + dev: false + /parse-entities@2.0.0: resolution: {integrity: sha512-kkywGpCcRYhqQIchaWqZ875wzpS/bMKhz5HnN3p7wveJTkTtyAB/AlnS0f8DFSqYW1T82t6yEAkEcB+A1I3MbQ==} dependencies: @@ -6565,6 +6652,10 @@ packages: engines: {node: '>=4'} dev: false + /postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + dev: false + /postcss@8.4.14: resolution: {integrity: sha512-E398TUmfAYFPBSdzgeieK2Y1+1cpdxJx8yXbK/m57nRhKSmk1GB2tO4lbLBtlkfPQTDKfe4Xqv1ASWPpayPEig==} engines: {node: ^10 || ^12 || >=14} @@ -7141,6 +7232,22 @@ packages: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} dev: false + /satori@0.10.1: + resolution: {integrity: sha512-F4bTCkDp931tLb7+UCNPBuSQwXhikrUkI4fBQo6fA8lF0Evqqgg3nDyUpRktQpR5Ry1DIiIVqLyEwkAms87ykg==} + engines: {node: '>=16'} + dependencies: + '@shuding/opentype.js': 1.4.0-beta.0 + css-background-parser: 0.1.0 + css-box-shadow: 1.0.0-3 + css-to-react-native: 3.2.0 + emoji-regex: 10.2.1 + escape-html: 1.0.3 + linebreak: 1.1.0 + parse-css-color: 0.2.1 + postcss-value-parser: 4.2.0 + yoga-wasm-web: 0.3.3 + dev: false + /scheduler@0.23.0: resolution: {integrity: sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==} dependencies: @@ -7368,6 +7475,10 @@ packages: strip-ansi: 6.0.1 dev: false + /string.prototype.codepointat@0.2.1: + resolution: {integrity: sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg==} + dev: false + /string.prototype.matchall@4.0.8: resolution: {integrity: sha512-6zOCOcJ+RJAQshcTvXPHoxoQGONa3e/Lqx90wUA+wEzX78sg5Bo+1tQo4N0pohS0erG9qtCqJDjNCQBjeWVxyg==} dependencies: @@ -7594,6 +7705,10 @@ packages: globrex: 0.1.2 dev: true + /tiny-inflate@1.0.3: + resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} + dev: false + /tiny-invariant@1.3.1: resolution: {integrity: sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==} dev: false @@ -7800,6 +7915,13 @@ packages: busboy: 1.6.0 dev: true + /unicode-trie@2.0.0: + resolution: {integrity: sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==} + dependencies: + pako: 0.2.9 + tiny-inflate: 1.0.3 + dev: false + /unpipe@1.0.0: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} @@ -8305,6 +8427,10 @@ packages: engines: {node: '>=12.20'} dev: true + /yoga-wasm-web@0.3.3: + resolution: {integrity: sha512-N+d4UJSJbt/R3wqY7Coqs5pcV0aUj2j9IaQ3rNj9bVCLld8tTGKRa2USARjnvZJWVx1NDmQev8EknoczaOQDOA==} + dev: false + /zod@3.21.4: resolution: {integrity: sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==} dev: false diff --git a/public/fonts/Inconsolata_SemiExpanded-Medium.ttf b/public/fonts/Inconsolata_SemiExpanded-Medium.ttf new file mode 100644 index 0000000..78c634e Binary files /dev/null and b/public/fonts/Inconsolata_SemiExpanded-Medium.ttf differ diff --git a/public/og.png b/public/og.png new file mode 100644 index 0000000..a4bee04 Binary files /dev/null and b/public/og.png differ diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index dc05f68..dde7e61 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -21,6 +21,14 @@ const MyApp: AppType<{ session: Session | null }> = ({ name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0" /> + + + + + diff --git a/src/pages/api/experiments/og-image.tsx b/src/pages/api/experiments/og-image.tsx new file mode 100644 index 0000000..5246dcd --- /dev/null +++ b/src/pages/api/experiments/og-image.tsx @@ -0,0 +1,81 @@ +import { ImageResponse } from "@vercel/og"; +import { type NextApiRequest, type NextApiResponse } from "next"; + +export const config = { + runtime: "experimental-edge", +}; + +const inconsolataRegularFontP = fetch( + new URL("../../../../public/fonts/Inconsolata_SemiExpanded-Medium.ttf", import.meta.url), +).then((res) => res.arrayBuffer()); + +const OgImage = async (req: NextApiRequest, res: NextApiResponse) => { + // @ts-expect-error - nextUrl is not defined on NextApiRequest for some reason + const searchParams = req.nextUrl?.searchParams as URLSearchParams; + const experimentLabel = searchParams.get("experimentLabel"); + const variantsCount = searchParams.get("variantsCount"); + const scenariosCount = searchParams.get("scenariosCount"); + + const inconsolataRegularFont = await inconsolataRegularFontP; + + return new ImageResponse( + ( +
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + OpenPipe Logo +
OpenPipe
+
+ +
{experimentLabel}
+
+
+ Variants: {variantsCount} +
+
+ Scenarios: {scenariosCount} +
+
+
+ ), + { + fonts: [ + { + name: "inconsolata", + data: inconsolataRegularFont, + style: "normal", + weight: 400, + }, + ], + }, + ); +}; + +export default OgImage; diff --git a/src/pages/experiments/[id].tsx b/src/pages/experiments/[id].tsx index 0157fdc..6f490a2 100644 --- a/src/pages/experiments/[id].tsx +++ b/src/pages/experiments/[id].tsx @@ -10,6 +10,9 @@ import { VStack, } from "@chakra-ui/react"; import Link from "next/link"; +import { type GetServerSidePropsContext } from "next"; +import { createServerSideHelpers } from "@trpc/react-query/server"; +import superjson from "superjson"; import { useRouter } from "next/router"; import { useState, useEffect } from "react"; @@ -22,13 +25,43 @@ import { useExperiment, useHandledAsyncCallback } from "~/utils/hooks"; import { useAppStore } from "~/state/store"; import { useSyncVariantEditor } from "~/state/sync"; import { HeaderButtons } from "~/components/experiments/HeaderButtons/HeaderButtons"; +import Head from "next/head"; +import { appRouter } from "~/server/api/root.router"; +import { createInnerTRPCContext } from "~/server/api/trpc"; + +export const getServerSideProps = async (context: GetServerSidePropsContext<{ id: string }>) => { + const experimentId = context.params?.id as string; + + const helpers = createServerSideHelpers({ + router: appRouter, + ctx: createInnerTRPCContext({ session: null }), + transformer: superjson, // optional - adds superjson serialization + }); + + // prefetch query + await helpers.experiments.stats.prefetch({ id: experimentId }); + + return { + props: { + trpcState: helpers.dehydrate(), + }, + }; +}; export default function Experiment() { const router = useRouter(); - const experiment = useExperiment(); const utils = api.useContext(); useSyncVariantEditor(); + const experiment = useExperiment(); + const experimentStats = api.experiments.stats.useQuery( + { id: router.query.id as string }, + { + enabled: !!router.query.id, + }, + ); + const stats = experimentStats.data; + useEffect(() => { useAppStore.getState().sharedVariantEditor.loadMonaco().catch(console.error); }); @@ -62,53 +95,65 @@ export default function Experiment() { const canModify = experiment.data?.access.canModify ?? false; return ( - - - - - - - - Experiments - - - - - {canModify ? ( - setLabel(e.target.value)} - onBlur={onSaveLabel} - borderWidth={1} - borderColor="transparent" - fontSize={16} - px={0} - minW={{ base: 100, lg: 300 }} - flex={1} - _hover={{ borderColor: "gray.300" }} - _focus={{ borderColor: "blue.500", outline: "none" }} - /> - ) : ( - - {experiment.data?.label} - - )} - - - - - - - - - - + <> + {stats && ( + + + + + )} + + + + + + + + Experiments + + + + + {canModify ? ( + setLabel(e.target.value)} + onBlur={onSaveLabel} + borderWidth={1} + borderColor="transparent" + fontSize={16} + px={0} + minW={{ base: 100, lg: 300 }} + flex={1} + _hover={{ borderColor: "gray.300" }} + _focus={{ borderColor: "blue.500", outline: "none" }} + /> + ) : ( + + {experiment.data?.label} + + )} + + + + + + + + + + + ); } diff --git a/src/server/api/routers/experiments.router.ts b/src/server/api/routers/experiments.router.ts index 26f87ff..f4fa8c4 100644 --- a/src/server/api/routers/experiments.router.ts +++ b/src/server/api/routers/experiments.router.ts @@ -15,6 +15,33 @@ import userOrg from "~/server/utils/userOrg"; import generateTypes from "~/modelProviders/generateTypes"; export const experimentsRouter = createTRPCRouter({ + stats: publicProcedure.input(z.object({ id: z.string() })).query(async ({ input, ctx }) => { + await requireCanViewExperiment(input.id, ctx); + + const [experiment, promptVariantCount, testScenarioCount] = await prisma.$transaction([ + prisma.experiment.findFirstOrThrow({ + where: { id: input.id }, + }), + prisma.promptVariant.count({ + where: { + experimentId: input.id, + visible: true, + }, + }), + prisma.testScenario.count({ + where: { + experimentId: input.id, + visible: true, + }, + }), + ]); + + return { + experimentLabel: experiment.label, + promptVariantCount, + testScenarioCount, + }; + }), list: protectedProcedure.query(async ({ ctx }) => { // Anyone can list experiments requireNothing(ctx); diff --git a/src/server/api/trpc.ts b/src/server/api/trpc.ts index e795857..f9ba180 100644 --- a/src/server/api/trpc.ts +++ b/src/server/api/trpc.ts @@ -40,7 +40,7 @@ const noOp = () => {}; * * @see https://create.t3.gg/en/usage/trpc#-serverapitrpcts */ -const createInnerTRPCContext = (opts: CreateContextOptions) => { +export const createInnerTRPCContext = (opts: CreateContextOptions) => { return { session: opts.session, prisma,