diff --git a/Nextcloud.xcodeproj/project.pbxproj b/Nextcloud.xcodeproj/project.pbxproj index 4b68394076..696786d48e 100644 --- a/Nextcloud.xcodeproj/project.pbxproj +++ b/Nextcloud.xcodeproj/project.pbxproj @@ -160,7 +160,12 @@ F3BB46522A39EC4900461F6E /* NCMoreAppSuggestionsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3BB46512A39EC4900461F6E /* NCMoreAppSuggestionsCell.swift */; }; F3BB46542A3A1E9D00461F6E /* CCCellMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3BB46532A3A1E9D00461F6E /* CCCellMore.swift */; }; F3C587AE2D47E4FE004532DB /* PHAssetCollectionThumbnailLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3C587AD2D47E4FE004532DB /* PHAssetCollectionThumbnailLoader.swift */; }; + F3C6F6F62F34CC0900C531B6 /* NCAssistantChatConversationsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3C6F6F52F34CC0900C531B6 /* NCAssistantChatConversationsModel.swift */; }; F3CA337D2D0B2B6C00672333 /* AlbumModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3CA337C2D0B2B6A00672333 /* AlbumModel.swift */; }; + F3DDFE0F2F15453900A784C8 /* NCAssistantChat.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3DDFE0E2F15453900A784C8 /* NCAssistantChat.swift */; }; + F3DDFE1E2F1F8EC600A784C8 /* ChatInputField.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3DDFE1D2F1F8EC600A784C8 /* ChatInputField.swift */; }; + F3DDFE212F1F953000A784C8 /* NCAssistantChatConversations.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3DDFE202F1F953000A784C8 /* NCAssistantChatConversations.swift */; }; + F3DDFE232F1FB4C300A784C8 /* NCAssistantChatModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3DDFE222F1FB4C300A784C8 /* NCAssistantChatModel.swift */; }; F3E173B02C9AF637006D177A /* ScreenAwakeManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3E173AF2C9AF637006D177A /* ScreenAwakeManager.swift */; }; F3E173C02C9B1067006D177A /* AwakeMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3E173BF2C9B1067006D177A /* AwakeMode.swift */; }; F3E173C12C9B1067006D177A /* AwakeMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3E173BF2C9B1067006D177A /* AwakeMode.swift */; }; @@ -1267,7 +1272,12 @@ F3BB46512A39EC4900461F6E /* NCMoreAppSuggestionsCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCMoreAppSuggestionsCell.swift; sourceTree = ""; }; F3BB46532A3A1E9D00461F6E /* CCCellMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CCCellMore.swift; sourceTree = ""; }; F3C587AD2D47E4FE004532DB /* PHAssetCollectionThumbnailLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PHAssetCollectionThumbnailLoader.swift; sourceTree = ""; }; + F3C6F6F52F34CC0900C531B6 /* NCAssistantChatConversationsModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCAssistantChatConversationsModel.swift; sourceTree = ""; }; F3CA337C2D0B2B6A00672333 /* AlbumModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlbumModel.swift; sourceTree = ""; }; + F3DDFE0E2F15453900A784C8 /* NCAssistantChat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCAssistantChat.swift; sourceTree = ""; }; + F3DDFE1D2F1F8EC600A784C8 /* ChatInputField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInputField.swift; sourceTree = ""; }; + F3DDFE202F1F953000A784C8 /* NCAssistantChatConversations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCAssistantChatConversations.swift; sourceTree = ""; }; + F3DDFE222F1FB4C300A784C8 /* NCAssistantChatModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCAssistantChatModel.swift; sourceTree = ""; }; F3E173AF2C9AF637006D177A /* ScreenAwakeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenAwakeManager.swift; sourceTree = ""; }; F3E173BF2C9B1067006D177A /* AwakeMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AwakeMode.swift; sourceTree = ""; }; F3F442ED2DDE292600FD701F /* NCMetadataPermissions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCMetadataPermissions.swift; sourceTree = ""; }; @@ -2107,6 +2117,7 @@ F3374A7F2D64AB40002A38F9 /* Components */ = { isa = PBXGroup; children = ( + F3DDFE1D2F1F8EC600A784C8 /* ChatInputField.swift */, F3374A832D64AC2C002A38F9 /* AssistantLabelStyle.swift */, F3374A802D64AB9E002A38F9 /* StatusInfo.swift */, F3A0478F2BD2668800658E7B /* NCAssistantEmptyView.swift */, @@ -2138,10 +2149,12 @@ isa = PBXGroup; children = ( F3A047962BD2668800658E7B /* NCAssistant.swift */, + F3A047932BD2668800658E7B /* NCAssistantModel.swift */, + F3DDFE0D2F15452F00A784C8 /* Chat */, + F3DDFE1F2F1F951000A784C8 /* Chat Sessions */, F3A047902BD2668800658E7B /* Create Task */, F3A047942BD2668800658E7B /* Task Detail */, F3374A7F2D64AB40002A38F9 /* Components */, - F3A047922BD2668800658E7B /* Models */, ); path = Assistant; sourceTree = ""; @@ -2154,14 +2167,6 @@ path = "Create Task"; sourceTree = ""; }; - F3A047922BD2668800658E7B /* Models */ = { - isa = PBXGroup; - children = ( - F3A047932BD2668800658E7B /* NCAssistantModel.swift */, - ); - path = Models; - sourceTree = ""; - }; F3A047942BD2668800658E7B /* Task Detail */ = { isa = PBXGroup; children = ( @@ -2181,6 +2186,24 @@ path = Cells; sourceTree = ""; }; + F3DDFE0D2F15452F00A784C8 /* Chat */ = { + isa = PBXGroup; + children = ( + F3DDFE0E2F15453900A784C8 /* NCAssistantChat.swift */, + F3DDFE222F1FB4C300A784C8 /* NCAssistantChatModel.swift */, + ); + path = Chat; + sourceTree = ""; + }; + F3DDFE1F2F1F951000A784C8 /* Chat Sessions */ = { + isa = PBXGroup; + children = ( + F3DDFE202F1F953000A784C8 /* NCAssistantChatConversations.swift */, + F3C6F6F52F34CC0900C531B6 /* NCAssistantChatConversationsModel.swift */, + ); + path = "Chat Sessions"; + sourceTree = ""; + }; F3E173BE2C9B1057006D177A /* ScreenAwakeManager */ = { isa = PBXGroup; children = ( @@ -4412,6 +4435,7 @@ F7AE00F8230E81CB007ACF8A /* NCBrowserWeb.swift in Sources */, F77DD6A82C5CC093009448FB /* NCSession.swift in Sources */, F702F30825EE5D47008F8E80 /* NCPopupViewController.swift in Sources */, + F3C6F6F62F34CC0900C531B6 /* NCAssistantChatConversationsModel.swift in Sources */, F76340FC2EBDF64D0056F538 /* NCManageDatabase+Tag.swift in Sources */, F733598125C1C188002ABA72 /* NCAskAuthorization.swift in Sources */, 370D26AF248A3D7A00121797 /* NCCellMain.swift in Sources */, @@ -4583,6 +4607,7 @@ F7816EF22C2C3E1F00A52517 /* NCPushNotification.swift in Sources */, F76882342C0DD1E7001CF441 /* NCDisplayView.swift in Sources */, F7C30DF6291BC0CA0017149B /* NCNetworkingE2EEUpload.swift in Sources */, + F3DDFE232F1FB4C300A784C8 /* NCAssistantChatModel.swift in Sources */, F7501C332212E57500FB1415 /* NCMedia.swift in Sources */, F7411C552D7B26D700F57358 /* NCNetworking+ServerError.swift in Sources */, F72944F22A84246400246839 /* NCEndToEndMetadataV20.swift in Sources */, @@ -4699,7 +4724,9 @@ F7A03E2F2D425A14007AA677 /* NCFavoriteNavigationController.swift in Sources */, F343A4BB2A1E734600DDA874 /* Optional+Extension.swift in Sources */, F76882232C0DD1E7001CF441 /* NCCapabilitiesModel.swift in Sources */, + F3DDFE212F1F953000A784C8 /* NCAssistantChatConversations.swift in Sources */, F7E2B64F2DDCC5C30075B4D0 /* NCMedia+TransferDelegate.swift in Sources */, + F3DDFE0F2F15453900A784C8 /* NCAssistantChat.swift in Sources */, F7D68FCC28CB9051009139F3 /* NCManageDatabase+DashboardWidget.swift in Sources */, F76882292C0DD1E7001CF441 /* NCManageE2EEModel.swift in Sources */, F799DF8B2C4B84EB003410B5 /* NCCollectionViewCommon+EndToEndInitialize.swift in Sources */, @@ -4723,6 +4750,7 @@ F76882272C0DD1E7001CF441 /* NCManageE2EEView.swift in Sources */, F7864ACC2A78FE73004870E0 /* NCManageDatabase+LocalFile.swift in Sources */, F7327E302B73A86700A462C7 /* NCNetworking+WebDAV.swift in Sources */, + F3DDFE1E2F1F8EC600A784C8 /* ChatInputField.swift in Sources */, F7D61EA82EBF1694007F865B /* NCManageDatabase+TableCapabilities.swift in Sources */, F79FFB262A97C24A0055EEA4 /* NCNetworkingE2EEMarkFolder.swift in Sources */, F70D8D8124A4A9BF000A5756 /* NCNetworkingProcess.swift in Sources */, diff --git a/Tests/NextcloudUITests/AssistantUITests.swift b/Tests/NextcloudUITests/AssistantUITests.swift index bce341aed9..51835393ea 100644 --- a/Tests/NextcloudUITests/AssistantUITests.swift +++ b/Tests/NextcloudUITests/AssistantUITests.swift @@ -50,7 +50,7 @@ final class AssistantUITests: BaseUIXCTestCase { } private func createTask(input: String) { - app.navigationBars["Assistant"].buttons["CreateButton"].tap() + app.navigationBars["Assistant"].buttons["ConversationsButton"].tap() let inputTextEditor = app.textViews["InputTextEditor"] inputTextEditor.await() diff --git a/iOSClient/Account/NCAccount.swift b/iOSClient/Account/NCAccount.swift index dd13742efe..e9d8d20190 100644 --- a/iOSClient/Account/NCAccount.swift +++ b/iOSClient/Account/NCAccount.swift @@ -194,7 +194,7 @@ class NCAccount: NSObject { return } - await showErrorBanner(controller: controller, text: "_account_unauthorized_", errorCode: NCGlobal.shared.errorUnauthorized401) + await showErrorBanner(controller: controller, text: String(format: NSLocalizedString("_account_unauthorized_", comment: ""), account), errorCode: NCGlobal.shared.errorUnauthorized401) let resultsWipe = await NextcloudKit.shared.getRemoteWipeStatusAsync(serverUrl: tblAccount.urlBase, token: token, account: account) { task in Task { diff --git a/iOSClient/Assistant/Chat Sessions/NCAssistantChatConversations.swift b/iOSClient/Assistant/Chat Sessions/NCAssistantChatConversations.swift new file mode 100644 index 0000000000..5fd16cf93d --- /dev/null +++ b/iOSClient/Assistant/Chat Sessions/NCAssistantChatConversations.swift @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation +import SwiftUI +import NextcloudKit + +struct NCAssistantChatConversations: View { + var conversationsModel: NCAssistantChatConversationsModel + var selectedConversation: AssistantConversation? + var onConversationSelected: (AssistantConversation?) -> Void + + @Environment(\.dismiss) private var dismiss + + var body: some View { + Group { + List(conversationsModel.conversations, id: \.id) { conversation in + Button { + onConversationSelected(conversation) + dismiss() + } label: { + HStack { + Text(conversation.validTitle) + Spacer() + if selectedConversation?.id == conversation.id { + Image(systemName: "checkmark") + .foregroundStyle(.blue) + } + } + .contentShape(Rectangle()) + } + } + } + .navigationTitle(NSLocalizedString("_conversations_", comment: "")) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("_new_conversation_", systemImage: "plus.message.fill") { + Task { + let session = await conversationsModel.createNewConversation() + onConversationSelected(session) + dismiss() + } + } + } + } + } +} + +#Preview { + NCAssistantChatConversations(conversationsModel: NCAssistantChatConversationsModel(controller: nil), selectedConversation: nil, onConversationSelected: { _ in }) +} diff --git a/iOSClient/Assistant/Chat Sessions/NCAssistantChatConversationsModel.swift b/iOSClient/Assistant/Chat Sessions/NCAssistantChatConversationsModel.swift new file mode 100644 index 0000000000..4ad1bb1d70 --- /dev/null +++ b/iOSClient/Assistant/Chat Sessions/NCAssistantChatConversationsModel.swift @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation +import NextcloudKit + +@Observable class NCAssistantChatConversationsModel { + var conversations: [AssistantConversation] = [] + var isLoading: Bool = false + var hasError: Bool = false + + private let ncSession: NCSession.Session + + init(controller: NCMainTabBarController?) { + self.ncSession = NCSession.shared.getSession(controller: controller) + loadAllSessions() + } + + func loadAllSessions() { + Task { + let result = await NextcloudKit.shared.getAssistantChatConversations(account: ncSession.account) + conversations = result.sessions ?? [] + } + } + + func createNewConversation(title: String? = nil) async -> AssistantConversation? { + let timestamp = Int(Date().timeIntervalSince1970) + let result = await NextcloudKit.shared.createAssistantChatConversation(title: title, timestamp: timestamp, account: ncSession.account) + if result.error == .success, let newConversation = result.conversation?.conversation { + conversations.insert(newConversation, at: 0) + return newConversation + } else { + hasError = true + return nil + } + } +} diff --git a/iOSClient/Assistant/Chat/NCAssistantChat.swift b/iOSClient/Assistant/Chat/NCAssistantChat.swift new file mode 100644 index 0000000000..184754418f --- /dev/null +++ b/iOSClient/Assistant/Chat/NCAssistantChat.swift @@ -0,0 +1,222 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI +import NextcloudKit + +struct NCAssistantChat: View { + @Environment(NCAssistantChatModel.self) var chatModel + @Binding var conversationsModel: NCAssistantChatConversationsModel + + var body: some View { + @Bindable var chatModel = chatModel + + if chatModel.messages.isEmpty { + NCAssistantEmptyView(titleKey: "_no_tasks_", subtitleKey: "_no_chat_subtitle_") + } + + ZStack { + VStack(spacing: 0) { + messageListView + } + + } + .safeAreaInset(edge: .bottom) { + ChatInputField(isLoading: $chatModel.isSending, isDisabled: $chatModel.isSendingDisabled) { input in + if chatModel.selectedConversation != nil { + chatModel.sendMessage(input: input) + } else { + chatModel.startNewConversationViaMessage(input: input, sessionsModel: conversationsModel) + } + } + } + .navigationTitle(NSLocalizedString("_assistant_chat_", comment: "")) + .navigationBarTitleDisplayMode(.inline) + } + + private var messageListView: some View { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 12) { + ForEach(chatModel.messages) { message in + MessageBubbleView(message: message, account: chatModel.controller?.account ?? "") + .id(message.id) + } + + if chatModel.isThinking { + ThinkingBubbleView() + .id("thinking") + } + + if chatModel.showRetryResponseGenerationButton { + let button = Button("_retry_response_generation_") { + chatModel.onRetryResponseGeneration() + } + .frame(maxWidth: .infinity) + .padding() + + if #available(iOS 26.0, *) { + button + .buttonStyle(.glass) + } else { + button + .buttonStyle(.bordered) + } + } + } + .padding(.vertical) + } + .onChange(of: chatModel.messages.count) { _, _ in + withAnimation { + if let lastMessage = chatModel.messages.last { + proxy.scrollTo(lastMessage.id, anchor: .bottom) + } + } + } + .onChange(of: chatModel.isThinking) { _, isThinking in + if isThinking { + withAnimation { + proxy.scrollTo("thinking", anchor: .bottom) + } + } + } + } + } +} + +// MARK: - Message Bubble View + +struct MessageBubbleView: View { + let message: AssistantChatMessage + let account: String + + var body: some View { + HStack { + if message.isFromHuman { + Spacer(minLength: 50) + } + + VStack(alignment: message.isFromHuman ? .trailing : .leading, spacing: 4) { + Text(message.content) + .font(.body) + .foregroundStyle(message.isFromHuman ? .white : .primary) + .padding() + .background(bubbleBackground) + .clipShape(.rect(cornerRadius: 16)) + + Text(NCUtility().getRelativeDateTitle(Date(timeIntervalSince1970: TimeInterval(message.timestamp)))) + .font(.caption) + .foregroundStyle(.secondary) + .padding(.horizontal, 4) + } + .frame(maxWidth: .infinity, alignment: message.isFromHuman ? .trailing : .leading) + .padding(.horizontal) + + if !message.isFromHuman { + Spacer(minLength: 50) + } + } + } + + private var bubbleBackground: Color { + if message.isFromHuman { + return Color(NCBrandColor.shared.getElement(account: account)) + } else { + return Color(NCBrandColor.shared.textColor2).opacity(0.1) + } + } +} + +// MARK: - Thinking Bubble View + +struct ThinkingBubbleView: View { + @State private var scale1: CGFloat = 1.0 + @State private var scale2: CGFloat = 1.0 + @State private var scale3: CGFloat = 1.0 + + var body: some View { + HStack(alignment: .center, spacing: 4) { + Circle() + .fill(Color.secondary) + .frame(width: 8, height: 8) + .scaleEffect(scale1) + + Circle() + .fill(Color.secondary) + .frame(width: 8, height: 8) + .scaleEffect(scale2) + + Circle() + .fill(Color.secondary) + .frame(width: 8, height: 8) + .scaleEffect(scale3) + } + .padding() + .background(Color(NCBrandColor.shared.textColor2).opacity(0.1)) + .clipShape(.rect(cornerRadius: 16)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal) + .onAppear { + startAnimation() + } + } + + private func startAnimation() { + withAnimation(.easeInOut(duration: 0.4).repeatForever(autoreverses: true)) { + scale1 = 1.3 + } + withAnimation(.easeInOut(duration: 0.4).repeatForever(autoreverses: true).delay(0.15)) { + scale2 = 1.3 + } + withAnimation(.easeInOut(duration: 0.4).repeatForever(autoreverses: true).delay(0.3)) { + scale3 = 1.3 + } + } +} + +// MARK: - Empty Chat View + +struct EmptyChatView: View { + @Environment(NCAssistantChatModel.self) var chatModel + + var body: some View { + VStack(spacing: 16) { + Image(systemName: "bubble.left.and.bubble.right") + .renderingMode(.template) + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundStyle(Color(NCBrandColor.shared.getElement(account: chatModel.controller?.account))) + .font(Font.system(.body).weight(.light)) + .frame(height: 100) + + Text(NSLocalizedString("_start_conversation_", comment: "")) + .font(.system(size: 22, weight: .bold)) + .padding(.bottom, 5) + + Text(NSLocalizedString("_ask_assistant_anything_", comment: "")) + .font(.system(size: 14)) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + } + } +} + +// MARK: - Preview + +#Preview { + NavigationStack { + NCAssistantChat(conversationsModel: .constant(NCAssistantChatConversationsModel(controller: nil))) + .environment(NCAssistantChatModel(controller: nil)) + .environment(NCAssistantModel(controller: nil)) + } +} + +#Preview("With Messages") { + NavigationStack { + NCAssistantChat(conversationsModel: .constant(NCAssistantChatConversationsModel(controller: nil))) + .environment(NCAssistantChatModel.example) + .environment(NCAssistantModel(controller: nil)) + } +} diff --git a/iOSClient/Assistant/Chat/NCAssistantChatModel.swift b/iOSClient/Assistant/Chat/NCAssistantChatModel.swift new file mode 100644 index 0000000000..140f50870d --- /dev/null +++ b/iOSClient/Assistant/Chat/NCAssistantChatModel.swift @@ -0,0 +1,184 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import NextcloudKit + +@Observable class NCAssistantChatModel { + var messages: [AssistantChatMessage] = [] + var isSending: Bool = false + var isThinking: Bool = false + var isSendingDisabled = false + var hasError: Bool = false + var showRetryResponseGenerationButton = false + var showMessageNotSentError: Bool = false + + public private(set) var selectedConversation: AssistantConversation? + + var currentSession: AssistantSession? + + private let ncSession: NCSession.Session + private var pollingTask: Task? + + @ObservationIgnored var controller: NCMainTabBarController? + @ObservationIgnored private var chatMessageTaskId: Int? + + init(controller: NCMainTabBarController?, messages: [AssistantChatMessage] = []) { + self.controller = controller + self.ncSession = NCSession.shared.getSession(controller: controller) + self.messages = messages + } + + func startPollingForResponse(interval: TimeInterval = 4.0) { + stopPolling() + isSendingDisabled = true + isThinking = true + showRetryResponseGenerationButton = false + + pollingTask = Task { + while !Task.isCancelled { + + await loadLastMessage() + try? await Task.sleep(for: .seconds(interval)) + } + } + } + + func stopPolling() { + pollingTask?.cancel() + pollingTask = nil + isThinking = false + isSendingDisabled = false + } + + func selectConversation(selectedConversation: AssistantConversation) async { + self.selectedConversation = selectedConversation + + stopPolling() + showRetryResponseGenerationButton = false + currentSession = nil + + await loadAllMessages() + currentSession = await checkChatSession(sessionId: selectedConversation.id) + chatMessageTaskId = currentSession?.messageTaskId + + if messages.last?.isFromHuman == true, chatMessageTaskId == nil, isSending == false { + showRetryResponseGenerationButton = true + } else if chatMessageTaskId != nil { + startPollingForResponse() + } + } + + func generateChatSession() async { + guard let sessionId = selectedConversation?.id else { return } + + let result = await NextcloudKit.shared.generateAssistantChatSession(sessionId: sessionId, account: ncSession.account) + chatMessageTaskId = result.sessionTask?.taskId + } + + func onRetryResponseGeneration() { + Task { + await generateChatSession() + startPollingForResponse() + } + } + + private func checkChatSession(sessionId: Int) async -> AssistantSession? { + let result = await NextcloudKit.shared.checkAssistantChatSession(sessionId: sessionId, account: ncSession.account) + return result.session + } + + private func loadAllMessages() async { + guard let sessionId = selectedConversation?.id else { return } + + let result = await NextcloudKit.shared.getAssistantChatMessages(sessionId: sessionId, account: ncSession.account) + + if result.error == .success { + messages = result.chatMessages ?? [] + } else { + await showErrorBanner(controller: controller, title: "_error_", text: "_assistant_error_load_messages_", errorCode: result.error.errorCode) + } + } + + private func loadLastMessage() async { + guard let chatMessageTaskId else { return } + + let result = await NextcloudKit.shared.checkAssistantChatGeneration(taskId: chatMessageTaskId, sessionId: selectedConversation?.id ?? 0, account: ncSession.account) + + if result.error != .success { + stopPolling() + await showErrorBanner(controller: controller, title: "_error_", text: "_assistant_error_generate_response_", errorCode: result.error.errorCode) + return + } + + if let lastMessage = result.chatMessage, lastMessage.role == "assistant" { + stopPolling() + messages.append(lastMessage) + } + } + + func sendMessage(input: String) { + guard let selectedConversation else { return } + + let request = AssistantChatMessageRequest(sessionId: selectedConversation.id, role: "human", content: input, timestamp: Int(Date().timeIntervalSince1970), firstHumanMessage: messages.isEmpty) + isSending = true + isSendingDisabled = true + + Task { + let result = await NextcloudKit.shared.createAssistantChatMessage(messageRequest: request, account: ncSession.account) + if result.error == .success { + guard let chatMessage = result.chatMessage else { return } + messages.append(chatMessage) + + stopPolling() + await generateChatSession() + startPollingForResponse() + } else { + await showErrorBanner(controller: controller, title: "_error_", text: "_assistant_error_send_message_", errorCode: 20) + } + + isSending = false + } + } + + func startNewConversationViaMessage(input: String, sessionsModel: NCAssistantChatConversationsModel) { + Task { + isSending = true + guard let conversation = await sessionsModel.createNewConversation(title: input) else { return } + await selectConversation(selectedConversation: conversation) + sendMessage(input: input) + } + } +} + +extension NCAssistantChatModel { + static var example = NCAssistantChatModel(controller: nil, messages: [ + AssistantChatMessage( + id: 1, + sessionId: 0, + role: "human", + content: "Hello! Can you help me summarize this document?", + timestamp: Int(Date().addingTimeInterval(-300).timeIntervalSince1970 * 1000) + ), + AssistantChatMessage( + id: 2, + sessionId: 0, + role: "assistant", + content: "Of course! I'd be happy to help you summarize your document. Please share the document or paste the text you'd like me to summarize.", + timestamp: Int(Date().addingTimeInterval(-240).timeIntervalSince1970 * 1000) + ), + AssistantChatMessage( + id: 3, + sessionId: 0, + role: "human", + content: "Here is the text: Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", + timestamp: Int(Date().addingTimeInterval(-180).timeIntervalSince1970 * 1000) + ), + AssistantChatMessage( + id: 4, + sessionId: 0, + role: "assistant", + content: "Based on the text you provided, here's a concise summary: The document discusses the classic Lorem Ipsum placeholder text, which has been used in the printing and typesetting industry for centuries as a standard dummy text.", + timestamp: Int(Date().addingTimeInterval(-120).timeIntervalSince1970 * 1000) + )]) +} diff --git a/iOSClient/Assistant/Components/ChatInputField.swift b/iOSClient/Assistant/Components/ChatInputField.swift new file mode 100644 index 0000000000..907d06e339 --- /dev/null +++ b/iOSClient/Assistant/Components/ChatInputField.swift @@ -0,0 +1,63 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI + +struct ChatInputField: View { + @FocusState private var isInputFocused: Bool + @State var text: String = "" + @Binding var isLoading: Bool + @Binding var isDisabled: Bool + var onSend: ((_ input: String) -> Void)? + + init(isLoading: Binding = .constant(false), isDisabled: Binding = .constant(false), onSend: ((_: String) -> Void)? = nil) { + _isLoading = isLoading + _isDisabled = isDisabled + self.onSend = onSend + } + + var body: some View { + VStack { + Text("_assistant_ai_warning_") + .lineLimit(1) + .allowsTightening(true) + .minimumScaleFactor(0.5) + + HStack(spacing: 8) { + TextField(NSLocalizedString("_type_message_", comment: ""), text: $text, axis: .vertical) + .textFieldStyle(.plain) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(.primary.opacity(0.1)) + .clipShape(.rect(cornerRadius: 20)) + .focused($isInputFocused) + .lineLimit(1...5) + + Button(action: { + isInputFocused = false + onSend?(text.trimmingCharacters(in: .whitespaces)) + text = "" + }) { + if isLoading { + ProgressView() + .frame(width: 28, height: 28) + } else { + Image(systemName: "arrow.up.circle.fill") + .font(.system(size: 28)) + } + } + .disabled(text.trimmingCharacters(in: .whitespaces).isEmpty || isDisabled || isLoading) + } + } + .padding(.horizontal) + .padding(.top, 16) + .padding(.bottom, 16) + .background(.background) + } +} + +#Preview { + ChatInputField(isLoading: .constant(false)) + ChatInputField(isLoading: .constant(true)) +} diff --git a/iOSClient/Assistant/Components/NCAssistantEmptyView.swift b/iOSClient/Assistant/Components/NCAssistantEmptyView.swift index d24cca06ca..0c6f424a9d 100644 --- a/iOSClient/Assistant/Components/NCAssistantEmptyView.swift +++ b/iOSClient/Assistant/Components/NCAssistantEmptyView.swift @@ -1,15 +1,11 @@ -// -// EmptyTasksView.swift -// Nextcloud -// -// Created by Milen on 16.04.24. -// Copyright © 2024 Marino Faggiana. All rights reserved. -// +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2024 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later import SwiftUI struct NCAssistantEmptyView: View { - @EnvironmentObject var model: NCAssistantModel + @Environment(NCAssistantModel.self) var assistantModel let titleKey, subtitleKey: String var body: some View { @@ -18,7 +14,7 @@ struct NCAssistantEmptyView: View { .renderingMode(.template) .resizable() .aspectRatio(contentMode: .fit) - .foregroundStyle(Color(NCBrandColor.shared.getElement(account: model.controller?.account))) + .foregroundStyle(Color(NCBrandColor.shared.getElement(account: assistantModel.controller?.account))) .font(Font.system(.body).weight(.light)) .frame(height: 100) diff --git a/iOSClient/Assistant/Create Task/NCAssistantCreateNewTask.swift b/iOSClient/Assistant/Create Task/NCAssistantCreateNewTask.swift index cd3250d38c..a172e724f7 100644 --- a/iOSClient/Assistant/Create Task/NCAssistantCreateNewTask.swift +++ b/iOSClient/Assistant/Create Task/NCAssistantCreateNewTask.swift @@ -9,7 +9,7 @@ import SwiftUI struct NCAssistantCreateNewTask: View { - @EnvironmentObject var model: NCAssistantModel + @Environment(NCAssistantModel.self) var model @State var text = "" @FocusState private var inFocus: Bool @Environment(\.presentationMode) var presentationMode @@ -60,7 +60,7 @@ struct NCAssistantCreateNewTask: View { let model = NCAssistantModel(controller: nil) NCAssistantCreateNewTask() - .environmentObject(model) + .environment(model) .onAppear { model.loadDummyData() } diff --git a/iOSClient/Assistant/NCAssistant.swift b/iOSClient/Assistant/NCAssistant.swift index 673f37e6e5..07dea8b63d 100644 --- a/iOSClient/Assistant/NCAssistant.swift +++ b/iOSClient/Assistant/NCAssistant.swift @@ -1,36 +1,33 @@ -// -// NCAssistant.swift -// Nextcloud -// -// Created by Milen on 03.04.24. -// Copyright © 2024 Marino Faggiana. All rights reserved. -// +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2025 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later import SwiftUI import NextcloudKit import PopupView struct NCAssistant: View { - @EnvironmentObject var model: NCAssistantModel + @State var assistantModel: NCAssistantModel + @State var chatModel: NCAssistantChatModel + @State var conversationsModel: NCAssistantChatConversationsModel @State var input = "" @Environment(\.presentationMode) var presentationMode var body: some View { NavigationView { ZStack { - TaskList() + if assistantModel.types.isEmpty, !assistantModel.isLoading { + NCAssistantEmptyView(titleKey: "_no_types_", subtitleKey: "_no_types_subtitle_") + } else if assistantModel.isSelectedTypeChat { + NCAssistantChat(conversationsModel: $conversationsModel) + } else { + TaskList() + } - if model.isLoading, !model.isRefreshing { + if assistantModel.isLoading, !assistantModel.isRefreshing { ProgressView() .controlSize(.regular) } - - if model.types.isEmpty, !model.isLoading { - NCAssistantEmptyView(titleKey: "_no_types_", subtitleKey: "_no_types_subtitle_") - } else if model.filteredTasks.isEmpty, !model.isLoading { - NCAssistantEmptyView(titleKey: "_no_tasks_", subtitleKey: "_create_task_subtitle_") - } - } .toolbar { ToolbarItem(placement: .topBarLeading) { @@ -41,17 +38,27 @@ struct NCAssistant: View { } } ToolbarItem(placement: .topBarTrailing) { - NavigationLink(destination: NCAssistantCreateNewTask()) { - Image(systemName: "plus") + NavigationLink(destination: NCAssistantChatConversations(conversationsModel: conversationsModel, selectedConversation: chatModel.selectedConversation) { conversation in + guard let conversation else { return } + + Task { + await chatModel.selectConversation(selectedConversation: conversation) + assistantModel.selectChatTaskType() + } + }) { + Image(systemName: "clock.arrow.trianglehead.counterclockwise.rotate.90") .font(Font.system(.body).weight(.light)) .foregroundStyle(Color(NCBrandColor.shared.iconImageColor)) } - .disabled(model.selectedType == nil) - .accessibilityIdentifier("CreateButton") + .disabled(assistantModel.selectedType == nil) + .accessibilityIdentifier("ConversationsButton") } } .navigationBarTitleDisplayMode(.inline) .navigationTitle(NSLocalizedString("_assistant_", comment: "")) + .modifier(NavigationSubtitleModifier(subtitle: assistantModel.isSelectedTypeChat ? + chatModel.currentSession?.sessionTitle ?? chatModel.selectedConversation?.validTitle + : "")) .frame(maxWidth: .infinity, maxHeight: .infinity) .safeAreaInset(edge: .top, spacing: -10) { TypeList() @@ -59,7 +66,7 @@ struct NCAssistant: View { } .navigationViewStyle(.stack) - .popup(isPresented: $model.hasError) { + .popup(isPresented: $assistantModel.hasError) { Text(NSLocalizedString("_error_occurred_", comment: "")) .padding() .background(.red) @@ -71,22 +78,27 @@ struct NCAssistant: View { .position(.bottom) } .accentColor(Color(NCBrandColor.shared.iconImageColor)) - .environmentObject(model) + .environment(assistantModel) + .environment(chatModel) + .onDisappear { + chatModel.stopPolling() + } } } #Preview { + @Previewable @State var chatModel = NCAssistantChatModel(controller: nil) let model = NCAssistantModel(controller: nil) + let conversationsModel = NCAssistantChatConversationsModel(controller: nil) - NCAssistant() - .environmentObject(model) + NCAssistant(assistantModel: model, chatModel: chatModel, conversationsModel: conversationsModel) .onAppear { model.loadDummyData() } } struct TaskList: View { - @EnvironmentObject var model: NCAssistantModel + @Environment(NCAssistantModel.self) var assistantModel @State var presentEditTask = false @State var showDeleteConfirmation = false @@ -94,11 +106,13 @@ struct TaskList: View { @State var taskToDelete: AssistantTask? var body: some View { - List(model.filteredTasks, id: \.id) { task in + @Bindable var assistantModel = assistantModel + + List(assistantModel.filteredTasks, id: \.id) { task in TaskItem(showDeleteConfirmation: $showDeleteConfirmation, taskToDelete: $taskToDelete, task: task) .contextMenu { Button { - model.shareTask(task) + assistantModel.shareTask(task) } label: { Label { Text("_share_") @@ -108,7 +122,7 @@ struct TaskList: View { } Button { - model.scheduleTask(input: task.input?.input ?? "") + assistantModel.scheduleTask(input: task.input?.input ?? "") } label: { Label { Text("_retry_") @@ -144,16 +158,16 @@ struct TaskList: View { } .accessibilityIdentifier("TaskContextMenu") } - .if(!model.types.isEmpty) { view in + .if(!assistantModel.types.isEmpty) { view in view.refreshable { - model.refresh() + assistantModel.refresh() } } .confirmationDialog("", isPresented: $showDeleteConfirmation) { Button(NSLocalizedString("_delete_", comment: ""), role: .destructive) { withAnimation { guard let taskToDelete else { return } - model.deleteTask(taskToDelete) + assistantModel.deleteTask(taskToDelete) } } } @@ -162,11 +176,20 @@ struct TaskList: View { NCAssistantCreateNewTask(text: taskToEdit?.input?.input ?? "", editMode: true) } } + .safeAreaInset(edge: .bottom) { + ChatInputField(isLoading: $assistantModel.isLoading) { input in + assistantModel.scheduleTask(input: input) + } + } + + if assistantModel.filteredTasks.isEmpty, !assistantModel.isLoading { + NCAssistantEmptyView(titleKey: "_no_tasks_", subtitleKey: "_create_task_subtitle_") + } } } struct TypeButton: View { - @EnvironmentObject var model: NCAssistantModel + @Environment(NCAssistantModel.self) var model let taskType: TaskTypeData? var scrollProxy: ScrollViewProxy @@ -201,7 +224,7 @@ struct TypeButton: View { } struct TaskItem: View { - @EnvironmentObject var model: NCAssistantModel + @Environment(NCAssistantModel.self) var model @Binding var showDeleteConfirmation: Bool @Binding var taskToDelete: AssistantTask? var task: AssistantTask @@ -245,8 +268,20 @@ struct TaskItem: View { } } +struct NavigationSubtitleModifier: ViewModifier { + let subtitle: String? + + func body(content: Content) -> some View { + if #available(iOS 26.0, *) { + content.navigationSubtitle(subtitle ?? "") + } else { + content + } + } +} + struct TypeList: View { - @EnvironmentObject var model: NCAssistantModel + @Environment(NCAssistantModel.self) var model var body: some View { ScrollViewReader { scrollProxy in @@ -260,6 +295,11 @@ struct TypeList: View { .frame(height: 50) } .background(.ultraThinMaterial) + .onChange(of: model.scrollTypeListToTop) { + withAnimation(.easeInOut(duration: 0.7)) { + scrollProxy.scrollTo(model.types.first?.id, anchor: .center) + } + } } } } diff --git a/iOSClient/Assistant/Models/NCAssistantModel.swift b/iOSClient/Assistant/NCAssistantModel.swift similarity index 62% rename from iOSClient/Assistant/Models/NCAssistantModel.swift rename to iOSClient/Assistant/NCAssistantModel.swift index dd99f16704..3a6640e376 100644 --- a/iOSClient/Assistant/Models/NCAssistantModel.swift +++ b/iOSClient/Assistant/NCAssistantModel.swift @@ -7,22 +7,24 @@ import UIKit import NextcloudKit import SwiftUI -class NCAssistantModel: ObservableObject { - @Published var types: [TaskTypeData] = [] - @Published var filteredTasks: [AssistantTask] = [] - @Published var selectedType: TaskTypeData? - @Published var selectedTask: AssistantTask? - - @Published var hasError: Bool = false - @Published var isLoading: Bool = false - @Published var isRefreshing: Bool = false - @Published var controller: NCMainTabBarController? - - private var tasks: [AssistantTask] = [] - - private let session: NCSession.Session - - private let useV2: Bool +@Observable +class NCAssistantModel { + var types: [TaskTypeData] = [] + var filteredTasks: [AssistantTask] = [] + var selectedType: TaskTypeData? + var selectedTask: AssistantTask? + + var hasError: Bool = false + var isLoading: Bool = false + var isRefreshing: Bool = false + var scrollTypeListToTop: Bool = false + + @ObservationIgnored let controller: NCMainTabBarController? + @ObservationIgnored private var tasks: [AssistantTask] = [] + @ObservationIgnored private let session: NCSession.Session + @ObservationIgnored private let useV2: Bool + @ObservationIgnored private let chatTypeId = "core:text2text:chat" + @ObservationIgnored var isSelectedTypeChat: Bool { selectedType?.id == chatTypeId } init(controller: NCMainTabBarController?) { self.controller = controller @@ -30,7 +32,6 @@ class NCAssistantModel: ObservableObject { let capabilities = NCNetworking.shared.capabilities[session.account] ?? NKCapabilities.Capabilities() useV2 = capabilities.serverVersionMajor >= NCGlobal.shared.nextcloudVersion30 - // useV2 = false loadAllTypes() } @@ -49,6 +50,11 @@ class NCAssistantModel: ObservableObject { self.filteredTasks = filteredTasks.sorted(by: { $0.completionExpectedAt ?? 0 > $1.completionExpectedAt ?? 0 }) } + func selectChatTaskType() { + selectTaskType(types.first) + scrollTypeListToTop.toggle() + } + func selectTaskType(_ type: TaskTypeData?) { selectedType = type @@ -60,19 +66,18 @@ class NCAssistantModel: ObservableObject { selectedTask = task isLoading = true - /* - if useV2 { - NextcloudKit.shared.textProcessingGetTasksV2(taskType: task.type ?? "", account: session.account, completion: { _, _, _, error in - handle(task: task, error: error) - }) - } else { - NextcloudKit.shared.textProcessingGetTask(taskId: Int(task.id), account: session.account) { _, task, _, error in - guard let task else { return } - let taskV2 = NKTextProcessingTask.toV2(tasks: [task]).tasks.first - handle(task: taskV2, error: error) + Task { + if useV2 { + let result = await NextcloudKit.shared.textProcessingGetTasksV2(taskType: task.type ?? "", account: session.account) + handle(task: task, error: result.error) + } else { + NextcloudKit.shared.textProcessingGetTask(taskId: Int(task.id), account: session.account) { _, task, _, error in + guard let task else { return } + let taskV2 = NKTextProcessingTask.toV2(tasks: [task]).tasks.first + handle(task: taskV2, error: error) + } } } - */ func handle(task: AssistantTask?, error: NKError?) { self.isLoading = false @@ -89,19 +94,18 @@ class NCAssistantModel: ObservableObject { func scheduleTask(input: String) { isLoading = true - /* - if useV2 { - guard let selectedType else { return } - NextcloudKit.shared.textProcessingScheduleV2(input: input, taskType: selectedType, account: session.account) { _, task, _, error in - handle(task: task, error: error) - } - } else { - NextcloudKit.shared.textProcessingSchedule(input: input, typeId: selectedType?.id ?? "", identifier: "assistant", account: session.account) { _, task, _, error in - guard let task, let taskV2 = NKTextProcessingTask.toV2(tasks: [task]).tasks.first else { return } - handle(task: taskV2, error: error) + Task { + if useV2 { + guard let selectedType else { return } + let result = await NextcloudKit.shared.textProcessingScheduleV2(input: input, taskType: selectedType, account: session.account) + handle(task: result.task, error: result.error) + } else { + NextcloudKit.shared.textProcessingSchedule(input: input, typeId: selectedType?.id ?? "", identifier: "assistant", account: session.account) { _, task, _, error in + guard let task, let taskV2 = NKTextProcessingTask.toV2(tasks: [task]).tasks.first else { return } + handle(task: taskV2, error: error) + } } } - */ func handle(task: AssistantTask?, error: NKError?) { self.isLoading = false @@ -121,17 +125,16 @@ class NCAssistantModel: ObservableObject { func deleteTask(_ task: AssistantTask) { isLoading = true - /* - if useV2 { - NextcloudKit.shared.textProcessingDeleteTaskV2(taskId: task.id, account: session.account) { _, _, error in - handle(task: task, error: error) - } - } else { - NextcloudKit.shared.textProcessingDeleteTask(taskId: Int(task.id), account: session.account) { _, _, _, error in - handle(task: task, error: error) + Task { + if useV2 { + let result = await NextcloudKit.shared.textProcessingDeleteTaskV2(taskId: task.id, account: session.account) + handle(task: task, error: result.error) + } else { + NextcloudKit.shared.textProcessingDeleteTask(taskId: Int(task.id), account: session.account) { _, _, _, error in + handle(task: task, error: error) + } } } - */ func handle(task: AssistantTask, error: NKError?) { self.isLoading = false @@ -154,20 +157,19 @@ class NCAssistantModel: ObservableObject { private func loadAllTypes() { isLoading = true - /* - if useV2 { - NextcloudKit.shared.textProcessingGetTypesV2(account: session.account) { _, types, _, error in - handle(types: types, error: error) - } - } else { - NextcloudKit.shared.textProcessingGetTypes(account: session.account) { _, types, _, error in - guard let types else { return } - let typesV2 = NKTextProcessingTaskType.toV2(type: types).types - - handle(types: typesV2, error: error) + Task { + if useV2 { + let result = await NextcloudKit.shared.textProcessingGetTypesV2(account: session.account) + handle(types: result.types, error: result.error) + } else { + NextcloudKit.shared.textProcessingGetTypes(account: session.account) { _, types, _, error in + guard let types else { return } + let typesV2 = NKTextProcessingTaskType.toV2(type: types).types + + handle(types: typesV2, error: error) + } } } - */ func handle(types: [TaskTypeData]?, error: NKError) { self.isLoading = false @@ -179,10 +181,10 @@ class NCAssistantModel: ObservableObject { guard let types else { return } - self.types = types + self.types = types.sorted { $0.id == chatTypeId && $1.id != chatTypeId } if self.selectedType == nil { - self.selectTaskType(types.first) + self.selectTaskType(self.types.first) } self.loadAllTasks(type: selectedType) @@ -192,19 +194,18 @@ class NCAssistantModel: ObservableObject { private func loadAllTasks(appId: String = "assistant", type: TaskTypeData?) { isLoading = true - /* - if useV2 { - NextcloudKit.shared.textProcessingGetTasksV2(taskType: type?.id ?? "", account: session.account) { _, tasks, _, error in - guard let tasks = tasks?.tasks.filter({ $0.appId == "assistant" }) else { return } - handle(tasks: tasks, error: error) - } - } else { - NextcloudKit.shared.textProcessingTaskList(appId: appId, account: session.account) { _, tasks, _, error in - guard let tasks else { return } - handle(tasks: NKTextProcessingTask.toV2(tasks: tasks).tasks, error: error) + Task { + if useV2 { + let result = await NextcloudKit.shared.textProcessingGetTasksV2(taskType: type?.id ?? "", account: session.account) + guard let tasks = result.tasks?.tasks.filter({ $0.appId == "assistant" }) else { return } + handle(tasks: tasks, error: result.error) + } else { + NextcloudKit.shared.textProcessingTaskList(appId: appId, account: session.account) { _, tasks, _, error in + guard let tasks else { return } + handle(tasks: NKTextProcessingTask.toV2(tasks: tasks).tasks, error: error) + } } } - */ func handle(tasks: [AssistantTask], error: NKError?) { isLoading = false @@ -232,10 +233,11 @@ extension NCAssistantModel { } self.types = [ - TaskTypeData(id: "1", name: "Free Prompt", description: "", inputShape: nil, outputShape: nil), + TaskTypeData(id: "1", name: "Chat", description: "", inputShape: nil, outputShape: nil), TaskTypeData(id: "2", name: "Summarize", description: "", inputShape: nil, outputShape: nil), TaskTypeData(id: "3", name: "Generate headline", description: "", inputShape: nil, outputShape: nil), - TaskTypeData(id: "4", name: "Reformulate", description: "", inputShape: nil, outputShape: nil) + TaskTypeData(id: "4", name: "Reformulate", description: "", inputShape: nil, outputShape: nil), + TaskTypeData(id: "5", name: "Free Prompt", description: "", inputShape: nil, outputShape: nil) ] self.tasks = tasks diff --git a/iOSClient/Assistant/Task Detail/NCAssistantTaskDetail.swift b/iOSClient/Assistant/Task Detail/NCAssistantTaskDetail.swift index 3a2e9b9bba..7533dfc588 100644 --- a/iOSClient/Assistant/Task Detail/NCAssistantTaskDetail.swift +++ b/iOSClient/Assistant/Task Detail/NCAssistantTaskDetail.swift @@ -10,7 +10,7 @@ import SwiftUI import NextcloudKit struct NCAssistantTaskDetail: View { - @EnvironmentObject var model: NCAssistantModel + @Environment(NCAssistantModel.self) var assistantModel let task: AssistantTask var body: some View { @@ -21,7 +21,7 @@ struct NCAssistantTaskDetail: View { } .toolbar { Button(action: { - model.shareTask(task) + assistantModel.shareTask(task) }, label: { Image(systemName: "square.and.arrow.up") }) @@ -29,23 +29,23 @@ struct NCAssistantTaskDetail: View { .navigationBarTitleDisplayMode(.inline) .navigationTitle(NSLocalizedString("_task_details_", comment: "")) .onAppear { - model.selectTask(task) + assistantModel.selectTask(task) } } } #Preview { - let model = NCAssistantModel(controller: nil) + let assistantModel = NCAssistantModel(controller: nil) - NCAssistantTaskDetail(task: model.selectedTask!) - .environmentObject(model) + NCAssistantTaskDetail(task: assistantModel.selectedTask!) + .environment(assistantModel) .onAppear { - model.loadDummyData() + assistantModel.loadDummyData() } } struct InputOutputScrollView: View { - @EnvironmentObject var model: NCAssistantModel + @Environment(NCAssistantModel.self) var model let task: AssistantTask var body: some View { @@ -79,7 +79,7 @@ struct InputOutputScrollView: View { } struct BottomDetailsBar: View { - @EnvironmentObject var model: NCAssistantModel + @Environment(NCAssistantModel.self) var assistantModel let task: AssistantTask var body: some View { diff --git a/iOSClient/Main/Collection Common/Cell/UIView+BlurVibrancy.swift b/iOSClient/Main/Collection Common/Cell/UIView+BlurVibrancy.swift index e48bf71263..d31c53262a 100644 --- a/iOSClient/Main/Collection Common/Cell/UIView+BlurVibrancy.swift +++ b/iOSClient/Main/Collection Common/Cell/UIView+BlurVibrancy.swift @@ -1,8 +1,6 @@ -// -// UIView+BlurVibrancy.swift -// -// Created by Xcode Assistant. -// +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2025 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later import UIKit import ObjectiveC @@ -85,65 +83,4 @@ public extension UIView { blurEffectView = blurView return blurView } - - /// Adds a vibrancy overlay tied to a blur view and returns the vibrancy effect view. - /// If `blurView` is `nil`, this method will use the previously added blur view or create a new one with default style. - /// The vibrancy view is added inside the blur view's contentView and pinned to its edges. - /// - /// - Parameters: - /// - using: The blur view to attach vibrancy to. If `nil`, uses/creates one. - /// - style: The `UIVibrancyEffectStyle` to use. Defaults to `.label`. - /// - insets: Edge insets to apply when pinning the vibrancy view. Defaults to `.zero`. - /// - Returns: The configured and inserted `UIVisualEffectView` for vibrancy, or `nil` if a blur effect could not be determined. - @discardableResult - func addVibrancyOverlay(using blurView: UIVisualEffectView? = nil, - style: UIVibrancyEffectStyle = .label, - insets: UIEdgeInsets = .zero) -> UIVisualEffectView? { - // Ensure we have a blur view - let blur: UIVisualEffectView - if let provided = blurView { - blur = provided - } else if let existing = blurEffectView { - blur = existing - } else { - // Create a default blur if none exists - blur = addBlurBackground() - } - - guard let blurEffect = blur.effect as? UIBlurEffect else { return nil } - - // Remove existing vibrancy (if any) that was previously added via this extension - if let existingVibrancy = vibrancyEffectView { - existingVibrancy.removeFromSuperview() - vibrancyEffectView = nil - } - - let vibrancyEffect = UIVibrancyEffect(blurEffect: blurEffect, style: style) - let vibrancyView = UIVisualEffectView(effect: vibrancyEffect) - vibrancyView.isUserInteractionEnabled = false - vibrancyView.translatesAutoresizingMaskIntoConstraints = false - - blur.contentView.addSubview(vibrancyView) - NSLayoutConstraint.activate([ - vibrancyView.leadingAnchor.constraint(equalTo: blur.contentView.leadingAnchor, constant: insets.left), - vibrancyView.trailingAnchor.constraint(equalTo: blur.contentView.trailingAnchor, constant: -insets.right), - vibrancyView.topAnchor.constraint(equalTo: blur.contentView.topAnchor, constant: insets.top), - vibrancyView.bottomAnchor.constraint(equalTo: blur.contentView.bottomAnchor, constant: -insets.bottom) - ]) - - vibrancyEffectView = vibrancyView - return vibrancyView - } - - /// Removes the blur and vibrancy effect views previously added via this extension. - func removeBlurAndVibrancy() { - if let vibrancy = vibrancyEffectView { - vibrancy.removeFromSuperview() - vibrancyEffectView = nil - } - if let blur = blurEffectView { - blur.removeFromSuperview() - blurEffectView = nil - } - } } diff --git a/iOSClient/Main/NCMainNavigationController.swift b/iOSClient/Main/NCMainNavigationController.swift index 3b7a5d7a1d..87ef17bee7 100644 --- a/iOSClient/Main/NCMainNavigationController.swift +++ b/iOSClient/Main/NCMainNavigationController.swift @@ -80,8 +80,7 @@ class NCMainNavigationController: UINavigationController, UINavigationController assistantButtonItem.title = NSLocalizedString("_assistant_", comment: "") assistantButtonItem.tintColor = NCBrandColor.shared.iconImageColor assistantButtonItem.primaryAction = UIAction(handler: { _ in - let assistant = NCAssistant() - .environmentObject(NCAssistantModel(controller: self.controller)) + let assistant = NCAssistant(assistantModel: NCAssistantModel(controller: self.controller), chatModel: NCAssistantChatModel(controller: self.controller), conversationsModel: NCAssistantChatConversationsModel(controller: self.controller)) let hostingController = UIHostingController(rootView: assistant) self.present(hostingController, animated: true, completion: nil) }) diff --git a/iOSClient/More/Cells/NCMoreAppSuggestionsCell.swift b/iOSClient/More/Cells/NCMoreAppSuggestionsCell.swift index 52dfb9e3f7..0fe44f09b4 100644 --- a/iOSClient/More/Cells/NCMoreAppSuggestionsCell.swift +++ b/iOSClient/More/Cells/NCMoreAppSuggestionsCell.swift @@ -28,7 +28,6 @@ import SwiftUI import NextcloudKit class NCMoreAppSuggestionsCell: BaseNCMoreCell { - @IBOutlet weak var assistantView: UIStackView! @IBOutlet weak var talkView: UIStackView! @IBOutlet weak var notesView: UIStackView! @IBOutlet weak var moreAppsView: UIStackView! @@ -44,7 +43,6 @@ class NCMoreAppSuggestionsCell: BaseNCMoreCell { super.awakeFromNib() backgroundColor = .clear - assistantView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(assistantTapped(_:)))) talkView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(talkTapped(_:)))) notesView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(notesTapped(_:)))) moreAppsView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(moreAppsTapped(_:)))) @@ -55,19 +53,9 @@ class NCMoreAppSuggestionsCell: BaseNCMoreCell { return } - assistantView.isHidden = !capabilities.assistantEnabled self.controller = controller } - @objc func assistantTapped(_ sender: Any?) { - if let viewController = self.window?.rootViewController { - let assistant = NCAssistant() - .environmentObject(NCAssistantModel(controller: self.controller)) - let hostingController = UIHostingController(rootView: assistant) - viewController.present(hostingController, animated: true, completion: nil) - } - } - @objc func talkTapped(_ sender: Any?) { guard let url = URL(string: NCGlobal.shared.talkSchemeUrl) else { return } diff --git a/iOSClient/More/Cells/NCMoreAppSuggestionsCell.xib b/iOSClient/More/Cells/NCMoreAppSuggestionsCell.xib index b3ec728c77..3bc0d1a9eb 100644 --- a/iOSClient/More/Cells/NCMoreAppSuggestionsCell.xib +++ b/iOSClient/More/Cells/NCMoreAppSuggestionsCell.xib @@ -1,9 +1,8 @@ - + - - + @@ -20,41 +19,11 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - + - + @@ -62,7 +31,7 @@ - + - + @@ -92,7 +61,7 @@ - + - + @@ -122,7 +91,7 @@ - - @@ -163,7 +130,6 @@ - @@ -174,7 +140,6 @@ - diff --git a/iOSClient/More/NCMore.swift b/iOSClient/More/NCMore.swift index 43301a4fd4..57c3f016cc 100644 --- a/iOSClient/More/NCMore.swift +++ b/iOSClient/More/NCMore.swift @@ -407,11 +407,6 @@ class NCMore: UIViewController, UITableViewDelegate, UITableViewDataSource { alertController.addAction(actionYes) alertController.addAction(actionNo) self.present(alertController, animated: true, completion: nil) - } else if item.url == "openAssistant" { - let assistant = NCAssistant() - .environmentObject(NCAssistantModel(controller: self.controller)) - let hostingController = UIHostingController(rootView: assistant) - present(hostingController, animated: true, completion: nil) } else if item.url == "openSettings" { let settingsView = NCSettingsView(model: NCSettingsModel(controller: self.controller)) let settingsController = UIHostingController(rootView: settingsView) diff --git a/iOSClient/Supporting Files/en.lproj/Localizable.strings b/iOSClient/Supporting Files/en.lproj/Localizable.strings index 59cf1839df..d83b987a48 100644 --- a/iOSClient/Supporting Files/en.lproj/Localizable.strings +++ b/iOSClient/Supporting Files/en.lproj/Localizable.strings @@ -711,12 +711,21 @@ You can stop it at any time, adjust the settings, and enable it again."; "_task_details_" = "Task details"; "_assistant_" = "Assistant"; "_new_task_" = "New %@ task"; -"_no_tasks_" = "No tasks in here"; -"_create_task_subtitle_" = "Use the + button to create one"; +"_no_tasks_" = "Hello there! What can I help you with today?"; +"_create_task_subtitle_" = "Use the + button to create a new task"; "_edit_task_" = "Edit %@ task"; "_no_types_" = "No provider found"; "_no_types_subtitle_" = "AI Providers need to be installed to use the Assistant."; - +"_assistant_ai_warning_" = "Output shown here is generated by AI. Make sure to always double-check."; +"_no_chat_subtitle_" = "Try sending a message to spark a conversation."; +"_retry_response_generation_" = "Retry response generation"; +"_conversations_" = "Conversations"; +"_new_conversation_" = "New conversation"; +"_type_message_" = "Type a message..."; +"_assistant_chat_" = "Assistant Chat"; +"_assistant_error_send_message_" = "Could not send message. Please try again."; +"_assistant_error_load_messages_" = "Could not load messages. Please try again."; +"_assistant_error_generate_response_" = "Could not generate response. Please try again."; // MARK: Client certificate "_no_client_cert_found_" = "The server is requesting a client certificate."; "_no_client_cert_found_desc_" = "Do you want to install a TLS client certificate? \n Note that the .p12 certificate must be installed on your device first by clicking on it and installing it as an Identitity Certificate Profile in Settings. The certificate MUST also have a password as that is a requirement by iOS.";