From a45ed071c158ae24978859f639cfbda8040219b4 Mon Sep 17 00:00:00 2001 From: Vyacheslav Matyukhin Date: Tue, 24 Dec 2024 12:20:39 -0300 Subject: [PATCH 01/10] show workflow date --- apps/hub/src/app/ai/WorkflowViewer/index.tsx | 51 +++++++++++--------- 1 file changed, 29 insertions(+), 22 deletions(-) diff --git a/apps/hub/src/app/ai/WorkflowViewer/index.tsx b/apps/hub/src/app/ai/WorkflowViewer/index.tsx index a60e8a7791..b6ce37f65e 100644 --- a/apps/hub/src/app/ai/WorkflowViewer/index.tsx +++ b/apps/hub/src/app/ai/WorkflowViewer/index.tsx @@ -1,9 +1,11 @@ "use client"; -import { FC } from "react"; +import { format } from "date-fns"; +import { Children, FC } from "react"; import { ClientWorkflow } from "@quri/squiggle-ai"; import { StyledTab } from "@quri/ui"; +import { commonDateFormat } from "@/lib/constants"; import { useAvailableHeight } from "@/lib/hooks/useAvailableHeight"; import { LogsView } from "../LogsView"; @@ -18,26 +20,26 @@ type WorkflowViewerProps< workflow: Extract; }; -const StatsDisplay = ({ - runTimeMs, - totalPrice, - llmRunCount, -}: { - runTimeMs: number; - totalPrice: number; - llmRunCount: number; -}) => { +const LineSeparatedList: FC<{ children: React.ReactNode }> = ({ children }) => { + const childrenArray = Children.toArray(children); return ( -
- {(runTimeMs / 1000).toFixed(2)}s - | - ${totalPrice.toFixed(2)} - | - {llmRunCount} LLM runs +
+ {childrenArray.flatMap((child, index) => [ + index > 0 && ( +
+ | +
+ ), +
{child}
, + ])}
); }; +const WorkflowDate: FC<{ workflow: ClientWorkflow }> = ({ workflow }) => { + return {format(workflow.timestamp, commonDateFormat)}; +}; + const FinishedWorkflowViewer: FC> = ({ workflow, }) => { @@ -50,11 +52,12 @@ const FinishedWorkflowViewer: FC> = ({
( - + + {(workflow.result.runTimeMs / 1000).toFixed(2)}s + ${workflow.result.totalPrice.toFixed(2)} + {workflow.result.llmRunCount} LLM runs + + )} renderRight={() => (
@@ -98,7 +101,11 @@ const LoadingWorkflowViewer: FC> = ({
null} + renderLeft={() => ( + + + + )} renderRight={() => null} />
From 821de62feee28f9796cea5f8cee59b11d519deea Mon Sep 17 00:00:00 2001 From: Vyacheslav Matyukhin Date: Tue, 24 Dec 2024 12:23:46 -0300 Subject: [PATCH 02/10] component renames --- .../{StepNode.tsx => StepListItem.tsx} | 14 +++++++------- .../{ClientStepView.tsx => StepView.tsx} | 2 +- .../src/app/ai/WorkflowViewer/WorkflowSteps.tsx | 10 +++++----- 3 files changed, 13 insertions(+), 13 deletions(-) rename apps/hub/src/app/ai/WorkflowViewer/{StepNode.tsx => StepListItem.tsx} (80%) rename apps/hub/src/app/ai/WorkflowViewer/{ClientStepView.tsx => StepView.tsx} (99%) diff --git a/apps/hub/src/app/ai/WorkflowViewer/StepNode.tsx b/apps/hub/src/app/ai/WorkflowViewer/StepListItem.tsx similarity index 80% rename from apps/hub/src/app/ai/WorkflowViewer/StepNode.tsx rename to apps/hub/src/app/ai/WorkflowViewer/StepListItem.tsx index bf970a0008..bec29e36a6 100644 --- a/apps/hub/src/app/ai/WorkflowViewer/StepNode.tsx +++ b/apps/hub/src/app/ai/WorkflowViewer/StepListItem.tsx @@ -6,8 +6,8 @@ import { ClientStep } from "@quri/squiggle-ai"; import { StepStatusIcon } from "../StepStatusIcon"; import { stepNames } from "../utils"; -type StepNodeProps = { - data: ClientStep; +type Props = { + step: ClientStep; onClick?: (event: MouseEvent) => void; isSelected?: boolean; stepNumber: number; @@ -19,8 +19,8 @@ const getStepNodeClassName = (isSelected: boolean) => isSelected ? "bg-slate-200" : "bg-slate-50 hover:bg-slate-100" ); -export const StepNode: FC = ({ - data, +export const StepListItem: FC = ({ + step, onClick, isSelected = false, stepNumber, @@ -30,11 +30,11 @@ export const StepNode: FC = ({
{stepNumber}. - {stepNames[data.name] || data.name} + {stepNames[step.name] || step.name}
- {data.state.kind !== "DONE" && ( + {step.state.kind !== "DONE" && (
- +
)}
diff --git a/apps/hub/src/app/ai/WorkflowViewer/ClientStepView.tsx b/apps/hub/src/app/ai/WorkflowViewer/StepView.tsx similarity index 99% rename from apps/hub/src/app/ai/WorkflowViewer/ClientStepView.tsx rename to apps/hub/src/app/ai/WorkflowViewer/StepView.tsx index be2400a713..f4d43be963 100644 --- a/apps/hub/src/app/ai/WorkflowViewer/ClientStepView.tsx +++ b/apps/hub/src/app/ai/WorkflowViewer/StepView.tsx @@ -32,7 +32,7 @@ const NavButton: FC<{ ); }; -export const ClientStepView: FC<{ +export const StepView: FC<{ step: ClientStep; onSelectPreviousStep?: () => void; onSelectNextStep?: () => void; diff --git a/apps/hub/src/app/ai/WorkflowViewer/WorkflowSteps.tsx b/apps/hub/src/app/ai/WorkflowViewer/WorkflowSteps.tsx index 9b3099a093..023814d29d 100644 --- a/apps/hub/src/app/ai/WorkflowViewer/WorkflowSteps.tsx +++ b/apps/hub/src/app/ai/WorkflowViewer/WorkflowSteps.tsx @@ -2,8 +2,8 @@ import { FC, useEffect, useRef, useState } from "react"; import { ClientWorkflow } from "@quri/squiggle-ai"; -import { ClientStepView } from "./ClientStepView"; -import { StepNode } from "./StepNode"; +import { StepListItem } from "./StepListItem"; +import { StepView } from "./StepView"; export const WorkflowSteps: FC<{ workflow: ClientWorkflow; @@ -53,8 +53,8 @@ export const WorkflowSteps: FC<{
{workflow.steps.map((step, index) => ( - setSelectedStepIndex(index)} isSelected={selectedStepIndex === index} stepNumber={index + 1} @@ -64,7 +64,7 @@ export const WorkflowSteps: FC<{
{hasSelectedStep && ( - Date: Tue, 24 Dec 2024 12:49:41 -0300 Subject: [PATCH 03/10] fix workflow.timestamp; display step durations --- apps/hub/src/ai/data/v1_0.ts | 14 ++++++++++++-- apps/hub/src/app/ai/StepStatusIcon.tsx | 3 +++ apps/hub/src/app/ai/WorkflowStatusIcon.tsx | 9 ++++++++- apps/hub/src/app/ai/WorkflowViewer/StepView.tsx | 13 ++++++++++--- internal-packages/ai/src/types.ts | 3 +++ internal-packages/ai/src/workflows/Workflow.ts | 8 +++++--- internal-packages/ai/src/workflows/streaming.ts | 3 +++ 7 files changed, 44 insertions(+), 9 deletions(-) diff --git a/apps/hub/src/ai/data/v1_0.ts b/apps/hub/src/ai/data/v1_0.ts index c4ba32595c..e034b009e1 100644 --- a/apps/hub/src/ai/data/v1_0.ts +++ b/apps/hub/src/ai/data/v1_0.ts @@ -108,10 +108,20 @@ export function decodeV1_0JsonToClientWorkflow( // modern steps in ClientWorkflow store state as an object state: step.state === "DONE" - ? ({ kind: "DONE", outputs } as const) + ? ({ + kind: "DONE", + outputs, + durationMs: 0, // old workflow steps don't have durationMs + } as const) : step.state === "FAILED" - ? { kind: "FAILED", errorType: "CRITICAL", message: "Unknown" } + ? { + kind: "FAILED", + errorType: "CRITICAL", + message: "Unknown", + durationMs: 0, // old workflow steps don't have durationMs + } : { kind: "PENDING" }, + startTime: v1Workflow.timestamp, // old workflow steps don't have start times })), inputs: input.type === "Create" diff --git a/apps/hub/src/app/ai/StepStatusIcon.tsx b/apps/hub/src/app/ai/StepStatusIcon.tsx index 09da1f4bc4..9c972a0c45 100644 --- a/apps/hub/src/app/ai/StepStatusIcon.tsx +++ b/apps/hub/src/app/ai/StepStatusIcon.tsx @@ -4,6 +4,9 @@ import { ClientStep } from "@quri/squiggle-ai"; import { CheckCircleIcon, ErrorIcon, RefreshIcon, TextTooltip } from "@quri/ui"; export const StepStatusIcon: FC<{ step: ClientStep }> = ({ step }) => { + const ageInSeconds = (new Date().getTime() - step.startTime) / 1000; + const maxLoadingAge = 300; + switch (step.state.kind) { case "PENDING": return ; diff --git a/apps/hub/src/app/ai/WorkflowStatusIcon.tsx b/apps/hub/src/app/ai/WorkflowStatusIcon.tsx index 681a59a2f2..81e263ec90 100644 --- a/apps/hub/src/app/ai/WorkflowStatusIcon.tsx +++ b/apps/hub/src/app/ai/WorkflowStatusIcon.tsx @@ -6,9 +6,16 @@ import { CheckCircleIcon, ErrorIcon, RefreshIcon } from "@quri/ui"; export const WorkflowStatusIcon: FC<{ workflow: ClientWorkflow }> = ({ workflow, }) => { + const ageInSeconds = (new Date().getTime() - workflow.timestamp) / 1000; + const maxLoadingAge = 300; + switch (workflow.status) { case "loading": - return ; + return ageInSeconds < maxLoadingAge ? ( + + ) : ( + + ); case "finished": return workflow.result.isValid ? ( diff --git a/apps/hub/src/app/ai/WorkflowViewer/StepView.tsx b/apps/hub/src/app/ai/WorkflowViewer/StepView.tsx index f4d43be963..b1ba6b6cf2 100644 --- a/apps/hub/src/app/ai/WorkflowViewer/StepView.tsx +++ b/apps/hub/src/app/ai/WorkflowViewer/StepView.tsx @@ -58,9 +58,16 @@ export const StepView: FC<{ ref={ref} >
-

- {stepNames[step.name] || step.name} -

+
+

+ {stepNames[step.name] || step.name} +

+ {step.state.kind === "PENDING" ? null : ( +
+ {(step.state.durationMs / 1000).toFixed(2)}s +
+ )} +
; diff --git a/internal-packages/ai/src/workflows/Workflow.ts b/internal-packages/ai/src/workflows/Workflow.ts index b49d28d707..1172efcfcd 100644 --- a/internal-packages/ai/src/workflows/Workflow.ts +++ b/internal-packages/ai/src/workflows/Workflow.ts @@ -104,8 +104,6 @@ export class Workflow { public readonly inputs: Inputs; public llmConfig: LlmConfig; - // This field is somewhat broken - it's set to `Date.now()`, even when the workflow was deserialized from the database. - // It's better to use `steps[0].startTime` as the start time, if you're sure that the workflow has already started and so it has at least one step. public startTime: number; private steps: LLMStepInstance[]; @@ -119,7 +117,7 @@ export class Workflow { this.inputs = params.inputs; this.llmConfig = params.llmConfig ?? llmConfigDefault; - this.startTime = Date.now(); + this.startTime = params.steps.at(0)?.startTime ?? Date.now(); this.steps = params.steps ?? []; this.llmClient = new LLMClient( @@ -474,6 +472,10 @@ export class Workflow { .map(visitor.step) .map((params) => LLMStepInstance.fromParams(params, workflow)); + if (workflow.steps.length) { + workflow.startTime = workflow.steps[0].startTime; + } + return workflow; } diff --git a/internal-packages/ai/src/workflows/streaming.ts b/internal-packages/ai/src/workflows/streaming.ts index ffcff8de9a..0ea130d9a4 100644 --- a/internal-packages/ai/src/workflows/streaming.ts +++ b/internal-packages/ai/src/workflows/streaming.ts @@ -51,6 +51,7 @@ function getClientState( if (state.kind === "DONE") { return { kind: "DONE", + durationMs: state.durationMs, outputs: Object.fromEntries( Object.entries(state.outputs) .filter( @@ -77,6 +78,7 @@ export function stepToClientStep(step: LLMStepInstance): ClientStep { ]) ), messages: step.getConversationMessages(), + startTime: step.startTime, }; } @@ -125,6 +127,7 @@ export function addStreamingListeners( content: { id: event.data.step.id, name: event.data.step.template.name ?? "unknown", + startTime: event.data.step.startTime, inputs: Object.fromEntries( Object.entries(event.data.step.getInputs()).map(([key, value]) => [ key, From 5d0661cb53e5513fed1505fc72ea54d5cc93ab5f Mon Sep 17 00:00:00 2001 From: Vyacheslav Matyukhin Date: Tue, 24 Dec 2024 13:03:28 -0300 Subject: [PATCH 04/10] use default squiggle for ai, not dev --- apps/hub/src/app/ai/SquigglePlaygroundForWorkflow.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/hub/src/app/ai/SquigglePlaygroundForWorkflow.tsx b/apps/hub/src/app/ai/SquigglePlaygroundForWorkflow.tsx index ff07056d8f..9cba9118e6 100644 --- a/apps/hub/src/app/ai/SquigglePlaygroundForWorkflow.tsx +++ b/apps/hub/src/app/ai/SquigglePlaygroundForWorkflow.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useState } from "react"; import { llmLinker } from "@quri/squiggle-ai"; import { + defaultSquiggleVersion, SquigglePlaygroundVersionPicker, type SquiggleVersion, versionedSquigglePackages, @@ -20,7 +21,9 @@ export function SquigglePlaygroundForWorkflow({ const [squiggle, setSquiggle] = useState< undefined | Awaited> >(); - const [version, setVersion] = useState("dev"); // Later versions are often buggy + const [version, setVersion] = useState( + defaultSquiggleVersion + ); const onVersionChange = (version: SquiggleVersion) => { setVersion(version); @@ -42,7 +45,7 @@ export function SquigglePlaygroundForWorkflow({ (
Date: Tue, 24 Dec 2024 13:08:53 -0300 Subject: [PATCH 05/10] fix extra border in workflow list --- apps/hub/src/app/ai/WorkflowSummaryItem.tsx | 6 ++++-- apps/hub/src/app/ai/WorkflowSummaryList.tsx | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/hub/src/app/ai/WorkflowSummaryItem.tsx b/apps/hub/src/app/ai/WorkflowSummaryItem.tsx index 9648aeea98..600b36f36a 100644 --- a/apps/hub/src/app/ai/WorkflowSummaryItem.tsx +++ b/apps/hub/src/app/ai/WorkflowSummaryItem.tsx @@ -10,12 +10,14 @@ export const WorkflowSummaryItem: FC<{ workflow: ClientWorkflow; onSelect: () => void; isSelected: boolean; -}> = ({ workflow, onSelect, isSelected }) => { + isLast?: boolean; +}> = ({ workflow, onSelect, isSelected, isLast }) => { return (
diff --git a/apps/hub/src/app/ai/WorkflowSummaryList.tsx b/apps/hub/src/app/ai/WorkflowSummaryList.tsx index bc1b723b5a..09ace88e91 100644 --- a/apps/hub/src/app/ai/WorkflowSummaryList.tsx +++ b/apps/hub/src/app/ai/WorkflowSummaryList.tsx @@ -11,12 +11,13 @@ export const WorkflowSummaryList: FC<{ }> = ({ workflows, selectedWorkflow, selectWorkflow }) => { return (
- {workflows.map((workflow) => ( + {workflows.map((workflow, index) => ( selectWorkflow(workflow.id)} isSelected={workflow.id === selectedWorkflow?.id} + isLast={index === workflows.length - 1} /> ))}
From a98fd4ba1f10f5368fc9ac61b24bdc2a0dac7474 Mon Sep 17 00:00:00 2001 From: Vyacheslav Matyukhin Date: Tue, 24 Dec 2024 13:16:43 -0300 Subject: [PATCH 06/10] refactor root auth --- apps/hub/src/app/admin/layout.tsx | 12 ++---------- apps/hub/src/components/WithAuth/index.tsx | 4 ++-- apps/hub/src/users/auth.ts | 4 ++-- 3 files changed, 6 insertions(+), 14 deletions(-) diff --git a/apps/hub/src/app/admin/layout.tsx b/apps/hub/src/app/admin/layout.tsx index 3649116577..d9b803f158 100644 --- a/apps/hub/src/app/admin/layout.tsx +++ b/apps/hub/src/app/admin/layout.tsx @@ -3,19 +3,11 @@ import { PropsWithChildren } from "react"; import { LockIcon } from "@quri/ui"; import { FullLayoutWithPadding } from "@/components/layout/FullLayoutWithPadding"; -import { NarrowPageLayout } from "@/components/layout/NarrowPageLayout"; import { H1 } from "@/components/ui/Headers"; -import { auth } from "@/lib/server/auth"; -import { isRootEmail } from "@/users/auth"; +import { checkRootUser } from "@/users/auth"; export default async function AdminLayout({ children }: PropsWithChildren) { - const session = await auth(); - - const email = session?.user.email; - - if (!email || !isRootEmail(email)) { - return Access denied.; - } + await checkRootUser(); return (
diff --git a/apps/hub/src/components/WithAuth/index.tsx b/apps/hub/src/components/WithAuth/index.tsx index ec5fd6a16b..ab69073f67 100644 --- a/apps/hub/src/components/WithAuth/index.tsx +++ b/apps/hub/src/components/WithAuth/index.tsx @@ -2,7 +2,7 @@ import { FC, PropsWithChildren } from "react"; import { auth } from "@/lib/server/auth"; import { prisma } from "@/lib/server/prisma"; -import { isRootEmail, isSignedIn } from "@/users/auth"; +import { isRootUser, isSignedIn } from "@/users/auth"; import { RedirectToLogin } from "./RedirectToLogin"; @@ -20,7 +20,7 @@ export const WithAuth: FC = async ({ children, rootOnly = false }) => { const user = await prisma.user.findUniqueOrThrow({ where: { email: session.user.email }, }); - if (!(user.email && user.emailVerified && isRootEmail(user.email))) { + if (!isRootUser(user)) { return (
Unauthorized
diff --git a/apps/hub/src/users/auth.ts b/apps/hub/src/users/auth.ts index 3235fb3353..1f6ffc1f8d 100644 --- a/apps/hub/src/users/auth.ts +++ b/apps/hub/src/users/auth.ts @@ -24,7 +24,7 @@ export async function checkRootUser() { const user = await prisma.user.findUniqueOrThrow({ where: { email: sessionUser.email }, }); - if (!(user.email && user.emailVerified && isRootEmail(user.email))) { + if (!isRootUser(user)) { throw new Error("Unauthorized"); } return user as User & { email: NonNullable }; @@ -55,7 +55,7 @@ export async function getSelf(session: SignedInSession) { const ROOT_EMAILS = (process.env["ROOT_EMAILS"] ?? "").split(","); -export function isRootEmail(email: string) { +function isRootEmail(email: string) { return ROOT_EMAILS.includes(email); } From 846aef4ec3adf54f633f91bb36c736a93bffd8bb Mon Sep 17 00:00:00 2001 From: Vyacheslav Matyukhin Date: Tue, 24 Dec 2024 14:20:31 -0300 Subject: [PATCH 07/10] admin mode --- apps/hub/src/ai/data/loadWorkflows.ts | 17 +++-- apps/hub/src/app/ai/Sidebar.tsx | 17 ++--- apps/hub/src/app/ai/WorkflowSummaryList.tsx | 76 ++++++++++++++++--- apps/hub/src/app/ai/page.tsx | 8 +- apps/hub/src/app/ai/useSquiggleWorkflows.tsx | 12 +-- apps/hub/src/components/WithAuth/index.tsx | 4 +- .../src/components/admin/AdminControls.tsx | 42 ++++++++++ .../src/components/admin/AdminProvider.tsx | 34 +++++++++ .../components/layout/RootLayout/PageMenu.tsx | 20 +++-- .../components/layout/RootLayout/index.tsx | 21 +++-- apps/hub/src/users/auth.ts | 21 +++-- 11 files changed, 217 insertions(+), 55 deletions(-) create mode 100644 apps/hub/src/components/admin/AdminControls.tsx create mode 100644 apps/hub/src/components/admin/AdminProvider.tsx diff --git a/apps/hub/src/ai/data/loadWorkflows.ts b/apps/hub/src/ai/data/loadWorkflows.ts index c5e7c20232..444a049aaa 100644 --- a/apps/hub/src/ai/data/loadWorkflows.ts +++ b/apps/hub/src/ai/data/loadWorkflows.ts @@ -1,22 +1,29 @@ -import "server-only"; +import { Prisma } from "@prisma/client"; import { prisma } from "@/lib/server/prisma"; -import { getSessionUserOrRedirect } from "@/users/auth"; +import { checkRootUser, getSessionUserOrRedirect } from "@/users/auth"; import { decodeDbWorkflowToClientWorkflow } from "./storage"; export async function loadWorkflows({ limit = 20, + allUsers = false, }: { limit?: number; + allUsers?: boolean; } = {}) { const sessionUser = await getSessionUserOrRedirect(); + const where: Prisma.AiWorkflowWhereInput = {}; + if (allUsers) { + await checkRootUser(); + } else { + where.user = { email: sessionUser.email }; + } + const rows = await prisma.aiWorkflow.findMany({ orderBy: { createdAt: "desc" }, - where: { - user: { email: sessionUser.email }, - }, + where, take: limit + 1, }); diff --git a/apps/hub/src/app/ai/Sidebar.tsx b/apps/hub/src/app/ai/Sidebar.tsx index c3793dd8b5..56dcf2a75c 100644 --- a/apps/hub/src/app/ai/Sidebar.tsx +++ b/apps/hub/src/app/ai/Sidebar.tsx @@ -20,8 +20,6 @@ import { TextFormField, } from "@quri/ui"; -import { LoadMoreViaSearchParam } from "@/components/LoadMoreViaSearchParam"; - import { AiRequestBody } from "./utils"; import { WorkflowSummaryList } from "./WorkflowSummaryList"; @@ -228,15 +226,12 @@ Outputs: -
-

Workflows

- - {hasMoreWorkflows && } -
+
); diff --git a/apps/hub/src/app/ai/WorkflowSummaryList.tsx b/apps/hub/src/app/ai/WorkflowSummaryList.tsx index 09ace88e91..b40157f53e 100644 --- a/apps/hub/src/app/ai/WorkflowSummaryList.tsx +++ b/apps/hub/src/app/ai/WorkflowSummaryList.tsx @@ -1,25 +1,77 @@ -import { FC } from "react"; +import { useSearchParams } from "next/navigation"; +import { FC, use } from "react"; import { ClientWorkflow } from "@quri/squiggle-ai"; +import { Button, FireIcon } from "@quri/ui"; + +import { AdminContext } from "@/components/admin/AdminProvider"; +import { LoadMoreViaSearchParam } from "@/components/LoadMoreViaSearchParam"; +import { useUpdateSearchParams } from "@/lib/hooks/useUpdateSearchParams"; import { WorkflowSummaryItem } from "./WorkflowSummaryItem"; +const WorkflowListAdminControls = () => { + const { isAdminMode } = use(AdminContext); + const updateSearchParams = useUpdateSearchParams(); + const searchParams = useSearchParams(); + + if (!isAdminMode) { + return null; + } + + return searchParams.get("allUsers") ? ( + + ) : ( + + ); +}; + export const WorkflowSummaryList: FC<{ workflows: ClientWorkflow[]; selectedWorkflow: ClientWorkflow | undefined; selectWorkflow: (id: string) => void; -}> = ({ workflows, selectedWorkflow, selectWorkflow }) => { + hasMoreWorkflows: boolean; +}> = ({ workflows, selectedWorkflow, selectWorkflow, hasMoreWorkflows }) => { return ( -
- {workflows.map((workflow, index) => ( - selectWorkflow(workflow.id)} - isSelected={workflow.id === selectedWorkflow?.id} - isLast={index === workflows.length - 1} - /> - ))} +
+
+

Workflows

+ +
+
+ {workflows.map((workflow, index) => ( + selectWorkflow(workflow.id)} + isSelected={workflow.id === selectedWorkflow?.id} + isLast={index === workflows.length - 1} + /> + ))} +
+ {hasMoreWorkflows && }
); }; diff --git a/apps/hub/src/app/ai/page.tsx b/apps/hub/src/app/ai/page.tsx index b64761300e..71c15ae6dc 100644 --- a/apps/hub/src/app/ai/page.tsx +++ b/apps/hub/src/app/ai/page.tsx @@ -10,13 +10,17 @@ export default async function SessionsPage({ }: { searchParams: Promise<{ [key: string]: string | string[] | undefined }>; }) { - const { limit } = z + const { limit, allUsers } = z .object({ limit: numberInString.optional(), + allUsers: z.string().optional(), // root-only flag }) .parse(await searchParams); - const { workflows, hasMore } = await loadWorkflows({ limit }); + const { workflows, hasMore } = await loadWorkflows({ + limit, + allUsers: !!allUsers, + }); return ( diff --git a/apps/hub/src/app/ai/useSquiggleWorkflows.tsx b/apps/hub/src/app/ai/useSquiggleWorkflows.tsx index 2792eb8695..dfaa03cf26 100644 --- a/apps/hub/src/app/ai/useSquiggleWorkflows.tsx +++ b/apps/hub/src/app/ai/useSquiggleWorkflows.tsx @@ -11,13 +11,15 @@ export function useSquiggleWorkflows(preloadedWorkflows: ClientWorkflow[]) { // `preloadedWorkflows` can change when the user presses the "load more" button useEffect(() => { - setWorkflows((list) => { - if (list === preloadedWorkflows) return list; - const knownWorkflows = new Set(list.map((w) => w.id)); + setWorkflows((workflows) => { + if (workflows === preloadedWorkflows) return workflows; + const knownIds = new Set(workflows.map((w) => w.id)); const newWorkflows = preloadedWorkflows.filter( - (w) => !knownWorkflows.has(w.id) + (w) => !knownIds.has(w.id) + ); + return [...workflows, ...newWorkflows].sort( + (a, b) => b.timestamp - a.timestamp ); - return [...list, ...newWorkflows]; }); }, [preloadedWorkflows]); diff --git a/apps/hub/src/components/WithAuth/index.tsx b/apps/hub/src/components/WithAuth/index.tsx index ab69073f67..3833297626 100644 --- a/apps/hub/src/components/WithAuth/index.tsx +++ b/apps/hub/src/components/WithAuth/index.tsx @@ -2,7 +2,7 @@ import { FC, PropsWithChildren } from "react"; import { auth } from "@/lib/server/auth"; import { prisma } from "@/lib/server/prisma"; -import { isRootUser, isSignedIn } from "@/users/auth"; +import { isAdminUser, isSignedIn } from "@/users/auth"; import { RedirectToLogin } from "./RedirectToLogin"; @@ -20,7 +20,7 @@ export const WithAuth: FC = async ({ children, rootOnly = false }) => { const user = await prisma.user.findUniqueOrThrow({ where: { email: session.user.email }, }); - if (!isRootUser(user)) { + if (!isAdminUser(user)) { return (
Unauthorized
diff --git a/apps/hub/src/components/admin/AdminControls.tsx b/apps/hub/src/components/admin/AdminControls.tsx new file mode 100644 index 0000000000..764bc846ee --- /dev/null +++ b/apps/hub/src/components/admin/AdminControls.tsx @@ -0,0 +1,42 @@ +"use client"; +import clsx from "clsx"; +import { FC, use } from "react"; + +import { FireIcon, useToast } from "@quri/ui"; + +import { AdminContext } from "./AdminProvider"; + +const InnerAdminControls: FC = () => { + const { isAdminMode, setIsAdminMode } = use(AdminContext); + const toast = useToast(); + + const toggleAdminMode = () => { + setIsAdminMode(!isAdminMode); + toast( + "Admin mode " + (isAdminMode ? "disabled" : "enabled"), + "confirmation" + ); + }; + + return ( +
+ +
+ ); +}; + +export const AdminControls: FC = () => { + const { isAdmin } = use(AdminContext); + + if (!isAdmin) { + return null; + } + + return ; +}; diff --git a/apps/hub/src/components/admin/AdminProvider.tsx b/apps/hub/src/components/admin/AdminProvider.tsx new file mode 100644 index 0000000000..23e773a6f7 --- /dev/null +++ b/apps/hub/src/components/admin/AdminProvider.tsx @@ -0,0 +1,34 @@ +"use client"; +import { createContext, FC, PropsWithChildren, useState } from "react"; + +type ContextShape = { + isAdmin: boolean; + isAdminMode: boolean; + setIsAdmin: (isAdmin: boolean) => void; + setIsAdminMode: (mode: boolean) => void; +}; + +export const AdminContext = createContext({ + isAdmin: false, + isAdminMode: false, + setIsAdmin: () => {}, + setIsAdminMode: () => {}, +}); + +export const AdminProvider: FC = ({ children }) => { + // `isAdmin` is initially unknown - we load it dynamically in `WrappedPageMenu` and set to this context. + // This is done for performance reasons - we want to start rendering the page even while the session is loading. + + // The consequence is that all components that rely on this context will re-render when the session is loaded. + + const [isAdmin, setIsAdmin] = useState(false); + const [isAdminMode, setIsAdminMode] = useState(false); + + return ( + + {children} + + ); +}; diff --git a/apps/hub/src/components/layout/RootLayout/PageMenu.tsx b/apps/hub/src/components/layout/RootLayout/PageMenu.tsx index 8a8a82e028..267b3fac10 100644 --- a/apps/hub/src/components/layout/RootLayout/PageMenu.tsx +++ b/apps/hub/src/components/layout/RootLayout/PageMenu.tsx @@ -1,7 +1,7 @@ "use client"; import { Session } from "next-auth"; import { signIn, signOut } from "next-auth/react"; -import { FC, useState } from "react"; +import { FC, use, useEffect, useState } from "react"; import { BoltIcon, @@ -17,6 +17,7 @@ import { UserCircleIcon, } from "@quri/ui"; +import { AdminContext } from "@/components/admin/AdminProvider"; import { GroupCardDTO } from "@/groups/data/groupCards"; import { SQUIGGLE_DOCS_URL } from "@/lib/constants"; import { aboutRoute, aiRoute, newModelRoute } from "@/lib/routes"; @@ -63,6 +64,7 @@ const NewModelMenuLink: FC = (props) => { type MenuProps = { groups: Paginated; session: Session | null; + isAdmin: boolean; }; const DesktopMenu: FC = ({ groups, session }) => { @@ -149,11 +151,19 @@ const MobileMenu: FC = ({ groups, session }) => { ); }; -export const PageMenu: FC = ({ session, groups }) => { +export const PageMenu: FC = (props) => { // TODO - if redirecting, return a custom menu; right now we render the // confused version where "New Model" button is visible, but "Sign In" button // is visible too - const { shouldChoose } = useForceChooseUsername(session); + const { shouldChoose } = useForceChooseUsername(props.session); + + const { setIsAdmin } = use(AdminContext); + + useEffect(() => { + if (props.isAdmin) { + setIsAdmin(true); + } + }, [props.isAdmin, setIsAdmin]); if (shouldChoose) { return ( @@ -164,10 +174,10 @@ export const PageMenu: FC = ({ session, groups }) => { return ( <>
- +
- +
); diff --git a/apps/hub/src/components/layout/RootLayout/index.tsx b/apps/hub/src/components/layout/RootLayout/index.tsx index a96ac6a73b..6da27f62cb 100644 --- a/apps/hub/src/components/layout/RootLayout/index.tsx +++ b/apps/hub/src/components/layout/RootLayout/index.tsx @@ -1,8 +1,11 @@ import { FC, PropsWithChildren, Suspense } from "react"; +import { AdminControls } from "@/components/admin/AdminControls"; +import { AdminProvider } from "@/components/admin/AdminProvider"; import { Link } from "@/components/ui/Link"; import { loadGroupCards } from "@/groups/data/groupCards"; import { auth } from "@/lib/server/auth"; +import { isAdminUser } from "@/users/auth"; import { ReactRoot } from "../../ReactRoot"; import { PageFooterIfNecessary } from "./PageFooterIfNecessary"; @@ -18,16 +21,21 @@ const WrappedPageMenu: FC = async () => { ? await loadGroupCards({ username: session?.user?.username }) : { items: [] }; - return ; + const isAdmin = session?.user ? await isAdminUser(session.user) : false; + + return ; }; const InnerRootLayout: FC = ({ children }) => { return (
- - Squiggle Hub - +
+ + Squiggle Hub + + +
{/* Top menu is not essential for fetching and rendering other content, so we render it in a Suspense boundary */} @@ -49,7 +57,10 @@ const InnerRootLayout: FC = ({ children }) => { export const RootLayout: FC = ({ children }) => { return ( - {children} + {/* TODO - find a way to include AdminProvider in ReactRoot (this would require loading the session in ReactRoot) */} + + {children} + ); }; diff --git a/apps/hub/src/users/auth.ts b/apps/hub/src/users/auth.ts index 1f6ffc1f8d..3a4d2f862d 100644 --- a/apps/hub/src/users/auth.ts +++ b/apps/hub/src/users/auth.ts @@ -21,12 +21,14 @@ export async function getSessionUserOrRedirect() { // Checks if the user is a root user. If so, returns the user. export async function checkRootUser() { const sessionUser = await getSessionUserOrRedirect(); + if (!isAdminUser(sessionUser)) { + throw new Error("Unauthorized"); + } + + // TODO - is this necessary? we usually don't need to load the user from the database. const user = await prisma.user.findUniqueOrThrow({ where: { email: sessionUser.email }, }); - if (!isRootUser(user)) { - throw new Error("Unauthorized"); - } return user as User & { email: NonNullable }; } @@ -53,12 +55,15 @@ export async function getSelf(session: SignedInSession) { return user; } -const ROOT_EMAILS = (process.env["ROOT_EMAILS"] ?? "").split(","); +// Env var is called "ROOT_EMAILS" for historical reasons. +// All other APIs are called "admin" to avoid the confusion because we also use the word "root" in another sense, in React components. +// (e.g. `RootLayout`, `ReactRoot`). +const ADMIN_EMAILS = (process.env["ROOT_EMAILS"] ?? "").split(","); -function isRootEmail(email: string) { - return ROOT_EMAILS.includes(email); +function isAdminEmail(email: string) { + return ADMIN_EMAILS.includes(email); } -export async function isRootUser(user: User) { - return Boolean(user.email && user.emailVerified && isRootEmail(user.email)); +export async function isAdminUser(user: NonNullable) { + return Boolean(user.email && isAdminEmail(user.email)); } From 3d1543fe8d4d5d7ab5f7ae8fc72327ba6bf6eeea Mon Sep 17 00:00:00 2001 From: Vyacheslav Matyukhin Date: Tue, 24 Dec 2024 14:32:05 -0300 Subject: [PATCH 08/10] refactor --- apps/hub/src/app/ai/WorkflowStatusIcon.tsx | 28 ------------ .../WorkflowListAdminControls.tsx | 45 +++++++++++++++++++ .../WorkflowName.tsx | 0 .../WorkflowStatusIcon.tsx | 36 +++++++++++++++ .../WorkflowSummaryItem.tsx | 5 --- .../index.tsx} | 45 +------------------ apps/hub/src/app/ai/useSquiggleWorkflows.tsx | 2 + 7 files changed, 85 insertions(+), 76 deletions(-) delete mode 100644 apps/hub/src/app/ai/WorkflowStatusIcon.tsx create mode 100644 apps/hub/src/app/ai/WorkflowSummaryList/WorkflowListAdminControls.tsx rename apps/hub/src/app/ai/{ => WorkflowSummaryList}/WorkflowName.tsx (100%) create mode 100644 apps/hub/src/app/ai/WorkflowSummaryList/WorkflowStatusIcon.tsx rename apps/hub/src/app/ai/{ => WorkflowSummaryList}/WorkflowSummaryItem.tsx (87%) rename apps/hub/src/app/ai/{WorkflowSummaryList.tsx => WorkflowSummaryList/index.tsx} (53%) diff --git a/apps/hub/src/app/ai/WorkflowStatusIcon.tsx b/apps/hub/src/app/ai/WorkflowStatusIcon.tsx deleted file mode 100644 index 81e263ec90..0000000000 --- a/apps/hub/src/app/ai/WorkflowStatusIcon.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { FC } from "react"; - -import { ClientWorkflow } from "@quri/squiggle-ai"; -import { CheckCircleIcon, ErrorIcon, RefreshIcon } from "@quri/ui"; - -export const WorkflowStatusIcon: FC<{ workflow: ClientWorkflow }> = ({ - workflow, -}) => { - const ageInSeconds = (new Date().getTime() - workflow.timestamp) / 1000; - const maxLoadingAge = 300; - - switch (workflow.status) { - case "loading": - return ageInSeconds < maxLoadingAge ? ( - - ) : ( - - ); - case "finished": - return workflow.result.isValid ? ( - - ) : ( - - ); - case "error": - return ; - } -}; diff --git a/apps/hub/src/app/ai/WorkflowSummaryList/WorkflowListAdminControls.tsx b/apps/hub/src/app/ai/WorkflowSummaryList/WorkflowListAdminControls.tsx new file mode 100644 index 0000000000..69fd00dbe8 --- /dev/null +++ b/apps/hub/src/app/ai/WorkflowSummaryList/WorkflowListAdminControls.tsx @@ -0,0 +1,45 @@ +import { useSearchParams } from "next/navigation"; +import { FC, use } from "react"; + +import { Button, FireIcon } from "@quri/ui"; + +import { AdminContext } from "@/components/admin/AdminProvider"; +import { useUpdateSearchParams } from "@/lib/hooks/useUpdateSearchParams"; + +export const WorkflowListAdminControls: FC = () => { + const { isAdminMode } = use(AdminContext); + const updateSearchParams = useUpdateSearchParams(); + const searchParams = useSearchParams(); + + if (!isAdminMode) { + return null; + } + + return searchParams.get("allUsers") ? ( + + ) : ( + + ); +}; diff --git a/apps/hub/src/app/ai/WorkflowName.tsx b/apps/hub/src/app/ai/WorkflowSummaryList/WorkflowName.tsx similarity index 100% rename from apps/hub/src/app/ai/WorkflowName.tsx rename to apps/hub/src/app/ai/WorkflowSummaryList/WorkflowName.tsx diff --git a/apps/hub/src/app/ai/WorkflowSummaryList/WorkflowStatusIcon.tsx b/apps/hub/src/app/ai/WorkflowSummaryList/WorkflowStatusIcon.tsx new file mode 100644 index 0000000000..dd6fcf7e9f --- /dev/null +++ b/apps/hub/src/app/ai/WorkflowSummaryList/WorkflowStatusIcon.tsx @@ -0,0 +1,36 @@ +import { FC } from "react"; + +import { ClientWorkflow } from "@quri/squiggle-ai"; +import { CheckCircleIcon, ErrorIcon, RefreshIcon } from "@quri/ui"; + +function getWorkflowStatusForIcon( + workflow: ClientWorkflow +): ClientWorkflow["status"] { + const ageInSeconds = (new Date().getTime() - workflow.timestamp) / 1000; + const maxLoadingAge = 300; + + if (workflow.status === "loading") { + return ageInSeconds < maxLoadingAge ? "loading" : "error"; + } + + if (workflow.status === "finished" && !workflow.result.isValid) { + return "error"; + } + + return workflow.status; +} + +export const WorkflowStatusIcon: FC<{ workflow: ClientWorkflow }> = ({ + workflow, +}) => { + const status = getWorkflowStatusForIcon(workflow); + + switch (status) { + case "loading": + return ; + case "finished": + return ; + case "error": + return ; + } +}; diff --git a/apps/hub/src/app/ai/WorkflowSummaryItem.tsx b/apps/hub/src/app/ai/WorkflowSummaryList/WorkflowSummaryItem.tsx similarity index 87% rename from apps/hub/src/app/ai/WorkflowSummaryItem.tsx rename to apps/hub/src/app/ai/WorkflowSummaryList/WorkflowSummaryItem.tsx index 600b36f36a..d65549c1f0 100644 --- a/apps/hub/src/app/ai/WorkflowSummaryItem.tsx +++ b/apps/hub/src/app/ai/WorkflowSummaryList/WorkflowSummaryItem.tsx @@ -29,11 +29,6 @@ export const WorkflowSummaryItem: FC<{
- {workflow.status === "loading" && ( -
-

{workflow.steps.at(-1)?.name}

-
- )}
); }; diff --git a/apps/hub/src/app/ai/WorkflowSummaryList.tsx b/apps/hub/src/app/ai/WorkflowSummaryList/index.tsx similarity index 53% rename from apps/hub/src/app/ai/WorkflowSummaryList.tsx rename to apps/hub/src/app/ai/WorkflowSummaryList/index.tsx index b40157f53e..2c3a7f234b 100644 --- a/apps/hub/src/app/ai/WorkflowSummaryList.tsx +++ b/apps/hub/src/app/ai/WorkflowSummaryList/index.tsx @@ -1,53 +1,12 @@ -import { useSearchParams } from "next/navigation"; -import { FC, use } from "react"; +import { FC } from "react"; import { ClientWorkflow } from "@quri/squiggle-ai"; -import { Button, FireIcon } from "@quri/ui"; -import { AdminContext } from "@/components/admin/AdminProvider"; import { LoadMoreViaSearchParam } from "@/components/LoadMoreViaSearchParam"; -import { useUpdateSearchParams } from "@/lib/hooks/useUpdateSearchParams"; +import { WorkflowListAdminControls } from "./WorkflowListAdminControls"; import { WorkflowSummaryItem } from "./WorkflowSummaryItem"; -const WorkflowListAdminControls = () => { - const { isAdminMode } = use(AdminContext); - const updateSearchParams = useUpdateSearchParams(); - const searchParams = useSearchParams(); - - if (!isAdminMode) { - return null; - } - - return searchParams.get("allUsers") ? ( - - ) : ( - - ); -}; - export const WorkflowSummaryList: FC<{ workflows: ClientWorkflow[]; selectedWorkflow: ClientWorkflow | undefined; diff --git a/apps/hub/src/app/ai/useSquiggleWorkflows.tsx b/apps/hub/src/app/ai/useSquiggleWorkflows.tsx index dfaa03cf26..84a9166b69 100644 --- a/apps/hub/src/app/ai/useSquiggleWorkflows.tsx +++ b/apps/hub/src/app/ai/useSquiggleWorkflows.tsx @@ -20,6 +20,8 @@ export function useSquiggleWorkflows(preloadedWorkflows: ClientWorkflow[]) { return [...workflows, ...newWorkflows].sort( (a, b) => b.timestamp - a.timestamp ); + // TODO - remove the workflows that are no longer in `preloadedWorkflows` + // (This can happen when `allUsers` root mode becomes disabled) }); }, [preloadedWorkflows]); From ca5ebe554b50d090d912b27963c0a4c7bf22be67 Mon Sep 17 00:00:00 2001 From: Vyacheslav Matyukhin Date: Tue, 24 Dec 2024 14:38:21 -0300 Subject: [PATCH 09/10] timed out indicator --- .../WorkflowSummaryList/WorkflowStatusIcon.tsx | 12 ++++++++---- apps/hub/src/app/ai/WorkflowViewer/index.tsx | 17 +++++++++++++++-- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/apps/hub/src/app/ai/WorkflowSummaryList/WorkflowStatusIcon.tsx b/apps/hub/src/app/ai/WorkflowSummaryList/WorkflowStatusIcon.tsx index dd6fcf7e9f..21e9818d0b 100644 --- a/apps/hub/src/app/ai/WorkflowSummaryList/WorkflowStatusIcon.tsx +++ b/apps/hub/src/app/ai/WorkflowSummaryList/WorkflowStatusIcon.tsx @@ -3,14 +3,18 @@ import { FC } from "react"; import { ClientWorkflow } from "@quri/squiggle-ai"; import { CheckCircleIcon, ErrorIcon, RefreshIcon } from "@quri/ui"; +export const maxWorkflowLoadingAge = 300; // 5 minutes + +export function isWorkflowOutdated(workflow: ClientWorkflow): boolean { + const ageInSeconds = (new Date().getTime() - workflow.timestamp) / 1000; + return ageInSeconds > maxWorkflowLoadingAge; +} + function getWorkflowStatusForIcon( workflow: ClientWorkflow ): ClientWorkflow["status"] { - const ageInSeconds = (new Date().getTime() - workflow.timestamp) / 1000; - const maxLoadingAge = 300; - if (workflow.status === "loading") { - return ageInSeconds < maxLoadingAge ? "loading" : "error"; + return isWorkflowOutdated(workflow) ? "error" : "loading"; } if (workflow.status === "finished" && !workflow.result.isValid) { diff --git a/apps/hub/src/app/ai/WorkflowViewer/index.tsx b/apps/hub/src/app/ai/WorkflowViewer/index.tsx index b6ce37f65e..d225064ea7 100644 --- a/apps/hub/src/app/ai/WorkflowViewer/index.tsx +++ b/apps/hub/src/app/ai/WorkflowViewer/index.tsx @@ -3,13 +3,14 @@ import { format } from "date-fns"; import { Children, FC } from "react"; import { ClientWorkflow } from "@quri/squiggle-ai"; -import { StyledTab } from "@quri/ui"; +import { ErrorIcon, StyledTab } from "@quri/ui"; import { commonDateFormat } from "@/lib/constants"; import { useAvailableHeight } from "@/lib/hooks/useAvailableHeight"; import { LogsView } from "../LogsView"; import { SquigglePlaygroundForWorkflow } from "../SquigglePlaygroundForWorkflow"; +import { isWorkflowOutdated } from "../WorkflowSummaryList/WorkflowStatusIcon"; import { Header } from "./Header"; import { PublishWorkflowButton } from "./PublishWorkflowButton"; import { WorkflowSteps } from "./WorkflowSteps"; @@ -106,7 +107,19 @@ const LoadingWorkflowViewer: FC> = ({ )} - renderRight={() => null} + renderRight={() => { + if (isWorkflowOutdated(workflow)) { + return ( +
+ + + Timed Out + +
+ ); + } + return null; + }} />
From d9393c2ef6607bda1713e0f943b3c1f43d1333f8 Mon Sep 17 00:00:00 2001 From: Vyacheslav Matyukhin Date: Tue, 24 Dec 2024 16:19:27 -0300 Subject: [PATCH 10/10] author in new AiWorkflow type; refactor load more; show user icon on not-owned workflows --- apps/hub/src/ai/data/loadWorkflows.ts | 58 ++++++++++--- apps/hub/src/app/ai/AiDashboard.tsx | 23 ++--- apps/hub/src/app/ai/Sidebar.tsx | 22 ++--- .../WorkflowSummaryItem.tsx | 31 +++++-- .../src/app/ai/WorkflowSummaryList/index.tsx | 21 +++-- apps/hub/src/app/ai/page.tsx | 12 +-- apps/hub/src/app/ai/useSquiggleWorkflows.tsx | 84 ++++++++++++------- .../src/components/LoadMoreViaSearchParam.tsx | 3 + .../components/layout/RootLayout/index.tsx | 2 +- apps/hub/src/lib/hooks/usePaginator.ts | 12 +-- apps/hub/src/users/auth.ts | 8 +- 11 files changed, 169 insertions(+), 107 deletions(-) diff --git a/apps/hub/src/ai/data/loadWorkflows.ts b/apps/hub/src/ai/data/loadWorkflows.ts index 444a049aaa..196e0f221f 100644 --- a/apps/hub/src/ai/data/loadWorkflows.ts +++ b/apps/hub/src/ai/data/loadWorkflows.ts @@ -1,21 +1,34 @@ import { Prisma } from "@prisma/client"; +import { ClientWorkflow } from "@quri/squiggle-ai"; + import { prisma } from "@/lib/server/prisma"; +import { Paginated } from "@/lib/types"; import { checkRootUser, getSessionUserOrRedirect } from "@/users/auth"; import { decodeDbWorkflowToClientWorkflow } from "./storage"; -export async function loadWorkflows({ - limit = 20, - allUsers = false, -}: { - limit?: number; - allUsers?: boolean; -} = {}) { +export type AiWorkflow = { + workflow: ClientWorkflow; + author: { + username: string; + }; +}; + +export async function loadWorkflows( + params: { + allUsers?: boolean; + cursor?: string; + limit?: number; + } = {} +): Promise> { const sessionUser = await getSessionUserOrRedirect(); + const limit = params.limit ?? 20; + const where: Prisma.AiWorkflowWhereInput = {}; - if (allUsers) { + if (params.allUsers) { + console.log("loading all workflows"); await checkRootUser(); } else { where.user = { email: sessionUser.email }; @@ -23,14 +36,37 @@ export async function loadWorkflows({ const rows = await prisma.aiWorkflow.findMany({ orderBy: { createdAt: "desc" }, + cursor: params.cursor ? { id: params.cursor } : undefined, where, + include: { + user: { + select: { + asOwner: { + select: { + slug: true, + }, + }, + }, + }, + }, take: limit + 1, }); - const workflows = rows.map((row) => decodeDbWorkflowToClientWorkflow(row)); + // TODO - it would be good to preserve author information in the client, but this would require a new type (ClientWorkflowWithAuthor?) + const workflows = rows.map((row) => ({ + workflow: decodeDbWorkflowToClientWorkflow(row), + author: { username: row.user.asOwner?.slug ?? "[unknown]" }, + })); + + const nextCursor = workflows[workflows.length - 1]?.workflow.id; + + async function loadMore(limit: number) { + "use server"; + return loadWorkflows({ ...params, cursor: nextCursor, limit }); + } return { - workflows: limit ? workflows.slice(0, limit) : workflows, - hasMore: limit ? workflows.length > limit : false, + items: workflows.slice(0, limit), + loadMore: workflows.length > limit ? loadMore : undefined, }; } diff --git a/apps/hub/src/app/ai/AiDashboard.tsx b/apps/hub/src/app/ai/AiDashboard.tsx index 178ae28d6b..1239f24910 100644 --- a/apps/hub/src/app/ai/AiDashboard.tsx +++ b/apps/hub/src/app/ai/AiDashboard.tsx @@ -2,23 +2,24 @@ import { FC, useRef } from "react"; -import { ClientWorkflow } from "@quri/squiggle-ai"; +import { AiWorkflow } from "@/ai/data/loadWorkflows"; +import { usePaginator } from "@/lib/hooks/usePaginator"; +import { Paginated } from "@/lib/types"; import { Sidebar } from "./Sidebar"; import { useSquiggleWorkflows } from "./useSquiggleWorkflows"; import { WorkflowViewer } from "./WorkflowViewer"; type Props = { - initialWorkflows: ClientWorkflow[]; - hasMoreWorkflows: boolean; + initialWorkflows: Paginated; }; -export const AiDashboard: FC = ({ - initialWorkflows, - hasMoreWorkflows, -}: Props) => { +export const AiDashboard: FC = ({ initialWorkflows }: Props) => { + const { items: unpaginatedWorkflows, loadNext } = + usePaginator(initialWorkflows); + const { workflows, submitWorkflow, selectedWorkflow, selectWorkflow } = - useSquiggleWorkflows(initialWorkflows); + useSquiggleWorkflows(unpaginatedWorkflows); const sidebarRef = useRef<{ edit: (code: string) => void }>(null); @@ -31,7 +32,7 @@ export const AiDashboard: FC = ({ selectWorkflow={selectWorkflow} selectedWorkflow={selectedWorkflow} workflows={workflows} - hasMoreWorkflows={hasMoreWorkflows} + loadNext={loadNext} ref={sidebarRef} />
@@ -39,8 +40,8 @@ export const AiDashboard: FC = ({ {selectedWorkflow && (
)} diff --git a/apps/hub/src/app/ai/Sidebar.tsx b/apps/hub/src/app/ai/Sidebar.tsx index 56dcf2a75c..4ff249e207 100644 --- a/apps/hub/src/app/ai/Sidebar.tsx +++ b/apps/hub/src/app/ai/Sidebar.tsx @@ -10,7 +10,7 @@ import { } from "react"; import { FormProvider, useForm } from "react-hook-form"; -import { ClientWorkflow, LlmId, MODEL_CONFIGS } from "@quri/squiggle-ai"; +import { LlmId, MODEL_CONFIGS } from "@quri/squiggle-ai"; import { Button, NumberFormField, @@ -20,6 +20,8 @@ import { TextFormField, } from "@quri/ui"; +import { AiWorkflow } from "@/ai/data/loadWorkflows"; + import { AiRequestBody } from "./utils"; import { WorkflowSummaryList } from "./WorkflowSummaryList"; @@ -30,9 +32,9 @@ type Handle = { type Props = { submitWorkflow: (requestBody: AiRequestBody) => void; selectWorkflow: (id: string) => void; - selectedWorkflow: ClientWorkflow | undefined; - workflows: ClientWorkflow[]; - hasMoreWorkflows: boolean; + selectedWorkflow: AiWorkflow | undefined; + workflows: AiWorkflow[]; + loadNext?: (count: number) => void; }; type FormShape = { @@ -45,13 +47,7 @@ type FormShape = { }; export const Sidebar = forwardRef(function Sidebar( - { - submitWorkflow, - selectWorkflow, - selectedWorkflow, - workflows, - hasMoreWorkflows, - }, + { submitWorkflow, selectWorkflow, selectedWorkflow, workflows, loadNext }, ref ) { const form = useForm({ @@ -80,7 +76,7 @@ Outputs: useEffect(() => { if (workflows.length > prevWorkflowsLengthRef.current) { - selectWorkflow(workflows[0].id); + selectWorkflow(workflows[0].workflow.id); prevWorkflowsLengthRef.current = workflows.length; } }, [workflows, selectWorkflow]); @@ -228,7 +224,7 @@ Outputs: diff --git a/apps/hub/src/app/ai/WorkflowSummaryList/WorkflowSummaryItem.tsx b/apps/hub/src/app/ai/WorkflowSummaryList/WorkflowSummaryItem.tsx index d65549c1f0..7b1c19bd1b 100644 --- a/apps/hub/src/app/ai/WorkflowSummaryList/WorkflowSummaryItem.tsx +++ b/apps/hub/src/app/ai/WorkflowSummaryList/WorkflowSummaryItem.tsx @@ -1,17 +1,22 @@ import clsx from "clsx"; +import { useSession } from "next-auth/react"; import { FC } from "react"; -import { ClientWorkflow } from "@quri/squiggle-ai"; +import { TextTooltip, UserIcon } from "@quri/ui"; + +import { AiWorkflow } from "@/ai/data/loadWorkflows"; import { WorkflowName } from "./WorkflowName"; import { WorkflowStatusIcon } from "./WorkflowStatusIcon"; export const WorkflowSummaryItem: FC<{ - workflow: ClientWorkflow; + workflow: AiWorkflow; onSelect: () => void; isSelected: boolean; isLast?: boolean; }> = ({ workflow, onSelect, isSelected, isLast }) => { + const session = useSession(); + return (
-
-
- -
-
- +
+
+
+ +
+
+ +
+ {session.data?.user?.username && + session.data?.user?.username !== workflow.author.username && ( + +
+ +
+
+ )}
); diff --git a/apps/hub/src/app/ai/WorkflowSummaryList/index.tsx b/apps/hub/src/app/ai/WorkflowSummaryList/index.tsx index 2c3a7f234b..a29190a994 100644 --- a/apps/hub/src/app/ai/WorkflowSummaryList/index.tsx +++ b/apps/hub/src/app/ai/WorkflowSummaryList/index.tsx @@ -1,18 +1,17 @@ import { FC } from "react"; -import { ClientWorkflow } from "@quri/squiggle-ai"; - -import { LoadMoreViaSearchParam } from "@/components/LoadMoreViaSearchParam"; +import { AiWorkflow } from "@/ai/data/loadWorkflows"; +import { LoadMore } from "@/components/LoadMore"; import { WorkflowListAdminControls } from "./WorkflowListAdminControls"; import { WorkflowSummaryItem } from "./WorkflowSummaryItem"; export const WorkflowSummaryList: FC<{ - workflows: ClientWorkflow[]; - selectedWorkflow: ClientWorkflow | undefined; + workflows: AiWorkflow[]; + selectedWorkflow: AiWorkflow | undefined; selectWorkflow: (id: string) => void; - hasMoreWorkflows: boolean; -}> = ({ workflows, selectedWorkflow, selectWorkflow, hasMoreWorkflows }) => { + loadNext?: (count: number) => void; +}> = ({ workflows, selectedWorkflow, selectWorkflow, loadNext }) => { return (
@@ -22,15 +21,15 @@ export const WorkflowSummaryList: FC<{
{workflows.map((workflow, index) => ( selectWorkflow(workflow.id)} - isSelected={workflow.id === selectedWorkflow?.id} + onSelect={() => selectWorkflow(workflow.workflow.id)} + isSelected={workflow.workflow.id === selectedWorkflow?.workflow.id} isLast={index === workflows.length - 1} /> ))}
- {hasMoreWorkflows && } + {loadNext && }
); }; diff --git a/apps/hub/src/app/ai/page.tsx b/apps/hub/src/app/ai/page.tsx index 71c15ae6dc..5e912e8359 100644 --- a/apps/hub/src/app/ai/page.tsx +++ b/apps/hub/src/app/ai/page.tsx @@ -1,7 +1,7 @@ +import { SessionProvider } from "next-auth/react"; import { z } from "zod"; import { loadWorkflows } from "@/ai/data/loadWorkflows"; -import { numberInString } from "@/lib/zodUtils"; import { AiDashboard } from "./AiDashboard"; @@ -10,19 +10,19 @@ export default async function SessionsPage({ }: { searchParams: Promise<{ [key: string]: string | string[] | undefined }>; }) { - const { limit, allUsers } = z + const { allUsers } = z .object({ - limit: numberInString.optional(), allUsers: z.string().optional(), // root-only flag }) .parse(await searchParams); - const { workflows, hasMore } = await loadWorkflows({ - limit, + const page = await loadWorkflows({ allUsers: !!allUsers, }); return ( - + + + ); } diff --git a/apps/hub/src/app/ai/useSquiggleWorkflows.tsx b/apps/hub/src/app/ai/useSquiggleWorkflows.tsx index 84a9166b69..eb2602bbd4 100644 --- a/apps/hub/src/app/ai/useSquiggleWorkflows.tsx +++ b/apps/hub/src/app/ai/useSquiggleWorkflows.tsx @@ -1,27 +1,32 @@ +import { useSession } from "next-auth/react"; import { useCallback, useEffect, useState } from "react"; import { ClientWorkflow, decodeWorkflowFromReader } from "@quri/squiggle-ai"; +import { AiWorkflow } from "@/ai/data/loadWorkflows"; + import { AiRequestBody, bodyToLineReader } from "./utils"; -export function useSquiggleWorkflows(preloadedWorkflows: ClientWorkflow[]) { - const [workflows, setWorkflows] = - useState(preloadedWorkflows); +export function useSquiggleWorkflows(preloadedWorkflows: AiWorkflow[]) { + const [workflows, setWorkflows] = useState(preloadedWorkflows); const [selected, setSelected] = useState(undefined); + const session = useSession(); + // `preloadedWorkflows` can change when the user presses the "load more" button useEffect(() => { setWorkflows((workflows) => { if (workflows === preloadedWorkflows) return workflows; - const knownIds = new Set(workflows.map((w) => w.id)); + const knownIds = new Set(workflows.map((w) => w.workflow.id)); const newWorkflows = preloadedWorkflows.filter( - (w) => !knownIds.has(w.id) + (w) => !knownIds.has(w.workflow.id) ); return [...workflows, ...newWorkflows].sort( - (a, b) => b.timestamp - a.timestamp + (a, b) => b.workflow.timestamp - a.workflow.timestamp ); - // TODO - remove the workflows that are no longer in `preloadedWorkflows` - // (This can happen when `allUsers` root mode becomes disabled) + // TODO - remove the workflows that are no longer in `preloadedWorkflows`? + // This can happen when `allUsers` root mode becomes disabled. + // OTOH, if we have a mock workflow in the list, this could be confusing. }); }, [preloadedWorkflows]); @@ -29,39 +34,50 @@ export function useSquiggleWorkflows(preloadedWorkflows: ClientWorkflow[]) { (id: string, update: (workflow: ClientWorkflow) => ClientWorkflow) => { setWorkflows((workflows) => workflows.map((workflow) => { - return workflow.id === id ? update(workflow) : workflow; + return workflow.workflow.id === id + ? { ...workflow, workflow: update(workflow.workflow) } + : workflow; }) ); }, [] ); - const addMockWorkflow = useCallback((request: AiRequestBody) => { - // This will be replaced with a real workflow once we receive the first message from the server. - const id = `loading-${Date.now().toString()}`; - const workflow: ClientWorkflow = { - id, - timestamp: new Date().getTime(), - status: "loading", - inputs: { - prompt: { - id: "prompt", - kind: "prompt", - value: request.kind === "create" ? request.prompt : "Improving...", + const addMockWorkflow = useCallback( + (request: AiRequestBody) => { + // This will be replaced with the real workflow once we receive the first message from the server. + const id = `loading-${Date.now().toString()}`; + const workflow: AiWorkflow = { + workflow: { + id, + timestamp: new Date().getTime(), + status: "loading", + inputs: { + prompt: { + id: "prompt", + kind: "prompt", + value: + request.kind === "create" ? request.prompt : "Improving...", + }, + }, + steps: [], + }, + author: { + username: session.data?.user?.name ?? "Unknown", }, - }, - steps: [], - }; - setWorkflows((workflows) => [workflow, ...workflows]); - setSelected(0); - return workflow; - }, []); + }; + setWorkflows((workflows) => [workflow, ...workflows]); + setSelected(0); + return workflow; + }, + [session] + ); const submitWorkflow = useCallback( async (request: AiRequestBody) => { // Add a mock workflow to show loading state while we wait for the server to respond. - // It will be replaced by the real workflow once we receive the first message from the server. - let id = addMockWorkflow(request).id; + // It will be replaced with the real workflow once we receive the first message from the server. + let id = addMockWorkflow(request).workflow.id; try { const response = await fetch("/ai/api/create", { @@ -82,7 +98,9 @@ export function useSquiggleWorkflows(preloadedWorkflows: ClientWorkflow[]) { addWorkflow: async (workflow) => { // Replace the mock workflow with the real workflow. setWorkflows((workflows) => - workflows.map((w) => (w.id === id ? workflow : w)) + workflows.map((w) => + w.workflow.id === id ? { ...w, workflow } : w + ) ); id = workflow.id; }, @@ -104,7 +122,9 @@ export function useSquiggleWorkflows(preloadedWorkflows: ClientWorkflow[]) { const selectWorkflow = useCallback( (id: string) => { - const index = workflows.findIndex((workflow) => workflow.id === id); + const index = workflows.findIndex( + (workflow) => workflow.workflow.id === id + ); setSelected(index === -1 ? undefined : index); }, [workflows] diff --git a/apps/hub/src/components/LoadMoreViaSearchParam.tsx b/apps/hub/src/components/LoadMoreViaSearchParam.tsx index f00d7e71cf..b9e54ea7c5 100644 --- a/apps/hub/src/components/LoadMoreViaSearchParam.tsx +++ b/apps/hub/src/components/LoadMoreViaSearchParam.tsx @@ -8,6 +8,9 @@ type Props = { param?: string; }; +// Currently unused, you should prefer to use paginator pattern (pass around an +// action closure that can load new data) which doesn't use search params, in +// most cases. export const LoadMoreViaSearchParam: FC = ({ param = "limit" }) => { const updateSearchParams = useUpdateSearchParams(); diff --git a/apps/hub/src/components/layout/RootLayout/index.tsx b/apps/hub/src/components/layout/RootLayout/index.tsx index 6da27f62cb..cb6cba2566 100644 --- a/apps/hub/src/components/layout/RootLayout/index.tsx +++ b/apps/hub/src/components/layout/RootLayout/index.tsx @@ -21,7 +21,7 @@ const WrappedPageMenu: FC = async () => { ? await loadGroupCards({ username: session?.user?.username }) : { items: [] }; - const isAdmin = session?.user ? await isAdminUser(session.user) : false; + const isAdmin = session?.user ? isAdminUser(session.user) : false; return ; }; diff --git a/apps/hub/src/lib/hooks/usePaginator.ts b/apps/hub/src/lib/hooks/usePaginator.ts index 18bf1f5403..12ed40dca0 100644 --- a/apps/hub/src/lib/hooks/usePaginator.ts +++ b/apps/hub/src/lib/hooks/usePaginator.ts @@ -32,14 +32,10 @@ export function usePaginator(initialPage: Paginated): FullPaginated { }, []); const update = useCallback((update: (item: T) => T) => { - setPage(({ items, loadMore }) => { - const newItems = { - items: items.map(update), - loadMore, - }; - console.log(newItems); - return newItems; - }); + setPage(({ items, loadMore }) => ({ + items: items.map(update), + loadMore, + })); }, []); return { diff --git a/apps/hub/src/users/auth.ts b/apps/hub/src/users/auth.ts index 3a4d2f862d..5852154c42 100644 --- a/apps/hub/src/users/auth.ts +++ b/apps/hub/src/users/auth.ts @@ -60,10 +60,6 @@ export async function getSelf(session: SignedInSession) { // (e.g. `RootLayout`, `ReactRoot`). const ADMIN_EMAILS = (process.env["ROOT_EMAILS"] ?? "").split(","); -function isAdminEmail(email: string) { - return ADMIN_EMAILS.includes(email); -} - -export async function isAdminUser(user: NonNullable) { - return Boolean(user.email && isAdminEmail(user.email)); +export function isAdminUser(user: NonNullable) { + return Boolean(user.email && ADMIN_EMAILS.includes(user.email)); }