Jump to content

User:Phlsph7/WikiChatbot.js

From Wikipedia, the free encyclopedia
This is an old revision of this page, as edited by Phlsph7 (talk | contribs) at 15:26, 11 July 2023 (fix text selection). The present address (URL) is a permanent link to this revision, which may differ significantly from the current revision.
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(){
	// define constants
	const tokenLimit = 4096;
	const temperature = 0.5;
	const model = 'gpt-3.5-turbo';
	const charLimit = tokenLimit * 5; // rough estimate
	const articleContextLimit = charLimit * 0.1;
	const historyLimit = charLimit * 0.2;
	const selectionLimit = charLimit * 0.25;
	const promptLimit = charLimit * 0.25;
	const backgroundColor = '#def';
	const backgroundColorUser = '#ddd';
	const backgroundColorAI = '#dfd';
	const backgroundColorError = '#faa';
	const messages = getInitialMessages();

	// declare for later references
	const bodyContent = document.getElementById('bodyContent');
	let controlContainer;
	let chatContainer;
	let chatLog;
	let chatSend;
	
	// 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) {
		createControlUI();
		createChatUI();
		logAIMessage('How can I assist you? <br>(Please scrutinize all my responses before making changes to the article. See <a href="https://en.wikipedia.org/wiki/Wikipedia:Large_language_models">WP:LLM</a> for more information.)');
		
		// add a link to the toolbox
		$.when(mw.loader.using('mediawiki.util'), $.ready).then(addPortletAndActivate);
	}

	function getInitialMessages(){
		return [
		{"role":"system", "content": `You are a Wikipedia AI assistant. You help users with the Wikipedia article "${getTitle()}". User can select the text they wish to work on.`},
		{"role":"user","content": `I need help in reviewing and improving a Wikipedia article. So you know the context, I'll give an excerpt from the lead section of the article.
		
		Context:"""${getArticleIntroduction()}"""
		
		`},
		{"role":"assistant","content": "Thank you, I will use this information as context. How can I help you?"}
		];
	}

	function createControlUI(){
		controlContainer = document.createElement('div');
		if(localStorage.getItem('AIAssistantActivated') === '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.flexDirection = 'column';
		controlContainer.style.padding = '10px';
		controlContainer.style.borderRadius = '10px';
		controlContainer.style.whiteSpace = 'nowrap';
		controlContainer.style.alignItems = 'center';
		controlContainer.style.zIndex = '999';
		
		addButtons();
		
		function addButtons(){
			addControlButton('Copyedit', 'Copyedit the selected text', getQueryFunction(charLimit * 0.5, function(){
				return `Copyedit the following text:

text: """${getSelectedText()}"""`;
			}));

			addControlButton('Reformulate', 'Reformulate the selected text', getQueryFunction(charLimit * 0.5, function(){
				return `Reformulate the following text: 

text: """${getSelectedText()}"""`;
			}));

			addControlButton('Simplify', 'Simplify the selected text', getQueryFunction(charLimit * 0.5, function(){
				return `Simplify the following text:

text: """${getSelectedText()}"""`;
			}));

			addControlButton('Regular summary', 'Provide a regular summary of the selected text', getQueryFunction(charLimit * 0.5, function(){
				return `Provide a summary to reduce the length of the following text: 

text: """${getSelectedText()}"""`;
			}));

			addControlButton('Short summary', 'Provide a short summary of the selected text', getQueryFunction(charLimit * 0.5, function(){
				return `Provide a very short summary to greatly reduce the length of the following text: 

text: """${getSelectedText()}"""`;
			}));

			addControlButton('Check spelling/grammar', 'Assess the spelling and grammar of the selected text', getQueryFunction(charLimit * 0.5, function(){
				return `Does the following text have problems with spelling or grammar?

text: """${getSelectedText()}"""`;
			}));

			addControlButton('Is it true?', 'Assess whether the selected text is factually correct', getQueryFunction(charLimit * 0.5, function(){
				return `Is the following text factually correct or does it contain false claims?

text: """${getSelectedText()}"""`;
			}));

			addControlButton('Is it biased?', 'Assess whether the selected text is biased', getQueryFunction(charLimit * 0.5, function(){
				return `Does the following text present a neutral point of view without editorial bias? 

text: """${getSelectedText()}"""`;
			}));

			addControlButton('Is this source reliable?', 'Select one or several sources in the reference section to assess their reliablity', getQueryFunction(charLimit * 0.5, function(){
				return `Wikipedia has strict guidelines on what sources are generally considered to be reliable. Please give a rough estimation: which of the following sources could be unreliable? 

sources: """${getSelectedText()}"""`;
			}));

			addControlButton('Explain', 'Explain the selected text', getQueryFunction(charLimit * 0.5, function(){
				return `Please explain the following text to me:

text: """${getSelectedText()}"""`;
			}));

			addControlButton('Suggest expansion', 'Suggest ideas how the selected text could be expanded', getQueryFunction(charLimit * 0.5, function(){
				return `Suggest ideas how the following text could be expanded: 
				
text: """${getSelectedText()}"""`;
			}));

			addControlButton('Suggest images', 'Suggest which images could be used to illustrate the selected text', getQueryFunction(charLimit * 0.5, function(){
				return `Describe some images that could be used to illustrate the following text: 
				
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 following text should have a wikilink to another Wikipedia article? 
				
text: """${getSelectedText()}"""`;
			}));

			addControlLine();

			addControlButton('Write new article', 'Writes a new article on the topic of this article. Ignores the contents of the article and the selected text. Beware of hallucinations!', async function(){ // jshint ignore:line
				let userMessageText = `Write a detailed 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();

			addControlButton('Set API key', 'Enter the OpenAI API key required for usage', function(){
				let currentAPIKey = localStorage.getItem('AIAssistantAPIKey');
				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('AIAssistantAPIKey', input);
				}
			}); 
		}
		
		function addControlButton(heading, tooltip, clickFunction){
			let button = document.createElement('button');
			controlContainer.appendChild(button);
			button.innerHTML = heading;
			button.title = tooltip;
			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.onclick = clickFunction;
		}

		function addControlLine(){
			const borderLine = document.createElement('div');
			controlContainer.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 createChatUI(){
		chatContainer = document.createElement('div');
		if(localStorage.getItem('AIAssistantActivated') === '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 question/comand 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();
			console.log(storedSelection);
		};

		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 promptLimit = charLimit * 0.25;
			
			let selectedText = storedSelection;
			storedSelection = '';
			let selectedLength = storedSelection.length;
			let selectedLimit = charLimit * 0.25;
			if(promptLength > promptLimit){
				logErrorMessage(`The prompt text was too long: ${promptLength} characters were entered but the limit is ${promptLimit} characters.`);
			}
			else if(selectedLength > selectedLimit){
				logErrorMessage(`The selected text was too long: ${selectedText.length} characters were selected but the limit is ${selectedTextLimit} 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));
				console.log(messages);
				logUserMessage(promptText);
				getResponse(messages);
			}
		};
	}

	async function getResponse(messages){ // jshint ignore:line
		disableButtons();
		
		const approximateRemainingTokens = tokenLimit - Math.floor(getMessagesLength(messages) / 3);
		const url = "https://api.openai.com/v1/chat/completions";
		const body = JSON.stringify({
			"messages": messages,
			"model": model,
			"temperature": temperature,
			"max_tokens": approximateRemainingTokens,
		});
		const headers = {
			"content-type": "application/json",
			Authorization: "Bearer " + localStorage.getItem('AIAssistantAPIKey'),
		};
		const init = {
			method: "POST",
			body: body,
			headers: headers
		};
		
		console.log(messages);
		
		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);
					console.log(messages);
					logAIMessage(message.content);
				});
			}
			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 an incorrect API key was used.'));
				}
				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 = controlContainer.getElementsByTagName('button');
		for(let controlButton of controlButtons){
			controlButton.disabled = true;
		}
	}
	
	function enableButtons(){
		chatSend.disabled = false;
		let controlButtons = controlContainer.getElementsByTagName('button');
		for(let controlButton of controlButtons){
			controlButton.disabled = false;
		}
	}

	function getArticleIntroduction(){
		let paragraphs = document.querySelectorAll('.mw-parser-output > p');
		let innerText = '';
		hideRefs();
		for(let paragraph of paragraphs){
			innerText += paragraph.innerText;
			if(innerText.length > articleContextLimit){
				break;
			}
		}
		showRefs();
		articleIntroduction = innerText.substring(0, articleContextLimit);
		return articleIntroduction;
	}

	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 <= 3){
				break;
			}
			messages.splice(3, 1);
		}
	}

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

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

	function logAIMessage(text){
		logMessage("AI: " + text, backgroundColorAI, '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 AI Assistant', 'portletlinkActivateId');
		portletlinkActivate.onclick = function(e) {
			e.preventDefault();
			activate();
		};
		
		// portlet link to deactivate
		const portletlinkDeactivate = mw.util.addPortletLink('p-tb', '#', 'Deactivate AI Assistant', 'portletlinkDeactivateId');
		portletlinkDeactivate.onclick = function(e) {
			e.preventDefault();
			deactivate();
		};
		
		if(localStorage.getItem('AIAssistantActivated') === null){
			localStorage.setItem('AIAssistantActivated', 'false');
		}
		
		if(localStorage.getItem('AIAssistantActivated') === 'true'){
			activate();
		}
		
		else{
			deactivate();
		}

		function activate(){
			localStorage.setItem('AIAssistantActivated', 'true');
			mw.util.hidePortlet('portletlinkActivateId');
			mw.util.showPortlet('portletlinkDeactivateId');
			controlContainer.style.display = 'flex';
			chatContainer.style.display = '';
			
		}

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