Added error messages in UI

This commit is contained in:
w1gs 2024-10-03 21:24:53 -04:00
parent 95be6e0b37
commit bed7929e48
2 changed files with 338 additions and 200 deletions

View file

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

View file

@ -144,6 +144,11 @@ const Sidebar = () => {
const [chatMessageHistory, setChatMessageHistory] = useState<ChatMessage[]>([])
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 = () => {
</div>
)}
</div>
{/* error message */}
{requestFailed && (
<div className="bg-gray-800 text-red-500 text-center p-4 mb-4 rounded-md shadow-md">
<div className="text-lg">{`${requestFailedReason}`}</div>
</div>
)}
<form
ref={formRef}
className="flex flex-row items-center rounded-md p-2 input"
@ -292,8 +308,8 @@ const Sidebar = () => {
e.preventDefault();
onSubmit(e)
}}>
{/* input */}
{/* 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"