Merge pull request #87 from w1gs/main

Added displaying error messages in UI
This commit is contained in:
Andrew Pareles 2024-10-17 17:18:56 -07:00 committed by GitHub
commit 2d17133684
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 287 additions and 164 deletions

View file

@ -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: () => {} };
}
}
}

View file

@ -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(() => {