Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ A Copilot browser extension for the Overleaf website.
- Auto-Completion
- Text Enhancement with GPT
- Discover Related Content on arXiv
- Ask specific questions to GPT


---
Expand Down
1 change: 1 addition & 0 deletions config/webpack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const config = (env, argv) =>
https: false,
url: false,
timers: false,
string_decoder: false
},
},
});
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 15 additions & 0 deletions public/chatPanel.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<div id="copilot-side-panel">
<button id="btn-copilot-close" class="btn">Close</button>
<h5>Ask Copilot</h5>
<textarea id="copilot-gpt-question"></textarea>
<button id="btn-copilot-ask">Ask</button>
<div style="display: none">
<h5>GPT Response</h5>
<div style="margin: 10px 0">
<textarea id="copilot-gpt-response"></textarea>
</div>
<button id="btn-copilot-replace">Replace</button>
<button id="btn-copilot-regenerate" style="float: right">Regenerate</button>
</div>
</div>

18 changes: 15 additions & 3 deletions public/manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "Overleaf Copilot",
"version": "0.1.8",
"version": "0.1.9",
"description": "Copilot for Overleaf",
"icons": {
"16": "icons/icon_16.png",
Expand All @@ -14,6 +14,7 @@
"resources": [
"icons/icon_128.png",
"sidePanel.html",
"chatPanel.html",
"search.html"
],
"matches": [
Expand All @@ -29,7 +30,9 @@
"default_popup": "popup.html"
},
"permissions": [
"storage"
"storage",
"scripting",
"activeTab"
],
"content_scripts": [
{
Expand All @@ -54,5 +57,14 @@
"contentIsoScript.css"
]
}
]
],
"commands": {
"chat": {
"suggested_key": {
"default": "Ctrl+Shift+I",
"mac": "Command+Shift+I"
},
"description": "Prompt user to chat with Copilot"
}
}
}
10 changes: 10 additions & 0 deletions src/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,13 @@ chrome.runtime.onMessage.addListener(function (request) {
});
}
});

chrome.commands.onCommand.addListener((command) => {
if (command === "chat") {
chrome.tabs.query({ active: true, currentWindow: true }, function (tabs) {
if (tabs && tabs[0] && tabs[0].id) {
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: tabs?[0]?.id

return chrome.tabs.sendMessage(tabs[0].id, { type: "chat:open" });
}
});
}
});
101 changes: 101 additions & 0 deletions src/chatPanel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
'use strict';

import { EditorContent } from "./types";
import { AskQuestion } from "./utils/chat";

export async function LoadChatPanel(
selection?: string,
from?: number,
to?: number
) {
const rightContainer = document.querySelector(
'.ide-react-panel[data-panel-id="panel-pdf"]'
) as HTMLElement;
if (!rightContainer) return;

const sidePanel = document.createElement('div');
const sidePanelUrl = chrome.runtime.getURL('chatPanel.html');
sidePanel.innerHTML = await (await fetch(sidePanelUrl)).text();

const questionTxt = sidePanel.querySelector(
'#copilot-gpt-question'
) as HTMLInputElement;

const askBtn = sidePanel.querySelector(
'#btn-copilot-ask'
) as HTMLButtonElement;
askBtn.onclick = async () => {
askBtn.disabled = true;
await generate(questionTxt.value, selection, sidePanel);
askBtn.disabled = false;
};

const regenerateBtn = sidePanel.querySelector(
'#btn-copilot-regenerate'
) as HTMLButtonElement;
regenerateBtn.onclick = async () => {
regenerateBtn.disabled = true;
await generate(questionTxt.value, selection, sidePanel);
regenerateBtn.disabled = false;
};

const replaceBtn = sidePanel.querySelector(
'#btn-copilot-replace'
) as HTMLButtonElement;
if (!selection) {
// Replace is "insert"
replaceBtn.textContent = 'Insert';
}

replaceBtn.onclick = () => {
const responseTxt = (
sidePanel.querySelector('#copilot-gpt-response') as HTMLInputElement
).value;

if (selection) {
window.dispatchEvent(
new CustomEvent('copilot:editor:replace', {
detail: {
improvement: responseTxt,
from: from,
to: to,
},
})
);
} else {
window.dispatchEvent(
new CustomEvent('copilot:editor:append', {
detail: {
content: responseTxt,
},
})
);
}
document.getElementById('copilot-side-panel')?.remove();
};

const closeBtn = sidePanel.querySelector(
'#btn-copilot-close'
) as HTMLButtonElement;
closeBtn.onclick = () =>
document.getElementById('copilot-side-panel')?.remove();
rightContainer.appendChild(sidePanel);

// Focus on the question input
questionTxt.focus();

return;
}

async function generate(question: string, selection: string | undefined, sidePanel: HTMLElement) {
const textarea = sidePanel.querySelector(
'#copilot-gpt-response'
) as HTMLInputElement;
textarea.disabled = true;
const improvement = await AskQuestion(question, selection);
textarea.value = improvement;
textarea.parentElement!.parentElement!.style.display = 'block';
textarea.style.height = 'auto';
textarea.style.height = `${Math.min(textarea.scrollHeight + 2, 200)}px`;
textarea.disabled = false;
}
3 changes: 2 additions & 1 deletion src/contentIsoScript.css
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,13 @@ p#copilot-original-content {
border-radius: 2px;
}

textarea#copilot-gpt-response {
textarea#copilot-gpt-response, textarea#copilot-gpt-question {
resize: none;
width: 100%;
padding: 5px 10px;
}


button#btn-copilot-close {
float: right;
margin-top: 7px;
Expand Down
19 changes: 19 additions & 0 deletions src/contentIsoScript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,14 @@ import {
CONFIG_DISABLE_IMPROVEMENT,
CONFIG_MAX_PROMPT_WORDS,
} from './constants';
import { LoadChatPanel } from './chatPanel';

let cursorPos: { row: number; column: number } | null = null;
let currentSelection: {
selection?: string;
from?: number;
to?: number;
} = {};

function debounce<
T extends (
Expand Down Expand Up @@ -52,6 +58,9 @@ async function onEditorUpdate(
col: number,
signal: AbortSignal
) {
// Delete selection
currentSelection = {};

const config = await chrome.storage.local.get([CONFIG_DISABLE_COMPLETION]);
if (!!config[CONFIG_DISABLE_COMPLETION]) return;

Expand Down Expand Up @@ -110,6 +119,9 @@ async function onEditorSelect(
head: number;
}>
) {
// Save current selection
currentSelection = event.detail;

const config = await chrome.storage.local.get([CONFIG_DISABLE_IMPROVEMENT]);

if (!!config[CONFIG_DISABLE_IMPROVEMENT]) return;
Expand Down Expand Up @@ -165,4 +177,11 @@ window.addEventListener('load', onConfigUpdate);

chrome.runtime.onMessage.addListener(async function (request) {
if (request.type === 'config:update') await onConfigUpdate();
if (request.type === 'chat:open') {

await LoadChatPanel(
currentSelection.selection,
currentSelection.from,
currentSelection.to)
};
});
15 changes: 14 additions & 1 deletion src/contentMainScript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ function debounce<T extends () => void>(func: T): () => void {
document.getElementById('copilot-sidebar-button')?.remove();
document.getElementById('copilot-suggestion')?.remove();
document.getElementById('copilot-side-panel')?.remove();
document.getElementById('copilot-chat-panel')?.remove();

if (timeout) clearTimeout(timeout);

Expand Down Expand Up @@ -104,6 +105,17 @@ function onConfigUpdate(e: CustomEvent<{ [CONFIG_MAX_PROMPT_WORDS]: number }>) {
maxPromptWords = e.detail[CONFIG_MAX_PROMPT_WORDS] || maxPromptWords;
}

function onEditorAppend(
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: let's use "insert", since it's not always adding content to the end of the document.

e: CustomEvent<{ content: string }>
) {
var editor = document.querySelector('.cm-content');
if (!editor) return;
const content = editor as any as EditorContent;
const currentPos = content.cmView.view.state.selection.main.head;
const changes = { from: currentPos, to: currentPos, insert: e.detail.content };
content.cmView.view.dispatch({ changes });
}

function onAcceptImprovement(
e: CustomEvent<{ improvement: string; from: number; to: number }>
) {
Expand All @@ -130,6 +142,7 @@ window.addEventListener(
onAcceptImprovement as EventListener
);
window.addEventListener('cursor:editor:update', debounce(onCursorUpdate));
window.addEventListener('copilot:editor:append', onEditorAppend as EventListener);
window.addEventListener(
'copilot:config:update',
onConfigUpdate as EventListener
Expand All @@ -147,4 +160,4 @@ const setupKeydownListener = (n: number) => {
return true;
};

setupKeydownListener(10);
setupKeydownListener(10);
58 changes: 58 additions & 0 deletions src/utils/chat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
'use strict';

import OpenAI from 'openai';
import {
CONFIG_API_KEY,
CONFIG_BASE_URL,
CONFIG_IMPROVEMENT_CUSTOM_PROMPT,
CONFIG_MODEL,
DEFAULT_MODEL,
} from '../constants';

export async function AskQuestion(question: string, selection?: string) {
const config = await chrome.storage.local.get([
CONFIG_API_KEY,
CONFIG_BASE_URL,
CONFIG_MODEL,
CONFIG_IMPROVEMENT_CUSTOM_PROMPT,
]);

if (!config[CONFIG_API_KEY]) return '';

const openai = new OpenAI({
apiKey: config[CONFIG_API_KEY],
baseURL: config[CONFIG_BASE_URL] || undefined,
dangerouslyAllowBrowser: true,
});

const completion = await openai.chat.completions.create({
messages: [
{
role: 'user',
content: buildAskPrompt(
question,
selection
),
},
],
model: config[CONFIG_MODEL] || DEFAULT_MODEL,
});

return completion.choices[0].message.content?.trim() ?? '';
}

function buildAskPrompt(question: string, selection?: string) {
if (selection) {
return (
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this prompt seems a bit unclear to me, would it be more intuitive to ask GPT to answer the question based on the selection?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The issue is that the reply is often:

"Sure thing! In order to solve xxxx you write:"

Where the answer includes a human response and explanation instead of simply giving the answer

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hence why the prompt is forced to only provide the output

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This part makes sense to me. I'm a bit confused about how the user is expected to use this feature. I think when a user selects some text, they may want to:
1 rewrite the selection maybe with some specific instructions, or
2 ask a question regarding the selection
It looks like the prompt tries to do 1 but it also tries to answer a question.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I aim to get an improvement/latex as a response rather than an explanation. When selecting text it aims to answer the question with the selection as context.
Other than the existing "improvement" button in this extension the user can ask more specific improvements such as "make this shorter", "change the color of the headings in this table to red",...

When not selecting text it just tries to answer the question with a response that can be inserted into the paper.

So your 1. point is correct. Answering questions about a selection is not my aim with this PR.

Github copilot solves this ambiguity using commands such as /explain and /fix that indicate what you want to do with a selection. Future changes could incorporate something similar.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

... Or we could add a "improve" and "explain" button?

`Rewrite and improve the following content:\n` + `${selection}\n\n` +
`Making sure to maintain semantic continuity, the content should adhere to the following question:\n` +
`${question}`
);
} else {
return (
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no content from the paper is included in the prompt right?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, currently it only includes the question

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this expected? GPT doesn't have the context to continue the "academic paper" in the Overleaf editor.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes and no. In my examples I usually selected something and asked a question to modify that selected text. Or I asked for non-contextual question such as "create a table to do this or that" which did not require input about the paper. But I agree it could be useful to add context.

`Continue the academic paper in LaTeX below by providing a solution to the following question.\n` +
`Only provide the output that has to be written to answer the question:\n\n` +
`${question}`
);
}
}