diff --git a/extensions/void/src/common/sendLLMMessage.ts b/extensions/void/src/common/sendLLMMessage.ts index f8ea1d97..b505e6a2 100644 --- a/extensions/void/src/common/sendLLMMessage.ts +++ b/extensions/void/src/common/sendLLMMessage.ts @@ -1,7 +1,6 @@ -import Anthropic from '@anthropic-ai/sdk'; -import OpenAI from 'openai'; +import Anthropic from '@anthropic-ai/sdk' import { Ollama } from 'ollama/browser' - +import OpenAI from 'openai' // always compare these against package.json to make sure every setting in this type can actually be provided by the user export type ApiConfig = { @@ -12,7 +11,7 @@ export type ApiConfig = { }, openAI: { apikey: string, - model: string, + model: string }, greptile: { apikey: string, @@ -39,96 +38,138 @@ export type ApiConfig = { whichApi: string } - - -type OnText = (newText: string, fullText: string) => void +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 - } + onError: (message: 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 } - + onError: (message: string) => void, + apiConfig: ApiConfig | null +}) => { + abort: () => void }; +type AnthropicErrorResponse = { + type: string, + error: { + type: string, + message: string + }; +}; +// 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; -// OpenAI, OpenRouter, OpenAICompatible -const sendOpenAIMsg: SendLLMMessageFnTypeInternal = ({ messages, onText, onFinalMessage, apiConfig }) => { + if (!apikey) { + return handleMissingApiKey('Anthropic', onError); + } - let didAbort = false - let fullText = '' + let didAbort = false; - // if abort is called, onFinalMessage is NOT called, and no later onTexts are called either - let abort: () => void = () => { + 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; }; - let openai: OpenAI - let options: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming + return { abort }; +}; + + +// OpenAI, OpenRouter, OpenAICompatible +const sendOpenAIMsg: SendLLMMessageFnTypeInternal = ({ + messages, + onText, + onFinalMessage, + onError, + apiConfig +}) => { + const { apikey, model } = apiConfig.openAI; + + + + let didAbort = false; + let fullText = ''; + + let abort = () => { + didAbort = true; + }; + + let openai: OpenAI; + let options: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming; + if (apiConfig.whichApi === 'openAI') { + if (!apikey) { + return handleMissingApiKey('OpenAI', onError); + } openai = new OpenAI({ apiKey: apiConfig.openAI.apikey, dangerouslyAllowBrowser: true }); - options = { model: apiConfig.openAI.model, messages: messages, stream: true, } + options = { model: apiConfig.openAI.model, messages: messages, stream: true, }; } else if (apiConfig.whichApi === 'openRouter') { openai = new OpenAI({ @@ -141,22 +182,21 @@ const sendOpenAIMsg: SendLLMMessageFnTypeInternal = ({ messages, onText, onFinal options = { model: apiConfig.openRouter.model, messages: messages, stream: true, } } else if (apiConfig.whichApi === 'openAICompatible') { - openai = new OpenAI({ baseURL: apiConfig.openAICompatible.endpoint, apiKey: apiConfig.openAICompatible.apikey, dangerouslyAllowBrowser: true }) - options = { model: apiConfig.openAICompatible.model, messages: messages, stream: true, } + openai = new OpenAI({ baseURL: apiConfig.openAICompatible.endpoint, apiKey: apiConfig.openAICompatible.apikey, dangerouslyAllowBrowser: true }); + 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}`) + onError(`Invalid API: ${apiConfig.whichApi}`); + throw new Error(`apiConfig.whichAPI was invalid: ${apiConfig.whichApi}`); } openai.chat.completions .create(options) - .then(async response => { + .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; @@ -164,42 +204,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 stream: ${error}`); console.error('Error in OpenAI stream:', error); - onFinalMessage(fullText); + if (!didAbort) { + onFinalMessage(fullText); + } } }) + .catch((responseError) => { + if (responseError.status === 401) { + onError('Unauthorized: Invalid API key'); + } else if (responseError.status === 400 && responseError.param === 'stream') { + onError(`The 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; @@ -207,109 +270,167 @@ 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': case 'openRouter': case 'openAICompatible': - 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: () => {} }; } -} +} \ No newline at end of file diff --git a/extensions/void/src/sidebar/Sidebar.tsx b/extensions/void/src/sidebar/Sidebar.tsx index e41e87e4..9f769dc1 100644 --- a/extensions/void/src/sidebar/Sidebar.tsx +++ b/extensions/void/src/sidebar/Sidebar.tsx @@ -11,6 +11,8 @@ import { SidebarChat } from "./SidebarChat"; const Sidebar = () => { const [isThreadSelectorOpen, setIsThreadSelectorOpen] = useState(false) + const [requestFailed, setRequestFailed] = useState(false) + const [requestFailedReason, setRequestFailedReason] = useState('') // get Api Config on mount useEffect(() => {