diff --git a/apps/api/src/services/ai/ai.py b/apps/api/src/services/ai/ai.py index bc776962..e0e21e3e 100644 --- a/apps/api/src/services/ai/ai.py +++ b/apps/api/src/services/ai/ai.py @@ -117,7 +117,7 @@ def ai_send_activity_chat_message( ) # Get Activity Content Blocks - content = activity.content + content = activity.content # Serialize Activity Content Blocks to a text comprehensible by the AI structured = structure_activity_content_by_type(content) diff --git a/apps/api/src/services/ai/base.py b/apps/api/src/services/ai/base.py index 02674e92..f2356fa2 100644 --- a/apps/api/src/services/ai/base.py +++ b/apps/api/src/services/ai/base.py @@ -59,7 +59,7 @@ def ask_ai( memory_key = "history" memory = AgentTokenBufferMemory( - memory_key=memory_key, llm=llm, chat_memory=message_history + memory_key=memory_key, llm=llm, chat_memory=message_history, max_tokens=1000 ) system_message = SystemMessage(content=(message_for_the_prompt)) @@ -77,6 +77,7 @@ def ask_ai( memory=memory, verbose=True, return_intermediate_steps=True, + handle_parsing_errors=True, ) return agent_executor({"input": question}) diff --git a/apps/web/app/editor/course/[courseid]/activity/[activityuuid]/edit/page.tsx b/apps/web/app/editor/course/[courseid]/activity/[activityuuid]/edit/page.tsx index 672002a1..f648cd84 100644 --- a/apps/web/app/editor/course/[courseid]/activity/[activityuuid]/edit/page.tsx +++ b/apps/web/app/editor/course/[courseid]/activity/[activityuuid]/edit/page.tsx @@ -8,6 +8,8 @@ import { getAccessTokenFromRefreshTokenCookie, getNewAccessTokenUsingRefreshToke import { getOrganizationContextInfo, getOrganizationContextInfoWithId } from "@services/organizations/orgs"; import SessionProvider from "@components/Contexts/SessionContext"; import EditorOptionsProvider from "@components/Contexts/Editor/EditorContext"; +import AIChatBotProvider from "@components/Contexts/AI/AIChatBotContext"; +import AIEditorProvider from "@components/Contexts/AI/AIEditorContext"; type MetadataProps = { params: { orgslug: string, courseid: string, activityid: string }; @@ -36,13 +38,14 @@ const EditActivity = async (params: any) => { const courseInfo = await getCourseMetadataWithAuthHeader(courseid, { revalidate: 0, tags: ['courses'] }, access_token ? access_token : null) const activity = await getActivityWithAuthHeader(activityuuid, { revalidate: 0, tags: ['activities'] }, access_token ? access_token : null) const org = await getOrganizationContextInfoWithId(courseInfo.org_id, { revalidate: 1800, tags: ['organizations'] }); - console.log('courseInfo', courseInfo) return ( - - - + + + + + ); } diff --git a/apps/web/components/AI/AIActivityAsk.tsx b/apps/web/components/AI/AIActivityAsk.tsx index 6ae62c49..c30f315d 100644 --- a/apps/web/components/AI/AIActivityAsk.tsx +++ b/apps/web/components/AI/AIActivityAsk.tsx @@ -31,10 +31,10 @@ function AIActivityAsk(props: AIActivityAskProps) { style={{ background: 'conic-gradient(from 32deg at 53.75% 50%, rgb(35, 40, 93) 4deg, rgba(20, 0, 52, 0.95) 59deg, rgba(164, 45, 238, 0.88) 281deg)', }} - className="rounded-full px-5 drop-shadow-md flex items-center space-x-1 p-2.5 text-sm text-white hover:cursor-pointer transition delay-150 duration-300 ease-in-out hover:scale-105"> + className="rounded-full px-5 drop-shadow-md flex items-center space-x-1.5 p-2.5 text-sm text-white hover:cursor-pointer transition delay-150 duration-300 ease-in-out hover:scale-105"> {" "} - + {" "} Ask AI @@ -59,8 +59,8 @@ function ActivityChatMessageBox(props: ActivityChatMessageBoxProps) { // TODO : come up with a better way to handle this const inputClass = aiChatBotState.isWaitingForResponse - ? 'ring-1 ring-inset ring-white/10 bg-transparent w-full rounded-lg outline-none px-4 py-2 text-white text-sm placeholder:text-white/30 opacity-30 ' - : 'ring-1 ring-inset ring-white/10 bg-transparent w-full rounded-lg outline-none px-4 py-2 text-white text-sm placeholder:text-white/30'; + ? 'ring-1 ring-inset ring-white/10 bg-gray-950/40 w-full rounded-lg outline-none px-4 py-2 text-white text-sm placeholder:text-white/30 opacity-30 ' + : 'ring-1 ring-inset ring-white/10 bg-gray-950/40 w-full rounded-lg outline-none px-4 py-2 text-white text-sm placeholder:text-white/30'; useEffect(() => { if (aiChatBotState.isModalOpen) { @@ -141,7 +141,7 @@ function ActivityChatMessageBox(props: ActivityChatMessageBoxProps) {
- + AI
diff --git a/apps/web/components/Contexts/AI/AIChatBotContext.tsx b/apps/web/components/Contexts/AI/AIChatBotContext.tsx index e7bd9cc3..d39d93e7 100644 --- a/apps/web/components/Contexts/AI/AIChatBotContext.tsx +++ b/apps/web/components/Contexts/AI/AIChatBotContext.tsx @@ -1,8 +1,6 @@ +'use client'; import { AIMessage } from '@components/AI/AIActivityAsk'; import React, { createContext, useContext, useReducer } from 'react' - - - export const AIChatBotContext = createContext(null) as any; export const AIChatBotDispatchContext = createContext(null) as any; diff --git a/apps/web/components/Contexts/AI/AIEditorContext.tsx b/apps/web/components/Contexts/AI/AIEditorContext.tsx new file mode 100644 index 00000000..aa314032 --- /dev/null +++ b/apps/web/components/Contexts/AI/AIEditorContext.tsx @@ -0,0 +1,82 @@ +'use client'; +import { AIMessage } from '@components/AI/AIActivityAsk'; +import React, { createContext, useContext, useReducer } from 'react' +export const AIEditorContext = createContext(null) as any; +export const AIEditorDispatchContext = createContext(null) as any; + +export type AIEditorStateTypes = { + + messages: AIMessage[], + isModalOpen: boolean, + isFeedbackModalOpen: boolean, + aichat_uuid: string, + isWaitingForResponse: boolean, + chatInputValue: string, + selectedTool: 'Writer' | 'ContinueWriting' | 'MakeLonger' | 'GenerateQuiz' | 'Translate' + isUserInputEnabled: boolean +} + +function AIEditorProvider({ children }: { children: React.ReactNode }) { + const [aIEditorState, dispatchAIEditor] = useReducer(aIEditorReducer, + { + messages: [] as AIMessage[], + isModalOpen: false, + isFeedbackModalOpen: false, + aichat_uuid: null, + isWaitingForResponse: false, + chatInputValue: '', + selectedTool: 'Writer', + isUserInputEnabled: true + } + ); + return ( + + + {children} + + + ) +} + +export default AIEditorProvider + +export function useAIEditor() { + return useContext(AIEditorContext); +} + +export function useAIEditorDispatch() { + return useContext(AIEditorDispatchContext); +} + +function aIEditorReducer(state: any, action: any) { + switch (action.type) { + case 'setMessages': + return { ...state, messages: action.payload }; + case 'addMessage': + return { ...state, messages: [...state.messages, action.payload] }; + case 'setIsModalOpen': + return { ...state, isModalOpen: true }; + case 'setIsModalClose': + return { ...state, isModalOpen: false }; + case 'setAichat_uuid': + return { ...state, aichat_uuid: action.payload }; + case 'setIsWaitingForResponse': + return { ...state, isWaitingForResponse: true }; + case 'setIsNoLongerWaitingForResponse': + return { ...state, isWaitingForResponse: false }; + case 'setChatInputValue': + return { ...state, chatInputValue: action.payload }; + case 'setSelectedTool': + return { ...state, selectedTool: action.payload }; + case 'setIsFeedbackModalOpen': + return { ...state, isFeedbackModalOpen: true }; + case 'setIsFeedbackModalClose': + return { ...state, isFeedbackModalOpen: false }; + case 'setIsUserInputEnabled': + return { ...state, isUserInputEnabled: action.payload }; + + + default: + throw new Error(`Unhandled action type: ${action.type}`) + } +} \ No newline at end of file diff --git a/apps/web/components/Objects/Activities/DynamicCanva/DynamicCanva.tsx b/apps/web/components/Objects/Activities/DynamicCanva/DynamicCanva.tsx index 8d2546f3..d4685919 100644 --- a/apps/web/components/Objects/Activities/DynamicCanva/DynamicCanva.tsx +++ b/apps/web/components/Objects/Activities/DynamicCanva/DynamicCanva.tsx @@ -101,7 +101,7 @@ function Canva(props: Editor) { - + diff --git a/apps/web/components/Objects/Activities/DynamicCanva/Elements/AICanvaToolkit.tsx b/apps/web/components/Objects/Activities/DynamicCanva/Elements/AICanvaToolkit.tsx index 5619bae9..4d53d921 100644 --- a/apps/web/components/Objects/Activities/DynamicCanva/Elements/AICanvaToolkit.tsx +++ b/apps/web/components/Objects/Activities/DynamicCanva/Elements/AICanvaToolkit.tsx @@ -21,7 +21,7 @@ function AICanvaToolkit(props: AICanvaToolkitProps) {
-
AI
+
AI
@@ -39,15 +39,13 @@ function AICanvaToolkit(props: AICanvaToolkitProps) { function AIActionButton(props: { editor: Editor, label: string, activity: any }) { const dispatchAIChatBot = useAIChatBotDispatch() as any; const aiChatBotState = useAIChatBot() as AIChatBotStateTypes; - const [aichat_uuid, setAichat_uuid] = React.useState(''); async function handleAction(label: string) { const selection = getTipTapEditorSelectedText(); const prompt = getPrompt(label, selection); dispatchAIChatBot({ type: 'setIsModalOpen' }); await sendMessage(prompt); - - + } const getTipTapEditorSelectedText = () => { diff --git a/apps/web/components/Objects/Editor/AI/AIEditorToolkit.tsx b/apps/web/components/Objects/Editor/AI/AIEditorToolkit.tsx new file mode 100644 index 00000000..46a01045 --- /dev/null +++ b/apps/web/components/Objects/Editor/AI/AIEditorToolkit.tsx @@ -0,0 +1,308 @@ +import React from 'react' +import learnhouseAI_icon from "public/learnhouse_ai_simple.png"; +import { motion, AnimatePresence } from 'framer-motion'; +import Image from 'next/image'; +import { BetweenHorizontalStart, FastForward, Feather, FileStack, HelpCircle, Languages, MessageCircle, MoreVertical, Pen, X } from 'lucide-react'; +import { Editor } from '@tiptap/react'; +import { AIChatBotStateTypes, useAIChatBot, useAIChatBotDispatch } from '@components/Contexts/AI/AIChatBotContext'; +import { AIEditorStateTypes, useAIEditor, useAIEditorDispatch } from '@components/Contexts/AI/AIEditorContext'; +import { sendActivityAIChatMessage, startActivityAIChatSession } from '@services/ai/ai'; + +type AIEditorToolkitProps = { + editor: Editor, + activity: any +} + +type AIPromptsLabels = { + label: 'Writer' | 'ContinueWriting' | 'MakeLonger' | 'GenerateQuiz' | 'Translate', + selection: string + +} + +function AIEditorToolkit(props: AIEditorToolkitProps) { + const dispatchAIEditor = useAIEditorDispatch() as any; + const aiEditorState = useAIEditor() as AIEditorStateTypes; + + + return ( + + {aiEditorState.isModalOpen && + <> + {aiEditorState.isFeedbackModalOpen && } +
+
+
+
+ +
AI Editor
+ +
+
+
+ + + + + +
+
+ Promise.all([dispatchAIEditor({ type: 'setIsModalClose' }), dispatchAIEditor({ type: 'setIsFeedbackModalClose' })])} size={20} className='text-white/50 hover:cursor-pointer bg-white/10 p-1 rounded-full items-center' /> +
+
+
+
} +
+ ) +} + + +const UserFeedbackModal = (props: AIEditorToolkitProps) => { + const dispatchAIEditor = useAIEditorDispatch() as any; + const aiEditorState = useAIEditor() as AIEditorStateTypes; + + const handleChange = async (event: React.ChangeEvent) => { + await dispatchAIEditor({ type: 'setChatInputValue', payload: event.currentTarget.value }); + + } + + const sendReqWithMessage = async (message: string) => { + if (aiEditorState.aichat_uuid) { + await dispatchAIEditor({ type: 'addMessage', payload: { sender: 'user', message: message, type: 'user' } }); + await dispatchAIEditor({ type: 'setIsWaitingForResponse' }); + const response = await sendActivityAIChatMessage(message, aiEditorState.aichat_uuid, props.activity.activity_uuid) + await dispatchAIEditor({ type: 'setIsNoLongerWaitingForResponse' }); + await dispatchAIEditor({ type: 'setChatInputValue', payload: '' }); + await dispatchAIEditor({ type: 'addMessage', payload: { sender: 'ai', message: response.message, type: 'ai' } }); + return response.message; + + } else { + await dispatchAIEditor({ type: 'addMessage', payload: { sender: 'user', message: message, type: 'user' } }); + await dispatchAIEditor({ type: 'setIsWaitingForResponse' }); + const response = await startActivityAIChatSession(message, props.activity.activity_uuid) + await dispatchAIEditor({ type: 'setAichat_uuid', payload: response.aichat_uuid }); + await dispatchAIEditor({ type: 'setIsNoLongerWaitingForResponse' }); + await dispatchAIEditor({ type: 'setChatInputValue', payload: '' }); + await dispatchAIEditor({ type: 'addMessage', payload: { sender: 'ai', message: response.message, type: 'ai' } }); + return response.message; + } + } + + const handleKeyPress = async (event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + await handleOperation(aiEditorState.selectedTool, aiEditorState.chatInputValue); + } + } + + const handleOperation = async (label: 'Writer' | 'ContinueWriting' | 'MakeLonger' | 'GenerateQuiz' | 'Translate', message: string) => { + // Set selected tool + await dispatchAIEditor({ type: 'setSelectedTool', payload: label }); + + // Check what operation that was + if (label === 'Writer') { + let ai_message = ''; + let prompt = getPrompt({ label: label, selection: message }); + if (prompt) { + ai_message = await sendReqWithMessage(prompt); + await fillEditorWithText(ai_message); + } + } else if (label === 'ContinueWriting') { + let ai_message = ''; + let text_selection = getTipTapEditorSelectedText(); + let prompt = getPrompt({ label: label, selection: text_selection }); + if (prompt) { + ai_message = await sendReqWithMessage(prompt); + const message_without_original_text = await removeSentences(text_selection, ai_message); + await fillEditorWithText(message_without_original_text); + } + } else if (label === 'MakeLonger') { + // Send message to AI + // Wait for response + // Add response to editor + // Close modal + + } else if (label === 'GenerateQuiz') { + // Send message to AI + // Wait for response + // Add response to editor + // Close modal + } else if (label === 'Translate') { + // Send message to AI + // Wait for response + // Add response to editor + // Close modal + } + } + + const removeSentences = async (textToRemove: string, originalText: string) => { + const phrase = textToRemove.toLowerCase(); + const original = originalText.toLowerCase(); + + if (original.includes(phrase)) { + const regex = new RegExp(phrase, 'g'); + const newText = original.replace(regex, ''); + return newText; + } else { + return originalText; + } + } + + async function fillEditorWithText(text: string) { + const words = text.split(' '); + + for (let i = 0; i < words.length; i++) { + const textNode = { + type: 'text', + text: words[i], + }; + + props.editor.chain().focus().insertContent(textNode).run(); + + // Add a space after each word except the last one + if (i < words.length - 1) { + const spaceNode = { + type: 'text', + text: ' ', + }; + + props.editor.chain().focus().insertContent(spaceNode).run(); + } + + // Wait for 0.3 seconds before adding the next word + await new Promise(resolve => setTimeout(resolve, 120)); + } + } + + const getPrompt = (args: AIPromptsLabels) => { + const { label, selection } = args; + + if (label === 'Writer') { + return `Write 3 sentences about ${selection}`; + } else if (label === 'ContinueWriting') { + return `Continue writing 3 more sentences based on "${selection}"`; + } else if (label === 'MakeLonger') { + return `Make longer this paragraph "${selection}"`; + } else if (label === 'GenerateQuiz') { + return `Generate a quiz about "${selection}", only return an array of objects, every object should respect the following interface: + interface Answer { + answer_id: string; + answer: string; + correct: boolean; + } + interface Question { + question_id: string; + question: string; + type: "multiple_choice" + answers: Answer[]; + } + " `; + } else if (label === 'Translate') { + return `Translate ${selection} to selected language`; + } + } + + const getTipTapEditorSelectedText = () => { + const pos = props.editor.state.selection.$from.pos; // get the cursor position + const resolvedPos = props.editor.state.doc.resolve(pos); // resolve the position in the document + const start = resolvedPos.before(1); // get the start position of the node + const end = resolvedPos.after(1); // get the end position of the node + const paragraph = props.editor.state.doc.textBetween(start, end, '\n', '\n'); // get the text of the node + return paragraph; + } + + + return ( + +
+
+ +
+
+
+ +
+
+ {aiEditorState.isUserInputEnabled &&
+ +
+ +
+
} +
+
+ ) +} + +const AiEditorToolButton = (props: any) => { + const dispatchAIEditor = useAIEditorDispatch() as any; + const aiEditorState = useAIEditor() as AIEditorStateTypes; + + const handleToolButtonClick = async (label: 'Writer' | 'ContinueWriting' | 'MakeLonger' | 'GenerateQuiz' | 'Translate') => { + if (label === 'Writer') { + await dispatchAIEditor({ type: 'setSelectedTool', payload: label }); + await dispatchAIEditor({ type: 'setIsUserInputEnabled', payload: true }); + await dispatchAIEditor({ type: 'setIsFeedbackModalOpen' }); + } + if (label === 'ContinueWriting') { + await dispatchAIEditor({ type: 'setSelectedTool', payload: label }); + await dispatchAIEditor({ type: 'setIsUserInputEnabled', payload: false }); + await dispatchAIEditor({ type: 'setIsFeedbackModalOpen' }); + } + } + + return ( + + ) +} + +const AiEditorActionScreen = ({ handleOperation }: { handleOperation: any }) => { + const dispatchAIEditor = useAIEditorDispatch() as any; + const aiEditorState = useAIEditor() as AIEditorStateTypes; + return ( +
+ {aiEditorState.selectedTool === 'Writer' && +
+ Write about... +
} + {aiEditorState.selectedTool === 'ContinueWriting' && +
{ + handleOperation(aiEditorState.selectedTool, aiEditorState.chatInputValue) + }} className='flex cursor-pointer space-x-1.5 p-4 items-center bg-white/10 rounded-md outline outline-1 outline-neutral-200/20 text-2xl font-semibold text-white/70 hover:bg-white/20 hover:outline-neutral-200/40 delay-75 ease-linear transition-all'> + +
} +
+ ) +} + + +export default AIEditorToolkit \ No newline at end of file diff --git a/apps/web/components/Objects/Editor/Editor.tsx b/apps/web/components/Objects/Editor/Editor.tsx index 93cac96b..6da8402e 100644 --- a/apps/web/components/Objects/Editor/Editor.tsx +++ b/apps/web/components/Objects/Editor/Editor.tsx @@ -9,6 +9,9 @@ import Image from "next/image"; import styled from "styled-components"; import { DividerVerticalIcon, SlashIcon } from "@radix-ui/react-icons"; import Avvvatars from "avvvatars-react"; +import learnhouseAI_icon from "public/learnhouse_ai_simple.png"; +import { AIEditorStateTypes, useAIEditor, useAIEditorDispatch } from "@components/Contexts/AI/AIEditorContext"; + // extensions import InfoCallout from "./Extensions/Callout/Info/InfoCallout"; import WarningCallout from "./Extensions/Callout/Warning/WarningCallout"; @@ -38,6 +41,8 @@ import java from 'highlight.js/lib/languages/java' import { CourseProvider } from "@components/Contexts/CourseContext"; import { OrgProvider } from "@components/Contexts/OrgContext"; import { useSession } from "@components/Contexts/SessionContext"; +import AIEditorTools from "./AI/AIEditorToolkit"; +import AIEditorToolkit from "./AI/AIEditorToolkit"; interface Editor { @@ -52,6 +57,9 @@ interface Editor { function Editor(props: Editor) { const session = useSession() as any; + const dispatchAIEditor = useAIEditorDispatch() as any; + const aiEditorState = useAIEditor() as AIEditorStateTypes; + // remove course_ from course_uuid const course_uuid = props.course.course_uuid.substring(7); @@ -156,19 +164,30 @@ function Editor(props: Editor) { {" "} {props.course.name} {props.activity.name}{" "} - - - - {!session.isAuthenticated && Loading} - {session.isAuthenticated && } - + +
+
+
dispatchAIEditor({ type: aiEditorState.isModalOpen ? 'setIsModalClose' : 'setIsModalOpen' })} + style={{ + background: 'conic-gradient(from 32deg at 53.75% 50%, rgb(35, 40, 93) 4deg, rgba(20, 0, 52, 0.95) 59deg, rgba(164, 45, 238, 0.88) 281deg)', + }} + className="rounded-md px-3 py-2 drop-shadow-md flex items-center space-x-1.5 text-sm text-white hover:cursor-pointer transition delay-150 duration-300 ease-in-out hover:scale-105"> + {" "} + + + {" "} + AI Editor +
+
+
- +
props.setContent(editor.getJSON())}> Save
@@ -178,6 +197,13 @@ function Editor(props: Editor) {
+ + + + {!session.isAuthenticated && Loading} + {session.isAuthenticated && } + +
@@ -193,6 +219,7 @@ function Editor(props: Editor) { exit={{ opacity: 0 }} > + diff --git a/apps/web/components/Objects/Editor/Toolbar/ToolbarButtons.tsx b/apps/web/components/Objects/Editor/Toolbar/ToolbarButtons.tsx index 3e910195..65e53b23 100644 --- a/apps/web/components/Objects/Editor/Toolbar/ToolbarButtons.tsx +++ b/apps/web/components/Objects/Editor/Toolbar/ToolbarButtons.tsx @@ -1,15 +1,15 @@ import styled from "styled-components"; -import { FontBoldIcon, FontItalicIcon, StrikethroughIcon, ArrowLeftIcon, ArrowRightIcon, OpacityIcon, DividerVerticalIcon, ListBulletIcon } from "@radix-ui/react-icons"; -import { AlertCircle, AlertTriangle, BadgeHelp, Code, FileText, GraduationCap, HelpCircle, ImagePlus, Info, ListChecks, Sigma, Video, Youtube } from "lucide-react"; +import { FontBoldIcon, FontItalicIcon, StrikethroughIcon, ArrowLeftIcon, ArrowRightIcon, DividerVerticalIcon, ListBulletIcon } from "@radix-ui/react-icons"; +import { AlertCircle, AlertTriangle, BadgeHelp, Code, FileText, ImagePlus, Sigma, Video, Youtube } from "lucide-react"; import ToolTip from "@components/StyledElements/Tooltip/Tooltip"; export const ToolbarButtons = ({ editor, props }: any) => { + if (!editor) { return null; } // YouTube extension - const addYoutubeVideo = () => { const url = prompt("Enter YouTube URL"); diff --git a/apps/web/public/learnhouse_ai_simple_colored.png b/apps/web/public/learnhouse_ai_simple_colored.png new file mode 100644 index 00000000..af78fbc4 Binary files /dev/null and b/apps/web/public/learnhouse_ai_simple_colored.png differ