diff --git a/src/core/code-listener.ts b/src/core/code-listener.ts index ed4ced2..896dab3 100644 --- a/src/core/code-listener.ts +++ b/src/core/code-listener.ts @@ -17,7 +17,7 @@ const listeners: Listener[] = []; function codeListener(config: Config) { document.body.addEventListener("keydown", function (event) { pressedKeys.push(event.key); - if (pressedKeys.length > config.code.length) pressedKeys.shift(); + if (pressedKeys.length > config.code!.length) pressedKeys.shift(); if (pressedKeys.join("") === config.code) { pressedKeys.length = 0; setUpMoodleGpt(config); @@ -25,51 +25,6 @@ function codeListener(config: Config) { }); } -/** - * Setup moodleGPT into the page (remove/injection) - * @param config - * @returns - */ -function setUpMoodleGpt(config: Config) { - /* Removing events */ - if (listeners.length > 0) { - for (const listener of listeners) { - if (config.cursor) listener.element.style.cursor = "initial"; - listener.element.removeEventListener("click", listener.fn); - } - if (config.title) titleIndications("Removed"); - listeners.length = 0; - return; - } - - /* Code injection */ - const inputQuery = ["checkbox", "radio", "text", "number"] - .map((e) => `input[type="${e}"]`) - .join(","); - const query = inputQuery + ", textarea, select, [contenteditable]"; - const forms = document.querySelectorAll(".formulation"); - - for (const form of forms) { - const hiddenButton: HTMLElement = form.querySelector(".qtext"); - - if (!hiddenButton) continue; - - if (config.cursor) hiddenButton.style.cursor = "pointer"; - - const injectionFunction = reply.bind( - null, - config, - hiddenButton, - form, - query - ); - listeners.push({ element: hiddenButton, fn: injectionFunction }); - hiddenButton.addEventListener("click", injectionFunction); - } - - if (config.title) titleIndications("Injected"); -} - /** * Remove the event listener on a specific question * @param element @@ -82,4 +37,51 @@ function removeListener(element: HTMLElement) { } } +/** + * Setup moodleGPT into the page (remove/injection) + * @param config + * @returns + */ +function setUpMoodleGpt(config: Config) { + // Removing events if there are already declared + if (listeners.length > 0) { + for (const listener of listeners) { + if (config.cursor) listener.element.style.cursor = "initial"; + listener.element.removeEventListener("click", listener.fn); + } + if (config.title) titleIndications("Removed"); + listeners.length = 0; + return; + } + + // Query to find inputs and forms + const inputTypeQuery = ["checkbox", "radio", "text", "number"] + .map((e) => `input[type="${e}"]`) + .join(","); + const inputQuery = inputTypeQuery + ", textarea, select, [contenteditable]"; + const forms = document.querySelectorAll(".formulation"); + + // For each form we inject a function on the queqtion + for (const form of forms) { + const questionElement: HTMLElement | null = form.querySelector(".qtext"); + + if (questionElement === null) continue; + + if (config.cursor) questionElement.style.cursor = "pointer"; + + const injectionFunction = reply.bind(null, { + config, + questionElement, + form: form as HTMLElement, + inputQuery, + removeListener: () => removeListener(questionElement), + }); + + listeners.push({ element: questionElement, fn: injectionFunction }); + questionElement.addEventListener("click", injectionFunction); + } + + if (config.title) titleIndications("Injected"); +} + export { codeListener, removeListener, setUpMoodleGpt }; diff --git a/src/core/create-question.ts b/src/core/create-question.ts index 9dee9f8..fbd68a5 100644 --- a/src/core/create-question.ts +++ b/src/core/create-question.ts @@ -1,24 +1,23 @@ -import type Config from "@typing/config"; import normalizeText from "@utils/normalize-text"; import htmlTableToString from "@utils/html-table-to-string"; /** - * Normalize the question and add sub informations + * Normalize the question as text and add sub informations * @param langage * @param question * @returns */ -function createQuestion(config: Config, questionContainer: HTMLElement) { +function createAndNormalizeQuestion(questionContainer: HTMLElement) { let question = questionContainer.innerText; - /* We remove unnecessary information */ + // We remove unnecessary information const accesshideElements: NodeListOf = questionContainer.querySelectorAll(".accesshide"); for (const useless of accesshideElements) { question = question.replace(useless.innerText, ""); } - /* Make tables more readable for chat-gpt */ + // Make tables more readable for chat-gpt const tables: NodeListOf = questionContainer.querySelectorAll(".qtext table"); for (const table of tables) { @@ -31,4 +30,4 @@ function createQuestion(config: Config, questionContainer: HTMLElement) { return normalizeText(question, false); } -export default createQuestion; +export default createAndNormalizeQuestion; diff --git a/src/core/get-response.ts b/src/core/get-response.ts index c94009b..007e154 100644 --- a/src/core/get-response.ts +++ b/src/core/get-response.ts @@ -2,6 +2,41 @@ import type Config from "@typing/config"; import type GPTAnswer from "@typing/gptAnswer"; import normalizeText from "@utils/normalize-text"; +type History = { + url: string | null; + system: { role: ROLE; content: string }; + history: { role: ROLE; content: string }[]; +}; + +enum ROLE { + SYSTEM = "system", + USER = "user", + ASSISTANT = "assistant", +} + +const INSTRUCTION: string = ` +Act as a quiz solver for the best notation with the following rules: +- When asked for the result of an equation, provide only the result without any other information and skip the other rules. +- If no answer(s) are given, answer the statement as usual without following the other rules, providing the most detailed, complete and precise explanation. +- For 'put in order' questions, provide the position of the answer separated by a new line (e.g., '1\n3\n2') and ignore other rules.- Always reply in this format: '\n\n...' +- Always reply in the format: '\n\n...'. +- Retain only the correct answer(s). +- Maintain the same order for the answers as in the text. +- Retain all text from the answer with its description, content or definition. +- Only provide answers that exactly match the given answer in the text. +- The question always has the correct answer(s), so you should always provide an answer. +- Always respond in the same language as the user's question. +`.trim(); + +const history: History = { + url: null, + system: { + role: ROLE.SYSTEM, + content: INSTRUCTION, + }, + history: [], +}; + /** * Get the response from chatGPT api * @param config @@ -12,8 +47,19 @@ async function getChatGPTResponse( config: Config, question: string ): Promise { + const URL = location.hostname + location.pathname; + + // We reset the history when we enter a new moodle quiz or when it's desactivate + if (!config.history || history.url !== URL) { + history.url = URL; + history.history = []; + } + const controller = new AbortController(); - const timeoutControler = setTimeout(() => controller.abort(), 15000); + const timeoutControler = setTimeout(() => controller.abort(), 15 * 1000); + + const message = { role: ROLE.USER, content: question }; + const req = await fetch("https://api.openai.com/v1/chat/completions", { method: "POST", headers: { @@ -23,33 +69,27 @@ async function getChatGPTResponse( signal: config.timeout ? controller.signal : null, body: JSON.stringify({ model: config.model, - messages: [ - { - role: "system", - content: ` -Follow those rules: -- Sometimes there won't be a question, so just answer the statement as you normally would without following the other rules and give the most detailled and complete answer with explication. -- For put in order question just give the good order separate by new line -- Your goal is to understand the statement and to reply to each question by giving only the answer. -- You will keep the same order for the answers like in the text. -- You will separate all the answer with new lines and only show the correctes one. -- You will only give the answers for each question and omit the questions, statement, title or other informations from the response. -- You will only give answer with exactly the same text as the gived answers. -- The question always have the good answer so you should always give an answer to the question. -- You will always respond in the same langage as the user question.`, - }, - { role: "user", content: question }, - ], + messages: [history.system, ...history.history, message], temperature: 0.8, top_p: 1.0, presence_penalty: 1.0, stop: null, }), }); + clearTimeout(timeoutControler); + const rep = await req.json(); const response = rep.choices[0].message.content; + + // Register the conversation + if (config.history) { + history.history.push(message); + history.history.push({ role: ROLE.ASSISTANT, content: response }); + } + return { + question, response, normalizedResponse: normalizeText(response), }; diff --git a/src/core/modes/autocomplete.ts b/src/core/modes/autocomplete.ts new file mode 100644 index 0000000..d3f384a --- /dev/null +++ b/src/core/modes/autocomplete.ts @@ -0,0 +1,45 @@ +import type GPTAnswer from "@typing/gptAnswer"; +import type Config from "@typing/config"; +import handleClipboard from "@core/questions/clipboard"; +import handleContentEditable from "@core/questions/contenteditable"; +import handleNumber from "@core/questions/number"; +import handleRadio from "@core/questions/radio"; +import handleCheckbox from "@core/questions/checkbox"; +import handleSelect from "@core/questions/select"; +import handleTextbox from "@core/questions/textbox"; + +type Props = { + config: Config; + questionElement: HTMLElement; + inputList: NodeListOf; + gptAnswer: GPTAnswer; + removeListener: () => void; +}; + +/** + * Autocomplete mode: + * Autocomplete the question by checking the good answer + * @param props + * @returns + */ +function autoCompleteMode(props: Props) { + if (!props.config.infinite) props.removeListener(); + + const handlers = [ + handleContentEditable, + handleTextbox, + handleNumber, + handleSelect, + handleRadio, + handleCheckbox, + ]; + + for (const handler of handlers) { + if (handler(props.config, props.inputList, props.gptAnswer)) return; + } + + // In the case we can't auto complete the question + handleClipboard(props.config, props.gptAnswer); +} + +export default autoCompleteMode; diff --git a/src/core/modes/clipboard.ts b/src/core/modes/clipboard.ts new file mode 100644 index 0000000..0c37151 --- /dev/null +++ b/src/core/modes/clipboard.ts @@ -0,0 +1,22 @@ +import type Config from "@typing/config"; +import type GPTAnswer from "@typing/gptAnswer"; +import handleClipboard from "@core/questions/clipboard"; + +type Props = { + config: Config; + questionElement: HTMLElement; + gptAnswer: GPTAnswer; + removeListener: () => void; +}; + +/** + * Clipboard mode: + * Simply copy the answer into the clipboard + * @param props + */ +function clipboardMode(props: Props) { + if (!props.config.infinite) props.removeListener(); + handleClipboard(props.config, props.gptAnswer); +} + +export default clipboardMode; diff --git a/src/core/modes/question-to-answer.ts b/src/core/modes/question-to-answer.ts new file mode 100644 index 0000000..21bb904 --- /dev/null +++ b/src/core/modes/question-to-answer.ts @@ -0,0 +1,36 @@ +import type GPTAnswer from "@typing/gptAnswer"; + +type Props = { + questionElement: HTMLElement; + gptAnswer: GPTAnswer; + removeListener: () => void; +}; + +/** + * Question to answer mode: + * Simply turn the question into the answer by clicking on it + * @param props + */ +function questionToAnswerMode(props: Props) { + const questionElement = props.questionElement; + + props.removeListener(); + + const questionBackup = questionElement.textContent; + + questionElement.textContent = props.gptAnswer.response; + + // Format the content + questionElement.style.whiteSpace = "pre-wrap"; + + // To go back to the question / answer + let contentIsResponse = true; + questionElement.addEventListener("click", function () { + questionElement.style.whiteSpace = contentIsResponse ? "" : "pre-warp"; + questionElement.textContent = contentIsResponse + ? questionBackup + : props.gptAnswer.response; + }); +} + +export default questionToAnswerMode; diff --git a/src/core/questions/checkbox.ts b/src/core/questions/checkbox.ts new file mode 100644 index 0000000..ac2e152 --- /dev/null +++ b/src/core/questions/checkbox.ts @@ -0,0 +1,58 @@ +import type Config from "@typing/config"; +import type GPTAnswer from "@typing/gptAnswer"; +import Logs from "@utils/logs"; +import normalizeText from "@utils/normalize-text"; +import { pickBestReponse, toPourcentage } from "@utils/pick-best-response"; + +/** + * Handle input checkbox elements + * @param config + * @param inputList + * @param gptAnswer + */ +function handleCheckbox( + config: Config, + inputList: NodeListOf, + gptAnswer: GPTAnswer +): boolean { + const firstInput = inputList?.[0] as HTMLInputElement; + + // Handle the case the input is not a checkbox + if (!firstInput || firstInput.type !== "checkbox") { + return false; + } + + const corrects = gptAnswer.normalizedResponse.split("\n"); + + const possibleAnswers = Array.from(inputList) + .map((inp) => ({ + element: inp, + value: normalizeText(inp?.parentElement?.textContent ?? ""), + })) + .filter((obj) => obj.value !== ""); + + for (const correct of corrects) { + const bestAnswer = pickBestReponse(correct, possibleAnswers); + + if (config.logs && bestAnswer.value) { + Logs.bestAnswer(bestAnswer.value, bestAnswer.similarity); + } + + const correctInput = bestAnswer.element as HTMLInputElement; + if (config.mouseover) { + correctInput.addEventListener( + "mouseover", + () => (correctInput.checked = true), + { + once: true, + } + ); + } else { + correctInput.checked = true; + } + } + + return true; +} + +export default handleCheckbox; diff --git a/src/core/questions/contenteditable.ts b/src/core/questions/contenteditable.ts index 1f3174b..742d68b 100644 --- a/src/core/questions/contenteditable.ts +++ b/src/core/questions/contenteditable.ts @@ -16,10 +16,11 @@ function handleContentEditable( const input = inputList[0]; if ( - inputList.length !== 1 || + inputList.length !== 1 || // for now we don't handle many input for editable textcontent input.getAttribute("contenteditable") !== "true" - ) + ) { return false; + } if (config.typing) { let index = 0; @@ -29,14 +30,16 @@ function handleContentEditable( event.preventDefault(); input.textContent = gptAnswer.response.slice(0, ++index); - /* Put the cursor at the end of the typed text */ + // Put the cursor at the end of the typed text input.focus(); const range = document.createRange(); range.selectNodeContents(input); range.collapse(false); const selection = window.getSelection(); - selection.removeAllRanges(); - selection.addRange(range); + if (selection !== null) { + selection.removeAllRanges(); + selection.addRange(range); + } }); } else { input.textContent = gptAnswer.response; diff --git a/src/core/questions/number.ts b/src/core/questions/number.ts index 3d49d11..d86d153 100644 --- a/src/core/questions/number.ts +++ b/src/core/questions/number.ts @@ -15,20 +15,25 @@ function handleNumber( ): boolean { const input = inputList[0] as HTMLInputElement | HTMLTextAreaElement; - if (inputList.length !== 1 || input.type !== "number") return false; + if ( + inputList.length !== 1 || // for now we don't handle many input number + input.type !== "number" + ) { + return false; + } const number = gptAnswer.normalizedResponse .match(/\d+([,\.]\d+)?/gi)?.[0] ?.replace(",", "."); - if (!number) return false; + if (number === undefined) return false; if (config.typing) { let index = 0; - input.addEventListener("keydown", function (event: KeyboardEvent) { - if (event.key === "Backspace") index = number.length + 1; - if (index > number.length) return; + input.addEventListener("keydown", function (event: Event) { event.preventDefault(); + if ((event).key === "Backspace") index = number.length + 1; + if (index > number.length) return; if (number.slice(index, index + 1) === ".") ++index; input.value = number.slice(0, ++index); }); diff --git a/src/core/questions/radio-checkbox.ts b/src/core/questions/radio-checkbox.ts deleted file mode 100644 index 14a6909..0000000 --- a/src/core/questions/radio-checkbox.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type Config from "@typing/config"; -import type GPTAnswer from "@typing/gptAnswer"; -import Logs from "@utils/logs"; -import normalizeText from "@utils/normalize-text"; - -/** - * Handle checkbox and input elements - * @param config - * @param inputList - * @param gptAnswer - */ -function handleRadioAndCheckbox( - config: Config, - inputList: NodeListOf, - gptAnswer: GPTAnswer -): boolean { - const input = inputList?.[0] as HTMLInputElement; - - if (!input || (input.type !== "checkbox" && input.type !== "radio")) - return false; - - for (const input of inputList as NodeListOf) { - const content = normalizeText(input.parentNode.textContent); - const valide = gptAnswer.normalizedResponse.includes(content); - if (config.logs) Logs.responseTry(content, valide); - if (valide) { - if (config.mouseover) { - input.addEventListener("mouseover", () => (input.checked = true), { - once: true, - }); - } else { - input.checked = true; - } - } - } - return true; -} - -export default handleRadioAndCheckbox; diff --git a/src/core/questions/radio.ts b/src/core/questions/radio.ts new file mode 100644 index 0000000..ad8ab4f --- /dev/null +++ b/src/core/questions/radio.ts @@ -0,0 +1,57 @@ +import type Config from "@typing/config"; +import type GPTAnswer from "@typing/gptAnswer"; +import Logs from "@utils/logs"; +import normalizeText from "@utils/normalize-text"; +import { pickBestReponse, toPourcentage } from "@utils/pick-best-response"; + +/** + * Handle input radio elements + * @param config + * @param inputList + * @param gptAnswer + */ +function handleRadio( + config: Config, + inputList: NodeListOf, + gptAnswer: GPTAnswer +): boolean { + const firstInput = inputList?.[0] as HTMLInputElement; + + // Handle the case the input is not a radio + if (!firstInput || firstInput.type !== "radio") { + return false; + } + + const possibleAnswers = Array.from(inputList) + .map((inp) => ({ + element: inp, + value: normalizeText(inp?.parentElement?.textContent ?? ""), + })) + .filter((obj) => obj.value !== ""); + + const bestAnswer = pickBestReponse( + gptAnswer.normalizedResponse, + possibleAnswers + ); + + if (config.logs && bestAnswer.value) { + Logs.bestAnswer(bestAnswer.value, bestAnswer.similarity); + } + + const correctInput = bestAnswer.element as HTMLInputElement; + if (config.mouseover) { + correctInput.addEventListener( + "mouseover", + () => (correctInput.checked = true), + { + once: true, + } + ); + } else { + correctInput.checked = true; + } + + return true; +} + +export default handleRadio; diff --git a/src/core/questions/select.ts b/src/core/questions/select.ts index c4c94cc..6c5c3b3 100644 --- a/src/core/questions/select.ts +++ b/src/core/questions/select.ts @@ -2,6 +2,7 @@ import type Config from "@typing/config"; import type GPTAnswer from "@typing/gptAnswer"; import Logs from "@utils/logs"; import normalizeText from "@utils/normalize-text"; +import { pickBestReponse, toPourcentage } from "@utils/pick-best-response"; /** * Handle select elements (and put in order select) @@ -17,81 +18,43 @@ function handleSelect( ): boolean { if (inputList.length === 0 || inputList[0].tagName !== "SELECT") return false; - let correct = gptAnswer.normalizedResponse.split("\n"); + const corrects = gptAnswer.normalizedResponse.split("\n"); - if (config.logs) Logs.array(correct); + if (config.logs) Logs.array(corrects); - /** - * Sometimes ChatGPT give the question so we should remove them - * Example: - * 5*5 - * 25 - * 10+10 - * 20 - * 20-10 - * 10 - * - * And we only want to keep answers - * 25 - * 20 - * 10 - */ - if (correct.length === inputList.length * 2) { - correct = correct.filter((answer, index) => index % 2 === 1); - } + for (let i = 0; i < inputList.length; ++i) { + if (!corrects[i]) break; - for (let j = 0; j < inputList.length; ++j) { - const options = inputList[j].querySelectorAll("option"); + const options = inputList[i].querySelectorAll("option"); - for (const option of options) { - const content = normalizeText(option.textContent); - const valide = correct[j].includes(content); + const possibleAnswers = Array.from(options) + .map((opt) => ({ + element: opt, + value: normalizeText(opt.textContent ?? ""), + })) + .filter((obj) => obj.value !== ""); - /* Handle put in order question */ - if (!/[^\d]+/gi.test(content)) { - const elementTitle = (option.parentNode as HTMLElement) - .closest("tr") - .querySelector(".text"); - const content = normalizeText(elementTitle.textContent); + const bestAnswer = pickBestReponse(corrects[i], possibleAnswers); - const indexCorrectAnswer = correct.findIndex((answer) => { - const valide = answer.includes(content); - if (config.logs) Logs.responseTry(content, valide); - return valide; - }); + if (config.logs && bestAnswer.value) { + Logs.bestAnswer(bestAnswer.value, bestAnswer.similarity); + } - if (indexCorrectAnswer !== -1) { - //we do + 1 because we skip the first option: Choose... - if (config.mouseover) { - options[indexCorrectAnswer + 1].closest("select").addEventListener( - "click", - function () { - options[indexCorrectAnswer + 1].selected = "selected" as any; - }, - { once: true } - ); - } else { - options[indexCorrectAnswer + 1].selected = "selected" as any; - } - break; + const correctOption = bestAnswer.element as HTMLOptionElement; + const currentSelect = correctOption.closest("select"); + + if (currentSelect === null) continue; + + if (config.mouseover) { + currentSelect.addEventListener( + "click", + () => (correctOption.selected = true), + { + once: true, } - } - /* End */ - - if (config.logs) Logs.responseTry(content, valide); - - if (valide) { - if (config.mouseover) { - option - .closest("select") - .addEventListener("click", () => (option.selected = true), { - once: true, - }); - } else { - option.selected = true; - } - break; - } + ); + } else { + correctOption.selected = true; } } diff --git a/src/core/questions/textbox.ts b/src/core/questions/textbox.ts index ddbb4a8..b817fa0 100644 --- a/src/core/questions/textbox.ts +++ b/src/core/questions/textbox.ts @@ -16,17 +16,20 @@ function handleTextbox( const input = inputList[0] as HTMLInputElement | HTMLTextAreaElement; if ( - inputList.length !== 1 || + inputList.length !== 1 || // for now we don't handle many input text (input.tagName !== "TEXTAREA" && input.type !== "text") - ) + ) { return false; + } if (config.typing) { let index = 0; - input.addEventListener("keydown", function (event: KeyboardEvent) { - if (event.key === "Backspace") index = gptAnswer.response.length + 1; - if (index > gptAnswer.response.length) return; + input.addEventListener("keydown", function (event: Event) { event.preventDefault(); + if ((event).key === "Backspace") { + index = gptAnswer.response.length + 1; + } + if (index > gptAnswer.response.length) return; input.value = gptAnswer.response.slice(0, ++index); }); } else { diff --git a/src/core/reply.ts b/src/core/reply.ts index b598977..f7aba91 100644 --- a/src/core/reply.ts +++ b/src/core/reply.ts @@ -1,35 +1,33 @@ import type Config from "@typing/config"; import Logs from "@utils/logs"; import getChatGPTResponse from "./get-response"; -import createQuestion from "./create-question"; -import handleRadioAndCheckbox from "./questions/radio-checkbox"; -import handleSelect from "./questions/select"; -import handleTextbox from "./questions/textbox"; -import handleClipboard from "./questions/clipboard"; -import handleNumber from "./questions/number"; -import handleContentEditable from "./questions/contenteditable"; -import { removeListener } from "./code-listener"; +import createAndNormalizeQuestion from "./create-question"; +import clipboardMode from "./modes/clipboard"; +import questionToAnswerMode from "./modes/question-to-answer"; +import autoCompleteMode from "./modes/autocomplete"; + +type Props = { + config: Config; + questionElement: HTMLElement; + form: HTMLElement; + inputQuery: string; + removeListener: () => void; +}; /** * Reply to the question - * @param config - * @param hiddenButton - * @param form - * @param query + * @param props * @returns */ -async function reply( - config: Config, - hiddenButton: HTMLElement, - form: HTMLElement, - query: string -) { - if (config.cursor) hiddenButton.style.cursor = "wait"; +async function reply(props: Props): Promise { + if (props.config.cursor) props.questionElement.style.cursor = "wait"; - const question = createQuestion(config, form); - const inputList: NodeListOf = form.querySelectorAll(query); + const question = createAndNormalizeQuestion(props.form); + const inputList: NodeListOf = props.form.querySelectorAll( + props.inputQuery + ); - const gptAnswer = await getChatGPTResponse(config, question).catch( + const gptAnswer = await getChatGPTResponse(props.config, question).catch( (error) => ({ error, }) @@ -37,63 +35,47 @@ async function reply( const haveError = typeof gptAnswer === "object" && "error" in gptAnswer; - if (config.cursor) - hiddenButton.style.cursor = - config.infinite || haveError ? "pointer" : "initial"; + if (props.config.cursor) { + props.questionElement.style.cursor = + props.config.infinite || haveError ? "pointer" : "initial"; + } if (haveError) { console.error(gptAnswer.error); return; } - if (config.logs) { + if (props.config.logs) { Logs.question(question); Logs.response(gptAnswer); } - /* Handle clipboard mode */ - if (config.mode === "clipboard") { - if (!config.infinite) removeListener(hiddenButton); - return handleClipboard(config, gptAnswer); + switch (props.config.mode) { + case "clipboard": + clipboardMode({ + config: props.config, + questionElement: props.questionElement, + gptAnswer, + removeListener: props.removeListener, + }); + break; + case "question-to-answer": + questionToAnswerMode({ + gptAnswer, + questionElement: props.questionElement, + removeListener: props.removeListener, + }); + break; + case "autocomplete": + autoCompleteMode({ + config: props.config, + gptAnswer, + inputList, + questionElement: props.questionElement, + removeListener: props.removeListener, + }); + break; } - - /* Handle question to answer mode */ - if (config.mode === "question-to-answer") { - removeListener(hiddenButton); - - const questionContainer = form.querySelector(".qtext"); - const questionBackup = questionContainer.textContent; - - questionContainer.textContent = gptAnswer.response; - questionContainer.style.whiteSpace = "pre-wrap"; - - questionContainer.addEventListener("click", function () { - const isNotResponse = questionContainer.textContent === questionBackup; - questionContainer.style.whiteSpace = isNotResponse ? "pre-wrap" : null; - questionContainer.textContent = isNotResponse - ? gptAnswer.response - : questionBackup; - }); - return; - } - - /* Better then set once on the event because if there is an error the user can click an other time on the question */ - if (!config.infinite) removeListener(hiddenButton); - - const handlers = [ - handleContentEditable, - handleTextbox, - handleNumber, - handleSelect, - handleRadioAndCheckbox, - ]; - - for (const handler of handlers) { - if (handler(config, inputList, gptAnswer)) return; - } - - /* In the case we can't auto complete the question */ - handleClipboard(config, gptAnswer); } export default reply; diff --git a/src/types/config.ts b/src/types/config.ts index b252f39..c317e68 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -9,6 +9,7 @@ type Config = { logs?: boolean; title?: boolean; timeout?: boolean; + history?: boolean; mode?: "autocomplete" | "question-to-answer" | "clipboard"; }; diff --git a/src/types/gptAnswer.ts b/src/types/gptAnswer.ts index 4742310..32a7694 100644 --- a/src/types/gptAnswer.ts +++ b/src/types/gptAnswer.ts @@ -1,4 +1,5 @@ type GPTAnswer = { + question: string; response: string; normalizedResponse: string; }; diff --git a/src/utils/html-table-to-string.ts b/src/utils/html-table-to-string.ts index 0d23c7c..c06ae6d 100644 --- a/src/utils/html-table-to-string.ts +++ b/src/utils/html-table-to-string.ts @@ -13,9 +13,9 @@ function htmlTableToString(table: HTMLTableElement) { const content = cell.textContent?.trim(); maxColumnsLength[index] = Math.max( maxColumnsLength[index] || 0, - content.length || 0 + content?.length || 0 ); - return content; + return content ?? ""; }); tab.push(cellsContent); }); @@ -29,7 +29,7 @@ function htmlTableToString(table: HTMLTableElement) { const mappedLine = line.map((content, index) => content.padEnd( maxColumnsLength[index], - "\u00A0" /* For no matching with \s */ + "\u00A0" // For no matching with \s ) ); return "| " + mappedLine.join(" | ") + " |"; diff --git a/src/utils/logs.ts b/src/utils/logs.ts index 5648f5b..0483ddb 100644 --- a/src/utils/logs.ts +++ b/src/utils/logs.ts @@ -1,4 +1,5 @@ import GPTAnswer from "@typing/gptAnswer"; +import { toPourcentage } from "./pick-best-response"; class Logs { static question(text: string) { @@ -6,9 +7,13 @@ class Logs { console.log("%c[QUESTION]: %s", css, text); } - static responseTry(text: string, valide: boolean) { - const css = "color: " + (valide ? "green" : "red"); - console.log("%c[CHECKING]: %s", css, text); + static bestAnswer(answer: string, similarity: number) { + const css = "color: green"; + console.log( + "%c[BEST ANSWER]: %s", + css, + `"${answer}" with a similarity of ${toPourcentage(similarity)}` + ); } static array(arr: unknown[]) { diff --git a/src/utils/normalize-text.ts b/src/utils/normalize-text.ts index dd314d3..40d0a83 100644 --- a/src/utils/normalize-text.ts +++ b/src/utils/normalize-text.ts @@ -3,20 +3,18 @@ * @param text */ function normalizeText(text: string, toLowerCase: boolean = true) { + if (toLowerCase) text = text.toLowerCase(); + let normalizedText = text .replace(/\n+/gi, "\n") //remove duplicate new lines - .replace(/(\n\s*\n)+/g, "\n") //remove useless white sapce from textcontent - .replace(/[ \t]+/gi, " "); //replace multiples space or tabs by a space + .replace(/(\n\s*\n)+/g, "\n") //remove useless white space from textcontent + .replace(/[ \t]+/gi, " ") //replace multiples space or tabs by a space + .trim() + // We remove the following content because sometimes ChatGPT will reply: "answer d" + .replace(/^[a-z\d]\.\s/gi, "") //a. text, b. text, c. text, 1. text, 2. text, 3.text + .replace(/\n[a-z\d]\.\s/gi, "\n"); //same but with new line - if (toLowerCase) normalizedText = normalizedText.toLowerCase(); - - return ( - normalizedText - .trim() - /* We remove that because sometimes ChatGPT will reply: "answer d" */ - .replace(/^[a-z\d]\.\s/gi, "") //a. text, b. text, c. text, 1. text, 2. text, 3.text - .replace(/\n[a-z\d]\.\s/gi, "\n") //same but with new line - ); + return normalizedText; } export default normalizeText; diff --git a/src/utils/pick-best-response.ts b/src/utils/pick-best-response.ts new file mode 100644 index 0000000..7956419 --- /dev/null +++ b/src/utils/pick-best-response.ts @@ -0,0 +1,115 @@ +type BestResponse = { + similarity: number; + value: string | null; + element: HTMLElement | null; +}; + +type ResponsesBySimilarity = { + similarity: number; + value: string; + element: HTMLElement; +}; + +/** + * Calculate the levenshtein distance between two sentence + * @param str1 + * @param str2 + * @returns + */ +function levenshteinDistance(str1: string, str2: string) { + if (str1.length === 0) return str2.length; + if (str2.length === 0) return str1.length; + + const matrix: number[][] = []; + const str1WithoutSpaces = str1.replace(/\s+/, ""); + const str2WithoutSpaces = str2.replace(/\s+/, ""); + + for (let i = 0; i <= str1WithoutSpaces.length; ++i) { + matrix.push([i]); + for (let j = 1; j <= str2WithoutSpaces.length; ++j) { + matrix[i][j] = + i === 0 + ? j + : Math.min( + matrix[i - 1][j] + 1, + matrix[i][j - 1] + 1, + matrix[i - 1][j - 1] + + (str1WithoutSpaces[i - 1] === str2WithoutSpaces[j - 1] ? 0 : 1) + ); + } + } + + return matrix[str1WithoutSpaces.length][str2WithoutSpaces.length]; +} + +/** + * Calculate the similarity between two sentences from 0 to 1 (best) + * @param str1 + * @param str2 + * @returns + */ +function sentenceSimilarity(str1: string, str2: string) { + const longerLength = str1.length > str2.length ? str1.length : str2.length; + if (longerLength === 0) return 1; + return (longerLength - levenshteinDistance(str1, str2)) / longerLength; +} + +/** + * Pick the best sentence that correspond to the answer + * @param arr + * @param answer + * @returns + */ +export function pickBestReponse( + answer: string, + arr: { element: HTMLElement; value: string }[] +): BestResponse { + let bestResponse: BestResponse = { + element: null, + similarity: 0, + value: null, + }; + for (const obj of arr) { + const similarity = sentenceSimilarity(obj.value, answer); + if (similarity === 1) { + return { element: obj.element, value: obj.value, similarity }; + } + if (similarity > bestResponse.similarity) { + bestResponse = { element: obj.element, value: obj.value, similarity }; + } + } + return bestResponse; +} + +/** + * Return the sentences sorted by score with a score superior or equal to what is asked + * @param answer + * @param arr + * @param score + * @returns + */ +export function pickResponsesWithSimilarityGreaterThan( + answer: string, + arr: { element: HTMLElement; value: string }[], + score: number +): ResponsesBySimilarity[] { + const responses: ResponsesBySimilarity[] = []; + for (const obj of arr) { + const similarity = sentenceSimilarity(obj.value, answer); + if (similarity >= score) + responses.push({ + similarity, + value: obj.value, + element: obj.element, + }); + } + return responses.sort((a, b) => a.similarity - b.similarity); +} + +/** + * Convert a number to a readable string pourcentage + * @param similarity + */ +export function toPourcentage(similarity: number): string { + return Math.round(similarity * 100 * 100) / 100 + "%"; +}