diff --git a/Modules/Notifications/Sidebar/Chat/ChatContent.qml b/Modules/Notifications/Sidebar/Chat/ChatContent.qml new file mode 100644 index 0000000..a859b96 --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/ChatContent.qml @@ -0,0 +1,280 @@ +pragma ComponentBehavior: Bound + +import ZShell.Config +import ZShell.Llm +import Quickshell +import QtQuick +import QtQuick.Layouts +import qs.Components +import qs.Services + +Item { + id: root + + required property ChatSession chatData + property bool following: true + readonly property int messageCount: chatData.messages.length + + signal requestClose + + function atBottom(): bool { + return list.contentHeight - list.height - list.contentY < 48; + } + + function send(): void { + if (Chat.busy || input.text.trim() === "") + return; + following = true; + Chat.send(chatData.id, input.text); + input.text = ""; + } + + VerticalFadeListView { + id: list + + anchors.bottom: inputRow.top + anchors.bottomMargin: Tokens.spacing.medium + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + cacheBuffer: height * 20 + clip: true + currentIndex: messages.values.length - 1 + fadeAmount: 0.1 + highlightFollowsCurrentItem: true + highlightRangeMode: ListView.ApplyRange + preferredHighlightEnd: list.height + spacing: Tokens.spacing.medium + + CustomScrollBar.vertical: CustomScrollBar { + flickable: list + } + Behavior on contentY { + Anim { + } + } + delegate: ColumnLayout { + id: messageRow + + readonly property bool isUser: modelData.role === ChatMessage.Role.User + required property var modelData + + anchors.left: parent.left + anchors.leftMargin: messageRow.isUser ? 0 : Tokens.spacing.extraSmall + anchors.right: parent.right + anchors.rightMargin: messageRow.isUser ? Tokens.spacing.extraSmall : 0 + + Loader { + Layout.fillWidth: true + Layout.rightMargin: Tokens.spacing.extraLarge + active: !messageRow.isUser && messageRow.modelData.reasoning !== "" + + sourceComponent: CustomRect { + id: reasoning + + property bool expanded: false + + color: Colors.palette.m3surfaceContainer + implicitHeight: expanded ? expandedText.implicitHeight : collapsedText.implicitHeight + Tokens.padding.medium * 2 + radius: Tokens.rounding.medium + + Behavior on implicitHeight { + Anim { + } + } + + Loader { + id: spinnerReasoning + + active: opacity > 0 + anchors.left: parent.left + anchors.margins: Tokens.padding.medium + anchors.verticalCenter: parent.verticalCenter + opacity: messageRow.modelData.reasoningActive ? 1 : 0 + + Behavior on opacity { + Anim { + } + } + sourceComponent: LoadingIndicator { + implicitSize: collapsedText.implicitHeight + } + } + + MaterialIcon { + id: reasoningDone + + anchors.left: parent.left + anchors.margins: Tokens.padding.medium + anchors.verticalCenter: parent.verticalCenter + font.pointSize: Tokens.font.size.large + opacity: messageRow.modelData.reasoningActive || reasoning.expanded ? 0 : 1 + text: "check" + + Behavior on opacity { + Anim { + } + } + } + + CustomText { + id: collapsedText + + anchors.left: messageRow.modelData.reasoningActive ? spinnerReasoning.right : reasoningDone.right + anchors.margins: Tokens.padding.medium + anchors.right: parent.right + anchors.top: parent.top + opacity: reasoning.expanded ? 0 : 1 + text: messageRow.modelData.reasoningActive ? qsTr("Thinking...") : qsTr("Thought for %1s").arg((messageRow.modelData.reasoningElapsedMs / 1000).toFixed(1)) + visible: opacity > 0 + + Behavior on opacity { + Anim { + } + } + } + + CustomText { + id: expandedText + + anchors.left: parent.left + anchors.margins: Tokens.padding.medium + anchors.right: parent.right + anchors.top: parent.top + color: Colors.palette.m3onSurface + opacity: reasoning.expanded ? 1 : 0 + text: messageRow.modelData.reasoning + visible: opacity > 0 + wrapMode: Text.WordWrap + + Behavior on opacity { + Anim { + } + } + } + + StateLayer { + onClicked: reasoning.expanded = !reasoning.expanded + } + } + } + + CustomRect { + Layout.alignment: messageRow.isUser ? Qt.AlignRight : Qt.AlignLeft + color: messageRow.isUser ? Colors.palette.m3primary : Colors.palette.m3surfaceContainer + implicitHeight: Math.max(msgText.implicitHeight + Tokens.padding.medium * 2, spinnerLoader.implicitHeight) + implicitWidth: Math.min(msgText.implicitWidth + Tokens.padding.medium * 2, messageRow.ListView.view.width - Tokens.spacing.extraSmall - Tokens.spacing.extraLarge) + radius: Tokens.rounding.medium + visible: !messageRow.modelData.reasoningActive + + CustomText { + id: msgText + + anchors.left: parent.left + anchors.margins: Tokens.padding.medium + anchors.right: parent.right + anchors.top: parent.top + color: messageRow.isUser ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface + text: messageRow.modelData.content + wrapMode: Text.WordWrap + } + + Loader { + id: spinnerLoader + + active: messageRow.modelData.streaming && msgText.text.length === 0 + anchors.left: parent.left + anchors.leftMargin: 8 + anchors.verticalCenter: parent.verticalCenter + + sourceComponent: Item { + implicitHeight: 18 + implicitWidth: 18 + + LoadingIndicator { + anchors.fill: parent + } + } + } + } + } + model: ScriptModel { + id: messages + + values: root.chatData.messages + } + + Component.onCompleted: positionViewAtEnd() + } + + ColumnLayout { + id: emptyState + + anchors.fill: parent + spacing: Tokens.spacing.small + visible: root.messageCount === 0 && !Chat.busy + + Item { + Layout.fillHeight: true + } + + MaterialIcon { + Layout.alignment: Qt.AlignHCenter + color: Colors.tPalette.m3outlineVariant + font.pointSize: 36 + text: "chat" + } + + CustomText { + Layout.alignment: Qt.AlignHCenter + color: Colors.tPalette.m3onSurfaceVariant + text: qsTr("No messages yet") + } + + Item { + Layout.fillHeight: true + } + } + + RowLayout { + id: inputRow + + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.right: parent.right + spacing: Tokens.spacing.extraSmall + + CustomTextField { + id: input + + Layout.fillWidth: true + placeholderText: qsTr("Send a message...") + type: CustomTextField.Filled + + onAccepted: root.send() + } + + IconButton { + id: actionBtn + + enabled: Chat.busy || input.text.trim() !== "" + font.pointSize: 20 + icon: Chat.busy ? "stop" : "send" + type: IconButton.Filled + + onClicked: Chat.busy ? Chat.stop() : root.send() + } + } + + IconButton { + anchors.left: parent.left + anchors.top: parent.top + icon: "arrow_back" + inactiveColor: Colors.tPalette.m3surfaceContainerHigh + inactiveOnColor: Colors.palette.m3onSurfaceVariant + isRound: true + type: IconButton.Tonal + + onClicked: root.requestClose() + } +} diff --git a/Modules/Notifications/Sidebar/Chat/ChatList.qml b/Modules/Notifications/Sidebar/Chat/ChatList.qml new file mode 100644 index 0000000..c98c385 --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/ChatList.qml @@ -0,0 +1,92 @@ +pragma ComponentBehavior: Bound + +import Quickshell +import QtQuick +import QtQuick.Layouts +import ZShell.Llm +import ZShell.Config +import qs.Services +import qs.Components + +Item { + id: root + + property alias model: list.model + + signal loadChatRequest(content: ChatSession) + signal newChatRequest + + CustomListView { + id: list + + anchors.fill: parent + cacheBuffer: height * 2 + clip: true + spacing: Tokens.spacing.medium + + delegate: CustomRect { + id: chatItem + + required property var modelData + + color: Colors.tPalette.m3surfaceContainer + implicitHeight: layout.implicitHeight + layout.anchors.topMargin + layout.anchors.bottomMargin + implicitWidth: ListView.view.width + radius: Tokens.rounding.medium + + ColumnLayout { + id: layout + + anchors.bottomMargin: Tokens.padding.small + anchors.fill: parent + anchors.leftMargin: Tokens.padding.medium + anchors.rightMargin: Tokens.padding.medium + anchors.topMargin: Tokens.padding.small + spacing: 0 + + CustomText { + id: title + + text: chatItem.modelData.title + } + + CustomText { + id: timestamp + + color: Colors.palette.m3outline + font.pointSize: Tokens.font.size.small + text: chatItem.modelData.updatedAt.toLocaleString(Qt.locale("en_US"), "MMM d, yyyy - h:mm AP") + } + } + + StateLayer { + onClicked: { + root.loadChatRequest(chatItem.modelData); + } + } + } + } + + IconButton { + id: newChatBtn + + anchors.bottom: parent.bottom + anchors.margins: Tokens.padding.small + anchors.right: parent.right + font.pointSize: Math.round(18 * 1.2) + icon: "add" + padding: 8 + radius: Tokens.rounding.full + + onClicked: { + root.newChatRequest(); + } + + Elevation { + anchors.fill: parent + level: newChatBtn.stateLayer.containsMouse ? 4 : 3 + radius: parent.radius + z: -1 + } + } +} diff --git a/Modules/Notifications/Sidebar/Chat/ChatPanel.qml b/Modules/Notifications/Sidebar/Chat/ChatPanel.qml new file mode 100644 index 0000000..57674de --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/ChatPanel.qml @@ -0,0 +1,73 @@ +pragma ComponentBehavior: Bound + +import ZShell.Config +import ZShell.Llm +import QtQuick +import QtQuick.Controls +import Quickshell +import qs.Modules.Notifications.Sidebar +import qs.Components +import qs.Services + +Item { + id: root + + property bool chatOpen: false + required property Props props + + anchors.fill: parent + anchors.margins: Tokens.padding.small + + Component.onCompleted: { + if (root.props.inChat) { + stack.push(chatList); + stack.push(chatContent, { + "chatData": root.props.chatSession + }); + } else + stack.push(chatList); + } + + StackView { + id: stack + + anchors.fill: parent + } + + Component { + id: chatList + + ChatList { + model: ScriptModel { + values: Chat.chats.values + } + + onLoadChatRequest: chat => { + stack.push(chatContent, { + "chatData": chat + }); + root.props.inChat = true; + root.props.chatSession = chat; + } + onNewChatRequest: { + const data = Chat.chats.insert(); + stack.push(chatContent, { + "chatData": data + }); + root.props.inChat = true; + root.props.chatSession = data; + } + } + } + + Component { + id: chatContent + + ChatContent { + onRequestClose: { + stack.pop(); + root.props.inChat = false; + } + } + } +} diff --git a/Modules/Notifications/Sidebar/Content.qml b/Modules/Notifications/Sidebar/Content.qml index 06f0c5d..cfd33c9 100644 --- a/Modules/Notifications/Sidebar/Content.qml +++ b/Modules/Notifications/Sidebar/Content.qml @@ -1,7 +1,11 @@ -import qs.Components +pragma ComponentBehavior: Bound + import ZShell.Config +import ZShell.Llm import QtQuick import QtQuick.Layouts +import qs.Components +import qs.Modules.Notifications.Sidebar.Chat import qs.Services Item { @@ -16,15 +20,64 @@ Item { anchors.fill: parent spacing: Tokens.spacing.small - CustomRect { + Tabs { + Layout.fillWidth: true + dashState: root.props + nonAnimWidth: layout.width + } + + CustomClippingRect { Layout.fillHeight: true Layout.fillWidth: true - color: Colors.tPalette.m3surfaceContainerLow radius: Tokens.rounding.small - NotifDock { - props: root.props - visibilities: root.visibilities + Item { + id: pages + + anchors.fill: parent + opacity: root.props.currentTab === 0 ? 1 : 0 + visible: opacity > 0.01 + + Behavior on opacity { + Anim { + } + } + + CustomRect { + anchors.fill: parent + color: Colors.tPalette.m3surfaceContainerLow + radius: Tokens.rounding.small + + NotifDock { + props: root.props + visibilities: root.visibilities + } + } + } + + Item { + id: chatPage + + anchors.fill: parent + opacity: visible ? 1 : 0 + visible: root.props.currentTab === 1 + z: 1 + + Behavior on opacity { + Anim { + } + } + + CustomRect { + anchors.fill: parent + color: Colors.tPalette.m3surfaceContainerLow + radius: Tokens.rounding.small + + ChatPanel { + anchors.fill: parent + props: root.props + } + } } } diff --git a/Modules/Notifications/Sidebar/Props.qml b/Modules/Notifications/Sidebar/Props.qml index 21a2541..02c0354 100644 --- a/Modules/Notifications/Sidebar/Props.qml +++ b/Modules/Notifications/Sidebar/Props.qml @@ -1,7 +1,11 @@ import Quickshell +import ZShell.Llm PersistentProperties { + property ChatSession chatSession + property int currentTab: 0 property list expandedNotifs: [] + property bool inChat: false reloadableId: "sidebar" } diff --git a/Modules/Notifications/Sidebar/Tabs.qml b/Modules/Notifications/Sidebar/Tabs.qml new file mode 100644 index 0000000..2a9bfb8 --- /dev/null +++ b/Modules/Notifications/Sidebar/Tabs.qml @@ -0,0 +1,142 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import QtQuick.Templates +import Quickshell +import ZShell.Config +import qs.Components +import qs.Services + +Item { + id: root + + readonly property alias count: bar.count + required property PersistentProperties dashState + required property real nonAnimWidth + + implicitHeight: bar.implicitHeight + indicator.implicitHeight + indicator.anchors.topMargin + separator.implicitHeight + + TabBar { + id: bar + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + background: null + currentIndex: root.dashState.currentTab + implicitHeight: contentHeight + + contentItem: RowLayout { + spacing: 0 + + Repeater { + model: bar.contentModel + } + } + + onCurrentIndexChanged: root.state.currentTab = currentIndex + + Tab { + iconName: "notifications" + text: qsTr("Notifications") + } + + Tab { + iconName: "chat" + text: qsTr("Chat") + } + } + + Item { + id: indicator + + anchors.top: bar.bottom + clip: true + implicitHeight: 3 + implicitWidth: bar.currentItem.implicitWidth + x: { + const tab = bar.currentItem; + const width = (root.nonAnimWidth - bar.spacing * (bar.count - 1)) / bar.count; + return width * tab.TabBar.index + (width - tab.implicitWidth) / 2; + } + + Behavior on implicitWidth { + Anim { + } + } + Behavior on x { + Anim { + } + } + + CustomRect { + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + color: Colors.palette.m3primary + implicitHeight: parent.implicitHeight * 2 + radius: Tokens.rounding.full + } + } + + CustomRect { + id: separator + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: indicator.bottom + color: Colors.palette.m3outlineVariant + implicitHeight: 1 + } + + component Tab: TabButton { + id: tab + + readonly property bool current: TabBar.tabBar.currentItem === this + required property string iconName + + Layout.fillWidth: true + Layout.preferredWidth: 1 + background: null + implicitHeight: implicitContentHeight + implicitWidth: implicitContentWidth + + contentItem: Item { + implicitHeight: icon.height + label.height + implicitWidth: Math.max(icon.width, label.width) + + StateLayer { + color: tab.current ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface + radius: Tokens.rounding.small + + onClicked: root.dashState.currentTab = tab.TabBar.index + } + + MaterialIcon { + id: icon + + anchors.bottom: label.top + anchors.horizontalCenter: parent.horizontalCenter + color: tab.current ? Colors.palette.m3primary : Colors.palette.m3onSurfaceVariant + fill: tab.current ? 1 : 0 + font.pointSize: 18 + text: tab.iconName + + Behavior on fill { + Anim { + } + } + } + + CustomText { + id: label + + anchors.bottom: parent.bottom + anchors.horizontalCenter: parent.horizontalCenter + color: tab.current ? Colors.palette.m3primary : Colors.palette.m3onSurfaceVariant + text: tab.text + } + } + } +} diff --git a/Modules/Notifications/Sidebar/Utils/Wrapper.qml b/Modules/Notifications/Sidebar/Utils/Wrapper.qml index 63a01b6..00936ed 100644 --- a/Modules/Notifications/Sidebar/Utils/Wrapper.qml +++ b/Modules/Notifications/Sidebar/Utils/Wrapper.qml @@ -23,9 +23,12 @@ Item { required property var visibilities anchors.bottomMargin: (-implicitHeight - 5) * offsetScale - implicitHeight: content.implicitHeight + 8 * 2 + clip: chatActive + implicitHeight: chatActive ? 21 : content.implicitHeight + 8 * 2 implicitWidth: sidebar.width * (1 - sidebar.offsetScale) - opacity: 1 - offsetScale + opacity: (1 - offsetScale) * chatFade + readonly property bool chatActive: sidebar.chatActive + property real chatFade: chatActive ? 0 : 1 visible: offsetScale < 1 Behavior on offsetScale { @@ -35,6 +38,20 @@ Item { } } + Behavior on chatFade { + Anim { + duration: Tokens.anim.durations.expressiveDefaultSpatial + easing: Tokens.anim.expressiveDefaultSpatial + } + } + + Behavior on implicitHeight { + Anim { + duration: Tokens.anim.durations.expressiveDefaultSpatial + easing: Tokens.anim.expressiveDefaultSpatial + } + } + Loader { id: content diff --git a/Modules/Notifications/Sidebar/Wrapper.qml b/Modules/Notifications/Sidebar/Wrapper.qml index 245247f..dc92aa7 100644 --- a/Modules/Notifications/Sidebar/Wrapper.qml +++ b/Modules/Notifications/Sidebar/Wrapper.qml @@ -1,7 +1,11 @@ pragma ComponentBehavior: Bound import qs.Components +import qs.Components.Toast +import ZShell import ZShell.Config +import ZShell.Llm +import Quickshell import QtQuick Item { @@ -12,6 +16,7 @@ Item { readonly property Props props: Props { } readonly property bool shouldBeActive: root.visibilities.sidebar && Config.sidebar.enabled + readonly property bool chatActive: props.currentTab === 1 required property var visibilities anchors.rightMargin: (-implicitWidth - 5) * offsetScale @@ -26,6 +31,17 @@ Item { } } + Connections { + target: Chat + + function onErrorOccurred(message: string): void { + if (root.shouldBeActive && root.chatActive) + return; + + Toaster.toast(qsTr("Chat"), message, "error_outline", Toast.Error); + } + } + Loader { id: content diff --git a/Plugins/ZShell/CMakeLists.txt b/Plugins/ZShell/CMakeLists.txt index d260f80..a20edf1 100644 --- a/Plugins/ZShell/CMakeLists.txt +++ b/Plugins/ZShell/CMakeLists.txt @@ -79,3 +79,4 @@ add_subdirectory(Services) add_subdirectory(Components) add_subdirectory(Blobs) add_subdirectory(Config) +add_subdirectory(Llm) diff --git a/Plugins/ZShell/Config/CMakeLists.txt b/Plugins/ZShell/Config/CMakeLists.txt index bee1215..55b3f7d 100644 --- a/Plugins/ZShell/Config/CMakeLists.txt +++ b/Plugins/ZShell/Config/CMakeLists.txt @@ -16,6 +16,7 @@ qml_module(ZShell-config dock.hpp general.hpp launcher.hpp + llm.hpp lock.hpp notifs.hpp osd.hpp diff --git a/Plugins/ZShell/Config/config.cpp b/Plugins/ZShell/Config/config.cpp index 268b4c1..8e0515d 100644 --- a/Plugins/ZShell/Config/config.cpp +++ b/Plugins/ZShell/Config/config.cpp @@ -9,6 +9,7 @@ #include "dock.hpp" #include "general.hpp" #include "launcher.hpp" +#include "llm.hpp" #include "lock.hpp" #include "notifs.hpp" #include "osd.hpp" @@ -33,6 +34,8 @@ namespace ZShell::config { +Config* Config::s_instance = nullptr; + Config::Config(QObject* parent) : ConfigObject(parent) , m_appearance(new Appearance(this)) @@ -44,6 +47,7 @@ Config::Config(QObject* parent) , m_dock(new Dock(this)) , m_general(new General(this)) , m_launcher(new Launcher(this)) + , m_llm(new Llm(this)) , m_lock(new Lock(this)) , m_notifs(new Notifs(this)) , m_osd(new Osd(this)) @@ -51,6 +55,7 @@ Config::Config(QObject* parent) , m_services(new Services(this)) , m_sidebar(new Sidebar(this)) , m_utilities(new Utilities(this)) { + s_instance = this; connect(this, &ConfigObject::propertiesChanged, this, &Config::scheduleSave); m_saveTimer.setSingleShot(true); @@ -81,8 +86,14 @@ Config::Config(QObject* parent) m_firstLoadDone = true; } +Config* Config::instance() { + return s_instance; +} + Config* Config::create(QQmlEngine*, QJSEngine*) { - return new Config(); + if (!s_instance) + s_instance = new Config(); + return s_instance; } QString Config::filePath() const { diff --git a/Plugins/ZShell/Config/config.hpp b/Plugins/ZShell/Config/config.hpp index e1881c5..6bbc3d3 100644 --- a/Plugins/ZShell/Config/config.hpp +++ b/Plugins/ZShell/Config/config.hpp @@ -24,6 +24,7 @@ class Dashboard; class Dock; class General; class Launcher; +class Llm; class Lock; class Notifs; class Osd; @@ -46,6 +47,7 @@ class Config : public ConfigObject { Q_MOC_INCLUDE("dock.hpp") Q_MOC_INCLUDE("general.hpp") Q_MOC_INCLUDE("launcher.hpp") + Q_MOC_INCLUDE("llm.hpp") Q_MOC_INCLUDE("lock.hpp") Q_MOC_INCLUDE("notifs.hpp") Q_MOC_INCLUDE("osd.hpp") @@ -63,6 +65,7 @@ class Config : public ConfigObject { CONFIG_SUBOBJECT(Dock, dock) CONFIG_SUBOBJECT(General, general) CONFIG_SUBOBJECT(Launcher, launcher) + CONFIG_SUBOBJECT(Llm, llm) CONFIG_SUBOBJECT(Lock, lock) CONFIG_SUBOBJECT(Notifs, notifs) CONFIG_SUBOBJECT(Osd, osd) @@ -74,6 +77,7 @@ class Config : public ConfigObject { public: explicit Config(QObject* parent = nullptr); static Config* create(QQmlEngine*, QJSEngine*); + [[nodiscard]] static Config* instance(); Q_INVOKABLE void load(); Q_INVOKABLE void saveNow(); @@ -103,6 +107,8 @@ class Config : public ConfigObject { bool m_loading = false; bool m_firstLoadDone = false; QFuture m_loadFuture; + + static Config* s_instance; }; } // namespace ZShell::config diff --git a/Plugins/ZShell/Config/llm.hpp b/Plugins/ZShell/Config/llm.hpp new file mode 100644 index 0000000..e68584d --- /dev/null +++ b/Plugins/ZShell/Config/llm.hpp @@ -0,0 +1,19 @@ +#pragma once +#include "configobject.hpp" +#include + +namespace ZShell::config { + +class Llm : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CFG_PROPERTY(QString, endpoint, "http://localhost:8080") + CFG_PROPERTY(QString, model, "") + CFG_PROPERTY(double, temperature, 0.7) + + public: + explicit Llm(QObject* parent = nullptr) : ConfigObject(parent) {} +}; + +} // namespace ZShell::config diff --git a/Plugins/ZShell/Llm/CMakeLists.txt b/Plugins/ZShell/Llm/CMakeLists.txt new file mode 100644 index 0000000..9474ceb --- /dev/null +++ b/Plugins/ZShell/Llm/CMakeLists.txt @@ -0,0 +1,13 @@ +qml_module(ZShell-llm + URI ZShell.Llm + SOURCES + chat.hpp chat.cpp + session.hpp session.cpp + message.hpp message.cpp + chatstore.hpp chatstore.cpp + LIBRARIES + Qt::Network + ZShell-config +) + +target_include_directories(ZShell-llm PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/../Config) diff --git a/Plugins/ZShell/Llm/chat.cpp b/Plugins/ZShell/Llm/chat.cpp new file mode 100644 index 0000000..f053599 --- /dev/null +++ b/Plugins/ZShell/Llm/chat.cpp @@ -0,0 +1,494 @@ +#include "chat.hpp" + +#include "config.hpp" +#include "llm.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ZShell { + +QString Chat::titleFrom(const QString& content) { + const QString flat = content.simplified(); + if (flat.isEmpty()) + return QString(); + if (flat.size() <= 48) + return flat; + return flat.left(47) + QStringLiteral("…"); +} + +QString Chat::completionsPath(const QString& endpoint, const QString& subpath) { + QString base = endpoint.trimmed(); + while (base.endsWith('/')) + base.chop(1); + if (!base.endsWith("/v1")) + base += "/v1"; + return base + subpath; +} + +QString Chat::serverErrorMessage( + const QByteArray& body, const QString& fallback) { + const QJsonDocument doc = QJsonDocument::fromJson(body); + if (doc.isObject()) { + const QJsonObject obj = doc.object(); + if (obj.contains("error")) { + const QJsonValue errorValue = obj["error"]; + if (errorValue.isObject()) { + const QString message = errorValue.toObject()["message"].toString(); + if (!message.isEmpty()) + return message; + } else if (!errorValue.toString().isEmpty()) { + return errorValue.toString(); + } + } + } + return fallback.isEmpty() ? QStringLiteral("Request to LLM server failed") + : fallback; +} + +void Chat::setBusy(Chat* chat, bool value) { + if (chat->m_busy == value) + return; + chat->m_busy = value; + Q_EMIT chat->busyChanged(); +} + +void Chat::setStreamingChatId(Chat* chat, const QString& id) { + if (chat->m_streamingChatId == id) + return; + chat->m_streamingChatId = id; + Q_EMIT chat->streamingChatIdChanged(); +} + +Chat::Chat(QObject* parent) : QObject(parent), m_store(new ChatStore(this)) { + if (!config::Config::instance()) + new config::Config(); + + const auto* llm = config::Config::instance()->llm(); + m_endpoint = llm->endpoint(); + m_temperature = llm->temperature(); + m_model = llm->model(); + + connect(llm, &config::Llm::endpointChanged, this, [this, llm]() { + if (m_endpoint != llm->endpoint()) { + m_endpoint = llm->endpoint(); + Q_EMIT endpointChanged(); + probeContextSize(); + } + if (m_model.isEmpty()) + refreshModels(); + }); + connect(llm, &config::Llm::modelChanged, this, [this, llm]() { + if (m_model == llm->model()) + return; + m_model = llm->model(); + Q_EMIT modelChanged(); + if (m_model.isEmpty()) + refreshModels(); + }); + connect( + llm, + &config::Llm::temperatureChanged, + this, + [this, llm]() { m_temperature = llm->temperature(); }); + + if (m_model.isEmpty()) + refreshModels(); + probeContextSize(); +} + +Chat::~Chat() { + if (m_reply) + m_reply->abort(); +} + +Chat* Chat::s_instance = nullptr; + +Chat* Chat::create(QQmlEngine*, QJSEngine*) { + if (!s_instance) + s_instance = new Chat(); + return s_instance; +} + +void Chat::send(const QString& chatId, const QString& content) { + const QString text = content.trimmed(); + if (text.isEmpty() || m_busy) + return; + + ChatSession* session = m_store->sessionById(chatId); + if (!session) { + qWarning() << "Chat: unknown chat id" << chatId; + return; + } + + if (!m_lastError.isEmpty()) { + m_lastError.clear(); + Q_EMIT lastErrorChanged(); + } + + session->ensureLoaded(); + session->appendMessage( + ChatMessage::Role::User, + text, + QDateTime::currentMSecsSinceEpoch()); + while (session->messageCount() > 200) + session->removeMessage(session->messages().first()); + if (session->title().isEmpty()) + session->setTitle(titleFrom(text)); + if (m_contextSize > 0 && m_lastTokenCount > m_contextSize * 4 / 5) + trimHistory(session, m_contextSize); + + m_active = session; + beginAssistant(); + m_store->persist(session); +} + +void Chat::beginAssistant() { + m_streaming = m_active->appendMessage( + ChatMessage::Role::Assistant, + QString(), + QDateTime::currentMSecsSinceEpoch()); + m_streaming->setStreaming(true); + setBusy(this, true); + setStreamingChatId(this, m_active->id()); + + const QUrl url = + QUrl::fromUserInput(completionsPath(m_endpoint, "/chat/completions")); + if (!url.isValid() || url.host().isEmpty()) { + fail(QStringLiteral("Invalid LLM endpoint: %1").arg(m_endpoint)); + return; + } + + QNetworkRequest request(url); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + request.setRawHeader("Accept", "text/event-stream"); + + QJsonArray messages; + for (const auto* message : m_active->messages()) { + if (message == m_streaming) + continue; + QJsonObject messageObj; + messageObj[QStringLiteral("role")] = + message->role() == ChatMessage::Role::User + ? QStringLiteral("user") + : QStringLiteral("assistant"); + messageObj[QStringLiteral("content")] = message->content(); + if (!message->reasoning().isEmpty()) + messageObj[QStringLiteral("reasoning_content")] = message->reasoning(); + messages.append(messageObj); + } + + QJsonObject body; + QJsonObject streamOptions; + streamOptions[QStringLiteral("include_usage")] = true; + body[QStringLiteral("stream_options")] = streamOptions; + body[QStringLiteral("messages")] = messages; + body[QStringLiteral("stream")] = true; + body[QStringLiteral("temperature")] = m_temperature; + if (!m_model.isEmpty()) + body[QStringLiteral("model")] = m_model; + + m_buffer.clear(); + m_reply = m_manager.post(request, QJsonDocument(body).toJson()); + + connect(m_reply, &QNetworkReply::readyRead, this, [this]() { + if (m_reply) + m_buffer.append(m_reply->readAll()); + drainBuffer(); + }); + connect(m_reply, &QNetworkReply::finished, this, [this]() { + QNetworkReply* reply = m_reply; + if (!reply) + return; + m_reply = nullptr; + + const QNetworkReply::NetworkError error = reply->error(); + const QString errorString = reply->errorString(); + const QByteArray responseBody = reply->readAll(); + reply->deleteLater(); + + drainBuffer(); + if (!m_streaming) + return; + + if (error == QNetworkReply::NoError || + error == QNetworkReply::OperationCanceledError) + finalize(); + else + fail(serverErrorMessage(responseBody, errorString)); + }); +} + +void Chat::stop() { + if (!m_busy) + return; + if (m_reply) + m_reply->abort(); +} + +void Chat::dismissError() { + if (m_lastError.isEmpty()) + return; + m_lastError.clear(); + Q_EMIT lastErrorChanged(); +} + +void Chat::clearConversation(const QString& chatId) { + ChatSession* session = m_store->sessionById(chatId); + if (!session) + return; + if (m_busy && m_active == session) { + m_pendingClearChatId = chatId; + stop(); + return; + } + session->ensureLoaded(); + session->clearMessages(); + m_store->persist(session); +} + +void Chat::finalize() { + if (!m_streaming) + return; + + ChatSession* session = m_active; + endStream(); + if (session && m_pendingClearChatId == session->id()) { + session->clearMessages(); + m_pendingClearChatId.clear(); + } + if (session) + m_store->persist(session); +} + +void Chat::endStream() { + if (!m_streaming) + return; + m_streaming->setStreaming(false); + if (m_streaming->content().isEmpty() && m_streaming->reasoning().isEmpty() && m_active) + m_active->removeMessage(m_streaming); + m_streaming = nullptr; + setBusy(this, false); + setStreamingChatId(this, QString()); +} + +void Chat::fail(const QString& message) { + qWarning() << "Chat:" << message; + + const bool overflow = message.contains("overflow") || + message.contains("exceed") || + m_lastTokenCount > m_contextSize; + const QString shown = overflow + ? QStringLiteral( + "%1 (using ~%2 of %3 tokens; the oldest messages were auto-removed " + "so the next one will fit)") + .arg(message, QString::number(m_lastTokenCount), + QString::number(m_contextSize)) + : message; + m_lastError = shown; + Q_EMIT lastErrorChanged(); + + if (m_streaming) { + ChatSession* session = m_active; + endStream(); + if (overflow) + trimHistory(session, m_contextSize); + if (session && m_pendingClearChatId == session->id()) { + session->clearMessages(); + m_pendingClearChatId.clear(); + } + if (session) + m_store->persist(session); + } + Q_EMIT errorOccurred(shown); +} + +void Chat::drainBuffer() { + while (true) { + const qsizetype newline = m_buffer.indexOf('\n'); + if (newline < 0) + break; + const QByteArray line = m_buffer.left(newline).trimmed(); + m_buffer.remove(0, newline + 1); + handleLine(line); + } +} + +void Chat::handleLine(const QByteArray& line) { + if (line.isEmpty() || line.startsWith('#') || !line.startsWith("data:")) + return; + + const QByteArray data = line.mid(5).trimmed(); + if (data == "[DONE]") { + finalize(); + return; + } + + const QJsonDocument doc = QJsonDocument::fromJson(data); + if (!doc.isObject()) + return; + const QJsonObject obj = doc.object(); + updateTokenUsage(obj); + + if (obj.contains("error")) { + const QJsonObject error = obj["error"].toObject(); + const QString message = error["message"].toString(); + fail(message.isEmpty() ? QStringLiteral("LLM server returned an error") : message); + return; + } + + for (const QJsonValue& choiceValue : obj["choices"].toArray()) { + const QJsonObject delta = choiceValue.toObject()["delta"].toObject(); + if (!m_streaming) + continue; + m_streaming->appendContent(delta["content"].toString()); + QString reasoning = delta["reasoning_content"].toString(); + if (reasoning.isEmpty()) + reasoning = delta["reasoning"].toString(); + m_streaming->appendReasoning(reasoning); + } +} + +void Chat::refreshModels() { + const QUrl url = QUrl::fromUserInput(completionsPath(m_endpoint, "/models")); + if (!url.isValid() || url.host().isEmpty()) + return; + + auto* reply = m_manager.get(QNetworkRequest(url)); + connect(reply, &QNetworkReply::finished, this, [this, reply]() { + const QNetworkReply::NetworkError error = reply->error(); + const QByteArray data = reply->readAll(); + reply->deleteLater(); + + if (error != QNetworkReply::NoError) { + qWarning() << "Chat: failed to fetch models:" << error; + return; + } + const QJsonArray dataArr = + QJsonDocument::fromJson(data).object()["data"].toArray(); + + QStringList models; + QSet seen; + for (const QJsonValue& value : dataArr) { + const QString id = value.toObject()["id"].toString(); + if (!id.isEmpty() && !seen.contains(id)) { + seen.insert(id); + models.append(id); + } + } + if (models.isEmpty()) + return; + + m_availableModels = models; + Q_EMIT availableModelsChanged(); + + if (m_model.isEmpty()) { + m_model = models.first(); + Q_EMIT modelChanged(); + } + }); +} + +void Chat::selectModel(const QString& id) { + if (id.isEmpty()) + return; + m_model = id; + Q_EMIT modelChanged(); + config::Config::instance()->llm()->set_model(id); +} + +void Chat::setContextSize(int size) { + if (size <= 0 || m_contextSize == size) + return; + m_contextSize = size; + Q_EMIT contextSizeChanged(); +} + +void Chat::probeContextSize() { + QString base = m_endpoint.trimmed(); + while (base.endsWith('/')) + base.chop(1); + const QUrl url = QUrl::fromUserInput(base + "/props"); + if (!url.isValid() || url.host().isEmpty()) + return; + + auto* reply = m_manager.get(QNetworkRequest(url)); + connect( + reply, + &QNetworkReply::finished, + this, + [this, reply]() { + const QNetworkReply::NetworkError error = reply->error(); + const QByteArray data = reply->readAll(); + reply->deleteLater(); + + int size = 0; + if (error == QNetworkReply::NoError) { + const QJsonDocument doc = QJsonDocument::fromJson(data); + if (doc.isArray()) { + for (const auto& value : doc.array()) { + const QJsonObject slot = value.toObject(); + if (slot.contains("n_ctx")) { + size = slot["n_ctx"].toInt(0); + if (size > 0) + break; + } + } + } else if (doc.isObject()) { + const QJsonObject obj = doc.object(); + size = obj["n_ctx"].toInt(0); + if (size <= 0) + size = obj["default_generation_settings"].toObject()["n_ctx"].toInt(0); + } + } + setContextSize(size > 0 ? size : 4096); + }); +} + +void Chat::updateTokenUsage(const QJsonObject& data) { + if (m_contextSize <= 0) + return; + const QJsonObject usage = data["usage"].toObject(); + if (usage.isEmpty()) + return; + const double used = usage.value("prompt_tokens").toDouble() + + usage.value("completion_tokens").toDouble(); + if (used > 0) + m_lastTokenCount = qMin(static_cast(used), m_contextSize * 2); +} + +void Chat::trimHistory(ChatSession* session, int contextSize) { + if (!session || contextSize <= 0) + return; + const int budget = contextSize * 4 / 5; + auto estimate = [&](const ChatMessage* message) -> qsizetype { + return (message->content().size() + + message->reasoning().size()) / + 4; + }; + qsizetype total = 0; + for (const auto* message : session->messages()) + total += estimate(message); + while (total > budget && session->messageCount() >= 2) { + ChatMessage* first = session->messages().first(); + const qsizetype used = estimate(first); + session->removeMessage(first); + total -= used; + if (session->messageCount() >= 2 && + session->messages().first()->role() == ChatMessage::Role::Assistant) { + ChatMessage* second = session->messages().first(); + const qsizetype usedSecond = estimate(second); + session->removeMessage(second); + total -= usedSecond; + } + } +} + +} // namespace ZShell diff --git a/Plugins/ZShell/Llm/chat.hpp b/Plugins/ZShell/Llm/chat.hpp new file mode 100644 index 0000000..8ba0944 --- /dev/null +++ b/Plugins/ZShell/Llm/chat.hpp @@ -0,0 +1,108 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "chatstore.hpp" + +class QQmlEngine; +class QJSEngine; +class QNetworkReply; + +namespace ZShell { + +class Chat : public QObject { + Q_OBJECT + QML_ELEMENT + QML_SINGLETON + + Q_PROPERTY(bool busy READ busy NOTIFY busyChanged) + Q_PROPERTY(QString endpoint READ endpoint NOTIFY endpointChanged) + Q_PROPERTY(QString model READ model NOTIFY modelChanged) + Q_PROPERTY(QStringList availableModels READ availableModels NOTIFY availableModelsChanged) + Q_PROPERTY(int contextSize READ contextSize NOTIFY contextSizeChanged) + Q_PROPERTY(QString lastError READ lastError NOTIFY lastErrorChanged) + Q_PROPERTY(ChatStore* chats READ chats CONSTANT) + Q_PROPERTY(QString streamingChatId READ streamingChatId NOTIFY streamingChatIdChanged) + + public: + explicit Chat(QObject* parent = nullptr); + ~Chat(); + + [[nodiscard]] bool busy() const { return m_busy; } + [[nodiscard]] QString endpoint() const { return m_endpoint; } + [[nodiscard]] QString model() const { return m_model; } + [[nodiscard]] QStringList availableModels() const { return m_availableModels; } + [[nodiscard]] int contextSize() const { return m_contextSize; } + [[nodiscard]] int lastTokenCount() const { return m_lastTokenCount; } + [[nodiscard]] QString lastError() const { return m_lastError; } + [[nodiscard]] ChatStore* chats() const { return m_store; } + [[nodiscard]] QString streamingChatId() const { return m_streamingChatId; } + [[nodiscard]] ChatSession* streamingSession() const { return m_active; } + + Q_INVOKABLE void send(const QString& chatId, const QString& content); + Q_INVOKABLE void stop(); + Q_INVOKABLE void clearConversation(const QString& chatId); + Q_INVOKABLE void dismissError(); + Q_INVOKABLE void refreshModels(); + Q_INVOKABLE void selectModel(const QString& id); + + static Chat* create(QQmlEngine*, QJSEngine*); + + Q_SIGNALS: + void busyChanged(); + void endpointChanged(); + void modelChanged(); + void availableModelsChanged(); + void contextSizeChanged(); + void errorOccurred(const QString& message); + void lastErrorChanged(); + void streamingChatIdChanged(); + + private: + void beginAssistant(); + void endStream(); + void finalize(); + void fail(const QString& message); + void handleLine(const QByteArray& line); + void drainBuffer(); + void probeContextSize(); + void setContextSize(int size); + void updateTokenUsage(const QJsonObject& data); + static void trimHistory(ChatSession* session, int contextSize); + + static QString titleFrom(const QString& content); + static QString completionsPath(const QString& endpoint, const QString& subpath); + static QString serverErrorMessage( + const QByteArray& body, const QString& fallback); + static void setBusy(Chat* chat, bool value); + static void setStreamingChatId(Chat* chat, const QString& id); + + QNetworkAccessManager m_manager; + ChatStore* m_store = nullptr; + QNetworkReply* m_reply = nullptr; + QByteArray m_buffer; + ChatSession* m_active = nullptr; + ChatMessage* m_streaming = nullptr; + QString m_pendingClearChatId; + bool m_busy = false; + QString m_endpoint; + QString m_model; + QStringList m_availableModels; + QString m_lastError; + QString m_streamingChatId; + double m_temperature = 0.7; + int m_contextSize = 0; + int m_lastTokenCount = 0; + + static Chat* s_instance; + + friend class ChatStore; +}; + +} // namespace ZShell diff --git a/Plugins/ZShell/Llm/chatstore.cpp b/Plugins/ZShell/Llm/chatstore.cpp new file mode 100644 index 0000000..4dfb055 --- /dev/null +++ b/Plugins/ZShell/Llm/chatstore.cpp @@ -0,0 +1,251 @@ +#include "chatstore.hpp" + +#include "chat.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace ZShell { + +ChatStore::ChatStore(QObject* parent) : QObject(parent) { + load(); +} + +QString ChatStore::dir() { + const QString base = + QStandardPaths::writableLocation(QStandardPaths::GenericDataLocation) + + QStringLiteral("/zshell"); + return base + QStringLiteral("/chats"); +} + +QString ChatStore::pathFor(const QString& id) { + return dir() + QStringLiteral("/") + id + QStringLiteral(".json"); +} + +bool ChatStore::writeFile(const QString& path, const QJsonObject& doc) { + QDir().mkpath(QFileInfo(path).absolutePath()); + + QSaveFile file(path); + if (!file.open(QIODevice::WriteOnly)) { + qWarning() << "ChatStore: failed to open" << path << "for writing:" + << file.errorString(); + return false; + } + if (file.write(QJsonDocument(doc).toJson(QJsonDocument::Indented)) < 0 || + !file.commit()) { + qWarning() << "ChatStore: failed to write" << path << ":" + << file.errorString(); + return false; + } + return true; +} + +int ChatStore::count() const { + return static_cast(m_sessions.size()); +} + +QVariantList ChatStore::values() const { + QVariantList vals; + vals.reserve(m_sessions.size()); + for (const auto* session : m_sessions) + vals.append(QVariant::fromValue(session)); + return vals; +} + +ChatSession* ChatStore::at(int index) const { + if (index < 0 || index >= m_sessions.size()) + return nullptr; + return m_sessions.at(index); +} + +ChatSession* ChatStore::insert(int index) { + const qint64 now = QDateTime::currentMSecsSinceEpoch(); + auto* session = new ChatSession(QString::number(now), this); + session->setPath(pathFor(session->id())); + session->setMeta(QString(), now, now, 0); + const int pos = + index >= 0 && index <= m_sessions.size() ? index : 0; + m_sessions.insert(pos, session); + Q_EMIT countChanged(); + Q_EMIT valuesChanged(); + return session; +} + +void ChatStore::remove(int index) { + removeSession(at(index)); +} + +void ChatStore::remove(ChatSession* chat) { + removeSession(chat); +} + +void ChatStore::removeSession(ChatSession* session) { + if (!session || !m_sessions.contains(session)) + return; + if (isStreaming(session)) { + qWarning() << "ChatStore: cannot remove a chat that is streaming"; + return; + } + const QList before = m_sessions; + QFile::remove(session->path()); + if (auto* chat = qobject_cast(parent())) + if (chat->m_active == session) + chat->m_active = nullptr; + m_sessions.removeOne(session); + session->deleteLater(); + notify(before); +} + +void ChatStore::move(int from, int to) { + if (from < 0 || from >= m_sessions.size() || to < 0 || + to >= m_sessions.size() || from == to) + return; + m_sessions.move(from, to); + Q_EMIT valuesChanged(); +} + +void ChatStore::clear() { + const QList before = m_sessions; + QList keep; + for (ChatSession* session : std::as_const(m_sessions)) { + if (isStreaming(session)) { + keep.append(session); + continue; + } + QFile::remove(session->path()); + session->deleteLater(); + } + m_sessions = keep; + notify(before); +} + +ChatSession* ChatStore::sessionById(const QString& id) { + for (auto* session : m_sessions) + if (session->id() == id) + return session; + return nullptr; +} + +void ChatStore::persist(ChatSession* session) { + if (!session || !m_sessions.contains(session)) + return; + session->setUpdatedAt(QDateTime::currentMSecsSinceEpoch()); + if (!writeFile(session->path(), session->document())) + return; + sortAndNotify(); +} + +bool ChatStore::isStreaming(ChatSession* session) const { + const auto* chat = qobject_cast(parent()); + return chat && chat->streamingSession() == session; +} + +void ChatStore::sortAndNotify() { + const QList before = m_sessions; + std::stable_sort( + m_sessions.begin(), + m_sessions.end(), + [](const ChatSession* a, const ChatSession* b) { + return a->updatedAtMs() > b->updatedAtMs(); + }); + notify(before); +} + +void ChatStore::notify(const QList& before) { + if (before.size() != m_sessions.size()) + Q_EMIT countChanged(); + bool same = before.size() == m_sessions.size(); + for (int i = 0; same && i < m_sessions.size(); ++i) + if (before.at(i) != m_sessions.at(i)) + same = false; + if (!same) + Q_EMIT valuesChanged(); +} + +void ChatStore::load() { + const QFileInfoList files = QDir(dir()).entryInfoList( + QStringList{QStringLiteral("*.json")}, + QDir::Files, + QDir::Time); + for (const QFileInfo& info : files) { + QFile file(info.absoluteFilePath()); + if (!file.open(QIODevice::ReadOnly)) + continue; + const QJsonDocument doc = QJsonDocument::fromJson(file.readAll()); + file.close(); + if (!doc.isObject()) + continue; + const QJsonObject obj = doc.object(); + auto* session = + new ChatSession(obj["id"].toString(info.completeBaseName()), this); + session->setPath(info.absoluteFilePath()); + session->setMeta( + obj["title"].toString(), + obj["createdAt"].toVariant().toLongLong(), + obj["updatedAt"].toVariant().toLongLong(), + int(obj["messages"].toArray().size())); + m_sessions.append(session); + } + sortAndNotify(); + migrateLegacy(); +} + +void ChatStore::migrateLegacy() { + const QString path = dir() + QStringLiteral("/chat.json"); + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) + return; + const QJsonDocument doc = QJsonDocument::fromJson(file.readAll()); + file.close(); + const QJsonArray arr = doc.isArray() ? doc.array() : QJsonArray(); + + QString title; + qint64 first = 0; + qint64 last = 0; + QJsonArray messages; + for (const QJsonValue& value : arr) { + const QJsonObject msg = value.toObject(); + const QString content = msg["content"].toString(); + if (content.isEmpty()) + continue; + if (first == 0) + first = msg["timestamp"].toVariant().toLongLong(); + last = msg["timestamp"].toVariant().toLongLong(); + if (title.isEmpty() && msg["role"].toString() == "user") + title = Chat::titleFrom(content); + messages.append(msg); + } + if (messages.isEmpty()) + return; + + const QString id = QString::number(QDateTime::currentMSecsSinceEpoch()); + auto* session = new ChatSession(id, this); + session->setPath(pathFor(id)); + + QJsonObject out; + out[QStringLiteral("id")] = id; + out[QStringLiteral("title")] = title; + out[QStringLiteral("createdAt")] = first; + out[QStringLiteral("updatedAt")] = last; + out[QStringLiteral("messages")] = messages; + if (!writeFile(session->path(), out)) + return; + QFile::remove(path); + + session->setMeta(title, first, last, int(messages.size())); + m_sessions.append(session); + sortAndNotify(); +} + +} // namespace ZShell diff --git a/Plugins/ZShell/Llm/chatstore.hpp b/Plugins/ZShell/Llm/chatstore.hpp new file mode 100644 index 0000000..3f6d299 --- /dev/null +++ b/Plugins/ZShell/Llm/chatstore.hpp @@ -0,0 +1,57 @@ +#pragma once + +#include "session.hpp" + +#include +#include +#include + +namespace ZShell { + +class Chat; + +class ChatStore : public QObject { + Q_OBJECT + QML_ANONYMOUS + + Q_PROPERTY(int count READ count NOTIFY countChanged) + Q_PROPERTY(QVariantList values READ values NOTIFY valuesChanged) + + public: + explicit ChatStore(QObject* parent = nullptr); + + [[nodiscard]] int count() const; + [[nodiscard]] QVariantList values() const; + [[nodiscard]] ChatSession* at(int index) const; + + Q_INVOKABLE ChatSession* insert(int index = -1); + Q_INVOKABLE void remove(int index); + Q_INVOKABLE void remove(ChatSession* chat); + Q_INVOKABLE void move(int from, int to); + Q_INVOKABLE void clear(); + + [[nodiscard]] ChatSession* sessionById(const QString& id); + void persist(ChatSession* session); + + Q_SIGNALS: + void countChanged(); + void valuesChanged(); + + private: + void load(); + void migrateLegacy(); + void sortAndNotify(); + void removeSession(ChatSession* session); + bool isStreaming(ChatSession* session) const; + void notify(const QList& before); + + QList m_sessions; + + static QString dir(); + static QString pathFor(const QString& id); + static bool writeFile(const QString& path, const QJsonObject& doc); + + friend class Chat; +}; + +} // namespace ZShell diff --git a/Plugins/ZShell/Llm/message.cpp b/Plugins/ZShell/Llm/message.cpp new file mode 100644 index 0000000..4153587 --- /dev/null +++ b/Plugins/ZShell/Llm/message.cpp @@ -0,0 +1,117 @@ +#include "message.hpp" + +#include + +namespace ZShell { + +ChatMessage::ChatMessage( + Role role, + const QString& content, + qint64 timestamp, + QObject* parent) +: QObject(parent), m_role(role), m_content(content), m_timestamp(timestamp) { + m_timer.setParent(this); + m_timer.setInterval(500); + m_timer.setTimerType(Qt::CoarseTimer); + connect(&m_timer, &QTimer::timeout, this, [this]() { + if (!reasoningInFlight() && !contentInFlight()) { + m_timer.stop(); + return; + } + Q_EMIT elapsedMsChanged(); + }); +} + +qint64 ChatMessage::reasoningElapsedMs() const { + if (m_reasoningStartedAt <= 0) + return 0; + const qint64 end = m_reasoningEndedAt > 0 + ? m_reasoningEndedAt + : QDateTime::currentMSecsSinceEpoch(); + return end - m_reasoningStartedAt; +} + +qint64 ChatMessage::contentElapsedMs() const { + if (m_contentStartedAt <= 0) + return 0; + const qint64 end = m_contentEndedAt > 0 + ? m_contentEndedAt + : QDateTime::currentMSecsSinceEpoch(); + return end - m_contentStartedAt; +} + +void ChatMessage::updateReasoningActive() { + const bool active = m_streaming && !m_reasoning.isEmpty() && m_content.isEmpty(); + if (m_reasoningActive == active) + return; + m_reasoningActive = active; + Q_EMIT reasoningActiveChanged(); +} + +void ChatMessage::appendContent(const QString& piece) { + if (piece.isEmpty()) + return; + if (m_content.isEmpty()) { + const qint64 now = QDateTime::currentMSecsSinceEpoch(); + if (reasoningInFlight()) + m_reasoningEndedAt = now; + m_contentStartedAt = now; + if (!m_timer.isActive()) + m_timer.start(); + } + m_content += piece; + Q_EMIT contentChanged(); + updateReasoningActive(); + Q_EMIT elapsedMsChanged(); +} + +void ChatMessage::appendReasoning(const QString& piece) { + if (piece.isEmpty()) + return; + if (m_reasoning.isEmpty()) { + m_reasoningStartedAt = QDateTime::currentMSecsSinceEpoch(); + if (!m_timer.isActive()) + m_timer.start(); + } + m_reasoning += piece; + Q_EMIT reasoningChanged(); + updateReasoningActive(); + Q_EMIT elapsedMsChanged(); +} + +void ChatMessage::setReasoning(const QString& value) { + if (m_reasoning == value) + return; + m_reasoning = value; + Q_EMIT reasoningChanged(); +} + +void ChatMessage::setElapsedMs(qint64 reasoningMs, qint64 contentMs) { + if (reasoningMs > 0) { + m_reasoningStartedAt = m_timestamp; + m_reasoningEndedAt = m_timestamp + reasoningMs; + } + if (contentMs > 0) { + m_contentStartedAt = m_timestamp; + m_contentEndedAt = m_timestamp + contentMs; + } +} + +void ChatMessage::setStreaming(bool value) { + if (m_streaming == value) + return; + m_streaming = value; + Q_EMIT streamingChanged(); + if (!value) { + const qint64 now = QDateTime::currentMSecsSinceEpoch(); + if (reasoningInFlight()) + m_reasoningEndedAt = now; + if (contentInFlight()) + m_contentEndedAt = now; + m_timer.stop(); + Q_EMIT elapsedMsChanged(); + } + updateReasoningActive(); +} + +} // namespace ZShell diff --git a/Plugins/ZShell/Llm/message.hpp b/Plugins/ZShell/Llm/message.hpp new file mode 100644 index 0000000..66202d4 --- /dev/null +++ b/Plugins/ZShell/Llm/message.hpp @@ -0,0 +1,87 @@ +#pragma once + +#include +#include +#include +#include + +namespace ZShell { + +class Chat; + +class ChatMessage : public QObject { + Q_OBJECT + QML_ELEMENT + QML_UNCREATABLE("Chat messages are created by the Chat singleton") + + Q_PROPERTY(Role role READ role NOTIFY roleChanged) + Q_PROPERTY(QString content READ content NOTIFY contentChanged) + Q_PROPERTY(QString reasoning READ reasoning NOTIFY reasoningChanged) + Q_PROPERTY(bool reasoningActive READ reasoningActive NOTIFY reasoningActiveChanged) + Q_PROPERTY(qint64 reasoningElapsedMs READ reasoningElapsedMs NOTIFY elapsedMsChanged) + Q_PROPERTY(qint64 contentElapsedMs READ contentElapsedMs NOTIFY elapsedMsChanged) + Q_PROPERTY(bool streaming READ streaming NOTIFY streamingChanged) + Q_PROPERTY(qint64 timestamp READ timestamp CONSTANT) + + public: + enum class Role : int { + User = 0, + Assistant + }; + Q_ENUM(Role) + + explicit ChatMessage( + Role role, + const QString& content, + qint64 timestamp, + QObject* parent = nullptr); + + [[nodiscard]] Role role() const { return m_role; } + [[nodiscard]] QString content() const { return m_content; } + [[nodiscard]] QString reasoning() const { return m_reasoning; } + [[nodiscard]] bool reasoningActive() const { return m_reasoningActive; } + [[nodiscard]] qint64 reasoningElapsedMs() const; + [[nodiscard]] qint64 contentElapsedMs() const; + [[nodiscard]] bool streaming() const { return m_streaming; } + [[nodiscard]] qint64 timestamp() const { return m_timestamp; } + + void appendContent(const QString& piece); + void appendReasoning(const QString& piece); + void setReasoning(const QString& value); + void setElapsedMs(qint64 reasoningMs, qint64 contentMs); + void setStreaming(bool value); + + Q_SIGNALS: + void roleChanged(); + void contentChanged(); + void reasoningChanged(); + void reasoningActiveChanged(); + void elapsedMsChanged(); + void streamingChanged(); + + private: + void updateReasoningActive(); + + [[nodiscard]] bool reasoningInFlight() const { + return m_reasoningStartedAt > 0 && m_reasoningEndedAt <= 0; + } + [[nodiscard]] bool contentInFlight() const { + return m_contentStartedAt > 0 && m_contentEndedAt <= 0; + } + + QTimer m_timer; + Role m_role; + QString m_content; + QString m_reasoning; + bool m_reasoningActive = false; + bool m_streaming = false; + qint64 m_timestamp; + qint64 m_reasoningStartedAt = 0; + qint64 m_reasoningEndedAt = 0; + qint64 m_contentStartedAt = 0; + qint64 m_contentEndedAt = 0; + + friend class Chat; +}; + +} // namespace ZShell diff --git a/Plugins/ZShell/Llm/session.cpp b/Plugins/ZShell/Llm/session.cpp new file mode 100644 index 0000000..4e7d4a1 --- /dev/null +++ b/Plugins/ZShell/Llm/session.cpp @@ -0,0 +1,150 @@ +#include "session.hpp" + +#include +#include +#include +#include +#include + +namespace ZShell { + +ChatSession::ChatSession(const QString& id, QObject* parent) +: QObject(parent), m_id(id) { +} + +void ChatSession::setTitle(const QString& value) { + if (m_title == value) + return; + m_title = value; + Q_EMIT titleChanged(); +} + +void ChatSession::setUpdatedAt(qint64 value) { + if (m_updatedAt == value) + return; + m_updatedAt = value; + Q_EMIT updatedAtChanged(); +} + +void ChatSession::setCount(int value) { + if (m_messageCount == value) + return; + m_messageCount = value; + Q_EMIT messageCountChanged(); +} + +void ChatSession::setMeta( + const QString& title, + qint64 createdAt, + qint64 updatedAt, + int messageCount) { + m_title = title; + m_createdAt = createdAt; + m_updatedAt = updatedAt; + m_messageCount = messageCount; +} + +void ChatSession::ensureLoaded() { + if (m_loaded || m_path.isEmpty()) + return; + m_loaded = true; + loadMessages(); +} + +void ChatSession::loadMessages() { + QFile file(m_path); + if (file.open(QIODevice::ReadOnly)) { + const QJsonDocument doc = QJsonDocument::fromJson(file.readAll()); + file.close(); + if (doc.isObject()) { + const QJsonObject obj = doc.object(); + m_title = obj["title"].toString(m_title); + m_createdAt = obj["createdAt"].toVariant().toLongLong() || m_createdAt; + m_updatedAt = obj["updatedAt"].toVariant().toLongLong() || m_updatedAt; + for (const QJsonValue& value : obj["messages"].toArray()) { + const QJsonObject msg = value.toObject(); + const QString content = msg["content"].toString(); + const QString reasoning = msg["reasoning"].toString(); + if (content.isEmpty() && reasoning.isEmpty()) + continue; + auto* message = new ChatMessage( + msg["role"].toString() == "user" ? ChatMessage::Role::User + : ChatMessage::Role::Assistant, + content, + msg["timestamp"].toVariant().toLongLong(), + this); + message->setReasoning(reasoning); + message->setElapsedMs( + msg["reasoningElapsedMs"].toVariant().toLongLong(), + msg["contentElapsedMs"].toVariant().toLongLong()); + m_messages.append(message); + } + } + } else { + qWarning() << "ChatSession: failed to load" << m_path << ":" + << file.errorString(); + } + setCount(int(m_messages.size())); + Q_EMIT titleChanged(); + Q_EMIT messagesChanged(); +} + +ChatMessage* ChatSession::appendMessage( + ChatMessage::Role role, const QString& content, qint64 timestamp) { + ensureLoaded(); + auto* message = new ChatMessage(role, content, timestamp, this); + m_messages.append(message); + setCount(int(m_messages.size())); + Q_EMIT messagesChanged(); + return message; +} + +void ChatSession::removeMessage(ChatMessage* message) { + if (!message || !m_messages.removeOne(message)) + return; + delete message; + setCount(int(m_messages.size())); + Q_EMIT messagesChanged(); +} + +void ChatSession::clearMessages() { + ensureLoaded(); + if (m_messages.isEmpty()) + return; + qDeleteAll(m_messages); + m_messages.clear(); + setCount(0); + Q_EMIT messagesChanged(); +} + +QJsonObject ChatSession::document() { + ensureLoaded(); + QJsonArray arr; + for (const auto* message : m_messages) { + QJsonObject messageObj; + messageObj[QStringLiteral("role")] = + message->role() == ChatMessage::Role::User + ? QStringLiteral("user") + : QStringLiteral("assistant"); + messageObj[QStringLiteral("content")] = message->content(); + if (!message->reasoning().isEmpty()) + messageObj[QStringLiteral("reasoning")] = message->reasoning(); + const qint64 reasoningMs = message->reasoningElapsedMs(); + if (reasoningMs > 0) + messageObj[QStringLiteral("reasoningElapsedMs")] = reasoningMs; + const qint64 contentMs = message->contentElapsedMs(); + if (contentMs > 0) + messageObj[QStringLiteral("contentElapsedMs")] = contentMs; + messageObj[QStringLiteral("timestamp")] = message->timestamp(); + arr.append(messageObj); + } + QJsonObject doc; + doc[QStringLiteral("id")] = m_id; + doc[QStringLiteral("title")] = m_title; + doc[QStringLiteral("createdAt")] = m_createdAt; + doc[QStringLiteral("updatedAt")] = m_updatedAt; + doc[QStringLiteral("messages")] = arr; + return doc; +} + +} // namespace ZShell diff --git a/Plugins/ZShell/Llm/session.hpp b/Plugins/ZShell/Llm/session.hpp new file mode 100644 index 0000000..0de284a --- /dev/null +++ b/Plugins/ZShell/Llm/session.hpp @@ -0,0 +1,84 @@ +#pragma once + +#include "message.hpp" + +#include +#include +#include +#include +#include +#include + +namespace ZShell { + +class ChatSession : public QObject { + Q_OBJECT + QML_ELEMENT + QML_UNCREATABLE("Chat sessions are managed by Chat.chats") + + Q_PROPERTY(QString id READ id CONSTANT) + Q_PROPERTY(QString title READ title NOTIFY titleChanged) + Q_PROPERTY(QDateTime createdAt READ createdAt CONSTANT) + Q_PROPERTY(QDateTime updatedAt READ updatedAt NOTIFY updatedAtChanged) + Q_PROPERTY(int messageCount READ messageCount NOTIFY messageCountChanged) + Q_PROPERTY(QList messages READ messages NOTIFY messagesChanged) + + public: + explicit ChatSession(const QString& id, QObject* parent = nullptr); + + [[nodiscard]] QString id() const { return m_id; } + [[nodiscard]] QString title() const { return m_title; } + [[nodiscard]] QDateTime createdAt() const { + return QDateTime::fromMSecsSinceEpoch(m_createdAt); + } + [[nodiscard]] QDateTime updatedAt() const { + return QDateTime::fromMSecsSinceEpoch(m_updatedAt); + } + [[nodiscard]] qint64 createdAtMs() const { return m_createdAt; } + [[nodiscard]] qint64 updatedAtMs() const { return m_updatedAt; } + [[nodiscard]] int messageCount() const { return m_messageCount; } + [[nodiscard]] QList messages() { + ensureLoaded(); + return m_messages; + } + + void setTitle(const QString& value); + void setUpdatedAt(qint64 value); + + void setPath(const QString& value) { m_path = value; } + [[nodiscard]] QString path() const { return m_path; } + void setMeta( + const QString& title, + qint64 createdAt, + qint64 updatedAt, + int messageCount); + + void ensureLoaded(); + [[nodiscard]] bool isLoaded() const { return m_loaded; } + ChatMessage* appendMessage( + ChatMessage::Role role, const QString& content, qint64 timestamp); + void removeMessage(ChatMessage* message); + void clearMessages(); + [[nodiscard]] QJsonObject document(); + + Q_SIGNALS: + void titleChanged(); + void updatedAtChanged(); + void messageCountChanged(); + void messagesChanged(); + + private: + void loadMessages(); + void setCount(int value); + + QString m_id; + QString m_title; + qint64 m_createdAt = 0; + qint64 m_updatedAt = 0; + int m_messageCount = 0; + QString m_path; + QList m_messages; + bool m_loaded = false; +}; + +} // namespace ZShell