From f9cd2e321c6462054e45e2234eb3e07e2fcc3b9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=8D=E7=9F=A5=E7=81=AB=20Shiranui?= Date: Thu, 25 Jul 2024 22:02:52 +0800 Subject: [PATCH] feat: add openai-compatible api models (#2683) Signed-off-by: Shiranui Signed-off-by: Jared Van Bortel Co-authored-by: Jared Van Bortel --- gpt4all-chat/CMakeLists.txt | 2 + gpt4all-chat/chatapi.cpp | 30 +++++++++- gpt4all-chat/chatllm.cpp | 16 ++++- gpt4all-chat/download.cpp | 51 +++++++++++++++- gpt4all-chat/download.h | 2 + gpt4all-chat/modellist.cpp | 87 ++++++++++++++++++++++++++- gpt4all-chat/modellist.h | 16 +++++ gpt4all-chat/network.cpp | 9 +++ gpt4all-chat/network.h | 1 + gpt4all-chat/qml/AddModelView.qml | 87 +++++++++++++++++++++++++-- gpt4all-chat/qml/ChatView.qml | 8 ++- gpt4all-chat/qml/ModelsView.qml | 87 +++++++++++++++++++++++++-- gpt4all-chat/qml/Toast.qml | 98 +++++++++++++++++++++++++++++++ gpt4all-chat/qml/ToastManager.qml | 60 +++++++++++++++++++ 14 files changed, 539 insertions(+), 15 deletions(-) create mode 100644 gpt4all-chat/qml/Toast.qml create mode 100644 gpt4all-chat/qml/ToastManager.qml diff --git a/gpt4all-chat/CMakeLists.txt b/gpt4all-chat/CMakeLists.txt index a583a905..70166b99 100644 --- a/gpt4all-chat/CMakeLists.txt +++ b/gpt4all-chat/CMakeLists.txt @@ -153,6 +153,8 @@ qt_add_qml_module(chat qml/SwitchModelDialog.qml qml/Theme.qml qml/ThumbsDownDialog.qml + qml/Toast.qml + qml/ToastManager.qml qml/MyBusyIndicator.qml qml/MyButton.qml qml/MyCheckBox.qml diff --git a/gpt4all-chat/chatapi.cpp b/gpt4all-chat/chatapi.cpp index e9106dfc..1cf94173 100644 --- a/gpt4all-chat/chatapi.cpp +++ b/gpt4all-chat/chatapi.cpp @@ -201,6 +201,11 @@ void ChatAPIWorker::request(const QString &apiKey, QNetworkRequest request(apiUrl); request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); request.setRawHeader("Authorization", authorization.toUtf8()); +#if defined(DEBUG) + qDebug() << "ChatAPI::request" + << "API URL: " << apiUrl.toString() + << "Authorization: " << authorization.toUtf8(); +#endif m_networkManager = new QNetworkAccessManager(this); QNetworkReply *reply = m_networkManager->post(request, array); connect(qGuiApp, &QCoreApplication::aboutToQuit, reply, &QNetworkReply::abort); @@ -218,10 +223,28 @@ void ChatAPIWorker::handleFinished() } QVariant response = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute); - Q_ASSERT(response.isValid()); + + if (!response.isValid()) { + m_chat->callResponse( + -1, + tr("ERROR: Network error occurred while connecting to the API server") + .toStdString() + ); + return; + } + bool ok; int code = response.toInt(&ok); if (!ok || code != 200) { + bool isReplyEmpty(reply->readAll().isEmpty()); + if (isReplyEmpty) + m_chat->callResponse( + -1, + tr("ChatAPIWorker::handleFinished got HTTP Error %1 %2") + .arg(code) + .arg(reply->errorString()) + .toStdString() + ); qWarning().noquote() << "ERROR: ChatAPIWorker::handleFinished got HTTP Error" << code << "response:" << reply->errorString(); } @@ -238,7 +261,10 @@ void ChatAPIWorker::handleReadyRead() } QVariant response = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute); - Q_ASSERT(response.isValid()); + + if (!response.isValid()) + return; + bool ok; int code = response.toInt(&ok); if (!ok || code != 200) { diff --git a/gpt4all-chat/chatllm.cpp b/gpt4all-chat/chatllm.cpp index 78fc799b..a0165026 100644 --- a/gpt4all-chat/chatllm.cpp +++ b/gpt4all-chat/chatllm.cpp @@ -322,6 +322,7 @@ bool ChatLLM::loadModel(const ModelInfo &modelInfo) QVariantMap modelLoadProps; if (modelInfo.isOnline) { QString apiKey; + QString requestUrl; QString modelName; { QFile file(filePath); @@ -332,11 +333,24 @@ bool ChatLLM::loadModel(const ModelInfo &modelInfo) QJsonObject obj = doc.object(); apiKey = obj["apiKey"].toString(); modelName = obj["modelName"].toString(); + if (modelInfo.isCompatibleApi) { + QString baseUrl(obj["baseUrl"].toString()); + QUrl apiUrl(QUrl::fromUserInput(baseUrl)); + if (!Network::isHttpUrlValid(apiUrl)) { + return false; + } + QString currentPath(apiUrl.path()); + QString suffixPath("%1/chat/completions"); + apiUrl.setPath(suffixPath.arg(currentPath)); + requestUrl = apiUrl.toString(); + } else { + requestUrl = modelInfo.url(); + } } m_llModelType = LLModelType::API_; ChatAPI *model = new ChatAPI(); model->setModelName(modelName); - model->setRequestURL(modelInfo.url()); + model->setRequestURL(requestUrl); model->setAPIKey(apiKey); m_llModelInfo.resetModel(this, model); } else if (!loadNewModel(modelInfo, modelLoadProps)) { diff --git a/gpt4all-chat/download.cpp b/gpt4all-chat/download.cpp index 6a138698..a65654f5 100644 --- a/gpt4all-chat/download.cpp +++ b/gpt4all-chat/download.cpp @@ -237,6 +237,54 @@ void Download::installModel(const QString &modelFile, const QString &apiKey) stream << doc.toJson(); file.close(); ModelList::globalInstance()->updateModelsFromDirectory(); + emit toastMessage(tr("Model \"%1\" is installed successfully.").arg(modelName)); + } + + ModelList::globalInstance()->updateDataByFilename(modelFile, {{ ModelList::InstalledRole, true }}); +} + +void Download::installCompatibleModel(const QString &modelName, const QString &apiKey, const QString &baseUrl) +{ + Q_ASSERT(!modelName.isEmpty()); + if (modelName.isEmpty()) { + emit toastMessage(tr("ERROR: $MODEL_NAME is empty.")); + return; + } + + Q_ASSERT(!apiKey.isEmpty()); + if (apiKey.isEmpty()) { + emit toastMessage(tr("ERROR: $API_KEY is empty.")); + return; + } + + QUrl apiBaseUrl(QUrl::fromUserInput(baseUrl)); + if (!Network::isHttpUrlValid(baseUrl)) { + emit toastMessage(tr("ERROR: $BASE_URL is invalid.")); + return; + } + + QString modelFile(ModelList::compatibleModelFilename(baseUrl, modelName)); + if (ModelList::globalInstance()->contains(modelFile)) { + emit toastMessage(tr("ERROR: Model \"%1 (%2)\" is conflict.").arg(modelName, baseUrl)); + return; + } + ModelList::globalInstance()->addModel(modelFile); + Network::globalInstance()->trackEvent("install_model", { {"model", modelFile} }); + + QString filePath = MySettings::globalInstance()->modelPath() + modelFile; + QFile file(filePath); + if (file.open(QIODeviceBase::WriteOnly | QIODeviceBase::Text)) { + QJsonObject obj; + obj.insert("apiKey", apiKey); + obj.insert("modelName", modelName); + obj.insert("baseUrl", apiBaseUrl.toString()); + QJsonDocument doc(obj); + + QTextStream stream(&file); + stream << doc.toJson(); + file.close(); + ModelList::globalInstance()->updateModelsFromDirectory(); + emit toastMessage(tr("Model \"%1 (%2)\" is installed successfully.").arg(modelName, baseUrl)); } ModelList::globalInstance()->updateDataByFilename(modelFile, {{ ModelList::InstalledRole, true }}); @@ -255,11 +303,12 @@ void Download::removeModel(const QString &modelFile) if (file.exists()) { const ModelInfo info = ModelList::globalInstance()->modelInfoByFilename(modelFile); MySettings::globalInstance()->eraseModel(info); - shouldRemoveInstalled = info.installed && !info.isClone() && (info.isDiscovered() || info.description() == "" /*indicates sideloaded*/); + shouldRemoveInstalled = info.installed && !info.isClone() && (info.isDiscovered() || info.isCompatibleApi || info.description() == "" /*indicates sideloaded*/); if (shouldRemoveInstalled) ModelList::globalInstance()->removeInstalled(info); Network::globalInstance()->trackEvent("remove_model", { {"model", modelFile} }); file.remove(); + emit toastMessage(tr("Model \"%1\" is removed.").arg(info.name())); } if (!shouldRemoveInstalled) { diff --git a/gpt4all-chat/download.h b/gpt4all-chat/download.h index 7fcedd2a..1a1195ed 100644 --- a/gpt4all-chat/download.h +++ b/gpt4all-chat/download.h @@ -63,6 +63,7 @@ public: Q_INVOKABLE void downloadModel(const QString &modelFile); Q_INVOKABLE void cancelDownload(const QString &modelFile); Q_INVOKABLE void installModel(const QString &modelFile, const QString &apiKey); + Q_INVOKABLE void installCompatibleModel(const QString &modelName, const QString &apiKey, const QString &baseUrl); Q_INVOKABLE void removeModel(const QString &modelFile); Q_INVOKABLE bool isFirstStart(bool writeVersion = false) const; @@ -87,6 +88,7 @@ Q_SIGNALS: void requestHashAndSave(const QString &hash, QCryptographicHash::Algorithm a, const QString &saveFilePath, QFile *tempFile, QNetworkReply *modelReply); void latestNewsChanged(); + void toastMessage(const QString &message); private: void parseReleaseJsonFile(const QByteArray &jsonData); diff --git a/gpt4all-chat/modellist.cpp b/gpt4all-chat/modellist.cpp index 4333b99c..e59d7235 100644 --- a/gpt4all-chat/modellist.cpp +++ b/gpt4all-chat/modellist.cpp @@ -514,6 +514,17 @@ ModelList::ModelList() QCoreApplication::instance()->installEventFilter(this); } +QString ModelList::compatibleModelNameHash(QUrl baseUrl, QString modelName) { + QCryptographicHash sha256(QCryptographicHash::Sha256); + sha256.addData((baseUrl.toString() + "_" + modelName).toUtf8()); + return sha256.result().toHex(); +}; + +QString ModelList::compatibleModelFilename(QUrl baseUrl, QString modelName) { + QString hash(compatibleModelNameHash(baseUrl, modelName)); + return QString(u"gpt4all-%1-capi.rmodel"_s).arg(hash); +}; + bool ModelList::eventFilter(QObject *obj, QEvent *ev) { if (obj == QCoreApplication::instance() && ev->type() == QEvent::LanguageChange) @@ -703,6 +714,8 @@ QVariant ModelList::dataInternal(const ModelInfo *info, int role) const return info->isDefault; case OnlineRole: return info->isOnline; + case CompatibleApiRole: + return info->isCompatibleApi; case DescriptionRole: return info->description(); case RequiresVersionRole: @@ -859,6 +872,8 @@ void ModelList::updateData(const QString &id, const QVector info->isDefault = value.toBool(); break; case OnlineRole: info->isOnline = value.toBool(); break; + case CompatibleApiRole: + info->isCompatibleApi = value.toBool(); break; case DescriptionRole: info->setDescription(value.toString()); break; case RequiresVersionRole: @@ -1079,6 +1094,7 @@ QString ModelList::clone(const ModelInfo &model) { ModelList::FilenameRole, model.filename() }, { ModelList::DirpathRole, model.dirpath }, { ModelList::OnlineRole, model.isOnline }, + { ModelList::CompatibleApiRole, model.isCompatibleApi }, { ModelList::IsEmbeddingModelRole, model.isEmbeddingModel }, { ModelList::TemperatureRole, model.temperature() }, { ModelList::TopPRole, model.topP() }, @@ -1113,7 +1129,7 @@ void ModelList::removeInstalled(const ModelInfo &model) { Q_ASSERT(model.installed); Q_ASSERT(!model.isClone()); - Q_ASSERT(model.isDiscovered() || model.description() == "" /*indicates sideloaded*/); + Q_ASSERT(model.isDiscovered() || model.isCompatibleApi || model.description() == "" /*indicates sideloaded*/); removeInternal(model); emit layoutChanged(); } @@ -1260,14 +1276,53 @@ void ModelList::updateModelsFromDirectory() QFileInfo info = it.fileInfo(); + bool isOnline(filename.endsWith(".rmodel")); + bool isCompatibleApi(filename.endsWith("-capi.rmodel")); + + QString name; + QString description; + if (isCompatibleApi) { + QJsonObject obj; + { + QFile file(path + filename); + bool success = file.open(QIODeviceBase::ReadOnly); + (void)success; + Q_ASSERT(success); + QJsonDocument doc = QJsonDocument::fromJson(file.readAll()); + obj = doc.object(); + } + { + QString apiKey(obj["apiKey"].toString()); + QString baseUrl(obj["baseUrl"].toString()); + QString modelName(obj["modelName"].toString()); + apiKey = apiKey.length() < 10 ? "*****" : apiKey.left(5) + "*****"; + name = tr("%1 (%2)").arg(modelName, baseUrl); + description = tr("OpenAI-Compatible API Model
" + "
  • API Key: %1
  • " + "
  • Base URL: %2
  • " + "
  • Model Name: %3
") + .arg(apiKey, baseUrl, modelName); + } + } + for (const QString &id : modelsById) { QVector> data { { InstalledRole, true }, { FilenameRole, filename }, - { OnlineRole, filename.endsWith(".rmodel") }, + { OnlineRole, isOnline }, + { CompatibleApiRole, isCompatibleApi }, { DirpathRole, info.dir().absolutePath() + "/" }, { FilesizeRole, toFileSize(info.size()) }, }; + if (isCompatibleApi) { + // The data will be saved to "GPT4All.ini". + data.append({ NameRole, name }); + // The description is hard-coded into "GPT4All.ini" due to performance issue. + // If the description goes to be dynamic from its .rmodel file, it will get high I/O usage while using the ModelList. + data.append({ DescriptionRole, description }); + // Prompt template should be clear while using ChatML format which is using in most of OpenAI-Compatible API server. + data.append({ PromptTemplateRole, "%1" }); + } updateData(id, data); } } @@ -1657,6 +1712,34 @@ void ModelList::parseModelsJsonFile(const QByteArray &jsonData, bool save) }; updateData(id, data); } + + const QString compatibleDesc = tr("
  • Requires personal API key and the API base URL.
  • " + "
  • WARNING: Will send your chats to " + "the OpenAI-compatible API Server you specified!
  • " + "
  • Your API key will be stored on disk
  • Will only be used" + " to communicate with the OpenAI-compatible API Server
  • "); + + { + const QString modelName = "OpenAI-compatible"; + const QString id = modelName; + if (!contains(id)) + addModel(id); + QVector> data { + { ModelList::NameRole, modelName }, + { ModelList::FilesizeRole, "minimal" }, + { ModelList::OnlineRole, true }, + { ModelList::CompatibleApiRole, true }, + { ModelList::DescriptionRole, + tr("Connect to OpenAI-compatible API server
    %1").arg(compatibleDesc) }, + { ModelList::RequiresVersionRole, "2.7.4" }, + { ModelList::OrderRole, "cf" }, + { ModelList::RamrequiredRole, 0 }, + { ModelList::ParametersRole, "?" }, + { ModelList::QuantRole, "NA" }, + { ModelList::TypeRole, "NA" }, + }; + updateData(id, data); + } } void ModelList::updateDiscoveredInstalled(const ModelInfo &info) diff --git a/gpt4all-chat/modellist.h b/gpt4all-chat/modellist.h index 88c00e70..7c13da8e 100644 --- a/gpt4all-chat/modellist.h +++ b/gpt4all-chat/modellist.h @@ -35,6 +35,7 @@ struct ModelInfo { Q_PROPERTY(bool installed MEMBER installed) Q_PROPERTY(bool isDefault MEMBER isDefault) Q_PROPERTY(bool isOnline MEMBER isOnline) + Q_PROPERTY(bool isCompatibleApi MEMBER isCompatibleApi) Q_PROPERTY(QString description READ description WRITE setDescription) Q_PROPERTY(QString requiresVersion MEMBER requiresVersion) Q_PROPERTY(QString versionRemoved MEMBER versionRemoved) @@ -123,7 +124,17 @@ public: bool calcHash = false; bool installed = false; bool isDefault = false; + // Differences between 'isOnline' and 'isCompatibleApi' in ModelInfo: + // 'isOnline': + // - Indicates whether this is a online model. + // - Linked with the ModelList, fetching info from it. bool isOnline = false; + // 'isCompatibleApi': + // - Indicates whether the model is using the OpenAI-compatible API which user custom. + // - When the property is true, 'isOnline' should also be true. + // - Does not link to the ModelList directly; instead, fetches info from the *-capi.rmodel file and works standalone. + // - Still needs to copy data from gpt4all.ini and *-capi.rmodel to the ModelList in memory while application getting started(as custom .gguf models do). + bool isCompatibleApi = false; QString requiresVersion; QString versionRemoved; qint64 bytesReceived = 0; @@ -276,6 +287,9 @@ class ModelList : public QAbstractListModel public: static ModelList *globalInstance(); + static QString compatibleModelNameHash(QUrl baseUrl, QString modelName); + static QString compatibleModelFilename(QUrl baseUrl, QString modelName); + enum DiscoverSort { Default, Likes, @@ -295,6 +309,7 @@ public: InstalledRole, DefaultRole, OnlineRole, + CompatibleApiRole, DescriptionRole, RequiresVersionRole, VersionRemovedRole, @@ -347,6 +362,7 @@ public: roles[InstalledRole] = "installed"; roles[DefaultRole] = "isDefault"; roles[OnlineRole] = "isOnline"; + roles[CompatibleApiRole] = "isCompatibleApi"; roles[DescriptionRole] = "description"; roles[RequiresVersionRole] = "requiresVersion"; roles[VersionRemovedRole] = "versionRemoved"; diff --git a/gpt4all-chat/network.cpp b/gpt4all-chat/network.cpp index b30b4fdf..e7ee616c 100644 --- a/gpt4all-chat/network.cpp +++ b/gpt4all-chat/network.cpp @@ -99,6 +99,15 @@ Network *Network::globalInstance() return networkInstance(); } +bool Network::isHttpUrlValid(QUrl url) { + if (!url.isValid()) + return false; + QString scheme(url.scheme()); + if (scheme != "http" && scheme != "https") + return false; + return true; +} + Network::Network() : QObject{nullptr} { diff --git a/gpt4all-chat/network.h b/gpt4all-chat/network.h index bf35ac11..7f4ae566 100644 --- a/gpt4all-chat/network.h +++ b/gpt4all-chat/network.h @@ -23,6 +23,7 @@ class Network : public QObject Q_OBJECT public: static Network *globalInstance(); + static bool isHttpUrlValid(const QUrl url); Q_INVOKABLE QString generateUniqueId() const; Q_INVOKABLE bool sendConversation(const QString &ingestId, const QString &conversation); diff --git a/gpt4all-chat/qml/AddModelView.qml b/gpt4all-chat/qml/AddModelView.qml index ad69e00a..c43b7f56 100644 --- a/gpt4all-chat/qml/AddModelView.qml +++ b/gpt4all-chat/qml/AddModelView.qml @@ -25,6 +25,10 @@ Rectangle { color: theme.viewBackground signal modelsViewRequested() + ToastManager { + id: messageToast + } + PopupDialog { id: downloadingErrorPopup anchors.centerIn: parent @@ -437,10 +441,35 @@ Rectangle { text: qsTr("Install") font.pixelSize: theme.fontSizeLarge onClicked: { - if (apiKey.text === "") + var apiKeyText = apiKey.text.trim(), + baseUrlText = baseUrl.text.trim(), + modelNameText = modelName.text.trim(); + + var apiKeyOk = apiKeyText !== "", + baseUrlOk = !isCompatibleApi || baseUrlText !== "", + modelNameOk = !isCompatibleApi || modelNameText !== ""; + + if (!apiKeyOk) apiKey.showError(); + if (!baseUrlOk) + baseUrl.showError(); + if (!modelNameOk) + modelName.showError(); + + if (!apiKeyOk || !baseUrlOk || !modelNameOk) + return; + + if (!isCompatibleApi) + Download.installModel( + filename, + apiKeyText, + ); else - Download.installModel(filename, apiKey.text); + Download.installCompatibleModel( + modelNameText, + apiKeyText, + baseUrlText, + ); } Accessible.role: Accessible.Button Accessible.name: qsTr("Install") @@ -571,16 +600,59 @@ Rectangle { Layout.alignment: Qt.AlignTop | Qt.AlignHCenter wrapMode: Text.WrapAnywhere function showError() { - apiKey.placeholderTextColor = theme.textErrorColor + messageToast.show(qsTr("ERROR: $API_KEY is empty.")); + apiKey.placeholderTextColor = theme.textErrorColor; } onTextChanged: { - apiKey.placeholderTextColor = theme.mutedTextColor + apiKey.placeholderTextColor = theme.mutedTextColor; } placeholderText: qsTr("enter $API_KEY") Accessible.role: Accessible.EditableText Accessible.name: placeholderText Accessible.description: qsTr("Whether the file hash is being calculated") } + + MyTextField { + id: baseUrl + visible: !installed && isOnline && isCompatibleApi + Layout.topMargin: 20 + Layout.leftMargin: 20 + Layout.minimumWidth: 200 + Layout.alignment: Qt.AlignTop | Qt.AlignHCenter + wrapMode: Text.WrapAnywhere + function showError() { + messageToast.show(qsTr("ERROR: $BASE_URL is empty.")); + baseUrl.placeholderTextColor = theme.textErrorColor; + } + onTextChanged: { + baseUrl.placeholderTextColor = theme.mutedTextColor; + } + placeholderText: qsTr("enter $BASE_URL") + Accessible.role: Accessible.EditableText + Accessible.name: placeholderText + Accessible.description: qsTr("Whether the file hash is being calculated") + } + + MyTextField { + id: modelName + visible: !installed && isOnline && isCompatibleApi + Layout.topMargin: 20 + Layout.leftMargin: 20 + Layout.minimumWidth: 200 + Layout.alignment: Qt.AlignTop | Qt.AlignHCenter + wrapMode: Text.WrapAnywhere + function showError() { + messageToast.show(qsTr("ERROR: $MODEL_NAME is empty.")) + modelName.placeholderTextColor = theme.textErrorColor; + } + onTextChanged: { + modelName.placeholderTextColor = theme.mutedTextColor; + } + placeholderText: qsTr("enter $MODEL_NAME") + Accessible.role: Accessible.EditableText + Accessible.name: placeholderText + Accessible.description: qsTr("Whether the file hash is being calculated") + } } } } @@ -718,4 +790,11 @@ Rectangle { } } } + + Connections { + target: Download + function onToastMessage(message) { + messageToast.show(message); + } + } } diff --git a/gpt4all-chat/qml/ChatView.qml b/gpt4all-chat/qml/ChatView.qml index e0b75d26..e0388efb 100644 --- a/gpt4all-chat/qml/ChatView.qml +++ b/gpt4all-chat/qml/ChatView.qml @@ -696,6 +696,7 @@ Rectangle { rightPadding: 60 leftPadding: 60 property string defaultModel: "" + property string defaultModelName: "" function updateDefaultModel() { var i = comboBox.find(MySettings.userDefaultModel) if (i !== -1) { @@ -703,9 +704,14 @@ Rectangle { } else { defaultModel = comboBox.valueAt(0); } + if (defaultModel !== "") { + defaultModelName = ModelList.modelInfo(defaultModel).name; + } else { + defaultModelName = ""; + } } - text: qsTr("Load \u00B7 %1 (default) \u2192").arg(defaultModel); + text: qsTr("Load \u00B7 %1 (default) \u2192").arg(defaultModelName); onClicked: { var i = comboBox.find(MySettings.userDefaultModel) if (i !== -1) { diff --git a/gpt4all-chat/qml/ModelsView.qml b/gpt4all-chat/qml/ModelsView.qml index b92fd8e9..8a244227 100644 --- a/gpt4all-chat/qml/ModelsView.qml +++ b/gpt4all-chat/qml/ModelsView.qml @@ -17,6 +17,10 @@ Rectangle { signal addModelViewRequested() + ToastManager { + id: messageToast + } + ColumnLayout { anchors.fill: parent anchors.margins: 20 @@ -233,10 +237,35 @@ Rectangle { text: qsTr("Install") font.pixelSize: theme.fontSizeLarge onClicked: { - if (apiKey.text === "") + var apiKeyText = apiKey.text.trim(), + baseUrlText = baseUrl.text.trim(), + modelNameText = modelName.text.trim(); + + var apiKeyOk = apiKeyText !== "", + baseUrlOk = !isCompatibleApi || baseUrlText !== "", + modelNameOk = !isCompatibleApi || modelNameText !== ""; + + if (!apiKeyOk) apiKey.showError(); + if (!baseUrlOk) + baseUrl.showError(); + if (!modelNameOk) + modelName.showError(); + + if (!apiKeyOk || !baseUrlOk || !modelNameOk) + return; + + if (!isCompatibleApi) + Download.installModel( + filename, + apiKeyText, + ); else - Download.installModel(filename, apiKey.text); + Download.installCompatibleModel( + modelNameText, + apiKeyText, + baseUrlText, + ); } Accessible.role: Accessible.Button Accessible.name: qsTr("Install") @@ -367,16 +396,59 @@ Rectangle { Layout.alignment: Qt.AlignTop | Qt.AlignHCenter wrapMode: Text.WrapAnywhere function showError() { - apiKey.placeholderTextColor = theme.textErrorColor + messageToast.show(qsTr("ERROR: $API_KEY is empty.")); + apiKey.placeholderTextColor = theme.textErrorColor; } onTextChanged: { - apiKey.placeholderTextColor = theme.mutedTextColor + apiKey.placeholderTextColor = theme.mutedTextColor; } placeholderText: qsTr("enter $API_KEY") Accessible.role: Accessible.EditableText Accessible.name: placeholderText Accessible.description: qsTr("Whether the file hash is being calculated") } + + MyTextField { + id: baseUrl + visible: !installed && isOnline && isCompatibleApi + Layout.topMargin: 20 + Layout.leftMargin: 20 + Layout.minimumWidth: 200 + Layout.alignment: Qt.AlignTop | Qt.AlignHCenter + wrapMode: Text.WrapAnywhere + function showError() { + messageToast.show(qsTr("ERROR: $BASE_URL is empty.")); + baseUrl.placeholderTextColor = theme.textErrorColor; + } + onTextChanged: { + baseUrl.placeholderTextColor = theme.mutedTextColor; + } + placeholderText: qsTr("enter $BASE_URL") + Accessible.role: Accessible.EditableText + Accessible.name: placeholderText + Accessible.description: qsTr("Whether the file hash is being calculated") + } + + MyTextField { + id: modelName + visible: !installed && isOnline && isCompatibleApi + Layout.topMargin: 20 + Layout.leftMargin: 20 + Layout.minimumWidth: 200 + Layout.alignment: Qt.AlignTop | Qt.AlignHCenter + wrapMode: Text.WrapAnywhere + function showError() { + messageToast.show(qsTr("ERROR: $MODEL_NAME is empty.")) + modelName.placeholderTextColor = theme.textErrorColor; + } + onTextChanged: { + modelName.placeholderTextColor = theme.mutedTextColor; + } + placeholderText: qsTr("enter $MODEL_NAME") + Accessible.role: Accessible.EditableText + Accessible.name: placeholderText + Accessible.description: qsTr("Whether the file hash is being calculated") + } } } } @@ -514,4 +586,11 @@ Rectangle { } } } + + Connections { + target: Download + function onToastMessage(message) { + messageToast.show(message); + } + } } diff --git a/gpt4all-chat/qml/Toast.qml b/gpt4all-chat/qml/Toast.qml new file mode 100644 index 00000000..c9e26254 --- /dev/null +++ b/gpt4all-chat/qml/Toast.qml @@ -0,0 +1,98 @@ +/* + * SPDX-License-Identifier: MIT + * Source: https://gist.github.com/jonmcclung/bae669101d17b103e94790341301c129 + * Adapted from StackOverflow: http://stackoverflow.com/questions/26879266/make-toast-in-android-by-qml + */ + +import QtQuick 2.0 + +/** + * @brief An Android-like timed message text in a box that self-destroys when finished if desired + */ +Rectangle { + /** + * Public + */ + + /** + * @brief Shows this Toast + * + * @param {string} text Text to show + * @param {real} duration Duration to show in milliseconds, defaults to 3000 + */ + function show(text, duration=3000) { + message.text = text; + if (typeof duration !== "undefined") { // checks if parameter was passed + time = Math.max(duration, 2 * fadeTime); + } + else { + time = defaultTime; + } + animation.start(); + } + + property bool selfDestroying: false // whether this Toast will self-destroy when it is finished + + /** + * Private + */ + + id: root + + readonly property real defaultTime: 3000 + property real time: defaultTime + readonly property real fadeTime: 300 + + property real margin: 10 + + anchors { + left: parent.left + right: parent.right + margins: margin + } + + height: message.height + margin + radius: margin + + opacity: 0 + color: "#222222" + + Text { + id: message + color: "white" + wrapMode: Text.Wrap + horizontalAlignment: Text.AlignHCenter + anchors { + top: parent.top + left: parent.left + right: parent.right + margins: margin / 2 + } + } + + SequentialAnimation on opacity { + id: animation + running: false + + + NumberAnimation { + to: .9 + duration: fadeTime + } + + PauseAnimation { + duration: time - 2 * fadeTime + } + + NumberAnimation { + to: 0 + duration: fadeTime + } + + onRunningChanged: { + if (!running && selfDestroying) { + root.destroy(); + } + } + } +} diff --git a/gpt4all-chat/qml/ToastManager.qml b/gpt4all-chat/qml/ToastManager.qml new file mode 100644 index 00000000..e4d509fc --- /dev/null +++ b/gpt4all-chat/qml/ToastManager.qml @@ -0,0 +1,60 @@ +/* + * SPDX-License-Identifier: MIT + * Source: https://gist.github.com/jonmcclung/bae669101d17b103e94790341301c129 + * Adapted from StackOverflow: http://stackoverflow.com/questions/26879266/make-toast-in-android-by-qml + */ + +import QtQuick 2.0 + +/** + * @brief Manager that creates Toasts dynamically + */ +ListView { + /** + * Public + */ + + /** + * @brief Shows a Toast + * + * @param {string} text Text to show + * @param {real} duration Duration to show in milliseconds, defaults to 3000 + */ + function show(text, duration=3000) { + model.insert(0, {text: text, duration: duration}); + } + + /** + * Private + */ + + id: root + + z: Infinity + spacing: 5 + anchors.fill: parent + anchors.bottomMargin: 10 + verticalLayoutDirection: ListView.BottomToTop + + interactive: false + + displaced: Transition { + NumberAnimation { + properties: "y" + easing.type: Easing.InOutQuad + } + } + + delegate: Toast { + Component.onCompleted: { + if (typeof duration === "undefined") { + show(text); + } + else { + show(text, duration); + } + } + } + + model: ListModel {id: model} +}