merge ollama

This commit is contained in:
Andrew 2024-10-01 22:25:16 -07:00
parent 74c610632e
commit 631f724138
8 changed files with 288 additions and 439 deletions

View file

@ -14,15 +14,15 @@ function getNonce() {
export class SidebarWebviewProvider implements vscode.WebviewViewProvider { export class SidebarWebviewProvider implements vscode.WebviewViewProvider {
public static readonly viewId = 'void.viewnumberone'; public static readonly viewId = 'void.viewnumberone';
public webview: Promise<vscode.Webview> // used to send messages to the webview public webview: Promise<vscode.Webview> // used to send messages to the webview, resolved by _res in resolveWebviewView
private _res: (c: vscode.Webview) => void // used to resolve the webview
private readonly _extensionUri: vscode.Uri private readonly _extensionUri: vscode.Uri
private _res: (c: vscode.Webview) => void // used to resolve the webview
private allowed_urls: string[] = ['https://api.anthropic.com', 'https://api.openai.com', 'https://api.greptile.com', 'http://localhost:11434']; private _webviewView?: vscode.WebviewView; // only used inside onDidChangeConfiguration
private _webviewView?: vscode.WebviewView;
constructor(context: vscode.ExtensionContext) { constructor(context: vscode.ExtensionContext) {
// const extensionPath = context.extensionPath // the directory where the extension is installed, might be useful later, not sure for what though... was included in webviewProvider code // const extensionPath = context.extensionPath // the directory where the extension is installed, might be useful later... was included in webviewProvider code
this._extensionUri = context.extensionUri this._extensionUri = context.extensionUri
let temp_res: typeof this._res | undefined = undefined let temp_res: typeof this._res | undefined = undefined
@ -32,23 +32,25 @@ export class SidebarWebviewProvider implements vscode.WebviewViewProvider {
vscode.workspace.onDidChangeConfiguration(event => { vscode.workspace.onDidChangeConfiguration(event => {
if (event.affectsConfiguration('void.ollamaSettings.endpoint')) { if (event.affectsConfiguration('void.ollamaSettings.endpoint')) {
this.updateAllowedUrls();
// Regenerate the webview's HTML content
if (this._webviewView) { if (this._webviewView) {
this._webviewView.webview.html = this.getWebviewContent(this._webviewView.webview); this.updateWebviewHTML(this._webviewView.webview);
} }
} }
}); });
} }
private getWebviewContent(webview: vscode.Webview): string { private updateWebviewHTML(webview: vscode.Webview) {
const allowed_urls = ['https://api.anthropic.com', 'https://api.openai.com', 'https://api.greptile.com'];
const ollamaEndpoint: string | undefined = vscode.workspace.getConfiguration('void').get('ollamaSettings.endpoint');
if (ollamaEndpoint)
allowed_urls.push(ollamaEndpoint);
const scriptUri = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, 'dist/sidebar/index.js')); const scriptUri = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, 'dist/sidebar/index.js'));
const stylesUri = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, 'dist/sidebar/styles.css')); const stylesUri = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, 'dist/sidebar/styles.css'));
const rootUri = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri)); const rootUri = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri));
const nonce = getNonce(); const nonce = getNonce();
const allowed_urls = this.allowed_urls; const webviewHTML = `<!DOCTYPE html>
return `<!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
@ -63,12 +65,10 @@ export class SidebarWebviewProvider implements vscode.WebviewViewProvider {
<script nonce="${nonce}" src="${scriptUri}"></script> <script nonce="${nonce}" src="${scriptUri}"></script>
</body> </body>
</html>`; </html>`;
webview.html = webviewHTML;
} }
private updateAllowedUrls() {
const ollamaEndpoint: string = vscode.workspace.getConfiguration('void').get('ollamaSettings.endpoint') || 'http://localhost:11434';
this.allowed_urls = ['https://api.anthropic.com', 'https://api.openai.com', 'https://api.greptile.com', ollamaEndpoint];
}
// called internally by vscode // called internally by vscode
resolveWebviewView( resolveWebviewView(
@ -76,31 +76,18 @@ export class SidebarWebviewProvider implements vscode.WebviewViewProvider {
context: vscode.WebviewViewResolveContext, context: vscode.WebviewViewResolveContext,
token: vscode.CancellationToken, token: vscode.CancellationToken,
) { ) {
this._webviewView = webviewView;
const webview = webviewView.webview const webview = webviewView.webview;
webview.options = { webview.options = {
enableScripts: true, enableScripts: true,
localResourceRoots: [this._extensionUri] localResourceRoots: [this._extensionUri]
}; };
// This allows us to use React in vscode this.updateWebviewHTML(webview);
// 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
// Regenerate the Sidebar html whenever the allowed_urls changes
this.updateAllowedUrls();
const allowed_urls = this.allowed_urls;
webview.html = this.getWebviewContent(webview);
// resolve webview and _webviewView
this._res(webview); this._res(webview);
this._webviewView = webviewView;
} }
} }

View file

@ -105,11 +105,13 @@ const sendClaudeMsg: SendLLMMessageFnTypeInternal = ({ messages, onText, onFinal
// OpenAI // OpenAI
const sendOpenAIMsg: SendLLMMessageFnTypeInternal = ({ messages, onText, onFinalMessage, apiConfig }) => { const sendOpenAIMsg: SendLLMMessageFnTypeInternal = ({ messages, onText, onFinalMessage, apiConfig }) => {
let did_abort = false let didAbort = false
let fullText = '' let fullText = ''
// if abort is called, onFinalMessage is NOT called, and no later onTexts are called either // 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 });
@ -120,13 +122,13 @@ const sendOpenAIMsg: SendLLMMessageFnTypeInternal = ({ messages, onText, onFinal
}) })
.then(async response => { .then(async response => {
abort = () => { abort = () => {
// response.controller.abort() // this isn't needed now, to keep consistency with claude will leave it commented // response.controller.abort()
did_abort = true; didAbort = true;
} }
// when receive text // when receive text
try { try {
for await (const chunk of response) { for await (const chunk of response) {
if (did_abort) return; if (didAbort) return;
const newText = chunk.choices[0]?.delta?.content || ''; const newText = chunk.choices[0]?.delta?.content || '';
fullText += newText; fullText += newText;
onText(newText, fullText); onText(newText, fullText);
@ -138,8 +140,50 @@ const sendOpenAIMsg: SendLLMMessageFnTypeInternal = ({ messages, onText, onFinal
console.error('Error in OpenAI stream:', error); console.error('Error in OpenAI stream:', error);
onFinalMessage(fullText); 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 }; return { abort };
}; };
@ -152,11 +196,11 @@ const sendOpenAIMsg: SendLLMMessageFnTypeInternal = ({ messages, onText, onFinal
const sendGreptileMsg: SendLLMMessageFnTypeInternal = ({ messages, onText, onFinalMessage, apiConfig }) => { const sendGreptileMsg: SendLLMMessageFnTypeInternal = ({ messages, onText, onFinalMessage, apiConfig }) => {
let did_abort = false let didAbort = false
let fullText = '' let fullText = ''
// if abort is called, onFinalMessage is NOT called, and no later onTexts are called either // 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', { fetch('https://api.greptile.com/v2/query', {
@ -180,7 +224,7 @@ const sendGreptileMsg: SendLLMMessageFnTypeInternal = ({ messages, onText, onFin
}) })
// TODO make this actually stream, right now it just sends one message at the end // TODO make this actually stream, right now it just sends one message at the end
.then(async responseArr => { .then(async responseArr => {
if (did_abort) if (didAbort)
return return
for (let response of responseArr) { for (let response of responseArr) {
@ -215,28 +259,13 @@ const sendGreptileMsg: SendLLMMessageFnTypeInternal = ({ messages, onText, onFin
return { abort } return { abort }
} }
export const sendLLMMessage: SendLLMMessageFnTypeExternal = ({ messages, onText, onFinalMessage, apiConfig }) => { export const sendLLMMessage: SendLLMMessageFnTypeExternal = ({ messages, onText, onFinalMessage, apiConfig }) => {
if (!apiConfig) return { abort: () => { } } if (!apiConfig) return { abort: () => { } }
if (
apiConfig.anthropic.apikey === '' &&
apiConfig.greptile.apikey === '' &&
apiConfig.openai.apikey === '' &&
apiConfig.ollama.endpoint === '' &&
apiConfig.ollama.model === '' &&
apiConfig.whichApi === ''
) {
getVSCodeAPI().postMessage({ type: 'displayError', message: 'Required API keys are not set.' })
return { abort: () => { }}
}
switch (apiConfig.whichApi) { switch (apiConfig.whichApi) {
case 'anthropic': case 'anthropic':
return sendClaudeMsg({ messages, onText, onFinalMessage, apiConfig }); return sendClaudeMsg({ messages, onText, onFinalMessage, apiConfig });
@ -249,41 +278,7 @@ export const sendLLMMessage: SendLLMMessageFnTypeExternal = ({ messages, onText,
default: default:
console.error(`Error: whichApi was ${apiConfig.whichApi}, which is not recognized!`); console.error(`Error: whichApi was ${apiConfig.whichApi}, which is not recognized!`);
return { abort: () => { } } return { abort: () => { } }
//return sendClaudeMsg({ messages, onText, onFinalMessage, apiConfig }); // TODO //return sendClaudeMsg({ messages, onText, onFinalMessage, apiConfig }); // TODO
} }
} }
// Ollama
export const sendOllamaMsg: SendLLMMessageFnTypeInternal = ({ messages, onText, onFinalMessage, apiConfig }) => {
const ollamaClient = new Ollama({ host: apiConfig.ollama.endpoint })
let didAbort = false;
let fullText = "";
// if abort is called, onFinalMessage is NOT called, and no later onTexts are called either
const abort = () => {
didAbort = true;
};
ollamaClient.chat({
model: apiConfig.ollama.model,
messages: messages,
stream: true,
})
.then(async (stream) => {
for await (const chunk of stream) {
if (didAbort) return;
const newText = chunk.message.content;
fullText += newText;
onText(newText, fullText);
}
onFinalMessage(fullText);
})
.catch((error) => {
console.error('Error:', error);
onFinalMessage(fullText);
});
return { abort };
};

View file

@ -114,7 +114,8 @@ export function activate(context: vscode.ExtensionContext) {
// send contents to webview // send contents to webview
webview.postMessage({ type: 'files', files, } satisfies WebviewMessage) webview.postMessage({ type: 'files', files, } satisfies WebviewMessage)
} else if (m.type === 'applyCode') { }
else if (m.type === 'applyCode') {
const editor = vscode.window.activeTextEditor const editor = vscode.window.activeTextEditor
if (!editor) { if (!editor) {
@ -124,19 +125,16 @@ export function activate(context: vscode.ExtensionContext) {
const oldContents = await readFileContentOfUri(editor.document.uri) const oldContents = await readFileContentOfUri(editor.document.uri)
const suggestedEdits = getDiffedLines(oldContents, m.code) const suggestedEdits = getDiffedLines(oldContents, m.code)
await approvalCodeLensProvider.addNewApprovals(editor, suggestedEdits) await approvalCodeLensProvider.addNewApprovals(editor, suggestedEdits)
} else if (m.type === 'getApiConfig') { }
else if (m.type === 'getApiConfig') {
const apiConfig = getApiConfig() const apiConfig = getApiConfig()
console.log('Api config:', apiConfig) console.log('Api config:', apiConfig)
webview.postMessage({ type: 'apiConfig', apiConfig } satisfies WebviewMessage) webview.postMessage({ type: 'apiConfig', apiConfig } satisfies WebviewMessage)
} else if (m.type === 'displayError') {
vscode.window.showErrorMessage(m.message, { modal: true });
} }
else { else {
console.error('unrecognized command', m.type, m) console.error('unrecognized command', m.type, m)
} }
}) })

View file

@ -27,9 +27,6 @@ type WebviewMessage = (
// editor -> sidebar // editor -> sidebar
| { type: 'apiConfig', apiConfig: ApiConfig } | { type: 'apiConfig', apiConfig: ApiConfig }
// Display native vscode error
| { type: 'displayError', message: string } //
) )
type Command = WebviewMessage['type'] type Command = WebviewMessage['type']

View file

@ -1,57 +1,31 @@
/* eslint-disable no-mixed-spaces-and-tabs */ import React, { useState, ChangeEvent, useEffect, useRef, useCallback, FormEvent } from "react"
import React, { import { ApiConfig, LLMMessage, sendLLMMessage } from "../common/sendLLMMessage"
useState, import { Command, File, Selection, WebviewMessage } from "../shared_types"
ChangeEvent, import { awaitVSCodeResponse, getVSCodeAPI, resolveAwaitingVSCodeResponse } from "./getVscodeApi"
useEffect,
useRef,
useCallback,
FormEvent,
} from "react";
import {
ApiConfig,
LLMMessage,
sendLLMMessage,
} from "../common/sendLLMMessage";
import { Command, File, Selection, WebviewMessage } from "../shared_types";
import {
awaitVSCodeResponse,
getVSCodeAPI,
resolveAwaitingVSCodeResponse,
} from "./getVscodeApi";
import { marked } from "marked"; import { marked } from 'marked';
import MarkdownRender, { BlockCode } from "./MarkdownRender"; import MarkdownRender, { BlockCode } from "./MarkdownRender";
import * as vscode from "vscode"; import * as vscode from 'vscode'
const filesStr = (fullFiles: File[]) => { const filesStr = (fullFiles: File[]) => {
return fullFiles return fullFiles.map(({ filepath, content }) =>
.map( `
({ filepath, content }) =>
`
${filepath.fsPath} ${filepath.fsPath}
\`\`\` \`\`\`
${content} ${content}
\`\`\`` \`\`\``).join('\n')
) }
.join("\n");
};
const userInstructionsStr = ( const userInstructionsStr = (instructions: string, files: File[], selection: Selection | null) => {
instructions: string,
files: File[],
selection: Selection | null
) => {
return ` return `
${filesStr(files)} ${filesStr(files)}
${!selection ${!selection ? '' : `
? ""
: `
I am currently selecting this code: I am currently selecting this code:
\`\`\`${selection.selectionStr}\`\`\` \`\`\`${selection.selectionStr}\`\`\`
` `}
}
Please edit the code following these instructions: Please edit the code following these instructions:
${instructions} ${instructions}
@ -62,303 +36,235 @@ If you make a change, rewrite the entire file.
const FilesSelector = ({ files, setFiles }: { files: vscode.Uri[], setFiles: (files: vscode.Uri[]) => void }) => { const FilesSelector = ({ files, setFiles }: { files: vscode.Uri[], setFiles: (files: vscode.Uri[]) => void }) => {
return files.length !== 0 && ( return files.length !== 0 && <div className='my-2'>
<div className='my-2'> Include files:
Include files: {files.map((filename, i) =>
{files.map((filename, i) => <div key={i} className='flex'>
<div key={i} className='flex'> {/* X button on a file */}
{/* X button on a file */} <button type='button' onClick={() => {
<button type='button' onClick={() => { let file_index = files.indexOf(filename)
let file_index = files.indexOf(filename) setFiles([...files.slice(0, file_index), ...files.slice(file_index + 1, Infinity)])
setFiles([...files.slice(0, file_index), ...files.slice(file_index + 1, Infinity)]) }}>
}}> -{' '}<span className='text-gray-500'>{getBasename(filename.fsPath)}</span>
-{' '}<span className='text-gray-500'>{getBasename(filename.fsPath)}</span> </button>
</button> </div>
</div> )}
)} </div>
</div>
);
} }
const IncludedFiles = ({ files }: { files: vscode.Uri[] }) => { const IncludedFiles = ({ files }: { files: vscode.Uri[] }) => {
return files.length !== 0 && ( return files.length !== 0 && <div className='text-xs my-2'>
<div className='text-xs my-2'> {files.map((filename, i) =>
{files.map((filename, i) => <div key={i} className='flex'>
<div key={i} className='flex'> <button type='button'
<button type='button' className='btn btn-secondary pointer-events-none'
className='btn btn-secondary pointer-events-none' onClick={() => {
onClick={() => { // TODO redirect to the document filename.fsPath, when add this remove pointer-events-none
// TODO redirect to the document filename.fsPath, when add this remove pointer-events-none
}}> }}>
-{' '}<span className='text-gray-100'>{getBasename(filename.fsPath)}</span> -{' '}<span className='text-gray-100'>{getBasename(filename.fsPath)}</span>
</button> </button>
</div> </div>
)} )}
</div> </div>
); }
};
const ChatBubble = ({ chatMessage }: { chatMessage: ChatMessage }) => { const ChatBubble = ({ chatMessage }: { chatMessage: ChatMessage }) => {
const role = chatMessage.role;
const children = chatMessage.displayContent;
if (!children) return null; const role = chatMessage.role
const children = chatMessage.displayContent
let chatbubbleContents: React.ReactNode; if (!children)
return null
if (role === "user") { let chatbubbleContents: React.ReactNode
chatbubbleContents = (
<> if (role === 'user') {
<IncludedFiles files={chatMessage.files} /> chatbubbleContents = <>
{chatMessage.selection?.selectionStr && ( <IncludedFiles files={chatMessage.files} />
<BlockCode {chatMessage.selection?.selectionStr && <BlockCode text={chatMessage.selection.selectionStr} disableApplyButton={true} />}
text={chatMessage.selection.selectionStr} {children}
disableApplyButton={true} </>
/> }
)} else if (role === 'assistant') {
{children}
</>
);
} else if (role === "assistant") {
const tokens = marked.lexer(children); // https://marked.js.org/using_pro#renderer const tokens = marked.lexer(children); // https://marked.js.org/using_pro#renderer
chatbubbleContents = <MarkdownRender tokens={tokens} />; // sectionsHTML chatbubbleContents = <MarkdownRender tokens={tokens} /> // sectionsHTML
} }
return (
<div className={`${role === 'user' ? 'text-right' : 'text-left'}`}> return <div className={`${role === 'user' ? 'text-right' : 'text-left'}`}>
<div className={`inline-block p-2 rounded-lg space-y-2 ${role === 'user' ? 'bg-vscode-input-bg text-vscode-input-fg' : ''} max-w-full`}> <div className={`inline-block p-2 rounded-lg space-y-2 ${role === 'user' ? 'bg-vscode-input-bg text-vscode-input-fg' : ''} max-w-full`}>
{chatbubbleContents} {chatbubbleContents}
</div>
</div> </div>
); </div>
}; }
const getBasename = (pathStr: string) => { const getBasename = (pathStr: string) => {
// "unixify" path // "unixify" path
pathStr = pathStr.replace(/[/\\]+/g, "/"); // replace any / or \ or \\ with / pathStr = pathStr.replace(/[/\\]+/g, '/'); // replace any / or \ or \\ with /
const parts = pathStr.split("/"); // split on / const parts = pathStr.split('/') // split on /
return parts[parts.length - 1]; return parts[parts.length - 1]
}; }
type ChatMessage = {
role: 'user'
content: string, // content sent to the llm
displayContent: string, // content displayed to user
selection: Selection | null, // the user's selection
files: vscode.Uri[], // the files sent in the message
} | {
role: 'assistant',
content: string, // content received from LLM
displayContent: string // content displayed to user (this is the same as content for now)
}
type ChatMessage =
| {
role: "user";
content: string; // content sent to the llm
displayContent: string; // content displayed to user
selection: Selection | null; // the user's selection
files: vscode.Uri[]; // the files sent in the message
}
| {
role: "assistant";
content: string; // content received from LLM
displayContent: string; // content displayed to user (this is the same as content for now)
};
// const [stateRef, setState] = useInstantState(initVal) // const [stateRef, setState] = useInstantState(initVal)
// setState instantly changes the value of stateRef instead of having to wait until the next render // setState instantly changes the value of stateRef instead of having to wait until the next render
const useInstantState = <T,>(initVal: T) => { const useInstantState = <T,>(initVal: T) => {
const stateRef = useRef<T>(initVal); const stateRef = useRef<T>(initVal)
const [_, setS] = useState<T>(initVal); const [_, setS] = useState<T>(initVal)
const setState = useCallback((newVal: T) => { const setState = useCallback((newVal: T) => {
setS(newVal); setS(newVal);
stateRef.current = newVal; stateRef.current = newVal;
}, []); }, [])
return [stateRef as React.RefObject<T>, setState] as const; // make s.current readonly - setState handles all changes return [stateRef as React.RefObject<T>, setState] as const // make s.current readonly - setState handles all changes
}; }
const Sidebar = () => { const Sidebar = () => {
// state of current message // state of current message
const [selection, setSelection] = useState<Selection | null>(null); // the code the user is selecting const [selection, setSelection] = useState<Selection | null>(null) // the code the user is selecting
const [files, setFiles] = useState<vscode.Uri[]>([]); // the names of the files in the chat const [files, setFiles] = useState<vscode.Uri[]>([]) // the names of the files in the chat
const [instructions, setInstructions] = useState(""); // the user's instructions const [instructions, setInstructions] = useState('') // the user's instructions
// state of chat // state of chat
const [chatMessageHistory, setChatHistory] = useState<ChatMessage[]>([]); const [chatMessageHistory, setChatHistory] = useState<ChatMessage[]>([])
const [messageStream, setMessageStream] = useState(""); const [messageStream, setMessageStream] = useState('')
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false)
const [isDisabled, setIsDisabled] = useState(false);
const [errorShown, setErrorShown] = useState(false);
const abortFnRef = useRef<(() => void) | null>(null); const abortFnRef = useRef<(() => void) | null>(null)
const [apiConfig, setApiConfig] = useState<ApiConfig | null>(null); const [apiConfig, setApiConfig] = useState<ApiConfig | null>(null)
const checkApiConfig = (apiConfig: ApiConfig) => {
if (
(apiConfig.anthropic.apikey === "" &&
apiConfig.greptile.apikey === "" &&
apiConfig.openai.apikey === "" &&
(apiConfig.ollama.endpoint === "" ||
apiConfig.ollama.model === "")) ||
apiConfig.whichApi === ""
) {
setIsDisabled(true);
} else {
setIsDisabled(false);
}
}
// get Api Config on mount // get Api Config on mount
useEffect(() => { useEffect(() => {
getVSCodeAPI().postMessage({ type: "getApiConfig" }); getVSCodeAPI().postMessage({ type: 'getApiConfig' })
}, []); }, [])
// Receive messages from the extension // Receive messages from the extension
useEffect(() => { useEffect(() => {
const listener = (event: MessageEvent) => { const listener = (event: MessageEvent) => {
const m = event.data as WebviewMessage; const m = event.data as WebviewMessage;
// resolve any awaiting promises // resolve any awaiting promises
// eg. it will resolve the promise below for `await VSCodeResponse('files')` // eg. it will resolve the promise below for `await VSCodeResponse('files')`
resolveAwaitingVSCodeResponse(m); resolveAwaitingVSCodeResponse(m)
// if user pressed ctrl+l, add their selection to the sidebar // if user pressed ctrl+l, add their selection to the sidebar
if (m.type === "ctrl+l") { if (m.type === 'ctrl+l') {
if (isDisabled) {
getVSCodeAPI().postMessage({
type: "displayError",
message: "Required API keys are not set.",
});
return;
}
setSelection(m.selection);
const filepath = m.selection.filePath; setSelection(m.selection)
const filepath = m.selection.filePath
// add file if it's not a duplicate // add file if it's not a duplicate
if (!files.find((f) => f.fsPath === filepath.fsPath)) if (!files.find(f => f.fsPath === filepath.fsPath)) setFiles(files => [...files, filepath])
setFiles((files) => [...files, filepath]);
} }
// when get apiConfig, set // when get apiConfig, set
else if (m.type === "apiConfig") { else if (m.type === 'apiConfig') {
setApiConfig(m.apiConfig); setApiConfig(m.apiConfig)
checkApiConfig(m.apiConfig);
} }
};
window.addEventListener("message", listener);
return () => {
window.removeEventListener("message", listener);
};
}, [files, selection, isDisabled]);
const formRef = useRef<HTMLFormElement | null>(null); }
window.addEventListener('message', listener);
return () => { window.removeEventListener('message', listener) }
}, [files, selection])
const formRef = useRef<HTMLFormElement | null>(null)
const onSubmit = async (e: FormEvent<HTMLFormElement>) => { const onSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (isLoading || isDisabled) return;
setIsLoading(true); e.preventDefault()
setInstructions(""); if (isLoading) return
setIsLoading(true)
setInstructions('');
formRef.current?.reset(); // reset the form's text formRef.current?.reset(); // reset the form's text
setSelection(null); setSelection(null)
setFiles([]); setFiles([])
// request file content from vscode and await response // request file content from vscode and await response
getVSCodeAPI().postMessage({ type: "requestFiles", filepaths: files }); getVSCodeAPI().postMessage({ type: 'requestFiles', filepaths: files })
const relevantFiles = await awaitVSCodeResponse("files"); const relevantFiles = await awaitVSCodeResponse('files')
// add message to chat history // add message to chat history
const content = userInstructionsStr( const content = userInstructionsStr(instructions, relevantFiles.files, selection)
instructions,
relevantFiles.files,
selection
);
// console.log('prompt:\n', content) // console.log('prompt:\n', content)
const newHistoryElt: ChatMessage = { const newHistoryElt: ChatMessage = { role: 'user', content, displayContent: instructions, selection, files }
role: "user", setChatHistory(chatMessageHistory => [...chatMessageHistory, newHistoryElt])
content,
displayContent: instructions,
selection,
files,
};
setChatHistory((chatMessageHistory) => [
...chatMessageHistory,
newHistoryElt,
]);
// send message to claude // send message to claude
let { abort } = sendLLMMessage({ let { abort } = sendLLMMessage({
messages: [ messages: [...chatMessageHistory.map(m => ({ role: m.role, content: m.content })), { role: 'user', content }],
...chatMessageHistory.map((m) => ({
role: m.role,
content: m.content,
})),
{ role: "user", content },
],
onText: (newText, fullText) => setMessageStream(fullText), onText: (newText, fullText) => setMessageStream(fullText),
onFinalMessage: (content) => { onFinalMessage: (content) => {
// add assistant's message to chat history // add assistant's message to chat history
const newHistoryElt: ChatMessage = { const newHistoryElt: ChatMessage = { role: 'assistant', content, displayContent: content, }
role: "assistant", setChatHistory(chatMessageHistory => [...chatMessageHistory, newHistoryElt])
content,
displayContent: content,
};
setChatHistory((chatMessageHistory) => [
...chatMessageHistory,
newHistoryElt,
]);
// clear selection // clear selection
setMessageStream(""); setMessageStream('')
setIsLoading(false); setIsLoading(false)
}, },
apiConfig: apiConfig, apiConfig: apiConfig
}); })
abortFnRef.current = abort
abortFnRef.current = abort; }
};
const onStop = useCallback(() => { const onStop = useCallback(() => {
// abort claude // abort claude
abortFnRef.current?.(); abortFnRef.current?.()
// if messageStream was not empty, add it to the history // if messageStream was not empty, add it to the history
const llmContent = messageStream || "(canceled)"; const llmContent = messageStream || '(canceled)'
const newHistoryElt: ChatMessage = { const newHistoryElt: ChatMessage = { role: 'assistant', displayContent: messageStream, content: llmContent }
role: "assistant", setChatHistory(chatMessageHistory => [...chatMessageHistory, newHistoryElt])
displayContent: messageStream,
content: llmContent,
};
setChatHistory((chatMessageHistory) => [
...chatMessageHistory,
newHistoryElt,
]);
setMessageStream(""); setMessageStream('')
setIsLoading(false); setIsLoading(false)
}, [messageStream]);
}, [messageStream])
//Clear code selection //Clear code selection
const clearSelection = () => { const clearSelection = () => {
setSelection(null); setSelection(null);
}; };
return ( return <>
<> <div className="flex flex-col h-screen w-full">
<div <div className="overflow-y-auto overflow-x-hidden space-y-4">
className={`flex flex-col h-screen w-full ${isDisabled ? 'no-select' : ''}`} {/* previous messages */}
> {chatMessageHistory.map((message, i) =>
<div className="overflow-y-auto overflow-x-hidden space-y-4"> <ChatBubble key={i} chatMessage={message} />
{/* previous messages */} )}
{chatMessageHistory.map((message, i) => ( {/* message stream */}
<ChatBubble key={i} chatMessage={message} /> <ChatBubble chatMessage={{ role: 'assistant', content: messageStream, displayContent: messageStream }} />
))} </div>
{/* message stream */} {/* chatbar */}
<ChatBubble <div className="shrink-0 py-4">
chatMessage={{ {/* selection */}
role: "assistant", <div className="text-left">
content: messageStream, {/* selected files */}
displayContent: messageStream, <FilesSelector files={files} setFiles={setFiles} />
}} {/* selected code */}
/> {!selection?.selectionStr ? null
</div> : (
{/* chatbar */}
<div className="shrink-0 py-4">
{/* selection */}
<div className="text-left">
{/* selected files */}
<FilesSelector files={files} setFiles={setFiles} />
{/* selected code */}
{!selection?.selectionStr ? null : (
<div className="relative"> <div className="relative">
<button <button
onClick={clearSelection} onClick={clearSelection}
@ -366,79 +272,53 @@ const Sidebar = () => {
> >
X X
</button> </button>
<BlockCode <BlockCode text={selection.selectionStr} disableApplyButton={true} />
text={selection.selectionStr}
disableApplyButton={true}
/>
</div> </div>
)} )}
</div>
<form
ref={formRef}
className="flex flex-row items-center rounded-md p-2 input"
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) onSubmit(e);
}}
onSubmit={(e) => {
console.log("submit!");
e.preventDefault();
onSubmit(e);
}}
>
{/* input */}
<textarea
onChange={(e) => {
setInstructions(e.target.value);
}}
className="w-full p-2 leading-tight resize-none max-h-[50vh] overflow-hidden bg-transparent border-none !outline-none"
style={{ outline: "0px solid" }}
placeholder="Ctrl+L to select"
rows={1}
onInput={(e) => {
e.currentTarget.style.height = "auto";
e.currentTarget.style.height =
e.currentTarget.scrollHeight + "px";
}} // Adjust height dynamically
/>
{/* submit button */}
{isLoading ? (
<button
onClick={onStop}
className="btn btn-primary rounded-r-lg max-h-10 p-2"
type="button"
>
Stop
</button>
) : (
<button
className="btn btn-primary font-bold size-8 flex justify-center items-center rounded-full p-2 max-h-10"
disabled={!instructions}
type="submit"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="12" y1="19" x2="12" y2="5"></line>
<polyline points="5 12 12 5 19 12"></polyline>
</svg>
</button>
)}
</form>
</div> </div>
{isDisabled && ( <form
<div className="absolute top-0 left-0 w-full h-full bg-gray-500 opacity-10 pointer-events-none" /> ref={formRef}
)} className="flex flex-row items-center rounded-md p-2 input"
</div> onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) onSubmit(e) }}
</>
);
};
export default Sidebar; onSubmit={(e) => {
console.log('submit!')
e.preventDefault();
onSubmit(e)
}}>
{/* input */}
<textarea
onChange={(e) => { setInstructions(e.target.value) }}
className="w-full p-2 leading-tight resize-none max-h-[50vh] overflow-hidden bg-transparent border-none !outline-none"
placeholder="Ctrl+L to select"
rows={1}
onInput={e => { e.currentTarget.style.height = 'auto'; e.currentTarget.style.height = e.currentTarget.scrollHeight + 'px' }} // Adjust height dynamically
/>
{/* submit button */}
{isLoading ?
<button
onClick={onStop}
className="btn btn-primary rounded-r-lg max-h-10 p-2"
type='button'
>Stop</button>
: <button
className="btn btn-primary font-bold size-8 flex justify-center items-center rounded-full p-2 max-h-10"
disabled={!instructions}
type='submit'
>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" y1="19" x2="12" y2="5"></line>
<polyline points="5 12 12 5 19 12"></polyline>
</svg>
</button>
}
</form>
</div>
</div>
</>
}
export default Sidebar

View file

@ -10,7 +10,6 @@ const awaiting: { [c in Command]: ((res: any) => void)[] } = {
"files": [], "files": [],
"apiConfig": [], "apiConfig": [],
"getApiConfig": [], "getApiConfig": [],
"displayError": []
} }
// use this function to await responses // use this function to await responses

View file

@ -33,9 +33,3 @@ html {
.input { .input {
@apply bg-vscode-input-bg text-vscode-input-fg border-vscode-input-border; @apply bg-vscode-input-bg text-vscode-input-fg border-vscode-input-border;
} }
.no-select {
user-select: none;
pointer-events: none;
filter: blur(3px)
}

View file

@ -23,5 +23,4 @@ module.exports = {
}, },
}, },
plugins: [], plugins: [],
}; };