diff --git a/extensions/void/src/common/sendLLMMessage.ts b/extensions/void/src/common/sendLLMMessage.ts index 8f651de2..a1d85228 100644 --- a/extensions/void/src/common/sendLLMMessage.ts +++ b/extensions/void/src/common/sendLLMMessage.ts @@ -1,133 +1,173 @@ import Anthropic from '@anthropic-ai/sdk'; +import { Ollama } from 'ollama/browser'; import OpenAI from 'openai'; -import { Ollama } from 'ollama/browser' -import { getVSCodeAPI } from '../sidebar/getVscodeApi'; - // 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 - }, + apikey: string; + model: string; + maxTokens: string; + }; openai: { - apikey: string, - model: string, - }, + apikey: string; + model: string; + }; greptile: { - apikey: string, - githubPAT: string, + apikey: string; + githubPAT: string; repoinfo: { - remote: string, // e.g. 'github' - repository: string, // e.g. 'voideditor/void' - branch: string // e.g. 'main' - } - }, + remote: string; // e.g. 'github' + repository: string; // e.g. 'voideditor/void' + branch: string; // e.g. 'main' + }; + }; ollama: { - endpoint: string, - model: string - }, - whichApi: string -} - - - -type OnText = (newText: string, fullText: string) => void - -export type LLMMessage = { - role: 'user' | 'assistant', - content: string -} - -type SendLLMMessageFnTypeInternal = (params: { - messages: LLMMessage[], - onText: OnText, - onFinalMessage: (input: string) => void, - apiConfig: ApiConfig, -}) - => { - abort: () => void - } - -type SendLLMMessageFnTypeExternal = (params: { - messages: LLMMessage[], - onText: OnText, - onFinalMessage: (input: string) => void, - apiConfig: ApiConfig | null, -}) - => { - abort: () => void - } - - - - -// Claude -const sendClaudeMsg: SendLLMMessageFnTypeInternal = ({ messages, onText, onFinalMessage, apiConfig }) => { - - const anthropic = new Anthropic({ apiKey: apiConfig.anthropic.apikey, dangerouslyAllowBrowser: true }); // defaults to process.env["ANTHROPIC_API_KEY"] - - const stream = anthropic.messages.stream({ - model: apiConfig.anthropic.model, - max_tokens: parseInt(apiConfig.anthropic.maxTokens), - messages: messages, - }); - - let did_abort = false - - // when receive text - stream.on('text', (newText, fullText) => { - if (did_abort) return - onText(newText, fullText) - }) - - // when we get the final message on this stream (or when error/fail) - stream.on('finalMessage', (claude_response) => { - if (did_abort) return - // stringify the response's content - let content = claude_response.content.map(c => { if (c.type === 'text') { return c.text } }).join('\n'); - onFinalMessage(content) - }) - - - // if abort is called, onFinalMessage is NOT called, and no later onTexts are called either - const abort = () => { - // stream.abort() // this doesnt appear to do anything, but it should try to stop claude from generating anymore - did_abort = true - } - - return { abort } - + endpoint: string; + model: string; + }; + whichApi: string; }; +type OnText = (newText: string, fullText: string) => void; +export type LLMMessage = { + role: 'user' | 'assistant'; + content: string; +}; +type SendLLMMessageFnTypeInternal = (params: { + messages: LLMMessage[]; + onText: OnText; + onFinalMessage: (input: string) => void; + onError: (message: string) => void; + apiConfig: ApiConfig; +}) => { + abort: () => void; +}; -// OpenAI -const sendOpenAIMsg: SendLLMMessageFnTypeInternal = ({ messages, onText, onFinalMessage, apiConfig }) => { +type SendLLMMessageFnTypeExternal = (params: { + messages: LLMMessage[]; + onText: OnText; + onFinalMessage: (input: string) => void; + onError: (message: string) => void; + apiConfig: ApiConfig | null; +}) => { + abort: () => void; +}; - let didAbort = false - let fullText = '' +type AnthropicErrorResponse = { + type: string; + error: { + type: string; + message: string; + }; +}; - // if abort is called, onFinalMessage is NOT called, and no later onTexts are called either - let abort: () => void = () => { +// Helper function to handle missing API keys +const handleMissingApiKey = (serviceName: string, onError: (message: string) => void) => { + onError(`${serviceName} API key not set`); + return { abort: () => {} }; +}; + +// Claude +const sendClaudeMsg: SendLLMMessageFnTypeInternal = ({ + messages, + onText, + onFinalMessage, + onError, + apiConfig, +}) => { + const { apikey, model, maxTokens } = apiConfig.anthropic; + + if (!apikey) { + return handleMissingApiKey('Anthropic', onError); + } + + let didAbort = false; + + const anthropic = new Anthropic({ + apiKey: apikey, + dangerouslyAllowBrowser: true, + }); + + const stream = anthropic.messages + .stream({ + model: model, + max_tokens: parseInt(maxTokens), + messages: messages, + stream: true, + }) + .on('error', (err) => { + if (err instanceof Anthropic.APIError) { + if (err.status === 401) { + onError('Unauthorized: Invalid Anthropic API key'); + } else { + onError((err.error as AnthropicErrorResponse).error.message); + } + } else { + console.error(err); + onError(err.message); + } + }) + .on('text', (newText, fullText) => { + if (didAbort) return; + onText(newText, fullText); + }) + .on('finalMessage', (claudeResponse) => { + if (didAbort) return; + const content = claudeResponse.content + .filter((c) => c.type === 'text') + .map((c) => c.text) + .join('\n'); + onFinalMessage(content); + }); + + const abort = () => { + stream.controller.abort(); didAbort = true; }; - const openai = new OpenAI({ apiKey: apiConfig.openai.apikey, dangerouslyAllowBrowser: true }); + return { abort }; +}; - openai.chat.completions.create({ - model: apiConfig.openai.model, - messages: messages, - stream: true, - }) - .then(async response => { +// OpenAI +const sendOpenAIMsg: SendLLMMessageFnTypeInternal = ({ + messages, + onText, + onFinalMessage, + onError, + apiConfig, +}) => { + const { apikey, model } = apiConfig.openai; + + if (!apikey) { + return handleMissingApiKey('OpenAI', onError); + } + + let didAbort = false; + let fullText = ''; + + const openai = new OpenAI({ + apiKey: apikey, + dangerouslyAllowBrowser: true, + }); + + let abort = () => { + didAbort = true; + }; + + openai.chat.completions + .create({ + model: model, + messages: messages, + stream: true, + }) + .then(async (response) => { abort = () => { - // response.controller.abort() + response.controller.abort(); didAbort = true; - } - // when receive text + }; try { for await (const chunk of response) { if (didAbort) return; @@ -135,43 +175,65 @@ const sendOpenAIMsg: SendLLMMessageFnTypeInternal = ({ messages, onText, onFinal fullText += newText; onText(newText, fullText); } - onFinalMessage(fullText); - } - // when error/fail - catch (error) { + if (!didAbort) { + onFinalMessage(fullText); + } + } catch (error) { + onError(`Error in OpenAI stream: ${error}`); console.error('Error in OpenAI stream:', error); - onFinalMessage(fullText); + if (!didAbort) { + onFinalMessage(fullText); + } } }) + .catch((responseError) => { + if (responseError.status === 401) { + onError('Unauthorized: Invalid OpenAI API key'); + } else if (responseError.status === 400 && responseError.param === 'stream') { + onError(`The OpenAI model '${model}' does not support streamed responses.`); + } else { + onError(responseError.message); + } + }); + return { abort }; }; - - // Ollama -export const sendOllamaMsg: SendLLMMessageFnTypeInternal = ({ messages, onText, onFinalMessage, apiConfig }) => { +const sendOllamaMsg: SendLLMMessageFnTypeInternal = ({ + messages, + onText, + onFinalMessage, + onError, + apiConfig, +}) => { + const { endpoint, model } = apiConfig.ollama; - let didAbort = false - let fullText = "" + if (!endpoint) { + onError('Ollama endpoint not set'); + return { abort: () => {} }; + } + + let didAbort = false; + let fullText = ''; + + const ollama = new Ollama({ host: endpoint }); - // 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 => { + ollama + .chat({ + model: model, + messages: messages, + stream: true, + }) + .then(async (stream) => { abort = () => { - // ollama.abort() - didAbort = true - } - // iterate through the stream + ollama.abort(); + didAbort = true; + }; try { for await (const chunk of stream) { if (didAbort) return; @@ -179,108 +241,168 @@ export const sendOllamaMsg: SendLLMMessageFnTypeInternal = ({ messages, onText, fullText += newText; onText(newText, fullText); } - onFinalMessage(fullText); - } - // when error/fail - catch (error) { - console.error('Error:', error); - onFinalMessage(fullText); + if (!didAbort) { + onFinalMessage(fullText); + } + } catch (error) { + onError(`Error while streaming response: ${error}`); + console.error('Error while streaming response:', error); + if (!didAbort) { + onFinalMessage(fullText); + } } }) + .catch((responseError) => { + if (responseError.error) { + onError(responseError.error.charAt(0).toUpperCase() + responseError.error.slice(1)); + } else { + onError(responseError.message); + } + console.error(responseError); + }); + return { abort }; }; - - // Greptile -// https://docs.greptile.com/api-reference/query -// https://docs.greptile.com/quickstart#sample-response-streamed +const sendGreptileMsg: SendLLMMessageFnTypeInternal = ({ + messages, + onText, + onFinalMessage, + onError, + apiConfig, +}) => { + const { apikey, githubPAT, repoinfo } = apiConfig.greptile; -const sendGreptileMsg: SendLLMMessageFnTypeInternal = ({ messages, onText, onFinalMessage, apiConfig }) => { + if (!apikey) { + return handleMissingApiKey('Greptile', onError); + } + if (!githubPAT) { + onError('GitHub token not set'); + return { abort: () => {} }; + } - let didAbort = false - let fullText = '' - - // if abort is called, onFinalMessage is NOT called, and no later onTexts are called either - let abort: () => void = () => { didAbort = true } + let didAbort = false; + let fullText = ''; + const controller = new AbortController(); fetch('https://api.greptile.com/v2/query', { method: 'POST', headers: { - "Authorization": `Bearer ${apiConfig.greptile.apikey}`, - "X-Github-Token": `${apiConfig.greptile.githubPAT}`, - "Content-Type": `application/json`, + Authorization: `Bearer ${apikey}`, + 'X-Github-Token': `${githubPAT}`, + 'Content-Type': `application/json`, }, body: JSON.stringify({ messages, stream: true, - repositories: [apiConfig.greptile.repoinfo] + repositories: [repoinfo], }), + signal: controller.signal, }) - // this is {message}\n{message}\n{message}...\n - .then(async response => { - const text = await response.text() - console.log('got greptile', text) - return JSON.parse(`[${text.trim().split('\n').join(',')}]`) + .then((response) => { + if (response.status === 401) { + onError('Unauthorized: Invalid Greptile API key'); + return null; + } else if (response.status !== 200) { + onError(`Error: ${response.status} ${response.statusText}`); + return null; + } + return response.body; }) - // TODO make this actually stream, right now it just sends one message at the end - .then(async responseArr => { - if (didAbort) - return - - for (let response of responseArr) { - - const type: string = response['type'] - const message = response['message'] - - // when receive text - if (type === 'message') { - fullText += message - onText(message, fullText) - } - else if (type === 'sources') { - const { filepath, linestart, lineend } = message as { filepath: string, linestart: number | null, lineend: number | null } - fullText += filepath - onText(filepath, fullText) - } - // type: 'status' with an empty 'message' means last message - else if (type === 'status') { - if (!message) { - onFinalMessage(fullText) + .then(async (body) => { + if (!body || didAbort) return; + const reader = body.getReader(); + const decoder = new TextDecoder('utf-8'); + while (!didAbort) { + const { done, value } = await reader.read(); + if (done || didAbort) break; + const chunk = decoder.decode(value, { stream: true }); + const messages = chunk.trim().split('\n').filter(Boolean); + for (const msg of messages) { + try { + const parsed = JSON.parse(msg); + const { type, message } = parsed; + if (type === 'message' || type === 'sources') { + fullText += message; + onText(message, fullText); + } else if (type === 'status' && !message) { + if (!didAbort) { + onFinalMessage(fullText); + } + } + } catch (e) { + console.error('Error parsing Greptile response:', e); + onError(`Error parsing Greptile response: ${e}`); } } } - }) - .catch(e => { + .catch((e) => { + if (didAbort) return; console.error('Error in Greptile stream:', e); - onFinalMessage(fullText); - + onError(`Error in Greptile stream: ${e}`); + if (!didAbort) { + onFinalMessage(fullText); + } }); - return { abort } + const abort = () => { + controller.abort(); + didAbort = true; + }; -} + return { abort }; +}; - - -export const sendLLMMessage: SendLLMMessageFnTypeExternal = ({ messages, onText, onFinalMessage, apiConfig }) => { - if (!apiConfig) return { abort: () => { } } +export const sendLLMMessage: SendLLMMessageFnTypeExternal = ({ + messages, + onText, + onFinalMessage, + onError, + apiConfig, +}) => { + if (!apiConfig) { + onError('API configuration is missing'); + return { abort: () => {} }; + } switch (apiConfig.whichApi) { case 'anthropic': - return sendClaudeMsg({ messages, onText, onFinalMessage, apiConfig }); + return sendClaudeMsg({ + messages, + onText, + onFinalMessage, + onError, + apiConfig, + }); case 'openai': - return sendOpenAIMsg({ messages, onText, onFinalMessage, apiConfig }); + return sendOpenAIMsg({ + messages, + onText, + onFinalMessage, + onError, + apiConfig, + }); case 'greptile': - return sendGreptileMsg({ messages, onText, onFinalMessage, apiConfig }); + return sendGreptileMsg({ + messages, + onText, + onFinalMessage, + onError, + apiConfig, + }); case 'ollama': - return sendOllamaMsg({ messages, onText, onFinalMessage, apiConfig }); + return sendOllamaMsg({ + messages, + onText, + onFinalMessage, + onError, + apiConfig, + }); default: - console.error(`Error: whichApi was ${apiConfig.whichApi}, which is not recognized!`); - return { abort: () => { } } - //return sendClaudeMsg({ messages, onText, onFinalMessage, apiConfig }); // TODO + onError(`Error: whichApi was '${apiConfig.whichApi}', which is not recognized!`); + return { abort: () => {} }; } -} - +}; diff --git a/extensions/void/src/sidebar/Sidebar.tsx b/extensions/void/src/sidebar/Sidebar.tsx index e96006d0..4b2443fb 100644 --- a/extensions/void/src/sidebar/Sidebar.tsx +++ b/extensions/void/src/sidebar/Sidebar.tsx @@ -144,6 +144,11 @@ const Sidebar = () => { const [chatMessageHistory, setChatMessageHistory] = useState([]) const [messageStream, setMessageStream] = useState('') const [isLoading, setIsLoading] = useState(false) + const [requestFailed, setRequestFailed] = useState(false) + const [requestFailedReason, setRequestFailedReason] = useState('') + + + const abortFnRef = useRef<(() => void) | null>(null) @@ -191,6 +196,10 @@ const Sidebar = () => { e.preventDefault() if (isLoading) return + // Reset any error messages from previous submit + setRequestFailed(false) + setRequestFailedReason('') + setIsLoading(true) setInstructions(''); formRef.current?.reset(); // reset the form's text @@ -216,7 +225,7 @@ const Sidebar = () => { const newHistoryElt: ChatMessage = { role: 'user', content, displayContent: instructions, selection, files } setChatMessageHistory(chatMessageHistory => [...chatMessageHistory, newHistoryElt]) - // send message to claude + // send message to LLM let { abort } = sendLLMMessage({ messages: [...chatMessageHistory.map(m => ({ role: m.role, content: m.content })), { role: 'user', content }], onText: (newText, fullText) => setMessageStream(fullText), @@ -227,6 +236,7 @@ const Sidebar = () => { setMessageStream('') setIsLoading(false) }, + onError: (message) => { onStop(); setRequestFailed(true); setRequestFailedReason(message)}, apiConfig: apiConfig }) abortFnRef.current = abort @@ -282,6 +292,12 @@ const Sidebar = () => { )} + {/* error message */} + {requestFailed && ( +
+
{`${requestFailedReason}`}
+
+ )}
{ e.preventDefault(); onSubmit(e) }}> - {/* input */} + {/* input */}