diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 982c6af6..e1622143 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,7 +4,7 @@ Welcome! 👋 This is a guide on how to contribute to Void. We want to make it a There are two main ways to contribute: -- Suggest New Features ([discord](https://discord.gg/4GAxHVAD)) +- Suggest New Features ([discord](https://discord.gg/RSNjgaugJs)) - Build New Features ([project](https://github.com/orgs/voideditor/projects/2/views/3)) We use a [VSCode extension](https://code.visualstudio.com/api/get-started/your-first-extension) to implement most of Void's functionality. Scroll down to see 1. How to build/contribute to the Extension, or 2. How to build/contribute to the full IDE (for more native changes). @@ -50,14 +50,14 @@ Now that you're set up, feel free to check out our [Issues](https://github.com/v Beyond the extension, we very occasionally edit the IDE when we need to access more functionality. If you want to work on the full IDE, please follow the steps below, or see VS Code's full [how to contribute](https://github.com/microsoft/vscode/wiki/How-to-Contribute) page. -Before starting, make sure you've built the extension (by running `cd .\extensions\void\` and `npm run build`). +Before starting, make sure you've built the extension (by running `cd .\extensions\void\` and `npm run build`). Also make sure you have Python on your system. Make sure you're on the correct NodeJS version as per `.nvmrc`. 1. Install all dependencies. ``` -yarn +npm install ``` 2. In VS Code, press Ctrl+Shift+B to start the build process - this can take some time. If you're not using VS Code, run `npm run watch` instead. @@ -66,7 +66,7 @@ yarn This should open up the built IDE after loading for some time. To see new changes without restarting the build, use Ctrl+Shift+P and run "Reload Window". -To bundle the IDE, run `npm run gulp vscode-win32-x64`. Here are the full options: vscode-{win32-ia32 | win32-x64 | darwin-x64 | darwin-arm64 | linux-ia32 | linux-x64 | linux-arm}(-min) +To bundle the IDE, run `npm run gulp vscode-darwin-arm64`. Here are the full options: `vscode-{win32-ia32 | win32-x64 | darwin-x64 | darwin-arm64 | linux-ia32 | linux-x64 | linux-arm}(-min)` If you're on Windows, we recommend running the project inside a dev container. VSCode should prompt you to do this automatically. @@ -120,19 +120,25 @@ Eventually, we want to build a convenient API for creating AI tools. The API wil # Guidelines -Please don't make big refactors without speaking with us first. We'd like to keep the codebase similar to vscode so we can periodically rebase, and if we have big changes this gets complicated. +Please don't make big refactors without speaking with us first. We'd like to keep the codebase similar to vscode so we can periodically rebase, and if we have big changes that gets complicated. # Submitting a Pull Request -When you've made changes and want to submit them, please submit a pull request. Here are a few guidelines: +Please submit a pull request once you've made a change. Here are a few guidelines: + +- A PR should be about one *single* feature change. The fewer items you change, the more likely the PR is to be accepted. + +- Your PR should contain a description that first explains at a high level what you did, and then describes the exact changes you made (and to which files). Please don't use vague statements like "refactored code" or "improved types" (instead, describe what code you refactored, or what types you changed). -- A PR should be about one *single* feature change. Break large changes into multiple PRs. - Your title should clearly describe the change you made. -- Your description should cover the exact files and changes you made. Please don't use vague statements like "refactored code" or "improved types" (instead, describe what code you refactored, or what types you changed). + - Add tags to help us stay organized! -- Please don't open a new issue for your PR. +- Please don't open a new Issue for your PR. Just submit the PR. +- Avoid refactoring and making feature changes in the same PR. + +- Write good code. For example, a common mistake when people edit Void's config is to hard-code a default value like `'claude-3.5'` in 2+ separate places. Please follow best practices or describe your thought process if you had to compromise. # Relevant files diff --git a/extensions/void/README.md b/extensions/void/README.md index 04d86826..fd4840c4 100644 --- a/extensions/void/README.md +++ b/extensions/void/README.md @@ -1 +1,8 @@ Please see the `CONTRIBUTING.md` for information on how to contribute :)! + + +Here's an overview on how the extension works: + +- The extension mounts in `extension.ts`. + +- The Sidebar's HTML (everything in `sidebar/`) is built in React, and it's rendered by mounting a ` + + `; + + webview.html = webviewHTML; + } + + // called internally by vscode resolveWebviewView( webviewView: vscode.WebviewView, @@ -36,43 +80,17 @@ export class SidebarWebviewProvider implements vscode.WebviewViewProvider { token: vscode.CancellationToken, ) { - const webview = webviewView.webview + const webview = webviewView.webview; webview.options = { enableScripts: true, localResourceRoots: [this._extensionUri] }; - // This allows us to use React in vscode - // when you run `npm run build`, we take the React code in the `sidebar` folder - // and compile it into `dist/sidebar/index.js` and `dist/sidebar/styles.css` - // we render that code here - const rootPath = this._extensionUri; - const scriptUri = webview.asWebviewUri(vscode.Uri.joinPath(rootPath, 'dist/sidebar/index.js')); - const stylesUri = webview.asWebviewUri(vscode.Uri.joinPath(rootPath, 'dist/sidebar/styles.css')); - const rootUri = webview.asWebviewUri(vscode.Uri.joinPath(rootPath)); - - const nonce = getNonce(); // only scripts with the nonce are allowed to run, this is a recommended security measure - - - const allowed_urls = ['https://api.anthropic.com', 'https://api.openai.com', 'https://api.greptile.com'] - webview.html = ` - - - - - Custom View - - - - - -
- - - `; - + this.updateWebviewHTML(webview); + // resolve webview and _webviewView this._res(webview); + this._webviewView = webviewView; } } diff --git a/extensions/void/src/common/sendLLMMessage.ts b/extensions/void/src/common/sendLLMMessage.ts index 9e47a80a..9c0227d1 100644 --- a/extensions/void/src/common/sendLLMMessage.ts +++ b/extensions/void/src/common/sendLLMMessage.ts @@ -1,16 +1,19 @@ import Anthropic from '@anthropic-ai/sdk'; import OpenAI from 'openai'; +import { Ollama } from 'ollama/browser' +import { getVSCodeAPI } from '../sidebar/getVscodeApi'; -// import ollama from 'ollama' +// always compare these against package.json to make sure every setting in this type can actually be provided by the user export type ApiConfig = { anthropic: { apikey: string, model: string, maxTokens: string }, - openai: { - apikey: string + openAI: { + apikey: string, + model: string, }, greptile: { apikey: string, @@ -22,8 +25,14 @@ export type ApiConfig = { } }, ollama: { - // TODO + endpoint: string, + model: string }, + openAICompatible: { + endpoint: string, + model: string, + apikey: string + } whichApi: string } @@ -100,31 +109,42 @@ const sendClaudeMsg: SendLLMMessageFnTypeInternal = ({ messages, onText, onFinal -// OpenAI +// OpenAI and OpenAICompatible const sendOpenAIMsg: SendLLMMessageFnTypeInternal = ({ messages, onText, onFinalMessage, apiConfig }) => { - let did_abort = false + let didAbort = false let fullText = '' // if abort is called, onFinalMessage is NOT called, and no later onTexts are called either - let abort: () => void = () => { did_abort = true } + let abort: () => void = () => { + didAbort = true; + }; - const openai = new OpenAI({ apiKey: apiConfig.openai.apikey, dangerouslyAllowBrowser: true }); + const openai = new OpenAI({ apiKey: apiConfig.openAI.apikey, dangerouslyAllowBrowser: true }); - openai.chat.completions.create({ - model: 'gpt-4o-2024-08-06', - messages: messages, - stream: true, - }) + let options: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming + if (apiConfig.whichApi === 'openAI') { + options = { model: apiConfig.openAI.model, messages: messages, stream: true, } + } + else if (apiConfig.whichApi === 'openAICompatible') { + options = { model: apiConfig.openAICompatible.model, messages: messages, stream: true, } + } + else { + console.error(`sendOpenAIMsg: invalid whichApi: ${apiConfig.whichApi}`) + throw new Error(`apiConfig.whichAPI was invalid: ${apiConfig.whichApi}`) + } + + openai.chat.completions + .create(options) .then(async response => { abort = () => { - // response.controller.abort() // this isn't needed now, to keep consistency with claude will leave it commented - did_abort = true; + // response.controller.abort() + didAbort = true; } // when receive text try { for await (const chunk of response) { - if (did_abort) return; + if (didAbort) return; const newText = chunk.choices[0]?.delta?.content || ''; fullText += newText; onText(newText, fullText); @@ -136,8 +156,49 @@ const sendOpenAIMsg: SendLLMMessageFnTypeInternal = ({ messages, onText, onFinal console.error('Error in OpenAI stream:', error); onFinalMessage(fullText); } - // when we get the final message on this stream - onFinalMessage(fullText) + }) + return { abort }; +}; + + +// Ollama +export const sendOllamaMsg: SendLLMMessageFnTypeInternal = ({ messages, onText, onFinalMessage, apiConfig }) => { + + let didAbort = false + let fullText = "" + + // if abort is called, onFinalMessage is NOT called, and no later onTexts are called either + let abort = () => { + didAbort = true; + }; + + const ollama = new Ollama({ host: apiConfig.ollama.endpoint }) + + ollama.chat({ + model: apiConfig.ollama.model, + messages: messages, + stream: true, + }) + .then(async stream => { + abort = () => { + // ollama.abort() + didAbort = true + } + // iterate through the stream + try { + for await (const chunk of stream) { + if (didAbort) return; + const newText = chunk.message.content; + fullText += newText; + onText(newText, fullText); + } + onFinalMessage(fullText); + } + // when error/fail + catch (error) { + console.error('Error:', error); + onFinalMessage(fullText); + } }) return { abort }; }; @@ -150,11 +211,11 @@ const sendOpenAIMsg: SendLLMMessageFnTypeInternal = ({ messages, onText, onFinal const sendGreptileMsg: SendLLMMessageFnTypeInternal = ({ messages, onText, onFinalMessage, apiConfig }) => { - let did_abort = false + let didAbort = false let fullText = '' // if abort is called, onFinalMessage is NOT called, and no later onTexts are called either - let abort: () => void = () => { did_abort = true } + let abort: () => void = () => { didAbort = true } fetch('https://api.greptile.com/v2/query', { @@ -178,7 +239,7 @@ const sendGreptileMsg: SendLLMMessageFnTypeInternal = ({ messages, onText, onFin }) // TODO make this actually stream, right now it just sends one message at the end .then(async responseArr => { - if (did_abort) + if (didAbort) return for (let response of responseArr) { @@ -213,74 +274,27 @@ const sendGreptileMsg: SendLLMMessageFnTypeInternal = ({ messages, onText, onFin return { abort } - - } + export const sendLLMMessage: SendLLMMessageFnTypeExternal = ({ messages, onText, onFinalMessage, apiConfig }) => { if (!apiConfig) return { abort: () => { } } - const whichApi = apiConfig.whichApi - - if (whichApi === 'anthropic') { - return sendClaudeMsg({ messages, onText, onFinalMessage, apiConfig }) + switch (apiConfig.whichApi) { + case 'anthropic': + return sendClaudeMsg({ messages, onText, onFinalMessage, apiConfig }); + case 'openAI': + case 'openAICompatible': + return sendOpenAIMsg({ messages, onText, onFinalMessage, apiConfig }); + case 'greptile': + return sendGreptileMsg({ messages, onText, onFinalMessage, apiConfig }); + case 'ollama': + return sendOllamaMsg({ messages, onText, onFinalMessage, apiConfig }); + default: + console.error(`Error: whichApi was ${apiConfig.whichApi}, which is not recognized!`); + return { abort: () => { } } + //return sendClaudeMsg({ messages, onText, onFinalMessage, apiConfig }); // TODO } - else if (whichApi === 'openai') { - return sendOpenAIMsg({ messages, onText, onFinalMessage, apiConfig }) - } - else if (whichApi === 'greptile') { - return sendGreptileMsg({ messages, onText, onFinalMessage, apiConfig }) - } - else if (whichApi === 'ollama') { - return sendClaudeMsg({ messages, onText, onFinalMessage, apiConfig }) // TODO - } - else { - console.error(`Error: whichApi was ${whichApi}, which is not recognized!`) - return sendClaudeMsg({ messages, onText, onFinalMessage, apiConfig }) // TODO - } - } - -// Ollama -// const sendOllamaMsg: sendMsgFnType = ({ messages, onText, onFinalMessage }) => { - -// let did_abort = false -// let fullText = '' - -// // if abort is called, onFinalMessage is NOT called, and no later onTexts are called either -// let abort: () => void = () => { -// did_abort = true -// } - -// ollama.chat({ model: 'llama3.1', messages: messages, stream: true }) -// .then(async response => { - -// abort = () => { -// // response.abort() // this isn't needed now, to keep consistency with claude will leave it commented for now -// did_abort = true; -// } - -// // when receive text -// try { -// for await (const part of response) { -// if (did_abort) return -// let newText = part.message.content -// fullText += newText -// onText(newText, fullText) -// } -// } -// // when error/fail -// catch (e) { -// onFinalMessage(fullText) -// return -// } - -// // when we get the final message on this stream -// onFinalMessage(fullText) -// }) - -// return { abort }; -// }; - diff --git a/extensions/void/src/extension.ts b/extensions/void/src/extension.ts index afcd20b0..00b8b557 100644 --- a/extensions/void/src/extension.ts +++ b/extensions/void/src/extension.ts @@ -14,14 +14,17 @@ const readFileContentOfUri = async (uri: vscode.Uri) => { const getApiConfig = () => { const apiConfig: ApiConfig = { anthropic: { - apikey: vscode.workspace.getConfiguration('void').get('anthropicApiKey') ?? '', - model: vscode.workspace.getConfiguration('void').get('anthropicModel') ?? '', - maxTokens: vscode.workspace.getConfiguration('void').get('anthropicMaxToken') ?? '', + apikey: vscode.workspace.getConfiguration('void.anthropic').get('apiKey') ?? '', + model: vscode.workspace.getConfiguration('void.anthropic').get('model') ?? '', + maxTokens: vscode.workspace.getConfiguration('void.anthropic').get('maxTokens') ?? '', + }, + openAI: { + apikey: vscode.workspace.getConfiguration('void.openAI').get('apiKey') ?? '', + model: vscode.workspace.getConfiguration('void.openAI').get('model') ?? '', }, - openai: { apikey: vscode.workspace.getConfiguration('void').get('openAIApiKey') ?? '' }, greptile: { - apikey: vscode.workspace.getConfiguration('void').get('greptileApiKey') ?? '', - githubPAT: vscode.workspace.getConfiguration('void').get('githubPAT') ?? '', + apikey: vscode.workspace.getConfiguration('void.greptile').get('apiKey') ?? '', + githubPAT: vscode.workspace.getConfiguration('void.greptile').get('githubPAT') ?? '', repoinfo: { remote: 'github', repository: 'TODO', @@ -29,13 +32,20 @@ const getApiConfig = () => { } }, ollama: { - // apikey: vscode.workspace.getConfiguration('void').get('ollamaSettings') ?? '', + endpoint: vscode.workspace.getConfiguration('void.ollama').get('endpoint') ?? '', + model: vscode.workspace.getConfiguration('void.ollama').get('model') ?? '', + }, + openAICompatible: { + endpoint: vscode.workspace.getConfiguration('void.openAICompatible').get('endpoint') ?? '', + apikey: vscode.workspace.getConfiguration('void.openAICompatible').get('apiKey') ?? '', + model: vscode.workspace.getConfiguration('void.openAICompatible').get('model') ?? '', }, whichApi: vscode.workspace.getConfiguration('void').get('whichApi') ?? '' } return apiConfig } + export function activate(context: vscode.ExtensionContext) { // 1. Mount the chat sidebar @@ -120,7 +130,8 @@ export function activate(context: vscode.ExtensionContext) { // send contents to webview webview.postMessage({ type: 'files', files, } satisfies WebviewMessage) - } else if (m.type === 'applyCode') { + } + else if (m.type === 'applyCode') { const editor = vscode.window.activeTextEditor if (!editor) { @@ -154,7 +165,6 @@ export function activate(context: vscode.ExtensionContext) { webview.postMessage({ type: 'threadHistory', threads: updatedThreads } satisfies WebviewMessage) } else { - console.error('unrecognized command', m.type, m) } }) diff --git a/extensions/void/src/sidebar/Sidebar.tsx b/extensions/void/src/sidebar/Sidebar.tsx index c36d3e88..d94e9848 100644 --- a/extensions/void/src/sidebar/Sidebar.tsx +++ b/extensions/void/src/sidebar/Sidebar.tsx @@ -30,7 +30,7 @@ I am currently selecting this code: \`\`\`${selection.selectionStr}\`\`\` `} -Please edit the code following these instructions: +Please edit the code following these instructions (or, if appropriate, answer my question instead): ${instructions} If you make a change, rewrite the entire file. @@ -97,6 +97,7 @@ const Sidebar = () => { const [instructions, setInstructions] = useState('') // the user's instructions // state of chat + const [chatMessageHistory, setChatMessageHistory] = useState([]) const [messageStream, setMessageStream] = useState('') const [isLoading, setIsLoading] = useState(false) const [showThreadsHistory, setShowThreadsHistory] = useState(false) @@ -169,6 +170,15 @@ const Sidebar = () => { setSelection(null) setFiles([]) + + // TODO this is just a hack, turn this into a button instead, and track all histories somewhere + if (instructions === 'clear') { + setChatMessageHistory([]) + setMessageStream('') + setIsLoading(false) + return + } + // request file content from vscode and await response getVSCodeAPI().postMessage({ type: 'requestFiles', filepaths: files }) const relevantFiles = await awaitVSCodeResponse('files') @@ -184,8 +194,7 @@ const Sidebar = () => { messages: [...thread.messages.map(m => ({ role: m.role, content: m.content })), { role: 'user', content }], onText: (newText, fullText) => setMessageStream(fullText), onFinalMessage: (content) => { - - // add assistant's message to chat history + // add assistant's message to chat history, and clear selection const newHistoryElt: ChatMessage = { role: 'assistant', content, displayContent: content, } addMessageToHistory(newHistoryElt) @@ -207,6 +216,7 @@ const Sidebar = () => { const llmContent = messageStream || '(canceled)' const newHistoryElt: ChatMessage = { role: 'assistant', displayContent: messageStream, content: llmContent } addMessageToHistory(newHistoryElt) + setChatMessageHistory(chatMessageHistory => [...chatMessageHistory, newHistoryElt]) setMessageStream('') setIsLoading(false) @@ -235,6 +245,15 @@ const Sidebar = () => { {/* chatbar */}
+ {/* selection */} +
+ {/* selected files */} + + {/* selected code */} + {!selection?.selectionStr ? null + : ( +
+