From 7a6262bbfe422ac5c87a51b4e9f03443813cabd0 Mon Sep 17 00:00:00 2001 From: Milen Pivchev Date: Mon, 19 Jan 2026 13:48:43 +0100 Subject: [PATCH 01/31] WIP Signed-off-by: Milen Pivchev --- Nextcloud.xcodeproj/project.pbxproj | 14 + .../Assistant/Chat/NCAssistantChat.swift | 385 ++++++++++++++++++ .../Assistant/Models/NCAssistantModel.swift | 17 +- iOSClient/Assistant/NCAssistant.swift | 6 +- .../Cell/UIView+BlurVibrancy.swift | 69 +--- 5 files changed, 418 insertions(+), 73 deletions(-) create mode 100644 iOSClient/Assistant/Chat/NCAssistantChat.swift diff --git a/Nextcloud.xcodeproj/project.pbxproj b/Nextcloud.xcodeproj/project.pbxproj index cc39c7ec55..7402a0fd5f 100644 --- a/Nextcloud.xcodeproj/project.pbxproj +++ b/Nextcloud.xcodeproj/project.pbxproj @@ -166,6 +166,7 @@ F3BB46542A3A1E9D00461F6E /* CCCellMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3BB46532A3A1E9D00461F6E /* CCCellMore.swift */; }; F3C587AE2D47E4FE004532DB /* PHAssetCollectionThumbnailLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3C587AD2D47E4FE004532DB /* PHAssetCollectionThumbnailLoader.swift */; }; F3CA337D2D0B2B6C00672333 /* AlbumModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3CA337C2D0B2B6A00672333 /* AlbumModel.swift */; }; + F3DDFE0F2F15453900A784C8 /* NCAssistantChat.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3DDFE0E2F15453900A784C8 /* NCAssistantChat.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 */; }; @@ -1274,6 +1275,8 @@ F3BB46532A3A1E9D00461F6E /* CCCellMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CCCellMore.swift; sourceTree = ""; }; F3C587AD2D47E4FE004532DB /* PHAssetCollectionThumbnailLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PHAssetCollectionThumbnailLoader.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 = ""; }; + F3DDFE102F16732D00A784C8 /* NextcloudKit */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = NextcloudKit; path = ../NextcloudKit; sourceTree = SOURCE_ROOT; }; 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 = ""; }; @@ -2149,6 +2152,7 @@ F3A0478E2BD2668800658E7B /* Assistant */ = { isa = PBXGroup; children = ( + F3DDFE0D2F15452F00A784C8 /* Chat */, F3A047962BD2668800658E7B /* NCAssistant.swift */, F3A047902BD2668800658E7B /* Create Task */, F3A047942BD2668800658E7B /* Task Detail */, @@ -2193,6 +2197,14 @@ path = Cells; sourceTree = ""; }; + F3DDFE0D2F15452F00A784C8 /* Chat */ = { + isa = PBXGroup; + children = ( + F3DDFE0E2F15453900A784C8 /* NCAssistantChat.swift */, + ); + path = Chat; + sourceTree = ""; + }; F3E173BE2C9B1057006D177A /* ScreenAwakeManager */ = { isa = PBXGroup; children = ( @@ -3241,6 +3253,7 @@ C04E2F202A17BB4D001BAD85 /* NextcloudIntegrationTests.xctest */, C0046CDA2A17B98400D87C9D /* NextcloudUITests.xctest */, F7F1FBA62E27D13700C79E20 /* Frameworks */, + F3DDFE102F16732D00A784C8 /* NextcloudKit */, ); sourceTree = ""; }; @@ -4715,6 +4728,7 @@ F343A4BB2A1E734600DDA874 /* Optional+Extension.swift in Sources */, F76882232C0DD1E7001CF441 /* NCCapabilitiesModel.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 */, diff --git a/iOSClient/Assistant/Chat/NCAssistantChat.swift b/iOSClient/Assistant/Chat/NCAssistantChat.swift new file mode 100644 index 0000000000..3d5744be9a --- /dev/null +++ b/iOSClient/Assistant/Chat/NCAssistantChat.swift @@ -0,0 +1,385 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI +import NextcloudKit + +// MARK: - Data Models + +struct ChatMessage: Identifiable, Equatable { + let id: UUID + let content: String + let isFromUser: Bool + let timestamp: Date + + init(id: UUID = UUID(), content: String, isFromUser: Bool, timestamp: Date = Date()) { + self.id = id + self.content = content + self.isFromUser = isFromUser + self.timestamp = timestamp + } +} + +// MARK: - View Model + +@Observable class NCAssistantChatModel { + var messages: [ChatMessage] = [] + var isThinking: Bool = false + var inputText: String = "" + var hasError: Bool = false + +// @ObservationIgnored private let session: NCSession.Session + var sessions: [AssistantSession] +// @ObservationIgnored private let taskType: TaskTypeData? +// @ObservationIgnored private let useV2: Bool + @ObservationIgnored var controller: NCMainTabBarController? + private let session: NCSession.Session + + init(controller: NCMainTabBarController?) { + self.controller = controller + let session = NCSession.shared.getSession(controller: controller) +// self.session = session +// self.taskType = taskType + + let capabilities = NCNetworking.shared.capabilities[session.account] ?? NKCapabilities.Capabilities() +// self.useV2 = capabilities.serverVersionMajor >= NCGlobal.shared.nextcloudVersion30 + } + + func getSessions() { + Task { + let result = await NextcloudKit.shared.textProcessingGetChatSessionsV2Async(account: session.account) + sessions = result.sessions + } + } + + func sendMessage() { + guard !inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } + + let userMessage = inputText + addUserMessage(userMessage) + inputText = "" + + isThinking = true + scheduleTask(input: userMessage) + } + + func scheduleTask(input: String) { + // TODO: Implement actual API call when chat API is ready + // Reference pattern from NCAssistantModel.swift:87-115 + /* + if useV2 { + guard let taskType else { return } + NextcloudKit.shared.textProcessingScheduleV2( + input: input, + taskType: taskType, + account: account + ) { _, task, _, error in + self.handleTaskResponse(task: task, error: error) + } + } else { + NextcloudKit.shared.textProcessingSchedule( + input: input, + typeId: taskType?.id ?? "", + identifier: "assistant", + account: account + ) { _, task, _, error in + guard let task, let taskV2 = NKTextProcessingTask.toV2(tasks: [task]).tasks.first else { return } + self.handleTaskResponse(task: taskV2, error: error) + } + } + */ + + // Temporary mock for testing UI + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + self.isThinking = false + self.addAssistantMessage("This is a placeholder response. The actual API integration will be implemented when the chat API is ready.") + } + } + + private func handleTaskResponse(task: AssistantTask?, error: NKError?) { + isThinking = false + + if error != .success { + hasError = true + return + } + + guard let task, let output = task.output?.output else { + hasError = true + return + } + + addAssistantMessage(output) + } + + private func addUserMessage(_ text: String) { + let message = ChatMessage(content: text, isFromUser: true) + messages.append(message) + } + + private func addAssistantMessage(_ text: String) { + let message = ChatMessage(content: text, isFromUser: false) + messages.append(message) + } + + func loadDummyData() { + messages = [ + ChatMessage( + content: "Hello! Can you help me summarize this document?", + isFromUser: true, + timestamp: Date().addingTimeInterval(-300) + ), + ChatMessage( + 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.", + isFromUser: false, + timestamp: Date().addingTimeInterval(-240) + ), + ChatMessage( + content: "Here is the text: Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", + isFromUser: true, + timestamp: Date().addingTimeInterval(-180) + ), + ChatMessage( + 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.", + isFromUser: false, + timestamp: Date().addingTimeInterval(-120) + ) + ] + } +} + +// MARK: - Main View + +struct NCAssistantChat: View { + @State var model: NCAssistantChatModel + + init(controller: NCMainTabBarController?) { + self.model = NCAssistantChatModel(controller: controller) + } + + var body: some View { + ZStack { + VStack(spacing: 0) { + messageListView + } + .safeAreaInset(edge: .bottom) { + ChatInputField(model: model) + } + + if model.messages.isEmpty && !model.isThinking { + EmptyChatView(model: model) + } + } + .navigationTitle("Assistant Chat") + .navigationBarTitleDisplayMode(.inline) + } + + private var messageListView: some View { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 12) { + ForEach(model.messages) { message in + MessageBubbleView(message: message, account: model.controller?.account ?? "") + .id(message.id) + } + + if model.isThinking { + ThinkingBubbleView() + .id("thinking") + } + } + .padding(.vertical) + } + .onChange(of: model.messages.count) { _, _ in + withAnimation { + if let lastMessage = model.messages.last { + proxy.scrollTo(lastMessage.id, anchor: .bottom) + } + } + } + .onChange(of: model.isThinking) { _, isThinking in + if isThinking { + withAnimation { + proxy.scrollTo("thinking", anchor: .bottom) + } + } + } + } + } +} + +// MARK: - Message Bubble View + +struct MessageBubbleView: View { + let message: ChatMessage + let account: String + + var body: some View { + HStack { + if message.isFromUser { + Spacer(minLength: 50) + } + + VStack(alignment: message.isFromUser ? .trailing : .leading, spacing: 4) { + Text(message.content) + .font(.body) + .foregroundStyle(message.isFromUser ? .white : .primary) + .padding() + .background(bubbleBackground) + .clipShape(.rect(cornerRadius: 16)) + + Text(NCUtility().getRelativeDateTitle(message.timestamp)) + .font(.caption) + .foregroundStyle(.secondary) + .padding(.horizontal, 4) + } + .frame(maxWidth: .infinity, alignment: message.isFromUser ? .trailing : .leading) + .padding(.horizontal) + + if !message.isFromUser { + Spacer(minLength: 50) + } + } + } + + private var bubbleBackground: Color { + if message.isFromUser { + 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: - Chat Input Field + +struct ChatInputField: View { + @Bindable var model: NCAssistantChatModel + @FocusState private var isInputFocused: Bool + + var body: some View { + HStack(spacing: 8) { + TextField(NSLocalizedString("_type_message_", comment: ""), text: $model.inputText, axis: .vertical) + .textFieldStyle(.plain) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color(NCBrandColor.shared.textColor2).opacity(0.1)) + .clipShape(.rect(cornerRadius: 20)) + .focused($isInputFocused) + .lineLimit(1...5) + + Button(action: { + model.sendMessage() + isInputFocused = false + }) { + Image(systemName: "arrow.up.circle.fill") + .font(.system(size: 28)) + .foregroundStyle(Color(NCBrandColor.shared.getElement(account: model.controller?.account))) + } + .disabled(model.inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || model.isThinking) + .opacity(model.inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || model.isThinking ? 0.5 : 1.0) + } + .padding(.horizontal) + .padding(.vertical, 8) + .background(Color(uiColor: .systemBackground)) + } +} + +// MARK: - Empty Chat View + +struct EmptyChatView: View { +// let account: String + @Bindable var model: NCAssistantChatModel + + 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: model.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(controller: nil) + .onAppear { + // Preview will show empty state + } + } +} + +#Preview("With Messages") { + let chat = NCAssistantChat(controller: nil) + + return NavigationStack { + chat + .onAppear { + chat.model.loadDummyData() + } + } +} diff --git a/iOSClient/Assistant/Models/NCAssistantModel.swift b/iOSClient/Assistant/Models/NCAssistantModel.swift index a4f34f0a34..18d8fc3a60 100644 --- a/iOSClient/Assistant/Models/NCAssistantModel.swift +++ b/iOSClient/Assistant/Models/NCAssistantModel.swift @@ -16,7 +16,8 @@ class NCAssistantModel: ObservableObject { @Published var hasError: Bool = false @Published var isLoading: Bool = false @Published var isRefreshing: Bool = false - @Published var controller: NCMainTabBarController? + + let controller: NCMainTabBarController? private var tasks: [AssistantTask] = [] @@ -24,13 +25,16 @@ class NCAssistantModel: ObservableObject { private let useV2: Bool + private let chatTypeId = "core:text2text:chat" + + var isSelectedTypeChat: Bool { selectedType?.id == chatTypeId } + init(controller: NCMainTabBarController?) { self.controller = controller session = NCSession.shared.getSession(controller: controller) let capabilities = NCNetworking.shared.capabilities[session.account] ?? NKCapabilities.Capabilities() useV2 = capabilities.serverVersionMajor >= NCGlobal.shared.nextcloudVersion30 - // useV2 = false loadAllTypes() } @@ -171,10 +175,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) @@ -222,10 +226,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/NCAssistant.swift b/iOSClient/Assistant/NCAssistant.swift index 673f37e6e5..0c2404a679 100644 --- a/iOSClient/Assistant/NCAssistant.swift +++ b/iOSClient/Assistant/NCAssistant.swift @@ -18,7 +18,11 @@ struct NCAssistant: View { var body: some View { NavigationView { ZStack { - TaskList() + if model.isSelectedTypeChat { + NCAssistantChat(controller: model.controller) + } else { + TaskList() + } if model.isLoading, !model.isRefreshing { ProgressView() 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 - } - } } From 2b07b93f5ebe0cecea963ff95979f8325c5c10c6 Mon Sep 17 00:00:00 2001 From: Milen Pivchev Date: Tue, 20 Jan 2026 14:18:48 +0100 Subject: [PATCH 02/31] WIP Signed-off-by: Milen Pivchev --- Nextcloud.xcodeproj/project.pbxproj | 20 ++ Tests/NextcloudUITests/AssistantUITests.swift | 2 +- .../Assistant/Chat/NCAssistantChat.swift | 223 +++--------------- .../Assistant/Components/ChatInputField.swift | 42 ++++ .../Components/NCAssistantEmptyView.swift | 10 +- .../Models/NCAssistantChatModel.swift | 108 +++++++++ iOSClient/Assistant/NCAssistant.swift | 11 +- .../Sessions/NCAssistantChatSessions.swift | 57 +++++ 8 files changed, 273 insertions(+), 200 deletions(-) create mode 100644 iOSClient/Assistant/Components/ChatInputField.swift create mode 100644 iOSClient/Assistant/Models/NCAssistantChatModel.swift create mode 100644 iOSClient/Assistant/Sessions/NCAssistantChatSessions.swift diff --git a/Nextcloud.xcodeproj/project.pbxproj b/Nextcloud.xcodeproj/project.pbxproj index 7402a0fd5f..c7fa8e6725 100644 --- a/Nextcloud.xcodeproj/project.pbxproj +++ b/Nextcloud.xcodeproj/project.pbxproj @@ -167,6 +167,9 @@ F3C587AE2D47E4FE004532DB /* PHAssetCollectionThumbnailLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3C587AD2D47E4FE004532DB /* PHAssetCollectionThumbnailLoader.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 /* NCAssistantChatSessions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3DDFE202F1F953000A784C8 /* NCAssistantChatSessions.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 */; }; @@ -1277,6 +1280,9 @@ F3CA337C2D0B2B6A00672333 /* AlbumModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlbumModel.swift; sourceTree = ""; }; F3DDFE0E2F15453900A784C8 /* NCAssistantChat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCAssistantChat.swift; sourceTree = ""; }; F3DDFE102F16732D00A784C8 /* NextcloudKit */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = NextcloudKit; path = ../NextcloudKit; sourceTree = SOURCE_ROOT; }; + F3DDFE1D2F1F8EC600A784C8 /* ChatInputField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInputField.swift; sourceTree = ""; }; + F3DDFE202F1F953000A784C8 /* NCAssistantChatSessions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCAssistantChatSessions.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 = ""; }; @@ -2122,6 +2128,7 @@ F3374A7F2D64AB40002A38F9 /* Components */ = { isa = PBXGroup; children = ( + F3DDFE1D2F1F8EC600A784C8 /* ChatInputField.swift */, F3374A832D64AC2C002A38F9 /* AssistantLabelStyle.swift */, F3374A802D64AB9E002A38F9 /* StatusInfo.swift */, F3A0478F2BD2668800658E7B /* NCAssistantEmptyView.swift */, @@ -2152,6 +2159,7 @@ F3A0478E2BD2668800658E7B /* Assistant */ = { isa = PBXGroup; children = ( + F3DDFE1F2F1F951000A784C8 /* Sessions */, F3DDFE0D2F15452F00A784C8 /* Chat */, F3A047962BD2668800658E7B /* NCAssistant.swift */, F3A047902BD2668800658E7B /* Create Task */, @@ -2174,6 +2182,7 @@ isa = PBXGroup; children = ( F3A047932BD2668800658E7B /* NCAssistantModel.swift */, + F3DDFE222F1FB4C300A784C8 /* NCAssistantChatModel.swift */, ); path = Models; sourceTree = ""; @@ -2205,6 +2214,14 @@ path = Chat; sourceTree = ""; }; + F3DDFE1F2F1F951000A784C8 /* Sessions */ = { + isa = PBXGroup; + children = ( + F3DDFE202F1F953000A784C8 /* NCAssistantChatSessions.swift */, + ); + path = Sessions; + sourceTree = ""; + }; F3E173BE2C9B1057006D177A /* ScreenAwakeManager */ = { isa = PBXGroup; children = ( @@ -4613,6 +4630,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 */, @@ -4727,6 +4745,7 @@ F7A03E2F2D425A14007AA677 /* NCFavoriteNavigationController.swift in Sources */, F343A4BB2A1E734600DDA874 /* Optional+Extension.swift in Sources */, F76882232C0DD1E7001CF441 /* NCCapabilitiesModel.swift in Sources */, + F3DDFE212F1F953000A784C8 /* NCAssistantChatSessions.swift in Sources */, F7E2B64F2DDCC5C30075B4D0 /* NCMedia+TransferDelegate.swift in Sources */, F3DDFE0F2F15453900A784C8 /* NCAssistantChat.swift in Sources */, F7D68FCC28CB9051009139F3 /* NCManageDatabase+DashboardWidget.swift in Sources */, @@ -4752,6 +4771,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..ba5f670fa3 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["SessionsButton"].tap() let inputTextEditor = app.textViews["InputTextEditor"] inputTextEditor.await() diff --git a/iOSClient/Assistant/Chat/NCAssistantChat.swift b/iOSClient/Assistant/Chat/NCAssistantChat.swift index 3d5744be9a..82a6aa8c3b 100644 --- a/iOSClient/Assistant/Chat/NCAssistantChat.swift +++ b/iOSClient/Assistant/Chat/NCAssistantChat.swift @@ -6,148 +6,20 @@ import SwiftUI import NextcloudKit // MARK: - Data Models - -struct ChatMessage: Identifiable, Equatable { - let id: UUID - let content: String - let isFromUser: Bool - let timestamp: Date - - init(id: UUID = UUID(), content: String, isFromUser: Bool, timestamp: Date = Date()) { - self.id = id - self.content = content - self.isFromUser = isFromUser - self.timestamp = timestamp - } -} - -// MARK: - View Model - -@Observable class NCAssistantChatModel { - var messages: [ChatMessage] = [] - var isThinking: Bool = false - var inputText: String = "" - var hasError: Bool = false - -// @ObservationIgnored private let session: NCSession.Session - var sessions: [AssistantSession] -// @ObservationIgnored private let taskType: TaskTypeData? -// @ObservationIgnored private let useV2: Bool - @ObservationIgnored var controller: NCMainTabBarController? - private let session: NCSession.Session - - init(controller: NCMainTabBarController?) { - self.controller = controller - let session = NCSession.shared.getSession(controller: controller) -// self.session = session -// self.taskType = taskType - - let capabilities = NCNetworking.shared.capabilities[session.account] ?? NKCapabilities.Capabilities() -// self.useV2 = capabilities.serverVersionMajor >= NCGlobal.shared.nextcloudVersion30 - } - - func getSessions() { - Task { - let result = await NextcloudKit.shared.textProcessingGetChatSessionsV2Async(account: session.account) - sessions = result.sessions - } - } - - func sendMessage() { - guard !inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } - - let userMessage = inputText - addUserMessage(userMessage) - inputText = "" - - isThinking = true - scheduleTask(input: userMessage) - } - - func scheduleTask(input: String) { - // TODO: Implement actual API call when chat API is ready - // Reference pattern from NCAssistantModel.swift:87-115 - /* - if useV2 { - guard let taskType else { return } - NextcloudKit.shared.textProcessingScheduleV2( - input: input, - taskType: taskType, - account: account - ) { _, task, _, error in - self.handleTaskResponse(task: task, error: error) - } - } else { - NextcloudKit.shared.textProcessingSchedule( - input: input, - typeId: taskType?.id ?? "", - identifier: "assistant", - account: account - ) { _, task, _, error in - guard let task, let taskV2 = NKTextProcessingTask.toV2(tasks: [task]).tasks.first else { return } - self.handleTaskResponse(task: taskV2, error: error) - } - } - */ - - // Temporary mock for testing UI - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { - self.isThinking = false - self.addAssistantMessage("This is a placeholder response. The actual API integration will be implemented when the chat API is ready.") - } - } - - private func handleTaskResponse(task: AssistantTask?, error: NKError?) { - isThinking = false - - if error != .success { - hasError = true - return - } - - guard let task, let output = task.output?.output else { - hasError = true - return - } - - addAssistantMessage(output) - } - - private func addUserMessage(_ text: String) { - let message = ChatMessage(content: text, isFromUser: true) - messages.append(message) - } - - private func addAssistantMessage(_ text: String) { - let message = ChatMessage(content: text, isFromUser: false) - messages.append(message) - } - - func loadDummyData() { - messages = [ - ChatMessage( - content: "Hello! Can you help me summarize this document?", - isFromUser: true, - timestamp: Date().addingTimeInterval(-300) - ), - ChatMessage( - 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.", - isFromUser: false, - timestamp: Date().addingTimeInterval(-240) - ), - ChatMessage( - content: "Here is the text: Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", - isFromUser: true, - timestamp: Date().addingTimeInterval(-180) - ), - ChatMessage( - 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.", - isFromUser: false, - timestamp: Date().addingTimeInterval(-120) - ) - ] - } -} +// +//struct ChatMessage: Identifiable, Equatable { +// let id: UUID +// let content: String +// let isFromUser: Bool +// let timestamp: Date +// +// init(id: UUID = UUID(), content: String, isFromUser: Bool, timestamp: Date = Date()) { +// self.id = id +// self.content = content +// self.isFromUser = isFromUser +// self.timestamp = timestamp +// } +//} // MARK: - Main View @@ -163,12 +35,15 @@ struct NCAssistantChat: View { VStack(spacing: 0) { messageListView } - .safeAreaInset(edge: .bottom) { - ChatInputField(model: model) - } + - if model.messages.isEmpty && !model.isThinking { - EmptyChatView(model: model) +// if model.messages.isEmpty && !model.isThinking { +// EmptyChatView(model: model) +// } + } + .safeAreaInset(edge: .bottom) { + ChatInputField { input in + model.sendMessage(input: input) } } .navigationTitle("Assistant Chat") @@ -217,34 +92,34 @@ struct MessageBubbleView: View { var body: some View { HStack { - if message.isFromUser { - Spacer(minLength: 50) - } +// if message.isFromUser { +// Spacer(minLength: 50) +// } - VStack(alignment: message.isFromUser ? .trailing : .leading, spacing: 4) { + VStack(alignment: true ? .trailing : .leading, spacing: 4) { Text(message.content) .font(.body) - .foregroundStyle(message.isFromUser ? .white : .primary) + .foregroundStyle(true ? .white : .primary) .padding() .background(bubbleBackground) .clipShape(.rect(cornerRadius: 16)) - Text(NCUtility().getRelativeDateTitle(message.timestamp)) + Text(NCUtility().getRelativeDateTitle(Date(timeIntervalSince1970: TimeInterval(message.timestamp / 1000)))) .font(.caption) .foregroundStyle(.secondary) .padding(.horizontal, 4) } - .frame(maxWidth: .infinity, alignment: message.isFromUser ? .trailing : .leading) + .frame(maxWidth: .infinity, alignment: true ? .trailing : .leading) .padding(.horizontal) - if !message.isFromUser { - Spacer(minLength: 50) - } +// if !message.isFromUser { +// Spacer(minLength: 50) +// } } } private var bubbleBackground: Color { - if message.isFromUser { + if true { return Color(NCBrandColor.shared.getElement(account: account)) } else { return Color(NCBrandColor.shared.textColor2).opacity(0.1) @@ -301,37 +176,6 @@ struct ThinkingBubbleView: View { // MARK: - Chat Input Field -struct ChatInputField: View { - @Bindable var model: NCAssistantChatModel - @FocusState private var isInputFocused: Bool - - var body: some View { - HStack(spacing: 8) { - TextField(NSLocalizedString("_type_message_", comment: ""), text: $model.inputText, axis: .vertical) - .textFieldStyle(.plain) - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background(Color(NCBrandColor.shared.textColor2).opacity(0.1)) - .clipShape(.rect(cornerRadius: 20)) - .focused($isInputFocused) - .lineLimit(1...5) - - Button(action: { - model.sendMessage() - isInputFocused = false - }) { - Image(systemName: "arrow.up.circle.fill") - .font(.system(size: 28)) - .foregroundStyle(Color(NCBrandColor.shared.getElement(account: model.controller?.account))) - } - .disabled(model.inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || model.isThinking) - .opacity(model.inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || model.isThinking ? 0.5 : 1.0) - } - .padding(.horizontal) - .padding(.vertical, 8) - .background(Color(uiColor: .systemBackground)) - } -} // MARK: - Empty Chat View @@ -383,3 +227,4 @@ struct EmptyChatView: View { } } } + diff --git a/iOSClient/Assistant/Components/ChatInputField.swift b/iOSClient/Assistant/Components/ChatInputField.swift new file mode 100644 index 0000000000..bee8c8d869 --- /dev/null +++ b/iOSClient/Assistant/Components/ChatInputField.swift @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI + + +struct ChatInputField: View { +// @Bindable var model: NCAssistantChatModel + var onSend: ((_ input: String) -> Void)? = nil + @FocusState private var isInputFocused: Bool + @State var text: String = "" + + var body: some View { + HStack(spacing: 8) { + TextField(NSLocalizedString("_type_message_", comment: ""), text: $text, axis: .vertical) + .textFieldStyle(.plain) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color(NCBrandColor.shared.textColor2).opacity(0.1)) + .clipShape(.rect(cornerRadius: 20)) + .focused($isInputFocused) + .lineLimit(1...5) + + Button(action: { + isInputFocused = false + onSend?(text) + text = "" + }) { + Image(systemName: "arrow.up.circle.fill") + .font(.system(size: 28)) +// .foregroundStyle(Color(NCBrandColor.shared.getElement(account: model.controller?.account))) + } +// .disabled(model.inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || model.isThinking) +// .opacity(model.inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || model.isThinking ? 0.5 : 1.0) + } + .padding(.horizontal) + .padding(.vertical, 8) + .background(Color(uiColor: .systemBackground)) + } +} + diff --git a/iOSClient/Assistant/Components/NCAssistantEmptyView.swift b/iOSClient/Assistant/Components/NCAssistantEmptyView.swift index d24cca06ca..408694b42d 100644 --- a/iOSClient/Assistant/Components/NCAssistantEmptyView.swift +++ b/iOSClient/Assistant/Components/NCAssistantEmptyView.swift @@ -1,10 +1,6 @@ -// -// 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 diff --git a/iOSClient/Assistant/Models/NCAssistantChatModel.swift b/iOSClient/Assistant/Models/NCAssistantChatModel.swift new file mode 100644 index 0000000000..ae59841477 --- /dev/null +++ b/iOSClient/Assistant/Models/NCAssistantChatModel.swift @@ -0,0 +1,108 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import NextcloudKit + +@Observable class NCAssistantChatModel { + var messages: [ChatMessage] = [] + var isThinking: Bool = false +// var inputText: String = "" + var hasError: Bool = false + var selectedSession: AssistantSession? { + didSet { + loadMessages() + } + } + +// @ObservationIgnored private let session: NCSession.Session + var sessions: [AssistantSession] = [] +// @ObservationIgnored private let taskType: TaskTypeData? +// @ObservationIgnored private let useV2: Bool + @ObservationIgnored var controller: NCMainTabBarController? + private let session: NCSession.Session + + init(controller: NCMainTabBarController?) { + self.controller = controller + self.session = NCSession.shared.getSession(controller: controller) +// self.session = session +// self.taskType = taskType + +// self.useV2 = capabilities.serverVersionMajor >= NCGlobal.shared.nextcloudVersion30 + +// getSessions() + loadAllSessions() + } + + func loadAllSessions() { + Task { + let result = await NextcloudKit.shared.textProcessingGetChatSessionsV2Async(account: session.account) + sessions = result.sessions ?? [] + } + } + + func loadMessages() { + + } + + func sendMessage(input: String) { +// guard !inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } + +// let userMessage = inputText + addUserMessage(input) + + isThinking = true +// scheduleTask(input: userMessage) + } + + private func handleTaskResponse(task: AssistantTask?, error: NKError?) { + isThinking = false + + if error != .success { + hasError = true + return + } + + guard let task, let output = task.output?.output else { + hasError = true + return + } + + addAssistantMessage(output) + } + + private func addUserMessage(_ text: String) { + let message = ChatMessage(content: text, isFromUser: true) + messages.append(message) + } + + private func addAssistantMessage(_ text: String) { + let message = ChatMessage(content: text, isFromUser: false) + messages.append(message) + } + + func loadDummyData() { + messages = [ + ChatMessage( + content: "Hello! Can you help me summarize this document?", + isFromUser: true, + timestamp: Date().addingTimeInterval(-300) + ), + ChatMessage( + 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.", + isFromUser: false, + timestamp: Date().addingTimeInterval(-240) + ), + ChatMessage( + content: "Here is the text: Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", + isFromUser: true, + timestamp: Date().addingTimeInterval(-180) + ), + ChatMessage( + 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.", + isFromUser: false, + timestamp: Date().addingTimeInterval(-120) + ) + ] + } +} diff --git a/iOSClient/Assistant/NCAssistant.swift b/iOSClient/Assistant/NCAssistant.swift index 0c2404a679..664c39550c 100644 --- a/iOSClient/Assistant/NCAssistant.swift +++ b/iOSClient/Assistant/NCAssistant.swift @@ -45,13 +45,13 @@ struct NCAssistant: View { } } ToolbarItem(placement: .topBarTrailing) { - NavigationLink(destination: NCAssistantCreateNewTask()) { - Image(systemName: "plus") + NavigationLink(destination: NCAssistantChatSessions(controller: model.controller)) { + 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") + .accessibilityIdentifier("SessionsButton") } } .navigationBarTitleDisplayMode(.inline) @@ -166,6 +166,11 @@ struct TaskList: View { NCAssistantCreateNewTask(text: taskToEdit?.input?.input ?? "", editMode: true) } } + .safeAreaInset(edge: .bottom) { + ChatInputField { input in + model.scheduleTask(input: input) + } + } } } diff --git a/iOSClient/Assistant/Sessions/NCAssistantChatSessions.swift b/iOSClient/Assistant/Sessions/NCAssistantChatSessions.swift new file mode 100644 index 0000000000..3bb52755cf --- /dev/null +++ b/iOSClient/Assistant/Sessions/NCAssistantChatSessions.swift @@ -0,0 +1,57 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation +import SwiftUI +import NextcloudKit + +//@Observable class NCAssistantChatSessionsModel { +// var sessions: [AssistantSession] = [] +// var isLoading: Bool = false +// var hasError: Bool = false +// let controller: NCMainTabBarController? +// +// private let session: NCSession.Session +// +// init(controller: NCMainTabBarController?) { +// self.controller = controller +// session = NCSession.shared.getSession(controller: controller) +// +// loadSessions() +// } +// +// func loadSessions() { +// Task { +// let result = await NextcloudKit.shared.textProcessingGetChatSessionsV2Async(account: session.account) +// sessions = result.sessions ?? [] +// } +// } +//} + +struct NCAssistantChatSessions: View { + @Binding var model: NCAssistantChatModel + @Environment(\.dismiss) private var dismiss + + var body: some View { + Group { +// if model.isLoading { +// ProgressView() + if model.sessions.isEmpty { + Text("No sessions found") + } else { + List(model.sessions, id: \.id) { session in + Text(session.title ?? "Untitled Session") + .onTapGesture { + model.selectedSession = session + dismiss() + } + } + } + } + } +} + +#Preview { + @Previewable @State var model = NCAssistantChatModel(controller: nil) + return NCAssistantChatSessions(model: $model)} From a38575601324ee87bc5bfc512e602a0da81fefc6 Mon Sep 17 00:00:00 2001 From: Milen Pivchev Date: Wed, 21 Jan 2026 15:10:29 +0100 Subject: [PATCH 03/31] WIP Signed-off-by: Milen Pivchev --- .../Assistant/Chat/NCAssistantChat.swift | 32 +++++----- .../Models/NCAssistantChatModel.swift | 63 ++++++++++--------- iOSClient/Assistant/NCAssistant.swift | 9 ++- .../Main/NCMainNavigationController.swift | 2 +- .../More/Cells/NCMoreAppSuggestionsCell.swift | 2 +- iOSClient/More/NCMore.swift | 2 +- 6 files changed, 60 insertions(+), 50 deletions(-) diff --git a/iOSClient/Assistant/Chat/NCAssistantChat.swift b/iOSClient/Assistant/Chat/NCAssistantChat.swift index 82a6aa8c3b..c22f02008b 100644 --- a/iOSClient/Assistant/Chat/NCAssistantChat.swift +++ b/iOSClient/Assistant/Chat/NCAssistantChat.swift @@ -24,11 +24,11 @@ import NextcloudKit // MARK: - Main View struct NCAssistantChat: View { - @State var model: NCAssistantChatModel + @Binding var model: NCAssistantChatModel - init(controller: NCMainTabBarController?) { - self.model = NCAssistantChatModel(controller: controller) - } +// init(controller: NCMainTabBarController?) { +// self.model = NCAssistantChatModel(controller: controller) +// } var body: some View { ZStack { @@ -92,9 +92,9 @@ struct MessageBubbleView: View { var body: some View { HStack { -// if message.isFromUser { -// Spacer(minLength: 50) -// } + if message.isFromHuman { + Spacer(minLength: 50) + } VStack(alignment: true ? .trailing : .leading, spacing: 4) { Text(message.content) @@ -112,14 +112,14 @@ struct MessageBubbleView: View { .frame(maxWidth: .infinity, alignment: true ? .trailing : .leading) .padding(.horizontal) -// if !message.isFromUser { -// Spacer(minLength: 50) -// } + if !message.isFromHuman { + Spacer(minLength: 50) + } } } private var bubbleBackground: Color { - if true { + if message.isFromHuman { return Color(NCBrandColor.shared.getElement(account: account)) } else { return Color(NCBrandColor.shared.textColor2).opacity(0.1) @@ -209,8 +209,10 @@ struct EmptyChatView: View { // MARK: - Preview #Preview { + @Previewable @State var model = NCAssistantChatModel(controller: nil) + NavigationStack { - NCAssistantChat(controller: nil) + NCAssistantChat(model: $model) .onAppear { // Preview will show empty state } @@ -218,12 +220,14 @@ struct EmptyChatView: View { } #Preview("With Messages") { - let chat = NCAssistantChat(controller: nil) + @Previewable @State var model = NCAssistantChatModel(controller: nil) + + let chat = NCAssistantChat(model: $model) return NavigationStack { chat .onAppear { - chat.model.loadDummyData() +// chat.model.loadDummyData() } } } diff --git a/iOSClient/Assistant/Models/NCAssistantChatModel.swift b/iOSClient/Assistant/Models/NCAssistantChatModel.swift index ae59841477..214854c7f8 100644 --- a/iOSClient/Assistant/Models/NCAssistantChatModel.swift +++ b/iOSClient/Assistant/Models/NCAssistantChatModel.swift @@ -36,13 +36,16 @@ import NextcloudKit func loadAllSessions() { Task { - let result = await NextcloudKit.shared.textProcessingGetChatSessionsV2Async(account: session.account) + let result = await NextcloudKit.shared.getAssistantChatSessionsAsync(account: session.account) sessions = result.sessions ?? [] } } func loadMessages() { - + Task { + let result = await NextcloudKit.shared.getAssistantChatMessagesAsync(sessionId: selectedSession?.id ?? 0, account: session.account) + messages = result.chatMessage ?? [] + } } func sendMessage(input: String) { @@ -72,37 +75,37 @@ import NextcloudKit } private func addUserMessage(_ text: String) { - let message = ChatMessage(content: text, isFromUser: true) - messages.append(message) +// let message = ChatMessage(content: text, isFromUser: true) +// messages.append(message) } private func addAssistantMessage(_ text: String) { - let message = ChatMessage(content: text, isFromUser: false) - messages.append(message) +// let message = ChatMessage(content: text, isFromUser: false) +// messages.append(message) } - func loadDummyData() { - messages = [ - ChatMessage( - content: "Hello! Can you help me summarize this document?", - isFromUser: true, - timestamp: Date().addingTimeInterval(-300) - ), - ChatMessage( - 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.", - isFromUser: false, - timestamp: Date().addingTimeInterval(-240) - ), - ChatMessage( - content: "Here is the text: Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", - isFromUser: true, - timestamp: Date().addingTimeInterval(-180) - ), - ChatMessage( - 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.", - isFromUser: false, - timestamp: Date().addingTimeInterval(-120) - ) - ] - } +// func loadDummyData() { +// messages = [ +// ChatMessage( +// content: "Hello! Can you help me summarize this document?", +// isFromUser: true, +// timestamp: Date().addingTimeInterval(-300) +// ), +// ChatMessage( +// 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.", +// isFromUser: false, +// timestamp: Date().addingTimeInterval(-240) +// ), +// ChatMessage( +// content: "Here is the text: Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", +// isFromUser: true, +// timestamp: Date().addingTimeInterval(-180) +// ), +// ChatMessage( +// 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.", +// isFromUser: false, +// timestamp: Date().addingTimeInterval(-120) +// ) +// ] +// } } diff --git a/iOSClient/Assistant/NCAssistant.swift b/iOSClient/Assistant/NCAssistant.swift index 664c39550c..3ad962e3fe 100644 --- a/iOSClient/Assistant/NCAssistant.swift +++ b/iOSClient/Assistant/NCAssistant.swift @@ -12,6 +12,7 @@ import PopupView struct NCAssistant: View { @EnvironmentObject var model: NCAssistantModel + @State var chatModel: NCAssistantChatModel @State var input = "" @Environment(\.presentationMode) var presentationMode @@ -19,7 +20,7 @@ struct NCAssistant: View { NavigationView { ZStack { if model.isSelectedTypeChat { - NCAssistantChat(controller: model.controller) + NCAssistantChat(model: $chatModel) } else { TaskList() } @@ -45,7 +46,7 @@ struct NCAssistant: View { } } ToolbarItem(placement: .topBarTrailing) { - NavigationLink(destination: NCAssistantChatSessions(controller: model.controller)) { + NavigationLink(destination: NCAssistantChatSessions(model: $chatModel)) { Image(systemName: "clock.arrow.trianglehead.counterclockwise.rotate.90") .font(Font.system(.body).weight(.light)) .foregroundStyle(Color(NCBrandColor.shared.iconImageColor)) @@ -80,10 +81,12 @@ struct NCAssistant: View { } #Preview { + @Previewable @State var chatModel = NCAssistantChatModel(controller: nil) let model = NCAssistantModel(controller: nil) - NCAssistant() + NCAssistant(chatModel: chatModel) .environmentObject(model) + // .environment(chatModel) .onAppear { model.loadDummyData() } diff --git a/iOSClient/Main/NCMainNavigationController.swift b/iOSClient/Main/NCMainNavigationController.swift index c56942c079..c5b12a12a3 100644 --- a/iOSClient/Main/NCMainNavigationController.swift +++ b/iOSClient/Main/NCMainNavigationController.swift @@ -98,7 +98,7 @@ class NCMainNavigationController: UINavigationController, UINavigationController assistantButton.setImage(UIImage(systemName: "sparkles"), for: .normal) assistantButton.tintColor = NCBrandColor.shared.iconImageColor assistantButton.addAction(UIAction(handler: { _ in - let assistant = NCAssistant() + let assistant = NCAssistant(chatModel: NCAssistantChatModel(controller: self.controller)) .environmentObject(NCAssistantModel(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..0d3108a61d 100644 --- a/iOSClient/More/Cells/NCMoreAppSuggestionsCell.swift +++ b/iOSClient/More/Cells/NCMoreAppSuggestionsCell.swift @@ -61,7 +61,7 @@ class NCMoreAppSuggestionsCell: BaseNCMoreCell { @objc func assistantTapped(_ sender: Any?) { if let viewController = self.window?.rootViewController { - let assistant = NCAssistant() + let assistant = NCAssistant(chatModel: NCAssistantChatModel(controller: self.controller)) .environmentObject(NCAssistantModel(controller: self.controller)) let hostingController = UIHostingController(rootView: assistant) viewController.present(hostingController, animated: true, completion: nil) diff --git a/iOSClient/More/NCMore.swift b/iOSClient/More/NCMore.swift index 43301a4fd4..c79cd8737c 100644 --- a/iOSClient/More/NCMore.swift +++ b/iOSClient/More/NCMore.swift @@ -408,7 +408,7 @@ class NCMore: UIViewController, UITableViewDelegate, UITableViewDataSource { alertController.addAction(actionNo) self.present(alertController, animated: true, completion: nil) } else if item.url == "openAssistant" { - let assistant = NCAssistant() + let assistant = NCAssistant(chatModel: NCAssistantChatModel(controller: self.controller)) .environmentObject(NCAssistantModel(controller: self.controller)) let hostingController = UIHostingController(rootView: assistant) present(hostingController, animated: true, completion: nil) From 0e0c8e36d7395307212d4cb1fe9d6f831e127c85 Mon Sep 17 00:00:00 2001 From: Milen Pivchev Date: Fri, 23 Jan 2026 15:26:16 +0100 Subject: [PATCH 04/31] Send message Signed-off-by: Milen Pivchev --- .../Models/NCAssistantChatModel.swift | 28 +++++++++---------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/iOSClient/Assistant/Models/NCAssistantChatModel.swift b/iOSClient/Assistant/Models/NCAssistantChatModel.swift index 214854c7f8..c3c5c94e10 100644 --- a/iOSClient/Assistant/Models/NCAssistantChatModel.swift +++ b/iOSClient/Assistant/Models/NCAssistantChatModel.swift @@ -7,7 +7,6 @@ import NextcloudKit @Observable class NCAssistantChatModel { var messages: [ChatMessage] = [] var isThinking: Bool = false -// var inputText: String = "" var hasError: Bool = false var selectedSession: AssistantSession? { didSet { @@ -15,22 +14,13 @@ import NextcloudKit } } -// @ObservationIgnored private let session: NCSession.Session var sessions: [AssistantSession] = [] -// @ObservationIgnored private let taskType: TaskTypeData? -// @ObservationIgnored private let useV2: Bool @ObservationIgnored var controller: NCMainTabBarController? private let session: NCSession.Session init(controller: NCMainTabBarController?) { self.controller = controller self.session = NCSession.shared.getSession(controller: controller) -// self.session = session -// self.taskType = taskType - -// self.useV2 = capabilities.serverVersionMajor >= NCGlobal.shared.nextcloudVersion30 - -// getSessions() loadAllSessions() } @@ -49,13 +39,21 @@ import NextcloudKit } func sendMessage(input: String) { -// guard !inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } + guard let selectedSession else { return } + + let request = ChatMessageRequest(sessionId: selectedSession.id, role: "human", content: input, timestamp: Int(Date().timeIntervalSince1970 * 1000)) -// let userMessage = inputText - addUserMessage(input) + Task { + let result = await NextcloudKit.shared.createAssistantChatMessageAsync(messageRequest: request, account: session.account) + if result.error == .success { + guard let chatMessage = result.chatMessage else { return } + messages.append(chatMessage) + } + if result.error != .success { + // TODO + } + } - isThinking = true -// scheduleTask(input: userMessage) } private func handleTaskResponse(task: AssistantTask?, error: NKError?) { From 6728acb4c2bb7dd6b88d8ee442de5c7f34adb161 Mon Sep 17 00:00:00 2001 From: Milen Pivchev Date: Mon, 26 Jan 2026 16:00:04 +0100 Subject: [PATCH 05/31] WIP Signed-off-by: Milen Pivchev --- iOSClient/Assistant/Chat/NCAssistantChat.swift | 4 ++++ .../Assistant/Components/ChatInputField.swift | 5 +++-- iOSClient/Assistant/NCAssistant.swift | 17 ++++++++--------- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/iOSClient/Assistant/Chat/NCAssistantChat.swift b/iOSClient/Assistant/Chat/NCAssistantChat.swift index c22f02008b..ec3bbdb9e8 100644 --- a/iOSClient/Assistant/Chat/NCAssistantChat.swift +++ b/iOSClient/Assistant/Chat/NCAssistantChat.swift @@ -31,6 +31,10 @@ struct NCAssistantChat: View { // } var body: some View { + if model.messages.isEmpty { + NCAssistantEmptyView(titleKey: "_no_chat_", subtitleKey: "_no_chat_subtitle_") + } + ZStack { VStack(spacing: 0) { messageListView diff --git a/iOSClient/Assistant/Components/ChatInputField.swift b/iOSClient/Assistant/Components/ChatInputField.swift index bee8c8d869..e0a68169cf 100644 --- a/iOSClient/Assistant/Components/ChatInputField.swift +++ b/iOSClient/Assistant/Components/ChatInputField.swift @@ -7,9 +7,10 @@ import SwiftUI struct ChatInputField: View { // @Bindable var model: NCAssistantChatModel - var onSend: ((_ input: String) -> Void)? = nil @FocusState private var isInputFocused: Bool @State var text: String = "" + @Binding var isLoading: Bool + var onSend: ((_ input: String) -> Void)? = nil var body: some View { HStack(spacing: 8) { @@ -31,7 +32,7 @@ struct ChatInputField: View { .font(.system(size: 28)) // .foregroundStyle(Color(NCBrandColor.shared.getElement(account: model.controller?.account))) } -// .disabled(model.inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || model.isThinking) + .disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isLoading) // .opacity(model.inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || model.isThinking ? 0.5 : 1.0) } .padding(.horizontal) diff --git a/iOSClient/Assistant/NCAssistant.swift b/iOSClient/Assistant/NCAssistant.swift index 3ad962e3fe..185c614274 100644 --- a/iOSClient/Assistant/NCAssistant.swift +++ b/iOSClient/Assistant/NCAssistant.swift @@ -19,7 +19,9 @@ struct NCAssistant: View { var body: some View { NavigationView { ZStack { - if model.isSelectedTypeChat { + if model.types.isEmpty, !model.isLoading { + NCAssistantEmptyView(titleKey: "_no_types_", subtitleKey: "_no_types_subtitle_") + } else if model.isSelectedTypeChat { NCAssistantChat(model: $chatModel) } else { TaskList() @@ -29,13 +31,6 @@ struct NCAssistant: View { 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) { @@ -170,10 +165,14 @@ struct TaskList: View { } } .safeAreaInset(edge: .bottom) { - ChatInputField { input in + ChatInputField(isLoading: model.$isLoading) { input in model.scheduleTask(input: input) } } + + if model.filteredTasks.isEmpty, !model.isLoading { + NCAssistantEmptyView(titleKey: "_no_tasks_", subtitleKey: "_create_task_subtitle_") + } } } From d3c21c207152640a14b37c5656261b599ae8383e Mon Sep 17 00:00:00 2001 From: Milen Pivchev Date: Mon, 26 Jan 2026 16:04:35 +0100 Subject: [PATCH 06/31] WIP Signed-off-by: Milen Pivchev --- iOSClient/Assistant/NCAssistant.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/iOSClient/Assistant/NCAssistant.swift b/iOSClient/Assistant/NCAssistant.swift index 185c614274..3fcca8c82c 100644 --- a/iOSClient/Assistant/NCAssistant.swift +++ b/iOSClient/Assistant/NCAssistant.swift @@ -11,7 +11,7 @@ import NextcloudKit import PopupView struct NCAssistant: View { - @EnvironmentObject var model: NCAssistantModel + @State var model: NCAssistantModel @State var chatModel: NCAssistantChatModel @State var input = "" @Environment(\.presentationMode) var presentationMode @@ -88,7 +88,7 @@ struct NCAssistant: View { } struct TaskList: View { - @EnvironmentObject var model: NCAssistantModel + @Binding var model: NCAssistantModel @State var presentEditTask = false @State var showDeleteConfirmation = false @@ -177,7 +177,7 @@ struct TaskList: View { } struct TypeButton: View { - @EnvironmentObject var model: NCAssistantModel + @Binding var model: NCAssistantModel let taskType: TaskTypeData? var scrollProxy: ScrollViewProxy @@ -212,7 +212,7 @@ struct TypeButton: View { } struct TaskItem: View { - @EnvironmentObject var model: NCAssistantModel + @Binding var model: NCAssistantModel @Binding var showDeleteConfirmation: Bool @Binding var taskToDelete: AssistantTask? var task: AssistantTask @@ -257,7 +257,7 @@ struct TaskItem: View { } struct TypeList: View { - @EnvironmentObject var model: NCAssistantModel + @Binding var model: NCAssistantModel var body: some View { ScrollViewReader { scrollProxy in From a17f15abe74feda463423fa849a08aba281c0fb7 Mon Sep 17 00:00:00 2001 From: Milen Pivchev Date: Mon, 26 Jan 2026 16:32:23 +0100 Subject: [PATCH 07/31] Refactor Signed-off-by: Milen Pivchev --- iOSClient/Account/NCAccount.swift | 2 +- .../Assistant/Chat/NCAssistantChat.swift | 41 ++++++------- .../Components/NCAssistantEmptyView.swift | 4 +- .../NCAssistantCreateNewTask.swift | 4 +- .../Assistant/Models/NCAssistantModel.swift | 29 +++++----- iOSClient/Assistant/NCAssistant.swift | 46 +++++++-------- .../Task Detail/NCAssistantTaskDetail.swift | 18 +++--- .../Main/NCMainNavigationController.swift | 3 +- .../More/Cells/NCMoreAppSuggestionsCell.swift | 12 ---- .../More/Cells/NCMoreAppSuggestionsCell.xib | 57 ++++--------------- iOSClient/More/NCMore.swift | 5 -- 11 files changed, 80 insertions(+), 141 deletions(-) diff --git a/iOSClient/Account/NCAccount.swift b/iOSClient/Account/NCAccount.swift index c392cc099f..877276b2e9 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_") + await showErrorBanner(controller: controller, text: String(format: NSLocalizedString("_account_unauthorized_", comment: ""), account)) let resultsWipe = await NextcloudKit.shared.getRemoteWipeStatusAsync(serverUrl: tblAccount.urlBase, token: token, account: account) { task in Task { diff --git a/iOSClient/Assistant/Chat/NCAssistantChat.swift b/iOSClient/Assistant/Chat/NCAssistantChat.swift index ec3bbdb9e8..cd016f0853 100644 --- a/iOSClient/Assistant/Chat/NCAssistantChat.swift +++ b/iOSClient/Assistant/Chat/NCAssistantChat.swift @@ -24,14 +24,14 @@ import NextcloudKit // MARK: - Main View struct NCAssistantChat: View { - @Binding var model: NCAssistantChatModel + @Environment(NCAssistantChatModel.self) var chatModel // init(controller: NCMainTabBarController?) { // self.model = NCAssistantChatModel(controller: controller) // } var body: some View { - if model.messages.isEmpty { + if chatModel.messages.isEmpty { NCAssistantEmptyView(titleKey: "_no_chat_", subtitleKey: "_no_chat_subtitle_") } @@ -46,9 +46,9 @@ struct NCAssistantChat: View { // } } .safeAreaInset(edge: .bottom) { - ChatInputField { input in - model.sendMessage(input: input) - } +// ChatInputField { input in +// chatModel.sendMessage(input: input) +// } } .navigationTitle("Assistant Chat") .navigationBarTitleDisplayMode(.inline) @@ -58,26 +58,26 @@ struct NCAssistantChat: View { ScrollViewReader { proxy in ScrollView { LazyVStack(alignment: .leading, spacing: 12) { - ForEach(model.messages) { message in - MessageBubbleView(message: message, account: model.controller?.account ?? "") + ForEach(chatModel.messages) { message in + MessageBubbleView(message: message, account: chatModel.controller?.account ?? "") .id(message.id) } - if model.isThinking { + if chatModel.isThinking { ThinkingBubbleView() .id("thinking") } } .padding(.vertical) } - .onChange(of: model.messages.count) { _, _ in + .onChange(of: chatModel.messages.count) { _, _ in withAnimation { - if let lastMessage = model.messages.last { + if let lastMessage = chatModel.messages.last { proxy.scrollTo(lastMessage.id, anchor: .bottom) } } } - .onChange(of: model.isThinking) { _, isThinking in + .onChange(of: chatModel.isThinking) { _, isThinking in if isThinking { withAnimation { proxy.scrollTo("thinking", anchor: .bottom) @@ -178,14 +178,10 @@ struct ThinkingBubbleView: View { } } -// MARK: - Chat Input Field - - // MARK: - Empty Chat View struct EmptyChatView: View { -// let account: String - @Bindable var model: NCAssistantChatModel + @Environment(NCAssistantChatModel.self) var chatModel var body: some View { VStack(spacing: 16) { @@ -193,7 +189,7 @@ struct EmptyChatView: View { .renderingMode(.template) .resizable() .aspectRatio(contentMode: .fit) - .foregroundStyle(Color(NCBrandColor.shared.getElement(account: model.controller?.account))) + .foregroundStyle(Color(NCBrandColor.shared.getElement(account: chatModel.controller?.account))) .font(Font.system(.body).weight(.light)) .frame(height: 100) @@ -216,7 +212,8 @@ struct EmptyChatView: View { @Previewable @State var model = NCAssistantChatModel(controller: nil) NavigationStack { - NCAssistantChat(model: $model) + NCAssistantChat() + .environment(model) .onAppear { // Preview will show empty state } @@ -226,13 +223,11 @@ struct EmptyChatView: View { #Preview("With Messages") { @Previewable @State var model = NCAssistantChatModel(controller: nil) - let chat = NCAssistantChat(model: $model) - - return NavigationStack { - chat + NavigationStack { + NCAssistantChat() + .environment(model) .onAppear { // chat.model.loadDummyData() } } } - diff --git a/iOSClient/Assistant/Components/NCAssistantEmptyView.swift b/iOSClient/Assistant/Components/NCAssistantEmptyView.swift index 408694b42d..0c6f424a9d 100644 --- a/iOSClient/Assistant/Components/NCAssistantEmptyView.swift +++ b/iOSClient/Assistant/Components/NCAssistantEmptyView.swift @@ -5,7 +5,7 @@ import SwiftUI struct NCAssistantEmptyView: View { - @EnvironmentObject var model: NCAssistantModel + @Environment(NCAssistantModel.self) var assistantModel let titleKey, subtitleKey: String var body: some View { @@ -14,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/Models/NCAssistantModel.swift b/iOSClient/Assistant/Models/NCAssistantModel.swift index 18d8fc3a60..117d1c8d12 100644 --- a/iOSClient/Assistant/Models/NCAssistantModel.swift +++ b/iOSClient/Assistant/Models/NCAssistantModel.swift @@ -7,27 +7,28 @@ 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? +@Observable +class NCAssistantModel { + var types: [TaskTypeData] = [] + var filteredTasks: [AssistantTask] = [] + var selectedType: TaskTypeData? + var selectedTask: AssistantTask? - @Published var hasError: Bool = false - @Published var isLoading: Bool = false - @Published var isRefreshing: Bool = false + var hasError: Bool = false + var isLoading: Bool = false + var isRefreshing: Bool = false - let controller: NCMainTabBarController? + @ObservationIgnored let controller: NCMainTabBarController? - private var tasks: [AssistantTask] = [] + @ObservationIgnored private var tasks: [AssistantTask] = [] - private let session: NCSession.Session + @ObservationIgnored private let session: NCSession.Session - private let useV2: Bool + @ObservationIgnored private let useV2: Bool - private let chatTypeId = "core:text2text:chat" + @ObservationIgnored private let chatTypeId = "core:text2text:chat" - var isSelectedTypeChat: Bool { selectedType?.id == chatTypeId } + @ObservationIgnored var isSelectedTypeChat: Bool { selectedType?.id == chatTypeId } init(controller: NCMainTabBarController?) { self.controller = controller diff --git a/iOSClient/Assistant/NCAssistant.swift b/iOSClient/Assistant/NCAssistant.swift index 3fcca8c82c..87e7d8eaf6 100644 --- a/iOSClient/Assistant/NCAssistant.swift +++ b/iOSClient/Assistant/NCAssistant.swift @@ -1,33 +1,30 @@ -// -// 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 { - @State var model: NCAssistantModel + @State var assistantModel: NCAssistantModel @State var chatModel: NCAssistantChatModel + @State var input = "" @Environment(\.presentationMode) var presentationMode var body: some View { NavigationView { ZStack { - if model.types.isEmpty, !model.isLoading { + if assistantModel.types.isEmpty, !assistantModel.isLoading { NCAssistantEmptyView(titleKey: "_no_types_", subtitleKey: "_no_types_subtitle_") - } else if model.isSelectedTypeChat { - NCAssistantChat(model: $chatModel) + } else if assistantModel.isSelectedTypeChat { + NCAssistantChat() } else { TaskList() } - if model.isLoading, !model.isRefreshing { + if assistantModel.isLoading, !assistantModel.isRefreshing { ProgressView() .controlSize(.regular) } @@ -46,7 +43,7 @@ struct NCAssistant: View { .font(Font.system(.body).weight(.light)) .foregroundStyle(Color(NCBrandColor.shared.iconImageColor)) } - .disabled(model.selectedType == nil) + .disabled(assistantModel.selectedType == nil) .accessibilityIdentifier("SessionsButton") } } @@ -59,7 +56,7 @@ struct NCAssistant: View { } .navigationViewStyle(.stack) - .popup(isPresented: $model.hasError) { + .popup(isPresented: $assistantModel.hasError) { Text(NSLocalizedString("_error_occurred_", comment: "")) .padding() .background(.red) @@ -71,7 +68,8 @@ struct NCAssistant: View { .position(.bottom) } .accentColor(Color(NCBrandColor.shared.iconImageColor)) - .environmentObject(model) + .environment(assistantModel) + .environment(chatModel) } } @@ -79,16 +77,14 @@ struct NCAssistant: View { @Previewable @State var chatModel = NCAssistantChatModel(controller: nil) let model = NCAssistantModel(controller: nil) - NCAssistant(chatModel: chatModel) - .environmentObject(model) - // .environment(chatModel) + NCAssistant(assistantModel: model, chatModel: chatModel) .onAppear { model.loadDummyData() } } struct TaskList: View { - @Binding var model: NCAssistantModel + @Environment(NCAssistantModel.self) var model @State var presentEditTask = false @State var showDeleteConfirmation = false @@ -165,9 +161,9 @@ struct TaskList: View { } } .safeAreaInset(edge: .bottom) { - ChatInputField(isLoading: model.$isLoading) { input in - model.scheduleTask(input: input) - } +// ChatInputField(isLoading: model._isLoading) { input in +// model.scheduleTask(input: input) +// } } if model.filteredTasks.isEmpty, !model.isLoading { @@ -177,7 +173,7 @@ struct TaskList: View { } struct TypeButton: View { - @Binding var model: NCAssistantModel + @Environment(NCAssistantModel.self) var model let taskType: TaskTypeData? var scrollProxy: ScrollViewProxy @@ -212,7 +208,7 @@ struct TypeButton: View { } struct TaskItem: View { - @Binding var model: NCAssistantModel + @Environment(NCAssistantModel.self) var model @Binding var showDeleteConfirmation: Bool @Binding var taskToDelete: AssistantTask? var task: AssistantTask @@ -257,7 +253,7 @@ struct TaskItem: View { } struct TypeList: View { - @Binding var model: NCAssistantModel + @Environment(NCAssistantModel.self) var model var body: some View { ScrollViewReader { scrollProxy in 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/NCMainNavigationController.swift b/iOSClient/Main/NCMainNavigationController.swift index 2be449f9ab..4f98ce1213 100644 --- a/iOSClient/Main/NCMainNavigationController.swift +++ b/iOSClient/Main/NCMainNavigationController.swift @@ -98,8 +98,7 @@ class NCMainNavigationController: UINavigationController, UINavigationController assistantButton.setImage(UIImage(systemName: "sparkles"), for: .normal) assistantButton.tintColor = NCBrandColor.shared.iconImageColor assistantButton.addAction(UIAction(handler: { _ in - let assistant = NCAssistant(chatModel: NCAssistantChatModel(controller: self.controller)) - .environmentObject(NCAssistantModel(controller: self.controller)) + let assistant = NCAssistant(assistantModel: NCAssistantModel(controller: self.controller), chatModel: NCAssistantChatModel(controller: self.controller)) let hostingController = UIHostingController(rootView: assistant) self.present(hostingController, animated: true, completion: nil) }), for: .touchUpInside) diff --git a/iOSClient/More/Cells/NCMoreAppSuggestionsCell.swift b/iOSClient/More/Cells/NCMoreAppSuggestionsCell.swift index 0d3108a61d..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(chatModel: NCAssistantChatModel(controller: self.controller)) - .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 c79cd8737c..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(chatModel: NCAssistantChatModel(controller: self.controller)) - .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) From c1711eb935f058fc940f7142dffe831916f4e87a Mon Sep 17 00:00:00 2001 From: Milen Pivchev Date: Tue, 27 Jan 2026 10:42:19 +0100 Subject: [PATCH 08/31] WIP Signed-off-by: Milen Pivchev --- .../Assistant/Chat/NCAssistantChat.swift | 8 ++++--- .../Assistant/Components/ChatInputField.swift | 9 ++----- .../Models/NCAssistantChatModel.swift | 14 +++++++++++ iOSClient/Assistant/NCAssistant.swift | 24 ++++++++++--------- .../Sessions/NCAssistantChatSessions.swift | 18 ++++++++++---- 5 files changed, 48 insertions(+), 25 deletions(-) diff --git a/iOSClient/Assistant/Chat/NCAssistantChat.swift b/iOSClient/Assistant/Chat/NCAssistantChat.swift index cd016f0853..1d7abcaef2 100644 --- a/iOSClient/Assistant/Chat/NCAssistantChat.swift +++ b/iOSClient/Assistant/Chat/NCAssistantChat.swift @@ -31,6 +31,8 @@ struct NCAssistantChat: View { // } var body: some View { + @Bindable var chatModel = chatModel + if chatModel.messages.isEmpty { NCAssistantEmptyView(titleKey: "_no_chat_", subtitleKey: "_no_chat_subtitle_") } @@ -46,9 +48,9 @@ struct NCAssistantChat: View { // } } .safeAreaInset(edge: .bottom) { -// ChatInputField { input in -// chatModel.sendMessage(input: input) -// } + ChatInputField(isLoading: $chatModel.isThinking) { input in + chatModel.sendMessage(input: input) + } } .navigationTitle("Assistant Chat") .navigationBarTitleDisplayMode(.inline) diff --git a/iOSClient/Assistant/Components/ChatInputField.swift b/iOSClient/Assistant/Components/ChatInputField.swift index e0a68169cf..9e9af3178d 100644 --- a/iOSClient/Assistant/Components/ChatInputField.swift +++ b/iOSClient/Assistant/Components/ChatInputField.swift @@ -4,9 +4,7 @@ import SwiftUI - struct ChatInputField: View { -// @Bindable var model: NCAssistantChatModel @FocusState private var isInputFocused: Bool @State var text: String = "" @Binding var isLoading: Bool @@ -25,19 +23,16 @@ struct ChatInputField: View { Button(action: { isInputFocused = false - onSend?(text) + onSend?(text.trimmingCharacters(in: .whitespaces)) text = "" }) { Image(systemName: "arrow.up.circle.fill") .font(.system(size: 28)) -// .foregroundStyle(Color(NCBrandColor.shared.getElement(account: model.controller?.account))) } - .disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isLoading) -// .opacity(model.inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || model.isThinking ? 0.5 : 1.0) + .disabled(text.trimmingCharacters(in: .whitespaces).isEmpty || isLoading) } .padding(.horizontal) .padding(.vertical, 8) .background(Color(uiColor: .systemBackground)) } } - diff --git a/iOSClient/Assistant/Models/NCAssistantChatModel.swift b/iOSClient/Assistant/Models/NCAssistantChatModel.swift index c3c5c94e10..1596567c18 100644 --- a/iOSClient/Assistant/Models/NCAssistantChatModel.swift +++ b/iOSClient/Assistant/Models/NCAssistantChatModel.swift @@ -56,6 +56,19 @@ import NextcloudKit } + func createNewSession(title: String? = nil) async -> AssistantSession? { + let ts = Int(Date().timeIntervalSince1970 * 1000) + let result = await NextcloudKit.shared.createAssistantChatSessionAsync(title: title, timestamp: ts, account: session.account) + if result.error == .success, let newSession = result.conversation?.session { + sessions.insert(newSession, at: 0) + selectedSession = newSession + return newSession + } else { + hasError = true + return nil + } + } + private func handleTaskResponse(task: AssistantTask?, error: NKError?) { isThinking = false @@ -107,3 +120,4 @@ import NextcloudKit // ] // } } + diff --git a/iOSClient/Assistant/NCAssistant.swift b/iOSClient/Assistant/NCAssistant.swift index 87e7d8eaf6..9a34ec162d 100644 --- a/iOSClient/Assistant/NCAssistant.swift +++ b/iOSClient/Assistant/NCAssistant.swift @@ -84,7 +84,7 @@ struct NCAssistant: View { } struct TaskList: View { - @Environment(NCAssistantModel.self) var model + @Environment(NCAssistantModel.self) var assistantModel @State var presentEditTask = false @State var showDeleteConfirmation = false @@ -92,11 +92,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_") @@ -106,7 +108,7 @@ struct TaskList: View { } Button { - model.scheduleTask(input: task.input?.input ?? "") + assistantModel.scheduleTask(input: task.input?.input ?? "") } label: { Label { Text("_retry_") @@ -142,16 +144,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) } } } @@ -161,12 +163,12 @@ struct TaskList: View { } } .safeAreaInset(edge: .bottom) { -// ChatInputField(isLoading: model._isLoading) { input in -// model.scheduleTask(input: input) -// } + ChatInputField(isLoading: $assistantModel.isLoading) { input in + assistantModel.scheduleTask(input: input) + } } - if model.filteredTasks.isEmpty, !model.isLoading { + if assistantModel.filteredTasks.isEmpty, !assistantModel.isLoading { NCAssistantEmptyView(titleKey: "_no_tasks_", subtitleKey: "_create_task_subtitle_") } } diff --git a/iOSClient/Assistant/Sessions/NCAssistantChatSessions.swift b/iOSClient/Assistant/Sessions/NCAssistantChatSessions.swift index 3bb52755cf..32d283d87f 100644 --- a/iOSClient/Assistant/Sessions/NCAssistantChatSessions.swift +++ b/iOSClient/Assistant/Sessions/NCAssistantChatSessions.swift @@ -37,16 +37,26 @@ struct NCAssistantChatSessions: View { Group { // if model.isLoading { // ProgressView() - if model.sessions.isEmpty { - Text("No sessions found") - } else { +// if model.sessions.isEmpty { +// Text("No sessions found") +// } else { List(model.sessions, id: \.id) { session in - Text(session.title ?? "Untitled Session") + Text(session.title ?? "") .onTapGesture { model.selectedSession = session dismiss() } } +// } + } + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("New Conversation", systemImage: "plus.message.fill") { + Task { + _ = await model.createNewSession() + dismiss() + } + } } } } From 7197086d568fccf75d48b5a355e939cc6462a6f0 Mon Sep 17 00:00:00 2001 From: Milen Pivchev Date: Thu, 29 Jan 2026 19:03:10 +0100 Subject: [PATCH 09/31] WIP Signed-off-by: Milen Pivchev --- Nextcloud.xcodeproj/project.pbxproj | 20 +- .../NCAssistantChatSessions.swift | 3 +- .../Assistant/Chat/NCAssistantChat.swift | 14 +- .../Assistant/Chat/NCAssistantChatModel.swift | 178 ++++++++++++++++++ .../Assistant/Components/ChatInputField.swift | 7 +- .../Models/NCAssistantChatModel.swift | 123 ------------ iOSClient/Assistant/NCAssistant.swift | 1 - .../{Models => }/NCAssistantModel.swift | 5 - 8 files changed, 198 insertions(+), 153 deletions(-) rename iOSClient/Assistant/{Sessions => Chat Sessions}/NCAssistantChatSessions.swift (95%) create mode 100644 iOSClient/Assistant/Chat/NCAssistantChatModel.swift delete mode 100644 iOSClient/Assistant/Models/NCAssistantChatModel.swift rename iOSClient/Assistant/{Models => }/NCAssistantModel.swift (99%) diff --git a/Nextcloud.xcodeproj/project.pbxproj b/Nextcloud.xcodeproj/project.pbxproj index f9eeb38305..d59a84c050 100644 --- a/Nextcloud.xcodeproj/project.pbxproj +++ b/Nextcloud.xcodeproj/project.pbxproj @@ -2163,13 +2163,13 @@ F3A0478E2BD2668800658E7B /* Assistant */ = { isa = PBXGroup; children = ( - F3DDFE1F2F1F951000A784C8 /* Sessions */, - F3DDFE0D2F15452F00A784C8 /* Chat */, F3A047962BD2668800658E7B /* NCAssistant.swift */, + F3A047932BD2668800658E7B /* NCAssistantModel.swift */, + F3DDFE0D2F15452F00A784C8 /* Chat */, + F3DDFE1F2F1F951000A784C8 /* Chat Sessions */, F3A047902BD2668800658E7B /* Create Task */, F3A047942BD2668800658E7B /* Task Detail */, F3374A7F2D64AB40002A38F9 /* Components */, - F3A047922BD2668800658E7B /* Models */, ); path = Assistant; sourceTree = ""; @@ -2182,15 +2182,6 @@ path = "Create Task"; sourceTree = ""; }; - F3A047922BD2668800658E7B /* Models */ = { - isa = PBXGroup; - children = ( - F3A047932BD2668800658E7B /* NCAssistantModel.swift */, - F3DDFE222F1FB4C300A784C8 /* NCAssistantChatModel.swift */, - ); - path = Models; - sourceTree = ""; - }; F3A047942BD2668800658E7B /* Task Detail */ = { isa = PBXGroup; children = ( @@ -2214,16 +2205,17 @@ isa = PBXGroup; children = ( F3DDFE0E2F15453900A784C8 /* NCAssistantChat.swift */, + F3DDFE222F1FB4C300A784C8 /* NCAssistantChatModel.swift */, ); path = Chat; sourceTree = ""; }; - F3DDFE1F2F1F951000A784C8 /* Sessions */ = { + F3DDFE1F2F1F951000A784C8 /* Chat Sessions */ = { isa = PBXGroup; children = ( F3DDFE202F1F953000A784C8 /* NCAssistantChatSessions.swift */, ); - path = Sessions; + path = "Chat Sessions"; sourceTree = ""; }; F3E173BE2C9B1057006D177A /* ScreenAwakeManager */ = { diff --git a/iOSClient/Assistant/Sessions/NCAssistantChatSessions.swift b/iOSClient/Assistant/Chat Sessions/NCAssistantChatSessions.swift similarity index 95% rename from iOSClient/Assistant/Sessions/NCAssistantChatSessions.swift rename to iOSClient/Assistant/Chat Sessions/NCAssistantChatSessions.swift index 32d283d87f..94cfbdca6a 100644 --- a/iOSClient/Assistant/Sessions/NCAssistantChatSessions.swift +++ b/iOSClient/Assistant/Chat Sessions/NCAssistantChatSessions.swift @@ -41,7 +41,7 @@ struct NCAssistantChatSessions: View { // Text("No sessions found") // } else { List(model.sessions, id: \.id) { session in - Text(session.title ?? "") + Text(session.validTitle) .onTapGesture { model.selectedSession = session dismiss() @@ -49,6 +49,7 @@ struct NCAssistantChatSessions: View { } // } } + .navigationTitle("Conversations") .toolbar { ToolbarItem(placement: .topBarTrailing) { Button("New Conversation", systemImage: "plus.message.fill") { diff --git a/iOSClient/Assistant/Chat/NCAssistantChat.swift b/iOSClient/Assistant/Chat/NCAssistantChat.swift index 1d7abcaef2..2b6f30dea1 100644 --- a/iOSClient/Assistant/Chat/NCAssistantChat.swift +++ b/iOSClient/Assistant/Chat/NCAssistantChat.swift @@ -34,26 +34,24 @@ struct NCAssistantChat: View { @Bindable var chatModel = chatModel if chatModel.messages.isEmpty { - NCAssistantEmptyView(titleKey: "_no_chat_", subtitleKey: "_no_chat_subtitle_") + NCAssistantEmptyView(titleKey: "_no_chat_", subtitleKey: "_no_chat_subtitle_") } - + ZStack { VStack(spacing: 0) { messageListView } - - -// if model.messages.isEmpty && !model.isThinking { -// EmptyChatView(model: model) -// } } .safeAreaInset(edge: .bottom) { - ChatInputField(isLoading: $chatModel.isThinking) { input in + ChatInputField { input in chatModel.sendMessage(input: input) } } .navigationTitle("Assistant Chat") .navigationBarTitleDisplayMode(.inline) + .onDisappear { + chatModel.stopPolling() + } } private var messageListView: some View { diff --git a/iOSClient/Assistant/Chat/NCAssistantChatModel.swift b/iOSClient/Assistant/Chat/NCAssistantChatModel.swift new file mode 100644 index 0000000000..01fa477451 --- /dev/null +++ b/iOSClient/Assistant/Chat/NCAssistantChatModel.swift @@ -0,0 +1,178 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import NextcloudKit + +@Observable class NCAssistantChatModel { + var messages: [ChatMessage] = [] + var isThinking: Bool = false + var hasError: Bool = false + var selectedSession: AssistantSession? { + didSet { + loadMessages() + startPolling() + } + } + + var sessions: [AssistantSession] = [] + private let ncSession: NCSession.Session + private var pollingTask: Task? + + // @ObservationIgnored static let chatTypeId = "core:text2text:chat" + @ObservationIgnored var controller: NCMainTabBarController? + @ObservationIgnored private var currentChatTaskId: String? + + init(controller: NCMainTabBarController?) { + self.controller = controller + self.ncSession = NCSession.shared.getSession(controller: controller) + loadAllSessions() + } + + func startPolling(interval: TimeInterval = 2.0) { + stopPolling() + pollingTask = Task { + while !Task.isCancelled { + if currentChatTaskId == nil { + createChatSession() + } + + loadLastMessage() + try? await Task.sleep(for: .seconds(interval)) + } + } + } + + func stopPolling() { + pollingTask?.cancel() + pollingTask = nil + } + + private func createChatSession() { + guard let sessionId = selectedSession?.id else { return } + + Task { + let result = await NextcloudKit.shared.generateAssistantChatSessionAsync(sessionId: sessionId, account: ncSession.account) + currentChatTaskId = result.sessionTask?.taskId + } + } + + private func loadAllSessions() { + Task { + let result = await NextcloudKit.shared.getAssistantChatConversationsAsync(account: ncSession.account) + sessions = result.sessions ?? [] + } + } + + private func loadMessages() { + guard let sessionId = selectedSession?.id else { return } + + Task { + let result = await NextcloudKit.shared.getAssistantChatMessagesAsync(sessionId: sessionId, account: ncSession.account) + messages = result.chatMessage ?? [] + } + } + + private func loadLastMessage() { + guard let currentChatTaskId else { return } + + Task { + let result = await NextcloudKit.shared.checkAssistantChatGenerationAsync(taskId: currentChatTaskId, sessionId: selectedSession?.id ?? 0, account: ncSession.account) + let lastMessage = result.chatMessage + + if let lastMessage, lastMessage.role == "assistant" { + isThinking = false + messages.append(lastMessage) + } + } + } + + func sendMessage(input: String) { + if let selectedSession { + let request = ChatMessageRequest(sessionId: selectedSession.id, role: "human", content: input, timestamp: Int(Date().timeIntervalSince1970 * 1000)) + isThinking = true + + Task { + let result = await NextcloudKit.shared.createAssistantChatMessageAsync(messageRequest: request, account: ncSession.account) + if result.error == .success { + guard let chatMessage = result.chatMessage else { return } + messages.append(chatMessage) + } + if result.error != .success { + // TODO + } + } + } else { + Task { + let session = await createNewConversation(title: input) + selectedSession = session + sendMessage(input: input) + } + } + } + + private func createNewConversation(title: String? = nil) async -> AssistantSession? { + let timestamp = Int(Date().timeIntervalSince1970) + let result = await NextcloudKit.shared.createAssistantChatConversationAsync(title: title, timestamp: timestamp, account: ncSession.account) + if result.error == .success, let newSession = result.conversation?.session { + sessions.insert(newSession, at: 0) + selectedSession = newSession + return newSession + } else { + hasError = true + return nil + } + } + + private func handleTaskResponse(task: AssistantTask?, error: NKError?) { + isThinking = false + + if error != .success { + hasError = true + return + } + + guard let task, let output = task.output?.output else { + hasError = true + return + } + + addAssistantMessage(output) + } + + private func addUserMessage(_ text: String) { + // let message = ChatMessage(content: text, isFromUser: true) + // messages.append(message) + } + + private func addAssistantMessage(_ text: String) { + // let message = ChatMessage(content: text, isFromUser: false) + // messages.append(message) + } + + // func loadDummyData() { + // messages = [ + // ChatMessage( + // content: "Hello! Can you help me summarize this document?", + // isFromUser: true, + // timestamp: Date().addingTimeInterval(-300) + // ), + // ChatMessage( + // 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.", + // isFromUser: false, + // timestamp: Date().addingTimeInterval(-240) + // ), + // ChatMessage( + // content: "Here is the text: Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", + // isFromUser: true, + // timestamp: Date().addingTimeInterval(-180) + // ), + // ChatMessage( + // 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.", + // isFromUser: false, + // timestamp: Date().addingTimeInterval(-120) + // ) + // ] + // } +} + diff --git a/iOSClient/Assistant/Components/ChatInputField.swift b/iOSClient/Assistant/Components/ChatInputField.swift index 9e9af3178d..1d47981b3a 100644 --- a/iOSClient/Assistant/Components/ChatInputField.swift +++ b/iOSClient/Assistant/Components/ChatInputField.swift @@ -9,7 +9,12 @@ struct ChatInputField: View { @State var text: String = "" @Binding var isLoading: Bool var onSend: ((_ input: String) -> Void)? = nil - + + init(isLoading: Binding = .constant(false), onSend: ((_: String) -> Void)? = nil) { + _isLoading = isLoading + self.onSend = onSend + } + var body: some View { HStack(spacing: 8) { TextField(NSLocalizedString("_type_message_", comment: ""), text: $text, axis: .vertical) diff --git a/iOSClient/Assistant/Models/NCAssistantChatModel.swift b/iOSClient/Assistant/Models/NCAssistantChatModel.swift deleted file mode 100644 index 1596567c18..0000000000 --- a/iOSClient/Assistant/Models/NCAssistantChatModel.swift +++ /dev/null @@ -1,123 +0,0 @@ -// SPDX-FileCopyrightText: Nextcloud GmbH -// SPDX-FileCopyrightText: 2026 Milen Pivchev -// SPDX-License-Identifier: GPL-3.0-or-later - -import NextcloudKit - -@Observable class NCAssistantChatModel { - var messages: [ChatMessage] = [] - var isThinking: Bool = false - var hasError: Bool = false - var selectedSession: AssistantSession? { - didSet { - loadMessages() - } - } - - var sessions: [AssistantSession] = [] - @ObservationIgnored var controller: NCMainTabBarController? - private let session: NCSession.Session - - init(controller: NCMainTabBarController?) { - self.controller = controller - self.session = NCSession.shared.getSession(controller: controller) - loadAllSessions() - } - - func loadAllSessions() { - Task { - let result = await NextcloudKit.shared.getAssistantChatSessionsAsync(account: session.account) - sessions = result.sessions ?? [] - } - } - - func loadMessages() { - Task { - let result = await NextcloudKit.shared.getAssistantChatMessagesAsync(sessionId: selectedSession?.id ?? 0, account: session.account) - messages = result.chatMessage ?? [] - } - } - - func sendMessage(input: String) { - guard let selectedSession else { return } - - let request = ChatMessageRequest(sessionId: selectedSession.id, role: "human", content: input, timestamp: Int(Date().timeIntervalSince1970 * 1000)) - - Task { - let result = await NextcloudKit.shared.createAssistantChatMessageAsync(messageRequest: request, account: session.account) - if result.error == .success { - guard let chatMessage = result.chatMessage else { return } - messages.append(chatMessage) - } - if result.error != .success { - // TODO - } - } - - } - - func createNewSession(title: String? = nil) async -> AssistantSession? { - let ts = Int(Date().timeIntervalSince1970 * 1000) - let result = await NextcloudKit.shared.createAssistantChatSessionAsync(title: title, timestamp: ts, account: session.account) - if result.error == .success, let newSession = result.conversation?.session { - sessions.insert(newSession, at: 0) - selectedSession = newSession - return newSession - } else { - hasError = true - return nil - } - } - - private func handleTaskResponse(task: AssistantTask?, error: NKError?) { - isThinking = false - - if error != .success { - hasError = true - return - } - - guard let task, let output = task.output?.output else { - hasError = true - return - } - - addAssistantMessage(output) - } - - private func addUserMessage(_ text: String) { -// let message = ChatMessage(content: text, isFromUser: true) -// messages.append(message) - } - - private func addAssistantMessage(_ text: String) { -// let message = ChatMessage(content: text, isFromUser: false) -// messages.append(message) - } - -// func loadDummyData() { -// messages = [ -// ChatMessage( -// content: "Hello! Can you help me summarize this document?", -// isFromUser: true, -// timestamp: Date().addingTimeInterval(-300) -// ), -// ChatMessage( -// 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.", -// isFromUser: false, -// timestamp: Date().addingTimeInterval(-240) -// ), -// ChatMessage( -// content: "Here is the text: Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", -// isFromUser: true, -// timestamp: Date().addingTimeInterval(-180) -// ), -// ChatMessage( -// 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.", -// isFromUser: false, -// timestamp: Date().addingTimeInterval(-120) -// ) -// ] -// } -} - diff --git a/iOSClient/Assistant/NCAssistant.swift b/iOSClient/Assistant/NCAssistant.swift index 9a34ec162d..dea0b2352c 100644 --- a/iOSClient/Assistant/NCAssistant.swift +++ b/iOSClient/Assistant/NCAssistant.swift @@ -9,7 +9,6 @@ import PopupView struct NCAssistant: View { @State var assistantModel: NCAssistantModel @State var chatModel: NCAssistantChatModel - @State var input = "" @Environment(\.presentationMode) var presentationMode diff --git a/iOSClient/Assistant/Models/NCAssistantModel.swift b/iOSClient/Assistant/NCAssistantModel.swift similarity index 99% rename from iOSClient/Assistant/Models/NCAssistantModel.swift rename to iOSClient/Assistant/NCAssistantModel.swift index 117d1c8d12..26127f0834 100644 --- a/iOSClient/Assistant/Models/NCAssistantModel.swift +++ b/iOSClient/Assistant/NCAssistantModel.swift @@ -19,15 +19,10 @@ class NCAssistantModel { var isRefreshing: 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?) { From 32022dbde98f3db5ff98fe4b542f7e579a0b9655 Mon Sep 17 00:00:00 2001 From: Milen Pivchev Date: Fri, 30 Jan 2026 13:53:21 +0100 Subject: [PATCH 10/31] WIP Signed-off-by: Milen Pivchev --- .../Assistant/Chat Sessions/NCAssistantChatSessions.swift | 2 +- iOSClient/Assistant/Chat/NCAssistantChatModel.swift | 7 +++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/iOSClient/Assistant/Chat Sessions/NCAssistantChatSessions.swift b/iOSClient/Assistant/Chat Sessions/NCAssistantChatSessions.swift index 94cfbdca6a..fa2e652f9a 100644 --- a/iOSClient/Assistant/Chat Sessions/NCAssistantChatSessions.swift +++ b/iOSClient/Assistant/Chat Sessions/NCAssistantChatSessions.swift @@ -54,7 +54,7 @@ struct NCAssistantChatSessions: View { ToolbarItem(placement: .topBarTrailing) { Button("New Conversation", systemImage: "plus.message.fill") { Task { - _ = await model.createNewSession() + _ = await model.createNewConversation() dismiss() } } diff --git a/iOSClient/Assistant/Chat/NCAssistantChatModel.swift b/iOSClient/Assistant/Chat/NCAssistantChatModel.swift index 01fa477451..131d6454bd 100644 --- a/iOSClient/Assistant/Chat/NCAssistantChatModel.swift +++ b/iOSClient/Assistant/Chat/NCAssistantChatModel.swift @@ -19,9 +19,8 @@ import NextcloudKit private let ncSession: NCSession.Session private var pollingTask: Task? - // @ObservationIgnored static let chatTypeId = "core:text2text:chat" @ObservationIgnored var controller: NCMainTabBarController? - @ObservationIgnored private var currentChatTaskId: String? + @ObservationIgnored private var currentChatTaskId: Int? init(controller: NCMainTabBarController?) { self.controller = controller @@ -29,7 +28,7 @@ import NextcloudKit loadAllSessions() } - func startPolling(interval: TimeInterval = 2.0) { + func startPolling(interval: TimeInterval = 10.0) { stopPolling() pollingTask = Task { while !Task.isCancelled { @@ -111,7 +110,7 @@ import NextcloudKit } } - private func createNewConversation(title: String? = nil) async -> AssistantSession? { + func createNewConversation(title: String? = nil) async -> AssistantSession? { let timestamp = Int(Date().timeIntervalSince1970) let result = await NextcloudKit.shared.createAssistantChatConversationAsync(title: title, timestamp: timestamp, account: ncSession.account) if result.error == .success, let newSession = result.conversation?.session { From bb62d62afe763cdafbd4a6462bb8c582ceee6c72 Mon Sep 17 00:00:00 2001 From: Milen Pivchev Date: Fri, 30 Jan 2026 15:40:12 +0100 Subject: [PATCH 11/31] WIP Signed-off-by: Milen Pivchev --- .../Assistant/Chat/NCAssistantChatModel.swift | 34 +++++++++++-------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/iOSClient/Assistant/Chat/NCAssistantChatModel.swift b/iOSClient/Assistant/Chat/NCAssistantChatModel.swift index 131d6454bd..5650253e2b 100644 --- a/iOSClient/Assistant/Chat/NCAssistantChatModel.swift +++ b/iOSClient/Assistant/Chat/NCAssistantChatModel.swift @@ -10,8 +10,8 @@ import NextcloudKit var hasError: Bool = false var selectedSession: AssistantSession? { didSet { + stopPolling() loadMessages() - startPolling() } } @@ -20,7 +20,7 @@ import NextcloudKit private var pollingTask: Task? @ObservationIgnored var controller: NCMainTabBarController? - @ObservationIgnored private var currentChatTaskId: Int? + @ObservationIgnored private var chatResponseTaskId: Int? init(controller: NCMainTabBarController?) { self.controller = controller @@ -28,13 +28,13 @@ import NextcloudKit loadAllSessions() } - func startPolling(interval: TimeInterval = 10.0) { + func startPollingForResponse(interval: TimeInterval = 4.0) { stopPolling() pollingTask = Task { while !Task.isCancelled { - if currentChatTaskId == nil { - createChatSession() - } +// if currentChatTaskId == nil { +// requestResponse() +// } loadLastMessage() try? await Task.sleep(for: .seconds(interval)) @@ -47,12 +47,14 @@ import NextcloudKit pollingTask = nil } - private func createChatSession() { + private func requestResponse() { guard let sessionId = selectedSession?.id else { return } Task { let result = await NextcloudKit.shared.generateAssistantChatSessionAsync(sessionId: sessionId, account: ncSession.account) - currentChatTaskId = result.sessionTask?.taskId + chatResponseTaskId = result.sessionTask?.taskId + + startPollingForResponse() } } @@ -73,13 +75,14 @@ import NextcloudKit } private func loadLastMessage() { - guard let currentChatTaskId else { return } + guard let chatResponseTaskId else { return } Task { - let result = await NextcloudKit.shared.checkAssistantChatGenerationAsync(taskId: currentChatTaskId, sessionId: selectedSession?.id ?? 0, account: ncSession.account) + let result = await NextcloudKit.shared.checkAssistantChatGenerationAsync(taskId: chatResponseTaskId, sessionId: selectedSession?.id ?? 0, account: ncSession.account) let lastMessage = result.chatMessage if let lastMessage, lastMessage.role == "assistant" { + stopPolling() isThinking = false messages.append(lastMessage) } @@ -88,7 +91,7 @@ import NextcloudKit func sendMessage(input: String) { if let selectedSession { - let request = ChatMessageRequest(sessionId: selectedSession.id, role: "human", content: input, timestamp: Int(Date().timeIntervalSince1970 * 1000)) + let request = ChatMessageRequest(sessionId: selectedSession.id, role: "human", content: input, timestamp: Int(Date().timeIntervalSince1970 * 1000), firstHumanMessage: messages.isEmpty) isThinking = true Task { @@ -96,10 +99,13 @@ import NextcloudKit if result.error == .success { guard let chatMessage = result.chatMessage else { return } messages.append(chatMessage) + + stopPolling() + requestResponse() + } else { + //TODO } - if result.error != .success { - // TODO - } + } } else { Task { From 0afd898396a2ec4f0a245a00c88fea35fe8c6e88 Mon Sep 17 00:00:00 2001 From: Milen Pivchev Date: Mon, 2 Feb 2026 11:11:39 +0100 Subject: [PATCH 12/31] WIP Signed-off-by: Milen Pivchev --- .../Assistant/Chat/NCAssistantChat.swift | 36 +------- .../Assistant/Chat/NCAssistantChatModel.swift | 85 +++++++------------ .../Assistant/Components/ChatInputField.swift | 52 +++++++----- .../en.lproj/Localizable.strings | 5 +- 4 files changed, 69 insertions(+), 109 deletions(-) diff --git a/iOSClient/Assistant/Chat/NCAssistantChat.swift b/iOSClient/Assistant/Chat/NCAssistantChat.swift index 2b6f30dea1..4e5b98a7a5 100644 --- a/iOSClient/Assistant/Chat/NCAssistantChat.swift +++ b/iOSClient/Assistant/Chat/NCAssistantChat.swift @@ -5,31 +5,9 @@ import SwiftUI import NextcloudKit -// MARK: - Data Models -// -//struct ChatMessage: Identifiable, Equatable { -// let id: UUID -// let content: String -// let isFromUser: Bool -// let timestamp: Date -// -// init(id: UUID = UUID(), content: String, isFromUser: Bool, timestamp: Date = Date()) { -// self.id = id -// self.content = content -// self.isFromUser = isFromUser -// self.timestamp = timestamp -// } -//} - -// MARK: - Main View - struct NCAssistantChat: View { @Environment(NCAssistantChatModel.self) var chatModel -// init(controller: NCMainTabBarController?) { -// self.model = NCAssistantChatModel(controller: controller) -// } - var body: some View { @Bindable var chatModel = chatModel @@ -209,25 +187,15 @@ struct EmptyChatView: View { // MARK: - Preview #Preview { - @Previewable @State var model = NCAssistantChatModel(controller: nil) - NavigationStack { NCAssistantChat() - .environment(model) - .onAppear { - // Preview will show empty state - } + .environment(NCAssistantChatModel(controller: nil)) } } #Preview("With Messages") { - @Previewable @State var model = NCAssistantChatModel(controller: nil) - NavigationStack { NCAssistantChat() - .environment(model) - .onAppear { -// chat.model.loadDummyData() - } + .environment(NCAssistantChatModel.example) } } diff --git a/iOSClient/Assistant/Chat/NCAssistantChatModel.swift b/iOSClient/Assistant/Chat/NCAssistantChatModel.swift index 5650253e2b..cad870c017 100644 --- a/iOSClient/Assistant/Chat/NCAssistantChatModel.swift +++ b/iOSClient/Assistant/Chat/NCAssistantChatModel.swift @@ -22,9 +22,10 @@ import NextcloudKit @ObservationIgnored var controller: NCMainTabBarController? @ObservationIgnored private var chatResponseTaskId: Int? - init(controller: NCMainTabBarController?) { + init(controller: NCMainTabBarController?, messages: [ChatMessage] = []) { self.controller = controller self.ncSession = NCSession.shared.getSession(controller: controller) + self.messages = messages loadAllSessions() } @@ -128,56 +129,36 @@ import NextcloudKit return nil } } - - private func handleTaskResponse(task: AssistantTask?, error: NKError?) { - isThinking = false - - if error != .success { - hasError = true - return - } - - guard let task, let output = task.output?.output else { - hasError = true - return - } - - addAssistantMessage(output) - } - - private func addUserMessage(_ text: String) { - // let message = ChatMessage(content: text, isFromUser: true) - // messages.append(message) - } - - private func addAssistantMessage(_ text: String) { - // let message = ChatMessage(content: text, isFromUser: false) - // messages.append(message) - } - - // func loadDummyData() { - // messages = [ - // ChatMessage( - // content: "Hello! Can you help me summarize this document?", - // isFromUser: true, - // timestamp: Date().addingTimeInterval(-300) - // ), - // ChatMessage( - // 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.", - // isFromUser: false, - // timestamp: Date().addingTimeInterval(-240) - // ), - // ChatMessage( - // content: "Here is the text: Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", - // isFromUser: true, - // timestamp: Date().addingTimeInterval(-180) - // ), - // ChatMessage( - // 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.", - // isFromUser: false, - // timestamp: Date().addingTimeInterval(-120) - // ) - // ] - // } } +extension NCAssistantChatModel { + static var example = NCAssistantChatModel(controller: nil, messages: [ + ChatMessage( + id: 1, + sessionId: 0, + role: "human", + content: "Hello! Can you help me summarize this document?", + timestamp: Int(Date().addingTimeInterval(-300).timeIntervalSince1970 * 1000) + ), + ChatMessage( + 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) + ), + ChatMessage( + 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) + ), + ChatMessage( + 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 index 1d47981b3a..2fde5c1f42 100644 --- a/iOSClient/Assistant/Components/ChatInputField.swift +++ b/iOSClient/Assistant/Components/ChatInputField.swift @@ -8,36 +8,46 @@ struct ChatInputField: View { @FocusState private var isInputFocused: Bool @State var text: String = "" @Binding var isLoading: Bool - var onSend: ((_ input: String) -> Void)? = nil - + var onSend: ((_ input: String) -> Void)? + init(isLoading: Binding = .constant(false), onSend: ((_: String) -> Void)? = nil) { _isLoading = isLoading self.onSend = onSend } - + var body: some View { - HStack(spacing: 8) { - TextField(NSLocalizedString("_type_message_", comment: ""), text: $text, axis: .vertical) - .textFieldStyle(.plain) - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background(Color(NCBrandColor.shared.textColor2).opacity(0.1)) - .clipShape(.rect(cornerRadius: 20)) - .focused($isInputFocused) - .lineLimit(1...5) + 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 = "" - }) { - Image(systemName: "arrow.up.circle.fill") - .font(.system(size: 28)) + Button(action: { + isInputFocused = false + onSend?(text.trimmingCharacters(in: .whitespaces)) + text = "" + }) { + Image(systemName: "arrow.up.circle.fill") + .font(.system(size: 28)) + } + .disabled(text.trimmingCharacters(in: .whitespaces).isEmpty || isLoading) } - .disabled(text.trimmingCharacters(in: .whitespaces).isEmpty || isLoading) } .padding(.horizontal) .padding(.vertical, 8) - .background(Color(uiColor: .systemBackground)) } } + +#Preview { + ChatInputField(isLoading: .constant(false)) +} diff --git a/iOSClient/Supporting Files/en.lproj/Localizable.strings b/iOSClient/Supporting Files/en.lproj/Localizable.strings index fa5310d83b..c32ed7c1fd 100644 --- a/iOSClient/Supporting Files/en.lproj/Localizable.strings +++ b/iOSClient/Supporting Files/en.lproj/Localizable.strings @@ -710,11 +710,12 @@ 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."; // MARK: Client certificate "_no_client_cert_found_" = "The server is requesting a client certificate."; From 8febeb76a6ab8276a41377395fb11e70b1cc5b53 Mon Sep 17 00:00:00 2001 From: Milen Pivchev Date: Tue, 3 Feb 2026 10:39:11 +0100 Subject: [PATCH 13/31] WIP Signed-off-by: Milen Pivchev --- iOSClient/Assistant/Chat/NCAssistantChat.swift | 4 ++-- iOSClient/Share/NCShareUserCell.swift | 1 - iOSClient/Supporting Files/en.lproj/Localizable.strings | 1 + 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/iOSClient/Assistant/Chat/NCAssistantChat.swift b/iOSClient/Assistant/Chat/NCAssistantChat.swift index 4e5b98a7a5..56c97dfba5 100644 --- a/iOSClient/Assistant/Chat/NCAssistantChat.swift +++ b/iOSClient/Assistant/Chat/NCAssistantChat.swift @@ -12,7 +12,7 @@ struct NCAssistantChat: View { @Bindable var chatModel = chatModel if chatModel.messages.isEmpty { - NCAssistantEmptyView(titleKey: "_no_chat_", subtitleKey: "_no_chat_subtitle_") + NCAssistantEmptyView(titleKey: "_no_tasks_", subtitleKey: "_no_chat_subtitle_") } ZStack { @@ -21,7 +21,7 @@ struct NCAssistantChat: View { } } .safeAreaInset(edge: .bottom) { - ChatInputField { input in + ChatInputField(isLoading: $chatModel.isThinking) { input in chatModel.sendMessage(input: input) } } diff --git a/iOSClient/Share/NCShareUserCell.swift b/iOSClient/Share/NCShareUserCell.swift index e6b60c8a93..1d92a59406 100644 --- a/iOSClient/Share/NCShareUserCell.swift +++ b/iOSClient/Share/NCShareUserCell.swift @@ -25,7 +25,6 @@ import DropDown import NextcloudKit class NCShareUserCell: UITableViewCell, NCCellProtocol { - @IBOutlet weak var imageItem: UIImageView! @IBOutlet weak var labelTitle: UILabel! @IBOutlet weak var buttonMenu: UIButton! diff --git a/iOSClient/Supporting Files/en.lproj/Localizable.strings b/iOSClient/Supporting Files/en.lproj/Localizable.strings index c32ed7c1fd..4b313d1649 100644 --- a/iOSClient/Supporting Files/en.lproj/Localizable.strings +++ b/iOSClient/Supporting Files/en.lproj/Localizable.strings @@ -716,6 +716,7 @@ You can stop it at any time, adjust the settings, and enable it again."; "_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."; // MARK: Client certificate "_no_client_cert_found_" = "The server is requesting a client certificate."; From e99ef9d1506f18cfbe990bd683dbd625c2fd1f4a Mon Sep 17 00:00:00 2001 From: Milen Pivchev Date: Thu, 5 Feb 2026 13:59:53 +0100 Subject: [PATCH 14/31] WIP Signed-off-by: Milen Pivchev --- iOSClient/Assistant/Chat/NCAssistantChatModel.swift | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/iOSClient/Assistant/Chat/NCAssistantChatModel.swift b/iOSClient/Assistant/Chat/NCAssistantChatModel.swift index cad870c017..90c00aac6d 100644 --- a/iOSClient/Assistant/Chat/NCAssistantChatModel.swift +++ b/iOSClient/Assistant/Chat/NCAssistantChatModel.swift @@ -12,9 +12,17 @@ import NextcloudKit didSet { stopPolling() loadMessages() + + if messages.last.isFromHuman { + startPollingForResponse() + } } } + + // A session that has been selected at least once while this screen is showing is added here. + var alreadyOpenedSessions: Set = [] + var sessions: [AssistantSession] = [] private let ncSession: NCSession.Session private var pollingTask: Task? From 6c3644395b3c335584d560e24d4df0dbb5ea2f60 Mon Sep 17 00:00:00 2001 From: Milen Pivchev Date: Thu, 5 Feb 2026 14:27:55 +0100 Subject: [PATCH 15/31] WIP Signed-off-by: Milen Pivchev --- Nextcloud.xcodeproj/project.pbxproj | 4 ++ .../NCAssistantChatSessions.swift | 54 +++++----------- .../NCAssistantChatSessionsModel.swift | 38 ++++++++++++ .../Assistant/Chat/NCAssistantChat.swift | 13 +++- .../Assistant/Chat/NCAssistantChatModel.swift | 61 +++++-------------- iOSClient/Assistant/NCAssistant.swift | 9 ++- .../Main/NCMainNavigationController.swift | 2 +- 7 files changed, 91 insertions(+), 90 deletions(-) create mode 100644 iOSClient/Assistant/Chat Sessions/NCAssistantChatSessionsModel.swift diff --git a/Nextcloud.xcodeproj/project.pbxproj b/Nextcloud.xcodeproj/project.pbxproj index ed96f2bc22..90c3bb2632 100644 --- a/Nextcloud.xcodeproj/project.pbxproj +++ b/Nextcloud.xcodeproj/project.pbxproj @@ -163,6 +163,7 @@ 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 /* NCAssistantChatSessionsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3C6F6F52F34CC0900C531B6 /* NCAssistantChatSessionsModel.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 */; }; @@ -1277,6 +1278,7 @@ 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 /* NCAssistantChatSessionsModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCAssistantChatSessionsModel.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 = ""; }; F3DDFE102F16732D00A784C8 /* NextcloudKit */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = NextcloudKit; path = ../NextcloudKit; sourceTree = SOURCE_ROOT; }; @@ -2210,6 +2212,7 @@ isa = PBXGroup; children = ( F3DDFE202F1F953000A784C8 /* NCAssistantChatSessions.swift */, + F3C6F6F52F34CC0900C531B6 /* NCAssistantChatSessionsModel.swift */, ); path = "Chat Sessions"; sourceTree = ""; @@ -4455,6 +4458,7 @@ F7AE00F8230E81CB007ACF8A /* NCBrowserWeb.swift in Sources */, F77DD6A82C5CC093009448FB /* NCSession.swift in Sources */, F702F30825EE5D47008F8E80 /* NCPopupViewController.swift in Sources */, + F3C6F6F62F34CC0900C531B6 /* NCAssistantChatSessionsModel.swift in Sources */, F76340FC2EBDF64D0056F538 /* NCManageDatabase+Tag.swift in Sources */, F733598125C1C188002ABA72 /* NCAskAuthorization.swift in Sources */, 370D26AF248A3D7A00121797 /* NCCellProtocol.swift in Sources */, diff --git a/iOSClient/Assistant/Chat Sessions/NCAssistantChatSessions.swift b/iOSClient/Assistant/Chat Sessions/NCAssistantChatSessions.swift index fa2e652f9a..8a88cf85eb 100644 --- a/iOSClient/Assistant/Chat Sessions/NCAssistantChatSessions.swift +++ b/iOSClient/Assistant/Chat Sessions/NCAssistantChatSessions.swift @@ -6,55 +6,28 @@ import Foundation import SwiftUI import NextcloudKit -//@Observable class NCAssistantChatSessionsModel { -// var sessions: [AssistantSession] = [] -// var isLoading: Bool = false -// var hasError: Bool = false -// let controller: NCMainTabBarController? -// -// private let session: NCSession.Session -// -// init(controller: NCMainTabBarController?) { -// self.controller = controller -// session = NCSession.shared.getSession(controller: controller) -// -// loadSessions() -// } -// -// func loadSessions() { -// Task { -// let result = await NextcloudKit.shared.textProcessingGetChatSessionsV2Async(account: session.account) -// sessions = result.sessions ?? [] -// } -// } -//} - struct NCAssistantChatSessions: View { - @Binding var model: NCAssistantChatModel + @Environment(NCAssistantChatSessionsModel.self) var sessionsModel + var onSessionSelected: (AssistantSession?) -> Void @Environment(\.dismiss) private var dismiss var body: some View { Group { -// if model.isLoading { -// ProgressView() -// if model.sessions.isEmpty { -// Text("No sessions found") -// } else { - List(model.sessions, id: \.id) { session in - Text(session.validTitle) - .onTapGesture { - model.selectedSession = session - dismiss() - } - } -// } + List(sessionsModel.sessions, id: \.id) { session in + Text(session.validTitle) + .onTapGesture { + onSessionSelected(session) + dismiss() + } + } } .navigationTitle("Conversations") .toolbar { ToolbarItem(placement: .topBarTrailing) { Button("New Conversation", systemImage: "plus.message.fill") { Task { - _ = await model.createNewConversation() + let session = await sessionsModel.createNewConversation() + onSessionSelected(session) dismiss() } } @@ -64,5 +37,6 @@ struct NCAssistantChatSessions: View { } #Preview { - @Previewable @State var model = NCAssistantChatModel(controller: nil) - return NCAssistantChatSessions(model: $model)} + NCAssistantChatSessions(onSessionSelected: { _ in }) + .environment(NCAssistantChatSessionsModel(controller: nil)) +} diff --git a/iOSClient/Assistant/Chat Sessions/NCAssistantChatSessionsModel.swift b/iOSClient/Assistant/Chat Sessions/NCAssistantChatSessionsModel.swift new file mode 100644 index 0000000000..fb5bfc728f --- /dev/null +++ b/iOSClient/Assistant/Chat Sessions/NCAssistantChatSessionsModel.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 NCAssistantChatSessionsModel { + var sessions: [AssistantSession] = [] + 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.getAssistantChatConversationsAsync(account: ncSession.account) + sessions = result.sessions ?? [] + } + } + + func createNewConversation(title: String? = nil) async -> AssistantSession? { + let timestamp = Int(Date().timeIntervalSince1970) + let result = await NextcloudKit.shared.createAssistantChatConversationAsync(title: title, timestamp: timestamp, account: ncSession.account) + if result.error == .success, let newSession = result.conversation?.session { + sessions.insert(newSession, at: 0) + return newSession + } else { + hasError = true + return nil + } + } +} diff --git a/iOSClient/Assistant/Chat/NCAssistantChat.swift b/iOSClient/Assistant/Chat/NCAssistantChat.swift index 56c97dfba5..36c504bc6e 100644 --- a/iOSClient/Assistant/Chat/NCAssistantChat.swift +++ b/iOSClient/Assistant/Chat/NCAssistantChat.swift @@ -7,6 +7,7 @@ import NextcloudKit struct NCAssistantChat: View { @Environment(NCAssistantChatModel.self) var chatModel + @Environment(NCAssistantChatSessionsModel.self) var sessionsModel var body: some View { @Bindable var chatModel = chatModel @@ -22,7 +23,15 @@ struct NCAssistantChat: View { } .safeAreaInset(edge: .bottom) { ChatInputField(isLoading: $chatModel.isThinking) { input in - chatModel.sendMessage(input: input) + if chatModel.selectedSession != nil { + chatModel.sendMessage(input: input) + } else { + Task { + let session = await sessionsModel.createNewConversation(title: input) + chatModel.selectedSession = session + chatModel.sendMessage(input: input) + } + } } } .navigationTitle("Assistant Chat") @@ -190,6 +199,7 @@ struct EmptyChatView: View { NavigationStack { NCAssistantChat() .environment(NCAssistantChatModel(controller: nil)) + .environment(NCAssistantChatSessionsModel(controller: nil)) } } @@ -197,5 +207,6 @@ struct EmptyChatView: View { NavigationStack { NCAssistantChat() .environment(NCAssistantChatModel.example) + .environment(NCAssistantChatSessionsModel(controller: nil)) } } diff --git a/iOSClient/Assistant/Chat/NCAssistantChatModel.swift b/iOSClient/Assistant/Chat/NCAssistantChatModel.swift index 90c00aac6d..ceacea6ada 100644 --- a/iOSClient/Assistant/Chat/NCAssistantChatModel.swift +++ b/iOSClient/Assistant/Chat/NCAssistantChatModel.swift @@ -13,28 +13,24 @@ import NextcloudKit stopPolling() loadMessages() - if messages.last.isFromHuman { + if messages.last?.isFromHuman == true { startPollingForResponse() } } } - // A session that has been selected at least once while this screen is showing is added here. var alreadyOpenedSessions: Set = [] - var sessions: [AssistantSession] = [] private let ncSession: NCSession.Session private var pollingTask: Task? @ObservationIgnored var controller: NCMainTabBarController? @ObservationIgnored private var chatResponseTaskId: Int? - init(controller: NCMainTabBarController?, messages: [ChatMessage] = []) { self.controller = controller self.ncSession = NCSession.shared.getSession(controller: controller) self.messages = messages - loadAllSessions() } func startPollingForResponse(interval: TimeInterval = 4.0) { @@ -67,13 +63,6 @@ import NextcloudKit } } - private func loadAllSessions() { - Task { - let result = await NextcloudKit.shared.getAssistantChatConversationsAsync(account: ncSession.account) - sessions = result.sessions ?? [] - } - } - private func loadMessages() { guard let sessionId = selectedSession?.id else { return } @@ -99,42 +88,22 @@ import NextcloudKit } func sendMessage(input: String) { - if let selectedSession { - let request = ChatMessageRequest(sessionId: selectedSession.id, role: "human", content: input, timestamp: Int(Date().timeIntervalSince1970 * 1000), firstHumanMessage: messages.isEmpty) - isThinking = true - - Task { - let result = await NextcloudKit.shared.createAssistantChatMessageAsync(messageRequest: request, account: ncSession.account) - if result.error == .success { - guard let chatMessage = result.chatMessage else { return } - messages.append(chatMessage) - - stopPolling() - requestResponse() - } else { - //TODO - } + guard let selectedSession else { return } - } - } else { - Task { - let session = await createNewConversation(title: input) - selectedSession = session - sendMessage(input: input) - } - } - } + let request = ChatMessageRequest(sessionId: selectedSession.id, role: "human", content: input, timestamp: Int(Date().timeIntervalSince1970 * 1000), firstHumanMessage: messages.isEmpty) + isThinking = true - func createNewConversation(title: String? = nil) async -> AssistantSession? { - let timestamp = Int(Date().timeIntervalSince1970) - let result = await NextcloudKit.shared.createAssistantChatConversationAsync(title: title, timestamp: timestamp, account: ncSession.account) - if result.error == .success, let newSession = result.conversation?.session { - sessions.insert(newSession, at: 0) - selectedSession = newSession - return newSession - } else { - hasError = true - return nil + Task { + let result = await NextcloudKit.shared.createAssistantChatMessageAsync(messageRequest: request, account: ncSession.account) + if result.error == .success { + guard let chatMessage = result.chatMessage else { return } + messages.append(chatMessage) + + stopPolling() + requestResponse() + } else { + //TODO + } } } } diff --git a/iOSClient/Assistant/NCAssistant.swift b/iOSClient/Assistant/NCAssistant.swift index dea0b2352c..d8e16252b0 100644 --- a/iOSClient/Assistant/NCAssistant.swift +++ b/iOSClient/Assistant/NCAssistant.swift @@ -9,6 +9,7 @@ import PopupView struct NCAssistant: View { @State var assistantModel: NCAssistantModel @State var chatModel: NCAssistantChatModel + @State var sessionsModel: NCAssistantChatSessionsModel @State var input = "" @Environment(\.presentationMode) var presentationMode @@ -37,7 +38,9 @@ struct NCAssistant: View { } } ToolbarItem(placement: .topBarTrailing) { - NavigationLink(destination: NCAssistantChatSessions(model: $chatModel)) { + NavigationLink(destination: NCAssistantChatSessions(onSessionSelected: { session in + chatModel.selectedSession = session + })) { Image(systemName: "clock.arrow.trianglehead.counterclockwise.rotate.90") .font(Font.system(.body).weight(.light)) .foregroundStyle(Color(NCBrandColor.shared.iconImageColor)) @@ -69,14 +72,16 @@ struct NCAssistant: View { .accentColor(Color(NCBrandColor.shared.iconImageColor)) .environment(assistantModel) .environment(chatModel) + .environment(sessionsModel) } } #Preview { @Previewable @State var chatModel = NCAssistantChatModel(controller: nil) let model = NCAssistantModel(controller: nil) + let sessionsModel = NCAssistantChatSessionsModel(controller: nil) - NCAssistant(assistantModel: model, chatModel: chatModel) + NCAssistant(assistantModel: model, chatModel: chatModel, sessionsModel: sessionsModel) .onAppear { model.loadDummyData() } diff --git a/iOSClient/Main/NCMainNavigationController.swift b/iOSClient/Main/NCMainNavigationController.swift index b93da19b7b..c70cb00c1b 100644 --- a/iOSClient/Main/NCMainNavigationController.swift +++ b/iOSClient/Main/NCMainNavigationController.swift @@ -98,7 +98,7 @@ class NCMainNavigationController: UINavigationController, UINavigationController assistantButton.setImage(UIImage(systemName: "sparkles"), for: .normal) assistantButton.tintColor = NCBrandColor.shared.iconImageColor assistantButton.addAction(UIAction(handler: { _ in - let assistant = NCAssistant(assistantModel: NCAssistantModel(controller: self.controller), chatModel: NCAssistantChatModel(controller: self.controller)) + let assistant = NCAssistant(assistantModel: NCAssistantModel(controller: self.controller), chatModel: NCAssistantChatModel(controller: self.controller), sessionsModel: NCAssistantChatSessionsModel(controller: self.controller)) let hostingController = UIHostingController(rootView: assistant) self.present(hostingController, animated: true, completion: nil) }), for: .touchUpInside) From 8334485d9179be303be72b11212f0485a98960fa Mon Sep 17 00:00:00 2001 From: Milen Pivchev Date: Thu, 5 Feb 2026 14:44:12 +0100 Subject: [PATCH 16/31] WIP Signed-off-by: Milen Pivchev --- .../Chat Sessions/NCAssistantChatSessions.swift | 5 ++--- iOSClient/Assistant/Chat/NCAssistantChat.swift | 14 ++++---------- .../Assistant/Chat/NCAssistantChatModel.swift | 9 +++++++++ iOSClient/Assistant/NCAssistant.swift | 5 ++--- 4 files changed, 17 insertions(+), 16 deletions(-) diff --git a/iOSClient/Assistant/Chat Sessions/NCAssistantChatSessions.swift b/iOSClient/Assistant/Chat Sessions/NCAssistantChatSessions.swift index 8a88cf85eb..9be20ffeda 100644 --- a/iOSClient/Assistant/Chat Sessions/NCAssistantChatSessions.swift +++ b/iOSClient/Assistant/Chat Sessions/NCAssistantChatSessions.swift @@ -7,7 +7,7 @@ import SwiftUI import NextcloudKit struct NCAssistantChatSessions: View { - @Environment(NCAssistantChatSessionsModel.self) var sessionsModel + var sessionsModel: NCAssistantChatSessionsModel var onSessionSelected: (AssistantSession?) -> Void @Environment(\.dismiss) private var dismiss @@ -37,6 +37,5 @@ struct NCAssistantChatSessions: View { } #Preview { - NCAssistantChatSessions(onSessionSelected: { _ in }) - .environment(NCAssistantChatSessionsModel(controller: nil)) + NCAssistantChatSessions(sessionsModel: NCAssistantChatSessionsModel(controller: nil), onSessionSelected: { _ in }) } diff --git a/iOSClient/Assistant/Chat/NCAssistantChat.swift b/iOSClient/Assistant/Chat/NCAssistantChat.swift index 36c504bc6e..69db9c7675 100644 --- a/iOSClient/Assistant/Chat/NCAssistantChat.swift +++ b/iOSClient/Assistant/Chat/NCAssistantChat.swift @@ -7,7 +7,7 @@ import NextcloudKit struct NCAssistantChat: View { @Environment(NCAssistantChatModel.self) var chatModel - @Environment(NCAssistantChatSessionsModel.self) var sessionsModel + var sessionsModel: NCAssistantChatSessionsModel var body: some View { @Bindable var chatModel = chatModel @@ -26,11 +26,7 @@ struct NCAssistantChat: View { if chatModel.selectedSession != nil { chatModel.sendMessage(input: input) } else { - Task { - let session = await sessionsModel.createNewConversation(title: input) - chatModel.selectedSession = session - chatModel.sendMessage(input: input) - } + chatModel.startNewConversation(input: input, sessionsModel: sessionsModel) } } } @@ -197,16 +193,14 @@ struct EmptyChatView: View { #Preview { NavigationStack { - NCAssistantChat() + NCAssistantChat(sessionsModel: NCAssistantChatSessionsModel(controller: nil)) .environment(NCAssistantChatModel(controller: nil)) - .environment(NCAssistantChatSessionsModel(controller: nil)) } } #Preview("With Messages") { NavigationStack { - NCAssistantChat() + NCAssistantChat(sessionsModel: NCAssistantChatSessionsModel(controller: nil)) .environment(NCAssistantChatModel.example) - .environment(NCAssistantChatSessionsModel(controller: nil)) } } diff --git a/iOSClient/Assistant/Chat/NCAssistantChatModel.swift b/iOSClient/Assistant/Chat/NCAssistantChatModel.swift index ceacea6ada..8b465bbdcf 100644 --- a/iOSClient/Assistant/Chat/NCAssistantChatModel.swift +++ b/iOSClient/Assistant/Chat/NCAssistantChatModel.swift @@ -27,6 +27,7 @@ import NextcloudKit @ObservationIgnored var controller: NCMainTabBarController? @ObservationIgnored private var chatResponseTaskId: Int? + init(controller: NCMainTabBarController?, messages: [ChatMessage] = []) { self.controller = controller self.ncSession = NCSession.shared.getSession(controller: controller) @@ -106,6 +107,14 @@ import NextcloudKit } } } + + func startNewConversation(input: String, sessionsModel: NCAssistantChatSessionsModel) { + Task { + let session = await sessionsModel.createNewConversation(title: input) + selectedSession = session + sendMessage(input: input) + } + } } extension NCAssistantChatModel { diff --git a/iOSClient/Assistant/NCAssistant.swift b/iOSClient/Assistant/NCAssistant.swift index d8e16252b0..84cd831787 100644 --- a/iOSClient/Assistant/NCAssistant.swift +++ b/iOSClient/Assistant/NCAssistant.swift @@ -19,7 +19,7 @@ struct NCAssistant: View { if assistantModel.types.isEmpty, !assistantModel.isLoading { NCAssistantEmptyView(titleKey: "_no_types_", subtitleKey: "_no_types_subtitle_") } else if assistantModel.isSelectedTypeChat { - NCAssistantChat() + NCAssistantChat(sessionsModel: sessionsModel) } else { TaskList() } @@ -38,7 +38,7 @@ struct NCAssistant: View { } } ToolbarItem(placement: .topBarTrailing) { - NavigationLink(destination: NCAssistantChatSessions(onSessionSelected: { session in + NavigationLink(destination: NCAssistantChatSessions(sessionsModel: sessionsModel, onSessionSelected: { session in chatModel.selectedSession = session })) { Image(systemName: "clock.arrow.trianglehead.counterclockwise.rotate.90") @@ -72,7 +72,6 @@ struct NCAssistant: View { .accentColor(Color(NCBrandColor.shared.iconImageColor)) .environment(assistantModel) .environment(chatModel) - .environment(sessionsModel) } } From ae375a1ded2cf823f89282eb5664036e9631bb22 Mon Sep 17 00:00:00 2001 From: Milen Pivchev Date: Fri, 6 Feb 2026 16:31:18 +0100 Subject: [PATCH 17/31] WIP Signed-off-by: Milen Pivchev --- Nextcloud.xcodeproj/project.pbxproj | 16 ++-- Tests/NextcloudUITests/AssistantUITests.swift | 2 +- .../NCAssistantChatConversations.swift | 41 ++++++++++ ...> NCAssistantChatConversationsModel.swift} | 14 ++-- .../NCAssistantChatSessions.swift | 41 ---------- .../Assistant/Chat/NCAssistantChat.swift | 37 +++++++--- .../Assistant/Chat/NCAssistantChatModel.swift | 74 +++++++++++-------- .../Assistant/Components/ChatInputField.swift | 4 +- iOSClient/Assistant/NCAssistant.swift | 14 ++-- .../Main/NCMainNavigationController.swift | 2 +- .../en.lproj/Localizable.strings | 3 + 11 files changed, 142 insertions(+), 106 deletions(-) create mode 100644 iOSClient/Assistant/Chat Sessions/NCAssistantChatConversations.swift rename iOSClient/Assistant/Chat Sessions/{NCAssistantChatSessionsModel.swift => NCAssistantChatConversationsModel.swift} (72%) delete mode 100644 iOSClient/Assistant/Chat Sessions/NCAssistantChatSessions.swift diff --git a/Nextcloud.xcodeproj/project.pbxproj b/Nextcloud.xcodeproj/project.pbxproj index 90c3bb2632..51360eef0c 100644 --- a/Nextcloud.xcodeproj/project.pbxproj +++ b/Nextcloud.xcodeproj/project.pbxproj @@ -163,11 +163,11 @@ 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 /* NCAssistantChatSessionsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3C6F6F52F34CC0900C531B6 /* NCAssistantChatSessionsModel.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 /* NCAssistantChatSessions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3DDFE202F1F953000A784C8 /* NCAssistantChatSessions.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 */; }; @@ -1278,12 +1278,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 /* NCAssistantChatSessionsModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCAssistantChatSessionsModel.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 = ""; }; F3DDFE102F16732D00A784C8 /* NextcloudKit */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = NextcloudKit; path = ../NextcloudKit; sourceTree = SOURCE_ROOT; }; F3DDFE1D2F1F8EC600A784C8 /* ChatInputField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInputField.swift; sourceTree = ""; }; - F3DDFE202F1F953000A784C8 /* NCAssistantChatSessions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCAssistantChatSessions.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 = ""; }; @@ -2211,8 +2211,8 @@ F3DDFE1F2F1F951000A784C8 /* Chat Sessions */ = { isa = PBXGroup; children = ( - F3DDFE202F1F953000A784C8 /* NCAssistantChatSessions.swift */, - F3C6F6F52F34CC0900C531B6 /* NCAssistantChatSessionsModel.swift */, + F3DDFE202F1F953000A784C8 /* NCAssistantChatConversations.swift */, + F3C6F6F52F34CC0900C531B6 /* NCAssistantChatConversationsModel.swift */, ); path = "Chat Sessions"; sourceTree = ""; @@ -4458,7 +4458,7 @@ F7AE00F8230E81CB007ACF8A /* NCBrowserWeb.swift in Sources */, F77DD6A82C5CC093009448FB /* NCSession.swift in Sources */, F702F30825EE5D47008F8E80 /* NCPopupViewController.swift in Sources */, - F3C6F6F62F34CC0900C531B6 /* NCAssistantChatSessionsModel.swift in Sources */, + F3C6F6F62F34CC0900C531B6 /* NCAssistantChatConversationsModel.swift in Sources */, F76340FC2EBDF64D0056F538 /* NCManageDatabase+Tag.swift in Sources */, F733598125C1C188002ABA72 /* NCAskAuthorization.swift in Sources */, 370D26AF248A3D7A00121797 /* NCCellProtocol.swift in Sources */, @@ -4745,7 +4745,7 @@ F7A03E2F2D425A14007AA677 /* NCFavoriteNavigationController.swift in Sources */, F343A4BB2A1E734600DDA874 /* Optional+Extension.swift in Sources */, F76882232C0DD1E7001CF441 /* NCCapabilitiesModel.swift in Sources */, - F3DDFE212F1F953000A784C8 /* NCAssistantChatSessions.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 */, diff --git a/Tests/NextcloudUITests/AssistantUITests.swift b/Tests/NextcloudUITests/AssistantUITests.swift index ba5f670fa3..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["SessionsButton"].tap() + app.navigationBars["Assistant"].buttons["ConversationsButton"].tap() let inputTextEditor = app.textViews["InputTextEditor"] inputTextEditor.await() diff --git a/iOSClient/Assistant/Chat Sessions/NCAssistantChatConversations.swift b/iOSClient/Assistant/Chat Sessions/NCAssistantChatConversations.swift new file mode 100644 index 0000000000..5e4a8e8a0e --- /dev/null +++ b/iOSClient/Assistant/Chat Sessions/NCAssistantChatConversations.swift @@ -0,0 +1,41 @@ +// 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 onConversationSelected: (AssistantConversation?) -> Void + @Environment(\.dismiss) private var dismiss + + var body: some View { + Group { + List(conversationsModel.conversations, id: \.id) { conversations in + Text(conversations.validTitle) + .onTapGesture { + onConversationSelected(conversations) + dismiss() + } + } + } + .navigationTitle("_conversations_") + .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), onConversationSelected: { _ in }) +} diff --git a/iOSClient/Assistant/Chat Sessions/NCAssistantChatSessionsModel.swift b/iOSClient/Assistant/Chat Sessions/NCAssistantChatConversationsModel.swift similarity index 72% rename from iOSClient/Assistant/Chat Sessions/NCAssistantChatSessionsModel.swift rename to iOSClient/Assistant/Chat Sessions/NCAssistantChatConversationsModel.swift index fb5bfc728f..6e46c9631f 100644 --- a/iOSClient/Assistant/Chat Sessions/NCAssistantChatSessionsModel.swift +++ b/iOSClient/Assistant/Chat Sessions/NCAssistantChatConversationsModel.swift @@ -5,8 +5,8 @@ import Foundation import NextcloudKit -@Observable class NCAssistantChatSessionsModel { - var sessions: [AssistantSession] = [] +@Observable class NCAssistantChatConversationsModel { + var conversations: [AssistantConversation] = [] var isLoading: Bool = false var hasError: Bool = false @@ -20,16 +20,16 @@ import NextcloudKit func loadAllSessions() { Task { let result = await NextcloudKit.shared.getAssistantChatConversationsAsync(account: ncSession.account) - sessions = result.sessions ?? [] + conversations = result.sessions ?? [] } } - func createNewConversation(title: String? = nil) async -> AssistantSession? { + func createNewConversation(title: String? = nil) async -> AssistantConversation? { let timestamp = Int(Date().timeIntervalSince1970) let result = await NextcloudKit.shared.createAssistantChatConversationAsync(title: title, timestamp: timestamp, account: ncSession.account) - if result.error == .success, let newSession = result.conversation?.session { - sessions.insert(newSession, at: 0) - return newSession + 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 Sessions/NCAssistantChatSessions.swift b/iOSClient/Assistant/Chat Sessions/NCAssistantChatSessions.swift deleted file mode 100644 index 9be20ffeda..0000000000 --- a/iOSClient/Assistant/Chat Sessions/NCAssistantChatSessions.swift +++ /dev/null @@ -1,41 +0,0 @@ -// SPDX-FileCopyrightText: Nextcloud GmbH -// SPDX-FileCopyrightText: 2026 Milen Pivchev -// SPDX-License-Identifier: GPL-3.0-or-later - -import Foundation -import SwiftUI -import NextcloudKit - -struct NCAssistantChatSessions: View { - var sessionsModel: NCAssistantChatSessionsModel - var onSessionSelected: (AssistantSession?) -> Void - @Environment(\.dismiss) private var dismiss - - var body: some View { - Group { - List(sessionsModel.sessions, id: \.id) { session in - Text(session.validTitle) - .onTapGesture { - onSessionSelected(session) - dismiss() - } - } - } - .navigationTitle("Conversations") - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button("New Conversation", systemImage: "plus.message.fill") { - Task { - let session = await sessionsModel.createNewConversation() - onSessionSelected(session) - dismiss() - } - } - } - } - } -} - -#Preview { - NCAssistantChatSessions(sessionsModel: NCAssistantChatSessionsModel(controller: nil), onSessionSelected: { _ in }) -} diff --git a/iOSClient/Assistant/Chat/NCAssistantChat.swift b/iOSClient/Assistant/Chat/NCAssistantChat.swift index 69db9c7675..38e9b76384 100644 --- a/iOSClient/Assistant/Chat/NCAssistantChat.swift +++ b/iOSClient/Assistant/Chat/NCAssistantChat.swift @@ -7,7 +7,7 @@ import NextcloudKit struct NCAssistantChat: View { @Environment(NCAssistantChatModel.self) var chatModel - var sessionsModel: NCAssistantChatSessionsModel + @Binding var conversationsModel: NCAssistantChatConversationsModel var body: some View { @Bindable var chatModel = chatModel @@ -23,18 +23,15 @@ struct NCAssistantChat: View { } .safeAreaInset(edge: .bottom) { ChatInputField(isLoading: $chatModel.isThinking) { input in - if chatModel.selectedSession != nil { + if chatModel.selectedConversation != nil { chatModel.sendMessage(input: input) } else { - chatModel.startNewConversation(input: input, sessionsModel: sessionsModel) + chatModel.startNewConversation(input: input, sessionsModel: conversationsModel) } } } .navigationTitle("Assistant Chat") .navigationBarTitleDisplayMode(.inline) - .onDisappear { - chatModel.stopPolling() - } } private var messageListView: some View { @@ -50,6 +47,22 @@ struct NCAssistantChat: View { ThinkingBubbleView() .id("thinking") } + + if chatModel.showRetryResponseGenerationButton { + let button = Button("_retry_response_generation_") { + chatModel.requestResponse() + } + .frame(maxWidth: .infinity) + .padding() + + if #available(iOS 26.0, *) { + button + .buttonStyle(.glassProminent) + } else { + button + .buttonStyle(.borderedProminent) + } + } } .padding(.vertical) } @@ -83,15 +96,15 @@ struct MessageBubbleView: View { Spacer(minLength: 50) } - VStack(alignment: true ? .trailing : .leading, spacing: 4) { + VStack(alignment: message.isFromHuman ? .trailing : .leading, spacing: 4) { Text(message.content) .font(.body) - .foregroundStyle(true ? .white : .primary) + .foregroundStyle(message.isFromHuman ? .white : .primary) .padding() .background(bubbleBackground) .clipShape(.rect(cornerRadius: 16)) - Text(NCUtility().getRelativeDateTitle(Date(timeIntervalSince1970: TimeInterval(message.timestamp / 1000)))) + Text(NCUtility().getRelativeDateTitle(Date(timeIntervalSince1970: TimeInterval(message.timestamp)))) .font(.caption) .foregroundStyle(.secondary) .padding(.horizontal, 4) @@ -193,14 +206,16 @@ struct EmptyChatView: View { #Preview { NavigationStack { - NCAssistantChat(sessionsModel: NCAssistantChatSessionsModel(controller: nil)) + NCAssistantChat(conversationsModel: NCAssistantChatConversationsModel(controller: nil)) .environment(NCAssistantChatModel(controller: nil)) + .environment(NCAssistantModel(controller: nil)) } } #Preview("With Messages") { NavigationStack { - NCAssistantChat(sessionsModel: NCAssistantChatSessionsModel(controller: nil)) + NCAssistantChat(conversationsModel: NCAssistantChatConversationsModel(controller: nil)) .environment(NCAssistantChatModel.example) + .environment(NCAssistantModel(controller: nil)) } } diff --git a/iOSClient/Assistant/Chat/NCAssistantChatModel.swift b/iOSClient/Assistant/Chat/NCAssistantChatModel.swift index 8b465bbdcf..e4c0b42019 100644 --- a/iOSClient/Assistant/Chat/NCAssistantChatModel.swift +++ b/iOSClient/Assistant/Chat/NCAssistantChatModel.swift @@ -8,19 +8,22 @@ import NextcloudKit var messages: [ChatMessage] = [] var isThinking: Bool = false var hasError: Bool = false - var selectedSession: AssistantSession? { - didSet { - stopPolling() - loadMessages() + var showRetryResponseGenerationButton = false - if messages.last?.isFromHuman == true { - startPollingForResponse() - } + var selectedConversation: AssistantConversation? { + didSet { + onConversationSelected() } } - // A session that has been selected at least once while this screen is showing is added here. - var alreadyOpenedSessions: Set = [] + /// This is true when `sendMessage()` has been called at least once while this conversation is selected. + private var isSelectedConversationAlreadyMessaged: Bool { + guard let selectedConversation else { return false } + return alreadyMessagedConversations.contains(selectedConversation) + } + + /// A conversation that has been messaged to at least once while this screen is showing is added here. + private var alreadyMessagedConversations: Set = [] private let ncSession: NCSession.Session private var pollingTask: Task? @@ -36,13 +39,13 @@ import NextcloudKit func startPollingForResponse(interval: TimeInterval = 4.0) { stopPolling() + isThinking = true + showRetryResponseGenerationButton = false + pollingTask = Task { while !Task.isCancelled { -// if currentChatTaskId == nil { -// requestResponse() -// } - loadLastMessage() + await loadLastMessage() try? await Task.sleep(for: .seconds(interval)) } } @@ -51,10 +54,26 @@ import NextcloudKit func stopPolling() { pollingTask?.cancel() pollingTask = nil + isThinking = false + } + + private func onConversationSelected() { + stopPolling() + + Task { + await loadMessages() + if messages.last?.isFromHuman == true { + if isSelectedConversationAlreadyMessaged { + requestResponse() + } else { + showRetryResponseGenerationButton = true + } + } + } } - private func requestResponse() { - guard let sessionId = selectedSession?.id else { return } + func requestResponse() { + guard let sessionId = selectedConversation?.id else { return } Task { let result = await NextcloudKit.shared.generateAssistantChatSessionAsync(sessionId: sessionId, account: ncSession.account) @@ -64,20 +83,17 @@ import NextcloudKit } } - private func loadMessages() { - guard let sessionId = selectedSession?.id else { return } + private func loadMessages() async { + guard let sessionId = selectedConversation?.id else { return } - Task { - let result = await NextcloudKit.shared.getAssistantChatMessagesAsync(sessionId: sessionId, account: ncSession.account) - messages = result.chatMessage ?? [] - } + let result = await NextcloudKit.shared.getAssistantChatMessagesAsync(sessionId: sessionId, account: ncSession.account) + messages = result.chatMessage ?? [] } - private func loadLastMessage() { + private func loadLastMessage() async { guard let chatResponseTaskId else { return } - Task { - let result = await NextcloudKit.shared.checkAssistantChatGenerationAsync(taskId: chatResponseTaskId, sessionId: selectedSession?.id ?? 0, account: ncSession.account) + let result = await NextcloudKit.shared.checkAssistantChatGenerationAsync(taskId: chatResponseTaskId, sessionId: selectedConversation?.id ?? 0, account: ncSession.account) let lastMessage = result.chatMessage if let lastMessage, lastMessage.role == "assistant" { @@ -85,14 +101,14 @@ import NextcloudKit isThinking = false messages.append(lastMessage) } - } } func sendMessage(input: String) { - guard let selectedSession else { return } + guard let selectedConversation else { return } - let request = ChatMessageRequest(sessionId: selectedSession.id, role: "human", content: input, timestamp: Int(Date().timeIntervalSince1970 * 1000), firstHumanMessage: messages.isEmpty) + let request = ChatMessageRequest(sessionId: selectedConversation.id, role: "human", content: input, timestamp: Int(Date().timeIntervalSince1970 * 1000), firstHumanMessage: messages.isEmpty) isThinking = true + alreadyMessagedConversations.insert(selectedConversation) Task { let result = await NextcloudKit.shared.createAssistantChatMessageAsync(messageRequest: request, account: ncSession.account) @@ -108,10 +124,10 @@ import NextcloudKit } } - func startNewConversation(input: String, sessionsModel: NCAssistantChatSessionsModel) { + func startNewConversation(input: String, sessionsModel: NCAssistantChatConversationsModel) { Task { let session = await sessionsModel.createNewConversation(title: input) - selectedSession = session + selectedConversation = session sendMessage(input: input) } } diff --git a/iOSClient/Assistant/Components/ChatInputField.swift b/iOSClient/Assistant/Components/ChatInputField.swift index 2fde5c1f42..cc179e9062 100644 --- a/iOSClient/Assistant/Components/ChatInputField.swift +++ b/iOSClient/Assistant/Components/ChatInputField.swift @@ -44,7 +44,9 @@ struct ChatInputField: View { } } .padding(.horizontal) - .padding(.vertical, 8) + .padding(.top, 16) + .padding(.bottom, 16) + .background(.background) } } diff --git a/iOSClient/Assistant/NCAssistant.swift b/iOSClient/Assistant/NCAssistant.swift index 84cd831787..f4e8feb0b5 100644 --- a/iOSClient/Assistant/NCAssistant.swift +++ b/iOSClient/Assistant/NCAssistant.swift @@ -9,7 +9,7 @@ import PopupView struct NCAssistant: View { @State var assistantModel: NCAssistantModel @State var chatModel: NCAssistantChatModel - @State var sessionsModel: NCAssistantChatSessionsModel + @State var conversationsModel: NCAssistantChatConversationsModel @State var input = "" @Environment(\.presentationMode) var presentationMode @@ -19,7 +19,7 @@ struct NCAssistant: View { if assistantModel.types.isEmpty, !assistantModel.isLoading { NCAssistantEmptyView(titleKey: "_no_types_", subtitleKey: "_no_types_subtitle_") } else if assistantModel.isSelectedTypeChat { - NCAssistantChat(sessionsModel: sessionsModel) + NCAssistantChat(conversationsModel: $conversationsModel) } else { TaskList() } @@ -38,15 +38,15 @@ struct NCAssistant: View { } } ToolbarItem(placement: .topBarTrailing) { - NavigationLink(destination: NCAssistantChatSessions(sessionsModel: sessionsModel, onSessionSelected: { session in - chatModel.selectedSession = session + NavigationLink(destination: NCAssistantChatConversations(conversationsModel: conversationsModel, onConversationSelected: { conversation in + chatModel.selectedConversation = conversation })) { Image(systemName: "clock.arrow.trianglehead.counterclockwise.rotate.90") .font(Font.system(.body).weight(.light)) .foregroundStyle(Color(NCBrandColor.shared.iconImageColor)) } .disabled(assistantModel.selectedType == nil) - .accessibilityIdentifier("SessionsButton") + .accessibilityIdentifier("ConversationsButton") } } .navigationBarTitleDisplayMode(.inline) @@ -78,9 +78,9 @@ struct NCAssistant: View { #Preview { @Previewable @State var chatModel = NCAssistantChatModel(controller: nil) let model = NCAssistantModel(controller: nil) - let sessionsModel = NCAssistantChatSessionsModel(controller: nil) + let conversationsModel = NCAssistantChatConversationsModel(controller: nil) - NCAssistant(assistantModel: model, chatModel: chatModel, sessionsModel: sessionsModel) + NCAssistant(assistantModel: model, chatModel: chatModel, conversationsModel: conversationsModel) .onAppear { model.loadDummyData() } diff --git a/iOSClient/Main/NCMainNavigationController.swift b/iOSClient/Main/NCMainNavigationController.swift index c70cb00c1b..9867dd5f29 100644 --- a/iOSClient/Main/NCMainNavigationController.swift +++ b/iOSClient/Main/NCMainNavigationController.swift @@ -98,7 +98,7 @@ class NCMainNavigationController: UINavigationController, UINavigationController assistantButton.setImage(UIImage(systemName: "sparkles"), for: .normal) assistantButton.tintColor = NCBrandColor.shared.iconImageColor assistantButton.addAction(UIAction(handler: { _ in - let assistant = NCAssistant(assistantModel: NCAssistantModel(controller: self.controller), chatModel: NCAssistantChatModel(controller: self.controller), sessionsModel: NCAssistantChatSessionsModel(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) }), for: .touchUpInside) diff --git a/iOSClient/Supporting Files/en.lproj/Localizable.strings b/iOSClient/Supporting Files/en.lproj/Localizable.strings index 4b313d1649..dccc004454 100644 --- a/iOSClient/Supporting Files/en.lproj/Localizable.strings +++ b/iOSClient/Supporting Files/en.lproj/Localizable.strings @@ -717,6 +717,9 @@ You can stop it at any time, adjust the settings, and enable it again."; "_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"; // MARK: Client certificate "_no_client_cert_found_" = "The server is requesting a client certificate."; From 1f70fb127fae419f59b904eaf749908953f6587a Mon Sep 17 00:00:00 2001 From: Milen Pivchev Date: Fri, 6 Feb 2026 16:56:26 +0100 Subject: [PATCH 18/31] WIP Signed-off-by: Milen Pivchev --- .../Chat Sessions/NCAssistantChatConversations.swift | 2 +- iOSClient/Assistant/Chat/NCAssistantChat.swift | 4 ++-- iOSClient/Assistant/NCAssistant.swift | 1 + iOSClient/Assistant/NCAssistantModel.swift | 4 ++++ 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/iOSClient/Assistant/Chat Sessions/NCAssistantChatConversations.swift b/iOSClient/Assistant/Chat Sessions/NCAssistantChatConversations.swift index 5e4a8e8a0e..17eae0265f 100644 --- a/iOSClient/Assistant/Chat Sessions/NCAssistantChatConversations.swift +++ b/iOSClient/Assistant/Chat Sessions/NCAssistantChatConversations.swift @@ -21,7 +21,7 @@ struct NCAssistantChatConversations: View { } } } - .navigationTitle("_conversations_") + .navigationTitle(NSLocalizedString("_conversations_", comment: "")) .toolbar { ToolbarItem(placement: .topBarTrailing) { Button("_new_conversation_", systemImage: "plus.message.fill") { diff --git a/iOSClient/Assistant/Chat/NCAssistantChat.swift b/iOSClient/Assistant/Chat/NCAssistantChat.swift index 38e9b76384..0a21a508bb 100644 --- a/iOSClient/Assistant/Chat/NCAssistantChat.swift +++ b/iOSClient/Assistant/Chat/NCAssistantChat.swift @@ -206,7 +206,7 @@ struct EmptyChatView: View { #Preview { NavigationStack { - NCAssistantChat(conversationsModel: NCAssistantChatConversationsModel(controller: nil)) + NCAssistantChat(conversationsModel: .constant(NCAssistantChatConversationsModel(controller: nil))) .environment(NCAssistantChatModel(controller: nil)) .environment(NCAssistantModel(controller: nil)) } @@ -214,7 +214,7 @@ struct EmptyChatView: View { #Preview("With Messages") { NavigationStack { - NCAssistantChat(conversationsModel: NCAssistantChatConversationsModel(controller: nil)) + NCAssistantChat(conversationsModel: .constant(NCAssistantChatConversationsModel(controller: nil))) .environment(NCAssistantChatModel.example) .environment(NCAssistantModel(controller: nil)) } diff --git a/iOSClient/Assistant/NCAssistant.swift b/iOSClient/Assistant/NCAssistant.swift index f4e8feb0b5..94a82c150c 100644 --- a/iOSClient/Assistant/NCAssistant.swift +++ b/iOSClient/Assistant/NCAssistant.swift @@ -40,6 +40,7 @@ struct NCAssistant: View { ToolbarItem(placement: .topBarTrailing) { NavigationLink(destination: NCAssistantChatConversations(conversationsModel: conversationsModel, onConversationSelected: { conversation in chatModel.selectedConversation = conversation + assistantModel.selectChatTaskType() })) { Image(systemName: "clock.arrow.trianglehead.counterclockwise.rotate.90") .font(Font.system(.body).weight(.light)) diff --git a/iOSClient/Assistant/NCAssistantModel.swift b/iOSClient/Assistant/NCAssistantModel.swift index 26127f0834..b41b3acd69 100644 --- a/iOSClient/Assistant/NCAssistantModel.swift +++ b/iOSClient/Assistant/NCAssistantModel.swift @@ -49,6 +49,10 @@ class NCAssistantModel { self.filteredTasks = filteredTasks.sorted(by: { $0.completionExpectedAt ?? 0 > $1.completionExpectedAt ?? 0 }) } + func selectChatTaskType() { + selectTaskType(types.first) + } + func selectTaskType(_ type: TaskTypeData?) { selectedType = type From 53a5820dc33fd7837df4286f547c6c62c141d779 Mon Sep 17 00:00:00 2001 From: Milen Pivchev Date: Fri, 6 Feb 2026 17:05:07 +0100 Subject: [PATCH 19/31] WIP Signed-off-by: Milen Pivchev --- .../Chat Sessions/NCAssistantChatConversations.swift | 11 +++++------ iOSClient/Assistant/NCAssistant.swift | 5 +++++ iOSClient/Assistant/NCAssistantModel.swift | 2 ++ 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/iOSClient/Assistant/Chat Sessions/NCAssistantChatConversations.swift b/iOSClient/Assistant/Chat Sessions/NCAssistantChatConversations.swift index 17eae0265f..b912216135 100644 --- a/iOSClient/Assistant/Chat Sessions/NCAssistantChatConversations.swift +++ b/iOSClient/Assistant/Chat Sessions/NCAssistantChatConversations.swift @@ -13,12 +13,11 @@ struct NCAssistantChatConversations: View { var body: some View { Group { - List(conversationsModel.conversations, id: \.id) { conversations in - Text(conversations.validTitle) - .onTapGesture { - onConversationSelected(conversations) - dismiss() - } + List(conversationsModel.conversations, id: \.id) { conversation in + Button(conversation.validTitle) { + onConversationSelected(conversation) + dismiss() + } } } .navigationTitle(NSLocalizedString("_conversations_", comment: "")) diff --git a/iOSClient/Assistant/NCAssistant.swift b/iOSClient/Assistant/NCAssistant.swift index 94a82c150c..060545cf65 100644 --- a/iOSClient/Assistant/NCAssistant.swift +++ b/iOSClient/Assistant/NCAssistant.swift @@ -273,6 +273,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/NCAssistantModel.swift b/iOSClient/Assistant/NCAssistantModel.swift index b41b3acd69..87711f9511 100644 --- a/iOSClient/Assistant/NCAssistantModel.swift +++ b/iOSClient/Assistant/NCAssistantModel.swift @@ -17,6 +17,7 @@ class NCAssistantModel { 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] = [] @@ -51,6 +52,7 @@ class NCAssistantModel { func selectChatTaskType() { selectTaskType(types.first) + scrollTypeListToTop.toggle() } func selectTaskType(_ type: TaskTypeData?) { From a4d5786dde2ecb16ce155c2fa3e937a4ac04c1a7 Mon Sep 17 00:00:00 2001 From: Milen Pivchev Date: Fri, 6 Feb 2026 17:09:42 +0100 Subject: [PATCH 20/31] WIP Signed-off-by: Milen Pivchev --- .../NCAssistantChatConversations.swift | 16 ++++++++++++++-- iOSClient/Assistant/NCAssistant.swift | 4 ++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/iOSClient/Assistant/Chat Sessions/NCAssistantChatConversations.swift b/iOSClient/Assistant/Chat Sessions/NCAssistantChatConversations.swift index b912216135..ec35033ae7 100644 --- a/iOSClient/Assistant/Chat Sessions/NCAssistantChatConversations.swift +++ b/iOSClient/Assistant/Chat Sessions/NCAssistantChatConversations.swift @@ -8,15 +8,27 @@ 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(conversation.validTitle) { + Button { onConversationSelected(conversation) dismiss() + } label: { + HStack { + Text(conversation.validTitle) + Spacer() + if selectedConversation?.id == conversation.id { + Image(systemName: "checkmark") + .foregroundStyle(.blue) + } + } + .contentShape(Rectangle()) } } } @@ -36,5 +48,5 @@ struct NCAssistantChatConversations: View { } #Preview { - NCAssistantChatConversations(conversationsModel: NCAssistantChatConversationsModel(controller: nil), onConversationSelected: { _ in }) + NCAssistantChatConversations(conversationsModel: NCAssistantChatConversationsModel(controller: nil), selectedConversation: nil, onConversationSelected: { _ in }) } diff --git a/iOSClient/Assistant/NCAssistant.swift b/iOSClient/Assistant/NCAssistant.swift index 060545cf65..ddc3379622 100644 --- a/iOSClient/Assistant/NCAssistant.swift +++ b/iOSClient/Assistant/NCAssistant.swift @@ -38,10 +38,10 @@ struct NCAssistant: View { } } ToolbarItem(placement: .topBarTrailing) { - NavigationLink(destination: NCAssistantChatConversations(conversationsModel: conversationsModel, onConversationSelected: { conversation in + NavigationLink(destination: NCAssistantChatConversations(conversationsModel: conversationsModel, selectedConversation: chatModel.selectedConversation) { conversation in chatModel.selectedConversation = conversation assistantModel.selectChatTaskType() - })) { + }) { Image(systemName: "clock.arrow.trianglehead.counterclockwise.rotate.90") .font(Font.system(.body).weight(.light)) .foregroundStyle(Color(NCBrandColor.shared.iconImageColor)) From 34cc2c71e4e10056112707942460903974cebb52 Mon Sep 17 00:00:00 2001 From: Milen Pivchev Date: Fri, 6 Feb 2026 17:28:21 +0100 Subject: [PATCH 21/31] WIP Signed-off-by: Milen Pivchev --- iOSClient/Assistant/Chat/NCAssistantChat.swift | 10 +++++----- iOSClient/Assistant/NCAssistant.swift | 1 + 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/iOSClient/Assistant/Chat/NCAssistantChat.swift b/iOSClient/Assistant/Chat/NCAssistantChat.swift index 0a21a508bb..581afdf418 100644 --- a/iOSClient/Assistant/Chat/NCAssistantChat.swift +++ b/iOSClient/Assistant/Chat/NCAssistantChat.swift @@ -52,15 +52,15 @@ struct NCAssistantChat: View { let button = Button("_retry_response_generation_") { chatModel.requestResponse() } - .frame(maxWidth: .infinity) - .padding() + .frame(maxWidth: .infinity) + .padding() if #available(iOS 26.0, *) { button - .buttonStyle(.glassProminent) + .buttonStyle(.glass) } else { button - .buttonStyle(.borderedProminent) + .buttonStyle(.bordered) } } } @@ -109,7 +109,7 @@ struct MessageBubbleView: View { .foregroundStyle(.secondary) .padding(.horizontal, 4) } - .frame(maxWidth: .infinity, alignment: true ? .trailing : .leading) + .frame(maxWidth: .infinity, alignment: message.isFromHuman ? .trailing : .leading) .padding(.horizontal) if !message.isFromHuman { diff --git a/iOSClient/Assistant/NCAssistant.swift b/iOSClient/Assistant/NCAssistant.swift index ddc3379622..ae2ebe9b74 100644 --- a/iOSClient/Assistant/NCAssistant.swift +++ b/iOSClient/Assistant/NCAssistant.swift @@ -52,6 +52,7 @@ struct NCAssistant: View { } .navigationBarTitleDisplayMode(.inline) .navigationTitle(NSLocalizedString("_assistant_", comment: "")) +// .navigationSubtitle("Test") .frame(maxWidth: .infinity, maxHeight: .infinity) .safeAreaInset(edge: .top, spacing: -10) { TypeList() From 61b65abc155d3c168051ec45caa64d98173617d5 Mon Sep 17 00:00:00 2001 From: Milen Pivchev Date: Fri, 6 Feb 2026 18:10:54 +0100 Subject: [PATCH 22/31] WIP Signed-off-by: Milen Pivchev --- .../NCAssistantChatConversations.swift | 2 +- iOSClient/Assistant/Chat/NCAssistantChat.swift | 2 +- .../Assistant/Chat/NCAssistantChatModel.swift | 13 ++++++++++--- .../Assistant/Components/ChatInputField.swift | 16 ++++++++++++---- iOSClient/Assistant/NCAssistant.swift | 14 +++++++++++++- .../en.lproj/Localizable.strings | 3 ++- 6 files changed, 39 insertions(+), 11 deletions(-) diff --git a/iOSClient/Assistant/Chat Sessions/NCAssistantChatConversations.swift b/iOSClient/Assistant/Chat Sessions/NCAssistantChatConversations.swift index ec35033ae7..5fd16cf93d 100644 --- a/iOSClient/Assistant/Chat Sessions/NCAssistantChatConversations.swift +++ b/iOSClient/Assistant/Chat Sessions/NCAssistantChatConversations.swift @@ -10,7 +10,7 @@ struct NCAssistantChatConversations: View { var conversationsModel: NCAssistantChatConversationsModel var selectedConversation: AssistantConversation? var onConversationSelected: (AssistantConversation?) -> Void - + @Environment(\.dismiss) private var dismiss var body: some View { diff --git a/iOSClient/Assistant/Chat/NCAssistantChat.swift b/iOSClient/Assistant/Chat/NCAssistantChat.swift index 581afdf418..20888f9002 100644 --- a/iOSClient/Assistant/Chat/NCAssistantChat.swift +++ b/iOSClient/Assistant/Chat/NCAssistantChat.swift @@ -22,7 +22,7 @@ struct NCAssistantChat: View { } } .safeAreaInset(edge: .bottom) { - ChatInputField(isLoading: $chatModel.isThinking) { input in + ChatInputField(isLoading: $chatModel.isSendingDisabled) { input in if chatModel.selectedConversation != nil { chatModel.sendMessage(input: input) } else { diff --git a/iOSClient/Assistant/Chat/NCAssistantChatModel.swift b/iOSClient/Assistant/Chat/NCAssistantChatModel.swift index e4c0b42019..7f6fcfd33e 100644 --- a/iOSClient/Assistant/Chat/NCAssistantChatModel.swift +++ b/iOSClient/Assistant/Chat/NCAssistantChatModel.swift @@ -6,7 +6,9 @@ import NextcloudKit @Observable class NCAssistantChatModel { var messages: [ChatMessage] = [] + var isSending: Bool = false var isThinking: Bool = false + var isSendingDisabled = false var hasError: Bool = false var showRetryResponseGenerationButton = false @@ -30,7 +32,7 @@ import NextcloudKit @ObservationIgnored var controller: NCMainTabBarController? @ObservationIgnored private var chatResponseTaskId: Int? - + init(controller: NCMainTabBarController?, messages: [ChatMessage] = []) { self.controller = controller self.ncSession = NCSession.shared.getSession(controller: controller) @@ -39,6 +41,7 @@ import NextcloudKit func startPollingForResponse(interval: TimeInterval = 4.0) { stopPolling() + isSendingDisabled = true isThinking = true showRetryResponseGenerationButton = false @@ -55,6 +58,7 @@ import NextcloudKit pollingTask?.cancel() pollingTask = nil isThinking = false + isSendingDisabled = false } private func onConversationSelected() { @@ -98,7 +102,6 @@ import NextcloudKit if let lastMessage, lastMessage.role == "assistant" { stopPolling() - isThinking = false messages.append(lastMessage) } } @@ -107,7 +110,8 @@ import NextcloudKit guard let selectedConversation else { return } let request = ChatMessageRequest(sessionId: selectedConversation.id, role: "human", content: input, timestamp: Int(Date().timeIntervalSince1970 * 1000), firstHumanMessage: messages.isEmpty) - isThinking = true + isSending = true + isSendingDisabled = true alreadyMessagedConversations.insert(selectedConversation) Task { @@ -121,6 +125,9 @@ import NextcloudKit } else { //TODO } + + isSending = false + isSendingDisabled = false } } diff --git a/iOSClient/Assistant/Components/ChatInputField.swift b/iOSClient/Assistant/Components/ChatInputField.swift index cc179e9062..907d06e339 100644 --- a/iOSClient/Assistant/Components/ChatInputField.swift +++ b/iOSClient/Assistant/Components/ChatInputField.swift @@ -8,10 +8,12 @@ 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), onSend: ((_: String) -> Void)? = nil) { + init(isLoading: Binding = .constant(false), isDisabled: Binding = .constant(false), onSend: ((_: String) -> Void)? = nil) { _isLoading = isLoading + _isDisabled = isDisabled self.onSend = onSend } @@ -37,10 +39,15 @@ struct ChatInputField: View { onSend?(text.trimmingCharacters(in: .whitespaces)) text = "" }) { - Image(systemName: "arrow.up.circle.fill") - .font(.system(size: 28)) + 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 || isLoading) + .disabled(text.trimmingCharacters(in: .whitespaces).isEmpty || isDisabled || isLoading) } } .padding(.horizontal) @@ -52,4 +59,5 @@ struct ChatInputField: View { #Preview { ChatInputField(isLoading: .constant(false)) + ChatInputField(isLoading: .constant(true)) } diff --git a/iOSClient/Assistant/NCAssistant.swift b/iOSClient/Assistant/NCAssistant.swift index ae2ebe9b74..0df64cee87 100644 --- a/iOSClient/Assistant/NCAssistant.swift +++ b/iOSClient/Assistant/NCAssistant.swift @@ -52,7 +52,7 @@ struct NCAssistant: View { } .navigationBarTitleDisplayMode(.inline) .navigationTitle(NSLocalizedString("_assistant_", comment: "")) -// .navigationSubtitle("Test") + .modifier(NavigationSubtitleModifier(subtitle: assistantModel.isSelectedTypeChat ? chatModel.selectedConversation?.validTitle : "")) .frame(maxWidth: .infinity, maxHeight: .infinity) .safeAreaInset(edge: .top, spacing: -10) { TypeList() @@ -259,6 +259,18 @@ 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 { @Environment(NCAssistantModel.self) var model diff --git a/iOSClient/Supporting Files/en.lproj/Localizable.strings b/iOSClient/Supporting Files/en.lproj/Localizable.strings index dccc004454..f8483494a8 100644 --- a/iOSClient/Supporting Files/en.lproj/Localizable.strings +++ b/iOSClient/Supporting Files/en.lproj/Localizable.strings @@ -719,7 +719,8 @@ You can stop it at any time, adjust the settings, and enable it again."; "_no_chat_subtitle_" = "Try sending a message to spark a conversation."; "_retry_response_generation_" = "Retry response generation"; "_conversations_" = "Conversations"; -"_new_conversation_" = "New conversation"; +"_new_conversation_" = "New conversation"; +"_type_message_" = "Type a message..."; // MARK: Client certificate "_no_client_cert_found_" = "The server is requesting a client certificate."; From 9c3a97373aafbd8eb345de54bc0b5588ff15a997 Mon Sep 17 00:00:00 2001 From: Milen Pivchev Date: Mon, 9 Feb 2026 12:02:08 +0100 Subject: [PATCH 23/31] WIP Signed-off-by: Milen Pivchev --- .../NCAssistantChatConversationsModel.swift | 4 +- .../Assistant/Chat/NCAssistantChatModel.swift | 25 ++--- iOSClient/Assistant/NCAssistantModel.swift | 95 ++++++++++--------- 3 files changed, 65 insertions(+), 59 deletions(-) diff --git a/iOSClient/Assistant/Chat Sessions/NCAssistantChatConversationsModel.swift b/iOSClient/Assistant/Chat Sessions/NCAssistantChatConversationsModel.swift index 6e46c9631f..4ad1bb1d70 100644 --- a/iOSClient/Assistant/Chat Sessions/NCAssistantChatConversationsModel.swift +++ b/iOSClient/Assistant/Chat Sessions/NCAssistantChatConversationsModel.swift @@ -19,14 +19,14 @@ import NextcloudKit func loadAllSessions() { Task { - let result = await NextcloudKit.shared.getAssistantChatConversationsAsync(account: ncSession.account) + 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.createAssistantChatConversationAsync(title: title, timestamp: timestamp, account: ncSession.account) + 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 diff --git a/iOSClient/Assistant/Chat/NCAssistantChatModel.swift b/iOSClient/Assistant/Chat/NCAssistantChatModel.swift index 7f6fcfd33e..cfbf8b9d51 100644 --- a/iOSClient/Assistant/Chat/NCAssistantChatModel.swift +++ b/iOSClient/Assistant/Chat/NCAssistantChatModel.swift @@ -63,6 +63,7 @@ import NextcloudKit private func onConversationSelected() { stopPolling() + showRetryResponseGenerationButton = false Task { await loadMessages() @@ -80,7 +81,7 @@ import NextcloudKit guard let sessionId = selectedConversation?.id else { return } Task { - let result = await NextcloudKit.shared.generateAssistantChatSessionAsync(sessionId: sessionId, account: ncSession.account) + let result = await NextcloudKit.shared.generateAssistantChatSession(sessionId: sessionId, account: ncSession.account) chatResponseTaskId = result.sessionTask?.taskId startPollingForResponse() @@ -90,32 +91,32 @@ import NextcloudKit private func loadMessages() async { guard let sessionId = selectedConversation?.id else { return } - let result = await NextcloudKit.shared.getAssistantChatMessagesAsync(sessionId: sessionId, account: ncSession.account) - messages = result.chatMessage ?? [] + let result = await NextcloudKit.shared.getAssistantChatMessages(sessionId: sessionId, account: ncSession.account) + messages = result.chatMessages ?? [] } private func loadLastMessage() async { guard let chatResponseTaskId else { return } - - let result = await NextcloudKit.shared.checkAssistantChatGenerationAsync(taskId: chatResponseTaskId, sessionId: selectedConversation?.id ?? 0, account: ncSession.account) - let lastMessage = result.chatMessage - if let lastMessage, lastMessage.role == "assistant" { - stopPolling() - messages.append(lastMessage) - } + let result = await NextcloudKit.shared.checkAssistantChatGeneration(taskId: chatResponseTaskId, sessionId: selectedConversation?.id ?? 0, account: ncSession.account) + let lastMessage = result.chatMessage + + if let lastMessage, lastMessage.role == "assistant" { + stopPolling() + messages.append(lastMessage) + } } func sendMessage(input: String) { guard let selectedConversation else { return } - let request = ChatMessageRequest(sessionId: selectedConversation.id, role: "human", content: input, timestamp: Int(Date().timeIntervalSince1970 * 1000), firstHumanMessage: messages.isEmpty) + let request = ChatMessageRequest(sessionId: selectedConversation.id, role: "human", content: input, timestamp: Int(Date().timeIntervalSince1970), firstHumanMessage: messages.isEmpty) isSending = true isSendingDisabled = true alreadyMessagedConversations.insert(selectedConversation) Task { - let result = await NextcloudKit.shared.createAssistantChatMessageAsync(messageRequest: request, account: ncSession.account) + 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) diff --git a/iOSClient/Assistant/NCAssistantModel.swift b/iOSClient/Assistant/NCAssistantModel.swift index 87711f9511..3a6640e376 100644 --- a/iOSClient/Assistant/NCAssistantModel.swift +++ b/iOSClient/Assistant/NCAssistantModel.swift @@ -66,15 +66,16 @@ class NCAssistantModel { 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) + } } } @@ -93,15 +94,16 @@ class NCAssistantModel { 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) + } } } @@ -123,13 +125,14 @@ class NCAssistantModel { 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) + } } } @@ -154,16 +157,17 @@ class NCAssistantModel { 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) + } } } @@ -190,15 +194,16 @@ class NCAssistantModel { 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) + } } } @@ -232,7 +237,7 @@ extension NCAssistantModel { 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: "5", name: "Free Prompt", description: "", inputShape: nil, outputShape: nil), + TaskTypeData(id: "5", name: "Free Prompt", description: "", inputShape: nil, outputShape: nil) ] self.tasks = tasks From d958fc67244a52319e5a96a62bbc477419838610 Mon Sep 17 00:00:00 2001 From: Milen Pivchev Date: Mon, 9 Feb 2026 14:35:54 +0100 Subject: [PATCH 24/31] WIP Signed-off-by: Milen Pivchev --- .../Assistant/Chat/NCAssistantChat.swift | 2 +- .../Assistant/Chat/NCAssistantChatModel.swift | 68 ++++++++++++------- iOSClient/Assistant/NCAssistant.swift | 7 +- 3 files changed, 51 insertions(+), 26 deletions(-) diff --git a/iOSClient/Assistant/Chat/NCAssistantChat.swift b/iOSClient/Assistant/Chat/NCAssistantChat.swift index 20888f9002..71932cc962 100644 --- a/iOSClient/Assistant/Chat/NCAssistantChat.swift +++ b/iOSClient/Assistant/Chat/NCAssistantChat.swift @@ -50,7 +50,7 @@ struct NCAssistantChat: View { if chatModel.showRetryResponseGenerationButton { let button = Button("_retry_response_generation_") { - chatModel.requestResponse() + chatModel.onRetryResponseGeneration() } .frame(maxWidth: .infinity) .padding() diff --git a/iOSClient/Assistant/Chat/NCAssistantChatModel.swift b/iOSClient/Assistant/Chat/NCAssistantChatModel.swift index cfbf8b9d51..18b0c0e836 100644 --- a/iOSClient/Assistant/Chat/NCAssistantChatModel.swift +++ b/iOSClient/Assistant/Chat/NCAssistantChatModel.swift @@ -18,20 +18,22 @@ import NextcloudKit } } - /// This is true when `sendMessage()` has been called at least once while this conversation is selected. - private var isSelectedConversationAlreadyMessaged: Bool { - guard let selectedConversation else { return false } - return alreadyMessagedConversations.contains(selectedConversation) - } + var currentSession: AssistantSession? + + // /// This is true when `sendMessage()` has been called at least once while this conversation is selected. + // private var isSelectedConversationAlreadyMessaged: Bool { + // guard let selectedConversation else { return false } + // return alreadyMessagedConversations.contains(selectedConversation) + // } - /// A conversation that has been messaged to at least once while this screen is showing is added here. - private var alreadyMessagedConversations: Set = [] + // /// A conversation that has been messaged to at least once while this screen is showing is added here. + // private var alreadyMessagedConversations: Set = [] private let ncSession: NCSession.Session private var pollingTask: Task? @ObservationIgnored var controller: NCMainTabBarController? - @ObservationIgnored private var chatResponseTaskId: Int? + @ObservationIgnored private var chatMessageTaskId: Int? init(controller: NCMainTabBarController?, messages: [ChatMessage] = []) { self.controller = controller @@ -62,33 +64,50 @@ import NextcloudKit } private func onConversationSelected() { + guard let selectedConversation else { return } + stopPolling() showRetryResponseGenerationButton = false + currentSession = nil Task { - await loadMessages() - if messages.last?.isFromHuman == true { - if isSelectedConversationAlreadyMessaged { - requestResponse() - } else { - showRetryResponseGenerationButton = true - } + await loadAllMessages() + currentSession = await checkChatSession(sessionId: selectedConversation.id) + chatMessageTaskId = currentSession?.messageTaskId + + if messages.last?.isFromHuman == true, chatMessageTaskId == nil { + //// if isSelectedConversationAlreadyMessaged { + //// generateChatSession() + //// } else { + showRetryResponseGenerationButton = true + //// } + } else if chatMessageTaskId != nil { + startPollingForResponse() } + } } - func requestResponse() { + func generateChatSession() { guard let sessionId = selectedConversation?.id else { return } Task { let result = await NextcloudKit.shared.generateAssistantChatSession(sessionId: sessionId, account: ncSession.account) - chatResponseTaskId = result.sessionTask?.taskId - - startPollingForResponse() + chatMessageTaskId = result.sessionTask?.taskId } } - private func loadMessages() async { + func onRetryResponseGeneration() { + 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) @@ -96,9 +115,9 @@ import NextcloudKit } private func loadLastMessage() async { - guard let chatResponseTaskId else { return } + guard let chatMessageTaskId else { return } - let result = await NextcloudKit.shared.checkAssistantChatGeneration(taskId: chatResponseTaskId, sessionId: selectedConversation?.id ?? 0, account: ncSession.account) + let result = await NextcloudKit.shared.checkAssistantChatGeneration(taskId: chatMessageTaskId, sessionId: selectedConversation?.id ?? 0, account: ncSession.account) let lastMessage = result.chatMessage if let lastMessage, lastMessage.role == "assistant" { @@ -113,7 +132,7 @@ import NextcloudKit let request = ChatMessageRequest(sessionId: selectedConversation.id, role: "human", content: input, timestamp: Int(Date().timeIntervalSince1970), firstHumanMessage: messages.isEmpty) isSending = true isSendingDisabled = true - alreadyMessagedConversations.insert(selectedConversation) + // alreadyMessagedConversations.insert(selectedConversation) Task { let result = await NextcloudKit.shared.createAssistantChatMessage(messageRequest: request, account: ncSession.account) @@ -122,7 +141,8 @@ import NextcloudKit messages.append(chatMessage) stopPolling() - requestResponse() + generateChatSession() + startPollingForResponse() } else { //TODO } diff --git a/iOSClient/Assistant/NCAssistant.swift b/iOSClient/Assistant/NCAssistant.swift index 0df64cee87..5479917e55 100644 --- a/iOSClient/Assistant/NCAssistant.swift +++ b/iOSClient/Assistant/NCAssistant.swift @@ -52,7 +52,9 @@ struct NCAssistant: View { } .navigationBarTitleDisplayMode(.inline) .navigationTitle(NSLocalizedString("_assistant_", comment: "")) - .modifier(NavigationSubtitleModifier(subtitle: assistantModel.isSelectedTypeChat ? chatModel.selectedConversation?.validTitle : "")) + .modifier(NavigationSubtitleModifier(subtitle: assistantModel.isSelectedTypeChat ? + chatModel.currentSession?.sessionTitle ?? chatModel.selectedConversation?.validTitle + : "")) .frame(maxWidth: .infinity, maxHeight: .infinity) .safeAreaInset(edge: .top, spacing: -10) { TypeList() @@ -74,6 +76,9 @@ struct NCAssistant: View { .accentColor(Color(NCBrandColor.shared.iconImageColor)) .environment(assistantModel) .environment(chatModel) + .onDisappear { + chatModel.stopPolling() + } } } From 896da8776fd548edbaaf1b9eeed709f3374acbab Mon Sep 17 00:00:00 2001 From: Milen Pivchev Date: Mon, 9 Feb 2026 14:59:34 +0100 Subject: [PATCH 25/31] WIP Signed-off-by: Milen Pivchev --- iOSClient/Assistant/Chat/NCAssistantChat.swift | 2 +- .../Assistant/Chat/NCAssistantChatModel.swift | 15 ++------------- 2 files changed, 3 insertions(+), 14 deletions(-) diff --git a/iOSClient/Assistant/Chat/NCAssistantChat.swift b/iOSClient/Assistant/Chat/NCAssistantChat.swift index 71932cc962..c40c9ae4de 100644 --- a/iOSClient/Assistant/Chat/NCAssistantChat.swift +++ b/iOSClient/Assistant/Chat/NCAssistantChat.swift @@ -22,7 +22,7 @@ struct NCAssistantChat: View { } } .safeAreaInset(edge: .bottom) { - ChatInputField(isLoading: $chatModel.isSendingDisabled) { input in + ChatInputField(isLoading: $chatModel.isSending, isDisabled: $chatModel.isSendingDisabled) { input in if chatModel.selectedConversation != nil { chatModel.sendMessage(input: input) } else { diff --git a/iOSClient/Assistant/Chat/NCAssistantChatModel.swift b/iOSClient/Assistant/Chat/NCAssistantChatModel.swift index 18b0c0e836..7f86e8291d 100644 --- a/iOSClient/Assistant/Chat/NCAssistantChatModel.swift +++ b/iOSClient/Assistant/Chat/NCAssistantChatModel.swift @@ -20,15 +20,6 @@ import NextcloudKit var currentSession: AssistantSession? - // /// This is true when `sendMessage()` has been called at least once while this conversation is selected. - // private var isSelectedConversationAlreadyMessaged: Bool { - // guard let selectedConversation else { return false } - // return alreadyMessagedConversations.contains(selectedConversation) - // } - - // /// A conversation that has been messaged to at least once while this screen is showing is added here. - // private var alreadyMessagedConversations: Set = [] - private let ncSession: NCSession.Session private var pollingTask: Task? @@ -75,7 +66,7 @@ import NextcloudKit currentSession = await checkChatSession(sessionId: selectedConversation.id) chatMessageTaskId = currentSession?.messageTaskId - if messages.last?.isFromHuman == true, chatMessageTaskId == nil { + if messages.last?.isFromHuman == true, chatMessageTaskId == nil, isSending == false { //// if isSelectedConversationAlreadyMessaged { //// generateChatSession() //// } else { @@ -132,7 +123,6 @@ import NextcloudKit let request = ChatMessageRequest(sessionId: selectedConversation.id, role: "human", content: input, timestamp: Int(Date().timeIntervalSince1970), firstHumanMessage: messages.isEmpty) isSending = true isSendingDisabled = true - // alreadyMessagedConversations.insert(selectedConversation) Task { let result = await NextcloudKit.shared.createAssistantChatMessage(messageRequest: request, account: ncSession.account) @@ -148,15 +138,14 @@ import NextcloudKit } isSending = false - isSendingDisabled = false } } func startNewConversation(input: String, sessionsModel: NCAssistantChatConversationsModel) { Task { let session = await sessionsModel.createNewConversation(title: input) - selectedConversation = session sendMessage(input: input) + selectedConversation = session } } } From 16da8f52c010fab420a02a54f84753d2575c9878 Mon Sep 17 00:00:00 2001 From: Milen Pivchev Date: Mon, 9 Feb 2026 15:09:49 +0100 Subject: [PATCH 26/31] WIP Signed-off-by: Milen Pivchev --- .../Assistant/Chat/NCAssistantChat.swift | 2 +- .../Assistant/Chat/NCAssistantChatModel.swift | 24 ++++++------------- iOSClient/Assistant/NCAssistant.swift | 8 +++++-- 3 files changed, 14 insertions(+), 20 deletions(-) diff --git a/iOSClient/Assistant/Chat/NCAssistantChat.swift b/iOSClient/Assistant/Chat/NCAssistantChat.swift index c40c9ae4de..6c182b9778 100644 --- a/iOSClient/Assistant/Chat/NCAssistantChat.swift +++ b/iOSClient/Assistant/Chat/NCAssistantChat.swift @@ -26,7 +26,7 @@ struct NCAssistantChat: View { if chatModel.selectedConversation != nil { chatModel.sendMessage(input: input) } else { - chatModel.startNewConversation(input: input, sessionsModel: conversationsModel) + chatModel.startNewConversationViaMessage(input: input, sessionsModel: conversationsModel) } } } diff --git a/iOSClient/Assistant/Chat/NCAssistantChatModel.swift b/iOSClient/Assistant/Chat/NCAssistantChatModel.swift index 7f86e8291d..c912087018 100644 --- a/iOSClient/Assistant/Chat/NCAssistantChatModel.swift +++ b/iOSClient/Assistant/Chat/NCAssistantChatModel.swift @@ -12,11 +12,7 @@ import NextcloudKit var hasError: Bool = false var showRetryResponseGenerationButton = false - var selectedConversation: AssistantConversation? { - didSet { - onConversationSelected() - } - } + public private(set) var selectedConversation: AssistantConversation? var currentSession: AssistantSession? @@ -54,29 +50,22 @@ import NextcloudKit isSendingDisabled = false } - private func onConversationSelected() { - guard let selectedConversation else { return } + func selectConversation(selectedConversation: AssistantConversation) async { + self.selectedConversation = selectedConversation stopPolling() showRetryResponseGenerationButton = false currentSession = nil - Task { await loadAllMessages() currentSession = await checkChatSession(sessionId: selectedConversation.id) chatMessageTaskId = currentSession?.messageTaskId if messages.last?.isFromHuman == true, chatMessageTaskId == nil, isSending == false { - //// if isSelectedConversationAlreadyMessaged { - //// generateChatSession() - //// } else { showRetryResponseGenerationButton = true - //// } } else if chatMessageTaskId != nil { startPollingForResponse() } - - } } func generateChatSession() { @@ -141,11 +130,12 @@ import NextcloudKit } } - func startNewConversation(input: String, sessionsModel: NCAssistantChatConversationsModel) { + func startNewConversationViaMessage(input: String, sessionsModel: NCAssistantChatConversationsModel) { Task { - let session = await sessionsModel.createNewConversation(title: input) + isSending = true + guard let conversation = await sessionsModel.createNewConversation(title: input) else { return } + await selectConversation(selectedConversation: conversation) sendMessage(input: input) - selectedConversation = session } } } diff --git a/iOSClient/Assistant/NCAssistant.swift b/iOSClient/Assistant/NCAssistant.swift index 5479917e55..07dea8b63d 100644 --- a/iOSClient/Assistant/NCAssistant.swift +++ b/iOSClient/Assistant/NCAssistant.swift @@ -39,8 +39,12 @@ struct NCAssistant: View { } ToolbarItem(placement: .topBarTrailing) { NavigationLink(destination: NCAssistantChatConversations(conversationsModel: conversationsModel, selectedConversation: chatModel.selectedConversation) { conversation in - chatModel.selectedConversation = conversation - assistantModel.selectChatTaskType() + 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)) From 3634a53b29b6b5579cc693be545b1c8c08054a6f Mon Sep 17 00:00:00 2001 From: Milen Pivchev Date: Mon, 9 Feb 2026 17:24:10 +0100 Subject: [PATCH 27/31] WIP Signed-off-by: Milen Pivchev --- .../Assistant/Chat/NCAssistantChatModel.swift | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/iOSClient/Assistant/Chat/NCAssistantChatModel.swift b/iOSClient/Assistant/Chat/NCAssistantChatModel.swift index c912087018..be9e4357e2 100644 --- a/iOSClient/Assistant/Chat/NCAssistantChatModel.swift +++ b/iOSClient/Assistant/Chat/NCAssistantChatModel.swift @@ -68,18 +68,18 @@ import NextcloudKit } } - func generateChatSession() { + func generateChatSession() async { guard let sessionId = selectedConversation?.id else { return } - Task { - let result = await NextcloudKit.shared.generateAssistantChatSession(sessionId: sessionId, account: ncSession.account) - chatMessageTaskId = result.sessionTask?.taskId - } + let result = await NextcloudKit.shared.generateAssistantChatSession(sessionId: sessionId, account: ncSession.account) + chatMessageTaskId = result.sessionTask?.taskId } func onRetryResponseGeneration() { - generateChatSession() - startPollingForResponse() + Task { + await generateChatSession() + startPollingForResponse() + } } private func checkChatSession(sessionId: Int) async -> AssistantSession? { @@ -120,7 +120,7 @@ import NextcloudKit messages.append(chatMessage) stopPolling() - generateChatSession() + await generateChatSession() startPollingForResponse() } else { //TODO From 2f961609700b1a0615691e8db62bfd028114142a Mon Sep 17 00:00:00 2001 From: Milen Pivchev Date: Mon, 9 Feb 2026 17:27:58 +0100 Subject: [PATCH 28/31] NCKit Signed-off-by: Milen Pivchev --- Nextcloud.xcodeproj/project.pbxproj | 2 -- 1 file changed, 2 deletions(-) diff --git a/Nextcloud.xcodeproj/project.pbxproj b/Nextcloud.xcodeproj/project.pbxproj index c903eb669d..41e4e5a2ff 100644 --- a/Nextcloud.xcodeproj/project.pbxproj +++ b/Nextcloud.xcodeproj/project.pbxproj @@ -1279,7 +1279,6 @@ 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 = ""; }; - F3DDFE102F16732D00A784C8 /* NextcloudKit */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = NextcloudKit; path = ../NextcloudKit; sourceTree = SOURCE_ROOT; }; 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 = ""; }; @@ -3263,7 +3262,6 @@ C04E2F202A17BB4D001BAD85 /* NextcloudIntegrationTests.xctest */, C0046CDA2A17B98400D87C9D /* NextcloudUITests.xctest */, F7F1FBA62E27D13700C79E20 /* Frameworks */, - F3DDFE102F16732D00A784C8 /* NextcloudKit */, ); sourceTree = ""; }; From f8ff12fefc9c33abb50e3e50bf62dd67488263e9 Mon Sep 17 00:00:00 2001 From: Milen Pivchev Date: Thu, 12 Feb 2026 18:09:23 +0100 Subject: [PATCH 29/31] Refactor Signed-off-by: Milen Pivchev --- iOSClient/Assistant/Chat/NCAssistantChat.swift | 4 ++-- .../Assistant/Chat/NCAssistantChatModel.swift | 14 +++++++------- .../Supporting Files/en.lproj/Localizable.strings | 1 + 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/iOSClient/Assistant/Chat/NCAssistantChat.swift b/iOSClient/Assistant/Chat/NCAssistantChat.swift index 6c182b9778..f7abdaa15a 100644 --- a/iOSClient/Assistant/Chat/NCAssistantChat.swift +++ b/iOSClient/Assistant/Chat/NCAssistantChat.swift @@ -30,7 +30,7 @@ struct NCAssistantChat: View { } } } - .navigationTitle("Assistant Chat") + .navigationTitle("_assistant_chat_") .navigationBarTitleDisplayMode(.inline) } @@ -87,7 +87,7 @@ struct NCAssistantChat: View { // MARK: - Message Bubble View struct MessageBubbleView: View { - let message: ChatMessage + let message: AssistantChatMessage let account: String var body: some View { diff --git a/iOSClient/Assistant/Chat/NCAssistantChatModel.swift b/iOSClient/Assistant/Chat/NCAssistantChatModel.swift index be9e4357e2..f66f858998 100644 --- a/iOSClient/Assistant/Chat/NCAssistantChatModel.swift +++ b/iOSClient/Assistant/Chat/NCAssistantChatModel.swift @@ -5,7 +5,7 @@ import NextcloudKit @Observable class NCAssistantChatModel { - var messages: [ChatMessage] = [] + var messages: [AssistantChatMessage] = [] var isSending: Bool = false var isThinking: Bool = false var isSendingDisabled = false @@ -22,7 +22,7 @@ import NextcloudKit @ObservationIgnored var controller: NCMainTabBarController? @ObservationIgnored private var chatMessageTaskId: Int? - init(controller: NCMainTabBarController?, messages: [ChatMessage] = []) { + init(controller: NCMainTabBarController?, messages: [AssistantChatMessage] = []) { self.controller = controller self.ncSession = NCSession.shared.getSession(controller: controller) self.messages = messages @@ -109,7 +109,7 @@ import NextcloudKit func sendMessage(input: String) { guard let selectedConversation else { return } - let request = ChatMessageRequest(sessionId: selectedConversation.id, role: "human", content: input, timestamp: Int(Date().timeIntervalSince1970), firstHumanMessage: messages.isEmpty) + let request = AssistantChatMessageRequest(sessionId: selectedConversation.id, role: "human", content: input, timestamp: Int(Date().timeIntervalSince1970), firstHumanMessage: messages.isEmpty) isSending = true isSendingDisabled = true @@ -142,28 +142,28 @@ import NextcloudKit extension NCAssistantChatModel { static var example = NCAssistantChatModel(controller: nil, messages: [ - ChatMessage( + AssistantChatMessage( id: 1, sessionId: 0, role: "human", content: "Hello! Can you help me summarize this document?", timestamp: Int(Date().addingTimeInterval(-300).timeIntervalSince1970 * 1000) ), - ChatMessage( + 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) ), - ChatMessage( + 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) ), - ChatMessage( + AssistantChatMessage( id: 4, sessionId: 0, role: "assistant", diff --git a/iOSClient/Supporting Files/en.lproj/Localizable.strings b/iOSClient/Supporting Files/en.lproj/Localizable.strings index 26c0951416..bbd7081862 100644 --- a/iOSClient/Supporting Files/en.lproj/Localizable.strings +++ b/iOSClient/Supporting Files/en.lproj/Localizable.strings @@ -722,6 +722,7 @@ You can stop it at any time, adjust the settings, and enable it again."; "_conversations_" = "Conversations"; "_new_conversation_" = "New conversation"; "_type_message_" = "Type a message..."; +"_assistant_chat_" = "Assistant Chat"; // MARK: Client certificate "_no_client_cert_found_" = "The server is requesting a client certificate."; From 020bce8bacbd988a05206467d0437d9e5900a8ab Mon Sep 17 00:00:00 2001 From: Milen Pivchev Date: Fri, 13 Feb 2026 16:28:20 +0100 Subject: [PATCH 30/31] Add error handling Signed-off-by: Milen Pivchev --- .../Assistant/Chat/NCAssistantChat.swift | 5 +++-- .../Assistant/Chat/NCAssistantChatModel.swift | 19 +++++++++++++++---- .../en.lproj/Localizable.strings | 4 +++- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/iOSClient/Assistant/Chat/NCAssistantChat.swift b/iOSClient/Assistant/Chat/NCAssistantChat.swift index f7abdaa15a..184754418f 100644 --- a/iOSClient/Assistant/Chat/NCAssistantChat.swift +++ b/iOSClient/Assistant/Chat/NCAssistantChat.swift @@ -15,11 +15,12 @@ struct NCAssistantChat: View { 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 @@ -30,7 +31,7 @@ struct NCAssistantChat: View { } } } - .navigationTitle("_assistant_chat_") + .navigationTitle(NSLocalizedString("_assistant_chat_", comment: "")) .navigationBarTitleDisplayMode(.inline) } diff --git a/iOSClient/Assistant/Chat/NCAssistantChatModel.swift b/iOSClient/Assistant/Chat/NCAssistantChatModel.swift index f66f858998..140f50870d 100644 --- a/iOSClient/Assistant/Chat/NCAssistantChatModel.swift +++ b/iOSClient/Assistant/Chat/NCAssistantChatModel.swift @@ -11,6 +11,7 @@ import NextcloudKit var isSendingDisabled = false var hasError: Bool = false var showRetryResponseGenerationButton = false + var showMessageNotSentError: Bool = false public private(set) var selectedConversation: AssistantConversation? @@ -91,16 +92,26 @@ import NextcloudKit guard let sessionId = selectedConversation?.id else { return } let result = await NextcloudKit.shared.getAssistantChatMessages(sessionId: sessionId, account: ncSession.account) - messages = result.chatMessages ?? [] + + 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) - let lastMessage = result.chatMessage - if let lastMessage, lastMessage.role == "assistant" { + 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) } @@ -123,7 +134,7 @@ import NextcloudKit await generateChatSession() startPollingForResponse() } else { - //TODO + await showErrorBanner(controller: controller, title: "_error_", text: "_assistant_error_send_message_", errorCode: 20) } isSending = false diff --git a/iOSClient/Supporting Files/en.lproj/Localizable.strings b/iOSClient/Supporting Files/en.lproj/Localizable.strings index bbd7081862..6da63ea7bd 100644 --- a/iOSClient/Supporting Files/en.lproj/Localizable.strings +++ b/iOSClient/Supporting Files/en.lproj/Localizable.strings @@ -723,7 +723,9 @@ You can stop it at any time, adjust the settings, and enable it again."; "_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."; From 558fad209ec6fcac57514b9c1348f1ca61bfff81 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Sun, 15 Feb 2026 13:56:32 +0100 Subject: [PATCH 31/31] fix assistantButtonItem Signed-off-by: Marino Faggiana --- iOSClient/Main/NCMainNavigationController.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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) })