diff --git a/README.md b/README.md
index 89dfc483..6179e4bf 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,7 @@
## [Try MinimalGPT/MinimalClaude/MinimalLocal (Public Site)](https://minimalgpt.app/)
![Build Status](https://img.shields.io/badge/build-passing-brightgreen)
-![Version](https://img.shields.io/badge/version-5.0.2-blue)
+![Version](https://img.shields.io/badge/version-5.0.3-blue)
![License](https://img.shields.io/badge/license-MIT-green)
**MinimalChat** is an open-source LLM chat web app designed to be as self-contained as possible. All conversations are stored locally on the client's device, with the only information being sent to the server being API calls to GPT or Claude (uses a CORS proxy) chat when the user sends a message and when a user saves a conversation to generate a conversation title.
@@ -22,8 +22,9 @@
To use MinimalGPT with the various language models, you'll need to obtain API keys from their respective providers:
-- **OpenAI (GPT-3, GPT-4)**: Sign up for an API key at [OpenAI's website](https://beta.openai.com/signup/).
+- **OpenAI (GPT-3, GPT-4)**: Sign up for an API key at [OpenAI website](https://beta.openai.com/signup/).
- **Anthropic Claude-3**: Request access to the Claude API by filling out the form on [Anthropic's website](https://www.anthropic.com/product).
+- **Hugging Face**: Sign up for an API key at [Hugging Face website](https://huggingface.co/docs/api-inference/en/quicktour#get-your-api-token).
Once you have your API keys, input them in the app's settings to start using the corresponding language models.
@@ -66,7 +67,7 @@ On Android the process is basically the same except the name of the option is **
A: Yes, MinimalGPT is open-source and free to use. However, you'll need to provide your own API keys for the language models you want to use.
**Q: Can I use MinimalGPT without an internet connection?**
-A: No, MinimalGPT requires an internet connection to communicate with the language model APIs.
+A: Yes! If you use [LM Studio](https://lmstudio.ai/) to locally host a LLM Model you can connect and chat with any model supported within [LM Studio](https://lmstudio.ai/)
**Q: Are my conversations secure and private?**
A: Yes, all conversations are stored locally on your device and are not sent to any servers other than the necessary API calls to the language models.
@@ -86,11 +87,13 @@ A: Yes, MinimalGPT is designed be responsive and works well on mobile devices. Y
- **Claude 3 Sonnet**
- **Claude 3 Haiku**
- **Claude Vision** activated by having the **Claude** model selected and starting a message with **vision::** and then your prompt
+ - **Hugging Face Inference Endpoint**
+ - **Max Tokens** - Hugging Face models and their context windows can vary greatky. Use this setting to adjust the maximum number of tokens that can be generated as a response.
- **Local LLM Model (Via [LM Studio](https://lmstudio.ai/))** users configure the current model name and [LM Studio](https://lmstudio.ai/) api endpoint url in the settings panel.
- **Local Model Name**: The name of the model you are hosting locally
- - **Example**: [This DeepSeek Coder Model](https://huggingface.co/LoneStriker/deepseek-coder-7b-instruct-v1.5-GGUF) has a model name of `LoneStriker/deepseek-coder-7b-instruct-v1.5-GGUF`. That is what should be entered into the **Local Model Name** field. This is also displayed directly in **[LM Studio](https://lmstudio.ai/)** for the user.
- - **Local URL**: The API endpoint URL that **[LM Studio](https://lmstudio.ai/)** is running on
- - **Example**: `http://192.168.0.45:1234`
+ - **Example**: [This DeepSeek Coder Model](https://huggingface.co/LoneStriker/deepseek-coder-7b-instruct-v1.5-GGUF) has a model name of `LoneStriker/deepseek-coder-7b-instruct-v1.5-GGUF`. That is what should be entered into the **Local Model Name** field. This is also displayed directly in **[LM Studio](https://lmstudio.ai/)** for the user.
+ - **Local URL**: The API endpoint URL that **[LM Studio](https://lmstudio.ai/)** is running on
+ - **Example**: `http://192.168.0.45:1234`
- Switch models mid conversations and maintain context
- Swipe Gestures for quick settings and conversations access
- Markdown Support
@@ -143,6 +146,7 @@ MinimalGPT is made possible thanks to the following libraries, frameworks, and r
- **[OpenAI API](https://openai.com/)**
- **[Anthropic Claude API](https://www.anthropic.com/)**
- **[LM Studio](https://lmstudio.ai/)**
+- **[Hugging Face](https://huggingface.co/)**
## License
@@ -165,5 +169,3 @@ Also `npm run build` will output a dist folder with minified files etc...`npm ru
### Building/Bundling (WIP)
- Running `npm run build` will perform a dist build process that incldues minification and cache busting (sort of) and output to the `dist` folder.
-
-
diff --git a/src/components/chat-header.vue b/src/components/chat-header.vue
index 705cdcaf..006417cb 100644
--- a/src/components/chat-header.vue
+++ b/src/components/chat-header.vue
@@ -55,6 +55,10 @@ function onShowConversationsClick() {
href="https://github.com/fingerthief/minimal-gpt#try-minimalgpt" target="_blank" class="no-style-link">
MinimalLocal
+
+ MinimalHugging
+
diff --git a/src/components/settings-dialog.vue b/src/components/settings-dialog.vue
index f4c0d2dd..8b4fa47b 100644
--- a/src/components/settings-dialog.vue
+++ b/src/components/settings-dialog.vue
@@ -7,25 +7,33 @@ const props = defineProps({
selectedModel: String,
localModelName: String,
localModelEndpoint: String,
+ huggingFaceEndpoint: String,
localSliderValue: Number,
gptKey: String,
+ hfKey: String,
sliderValue: Number,
claudeKey: String,
claudeSliderValue: Number,
+ hfSliderValue: Number,
selectedDallEImageCount: Number,
selectedDallEImageResolution: String,
- selectedAutoSaveOption: String
+ selectedAutoSaveOption: String,
+ maxTokens: Number
});
const emit = defineEmits([
+ 'update:maxTokens',
'update:model',
'update:localModelName',
'update:localModelEndpoint',
'update:localSliderValue',
+ 'update:huggingFaceEndpoint',
'update:gptKey',
+ 'update:hfKey',
'update:sliderValue',
'update:claudeKey',
'update:claudeSliderValue',
+ 'update:hfSliderValue',
'update:selectedDallEImageCount',
'update:selectedDallEImageResolution',
'update:selectedAutoSaveOption',
@@ -52,7 +60,7 @@ function toggleSidebar() {
- Settings | V5.0.2
+ Settings | V5.0.3
@@ -111,6 +120,30 @@ function toggleSidebar() {
@blur="update('claudeSliderValue', $event.target.value)">
DALL-E Image Count:
diff --git a/src/libs/hugging-face-api-access.js b/src/libs/hugging-face-api-access.js
new file mode 100644
index 00000000..2b66d1ab
--- /dev/null
+++ b/src/libs/hugging-face-api-access.js
@@ -0,0 +1,155 @@
+/* eslint-disable no-unused-vars */
+import { showToast, sleep } from "./utils";
+
+let hfStreamRetryCount = 0;
+export async function fetchHuggingFaceModelResponseStream(conversation, attitude, model, huggingFaceEndpoint, updateUiFunction, apiKey, maxTokens) {
+ const gptMessagesOnly = filterMessages(conversation);
+
+ const requestOptions = {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Authorization": `Bearer ${apiKey}`,
+ },
+ body: JSON.stringify({
+ model: model,
+ stream: true,
+ messages: gptMessagesOnly,
+ temperature: attitude * 0.01,
+ max_tokens: parseInt(maxTokens)
+ }),
+ };
+
+ try {
+ const response = await fetch(`${huggingFaceEndpoint + `/v1/chat/completions`}`, requestOptions);
+
+ const result = await readResponseStream(response, updateUiFunction);
+
+ hfStreamRetryCount = 0;
+ return result;
+ } catch (error) {
+ console.error("Error fetching Hugging Face Model response:", error);
+ hfStreamRetryCount++
+
+ if (hfStreamRetryCount < 3) {
+ await sleep(1500);
+ return fetchHuggingFaceModelResponseStream(conversation, attitude, model, huggingFaceEndpoint, updateUiFunction);
+ }
+
+ return "Error fetching response from Hugging Face Model";
+
+ }
+}
+
+
+let retryCount = 0;
+export async function getConversationTitleFromHuggingFaceModel(messages, model, sliderValue, HuggingFaceModelEndpoint) {
+ try {
+ const apiKey = document.getElementById('api-key');
+ apiKey.value = localStorage.getItem("hfKey");
+
+ let tempMessages = messages.slice(0);
+ tempMessages.push({ role: 'user', content: "Summarize my inital request or greeting in 5 words or less." });
+
+ const requestOptions = {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Authorization": `Bearer ${apiKey.value}`,
+ },
+ body: JSON.stringify({
+ model: model,
+ stream: true,
+ messages: tempMessages,
+ temperature: sliderValue * 0.01,
+ max_tokens: 500
+ }),
+ };
+
+ const response = await fetch(`${HuggingFaceModelEndpoint + `/v1/chat/completions`}`, requestOptions);
+
+ const result = await readResponseStream(response);
+
+ hfStreamRetryCount = 0;
+ return result;
+ } catch (error) {
+
+ if (retryCount < 5) {
+ retryCount++;
+ getConversationTitleFromHuggingFaceModel(messages, model, sliderValue);
+ }
+
+ console.error("Error fetching Hugging Face Model response:", error);
+ return "An error occurred while generating conversaton title.";
+ }
+}
+
+async function readResponseStream(response, updateUiFunction) {
+ let decodedResult = "";
+
+ const reader = await response.body.getReader();
+ const decoder = new TextDecoder("utf-8");
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) {
+ return decodedResult
+ };
+ const chunk = decoder.decode(value);
+ const parsedLines = parseHuggingFaceResponseChunk(chunk);
+ for (const parsedLine of parsedLines) {
+ const { choices } = parsedLine;
+ const { delta } = choices[0];
+ const { content } = delta;
+ if (content) {
+ decodedResult += content;
+
+ if (updateUiFunction) {
+ updateUiFunction(content);
+ }
+ }
+ }
+ }
+}
+
+let buffer = ""; // Buffer to hold incomplete JSON data across chunks
+function parseHuggingFaceResponseChunk(chunk) {
+ buffer += chunk; // Append new chunk to buffer
+ const lines = buffer.split("\n");
+
+ const completeLines = lines.slice(0, -1); // All lines except the last one
+ buffer = lines[lines.length - 1]; // Last line might be incomplete, keep it in buffer
+
+ const results = [];
+ for (const line of completeLines) {
+ let cleanedLine = line.trim();
+
+ // Check if the line contains the control message [DONE] and remove it
+ if (cleanedLine.includes("[DONE]")) {
+ cleanedLine = cleanedLine.replace("[DONE]", "").trim();
+ }
+
+ // Remove any "data: " prefix that might be present after cleaning
+ // Using regex to handle any case variations and extra spaces
+ cleanedLine = cleanedLine.replace(/^data:\s*/i, "").trim();
+
+ if (cleanedLine !== "") {
+ try {
+ const parsed = JSON.parse(cleanedLine);
+ results.push(parsed);
+ } catch (error) {
+ console.error("Failed to parse JSON:", cleanedLine, error);
+ }
+ }
+ }
+ return results;
+}
+
+function filterMessages(conversation) {
+ let lastMessageContent = "";
+ return conversation.filter(message => {
+ const isGPT = !message.content.trim().toLowerCase().startsWith("image::") &&
+ !lastMessageContent.startsWith("image::");
+ lastMessageContent = message.content.trim().toLowerCase();
+ return isGPT;
+ });
+}
\ No newline at end of file
diff --git a/src/libs/utils.js b/src/libs/utils.js
index 930e9239..aa956321 100644
--- a/src/libs/utils.js
+++ b/src/libs/utils.js
@@ -52,7 +52,7 @@ export async function getConversationTitleFromGPT(messages, model, sliderValue)
if (retryCount < 5) {
retryCount++;
- self.getConversationTitleFromGPT(messages, model, sliderValue);
+ getConversationTitleFromGPT(messages, model, sliderValue);
}
console.error("Error fetching GPT response:", error);
diff --git a/src/views/ChatLayout.vue b/src/views/ChatLayout.vue
index cf8c3662..f6555394 100644
--- a/src/views/ChatLayout.vue
+++ b/src/views/ChatLayout.vue
@@ -8,6 +8,7 @@ import { fetchClaudeConversationTitle, streamClaudeResponse } from '@/libs/claud
import { getConversationTitleFromGPT, showToast } from '@/libs/utils';
import { analyzeImage } from '@/libs/image-analysis';
import { fetchLocalModelResponseStream } from '@/libs/local-model-access';
+import { fetchHuggingFaceModelResponseStream, getConversationTitleFromHuggingFaceModel } from '@/libs/hugging-face-api-access';
import messageItem from '@/components/message-item.vue';
import chatInput from '@/components/chat-input.vue';
@@ -42,6 +43,12 @@ const selectedDallEImageCount = ref(parseInt(localStorage.getItem("selectedDallE
const selectedDallEImageResolution = ref(localStorage.getItem("selectedDallEImageResolution") || '256x256');
const selectedAutoSaveOption = ref(localStorage.getItem("selectedAutoSaveOption") || true);
+const hfKey = ref(localStorage.getItem("hfKey") || '');
+const hfSliderValue = ref(parseInt(localStorage.getItem("hf-attitude")) || 50);
+const huggingFaceEndpoint = ref(localStorage.getItem("huggingFaceEndpoint") || '');
+const isUsingHuggingFaceModel = ref(false);
+const maxTokens = ref(parseInt(localStorage.getItem("hf-max-tokens")) || 3000);
+
const conversations = ref(loadConversationTitles());
const conversationTitles = ref(loadConversationTitles());
const storedConversations = ref(loadStoredConversations());
@@ -58,14 +65,16 @@ watch(selectedModel, (newValue) => {
const MODEL_TYPES = {
LMSTUDIO: 'lmstudio',
CLAUDE: 'claude',
- BISON: 'bison'
+ HUGGING_FACE: 'tgi'
};
// Default settings
let useLocalModel = false;
+
const flags = {
isUsingLocalModel: false,
- isClaudeEnabled: false
+ isClaudeEnabled: false,
+ isUsingHuggingFaceModel: false
};
// Determine settings based on model type
@@ -73,8 +82,12 @@ watch(selectedModel, (newValue) => {
useLocalModel = true;
flags.isUsingLocalModel = true;
}
+ else if (newValue.includes(MODEL_TYPES.HUGGING_FACE)) {
+ useLocalModel = false;
+ flags.isUsingHuggingFaceModel = true;
+ }
else if (newValue.includes(MODEL_TYPES.CLAUDE)) {
- useLocalModel = true;
+ useLocalModel = false;
flags.isClaudeEnabled = true;
}
@@ -85,12 +98,29 @@ watch(selectedModel, (newValue) => {
localStorage.setItem('selectedModel', newValue);
isUsingLocalModel.value = flags.isUsingLocalModel;
isClaudeEnabled.value = flags.isClaudeEnabled;
+ isUsingHuggingFaceModel.value = flags.isUsingHuggingFaceModel;
}
catch (error) {
console.error('Error updating settings:', error);
}
});
+watch(maxTokens, (newValue) => {
+ localStorage.setItem('maxTokens', newValue);
+});
+
+watch(huggingFaceEndpoint, (newValue) => {
+ localStorage.setItem('huggingFaceEndpoint', newValue);
+});
+
+watch(hfSliderValue, (newValue) => {
+ localStorage.setItem('hf-attitude', newValue);
+});
+
+watch(hfKey, (newValue) => {
+ localStorage.setItem('hfKey', newValue);
+});
+
watch(localModelName, (newValue) => {
localStorage.setItem('localModelName', newValue);
});
@@ -347,6 +377,7 @@ async function createNewConversationWithTitle() {
if (isClaudeEnabled.value) {
newConversationWithTitle.title = await fetchClaudeConversationTitle(messages.value.slice(0));
}
+
if (isUsingLocalModel.value) {
//Local Models are weird with trying to title conversations...
@@ -355,6 +386,10 @@ async function createNewConversationWithTitle() {
newConversationWithTitle.title = firstMessage.substring(0, Math.min(firstMessage.length, titleLength));
}
+
+ if (isUsingHuggingFaceModel.value) {
+ newConversationWithTitle.title = await getConversationTitleFromHuggingFaceModel(messages.value.slice(0), selectedModel.value, hfSliderValue.value, huggingFaceEndpoint.value);
+ }
else {
newConversationWithTitle.title = await getConversationTitleFromGPT(messages.value.slice(0), selectedModel.value, sliderValue.value);
}
@@ -461,6 +496,11 @@ async function sendMessage(event) {
return;
}
+ if (selectedModel.value.indexOf("tgi") !== -1) {
+ await sendHuggingFaceMessage(messageText);
+ return;
+ }
+
isClaudeEnabled.value = false;
addMessage("user", messageText);
@@ -525,6 +565,32 @@ async function sendGPTMessage(message) {
}
}
+async function sendHuggingFaceMessage(message) {
+ addMessage("user", message);
+
+ scrollToBottom();
+
+ userText.value = "";
+
+ streamedMessageText.value = "";
+ isLoading.value = true;
+
+ try {
+ let response = await fetchHuggingFaceModelResponseStream(messages.value, hfSliderValue.value, selectedModel.value, huggingFaceEndpoint.value, updateUI, hfKey.value, maxTokens.value);
+
+ isLoading.value = false;
+
+ addMessage('assistant', response);
+
+ await saveMessages();
+
+ scrollToBottom();
+ }
+ catch (error) {
+ console.error("Error sending message:", error);
+ }
+}
+
async function sendClaudeMessage(messageText) {
if (messageText.toLowerCase().startsWith("vision::")) {
addMessage("user", messageText);
@@ -712,7 +778,11 @@ const refs = {
claudeSliderValue,
selectedDallEImageCount,
selectedDallEImageResolution,
- selectedAutoSaveOption
+ selectedAutoSaveOption,
+ hfKey,
+ hfSliderValue,
+ huggingFaceEndpoint,
+ maxTokens
};
// Event handlers for updating the parent's state when the child emits an update
const updateSetting = (field, value) => {
@@ -761,15 +831,23 @@ onMounted(() => {