diff --git a/apps/web/src/actions/datasets/generateDataset.ts b/apps/web/src/actions/datasets/generateDataset.ts index 06d926aebb..38da40b61d 100644 --- a/apps/web/src/actions/datasets/generateDataset.ts +++ b/apps/web/src/actions/datasets/generateDataset.ts @@ -1,84 +1,34 @@ 'use server' import { z } from 'zod' -import { BadRequestError } from '@latitude-data/constants/errors' -import { env } from '@latitude-data/env' -import { createSdk } from '$/app/(private)/_lib/createSdk' -import { getCurrentUserOrRedirect } from '$/services/auth/getCurrentUser' import { authProcedure } from '$/actions/procedures' +import { generateDatasetWithCopilot } from '@latitude-data/core/services/datasets/generateWithCopilot' import { createDatasetFromJson } from '@latitude-data/core/services/datasets/createFromJson' -import { - ChainStepResponse, - CLOUD_MESSAGES, - LogSources, -} from '@latitude-data/core/constants' export const generateDatasetAction = authProcedure .inputSchema( z.object({ parameters: z.string(), - description: z.string(), + description: z.string().optional(), rowCount: z.number(), name: z.string(), }), ) - .action(async ({ parsedInput }) => { - if (!env.LATITUDE_CLOUD) { - throw new BadRequestError(CLOUD_MESSAGES.generateDatasets) - } - if (!env.COPILOT_PROJECT_ID) { - throw new BadRequestError('COPILOT_PROJECT_ID is not set') - } - if (!env.COPILOT_PROMPT_DATASET_GENERATOR_PATH) { - throw new BadRequestError( - 'COPILOT_PROMPT_DATASET_GENERATOR_PATH is not set', - ) - } - if (!env.COPILOT_WORKSPACE_API_KEY) { - throw new BadRequestError('COPILOT_WORKSPACE_API_KEY is not set') - } + .action(async ({ parsedInput, ctx }) => { + const { parameters, description, rowCount, name } = parsedInput - const { user, workspace } = await getCurrentUserOrRedirect() - const sdk = await createSdk({ - workspace, - apiKey: env.COPILOT_WORKSPACE_API_KEY, - projectId: env.COPILOT_PROJECT_ID, - __internal: { source: LogSources.Playground }, + const generatedDatasetContent = await generateDatasetWithCopilot({ + parameters, + description, + rowCount, }).then((r) => r.unwrap()) - const sdkResponse = await sdk.prompts.run<{}>( - env.COPILOT_PROMPT_DATASET_GENERATOR_PATH, - { - stream: false, - parameters: { - row_count: parsedInput.rowCount, - parameters: parsedInput.parameters, - user_message: parsedInput.description, - }, - }, - ) - const sdkResult = sdkResponse - - if (!sdkResult) { - throw new BadRequestError( - 'Something went wrong generating the Dataset preview', - ) - } - - const response = sdkResult.response as ChainStepResponse<'object'> - const name = parsedInput.name - const result = await createDatasetFromJson({ - author: user, - workspace, + return await createDatasetFromJson({ + author: ctx.user, + workspace: ctx.workspace, data: { name, - rows: response.object.rows, + rows: JSON.stringify(generatedDatasetContent.rows), }, - }) - - if (result.error) { - throw result.error - } - - return result.value + }).then((r) => r.unwrap()) }) diff --git a/apps/web/src/actions/datasets/generateOnboardingDataset.ts b/apps/web/src/actions/datasets/generateOnboardingDataset.ts new file mode 100644 index 0000000000..2a5ece9d6d --- /dev/null +++ b/apps/web/src/actions/datasets/generateOnboardingDataset.ts @@ -0,0 +1,50 @@ +'use server' + +import { z } from 'zod' +import { authProcedure } from '$/actions/procedures' +import { generateDatasetWithCopilot } from '@latitude-data/core/services/datasets/generateWithCopilot' +import { + SAMPLE_PROMPT, + SAMPLE_PROMPT_DATASET, +} from '$/app/(onboarding)/onboarding-dataset/paste-your-prompt/constants' +import { createDatasetFromJson } from '@latitude-data/core/services/datasets/createFromJson' + +export const generateOnboardingDatasetAction = authProcedure + .inputSchema( + z.object({ + parameters: z.string(), + prompt: z.string().optional(), + rowCount: z.number(), + name: z.string(), + }), + ) + .action(async ({ parsedInput, ctx }) => { + const { parameters, prompt, rowCount, name } = parsedInput + + if (prompt === SAMPLE_PROMPT) { + // Caching the sample prompt dataset to avoid generating it every time + return await createDatasetFromJson({ + author: ctx.user, + workspace: ctx.workspace, + data: { + name, + rows: JSON.stringify(SAMPLE_PROMPT_DATASET), + }, + }).then((r) => r.unwrap()) + } + + const generatedDatasetContent = await generateDatasetWithCopilot({ + parameters, + prompt, + rowCount, + }).then((r) => r.unwrap()) + + return await createDatasetFromJson({ + author: ctx.user, + workspace: ctx.workspace, + data: { + name, + rows: JSON.stringify(generatedDatasetContent.rows), + }, + }).then((r) => r.unwrap()) + }) diff --git a/apps/web/src/actions/sdk/generateDatasetPreviewAction.ts b/apps/web/src/actions/sdk/generateDatasetPreviewAction.ts index 1da781fed8..1f1e5736a1 100644 --- a/apps/web/src/actions/sdk/generateDatasetPreviewAction.ts +++ b/apps/web/src/actions/sdk/generateDatasetPreviewAction.ts @@ -1,11 +1,8 @@ 'use server' -import { BadRequestError } from '@latitude-data/constants/errors' -import { env } from '@latitude-data/env' -import { createSdk } from '$/app/(private)/_lib/createSdk' import { generatePreviewRowsFromJson } from '@latitude-data/core/services/datasetRows/generatePreviewRowsFromJson' import { authProcedure } from '$/actions/procedures' import { z } from 'zod' -import { CLOUD_MESSAGES, LogSources } from '@latitude-data/core/constants' +import { generateDatasetWithCopilot } from '@latitude-data/core/services/datasets/generateWithCopilot' export const generateDatasetPreviewAction = authProcedure .inputSchema( @@ -14,62 +11,21 @@ export const generateDatasetPreviewAction = authProcedure parameters: z.string(), }), ) - .action(async ({ parsedInput, ctx }) => { - if (!env.LATITUDE_CLOUD) { - throw new BadRequestError(CLOUD_MESSAGES.generateDatasets) - } - - if (!env.COPILOT_PROJECT_ID) { - throw new BadRequestError('COPILOT_PROJECT_ID is not set') - } - if (!env.COPILOT_PROMPT_DATASET_GENERATOR_PATH) { - throw new BadRequestError( - 'COPILOT_PROMPT_DATASET_GENERATOR_PATH is not set', - ) - } - if (!env.COPILOT_WORKSPACE_API_KEY) { - throw new BadRequestError('COPILOT_WORKSPACE_API_KEY is not set') - } - const sdk = await createSdk({ - workspace: ctx.workspace, - apiKey: env.COPILOT_WORKSPACE_API_KEY, - projectId: env.COPILOT_PROJECT_ID, - __internal: { source: LogSources.Playground }, + .action(async ({ parsedInput }) => { + const generatedDatasetContent = await generateDatasetWithCopilot({ + parameters: parsedInput.parameters, + description: parsedInput.description, + rowCount: 10, }).then((r) => r.unwrap()) - const result = await sdk.prompts.run<{ - rows: string - explanation: string - }>(env.COPILOT_PROMPT_DATASET_GENERATOR_PATH, { - stream: false, - parameters: { - row_count: 10, - parameters: parsedInput.parameters, - user_message: parsedInput.description, - }, - }) - if (!result) { - throw new BadRequestError( - 'Something went wrong generating the CSV preview', - ) - } - - const response = result.response - - if (response.streamType !== 'object') { - throw new BadRequestError( - 'Generated AI response for CSV preview is not valid', - ) - } const parseResult = generatePreviewRowsFromJson({ - rows: response.object.rows, + rows: JSON.stringify(generatedDatasetContent.rows), }) const { headers, rows } = parseResult.unwrap() - const explanation = response.object.explanation as string return { headers, rows, - explanation, + explanation: generatedDatasetContent.explanation, } }) diff --git a/apps/web/src/actions/user/setupAction.ts b/apps/web/src/actions/user/setupAction.ts index 58afdd325d..7a8703f8ac 100644 --- a/apps/web/src/actions/user/setupAction.ts +++ b/apps/web/src/actions/user/setupAction.ts @@ -10,6 +10,11 @@ import { unsafelyFindUserByEmail } from '@latitude-data/core/data-access/users' import { errorHandlingProcedure } from '../procedures' import { frontendRedirect } from '$/lib/frontendRedirect' import { UserTitle } from '@latitude-data/constants/users' +import { isFeatureEnabledByName } from '@latitude-data/core/services/workspaceFeatures/isFeatureEnabledByName' +import { Result } from '@latitude-data/core/lib/Result' +import { env } from '@latitude-data/env' +import { markWorkspaceOnboardingComplete } from '@latitude-data/core/services/workspaceOnboarding/update' +import { getWorkspaceOnboarding } from '@latitude-data/core/services/workspaceOnboarding/get' export const setupAction = errorHandlingProcedure .inputSchema( @@ -55,6 +60,34 @@ export const setupAction = errorHandlingProcedure // If there is no returnTo or its NOT a clone action url, redirect to the setup form if (!parsedInput.returnTo || !isCloneActionUrl(parsedInput.returnTo)) { + const isDatasetOnboardingEnabledResult = await isFeatureEnabledByName( + workspace.id, + 'datasetOnboarding', + ) + + if (!Result.isOk(isDatasetOnboardingEnabledResult)) { + return frontendRedirect(ROUTES.dashboard.root) + } + const isDatasetOnboardingEnabled = + isDatasetOnboardingEnabledResult.unwrap() + + const isCloud = !!env.LATITUDE_CLOUD + + if (isDatasetOnboardingEnabled) { + if (isCloud) { + return frontendRedirect(ROUTES.onboarding.dataset.pasteYourPrompt) + } + // If user is self-hosted and they're in the new dataset onboarding, we complete the onboarding and redirect to the dashboard as they cannot generate the dataset with copilot + const onboarding = await getWorkspaceOnboarding({ + workspace, + }).then((r) => r.unwrap()) + + await markWorkspaceOnboardingComplete({ + onboarding, + }).then((r) => r.unwrap()) + + return frontendRedirect(ROUTES.dashboard.root) + } return frontendRedirect(ROUTES.auth.setup.form) } diff --git a/apps/web/src/actions/workspaceOnboarding/complete.ts b/apps/web/src/actions/workspaceOnboarding/complete.ts index ab28bb87ef..75f8955ee9 100644 --- a/apps/web/src/actions/workspaceOnboarding/complete.ts +++ b/apps/web/src/actions/workspaceOnboarding/complete.ts @@ -6,6 +6,8 @@ import { authProcedure } from '../procedures' import { frontendRedirect } from '$/lib/frontendRedirect' import { ROUTES } from '$/services/routes' import { z } from 'zod' +import { isFeatureEnabledByName } from '@latitude-data/core/services/workspaceFeatures/isFeatureEnabledByName' +import { Result } from '@latitude-data/core/lib/Result' /** * Mark onboarding as complete @@ -40,6 +42,24 @@ export const completeOnboardingAction = authProcedure ) } + const isDatasetOnboardingEnabledResult = await isFeatureEnabledByName( + ctx.workspace.id, + 'datasetOnboarding', + ) + + if (!Result.isOk(isDatasetOnboardingEnabledResult)) { + return frontendRedirect(ROUTES.dashboard.root) + } + const isDatasetOnboardingEnabled = isDatasetOnboardingEnabledResult.unwrap() + + if (isDatasetOnboardingEnabled) { + return frontendRedirect( + ROUTES.projects + .detail({ id: projectId }) + .commits.detail({ uuid: commitUuid }).runs.root, + ) + } + // Default redirect to the project's home page return frontendRedirect( ROUTES.projects diff --git a/apps/web/src/app/(onboarding)/onboarding-agents/start/lib/OnboardingStep.tsx b/apps/web/src/app/(onboarding)/_lib/OnboardingStep.tsx similarity index 74% rename from apps/web/src/app/(onboarding)/onboarding-agents/start/lib/OnboardingStep.tsx rename to apps/web/src/app/(onboarding)/_lib/OnboardingStep.tsx index cb9c874a1c..a42586ece5 100644 --- a/apps/web/src/app/(onboarding)/onboarding-agents/start/lib/OnboardingStep.tsx +++ b/apps/web/src/app/(onboarding)/_lib/OnboardingStep.tsx @@ -25,3 +25,15 @@ export const OnboardingStep = { Body: OnboardingStepBody, Header: OnboardingStepHeader, } + +export const DatasetOnboardingStepRoot = ({ + children, +}: { + children: ReactNode +}) => { + return ( +
+ {children} +
+ ) +} diff --git a/apps/web/src/app/(onboarding)/onboarding-agents/start/_components/OnboardingClient/ConfigureTriggers/index.tsx b/apps/web/src/app/(onboarding)/onboarding-agents/start/_components/OnboardingClient/ConfigureTriggers/index.tsx index df2197a20e..e47da37081 100644 --- a/apps/web/src/app/(onboarding)/onboarding-agents/start/_components/OnboardingClient/ConfigureTriggers/index.tsx +++ b/apps/web/src/app/(onboarding)/onboarding-agents/start/_components/OnboardingClient/ConfigureTriggers/index.tsx @@ -14,7 +14,7 @@ import { import { ConfiguredTriggers } from './_components/ConfiguredTriggers' import { IsLoadingOnboardingItem } from '../../../lib/IsLoadingOnboardingItem' import { OnboardingStepKey } from '@latitude-data/constants/onboardingSteps' -import { OnboardingStep } from '../../../lib/OnboardingStep' +import { OnboardingStep } from '../../../../../_lib/OnboardingStep' export function ConfigureTriggersHeader() { const { project } = useCurrentProject() diff --git a/apps/web/src/app/(onboarding)/onboarding-agents/start/_components/OnboardingClient/RunAgent/index.tsx b/apps/web/src/app/(onboarding)/onboarding-agents/start/_components/OnboardingClient/RunAgent/index.tsx index bdde7c96e5..1025afd9e3 100644 --- a/apps/web/src/app/(onboarding)/onboarding-agents/start/_components/OnboardingClient/RunAgent/index.tsx +++ b/apps/web/src/app/(onboarding)/onboarding-agents/start/_components/OnboardingClient/RunAgent/index.tsx @@ -7,7 +7,7 @@ import { useCurrentCommit } from '$/app/providers/CommitProvider' import { useCurrentProject } from '$/app/providers/ProjectProvider' import { useAutoScroll } from '@latitude-data/web-ui/hooks/useAutoScroll' import { StatusIndicator } from '$/components/PlaygroundCommon/StatusIndicator' -import { OnboardingStep } from '../../../lib/OnboardingStep' +import { OnboardingStep } from '../../../../../_lib/OnboardingStep' import { usePlayground } from '../../../lib/PlaygroundProvider' import { IsLoadingOnboardingItem } from '../../../lib/IsLoadingOnboardingItem' import { useCurrentWorkspace } from '$/app/providers/WorkspaceProvider' diff --git a/apps/web/src/app/(onboarding)/onboarding-agents/start/_components/OnboardingClient/SetupIntegrations/index.tsx b/apps/web/src/app/(onboarding)/onboarding-agents/start/_components/OnboardingClient/SetupIntegrations/index.tsx index edbc75cad0..6bc6957af2 100644 --- a/apps/web/src/app/(onboarding)/onboarding-agents/start/_components/OnboardingClient/SetupIntegrations/index.tsx +++ b/apps/web/src/app/(onboarding)/onboarding-agents/start/_components/OnboardingClient/SetupIntegrations/index.tsx @@ -10,7 +10,7 @@ import { import { ConfiguredIntegrations } from '$/components/Integrations/ConfiguredIntegrations' import { IsLoadingOnboardingItem } from '../../../lib/IsLoadingOnboardingItem' import { OnboardingStepKey } from '@latitude-data/constants/onboardingSteps' -import { OnboardingStep } from '../../../lib/OnboardingStep' +import { OnboardingStep } from '../../../../../_lib/OnboardingStep' export function SetupIntegrationsHeader() { const { data: integrations } = useIntegrations() diff --git a/apps/web/src/app/(onboarding)/onboarding-agents/start/_components/OnboardingClient/TriggerAgent/index.tsx b/apps/web/src/app/(onboarding)/onboarding-agents/start/_components/OnboardingClient/TriggerAgent/index.tsx index 56a520c30c..5fe0bc93f8 100644 --- a/apps/web/src/app/(onboarding)/onboarding-agents/start/_components/OnboardingClient/TriggerAgent/index.tsx +++ b/apps/web/src/app/(onboarding)/onboarding-agents/start/_components/OnboardingClient/TriggerAgent/index.tsx @@ -10,7 +10,7 @@ import { RunTrigger } from './_components/RunTrigger' import useIntegrations from '$/stores/integrations' import { IsLoadingOnboardingItem } from '../../../lib/IsLoadingOnboardingItem' import { OnboardingStepKey } from '@latitude-data/constants/onboardingSteps' -import { OnboardingStep } from '../../../lib/OnboardingStep' +import { OnboardingStep } from '../../../../../_lib/OnboardingStep' import { usePlayground } from '../../../lib/PlaygroundProvider' import { RunDocumentProps } from '$/components/TriggersManagement/types' import { AgentInput } from '$/components/Agent/AgentInput' diff --git a/apps/web/src/app/(onboarding)/onboarding-agents/start/_components/OnboardingClient/index.tsx b/apps/web/src/app/(onboarding)/onboarding-agents/start/_components/OnboardingClient/index.tsx index cb6c5fea7d..de978e25fc 100644 --- a/apps/web/src/app/(onboarding)/onboarding-agents/start/_components/OnboardingClient/index.tsx +++ b/apps/web/src/app/(onboarding)/onboarding-agents/start/_components/OnboardingClient/index.tsx @@ -14,7 +14,7 @@ import { import { TriggerAgentHeader, TriggerAgentBody } from './TriggerAgent' import { useState } from 'react' import { RunAgentHeader, RunAgentBody } from './RunAgent' -import { OnboardingStep } from '$/app/(onboarding)/onboarding-agents/start/lib/OnboardingStep' +import { OnboardingStep } from '$/app/(onboarding)/_lib/OnboardingStep' import { PlaygroundProvider } from '../../lib/PlaygroundProvider' import { MetadataProvider } from '$/components/MetadataProvider' import { User } from '@latitude-data/core/schema/models/types/User' diff --git a/apps/web/src/app/(onboarding)/onboarding-dataset/_components/OnboardingEditor/index.tsx b/apps/web/src/app/(onboarding)/onboarding-dataset/_components/OnboardingEditor/index.tsx new file mode 100644 index 0000000000..a56656695c --- /dev/null +++ b/apps/web/src/app/(onboarding)/onboarding-dataset/_components/OnboardingEditor/index.tsx @@ -0,0 +1,56 @@ +import { useCurrentCommit } from '$/app/providers/CommitProvider' +import { useCurrentProject } from '$/app/providers/ProjectProvider' +import { useCurrentDocument } from '$/app/providers/DocumentProvider' +import { useDocumentValue } from '$/hooks/useDocumentValueContext' +import { memo, useCallback } from 'react' +import { toast } from '@latitude-data/web-ui/atoms/Toast' +import { useIncludabledPrompts } from '$/app/(private)/projects/[projectId]/versions/[commitUuid]/documents/[documentUuid]/_components/DocumentEditor/Editor/BlocksEditor/useIncludabledPrompts' +import { BlockRootNode, BlocksEditor } from '$/components/BlocksEditor' +import { EMPTY_ROOT_BLOCK } from '$/components/BlocksEditor/Editor/state/promptlToLexical/fromAstToBlocks' + +export const OnboardingEditor = memo( + ({ + readOnly, + initialValue, + }: { + readOnly: boolean + initialValue: BlockRootNode + }) => { + const { project } = useCurrentProject() + const { commit } = useCurrentCommit() + const { updateDocumentContent } = useDocumentValue() + const { document } = useCurrentDocument() + + const onError = useCallback((error: Error) => { + toast({ + variant: 'destructive', + title: 'Error during edition', + description: error.message, + }) + }, []) + + const prompts = useIncludabledPrompts({ + project, + commit, + document, + documents: [document], + }) + return ( +
+ +
+ ) + }, +) diff --git a/apps/web/src/app/(onboarding)/onboarding-dataset/_components/OnboardingHeader/index.tsx b/apps/web/src/app/(onboarding)/onboarding-dataset/_components/OnboardingHeader/index.tsx new file mode 100644 index 0000000000..8008c1a6de --- /dev/null +++ b/apps/web/src/app/(onboarding)/onboarding-dataset/_components/OnboardingHeader/index.tsx @@ -0,0 +1,19 @@ +import { Icon } from '@latitude-data/web-ui/atoms/Icons' +import { Text } from '@latitude-data/web-ui/atoms/Text' +import { User } from '@latitude-data/core/schema/models/types/User' + +export default function OnboardingHeader({ user }: { user: User }) { + return ( +
+ +
+ + Welcome to Latitude + + + Hello {user.name || 'there'}! Let's help you get started + +
+
+ ) +} diff --git a/apps/web/src/app/(onboarding)/onboarding-dataset/_components/SimpleDatasetTable/index.tsx b/apps/web/src/app/(onboarding)/onboarding-dataset/_components/SimpleDatasetTable/index.tsx new file mode 100644 index 0000000000..61ac64f4cb --- /dev/null +++ b/apps/web/src/app/(onboarding)/onboarding-dataset/_components/SimpleDatasetTable/index.tsx @@ -0,0 +1,75 @@ +import { TableSkeleton } from '@latitude-data/web-ui/molecules/TableSkeleton' +import { + Table, + TableHeader, + TableRow, + TableHead, + TableBody, + TableCell, +} from '@latitude-data/web-ui/atoms/Table' +import useDatasetRows from '$/stores/datasetRows' +import { useMemo } from 'react' +import useDatasets from '$/stores/datasets' +import { Dataset } from '@latitude-data/core/schema/models/types/Dataset' + +export default function SimpleDatasetTable({ + numberOfRows, + onlyShowSkeleton = false, + documentParameters, + latestDataset, +}: { + numberOfRows: number + onlyShowSkeleton?: boolean + documentParameters: string[] + latestDataset?: Dataset +}) { + const { generateIsLoading, isLoading: isLoadingDatasets } = useDatasets() + + const { data: rows, isLoading: isLoadingRows } = useDatasetRows({ + dataset: latestDataset, + pageSize: numberOfRows.toString(), + }) + + const onboardingDatasetColumns = useMemo(() => { + return latestDataset?.columns ?? [] + }, [latestDataset]) + + const isLoadingDataset = useMemo(() => { + return generateIsLoading || isLoadingRows || isLoadingDatasets + }, [generateIsLoading, isLoadingRows, isLoadingDatasets]) + + if (isLoadingDataset || onlyShowSkeleton) { + return ( +
+ +
+
+ ) + } + + return ( +
+ + + + {documentParameters.map((parameter) => ( + {parameter} + ))} + + + + {rows?.map((row) => ( + + {onboardingDatasetColumns?.map((column) => ( + + {row.processedRowData[column.identifier] ?? ''} + + ))} + + ))} + +
+
+
+ ) +} diff --git a/apps/web/src/app/(onboarding)/onboarding-dataset/datasetOnboarding.ts b/apps/web/src/app/(onboarding)/onboarding-dataset/datasetOnboarding.ts new file mode 100644 index 0000000000..5d40eac93b --- /dev/null +++ b/apps/web/src/app/(onboarding)/onboarding-dataset/datasetOnboarding.ts @@ -0,0 +1,72 @@ +'use client' + +import { useCallback } from 'react' +import { BlockRootNode } from '$/components/BlocksEditor' +import { EMPTY_ROOT_BLOCK } from '$/components/BlocksEditor/Editor/state/promptlToLexical' +import { + useLocalStorage, + AppLocalStorage, +} from '@latitude-data/web-ui/hooks/useLocalStorage' + +type DatasetOnboardingState = { + initialValue: BlockRootNode + documentParameters: string[] + latestDatasetName: string +} + +const defaultDatasetOnboardingState: DatasetOnboardingState = { + initialValue: EMPTY_ROOT_BLOCK, + documentParameters: [], + latestDatasetName: 'Dataset Onboarding', +} + +/** + * Store for managing dataset onboarding state. + * Provides access to onboarding step navigation and persisted values (initialValue, documentParameters). + */ +export function useDatasetOnboarding() { + const { value: state, setValue: setState } = + useLocalStorage({ + key: AppLocalStorage.datasetOnboardingState, + defaultValue: defaultDatasetOnboardingState, + }) + + const setInitialValue = useCallback( + (newValue: BlockRootNode) => { + setState((prev) => ({ + ...prev, + initialValue: newValue, + })) + }, + [setState], + ) + + const setDocumentParameters = useCallback( + (newValue: string[]) => { + setState((prev) => ({ + ...prev, + documentParameters: newValue, + })) + }, + [setState], + ) + + const setLatestDatasetName = useCallback( + (newValue: string) => { + setState((prev) => ({ + ...prev, + latestDatasetName: newValue, + })) + }, + [setState], + ) + + return { + initialValue: state.initialValue, + setInitialValue, + documentParameters: state.documentParameters, + setDocumentParameters, + latestDatasetName: state.latestDatasetName, + setLatestDatasetName, + } +} diff --git a/apps/web/src/app/(onboarding)/onboarding-dataset/generate-dataset/_components/GenerateDataset/index.tsx b/apps/web/src/app/(onboarding)/onboarding-dataset/generate-dataset/_components/GenerateDataset/index.tsx new file mode 100644 index 0000000000..b67f4f7a3f --- /dev/null +++ b/apps/web/src/app/(onboarding)/onboarding-dataset/generate-dataset/_components/GenerateDataset/index.tsx @@ -0,0 +1,78 @@ +'use client' + +import { Badge } from '@latitude-data/web-ui/atoms/Badge' +import { Text } from '@latitude-data/web-ui/atoms/Text' +import { Button } from '@latitude-data/web-ui/atoms/Button' +import { Suspense, useCallback } from 'react' +import { BlocksEditorPlaceholder } from '$/components/BlocksEditor' +import { OnboardingEditor } from '../../../_components/OnboardingEditor' +import { TableSkeleton } from '@latitude-data/web-ui/molecules/TableSkeleton' +import { useDatasetOnboarding } from '$/app/(onboarding)/onboarding-dataset/datasetOnboarding' +import { ROUTES } from '$/services/routes' +import { useNavigate } from '$/hooks/useNavigate' + +export function GenerateDatasetBody() { + const { initialValue, documentParameters } = useDatasetOnboarding() + const router = useNavigate() + const moveNextStep = useCallback(() => { + router.push(ROUTES.onboarding.dataset.runExperiment) + }, [router]) + + return ( +
+
+
+ }> +
+ +
+
+ +
+ +
+
+
+
+
+
+
+ + Step 2 of 3 + + Use a dataset +
+
+ + Next, we need to use some data to +
+ populate your prompt. +
+ + Later, you can upload your own dataset or +
+ integrate our SDK to use production data. +
+ + For now, we'll generate some synthetic +
+ data based on your prompt. +
+
+
+ +
+
+ ) +} diff --git a/apps/web/src/app/(onboarding)/onboarding-dataset/generate-dataset/page.tsx b/apps/web/src/app/(onboarding)/onboarding-dataset/generate-dataset/page.tsx new file mode 100644 index 0000000000..80661fee5a --- /dev/null +++ b/apps/web/src/app/(onboarding)/onboarding-dataset/generate-dataset/page.tsx @@ -0,0 +1,31 @@ +import { DatasetOnboardingStepRoot } from '../../_lib/OnboardingStep' +import { GenerateDatasetBody } from './_components/GenerateDataset' +import OnboardingHeader from '../_components/OnboardingHeader' +import { getCurrentUserOrRedirect } from '$/services/auth/getCurrentUser' +import buildMetatags from '$/app/_lib/buildMetatags' +import { PageTrackingWrapper } from '$/components/PageTrackingWrapper' + +export async function generateMetadata() { + // TODO(onboarding): change this to prompt engineering onboarding title once we activate the onboarding + return buildMetatags({ + title: 'Dataset Onboarding - Generate Dataset', + }) +} + +export default async function GenerateDatasetPage() { + const { user, workspace } = await getCurrentUserOrRedirect() + + return ( + +
+ + + + +
+
+ ) +} diff --git a/apps/web/src/app/(onboarding)/onboarding-dataset/layout.tsx b/apps/web/src/app/(onboarding)/onboarding-dataset/layout.tsx new file mode 100644 index 0000000000..abcbf93331 --- /dev/null +++ b/apps/web/src/app/(onboarding)/onboarding-dataset/layout.tsx @@ -0,0 +1,82 @@ +'use server' + +import { PageTrackingWrapper } from '$/components/PageTrackingWrapper' +import { getCurrentUserOrRedirect } from '$/services/auth/getCurrentUser' +import { isFeatureEnabledByName } from '@latitude-data/core/services/workspaceFeatures/isFeatureEnabledByName' +import { Result } from '@latitude-data/core/lib/Result' +import buildMetatags from '$/app/_lib/buildMetatags' +import { getOnboardingResources } from '$/data-access/workspaceOnboarding' +import { redirect } from 'next/navigation' +import { ROUTES } from '$/services/routes' +import { ProjectProvider } from '$/app/providers/ProjectProvider' +import { CommitProvider } from '$/app/providers/CommitProvider' +import { ONBOARDING_DOCUMENT_PATH } from '@latitude-data/core/constants' +import { DocumentValueProvider } from '$/hooks/useDocumentValueContext' +import { DevModeProvider } from '$/hooks/useDevMode' +import { MetadataProvider } from '$/components/MetadataProvider' +import { DocumentVersionProvider } from '$/app/providers/DocumentProvider' +import { ReactNode } from 'react' + +export async function generateMetadata() { + // TODO(onboarding): change this to prompt engineering onboarding title once we activate the onboarding + return buildMetatags({ + title: 'Dataset Onboarding', + }) +} + +export default async function OnboardingDatasetLayout({ + children, +}: { + children: ReactNode +}) { + const { user, workspace } = await getCurrentUserOrRedirect() + const { project, commit, documents } = await getOnboardingResources() + if (project === null || commit === null) { + return redirect(ROUTES.auth.login) + } + const document = documents.find((d) => d.path === ONBOARDING_DOCUMENT_PATH) + if (!document) { + return redirect(ROUTES.auth.login) + } + + // TODO(onboarding): remove this once we activate the onboarding + const isDatasetOnboardingEnabledResult = await isFeatureEnabledByName( + workspace.id, + 'datasetOnboarding', + ) + + if (!Result.isOk(isDatasetOnboardingEnabledResult)) { + return redirect(ROUTES.dashboard.root) + } + const isDatasetOnboardingEnabled = isDatasetOnboardingEnabledResult.unwrap() + + if (!isDatasetOnboardingEnabled) { + return redirect(ROUTES.dashboard.root) + } + + // TODO(onboarding): change dataset onboarding to prompt engineering onboarding once we activate the onboarding + return ( + + + + + + + + {children} + + + + + + + + ) +} diff --git a/apps/web/src/app/(onboarding)/onboarding-dataset/paste-your-prompt/_components/PasteYourPrompt/index.tsx b/apps/web/src/app/(onboarding)/onboarding-dataset/paste-your-prompt/_components/PasteYourPrompt/index.tsx new file mode 100644 index 0000000000..fbcf2416c9 --- /dev/null +++ b/apps/web/src/app/(onboarding)/onboarding-dataset/paste-your-prompt/_components/PasteYourPrompt/index.tsx @@ -0,0 +1,146 @@ +'use client' + +import { Badge } from '@latitude-data/web-ui/atoms/Badge' +import { Button } from '@latitude-data/web-ui/atoms/Button' +import { Text } from '@latitude-data/web-ui/atoms/Text' +import { Suspense, useCallback, useState } from 'react' +import { BlocksEditorPlaceholder } from '$/components/BlocksEditor' +import { useDocumentValue } from '$/hooks/useDocumentValueContext' +import useDatasets from '$/stores/datasets' +import { OnboardingEditor } from '../../../_components/OnboardingEditor' +import { scan } from 'promptl-ai' +import { fromAstToBlocks } from '$/components/BlocksEditor/Editor/state/promptlToLexical/fromAstToBlocks' +import { useDatasetOnboarding } from '$/app/(onboarding)/onboarding-dataset/datasetOnboarding' +import { ROUTES } from '$/services/routes' +import { useNavigate } from '$/hooks/useNavigate' +import { SAMPLE_PROMPT } from '../../constants' + +export function PasteYourPromptBody() { + const { value, updateDocumentContent } = useDocumentValue() + const { data: datasets, runGenerateOnboardingAction } = useDatasets() + const [editorKey, setEditorKey] = useState(0) + const { + initialValue, + setInitialValue, + setDocumentParameters, + setLatestDatasetName, + } = useDatasetOnboarding() + const router = useNavigate() + + const onNext = useCallback(async () => { + // We need max speed here, so we don't want to use the useMetadata hook to get the metadata.ast or parameters + const metadata = await scan({ prompt: value }) + setInitialValue(fromAstToBlocks({ ast: metadata.ast, prompt: value })) + // If the user doesnt add any parameters, we default to 'message' + const documentParameters = + Array.from(metadata.parameters).length > 0 + ? Array.from(metadata.parameters) + : ['message'] + setDocumentParameters(documentParameters) + const parameters = documentParameters.join(', ') ?? 'message' + + const latestDatasetName = datasets?.[datasets.length - 1] + ? `Dataset Onboarding ${datasets.length}` + : 'Dataset Onboarding' + setLatestDatasetName(latestDatasetName) + + runGenerateOnboardingAction({ + parameters, + prompt: value, + rowCount: 10, + name: latestDatasetName, + }) + router.push(ROUTES.onboarding.dataset.generateDataset) + }, [ + runGenerateOnboardingAction, + setDocumentParameters, + setInitialValue, + value, + router, + datasets, + setLatestDatasetName, + ]) + + const onUseSamplePrompt = useCallback(async () => { + // We need max speed here, so we don't want to use the useMetadata hook to get the metadata.ast or parameters + const metadata = await scan({ prompt: SAMPLE_PROMPT }) + const newInitialValue = fromAstToBlocks({ + ast: metadata.ast, + prompt: SAMPLE_PROMPT, + }) + setInitialValue(newInitialValue) + updateDocumentContent(SAMPLE_PROMPT) + setDocumentParameters(Array.from(metadata.parameters ?? [])) + // Force re-render of OnboardingEditor by changing key + setEditorKey((prev) => prev + 1) + }, [setInitialValue, setDocumentParameters, updateDocumentContent]) + + return ( +
+
+
+ }> + +
+ +
+
+
+
+
+
+
+ + Step 1 of 3 + + Paste Your Prompt +
+
+ + With Latitude, it's easy to test your +
+ prompts at scale. +
+ + Paste one of your existing prompts here. + + + Make sure you replace any dynamic parts with{' '} + + {{ input }} + {' '} + variables. + +
+
+ +
+
+ ) +} diff --git a/apps/web/src/app/(onboarding)/onboarding-dataset/paste-your-prompt/constants.ts b/apps/web/src/app/(onboarding)/onboarding-dataset/paste-your-prompt/constants.ts new file mode 100644 index 0000000000..dcb6f1d72e --- /dev/null +++ b/apps/web/src/app/(onboarding)/onboarding-dataset/paste-your-prompt/constants.ts @@ -0,0 +1,98 @@ +import { nanoidHashAlgorithm } from '@latitude-data/core/services/datasets/utils' +import { Column } from '@latitude-data/core/schema/models/datasets' +import { DATASET_COLUMN_ROLES } from '@latitude-data/core/constants' + +export type SamplePromptDocumentParameterKeys = 'score' | 'message' + +export type SamplePromptParameters = Record< + SamplePromptDocumentParameterKeys, + string | number +> + +const SAMPLE_PROMPT = ` +--- +provider: OpenAI +model: gpt-4.1-mini +--- + +This is a response from an NPS survey: + +Score: {{score}} +Message: {{message}} + +Analyze the sentiment based on both the score and message. Prioritize identifying the primary concern in the feedback, +focusing on the core issue mentioned by the user. Categorize the sentiment into one of the following categories: + +- Product Features and Functionality +- User Interface (UI) and User Experience (UX) +- Performance and Reliability +- Customer Support and Service +- Onboarding and Learning Curve +- Pricing and Value Perception +- Integrations and Compatibility +- Scalability and Customization +- Feature Requests and Product Roadmap +- Competitor Comparison +- General Feedback (Neutral/Non-specific) + +Return only one of the categories. +` + +const SAMPLE_PROMPT_DATASET_COLUMNS: Column[] = [ + { + identifier: nanoidHashAlgorithm({ columnName: 'score' }), + name: 'score', + role: DATASET_COLUMN_ROLES.parameter, + }, + { + identifier: nanoidHashAlgorithm({ columnName: 'message' }), + name: 'message', + role: DATASET_COLUMN_ROLES.parameter, + }, +] + +const SAMPLE_PROMPT_DATASET: SamplePromptParameters[] = [ + { + score: 5, + message: 'The experience is neutral, with no standout problems.', + }, + { + score: 7, + message: + 'Overall, pretty satisfied, though some competition offers more features.', + }, + { + score: 2, + message: 'Really hard to use; the learning curve is tough.', + }, + { + score: 10, + message: 'Great service and seamless integration with other tools.', + }, + { + score: 4, + message: 'The product is great, but the pricing is a bit high.', + }, + { + score: 6, + message: 'The product is good, but the pricing is a bit high.', + }, + { + score: 8, + message: 'Performance is generally good, though it can be slow at times.', + }, + { + score: 6, + message: 'Navigation is okay, but onboarding materials are lacking.', + }, + { + score: 3, + message: 'The app crashes frequently, making it unreliable.', + }, + { + score: 5, + message: 'The experience is neutral, with no standout problems.', + }, +] + +export { SAMPLE_PROMPT_DATASET_COLUMNS, SAMPLE_PROMPT_DATASET, SAMPLE_PROMPT } diff --git a/apps/web/src/app/(onboarding)/onboarding-dataset/paste-your-prompt/page.tsx b/apps/web/src/app/(onboarding)/onboarding-dataset/paste-your-prompt/page.tsx new file mode 100644 index 0000000000..3f906364a1 --- /dev/null +++ b/apps/web/src/app/(onboarding)/onboarding-dataset/paste-your-prompt/page.tsx @@ -0,0 +1,31 @@ +import { DatasetOnboardingStepRoot } from '../../_lib/OnboardingStep' +import { PasteYourPromptBody } from './_components/PasteYourPrompt' +import OnboardingHeader from '../_components/OnboardingHeader' +import { getCurrentUserOrRedirect } from '$/services/auth/getCurrentUser' +import buildMetatags from '$/app/_lib/buildMetatags' +import { PageTrackingWrapper } from '$/components/PageTrackingWrapper' + +export async function generateMetadata() { + // TODO(onboarding): change this to prompt engineering onboarding title once we activate the onboarding + return buildMetatags({ + title: 'Dataset Onboarding - Paste Your Prompt', + }) +} + +export default async function PasteYourPromptPage() { + const { user, workspace } = await getCurrentUserOrRedirect() + + return ( + +
+ + + + +
+
+ ) +} diff --git a/apps/web/src/app/(onboarding)/onboarding-dataset/run-experiment/_components/RunExperiment/index.tsx b/apps/web/src/app/(onboarding)/onboarding-dataset/run-experiment/_components/RunExperiment/index.tsx new file mode 100644 index 0000000000..b3d652d95b --- /dev/null +++ b/apps/web/src/app/(onboarding)/onboarding-dataset/run-experiment/_components/RunExperiment/index.tsx @@ -0,0 +1,151 @@ +'use client' + +import { useCurrentProject } from '$/app/providers/ProjectProvider' +import { useCurrentCommit } from '$/app/providers/CommitProvider' +import { useCurrentDocument } from '$/app/providers/DocumentProvider' +import { useCallback, useMemo } from 'react' +import { Badge } from '@latitude-data/web-ui/atoms/Badge' +import { Text } from '@latitude-data/web-ui/atoms/Text' +import { Button } from '@latitude-data/web-ui/atoms/Button' +import { Suspense } from 'react' +import { BlocksEditorPlaceholder } from '$/components/BlocksEditor' +import { OnboardingEditor } from '../../../_components/OnboardingEditor' +import SimpleDatasetTable from '../../../_components/SimpleDatasetTable' +import { useExperiments } from '$/stores/experiments' +import useDatasets from '$/stores/datasets' +import { envClient } from '$/envClient' +import { useDatasetOnboarding } from '$/app/(onboarding)/onboarding-dataset/datasetOnboarding' +import useWorkspaceOnboarding from '$/stores/workspaceOnboarding' + +const EXPERIMENT_VARIANT = [ + { + name: 'Onboarding Experiment', + provider: envClient.NEXT_PUBLIC_DEFAULT_PROVIDER_NAME, + model: 'gpt-4o-mini', + temperature: 1, + }, +] + +export default function RunExperimentBody() { + const { executeCompleteOnboarding } = useWorkspaceOnboarding() + const { project } = useCurrentProject() + const { commit } = useCurrentCommit() + const { document } = useCurrentDocument() + const { data: datasets } = useDatasets() + const { initialValue, documentParameters, latestDatasetName } = + useDatasetOnboarding() + + // Get the latest dataset, as the user might have gone back and forth between steps, creating multiple datasets + const latestDataset = datasets.find((ds) => ds.name === latestDatasetName) + + const parametersMap = useMemo(() => { + return latestDataset && documentParameters.length + ? Object.fromEntries( + latestDataset.columns + .map((col, index) => + documentParameters.includes(col.name) ? [col.name, index] : null, + ) + .filter(Boolean) as [string, number][], + ) + : {} + }, [latestDataset, documentParameters]) + + const { create, isCreating } = useExperiments( + { + projectId: project.id, + documentUuid: document.documentUuid, + }, + { + onCreate: async () => { + executeCompleteOnboarding({ + projectId: project.id, + commitUuid: commit.uuid, + documentUuid: document.documentUuid, + }) + }, + }, + ) + + const onCompleteOnboarding = useCallback(() => { + create({ + projectId: project.id, + commitUuid: commit.uuid, + documentUuid: document.documentUuid, + variants: EXPERIMENT_VARIANT, + datasetId: latestDataset?.id, + parametersMap, + datasetLabels: {}, + fromRow: 1, + evaluationUuids: [], + }) + }, [ + create, + latestDataset?.id, + project.id, + commit.uuid, + document.documentUuid, + parametersMap, + ]) + + return ( +
+
+
+ }> +
+ +
+
+ + +
+
+
+
+
+ + Step 3 of 3 + + Run experiment +
+
+ + Finally, let's run an experiment to see how +
+ your prompt does. +
+ + After running it, you'll be able to review +
+ and annotate each run. +
+ + We automatically aggregate the issues +
+ detected and generate specific LLM +
+ evaluators for each one. +
+
+
+ +
+
+ ) +} diff --git a/apps/web/src/app/(onboarding)/onboarding-dataset/run-experiment/page.tsx b/apps/web/src/app/(onboarding)/onboarding-dataset/run-experiment/page.tsx new file mode 100644 index 0000000000..76c3b06738 --- /dev/null +++ b/apps/web/src/app/(onboarding)/onboarding-dataset/run-experiment/page.tsx @@ -0,0 +1,31 @@ +import { DatasetOnboardingStepRoot } from '../../_lib/OnboardingStep' +import RunExperimentBody from './_components/RunExperiment' +import OnboardingHeader from '../_components/OnboardingHeader' +import { getCurrentUserOrRedirect } from '$/services/auth/getCurrentUser' +import buildMetatags from '$/app/_lib/buildMetatags' +import { PageTrackingWrapper } from '$/components/PageTrackingWrapper' + +export async function generateMetadata() { + // TODO(onboarding): change this to prompt engineering onboarding title once we activate the onboarding + return buildMetatags({ + title: 'Dataset Onboarding - Run Experiment', + }) +} + +export default async function RunExperimentPage() { + const { user, workspace } = await getCurrentUserOrRedirect() + + return ( + +
+ + + + +
+
+ ) +} diff --git a/apps/web/src/app/(private)/datasets/_components/RootHeader/GenerateDatasetModal/GenerateDatasetModal/index.tsx b/apps/web/src/app/(private)/datasets/_components/RootHeader/GenerateDatasetModal/GenerateDatasetModal/index.tsx index 62942c2216..631b946822 100644 --- a/apps/web/src/app/(private)/datasets/_components/RootHeader/GenerateDatasetModal/GenerateDatasetModal/index.tsx +++ b/apps/web/src/app/(private)/datasets/_components/RootHeader/GenerateDatasetModal/GenerateDatasetModal/index.tsx @@ -1,13 +1,11 @@ import { FormEvent } from 'react' import { useToast } from '@latitude-data/web-ui/atoms/Toast' -import { generateDatasetAction } from '$/actions/datasets/generateDataset' import { useNavigate } from '$/hooks/useNavigate' import { ROUTES } from '$/services/routes' import useDatasets from '$/stores/datasets' import { GenerateDatasetModalComponent } from './GenerateDatasetModalComponent' import { useDatasetPreviewModal } from './useDatasetPreviewModal' -import useLatitudeAction from '$/hooks/useLatitudeAction' export function GenerateDatasetModal({ open, @@ -24,20 +22,14 @@ export function GenerateDatasetModal({ }) { const navigate = useNavigate() const { toast } = useToast() - const { data: datasets, mutate } = useDatasets() const { - execute: runGenerateAction, - isPending: generateIsLoading, - error: generateError, - } = useLatitudeAction(generateDatasetAction, { - onError: (error) => { - toast({ - title: 'Failed to generate dataset', - description: error.message, - variant: 'destructive', - }) - }, - }) + data: datasets, + mutate, + runGenerateAction, + generateIsLoading, + generateError, + } = useDatasets() + const modalData = useDatasetPreviewModal({ defaultParameters, generateErrorMessage: generateError?.message, diff --git a/apps/web/src/app/(private)/layout.tsx b/apps/web/src/app/(private)/layout.tsx index d3f417257d..7567b98f43 100644 --- a/apps/web/src/app/(private)/layout.tsx +++ b/apps/web/src/app/(private)/layout.tsx @@ -17,6 +17,8 @@ import { redirect } from 'next/navigation' import { CSPostHogProvider, IdentifyUser } from '../providers' import { PaywallModalProvider } from './providers/PaywallModalProvider' +import { isFeatureEnabledByName } from '@latitude-data/core/services/workspaceFeatures/isFeatureEnabledByName' +import { Result } from '@latitude-data/core/lib/Result' export const metadata = buildMetatags({ title: 'Home', @@ -31,7 +33,20 @@ export default async function PrivateLayout({ const { workspace, user, subscriptionPlan } = await getCurrentUserOrRedirect() const completed = await isOnboardingCompleted() + + const datasetOnboardingEnabledResult = await isFeatureEnabledByName( + workspace.id, + 'datasetOnboarding', + ) + if (!Result.isOk(datasetOnboardingEnabledResult)) { + redirect(ROUTES.dashboard.root) + } + const isDatasetOnboardingEnabled = datasetOnboardingEnabledResult.unwrap() + if (!completed) { + if (isDatasetOnboardingEnabled) { + redirect(ROUTES.onboarding.dataset.pasteYourPrompt) + } redirect(ROUTES.onboarding.agents.selectAgent) } diff --git a/apps/web/src/components/BlocksEditor/Editor/index.tsx b/apps/web/src/components/BlocksEditor/Editor/index.tsx index ec621a16b5..7c7928a663 100644 --- a/apps/web/src/components/BlocksEditor/Editor/index.tsx +++ b/apps/web/src/components/BlocksEditor/Editor/index.tsx @@ -98,6 +98,7 @@ export function BlocksEditor({ onToggleDevEditor, readOnlyMessage, autoFocus = false, + greyTheme = false, }: BlocksEditorProps) { const readOnly = Boolean(readOnlyMessage) const [floatingAnchorElem, setFloatingAnchorElem] = @@ -137,6 +138,7 @@ export function BlocksEditor({
@@ -192,14 +194,18 @@ export function BlocksEditor({ - - + {onRequestPromptMetadata && onToggleDevEditor && prompts && ( + + )} + {onRequestPromptMetadata && ( + + )} diff --git a/apps/web/src/components/BlocksEditor/Editor/plugins/ReferenceEditPlugin.tsx b/apps/web/src/components/BlocksEditor/Editor/plugins/ReferenceEditPlugin.tsx index b9081a6d48..957300195a 100644 --- a/apps/web/src/components/BlocksEditor/Editor/plugins/ReferenceEditPlugin.tsx +++ b/apps/web/src/components/BlocksEditor/Editor/plugins/ReferenceEditPlugin.tsx @@ -13,6 +13,8 @@ export function ReferenceEditPlugin({ const [editor] = useLexicalComposerContext() useEffect(() => { + if (!onRequestPromptMetadata) return + const abortController = new AbortController() const handleReferencePathUpdate = async (event: Event) => { diff --git a/apps/web/src/components/BlocksEditor/Editor/plugins/ReferencesPlugin.tsx b/apps/web/src/components/BlocksEditor/Editor/plugins/ReferencesPlugin.tsx index 9220a2ca91..d0fb24a21c 100644 --- a/apps/web/src/components/BlocksEditor/Editor/plugins/ReferencesPlugin.tsx +++ b/apps/web/src/components/BlocksEditor/Editor/plugins/ReferencesPlugin.tsx @@ -49,19 +49,19 @@ export function EventListeners({ onToggleDevEditor: BlocksEditorProps['onToggleDevEditor'] }) { useEffect(() => { + if (!onToggleDevEditor) return + const abortController = new AbortController() - document.addEventListener( - CUSTOM_EVENTS.GO_TO_DEV_EDITOR, - onToggleDevEditor, - { signal: abortController.signal }, - ) + const handleEvent = () => { + onToggleDevEditor() + } + document.addEventListener(CUSTOM_EVENTS.GO_TO_DEV_EDITOR, handleEvent, { + signal: abortController.signal, + }) return () => { abortController.abort() - document.removeEventListener( - CUSTOM_EVENTS.GO_TO_DEV_EDITOR, - onToggleDevEditor, - ) + document.removeEventListener(CUSTOM_EVENTS.GO_TO_DEV_EDITOR, handleEvent) } }, [onToggleDevEditor]) @@ -144,6 +144,8 @@ export function ReferencesPlugin({ closeMenu: () => void, _matchingString: string, ) => { + if (!onRequestPromptMetadata) return + let referenceNode: ReferenceNode editor.update(() => { diff --git a/apps/web/src/components/BlocksEditor/Editor/state/promptlToLexical/fromAstToBlocks.ts b/apps/web/src/components/BlocksEditor/Editor/state/promptlToLexical/fromAstToBlocks.ts index 80f7817370..ee5143040b 100644 --- a/apps/web/src/components/BlocksEditor/Editor/state/promptlToLexical/fromAstToBlocks.ts +++ b/apps/web/src/components/BlocksEditor/Editor/state/promptlToLexical/fromAstToBlocks.ts @@ -874,4 +874,13 @@ export function fromAstToBlocks({ } satisfies BlockRootNode } +export const EMPTY_ROOT_BLOCK: BlockRootNode = { + type: BLOCK_EDITOR_TYPE.ROOT, + children: [createEmptyParagraph({ content: '' })], + version: 1, + direction: 'ltr', + indent: 0, + format: '', +} + export type { BlockRootNode } diff --git a/apps/web/src/components/BlocksEditor/types.ts b/apps/web/src/components/BlocksEditor/types.ts index b5b30d782a..7281e665a7 100644 --- a/apps/web/src/components/BlocksEditor/types.ts +++ b/apps/web/src/components/BlocksEditor/types.ts @@ -21,14 +21,15 @@ export type BlocksEditorProps = { currentDocument: { path: string } initialValue: BlockRootNode placeholder: string - onToggleDevEditor: () => void + onToggleDevEditor?: () => void onError: (error: Error) => void prompts: Record - onRequestPromptMetadata: ( + onRequestPromptMetadata?: ( prompt: IncludedPrompt, ) => Promise onChange: updateContentFn className?: string readOnlyMessage?: string autoFocus?: boolean + greyTheme?: boolean } diff --git a/apps/web/src/components/MetadataProvider.tsx b/apps/web/src/components/MetadataProvider.tsx index 3cfff67500..a827203d36 100644 --- a/apps/web/src/components/MetadataProvider.tsx +++ b/apps/web/src/components/MetadataProvider.tsx @@ -23,7 +23,7 @@ type MetadataProviderProps = { } export function MetadataProvider({ children }: MetadataProviderProps) { - const { setMetadata, setWorker } = useMetadataStore() + const { setMetadata, setWorker, reset } = useMetadataStore() useEffect(() => { if (typeof window === 'undefined') return // Only on client code @@ -56,8 +56,9 @@ export function MetadataProvider({ children }: MetadataProviderProps) { currentWorker.terminate() setWorker(null) } + reset() } - }, [setMetadata, setWorker]) + }, [setMetadata, setWorker, reset]) return <>{children} } diff --git a/apps/web/src/data-access/workspaceOnboarding.ts b/apps/web/src/data-access/workspaceOnboarding.ts index 875543ff50..d3b3d704d5 100644 --- a/apps/web/src/data-access/workspaceOnboarding.ts +++ b/apps/web/src/data-access/workspaceOnboarding.ts @@ -62,6 +62,9 @@ export async function getOnboardingResources() { return { workspace, documents, project, commit } } +/** + * Get the onboarding dataset (if it exists) + */ export async function getOnboardingDataset() { const { workspace } = await getCurrentUserOrRedirect() if (!workspace?.id) { @@ -74,6 +77,9 @@ export async function getOnboardingDataset() { return datasetResult.unwrap() } +/** + * Get the necessary onboarding steps to complete the onboarding + */ export async function getNecessaryOnboardingSteps() { const { workspace } = await getCurrentUserOrRedirect() if (!workspace?.id) { diff --git a/apps/web/src/services/routes.ts b/apps/web/src/services/routes.ts index d444834eb0..e181e38e78 100644 --- a/apps/web/src/services/routes.ts +++ b/apps/web/src/services/routes.ts @@ -46,6 +46,11 @@ export const ROUTES = { start: '/onboarding-agents/start', }, promptEngineering: '/onboarding-prompt-engineering', + dataset: { + pasteYourPrompt: '/onboarding-dataset/paste-your-prompt', + generateDataset: '/onboarding-dataset/generate-dataset', + runExperiment: '/onboarding-dataset/run-experiment', + }, }, backoffice: { root: BACKOFFICE_ROOT, diff --git a/apps/web/src/stores/datasetRows/index.ts b/apps/web/src/stores/datasetRows/index.ts index 163b168bb9..fdd0a286cf 100644 --- a/apps/web/src/stores/datasetRows/index.ts +++ b/apps/web/src/stores/datasetRows/index.ts @@ -149,7 +149,7 @@ export default function useDatasetRows( if (!createdRow || !dataset) return const row = serializeRow({ row: createdRow }) - mutate([row, ...data]) + mutate([...data, row]) }, }, ) diff --git a/apps/web/src/stores/datasets.ts b/apps/web/src/stores/datasets.ts index 87b11880dd..e5bdb6da8f 100644 --- a/apps/web/src/stores/datasets.ts +++ b/apps/web/src/stores/datasets.ts @@ -9,6 +9,8 @@ import useSWR, { SWRConfiguration } from 'swr' import { compactObject } from '@latitude-data/core/lib/compactObject' import { useCallback, useState } from 'react' import { Dataset } from '@latitude-data/core/schema/models/types/Dataset' +import { generateDatasetAction } from '$/actions/datasets/generateDataset' +import { generateOnboardingDatasetAction } from '$/actions/datasets/generateOnboardingDataset' const EMPTY_ARRAY: Dataset[] = [] @@ -137,6 +139,40 @@ export default function useDatasets( }, }) + const { + execute: runGenerateAction, + isPending: generateIsLoading, + error: generateError, + } = useLatitudeAction(generateDatasetAction, { + onError: (error) => { + toast({ + title: 'Failed to generate dataset', + description: error.message, + variant: 'destructive', + }) + }, + onSuccess: ({ data: dataset }) => { + mutate([...data, dataset]) + }, + }) + + const { + execute: runGenerateOnboardingAction, + isPending: generateOnboardingIsLoading, + error: generateOnboardingError, + } = useLatitudeAction(generateOnboardingDatasetAction, { + onError: (error) => { + toast({ + title: 'Failed to generate onboarding dataset', + description: error.message, + variant: 'destructive', + }) + }, + onSuccess: ({ data: dataset }) => { + mutate([...data, dataset]) + }, + }) + return { data, mutate, @@ -147,6 +183,12 @@ export default function useDatasets( isDestroying, updateColumn, isUpdatingColumn, + runGenerateAction, + generateIsLoading, + generateError, + runGenerateOnboardingAction, + generateOnboardingIsLoading, + generateOnboardingError, ...rest, } } diff --git a/apps/web/src/stores/workspaceOnboarding.ts b/apps/web/src/stores/workspaceOnboarding.ts index 189f6abf90..0b7b761b96 100644 --- a/apps/web/src/stores/workspaceOnboarding.ts +++ b/apps/web/src/stores/workspaceOnboarding.ts @@ -25,13 +25,27 @@ export default function useWorkspaceOnboarding() { const { execute: executeCompleteOnboarding } = useLatitudeAction( completeOnboardingAction, + { + onSuccess: () => { + // No-op + }, + }, ) const { execute: executeCreatePromptEngineeringResources } = - useLatitudeAction(createPromptEngineeringResourcesAction) + useLatitudeAction(createPromptEngineeringResourcesAction, { + onSuccess: () => { + // No-op + }, + }) const { execute: createDefaultAgentOnboardingProject } = useLatitudeAction( createDefaultAgentOnboardingProjectAction, + { + onSuccess: () => { + // No-op + }, + }, ) const { diff --git a/packages/constants/src/onboardingSteps.ts b/packages/constants/src/onboardingSteps.ts index a8a19f3ada..71e7b090be 100644 --- a/packages/constants/src/onboardingSteps.ts +++ b/packages/constants/src/onboardingSteps.ts @@ -19,3 +19,21 @@ export const ONBOARDING_STEPS = { order: 4, }, } as const + +export enum DatasetOnboardingStepKey { + PasteYourPrompt = 'pasteYourPrompt', + GenerateDataset = 'generateDataset', + RunExperiment = 'runExperiment', +} + +export const DATASET_ONBOARDING_STEPS = { + [DatasetOnboardingStepKey.PasteYourPrompt]: { + order: 1, + }, + [DatasetOnboardingStepKey.GenerateDataset]: { + order: 2, + }, + [DatasetOnboardingStepKey.RunExperiment]: { + order: 3, + }, +} as const diff --git a/packages/core/src/services/datasets/generateWithCopilot.test.ts b/packages/core/src/services/datasets/generateWithCopilot.test.ts new file mode 100644 index 0000000000..5c7ee7a9a6 --- /dev/null +++ b/packages/core/src/services/datasets/generateWithCopilot.test.ts @@ -0,0 +1,173 @@ +import { describe, it, expect, beforeEach, vi, type MockInstance } from 'vitest' +import * as env from '@latitude-data/env' +import { BadRequestError } from '@latitude-data/constants/errors' +import { Result } from '../../lib/Result' +import { UnprocessableEntityError } from '../../lib/errors' +import { CLOUD_MESSAGES } from '../../constants' +import * as copilotGet from '../copilot/get' +import * as copilotRun from '../copilot/run' +import { generateDatasetWithCopilot } from './generateWithCopilot' + +let mockGetCopilot: MockInstance +let mockRunCopilot: MockInstance +let envSpy: MockInstance + +describe('generateDatasetWithCopilot', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.resetAllMocks() + + // Setup env spy with default values + envSpy = vi.spyOn(env, 'env', 'get').mockReturnValue({ + ...env.env, + LATITUDE_CLOUD: true, + COPILOT_PROJECT_ID: 'project-id', + COPILOT_PROMPT_DATASET_GENERATOR_PATH: '/copilot/datasets/generator', + COPILOT_WORKSPACE_API_KEY: 'workspace-api-key', + } as any) + + // Setup default mocks for copilot functions + mockGetCopilot = vi.spyOn(copilotGet, 'getCopilot').mockResolvedValue( + Result.ok({ + workspace: {} as any, + commit: {} as any, + document: {} as any, + }), + ) + + mockRunCopilot = vi.spyOn(copilotRun, 'runCopilot').mockResolvedValue( + Result.ok({ + rows: [ + { name: 'Alice', age: 30 }, + { name: 'Bob', age: 25 }, + ], + explanation: 'Generated sample dataset', + }), + ) + }) + + it('returns rows and explanation from copilot', async () => { + const result = await generateDatasetWithCopilot({ + parameters: 'name,age', + description: 'People dataset', + prompt: 'Generate sample people', + rowCount: 2, + }) + + expect(Result.isOk(result)).toBe(true) + const data = result.value! + expect(data.rows).toEqual([ + { name: 'Alice', age: 30 }, + { name: 'Bob', age: 25 }, + ]) + expect(data.explanation).toBe('Generated sample dataset') + expect(data.rows).toHaveLength(2) + }) + + it('returns error when LATITUDE_CLOUD is not enabled', async () => { + envSpy.mockReturnValue({ + ...env.env, + LATITUDE_CLOUD: false, + COPILOT_PROJECT_ID: 'project-id', + COPILOT_PROMPT_DATASET_GENERATOR_PATH: '/copilot/datasets/generator', + COPILOT_WORKSPACE_API_KEY: 'workspace-api-key', + } as any) + + const res = await generateDatasetWithCopilot({ + parameters: 'foo', + rowCount: 1, + }) + + expect(Result.isOk(res)).toBe(false) + expect(res.error).toBeInstanceOf(BadRequestError) + expect(res.error?.message).toBe(CLOUD_MESSAGES.generateDatasets) + }) + + it('returns error when COPILOT_PROJECT_ID is not set', async () => { + envSpy.mockReturnValue({ + ...env.env, + LATITUDE_CLOUD: true, + COPILOT_PROJECT_ID: '', + COPILOT_PROMPT_DATASET_GENERATOR_PATH: '/copilot/datasets/generator', + COPILOT_WORKSPACE_API_KEY: 'workspace-api-key', + } as any) + + const res = await generateDatasetWithCopilot({ + parameters: 'foo', + rowCount: 1, + }) + + expect(Result.isOk(res)).toBe(false) + expect(res.error).toBeInstanceOf(BadRequestError) + expect(res.error?.message).toBe('COPILOT_PROJECT_ID is not set') + }) + + it('returns error when COPILOT_PROMPT_DATASET_GENERATOR_PATH is not set', async () => { + envSpy.mockReturnValue({ + ...env.env, + LATITUDE_CLOUD: true, + COPILOT_PROJECT_ID: 'project-id', + COPILOT_PROMPT_DATASET_GENERATOR_PATH: '', + COPILOT_WORKSPACE_API_KEY: 'workspace-api-key', + } as any) + + const res = await generateDatasetWithCopilot({ + parameters: 'foo', + rowCount: 1, + }) + + expect(Result.isOk(res)).toBe(false) + expect(res.error).toBeInstanceOf(BadRequestError) + expect(res.error?.message).toBe( + 'COPILOT_PROMPT_DATASET_GENERATOR_PATH is not set', + ) + }) + + it('returns error when COPILOT_WORKSPACE_API_KEY is not set', async () => { + envSpy.mockReturnValue({ + ...env.env, + LATITUDE_CLOUD: true, + COPILOT_PROJECT_ID: 'project-id', + COPILOT_PROMPT_DATASET_GENERATOR_PATH: '/copilot/datasets/generator', + COPILOT_WORKSPACE_API_KEY: '', + } as any) + + const res = await generateDatasetWithCopilot({ + parameters: 'foo', + rowCount: 1, + }) + + expect(Result.isOk(res)).toBe(false) + expect(res.error).toBeInstanceOf(BadRequestError) + expect(res.error?.message).toBe('COPILOT_WORKSPACE_API_KEY is not set') + }) + + it('returns error when getCopilot fails', async () => { + mockGetCopilot.mockResolvedValue( + Result.error(new Error('Copilot not found')), + ) + + const res = await generateDatasetWithCopilot({ + parameters: 'foo', + rowCount: 1, + }) + + expect(Result.isOk(res)).toBe(false) + expect(res.error?.message).toBe('Copilot not found') + }) + + it('returns error when runCopilot fails', async () => { + mockRunCopilot.mockResolvedValue( + Result.error(new UnprocessableEntityError('Copilot execution failed')), + ) + + const res = await generateDatasetWithCopilot({ + parameters: 'foo', + rowCount: 1, + }) + + expect(Result.isOk(res)).toBe(false) + expect(res.error).toBeInstanceOf(UnprocessableEntityError) + expect(res.error?.message).toBe('Copilot execution failed') + }) +}) diff --git a/packages/core/src/services/datasets/generateWithCopilot.ts b/packages/core/src/services/datasets/generateWithCopilot.ts new file mode 100644 index 0000000000..37f78b0056 --- /dev/null +++ b/packages/core/src/services/datasets/generateWithCopilot.ts @@ -0,0 +1,69 @@ +import { env } from '@latitude-data/env' +import { BadRequestError } from '@latitude-data/constants/errors' +import { Result } from '../../lib/Result' +import { database } from '../../client' +import { getCopilot } from '../copilot/get' +import { runCopilot } from '../copilot/run' +import { z } from 'zod' +import { CLOUD_MESSAGES } from '../../constants' + +const generatedDatasetResponseSchema = z.object({ + rows: z.array(z.record(z.string(), z.unknown())), + explanation: z.string(), +}) + +export async function generateDatasetWithCopilot( + { + parameters, + description, + prompt, + rowCount, + }: { + parameters: string + description?: string + prompt?: string + rowCount: number + }, + db = database, +) { + if (!env.LATITUDE_CLOUD) { + return Result.error(new BadRequestError(CLOUD_MESSAGES.generateDatasets)) + } + + if (!env.COPILOT_PROJECT_ID) { + return Result.error(new BadRequestError('COPILOT_PROJECT_ID is not set')) + } + if (!env.COPILOT_PROMPT_DATASET_GENERATOR_PATH) { + return Result.error( + new BadRequestError('COPILOT_PROMPT_DATASET_GENERATOR_PATH is not set'), + ) + } + if (!env.COPILOT_WORKSPACE_API_KEY) { + return Result.error( + new BadRequestError('COPILOT_WORKSPACE_API_KEY is not set'), + ) + } + + const copilotResult = await getCopilot( + { + path: env.COPILOT_PROMPT_DATASET_GENERATOR_PATH, + }, + db, + ) + + if (!Result.isOk(copilotResult)) { + return copilotResult + } + + const copilot = copilotResult.unwrap() + return await runCopilot({ + copilot: copilot, + parameters: { + row_count: rowCount, + parameters: parameters, + user_message: description, + prompt: prompt, + }, + schema: generatedDatasetResponseSchema, + }) +} diff --git a/packages/core/src/services/onboardingResources/createDatasetOnboarding.ts b/packages/core/src/services/onboardingResources/createDatasetOnboarding.ts new file mode 100644 index 0000000000..cc9e304cef --- /dev/null +++ b/packages/core/src/services/onboardingResources/createDatasetOnboarding.ts @@ -0,0 +1,36 @@ +import { type User } from '../../schema/models/types/User' +import { type Workspace } from '../../schema/models/types/Workspace' +import Transaction from '../../lib/Transaction' +import { createOnboardingProject } from '../projects/createOnboardingProject' +import { Result } from '../../lib/Result' + +export async function createDatasetOnboarding( + { + workspace, + user, + }: { + workspace: Workspace + user: User + }, + transaction = new Transaction(), +) { + return transaction.call(async () => { + const projectResult = await createOnboardingProject( + { + workspace, + user, + }, + transaction, + ) + if (projectResult.error) { + return projectResult + } + const { project, documents, commit } = projectResult.unwrap() + + return Result.ok({ + project, + documents, + commit, + }) + }) +} diff --git a/packages/core/src/services/projects/createOnboardingProject.ts b/packages/core/src/services/projects/createOnboardingProject.ts index bd82d3e393..09d5940623 100644 --- a/packages/core/src/services/projects/createOnboardingProject.ts +++ b/packages/core/src/services/projects/createOnboardingProject.ts @@ -4,10 +4,9 @@ import { Result } from '../../lib/Result' import Transaction from '../../lib/Transaction' import { createProject } from './create' import { createNewDocument } from '../documents/create' -import { mergeCommit } from '../commits/merge' /** - * Creates an onboarding project with a single document containing a template for product descriptions + * Creates an onboarding project with a single empty document to let the user fill in */ export async function createOnboardingProject( { @@ -29,39 +28,25 @@ export async function createOnboardingProject( transaction, ).then((r) => r.unwrap()) - // Create a document with the specified content + // Create an empty document to fill in by the user const documentResult = await createNewDocument( { workspace, user, commit, path: 'onboarding', - content: `--- -provider: Latitude -model: gpt-4o-mini ---- - -Write a compelling product description for {{product_name}} with the following features: -{{features}} - -The description should be appropriate for {{target_audience}} and highlight the main benefits. -Tone: {{tone}} -Length: {{word_count}} words`, }, transaction, ) - if (documentResult.error) { + if (!Result.isOk(documentResult)) { return documentResult } - // Merge the commit to finalize the document - const mergedCommit = await mergeCommit(commit, transaction).then((r) => - r.unwrap(), - ) + const document = documentResult.unwrap() return Result.ok({ project, - documents: [documentResult.unwrap()], - commit: mergedCommit, + documents: [document], + commit, }) } diff --git a/packages/core/src/services/users/setupService.ts b/packages/core/src/services/users/setupService.ts index ab5bf62c66..9e8e89c64b 100644 --- a/packages/core/src/services/users/setupService.ts +++ b/packages/core/src/services/users/setupService.ts @@ -11,6 +11,8 @@ import { createProviderApiKey } from '../providerApiKeys' import { createWorkspace } from '../workspaces' import { createUser } from './createUser' import { UserTitle } from '@latitude-data/constants/users' +import { createDatasetOnboarding } from '../onboardingResources/createDatasetOnboarding' +import { isFeatureEnabledByName } from '../workspaceFeatures/isFeatureEnabledByName' const DEFAULT_MODEL = 'gpt-4o-mini' @@ -36,7 +38,7 @@ export default async function setupService( }, transaction = new Transaction(), ): PromisedResult<{ user: User; workspace: Workspace }> { - return transaction.call(async () => { + return transaction.call(async (trx) => { const user = await createUser( { email, name, confirmedAt: new Date(), title }, transaction, @@ -75,6 +77,25 @@ export default async function setupService( r.unwrap(), ) + const isDatasetOnboardingEnabledResult = await isFeatureEnabledByName( + workspace.id, + 'datasetOnboarding', + trx, + ) + + if (!Result.isOk(isDatasetOnboardingEnabledResult)) { + return Result.error( + new Error('Failed checking dataset onboarding feature'), + ) + } + const isDatasetOnboardingEnabled = isDatasetOnboardingEnabledResult.unwrap() + + if (isDatasetOnboardingEnabled) { + await createDatasetOnboarding({ workspace, user }, transaction).then( + (r) => r.unwrap(), + ) + } + publisher.publishLater({ type: 'userCreated', data: { diff --git a/packages/web-ui/src/lib/hooks/useLocalStorage.ts b/packages/web-ui/src/lib/hooks/useLocalStorage.ts index 7ea24c3be3..62af44fc2d 100644 --- a/packages/web-ui/src/lib/hooks/useLocalStorage.ts +++ b/packages/web-ui/src/lib/hooks/useLocalStorage.ts @@ -20,6 +20,7 @@ export enum AppLocalStorage { chatDebugMode = 'chatDebugMode', latteThread = 'latteThread', latteSidebarWidth = 'latteSidebarWidth', + datasetOnboardingState = 'datasetOnboardingState', } export const isLocalStorageAvailable = (() => {