feat: add openai-compatible api models (#2683)

Signed-off-by: Shiranui <supersonic@livemail.tw>
Signed-off-by: Jared Van Bortel <jared@nomic.ai>
Co-authored-by: Jared Van Bortel <jared@nomic.ai>
This commit is contained in:
不知火 Shiranui 2024-07-25 22:02:52 +08:00 committed by GitHub
parent 6b97d0f8ea
commit f9cd2e321c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 539 additions and 15 deletions

View File

@ -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

View File

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

View File

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

View File

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

View File

@ -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);

View File

@ -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<QPair<int, QVariant>
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("<strong>OpenAI-Compatible API Model</strong><br>"
"<ul><li>API Key: %1</li>"
"<li>Base URL: %2</li>"
"<li>Model Name: %3</li></ul>")
.arg(apiKey, baseUrl, modelName);
}
}
for (const QString &id : modelsById) {
QVector<QPair<int, QVariant>> 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("<ul><li>Requires personal API key and the API base URL.</li>"
"<li>WARNING: Will send your chats to "
"the OpenAI-compatible API Server you specified!</li>"
"<li>Your API key will be stored on disk</li><li>Will only be used"
" to communicate with the OpenAI-compatible API Server</li>");
{
const QString modelName = "OpenAI-compatible";
const QString id = modelName;
if (!contains(id))
addModel(id);
QVector<QPair<int, QVariant>> data {
{ ModelList::NameRole, modelName },
{ ModelList::FilesizeRole, "minimal" },
{ ModelList::OnlineRole, true },
{ ModelList::CompatibleApiRole, true },
{ ModelList::DescriptionRole,
tr("<strong>Connect to OpenAI-compatible API server</strong><br> %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)

View File

@ -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";

View File

@ -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}
{

View File

@ -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);

View File

@ -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);
}
}
}

View File

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

View File

@ -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);
}
}
}

View File

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

View File

@ -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}
}