diff --git a/CHANGE_LOG.md b/CHANGE_LOG.md index 4da1950e8..0a5067ea1 100644 --- a/CHANGE_LOG.md +++ b/CHANGE_LOG.md @@ -1,3 +1,42 @@ +## [1.17.1](https://github.com/oaknational/oak-ai-lesson-assistant/compare/v1.17.0...v1.17.1) (2024-12-03) + + +### Bug Fixes + +* add linting command to db package ([#392](https://github.com/oaknational/oak-ai-lesson-assistant/issues/392)) ([d2177d5](https://github.com/oaknational/oak-ai-lesson-assistant/commit/d2177d5c061e973affd1ea52b0ef025c8c37cb29)) +* address sonar major issues ([#393](https://github.com/oaknational/oak-ai-lesson-assistant/issues/393)) ([202a21f](https://github.com/oaknational/oak-ai-lesson-assistant/commit/202a21fbac0d457514c9944735b174c79dced08c)) +* do not define components inline ([#413](https://github.com/oaknational/oak-ai-lesson-assistant/issues/413)) ([abda175](https://github.com/oaknational/oak-ai-lesson-assistant/commit/abda1753afecd9385b19b695767568abdd4383c1)) +* do not use array index for key / use void for onSubmit ([#409](https://github.com/oaknational/oak-ai-lesson-assistant/issues/409)) ([44b5961](https://github.com/oaknational/oak-ai-lesson-assistant/commit/44b59617f3af8cad83110efdc2cb4df23d06e073)) +* help page cloudflare email ([#399](https://github.com/oaknational/oak-ai-lesson-assistant/issues/399)) ([f6262f2](https://github.com/oaknational/oak-ai-lesson-assistant/commit/f6262f26d470a30ea721343bbab2cbfded77b91d)) +* high and medium severity bugs on sonar cloud - AI-637 ([#379](https://github.com/oaknational/oak-ai-lesson-assistant/issues/379)) ([fb0258e](https://github.com/oaknational/oak-ai-lesson-assistant/commit/fb0258ec2f3c0d5fb79d884d3918827460cba404)) +* icons in dialogs ([#398](https://github.com/oaknational/oak-ai-lesson-assistant/issues/398)) ([9700214](https://github.com/oaknational/oak-ai-lesson-assistant/commit/970021462a94b800dba270130f5ba1b1548e8745)) +* intentionality of async / promise code for question generation ([#402](https://github.com/oaknational/oak-ai-lesson-assistant/issues/402)) ([65d1c5f](https://github.com/oaknational/oak-ai-lesson-assistant/commit/65d1c5f89c1b238e24315c02bde2e9eb253b4da3)) +* memoize the sidebar context provider's value ([#408](https://github.com/oaknational/oak-ai-lesson-assistant/issues/408)) ([60ee010](https://github.com/oaknational/oak-ai-lesson-assistant/commit/60ee0102ea1ee733d6527c5460fd404cd7773292)) +* minor sonar issues ([#390](https://github.com/oaknational/oak-ai-lesson-assistant/issues/390)) ([015cd25](https://github.com/oaknational/oak-ai-lesson-assistant/commit/015cd25984c3e5d1a545afef39fd111aa5245d58)) +* prefer nullish coalescing ([#391](https://github.com/oaknational/oak-ai-lesson-assistant/issues/391)) ([b40def9](https://github.com/oaknational/oak-ai-lesson-assistant/commit/b40def9cfd3d69a0089db861a2f6ed47321a3753)) +* readonly props for icons.tsx ([#389](https://github.com/oaknational/oak-ai-lesson-assistant/issues/389)) ([7b4d5bc](https://github.com/oaknational/oak-ai-lesson-assistant/commit/7b4d5bcc508b3179ea5313ec783aba90d1a7f3ae)) +* revert tabindex change ([#404](https://github.com/oaknational/oak-ai-lesson-assistant/issues/404)) ([ac72713](https://github.com/oaknational/oak-ai-lesson-assistant/commit/ac72713dc54595f6bfacfd99e63899616f18b8ec)) +* sonar maintain issues [#4](https://github.com/oaknational/oak-ai-lesson-assistant/issues/4) ([#405](https://github.com/oaknational/oak-ai-lesson-assistant/issues/405)) ([eca0019](https://github.com/oaknational/oak-ai-lesson-assistant/commit/eca001996a684f8d01465196c1c600d00e43a964)) +* sonar maintain linting [#1](https://github.com/oaknational/oak-ai-lesson-assistant/issues/1) ([#394](https://github.com/oaknational/oak-ai-lesson-assistant/issues/394)) ([f4d95fc](https://github.com/oaknational/oak-ai-lesson-assistant/commit/f4d95fcbf39c3c43c3811f8c2022a37af363826a)) +* sonar maintain linting [#2](https://github.com/oaknational/oak-ai-lesson-assistant/issues/2) ([#395](https://github.com/oaknational/oak-ai-lesson-assistant/issues/395)) ([1ed9d60](https://github.com/oaknational/oak-ai-lesson-assistant/commit/1ed9d6028713b096a68a0558d67608dc9efb812f)) +* sonar maintain linting [#3](https://github.com/oaknational/oak-ai-lesson-assistant/issues/3) ([#403](https://github.com/oaknational/oak-ai-lesson-assistant/issues/403)) ([daa7efe](https://github.com/oaknational/oak-ai-lesson-assistant/commit/daa7efe6a2d5d2501f5108cd3c1ccaec86126655)) +* sonar minors [#5](https://github.com/oaknational/oak-ai-lesson-assistant/issues/5) ([#414](https://github.com/oaknational/oak-ai-lesson-assistant/issues/414)) ([5f749f4](https://github.com/oaknational/oak-ai-lesson-assistant/commit/5f749f42f9f5d3d78c736438e313f4f5eff5406b)) + +# [1.17.0](https://github.com/oaknational/oak-ai-lesson-assistant/compare/v1.16.2...v1.17.0) (2024-11-28) + + +### Bug Fixes + +* assert readonly props ([#381](https://github.com/oaknational/oak-ai-lesson-assistant/issues/381)) ([cd88576](https://github.com/oaknational/oak-ai-lesson-assistant/commit/cd88576c5a337ad30f48783d74ea45d746a60956)) +* help page cloudflare email ([#399](https://github.com/oaknational/oak-ai-lesson-assistant/issues/399)) ([391b67c](https://github.com/oaknational/oak-ai-lesson-assistant/commit/391b67c27a4048d4e422be22226d8d5aa1ac71bd)) +* minor linting ([#384](https://github.com/oaknational/oak-ai-lesson-assistant/issues/384)) ([ec4ce6e](https://github.com/oaknational/oak-ai-lesson-assistant/commit/ec4ce6e5dcc7dbd4e242be65fd3b5f9708b94a40)) +* svg clip-rule should be clipRule in JSX ([#382](https://github.com/oaknational/oak-ai-lesson-assistant/issues/382)) ([610d8c8](https://github.com/oaknational/oak-ai-lesson-assistant/commit/610d8c838273de24212a8531a8bc4b6136c05db1)) + + +### Features + +* move delete all button and restyle side menu ([#375](https://github.com/oaknational/oak-ai-lesson-assistant/issues/375)) ([69b2371](https://github.com/oaknational/oak-ai-lesson-assistant/commit/69b2371bf9ee7e7f783aa191ba6932dba0171837)) + ## [1.16.2](https://github.com/oaknational/oak-ai-lesson-assistant/compare/v1.16.1...v1.16.2) (2024-11-25) diff --git a/apps/nextjs/.storybook/preview.tsx b/apps/nextjs/.storybook/preview.tsx index 428690b9c..99b2ad60e 100644 --- a/apps/nextjs/.storybook/preview.tsx +++ b/apps/nextjs/.storybook/preview.tsx @@ -45,7 +45,6 @@ export const decorators: Decorator[] = [ ClerkDecorator, (Story) => ( <> - {/* TODO: Mock tRPC calls with MSW */} diff --git a/apps/nextjs/src/ai-apps/quiz-designer/quizRequestGeneration.ts b/apps/nextjs/src/ai-apps/quiz-designer/quizRequestGeneration.ts index 5efddcb15..0918104d8 100644 --- a/apps/nextjs/src/ai-apps/quiz-designer/quizRequestGeneration.ts +++ b/apps/nextjs/src/ai-apps/quiz-designer/quizRequestGeneration.ts @@ -1,11 +1,14 @@ import type { GenerationPart } from "@oakai/core/src/types"; +import { aiLogger } from "@oakai/logger"; import { getAgesFromKeyStage } from "@/utils/getAgesFromKeyStage"; import { extraQuizPromptInfo } from "./extraQuizPromptInfo"; import type { QuizAppState, QuizAppStateQuestion } from "./state/types"; -type RequestionGenerationInputs = { +const logger = aiLogger("quiz"); + +export type RequestionGenerationInputs = { lastGenerationId: string | null; sessionId: string; factQuestion: string; @@ -26,14 +29,16 @@ type RequestionGenerationInputs = { }; }; -type QuizRequestGenerationProps = { +export type QuizRequestGenerationProps = { state: QuizAppState; questionRow: QuizAppStateQuestion; lastGeneration: GenerationPart | undefined; - requestGeneration: (requestionGenInputs: RequestionGenerationInputs) => void; + requestGeneration: ( + requestionGenInputs: RequestionGenerationInputs, + ) => Promise; }; -export function quizRequestGeneration({ +export async function quizRequestGeneration({ state, questionRow, requestGeneration, @@ -47,7 +52,7 @@ export function quizRequestGeneration({ state, questionRow, }); - requestGeneration({ + await requestGeneration({ lastGenerationId: lastGeneration?.lastGenerationId ?? null, sessionId, factQuestion: `${topic}: ${questionRow.question.value}`, @@ -68,5 +73,7 @@ export function quizRequestGeneration({ (distractor) => distractor.value, ), }, + }).catch((e) => { + logger.error(e); }); } diff --git a/apps/nextjs/src/app/admin/aila/[chatId]/view.tsx b/apps/nextjs/src/app/admin/aila/[chatId]/view.tsx index 8b7174c78..5a2c43b14 100644 --- a/apps/nextjs/src/app/admin/aila/[chatId]/view.tsx +++ b/apps/nextjs/src/app/admin/aila/[chatId]/view.tsx @@ -7,7 +7,11 @@ import { OakAccordion, OakPrimaryButton } from "@oaknational/oak-components"; import { trpc } from "@/utils/trpc"; -function ModerationListItem({ moderation }: { readonly moderation: Moderation }) { +function ModerationListItem({ + moderation, +}: { + readonly moderation: Moderation; +}) { const { id, invalidatedAt } = moderation; const [invalidated, setInvalidated] = useState(Boolean(invalidatedAt)); const invalidateModeration = trpc.admin.invalidateModeration.useMutation({ @@ -28,7 +32,7 @@ function ModerationListItem({ moderation }: { readonly moderation: Moderation }) iconName="cross" className="ml-auto" onClick={() => - invalidateModeration.mutateAsync({ moderationId: id }) + void invalidateModeration.mutateAsync({ moderationId: id }) } isLoading={invalidateModeration.isLoading} disabled={!!invalidated} @@ -41,14 +45,16 @@ function ModerationListItem({ moderation }: { readonly moderation: Moderation }) {moderation.justification}
- {moderation.categories.map((category, index) => ( - - {String(category)} - - ))} + {Array.from(new Set(moderation.categories)) + .map((c) => String(c)) + .map((category) => ( + + {category} + + ))}
diff --git a/apps/nextjs/src/components/AppComponents/Chat/Chat/ChatModerationDisplay.stories.tsx b/apps/nextjs/src/components/AppComponents/Chat/Chat/ChatModerationDisplay.stories.tsx index 84aa6955e..ec9bcd8d9 100644 --- a/apps/nextjs/src/components/AppComponents/Chat/Chat/ChatModerationDisplay.stories.tsx +++ b/apps/nextjs/src/components/AppComponents/Chat/Chat/ChatModerationDisplay.stories.tsx @@ -4,7 +4,7 @@ import type { Meta, StoryObj } from "@storybook/react"; import { ChatModerationDisplay } from "./ChatModerationDisplay"; const meta: Meta = { - title: "Components/Chat/ChatModerationDisplay", + title: "Components/Dialogs/ChatModerationDisplay", component: ChatModerationDisplay, tags: ["autodocs"], decorators: [ diff --git a/apps/nextjs/src/components/AppComponents/Chat/button-scroll-to-bottom.tsx b/apps/nextjs/src/components/AppComponents/Chat/button-scroll-to-bottom.tsx index 5c9800993..c5fe3b3bc 100644 --- a/apps/nextjs/src/components/AppComponents/Chat/button-scroll-to-bottom.tsx +++ b/apps/nextjs/src/components/AppComponents/Chat/button-scroll-to-bottom.tsx @@ -9,7 +9,10 @@ import useAnalytics from "@/lib/analytics/useAnalytics"; import { useAtBottom } from "@/lib/hooks/use-at-bottom"; import { cn } from "@/lib/utils"; -export function ButtonScrollToBottom({ className, ...props }: ButtonProps) { +export function ButtonScrollToBottom({ + className, + ...props +}: Readonly) { const isAtBottom = useAtBottom(); const { trackEvent } = useAnalytics(); return ( diff --git a/apps/nextjs/src/components/AppComponents/Chat/chat-list/demo-limit-message.stories.tsx b/apps/nextjs/src/components/AppComponents/Chat/chat-list/demo-limit-message.stories.tsx new file mode 100644 index 000000000..135fef708 --- /dev/null +++ b/apps/nextjs/src/components/AppComponents/Chat/chat-list/demo-limit-message.stories.tsx @@ -0,0 +1,25 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import { ChatModerationProvider } from "@/components/ContextProviders/ChatModerationContext"; + +import { DemoLimitMessage } from "./demo-limit-message"; + +const meta: Meta = { + title: "Components/Chat/DemoLimitMessage", + component: DemoLimitMessage, + tags: ["autodocs"], + decorators: [ + (Story) => ( + + + + ), + ], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: {}, +}; diff --git a/apps/nextjs/src/components/AppComponents/Chat/chat-list/demo-limit-message.tsx b/apps/nextjs/src/components/AppComponents/Chat/chat-list/demo-limit-message.tsx new file mode 100644 index 000000000..1bd5393b1 --- /dev/null +++ b/apps/nextjs/src/components/AppComponents/Chat/chat-list/demo-limit-message.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { ChatMessage } from "@/components/AppComponents/Chat/chat-message"; + +export function DemoLimitMessage({ id }: Readonly<{ id: string }>) { + return ( +
+ } + /> +
+ ); +} diff --git a/apps/nextjs/src/components/AppComponents/Chat/chat-list/in-chat-download-buttons.stories.tsx b/apps/nextjs/src/components/AppComponents/Chat/chat-list/in-chat-download-buttons.stories.tsx new file mode 100644 index 000000000..69a678a93 --- /dev/null +++ b/apps/nextjs/src/components/AppComponents/Chat/chat-list/in-chat-download-buttons.stories.tsx @@ -0,0 +1,40 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import { DemoContext } from "@/components/ContextProviders/Demo"; + +import { InChatDownloadButtons } from "./in-chat-download-buttons"; + +const DemoDecorator: Story["decorators"] = (Story, { parameters }) => ( + + + +); + +const meta: Meta = { + title: "Components/Chat/InChatDownloadButtons", + component: InChatDownloadButtons, + tags: ["autodocs"], + args: { + id: "test-chat-id", + }, + decorators: [DemoDecorator], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const SharingDisabled: Story = { + parameters: { + demoContext: { + isSharingEnabled: false, + }, + }, +}; diff --git a/apps/nextjs/src/components/AppComponents/Chat/chat-list/in-chat-download-buttons.tsx b/apps/nextjs/src/components/AppComponents/Chat/chat-list/in-chat-download-buttons.tsx new file mode 100644 index 000000000..834baf05b --- /dev/null +++ b/apps/nextjs/src/components/AppComponents/Chat/chat-list/in-chat-download-buttons.tsx @@ -0,0 +1,63 @@ +import { OakBox, OakFlex, OakIcon, OakSpan } from "@oaknational/oak-components"; +import Link from "next/link"; + +import { useDemoUser } from "@/components/ContextProviders/Demo"; + +import { useDialog } from "../../DialogContext"; + +export const InChatDownloadButtons = ({ id }: { readonly id: string }) => { + const demo = useDemoUser(); + const { setDialogWindow } = useDialog(); + + return ( + + {demo.isSharingEnabled && ( + { + if (!demo.isSharingEnabled) { + setDialogWindow("demo-share-locked"); + } + }} + > + Download + + )} + + + ); +}; + +const InnerInChatButton = ({ + iconName, + children, +}: { + readonly iconName: "download" | "share"; + readonly children: string; +}) => { + return ( + + + + + {children} + + ); +}; diff --git a/apps/nextjs/src/components/AppComponents/Chat/chat-list.tsx b/apps/nextjs/src/components/AppComponents/Chat/chat-list/index.tsx similarity index 70% rename from apps/nextjs/src/components/AppComponents/Chat/chat-list.tsx rename to apps/nextjs/src/components/AppComponents/Chat/chat-list/index.tsx index 32b00ee94..49446abc2 100644 --- a/apps/nextjs/src/components/AppComponents/Chat/chat-list.tsx +++ b/apps/nextjs/src/components/AppComponents/Chat/chat-list/index.tsx @@ -1,47 +1,24 @@ "use client"; -import type { Dispatch, SetStateAction } from "react"; import { useCallback, useEffect, useRef, useState } from "react"; import type { PersistedModerationBase } from "@oakai/core/src/utils/ailaModeration/moderationSchema"; -import { OakBox, OakFlex, OakIcon, OakSpan } from "@oaknational/oak-components"; import type { Message } from "ai"; -import Link from "next/link"; import { ChatMessage } from "@/components/AppComponents/Chat/chat-message"; import { useLessonChat } from "@/components/ContextProviders/ChatProvider"; import type { DemoContextProps } from "@/components/ContextProviders/Demo"; -import { useDialog } from "../DialogContext"; -import type { AilaStreamingStatus } from "./Chat/hooks/useAilaStreamingStatus"; -import { useProgressForDownloads } from "./Chat/hooks/useProgressForDownloads"; -import type { DialogTypes } from "./Chat/types"; +import type { AilaStreamingStatus } from "../Chat/hooks/useAilaStreamingStatus"; +import { useProgressForDownloads } from "../Chat/hooks/useProgressForDownloads"; +import { DemoLimitMessage } from "./demo-limit-message"; +import { InChatDownloadButtons } from "./in-chat-download-buttons"; export interface ChatListProps { isDemoLocked: boolean; showLessonMobile: boolean; demo: DemoContextProps; } - -function DemoLimitMessage({ id }: Readonly<{ id: string }>) { - return ( -
- } - /> -
- ); -} - export function ChatList({ isDemoLocked, showLessonMobile, @@ -129,7 +106,6 @@ export const ChatMessagesDisplay = ({ demo, }: ChatMessagesDisplayProps) => { const { lessonPlan, isStreaming } = useLessonChat(); - const { setDialogWindow } = useDialog(); const { totalSections, totalSectionsComplete } = useProgressForDownloads({ lessonPlan, isStreaming, @@ -228,71 +204,7 @@ export const ChatMessagesDisplay = ({ (message.role !== "user" && message.content.includes("download") && message.content.includes("share")), - ) && } + ) && } ); }; - -const InChatDownloadButtons = ({ - demo, - id, - setDialogWindow, -}: { - readonly demo: DemoContextProps; - readonly id: string; - readonly setDialogWindow: Dispatch>; -}) => { - return ( - - {demo.isSharingEnabled && ( - { - if (!demo.isSharingEnabled) { - setDialogWindow("demo-share-locked"); - } - }} - > - Download - - )} - - - ); -}; - -const InnerInChatButton = ({ - iconName, - - children, -}: { - readonly iconName: "download" | "share"; - - readonly children: string; -}) => { - return ( - - - - - {children} - - ); -}; diff --git a/apps/nextjs/src/components/AppComponents/Chat/chat-message/ChatMessagePart.stories.tsx b/apps/nextjs/src/components/AppComponents/Chat/chat-message/ChatMessagePart.stories.tsx new file mode 100644 index 000000000..1b97e09bd --- /dev/null +++ b/apps/nextjs/src/components/AppComponents/Chat/chat-message/ChatMessagePart.stories.tsx @@ -0,0 +1,74 @@ +import { MessagePart } from "@oakai/aila/src/protocol/jsonPatchProtocol"; +import type { Meta, StoryObj } from "@storybook/react"; + +import { ChatMessagePart } from "./ChatMessagePart"; + +const meta: Meta = { + title: "Components/Chat/ChatMessagePart", + component: ChatMessagePart, + tags: ["autodocs"], + args: { + inspect: false, + }, +}; + +export default meta; +type Story = StoryObj; + +const basePart: Omit = { + type: "message-part", + id: "test-part-id", + isPartial: false, +}; + +export const PromptMessagePart: Story = { + args: { + part: { + ...basePart, + document: { + type: "prompt", + message: + "Are the learning outcome and learning cycles appropriate for your pupils? If not, suggest an edit. Otherwise, tap **Continue** to move on to the next step.", + }, + }, + }, +}; + +export const ErrorMessagePart: Story = { + args: { + part: { + ...basePart, + document: { + type: "error", + message: + "**Unfortunately you’ve exceeded your fair usage limit for today.** Please come back in 11 hours. If you require a higher limit, please [make a request](https://forms.gle/tHsYMZJR367zydsG8).", + }, + }, + }, +}; + +export const TextMessagePart: Story = { + args: { + part: { + ...basePart, + document: { + type: "text", + value: + "Are the learning outcome and learning cycles appropriate for your pupils? If not, suggest an edit. Otherwise, tap **Continue** to move on to the next step.", + }, + }, + }, +}; + +export const WithInspector: Story = { + args: { + inspect: true, + part: { + ...basePart, + document: { + type: "prompt", + message: "This is a prompt", + }, + }, + }, +}; diff --git a/apps/nextjs/src/components/AppComponents/Chat/chat-message/ChatMessagePart.tsx b/apps/nextjs/src/components/AppComponents/Chat/chat-message/ChatMessagePart.tsx new file mode 100644 index 000000000..e428bb8a3 --- /dev/null +++ b/apps/nextjs/src/components/AppComponents/Chat/chat-message/ChatMessagePart.tsx @@ -0,0 +1,94 @@ +import type { + ErrorDocument, + MessagePart, + PromptDocument, + TextDocument, +} from "@oakai/aila/src/protocol/jsonPatchProtocol"; +import { aiLogger } from "@oakai/logger"; + +import { MemoizedReactMarkdownWithStyles } from "@/components/AppComponents/Chat/markdown"; + +import type { ModerationModalHelpers } from "../../FeedbackForms/ModerationFeedbackModal"; + +const log = aiLogger("chat"); + +const components = { + comment: NonRenderedPart, + prompt: PromptMessagePart, + error: ErrorMessagePart, + bad: NonRenderedPart, + patch: NonRenderedPart, + /** + * Patches do not get rendered, they get applied to the lesson plan + * state, which is then rendered in the right hand side. + */ + experimentalPatch: NonRenderedPart, + state: NonRenderedPart, + text: TextMessagePart, + action: NonRenderedPart, + moderation: NonRenderedPart, + id: NonRenderedPart, + unknown: NonRenderedPart, +}; + +export interface ChatMessagePartProps { + part: MessagePart; + inspect: boolean; + moderationModalHelpers: ModerationModalHelpers; +} + +export function ChatMessagePart({ + part, + inspect, +}: Readonly) { + const PartComponent = components[part.document.type] as React.ComponentType<{ + part: typeof part.document; + }>; + + if (!PartComponent) { + log.info("Unknown part type", part.document.type, JSON.stringify(part)); + return null; + } + + return ( +
+ + + { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + inspect && + } +
+ ); +} + +function NonRenderedPart() { + return null; +} + +function PromptMessagePart({ part }: Readonly<{ part: PromptDocument }>) { + return ; +} + +function ErrorMessagePart({ + part, +}: Readonly<{ + part: ErrorDocument; +}>) { + const markdown = part.message ?? "Sorry, an error has occurred"; + return ; +} + +function TextMessagePart({ part }: Readonly<{ part: TextDocument }>) { + return ; +} + +function PartInspector({ part }: Readonly<{ part: MessagePart }>) { + return ( +
+
+        {JSON.stringify(part, null, 2)}
+      
+
+ ); +} diff --git a/apps/nextjs/src/components/AppComponents/Chat/chat-message/index.stories.tsx b/apps/nextjs/src/components/AppComponents/Chat/chat-message/index.stories.tsx new file mode 100644 index 000000000..1b318e488 --- /dev/null +++ b/apps/nextjs/src/components/AppComponents/Chat/chat-message/index.stories.tsx @@ -0,0 +1,57 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import { ChatModerationProvider } from "@/components/ContextProviders/ChatModerationContext"; + +import { ChatMessage } from "./"; + +const meta: Meta = { + title: "Components/Chat/ChatMessage", + component: ChatMessage, + tags: ["autodocs"], + decorators: [ + (Story) => ( + + + + ), + ], + args: { + persistedModerations: [], + }, +}; + +export default meta; +type Story = StoryObj; + +export const UserMessage: Story = { + args: { + message: { + id: "test-chat-id", + content: + "Create a lesson plan about the end of Roman Britain for key stage 3 history", + role: "user", + }, + }, +}; + +export const LlmMessage: Story = { + args: { + message: { + id: "test-chat-id", + content: + '{"type":"llmMessage","sectionsToEdit":["learningOutcome","learningCycles"],"patches":[{"type":"patch","reasoning":"Since there are no existing Oak lessons for this topic, I have created a new lesson plan from scratch focusing on the end of Roman Britain.","value":{"type":"string","op":"add","path":"/learningOutcome","value":"I can explain the reasons behind the decline of Roman Britain and its impact on society."},"status":"complete"},{"type":"patch","reasoning":"I have outlined the learning cycles to break down the lesson structure for teaching about the end of Roman Britain.","value":{"type":"string-array","op":"add","path":"/learningCycles","value":["Identify the key events leading to the end of Roman Britain.","Describe the societal changes that occurred post-Roman withdrawal.","Analyse the archaeological evidence of Roman Britain\'s legacy."]},"status":"complete"}],"sectionsEdited":["learningOutcome","learningCycles"],"prompt":{"type":"text","value":"Are the learning outcome and learning cycles appropriate for your pupils? If not, suggest an edit. Otherwise, tap **Continue** to move on to the next step."},"status":"complete"}', + role: "assistant", + }, + }, +}; + +export const ErrorMessage: Story = { + args: { + message: { + id: "test-chat-id", + role: "assistant", + content: + '{"type":"error","value":"Rate limit exceeded","message":"**Unfortunately you’ve exceeded your fair usage limit for today.** Please come back in 11 hours. If you require a higher limit, please [make a request](https://forms.gle/tHsYMZJR367zydsG8)."}', + }, + }, +}; diff --git a/apps/nextjs/src/components/AppComponents/Chat/chat-message/index.tsx b/apps/nextjs/src/components/AppComponents/Chat/chat-message/index.tsx index 6bcab07d9..06359a59c 100644 --- a/apps/nextjs/src/components/AppComponents/Chat/chat-message/index.tsx +++ b/apps/nextjs/src/components/AppComponents/Chat/chat-message/index.tsx @@ -3,23 +3,10 @@ import type { ReactNode } from "react"; import { useState } from "react"; -import type { - ActionDocument, - BadDocument, - CommentDocument, - ErrorDocument, - MessagePart, - ModerationDocument, - PatchDocument, - PromptDocument, - StateDocument, - TextDocument, - UnknownDocument, -} from "@oakai/aila/src/protocol/jsonPatchProtocol"; +import type { MessagePart } from "@oakai/aila/src/protocol/jsonPatchProtocol"; import { parseMessageParts } from "@oakai/aila/src/protocol/jsonPatchProtocol"; import { isSafe } from "@oakai/core/src/utils/ailaModeration/helpers"; import type { PersistedModerationBase } from "@oakai/core/src/utils/ailaModeration/moderationSchema"; -import { aiLogger } from "@oakai/logger"; import type { Message } from "ai"; import { MemoizedReactMarkdownWithStyles } from "@/components/AppComponents/Chat/markdown"; @@ -27,12 +14,10 @@ import { useChatModeration } from "@/components/ContextProviders/ChatModerationC import { Icon } from "@/components/Icon"; import { cn } from "@/lib/utils"; -import type { ModerationModalHelpers } from "../../FeedbackForms/ModerationFeedbackModal"; import type { AilaStreamingStatus } from "../Chat/hooks/useAilaStreamingStatus"; +import { ChatMessagePart } from "./ChatMessagePart"; import { isModeration } from "./protocol"; -const log = aiLogger("chat"); - export interface ChatMessageProps { chatId: string; // Needed for when we refactor to use a moderation provider message: Message; @@ -220,130 +205,3 @@ function MessageTextWrapper({ children }: Readonly<{ children: ReactNode }>) { ); } - -export interface ChatMessagePartProps { - part: MessagePart; - inspect: boolean; - moderationModalHelpers: ModerationModalHelpers; -} - -function ChatMessagePart({ - part, - inspect, - moderationModalHelpers, -}: Readonly) { - const PartComponent = { - comment: CommentMessagePart, - prompt: PromptMessagePart, - error: ErrorMessagePart, - bad: BadMessagePart, - patch: PatchMessagePart, - experimentalPatch: ExperimentalPatchMessageComponent, - state: StateMessagePart, - text: TextMessagePart, - action: ActionMessagePart, - moderation: ModerationMessagePart, - id: IdMessagePart, - unknown: UnknownMessagePart, - }[part.document.type] as React.ComponentType<{ - part: typeof part.document; - moderationModalHelpers: ModerationModalHelpers; - }>; - - if (!PartComponent) { - log.info("Unknown part type", part.document.type, JSON.stringify(part)); - return null; - } - - return ( -
- - - { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - inspect && - } -
- ); -} - -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function BadMessagePart({ part }: Readonly<{ part: BadDocument }>) { - return null; -} - -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function CommentMessagePart({ part }: Readonly<{ part: CommentDocument }>) { - return null; -} - -function PromptMessagePart({ part }: Readonly<{ part: PromptDocument }>) { - return ; -} - -function ModerationMessagePart({ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - part, -}: Readonly<{ part: ModerationDocument }>) { - return null; -} - -function ErrorMessagePart({ - part, -}: Readonly<{ - part: ErrorDocument; -}>) { - const markdown = part.message ?? "Sorry, an error has occurred"; - return ; -} - -function TextMessagePart({ part }: Readonly<{ part: TextDocument }>) { - return ; -} - -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function PatchMessagePart({ part }: Readonly<{ part: PatchDocument }>) { - return null; -} - -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function StateMessagePart({ part }: Readonly<{ part: StateDocument }>) { - return null; -} - -function IdMessagePart() { - return null; -} - -function ActionMessagePart({ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - part, -}: Readonly<{ part: ActionDocument }>) { - return null; -} - -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function UnknownMessagePart({ part }: Readonly<{ part: UnknownDocument }>) { - return null; -} - -function PartInspector({ part }: Readonly<{ part: MessagePart }>) { - return ( -
-
-        {JSON.stringify(part, null, 2)}
-      
-
- ); -} - -/** - * Patches do not get rendered, they get applied to the lesson plan - * state, which is then rendered in the right hand side. - */ -function ExperimentalPatchMessageComponent() { - return null; -} diff --git a/apps/nextjs/src/components/AppComponents/Chat/chat-start-form.stories.tsx b/apps/nextjs/src/components/AppComponents/Chat/chat-start-form.stories.tsx index f57506495..8ca82763a 100644 --- a/apps/nextjs/src/components/AppComponents/Chat/chat-start-form.stories.tsx +++ b/apps/nextjs/src/components/AppComponents/Chat/chat-start-form.stories.tsx @@ -3,7 +3,7 @@ import type { Meta, StoryObj } from "@storybook/react"; import { ChatStartForm } from "./chat-start-form"; const meta: Meta = { - title: "Components/Chat/ChatStartForm", + title: "Components/Chat Start/ChatStartForm", component: ChatStartForm, tags: ["autodocs"], }; diff --git a/apps/nextjs/src/components/AppComponents/Chat/guidance-required.stories.tsx b/apps/nextjs/src/components/AppComponents/Chat/guidance-required.stories.tsx index 8d7b547d3..bae1cc2e9 100644 --- a/apps/nextjs/src/components/AppComponents/Chat/guidance-required.stories.tsx +++ b/apps/nextjs/src/components/AppComponents/Chat/guidance-required.stories.tsx @@ -23,18 +23,19 @@ export const Default: Story = { }, }; -export const CustomClass: Story = { - args: { - moderation: mockModeration, - className: "custom-class", - }, -}; - -export const Safe: Story = { +export const NoModeration: Story = { args: { moderation: { id: "safe", categories: [], }, }, + decorators: [ + (Story) => ( + <> + +

(Nothing should render here)

+ + ), + ], }; diff --git a/apps/nextjs/src/components/AppComponents/Chat/markdown.tsx b/apps/nextjs/src/components/AppComponents/Chat/markdown.tsx index fcee6eb5b..f50163802 100644 --- a/apps/nextjs/src/components/AppComponents/Chat/markdown.tsx +++ b/apps/nextjs/src/components/AppComponents/Chat/markdown.tsx @@ -1,6 +1,6 @@ import type { FC } from "react"; -import React, { memo } from "react"; -import type { Options } from "react-markdown"; +import React, { memo, useMemo } from "react"; +import type { Components, Options } from "react-markdown"; import ReactMarkdown from "react-markdown"; import * as Tooltip from "@radix-ui/react-tooltip"; @@ -24,110 +24,112 @@ export type ReactMarkdownWithStylesProps = Readonly<{ className?: string; }>; +// This could do with further refactoring to make it more readable +const createComponents = ( + className?: string, + lessonPlanSectionDescription?: string, +): Partial => ({ + li: ({ children }) => ( +
  • {children}
  • + ), + p: ({ children }) => ( +

    {children}

    + ), + h1: ({ children }) => ( + + +

    {children}

    +
    + {!!lessonPlanSectionDescription && ( + + + + + + + + + + {lessonPlanSectionDescription} + + + + + + )} +
    + ), + code: (props) => { + const { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + node, + className, + children, + inline, + ...restProps + } = props as { + node?: React.ReactNode; + inline?: boolean; + className?: string; + children?: React.ReactNode; + }; + if (children && Array.isArray(children) && children.length) { + if (children[0] == "▍") { + return ; + } + + children[0] = (children[0] as string).replace("`▍`", "▍"); + } + + const match = /language-(\w+)/.exec(className ?? ""); + + if (inline) { + return ( + + {children} + + ); + } + + return ( + + ); + }, + a: ({ children, href }) => { + const isExternal = href?.startsWith("http"); + const tags = isExternal + ? { target: "_blank", rel: "noopener noreferrer" } + : {}; + return ( + + {children} + + ); + }, +}); + export const MemoizedReactMarkdownWithStyles = ({ markdown, lessonPlanSectionDescription, className, }: ReactMarkdownWithStylesProps) => { + const components: Partial = useMemo(() => { + return createComponents(className, lessonPlanSectionDescription); + }, [className, lessonPlanSectionDescription]); return ( {children} - ); - }, - p({ children }) { - return

    {children}

    ; - }, - h1({ children }) { - return ( - - -

    {children}

    -
    - {!!lessonPlanSectionDescription && ( - - - - - - - - - - {lessonPlanSectionDescription} - - - - - - )} -
    - ); - }, - code(props) { - const { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - node, - className, - children, - inline, - ...restProps - } = props as { - node?: React.ReactNode; - inline?: boolean; - className?: string; - children?: React.ReactNode; - }; - if (children && Array.isArray(children) && children.length) { - if (children[0] == "▍") { - return ( - - ); - } - - children[0] = (children[0] as string).replace("`▍`", "▍"); - } - - const match = /language-(\w+)/.exec(className ?? ""); - - if (inline) { - return ( - - {children} - - ); - } - - return ( - - ); - }, - a({ children, href }) { - const isExternal = href?.startsWith("http"); - const tags = isExternal - ? { target: "_blank", rel: "noopener noreferrer" } - : {}; - return ( - - {children} - - ); - }, - }} + components={components} > {markdown}
    diff --git a/apps/nextjs/src/components/AppComponents/Chat/ui/button.tsx b/apps/nextjs/src/components/AppComponents/Chat/ui/button.tsx index 9ce6ee098..5846852bc 100644 --- a/apps/nextjs/src/components/AppComponents/Chat/ui/button.tsx +++ b/apps/nextjs/src/components/AppComponents/Chat/ui/button.tsx @@ -41,7 +41,7 @@ export interface ButtonProps readonly asChild?: boolean; } -const Button = React.forwardRef( +const Button = React.forwardRef>( ({ className, variant, size, asChild = false, ...props }, ref) => { const Comp = asChild ? Slot : "button"; return ( diff --git a/apps/nextjs/src/components/AppComponents/FeedbackForms/ModerationFeedbackForm.tsx b/apps/nextjs/src/components/AppComponents/FeedbackForms/ModerationFeedbackForm.tsx index e5a072be0..c77be1132 100644 --- a/apps/nextjs/src/components/AppComponents/FeedbackForms/ModerationFeedbackForm.tsx +++ b/apps/nextjs/src/components/AppComponents/FeedbackForms/ModerationFeedbackForm.tsx @@ -59,7 +59,12 @@ export const ModerationFeedbackForm = ({ -
    + { + e.preventDefault(); + void onSubmit(); + }} + >