Initial commit

This commit is contained in:
Achiya Elyasaf
2023-07-31 15:00:05 +03:00
commit 06d7c3af5c
16 changed files with 776 additions and 0 deletions

94
.gitignore vendored Normal file
View File

@@ -0,0 +1,94 @@
# Created by https://www.toptal.com/developers/gitignore/api/intellij+all
# Edit at https://www.toptal.com/developers/gitignore?templates=intellij+all
### Intellij+all ###
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# User-specific stuff
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/**/usage.statistics.xml
.idea/**/dictionaries
.idea/**/shelf
# AWS User-specific
.idea/**/aws.xml
# Generated files
.idea/**/contentModel.xml
# Sensitive or high-churn files
.idea/**/dataSources/
.idea/**/dataSources.ids
.idea/**/dataSources.local.xml
.idea/**/sqlDataSources.xml
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
.idea/**/dbnavigator.xml
# Gradle
.idea/**/gradle.xml
.idea/**/libraries
# Gradle and Maven with auto-import
# When using Gradle or Maven with auto-import, you should exclude module files,
# since they will be recreated, and may cause churn. Uncomment if using
# auto-import.
# .idea/artifacts
# .idea/compiler.xml
# .idea/jarRepositories.xml
# .idea/modules.xml
# .idea/*.iml
# .idea/modules
# *.iml
# *.ipr
# CMake
cmake-build-*/
# Mongo Explorer plugin
.idea/**/mongoSettings.xml
# File-based project format
*.iws
# IntelliJ
out/
# mpeltonen/sbt-idea plugin
.idea_modules/
# JIRA plugin
atlassian-ide-plugin.xml
# Cursive Clojure plugin
.idea/replstate.xml
# SonarLint plugin
.idea/sonarlint/
# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
# Editor-based Rest Client
.idea/httpRequests
# Android studio 3.1+ serialized cache file
.idea/caches/build_file_checksums.ser
### Intellij+all Patch ###
# Ignore everything but code style settings and run configurations
# that are supposed to be shared within teams.
.idea/*
!.idea/codeStyles
!.idea/runConfigurations
# End of https://www.toptal.com/developers/gitignore/api/intellij+all
LeafLLM*.zip

70
README.md Normal file
View File

@@ -0,0 +1,70 @@
# LeafLLM: an AI-powered Overleaf
This Chrome extension adds the power of large-language models (LLMs) to Overleaf through a Chrome extension.
The extension originated from [GPT4Overleaf](https://github.com/e3ntity/gpt4overleaf).
## Manual installation
1. Clone the repository
2. Open Chrome and go to `chrome://extensions/`
3. Enable developer mode
4. Click "Load unpacked" and select the repository folder
## Configuration
The plugin can be configured by clicking the plugin button in the Chrome toolbar. It requires inserting an API key from [OpenAI](https://platform.openai.com/account/api-keys). You also need to choose which tools you wish to enable.
## Usage
These are the tools that are currently available:
### Auto-complete
Select a text and press `Alt+c` to trigger the auto-complete tool.
### Improve
Select a text and press `Alt+i` to trigger the improvement tool. The original text will be commented out and the improved text will be inserted below it.
### Ask
Select a text and press `Alt+a` to trigger the ask tool. The original text will be deleted and the answer will be inserted in its place.
For example: "Create a table 4x3 that the first row is bold face" will be replaced with, e.g.,:
```latex
\begin{tabular}{|c|c|c|}
\hline
\textbf{Column 1} & \textbf{Column 2} & \textbf{Column 3}\\
\hline
Entry 1 & Entry 2 & Entry 3\\
\hline
Entry 4 & Entry 5 & Entry 6\\
\hline
Entry 7 & Entry 8 & Entry 9\\
\hline
\end{tabular}
```
You can then, for example:
1. Write before the table: Place the following tabular inside a table environment, center it, and give the following title: "The comparison of the three approaches"
2. Select the sentence and the table
3. Press `Alt+a` to trigger the ask tool.
The result will be:
```latex
\begin{table}[h]
\centering
\caption{The comparison of the three approaches}
\begin{tabular}{|c|c|c|}
\hline
\textbf{Column 1} & \textbf{Column 2} & \textbf{Column 3}\\
\hline
Entry 1 & Entry 2 & Entry 3\\
\hline
Entry 4 & Entry 5 & Entry 6\\
\hline
Entry 7 & Entry 8 & Entry 9\\
\hline
\end{tabular}
\end{table}
```
## Issues
If you encounter any issues, please open an issue in the project's repository.
## Privacy
The plugin does not collect any data. The only data that is sent is the text that you select and the API key that you provide. The data is sent to OpenAI's servers only.

22
deploy.ps1 Normal file
View File

@@ -0,0 +1,22 @@
# Define the output ZIP file name
$zipFileName = "LeafLLM.zip"
# Get the current directory path
$currentDirectory = Get-Location
# Delete the ZIP file if it already exists
if (Test-Path $zipFileName) {
Remove-Item $zipFileName -Force
Write-Host "Existing ZIP file '$zipFileName' deleted."
}
# Create an array of directories and files to exclude from the ZIP
$excludeItems = @(".idea", ".git", ".gitignore", "deploy.sh", "deploy.ps1")
# Get the files and folders in the current directory excluding the specified items
$itemsToZip = Get-ChildItem -Path $currentDirectory -Exclude $excludeItems
# Compress the items to a ZIP archive
Compress-Archive -Path $itemsToZip.FullName -DestinationPath $zipFileName
Write-Host "ZIP file created: $zipFileName"

3
deploy.sh Normal file
View File

@@ -0,0 +1,3 @@
echo "Packaging extension for submission to the Chrome Web Store."
zip -r LeafLLM.zip ./* -x .git/**\* -x .idea/**\* -x .gitignore -x deploy.sh -x README.md

46
manifest.json Normal file
View File

@@ -0,0 +1,46 @@
{
"action": {
"default_popup": "popup/popup.html",
"default_icon": "popup/icon.png"
},
"content_scripts": [
{
"js": ["scripts/jquery.js", "scripts/content.js"],
"matches": ["https://*.overleaf.com/project/*"]
}
],
"description": "LLM-based tools for Overleaf",
"icons": {
"16": "popup/icon_16.png",
"48": "popup/icon_48.png",
"128": "popup/icon_128.png"
},
"commands": {
"Complete": {
"suggested_key": {
"default": "Alt+C"
},
"description": "Complete selected text"
},
"Improve": {
"suggested_key": {
"default": "Alt+I"
},
"description": "Improve selected text"
},
"Ask": {
"suggested_key": {
"default": "Alt+A"
},
"description": "Use the selected text to ask GPT. It adds to the beginning of the selected text: 'In Latex, '"
}
},
"background": {
"service_worker": "scripts/service-worker.js"
},
"permissions": ["storage", "tabs"],
"manifest_version": 3,
"name": "LeafLLM",
"homepage_url": "https://github.com/achiyae/LeafLLM",
"version": "1.0.0"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
popup/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

BIN
popup/icon_128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
popup/icon_16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

BIN
popup/icon_48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

175
popup/popup.css Normal file
View File

@@ -0,0 +1,175 @@
body {
font-family: Arial, sans-serif;
background-color: #f5f5f5;
margin: 0;
padding: 20px;
}
.extension-title {
font-size: 24px;
color: #4a4a4a;
margin-bottom: 20px;
}
.form-container {
background-color: #fff;
border-radius: 5px;
margin: 10px 0;
padding: 20px;
box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.1);
}
.api-token-text {
font-size: 16px;
color: #4a4a4a;
margin-bottom: 10px;
}
.api-token-status {
font-weight: bold;
}
.api-token-input {
width: 100%;
padding: 8px;
font-size: 14px;
border: 1px solid #ccc;
border-radius: 4px;
margin-bottom: 10px;
}
.btn {
font-size: 14px;
padding: 8px 16px;
background-color: #4a4a4a;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
margin: 5px 0;
transition: background-color 0.3s;
width: 300px;
}
.btn:hover {
background-color: #333;
}
.message-box {
font-size: 14px;
padding: 20px;
color: #4a4a4a;
}
.message-box .error {
color: #ff0000;
}
.message-box .message {
color: #4a4a4a;
}
#controls {
margin-top: 30px;
display: flex;
flex-direction: column;
}
.control {
display: flex;
justify-content: space-between;
margin-bottom: 10px;
}
.input {
display: flex;
align-items: center;
font-weight: bold;
color: #4a4a4a;
}
.input span:first-child {
margin-right: 5px;
}
.effect {
font-size: 14px;
color: #4a4a4a;
}
.settings-form {
margin-top: 30px;
}
.settings-text {
font-size: 18px;
color: #4a4a4a;
margin-bottom: 20px;
}
.settings-container {
display: flex;
flex-direction: column;
}
.settings-item {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.settings-item-text {
font-size: 16px;
color: #4a4a4a;
}
.switch {
position: relative;
display: inline-block;
width: 60px;
height: 34px;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
transition: 0.4s;
}
.slider:before {
position: absolute;
content: "";
height: 26px;
width: 26px;
left: 4px;
bottom: 4px;
background-color: white;
transition: 0.4s;
}
input:checked + .slider {
background-color: #4a4a4a;
}
input:checked + .slider:before {
transform: translateX(26px);
}
.slider.round {
border-radius: 34px;
}
.slider.round:before {
border-radius: 50%;
}

58
popup/popup.html Normal file
View File

@@ -0,0 +1,58 @@
<html>
<head>
<script src="/scripts/jquery.js"></script>
<script src="/popup/popup.js"></script>
<link rel="stylesheet" href="/popup/popup.css" />
</head>
<body>
<h1 class="extension-title">LeafLLM: an AI-powered Overleaf</h1>
<div id="controls" class="form-container">
<div class="control">
<div class="input"><span>ALT</span><span>I</span></div>
<div class="effect">Improve selected text.</div>
</div>
<div class="control">
<div class="input"><span>ALT</span><span>C</span></div>
<div class="effect">Complete selected text.</div>
</div>
<div class="control">
<div class="input"><span>ALT</span><span>A</span></div>
<div class="effect">Ask GPT.</div>
</div>
</div>
<div id="settings-form" class="form-container">
<div class="settings-text">Settings</div>
<div class="settings-container">
<div class="settings-item">
<div class="settings-item-text">Text Improvement</div>
<label class="switch">
<input name="text-improvement" class="settings-item-checkbox" type="checkbox" />
<span class="slider round"></span>
</label>
</div>
<div class="settings-item">
<div class="settings-item-text">Text Completion</div>
<label class="switch">
<input name="text-completion" class="settings-item-checkbox" type="checkbox" />
<span class="slider round"></span>
</label>
</div>
<div class="settings-item">
<div class="settings-item-text">Ask</div>
<label class="switch">
<input name="text-ask" class="settings-item-checkbox" type="checkbox" />
<span class="slider round"></span>
</label>
</div>
</div>
</div>
<div id="api-token-form" class="form-container">
<div class="api-token-text"><span>API Token is </span><span class="api-token-status">not set</span></div>
<input class="api-token-input" name="api-token" placeholder="OpenAI API Token" type="text" />
<div class="message">To get an API key, go to <a href="https://platform.openai.com/account/api-keys">https://platform.openai.com/account/api-keys</a> </div>
<button class="submit btn">Set API Token</button>
<button class="clear btn">Reset API Token</button>
</div>
<div id="message-box" class="message-box"></div>
</body>
</html>

105
popup/popup.js Normal file
View File

@@ -0,0 +1,105 @@
const apiKeyRegex = /sk-[a-zA-Z0-9]{48}/;
const settings = [
{ key: "textCompletion", name: "text-completion" },
{ key: "textImprovement", name: "text-improvement" },
{ key: "textAsk", name: "text-ask" },
];
function addMessage(message) {
$("#message-box").append(`<div class="message">${message}</div>`);
}
function addErrorMessage(message) {
$("#message-box").append(`<div class="error">${message}</div>`);
}
function clearMessages() {
$("#message-box").empty();
}
async function refreshStorage() {
chrome.storage.local.get("openAIAPIKey").then(({ openAIAPIKey }) => {
$("#api-token-form .api-token-status").text(chrome.runtime.lastError || !openAIAPIKey ? "not set" : "set");
});
chrome.storage.local.get(settings.map(({ key }) => key)).then((storage) => {
settings.forEach(({ key, name }) => {
$(`#settings-form input[name='${name}']:checkbox`).prop("checked", storage[key]);
});
});
}
async function handleAPITokenSet(event) {
event.preventDefault();
event.stopPropagation();
clearMessages();
const input = $("#api-token-form").find("input[name='api-token']");
const openAIAPIKey = input.val();
input.val("");
if (!openAIAPIKey || !apiKeyRegex.test(openAIAPIKey)) {
addErrorMessage("Invalid API Token.");
return;
}
try {
await chrome.storage.local.set({ openAIAPIKey });
} catch (error) {
console.log(error);
addErrorMessage("Failed to set API Token.");
return;
}
await refreshStorage();
}
async function handleAPITokenClear(event) {
event.preventDefault();
event.stopPropagation();
clearMessages();
try {
await chrome.storage.local.remove("openAIAPIKey");
} catch (error) {
console.log(error);
addErrorMessage("Failed to remove API Token.");
return;
}
await refreshStorage();
}
function makeHandleSettingChange(key) {
return async (event) => {
event.preventDefault();
event.stopPropagation();
clearMessages();
const value = event.target.checked;
try {
await chrome.storage.local.set({ [key]: value });
} catch (error) {
console.log(error);
addErrorMessage(`Failed to set ${key} setting.`);
return;
}
await refreshStorage();
};
}
$(document).ready(async function () {
$("#api-token-form .submit").on("click", handleAPITokenSet);
$("#api-token-form .clear").on("click", handleAPITokenClear);
settings.forEach(({ key, name }) =>
$(`#settings-form input[name='${name}']:checkbox`).on("change", makeHandleSettingChange(key))
);
await refreshStorage();
});

169
scripts/content.js Normal file
View File

@@ -0,0 +1,169 @@
class OpenAIAPI {
static defaultModel = 'text-davinci-003'
constructor(apiKey) {
this.apiKey = apiKey
}
query(endpoint, data) {
return new Promise((resolve, reject) => {
const url = `https://api.openai.com/v1/${endpoint}`
if (!data.model) data.model = OpenAIAPI.defaultModel
const xhr = new XMLHttpRequest()
xhr.open('POST', url, true)
xhr.setRequestHeader('Content-Type', 'application/json')
xhr.setRequestHeader('Authorization', `Bearer ${this.apiKey}`)
xhr.onreadystatechange = function () {
if (xhr.readyState !== 4) return
if (xhr.status !== 200) return reject('Failed to query OpenAI API.')
const jsonResponse = JSON.parse(xhr.responseText)
if (!jsonResponse.choices) return reject('Failed to query OpenAI API.')
return resolve(jsonResponse.choices)
}
xhr.send(JSON.stringify(data))
})
}
async completeText(text) {
const data = {
max_tokens: 512,
prompt: text,
n: 1,
temperature: 0.5
}
const result = await this.query('completions', data)
return result[0].text
}
async improveText(text) {
const data = {
model: 'code-davinci-edit-001',
input: text,
instruction:
'Correct any spelling mistakes, grammar mistakes, and improve the overall style of the (latex) text.',
n: 1,
temperature: 0.5
}
const result = await this.query('edits', data)
return result[0].text
}
}
function replaceSelectedText(replacementText, selection) {
const sel = selection === undefined ? window.getSelection() : selection
if (sel.rangeCount) {
const range = sel.getRangeAt(0)
range.deleteContents()
range.insertNode(document.createTextNode(replacementText))
}
}
async function settingIsEnabled(setting) {
let result
try {
result = await chrome.storage.local.get(setting)
} catch (error) {
return false
}
return result[setting]
}
function commentText(text) {
const regexPattern = /\n/g
const replacementString = '\n%'
let comment = text.replace(regexPattern, replacementString)
if (!comment.startsWith('%')) {
comment = '%' + comment
}
return comment
}
async function improveTextHandler(openAI) {
if (!(await settingIsEnabled('textImprovement'))) return
const selection = window.getSelection()
const selectedText = selection.toString()
if (!selectedText) return
const editedText = await openAI.improveText(selectedText)
const commentedText = commentText(selectedText)
replaceSelectedText(commentedText + '\n' + editedText, selection)
}
async function completeTextHandler(openAI) {
if (!(await settingIsEnabled('textCompletion'))) return
const selection = window.getSelection()
const selectedText = selection.toString()
if (!selectedText) return
const editedText = await openAI.completeText(selectedText)
replaceSelectedText(selectedText + editedText, selection)
}
async function askHandler(openAI) {
if (!(await settingIsEnabled('textAsk'))) return
const selection = window.getSelection()
const selectedText = selection.toString()
if (!selectedText) return
const editedText = await openAI.completeText('In Latex, ' + selectedText)
replaceSelectedText(editedText, selection)
}
let currentAPIKey
let openAI = undefined
function cleanup() {
}
function setAPIKey(key) {
if (currentAPIKey === key) return
cleanup()
currentAPIKey = key
if (currentAPIKey) {
openAI = new OpenAIAPI(currentAPIKey)
console.log('AI4Overleaf: OpenAI API key set, enabling AI4Overleaf features.')
} else {
openAI = undefined
console.log('AI4Overleaf: OpenAI API key is not set, AI4Overleaf features are disabled.')
}
}
function handleCommand(command) {
console.log('Handling command')
if (command === 'Improve') {
improveTextHandler(openAI)
} else if (command === 'Complete') {
completeTextHandler(openAI)
} else if (command === 'Ask') {
askHandler(openAI)
}
}
chrome.runtime.onMessage.addListener(
function (request, sender, sendResponse) {
console.log(`Received request: ${JSON.stringify(request)}`)
if (request.command === 'setup') {
setAPIKey(request.apiKey)
} else {
if (!openAI) {
chrome.storage.local.get('openAIAPIKey').then(({ openAIAPIKey }) => {
setAPIKey(openAIAPIKey)
if (openAI) {
handleCommand(request.command)
}
})
} else {
handleCommand(request.command)
}
}
}
)

2
scripts/jquery.js vendored Normal file

File diff suppressed because one or more lines are too long

32
scripts/service-worker.js Normal file
View File

@@ -0,0 +1,32 @@
async function sendMessage(message) {
const [tab] = await chrome.tabs.query({ active: true, lastFocusedWindow: true })
if (tab == null || tab.url?.startsWith('chrome://')) return undefined
const response = await chrome.tabs.sendMessage(tab.id, message)
// do something with response here, not outside the function
// console.log(response)
}
function addListener(commandName) {
chrome.commands.onCommand.addListener((command) => {
if (command !== commandName) return
console.log(`Command ${command} triggered`)
sendMessage({ command: command })
})
}
async function setup() {
addListener('Improve')
addListener('Complete')
addListener('Ask')
/*const [tab] = await chrome.tabs.query({ active: true, lastFocusedWindow: true })
if (tab?.url?.startsWith('chrome://')) return undefined
console.log('tab'+tab)
chrome.scripting.executeScript({
target: { tabId: tab.id },
files: ['scripts/content.js']
}).then(() => {
})*/
}
setup()