From 30f84bd63539d6474a836f2e9879988424f3d8ea Mon Sep 17 00:00:00 2001 From: Joe Baker Date: Tue, 8 Oct 2024 12:35:29 +0100 Subject: [PATCH 1/9] refactor: demo components to oak components --- apps/nextjs/src/app/aila/help/index.tsx | 17 +- .../components/AppComponents/Chat/header.tsx | 145 +++++++++++------- .../AppComponents/Chat/sidebar-mobile.tsx | 9 +- .../ContentOptions/DemoInterstitialDialog.tsx | 105 ++++++------- .../ContentOptions/DemoShareLockedDialog.tsx | 57 +++---- .../ContentOptions/DemoSharedComponents.tsx | 30 ++++ 6 files changed, 213 insertions(+), 150 deletions(-) create mode 100644 apps/nextjs/src/components/DialogControl/ContentOptions/DemoSharedComponents.tsx diff --git a/apps/nextjs/src/app/aila/help/index.tsx b/apps/nextjs/src/app/aila/help/index.tsx index a58146343..25cc745cd 100644 --- a/apps/nextjs/src/app/aila/help/index.tsx +++ b/apps/nextjs/src/app/aila/help/index.tsx @@ -2,11 +2,12 @@ import { useRef } from "react"; -import { OakLink } from "@oaknational/oak-components"; +import { OakFlex, OakLink } from "@oaknational/oak-components"; import { useSearchParams } from "next/navigation"; import { Header } from "@/components/AppComponents/Chat/header"; import GetInTouchBox from "@/components/AppComponents/GetInTouchBox"; +import { useDemoUser } from "@/components/ContextProviders/Demo"; const Help = () => { const startingRef = useRef(null); @@ -28,11 +29,19 @@ const Help = () => { const searchParams = useSearchParams(); const ailaId = searchParams.get("ailaId"); - + const demo = useDemoUser(); + const marginTop = demo.isDemoUser ? "200px" : "125px"; return ( <>
-
+

Help

@@ -219,7 +228,7 @@ const Help = () => {

-
+ ); }; diff --git a/apps/nextjs/src/components/AppComponents/Chat/header.tsx b/apps/nextjs/src/components/AppComponents/Chat/header.tsx index 0ac67fd99..94bf836dc 100644 --- a/apps/nextjs/src/components/AppComponents/Chat/header.tsx +++ b/apps/nextjs/src/components/AppComponents/Chat/header.tsx @@ -2,13 +2,17 @@ import * as React from "react"; -import { OakIcon } from "@oaknational/oak-components"; +import { + OakBox, + OakFlex, + OakIcon, + OakLink, + OakSpan, +} from "@oaknational/oak-components"; import { useClerkDemoMetadata } from "hooks/useClerkDemoMetadata"; -import Link from "next/link"; import { usePathname } from "next/navigation"; import { useDemoUser } from "@/components/ContextProviders/Demo"; -import { Icon } from "@/components/Icon"; import OakIconLogo from "@/components/OakIconLogo"; import { BetaTagHeader } from "./beta-tag"; @@ -25,67 +29,104 @@ export function Header() { const ailaId = usePathname().split("aila/")[1]; return ( -
+ {clerkMetadata.isSet && demo.isDemoUser && ( -
-
- - Create {demo.appSessionsPerMonth} lessons per month - - - If you are a teacher in the UK,{" "} - - contact us for full access. - -
- - - -
+ + + Create {demo.appSessionsPerMonth} lessons per month • + {" "} + If you are a teacher in the UK,{" "} + + contact us for full access + + + + {demo.appSessionsRemaining !== undefined && ( - - {demo.appSessionsRemaining} of {demo.appSessionsPerMonth} lessons - remaining - + + + {demo.appSessionsRemaining} of {demo.appSessionsPerMonth}{" "} + lessons remaining + + )} -
+ )} -
-
- - + + + + - - Aila - -
+ + Aila + + -
-
+ + -
- - -
Help
- -
+ + + + + + + Help + + + + + -
-
+ + -
-
-
-
+ + + + ); } diff --git a/apps/nextjs/src/components/AppComponents/Chat/sidebar-mobile.tsx b/apps/nextjs/src/components/AppComponents/Chat/sidebar-mobile.tsx index 329a2336a..4fa30dc87 100644 --- a/apps/nextjs/src/components/AppComponents/Chat/sidebar-mobile.tsx +++ b/apps/nextjs/src/components/AppComponents/Chat/sidebar-mobile.tsx @@ -1,5 +1,7 @@ "use client"; +import { OakFlex, OakSpan } from "@oaknational/oak-components"; + import { Sidebar } from "@/components/AppComponents/Chat/sidebar"; import { Button } from "@/components/AppComponents/Chat/ui/button"; import { @@ -27,9 +29,10 @@ export function SidebarMobile({ children }: Readonly) { }} > -
- Menu -
+ + Menu + + Toggle Sidebar diff --git a/apps/nextjs/src/components/DialogControl/ContentOptions/DemoInterstitialDialog.tsx b/apps/nextjs/src/components/DialogControl/ContentOptions/DemoInterstitialDialog.tsx index bd8972f01..ad90c7f3a 100644 --- a/apps/nextjs/src/components/DialogControl/ContentOptions/DemoInterstitialDialog.tsx +++ b/apps/nextjs/src/components/DialogControl/ContentOptions/DemoInterstitialDialog.tsx @@ -1,11 +1,20 @@ import { useCallback, useEffect, useState } from "react"; -import { Flex } from "@radix-ui/themes"; +import { + OakFlex, + OakLink, + OakPrimaryButton, + OakSecondaryLink, +} from "@oaknational/oak-components"; import { captureMessage } from "@sentry/nextjs"; -import Button from "@/components/Button"; import { useDemoUser } from "@/components/ContextProviders/Demo"; -import LoadingWheel from "@/components/LoadingWheel"; + +import { + DialogContainer, + DialogContent, + DialogHeading, +} from "./DemoSharedComponents"; function friendlyNumber( appSessionsRemaining: number | undefined, @@ -28,27 +37,6 @@ function friendlyNumber( } } -function DialogContainer({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ); -} - -function Heading({ children }: { children: React.ReactNode }) { - return

{children}

; -} - -function Content({ children }: { children: React.ReactNode }) { - return

{children}

; -} - const CreatingChatDialog = ({ submit, closeDialog, @@ -91,58 +79,59 @@ const CreatingChatDialog = ({ if (appSessionsRemaining === 0) { return ( - Lesson limit reached - + Lesson limit reached + You have created {demo.appSessionsPerMonth} of your{" "} {demo.appSessionsPerMonth} lessons available this month. If you are a teacher in the UK, please{" "} - + contact us for full access. - - - -
- - -
+ +
); } return ( - + Your {friendlyNumber(appSessionsRemaining, demo.appSessionsPerMonth)} demo lesson… - - + + You can create {demo.appSessionsPerMonth} chats per month. If you are a teacher in the UK and want to create more lessons,{" "} - + contact us for full access. - - - -
- - {!isSubmitting && ( - - )} - {isSubmitting && ( - - )} -
+ + + + Continue with lesson + +
); }; diff --git a/apps/nextjs/src/components/DialogControl/ContentOptions/DemoShareLockedDialog.tsx b/apps/nextjs/src/components/DialogControl/ContentOptions/DemoShareLockedDialog.tsx index 27078336d..7a88c6642 100644 --- a/apps/nextjs/src/components/DialogControl/ContentOptions/DemoShareLockedDialog.tsx +++ b/apps/nextjs/src/components/DialogControl/ContentOptions/DemoShareLockedDialog.tsx @@ -1,28 +1,16 @@ -import { Flex } from "@radix-ui/themes"; +import { + OakFlex, + OakLink, + OakPrimaryButton, +} from "@oaknational/oak-components"; -import Button from "@/components/Button"; import { useDemoUser } from "@/components/ContextProviders/Demo"; -function DialogContainer({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ); -} - -function Heading({ children }: { children: React.ReactNode }) { - return

{children}

; -} - -function Content({ children }: { children: React.ReactNode }) { - return

{children}

; -} +import { + DialogContainer, + DialogContent, + DialogHeading, +} from "./DemoSharedComponents"; const DemoShareLockedDialog = ({ closeDialog, @@ -37,21 +25,24 @@ const DemoShareLockedDialog = ({ return ( - Sharing and downloading - + Sharing and downloading + Share and download options are not available to users outside of the UK. If you are a teacher in the UK,{" "} - + contact us for full access. - - - -
-
- -
+ + ); }; diff --git a/apps/nextjs/src/components/DialogControl/ContentOptions/DemoSharedComponents.tsx b/apps/nextjs/src/components/DialogControl/ContentOptions/DemoSharedComponents.tsx new file mode 100644 index 000000000..9f3e756d6 --- /dev/null +++ b/apps/nextjs/src/components/DialogControl/ContentOptions/DemoSharedComponents.tsx @@ -0,0 +1,30 @@ +import { OakFlex, OakHeading, OakP } from "@oaknational/oak-components"; + +export function DialogContainer({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +export function DialogHeading({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +export function DialogContent({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} From d8108fff76d29c03fbbb11cebc8dcad0f64e6c87 Mon Sep 17 00:00:00 2001 From: Joe Baker Date: Tue, 8 Oct 2024 12:47:55 +0100 Subject: [PATCH 2/9] fix: add missing test id --- apps/nextjs/src/components/AppComponents/Chat/header.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/nextjs/src/components/AppComponents/Chat/header.tsx b/apps/nextjs/src/components/AppComponents/Chat/header.tsx index 94bf836dc..75dfcc30a 100644 --- a/apps/nextjs/src/components/AppComponents/Chat/header.tsx +++ b/apps/nextjs/src/components/AppComponents/Chat/header.tsx @@ -42,6 +42,7 @@ export function Header() { $background={"lemon"} $pv={"inner-padding-s"} $ph={"inner-padding-xl"} + data-testid="demo-banner" > From 405a24257a2976431686ac088fd21d04b5cb43c8 Mon Sep 17 00:00:00 2001 From: Joe Baker Date: Tue, 8 Oct 2024 13:27:17 +0100 Subject: [PATCH 3/9] style: bump heading size --- .../DialogControl/ContentOptions/DemoSharedComponents.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/nextjs/src/components/DialogControl/ContentOptions/DemoSharedComponents.tsx b/apps/nextjs/src/components/DialogControl/ContentOptions/DemoSharedComponents.tsx index 9f3e756d6..e4432627c 100644 --- a/apps/nextjs/src/components/DialogControl/ContentOptions/DemoSharedComponents.tsx +++ b/apps/nextjs/src/components/DialogControl/ContentOptions/DemoSharedComponents.tsx @@ -15,7 +15,7 @@ export function DialogContainer({ children }: { children: React.ReactNode }) { export function DialogHeading({ children }: { children: React.ReactNode }) { return ( - + {children} ); From 5d8ca79e9bed644c9dba5af9d2601489a1038236 Mon Sep 17 00:00:00 2001 From: MG Date: Thu, 10 Oct 2024 11:23:45 +0100 Subject: [PATCH 4/9] chore: add ingest config, split user prompt parts, add fixtures (#210) --- packages/db/prisma/schema.prisma | 1 + packages/ingest/src/config/ingestConfig.ts | 12 + .../src/db-helpers/createIngestRecord.ts | 20 + .../ingest/src/db-helpers/getIngestById.ts | 43 ++ packages/ingest/src/fixtures/rawLesson.json | 393 ++++++++++++++++++ packages/ingest/src/fixtures/userPrompt.txt | 52 +++ .../userPromptTitleSubjectKeyStage.txt | 1 + .../user-prompt-parts/exitQuiz.promptPart.ts | 13 + .../keyLearningPoints.promptPart.ts | 11 + .../user-prompt-parts/keywords.promptPart.ts | 19 + .../learningOutcome.promptPart.ts | 8 + .../misconceptions.promptPart.ts | 21 + .../starterQuiz.promptPart.ts | 13 + .../titleSubjectKeyStage.promptPart.ts | 8 + .../user-prompt-parts/toMarkdownList.ts | 8 + .../transcript.promptPart.ts | 13 + .../user-prompt-parts/year.promptPart.ts | 4 + 17 files changed, 640 insertions(+) create mode 100644 packages/ingest/src/config/ingestConfig.ts create mode 100644 packages/ingest/src/db-helpers/createIngestRecord.ts create mode 100644 packages/ingest/src/db-helpers/getIngestById.ts create mode 100644 packages/ingest/src/fixtures/rawLesson.json create mode 100644 packages/ingest/src/fixtures/userPrompt.txt create mode 100644 packages/ingest/src/fixtures/userPromptTitleSubjectKeyStage.txt create mode 100644 packages/ingest/src/generate-lesson-plans/user-prompt-parts/exitQuiz.promptPart.ts create mode 100644 packages/ingest/src/generate-lesson-plans/user-prompt-parts/keyLearningPoints.promptPart.ts create mode 100644 packages/ingest/src/generate-lesson-plans/user-prompt-parts/keywords.promptPart.ts create mode 100644 packages/ingest/src/generate-lesson-plans/user-prompt-parts/learningOutcome.promptPart.ts create mode 100644 packages/ingest/src/generate-lesson-plans/user-prompt-parts/misconceptions.promptPart.ts create mode 100644 packages/ingest/src/generate-lesson-plans/user-prompt-parts/starterQuiz.promptPart.ts create mode 100644 packages/ingest/src/generate-lesson-plans/user-prompt-parts/titleSubjectKeyStage.promptPart.ts create mode 100644 packages/ingest/src/generate-lesson-plans/user-prompt-parts/toMarkdownList.ts create mode 100644 packages/ingest/src/generate-lesson-plans/user-prompt-parts/transcript.promptPart.ts create mode 100644 packages/ingest/src/generate-lesson-plans/user-prompt-parts/year.promptPart.ts diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 61b64db7f..103250d55 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -984,6 +984,7 @@ enum QuizAnswerStatus { model Ingest { id String @id @default(cuid()) + config Json @default("{}") @map("config") @db.JsonB status String createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") diff --git a/packages/ingest/src/config/ingestConfig.ts b/packages/ingest/src/config/ingestConfig.ts new file mode 100644 index 000000000..93ea95224 --- /dev/null +++ b/packages/ingest/src/config/ingestConfig.ts @@ -0,0 +1,12 @@ +import { z } from "zod"; + +export const IngestConfigSchema = z.object({ + completionModel: z.literal("gpt-4o-2024-08-06"), + embeddingModel: z.literal("text-embedding-3-large"), + embeddingDimensions: z.literal(256), + sourcePartsToInclude: z.union([ + z.literal("all"), + z.literal("title-subject-key-stage"), + ]), +}); +export type IngestConfig = z.infer; diff --git a/packages/ingest/src/db-helpers/createIngestRecord.ts b/packages/ingest/src/db-helpers/createIngestRecord.ts new file mode 100644 index 000000000..101be8ca3 --- /dev/null +++ b/packages/ingest/src/db-helpers/createIngestRecord.ts @@ -0,0 +1,20 @@ +import { PrismaClientWithAccelerate } from "@oakai/db"; + +import { IngestConfig } from "../config/ingestConfig"; + +export async function createIngestRecord({ + prisma, + config, +}: { + prisma: PrismaClientWithAccelerate; + config: IngestConfig; +}) { + const ingest = await prisma.ingest.create({ + data: { + config, + status: "active", + }, + }); + + return { ...ingest, config }; +} diff --git a/packages/ingest/src/db-helpers/getIngestById.ts b/packages/ingest/src/db-helpers/getIngestById.ts new file mode 100644 index 000000000..f388482ad --- /dev/null +++ b/packages/ingest/src/db-helpers/getIngestById.ts @@ -0,0 +1,43 @@ +import { PrismaClientWithAccelerate } from "@oakai/db"; + +import { IngestError } from "../IngestError"; +import { IngestConfigSchema } from "../config/ingestConfig"; + +export async function getIngestById({ + prisma, + ingestId, +}: { + prisma: PrismaClientWithAccelerate; + ingestId: string; +}) { + const ingestRecord = await prisma.ingest.findUnique({ + where: { + id: ingestId, + }, + }); + + if (!ingestRecord) { + throw new IngestError(`Ingest with id ${ingestId} not found`); + } + + const config = IngestConfigSchema.safeParse(ingestRecord.config); + + if (!config.success) { + throw new IngestError( + `Ingest with id ${ingestId} has unsupported config: ${config.error.message}`, + { + errorDetail: { + config: ingestRecord.config, + zodError: config.error, + }, + }, + ); + } + + return { + ...ingestRecord, + config: IngestConfigSchema.parse(ingestRecord.config), + }; +} + +export type PersistedIngest = Awaited>; diff --git a/packages/ingest/src/fixtures/rawLesson.json b/packages/ingest/src/fixtures/rawLesson.json new file mode 100644 index 000000000..1219b3886 --- /dev/null +++ b/packages/ingest/src/fixtures/rawLesson.json @@ -0,0 +1,393 @@ +{ + "exitQuiz": [ + { + "hint": "This word can be used to describe the experience of enslaved people working on plantations in Saint-Domingue.", + "active": false, + "answers": {}, + "feedback": "Intolerable refers to being unable to bear an experience any longer.", + "questionId": 259037, + "questionUid": "QUES-MLGE2-59037", + "questionStem": [ + { + "text": "What term refers to being unable to bear an experience any longer?", + "type": "text" + } + ], + "questionType": "short-answer" + }, + { + "hint": "These things intensified British public opposition to enslavement.", + "active": false, + "answers": { + "multiple-choice": [ + { + "answer": [ + { + "text": "revolts spreading around European colonies in the Caribbean", + "type": "text" + } + ], + "answer_is_correct": true + }, + { + "answer": [ + { + "text": "the direct experiences that members of the British public had of enslavement", + "type": "text" + } + ], + "answer_is_correct": false + }, + { + "answer": [{ "text": "the Zong massacre", "type": "text" }], + "answer_is_correct": true + }, + { + "answer": [{ "text": "slave-owners' testimony", "type": "text" }], + "answer_is_correct": false + }, + { + "answer": [ + { "text": "Olaudah Equiano's autobiography", "type": "text" } + ], + "answer_is_correct": true + } + ] + }, + "feedback": "Revolts spreading around European colonies in the Caribbean, the Zong massacre, and Olaudah Equiano's autobiography all intensified British public opposition to enslavement.", + "questionId": 259038, + "questionUid": "QUES-VWLK2-59038", + "questionStem": [ + { + "text": "What was the proof for the British public that life for enslaved people was horrific?", + "type": "text" + } + ], + "questionType": "multiple-choice" + }, + { + "hint": "These were three key abolitionist figures at the time.", + "active": false, + "answers": {}, + "feedback": "William Wilberforce and Olaudah Equiano worked for abolition in Britain, while Toussaint L'Ouverture fought for abolition in Saint-Domingue.", + "questionId": 259039, + "questionUid": "QUES-BNXO2-59039", + "questionStem": [ + { + "text": "Match the key figures of abolition to the correct description.", + "type": "text" + } + ], + "questionType": "match" + }, + { + "hint": "This is a Latin phrase literally translated as meaning: \"that you have the body\".", + "active": false, + "answers": { + "multiple-choice": [ + { + "answer": [{ "text": "carpe diem", "type": "text" }], + "answer_is_correct": false + }, + { + "answer": [{ "text": "habeas corpus", "type": "text" }], + "answer_is_correct": true + }, + { + "answer": [{ "text": "veni vidi vici", "type": "text" }], + "answer_is_correct": false + }, + { + "answer": [{ "text": "pro bono", "type": "text" }], + "answer_is_correct": false + } + ] + }, + "feedback": "Habeas corpus is the fundamental right in English law that prevents people from unlawful imprisonment.", + "questionId": 259040, + "questionUid": "QUES-DVLZ2-59040", + "questionStem": [ + { + "text": "What term is used to refer to the fundamental right in English law that prevents people from unlawful imprisonment?", + "type": "text" + } + ], + "questionType": "multiple-choice" + }, + { + "hint": "The Zong Massacre occurred towards the end of the 18th century.", + "active": false, + "answers": { + "multiple-choice": [ + { + "answer": [{ "text": "1681", "type": "text" }], + "answer_is_correct": false + }, + { + "answer": [{ "text": "1781", "type": "text" }], + "answer_is_correct": true + }, + { + "answer": [{ "text": "1881", "type": "text" }], + "answer_is_correct": false + }, + { + "answer": [{ "text": "1981", "type": "text" }], + "answer_is_correct": false + } + ] + }, + "feedback": "The Zong Massacre occurred in 1781 and intensified British public opinion against enslavement.", + "questionId": 259041, + "questionUid": "QUES-UUDM2-59041", + "questionStem": [ + { "text": "In what year did the Zong Massacre occur?", "type": "text" } + ], + "questionType": "multiple-choice" + }, + { + "hint": "His owner was a man named Charles Stewart, whom he deserted before being recaptured after a month of living in England as a free man.", + "active": false, + "answers": {}, + "feedback": "After the Somerset v Stewart case (1772), James Somerset's 'detention' was considered unlawful, and set a precedent of outlawing enslavement in England and Wales.", + "questionId": 259042, + "questionUid": "QUES-JXMF2-59042", + "questionStem": [ + { + "text": "James {{}} was the enslaved man whose court case helped to establish in 1772 that enslavement in England and Wales was unlawful.", + "type": "text" + } + ], + "questionType": "short-answer" + } + ], + "unitSlug": "the-haitian-revolution-what-was-its-role-in-the-abolition-of-the-slave-trade", + "tierTitle": null, + "yearTitle": "Year 8", + "lessonSlug": "abolitionist-movements-in-britain", + "videoTitle": "LESS-TXQOZ-G5333-abolitionist-movements-in-britain-v2", + "lessonTitle": "Abolitionist movements in Britain", + "oakLessonId": 5333, + "starterQuiz": [ + { + "hint": "This place is located in the Americas.", + "active": false, + "answers": {}, + "feedback": "Saint-Domingue had been France's most prosperous colony in the Caribbean during the 18th century.", + "questionId": 259031, + "questionUid": "QUES-NRIW2-59031", + "questionStem": [ + { + "text": "Complete the following sentence: Saint-Domingue had been France's most prosperous colony in the {{}} during the 18th century.", + "type": "text" + } + ], + "questionType": "short-answer" + }, + { + "hint": "France withdrew from Haiti late in 1803.", + "active": false, + "answers": {}, + "feedback": "Haiti declared its independence on 1st January 1804.", + "questionId": 259032, + "questionUid": "QUES-HHKM2-59032", + "questionStem": [ + { + "text": "In what year did Haiti declare its independence?", + "type": "text" + } + ], + "questionType": "short-answer" + }, + { + "hint": "This person had served as one of Toussaint L'Ouverture's key lieutenants throughout the conflict up until this time.", + "active": false, + "answers": { + "multiple-choice": [ + { + "answer": [{ "text": "Charles Leclerc", "type": "text" }], + "answer_is_correct": false + }, + { + "answer": [{ "text": "Jean-Jacques Dessalines", "type": "text" }], + "answer_is_correct": true + }, + { + "answer": [{ "text": "Napoleon Bonaparte", "type": "text" }], + "answer_is_correct": false + }, + { + "answer": [{ "text": "General Maitland", "type": "text" }], + "answer_is_correct": false + } + ] + }, + "feedback": "Jean-Jacques Dessalines took over the leadership of the rebels, securing victory at the Battle of Vertieres and dispelling the French threat finally.", + "questionId": 259033, + "questionUid": "QUES-DSXM2-59033", + "questionStem": [ + { + "text": "After Toussaint L'Ouverture was captured and transported back to France where he faced imprisonment and death, who took over the resistance in Saint-Domingue against France?", + "type": "text" + } + ], + "questionType": "multiple-choice" + }, + { + "hint": "Toussaint L'Ouverture was careful not to go too far in opposing such a strong military leader like Napoleon, but did continue with his reforms.", + "active": false, + "answers": { + "multiple-choice": [ + { + "answer": [ + { + "text": "He declared a new slavery law for Saint-Domingue.", + "type": "text" + } + ], + "answer_is_correct": false + }, + { + "answer": [ + { + "text": "He became an enemy of Napoleon and declared independence for Haiti.", + "type": "text" + } + ], + "answer_is_correct": false + }, + { + "answer": [ + { + "text": "He declared a new constitution for Saint-Domingue and prepared for independence.", + "type": "text" + } + ], + "answer_is_correct": true + }, + { + "answer": [ + { + "text": "He asked Britain for military assistance.", + "type": "text" + } + ], + "answer_is_correct": false + } + ] + }, + "feedback": "Toussaint L'Ouverture declared a new constitution for Saint-Domingue and prepared for independence for Haiti, knowing that his people would never accept the return of enslavement as proposed by Napoleon.", + "questionId": 259034, + "questionUid": "QUES-NVRQ2-59034", + "questionStem": [ + { + "text": "What did Toussaint L’Ouverture do when he learned of Napoleon’s plan to reinstate slavery in 1802?", + "type": "text" + } + ], + "questionType": "multiple-choice" + }, + { + "hint": "These events all occurred after Britain's withdrawal from the island.", + "active": false, + "answers": {}, + "feedback": "Napoleon's decision to reinstate slavery transformed France, from being L'Ouverture's ally, back into being his enemy.", + "questionId": 259035, + "questionUid": "QUES-USXE2-59035", + "questionStem": [ + { + "text": "Put the below events in chronological order, starting with the earliest:", + "type": "text" + } + ], + "questionType": "order" + }, + { + "hint": "This group of soldiers were lied to, with Napoleon telling them that they were travelling to Saint-Domingue to put down a prison revolt.", + "active": false, + "answers": { + "multiple-choice": [ + { + "answer": [{ "text": "British", "type": "text" }], + "answer_is_correct": false + }, + { + "answer": [{ "text": "German", "type": "text" }], + "answer_is_correct": false + }, + { + "answer": [{ "text": "Polish", "type": "text" }], + "answer_is_correct": true + }, + { + "answer": [{ "text": "French", "type": "text" }], + "answer_is_correct": false + }, + { + "answer": [{ "text": "Spanish", "type": "text" }], + "answer_is_correct": false + } + ] + }, + "feedback": "When the Polish soldiers realised they had been lied to, they switched sides. Some Poles settled in Saint-Domingue and there is still a community of Polish Haitians in Haiti today.", + "questionId": 259036, + "questionUid": "QUES-EOFT2-59036", + "questionStem": [ + { + "text": "What nationality were the portion of Leclerc's troops who switched sides to fight for the rebels against France?", + "type": "text" + } + ], + "questionType": "multiple-choice" + } + ], + "subjectSlug": "history", + "keyStageSlug": "ks3", + "subjectTitle": "History", + "programmeSlug": "history-secondary-ks3", + "examBoardTitle": null, + "lessonKeywords": [ + { + "keyword": "habeas corpus", + "description": "English law declaring detention or imprisonment as illegal if you have not committed a crime" + }, + { + "keyword": "massacre", + "description": "when many people are killed purposely in a violent manner" + }, + { + "keyword": "abolition", + "description": "the outlawing of enslavement by a particular country" + }, + { + "keyword": "intolerable", + "description": "being unable to bear an experience any longer" + } + ], + "copyrightContent": [], + "keyLearningPoints": [ + { + "keyLearningPoint": "In 1772 it was established that slavery went against English law and many people felt uneasy about Britain's role in it." + }, + { + "keyLearningPoint": "People in Britain began to criticise the conditions on slave ships across the 'Middle Passage' to the Americas." + }, + { + "keyLearningPoint": "The case of the Zong Massacre created public outrage in Britain at the conditions in which enslaved people were living." + }, + { + "keyLearningPoint": "Groups and individuals such as Olaudah Equiano and William Wilberforce worked for abolition in Britain and its colonies." + }, + { + "keyLearningPoint": "The Haitian Revolution created fear amongst British plantation owners about similar uprisings across the Caribbean." + } + ], + "pupilLessonOutcome": "I can explain British campaigns for abolition of slavery and the Haitian Revolution's role in strengthening these movements. ", + "transcriptSentences": null, + "misconceptionsAndCommonMistakes": [ + { + "response": "Abolition of slavery in Britain and its colonies was a response to a number of events and an ever-growing public demand for change.", + "misconception": "Abolition of slavery in Britain and its colonies was a response to a single event." + } + ] +} diff --git a/packages/ingest/src/fixtures/userPrompt.txt b/packages/ingest/src/fixtures/userPrompt.txt new file mode 100644 index 000000000..af5b51b98 --- /dev/null +++ b/packages/ingest/src/fixtures/userPrompt.txt @@ -0,0 +1,52 @@ +I would like to generate a lesson plan with the title "Abolitionist movements in Britain", subject "history" and key stage "ks3". + + +The lesson is intended for Year 8. + + +The lesson has the following transcript which is a recording of the lesson being delivered by a teacher. +I would like you to base your response on the content of the lesson rather than imagining other content that could be valid for a lesson with this title. +Think about the structure of the lesson based on the transcript and see if it can be broken up into logical sections which correspond to the definition of a learning cycle. +The transcript may include introductory and exit quizzes, so include these if they are multiple choice. Otherwise generate the multiple choice quiz questions based on the content of the rawLesson. +The transcript is as follows: + + +These are the captions + + + +One ore more of the learning cycles could be guided by the following: + +- I can explain British campaigns for abolition of slavery and the Haitian Revolution's role in strengthening these movements. + + +The lesson should include the following key learning points. Include these in the lesson plan: + +- In 1772 it was established that slavery went against English law and many people felt uneasy about Britain's role in it. +- People in Britain began to criticise the conditions on slave ships across the 'Middle Passage' to the Americas. +- The case of the Zong Massacre created public outrage in Britain at the conditions in which enslaved people were living. +- Groups and individuals such as Olaudah Equiano and William Wilberforce worked for abolition in Britain and its colonies. +- The Haitian Revolution created fear amongst British plantation owners about similar uprisings across the Caribbean. + + +The lesson should include the following misconceptions. Include these in the lesson plan: + +- **Misconception**: Abolition of slavery in Britain and its colonies was a response to a single event., **Description**: Abolition of slavery in Britain and its colonies was a response to a number of events and an ever-growing public demand for change. + + +The lesson should include the following keywords. Include these in the lesson plan: + +- **habeas corpus**: English law declaring detention or imprisonment as illegal if you have not committed a crime +- **massacre**: when many people are killed purposely in a violent manner +- **abolition**: the outlawing of enslavement by a particular country +- **intolerable**: being unable to bear an experience any longer + + +The lesson should include the following starter quiz questions. Include them within the lesson plan's starter quiz: + +[{"question":"After Toussaint L'Ouverture was captured and transported back to France where he faced imprisonment and death, who took over the resistance in Saint-Domingue against France?","answers":["Jean-Jacques Dessalines"],"distractors":["Charles Leclerc","Napoleon Bonaparte","General Maitland"]},{"question":"What did Toussaint L’Ouverture do when he learned of Napoleon’s plan to reinstate slavery in 1802?","answers":["He declared a new constitution for Saint-Domingue and prepared for independence."],"distractors":["He declared a new slavery law for Saint-Domingue.","He became an enemy of Napoleon and declared independence for Haiti.","He asked Britain for military assistance."]},{"question":"What nationality were the portion of Leclerc's troops who switched sides to fight for the rebels against France?","answers":["Polish"],"distractors":["British","German","French","Spanish"]}] + + +The lesson should include the following exit quiz questions. Include them within the lesson plan's exit quiz: + +[{"question":"What was the proof for the British public that life for enslaved people was horrific?","answers":["revolts spreading around European colonies in the Caribbean","the Zong massacre","Olaudah Equiano's autobiography"],"distractors":["the direct experiences that members of the British public had of enslavement","slave-owners' testimony"]},{"question":"What term is used to refer to the fundamental right in English law that prevents people from unlawful imprisonment?","answers":["habeas corpus"],"distractors":["carpe diem","veni vidi vici","pro bono"]},{"question":"In what year did the Zong Massacre occur?","answers":["1781"],"distractors":["1681","1881","1981"]}] \ No newline at end of file diff --git a/packages/ingest/src/fixtures/userPromptTitleSubjectKeyStage.txt b/packages/ingest/src/fixtures/userPromptTitleSubjectKeyStage.txt new file mode 100644 index 000000000..eb5aec29d --- /dev/null +++ b/packages/ingest/src/fixtures/userPromptTitleSubjectKeyStage.txt @@ -0,0 +1 @@ +I would like to generate a lesson plan with the title "Abolitionist movements in Britain", subject "history" and key stage "ks3". \ No newline at end of file diff --git a/packages/ingest/src/generate-lesson-plans/user-prompt-parts/exitQuiz.promptPart.ts b/packages/ingest/src/generate-lesson-plans/user-prompt-parts/exitQuiz.promptPart.ts new file mode 100644 index 000000000..db834f537 --- /dev/null +++ b/packages/ingest/src/generate-lesson-plans/user-prompt-parts/exitQuiz.promptPart.ts @@ -0,0 +1,13 @@ +import { RawLesson } from "../../zod-schema/zodSchema"; +import { transformQuiz } from "../transformQuiz"; + +export function exitQuizPromptPart(rawLesson: RawLesson) { + const { exitQuiz } = rawLesson; + + const exitQuizQuestions = exitQuiz ? transformQuiz(exitQuiz) : []; + return exitQuizQuestions.length + ? `The lesson should include the following exit quiz questions. Include them within the lesson plan's exit quiz: + +${JSON.stringify(exitQuizQuestions)}` + : null; +} diff --git a/packages/ingest/src/generate-lesson-plans/user-prompt-parts/keyLearningPoints.promptPart.ts b/packages/ingest/src/generate-lesson-plans/user-prompt-parts/keyLearningPoints.promptPart.ts new file mode 100644 index 000000000..7f9182725 --- /dev/null +++ b/packages/ingest/src/generate-lesson-plans/user-prompt-parts/keyLearningPoints.promptPart.ts @@ -0,0 +1,11 @@ +import { RawLesson } from "../../zod-schema/zodSchema"; +import { toMarkdownList } from "./toMarkdownList"; + +export const keyLearningPointsPromptPart = ({ + keyLearningPoints, +}: RawLesson) => + keyLearningPoints?.length + ? `The lesson should include the following key learning points. Include these in the lesson plan: + +${toMarkdownList(keyLearningPoints, (k) => k.keyLearningPoint)}` + : null; diff --git a/packages/ingest/src/generate-lesson-plans/user-prompt-parts/keywords.promptPart.ts b/packages/ingest/src/generate-lesson-plans/user-prompt-parts/keywords.promptPart.ts new file mode 100644 index 000000000..44e65328f --- /dev/null +++ b/packages/ingest/src/generate-lesson-plans/user-prompt-parts/keywords.promptPart.ts @@ -0,0 +1,19 @@ +import { RawLesson } from "../../zod-schema/zodSchema"; +import { toMarkdownList } from "./toMarkdownList"; + +export const lessonKeywordsPromptPart = ({ lessonKeywords }: RawLesson) => + lessonKeywords?.length + ? `The lesson should include the following keywords. Include these in the lesson plan: + +${toMarkdownList(lessonKeywords, getKeywordText)}` + : null; + +function getKeywordText({ + keyword, + description, +}: { + keyword: string; + description: string; +}) { + return `**${keyword}**: ${description}`; +} diff --git a/packages/ingest/src/generate-lesson-plans/user-prompt-parts/learningOutcome.promptPart.ts b/packages/ingest/src/generate-lesson-plans/user-prompt-parts/learningOutcome.promptPart.ts new file mode 100644 index 000000000..5cbcbc1b8 --- /dev/null +++ b/packages/ingest/src/generate-lesson-plans/user-prompt-parts/learningOutcome.promptPart.ts @@ -0,0 +1,8 @@ +import { RawLesson } from "../../zod-schema/zodSchema"; + +export const learningOutcomePromptPart = ({ pupilLessonOutcome }: RawLesson) => + pupilLessonOutcome + ? `One ore more of the learning cycles could be guided by the following: + +- ${pupilLessonOutcome}` + : null; diff --git a/packages/ingest/src/generate-lesson-plans/user-prompt-parts/misconceptions.promptPart.ts b/packages/ingest/src/generate-lesson-plans/user-prompt-parts/misconceptions.promptPart.ts new file mode 100644 index 000000000..8912fa3fa --- /dev/null +++ b/packages/ingest/src/generate-lesson-plans/user-prompt-parts/misconceptions.promptPart.ts @@ -0,0 +1,21 @@ +import { RawLesson } from "../../zod-schema/zodSchema"; +import { toMarkdownList } from "./toMarkdownList"; + +export const misconceptionsPromptPart = ({ + misconceptionsAndCommonMistakes, +}: RawLesson) => + misconceptionsAndCommonMistakes?.length + ? `The lesson should include the following misconceptions. Include these in the lesson plan: + +${toMarkdownList(misconceptionsAndCommonMistakes, misconceptionText)}` + : null; + +function misconceptionText({ + misconception, + response, +}: { + misconception: string; + response: string; +}) { + return `**Misconception**: ${misconception}, **Description**: ${response}`; +} diff --git a/packages/ingest/src/generate-lesson-plans/user-prompt-parts/starterQuiz.promptPart.ts b/packages/ingest/src/generate-lesson-plans/user-prompt-parts/starterQuiz.promptPart.ts new file mode 100644 index 000000000..927e51d93 --- /dev/null +++ b/packages/ingest/src/generate-lesson-plans/user-prompt-parts/starterQuiz.promptPart.ts @@ -0,0 +1,13 @@ +import { RawLesson } from "../../zod-schema/zodSchema"; +import { transformQuiz } from "../transformQuiz"; + +export function starterQuizPromptPart(rawLesson: RawLesson) { + const { starterQuiz } = rawLesson; + + const starterQuizQuestions = starterQuiz ? transformQuiz(starterQuiz) : []; + return starterQuizQuestions.length + ? `The lesson should include the following starter quiz questions. Include them within the lesson plan's starter quiz: + +${JSON.stringify(starterQuizQuestions)}` + : null; +} diff --git a/packages/ingest/src/generate-lesson-plans/user-prompt-parts/titleSubjectKeyStage.promptPart.ts b/packages/ingest/src/generate-lesson-plans/user-prompt-parts/titleSubjectKeyStage.promptPart.ts new file mode 100644 index 000000000..19fdb4319 --- /dev/null +++ b/packages/ingest/src/generate-lesson-plans/user-prompt-parts/titleSubjectKeyStage.promptPart.ts @@ -0,0 +1,8 @@ +import { RawLesson } from "../../zod-schema/zodSchema"; + +export const titleSubjectKeyStagePromptPart = ({ + lessonTitle, + subjectSlug, + keyStageSlug, +}: RawLesson) => + `I would like to generate a lesson plan with the title "${lessonTitle}", subject "${subjectSlug}" and key stage "${keyStageSlug}".`; diff --git a/packages/ingest/src/generate-lesson-plans/user-prompt-parts/toMarkdownList.ts b/packages/ingest/src/generate-lesson-plans/user-prompt-parts/toMarkdownList.ts new file mode 100644 index 000000000..d74cb5c9b --- /dev/null +++ b/packages/ingest/src/generate-lesson-plans/user-prompt-parts/toMarkdownList.ts @@ -0,0 +1,8 @@ +export function toMarkdownList( + items: T[], + getListItemText?: (item: T, i: number) => string, +) { + return items + .map((item, i) => `- ${getListItemText ? getListItemText(item, i) : item}`) + .join("\n"); +} diff --git a/packages/ingest/src/generate-lesson-plans/user-prompt-parts/transcript.promptPart.ts b/packages/ingest/src/generate-lesson-plans/user-prompt-parts/transcript.promptPart.ts new file mode 100644 index 000000000..37ea3b55b --- /dev/null +++ b/packages/ingest/src/generate-lesson-plans/user-prompt-parts/transcript.promptPart.ts @@ -0,0 +1,13 @@ +import { Captions } from "../../zod-schema/zodSchema"; + +export const transcriptPromptPart = ( + captions: Captions, +) => `The lesson has the following transcript which is a recording of the lesson being delivered by a teacher. +I would like you to base your response on the content of the lesson rather than imagining other content that could be valid for a lesson with this title. +Think about the structure of the lesson based on the transcript and see if it can be broken up into logical sections which correspond to the definition of a learning cycle. +The transcript may include introductory and exit quizzes, so include these if they are multiple choice. Otherwise generate the multiple choice quiz questions based on the content of the rawLesson. +The transcript is as follows: + + +${captions.map((c) => c.text).join(" ")} +`; diff --git a/packages/ingest/src/generate-lesson-plans/user-prompt-parts/year.promptPart.ts b/packages/ingest/src/generate-lesson-plans/user-prompt-parts/year.promptPart.ts new file mode 100644 index 000000000..9425fa1aa --- /dev/null +++ b/packages/ingest/src/generate-lesson-plans/user-prompt-parts/year.promptPart.ts @@ -0,0 +1,4 @@ +import { RawLesson } from "../../zod-schema/zodSchema"; + +export const yearPromptPart = ({ yearTitle }: RawLesson) => + yearTitle ? `The lesson is intended for ${yearTitle}.` : null; From 6732b0b8b802737f3257ac311acf98157ad040a5 Mon Sep 17 00:00:00 2001 From: Adam Howard <91115+codeincontext@users.noreply.github.com> Date: Thu, 10 Oct 2024 16:50:01 +0200 Subject: [PATCH 5/9] chore: refactor chat provider (#214) --- .../ContextProviders/ChatProvider.tsx | 306 +++++++++--------- ...seTemporaryLessonPlanWithStreamingEdits.ts | 19 +- 2 files changed, 158 insertions(+), 167 deletions(-) diff --git a/apps/nextjs/src/components/ContextProviders/ChatProvider.tsx b/apps/nextjs/src/components/ContextProviders/ChatProvider.tsx index 71889f014..a884343fb 100644 --- a/apps/nextjs/src/components/ContextProviders/ChatProvider.tsx +++ b/apps/nextjs/src/components/ContextProviders/ChatProvider.tsx @@ -9,7 +9,7 @@ import React, { } from "react"; import { toast } from "react-hot-toast"; -import { redirect, usePathname, useRouter } from "#next/navigation"; +import { redirect, usePathname } from "#next/navigation"; import { generateMessageId } from "@oakai/aila/src/helpers/chat/generateMessageId"; import { parseMessageParts } from "@oakai/aila/src/protocol/jsonPatchProtocol"; import { @@ -108,31 +108,133 @@ function getModerationFromMessage(message?: { content: string }) { return moderation; } +/** + * This is a hack to ensure that the assistant messages have a stable id + * across server and client. + * We should move away from this either when the vercel/ai package supports it + * natively, or when we move away from streaming. + */ +function useStableMessageId(messages: Message[]) { + useEffect(() => { + return messages.forEach((message) => { + if (message.role !== "assistant") { + return; + } + + const idIsStable = message.id.startsWith("a-"); + if (idIsStable) { + return; + } + + const idFromContent = findMessageIdFromContent(message); + if (idFromContent) { + message.id = idFromContent; + return; + } + + message.id = "TEMP_PENDING_" + nanoid(); + }); + }, [messages]); +} + +function useAppendInitialMessage({ + startingMessage, + append, +}: { + startingMessage: string | undefined; + append: ( + message: Message | CreateMessage, + ) => Promise; +}) { + const hasAppendedInitialMessage = useRef(false); + + useEffect(() => { + if (startingMessage && !hasAppendedInitialMessage.current) { + append({ + content: startingMessage, + role: "user", + }); + hasAppendedInitialMessage.current = true; + } + }, [startingMessage, append, hasAppendedInitialMessage]); +} + +function useQueueUserAction({ + hasFinished, + append, + reload, +}: { + hasFinished: boolean; + append: ( + message: Message | CreateMessage, + ) => Promise; + reload: () => void; +}) { + const [queuedUserAction, setQueuedUserAction] = useState(null); + const isExecutingAction = useRef(false); + + const queueUserAction = useCallback((action: string) => { + setQueuedUserAction(action); + }, []); + + const clearQueuedUserAction = useCallback(() => { + setQueuedUserAction(null); + }, []); + + const executeQueuedAction = useCallback(async () => { + if (!queuedUserAction || !hasFinished || isExecutingAction.current) return; + + isExecutingAction.current = true; + const actionToExecute = queuedUserAction; + setQueuedUserAction(null); + + try { + if (actionToExecute === "continue") { + await append({ + content: "Continue", + role: "user", + }); + } else if (actionToExecute === "regenerate") { + reload(); + } else { + // Assume it's a user message + await append({ + content: actionToExecute, + role: "user", + }); + } + } catch (error) { + console.error("Error handling queued action:", error); + } finally { + isExecutingAction.current = false; + } + }, [queuedUserAction, hasFinished, append, reload]); + + useEffect(() => { + if (hasFinished) { + executeQueuedAction(); + } + }, [hasFinished, executeQueuedAction]); + + return { + queueUserAction, + clearQueuedUserAction, + queuedUserAction, + executeQueuedAction, + }; +} + export function ChatProvider({ id, children }: Readonly) { const { data: chat, isLoading: isChatLoading, refetch: refetchChat, - } = trpc.chat.appSessions.getChat.useQuery( - { id }, - { - refetchOnMount: true, - refetchOnWindowFocus: true, - staleTime: 0, - }, - ); + } = trpc.chat.appSessions.getChat.useQuery({ id }); const { data: moderations, isLoading: isModerationsLoading, refetch: refetchModerations, - } = trpc.chat.appSessions.getModerations.useQuery( - { id }, - { - refetchOnMount: true, - refetchOnWindowFocus: true, - staleTime: 0, - }, - ); + } = trpc.chat.appSessions.getModerations.useQuery({ id }); // Ensure that we re-fetch on mount useEffect(() => { refetchChat(); @@ -141,22 +243,17 @@ export function ChatProvider({ id, children }: Readonly) { const trpcUtils = trpc.useUtils(); const lessonPlanTracking = useLessonPlanTracking(); - const shouldTrackStreamFinished = useRef(false); + const lessonPlanSnapshot = useRef({}); const [lastModeration, setLastModeration] = useState( moderations?.[moderations.length - 1] ?? null, ); - const router = useRouter(); const path = usePathname(); const chatAreaRef = useRef(null); const [hasFinished, setHasFinished] = useState(true); - const hasAppendedInitialMessage = useRef(false); - - const lessonPlanSnapshot = useRef({}); - const [overrideLessonPlan, setOverrideLessonPlan] = useState< LooseLessonPlan | undefined >(undefined); @@ -211,9 +308,6 @@ export function ChatProvider({ id, children }: Readonly) { if (hasFinished) { setHasFinished(false); } - if (!path?.includes("chat/[id]")) { - window.history.pushState({}, "", `/aila/${id}`); - } }, onFinish(response) { console.log("Chat: On Finish", new Date().toISOString(), { @@ -232,156 +326,56 @@ export function ChatProvider({ id, children }: Readonly) { invokeActionMessages(response.content); trpcUtils.chat.appSessions.getChat.invalidate({ id }); + clearHashCache(); setHasFinished(true); - shouldTrackStreamFinished.current = true; + + lessonPlanTracking.onStreamFinished({ + prevLesson: lessonPlanSnapshot.current, + nextLesson: tempLessonPlan, + messages, + }); + chatAreaRef.current?.scrollTo(0, chatAreaRef.current?.scrollHeight); }, }); - useEffect(() => { - /** - * This is a hack to ensure that the assistant messages have a stable id - * across server and client. - * We should move away from this either when the vercel/ai package supports it - * natively, or when we move away from streaming. - */ - return messages.forEach((message) => { - if (message.role !== "assistant") { - return; - } - - const idIsStable = message.id.startsWith("a-"); - if (idIsStable) { - return; - } - - const idFromContent = findMessageIdFromContent(message); - if (idFromContent) { - message.id = idFromContent; - return; - } + useStableMessageId(messages); + useAppendInitialMessage({ startingMessage: chat?.startingMessage, append }); - message.id = "TEMP_PENDING_" + nanoid(); - }); - }, [messages]); - - const { tempLessonPlan, partialPatches, validPatches } = - useTemporaryLessonPlanWithStreamingEdits({ - lessonPlan: chat?.lessonPlan ?? {}, - messages, - isStreaming: !hasFinished, - messageHashes, - }); + // NOTE: this hook also returns validPatches and partialPatches, but we don't use them + const { tempLessonPlan } = useTemporaryLessonPlanWithStreamingEdits({ + lessonPlan: chat?.lessonPlan ?? {}, + messages, + isStreaming: !hasFinished, + messageHashes, + }); // Handle queued user actions and messages - - const [queuedUserAction, setQueuedUserAction] = useState(null); - const isExecutingAction = useRef(false); - - const queueUserAction = useCallback((action: string) => { - setQueuedUserAction(action); - }, []); - - const executeQueuedAction = useCallback(async () => { - if (!queuedUserAction || !hasFinished || isExecutingAction.current) return; - - isExecutingAction.current = true; - const actionToExecute = queuedUserAction; - setQueuedUserAction(null); - - try { - if (actionToExecute === "continue") { - await append({ - content: "Continue", - role: "user", - }); - } else if (actionToExecute === "regenerate") { - reload(); - } else { - // Assume it's a user message - await append({ - content: actionToExecute, - role: "user", - }); - } - } catch (error) { - console.error("Error handling queued action:", error); - } finally { - isExecutingAction.current = false; - } - }, [queuedUserAction, hasFinished, append, reload]); - - useEffect(() => { - if (hasFinished) { - executeQueuedAction(); - } - }, [hasFinished, executeQueuedAction]); + const { + queuedUserAction, + queueUserAction, + clearQueuedUserAction, + executeQueuedAction, + } = useQueueUserAction({ hasFinished, append, reload }); const stop = useCallback(() => { if (queuedUserAction) { - setQueuedUserAction(null); + clearQueuedUserAction(); } else { stopStreaming(); } - }, [queuedUserAction, setQueuedUserAction, stopStreaming]); - - /** - * If the state is being restored from a previous lesson plan, set the lesson plan - */ - - useEffect(() => { - if (chat?.startingMessage && !hasAppendedInitialMessage.current) { - append({ - content: chat.startingMessage, - role: "user", - }); - hasAppendedInitialMessage.current = true; - } - }, [chat?.startingMessage, append, router, path, hasAppendedInitialMessage]); - - // Clear the hash cache each completed message - useEffect(() => { - clearHashCache(); - }, [hasFinished]); - - /** - * Update the lesson plan if the chat has finished updating - * Fetch the state from the last "state" command in the most recent assistant message - */ - useEffect(() => { - if (!hasFinished || !messages) return; - trpcUtils.chat.appSessions.getChat.invalidate({ id }); - if (shouldTrackStreamFinished.current) { - lessonPlanTracking.onStreamFinished({ - prevLesson: lessonPlanSnapshot.current, - nextLesson: tempLessonPlan, - messages, - }); - shouldTrackStreamFinished.current = false; - } - }, [ - id, - trpcUtils.chat.appSessions.getChat, - hasFinished, - messages, - lessonPlanTracking, - tempLessonPlan, - ]); + }, [queuedUserAction, clearQueuedUserAction, stopStreaming]); /** * Get the sensitive moderation id and pass to dialog */ - const toxicInitialModeration = moderations?.find(isToxic) ?? null; - const toxicModeration = lastModeration && isToxic(lastModeration) ? lastModeration : toxicInitialModeration; - const ailaStreamingStatus = useAilaStreamingStatus({ isLoading, messages }); - useEffect(() => { if (toxicModeration) { setMessages([]); @@ -389,6 +383,8 @@ export function ChatProvider({ id, children }: Readonly) { } }, [toxicModeration, setMessages]); + const ailaStreamingStatus = useAilaStreamingStatus({ isLoading, messages }); + const value: ChatContextProps = useMemo( () => ({ id, @@ -396,8 +392,6 @@ export function ChatProvider({ id, children }: Readonly) { initialModerations: moderations ?? [], toxicModeration, lessonPlan: overrideLessonPlan ?? tempLessonPlan, - hasFinished, - hasAppendedInitialMessage, chatAreaRef, append, messages, @@ -409,8 +403,6 @@ export function ChatProvider({ id, children }: Readonly) { stop, input, setInput, - partialPatches, - validPatches, queuedUserAction, queueUserAction, executeQueuedAction, @@ -421,8 +413,6 @@ export function ChatProvider({ id, children }: Readonly) { moderations, toxicModeration, tempLessonPlan, - hasFinished, - hasAppendedInitialMessage, chatAreaRef, messages, ailaStreamingStatus, @@ -433,8 +423,6 @@ export function ChatProvider({ id, children }: Readonly) { input, setInput, append, - partialPatches, - validPatches, overrideLessonPlan, queuedUserAction, queueUserAction, diff --git a/apps/nextjs/src/hooks/useTemporaryLessonPlanWithStreamingEdits.ts b/apps/nextjs/src/hooks/useTemporaryLessonPlanWithStreamingEdits.ts index e82212edb..a29292877 100644 --- a/apps/nextjs/src/hooks/useTemporaryLessonPlanWithStreamingEdits.ts +++ b/apps/nextjs/src/hooks/useTemporaryLessonPlanWithStreamingEdits.ts @@ -39,21 +39,24 @@ function patchHasBeenApplied( return { hasBeenApplied, patch: { ...patch, hash } }; } -export const useTemporaryLessonPlanWithStreamingEdits = ({ - lessonPlan, - messages, - //isStreaming, // Disable partial patches for now - messageHashes, -}: { +type UseTemporaryLessonPlanWithStreamingEditsProps = { lessonPlan?: LooseLessonPlan; messages?: Message[]; isStreaming?: boolean; messageHashes: Record; -}): { +}; +type UseTemporaryLessonPlanWithStreamingEditsReturn = { tempLessonPlan: LooseLessonPlan; validPatches: PatchDocument[]; partialPatches: PatchDocument[]; -} => { +}; + +export const useTemporaryLessonPlanWithStreamingEdits = ({ + lessonPlan, + messages, + //isStreaming, // Disable partial patches for now + messageHashes, +}: UseTemporaryLessonPlanWithStreamingEditsProps): UseTemporaryLessonPlanWithStreamingEditsReturn => { const throttledAssistantMessages = useThrottle(messages, 100); const tempLessonPlanRef = useRef(lessonPlan ?? {}); const appliedPatchesRef = useRef([]); From 8f3ed836f781af712f508704b5ab371a2a73fc19 Mon Sep 17 00:00:00 2001 From: Adam Howard <91115+codeincontext@users.noreply.github.com> Date: Thu, 10 Oct 2024 16:51:36 +0200 Subject: [PATCH 6/9] chore: "chore: refactor chat provider" (#215) --- .../ContextProviders/ChatProvider.tsx | 306 +++++++++--------- ...seTemporaryLessonPlanWithStreamingEdits.ts | 19 +- 2 files changed, 167 insertions(+), 158 deletions(-) diff --git a/apps/nextjs/src/components/ContextProviders/ChatProvider.tsx b/apps/nextjs/src/components/ContextProviders/ChatProvider.tsx index a884343fb..71889f014 100644 --- a/apps/nextjs/src/components/ContextProviders/ChatProvider.tsx +++ b/apps/nextjs/src/components/ContextProviders/ChatProvider.tsx @@ -9,7 +9,7 @@ import React, { } from "react"; import { toast } from "react-hot-toast"; -import { redirect, usePathname } from "#next/navigation"; +import { redirect, usePathname, useRouter } from "#next/navigation"; import { generateMessageId } from "@oakai/aila/src/helpers/chat/generateMessageId"; import { parseMessageParts } from "@oakai/aila/src/protocol/jsonPatchProtocol"; import { @@ -108,133 +108,31 @@ function getModerationFromMessage(message?: { content: string }) { return moderation; } -/** - * This is a hack to ensure that the assistant messages have a stable id - * across server and client. - * We should move away from this either when the vercel/ai package supports it - * natively, or when we move away from streaming. - */ -function useStableMessageId(messages: Message[]) { - useEffect(() => { - return messages.forEach((message) => { - if (message.role !== "assistant") { - return; - } - - const idIsStable = message.id.startsWith("a-"); - if (idIsStable) { - return; - } - - const idFromContent = findMessageIdFromContent(message); - if (idFromContent) { - message.id = idFromContent; - return; - } - - message.id = "TEMP_PENDING_" + nanoid(); - }); - }, [messages]); -} - -function useAppendInitialMessage({ - startingMessage, - append, -}: { - startingMessage: string | undefined; - append: ( - message: Message | CreateMessage, - ) => Promise; -}) { - const hasAppendedInitialMessage = useRef(false); - - useEffect(() => { - if (startingMessage && !hasAppendedInitialMessage.current) { - append({ - content: startingMessage, - role: "user", - }); - hasAppendedInitialMessage.current = true; - } - }, [startingMessage, append, hasAppendedInitialMessage]); -} - -function useQueueUserAction({ - hasFinished, - append, - reload, -}: { - hasFinished: boolean; - append: ( - message: Message | CreateMessage, - ) => Promise; - reload: () => void; -}) { - const [queuedUserAction, setQueuedUserAction] = useState(null); - const isExecutingAction = useRef(false); - - const queueUserAction = useCallback((action: string) => { - setQueuedUserAction(action); - }, []); - - const clearQueuedUserAction = useCallback(() => { - setQueuedUserAction(null); - }, []); - - const executeQueuedAction = useCallback(async () => { - if (!queuedUserAction || !hasFinished || isExecutingAction.current) return; - - isExecutingAction.current = true; - const actionToExecute = queuedUserAction; - setQueuedUserAction(null); - - try { - if (actionToExecute === "continue") { - await append({ - content: "Continue", - role: "user", - }); - } else if (actionToExecute === "regenerate") { - reload(); - } else { - // Assume it's a user message - await append({ - content: actionToExecute, - role: "user", - }); - } - } catch (error) { - console.error("Error handling queued action:", error); - } finally { - isExecutingAction.current = false; - } - }, [queuedUserAction, hasFinished, append, reload]); - - useEffect(() => { - if (hasFinished) { - executeQueuedAction(); - } - }, [hasFinished, executeQueuedAction]); - - return { - queueUserAction, - clearQueuedUserAction, - queuedUserAction, - executeQueuedAction, - }; -} - export function ChatProvider({ id, children }: Readonly) { const { data: chat, isLoading: isChatLoading, refetch: refetchChat, - } = trpc.chat.appSessions.getChat.useQuery({ id }); + } = trpc.chat.appSessions.getChat.useQuery( + { id }, + { + refetchOnMount: true, + refetchOnWindowFocus: true, + staleTime: 0, + }, + ); const { data: moderations, isLoading: isModerationsLoading, refetch: refetchModerations, - } = trpc.chat.appSessions.getModerations.useQuery({ id }); + } = trpc.chat.appSessions.getModerations.useQuery( + { id }, + { + refetchOnMount: true, + refetchOnWindowFocus: true, + staleTime: 0, + }, + ); // Ensure that we re-fetch on mount useEffect(() => { refetchChat(); @@ -243,17 +141,22 @@ export function ChatProvider({ id, children }: Readonly) { const trpcUtils = trpc.useUtils(); const lessonPlanTracking = useLessonPlanTracking(); - const lessonPlanSnapshot = useRef({}); + const shouldTrackStreamFinished = useRef(false); const [lastModeration, setLastModeration] = useState( moderations?.[moderations.length - 1] ?? null, ); + const router = useRouter(); const path = usePathname(); const chatAreaRef = useRef(null); const [hasFinished, setHasFinished] = useState(true); + const hasAppendedInitialMessage = useRef(false); + + const lessonPlanSnapshot = useRef({}); + const [overrideLessonPlan, setOverrideLessonPlan] = useState< LooseLessonPlan | undefined >(undefined); @@ -308,6 +211,9 @@ export function ChatProvider({ id, children }: Readonly) { if (hasFinished) { setHasFinished(false); } + if (!path?.includes("chat/[id]")) { + window.history.pushState({}, "", `/aila/${id}`); + } }, onFinish(response) { console.log("Chat: On Finish", new Date().toISOString(), { @@ -326,56 +232,156 @@ export function ChatProvider({ id, children }: Readonly) { invokeActionMessages(response.content); trpcUtils.chat.appSessions.getChat.invalidate({ id }); - clearHashCache(); setHasFinished(true); - - lessonPlanTracking.onStreamFinished({ - prevLesson: lessonPlanSnapshot.current, - nextLesson: tempLessonPlan, - messages, - }); - + shouldTrackStreamFinished.current = true; chatAreaRef.current?.scrollTo(0, chatAreaRef.current?.scrollHeight); }, }); - useStableMessageId(messages); - useAppendInitialMessage({ startingMessage: chat?.startingMessage, append }); + useEffect(() => { + /** + * This is a hack to ensure that the assistant messages have a stable id + * across server and client. + * We should move away from this either when the vercel/ai package supports it + * natively, or when we move away from streaming. + */ + return messages.forEach((message) => { + if (message.role !== "assistant") { + return; + } - // NOTE: this hook also returns validPatches and partialPatches, but we don't use them - const { tempLessonPlan } = useTemporaryLessonPlanWithStreamingEdits({ - lessonPlan: chat?.lessonPlan ?? {}, - messages, - isStreaming: !hasFinished, - messageHashes, - }); + const idIsStable = message.id.startsWith("a-"); + if (idIsStable) { + return; + } + + const idFromContent = findMessageIdFromContent(message); + if (idFromContent) { + message.id = idFromContent; + return; + } + + message.id = "TEMP_PENDING_" + nanoid(); + }); + }, [messages]); + + const { tempLessonPlan, partialPatches, validPatches } = + useTemporaryLessonPlanWithStreamingEdits({ + lessonPlan: chat?.lessonPlan ?? {}, + messages, + isStreaming: !hasFinished, + messageHashes, + }); // Handle queued user actions and messages - const { - queuedUserAction, - queueUserAction, - clearQueuedUserAction, - executeQueuedAction, - } = useQueueUserAction({ hasFinished, append, reload }); + + const [queuedUserAction, setQueuedUserAction] = useState(null); + const isExecutingAction = useRef(false); + + const queueUserAction = useCallback((action: string) => { + setQueuedUserAction(action); + }, []); + + const executeQueuedAction = useCallback(async () => { + if (!queuedUserAction || !hasFinished || isExecutingAction.current) return; + + isExecutingAction.current = true; + const actionToExecute = queuedUserAction; + setQueuedUserAction(null); + + try { + if (actionToExecute === "continue") { + await append({ + content: "Continue", + role: "user", + }); + } else if (actionToExecute === "regenerate") { + reload(); + } else { + // Assume it's a user message + await append({ + content: actionToExecute, + role: "user", + }); + } + } catch (error) { + console.error("Error handling queued action:", error); + } finally { + isExecutingAction.current = false; + } + }, [queuedUserAction, hasFinished, append, reload]); + + useEffect(() => { + if (hasFinished) { + executeQueuedAction(); + } + }, [hasFinished, executeQueuedAction]); const stop = useCallback(() => { if (queuedUserAction) { - clearQueuedUserAction(); + setQueuedUserAction(null); } else { stopStreaming(); } - }, [queuedUserAction, clearQueuedUserAction, stopStreaming]); + }, [queuedUserAction, setQueuedUserAction, stopStreaming]); + + /** + * If the state is being restored from a previous lesson plan, set the lesson plan + */ + + useEffect(() => { + if (chat?.startingMessage && !hasAppendedInitialMessage.current) { + append({ + content: chat.startingMessage, + role: "user", + }); + hasAppendedInitialMessage.current = true; + } + }, [chat?.startingMessage, append, router, path, hasAppendedInitialMessage]); + + // Clear the hash cache each completed message + useEffect(() => { + clearHashCache(); + }, [hasFinished]); + + /** + * Update the lesson plan if the chat has finished updating + * Fetch the state from the last "state" command in the most recent assistant message + */ + useEffect(() => { + if (!hasFinished || !messages) return; + trpcUtils.chat.appSessions.getChat.invalidate({ id }); + if (shouldTrackStreamFinished.current) { + lessonPlanTracking.onStreamFinished({ + prevLesson: lessonPlanSnapshot.current, + nextLesson: tempLessonPlan, + messages, + }); + shouldTrackStreamFinished.current = false; + } + }, [ + id, + trpcUtils.chat.appSessions.getChat, + hasFinished, + messages, + lessonPlanTracking, + tempLessonPlan, + ]); /** * Get the sensitive moderation id and pass to dialog */ + const toxicInitialModeration = moderations?.find(isToxic) ?? null; + const toxicModeration = lastModeration && isToxic(lastModeration) ? lastModeration : toxicInitialModeration; + const ailaStreamingStatus = useAilaStreamingStatus({ isLoading, messages }); + useEffect(() => { if (toxicModeration) { setMessages([]); @@ -383,8 +389,6 @@ export function ChatProvider({ id, children }: Readonly) { } }, [toxicModeration, setMessages]); - const ailaStreamingStatus = useAilaStreamingStatus({ isLoading, messages }); - const value: ChatContextProps = useMemo( () => ({ id, @@ -392,6 +396,8 @@ export function ChatProvider({ id, children }: Readonly) { initialModerations: moderations ?? [], toxicModeration, lessonPlan: overrideLessonPlan ?? tempLessonPlan, + hasFinished, + hasAppendedInitialMessage, chatAreaRef, append, messages, @@ -403,6 +409,8 @@ export function ChatProvider({ id, children }: Readonly) { stop, input, setInput, + partialPatches, + validPatches, queuedUserAction, queueUserAction, executeQueuedAction, @@ -413,6 +421,8 @@ export function ChatProvider({ id, children }: Readonly) { moderations, toxicModeration, tempLessonPlan, + hasFinished, + hasAppendedInitialMessage, chatAreaRef, messages, ailaStreamingStatus, @@ -423,6 +433,8 @@ export function ChatProvider({ id, children }: Readonly) { input, setInput, append, + partialPatches, + validPatches, overrideLessonPlan, queuedUserAction, queueUserAction, diff --git a/apps/nextjs/src/hooks/useTemporaryLessonPlanWithStreamingEdits.ts b/apps/nextjs/src/hooks/useTemporaryLessonPlanWithStreamingEdits.ts index a29292877..e82212edb 100644 --- a/apps/nextjs/src/hooks/useTemporaryLessonPlanWithStreamingEdits.ts +++ b/apps/nextjs/src/hooks/useTemporaryLessonPlanWithStreamingEdits.ts @@ -39,24 +39,21 @@ function patchHasBeenApplied( return { hasBeenApplied, patch: { ...patch, hash } }; } -type UseTemporaryLessonPlanWithStreamingEditsProps = { +export const useTemporaryLessonPlanWithStreamingEdits = ({ + lessonPlan, + messages, + //isStreaming, // Disable partial patches for now + messageHashes, +}: { lessonPlan?: LooseLessonPlan; messages?: Message[]; isStreaming?: boolean; messageHashes: Record; -}; -type UseTemporaryLessonPlanWithStreamingEditsReturn = { +}): { tempLessonPlan: LooseLessonPlan; validPatches: PatchDocument[]; partialPatches: PatchDocument[]; -}; - -export const useTemporaryLessonPlanWithStreamingEdits = ({ - lessonPlan, - messages, - //isStreaming, // Disable partial patches for now - messageHashes, -}: UseTemporaryLessonPlanWithStreamingEditsProps): UseTemporaryLessonPlanWithStreamingEditsReturn => { +} => { const throttledAssistantMessages = useThrottle(messages, 100); const tempLessonPlanRef = useRef(lessonPlan ?? {}); const appliedPatchesRef = useRef([]); From 4a30a7b309c453fbdfbf3ce64168e05b48439db7 Mon Sep 17 00:00:00 2001 From: Adam Howard <91115+codeincontext@users.noreply.github.com> Date: Thu, 10 Oct 2024 16:53:46 +0200 Subject: [PATCH 7/9] chore: update @clerk/nextjs and remove CSP nonce patch (#212) --- apps/nextjs/package.json | 2 +- apps/nextjs/src/app/layout.tsx | 2 +- .../app/quiz-designer/quiz-designer-page.tsx | 17 -- package.json | 5 - packages/aila/package.json | 2 +- packages/api/package.json | 2 +- .../rateLimiting/userBasedRateLimiter.ts | 3 +- patches/@clerk__nextjs@5.1.0.patch | 38 ---- patches/react-native@0.70.5.patch | 50 ----- pnpm-lock.yaml | 178 ++++++++++++------ 10 files changed, 121 insertions(+), 178 deletions(-) delete mode 100644 patches/@clerk__nextjs@5.1.0.patch delete mode 100644 patches/react-native@0.70.5.patch diff --git a/apps/nextjs/package.json b/apps/nextjs/package.json index da9b000d6..bdad3d7f5 100644 --- a/apps/nextjs/package.json +++ b/apps/nextjs/package.json @@ -24,7 +24,7 @@ }, "prettier": "@oakai/prettier-config", "dependencies": { - "@clerk/nextjs": "5.1.0", + "@clerk/nextjs": "5.7.2", "@clerk/testing": "^1.3.7", "@cloudinary/react": "^1.11.2", "@cloudinary/url-gen": "^1.14.0", diff --git a/apps/nextjs/src/app/layout.tsx b/apps/nextjs/src/app/layout.tsx index fab2ceeb7..5c476fa6d 100644 --- a/apps/nextjs/src/app/layout.tsx +++ b/apps/nextjs/src/app/layout.tsx @@ -77,7 +77,7 @@ export default function RootLayout({ children }: Readonly) { return ( - + { - const { userId } = getAuth(ctx.req); - - if (!userId) { - return { - redirect: { - destination: "/sign-up", - permanent: false, - }, - }; - } - - return { props: { ...buildClerkProps(ctx.req) } }; -}; - export default QuizDesignerPage; diff --git a/package.json b/package.json index 59b223b3a..dd93c488d 100644 --- a/package.json +++ b/package.json @@ -61,10 +61,5 @@ "engines": { "node": ">=20.9.0", "pnpm": ">=8" - }, - "pnpm": { - "patchedDependencies": { - "@clerk/nextjs@5.1.0": "patches/@clerk__nextjs@5.1.0.patch" - } } } diff --git a/packages/aila/package.json b/packages/aila/package.json index edcc0d41f..12ee95fe0 100644 --- a/packages/aila/package.json +++ b/packages/aila/package.json @@ -17,7 +17,7 @@ "prettier": "@oakai/prettier-config", "dependencies": { "@ai-sdk/openai": "^0.0.55", - "@clerk/nextjs": "5.1.0", + "@clerk/nextjs": "5.7.2", "@oakai/core": "*", "@oakai/db": "*", "@oakai/exports": "*", diff --git a/packages/api/package.json b/packages/api/package.json index 2213734ab..6be4be82c 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -12,7 +12,7 @@ }, "prettier": "@oakai/prettier-config", "dependencies": { - "@clerk/nextjs": "5.1.0", + "@clerk/nextjs": "5.7.2", "@oakai/core": "*", "@oakai/db": "*", "@oakai/exports": "*", diff --git a/packages/core/src/utils/rateLimiting/userBasedRateLimiter.ts b/packages/core/src/utils/rateLimiting/userBasedRateLimiter.ts index 748be2cdb..8dc94df9d 100644 --- a/packages/core/src/utils/rateLimiting/userBasedRateLimiter.ts +++ b/packages/core/src/utils/rateLimiting/userBasedRateLimiter.ts @@ -1,5 +1,4 @@ -import { User } from "@clerk/backend"; -import { clerkClient } from "@clerk/nextjs/server"; +import { clerkClient, User } from "@clerk/nextjs/server"; import { default as oakLogger } from "@oakai/logger"; import { Ratelimit } from "@upstash/ratelimit"; import { waitUntil } from "@vercel/functions"; diff --git a/patches/@clerk__nextjs@5.1.0.patch b/patches/@clerk__nextjs@5.1.0.patch deleted file mode 100644 index 62368ab56..000000000 --- a/patches/@clerk__nextjs@5.1.0.patch +++ /dev/null @@ -1,38 +0,0 @@ -diff --git a/dist/esm/utils/clerk-js-script.js b/dist/esm/utils/clerk-js-script.js -index 1994026ef2d407ee34a5de30326b1c3dc9c77836..e6ac75342fc5aac2336e8f646f3e0be5ea3b4aca 100644 ---- a/dist/esm/utils/clerk-js-script.js -+++ b/dist/esm/utils/clerk-js-script.js -@@ -4,7 +4,7 @@ import NextScript from "next/script"; - import React from "react"; - import { useClerkNextOptions } from "../client-boundary/NextOptionsContext"; - function ClerkJSScript(props) { -- const { publishableKey, clerkJSUrl, clerkJSVersion, clerkJSVariant } = useClerkNextOptions(); -+ const { publishableKey, clerkJSUrl, clerkJSVersion, clerkJSVariant, nonce } = useClerkNextOptions(); - const { domain, proxyUrl } = useClerk(); - const options = { - domain, -@@ -25,7 +25,8 @@ function ClerkJSScript(props) { - defer: props.router === "pages" ? false : void 0, - crossOrigin: "anonymous", - strategy: props.router === "pages" ? "beforeInteractive" : void 0, -- ...buildClerkJsScriptAttributes(options) -+ ...buildClerkJsScriptAttributes(options), -+ nonce, - } - ); - } -diff --git a/dist/types/types.d.ts b/dist/types/types.d.ts -index edc6a43624328c09e640114689c9d76b3af12bb0..b92d5cb0f4150aa69f3e92f7d579fb814cf3f31f 100644 ---- a/dist/types/types.d.ts -+++ b/dist/types/types.d.ts -@@ -15,5 +15,10 @@ export type NextClerkProviderProps = Without=18.17.0'} dependencies: - '@clerk/shared': 2.2.0(react-dom@18.2.0)(react@18.2.0) - cookie: 0.5.0 + '@clerk/shared': 2.9.0(react-dom@18.2.0)(react@18.2.0) + '@clerk/types': 4.25.0 + cookie: 0.7.0 snakecase-keys: 5.4.4 tslib: 2.4.1 transitivePeerDependencies: @@ -2726,63 +2722,64 @@ packages: - react-dom dev: false - /@clerk/clerk-react@5.2.0(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-a3dus1JRK7sw7cJjnJQexMA0HGTlFDEjoK0/y+HhaDxzcfhoUgvhhTWLWHWfwCHfEtMYhzIvdkyvXJDZpWTpvQ==} + /@clerk/clerk-react@5.11.0(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-JPvDxSPMV1Rrnh5k1ULxeemvUW+F5YdVUQAUhPPl/iZ6MHO6vXr5jn66xe0hM+wrw/snGlsrD9ePyy6tK8EDKw==} engines: {node: '>=18.17.0'} peerDependencies: - react: '>=18' - react-dom: '>=18' + react: '>=18 || >=19.0.0-beta' + react-dom: '>=18 || >=19.0.0-beta' dependencies: - '@clerk/shared': 2.2.0(react-dom@18.2.0)(react@18.2.0) - '@clerk/types': 4.5.0 + '@clerk/shared': 2.9.0(react-dom@18.2.0)(react@18.2.0) + '@clerk/types': 4.25.0 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) tslib: 2.4.1 dev: false - /@clerk/nextjs@5.1.0(patch_hash=svgzfjgl5zy2gm2avo4dkdk6u4)(next@14.2.5)(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-rIVGGKgYhM4+0ECWsB4iw/wzjHLIyoPlx9oR6mg4UPgxoR1TUOd0Gp6iBLc/k7fPf8gXBS7BAzPCQnkjCPO0Ag==} + /@clerk/nextjs@5.7.2(next@14.2.5)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-G2ilYV0RyOrwc6gI6qmKAsML+7YLiVX/VFlkuvh3vk+qJ6ka8RDaoaILcmREEs4AOaQXBHYQCfiCrYMUeEUJFg==} engines: {node: '>=18.17.0'} peerDependencies: - next: ^13.5.4 || ^14.0.3 - react: '>=18' - react-dom: '>=18' + next: ^13.5.4 || ^14.0.3 || >=15.0.0-rc + react: '>=18 || >=19.0.0-beta' + react-dom: '>=18 || >=19.0.0-beta' dependencies: - '@clerk/backend': 1.2.0(react-dom@18.2.0)(react@18.2.0) - '@clerk/clerk-react': 5.2.0(react-dom@18.2.0)(react@18.2.0) - '@clerk/shared': 2.2.0(react-dom@18.2.0)(react@18.2.0) + '@clerk/backend': 1.13.9(react-dom@18.2.0)(react@18.2.0) + '@clerk/clerk-react': 5.11.0(react-dom@18.2.0)(react@18.2.0) + '@clerk/shared': 2.9.0(react-dom@18.2.0)(react@18.2.0) + '@clerk/types': 4.25.0 crypto-js: 4.2.0 next: 14.2.5(@babel/core@7.24.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.47.2)(react-dom@18.2.0)(react@18.2.0) - path-to-regexp: 6.2.1 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) + server-only: 0.0.1 tslib: 2.4.1 dev: false - patched: true - /@clerk/shared@2.2.0(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-dqd/g8xTFEVYi0QDRyYI+YBT/EeTE1vIEoJbuP98C7Ybqmp7/RC0Cj88Ckam1wToD3okYYf9t13/qIJ+ux0XTg==} + /@clerk/shared@2.8.4(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-I+V05B/YqEWAaXb+SWb9VFCK6WSjChW01gC9OJxpkvjauNiBtDjFVghodZFg2dxcbwQGQNj6sC1hrf+mcBVBqw==} engines: {node: '>=18.17.0'} requiresBuild: true peerDependencies: - react: '>=18' - react-dom: '>=18' + react: '>=18 || >=19.0.0-beta' + react-dom: '>=18 || >=19.0.0-beta' peerDependenciesMeta: react: optional: true react-dom: optional: true dependencies: + '@clerk/types': 4.23.0 glob-to-regexp: 0.4.1 - js-cookie: 3.0.1 + js-cookie: 3.0.5 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) std-env: 3.7.0 - swr: 2.2.0(react@18.2.0) + swr: 2.2.5(react@18.2.0) dev: false - /@clerk/shared@2.8.4(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-I+V05B/YqEWAaXb+SWb9VFCK6WSjChW01gC9OJxpkvjauNiBtDjFVghodZFg2dxcbwQGQNj6sC1hrf+mcBVBqw==} + /@clerk/shared@2.9.0(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-DAqxJbmQ3QnQXZepG2InLC7Hdq+4T9/+A/kwtMQtAyQcsZoDwZ1TqVJkrqZ55lJIAkR97HEn3/g+g1ySspdEfA==} engines: {node: '>=18.17.0'} requiresBuild: true peerDependencies: @@ -2794,7 +2791,7 @@ packages: react-dom: optional: true dependencies: - '@clerk/types': 4.23.0 + '@clerk/types': 4.25.0 glob-to-regexp: 0.4.1 js-cookie: 3.0.5 react: 18.2.0 @@ -2832,8 +2829,8 @@ packages: csstype: 3.1.1 dev: false - /@clerk/types@4.5.0: - resolution: {integrity: sha512-g+CulWZq/HG9uhYFkXr8n9e376zZKY7elhW8/vXKJHDQEYeBmcUAkDgtgYXOU9OtXNS8xTbnhAwScn1bk1pRNg==} + /@clerk/types@4.25.0: + resolution: {integrity: sha512-p2IyJ0q5WF1e976L1pS1J6Mb5ducfkUC31DR1EvMjPwJkrlWJdAMCPc+zqRRAePVy/JBVK2gEKbUVtJ6/jrpag==} engines: {node: '>=18.17.0'} dependencies: csstype: 3.1.1 @@ -7768,7 +7765,7 @@ packages: resolve: 1.22.8 rollup: 3.29.4 stacktrace-parser: 0.1.10 - webpack: 5.93.0(esbuild@0.21.5) + webpack: 5.93.0 transitivePeerDependencies: - '@opentelemetry/api' - '@opentelemetry/core' @@ -7900,7 +7897,7 @@ packages: '@sentry/bundler-plugin-core': 2.20.1 unplugin: 1.0.1 uuid: 9.0.1 - webpack: 5.93.0(esbuild@0.21.5) + webpack: 5.93.0 transitivePeerDependencies: - encoding - supports-color @@ -11852,6 +11849,11 @@ packages: resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} engines: {node: '>= 0.6'} + /cookie@0.7.0: + resolution: {integrity: sha512-qCf+V4dtlNhSRXGAZatc1TasyFO6GjohcOul807YOb5ik3+kQSnb4d7iajeCL8QHaJ4uZEjCgiCJerKXwdRVlQ==} + engines: {node: '>= 0.6'} + dev: false + /copy-anything@3.0.3: resolution: {integrity: sha512-fpW2W/BqEzqPp29QS+MwwfisHCQZtiduTe/m8idFo0xbti9fIZ2WVhAsCv4ggFVH3AgCkVdpoOCtQC6gBrdhjw==} engines: {node: '>=12.13'} @@ -16343,11 +16345,6 @@ packages: resolution: {integrity: sha512-NnRs6dsyqUXejqk/yv2aiXlAvOs56sLkX6nUdeaNezI5LFFLlsZjOThmwnrcwh5ZZRwZlCMnVAY3CvhIhoVEKQ==} dev: false - /js-cookie@3.0.1: - resolution: {integrity: sha512-+0rgsUXZu4ncpPxRL+lNEptWMOWl9etvPHc/koSRp6MPwpRYAhmk0dUG00J4bxVV3r9uUzfo24wW0knS07SKSw==} - engines: {node: '>=12'} - dev: false - /js-cookie@3.0.5: resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==} engines: {node: '>=14'} @@ -19393,10 +19390,6 @@ packages: /path-to-regexp@0.1.7: resolution: {integrity: sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==} - /path-to-regexp@6.2.1: - resolution: {integrity: sha512-JLyh7xT1kizaEvcaXOQwOc2/Yhw6KZOvPf1S8401UyLk86CU79LN3vl7ztXGm/pZ+YjoyAJ4rxmHwbkBXJX+yw==} - dev: false - /path-type@1.1.0: resolution: {integrity: sha512-S4eENJz1pkiQn9Znv33Q+deTOKmbl+jj1Fl+qiP/vYezj+S8x+J3Uo0ISrx/QoEvIlOaDWJhPaRd1flJ9HXZqg==} engines: {node: '>=0.10.0'} @@ -21233,6 +21226,10 @@ packages: transitivePeerDependencies: - supports-color + /server-only@0.0.1: + resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} + dev: false + /set-blocking@2.0.0: resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} @@ -22086,15 +22083,6 @@ packages: tslib: 2.7.0 dev: true - /swr@2.2.0(react@18.2.0): - resolution: {integrity: sha512-AjqHOv2lAhkuUdIiBu9xbuettzAzWXmCEcLONNKJRba87WAefz8Ca9d6ds/SzrPc235n1IxWYdhJ2zF3MNUaoQ==} - peerDependencies: - react: ^16.11.0 || ^17.0.0 || ^18.0.0 - dependencies: - react: 18.2.0 - use-sync-external-store: 1.2.0(react@18.2.0) - dev: false - /swr@2.2.5(react@18.2.0): resolution: {integrity: sha512-QtxqyclFeAsxEUeZIYmsaQ0UjimSq1RZ9Un7I68/0ClKK/U3LoyQunwkQfJZr2fc22DfIXLNDc2wFyTEikCUpg==} peerDependencies: @@ -22260,6 +22248,31 @@ packages: serialize-javascript: 6.0.2 terser: 5.31.3 webpack: 5.93.0(esbuild@0.21.5) + dev: true + + /terser-webpack-plugin@5.3.10(webpack@5.93.0): + resolution: {integrity: sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w==} + engines: {node: '>= 10.13.0'} + peerDependencies: + '@swc/core': '*' + esbuild: '*' + uglify-js: '*' + webpack: ^5.1.0 + peerDependenciesMeta: + '@swc/core': + optional: true + esbuild: + optional: true + uglify-js: + optional: true + dependencies: + '@jridgewell/trace-mapping': 0.3.25 + jest-worker: 27.5.1 + schema-utils: 3.3.0 + serialize-javascript: 6.0.2 + terser: 5.31.3 + webpack: 5.93.0 + dev: false /terser@5.31.3: resolution: {integrity: sha512-pAfYn3NIZLyZpa83ZKigvj6Rn9c/vd5KfYGX7cN1mnzqgDcxWvrU5ZtAfIKhEXz9nRecw4z3LXkjaq96/qZqAA==} @@ -23411,6 +23424,46 @@ packages: resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} dev: true + /webpack@5.93.0: + resolution: {integrity: sha512-Y0m5oEY1LRuwly578VqluorkXbvXKh7U3rLoQCEO04M97ScRr44afGVkI0FQFsXzysk5OgFAxjZAb9rsGQVihA==} + engines: {node: '>=10.13.0'} + hasBin: true + peerDependencies: + webpack-cli: '*' + peerDependenciesMeta: + webpack-cli: + optional: true + dependencies: + '@types/eslint-scope': 3.7.7 + '@types/estree': 1.0.5 + '@webassemblyjs/ast': 1.12.1 + '@webassemblyjs/wasm-edit': 1.12.1 + '@webassemblyjs/wasm-parser': 1.12.1 + acorn: 8.12.1 + acorn-import-attributes: 1.9.5(acorn@8.12.1) + browserslist: 4.23.3 + chrome-trace-event: 1.0.4 + enhanced-resolve: 5.17.1 + es-module-lexer: 1.5.4 + eslint-scope: 5.1.1 + events: 3.3.0 + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.11 + json-parse-even-better-errors: 2.3.1 + loader-runner: 4.3.0 + mime-types: 2.1.35 + neo-async: 2.6.2 + schema-utils: 3.3.0 + tapable: 2.2.1 + terser-webpack-plugin: 5.3.10(webpack@5.93.0) + watchpack: 2.4.1 + webpack-sources: 3.2.3 + transitivePeerDependencies: + - '@swc/core' + - esbuild + - uglify-js + dev: false + /webpack@5.93.0(esbuild@0.21.5): resolution: {integrity: sha512-Y0m5oEY1LRuwly578VqluorkXbvXKh7U3rLoQCEO04M97ScRr44afGVkI0FQFsXzysk5OgFAxjZAb9rsGQVihA==} engines: {node: '>=10.13.0'} @@ -23449,6 +23502,7 @@ packages: - '@swc/core' - esbuild - uglify-js + dev: true /webvtt-parser@2.2.0: resolution: {integrity: sha512-FzmaED+jZyt8SCJPTKbSsimrrnQU8ELlViE1wuF3x1pgiQUM8Llj5XWj2j/s6Tlk71ucPfGSMFqZWBtKn/0uEA==} From eaf8559be326c1a81c27f24d835c8eb55358cd40 Mon Sep 17 00:00:00 2001 From: Adam Howard <91115+codeincontext@users.noreply.github.com> Date: Thu, 10 Oct 2024 17:07:15 +0200 Subject: [PATCH 8/9] feat: move aila analytics calls to background (#206) --- apps/nextjs/scripts/aila-cli.ts | 3 +++ apps/nextjs/src/app/api/chat/route.test.ts | 5 ++--- apps/nextjs/src/app/api/chat/webActionsPlugin.ts | 6 ++++++ packages/aila/src/core/plugins/types.ts | 1 + .../aila/src/features/analytics/AilaAnalytics.ts | 12 ++++++++++-- 5 files changed, 22 insertions(+), 5 deletions(-) diff --git a/apps/nextjs/scripts/aila-cli.ts b/apps/nextjs/scripts/aila-cli.ts index 3fdccf501..23c7f3dca 100644 --- a/apps/nextjs/scripts/aila-cli.ts +++ b/apps/nextjs/scripts/aila-cli.ts @@ -9,6 +9,9 @@ const cliPlugin: AilaPlugin = { onToxicModeration: async (moderation, { aila, enqueue }) => { // ... }, + onBackgroundWork: (promise) => { + // ... + }, }; const aila = new Aila({ diff --git a/apps/nextjs/src/app/api/chat/route.test.ts b/apps/nextjs/src/app/api/chat/route.test.ts index 0a3c7a591..7adfde59b 100644 --- a/apps/nextjs/src/app/api/chat/route.test.ts +++ b/apps/nextjs/src/app/api/chat/route.test.ts @@ -1,4 +1,4 @@ -import { Aila } from "@oakai/aila"; +import { Aila, AilaInitializationOptions } from "@oakai/aila"; import { MockLLMService } from "@oakai/aila/src/core/llm/MockLLMService"; import { MockCategoriser } from "@oakai/aila/src/features/categorisation/categorisers/MockCategoriser"; import { mockTracer } from "@oakai/core/src/tracing/mockTracer"; @@ -36,7 +36,7 @@ describe("Chat API Route", () => { testConfig = { createAila: jest.fn().mockImplementation(async (options) => { - const ailaConfig = { + const ailaConfig: AilaInitializationOptions = { options: { usePersistence: false, useRag: false, @@ -44,7 +44,6 @@ describe("Chat API Route", () => { useModeration: false, useErrorReporting: false, useThreatDetection: false, - useRateLimiting: false, }, chat: { id: chatId, diff --git a/apps/nextjs/src/app/api/chat/webActionsPlugin.ts b/apps/nextjs/src/app/api/chat/webActionsPlugin.ts index 318f408d6..07f134291 100644 --- a/apps/nextjs/src/app/api/chat/webActionsPlugin.ts +++ b/apps/nextjs/src/app/api/chat/webActionsPlugin.ts @@ -7,6 +7,7 @@ import { } from "@oakai/core"; import { UserBannedError } from "@oakai/core/src/models/safetyViolations"; import { PrismaClientWithAccelerate } from "@oakai/db"; +import { waitUntil } from "@vercel/functions"; type PluginCreator = ( prisma: PrismaClientWithAccelerate, @@ -88,8 +89,13 @@ export const createWebActionsPlugin: PluginCreator = ( } }; + const onBackgroundWork: AilaPlugin["onBackgroundWork"] = (promise) => { + waitUntil(promise); + }; + return { onStreamError, onToxicModeration, + onBackgroundWork, }; }; diff --git a/packages/aila/src/core/plugins/types.ts b/packages/aila/src/core/plugins/types.ts index 58a33341b..fd609b52c 100644 --- a/packages/aila/src/core/plugins/types.ts +++ b/packages/aila/src/core/plugins/types.ts @@ -13,4 +13,5 @@ export type AilaPlugin = { moderation: Moderation, context: AilaPluginContext, ): Promise; + onBackgroundWork(promise: Promise): void; }; diff --git a/packages/aila/src/features/analytics/AilaAnalytics.ts b/packages/aila/src/features/analytics/AilaAnalytics.ts index ac1cca82f..730777f45 100644 --- a/packages/aila/src/features/analytics/AilaAnalytics.ts +++ b/packages/aila/src/features/analytics/AilaAnalytics.ts @@ -4,6 +4,7 @@ import { AnalyticsAdapter } from "./adapters/AnalyticsAdapter"; export class AilaAnalytics { private _aila: AilaServices; private _adapters: AnalyticsAdapter[]; + private _operations: Promise[] = []; private _isShutdown: boolean = false; constructor({ @@ -22,11 +23,13 @@ export class AilaAnalytics { } public async reportUsageMetrics(responseBody: string, startedAt?: number) { - await Promise.all( + const promise = Promise.all( this._adapters.map((adapter) => adapter.reportUsageMetrics(responseBody, startedAt), ), ); + this._operations.push(promise); + this._aila.plugins.forEach((plugin) => plugin.onBackgroundWork(promise)); } // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -38,7 +41,12 @@ export class AilaAnalytics { public async shutdown() { if (!this._isShutdown) { - await Promise.all(this._adapters.map((adapter) => adapter.shutdown())); + const promise = (async () => { + await Promise.all(this._operations); + await Promise.all(this._adapters.map((adapter) => adapter.shutdown())); + })(); + + this._aila.plugins.forEach((plugin) => plugin.onBackgroundWork(promise)); this._isShutdown = true; } } From 6737c050f0a1e844fa974df6cac0d586a57c1e01 Mon Sep 17 00:00:00 2001 From: Adam Howard <91115+codeincontext@users.noreply.github.com> Date: Fri, 11 Oct 2024 10:28:36 +0200 Subject: [PATCH 9/9] test: add extra retries to flaky romans test (#217) --- .../tests/aila-chat/full-romans.test.ts | 132 ++++++++++-------- 1 file changed, 70 insertions(+), 62 deletions(-) diff --git a/apps/nextjs/tests-e2e/tests/aila-chat/full-romans.test.ts b/apps/nextjs/tests-e2e/tests/aila-chat/full-romans.test.ts index acc745f1c..38d8144f4 100644 --- a/apps/nextjs/tests-e2e/tests/aila-chat/full-romans.test.ts +++ b/apps/nextjs/tests-e2e/tests/aila-chat/full-romans.test.ts @@ -18,65 +18,73 @@ import { // const FIXTURE_MODE = "record" as FixtureMode; const FIXTURE_MODE = "replay" as FixtureMode; -test( - "Full aila flow with Romans fixture", - { tag: "@common-auth" }, - async ({ page }) => { - const generationTimeout = FIXTURE_MODE === "record" ? 75000 : 50000; - test.setTimeout(generationTimeout * 5); - - await test.step("Setup", async () => { - await bypassVercelProtection(page); - await setupClerkTestingToken({ page }); - - await page.goto(`${TEST_BASE_URL}/aila`); - await expect(page.getByTestId("chat-h1")).toBeInViewport(); - }); - - const { setFixture } = await applyLlmFixtures(page, FIXTURE_MODE); - - await test.step("Fill in the chat box", async () => { - const textbox = page.getByTestId("chat-input"); - const sendMessage = page.getByTestId("send-message"); - const message = - "Create a KS1 lesson on the end of Roman Britain. Ask a question for each quiz and cycle"; - await textbox.fill(message); - await expect(textbox).toContainText(message); - - // Temporary fix: The test goes quicker than a real user and submits before the demo status has loaded - // This means that a demo modal would be shown when submitting - await page.waitForTimeout(500); - - setFixture("roman-britain-1"); - await sendMessage.click(); - }); - - await test.step("Iterate through the fixtures", async () => { - await page.waitForURL(/\/aila\/.+/); - await waitForGeneration(page, generationTimeout); - await expectSectionsComplete(page, 1); - - setFixture("roman-britain-2"); - await continueChat(page); - await waitForGeneration(page, generationTimeout); - await expectSectionsComplete(page, 3); - - setFixture("roman-britain-3"); - await continueChat(page); - await waitForGeneration(page, generationTimeout); - await expectSectionsComplete(page, 7); - - setFixture("roman-britain-4"); - await continueChat(page); - await waitForGeneration(page, generationTimeout); - await expectSectionsComplete(page, 10); - - setFixture("roman-britain-5"); - await continueChat(page); - await waitForGeneration(page, generationTimeout); - await expectSectionsComplete(page, 10); - - await expectFinished(page); - }); - }, -); +test.describe(() => { + // NOTE(2024-10-10): This test is flaky, with the "10 of 10" check often returning 6-8 sections + // Remove extra retries when it becomes more stable + if (process.env.CI === "true") { + test.describe.configure({ retries: 4 }); + } + + test( + "Full aila flow with Romans fixture", + { tag: "@common-auth" }, + async ({ page }) => { + const generationTimeout = FIXTURE_MODE === "record" ? 75000 : 50000; + test.setTimeout(generationTimeout * 5); + + await test.step("Setup", async () => { + await bypassVercelProtection(page); + await setupClerkTestingToken({ page }); + + await page.goto(`${TEST_BASE_URL}/aila`); + await expect(page.getByTestId("chat-h1")).toBeInViewport(); + }); + + const { setFixture } = await applyLlmFixtures(page, FIXTURE_MODE); + + await test.step("Fill in the chat box", async () => { + const textbox = page.getByTestId("chat-input"); + const sendMessage = page.getByTestId("send-message"); + const message = + "Create a KS1 lesson on the end of Roman Britain. Ask a question for each quiz and cycle"; + await textbox.fill(message); + await expect(textbox).toContainText(message); + + // Temporary fix: The test goes quicker than a real user and submits before the demo status has loaded + // This means that a demo modal would be shown when submitting + await page.waitForTimeout(500); + + setFixture("roman-britain-1"); + await sendMessage.click(); + }); + + await test.step("Iterate through the fixtures", async () => { + await page.waitForURL(/\/aila\/.+/); + await waitForGeneration(page, generationTimeout); + await expectSectionsComplete(page, 1); + + setFixture("roman-britain-2"); + await continueChat(page); + await waitForGeneration(page, generationTimeout); + await expectSectionsComplete(page, 3); + + setFixture("roman-britain-3"); + await continueChat(page); + await waitForGeneration(page, generationTimeout); + await expectSectionsComplete(page, 7); + + setFixture("roman-britain-4"); + await continueChat(page); + await waitForGeneration(page, generationTimeout); + await expectSectionsComplete(page, 10); + + setFixture("roman-britain-5"); + await continueChat(page); + await waitForGeneration(page, generationTimeout); + await expectSectionsComplete(page, 10); + + await expectFinished(page); + }); + }, + ); +});