mirror of
https://github.com/voideditor/void
synced 2026-05-24 09:58:23 +00:00
Merge pull request #87 from w1gs/main
Added displaying error messages in UI
This commit is contained in:
commit
2d17133684
2 changed files with 287 additions and 164 deletions
|
|
@ -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: () => {} };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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(() => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue