From 29ae6afe7130bae9bb950e4c462ae0f27584dfbe Mon Sep 17 00:00:00 2001 From: Lakr <25259084+Lakr233@users.noreply.github.com> Date: Mon, 30 Jun 2025 15:51:00 +0900 Subject: [PATCH] chore: created first ai stream (#12968) ## Summary by CodeRabbit * **New Features** * Introduced a redesigned chat interface with new cell types for user, assistant, system, loading, and error messages. * Added streaming chat responses and improved session management. * Enhanced input box behavior, allowing sending messages with the return key and inserting new lines via the edit menu. * Added new GraphQL queries for fetching recent and latest chat sessions. * **Refactor** * Replaced previous chat message and session management with a new, more structured view model system. * Updated chat view to use a custom table view component for better message rendering and empty state handling. * Simplified and improved error and loading state handling in the chat UI. * **Bug Fixes** * Improved error reporting and retry options for failed chat messages. * Fixed inconsistent property types for message and error identifiers. * **Style** * Updated UI components for chat cells with modern layouts and consistent styling. * **Chores** * Added a new package dependency for event streaming support. * Renamed various internal properties and classes for clarity and consistency. --- .../xcshareddata/swiftpm/Package.resolved | 9 + .../App/AffineViewController+AIButton.swift | 40 +-- .../ios/App/App/AffineViewController.swift | 35 ++- ...CopilotLatestDocSessionQuery.graphql.swift | 141 ++++++++++ ...etCopilotRecentSessionsQuery.graphql.swift | 108 ++++++++ .../App/Packages/Intelligents/Package.swift | 2 + .../Intelligents/ChatManager/ChatError.swift | 31 +++ .../ChatManager/ChatManager+Closable.swift | 13 + .../ChatManager+ContextModels.swift | 125 --------- .../ChatManager/ChatManager+InputModels.swift | 44 --- .../ChatManager/ChatManager+Stream.swift | 138 ++++++++++ .../ChatManager+WorkflowModels.swift | 74 ------ .../ChatManager/ChatManager.swift | 251 ++++-------------- ...tMessage.swift => ChatSessionObject.swift} | 45 +--- .../Intelligents/ChatManager/PromptName.swift | 29 ++ .../IntelligentContext+CreateSession.swift | 57 ++++ .../IntelligentContext.swift | 36 ++- .../AttachmentManagementController.swift | 6 +- .../MainViewController+Input.swift | 45 +--- .../MainViewController.swift | 125 +-------- .../View/ChatCell/AssistantMessageCell.swift | 139 ++++++++++ .../View/ChatCell/ChatBaseCell.swift | 112 ++++++++ .../Interface/View/ChatCell/ChatCell.swift | 155 ----------- .../View/ChatCell/ChatCellFactory.swift | 69 +++++ .../View/ChatCell/ContextReferenceCell.swift | 0 .../Interface/View/ChatCell/ErrorCell.swift | 97 +++++++ .../Interface/View/ChatCell/LoadingCell.swift | 89 +++++++ .../View/ChatCell/SystemMessageCell.swift | 94 +++++++ .../View/ChatCell/UserMessageCell.swift | 94 +++++++ .../AssistantMessageCellViewModel.swift | 44 +++ .../ViewModel/AttachmentCellViewModel.swift | 23 -- .../View/ChatCell/ViewModel/CellType.swift | 6 +- .../ViewModel/ChatCellViewModel.swift | 13 + .../ViewModel/ChatCellViewModels.swift | 41 --- .../ContextReferenceCellViewModel.swift | 15 -- .../ViewModel/ErrorCellViewModel.swift | 4 +- .../ViewModel/LoadingCellViewModel.swift | 2 +- .../SystemMessageCellViewModel.swift | 15 ++ .../ViewModel/UserMessageCellViewModel.swift | 16 ++ .../WorkflowStatusCellViewModel.swift | 15 -- .../View/ChatTableView/ChatTableView.swift | 154 +++++++++++ .../View/InputBox/InputBox+Delegates.swift | 16 ++ .../Interface/View/InputBox/InputBox.swift | 3 +- .../View/InputBox/InputBoxFunctionBar.swift | 13 + .../ViewModel/InputBoxViewModel.swift | 14 +- 45 files changed, 1665 insertions(+), 932 deletions(-) create mode 100644 packages/frontend/apps/ios/App/Packages/AffineGraphQL/Sources/Operations/Queries/GetCopilotLatestDocSessionQuery.graphql.swift create mode 100644 packages/frontend/apps/ios/App/Packages/AffineGraphQL/Sources/Operations/Queries/GetCopilotRecentSessionsQuery.graphql.swift create mode 100644 packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/ChatManager/ChatError.swift create mode 100644 packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/ChatManager/ChatManager+Closable.swift delete mode 100644 packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/ChatManager/ChatManager+ContextModels.swift delete mode 100644 packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/ChatManager/ChatManager+InputModels.swift create mode 100644 packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/ChatManager/ChatManager+Stream.swift delete mode 100644 packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/ChatManager/ChatManager+WorkflowModels.swift rename packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/ChatManager/{ChatMessage.swift => ChatSessionObject.swift} (52%) create mode 100644 packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/ChatManager/PromptName.swift create mode 100644 packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentContext/IntelligentContext+CreateSession.swift create mode 100644 packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/AssistantMessageCell.swift create mode 100644 packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ChatBaseCell.swift delete mode 100644 packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ChatCell.swift create mode 100644 packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ChatCellFactory.swift create mode 100644 packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ContextReferenceCell.swift create mode 100644 packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ErrorCell.swift create mode 100644 packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/LoadingCell.swift create mode 100644 packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/SystemMessageCell.swift create mode 100644 packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/UserMessageCell.swift create mode 100644 packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/AssistantMessageCellViewModel.swift delete mode 100644 packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/AttachmentCellViewModel.swift create mode 100644 packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/ChatCellViewModel.swift delete mode 100644 packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/ChatCellViewModels.swift delete mode 100644 packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/ContextReferenceCellViewModel.swift create mode 100644 packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/SystemMessageCellViewModel.swift create mode 100644 packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/UserMessageCellViewModel.swift delete mode 100644 packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/WorkflowStatusCellViewModel.swift create mode 100644 packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatTableView/ChatTableView.swift diff --git a/packages/frontend/apps/ios/App/App.xcworkspace/xcshareddata/swiftpm/Package.resolved b/packages/frontend/apps/ios/App/App.xcworkspace/xcshareddata/swiftpm/Package.resolved index 11bb81ba6..0c4c49601 100644 --- a/packages/frontend/apps/ios/App/App.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/packages/frontend/apps/ios/App/App.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -9,6 +9,15 @@ "version" : "1.22.0" } }, + { + "identity" : "eventsource", + "kind" : "remoteSourceControl", + "location" : "https://github.com/loopwork-ai/eventsource.git", + "state" : { + "revision" : "07957602bb99a5355c810187e66e6ce378a1057d", + "version" : "1.1.1" + } + }, { "identity" : "snapkit", "kind" : "remoteSourceControl", diff --git a/packages/frontend/apps/ios/App/App/AffineViewController+AIButton.swift b/packages/frontend/apps/ios/App/App/AffineViewController+AIButton.swift index a5353ad76..f6c2dec22 100644 --- a/packages/frontend/apps/ios/App/App/AffineViewController+AIButton.swift +++ b/packages/frontend/apps/ios/App/App/AffineViewController+AIButton.swift @@ -10,24 +10,26 @@ import UIKit extension AFFiNEViewController: IntelligentsButtonDelegate { func onIntelligentsButtonTapped(_ button: IntelligentsButton) { - IntelligentContext.shared.webView = webView! - button.beginProgress() - - IntelligentContext.shared.preparePresent { result in - button.stopProgress() - switch result { - case .success: - let controller = IntelligentsController() - self.present(controller, animated: true) - case let .failure(failure): - let alert = UIAlertController( - title: "Error", - message: failure.localizedDescription, - preferredStyle: .alert - ) - alert.addAction(UIAlertAction(title: "OK", style: .default)) - self.present(alert, animated: true) - } - } + // if it shows up then we are ready to go + let controller = IntelligentsController() + self.present(controller, animated: true) + // IntelligentContext.shared.webView = webView + // button.beginProgress() + // IntelligentContext.shared.preparePresent { result in + // DispatchQueue.main.async { + // button.stopProgress() + // switch result { + // case .success: + // case let .failure(failure): + // let alert = UIAlertController( + // title: "Error", + // message: failure.localizedDescription, + // preferredStyle: .alert + // ) + // alert.addAction(UIAlertAction(title: "OK", style: .default)) + // self.present(alert, animated: true) + // } + // } + // } } } diff --git a/packages/frontend/apps/ios/App/App/AffineViewController.swift b/packages/frontend/apps/ios/App/App/AffineViewController.swift index 5ac07b77b..018702ea2 100644 --- a/packages/frontend/apps/ios/App/App/AffineViewController.swift +++ b/packages/frontend/apps/ios/App/App/AffineViewController.swift @@ -3,6 +3,8 @@ import Intelligents import UIKit class AFFiNEViewController: CAPBridgeViewController { + var intelligentsButton: IntelligentsButton? + override func viewDidLoad() { super.viewDidLoad() webView?.allowsBackForwardNavigationGestures = true @@ -11,6 +13,7 @@ class AFFiNEViewController: CAPBridgeViewController { edgesForExtendedLayout = [] let intelligentsButton = installIntelligentsButton() intelligentsButton.delegate = self + self.intelligentsButton = intelligentsButton dismissIntelligentsButton() } @@ -35,11 +38,39 @@ class AFFiNEViewController: CAPBridgeViewController { plugins.forEach { bridge?.registerPluginInstance($0) } } + private var intelligentsButtonTimer: Timer? + private var isCheckingIntelligentEligibility = false + override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) + IntelligentContext.shared.webView = webView navigationController?.setNavigationBarHidden(false, animated: animated) - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - self.presentIntelligentsButton() + let timer = Timer.scheduledTimer(withTimeInterval: 3, repeats: true) { [weak self] _ in + self?.checkEligibilityOfIntelligent() + } + intelligentsButtonTimer = timer + RunLoop.main.add(timer, forMode: .common) + } + + private func checkEligibilityOfIntelligent() { + guard !isCheckingIntelligentEligibility else { return } + assert(intelligentsButton != nil) + guard intelligentsButton?.isHidden ?? false else { return } // already eligible + isCheckingIntelligentEligibility = true + IntelligentContext.shared.webView = webView + IntelligentContext.shared.preparePresent { [self] result in + DispatchQueue.main.async { + defer { self.isCheckingIntelligentEligibility = false } + switch result { + case .failure: break + case .success: self.presentIntelligentsButton() + } + } } } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + intelligentsButtonTimer?.invalidate() + } } diff --git a/packages/frontend/apps/ios/App/Packages/AffineGraphQL/Sources/Operations/Queries/GetCopilotLatestDocSessionQuery.graphql.swift b/packages/frontend/apps/ios/App/Packages/AffineGraphQL/Sources/Operations/Queries/GetCopilotLatestDocSessionQuery.graphql.swift new file mode 100644 index 000000000..d3aa8db6e --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/AffineGraphQL/Sources/Operations/Queries/GetCopilotLatestDocSessionQuery.graphql.swift @@ -0,0 +1,141 @@ +// @generated +// This file was automatically generated and should not be edited. + +@_exported import ApolloAPI + +public class GetCopilotLatestDocSessionQuery: GraphQLQuery { + public static let operationName: String = "getCopilotLatestDocSession" + public static let operationDocument: ApolloAPI.OperationDocument = .init( + definition: .init( + #"query getCopilotLatestDocSession($workspaceId: String!, $docId: String!) { currentUser { __typename copilot(workspaceId: $workspaceId) { __typename histories( docId: $docId options: { limit: 1, sessionOrder: desc, action: false, fork: false } ) { __typename sessionId workspaceId docId pinned action tokens createdAt updatedAt messages { __typename id role content attachments params createdAt } } } } }"# + )) + + public var workspaceId: String + public var docId: String + + public init( + workspaceId: String, + docId: String + ) { + self.workspaceId = workspaceId + self.docId = docId + } + + public var __variables: Variables? { [ + "workspaceId": workspaceId, + "docId": docId + ] } + + public struct Data: AffineGraphQL.SelectionSet { + public let __data: DataDict + public init(_dataDict: DataDict) { __data = _dataDict } + + public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Query } + public static var __selections: [ApolloAPI.Selection] { [ + .field("currentUser", CurrentUser?.self), + ] } + + /// Get current user + public var currentUser: CurrentUser? { __data["currentUser"] } + + /// CurrentUser + /// + /// Parent Type: `UserType` + public struct CurrentUser: AffineGraphQL.SelectionSet { + public let __data: DataDict + public init(_dataDict: DataDict) { __data = _dataDict } + + public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.UserType } + public static var __selections: [ApolloAPI.Selection] { [ + .field("__typename", String.self), + .field("copilot", Copilot.self, arguments: ["workspaceId": .variable("workspaceId")]), + ] } + + public var copilot: Copilot { __data["copilot"] } + + /// CurrentUser.Copilot + /// + /// Parent Type: `Copilot` + public struct Copilot: AffineGraphQL.SelectionSet { + public let __data: DataDict + public init(_dataDict: DataDict) { __data = _dataDict } + + public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Copilot } + public static var __selections: [ApolloAPI.Selection] { [ + .field("__typename", String.self), + .field("histories", [History].self, arguments: [ + "docId": .variable("docId"), + "options": [ + "limit": 1, + "sessionOrder": "desc", + "action": false, + "fork": false + ] + ]), + ] } + + public var histories: [History] { __data["histories"] } + + /// CurrentUser.Copilot.History + /// + /// Parent Type: `CopilotHistories` + public struct History: AffineGraphQL.SelectionSet { + public let __data: DataDict + public init(_dataDict: DataDict) { __data = _dataDict } + + public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.CopilotHistories } + public static var __selections: [ApolloAPI.Selection] { [ + .field("__typename", String.self), + .field("sessionId", String.self), + .field("workspaceId", String.self), + .field("docId", String?.self), + .field("pinned", Bool.self), + .field("action", String?.self), + .field("tokens", Int.self), + .field("createdAt", AffineGraphQL.DateTime.self), + .field("updatedAt", AffineGraphQL.DateTime.self), + .field("messages", [Message].self), + ] } + + public var sessionId: String { __data["sessionId"] } + public var workspaceId: String { __data["workspaceId"] } + public var docId: String? { __data["docId"] } + public var pinned: Bool { __data["pinned"] } + /// An mark identifying which view to use to display the session + public var action: String? { __data["action"] } + /// The number of tokens used in the session + public var tokens: Int { __data["tokens"] } + public var createdAt: AffineGraphQL.DateTime { __data["createdAt"] } + public var updatedAt: AffineGraphQL.DateTime { __data["updatedAt"] } + public var messages: [Message] { __data["messages"] } + + /// CurrentUser.Copilot.History.Message + /// + /// Parent Type: `ChatMessage` + public struct Message: AffineGraphQL.SelectionSet { + public let __data: DataDict + public init(_dataDict: DataDict) { __data = _dataDict } + + public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.ChatMessage } + public static var __selections: [ApolloAPI.Selection] { [ + .field("__typename", String.self), + .field("id", AffineGraphQL.ID?.self), + .field("role", String.self), + .field("content", String.self), + .field("attachments", [String]?.self), + .field("params", AffineGraphQL.JSON?.self), + .field("createdAt", AffineGraphQL.DateTime.self), + ] } + + public var id: AffineGraphQL.ID? { __data["id"] } + public var role: String { __data["role"] } + public var content: String { __data["content"] } + public var attachments: [String]? { __data["attachments"] } + public var params: AffineGraphQL.JSON? { __data["params"] } + public var createdAt: AffineGraphQL.DateTime { __data["createdAt"] } + } + } + } + } + } +} diff --git a/packages/frontend/apps/ios/App/Packages/AffineGraphQL/Sources/Operations/Queries/GetCopilotRecentSessionsQuery.graphql.swift b/packages/frontend/apps/ios/App/Packages/AffineGraphQL/Sources/Operations/Queries/GetCopilotRecentSessionsQuery.graphql.swift new file mode 100644 index 000000000..3f430f3b4 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/AffineGraphQL/Sources/Operations/Queries/GetCopilotRecentSessionsQuery.graphql.swift @@ -0,0 +1,108 @@ +// @generated +// This file was automatically generated and should not be edited. + +@_exported import ApolloAPI + +public class GetCopilotRecentSessionsQuery: GraphQLQuery { + public static let operationName: String = "getCopilotRecentSessions" + public static let operationDocument: ApolloAPI.OperationDocument = .init( + definition: .init( + #"query getCopilotRecentSessions($workspaceId: String!, $limit: Int = 10) { currentUser { __typename copilot(workspaceId: $workspaceId) { __typename histories(options: { limit: $limit, sessionOrder: desc }) { __typename sessionId workspaceId docId pinned action tokens createdAt updatedAt } } } }"# + )) + + public var workspaceId: String + public var limit: GraphQLNullable + + public init( + workspaceId: String, + limit: GraphQLNullable = 10 + ) { + self.workspaceId = workspaceId + self.limit = limit + } + + public var __variables: Variables? { [ + "workspaceId": workspaceId, + "limit": limit + ] } + + public struct Data: AffineGraphQL.SelectionSet { + public let __data: DataDict + public init(_dataDict: DataDict) { __data = _dataDict } + + public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Query } + public static var __selections: [ApolloAPI.Selection] { [ + .field("currentUser", CurrentUser?.self), + ] } + + /// Get current user + public var currentUser: CurrentUser? { __data["currentUser"] } + + /// CurrentUser + /// + /// Parent Type: `UserType` + public struct CurrentUser: AffineGraphQL.SelectionSet { + public let __data: DataDict + public init(_dataDict: DataDict) { __data = _dataDict } + + public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.UserType } + public static var __selections: [ApolloAPI.Selection] { [ + .field("__typename", String.self), + .field("copilot", Copilot.self, arguments: ["workspaceId": .variable("workspaceId")]), + ] } + + public var copilot: Copilot { __data["copilot"] } + + /// CurrentUser.Copilot + /// + /// Parent Type: `Copilot` + public struct Copilot: AffineGraphQL.SelectionSet { + public let __data: DataDict + public init(_dataDict: DataDict) { __data = _dataDict } + + public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Copilot } + public static var __selections: [ApolloAPI.Selection] { [ + .field("__typename", String.self), + .field("histories", [History].self, arguments: ["options": [ + "limit": .variable("limit"), + "sessionOrder": "desc" + ]]), + ] } + + public var histories: [History] { __data["histories"] } + + /// CurrentUser.Copilot.History + /// + /// Parent Type: `CopilotHistories` + public struct History: AffineGraphQL.SelectionSet { + public let __data: DataDict + public init(_dataDict: DataDict) { __data = _dataDict } + + public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.CopilotHistories } + public static var __selections: [ApolloAPI.Selection] { [ + .field("__typename", String.self), + .field("sessionId", String.self), + .field("workspaceId", String.self), + .field("docId", String?.self), + .field("pinned", Bool.self), + .field("action", String?.self), + .field("tokens", Int.self), + .field("createdAt", AffineGraphQL.DateTime.self), + .field("updatedAt", AffineGraphQL.DateTime.self), + ] } + + public var sessionId: String { __data["sessionId"] } + public var workspaceId: String { __data["workspaceId"] } + public var docId: String? { __data["docId"] } + public var pinned: Bool { __data["pinned"] } + /// An mark identifying which view to use to display the session + public var action: String? { __data["action"] } + /// The number of tokens used in the session + public var tokens: Int { __data["tokens"] } + public var createdAt: AffineGraphQL.DateTime { __data["createdAt"] } + public var updatedAt: AffineGraphQL.DateTime { __data["updatedAt"] } + } + } + } + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Package.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Package.swift index 338097ebd..fd5ee90b1 100644 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Package.swift +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Package.swift @@ -19,6 +19,7 @@ let package = Package( .package(url: "https://github.com/devxoul/Then", from: "3.0.0"), .package(url: "https://github.com/SnapKit/SnapKit.git", from: "5.7.1"), .package(url: "https://github.com/SwifterSwift/SwifterSwift.git", from: "6.0.0"), + .package(url: "https://github.com/loopwork-ai/eventsource.git", from: "1.1.1"), ], targets: [ .target(name: "Intelligents", dependencies: [ @@ -28,6 +29,7 @@ let package = Package( "SwifterSwift", .product(name: "Apollo", package: "apollo-ios"), .product(name: "OrderedCollections", package: "swift-collections"), + .product(name: "EventSource", package: "eventsource"), ], resources: [ .process("Interface/View/InputBox/InputBox.xcassets"), .process("Interface/Controller/AttachmentManagementController/AttachmentIcon.xcassets"), diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/ChatManager/ChatError.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/ChatManager/ChatError.swift new file mode 100644 index 000000000..41d479e9e --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/ChatManager/ChatError.swift @@ -0,0 +1,31 @@ +// +// ChatError.swift +// Intelligents +// +// Created by 秋星桥 on 6/30/25. +// + +import Foundation + +public enum ChatError: LocalizedError { + case invalidServerConfiguration + case invalidStreamURL + case invalidResponse + case networkError(Error) + case unknownError + + public var errorDescription: String? { + switch self { + case .invalidServerConfiguration: + "Invalid server configuration" + case .invalidStreamURL: + "Invalid stream URL" + case .invalidResponse: + "Invalid response from server" + case let .networkError(error): + "Network error: \(error.localizedDescription)" + case .unknownError: + "An unknown error occurred" + } + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/ChatManager/ChatManager+Closable.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/ChatManager/ChatManager+Closable.swift new file mode 100644 index 000000000..8f0a43a88 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/ChatManager/ChatManager+Closable.swift @@ -0,0 +1,13 @@ +// +// ChatManager+Closable.swift +// Intelligents +// +// Created by 秋星桥 on 6/30/25. +// + +import EventSource +import Foundation + +protocol Closable { func close() } + +extension EventSource: @preconcurrency Closable {} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/ChatManager/ChatManager+ContextModels.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/ChatManager/ChatManager+ContextModels.swift deleted file mode 100644 index 76add7d25..000000000 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/ChatManager/ChatManager+ContextModels.swift +++ /dev/null @@ -1,125 +0,0 @@ -// -// ChatManager+ContextModels.swift -// Intelligents -// -// Created by 秋星桥 on 6/26/25. -// - -import AffineGraphQL -import Foundation - -// MARK: - ChatManager Context Models Extension - -extension ChatManager { - // MARK: - Context Models - - struct ContextReference: Codable, Identifiable, Equatable, Hashable { - var id: String - var fileId: String? - var docId: String? - var chunk: Int - var content: String - var distance: Double - var highlightedContent: String? - - init(fileId: String? = nil, docId: String? = nil, chunk: Int, content: String, distance: Double, highlightedContent: String? = nil) { - id = UUID().uuidString - self.fileId = fileId - self.docId = docId - self.chunk = chunk - self.content = content - self.distance = distance - self.highlightedContent = highlightedContent - } - } - - struct CopilotContext: Codable, Identifiable, Equatable, Hashable { - var id: String - var sessionId: String - var workspaceId: String - var files: [ContextFile] - var docs: [ContextDoc] - var categories: [ContextCategory] - - init(id: String, sessionId: String, workspaceId: String, files: [ContextFile] = [], docs: [ContextDoc] = [], categories: [ContextCategory] = []) { - self.id = id - self.sessionId = sessionId - self.workspaceId = workspaceId - self.files = files - self.docs = docs - self.categories = categories - } - } - - struct ContextFile: Codable, Identifiable, Equatable, Hashable { - var id: String - var contextId: String - var blobId: String - var fileName: String? - var fileSize: Int? - var mimeType: String? - var embeddingStatus: ContextEmbedStatus? - var createdAt: DateTime? - - var createdDate: Date? { - createdAt?.decoded - } - } - - struct ContextDoc: Codable, Identifiable, Equatable, Hashable { - var id: String - var contextId: String - var docId: String - var title: String? - var embeddingStatus: ContextEmbedStatus? - var createdAt: DateTime? - - var createdDate: Date? { - createdAt?.decoded - } - } - - struct ContextCategory: Codable, Identifiable, Equatable, Hashable { - var id: String - var contextId: String - var type: ContextCategoryType - var docs: [String] - var name: String? - var createdAt: DateTime? - - var createdDate: Date? { - createdAt?.decoded - } - } - - enum ContextEmbedStatus: String, Codable, CaseIterable { - case pending = "Pending" - case failed = "Failed" - case completed = "Completed" - } - - enum ContextCategoryType: String, Codable, CaseIterable { - case tag = "TAG" - case collection = "COLLECTION" - } - - struct MatchContextResult: Codable, Identifiable, Equatable, Hashable { - var id: String - var fileId: String? - var docId: String? - var chunk: Int - var content: String - var distance: Double - var highlightedContent: String? - - init(fileId: String? = nil, docId: String? = nil, chunk: Int, content: String, distance: Double, highlightedContent: String? = nil) { - id = UUID().uuidString - self.fileId = fileId - self.docId = docId - self.chunk = chunk - self.content = content - self.distance = distance - self.highlightedContent = highlightedContent - } - } -} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/ChatManager/ChatManager+InputModels.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/ChatManager/ChatManager+InputModels.swift deleted file mode 100644 index 64eb5fd8f..000000000 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/ChatManager/ChatManager+InputModels.swift +++ /dev/null @@ -1,44 +0,0 @@ -// -// ChatManager+InputModels.swift -// Intelligents -// -// Created by 秋星桥 on 6/26/25. -// - -import Foundation - -// MARK: - ChatManager Input Models Extension - -extension ChatManager { - // MARK: - Input Models - - struct AddContextFileInput: Codable, Equatable, Hashable { - var contextId: String - var blobId: String - } - - struct RemoveContextFileInput: Codable, Equatable, Hashable { - var contextId: String - var fileId: String - } - - struct AddContextDocInput: Codable, Equatable, Hashable { - var contextId: String - var docId: String - } - - struct RemoveContextDocInput: Codable, Equatable, Hashable { - var contextId: String - var docId: String - } - - struct AddContextCategoryInput: Codable, Equatable, Hashable { - var contextId: String - var docs: [String] - } - - struct RemoveContextCategoryInput: Codable, Equatable, Hashable { - var contextId: String - var categoryId: String - } -} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/ChatManager/ChatManager+Stream.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/ChatManager/ChatManager+Stream.swift new file mode 100644 index 000000000..7ce6e89c0 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/ChatManager/ChatManager+Stream.swift @@ -0,0 +1,138 @@ +// +// ChatManager+Stream.swift +// Intelligents +// +// Created by 秋星桥 on 6/30/25. +// + +import AffineGraphQL +import Apollo +import ApolloAPI +import EventSource +import Foundation + +extension ChatManager { + public func startUserRequest( + content: String, + inputBoxData: InputBoxData, + sessionId: String + ) { + append(sessionId: sessionId, UserMessageCellViewModel( + id: .init(), + content: inputBoxData.text, + timestamp: .init(), + attachments: [], + )) + + let messageParameters: [String: AnyHashable] = [ + // packages/frontend/core/src/blocksuite/ai/provider/setup-provider.tsx + "docs": inputBoxData.documentAttachments.map(\.documentID), // affine doc + "files": [String](), // attachment in context, keep nil for now + "searchMode": inputBoxData.isSearchEnabled ? "MUST" : "AUTO", + ] + let uploadableAttachments: [GraphQLFile] = [ + inputBoxData.fileAttachments.map { file -> GraphQLFile in + .init( + fieldName: file.name, + originalName: file.name, + data: file.data ?? .init() + ) + }, + inputBoxData.imageAttachments.map { image -> GraphQLFile in + .init( + fieldName: image.hashValue.description, + originalName: "image.jpg", + data: image.imageData + ) + }, + ].flatMap(\.self) + assert(uploadableAttachments.allSatisfy { !($0.data?.isEmpty ?? true) }) + guard let input = try? CreateChatMessageInput( + content: .some(content), + params: .some(AffineGraphQL.JSON(_jsonValue: messageParameters)), + sessionId: sessionId + ) else { + assertionFailure() // very unlikely to happen + return + } + let mutation = CreateCopilotMessageMutation(options: input) + QLService.shared.client.upload(operation: mutation, files: uploadableAttachments) { result in + DispatchQueue.main.async { + switch result { + case let .success(graphQLResult): + guard let messageIdentifier = graphQLResult.data?.createCopilotMessage else { + self.report(sessionId, ChatError.invalidResponse) + return + } + let viewModelId = self.append(sessionId: sessionId, AssistantMessageCellViewModel( + id: .init(), + content: .init(), + timestamp: .init() + )) + self.startStreamingResponse( + sessionId: sessionId, + messageId: messageIdentifier, + applyingTo: viewModelId + ) + case let .failure(error): + self.report(sessionId, error) + } + } + } + } + + private func startStreamingResponse(sessionId: String, messageId: String, applyingTo vmId: UUID) { + let base = IntelligentContext.shared.webViewMetadata[.currentServerBaseUrl] as? String + guard let base, let url = URL(string: base) else { + report(sessionId, ChatError.invalidServerConfiguration) + return + } + let streamUrl = url + .appendingPathComponent("api") + .appendingPathComponent("copilot") + .appendingPathComponent("chat") + .appendingPathComponent(sessionId) + .appendingPathComponent("stream") + var comps = URLComponents(url: streamUrl, resolvingAgainstBaseURL: false) + comps?.queryItems = [ + .init(name: "messageId", value: messageId), + .init(name: "retry", value: "false"), // TODO: IMPL FROM UI + ] + guard let finalUrl = comps?.url else { + report(sessionId, ChatError.invalidStreamURL) + return + } + let eventSource = EventSource( + request: .init( + url: finalUrl, + cachePolicy: .reloadIgnoringLocalAndRemoteCacheData, + timeoutInterval: 10 + ), + configuration: .default + ) + eventSource.onOpen = { + print("[*] \(messageId): connection established") + } + eventSource.onError = { + self.report(sessionId, $0 ?? ChatError.unknownError) + } + + var document = "" + let queue = DispatchQueue(label: "com.affine.chat.stream.\(sessionId)") + eventSource.onMessage = { event in + queue.async { + print("[*] \(messageId): \(event.event ?? "?") received message: \(event.data)") + switch event.event { + case "message": + document += event.data + self.with(sessionId: sessionId, vmId: vmId) { (viewModel: inout AssistantMessageCellViewModel) in + viewModel.content = document + } + default: + break + } + } + } + closable.append(eventSource) + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/ChatManager/ChatManager+WorkflowModels.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/ChatManager/ChatManager+WorkflowModels.swift deleted file mode 100644 index b1888759a..000000000 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/ChatManager/ChatManager+WorkflowModels.swift +++ /dev/null @@ -1,74 +0,0 @@ -// -// ChatManager+WorkflowModels.swift -// Intelligents -// -// Created by 秋星桥 on 6/26/25. -// - -import AffineGraphQL -import Foundation - -// MARK: - ChatManager Workflow Models Extension - -extension ChatManager { - // MARK: - Workflow Models - - struct WorkflowEventData: Codable, Identifiable, Equatable, Hashable { - var id: String - var status: String - var type: String - var progress: Double? - var message: String? - - init(status: String, type: String, progress: Double? = nil, message: String? = nil) { - id = UUID().uuidString - self.status = status - self.type = type - self.progress = progress - self.message = message - } - } - - struct WorkspaceEmbeddingStatus: Codable, Identifiable, Equatable, Hashable { - var id: String - var workspaceId: String - var total: Int - var embedded: Int - - var progress: Double { - total > 0 ? Double(embedded) / Double(total) : 0.0 - } - - init(workspaceId: String, total: Int, embedded: Int) { - id = workspaceId - self.workspaceId = workspaceId - self.total = total - self.embedded = embedded - } - } - - struct ChatEvent: Codable, Identifiable, Equatable, Hashable { - var id: String - var type: ChatEventType - var data: String - var timestamp: DateTime? - - var timestampDate: Date? { - timestamp?.decoded - } - - init(type: ChatEventType, data: String, timestamp: DateTime? = nil) { - id = UUID().uuidString - self.type = type - self.data = data - self.timestamp = timestamp - } - } - - enum ChatEventType: String, Codable, CaseIterable { - case message - case attachment - case event - case ping - } -} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/ChatManager/ChatManager.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/ChatManager/ChatManager.swift index 02c9f07dc..60601cce9 100644 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/ChatManager/ChatManager.swift +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/ChatManager/ChatManager.swift @@ -7,221 +7,76 @@ import AffineGraphQL import Apollo +import ApolloAPI import Combine +import EventSource import Foundation +import OrderedCollections -// MARK: - ChatManager - -public class ChatManager: ObservableObject { +public class ChatManager: ObservableObject, @unchecked Sendable { public static let shared = ChatManager() - // MARK: - Properties + public typealias SessionID = String + public typealias MessageID = UUID // ChatCellViewModel ID + @Published public private(set) var viewModels: OrderedDictionary< + SessionID, + OrderedDictionary + > = [:] - @Published public private(set) var sessions: [SessionViewModel] = [] - @Published public private(set) var currentSession: SessionViewModel? - @Published public private(set) var messages: [String: [ChatMessage]] = [:] - @Published public private(set) var isLoading = false - @Published public private(set) var error: Error? + var closable: [Closable] = [] - private var cancellables = Set() - private let apolloClient: ApolloClient + private init() {} - // MARK: - Initialization - - private init(apolloClient: ApolloClient = QLService.shared.client) { - self.apolloClient = apolloClient + public func closeAll() { + closable.forEach { $0.close() } + closable.removeAll() } - // MARK: - Public Methods + public func with(sessionId: String, _ action: (inout OrderedDictionary) -> Void) { + if Thread.isMainThread { + if var sessionViewModels = viewModels[sessionId] { + action(&sessionViewModels) + viewModels[sessionId] = sessionViewModels + } else { + var sessionViewModels = OrderedDictionary() + action(&sessionViewModels) + viewModels[sessionId] = sessionViewModels + } + } else { + DispatchQueue.main.asyncAndWait { + self.with(sessionId: sessionId, action) + } + } + } - public func createSession( - workspaceId: String, - promptName: String = "", - docId: String? = nil, - pinned: Bool = false - ) async throws -> SessionViewModel { - isLoading = true - error = nil - - do { - let input = CreateChatSessionInput( - docId: docId.map { .some($0) } ?? .null, - pinned: .some(pinned), - promptName: promptName, - workspaceId: workspaceId - ) - - let mutation = CreateCopilotSessionMutation(options: input) - - return try await withCheckedThrowingContinuation { continuation in - apolloClient.perform(mutation: mutation) { result in - switch result { - case let .success(graphQLResult): - guard let sessionId = graphQLResult.data?.createCopilotSession else { - continuation.resume(throwing: ChatError.invalidResponse) - return - } - - let session = SessionViewModel( - id: sessionId, - workspaceId: workspaceId, - docId: docId, - promptName: promptName, - model: nil, - pinned: pinned, - tokens: 0, - createdAt: DateTime(date: Date()), - updatedAt: DateTime(date: Date()), - parentSessionId: nil - ) - - Task { @MainActor in - self.sessions.append(session) - self.currentSession = session - self.messages[sessionId] = [] - self.isLoading = false - } - - continuation.resume(returning: session) - - case let .failure(error): - Task { @MainActor in - self.error = error - self.isLoading = false - } - continuation.resume(throwing: error) - } + public func with(sessionId: String, vmId: UUID, _ action: (inout T) -> Void) { + with(sessionId: sessionId) { sessionViewModels in + if let read = sessionViewModels[vmId], var convert = read as? T { + action(&convert) + guard let vm = convert as? any ChatCellViewModel else { + assertionFailure() + return } + sessionViewModels[vmId] = vm + } else { + assertionFailure() } - } catch { - await MainActor.run { - self.error = error - self.isLoading = false - } - throw error } } - public func sendMessage( - content: String, - attachments: [String] = [], - sessionId: String? = nil - ) async throws { - guard let targetSessionId = sessionId ?? currentSession?.id else { - throw ChatError.noActiveSession - } + @discardableResult + public func append(sessionId: String, _ viewModel: any ChatCellViewModel) -> UUID { + with(sessionId: sessionId) { $0.updateValue(viewModel, forKey: viewModel.id) } + return viewModel.id + } - isLoading = true - error = nil - - // Add user message immediately - let userMessage = ChatMessage( - id: UUID().uuidString, - role: .user, - content: content, - attachments: attachments.isEmpty ? nil : attachments, - params: nil, - createdAt: DateTime(date: Date()) + @discardableResult + public func report(_ sessionID: String, _ error: Error) -> UUID { + let model = ErrorCellViewModel( + id: .init(), + errorMessage: error.localizedDescription ) - - await MainActor.run { - var sessionMessages = self.messages[targetSessionId] ?? [] - sessionMessages.append(userMessage) - self.messages[targetSessionId] = sessionMessages - } - - do { - let input = CreateChatMessageInput( - attachments: attachments.isEmpty ? .null : .some(attachments), - blobs: .null, - content: .some(content), - params: .null, - sessionId: targetSessionId - ) - - let mutation = CreateCopilotMessageMutation(options: input) - - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - apolloClient.perform(mutation: mutation) { result in - switch result { - case let .success(graphQLResult): - guard let messageId = graphQLResult.data?.createCopilotMessage else { - continuation.resume(throwing: ChatError.invalidResponse) - return - } - - // Add assistant message placeholder - let assistantMessage = ChatMessage( - id: messageId, - role: .assistant, - content: "Thinking...", - attachments: nil, - params: nil, - createdAt: DateTime(date: Date()) - ) - - Task { @MainActor in - var sessionMessages = self.messages[targetSessionId] ?? [] - sessionMessages.append(assistantMessage) - self.messages[targetSessionId] = sessionMessages - self.isLoading = false - } - - continuation.resume() - - // TODO: Implement streaming response handling - - case let .failure(error): - Task { @MainActor in - self.error = error - self.isLoading = false - } - continuation.resume(throwing: error) - } - } - } - } catch { - await MainActor.run { - self.error = error - self.isLoading = false - } - throw error - } - } - - public func switchToSession(_ session: SessionViewModel) { - currentSession = session - } - - public func deleteSession(sessionId: String) { - sessions.removeAll { $0.id == sessionId } - messages.removeValue(forKey: sessionId) - - if currentSession?.id == sessionId { - currentSession = sessions.first - } - } - - public func clearError() { - error = nil - } -} - -// MARK: - ChatError - -public enum ChatError: LocalizedError { - case noActiveSession - case invalidResponse - case networkError(Error) - - public var errorDescription: String? { - switch self { - case .noActiveSession: - "No active chat session" - case .invalidResponse: - "Invalid response from server" - case let .networkError(error): - "Network error: \(error.localizedDescription)" - } + append(sessionId: sessionID, model) + return model.id } } diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/ChatManager/ChatMessage.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/ChatManager/ChatSessionObject.swift similarity index 52% rename from packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/ChatManager/ChatMessage.swift rename to packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/ChatManager/ChatSessionObject.swift index 45555aad0..fe4bdc0d5 100644 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/ChatManager/ChatMessage.swift +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/ChatManager/ChatSessionObject.swift @@ -1,5 +1,5 @@ // -// ChatMessage.swift +// ChatSessionObject.swift // Intelligents // // Created by 秋星桥 on 6/26/25. @@ -8,48 +8,7 @@ import AffineGraphQL import Foundation -public struct ChatMessage: Codable, Identifiable, Equatable, Hashable { - public var id: String? - public var role: MessageRole - public var content: String - public var attachments: [String]? - public var params: [String: String]? - public var createdAt: DateTime? - - public var createdDate: Date? { - createdAt?.decoded - } - - public var messageId: String { - id ?? UUID().uuidString - } - - public init( - id: String? = nil, - role: MessageRole, - content: String, - attachments: [String]? = nil, - params: [String: String]? = nil, - createdAt: DateTime? = nil - ) { - self.id = id - self.role = role - self.content = content - self.attachments = attachments - self.params = params - self.createdAt = createdAt - } -} - -public extension ChatMessage { - enum MessageRole: String, Codable, CaseIterable { - case user - case assistant - case system - } -} - -public struct SessionViewModel: Codable, Identifiable, Equatable, Hashable { +public struct ChatSessionObject: Codable, Identifiable, Equatable, Hashable { public var id: String public var workspaceId: String public var docId: String? diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/ChatManager/PromptName.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/ChatManager/PromptName.swift new file mode 100644 index 000000000..1de61f0b5 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/ChatManager/PromptName.swift @@ -0,0 +1,29 @@ +// +// PromptName.swift +// Intelligents +// +// Created by 秋星桥 on 6/30/25. +// + +import Foundation + +public enum PromptName: String, Codable { + case summary = "Summary" + case summaryAsTitle = "Summary as title" + case explainThis = "Explain this" + case writeAnArticleAboutThis = "Write an article about this" + case writeATwitterAboutThis = "Write a twitter about this" + case writeAPoemAboutThis = "Write a poem about this" + case writeABlogPostAboutThis = "Write a blog post about this" + case writeOutline = "Write outline" + case changeToneTo = "Change tone to" + case improveWritingForIt = "Improve writing for it" + case improveGrammarForIt = "Improve grammar for it" + case fixSpellingForIt = "Fix spelling for it" + case createHeadings = "Create headings" + case makeItLonger = "Make it longer" + case makeItShorter = "Make it shorter" + case continueWriting = "Continue writing" + case chatWithAffineAI = "Chat With AFFiNE AI" + case searchWithAffineAI = "Search With AFFiNE AI" +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentContext/IntelligentContext+CreateSession.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentContext/IntelligentContext+CreateSession.swift new file mode 100644 index 000000000..7d38b4df9 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentContext/IntelligentContext+CreateSession.swift @@ -0,0 +1,57 @@ +// +// IntelligentContext+CreateSession.swift +// Intelligents +// +// Created by 秋星桥 on 6/27/25. +// + +import AffineGraphQL +import Apollo +import ApolloAPI +import Foundation + +public extension IntelligentContext { + func createSession( + workspaceId: String, + promptName: PromptName = .chatWithAffineAI, + docId: String? = nil, + pinned: Bool = false, + completion: @escaping (Result) -> Void + ) { + let input = CreateChatSessionInput( + docId: docId.map { .some($0) } ?? .null, + pinned: .some(pinned), + promptName: promptName.rawValue, + workspaceId: workspaceId + ) + + let mutation = CreateCopilotSessionMutation(options: input) + + QLService.shared.client.perform(mutation: mutation) { result in + switch result { + case let .success(graphQLResult): + guard let sessionId = graphQLResult.data?.createCopilotSession else { + completion(.failure(IntelligentError.sessionCreationFailed("No session ID returned."))) + return + } + + let session = ChatSessionObject( + id: sessionId, + workspaceId: workspaceId, + docId: docId, + promptName: promptName.rawValue, + model: nil, + pinned: pinned, + tokens: 0, + createdAt: DateTime(date: Date()), + updatedAt: DateTime(date: Date()), + parentSessionId: nil + ) + completion(.success(session)) + + case let .failure(error): + completion(.failure(IntelligentError.sessionCreationFailed(error.localizedDescription))) + } + } + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentContext/IntelligentContext.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentContext/IntelligentContext.swift index 310dccbee..0827b8948 100644 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentContext/IntelligentContext.swift +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentContext/IntelligentContext.swift @@ -42,6 +42,9 @@ public class IntelligentContext { case currentI18nLocale } + public private(set) var currentSession: ChatSessionObject? + public private(set) var currentWorkspaceId: String? + public lazy var temporaryDirectory: URL = { let tempDir = FileManager.default.temporaryDirectory return tempDir.appendingPathComponent("IntelligentContext") @@ -49,11 +52,14 @@ public class IntelligentContext { public enum IntelligentError: Error, LocalizedError { case loginRequired(String) + case sessionCreationFailed(String) public var errorDescription: String? { switch self { case let .loginRequired(reason): "Login required: \(reason)" + case let .sessionCreationFailed(reason): + "Session creation failed: \(reason)" } } } @@ -61,6 +67,7 @@ public class IntelligentContext { private init() {} public func preparePresent(_ completion: @escaping (Result) -> Void) { + assert(webView != nil) DispatchQueue.global(qos: .userInitiated).async { [self] in prepareTemporaryDirectory() @@ -79,21 +86,18 @@ public class IntelligentContext { !baseUrlString.isEmpty, let url = URL(string: baseUrlString) else { - DispatchQueue.main.async { - completion(.failure(IntelligentError.loginRequired("Missing server base URL"))) - } + completion(.failure(IntelligentError.loginRequired("Missing server base URL"))) return } guard let workspaceId = webViewMetadataResult[.currentWorkspaceId] as? String, !workspaceId.isEmpty else { - DispatchQueue.main.async { - completion(.failure(IntelligentError.loginRequired("Missing workspace ID"))) - } + completion(.failure(IntelligentError.loginRequired("Missing workspace ID"))) return } + currentWorkspaceId = workspaceId QLService.shared.setEndpoint(base: url) let gqlGroup = DispatchGroup() @@ -106,20 +110,28 @@ public class IntelligentContext { gqlGroup.wait() qlMetadata = gqlMetadataResult - // Check required QL metadata guard let userIdentifier = gqlMetadataResult[.userIdentifierKey] as? String, !userIdentifier.isEmpty else { - DispatchQueue.main.async { - completion(.failure(IntelligentError.loginRequired("Missing user identifier"))) - } + completion(.failure(IntelligentError.loginRequired("Missing user identifier"))) return } + let currentDocumentId: String? = webViewMetadata[.currentDocId] as? String + dumpMetadataContents() - DispatchQueue.main.async { - completion(.success(())) + createSession( + workspaceId: workspaceId, + docId: currentDocumentId + ) { result in + switch result { + case let .success(session): + self.currentSession = session + completion(.success(())) + case let .failure(error): + completion(.failure(error)) + } } } } diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/AttachmentManagementController/AttachmentManagementController.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/AttachmentManagementController/AttachmentManagementController.swift index fb8187034..9833acbec 100644 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/AttachmentManagementController/AttachmentManagementController.swift +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/AttachmentManagementController/AttachmentManagementController.swift @@ -45,7 +45,7 @@ private class _AttachmentManagementController: UIViewController { Section, Item > = .init(tableView: tableView) { [weak self] tableView, indexPath, item in - let cell = tableView.dequeueReusableCell(withIdentifier: "AttachmentCell", for: indexPath) as! AttachmentCell + let cell = tableView.dequeueReusableCell(withIdentifier: "AttachmentManagementCell", for: indexPath) as! AttachmentManagementCell cell.configure(with: item) cell.onDelete = { [weak self] in guard let delegateController = self?.delegateController else { return } @@ -119,7 +119,7 @@ private class _AttachmentManagementController: UIViewController { tableView.backgroundColor = .clear tableView.separatorStyle = .none - tableView.register(AttachmentCell.self, forCellReuseIdentifier: "AttachmentCell") + tableView.register(AttachmentManagementCell.self, forCellReuseIdentifier: "AttachmentManagementCell") tableView.clipsToBounds = true tableView.rowHeight = UITableView.automaticDimension view.addSubview(tableView) @@ -156,7 +156,7 @@ private class _AttachmentManagementController: UIViewController { } } -private class AttachmentCell: UITableViewCell { +private class AttachmentManagementCell: UITableViewCell { let container = UIView().then { $0.layer.cornerRadius = 4 $0.layer.borderWidth = 0.5 diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/MainViewController/MainViewController+Input.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/MainViewController/MainViewController+Input.swift index 260621dd3..c9f95cefe 100644 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/MainViewController/MainViewController+Input.swift +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/MainViewController/MainViewController+Input.swift @@ -76,41 +76,20 @@ extension MainViewController: InputBoxDelegate { func inputBoxDidSend(_ inputBox: InputBox) { let inputData = inputBox.inputBoxData + inputBox.text = "" + inputBox.viewModel.clearAllAttachments() - Task { @MainActor in - do { - let chatManager = ChatManager.shared - - if let currentSession = chatManager.currentSession { - try await chatManager.sendMessage( - content: inputData.text, - attachments: [], // TODO: Handle attachments - sessionId: currentSession.id - ) - } else { - guard let workspaceId = IntelligentContext.shared.webViewMetadata[.currentWorkspaceId] as? String, - !workspaceId.isEmpty - else { - showAlert(title: "Error", message: "No workspace available") - return - } - - let session = try await chatManager.createSession(workspaceId: workspaceId) - - try await chatManager.sendMessage( - content: inputData.text, - attachments: [], // TODO: Handle attachments - sessionId: session.id - ) - } - - inputBox.text = "" - inputBox.viewModel.clearAllAttachments() - - } catch { - showAlert(title: "Error", message: error.localizedDescription) - } + guard let currentSession = IntelligentContext.shared.currentSession else { + showAlert(title: "Error", message: "No active session available") + return } + + ChatManager.shared.closeAll() + ChatManager.shared.startUserRequest( + content: inputData.text, + inputBoxData: inputData, + sessionId: currentSession.id + ) } private func showAlert(title: String, message: String) { diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/MainViewController/MainViewController.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/MainViewController/MainViewController.swift index e611c4c77..c99494429 100644 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/MainViewController/MainViewController.swift +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/MainViewController/MainViewController.swift @@ -10,25 +10,8 @@ class MainViewController: UIViewController { $0.delegate = self } - lazy var tableView = UITableView().then { - $0.backgroundColor = .clear - $0.separatorStyle = .none + lazy var chatTableView = ChatTableView().then { $0.delegate = self - $0.dataSource = self - $0.register(ChatCell.self, forCellReuseIdentifier: "ChatCell") - $0.keyboardDismissMode = .interactive - $0.contentInsetAdjustmentBehavior = .never - } - - lazy var emptyStateView = UIView().then { - $0.isHidden = true - } - - lazy var emptyStateLabel = UILabel().then { - $0.text = "Start a conversation..." - $0.font = .systemFont(ofSize: 18, weight: .medium) - $0.textColor = .systemGray - $0.textAlignment = .center } lazy var inputBox = InputBox().then { @@ -49,10 +32,9 @@ class MainViewController: UIViewController { // MARK: - Properties - private var messages: [ChatMessage] = [] - private var cancellables = Set() - private let intelligentContext = IntelligentContext.shared - private let chatManager = ChatManager.shared + var cancellables = Set() + let intelligentContext = IntelligentContext.shared + let chatManager = ChatManager.shared var terminateEditGesture: UITapGestureRecognizer! // MARK: - Lifecycle @@ -62,7 +44,6 @@ class MainViewController: UIViewController { view.backgroundColor = .affineLayerBackgroundPrimary setupUI() - setupBindings() view.isUserInteractionEnabled = true terminateEditGesture = UITapGestureRecognizer(target: self, action: #selector(terminateEditing)) @@ -73,32 +54,20 @@ class MainViewController: UIViewController { private func setupUI() { view.addSubview(headerView) - view.addSubview(tableView) - view.addSubview(emptyStateView) + view.addSubview(chatTableView) view.addSubview(inputBox) view.addSubview(documentPickerHideDetector) view.addSubview(documentPickerView) - emptyStateView.addSubview(emptyStateLabel) - headerView.snp.makeConstraints { make in make.top.equalTo(view.safeAreaLayoutGuide) make.leading.trailing.equalToSuperview() } - tableView.snp.makeConstraints { make in + chatTableView.snp.makeConstraints { make in make.top.equalTo(headerView.snp.bottom) - make.leading.trailing.equalToSuperview() - make.bottom.equalTo(inputBox.snp.top) - } - - emptyStateView.snp.makeConstraints { make in - make.center.equalTo(tableView) - make.width.lessThanOrEqualTo(tableView).inset(32) - } - - emptyStateLabel.snp.makeConstraints { make in - make.edges.equalToSuperview() + make.left.right.equalToSuperview() + make.bottom.equalToSuperview() } inputBox.snp.makeConstraints { make in @@ -117,24 +86,6 @@ class MainViewController: UIViewController { } } - private func setupBindings() { - chatManager.$currentSession - .receive(on: DispatchQueue.main) - .sink { [weak self] session in - self?.updateMessages(for: session?.id) - } - .store(in: &cancellables) - - chatManager.$messages - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - if let sessionId = self?.chatManager.currentSession?.id { - self?.updateMessages(for: sessionId) - } - } - .store(in: &cancellables) - } - override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) navigationController!.setNavigationBarHidden(true, animated: animated) @@ -154,64 +105,12 @@ class MainViewController: UIViewController { } // MARK: - Chat Methods - - private func updateMessages(for sessionId: String?) { - guard let sessionId else { - messages = [] - updateEmptyState() - tableView.reloadData() - return - } - - messages = chatManager.messages[sessionId] ?? [] - updateEmptyState() - tableView.reloadData() - - if !messages.isEmpty { - let indexPath = IndexPath(row: messages.count - 1, section: 0) - tableView.scrollToRow(at: indexPath, at: .bottom, animated: true) - } - } - - private func updateEmptyState() { - emptyStateView.isHidden = !messages.isEmpty - tableView.isHidden = messages.isEmpty - } - - // MARK: - Internal Methods for Preview/Testing - - #if DEBUG - func setMessagesForPreview(_ previewMessages: [ChatMessage]) { - messages = previewMessages - updateEmptyState() - tableView.reloadData() - } - #endif } -// MARK: - UITableViewDataSource +// MARK: - ChatTableViewDelegate -extension MainViewController: UITableViewDataSource { - func tableView(_: UITableView, numberOfRowsInSection _: Int) -> Int { - messages.count - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(withIdentifier: "ChatCell", for: indexPath) as! ChatCell - let message = messages[indexPath.row] - cell.configure(with: message) - return cell - } -} - -// MARK: - UITableViewDelegate - -extension MainViewController: UITableViewDelegate { - func tableView(_: UITableView, heightForRowAt _: IndexPath) -> CGFloat { - UITableView.automaticDimension - } - - func tableView(_: UITableView, estimatedHeightForRowAt _: IndexPath) -> CGFloat { - 60 +extension MainViewController: ChatTableViewDelegate { + func chatTableView(_: ChatTableView, didSelectRowAt _: IndexPath) { + // Handle cell selection if needed } } diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/AssistantMessageCell.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/AssistantMessageCell.swift new file mode 100644 index 000000000..3431731c6 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/AssistantMessageCell.swift @@ -0,0 +1,139 @@ +// +// AssistantMessageCell.swift +// Intelligents +// +// Created by 秋星桥 on 6/27/25. +// + +import SnapKit +import Then +import UIKit + +class AssistantMessageCell: ChatBaseCell { + // MARK: - UI Components + + private lazy var messageLabel = UILabel().then { + $0.numberOfLines = 0 + $0.font = .systemFont(ofSize: 16) + $0.textColor = .label + } + + private lazy var metadataStackView = UIStackView().then { + $0.axis = .horizontal + $0.spacing = 8 + $0.alignment = .center + } + + private lazy var modelLabel = UILabel().then { + $0.font = .systemFont(ofSize: 12, weight: .medium) + $0.textColor = .secondaryLabel + } + + private lazy var tokensLabel = UILabel().then { + $0.font = .systemFont(ofSize: 12) + $0.textColor = .secondaryLabel + } + + private lazy var timestampLabel = UILabel().then { + $0.font = .systemFont(ofSize: 12) + $0.textColor = .secondaryLabel + } + + private lazy var streamingIndicator = UIActivityIndicatorView().then { + $0.style = .medium + $0.hidesWhenStopped = true + } + + private lazy var retryButton = UIButton(type: .system).then { + $0.setTitle("重试", for: .normal) + $0.titleLabel?.font = .systemFont(ofSize: 12) + $0.setTitleColor(.systemBlue, for: .normal) + } + + private lazy var mainStackView = UIStackView().then { + $0.axis = .vertical + $0.spacing = 8 + $0.alignment = .fill + } + + // MARK: - Properties + + private var viewModel: AssistantMessageCellViewModel? + + // MARK: - Setup + + override func setupContentView() { + containerView.addSubview(mainStackView) + + mainStackView.addArrangedSubview(messageLabel) + mainStackView.addArrangedSubview(metadataStackView) + + metadataStackView.addArrangedSubview(modelLabel) + metadataStackView.addArrangedSubview(tokensLabel) + metadataStackView.addArrangedSubview(UIView()) // Spacer + metadataStackView.addArrangedSubview(streamingIndicator) + metadataStackView.addArrangedSubview(retryButton) + metadataStackView.addArrangedSubview(timestampLabel) + + mainStackView.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(contentInsets) + } + + retryButton.addTarget(self, action: #selector(retryButtonTapped), for: .touchUpInside) + } + + // MARK: - Configuration + + override func configure(with viewModel: ChatCellViewModel) { + guard let assistantViewModel = viewModel as? AssistantMessageCellViewModel else { return } + self.viewModel = assistantViewModel + + messageLabel.text = assistantViewModel.content + configureContainer(backgroundColor: backgroundColor(for: assistantViewModel.cellType)) + + // 配置模型信息 + if let model = assistantViewModel.model { + modelLabel.text = model + modelLabel.isHidden = false + } else { + modelLabel.isHidden = true + } + + // 配置 tokens 信息 + if let tokens = assistantViewModel.tokens { + tokensLabel.text = "\(tokens) tokens" + tokensLabel.isHidden = false + } else { + tokensLabel.isHidden = true + } + + // 配置时间戳 + let timestamp = assistantViewModel.timestamp + timestampLabel.text = formatTimestamp(timestamp) + timestampLabel.isHidden = false + + // 配置流式状态 + if assistantViewModel.isStreaming { + streamingIndicator.startAnimating() + } else { + streamingIndicator.stopAnimating() + } + + // 配置重试按钮 + retryButton.isHidden = !assistantViewModel.canRetry + } + + // MARK: - Actions + + @objc private func retryButtonTapped() { + // TODO: 实现重试逻辑 + } + + // MARK: - Helpers + + private func formatTimestamp(_ timestamp: Date) -> String { + let formatter = DateFormatter() + formatter.timeStyle = .short + return formatter.string(from: timestamp) + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ChatBaseCell.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ChatBaseCell.swift new file mode 100644 index 000000000..684780751 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ChatBaseCell.swift @@ -0,0 +1,112 @@ +// +// ChatBaseCell.swift +// Intelligents +// +// Created by 秋星桥 on 6/27/25. +// + +import SnapKit +import Then +import UIKit + +class ChatBaseCell: UITableViewCell { + // MARK: - UI Components + + /// 主容器视图,负责管理内边距和统一行为 + lazy var containerView = UIView().then { + $0.layer.cornerRadius = 8 + $0.layer.cornerCurve = .continuous + } + + // MARK: - Properties + + /// 容器视图的内边距,子类可以重写 + var containerInsets: UIEdgeInsets { + UIEdgeInsets(top: 8, left: 16, bottom: 8, right: 16) + } + + /// 容器视图内部的内边距,子类可以重写 + var contentInsets: UIEdgeInsets { + UIEdgeInsets(top: 12, left: 12, bottom: 12, right: 12) + } + + // MARK: - Initialization + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setupBaseUI() + setupContentView() + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Setup + + private func setupBaseUI() { + backgroundColor = .clear + selectionStyle = .none + + contentView.addSubview(containerView) + containerView.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(containerInsets) + } + } + + /// 子类重写此方法来设置具体的内容视图 + func setupContentView() { + // 子类实现 + } + + // MARK: - Configuration + + /// 配置容器视图的外观 + func configureContainer(backgroundColor: UIColor?, borderColor: UIColor? = nil, borderWidth: CGFloat = 0) { + containerView.backgroundColor = backgroundColor + + if let borderColor { + containerView.layer.borderColor = borderColor.cgColor + containerView.layer.borderWidth = borderWidth + } else { + containerView.layer.borderColor = nil + containerView.layer.borderWidth = 0 + } + } + + /// 配置 ViewModel,子类需要重写 + func configure(with _: ChatCellViewModel) { + // 子类实现 + } + + // MARK: - Helpers + + /// 获取适当的文本颜色 + func textColor(for cellType: CellType) -> UIColor { + switch cellType { + case .userMessage, .assistantMessage, .systemMessage: + .label + case .error: + .systemRed + case .loading: + .secondaryLabel + default: + .label + } + } + + /// 获取适当的背景颜色 + func backgroundColor(for cellType: CellType) -> UIColor? { + switch cellType { + case .userMessage, .assistantMessage: + .clear + case .systemMessage: + .systemYellow.withAlphaComponent(0.2) + case .error: + .systemRed.withAlphaComponent(0.1) + case .loading: + .systemGray6 + } + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ChatCell.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ChatCell.swift deleted file mode 100644 index b56b88099..000000000 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ChatCell.swift +++ /dev/null @@ -1,155 +0,0 @@ -// -// ChatCell.swift -// Intelligents -// -// Created by 秋星桥 on 6/26/25. -// - -import SnapKit -import Then -import UIKit - -class ChatCell: UITableViewCell { - // MARK: - UI Components - - private lazy var avatarImageView = UIImageView().then { - $0.contentMode = .scaleAspectFit - $0.layer.cornerRadius = 16 - $0.layer.cornerCurve = .continuous - $0.clipsToBounds = true - $0.backgroundColor = .systemGray5 - } - - private lazy var messageContainerView = UIView().then { - $0.layer.cornerRadius = 12 - $0.layer.cornerCurve = .continuous - } - - private lazy var messageLabel = UILabel().then { - $0.numberOfLines = 0 - $0.font = .systemFont(ofSize: 16) - $0.textColor = .label - } - - private lazy var timestampLabel = UILabel().then { - $0.font = .systemFont(ofSize: 12) - $0.textColor = .systemGray - $0.textAlignment = .right - } - - private lazy var stackView = UIStackView().then { - $0.axis = .horizontal - $0.spacing = 12 - $0.alignment = .top - } - - private lazy var messageStackView = UIStackView().then { - $0.axis = .vertical - $0.spacing = 4 - } - - // MARK: - Properties - - private var message: ChatMessage? - - // MARK: - Initialization - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - setupUI() - } - - @available(*, unavailable) - required init?(coder _: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: - Setup - - private func setupUI() { - backgroundColor = .clear - selectionStyle = .none - - contentView.addSubview(stackView) - - messageStackView.addArrangedSubview(messageContainerView) - messageStackView.addArrangedSubview(timestampLabel) - - messageContainerView.addSubview(messageLabel) - - stackView.addArrangedSubview(avatarImageView) - stackView.addArrangedSubview(messageStackView) - - stackView.snp.makeConstraints { make in - make.edges.equalToSuperview().inset(16) - } - - avatarImageView.snp.makeConstraints { make in - make.size.equalTo(32) - } - - messageLabel.snp.makeConstraints { make in - make.edges.equalToSuperview().inset(12) - } - - messageStackView.snp.makeConstraints { make in - make.width.lessThanOrEqualTo(250) - } - } - - // MARK: - Configuration - - func configure(with message: ChatMessage) { - self.message = message - - messageLabel.text = message.content - - if let createdDate = message.createdDate { - let formatter = DateFormatter() - formatter.dateStyle = .none - formatter.timeStyle = .short - timestampLabel.text = formatter.string(from: createdDate) - } else { - timestampLabel.text = "" - } - - switch message.role { - case .user: - configureUserMessage() - case .assistant: - configureAssistantMessage() - case .system: - configureSystemMessage() - } - } - - private func configureUserMessage() { - // User message - align to right - stackView.semanticContentAttribute = .forceRightToLeft - messageContainerView.backgroundColor = .systemBlue - messageLabel.textColor = .white - avatarImageView.image = UIImage(systemName: "person.circle.fill") - avatarImageView.tintColor = .systemBlue - timestampLabel.textAlignment = .left - } - - private func configureAssistantMessage() { - // Assistant message - align to left - stackView.semanticContentAttribute = .forceLeftToRight - messageContainerView.backgroundColor = .systemGray6 - messageLabel.textColor = .label - avatarImageView.image = UIImage(systemName: "brain.head.profile") - avatarImageView.tintColor = .systemPurple - timestampLabel.textAlignment = .right - } - - private func configureSystemMessage() { - // System message - center aligned - stackView.semanticContentAttribute = .forceLeftToRight - messageContainerView.backgroundColor = .systemYellow.withAlphaComponent(0.3) - messageLabel.textColor = .label - avatarImageView.image = UIImage(systemName: "gear") - avatarImageView.tintColor = .systemOrange - timestampLabel.textAlignment = .center - } -} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ChatCellFactory.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ChatCellFactory.swift new file mode 100644 index 000000000..c1abc556f --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ChatCellFactory.swift @@ -0,0 +1,69 @@ +// +// ChatCellFactory.swift +// Intelligents +// +// Created by 秋星桥 on 6/27/25. +// + +import UIKit + +class ChatCellFactory { + // MARK: - Cell Registration + + static func registerCells(for tableView: UITableView) { + tableView.register(UserMessageCell.self, forCellReuseIdentifier: CellType.userMessage.rawValue) + tableView.register(AssistantMessageCell.self, forCellReuseIdentifier: CellType.assistantMessage.rawValue) + tableView.register(SystemMessageCell.self, forCellReuseIdentifier: CellType.systemMessage.rawValue) + tableView.register(LoadingCell.self, forCellReuseIdentifier: CellType.loading.rawValue) + tableView.register(ErrorCell.self, forCellReuseIdentifier: CellType.error.rawValue) + } + + // MARK: - Cell Creation + + static func dequeueCell( + for tableView: UITableView, + at indexPath: IndexPath, + with viewModel: ChatCellViewModel + ) -> ChatBaseCell { + let identifier = viewModel.cellType.rawValue + + guard let cell = tableView.dequeueReusableCell( + withIdentifier: identifier, + for: indexPath + ) as? ChatBaseCell else { + // 如果无法获取指定类型的cell,使用系统消息cell作为fallback + let fallbackCell = tableView.dequeueReusableCell( + withIdentifier: CellType.systemMessage.rawValue, + for: indexPath + ) as! SystemMessageCell + + // 创建一个fallback的ViewModel + let fallbackViewModel = SystemMessageCellViewModel( + id: viewModel.id, + content: "不支持的消息类型: \\(viewModel.cellType.rawValue)", + timestamp: Date() + ) + fallbackCell.configure(with: fallbackViewModel) + return fallbackCell + } + + cell.configure(with: viewModel) + return cell + } + + // MARK: - Height Estimation + + static func estimatedHeight(for viewModel: ChatCellViewModel) -> CGFloat { + switch viewModel.cellType { + case .userMessage, + .assistantMessage: + 80 + case .systemMessage: + 60 + case .loading: + 100 + case .error: + 120 + } + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ContextReferenceCell.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ContextReferenceCell.swift new file mode 100644 index 000000000..e69de29bb diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ErrorCell.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ErrorCell.swift new file mode 100644 index 000000000..af15e4c55 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ErrorCell.swift @@ -0,0 +1,97 @@ +// +// ErrorCell.swift +// Intelligents +// +// Created by 秋星桥 on 6/27/25. +// + +import SnapKit +import Then +import UIKit + +class ErrorCell: ChatBaseCell { + // MARK: - UI Components + + private lazy var iconView = UIImageView().then { + $0.image = UIImage(systemName: "exclamationmark.triangle.fill") + $0.tintColor = .systemRed + $0.contentMode = .scaleAspectFit + } + + private lazy var errorLabel = UILabel().then { + $0.numberOfLines = 0 + $0.font = .systemFont(ofSize: 14, weight: .medium) + $0.textColor = .systemRed + } + + private lazy var retryButton = UIButton(type: .system).then { + $0.setTitle("Retry", for: .normal) + $0.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium) + $0.setTitleColor(.systemBlue, for: .normal) + $0.backgroundColor = .systemBlue.withAlphaComponent(0.1) + $0.layer.cornerRadius = 8 + $0.layer.cornerCurve = .continuous + $0.contentEdgeInsets = UIEdgeInsets(top: 8, left: 16, bottom: 8, right: 16) + } + + private lazy var contentStackView = UIStackView().then { + $0.axis = .horizontal + $0.spacing = 12 + $0.alignment = .top + } + + private lazy var textStackView = UIStackView().then { + $0.axis = .vertical + $0.spacing = 12 + $0.alignment = .fill + } + + // MARK: - Properties + + private var viewModel: ErrorCellViewModel? + + // MARK: - Setup + + override func setupContentView() { + containerView.addSubview(contentStackView) + + contentStackView.addArrangedSubview(iconView) + contentStackView.addArrangedSubview(textStackView) + + textStackView.addArrangedSubview(errorLabel) + textStackView.addArrangedSubview(retryButton) + + contentStackView.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(contentInsets) + } + + iconView.snp.makeConstraints { make in + make.width.height.equalTo(24) + } + + retryButton.addTarget(self, action: #selector(retryButtonTapped), for: .touchUpInside) + } + + // MARK: - Configuration + + override func configure(with viewModel: any ChatCellViewModel) { + guard let errorViewModel = viewModel as? ErrorCellViewModel else { + assertionFailure() + return + } + self.viewModel = errorViewModel + + errorLabel.text = errorViewModel.errorMessage + configureContainer( + backgroundColor: backgroundColor(for: errorViewModel.cellType), + borderColor: .systemRed.withAlphaComponent(0.3), + borderWidth: 1 + ) + } + + // MARK: - Actions + + @objc private func retryButtonTapped() { + // TODO: 实现重试逻辑 + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/LoadingCell.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/LoadingCell.swift new file mode 100644 index 000000000..1e2d92b29 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/LoadingCell.swift @@ -0,0 +1,89 @@ +// +// LoadingCell.swift +// Intelligents +// +// Created by 秋星桥 on 6/27/25. +// + +import SnapKit +import Then +import UIKit + +class LoadingCell: ChatBaseCell { + // MARK: - UI Components + + private lazy var activityIndicator = UIActivityIndicatorView().then { + $0.style = .medium + $0.hidesWhenStopped = false + } + + private lazy var messageLabel = UILabel().then { + $0.numberOfLines = 0 + $0.font = .systemFont(ofSize: 14) + $0.textColor = .secondaryLabel + $0.textAlignment = .center + } + + private lazy var progressView = UIProgressView().then { + $0.progressViewStyle = .default + $0.trackTintColor = .systemGray5 + $0.progressTintColor = .systemBlue + } + + private lazy var stackView = UIStackView().then { + $0.axis = .vertical + $0.spacing = 12 + $0.alignment = .center + } + + // MARK: - Properties + + private var viewModel: LoadingCellViewModel? + + // MARK: - Setup + + override func setupContentView() { + containerView.addSubview(stackView) + + stackView.addArrangedSubview(activityIndicator) + stackView.addArrangedSubview(messageLabel) + stackView.addArrangedSubview(progressView) + + stackView.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(contentInsets) + } + + progressView.snp.makeConstraints { make in + make.leading.trailing.equalToSuperview() + make.height.equalTo(4) + } + + activityIndicator.startAnimating() + } + + // MARK: - Configuration + + override func configure(with viewModel: ChatCellViewModel) { + guard let loadingViewModel = viewModel as? LoadingCellViewModel else { return } + self.viewModel = loadingViewModel + + configureContainer(backgroundColor: backgroundColor(for: loadingViewModel.cellType)) + + // 配置消息 + if let message = loadingViewModel.message { + messageLabel.text = message + messageLabel.isHidden = false + } else { + messageLabel.text = "Processing..." + messageLabel.isHidden = false + } + + // 配置进度 + if let progress = loadingViewModel.progress { + progressView.progress = Float(progress) + progressView.isHidden = false + } else { + progressView.isHidden = true + } + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/SystemMessageCell.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/SystemMessageCell.swift new file mode 100644 index 000000000..97efc6959 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/SystemMessageCell.swift @@ -0,0 +1,94 @@ +// +// SystemMessageCell.swift +// Intelligents +// +// Created by 秋星桥 on 6/27/25. +// + +import SnapKit +import Then +import UIKit + +class SystemMessageCell: ChatBaseCell { + // MARK: - UI Components + + private lazy var iconView = UIImageView().then { + $0.image = UIImage(systemName: "info.circle.fill") + $0.tintColor = .systemOrange + $0.contentMode = .scaleAspectFit + } + + private lazy var messageLabel = UILabel().then { + $0.numberOfLines = 0 + $0.font = .systemFont(ofSize: 14, weight: .medium) + $0.textColor = .label + } + + private lazy var timestampLabel = UILabel().then { + $0.font = .systemFont(ofSize: 12) + $0.textColor = .secondaryLabel + $0.textAlignment = .right + } + + private lazy var contentStackView = UIStackView().then { + $0.axis = .horizontal + $0.spacing = 12 + $0.alignment = .top + } + + private lazy var textStackView = UIStackView().then { + $0.axis = .vertical + $0.spacing = 4 + $0.alignment = .fill + } + + // MARK: - Properties + + private var viewModel: SystemMessageCellViewModel? + + // MARK: - Setup + + override func setupContentView() { + containerView.addSubview(contentStackView) + + contentStackView.addArrangedSubview(iconView) + contentStackView.addArrangedSubview(textStackView) + + textStackView.addArrangedSubview(messageLabel) + textStackView.addArrangedSubview(timestampLabel) + + contentStackView.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(contentInsets) + } + + iconView.snp.makeConstraints { make in + make.width.height.equalTo(20) + } + } + + // MARK: - Configuration + + override func configure(with viewModel: ChatCellViewModel) { + guard let systemViewModel = viewModel as? SystemMessageCellViewModel else { return } + self.viewModel = systemViewModel + + messageLabel.text = systemViewModel.content + configureContainer(backgroundColor: backgroundColor(for: systemViewModel.cellType)) + + // 配置时间戳 + if let timestamp = systemViewModel.timestamp { + timestampLabel.text = formatTimestamp(timestamp) + timestampLabel.isHidden = false + } else { + timestampLabel.isHidden = true + } + } + + // MARK: - Helpers + + private func formatTimestamp(_ timestamp: Date) -> String { + let formatter = DateFormatter() + formatter.timeStyle = .short + return formatter.string(from: timestamp) + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/UserMessageCell.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/UserMessageCell.swift new file mode 100644 index 000000000..f4a40c0d7 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/UserMessageCell.swift @@ -0,0 +1,94 @@ +// +// UserMessageCell.swift +// Intelligents +// +// Created by 秋星桥 on 6/27/25. +// + +import SnapKit +import Then +import UIKit + +class UserMessageCell: ChatBaseCell { + // MARK: - UI Components + + private lazy var messageLabel = UILabel().then { + $0.numberOfLines = 0 + $0.font = .systemFont(ofSize: 16) + $0.textColor = .label + } + + private lazy var timestampLabel = UILabel().then { + $0.font = .systemFont(ofSize: 12) + $0.textColor = .secondaryLabel + $0.textAlignment = .right + } + + private lazy var retryIndicator = UIActivityIndicatorView().then { + $0.style = .medium + $0.hidesWhenStopped = true + } + + private lazy var stackView = UIStackView().then { + $0.axis = .vertical + $0.spacing = 8 + $0.alignment = .fill + } + + // MARK: - Properties + + private var viewModel: UserMessageCellViewModel? + + // MARK: - Setup + + override func setupContentView() { + containerView.addSubview(stackView) + stackView.addArrangedSubview(messageLabel) + + let bottomContainer = UIView() + stackView.addArrangedSubview(bottomContainer) + + bottomContainer.addSubview(retryIndicator) + bottomContainer.addSubview(timestampLabel) + + stackView.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(contentInsets) + } + + retryIndicator.snp.makeConstraints { make in + make.leading.centerY.equalToSuperview() + make.width.height.equalTo(16) + } + + timestampLabel.snp.makeConstraints { make in + make.trailing.top.bottom.equalToSuperview() + make.leading.greaterThanOrEqualTo(retryIndicator.snp.trailing).offset(8) + } + + bottomContainer.snp.makeConstraints { make in + make.height.equalTo(16) + } + } + + // MARK: - Configuration + + override func configure(with viewModel: any ChatCellViewModel) { + guard let userViewModel = viewModel as? UserMessageCellViewModel else { return } + self.viewModel = userViewModel + + messageLabel.text = userViewModel.content + configureContainer(backgroundColor: backgroundColor(for: userViewModel.cellType)) + + let timestamp = userViewModel.timestamp + timestampLabel.text = formatTimestamp(timestamp) + timestampLabel.isHidden = false + } + + // MARK: - Helpers + + private func formatTimestamp(_ timestamp: Date) -> String { + let formatter = DateFormatter() + formatter.timeStyle = .short + return formatter.string(from: timestamp) + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/AssistantMessageCellViewModel.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/AssistantMessageCellViewModel.swift new file mode 100644 index 000000000..d68706789 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/AssistantMessageCellViewModel.swift @@ -0,0 +1,44 @@ +// +// AssistantMessageCellViewModel.swift +// Intelligents +// +// Created by 秋星桥 on 6/27/25. +// + +import Foundation + +struct AssistantMessageCellViewModel: ChatCellViewModel { + var cellType: CellType = .assistantMessage + var id: UUID + var content: String + var timestamp: Date + var isStreaming: Bool = false + var model: String? + var tokens: Int? + var canRetry: Bool = false + var citations: [CitationViewModel]? + var actions: [MessageActionViewModel]? +} + +struct CitationViewModel: Codable, Identifiable, Equatable, Hashable { + var id: String + var title: String + var url: String? + var snippet: String? +} + +struct MessageActionViewModel: Codable, Identifiable, Equatable, Hashable { + var id: String + var title: String + var actionType: ActionType + var data: [String: String]? + + enum ActionType: String, Codable { + case copy + case regenerate + case like + case dislike + case share + case edit + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/AttachmentCellViewModel.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/AttachmentCellViewModel.swift deleted file mode 100644 index fe12fe4a6..000000000 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/AttachmentCellViewModel.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// AttachmentCellViewModel.swift -// Intelligents -// -// Created by 秋星桥 on 6/26/25. -// - -import Foundation - -struct AttachmentCellViewModel: ChatCellViewModel { - var cellType: CellType = .attachment - var id: String - var attachments: [AttachmentViewModel] - var parentMessageId: String -} - -struct AttachmentViewModel: Codable, Identifiable, Equatable, Hashable { - var id: String - var url: String - var mimeType: String? - var fileName: String? - var size: Int64? -} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/CellType.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/CellType.swift index c5ee996ca..feadca0e5 100644 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/CellType.swift +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/CellType.swift @@ -7,14 +7,10 @@ import Foundation -enum CellType: String, Codable, CaseIterable { +public enum CellType: String, Codable, CaseIterable { case userMessage case assistantMessage case systemMessage - case attachment - case contextReference - case workflowStatus - case transcription case loading case error } diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/ChatCellViewModel.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/ChatCellViewModel.swift new file mode 100644 index 000000000..24a43bf15 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/ChatCellViewModel.swift @@ -0,0 +1,13 @@ +// +// ChatCellViewModel.swift +// Intelligents +// +// Created by 秋星桥 on 6/26/25. +// + +import Foundation + +public protocol ChatCellViewModel: Codable, Identifiable, Equatable, Hashable { + var id: UUID { get } + var cellType: CellType { get } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/ChatCellViewModels.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/ChatCellViewModels.swift deleted file mode 100644 index 57380b9d2..000000000 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/ChatCellViewModels.swift +++ /dev/null @@ -1,41 +0,0 @@ -// -// ChatCellViewModels.swift -// Intelligents -// -// Created by 秋星桥 on 6/26/25. -// - -import Foundation - -protocol ChatCellViewModel: Codable, Identifiable, Equatable, Hashable { - var cellType: CellType { get } - var id: String { get } -} - -struct UserMessageCellViewModel: ChatCellViewModel { - var cellType: CellType = .userMessage - var id: String - var content: String - var attachments: [AttachmentViewModel] - var timestamp: Date? - var isRetrying: Bool -} - -struct AssistantMessageCellViewModel: ChatCellViewModel { - var cellType: CellType = .assistantMessage - var id: String - var content: String - var attachments: [AttachmentViewModel] - var timestamp: Date? - var isStreaming: Bool - var model: String? - var tokens: Int? - var canRetry: Bool -} - -struct SystemMessageCellViewModel: ChatCellViewModel { - var cellType: CellType = .systemMessage - var id: String - var content: String - var timestamp: Date? -} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/ContextReferenceCellViewModel.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/ContextReferenceCellViewModel.swift deleted file mode 100644 index 487c79d4c..000000000 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/ContextReferenceCellViewModel.swift +++ /dev/null @@ -1,15 +0,0 @@ -// -// ContextReferenceCellViewModel.swift -// Intelligents -// -// Created by 秋星桥 on 6/26/25. -// - -import Foundation - -struct ContextReferenceCellViewModel: ChatCellViewModel { - var cellType: CellType = .contextReference - var id: String - var references: [ChatManager.ContextReference] - var parentMessageId: String -} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/ErrorCellViewModel.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/ErrorCellViewModel.swift index 7b8d8d1ba..66bf6ee71 100644 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/ErrorCellViewModel.swift +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/ErrorCellViewModel.swift @@ -9,8 +9,6 @@ import Foundation struct ErrorCellViewModel: ChatCellViewModel { var cellType: CellType = .error - var id: String + var id: UUID var errorMessage: String - var canRetry: Bool - var retryAction: String? } diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/LoadingCellViewModel.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/LoadingCellViewModel.swift index d9fd376f7..caed2ad61 100644 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/LoadingCellViewModel.swift +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/LoadingCellViewModel.swift @@ -9,7 +9,7 @@ import Foundation struct LoadingCellViewModel: ChatCellViewModel { var cellType: CellType = .loading - var id: String + var id: UUID var message: String? var progress: Double? } diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/SystemMessageCellViewModel.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/SystemMessageCellViewModel.swift new file mode 100644 index 000000000..06c1fc31c --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/SystemMessageCellViewModel.swift @@ -0,0 +1,15 @@ +// +// SystemMessageCellViewModel.swift +// Intelligents +// +// Created by 秋星桥 on 6/27/25. +// + +import Foundation + +struct SystemMessageCellViewModel: ChatCellViewModel { + var cellType: CellType = .systemMessage + var id: UUID + var content: String + var timestamp: Date? +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/UserMessageCellViewModel.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/UserMessageCellViewModel.swift new file mode 100644 index 000000000..8f9a7d98f --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/UserMessageCellViewModel.swift @@ -0,0 +1,16 @@ +// +// UserMessageCellViewModel.swift +// Intelligents +// +// Created by 秋星桥 on 6/27/25. +// + +import Foundation + +struct UserMessageCellViewModel: ChatCellViewModel { + var cellType: CellType = .userMessage + var id: UUID + var content: String + var timestamp: Date + var attachments: [String] = [] +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/WorkflowStatusCellViewModel.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/WorkflowStatusCellViewModel.swift deleted file mode 100644 index a282931d7..000000000 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/WorkflowStatusCellViewModel.swift +++ /dev/null @@ -1,15 +0,0 @@ -// -// WorkflowStatusCellViewModel.swift -// Intelligents -// -// Created by 秋星桥 on 6/26/25. -// - -import Foundation - -struct WorkflowStatusCellViewModel: ChatCellViewModel { - var cellType: CellType = .workflowStatus - var id: String - var workflow: ChatManager.WorkflowEventData - var parentMessageId: String -} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatTableView/ChatTableView.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatTableView/ChatTableView.swift new file mode 100644 index 000000000..bd8c20ad4 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatTableView/ChatTableView.swift @@ -0,0 +1,154 @@ +import Combine +import OrderedCollections +import SnapKit +import Then +import UIKit + +protocol ChatTableViewDelegate: AnyObject { + func chatTableView(_ tableView: ChatTableView, didSelectRowAt indexPath: IndexPath) +} + +class ChatTableView: UIView { + // MARK: - UI Components + + lazy var tableView = UITableView().then { + $0.backgroundColor = .clear + $0.separatorStyle = .none + $0.delegate = self + $0.dataSource = self + $0.keyboardDismissMode = .interactive + $0.contentInsetAdjustmentBehavior = .never + $0.tableFooterView = UIView(frame: .init(x: 0, y: 0, width: 100, height: 500)) + } + + lazy var emptyStateView = UIView().then { + $0.isHidden = true + } + + lazy var emptyStateLabel = UILabel().then { + $0.text = "Start a conversation..." + $0.font = .systemFont(ofSize: 18, weight: .medium) + $0.textColor = .systemGray + $0.textAlignment = .center + } + + // MARK: - Properties + + weak var delegate: ChatTableViewDelegate? + var sessionId: String? { + didSet { + if let sessionId { + bindToSession(sessionId) + } + } + } + + private var cancellables = Set() + + var cellViewModels: OrderedDictionary = [:] { + didSet { + updateEmptyState() + tableView.reloadData() + + if !cellViewModels.isEmpty { + let indexPath = IndexPath(row: cellViewModels.count - 1, section: 0) + tableView.scrollToRow(at: indexPath, at: .bottom, animated: true) + } + } + } + + // MARK: - Initialization + + override init(frame: CGRect) { + super.init(frame: frame) + setupUI() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setupUI() + } + + // MARK: - Setup + + private func setupUI() { + // 注册所有 cell 类型 + ChatCellFactory.registerCells(for: tableView) + + addSubview(tableView) + addSubview(emptyStateView) + + emptyStateView.addSubview(emptyStateLabel) + + tableView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + + emptyStateView.snp.makeConstraints { make in + make.center.equalTo(tableView) + make.width.lessThanOrEqualTo(tableView).inset(32) + } + + emptyStateLabel.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + } + + // MARK: - Public Methods + + func scrollToBottom(animated: Bool = true) { + guard !cellViewModels.isEmpty else { return } + let indexPath = IndexPath(row: cellViewModels.count - 1, section: 0) + tableView.scrollToRow(at: indexPath, at: .bottom, animated: animated) + } + + // MARK: - Private Methods + + private func bindToSession(_ sessionId: String) { + cancellables.removeAll() + + ChatManager.shared.$viewModels + .map { $0[sessionId] ?? [:] } + .receive(on: DispatchQueue.main) + .sink { [weak self] viewModels in + self?.cellViewModels = viewModels + } + .store(in: &cancellables) + } + + private func updateEmptyState() { + emptyStateView.isHidden = !cellViewModels.isEmpty + tableView.isHidden = cellViewModels.isEmpty + } +} + +// MARK: - UITableViewDataSource + +extension ChatTableView: UITableViewDataSource { + func tableView(_: UITableView, numberOfRowsInSection _: Int) -> Int { + cellViewModels.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let viewModel = cellViewModels.elements[indexPath.row].value + return ChatCellFactory.dequeueCell(for: tableView, at: indexPath, with: viewModel) + } +} + +// MARK: - UITableViewDelegate + +extension ChatTableView: UITableViewDelegate { + func tableView(_: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + let viewModel = cellViewModels.elements[indexPath.row].value + return ChatCellFactory.estimatedHeight(for: viewModel) + } + + func tableView(_: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { + let viewModel = cellViewModels.elements[indexPath.row].value + return ChatCellFactory.estimatedHeight(for: viewModel) + } + + func tableView(_: UITableView, didSelectRowAt indexPath: IndexPath) { + delegate?.chatTableView(self, didSelectRowAt: indexPath) + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBox+Delegates.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBox+Delegates.swift index e818f38e5..329ed8002 100644 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBox+Delegates.swift +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBox+Delegates.swift @@ -85,4 +85,20 @@ extension InputBox: UITextViewDelegate { updatePlaceholderVisibility() updateTextViewHeight() } + + func textView(_: UITextView, shouldChangeTextIn _: NSRange, replacementText text: String) -> Bool { + if text == "\n" { + delegate?.inputBoxDidSend(self) + return false + } + return true + } + + func textView(_ textView: UITextView, editMenuForTextIn _: NSRange, suggestedActions: [UIMenuElement]) -> UIMenu? { + let insertNewLineAction = UIAction(title: "Insert New Line") { _ in + textView.insertText("\n") + } + + return UIMenu(children: suggestedActions + [insertNewLineAction]) + } } diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBox.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBox.swift index 8dfe62a15..eeea5641c 100644 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBox.swift +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBox.swift @@ -30,6 +30,7 @@ class InputBox: UIView { $0.textContainerInset = .zero $0.delegate = self $0.text = "" + $0.returnKeyType = .send } lazy var placeholderLabel = UILabel().then { @@ -147,7 +148,7 @@ class InputBox: UIView { } .store(in: &cancellables) - viewModel.$isNetworkEnabled + viewModel.$isSearchEnabled .removeDuplicates() .sink { [weak self] enabled in self?.functionBar.updateNetworkState(isEnabled: enabled) diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBoxFunctionBar.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBoxFunctionBar.swift index 5151eabe2..549e59f03 100644 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBoxFunctionBar.swift +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/InputBoxFunctionBar.swift @@ -5,6 +5,16 @@ import UIKit private let unselectedColor: UIColor = .affineIconPrimary private let selectedColor: UIColor = .affineIconActivated +private let configurableOptions: [ConfigurableOptions] = [ + .networking, + .reasoning, +] +enum ConfigurableOptions { + case tool + case networking + case reasoning +} + class InputBoxFunctionBar: UIView { weak var delegate: InputBoxFunctionBarDelegate? @@ -23,6 +33,7 @@ class InputBoxFunctionBar: UIView { $0.tintColor = unselectedColor $0.imageView?.contentMode = .scaleAspectFit $0.addTarget(self, action: #selector(toolButtonTapped), for: .touchUpInside) + $0.isHidden = !configurableOptions.contains(.tool) } lazy var networkButton = UIButton(type: .system).then { @@ -30,6 +41,7 @@ class InputBoxFunctionBar: UIView { $0.tintColor = unselectedColor $0.imageView?.contentMode = .scaleAspectFit $0.addTarget(self, action: #selector(networkButtonTapped), for: .touchUpInside) + $0.isHidden = !configurableOptions.contains(.networking) } lazy var deepThinkingButton = UIButton(type: .system).then { @@ -37,6 +49,7 @@ class InputBoxFunctionBar: UIView { $0.tintColor = unselectedColor $0.imageView?.contentMode = .scaleAspectFit $0.addTarget(self, action: #selector(deepThinkingButtonTapped), for: .touchUpInside) + $0.isHidden = !configurableOptions.contains(.reasoning) } lazy var sendButton = UIButton(type: .system).then { diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/ViewModel/InputBoxViewModel.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/ViewModel/InputBoxViewModel.swift index 83e6c364d..769d44fbb 100644 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/ViewModel/InputBoxViewModel.swift +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/ViewModel/InputBoxViewModel.swift @@ -16,16 +16,16 @@ public struct InputBoxData { public var fileAttachments: [FileAttachment] = [] public var documentAttachments: [DocumentAttachment] = [] public var isToolEnabled: Bool - public var isNetworkEnabled: Bool + public var isSearchEnabled: Bool public var isDeepThinkingEnabled: Bool - public init(text: String, imageAttachments: [ImageAttachment], fileAttachments: [FileAttachment], documentAttachments: [DocumentAttachment], isToolEnabled: Bool, isNetworkEnabled: Bool, isDeepThinkingEnabled: Bool) { + public init(text: String, imageAttachments: [ImageAttachment], fileAttachments: [FileAttachment], documentAttachments: [DocumentAttachment], isToolEnabled: Bool, isSearchEnabled: Bool, isDeepThinkingEnabled: Bool) { self.text = text self.imageAttachments = imageAttachments self.fileAttachments = fileAttachments self.documentAttachments = documentAttachments self.isToolEnabled = isToolEnabled - self.isNetworkEnabled = isNetworkEnabled + self.isSearchEnabled = isSearchEnabled self.isDeepThinkingEnabled = isDeepThinkingEnabled } } @@ -37,7 +37,7 @@ public class InputBoxViewModel: ObservableObject { @Published public var inputText: String = "" @Published public var isToolEnabled: Bool = false - @Published public var isNetworkEnabled: Bool = false + @Published public var isSearchEnabled: Bool = false @Published public var isDeepThinkingEnabled: Bool = false @Published public var imageAttachments: [ImageAttachment] = [] @Published public var fileAttachments: [FileAttachment] = [] @@ -90,7 +90,7 @@ public extension InputBoxViewModel { } func toggleNetwork() { - isNetworkEnabled.toggle() + isSearchEnabled.toggle() } func toggleDeepThinking() { @@ -148,7 +148,7 @@ public extension InputBoxViewModel { fileAttachments: fileAttachments, documentAttachments: documentAttachments, isToolEnabled: isToolEnabled, - isNetworkEnabled: isNetworkEnabled, + isSearchEnabled: isSearchEnabled, isDeepThinkingEnabled: isDeepThinkingEnabled ) } @@ -159,7 +159,7 @@ public extension InputBoxViewModel { fileAttachments.removeAll() documentAttachments.removeAll() isToolEnabled = false - isNetworkEnabled = false + isSearchEnabled = false isDeepThinkingEnabled = false } }