diff --git a/backend/nodejs/controllers/ChatController.js b/backend/nodejs/controllers/ChatController.js index cd2503b..00c7908 100644 --- a/backend/nodejs/controllers/ChatController.js +++ b/backend/nodejs/controllers/ChatController.js @@ -17,6 +17,7 @@ export default class ChatController extends BaseController { // Read prompt and mode from the POST body const prompt = req.body.prompt; const mode = req.body.mode || "default"; + const destination = req.body.destination || "chat"; if (!prompt) { return res.status(400).json({ error: "Prompt is required" }); @@ -26,18 +27,23 @@ export default class ChatController extends BaseController { res.setHeader("Cache-Control", "no-cache"); res.setHeader("Connection", "keep-alive"); - const stream = await this.chatService.getChatResponse(prompt, mode); - - stream.on("data", (chunk) => { - res.write(`data: ${chunk.toString()}\n\n`); + const responseOrStream = await this.chatService.getChatResponse(prompt, mode); + if (typeof responseOrStream === 'string') { + res.write(`data: ${JSON.stringify({ fullContent: responseOrStream })}\n\n`); + res.write("data: [DONE]\n\n"); + return res.end(); + } + responseOrStream.on("data", (chunk) => { + res.write(chunk.toString()+"\n"); }); - - stream.on("end", () => { + responseOrStream.on("end", () => { + res.write("[DONE]\n"); res.end(); }); } catch (error) { console.error("Chat API Error:", error); - res.write("data: Error occurred while fetching response\n\n"); + res.write(`data: {"fullContent":"ERROR: ${error.message}"}\n\n`); + res.write("data: [DONE]\n\n"); res.end(); } } diff --git a/backend/nodejs/services/ChatService.js b/backend/nodejs/services/ChatService.js index 5fd2170..b24d1e1 100644 --- a/backend/nodejs/services/ChatService.js +++ b/backend/nodejs/services/ChatService.js @@ -4,10 +4,18 @@ import JSONStreamService from "./JSONStreamService.js"; export default class ChatService { constructor() { this.conversationHistory = []; + this.quizInProgress = false; + this.currentQuestion = null; + this.correctAnswer = null; + this.lastAnsweredBy = null; } clearHistory() { this.conversationHistory = []; + this.quizInProgress = false; + this.currentQuestion = null; + this.correctAnswer = null; + this.lastAnsweredBy = null; } async getChatResponse(prompt, mode) { @@ -16,68 +24,172 @@ export default class ChatService { } let formattedPrompt; + let isQuizAnswer = false; + let immediateLocalResponse = null; + switch (mode) { case "steps": + this.clearHistory(); formattedPrompt = `Explain in a step by step format: ${prompt}`; + this.conversationHistory.push({ role: "user", content: formattedPrompt }); break; case "example": + this.clearHistory(); formattedPrompt = `Explain with a real world example: ${prompt}`; + this.conversationHistory.push({ role: "user", content: formattedPrompt }); break; case "flashcards": + this.clearHistory(); formattedPrompt = `Generate 10 questions and answers on this subject: ${prompt}`; + this.conversationHistory.push({ role: "user", content: formattedPrompt }); break; + case "quizmaster": + this.clearHistory(); + if (prompt.toLowerCase().includes("start quiz")){ + const topic = prompt.replace(/start quiz/i, "").trim(); + formattedPrompt = `Generate exactly one question, with its answer, strictly related to '${topic}' without introducing any unrelated details, + explanations, extra text, or deviations. Respond ONLY with this JSON structure: + [ + { + "Question": "[the generated question]", + "Answer": "[the generated answer]" + } + ] + DO NOT use multiple objects in the array. Combine Question and Answer in the same object. DO NOT add any other keys, explanations, or formatting. + Failure to adhere to this format will result in an invalid response. Do not include additional text, formatting, alternative structures, + variables, markdown, escapes, line breaks, or placeholders`; + this.quizInProgress = true; + this.conversationHistory.push({ role: "user", content: formattedPrompt }); + } else if (this.quizInProgress && prompt.toLowerCase().includes("next question")){ + const topic = prompt.replace(/next question/i, "").trim(); + formattedPrompt = `Generate exactly one more question, with its answer, strictly related to '${topic}' without introducing any unrelated details, + explanations, extra text, or deviations. Respond ONLY with this JSON structure: + [ + { + "Question": "[the generated question]", + "Answer": "[the generated answer]" + } + ] + DO NOT use multiple objects in the array. Combine Question and Answer in the same object. DO NOT add any other keys, explanations, or formatting. + Failure to adhere to this format will result in an invalid response. Do not include additional text, formatting, alternative structures, + variables, markdown, escapes, line breaks, or placeholders`; + this.conversationHistory.push({ role: "user", content: formattedPrompt }); + }else if (this.quizInProgress && prompt.toLowerCase().includes("end quiz")){ + this.quizInProgress = false + this.currentQuestion = null; + this.correctAnswer = null; + this.lastAnsweredBy = null; + immediateLocalResponse = "The quiz has ended. Thanks for playing!"; + } else if (this.quizInProgress && this.currentQuestion && this.correctAnswer){ + isQuizAnswer = true; + immediateLocalResponse = this.validateQuizAnswer(prompt); + }else if (this.quizInProgress){ + immediateLocalResponse = `I'm in quiz mode right now. Type "start quiz [topic]" to start a new quiz, "next question" for another question, or "end quiz" to finish.`; + } else { + formattedPrompt = prompt; + this.conversationHistory.push({ role: "user", content: formattedPrompt }); + } + break default: + this.clearHistory(); formattedPrompt = prompt; + this.conversationHistory.push({ role: "user", content: formattedPrompt }); + break + } + if(immediateLocalResponse !== null || isQuizAnswer) { + return immediateLocalResponse; } - - // Add user input to conversation history - this.conversationHistory.push({ role: "user", content: formattedPrompt }); - try { - const response = await axios.post( - "https://ai.api.parsonlabs.com/v1/chat/completions", - { - model: "deepseek-r1:1.5b", - messages: this.conversationHistory, // IMPORTANT! the entire history must be pushed here, not just one single message. - stream: true, - }, - { - headers: { "Content-Type": "application/json" }, - responseType: "stream", - } - ); + if(mode==='quizmaster' && this.quizInProgress){ + const res = await axios.post( + "http://bappity.net:11434/v1/chat/completions", + { + model: "deepseek-r1:1.5b", + messages: this.conversationHistory, // IMPORTANT! the entire history must be pushed here, not just one single message. + }, + { headers: {"Content-Type": "application/json" } } + ); + const fullText = res.data.choices?.[0]?.message?.content || ""; + this.conversationHistory.push({ role:"assistant", content: fullText.trim() }); + return fullText; + }else{ + const response = await axios.post( + "http://bappity.net:11434/v1/chat/completions", + { + model: "deepseek-r1:1.5b", + messages: this.conversationHistory, // IMPORTANT! the entire history must be pushed here, not just one single message. + stream: true, + }, + { + headers: { "Content-Type": "application/json" }, + responseType: "stream", + } + ); - // Pipe the response stream through the JSONStreamService - const jsonStreamService = new JSONStreamService(); - response.data.pipe(jsonStreamService); + // Pipe the response stream through the JSONStreamService + const jsonStreamService = new JSONStreamService(); + response.data.pipe(jsonStreamService); // Builds and stores the assistant message as it is being streamed in - let assistantMessage = ""; - jsonStreamService.on("data", (data) => { - try { - const textData = data.toString().trim(); - if (textData === "[DONE]") return; // Ignore the [DONE] marker - const parsedData = JSON.parse(textData); - if (parsedData.fullContent) { - assistantMessage = parsedData.fullContent; + let assistantMessage = ""; + let previousFullContent = ""; + + jsonStreamService.on("data", (data) => { + try { + const textData = data.toString().trim(); + if (textData === "[DONE]") return; // Ignore the [DONE] marker + const parsedData = JSON.parse(textData); + if (parsedData.fullContent) { + const newFull = parsedData.fullContent; + const diff = newFull.substring(previousFullContent.length); + previousFullContent = newFull; + assistantMessage = newFull; + } + } catch (e) { + console.error("Error parsing streamed data:", e); } - } catch (e) { - console.error("Error parsing streamed data:", e); - } - }); + }); - // Stores the full assistant message in the conversation history array when stream ends - jsonStreamService.on("end", () => { - if (assistantMessage) { - assistantMessage = assistantMessage.replace(/[\s\S]*?<\/think>/g, "").trim(); - this.conversationHistory.push({ role: "assistant", content: assistantMessage }); - } - }); + // Stores the full assistant message in the conversation history array when stream ends + jsonStreamService.on("end", () => { + if (assistantMessage) { + const cleaned = assistantMessage.replace(/[\s\S]*?<\/think>/g, "").trim(); + this.conversationHistory.push({ role: "assistant", content: cleaned }); + } + }); - return jsonStreamService; + return jsonStreamService; + } } catch (error) { console.error("Chat API Error:", error); throw new Error("Error occurred while fetching response"); } } + validateQuizAnswer(prompt){ + let username = ""; + let userAnswer = prompt; + const colonIndex = prompt.indexOf(':'); + if (colonIndex > 0) { + username = prompt.substring(0, colonIndex).trim(); + userAnswer = prompt.substring(colonIndex+1).trim(); + } + if (this.isCorrectAnswer(userAnswer, this.correctAnswer)){ + if(this.lastAnsweredBy){ + return `That's correct, ${username}! But ${this.lastAnsweredby} already answered this question correctly. The answer is ${this.correctAnswer}. Type "next question" for another question.`; + }else{ + this.lastAnsweredBy = username; + return `Correct, ${username} gets 1 exp!. The answer was ${this.correctAnswer}. Type "next question" for another question.`; + } + }else{ + return `Sorry ${username}, that's not correct. Try again!`; + } + } + isCorrectAnswer(userAnswer, correctAnswer){ + if (!userAnswer || !correctAnswer) return false + + const normalUserAnswer = userAnswer.toLowerCase().trim(); + const normalCorrectAnswer = correctAnswer.toLowerCase().trim(); + if (normalUserAnswer.includes(normalCorrectAnswer) || normalCorrectAnswer.includes(normalUserAnswer)) return true; + return false + } } diff --git a/backend/nodejs/services/JSONStreamService.js b/backend/nodejs/services/JSONStreamService.js index 402cb3e..6102691 100644 --- a/backend/nodejs/services/JSONStreamService.js +++ b/backend/nodejs/services/JSONStreamService.js @@ -102,4 +102,4 @@ export default class JSONStreamService extends Transform { } return null; } -} \ No newline at end of file +} diff --git a/frontend/nodejs/public/images/AI Assistant.png b/frontend/nodejs/public/images/AI Assistant.png new file mode 100644 index 0000000..cb2b055 Binary files /dev/null and b/frontend/nodejs/public/images/AI Assistant.png differ diff --git a/frontend/nodejs/views/chat/partials/chatProcessing.ejs b/frontend/nodejs/views/chat/partials/chatProcessing.ejs index 48f64fb..c638609 100644 --- a/frontend/nodejs/views/chat/partials/chatProcessing.ejs +++ b/frontend/nodejs/views/chat/partials/chatProcessing.ejs @@ -1,24 +1,69 @@ \ No newline at end of file + diff --git a/frontend/nodejs/views/chat/partials/chatSettings.ejs b/frontend/nodejs/views/chat/partials/chatSettings.ejs index 10e5fbc..ec56343 100644 --- a/frontend/nodejs/views/chat/partials/chatSettings.ejs +++ b/frontend/nodejs/views/chat/partials/chatSettings.ejs @@ -16,6 +16,9 @@ +
@@ -27,10 +30,21 @@ Closed
+ +
+ Send AI output to: + + +
\ No newline at end of file + diff --git a/frontend/nodejs/views/chat/partials/chatStream.ejs b/frontend/nodejs/views/chat/partials/chatStream.ejs index 2952de3..b9cfc89 100644 --- a/frontend/nodejs/views/chat/partials/chatStream.ejs +++ b/frontend/nodejs/views/chat/partials/chatStream.ejs @@ -1,3 +1,10 @@ + + \ No newline at end of file + diff --git a/frontend/nodejs/views/notes/notesAI.ejs b/frontend/nodejs/views/notes/notesAI.ejs index cee6f06..7a1bf64 100644 --- a/frontend/nodejs/views/notes/notesAI.ejs +++ b/frontend/nodejs/views/notes/notesAI.ejs @@ -14,6 +14,7 @@
+ AI Assistant

Ai Assistant

<%- include('../chat/partials/chatSettings.ejs') %> @@ -24,6 +25,17 @@ + +
<%- include('../chat/partials/ui/chatUIMessages.ejs') %> <%- include('../chat/partials/ui/chatUIMarkup.ejs') %> @@ -36,3 +48,16 @@ <%- include('../chat/partials/chatProcessing.ejs') %> <%- include('../chat/partials/chatStream.ejs') %> + diff --git a/frontend/nodejs/views/notes/partials/components/editor.ejs b/frontend/nodejs/views/notes/partials/components/editor.ejs index 0e25c14..5965661 100644 --- a/frontend/nodejs/views/notes/partials/components/editor.ejs +++ b/frontend/nodejs/views/notes/partials/components/editor.ejs @@ -3,6 +3,7 @@ // Editor Setup // ----------------------------- const Editor = { + quill: null, init() { // Initialize Quill editor and modules Quill.register('modules/cursors', QuillCursors); @@ -21,90 +22,122 @@ setupEventListeners() { // Listen for text changes and broadcast updates this.quill.on('text-change', (delta, oldDelta, source) => { - if (source === 'user') { - State.changes.push(delta); - // Send user inactivity signal after 10s - Utils.timer('inactivity', WebSocketManager.sendInactivitySignal, 10000); - // Delay sending changes to the server for batching - Utils.timer('delay', () => { - let combinedChanges = new this.Delta(); - State.changes.forEach(change => { - combinedChanges = combinedChanges.compose(change); - }); - try { - const content = JSON.stringify(combinedChanges); - API.updateNote(content, State.currentID); - State.changes = []; - } catch (error) { - console.error(error); - } - }, 1000); - // Send live update via WebSocket if connected - WebSocketManager.sendUpdate(delta.ops); - } - }); + if (source !== 'user') return; + console.log("[editor] text-change fired"); + const enteredNewline = delta.ops.some(op => typeof op.insert === 'string' && op.insert.includes('\n')); + if (!enteredNewline) return; + setTimeout(() => { + console.log("[editor] Checking last line after Enter"); + const text = this.quill.getText().trim(); + const lines = text.split('\n').filter(line => line.trim() !== ''); + const lastLine = lines[lines.length - 1]; + console.log("[editor] Last line:", lastLine); + console.log("[editor] Current correct answer:", window.quizCorrectAnswer); - // Listen for cursor/selection changes and broadcast position - this.quill.on('selection-change', (range, oldRange, source) => { - if (source === 'user' && State.ws?.id) { - this.cursors.moveCursor(State.ws.id, range); - this.cursors.update(); - WebSocketManager.sendCursorPosition(State.ws.id, range); - } - }); - }, + const match = lastLine.match(/^(.+?):\s*(.+)$/); + console.log("[editor] Regex match result:", match); + if (match && window.quizCorrectAnswer) { + const username = match[1].trim(); + const userAnswer = match[2].trim(); - setContent(data) { - if (data.id && data.id.length > 0) { - State.currentID = data.id; + if (isCorrectAnswer(userAnswer, window.quizCorrectAnswer)) { + const message = `Correct, ${username}!`; + this.quill.insertText(this.quill.getLength(), `\n${message}\n`); + window.lastQuizAnsweredBy = username; + window.quizCorrectAnswer = null; + } else { + const message = `Sorry, ${username}, try again.`; + this.quill.insertText(this.quill.getLength(), `\n${message}\n`); + } + } + }, 50); + State.changes.push(delta); + // Send user inactivity signal after 10s + Utils.timer('inactivity', WebSocketManager.sendInactivitySignal, 10000); + // Delay sending changes to the server for batching + Utils.timer('delay', () => { + let combinedChanges = new this.Delta(); + State.changes.forEach(change => { + combinedChanges = combinedChanges.compose(change); + }); + try { + const content = JSON.stringify(combinedChanges); + API.updateNote(content, State.currentID); + State.changes = []; + } catch (error) { + console.error(error); + } + }, 1000); + // Send live update via WebSocket if connected + WebSocketManager.sendUpdate(delta.ops); + }); + // Listen for cursor/selection changes and broadcast position + this.quill.on('selection-change', (range, oldRange, source) => { + if (source === 'user' && State.ws?.id) { + this.cursors.moveCursor(State.ws.id, range); + this.cursors.update(); + WebSocketManager.sendCursorPosition(State.ws.id, range); } - this.quill.deleteText(0, this.quill.getLength()); - this.quill.setContents(JSON.parse(data.content)); - this.quill.enable(); - }, + }); + }, - setPreviewContent(data) { - this.quill.deleteText(0, this.quill.getLength()); - this.quill.setContents(JSON.parse(data.content)); - this.quill.disable(); - }, + setContent(data) { + if (data.id && data.id.length > 0) { + State.currentID = data.id; + } + this.quill.deleteText(0, this.quill.getLength()); + this.quill.setContents(JSON.parse(data.content)); + this.quill.enable(); + }, - clearOtherUserCursors() { - this.cursors.cursors().forEach(cursor => { - // Don't remove our own cursor - if (State.ws && cursor.id !== State.ws.id) { - this.cursors.removeCursor(cursor.id); - } - }); - }, + setPreviewContent(data) { + this.quill.deleteText(0, this.quill.getLength()); + this.quill.setContents(JSON.parse(data.content)); + this.quill.disable(); + }, - removeCursor(id) { - // Only remove if it exists and isn't ours - if (id && (!State.ws || id !== State.ws.id)) { - this.cursors.removeCursor(id); - console.log(`Removed cursor for user ${id}`); + clearOtherUserCursors() { + this.cursors.cursors().forEach(cursor => { + // Don't remove our own cursor + if (State.ws && cursor.id !== State.ws.id) { + this.cursors.removeCursor(cursor.id); } - }, + }); + }, - updateContentFromWS(delta) { - try { - this.quill.updateContents(delta); - this.quill.update(); - } catch (error) { - console.error('Error parsing message:', error); - } - }, + removeCursor(id) { + // Only remove if it exists and isn't ours + if (id && (!State.ws || id !== State.ws.id)) { + this.cursors.removeCursor(id); + console.log(`Removed cursor for user ${id}`); + } + }, - createCursor(id, name, color) { - if (!this.cursors.cursors().some(cursor => cursor.id === id)) { - this.cursors.createCursor(id, name, color); - } - }, + updateContentFromWS(delta) { + try { + this.quill.updateContents(delta); + this.quill.update(); + } catch (error) { + console.error('Error parsing message:', error); + } + }, - updateCursorPosition(id, range) { - this.createCursor(id, 'OtherUser', 'orange'); - this.cursors.moveCursor(id, range); - this.cursors.update(); + createCursor(id, name, color) { + if (!this.cursors.cursors().some(cursor => cursor.id === id)) { + this.cursors.createCursor(id, name, color); } - }; - \ No newline at end of file + }, + + updateCursorPosition(id, range) { + this.createCursor(id, 'OtherUser', 'orange'); + this.cursors.moveCursor(id, range); + this.cursors.update(); + } +}; +function isCorrectAnswer(userAnswer, correctAnswer) { + if (!userAnswer || !correctAnswer) return false; + const normUser = userAnswer.toLowerCase().trim(); + const normCorrect = correctAnswer.toLowerCase().trim(); + return normUser === normCorrect || normUser.includes(normCorrect) || normCorrect.includes(normUser); +} +