Compare commits

...

6 Commits

Author SHA1 Message Date
Jared Van Bortel
a0efb9d4c0 WIP: polymorphic ChatItem replacement 2024-10-30 16:57:17 -04:00
AT
62cab695eb Add tests for error codes with local API server (#3131)
Signed-off-by: Adam Treat <treat.adam@gmail.com>
Co-authored-by: Jared Van Bortel <jared@nomic.ai>
2024-10-30 10:15:19 -04:00
AT
861453c4d7 Fixup docx parsing (#3140)
Signed-off-by: Adam Treat <treat.adam@gmail.com>
2024-10-28 13:32:16 -04:00
AT
b19db6c20d Add txt and markdown files to attach feature. (#3135)
Signed-off-by: Adam Treat <treat.adam@gmail.com>
Signed-off-by: AT <manyoso@users.noreply.github.com>
Co-authored-by: Jared Van Bortel <jared@nomic.ai>
2024-10-28 11:42:46 -04:00
AT
da00527101 We can't return early here as nChunks > 0 (#3137)
Signed-off-by: Adam Treat <treat.adam@gmail.com>
2024-10-28 11:42:25 -04:00
Benjamin Gallois
57c0974f4a chat: system tray icon and close to tray (#3109)
Signed-off-by: bgallois <benjamin@gallois.cc>
Signed-off-by: Adam Treat <treat.adam@gmail.com>
Co-authored-by: Adam Treat <treat.adam@gmail.com>
2024-10-25 12:20:55 -04:00
26 changed files with 667 additions and 140 deletions

View File

@@ -2,4 +2,4 @@
[flake8]
exclude = .*,__pycache__
max-line-length = 120
extend-ignore = B001,C408,D,DAR,E221,E303,E722,E741,E800,N801,N806,P101,S101,S324,S404,S406,S410,S603,WPS100,WPS110,WPS111,WPS113,WPS114,WPS115,WPS120,WPS2,WPS300,WPS301,WPS304,WPS305,WPS306,WPS309,WPS316,WPS317,WPS318,WPS319,WPS322,WPS323,WPS326,WPS329,WPS330,WPS332,WPS336,WPS337,WPS347,WPS360,WPS361,WPS414,WPS420,WPS421,WPS429,WPS430,WPS431,WPS432,WPS433,WPS437,WPS440,WPS440,WPS441,WPS442,WPS457,WPS458,WPS460,WPS462,WPS463,WPS473,WPS501,WPS504,WPS505,WPS508,WPS509,WPS510,WPS515,WPS516,WPS519,WPS529,WPS531,WPS602,WPS604,WPS605,WPS608,WPS609,WPS613,WPS615
extend-ignore = B001,C408,D,DAR,E221,E303,E722,E741,E800,N801,N806,P101,S101,S324,S404,S406,S410,S603,WPS100,WPS110,WPS111,WPS113,WPS114,WPS115,WPS120,WPS2,WPS300,WPS301,WPS304,WPS305,WPS306,WPS309,WPS316,WPS317,WPS318,WPS319,WPS322,WPS323,WPS326,WPS329,WPS330,WPS332,WPS336,WPS337,WPS347,WPS360,WPS361,WPS407,WPS414,WPS420,WPS421,WPS429,WPS430,WPS431,WPS432,WPS433,WPS437,WPS440,WPS440,WPS441,WPS442,WPS457,WPS458,WPS460,WPS462,WPS463,WPS473,WPS501,WPS504,WPS505,WPS508,WPS509,WPS510,WPS515,WPS516,WPS519,WPS520,WPS529,WPS531,WPS602,WPS604,WPS605,WPS608,WPS609,WPS613,WPS615

View File

@@ -6,9 +6,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]
### Added
- Add ability to attach text, markdown, and rst files to chat ([#3135](https://github.com/nomic-ai/gpt4all/pull/3135))
- Add feature to minimize to system tray (by [@bgallois](https://github.com/bgallois) in ([#3109](https://github.com/nomic-ai/gpt4all/pull/3109))
### Changed
- Implement Qt 6.8 compatibility ([#3121](https://github.com/nomic-ai/gpt4all/pull/3121))
### Fixed
- Fix bug in GUI when localdocs encounters binary data ([#3137](https://github.com/nomic-ai/gpt4all/pull/3137))
- Fix LocalDocs bugs that prevented some docx files from fully chunking ([#3140](https://github.com/nomic-ai/gpt4all/pull/3140))
## [3.4.2] - 2024-10-16
### Fixed

View File

@@ -75,6 +75,7 @@ configure_file(
"${CMAKE_CURRENT_BINARY_DIR}/config.h"
)
set(CMAKE_FIND_PACKAGE_TARGETS_GLOBAL ON)
find_package(Qt6 6.5 COMPONENTS Core HttpServer LinguistTools Pdf Quick QuickDialogs2 Sql Svg REQUIRED)
if (QT_KNOWN_POLICY_QTP0004)
@@ -102,10 +103,30 @@ add_subdirectory(../gpt4all-backend llmodel)
if (GPT4ALL_TEST)
enable_testing()
# Llama-3.2-1B model
set(TEST_MODEL "Llama-3.2-1B-Instruct-Q4_0.gguf")
set(TEST_MODEL_MD5 "48ff0243978606fdba19d899b77802fc")
set(TEST_MODEL_PATH "${CMAKE_BINARY_DIR}/resources/${TEST_MODEL}")
set(TEST_MODEL_URL "https://huggingface.co/bartowski/Llama-3.2-1B-Instruct-GGUF/resolve/main/${TEST_MODEL}")
# Create a custom command to download the file if it does not exist or if the checksum does not match
add_custom_command(
OUTPUT "${TEST_MODEL_PATH}"
COMMAND ${CMAKE_COMMAND} -E echo "Downloading test model from ${TEST_MODEL_URL} ..."
COMMAND ${CMAKE_COMMAND} -DURL="${TEST_MODEL_URL}" -DOUTPUT_PATH="${TEST_MODEL_PATH}" -DEXPECTED_MD5="${TEST_MODEL_MD5}" -P "${CMAKE_SOURCE_DIR}/cmake/download_model.cmake"
DEPENDS "${CMAKE_SOURCE_DIR}/cmake/download_model.cmake"
)
# Define a custom target that depends on the downloaded model
add_custom_target(download_test_model
DEPENDS "${TEST_MODEL_PATH}"
)
add_subdirectory(tests)
# The 'check' target makes sure the tests and their dependencies are up-to-date before running them
add_custom_target(check COMMAND ${CMAKE_CTEST_COMMAND} --output-on-failure DEPENDS chat gpt4all_tests)
add_custom_target(check COMMAND ${CMAKE_CTEST_COMMAND} --output-on-failure DEPENDS download_test_model chat gpt4all_tests)
endif()
set(CHAT_EXE_RESOURCES)
@@ -152,6 +173,12 @@ if (APPLE)
set_source_files_properties(${CHAT_EXE_RESOURCES} PROPERTIES MACOSX_PACKAGE_LOCATION Resources)
endif()
set(MACOS_SOURCES)
if (APPLE)
find_library(COCOA_LIBRARY Cocoa)
list(APPEND MACOS_SOURCES src/macosdock.mm src/macosdock.h)
endif()
qt_add_executable(chat
src/main.cpp
src/chat.cpp src/chat.h
@@ -173,6 +200,7 @@ qt_add_executable(chat
src/server.cpp src/server.h
src/xlsxtomd.cpp src/xlsxtomd.h
${CHAT_EXE_RESOURCES}
${MACOS_SOURCES}
)
gpt4all_add_warning_options(chat)
@@ -210,6 +238,7 @@ qt_add_qml_module(chat
qml/MyDialog.qml
qml/MyDirectoryField.qml
qml/MyFileDialog.qml
qml/MyFileIcon.qml
qml/MyFolderDialog.qml
qml/MyFancyLink.qml
qml/MyMenu.qml
@@ -244,6 +273,7 @@ qt_add_qml_module(chat
icons/eject.svg
icons/email.svg
icons/file-doc.svg
icons/file-docx.svg
icons/file-md.svg
icons/file-pdf.svg
icons/file-txt.svg
@@ -357,6 +387,9 @@ target_link_libraries(chat
target_link_libraries(chat
PRIVATE llmodel SingleApplication fmt::fmt duckx::duckx QXlsx)
if (APPLE)
target_link_libraries(chat PRIVATE ${COCOA_LIBRARY})
endif()
# -- install --

View File

@@ -0,0 +1,12 @@
if(NOT DEFINED URL OR NOT DEFINED OUTPUT_PATH OR NOT DEFINED EXPECTED_MD5)
message(FATAL_ERROR "Usage: cmake -DURL=<url> -DOUTPUT_PATH=<path> -DEXPECTED_MD5=<md5> -P download_model.cmake")
endif()
message(STATUS "Downloading model from ${URL} to ${OUTPUT_PATH} ...")
file(DOWNLOAD "${URL}" "${OUTPUT_PATH}" EXPECTED_MD5 "${EXPECTED_MD5}" STATUS status)
list(GET status 0 status_code)
if(NOT status_code EQUAL 0)
message(FATAL_ERROR "Failed to download model: ${status}")
endif()

View File

@@ -3,7 +3,7 @@ set(BUILD_SHARED_LIBS OFF)
set(FMT_INSTALL OFF)
add_subdirectory(fmt)
set(QAPPLICATION_CLASS QGuiApplication)
set(QAPPLICATION_CLASS QApplication)
add_subdirectory(SingleApplication)
set(DUCKX_INSTALL OFF)

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><line x1="152" y1="96" x2="208" y2="96" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="152" y1="160" x2="208" y2="160" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M64,72V40a8,8,0,0,1,8-8H200a8,8,0,0,1,8,8V216a8,8,0,0,1-8,8H72a8,8,0,0,1-8-8V184" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><polyline points="64 104 76 152 92 120 108 152 120 104" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><rect x="32" y="72" width="120" height="112" rx="8" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>

After

Width:  |  Height:  |  Size: 893 B

View File

@@ -12,6 +12,7 @@ import network
import gpt4all
import localdocs
import mysettings
import Qt.labs.platform
Window {
id: window
@@ -22,6 +23,43 @@ Window {
visible: true
title: qsTr("GPT4All v%1").arg(Qt.application.version)
SystemTrayIcon {
id: systemTrayIcon
property bool shouldClose: false
visible: MySettings.systemTray && !shouldClose
icon.source: "qrc:/gpt4all/icons/gpt4all.svg"
function restore() {
LLM.showDockIcon();
window.show();
window.raise();
window.requestActivate();
}
onActivated: function(reason) {
if (reason === SystemTrayIcon.Context && Qt.platform.os !== "osx")
menu.open();
else if (reason === SystemTrayIcon.Trigger)
restore();
}
menu: Menu {
MenuItem {
text: qsTr("Restore")
onTriggered: systemTrayIcon.restore()
}
MenuItem {
text: qsTr("Quit")
onTriggered: {
systemTrayIcon.restore();
systemTrayIcon.shouldClose = true;
window.shouldClose = true;
savingPopup.open();
ChatListModel.saveChats();
}
}
}
}
Settings {
property alias x: window.x
property alias y: window.y
@@ -156,7 +194,7 @@ Window {
font.pixelSize: theme.fontSizeLarge
}
property bool hasSaved: false
property bool shouldClose: false
PopupDialog {
id: savingPopup
@@ -180,9 +218,18 @@ Window {
}
onClosing: function(close) {
if (window.hasSaved)
if (systemTrayIcon.visible) {
LLM.hideDockIcon();
window.visible = false;
ChatListModel.saveChats();
close.accepted = false;
return;
}
if (window.shouldClose)
return;
window.shouldClose = true;
savingPopup.open();
ChatListModel.saveChats();
close.accepted = false
@@ -191,9 +238,9 @@ Window {
Connections {
target: ChatListModel
function onSaveChatsFinished() {
window.hasSaved = true;
savingPopup.close();
window.close()
if (window.shouldClose)
window.close()
}
}

View File

@@ -504,17 +504,34 @@ MySettingsTab {
}
}
MySettingsLabel {
id: serverChatLabel
text: qsTr("Enable Local API Server")
helpText: qsTr("Expose an OpenAI-Compatible server to localhost. WARNING: Results in increased resource usage.")
id: trayLabel
text: qsTr("Enable System Tray")
helpText: qsTr("The application will minimize to the system tray when the window is closed.")
Layout.row: 13
Layout.column: 0
}
MyCheckBox {
id: serverChatBox
id: trayBox
Layout.row: 13
Layout.column: 2
Layout.alignment: Qt.AlignRight
checked: MySettings.systemTray
onClicked: {
MySettings.systemTray = !MySettings.systemTray
}
}
MySettingsLabel {
id: serverChatLabel
text: qsTr("Enable Local API Server")
helpText: qsTr("Expose an OpenAI-Compatible server to localhost. WARNING: Results in increased resource usage.")
Layout.row: 14
Layout.column: 0
}
MyCheckBox {
id: serverChatBox
Layout.row: 14
Layout.column: 2
Layout.alignment: Qt.AlignRight
checked: MySettings.serverChat
onClicked: {
MySettings.serverChat = !MySettings.serverChat
@@ -524,7 +541,7 @@ MySettingsTab {
id: serverPortLabel
text: qsTr("API Server Port")
helpText: qsTr("The port to use for the local server. Requires restart.")
Layout.row: 14
Layout.row: 15
Layout.column: 0
}
MyTextField {
@@ -532,7 +549,7 @@ MySettingsTab {
text: MySettings.networkPort
color: theme.textColor
font.pixelSize: theme.fontSizeLarge
Layout.row: 14
Layout.row: 15
Layout.column: 2
Layout.minimumWidth: 200
Layout.maximumWidth: 200
@@ -577,12 +594,12 @@ MySettingsTab {
id: updatesLabel
text: qsTr("Check For Updates")
helpText: qsTr("Manually check for an update to GPT4All.");
Layout.row: 15
Layout.row: 16
Layout.column: 0
}
MySettingsButton {
Layout.row: 15
Layout.row: 16
Layout.column: 2
Layout.alignment: Qt.AlignRight
text: qsTr("Updates");
@@ -593,7 +610,7 @@ MySettingsTab {
}
Rectangle {
Layout.row: 16
Layout.row: 17
Layout.column: 0
Layout.columnSpan: 3
Layout.fillWidth: true

View File

@@ -915,30 +915,12 @@ Rectangle {
anchors.fill: parent
anchors.margins: 5
Item {
id: attachmentFileIcon
width: 40
height: 40
Image {
id: fileIcon
anchors.fill: parent
visible: false
sourceSize.width: 40
sourceSize.height: 40
mipmap: true
source: {
return "qrc:/gpt4all/icons/file-xls.svg"
}
}
ColorOverlay {
anchors.fill: fileIcon
source: fileIcon
color: theme.textColor
}
MyFileIcon {
iconSize: 40
fileName: modelData.file
}
Text {
id: attachmentFileText
width: 295
height: 40
text: modelData.file
@@ -1343,32 +1325,11 @@ Rectangle {
id: title
spacing: 5
Layout.maximumWidth: 180
Item {
Layout.preferredWidth: 24
Layout.preferredHeight: 24
Image {
id: fileIcon
anchors.fill: parent
visible: false
sourceSize.width: 24
sourceSize.height: 24
mipmap: true
source: {
if (modelData.file.toLowerCase().endsWith(".txt"))
return "qrc:/gpt4all/icons/file-txt.svg"
else if (modelData.file.toLowerCase().endsWith(".pdf"))
return "qrc:/gpt4all/icons/file-pdf.svg"
else if (modelData.file.toLowerCase().endsWith(".md"))
return "qrc:/gpt4all/icons/file-md.svg"
else
return "qrc:/gpt4all/icons/file.svg"
}
}
ColorOverlay {
anchors.fill: fileIcon
source: fileIcon
color: theme.textColor
}
MyFileIcon {
iconSize: 24
fileName: modelData.file
Layout.preferredWidth: iconSize
Layout.preferredHeight: iconSize
}
Text {
Layout.maximumWidth: 156
@@ -1949,30 +1910,12 @@ Rectangle {
anchors.fill: parent
anchors.margins: 5
Item {
id: attachmentFileIcon2
width: 40
height: 40
Image {
id: fileIcon2
anchors.fill: parent
visible: false
sourceSize.width: 40
sourceSize.height: 40
mipmap: true
source: {
return "qrc:/gpt4all/icons/file-xls.svg"
}
}
ColorOverlay {
anchors.fill: fileIcon2
source: fileIcon2
color: theme.textColor
}
MyFileIcon {
iconSize: 40
fileName: model.file
}
Text {
id: attachmentFileText2
width: 265
height: 40
text: model.file
@@ -2188,7 +2131,7 @@ Rectangle {
MyFileDialog {
id: fileDialog
nameFilters: ["Excel files (*.xlsx)"]
nameFilters: ["All Supported Files (*.txt *.md *.rst *.xlsx)", "Text Files (*.txt *.md *.rst)", "Excel Worksheets (*.xlsx)"]
}
MyMenu {

View File

@@ -0,0 +1,41 @@
import QtCore
import QtQuick
import QtQuick.Controls
import QtQuick.Controls.Basic
import Qt5Compat.GraphicalEffects
Item {
id: fileIcon
property real iconSize: 24
property string fileName: ""
implicitWidth: iconSize
implicitHeight: iconSize
Image {
id: fileImage
anchors.fill: parent
visible: false
sourceSize.width: iconSize
sourceSize.height: iconSize
mipmap: true
source: {
if (fileIcon.fileName.toLowerCase().endsWith(".txt"))
return "qrc:/gpt4all/icons/file-txt.svg"
else if (fileIcon.fileName.toLowerCase().endsWith(".pdf"))
return "qrc:/gpt4all/icons/file-pdf.svg"
else if (fileIcon.fileName.toLowerCase().endsWith(".md"))
return "qrc:/gpt4all/icons/file-md.svg"
else if (fileIcon.fileName.toLowerCase().endsWith(".xlsx"))
return "qrc:/gpt4all/icons/file-xls.svg"
else if (fileIcon.fileName.toLowerCase().endsWith(".docx"))
return "qrc:/gpt4all/icons/file-docx.svg"
else
return "qrc:/gpt4all/icons/file.svg"
}
}
ColorOverlay {
anchors.fill: fileImage
source: fileImage
color: theme.textColor
}
}

View File

@@ -132,7 +132,13 @@ void Chat::newPromptResponsePair(const QString &prompt, const QList<QUrl> &attac
Q_ASSERT(url.isLocalFile());
const QString localFilePath = url.toLocalFile();
const QFileInfo info(localFilePath);
Q_ASSERT(info.suffix() == "xlsx"); // We only support excel right now
Q_ASSERT(
info.suffix().toLower() == "xlsx" ||
info.suffix().toLower() == "txt" ||
info.suffix().toLower() == "md" ||
info.suffix().toLower() == "rst"
);
PromptAttachment attached;
attached.url = url;

View File

@@ -130,6 +130,7 @@ public:
QList<QString> generatedQuestions() const { return m_generatedQuestions; }
bool needsSave() const { return m_needsSave; }
void setNeedsSave(bool n) { m_needsSave = n; }
public Q_SLOTS:
void serverNewPromptResponsePair(const QString &prompt, const QList<PromptAttachment> &attachments = {});

View File

@@ -51,6 +51,10 @@ void ChatListModel::loadChats()
connect(thread, &ChatsRestoreThread::finished, thread, &QObject::deleteLater);
thread->start();
ChatSaver *saver = new ChatSaver;
connect(this, &ChatListModel::requestSaveChats, saver, &ChatSaver::saveChats, Qt::QueuedConnection);
connect(saver, &ChatSaver::saveChatsFinished, this, &ChatListModel::saveChatsFinished, Qt::QueuedConnection);
connect(MySettings::globalInstance(), &MySettings::serverChatChanged, this, &ChatListModel::handleServerEnabledChanged);
}
@@ -88,9 +92,6 @@ void ChatListModel::saveChats()
return;
}
ChatSaver *saver = new ChatSaver;
connect(this, &ChatListModel::requestSaveChats, saver, &ChatSaver::saveChats, Qt::QueuedConnection);
connect(saver, &ChatSaver::saveChatsFinished, this, &ChatListModel::saveChatsFinished, Qt::QueuedConnection);
emit requestSaveChats(toSave);
}
@@ -128,6 +129,7 @@ void ChatSaver::saveChats(const QVector<Chat *> &chats)
continue;
}
chat->setNeedsSave(false);
if (originalFile.exists())
originalFile.remove();
tempFile.rename(filePath);

View File

@@ -33,7 +33,6 @@ class ChatSaver : public QObject
Q_OBJECT
public:
explicit ChatSaver();
void stop();
Q_SIGNALS:
void saveChatsFinished();
@@ -238,7 +237,6 @@ public Q_SLOTS:
Q_SIGNALS:
void countChanged();
void currentChatChanged();
void chatsSavedFinished();
void requestSaveChats(const QVector<Chat*> &);
void saveChatsFinished();

View File

@@ -40,6 +40,11 @@ public:
QString processedContent() const
{
const QString localFilePath = url.toLocalFile();
const QFileInfo info(localFilePath);
if (info.suffix().toLower() != "xlsx")
return u"## Attached: %1\n\n%2"_s.arg(file(), content);
QBuffer buffer;
buffer.setData(content);
buffer.open(QIODevice::ReadOnly);

View File

@@ -1208,11 +1208,14 @@ protected:
qsizetype wordEnd = wordStart + 1;
while (wordEnd >= m_buffer.size() || !m_buffer[wordEnd].isSpace()) {
if (wordEnd >= m_buffer.size() && !fillBuffer())
return std::nullopt;
break;
if (!m_buffer[wordEnd].isSpace())
++wordEnd;
}
if (wordStart == wordEnd)
return std::nullopt;
auto size = wordEnd - wordStart;
QString word = std::move(m_buffer);
m_buffer = word.sliced(wordStart + size);
@@ -1220,7 +1223,6 @@ protected:
word.resize(size);
else
word = word.sliced(wordStart, size);
return word;
}
@@ -1232,18 +1234,30 @@ protected:
// try next paragraph
if (!m_paragraph->has_next())
return false;
m_paragraph->next();
m_buffer += u'\n';
}
bool foundText = false;
auto &run = m_run->get_node();
const char *text = run.child("w:t").text().get();
if (!*text && run.child("w:tab"))
text = "\t";
m_run->next();
if (*text) {
m_buffer += QUtf8StringView(text);
return true;
for (auto node = run.first_child(); node; node = node.next_sibling()) {
std::string node_name = node.name();
if (node_name == "w:t") {
const char *text = node.text().get();
if (*text) {
foundText = true;
m_buffer += QUtf8StringView(text);
}
} else if (node_name == "w:br") {
m_buffer += u'\n';
} else if (node_name == "w:tab") {
m_buffer += u'\t';
}
}
m_run->next();
if (foundText) return true;
}
}
@@ -1355,7 +1369,8 @@ ChunkStreamer::Status ChunkStreamer::step()
for (;;) {
if (auto error = m_reader->getError()) {
m_docKey.reset(); // done processing
return *error;
retval = *error;
break;
}
// get a word, if needed
@@ -1515,8 +1530,22 @@ void Database::handleEmbeddingsGenerated(const QVector<EmbeddingResult> &embeddi
for (const auto &[key, stat]: std::as_const(stats).asKeyValueRange()) {
if (!m_collectionMap.contains(key.folder_id)) continue;
CollectionItem item = guiCollectionItem(key.folder_id);
item.currentEmbeddingsToIndex -= stat.nAdded + stat.nSkipped;
item.totalEmbeddingsToIndex -= stat.nSkipped;
Q_ASSERT(item.currentEmbeddingsToIndex >= stat.nAdded + stat.nSkipped);
if (item.currentEmbeddingsToIndex < stat.nAdded + stat.nSkipped) {
qWarning() << "Database ERROR: underflow in current embeddings to index statistics";
item.currentEmbeddingsToIndex = 0;
} else {
item.currentEmbeddingsToIndex -= stat.nAdded + stat.nSkipped;
}
Q_ASSERT(item.totalEmbeddingsToIndex >= stat.nSkipped);
if (item.totalEmbeddingsToIndex < stat.nSkipped) {
qWarning() << "Database ERROR: underflow in total embeddings to index statistics";
item.totalEmbeddingsToIndex = 0;
} else {
item.totalEmbeddingsToIndex -= stat.nSkipped;
}
if (!stat.lastFile.isNull())
item.fileCurrentlyProcessing = stat.lastFile;
@@ -1746,7 +1775,13 @@ void Database::scanQueue()
dequeue:
auto item = guiCollectionItem(folder_id);
item.currentBytesToIndex -= info.file.size();
Q_ASSERT(item.currentBytesToIndex >= info.file.size());
if (item.currentBytesToIndex < info.file.size()) {
qWarning() << "Database ERROR: underflow in current bytes to index statistics";
item.currentBytesToIndex = 0;
} else {
item.currentBytesToIndex -= info.file.size();
}
updateGuiForCollectionItem(item);
return updateFolderToIndex(folder_id, countForFolder);
}

View File

@@ -19,6 +19,10 @@
# include "network.h"
#endif
#ifdef Q_OS_MAC
#include "macosdock.h"
#endif
using namespace Qt::Literals::StringLiterals;
class MyLLM: public LLM { };
@@ -105,3 +109,21 @@ bool LLM::isNetworkOnline() const
auto * netinfo = QNetworkInformation::instance();
return !netinfo || netinfo->reachability() == QNetworkInformation::Reachability::Online;
}
void LLM::showDockIcon() const
{
#ifdef Q_OS_MAC
MacOSDock::showIcon();
#else
qt_noop();
#endif
}
void LLM::hideDockIcon() const
{
#ifdef Q_OS_MAC
MacOSDock::hideIcon();
#else
qt_noop();
#endif
}

View File

@@ -23,6 +23,9 @@ public:
Q_INVOKABLE QString systemTotalRAMInGBString() const;
Q_INVOKABLE bool isNetworkOnline() const;
Q_INVOKABLE void showDockIcon() const;
Q_INVOKABLE void hideDockIcon() const;
Q_SIGNALS:
void isNetworkOnlineChanged();

View File

@@ -0,0 +1,9 @@
#ifndef MACOSDOCK_H
#define MACOSDOCK_H
struct MacOSDock {
static void showIcon();
static void hideIcon();
};
#endif // MACOSDOCK_H

View File

@@ -0,0 +1,13 @@
#include "macosdock.h"
#include <Cocoa/Cocoa.h>
void MacOSDock::showIcon()
{
[[NSApplication sharedApplication] setActivationPolicy:NSApplicationActivationPolicyRegular];
}
void MacOSDock::hideIcon()
{
[[NSApplication sharedApplication] setActivationPolicy:NSApplicationActivationPolicyProhibited];
}

View File

@@ -44,6 +44,7 @@ static void raiseWindow(QWindow *window)
SetForegroundWindow(hwnd);
#else
LLM::globalInstance()->showDockIcon();
window->show();
window->raise();
window->requestActivate();

View File

@@ -0,0 +1,141 @@
#include <QString>
#include <QDataStream>
#include <QtGlobal>
#include <fmt/format.h>
#define THROW_IF_BAD(stream) \
do { \
if (auto status = (stream).status(); status != QDataStream::Status::OK) \
throw std::runtime_error(fmt::format("bad stream status: {}", status)); \
} while (0) \
inline namespace MessageEnums {
Q_NAMESPACE
// for DataLake
enum class MessageRating : quint8 { Unrated = 0, Positive = 1, Negative = 2, Max = Negative };
Q_ENUM_NS(MessageRating)
}
// TODO(Adam): Maybe we should include the model name here as well as timestamp?
class MessageContent {
Q_GADGET
Q_PROPERTY(QString role READ role CONSTANT)
Q_PROPERTY(QString content MEMBER content)
protected:
enum class Type : quint8 { Prompt = 0, Response = 1, Max = Response };
public:
virtual QString role() const = 0;
friend auto deserialize(QDataStream &stream, ChatModel *model) -> std::unique_ptr<MessageContent>
{
union { quint8 u8; };
THROW_IF_BAD(stream);
stream >> u8; // version
THROW_IF_BAD(stream);
if (u8 > VERSION)
throw std::invalid_argument(fmt::format("unknown version: {}", u8));
stream >> u8; // type
THROW_IF_BAD(stream);
if (u8 > Type::Max)
throw std::invalid_argument(fmt::format("unknown type: {}", u8));
auto type = Type(u8);
std::unique_ptr<MessageContent> result;
switch (type) {
case Prompt: result = std::make_unique<PromptContent> (); break;
case Response: result = std::make_unique<ResponseContent>(model); break;
}
stream >> result->content;
THROW_IF_BAD(stream);
// TODO: add more common fields as needed
result->deserializeInternal(stream, version);
return result;
}
protected:
virtual void deserializeInternal(QDataStream &stream, quint32 version) = 0;
public:
QString content;
private:
static quint8 VERSION = 0;
};
Q_DECLARE_METATYPE(MessageContent)
class PromptContent final : public MessageContent {
Q_GADGET
Q_PROPERTY(QList<ResultInfo> sources MEMBER sources)
Q_PROPERTY(QList<ResultInfo> consolidatedSources MEMBER consolidatedSources)
Q_PROPERTY(QList<PromptAttachment> promptAttachments MEMBER promptAttachments)
Q_PROPERTY(QString promptPlusAttachments READ promptPlusAttachments)
public:
QString role() const override { return u"user"_s; }
QString promptPlusAttachments() const
{
if (!promptAttachments.isEmpty()) {
QStringList items;
for (auto &attached : std::as_const(promptAttachments))
items << attached.processedContent();
items << content;
return items.join("\n\n");
}
return content;
}
protected:
void deserializeInternal(QDataStream &stream, quint32 version) override
{
Q_UNUSED(version); // only v0 exists currently
// TODO: ...
}
};
Q_DECLARE_METATYPE(PromptContent)
class ResponseContent final : public MessageContent {
Q_GADGET
Q_PROPERTY(QString newResponse MEMBER newResponse) // for DataLake
Q_PROPERTY(bool currentResponse READ currentResponse)
Q_PROPERTY(bool stopped MEMBER stopped) // for DataLake
Q_PROPERTY(MessageRating rating MEMBER rating) // for DataLake
public:
explicit ResponseContent(ChatModel *model)
: m_model(model) {}
QString role() const override { return u"assistant"_s; }
bool currentResponse() const { return this == m_model->currentResponse(); }
protected:
void deserializeInternal(QDataStream &stream, quint32 version) override
{
Q_UNUSED(version); // only v0 exists currently
stream >> newResponse;
THROW_IF_BAD(stream);
stream >> stopped;
THROW_IF_BAD(stream);
stream >> rating;
THROW_IF_BAD(stream);
}
public:
QString newResponse;
bool stopped = false;
MessageRating rating = MessageRating::Unrated;
private:
ChatModel *m_model;
};
Q_DECLARE_METATYPE(ResponseContent)

View File

@@ -49,6 +49,7 @@ static const QVariantMap basicDefaults {
{ "lastVersionStarted", "" },
{ "networkPort", 4891, },
{ "saveChatsContext", false },
{ "systemTray", false },
{ "serverChat", false },
{ "userDefaultModel", "Application default" },
{ "suggestionMode", QVariant::fromValue(SuggestionMode::LocalDocsOnly) },
@@ -206,6 +207,7 @@ void MySettings::restoreApplicationDefaults()
setDevice(defaults::device);
setThreadCount(defaults::threadCount);
setSaveChatsContext(basicDefaults.value("saveChatsContext").toBool());
setSystemTray(basicDefaults.value("saveTrayContext").toBool());
setServerChat(basicDefaults.value("serverChat").toBool());
setNetworkPort(basicDefaults.value("networkPort").toInt());
setModelPath(defaultLocalModelsPath());
@@ -444,6 +446,7 @@ void MySettings::setThreadCount(int value)
}
bool MySettings::saveChatsContext() const { return getBasicSetting("saveChatsContext" ).toBool(); }
bool MySettings::systemTray() const { return getBasicSetting("systemTray" ).toBool(); }
bool MySettings::serverChat() const { return getBasicSetting("serverChat" ).toBool(); }
int MySettings::networkPort() const { return getBasicSetting("networkPort" ).toInt(); }
QString MySettings::userDefaultModel() const { return getBasicSetting("userDefaultModel" ).toString(); }
@@ -462,6 +465,7 @@ FontSize MySettings::fontSize() const { return FontSize (getEnu
SuggestionMode MySettings::suggestionMode() const { return SuggestionMode(getEnumSetting("suggestionMode", suggestionModeNames)); }
void MySettings::setSaveChatsContext(bool value) { setBasicSetting("saveChatsContext", value); }
void MySettings::setSystemTray(bool value) { setBasicSetting("systemTray", value); }
void MySettings::setServerChat(bool value) { setBasicSetting("serverChat", value); }
void MySettings::setNetworkPort(int value) { setBasicSetting("networkPort", value); }
void MySettings::setUserDefaultModel(const QString &value) { setBasicSetting("userDefaultModel", value); }

View File

@@ -49,6 +49,7 @@ class MySettings : public QObject
Q_OBJECT
Q_PROPERTY(int threadCount READ threadCount WRITE setThreadCount NOTIFY threadCountChanged)
Q_PROPERTY(bool saveChatsContext READ saveChatsContext WRITE setSaveChatsContext NOTIFY saveChatsContextChanged)
Q_PROPERTY(bool systemTray READ systemTray WRITE setSystemTray NOTIFY systemTrayChanged)
Q_PROPERTY(bool serverChat READ serverChat WRITE setServerChat NOTIFY serverChatChanged)
Q_PROPERTY(QString modelPath READ modelPath WRITE setModelPath NOTIFY modelPathChanged)
Q_PROPERTY(QString userDefaultModel READ userDefaultModel WRITE setUserDefaultModel NOTIFY userDefaultModelChanged)
@@ -142,6 +143,8 @@ public:
void setThreadCount(int value);
bool saveChatsContext() const;
void setSaveChatsContext(bool value);
bool systemTray() const;
void setSystemTray(bool value);
bool serverChat() const;
void setServerChat(bool value);
QString modelPath();
@@ -218,6 +221,7 @@ Q_SIGNALS:
void suggestedFollowUpPromptChanged(const ModelInfo &info);
void threadCountChanged();
void saveChatsContextChanged();
void systemTrayChanged();
void serverChatChanged();
void modelPathChanged();
void userDefaultModelChanged();

View File

@@ -15,7 +15,7 @@ add_test(NAME ChatPythonTests
COMMAND ${Python3_EXECUTABLE} -m pytest --color=yes "${CMAKE_CURRENT_SOURCE_DIR}/python"
)
set_tests_properties(ChatPythonTests PROPERTIES
ENVIRONMENT "CHAT_EXECUTABLE=${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/chat"
ENVIRONMENT "CHAT_EXECUTABLE=${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/chat;TEST_MODEL_PATH=${TEST_MODEL_PATH}"
TIMEOUT 60
)

View File

@@ -1,9 +1,11 @@
import os
import shutil
import signal
import subprocess
import sys
import tempfile
import textwrap
from contextlib import contextmanager
from pathlib import Path
from subprocess import CalledProcessError
from typing import Any, Iterator
@@ -20,59 +22,82 @@ class Requestor:
self.session = requests.Session()
self.http_adapter = self.session.adapters['http://']
def get(self, path: str, *, wait: bool = False) -> Any:
return self._request('GET', path, wait)
def get(self, path: str, *, raise_for_status: bool = True, wait: bool = False) -> Any:
return self._request('GET', path, raise_for_status=raise_for_status, wait=wait)
def _request(self, method: str, path: str, wait: bool) -> Any:
def post(self, path: str, data: dict[str, Any] | None, *, raise_for_status: bool = True, wait: bool = False) -> Any:
return self._request('POST', path, data, raise_for_status=raise_for_status, wait=wait)
def _request(
self, method: str, path: str, data: dict[str, Any] | None = None, *, raise_for_status: bool, wait: bool,
) -> Any:
if wait:
retry = Retry(total=None, connect=10, read=False, status=0, other=0, backoff_factor=.01)
else:
retry = Retry(total=False)
self.http_adapter.max_retries = retry # type: ignore[attr-defined]
resp = self.session.request(method, f'http://localhost:4891/v1/{path}')
resp.raise_for_status()
return resp.json()
resp = self.session.request(method, f'http://localhost:4891/v1/{path}', json=data)
if raise_for_status:
resp.raise_for_status()
return resp.json()
try:
json_data = resp.json()
except ValueError:
json_data = None
return resp.status_code, json_data
request = Requestor()
@pytest.fixture
def chat_server_config() -> Iterator[dict[str, str]]:
def create_chat_server_config(tmpdir: Path, model_copied: bool = False) -> dict[str, str]:
xdg_confdir = tmpdir / 'config'
app_confdir = xdg_confdir / 'nomic.ai'
app_confdir.mkdir(parents=True)
with open(app_confdir / 'GPT4All.ini', 'w') as conf:
conf.write(textwrap.dedent(f"""\
[General]
serverChat=true
[download]
lastVersionStarted={config.APP_VERSION}
[network]
isActive=false
usageStatsActive=false
"""))
if model_copied:
app_data_dir = tmpdir / 'share' / 'nomic.ai' / 'GPT4All'
app_data_dir.mkdir(parents=True)
local_env_file_path = Path(os.environ['TEST_MODEL_PATH'])
shutil.copy(local_env_file_path, app_data_dir / local_env_file_path.name)
return dict(
os.environ,
XDG_CACHE_HOME=str(tmpdir / 'cache'),
XDG_DATA_HOME=str(tmpdir / 'share'),
XDG_CONFIG_HOME=str(xdg_confdir),
APPIMAGE=str(tmpdir), # hack to bypass SingleApplication
)
@contextmanager
def prepare_chat_server(model_copied: bool = False) -> Iterator[dict[str, str]]:
if os.name != 'posix' or sys.platform == 'darwin':
pytest.skip('Need non-Apple Unix to use alternate config path')
with tempfile.TemporaryDirectory(prefix='gpt4all-test') as td:
tmpdir = Path(td)
xdg_confdir = tmpdir / 'config'
app_confdir = xdg_confdir / 'nomic.ai'
app_confdir.mkdir(parents=True)
with open(app_confdir / 'GPT4All.ini', 'w') as conf:
conf.write(textwrap.dedent(f"""\
[General]
serverChat=true
[download]
lastVersionStarted={config.APP_VERSION}
[network]
isActive=false
usageStatsActive=false
"""))
yield dict(
os.environ,
XDG_CACHE_HOME=str(tmpdir / 'cache'),
XDG_DATA_HOME=str(tmpdir / 'share'),
XDG_CONFIG_HOME=str(xdg_confdir),
APPIMAGE=str(tmpdir), # hack to bypass SingleApplication
)
config = create_chat_server_config(tmpdir, model_copied=model_copied)
yield config
@pytest.fixture
def chat_server(chat_server_config: dict[str, str]) -> Iterator[None]:
def start_chat_server(config: dict[str, str]) -> Iterator[None]:
chat_executable = Path(os.environ['CHAT_EXECUTABLE']).absolute()
with subprocess.Popen(chat_executable, env=chat_server_config) as process:
with subprocess.Popen(chat_executable, env=config) as process:
try:
yield
except:
@@ -83,5 +108,161 @@ def chat_server(chat_server_config: dict[str, str]) -> Iterator[None]:
raise CalledProcessError(retcode, process.args)
def test_list_models_empty(chat_server: None) -> None:
assert request.get('models', wait=True) == {'object': 'list', 'data': []}
@pytest.fixture
def chat_server() -> Iterator[None]:
with prepare_chat_server(model_copied=False) as config:
yield from start_chat_server(config)
@pytest.fixture
def chat_server_with_model() -> Iterator[None]:
with prepare_chat_server(model_copied=True) as config:
yield from start_chat_server(config)
def test_with_models_empty(chat_server: None) -> None:
# non-sense endpoint
status_code, response = request.get('foobarbaz', wait=True, raise_for_status=False)
assert status_code == 404
assert response is None
# empty model list
response = request.get('models')
assert response == {'object': 'list', 'data': []}
# empty model info
response = request.get('models/foo')
assert response == {}
# POST for model list
status_code, response = request.post('models', data=None, raise_for_status=False)
assert status_code == 405
assert response == {'error': {
'code': None,
'message': 'Not allowed to POST on /v1/models. (HINT: Perhaps you meant to use a different HTTP method?)',
'param': None,
'type': 'invalid_request_error',
}}
# POST for model info
status_code, response = request.post('models/foo', data=None, raise_for_status=False)
assert status_code == 405
assert response == {'error': {
'code': None,
'message': 'Not allowed to POST on /v1/models/*. (HINT: Perhaps you meant to use a different HTTP method?)',
'param': None,
'type': 'invalid_request_error',
}}
# GET for completions
status_code, response = request.get('completions', raise_for_status=False)
assert status_code == 405
assert response == {'error': {
'code': 'method_not_supported',
'message': 'Only POST requests are accepted.',
'param': None,
'type': 'invalid_request_error',
}}
# GET for chat completions
status_code, response = request.get('chat/completions', raise_for_status=False)
assert status_code == 405
assert response == {'error': {
'code': 'method_not_supported',
'message': 'Only POST requests are accepted.',
'param': None,
'type': 'invalid_request_error',
}}
EXPECTED_MODEL_INFO = {
'created': 0,
'id': 'Llama 3.2 1B Instruct',
'object': 'model',
'owned_by': 'humanity',
'parent': None,
'permissions': [
{
'allow_create_engine': False,
'allow_fine_tuning': False,
'allow_logprobs': False,
'allow_sampling': False,
'allow_search_indices': False,
'allow_view': True,
'created': 0,
'group': None,
'id': 'placeholder',
'is_blocking': False,
'object': 'model_permission',
'organization': '*',
},
],
'root': 'Llama 3.2 1B Instruct',
}
EXPECTED_COMPLETIONS_RESPONSE = {
'choices': [
{
'finish_reason': 'stop',
'index': 0,
'logprobs': None,
'references': None,
'text': ' jumps over the lazy dog.',
},
],
'id': 'placeholder',
'model': 'Llama 3.2 1B Instruct',
'object': 'text_completion',
'usage': {
'completion_tokens': 6,
'prompt_tokens': 5,
'total_tokens': 11,
},
}
def test_with_models(chat_server_with_model: None) -> None:
response = request.get('models', wait=True)
assert response == {
'data': [EXPECTED_MODEL_INFO],
'object': 'list',
}
# Test the specific model endpoint
response = request.get('models/Llama 3.2 1B Instruct')
assert response == EXPECTED_MODEL_INFO
# Test the completions endpoint
status_code, response = request.post('completions', data=None, raise_for_status=False)
assert status_code == 400
assert response == {'error': {
'code': None,
'message': 'error parsing request JSON: illegal value',
'param': None,
'type': 'invalid_request_error',
}}
data = {
'model': 'Llama 3.2 1B Instruct',
'prompt': 'The quick brown fox',
'temperature': 0,
}
response = request.post('completions', data=data)
assert len(response['choices']) == 1
assert response['choices'][0].keys() == {'text', 'index', 'logprobs', 'references', 'finish_reason'}
assert response['choices'][0]['text'] == ' jumps over the lazy dog.'
assert 'created' in response
response.pop('created') # Remove the dynamic field for comparison
assert response == EXPECTED_COMPLETIONS_RESPONSE
@pytest.mark.xfail(reason='Assertion failure in GPT4All. See nomic-ai/gpt4all#3133')
def test_with_models_temperature(chat_server_with_model: None) -> None:
data = {
'model': 'Llama 3.2 1B Instruct',
'prompt': 'The quick brown fox',
'temperature': 0.5,
}
request.post('completions', data=data, wait=True, raise_for_status=True)