diff --git a/src/gui/networksettings.cpp b/src/gui/networksettings.cpp index f14b25e014a72..81debd67cbbfc 100644 --- a/src/gui/networksettings.cpp +++ b/src/gui/networksettings.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include namespace OCC { @@ -28,6 +29,14 @@ NetworkSettings::NetworkSettings(const AccountPtr &account, QWidget *parent) , _account(account) { _ui->setupUi(this); + setAutoFillBackground(true); + setBackgroundRole(QPalette::AlternateBase); + _ui->proxyGroupBox->setAutoFillBackground(true); + _ui->proxyGroupBox->setBackgroundRole(QPalette::AlternateBase); + _ui->downloadBox->setAutoFillBackground(true); + _ui->downloadBox->setBackgroundRole(QPalette::AlternateBase); + _ui->uploadBox->setAutoFillBackground(true); + _ui->uploadBox->setBackgroundRole(QPalette::AlternateBase); _ui->manualSettings->setVisible(_ui->manualProxyRadioButton->isChecked()); diff --git a/src/gui/tray/MainWindow.qml b/src/gui/tray/MainWindow.qml index 05455378a5ccd..0dfcc1f9cc7df 100644 --- a/src/gui/tray/MainWindow.qml +++ b/src/gui/tray/MainWindow.qml @@ -1,574 +1,893 @@ -/* - * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: GPL-2.0-or-later - */ - -import QtQml -import QtQuick -import QtQuick.Controls -import QtQuick.Window -import QtQuick.Layouts -import Qt5Compat.GraphicalEffects -import Qt.labs.platform as NativeDialogs - -import "../" -import "../filedetails/" - -// Custom qml modules are in /theme (and included by resources.qrc) -import Style - -import com.nextcloud.desktopclient - -ApplicationWindow { - id: trayWindow - - LayoutMirroring.enabled: Application.layoutDirection === Qt.RightToLeft - LayoutMirroring.childrenInherit: true - - title: Systray.windowTitle - // If the main dialog is displayed as a regular window we want it to be quadratic - width: Systray.useNormalWindow ? Style.trayWindowHeight : Style.trayWindowWidth - height: Style.trayWindowHeight - flags: Systray.useNormalWindow ? Qt.Window : Qt.Dialog | Qt.FramelessWindowHint - color: "transparent" - - readonly property int maxMenuHeight: Style.trayWindowHeight - Style.trayWindowHeaderHeight - 2 * Style.trayWindowBorderWidth - - Component.onCompleted: Systray.forceWindowInit(trayWindow) - - // Close tray window when focus is lost (e.g. click somewhere else on the screen) - onActiveChanged: { - if (!Systray.useNormalWindow && !active) { - hide(); - Systray.isOpen = false; - } - } - - onClosing: Systray.isOpen = false - - onVisibleChanged: { - // HACK: reload account Instantiator immediately by restting it - could be done better I guess - // see also id:trayWindowHeader.currentAccountHeaderButton.accountMenu below - trayWindowHeader.currentAccountHeaderButton.userLineInstantiator.active = false; - trayWindowHeader.currentAccountHeaderButton.userLineInstantiator.active = true; - syncStatus.model.load(); - } - - background: Rectangle { - radius: Systray.useNormalWindow ? 0.0 : Style.trayWindowRadius - border.width: Style.trayWindowBorderWidth - border.color: palette.dark - color: palette.window - } - - Connections { - target: UserModel - function onCurrentUserChanged() { - trayWindowHeader.currentAccountHeaderButton.accountMenu.close(); - syncStatus.model.load(); - } - } - - Component { - id: errorMessageDialog - - NativeDialogs.MessageDialog { - id: dialog - - title: Systray.windowTitle - - onAccepted: destroy() - onRejected: destroy() - } - } - - Connections { - target: Systray - - function onIsOpenChanged() { - userStatusDrawer.close() - fileDetailsDrawer.close(); - - if (Systray.isOpen) { - trayWindowHeader.currentAccountHeaderButton.accountMenu.close(); - trayWindowHeader.appsMenu.close(); - trayWindowHeader.openLocalFolderButton.closeMenu() - UserModel.refreshSyncErrorUsers() - } - } - - function onShowErrorMessageDialog(error) { - var newErrorDialog = errorMessageDialog.createObject(trayWindow) - newErrorDialog.text = error - newErrorDialog.open() - } - - function onShowFileDetails(accountState, localPath, fileDetailsPage) { - fileDetailsDrawer.openFileDetails(accountState, localPath, fileDetailsPage); - } - } - - OpacityMask { - anchors.fill: parent - anchors.margins: Style.trayWindowBorderWidth - source: ShaderEffectSource { - sourceItem: trayWindowMainItem - hideSource: true - } - maskSource: Rectangle { - width: trayWindow.width - height: trayWindow.height - radius: Systray.useNormalWindow ? 0.0 : Style.trayWindowRadius - } - } - - Drawer { - id: userStatusDrawer - width: parent.width - height: parent.height - Style.trayDrawerMargin - padding: 0 - edge: Qt.BottomEdge - modal: true - visible: false - - background: Rectangle { - radius: Systray.useNormalWindow ? 0.0 : Style.trayWindowRadius - border.width: Style.trayWindowBorderWidth - border.color: palette.dark - color: Style.colorWithoutTransparency(palette.base) - } - - property int userIndex: 0 - property string modeSetStatus: "setStatus" - property string modeStatusMessage: "statusMessage" - property string initialMode: modeSetStatus - - function openUserStatusDrawer(index, mode) { - console.log(`About to show dialog for user with index ${index}`); - userIndex = index; - initialMode = mode ? mode : modeSetStatus; - open(); - } - - function openUserStatusMessageDrawer(index) { - openUserStatusDrawer(index, modeStatusMessage); - } - - Loader { - id: userStatusContents - anchors.fill: parent - active: userStatusDrawer.visible - sourceComponent: UserStatusSelectorPage { - anchors.fill: parent - userIndex: userStatusDrawer.userIndex - mode: userStatusDrawer.initialMode - onFinished: userStatusDrawer.close() - } - } - } - - Drawer { - id: fileDetailsDrawer - width: parent.width - Style.trayDrawerMargin - height: parent.height - padding: 0 - edge: Qt.RightEdge - modal: true - visible: false - clip: true - - background: Rectangle { - radius: Systray.useNormalWindow ? 0.0 : Style.trayWindowRadius - border.width: Style.trayWindowBorderWidth - border.color: palette.dark - color: Style.colorWithoutTransparency(palette.base) - } - - property var folderAccountState: ({}) - property string fileLocalPath: "" - property var pageToShow: Systray.FileDetailsPage.Activity - - function openFileDetails(accountState, localPath, fileDetailsPage) { - console.log(`About to show file details view in tray for ${localPath}`); - folderAccountState = accountState; - fileLocalPath = localPath; - pageToShow = fileDetailsPage; - - if(!opened) { - open(); - } - } - - Loader { - id: fileDetailsContents - anchors.fill: parent - active: fileDetailsDrawer.visible - onActiveChanged: { - if (active) { - Systray.showFileDetailsPage(fileDetailsDrawer.fileLocalPath, - fileDetailsDrawer.pageToShow); - } - } - sourceComponent: FileDetailsView { - id: fileDetails - - width: parent.width - height: parent.height - - backgroundsVisible: false - accentColor: Style.accentColor - accountState: fileDetailsDrawer.folderAccountState - localPath: fileDetailsDrawer.fileLocalPath - showCloseButton: true - - onCloseButtonClicked: fileDetailsDrawer.close() - } - } - } - - Rectangle { - id: trayWindowMainItem - - property bool isUnifiedSearchActive: unifiedSearchResultsListViewSkeletonLoader.active - || unifiedSearchResultNothingFound.visible - || unifiedSearchResultsErrorLabel.visible - || unifiedSearchResultsListView.visible - || trayWindowUnifiedSearchInputContainer.activateSearchFocus - - anchors.fill: parent - anchors.margins: Style.trayWindowBorderWidth - clip: true - - radius: Systray.useNormalWindow ? 0.0 : Style.trayWindowRadius - color: Style.colorWithoutTransparency(palette.base) - - Accessible.role: Accessible.Grouping - Accessible.name: qsTr("Main content") - - MouseArea { - anchors.fill: parent - onClicked: forceActiveFocus() - } - - TrayWindowHeader { - id: trayWindowHeader - - anchors.top: parent.top - anchors.left: parent.left - anchors.right: parent.right - height: Style.trayWindowHeaderHeight - } - - Button { - id: trayWindowSyncWarning - - readonly property color warningIconColor: Style.errorBoxBackgroundColor - - anchors.top: trayWindowHeader.bottom - anchors.left: trayWindowMainItem.left - anchors.right: trayWindowMainItem.right - anchors.topMargin: Style.trayHorizontalMargin - anchors.leftMargin: Style.trayHorizontalMargin - anchors.rightMargin: Style.trayHorizontalMargin - - visible: UserModel.hasSyncErrors - && !(UserModel.syncErrorUserCount === 1 - && UserModel.firstSyncErrorUserId === UserModel.currentUserId) - padding: 0 - background: Rectangle { - radius: Style.slightlyRoundedButtonRadius - color: Qt.rgba(trayWindowSyncWarning.warningIconColor.r, - trayWindowSyncWarning.warningIconColor.g, - trayWindowSyncWarning.warningIconColor.b, - 0.2) - border.width: Style.normalBorderWidth - border.color: Qt.rgba(trayWindowSyncWarning.warningIconColor.r, - trayWindowSyncWarning.warningIconColor.g, - trayWindowSyncWarning.warningIconColor.b, - 0.6) - } - - Accessible.name: syncWarningText.text - Accessible.role: Accessible.Button - - contentItem: RowLayout { - anchors.fill: parent - spacing: 0 - - ColumnLayout { - Layout.fillWidth: true - Layout.alignment: Qt.AlignVCenter - Layout.topMargin: 4 - Layout.leftMargin: Style.trayHorizontalMargin - Layout.rightMargin: Style.trayHorizontalMargin - Layout.bottomMargin: 4 - - EnforcedPlainTextLabel { - id: syncWarningText - - Layout.fillWidth: true - font.pixelSize: Style.topLinePixelSize - font.bold: true - wrapMode: Text.WordWrap - horizontalAlignment: Text.AlignHCenter - text: { - if (UserModel.syncErrorUserCount <= 1) { - return qsTr("Issue with account %1").arg(UserModel.firstSyncErrorUser ? UserModel.firstSyncErrorUser.name : ""); - } - return qsTr("Issues with several accounts"); - } - } - } - } - - onClicked: { - if (UserModel.firstSyncErrorUserId >= 0) { - UserModel.currentUserId = UserModel.firstSyncErrorUserId - } - } - } - - UnifiedSearchInputContainer { - id: trayWindowUnifiedSearchInputContainer - - property bool activateSearchFocus: activeFocus - - anchors.top: trayWindowSyncWarning.visible - ? trayWindowSyncWarning.bottom - : trayWindowHeader.bottom - anchors.left: trayWindowMainItem.left - anchors.right: trayWindowMainItem.right - anchors.topMargin: Style.trayHorizontalMargin - anchors.leftMargin: Style.trayHorizontalMargin - anchors.rightMargin: Style.trayHorizontalMargin - - text: UserModel.currentUser.unifiedSearchResultsListModel.searchTerm - readOnly: !UserModel.currentUser.isConnected || UserModel.currentUser.unifiedSearchResultsListModel.currentFetchMoreInProgressProviderId - isSearchInProgress: UserModel.currentUser.unifiedSearchResultsListModel.isSearchInProgress - onTextEdited: { UserModel.currentUser.unifiedSearchResultsListModel.searchTerm = trayWindowUnifiedSearchInputContainer.text } - onClearText: { UserModel.currentUser.unifiedSearchResultsListModel.searchTerm = "" } - onActiveFocusChanged: activateSearchFocus = activeFocus && focusReason !== Qt.TabFocusReason && focusReason !== Qt.BacktabFocusReason - Keys.onEscapePressed: activateSearchFocus = false - } - - Rectangle { - id: bottomUnifiedSearchInputSeparator - - anchors.top: trayWindowUnifiedSearchInputContainer.bottom - anchors.left: parent.left - anchors.right: parent.right - anchors.topMargin: Style.trayHorizontalMargin - - height: 1 - color: palette.dark - visible: trayWindowMainItem.isUnifiedSearchActive - } - - ErrorBox { - id: unifiedSearchResultsErrorLabel - visible: UserModel.currentUser.unifiedSearchResultsListModel.errorString && !unifiedSearchResultsListView.visible && ! UserModel.currentUser.unifiedSearchResultsListModel.isSearchInProgress && ! UserModel.currentUser.unifiedSearchResultsListModel.currentFetchMoreInProgressProviderId - text: UserModel.currentUser.unifiedSearchResultsListModel.errorString - anchors.top: bottomUnifiedSearchInputSeparator.bottom - anchors.left: trayWindowMainItem.left - anchors.right: trayWindowMainItem.right - anchors.margins: Style.trayHorizontalMargin - } - - UnifiedSearchPlaceholderView { - id: unifiedSearchPlaceholderView - - anchors.top: bottomUnifiedSearchInputSeparator.bottom - anchors.left: trayWindowMainItem.left - anchors.right: trayWindowMainItem.right - anchors.bottom: trayWindowMainItem.bottom - anchors.topMargin: Style.trayHorizontalMargin - - visible: trayWindowUnifiedSearchInputContainer.activateSearchFocus && !UserModel.currentUser.unifiedSearchResultsListModel.searchTerm - } - - UnifiedSearchResultNothingFound { - id: unifiedSearchResultNothingFound - - anchors.top: bottomUnifiedSearchInputSeparator.bottom - anchors.left: trayWindowMainItem.left - anchors.right: trayWindowMainItem.right - anchors.topMargin: Style.trayHorizontalMargin - - text: UserModel.currentUser.unifiedSearchResultsListModel.searchTerm - - property bool isSearchRunning: UserModel.currentUser.unifiedSearchResultsListModel.isSearchInProgress - property bool waitingForSearchTermEditEnd: UserModel.currentUser.unifiedSearchResultsListModel.waitingForSearchTermEditEnd - property bool isSearchResultsEmpty: unifiedSearchResultsListView.count === 0 - property bool nothingFound: text && isSearchResultsEmpty && !UserModel.currentUser.unifiedSearchResultsListModel.errorString - - visible: !isSearchRunning && !waitingForSearchTermEditEnd && nothingFound - } - - Loader { - id: unifiedSearchResultsListViewSkeletonLoader - - anchors.top: bottomUnifiedSearchInputSeparator.bottom - anchors.left: trayWindowMainItem.left - anchors.right: trayWindowMainItem.right - anchors.bottom: trayWindowMainItem.bottom - anchors.margins: controlRoot.padding - - active: !unifiedSearchResultNothingFound.visible && - !unifiedSearchResultsListView.visible && - !UserModel.currentUser.unifiedSearchResultsListModel.errorString && - UserModel.currentUser.unifiedSearchResultsListModel.searchTerm - - sourceComponent: UnifiedSearchResultItemSkeletonContainer { - anchors.fill: parent - spacing: unifiedSearchResultsListView.spacing - animationRectangleWidth: trayWindow.width - } - } - - ScrollView { - id: controlRoot - contentWidth: availableWidth - - ScrollBar.horizontal.policy: ScrollBar.AlwaysOff - - data: WheelHandler { - target: controlRoot.contentItem - } - visible: unifiedSearchResultsListView.count > 0 - - anchors.top: bottomUnifiedSearchInputSeparator.bottom - anchors.left: trayWindowMainItem.left - anchors.right: trayWindowMainItem.right - anchors.bottom: trayWindowMainItem.bottom - - ListView { - id: unifiedSearchResultsListView - spacing: 4 - clip: true - - keyNavigationEnabled: true - - reuseItems: true - - Accessible.role: Accessible.List - Accessible.name: qsTr("Unified search results list") - - model: UserModel.currentUser.unifiedSearchResultsListModel - - delegate: UnifiedSearchResultListItem { - width: unifiedSearchResultsListView.width - isSearchInProgress: unifiedSearchResultsListView.model.isSearchInProgress - currentFetchMoreInProgressProviderId: unifiedSearchResultsListView.model.currentFetchMoreInProgressProviderId - fetchMoreTriggerClicked: unifiedSearchResultsListView.model.fetchMoreTriggerClicked - resultClicked: unifiedSearchResultsListView.model.resultClicked - ListView.onPooled: isPooled = true - ListView.onReused: isPooled = false - } - - section.property: "providerName" - section.criteria: ViewSection.FullString - section.delegate: UnifiedSearchResultSectionItem { - width: unifiedSearchResultsListView.width - } - } - } - - SyncStatus { - id: syncStatus - - accentColor: Style.accentColor - visible: !trayWindowMainItem.isUnifiedSearchActive - - anchors.top: trayWindowUnifiedSearchInputContainer.bottom - anchors.left: trayWindowMainItem.left - anchors.right: trayWindowMainItem.right - } - - Rectangle { - id: syncStatusSeparator - anchors.left: syncStatus.left - anchors.right: syncStatus.right - anchors.bottom: syncStatus.bottom - height: 1 - color: palette.dark - visible: !trayWindowMainItem.isUnifiedSearchActive - } - - Loader { - id: newActivitiesButtonLoader - - anchors.top: activityList.top - anchors.topMargin: 5 - anchors.horizontalCenter: activityList.horizontalCenter - - width: Style.newActivitiesButtonWidth - height: Style.newActivitiesButtonHeight - - z: 1 - - active: false - - sourceComponent: Button { - id: newActivitiesButton - hoverEnabled: true - padding: Style.smallSpacing - - anchors.fill: parent - - text: qsTr("New activities") - - icon.source: "image://svgimage-custom-color/expand-less-black.svg" + "/" + Style.currentUserHeaderTextColor - icon.width: Style.activityLabelBaseWidth - icon.height: Style.activityLabelBaseWidth - - onClicked: { - activityList.scrollToTop(); - newActivitiesButtonLoader.active = false - } - - Timer { - id: newActivitiesButtonDisappearTimer - interval: Style.newActivityButtonDisappearTimeout - running: newActivitiesButtonLoader.active && !newActivitiesButton.hovered - repeat: false - onTriggered: fadeoutActivitiesButtonDisappear.running = true - } - - OpacityAnimator { - id: fadeoutActivitiesButtonDisappear - target: newActivitiesButton - from: 1 - to: 0 - duration: Style.newActivityButtonDisappearFadeTimeout - loops: 1 - running: false - onFinished: newActivitiesButtonLoader.active = false - } - } - } - - ActivityList { - id: activityList - visible: !trayWindowMainItem.isUnifiedSearchActive - anchors.top: syncStatus.bottom - anchors.left: trayWindowMainItem.left - anchors.right: trayWindowMainItem.right - anchors.bottom: trayWindowMainItem.bottom - - activeFocusOnTab: true - model: activityModel - onOpenFile: Qt.openUrlExternally(filePath); - onActivityItemClicked: { - model.slotTriggerDefaultAction(index) - } - Connections { - target: activityModel - function onInteractiveActivityReceived() { - if (!activityList.atYBeginning) { - newActivitiesButtonLoader.active = true; - } - } - } - } - } // Item trayWindowMainItem -} +/* + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +import QtQml +import QtQuick +import QtQuick.Controls +import QtQuick.Window +import QtQuick.Layouts +import Qt5Compat.GraphicalEffects +import Qt.labs.platform as NativeDialogs + +import "../" +import "../filedetails/" + +// Custom qml modules are in /theme (and included by resources.qrc) +import Style + +import com.nextcloud.desktopclient + +ApplicationWindow { + id: trayWindow + + LayoutMirroring.enabled: Application.layoutDirection === Qt.RightToLeft + LayoutMirroring.childrenInherit: true + + title: Systray.windowTitle + // If the main dialog is displayed as a regular window we want it to be quadratic + width: Systray.useNormalWindow ? Style.trayWindowHeight : Style.trayWindowWidth + height: Style.trayWindowHeight + flags: Systray.useNormalWindow ? Qt.Window : Qt.Dialog | Qt.FramelessWindowHint + color: "transparent" + + readonly property int maxMenuHeight: Style.trayWindowHeight - Style.trayWindowHeaderHeight - 2 * Style.trayWindowBorderWidth + + Component.onCompleted: Systray.forceWindowInit(trayWindow) + + // Close tray window when focus is lost (e.g. click somewhere else on the screen) + onActiveChanged: { + if (!Systray.useNormalWindow && !active) { + hide(); + Systray.isOpen = false; + } + } + + onClosing: Systray.isOpen = false + + onVisibleChanged: { + // HACK: reload account Instantiator immediately by restting it - could be done better I guess + // see also id:trayWindowHeader.currentAccountHeaderButton.accountMenu below + trayWindowHeader.currentAccountHeaderButton.userLineInstantiator.active = false; + trayWindowHeader.currentAccountHeaderButton.userLineInstantiator.active = true; + syncStatus.model.load(); + } + + background: Rectangle { + radius: Systray.useNormalWindow ? 0.0 : Style.trayWindowRadius + border.width: Style.trayWindowBorderWidth + border.color: palette.dark + color: palette.window + } + + Connections { + target: UserModel + function onCurrentUserChanged() { + trayWindowHeader.currentAccountHeaderButton.accountMenu.close(); + syncStatus.model.load(); + } + } + + Component { + id: errorMessageDialog + + NativeDialogs.MessageDialog { + id: dialog + + title: Systray.windowTitle + + onAccepted: destroy() + onRejected: destroy() + } + } + + Connections { + target: Systray + + function onIsOpenChanged() { + userStatusDrawer.close() + fileDetailsDrawer.close(); + + if (Systray.isOpen) { + trayWindowHeader.currentAccountHeaderButton.accountMenu.close(); + trayWindowHeader.appsMenu.close(); + trayWindowHeader.openLocalFolderButton.closeMenu() + } + } + + function onShowErrorMessageDialog(error) { + var newErrorDialog = errorMessageDialog.createObject(trayWindow) + newErrorDialog.text = error + newErrorDialog.open() + } + + function onShowFileDetails(accountState, localPath, fileDetailsPage) { + fileDetailsDrawer.openFileDetails(accountState, localPath, fileDetailsPage); + } + } + + OpacityMask { + anchors.fill: parent + anchors.margins: Style.trayWindowBorderWidth + source: ShaderEffectSource { + sourceItem: trayWindowMainItem + hideSource: true + } + maskSource: Rectangle { + width: trayWindow.width + height: trayWindow.height + radius: Systray.useNormalWindow ? 0.0 : Style.trayWindowRadius + } + } + + Drawer { + id: userStatusDrawer + width: parent.width + height: parent.height - Style.trayDrawerMargin + padding: 0 + edge: Qt.BottomEdge + modal: true + visible: false + + background: Rectangle { + radius: Systray.useNormalWindow ? 0.0 : Style.trayWindowRadius + border.width: Style.trayWindowBorderWidth + border.color: palette.dark + color: Style.colorWithoutTransparency(palette.base) + } + + property int userIndex: 0 + property string modeSetStatus: "setStatus" + property string modeStatusMessage: "statusMessage" + property string initialMode: modeSetStatus + + function openUserStatusDrawer(index, mode) { + console.log(`About to show dialog for user with index ${index}`); + userIndex = index; + initialMode = mode ? mode : modeSetStatus; + open(); + } + + function openUserStatusMessageDrawer(index) { + openUserStatusDrawer(index, modeStatusMessage); + } + + Loader { + id: userStatusContents + anchors.fill: parent + active: userStatusDrawer.visible + sourceComponent: UserStatusSelectorPage { + anchors.fill: parent + userIndex: userStatusDrawer.userIndex + mode: userStatusDrawer.initialMode + onFinished: userStatusDrawer.close() + } + } + } + + Drawer { + id: fileDetailsDrawer + width: parent.width - Style.trayDrawerMargin + height: parent.height + padding: 0 + edge: Qt.RightEdge + modal: true + visible: false + clip: true + + background: Rectangle { + radius: Systray.useNormalWindow ? 0.0 : Style.trayWindowRadius + border.width: Style.trayWindowBorderWidth + border.color: palette.dark + color: Style.colorWithoutTransparency(palette.base) + } + + property var folderAccountState: ({}) + property string fileLocalPath: "" + property var pageToShow: Systray.FileDetailsPage.Activity + + function openFileDetails(accountState, localPath, fileDetailsPage) { + console.log(`About to show file details view in tray for ${localPath}`); + folderAccountState = accountState; + fileLocalPath = localPath; + pageToShow = fileDetailsPage; + + if(!opened) { + open(); + } + } + + Loader { + id: fileDetailsContents + anchors.fill: parent + active: fileDetailsDrawer.visible + onActiveChanged: { + if (active) { + Systray.showFileDetailsPage(fileDetailsDrawer.fileLocalPath, + fileDetailsDrawer.pageToShow); + } + } + sourceComponent: FileDetailsView { + id: fileDetails + + width: parent.width + height: parent.height + + backgroundsVisible: false + accentColor: Style.accentColor + accountState: fileDetailsDrawer.folderAccountState + localPath: fileDetailsDrawer.fileLocalPath + showCloseButton: true + + onCloseButtonClicked: fileDetailsDrawer.close() + } + } + } + + Rectangle { + id: trayWindowMainItem + + property bool isUnifiedSearchActive: unifiedSearchResultsListViewSkeletonLoader.active + || unifiedSearchResultNothingFound.visible + || unifiedSearchResultsErrorLabel.visible + || unifiedSearchResultsListView.visible + || trayWindowUnifiedSearchInputContainer.activateSearchFocus + property bool showAssistantPanel: false + property bool isAssistantActive: assistantPromptLoader.active + + anchors.fill: parent + clip: true + + radius: Systray.useNormalWindow ? 0.0 : Style.trayWindowRadius + color: Style.colorWithoutTransparency(palette.base) + + Accessible.role: Accessible.Grouping + Accessible.name: qsTr("Main content") + + MouseArea { + anchors.fill: parent + onClicked: forceActiveFocus() + } + + TrayWindowHeader { + id: trayWindowHeader + + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + height: Style.trayWindowHeaderHeight + + onFeaturedAppButtonClicked: { + if (UserModel.currentUser.isAssistantEnabled) { + trayWindowMainItem.showAssistantPanel = !trayWindowMainItem.showAssistantPanel + if (trayWindowMainItem.showAssistantPanel) { + assistantQuestionInput.forceActiveFocus() + } + } else { + UserModel.openCurrentAccountFeaturedApp() + } + } + } + + Button { + id: trayWindowSyncWarning + + readonly property color warningIconColor: Style.errorBoxBackgroundColor + + anchors.top: trayWindowHeader.bottom + anchors.left: trayWindowMainItem.left + anchors.right: trayWindowMainItem.right + anchors.topMargin: Style.trayHorizontalMargin + anchors.leftMargin: Style.trayHorizontalMargin + anchors.rightMargin: Style.trayHorizontalMargin + + visible: UserModel.hasSyncErrors + && !(UserModel.syncErrorUserCount === 1 + && UserModel.firstSyncErrorUserId === UserModel.currentUserId) + padding: 0 + background: Rectangle { + radius: Style.slightlyRoundedButtonRadius + color: Qt.rgba(trayWindowSyncWarning.warningIconColor.r, + trayWindowSyncWarning.warningIconColor.g, + trayWindowSyncWarning.warningIconColor.b, + 0.2) + border.width: Style.normalBorderWidth + border.color: Qt.rgba(trayWindowSyncWarning.warningIconColor.r, + trayWindowSyncWarning.warningIconColor.g, + trayWindowSyncWarning.warningIconColor.b, + 0.6) + } + + Accessible.name: syncWarningText.text + Accessible.role: Accessible.Button + + contentItem: RowLayout { + anchors.fill: parent + spacing: 0 + + ColumnLayout { + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter + Layout.topMargin: 4 + Layout.leftMargin: Style.trayHorizontalMargin + Layout.rightMargin: Style.trayHorizontalMargin + Layout.bottomMargin: 4 + + EnforcedPlainTextLabel { + id: syncWarningText + + Layout.fillWidth: true + font.pixelSize: Style.topLinePixelSize + font.bold: true + wrapMode: Text.WordWrap + horizontalAlignment: Text.AlignHCenter + text: { + if (UserModel.syncErrorUserCount <= 1) { + return qsTr("Issue with account %1").arg(UserModel.firstSyncErrorUser ? UserModel.firstSyncErrorUser.name : ""); + } + return qsTr("Issues with several accounts"); + } + } + } + } + + onClicked: { + if (UserModel.firstSyncErrorUserId >= 0) { + UserModel.currentUserId = UserModel.firstSyncErrorUserId + } + } + } + + UnifiedSearchInputContainer { + id: trayWindowUnifiedSearchInputContainer + visible: !trayWindowMainItem.showAssistantPanel + + property bool activateSearchFocus: activeFocus + + anchors.top: trayWindowHeader.bottom + anchors.left: trayWindowMainItem.left + anchors.right: trayWindowMainItem.right + anchors.topMargin: Style.trayHorizontalMargin + anchors.leftMargin: Style.trayHorizontalMargin + anchors.rightMargin: Style.trayHorizontalMargin + + text: UserModel.currentUser.unifiedSearchResultsListModel.searchTerm + readOnly: !UserModel.currentUser.isConnected || UserModel.currentUser.unifiedSearchResultsListModel.currentFetchMoreInProgressProviderId + isSearchInProgress: UserModel.currentUser.unifiedSearchResultsListModel.isSearchInProgress + onTextEdited: { UserModel.currentUser.unifiedSearchResultsListModel.searchTerm = trayWindowUnifiedSearchInputContainer.text } + onClearText: { UserModel.currentUser.unifiedSearchResultsListModel.searchTerm = "" } + onActiveFocusChanged: activateSearchFocus = activeFocus && focusReason !== Qt.TabFocusReason && focusReason !== Qt.BacktabFocusReason + Keys.onEscapePressed: activateSearchFocus = false + } + + Dialog { + id: assistantResetConfirmationDialogWrapper + modal: true + focus: true + x: (trayWindow.width - width) / 2 + y: (trayWindow.height - height) / 2 + header: Item {} + footer: Item {} + onOpened: assistantResetConfirmationDialog.open() + + background: Rectangle { + color: palette.base + border.width: 1 + border.color: "#808080" + radius: 10 + antialiasing: true + + layer.enabled: true + layer.smooth: true + layer.effect: DropShadow { + horizontalOffset: 4 + verticalOffset: 4 + radius: 10 + samples: 16 + color: "#80000000" + } + } + contentItem: Rectangle { + id: assistantResetConfirmationDialogContentRect + property int margin: 6 + + implicitWidth: assistantResetConfirmationDialog.implicitWidth + 2 * margin + implicitHeight: assistantResetConfirmationDialog.implicitHeight + 2 * margin + width: implicitWidth + height: implicitHeight + border.color: "transparent" + color: "transparent" + // color: "#ff0000" + + Dialog { + id: assistantResetConfirmationDialog + + modal: false + focus: true + title: qsTr("Start new conversation?") + x: assistantResetConfirmationDialogContentRect.margin + y: assistantResetConfirmationDialogContentRect.margin + + background: Rectangle { + border.color: "transparent" + color: "transparent" + } + + header: Label { + id: titleLabel + text: assistantResetConfirmationDialog.title + leftPadding: 0 + font.weight: Font.Bold + } + + footer: Row { + spacing: 6 + layoutDirection: Qt.RightToLeft + Button { + text: qsTr("New conversation") + onClicked: assistantResetConfirmationDialog.accept() + } + Button { + text: qsTr("Cancel") + onClicked: assistantResetConfirmationDialog.reject() + } + } + + onAccepted: { + assistantResetConfirmationDialogWrapper.close() + assistantInputContainer.resetAssistantConversation() + } + + onRejected: { + assistantResetConfirmationDialogWrapper.close() + } + + onDiscarded: { + assistantResetConfirmationDialogWrapper.close() + } + + contentItem: Label { + id: assistantResetConfirmationDialogLabel + anchors.fill: parent + text: qsTr("This will clear the existing conversation.") + wrapMode: Text.WordWrap + bottomPadding: 10 + topPadding: 10 + leftPadding: 0 + rightPadding: 0 + horizontalAlignment: Text.AlignLeft + verticalAlignment: Text.AlignVCenter + } + } + } + } + + RowLayout { + id: assistantInputContainer + visible: trayWindowMainItem.showAssistantPanel + + function resetAssistantConversation() { + UserModel.currentUser.clearAssistantResponse() + assistantQuestionInput.text = "" + assistantQuestionInput.forceActiveFocus() + } + + function submitQuestion() { + const question = assistantQuestionInput.text.trim() + if (question.length === 0) { + return + } + + UserModel.currentUser.submitAssistantQuestion(question) + assistantQuestionInput.text = "" + } + + anchors.bottom: trayWindowMainItem.bottom + anchors.left: trayWindowMainItem.left + anchors.right: trayWindowMainItem.right + anchors.topMargin: Style.trayHorizontalMargin + anchors.bottomMargin: Style.trayHorizontalMargin + spacing: Style.extraSmallSpacing + + TextField { + id: assistantQuestionInput + Layout.fillWidth: true + Layout.minimumWidth: 0 + Layout.fillHeight: true + placeholderText: qsTr("Ask Assistant…") + enabled: UserModel.currentUser.isConnected && !UserModel.currentUser.assistantRequestInProgress + onAccepted: assistantInputContainer.submitQuestion() + + Layout.leftMargin: Style.trayHorizontalMargin + leftPadding: 8 + rightPadding: 8 + topPadding: 10 + bottomPadding: 10 + } + + Button { + id: assistantSendButton + Layout.alignment: Qt.AlignVCenter + Layout.preferredHeight: assistantQuestionInput.height + Layout.preferredWidth: assistantQuestionInput.height + Layout.maximumWidth: assistantQuestionInput.height + padding: 0 + enabled: assistantQuestionInput.enabled && assistantQuestionInput.text.trim().length > 0 + icon.source: "image://svgimage-custom-color/send.svg/" + palette.windowText + icon.width: Math.round(assistantQuestionInput.height * 0.5) + icon.height: Math.round(assistantQuestionInput.height * 0.5) + display: AbstractButton.IconOnly + focusPolicy: Qt.StrongFocus + + onClicked: assistantInputContainer.submitQuestion() + + Accessible.role: Accessible.Button + Accessible.name: qsTr("Send assistant question") + Accessible.onPressAction: assistantInputContainer.submitQuestion() + } + + Button { + id: assistantResetButton + Layout.alignment: Qt.AlignVCenter + Layout.preferredHeight: assistantQuestionInput.height + Layout.preferredWidth: assistantQuestionInput.height + Layout.maximumWidth: assistantQuestionInput.height + Layout.rightMargin: Style.trayHorizontalMargin + padding: 0 + icon.source: "image://svgimage-custom-color/add.svg/" + palette.windowText + icon.width: Math.round(assistantQuestionInput.height * 0.5) + icon.height: Math.round(assistantQuestionInput.height * 0.5) + display: AbstractButton.IconOnly + focusPolicy: Qt.StrongFocus + + onClicked: assistantResetConfirmationDialogWrapper.open() + + Accessible.role: Accessible.Button + Accessible.name: qsTr("Start a new assistant chat") + Accessible.onPressAction: assistantResetConfirmationDialogWrapper.open() + } + } + + Connections { + target: UserModel.currentUser + function onAssistantStateChanged() { + if (!UserModel.currentUser.isAssistantEnabled) { + trayWindowMainItem.showAssistantPanel = false + } + } + } + + Loader { + id: assistantPromptLoader + + active: UserModel.currentUser.isAssistantEnabled + && trayWindowMainItem.showAssistantPanel + && !trayWindowMainItem.isUnifiedSearchActive + visible: trayWindowMainItem.showAssistantPanel + anchors.top: trayWindowHeader.bottom + anchors.bottom: assistantInputContainer.top + anchors.left: trayWindowMainItem.left + anchors.right: trayWindowMainItem.right + anchors.topMargin: Style.trayHorizontalMargin + clip: true + + sourceComponent: ColumnLayout { + id: assistantPrompt + spacing: Style.smallSpacing + + ScrollView { + id: assistantConversationScrollView + Layout.fillWidth: true + Layout.fillHeight: true + visible: assistantConversationList.count > 0 + contentWidth: availableWidth + leftPadding: 0 + rightPadding: 0 + + ScrollBar.horizontal.policy: ScrollBar.AlwaysOff + ScrollBar.vertical.policy: ScrollBar.AsNeeded + + ListView { + id: assistantConversationList + clip: true + spacing: Style.standardSpacing + boundsBehavior: Flickable.StopAtBounds + + leftMargin: Style.trayHorizontalMargin + rightMargin: Style.trayHorizontalMargin + + model: UserModel.currentUser.assistantMessages + + delegate: Item { + id: messageDelegate + width: assistantConversationList.width - ( assistantConversationList.leftMargin + assistantConversationList.rightMargin ) + implicitHeight: messageBubble.implicitHeight + + readonly property bool isAssistantMessage: modelData.role === "assistant" + + Rectangle { + id: messageBubble + + anchors.left: isAssistantMessage ? parent.left : undefined + anchors.right: isAssistantMessage ? undefined : parent.right + anchors.leftMargin: Style.trayHorizontalMargin + anchors.rightMargin: Style.trayHorizontalMargin + + radius: Style.smallSpacing + color: isAssistantMessage ? palette.alternateBase : palette.highlight + width: Math.min(messageDelegate.width * 0.8, messageText.implicitWidth + (Style.smallSpacing * 2)) + implicitHeight: messageText.implicitHeight + (Style.smallSpacing * 2) + + TextEdit { + id: messageText + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: Style.smallSpacing + text: modelData.text + wrapMode: Text.WrapAtWordBoundaryOrAnywhere + color: isAssistantMessage ? palette.windowText : palette.highlightedText + textFormat: Text.MarkdownText + readOnly: true + selectByMouse: true + } + } + } + + onCountChanged: { + assistantConversationList.positionViewAtEnd() + } + } + } + + EnforcedPlainTextLabel { + id: assistantStatusLabel + Layout.fillWidth: true + Layout.leftMargin: Style.trayHorizontalMargin + Layout.rightMargin: Style.trayHorizontalMargin + Layout.bottomMargin: Style.trayHorizontalMargin + Layout.topMargin: Style.trayHorizontalMargin + visible: true + text: UserModel.currentUser.assistantResponse + wrapMode: Text.Wrap + color: palette.windowText + } + + EnforcedPlainTextLabel { + id: assistantErrorLabel + Layout.fillWidth: true + Layout.leftMargin: Style.trayHorizontalMargin + Layout.rightMargin: Style.trayHorizontalMargin + Layout.bottomMargin: Style.trayHorizontalMargin + Layout.topMargin: Style.trayHorizontalMargin + visible: UserModel.currentUser.assistantError.length > 0 + text: UserModel.currentUser.assistantError + wrapMode: Text.Wrap + color: palette.highlight + } + } + } + + Rectangle { + id: bottomUnifiedSearchInputSeparator + + anchors.top: trayWindowMainItem.showAssistantPanel ? assistantInputContainer.bottom : trayWindowUnifiedSearchInputContainer.bottom + anchors.left: parent.left + anchors.right: parent.right + anchors.topMargin: Style.trayHorizontalMargin + + height: 1 + color: palette.dark + visible: trayWindowMainItem.isUnifiedSearchActive || trayWindowMainItem.showAssistantPanel + } + + ErrorBox { + id: unifiedSearchResultsErrorLabel + visible: UserModel.currentUser.unifiedSearchResultsListModel.errorString && !unifiedSearchResultsListView.visible && ! UserModel.currentUser.unifiedSearchResultsListModel.isSearchInProgress && ! UserModel.currentUser.unifiedSearchResultsListModel.currentFetchMoreInProgressProviderId + text: UserModel.currentUser.unifiedSearchResultsListModel.errorString + anchors.top: bottomUnifiedSearchInputSeparator.bottom + anchors.left: trayWindowMainItem.left + anchors.right: trayWindowMainItem.right + anchors.margins: Style.trayHorizontalMargin + } + + UnifiedSearchPlaceholderView { + id: unifiedSearchPlaceholderView + + anchors.top: bottomUnifiedSearchInputSeparator.bottom + anchors.left: trayWindowMainItem.left + anchors.right: trayWindowMainItem.right + anchors.bottom: trayWindowMainItem.bottom + anchors.topMargin: Style.trayHorizontalMargin + + visible: trayWindowUnifiedSearchInputContainer.activateSearchFocus && !UserModel.currentUser.unifiedSearchResultsListModel.searchTerm + } + + UnifiedSearchResultNothingFound { + id: unifiedSearchResultNothingFound + + anchors.top: bottomUnifiedSearchInputSeparator.bottom + anchors.left: trayWindowMainItem.left + anchors.right: trayWindowMainItem.right + anchors.topMargin: Style.trayHorizontalMargin + + text: UserModel.currentUser.unifiedSearchResultsListModel.searchTerm + + property bool isSearchRunning: UserModel.currentUser.unifiedSearchResultsListModel.isSearchInProgress + property bool waitingForSearchTermEditEnd: UserModel.currentUser.unifiedSearchResultsListModel.waitingForSearchTermEditEnd + property bool isSearchResultsEmpty: unifiedSearchResultsListView.count === 0 + property bool nothingFound: text && isSearchResultsEmpty && !UserModel.currentUser.unifiedSearchResultsListModel.errorString + + visible: !isSearchRunning && !waitingForSearchTermEditEnd && nothingFound + } + + Loader { + id: unifiedSearchResultsListViewSkeletonLoader + + anchors.top: bottomUnifiedSearchInputSeparator.bottom + anchors.left: trayWindowMainItem.left + anchors.right: trayWindowMainItem.right + anchors.bottom: trayWindowMainItem.bottom + anchors.margins: controlRoot.padding + + active: !unifiedSearchResultNothingFound.visible && + !unifiedSearchResultsListView.visible && + !UserModel.currentUser.unifiedSearchResultsListModel.errorString && + UserModel.currentUser.unifiedSearchResultsListModel.searchTerm + + sourceComponent: UnifiedSearchResultItemSkeletonContainer { + anchors.fill: parent + spacing: unifiedSearchResultsListView.spacing + animationRectangleWidth: trayWindow.width + } + } + + ScrollView { + id: controlRoot + contentWidth: availableWidth + + ScrollBar.horizontal.policy: ScrollBar.AlwaysOff + + data: WheelHandler { + target: controlRoot.contentItem + } + visible: unifiedSearchResultsListView.count > 0 + + anchors.top: bottomUnifiedSearchInputSeparator.bottom + anchors.left: trayWindowMainItem.left + anchors.right: trayWindowMainItem.right + anchors.bottom: trayWindowMainItem.bottom + + ListView { + id: unifiedSearchResultsListView + spacing: 4 + clip: true + + keyNavigationEnabled: true + + reuseItems: true + + Accessible.role: Accessible.List + Accessible.name: qsTr("Unified search results list") + + model: UserModel.currentUser.unifiedSearchResultsListModel + + delegate: UnifiedSearchResultListItem { + width: unifiedSearchResultsListView.width + isSearchInProgress: unifiedSearchResultsListView.model.isSearchInProgress + currentFetchMoreInProgressProviderId: unifiedSearchResultsListView.model.currentFetchMoreInProgressProviderId + fetchMoreTriggerClicked: unifiedSearchResultsListView.model.fetchMoreTriggerClicked + resultClicked: unifiedSearchResultsListView.model.resultClicked + ListView.onPooled: isPooled = true + ListView.onReused: isPooled = false + } + + section.property: "providerName" + section.criteria: ViewSection.FullString + section.delegate: UnifiedSearchResultSectionItem { + width: unifiedSearchResultsListView.width + } + } + } + + SyncStatus { + id: syncStatus + + accentColor: Style.accentColor + visible: !trayWindowMainItem.isUnifiedSearchActive && !trayWindowMainItem.showAssistantPanel + + anchors.top: trayWindowMainItem.showAssistantPanel ? assistantInputContainer.bottom : trayWindowUnifiedSearchInputContainer.bottom + anchors.left: trayWindowMainItem.left + anchors.right: trayWindowMainItem.right + } + + Rectangle { + id: syncStatusSeparator + anchors.left: syncStatus.left + anchors.right: syncStatus.right + anchors.bottom: syncStatus.bottom + height: 1 + color: palette.dark + visible: !trayWindowMainItem.isUnifiedSearchActive && !trayWindowMainItem.showAssistantPanel + } + + Loader { + id: newActivitiesButtonLoader + + anchors.top: activityList.top + anchors.topMargin: 5 + anchors.horizontalCenter: activityList.horizontalCenter + + width: Style.newActivitiesButtonWidth + height: Style.newActivitiesButtonHeight + + z: 1 + + active: false + + sourceComponent: Button { + id: newActivitiesButton + hoverEnabled: true + padding: Style.smallSpacing + + anchors.fill: parent + + text: qsTr("New activities") + + icon.source: "image://svgimage-custom-color/expand-less-black.svg" + "/" + Style.currentUserHeaderTextColor + icon.width: Style.activityLabelBaseWidth + icon.height: Style.activityLabelBaseWidth + + onClicked: { + activityList.scrollToTop(); + newActivitiesButtonLoader.active = false + } + + Timer { + id: newActivitiesButtonDisappearTimer + interval: Style.newActivityButtonDisappearTimeout + running: newActivitiesButtonLoader.active && !newActivitiesButton.hovered + repeat: false + onTriggered: fadeoutActivitiesButtonDisappear.running = true + } + + OpacityAnimator { + id: fadeoutActivitiesButtonDisappear + target: newActivitiesButton + from: 1 + to: 0 + duration: Style.newActivityButtonDisappearFadeTimeout + loops: 1 + running: false + onFinished: newActivitiesButtonLoader.active = false + } + } + } + + ActivityList { + id: activityList + visible: !trayWindowMainItem.isUnifiedSearchActive && !trayWindowMainItem.isAssistantActive + anchors.top: syncStatus.bottom + anchors.left: trayWindowMainItem.left + anchors.right: trayWindowMainItem.right + anchors.bottom: trayWindowMainItem.bottom + + activeFocusOnTab: true + model: activityModel + onOpenFile: Qt.openUrlExternally(filePath); + onActivityItemClicked: { + model.slotTriggerDefaultAction(index) + } + Connections { + target: activityModel + function onInteractiveActivityReceived() { + if (!activityList.atYBeginning) { + newActivitiesButtonLoader.active = true; + } + } + } + } + } // Item trayWindowMainItem +} diff --git a/src/gui/tray/TrayWindowHeader.qml b/src/gui/tray/TrayWindowHeader.qml index bcc1260f648de..1e2bb20230687 100644 --- a/src/gui/tray/TrayWindowHeader.qml +++ b/src/gui/tray/TrayWindowHeader.qml @@ -16,6 +16,8 @@ import com.nextcloud.desktopclient Rectangle { id: root + signal featuredAppButtonClicked + readonly property alias currentAccountHeaderButton: currentAccountHeaderButton readonly property alias openLocalFolderButton: openLocalFolderButton readonly property alias appsMenu: appsMenu @@ -71,12 +73,12 @@ Rectangle { id: trayWindowFeaturedAppButton Layout.alignment: Qt.AlignRight - Layout.preferredWidth: Style.trayWindowHeaderHeight + Layout.preferredWidth: Style.trayWindowHeaderHeight Layout.fillHeight: true - visible: UserModel.currentUser.isFeaturedAppEnabled + visible: UserModel.currentUser.isAssistantEnabled icon.source: UserModel.currentUser.featuredAppIcon + "/" + palette.windowText - onClicked: UserModel.openCurrentAccountFeaturedApp() + onClicked: root.featuredAppButtonClicked() Accessible.role: Accessible.Button Accessible.name: UserModel.currentUser.featuredAppAccessibleName diff --git a/src/gui/tray/usermodel.cpp b/src/gui/tray/usermodel.cpp index bad45f644b8f2..36a261940e8ea 100644 --- a/src/gui/tray/usermodel.cpp +++ b/src/gui/tray/usermodel.cpp @@ -26,6 +26,7 @@ #include "tray/talkreply.h" #include "userstatusconnector.h" #include "common/utility.h" +#include "ocsassistantconnector.h" #ifdef BUILD_FILE_PROVIDER_MODULE #include "gui/macOS/fileprovider.h" @@ -42,13 +43,101 @@ #include #include +using namespace Qt::StringLiterals; + // time span in milliseconds which has to be between two // refreshes of the notifications #define NOTIFICATION_REQUEST_FREE_PERIOD 15000 namespace { + constexpr qint64 expiredActivitiesCheckIntervalMsecs = 1000 * 60; constexpr qint64 activityDefaultExpirationTimeMsecs = 1000 * 60 * 10; +constexpr qint64 assistantPollIntervalMsecs = 2000; +constexpr int assistantSuccessMinStatusCode = 200; +constexpr int assistantSuccessMaxStatusCode = 300; + +QString assistantTaskTypeIdFromResponse(const QJsonDocument &json) +{ + const auto types = json.object().value("ocs"_L1).toObject().value("data"_L1).toObject().value("types"_L1).toObject(); + auto resultTypeId = QString{}; + auto fallbackTypeId = QString{}; + for (const auto &typeId : types.keys()) { + const auto typeObject = types[typeId].toObject(); + if (typeId.isEmpty()) { + continue; + } + if (typeId == "core:text2text:chat"_L1) { + qCDebug(lcActivity) << typeObject << typeId << types[typeId].toObject(); + resultTypeId = typeId; + break; + } + if (typeId == "core:text2text"_L1) { + qCDebug(lcActivity) << typeObject << typeId << types[typeId].toObject(); + fallbackTypeId = typeId; + } + } + return resultTypeId.isEmpty() ? fallbackTypeId : resultTypeId; +} + +qint64 assistantTaskIdFromSchedule(const QJsonDocument &json) +{ + const auto task = json.object().value("ocs"_L1).toObject().value("data"_L1).toObject().value("task"_L1).toObject(); + return static_cast(task.value("id"_L1).toDouble(-1)); +} + +bool assistantTaskStillRunning(const QJsonObject &task) +{ + auto result = true; + + if (!task.contains(u"status"_s)) { + return result; + } + if (task.value(u"status"_s).toString() == u"STATUS_FAILED"_s || task.value(u"status"_s).toString() == u"STATUS_SUCCESSFUL"_s) { + result = false; + } + qCDebug(lcActivity) << task.value(u"status"_s).toString(); + + return result; +} + +QString assistantOutputFromTask(const QJsonObject &task) +{ + const auto outputValue = task.value("output"_L1); + if (outputValue.isString()) { + return outputValue.toString(); + } + + if (outputValue.isObject()) { + const auto outputObject = outputValue.toObject(); + const auto nestedOutput = outputObject.value("output"_L1); + if (nestedOutput.isString()) { + return nestedOutput.toString(); + } + if (nestedOutput.isObject()) { + const auto nestedObject = nestedOutput.toObject(); + const auto textValue = nestedObject.value("text"_L1); + if (textValue.isString()) { + return textValue.toString(); + } + const auto answerValue = nestedObject.value("answer"_L1); + if (answerValue.isString()) { + return answerValue.toString(); + } + } + + const auto textValue = outputObject.value("text"_L1); + if (textValue.isString()) { + return textValue.toString(); + } + const auto answerValue = outputObject.value("answer"_L1); + if (answerValue.isString()) { + return answerValue.toString(); + } + } + + return QString(); +} struct SyncStatusInfo { QUrl icon; @@ -201,6 +290,7 @@ User::User(AccountStatePtr &account, const bool &isCurrent, QObject *parent) connect(_account->account().data(), &Account::capabilitiesChanged, this, &User::headerColorChanged); connect(_account->account().data(), &Account::capabilitiesChanged, this, &User::headerTextColorChanged); connect(_account->account().data(), &Account::capabilitiesChanged, this, &User::accentColorChanged); + connect(_account->account().data(), &Account::capabilitiesChanged, this, &User::assistantStateChanged); connect(_account->account().data(), &Account::capabilitiesChanged, this, &User::slotAccountCapabilitiesChangedRefreshGroupFolders); @@ -227,6 +317,9 @@ User::User(AccountStatePtr &account, const bool &isCurrent, QObject *parent) } }); + _assistantPollTimer.setInterval(assistantPollIntervalMsecs); + _assistantPollTimer.setSingleShot(false); + connect(&_assistantPollTimer, &QTimer::timeout, this, &User::slotAssistantPoll); const auto folderMan = FolderMan::instance(); connect(folderMan, &FolderMan::folderSyncStateChange, this, [this](const Folder *folder) { if (!folder || folder->accountState() == _account.data()) { @@ -1250,20 +1343,17 @@ bool User::serverHasTalk() const bool User::isFeaturedAppEnabled() const { - return isNcAssistantEnabled() || serverHasTalk(); + return isNcAssistantEnabled(); } QString User::featuredAppIcon() const { - return isNcAssistantEnabled() ? "image://svgimage-custom-color/nc-assistant-app.svg" - : "image://svgimage-custom-color/talk-app.svg"; + return "image://svgimage-custom-color/nc-assistant-app.svg"; } QString User::featuredAppAccessibleName() const { - return isNcAssistantEnabled() ? - tr("Open %1 Assistant in browser", "The placeholder will be the application name. Please keep it").arg(APPLICATION_NAME) : - tr("Open %1 Talk in browser", "The placeholder will be the application name. Please keep it").arg(APPLICATION_NAME); + return tr("Open %1 Assistant", "The placeholder will be the application name. Please keep it").arg(APPLICATION_NAME); } AccountApp *User::talkApp() const @@ -1281,6 +1371,31 @@ bool User::isNcAssistantEnabled() const return _account->account()->capabilities().ncAssistantEnabled(); } +QString User::assistantQuestion() const +{ + return _assistantQuestion; +} + +QString User::assistantResponse() const +{ + return _assistantResponse; +} + +QString User::assistantError() const +{ + return _assistantError; +} + +QVariantList User::assistantMessages() const +{ + return _assistantMessages; +} + +bool User::assistantRequestInProgress() const +{ + return _assistantRequestInProgress; +} + QColor User::headerColor() const { return _account->account()->headerColor(); @@ -1337,6 +1452,258 @@ void User::slotSendReplyMessage(const int activityIndex, const QString &token, c }); } +void User::submitAssistantQuestion(const QString &question) +{ + const auto trimmedQuestion = question.trimmed(); + if (trimmedQuestion.isEmpty()) { + return; + } + + if (!isNcAssistantEnabled()) { + _assistantError = tr("Assistant is not available for this account."); + emit assistantErrorChanged(); + return; + } + + if (_assistantRequestInProgress) { + _assistantError = tr("Assistant is already processing a request."); + emit assistantErrorChanged(); + return; + } + + if (!_assistantConnector) { + _assistantConnector = new OcsAssistantConnector(_account->account(), this); + connect(_assistantConnector, &OcsAssistantConnector::taskTypesFetched, this, &User::slotAssistantTaskTypesFetched); + connect(_assistantConnector, &OcsAssistantConnector::tasksFetched, this, &User::slotAssistantTasksFetched); + connect(_assistantConnector, &OcsAssistantConnector::taskScheduled, this, &User::slotAssistantTaskScheduled); + connect(_assistantConnector, &OcsAssistantConnector::taskDeleted, this, &User::slotAssistantTaskDeleted); + connect(_assistantConnector, &OcsAssistantConnector::requestError, this, &User::slotAssistantRequestError); + } + + QStringList history; + history.reserve(_assistantMessages.size()); + for (const auto &message : std::as_const(_assistantMessages)) { + const auto entry = message.toMap(); + const auto role = entry.value(QStringLiteral("role")).toString(); + const auto text = entry.value(QStringLiteral("text")).toString(); + if (text.isEmpty()) { + continue; + } + const auto historyRole = (role == QLatin1String("assistant")) ? QStringLiteral("assistant") : QStringLiteral("human"); + const QJsonObject historyEntry{ + {QStringLiteral("role"), historyRole}, + {QStringLiteral("content"), text}, + }; + history.append(QString::fromUtf8(QJsonDocument(historyEntry).toJson(QJsonDocument::Compact))); + } + + _assistantQuestion = trimmedQuestion; + emit assistantQuestionChanged(); + + _assistantError.clear(); + emit assistantErrorChanged(); + + _assistantResponse = tr("Sending your request…"); + emit assistantResponseChanged(); + + _assistantMessages.append(QVariantMap{ + {QStringLiteral("role"), QStringLiteral("user")}, + {QStringLiteral("text"), _assistantQuestion}, + }); + emit assistantMessagesChanged(); + + _assistantRequestInProgress = true; + emit assistantRequestInProgressChanged(); + + _assistantPollAttempts = 0; + _assistantTaskId = -1; + + if (_assistantTaskType.isEmpty()) { + _assistantConnector->fetchTaskTypes(); + return; + } + + _assistantConnector->scheduleTask(_assistantQuestion, _assistantTaskType, history); +} + +void User::clearAssistantResponse() +{ + const auto hadAssistantData = !_assistantResponse.isEmpty() + || !_assistantError.isEmpty() + || !_assistantQuestion.isEmpty() + || !_assistantMessages.isEmpty(); + + if (_assistantPollTimer.isActive()) { + _assistantPollTimer.stop(); + } + + const auto taskIdToDelete = _assistantTaskId; + _assistantTaskId = -1; + + if (_assistantRequestInProgress) { + _assistantRequestInProgress = false; + emit assistantRequestInProgressChanged(); + } + + if (!hadAssistantData) { + if (_assistantConnector && taskIdToDelete > 0) { + _assistantConnector->deleteTask(taskIdToDelete); + } + return; + } + _assistantQuestion.clear(); + _assistantResponse.clear(); + _assistantError.clear(); + _assistantMessages.clear(); + emit assistantQuestionChanged(); + emit assistantResponseChanged(); + emit assistantErrorChanged(); + emit assistantMessagesChanged(); + if (_assistantConnector && taskIdToDelete > 0) { + _assistantConnector->deleteTask(taskIdToDelete); + } +} + +void User::slotAssistantPoll() +{ + if (!_assistantConnector || _assistantTaskType.isEmpty()) { + _assistantPollTimer.stop(); + return; + } + + if (_assistantPollAttempts >= _assistantMaxPollAttempts) { + _assistantPollTimer.stop(); + _assistantRequestInProgress = false; + emit assistantRequestInProgressChanged(); + if (_assistantResponse.isEmpty()) { + _assistantResponse = tr("No response yet. Please try again later."); + emit assistantResponseChanged(); + } + return; + } + + ++_assistantPollAttempts; + _assistantConnector->fetchTasks(_assistantTaskType); +} + +void User::slotAssistantTaskTypesFetched(const QJsonDocument &json, int statusCode) +{ + if (statusCode < assistantSuccessMinStatusCode || statusCode >= assistantSuccessMaxStatusCode) { + slotAssistantRequestError(QStringLiteral("taskTypes"), statusCode); + return; + } + + _assistantTaskType = assistantTaskTypeIdFromResponse(json); + if (_assistantTaskType.isEmpty()) { + _assistantError = tr("No supported assistant task types were returned."); + emit assistantErrorChanged(); + _assistantRequestInProgress = false; + emit assistantRequestInProgressChanged(); + return; + } + + QStringList history; + history.reserve(_assistantMessages.size()); + for (const auto &message : std::as_const(_assistantMessages)) { + const auto entry = message.toMap(); + const auto role = entry.value(QStringLiteral("role")).toString(); + const auto text = entry.value(QStringLiteral("text")).toString(); + if (text.isEmpty()) { + continue; + } + const auto historyRole = (role == QLatin1String("assistant")) ? QStringLiteral("assistant") : QStringLiteral("human"); + const QJsonObject historyEntry{ + {QStringLiteral("role"), historyRole}, + {QStringLiteral("content"), text}, + }; + history.append(QString::fromUtf8(QJsonDocument(historyEntry).toJson(QJsonDocument::Compact))); + } + _assistantConnector->scheduleTask(_assistantQuestion, _assistantTaskType, history); +} + +void User::slotAssistantTasksFetched(const QJsonDocument &json, int statusCode) +{ + if (statusCode < assistantSuccessMinStatusCode || statusCode >= assistantSuccessMaxStatusCode) { + slotAssistantRequestError(QStringLiteral("tasks"), statusCode); + return; + } + + const auto tasks = json.object().value("ocs"_L1).toObject().value("data"_L1).toObject().value("tasks"_L1).toArray(); + auto output = QString{}; + auto taskIdToDelete = qint64{-1}; + for (const auto &entry : tasks) { + const auto taskObject = entry.toObject(); + const auto taskId = static_cast(taskObject.value("id"_L1).toDouble(-1)); + if (_assistantTaskId > 0 && taskId != _assistantTaskId) { + continue; + } + output = assistantOutputFromTask(taskObject); + if (!assistantTaskStillRunning(taskObject)) { + taskIdToDelete = taskId; + break; + } + } + + if (taskIdToDelete == -1) { + if (!_assistantPollTimer.isActive()) { + _assistantPollAttempts = 0; + _assistantPollTimer.start(); + } + return; + } + + _assistantPollTimer.stop(); + _assistantResponse = output; + emit assistantResponseChanged(); + _assistantMessages.append(QVariantMap{ + {QStringLiteral("role"), QStringLiteral("assistant")}, + {QStringLiteral("text"), _assistantResponse}, + }); + emit assistantMessagesChanged(); + _assistantResponse.clear(); + emit assistantResponseChanged(); + _assistantRequestInProgress = false; + emit assistantRequestInProgressChanged(); + if (taskIdToDelete > 0) { + _assistantConnector->deleteTask(taskIdToDelete); + } +} + +void User::slotAssistantTaskScheduled(const QJsonDocument &json, int statusCode) +{ + if (statusCode < assistantSuccessMinStatusCode || statusCode >= assistantSuccessMaxStatusCode) { + slotAssistantRequestError(QStringLiteral("schedule"), statusCode); + return; + } + + _assistantTaskId = assistantTaskIdFromSchedule(json); + _assistantResponse = tr("Waiting for the assistant response…"); + emit assistantResponseChanged(); + + _assistantPollAttempts = 0; + if (!_assistantPollTimer.isActive()) { + _assistantPollTimer.start(); + } +} + +void User::slotAssistantTaskDeleted(int statusCode) +{ + if (statusCode >= assistantSuccessMinStatusCode && statusCode < assistantSuccessMaxStatusCode) { + return; + } + slotAssistantRequestError(QStringLiteral("deleteTask"), statusCode); +} + +void User::slotAssistantRequestError(const QString &context, int statusCode) +{ + _assistantPollTimer.stop(); + _assistantRequestInProgress = false; + emit assistantRequestInProgressChanged(); + _assistantError = tr("Assistant request failed (%1).").arg(statusCode); + emit assistantErrorChanged(); + qCWarning(lcActivity) << "Assistant request error:" << context << statusCode; +} + void User::forceSyncNow() const { FolderMan::instance()->forceSyncForFolder(getFolder()); @@ -1721,13 +2088,6 @@ void UserModel::openCurrentAccountFeaturedApp() return; } - if (currentUser()->isNcAssistantEnabled()) { - auto serverUrl = currentUser()->server(false); - const auto assistanceUrl = serverUrl.append("/apps/assistant/"); - QDesktopServices::openUrl(QUrl::fromUserInput(assistanceUrl)); - return; - } - if (const auto talkApp = currentUser()->talkApp()) { Utility::openBrowser(talkApp->url()); } diff --git a/src/gui/tray/usermodel.h b/src/gui/tray/usermodel.h index 900216d5d928e..1a361c0559c53 100644 --- a/src/gui/tray/usermodel.h +++ b/src/gui/tray/usermodel.h @@ -9,9 +9,12 @@ #include #include #include +#include #include #include #include +#include +#include #include #include "accountfwd.h" @@ -26,6 +29,7 @@ namespace OCC { class UnifiedSearchResultsListModel; +class OcsAssistantConnector; class TrayFolderInfo @@ -75,6 +79,12 @@ class User : public QObject Q_PROPERTY(UnifiedSearchResultsListModel* unifiedSearchResultsListModel READ getUnifiedSearchResultsListModel CONSTANT) Q_PROPERTY(QVariantList groupFolders READ groupFolders NOTIFY groupFoldersChanged) Q_PROPERTY(bool canLogout READ canLogout CONSTANT) + Q_PROPERTY(bool isAssistantEnabled READ isNcAssistantEnabled NOTIFY assistantStateChanged) + Q_PROPERTY(QString assistantQuestion READ assistantQuestion NOTIFY assistantQuestionChanged) + Q_PROPERTY(QString assistantResponse READ assistantResponse NOTIFY assistantResponseChanged) + Q_PROPERTY(QString assistantError READ assistantError NOTIFY assistantErrorChanged) + Q_PROPERTY(QVariantList assistantMessages READ assistantMessages NOTIFY assistantMessagesChanged) + Q_PROPERTY(bool assistantRequestInProgress READ assistantRequestInProgress NOTIFY assistantRequestInProgressChanged) public: User(AccountStatePtr &account, const bool &isCurrent = false, QObject *parent = nullptr); @@ -121,6 +131,14 @@ class User : public QObject [[nodiscard]] const QVariantList &groupFolders() const; [[nodiscard]] bool canLogout() const; [[nodiscard]] bool isPublicShareLink() const; + [[nodiscard]] QString assistantQuestion() const; + [[nodiscard]] QString assistantResponse() const; + [[nodiscard]] QString assistantError() const; + [[nodiscard]] QVariantList assistantMessages() const; + [[nodiscard]] bool assistantRequestInProgress() const; + + Q_INVOKABLE void submitAssistantQuestion(const QString &question); + Q_INVOKABLE void clearAssistantResponse(); signals: void nameChanged(); @@ -136,6 +154,12 @@ class User : public QObject void syncStatusChanged(); void sendReplyMessage(const int activityIndex, const QString &conversationToken, const QString &message, const QString &replyTo); void groupFoldersChanged(); + void assistantStateChanged(); + void assistantQuestionChanged(); + void assistantResponseChanged(); + void assistantErrorChanged(); + void assistantMessagesChanged(); + void assistantRequestInProgressChanged(); public slots: void slotItemCompleted(const QString &folder, const OCC::SyncFileItemPtr &item); @@ -174,6 +198,12 @@ private slots: void slotCheckExpiredActivities(); void slotGroupFoldersFetched(QNetworkReply *reply); void slotQuotaChanged(const int64_t &usedBytes, const int64_t &availableBytes); + void slotAssistantPoll(); + void slotAssistantTaskTypesFetched(const QJsonDocument &json, int statusCode); + void slotAssistantTasksFetched(const QJsonDocument &json, int statusCode); + void slotAssistantTaskScheduled(const QJsonDocument &json, int statusCode); + void slotAssistantTaskDeleted(int statusCode); + void slotAssistantRequestError(const QString &context, int statusCode); void checkNotifiedNotifications(); void showDesktopNotification(const QString &title, const QString &message, const qint64 notificationId); void showDesktopNotification(const OCC::Activity &activity); @@ -222,6 +252,18 @@ private slots: // used for quota warnings int _lastQuotaPercent = 0; Activity _lastQuotaActivity; + + QPointer _assistantConnector; + QTimer _assistantPollTimer; + int _assistantPollAttempts = 0; + int _assistantMaxPollAttempts = 60; + qint64 _assistantTaskId = -1; + QString _assistantTaskType; + QString _assistantQuestion; + QString _assistantResponse; + QString _assistantError; + QVariantList _assistantMessages; + bool _assistantRequestInProgress = false; QUrl _syncStatusIcon; bool _syncStatusOk = true; }; diff --git a/src/libsync/CMakeLists.txt b/src/libsync/CMakeLists.txt index 5829eabdcfe70..10429efe187cb 100644 --- a/src/libsync/CMakeLists.txt +++ b/src/libsync/CMakeLists.txt @@ -144,6 +144,8 @@ set(libsync_SRCS rootencryptedfolderinfo.cpp foldermetadata.h foldermetadata.cpp + ocsassistantconnector.h + ocsassistantconnector.cpp ocsuserstatusconnector.h ocsuserstatusconnector.cpp rootencryptedfolderinfo.cpp diff --git a/src/libsync/ocsassistantconnector.cpp b/src/libsync/ocsassistantconnector.cpp new file mode 100644 index 0000000000000..d15483e4a5114 --- /dev/null +++ b/src/libsync/ocsassistantconnector.cpp @@ -0,0 +1,214 @@ +/* + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "ocsassistantconnector.h" + +#include "account.h" +#include "networkjobs.h" + +#include +#include +#include +#include +#include + +using namespace Qt::StringLiterals; + +namespace OCC { + +namespace { + +Q_LOGGING_CATEGORY(lcOcsAssistantConnector, "nextcloud.sync.ocsassistantconnector", QtInfoMsg) + +const auto basePath = u"/ocs/v2.php/taskprocessing"_s; +const auto assistantSystemPrompt = QStringLiteral( + "This is a conversation in a specific language between the user and you, Nextcloud Assistant. " + "You are a kind, polite and helpful AI that helps the user to the best of its abilities. " + "If you do not understand something, you will ask for clarification. Detect the language " + "that the user is using. Make sure to use the same language in your response. Do not mention " + "the language explicitly."); + +int statusCodeFromJson(const QString &jsonStr, int fallback) +{ + if (jsonStr.contains(""_L1)) { + static const QRegularExpression xmlRegex("(\\d+)"_L1); + const auto match = xmlRegex.match(jsonStr); + if (match.hasMatch()) { + return match.captured(1).toInt(); + } + return fallback; + } + + static const QRegularExpression jsonRegex(R"("statuscode":(\d+))"); + const auto match = jsonRegex.match(jsonStr); + if (match.hasMatch()) { + return match.captured(1).toInt(); + } + + return fallback; +} + +} + +class AssistantApiJob : public SimpleApiJob +{ + Q_OBJECT +public: + explicit AssistantApiJob(const AccountPtr &account, const QString &path, QObject *parent = nullptr) + : SimpleApiJob(account, path, parent) + { + } + + void setFormBody(const QUrlQuery &query) + { + const auto body = query.toString(QUrl::FullyEncoded).toUtf8(); + setBody(body); + request().setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded"); + } + +signals: + void jsonReceived(const QJsonDocument &json, int statusCode); + +protected: + bool finished() override + { + qCInfo(lcOcsAssistantConnector) << "AssistantApiJob of" << reply()->request().url() + << "FINISHED WITH STATUS" << replyStatusString(); + + const auto httpStatusCode = reply()->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + if (reply()->error() != QNetworkReply::NoError) { + qCWarning(lcOcsAssistantConnector) << "Network error:" << path() << errorString() << httpStatusCode; + emit jsonReceived(QJsonDocument(), httpStatusCode); + return true; + } + + const QByteArray replyData = reply()->readAll(); + const auto jsonStr = QString::fromUtf8(replyData); + const auto statusCode = statusCodeFromJson(jsonStr, httpStatusCode); + + QJsonParseError error{}; + auto json = QJsonDocument::fromJson(replyData, &error); + if (error.error != QJsonParseError::NoError) { + qCWarning(lcOcsAssistantConnector) << "Invalid JSON response:" << error.errorString(); + } + + emit jsonReceived(json, statusCode); + return true; + } +}; + +OcsAssistantConnector::OcsAssistantConnector(AccountPtr account, QObject *parent) + : QObject(parent) + , _account(std::move(account)) +{ + Q_ASSERT(_account); +} + +void OcsAssistantConnector::fetchTaskTypes() +{ + if (_taskTypesJob) { + qCDebug(lcOcsAssistantConnector) << "Task types job already running."; + return; + } + + _taskTypesJob = new JsonApiJob(_account, basePath + u"/tasktypes"_s, this); + connect(_taskTypesJob, &JsonApiJob::jsonReceived, this, [this](const QJsonDocument &json, int statusCode) { + qCInfo(lcOcsAssistantConnector).noquote() << statusCode << QString::fromUtf8(json.toJson(QJsonDocument::JsonFormat::Compact)); + emitIfError(QStringLiteral("taskTypes"), statusCode); + emit taskTypesFetched(json, statusCode); + }); + _taskTypesJob->start(); +} + +void OcsAssistantConnector::fetchTasks(const QString &taskType) +{ + if (_tasksJob) { + qCDebug(lcOcsAssistantConnector) << "Tasks job already running."; + return; + } + + _tasksJob = new JsonApiJob(_account, u"ocs/v2.php/apps/assistant/api/v1/tasks"_s, this); + QUrlQuery params; + params.addQueryItem(QStringLiteral("taskType"), taskType); + _tasksJob->addQueryParams(params); + connect(_tasksJob, &JsonApiJob::jsonReceived, this, [this](const QJsonDocument &json, int statusCode) { + qCInfo(lcOcsAssistantConnector).noquote() << statusCode << QString::fromUtf8(json.toJson(QJsonDocument::JsonFormat::Compact)); + emitIfError(QStringLiteral("tasks"), statusCode); + emit tasksFetched(json, statusCode); + }); + _tasksJob->start(); +} + +void OcsAssistantConnector::scheduleTask(const QString &input, const QString &taskType, const QStringList &history, + const QString &appId, const QString &customId) +{ + if (_scheduleJob) { + qCDebug(lcOcsAssistantConnector) << "Schedule job already running."; + return; + } + + _scheduleJob = new AssistantApiJob(_account, basePath + QStringLiteral("/schedule"), this); + _scheduleJob->setVerb(SimpleApiJob::Verb::Post); + + QUrlQuery params; + params.addQueryItem(QStringLiteral("format"), QStringLiteral("json")); + _scheduleJob->addQueryParams(params); + + QUrlQuery body; + body.addQueryItem(QStringLiteral("input[input]"), input); + body.addQueryItem(QStringLiteral("input[system_prompt]"), assistantSystemPrompt); + if (history.isEmpty()) { + const QJsonObject firstHistoryEntry{ + {QStringLiteral("role"), QStringLiteral("human")}, + {QStringLiteral("content"), input}, + }; + body.addQueryItem(QStringLiteral("input[history][0]"), QString::fromUtf8(QJsonDocument(firstHistoryEntry).toJson(QJsonDocument::Compact))); + } else { + for (int index = 0; index < history.size(); ++index) { + body.addQueryItem(QStringLiteral("input[history][%1]").arg(index), history.at(index)); + } + } + body.addQueryItem(QStringLiteral("type"), taskType); + body.addQueryItem(QStringLiteral("appId"), appId); + body.addQueryItem(QStringLiteral("customId"), customId); + _scheduleJob->setFormBody(body); + + connect(_scheduleJob, &AssistantApiJob::jsonReceived, this, [this](const QJsonDocument &json, int statusCode) { + qCInfo(lcOcsAssistantConnector).noquote() << statusCode << QString::fromUtf8(json.toJson(QJsonDocument::JsonFormat::Compact)); + emitIfError(QStringLiteral("schedule"), statusCode); + emit taskScheduled(json, statusCode); + }); + _scheduleJob->start(); +} + +void OcsAssistantConnector::deleteTask(qint64 taskId) +{ + if (_deleteJob) { + qCDebug(lcOcsAssistantConnector) << "Delete task job already running."; + return; + } + + const auto path = QString{basePath + QStringLiteral("/task/") + QString::number(taskId)}; + _deleteJob = new JsonApiJob(_account, path, this); + _deleteJob->setVerb(SimpleApiJob::Verb::Delete); + connect(_deleteJob, &JsonApiJob::jsonReceived, this, [this](const QJsonDocument &json, int statusCode) { + qCInfo(lcOcsAssistantConnector).noquote() << statusCode << QString::fromUtf8(json.toJson(QJsonDocument::JsonFormat::Compact)); + emitIfError(QStringLiteral("deleteTask"), statusCode); + emit taskDeleted(statusCode); + }); + _deleteJob->start(); +} + +void OcsAssistantConnector::emitIfError(const QString &context, int statusCode) +{ + if (statusCode < 200 || statusCode >= 300) { + qCWarning(lcOcsAssistantConnector) << "Assistant request failed:" << context << "status" << statusCode; + emit requestError(context, statusCode); + } +} + +} // namespace OCC + +#include "ocsassistantconnector.moc" diff --git a/src/libsync/ocsassistantconnector.h b/src/libsync/ocsassistantconnector.h new file mode 100644 index 0000000000000..2cdb3ed11ab33 --- /dev/null +++ b/src/libsync/ocsassistantconnector.h @@ -0,0 +1,51 @@ +/* + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#pragma once + +#include "accountfwd.h" +#include "owncloudlib.h" + +#include +#include +#include +#include + +namespace OCC { + +class JsonApiJob; +class AssistantApiJob; + +class OWNCLOUDSYNC_EXPORT OcsAssistantConnector : public QObject +{ + Q_OBJECT +public: + explicit OcsAssistantConnector(AccountPtr account, QObject *parent = nullptr); + + void fetchTaskTypes(); + void fetchTasks(const QString &taskType); + void scheduleTask(const QString &input, const QString &taskType, const QStringList &history, + const QString &appId = QStringLiteral("assistant"), + const QString &customId = QString()); + void deleteTask(qint64 taskId); + +signals: + void taskTypesFetched(const QJsonDocument &json, int statusCode); + void tasksFetched(const QJsonDocument &json, int statusCode); + void taskScheduled(const QJsonDocument &json, int statusCode); + void taskDeleted(int statusCode); + void requestError(const QString &context, int statusCode); + +private: + void emitIfError(const QString &context, int statusCode); + + AccountPtr _account; + QPointer _taskTypesJob; + QPointer _tasksJob; + QPointer _scheduleJob; + QPointer _deleteJob; +}; + +} // namespace OCC diff --git a/theme/send.svg b/theme/send.svg index 8c74eae9c7e2f..870d87713a5db 100644 --- a/theme/send.svg +++ b/theme/send.svg @@ -1,7 +1 @@ - - - - - - - + \ No newline at end of file