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 0c4c49601f..00131c3fde 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 @@ -12,10 +12,46 @@ { "identity" : "eventsource", "kind" : "remoteSourceControl", - "location" : "https://github.com/loopwork-ai/eventsource.git", + "location" : "https://github.com/Recouse/EventSource", "state" : { - "revision" : "07957602bb99a5355c810187e66e6ce378a1057d", - "version" : "1.1.1" + "revision" : "d783b1cf60599dbcec6396c55a6bab33a1c92dc3", + "version" : "0.1.4" + } + }, + { + "identity" : "listviewkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Lakr233/ListViewKit", + "state" : { + "revision" : "a4372d7f90c846d834c1f1575d1af0050d70fa0f", + "version" : "1.1.6" + } + }, + { + "identity" : "litext", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Lakr233/Litext", + "state" : { + "revision" : "c37f3ab5826659854311e20d6c3942d4905b00b6", + "version" : "0.5.0" + } + }, + { + "identity" : "lrucache", + "kind" : "remoteSourceControl", + "location" : "https://github.com/nicklockwood/LRUCache", + "state" : { + "revision" : "542f0449556327415409ededc9c43a4bd0a397dc", + "version" : "1.0.7" + } + }, + { + "identity" : "markdownview", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Lakr233/MarkdownView", + "state" : { + "revision" : "29a9da19d6dc21af4e629c423961b0f453ffe192", + "version" : "2.3.8" } }, { @@ -27,6 +63,33 @@ "version" : "5.7.1" } }, + { + "identity" : "splash", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Lakr233/Splash", + "state" : { + "revision" : "4d997712fe07f75695aacdf287aeb3b1f2c6ab88", + "version" : "0.17.0" + } + }, + { + "identity" : "springinterpolation", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Lakr233/SpringInterpolation", + "state" : { + "revision" : "f9ae95ece5d6b7cdceafd4381f1d5f0f9494e5d2", + "version" : "1.3.1" + } + }, + { + "identity" : "swift-cmark", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-cmark", + "state" : { + "revision" : "b022b08312decdc46585e0b3440d97f6f22ef703", + "version" : "0.6.0" + } + }, { "identity" : "swift-collections", "kind" : "remoteSourceControl", @@ -45,6 +108,15 @@ "version" : "6.2.0" } }, + { + "identity" : "swiftmath", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Lakr233/SwiftMath", + "state" : { + "revision" : "cfd646dcac0c5553e21ebf1ee05f9078277518bc", + "version" : "1.7.2" + } + }, { "identity" : "then", "kind" : "remoteSourceControl", diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Package.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Package.swift index fd5ee90b13..ce4ff9d1c2 100644 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Package.swift +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Package.swift @@ -19,7 +19,9 @@ 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"), + .package(url: "https://github.com/Recouse/EventSource", from: "0.1.4"), + .package(url: "https://github.com/Lakr233/ListViewKit", from: "1.1.6"), + .package(url: "https://github.com/Lakr233/MarkdownView", from: "2.3.8"), ], targets: [ .target(name: "Intelligents", dependencies: [ @@ -29,7 +31,10 @@ let package = Package( "SwifterSwift", .product(name: "Apollo", package: "apollo-ios"), .product(name: "OrderedCollections", package: "swift-collections"), - .product(name: "EventSource", package: "eventsource"), + + "ListViewKit", + "MarkdownView", + "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/ChatManager+Closable.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/ChatManager/ChatManager+Closable.swift index 8f0a43a88e..695e9dd412 100644 --- 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 @@ -5,9 +5,17 @@ // Created by 秋星桥 on 6/30/25. // -import EventSource import Foundation protocol Closable { func close() } -extension EventSource: @preconcurrency Closable {} +class ClosableTask: Closable { + let detachedTask: Task + init(detachedTask: Task) { + self.detachedTask = detachedTask + } + + func close() { + detachedTask.cancel() + } +} 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 index a970fb6cbd..0b6a7cc494 100644 --- 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 @@ -10,6 +10,19 @@ import Apollo import ApolloAPI import EventSource import Foundation +import MarkdownParser +import MarkdownView + +private let loadingIndicator = " ●" + +private extension InputBoxData { + var hasAttachment: Bool { + if !imageAttachments.isEmpty { return false } + if !fileAttachments.isEmpty { return false } + if !documentAttachments.isEmpty { return false } + return true + } +} extension ChatManager { public func startUserRequest( @@ -21,7 +34,13 @@ extension ChatManager { id: .init(), content: inputBoxData.text, timestamp: .init(), - attachments: [] + )) + append(sessionId: sessionId, UserHintCellViewModel( + id: .init(), + timestamp: .init(), + imageAttachments: inputBoxData.imageAttachments, + fileAttachments: inputBoxData.fileAttachments, + docAttachments: inputBoxData.documentAttachments )) let messageParameters: [String: AnyHashable] = [ @@ -102,37 +121,67 @@ extension ChatManager { report(sessionId, ChatError.invalidStreamURL) return } - let eventSource = EventSource( - request: .init( - url: finalUrl, - cachePolicy: .reloadIgnoringLocalAndRemoteCacheData, - timeoutInterval: 10 - ), - configuration: .default + var request = URLRequest( + url: finalUrl, + cachePolicy: .reloadIgnoringLocalAndRemoteCacheData, + timeoutInterval: 10 ) - eventSource.onOpen = { - print("[*] \(messageId): connection established") - } - eventSource.onError = { - self.report(sessionId, $0 ?? ChatError.unknownError) - } + request.setValue("close", forHTTPHeaderField: "Connection") - 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 + let closable = ClosableTask(detachedTask: .detached(operation: { + let eventSource = EventSource() + let dataTask = await eventSource.dataTask(for: request) + var document = "" + self.writeMarkdownContent(document + loadingIndicator, sessionId: sessionId, vmId: vmId) + for await event in await dataTask.events() { + switch event { + case .open: + print("[*] connection opened") + case let .error(error): + print("[!] error occurred", error) + case let .event(event): + guard let data = event.data else { continue } + document += data + self.writeMarkdownContent( + document + loadingIndicator, + sessionId: sessionId, + vmId: vmId + ) + self.scrollToBottomPublisher.send(sessionId) + case .closed: + print("[*] connection closed") } } + self.writeMarkdownContent(document, sessionId: sessionId, vmId: vmId) + self.closeAll() + })) + self.closable.append(closable) + } + + private func writeMarkdownContent( + _ document: String, + sessionId: SessionID, + vmId: UUID + ) { + let result = MarkdownParser().parse(document) + var renderedContexts: [String: RenderedItem] = [:] + for (key, value) in result.mathContext { + let image = MathRenderer.renderToImage( + latex: value, + fontSize: MarkdownTheme.default.fonts.body.pointSize, + textColor: MarkdownTheme.default.colors.body + )?.withRenderingMode(.alwaysTemplate) + let renderedContext = RenderedItem( + image: image, + text: value + ) + renderedContexts["math://\(key)"] = renderedContext + } + + with(sessionId: sessionId, vmId: vmId) { (viewModel: inout AssistantMessageCellViewModel) in + viewModel.content = document + viewModel.documentBlocks = result.document + viewModel.documentRenderedContent = renderedContexts } - closable.append(eventSource) } } 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 60601cce9c..3a9c8649d3 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 @@ -9,7 +9,6 @@ import AffineGraphQL import Apollo import ApolloAPI import Combine -import EventSource import Foundation import OrderedCollections @@ -22,12 +21,14 @@ public class ChatManager: ObservableObject, @unchecked Sendable { SessionID, OrderedDictionary > = [:] + public let scrollToBottomPublisher = PassthroughSubject() var closable: [Closable] = [] private init() {} public func closeAll() { + print("[+] terminating all closables...") closable.forEach { $0.close() } closable.removeAll() } @@ -74,7 +75,8 @@ public class ChatManager: ObservableObject, @unchecked Sendable { public func report(_ sessionID: String, _ error: Error) -> UUID { let model = ErrorCellViewModel( id: .init(), - errorMessage: error.localizedDescription + errorMessage: error.localizedDescription, + timestamp: .init() ) append(sessionId: sessionID, model) return model.id diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentContext/IntelligentContext+Markdown.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentContext/IntelligentContext+Markdown.swift new file mode 100644 index 0000000000..6246f778bf --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentContext/IntelligentContext+Markdown.swift @@ -0,0 +1,16 @@ +// +// IntelligentContext+Markdown.swift +// Intelligents +// +// Created by 秋星桥 on 7/4/25. +// + +import Foundation +import MarkdownView + +extension IntelligentContext { + func prepareMarkdownViewThemes() { + MarkdownTheme.default.colors.body = .affineTextPrimary + MarkdownTheme.default.colors.highlight = .affineTextLink + } +} 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 0827b89484..b84343e7c6 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,8 +42,8 @@ public class IntelligentContext { case currentI18nLocale } - public private(set) var currentSession: ChatSessionObject? - public private(set) var currentWorkspaceId: String? + @Published public private(set) var currentSession: ChatSessionObject? + @Published public private(set) var currentWorkspaceId: String? public lazy var temporaryDirectory: URL = { let tempDir = FileManager.default.temporaryDirectory @@ -70,6 +70,7 @@ public class IntelligentContext { assert(webView != nil) DispatchQueue.global(qos: .userInitiated).async { [self] in prepareTemporaryDirectory() + prepareMarkdownViewThemes() let webViewGroup = DispatchGroup() var webViewMetadataResult: [WebViewMetadataKey: Any] = [:] 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 c994944291..b96ac23297 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,9 +10,7 @@ class MainViewController: UIViewController { $0.delegate = self } - lazy var chatTableView = ChatTableView().then { - $0.delegate = self - } + lazy var listView = ChatListView() lazy var inputBox = InputBox().then { $0.delegate = self @@ -54,7 +52,7 @@ class MainViewController: UIViewController { private func setupUI() { view.addSubview(headerView) - view.addSubview(chatTableView) + view.addSubview(listView) view.addSubview(inputBox) view.addSubview(documentPickerHideDetector) view.addSubview(documentPickerView) @@ -64,7 +62,7 @@ class MainViewController: UIViewController { make.leading.trailing.equalToSuperview() } - chatTableView.snp.makeConstraints { make in + listView.snp.makeConstraints { make in make.top.equalTo(headerView.snp.bottom) make.left.right.equalToSuperview() make.bottom.equalToSuperview() @@ -100,17 +98,17 @@ class MainViewController: UIViewController { navigationController!.setNavigationBarHidden(false, animated: animated) } + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + let bottomAnchor = inputBox.frame.minY + let bottomInset = view.bounds.height - bottomAnchor + 64 + if listView.listView.bottomInset != bottomInset { + listView.listView.bottomInset = bottomInset + } + } + @objc func terminateEditing() { view.endEditing(true) } - - // MARK: - Chat Methods -} - -// MARK: - ChatTableViewDelegate - -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 index eb4b49ca6a..92ae876a9f 100644 --- 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 @@ -5,135 +5,59 @@ // Created by 秋星桥 on 6/27/25. // +import Litext +import MarkdownView import SnapKit import Then import UIKit +private let markdownViewForSizeCalculation: MarkdownTextView = .init() + class AssistantMessageCell: ChatBaseCell { - // MARK: - UI Components + let markdownView = MarkdownTextView() - private lazy var messageLabel = UILabel().then { - $0.numberOfLines = 0 - $0.font = .systemFont(ofSize: 16) - $0.textColor = .label + override func prepareContentView(inside contentView: UIView) { + super.prepareContentView(inside: contentView) + contentView.addSubview(markdownView) } - private lazy var metadataStackView = UIStackView().then { - $0.axis = .horizontal - $0.spacing = 8 - $0.alignment = .center + override func prepareForReuse() { + super.prepareForReuse() + markdownView.prepareForReuse() } - 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: any ChatCellViewModel) { - guard let assistantViewModel = viewModel as? AssistantMessageCellViewModel else { return } - self.viewModel = assistantViewModel + super.configure(with: viewModel) - 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 + guard let vm = viewModel as? AssistantMessageCellViewModel else { + assertionFailure() + return } - - // 配置 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 + markdownView.setMarkdown( + vm.documentBlocks, + renderedContent: vm.documentRenderedContent + ) } - // MARK: - Actions - - @objc private func retryButtonTapped() { - // TODO: 实现重试逻辑 + override func layoutContentView(bounds: CGRect) { + super.layoutContentView(bounds: bounds) + markdownView.frame = bounds } - // MARK: - Helpers - - private func formatTimestamp(_ timestamp: Date) -> String { - let formatter = DateFormatter() - formatter.timeStyle = .short - return formatter.string(from: timestamp) + override class func heightForContent( + for viewModel: any ChatCellViewModel, + width: CGFloat + ) -> CGFloat { + let vm = viewModel as! AssistantMessageCellViewModel + markdownViewForSizeCalculation.theme = .default + markdownViewForSizeCalculation.frame = .init( + x: 0, y: 0, width: width, height: .greatestFiniteMagnitude + ) + markdownViewForSizeCalculation.setMarkdown( + vm.documentBlocks, + renderedContent: vm.documentRenderedContent, + ) + let boundingSize = markdownViewForSizeCalculation.boundingSize(for: width) + return ceil(boundingSize.height) } } 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 index 5681e37abb..4c5560cb71 100644 --- 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 @@ -5,37 +5,24 @@ // Created by 秋星桥 on 6/27/25. // +import ListViewKit +import Litext +import MarkdownView 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 +class ChatBaseCell: ListRowView { + static var contentInsets: UIEdgeInsets { + .init(top: 0, left: 16, bottom: 16, right: 16) } - // MARK: - Properties + private let contentView = UIView() - /// 容器视图的内边距,子类可以重写 - 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() + init() { + super.init(frame: .zero) + addSubview(contentView) + prepareContentView(inside: contentView) } @available(*, unavailable) @@ -43,68 +30,49 @@ class ChatBaseCell: UITableViewCell { 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 prepareContentView(inside contentView: UIView) { + _ = contentView } - /// 子类重写此方法来设置具体的内容视图 - func setupContentView() { - // 子类实现 + override func layoutSubviews() { + super.layoutSubviews() + let contentInsets = Self.contentInsets + contentView.frame = .init( + x: contentInsets.left, + y: contentInsets.top, + width: bounds.width - contentInsets.left - contentInsets.right, + height: bounds.height - contentInsets.top - contentInsets.bottom + ) + layoutContentView(bounds: contentView.bounds) } - // 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 - } + override func addSubview(_ view: UIView) { + assert(view == contentView) + super.addSubview(view) } - /// 配置 ViewModel,子类需要重写 - func configure(with _: any ChatCellViewModel) { - // 子类实现 + func layoutContentView(bounds: CGRect) { + _ = bounds // override pass } - // MARK: - Helpers - - /// 获取适当的文本颜色 - func textColor(for cellType: CellType) -> UIColor { - switch cellType { - case .userMessage, .assistantMessage, .systemMessage: - .label - case .error: - .systemRed - case .loading: - .secondaryLabel - } + class func heightForContent( + for viewModel: any ChatCellViewModel, + width: CGFloat + ) -> CGFloat { + _ = viewModel + _ = width + return 0 // override pass } - /// 获取适当的背景颜色 - 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 - } + static func heightForCell(for viewModel: any ChatCellViewModel, width: CGFloat) -> CGFloat { + let contentWidth = width - contentInsets.left - contentInsets.right + return heightForContent( + for: viewModel, + width: contentWidth + ) + contentInsets.top + contentInsets.bottom + } + + func configure(with viewModel: any ChatCellViewModel) { + _ = viewModel } } 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 deleted file mode 100644 index 853b50c9df..0000000000 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ChatCellFactory.swift +++ /dev/null @@ -1,69 +0,0 @@ -// -// 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: any 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: any 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 deleted file mode 100644 index e69de29bb2..0000000000 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 index af15e4c55a..08feaf021c 100644 --- 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 @@ -5,93 +5,30 @@ // Created by 秋星桥 on 6/27/25. // +import Litext 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 + override func prepareContentView(inside contentView: UIView) { + super.prepareContentView(inside: contentView) } - private lazy var errorLabel = UILabel().then { - $0.numberOfLines = 0 - $0.font = .systemFont(ofSize: 14, weight: .medium) - $0.textColor = .systemRed + override func prepareForReuse() { + super.prepareForReuse() } - 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) + override func layoutContentView(bounds: CGRect) { + super.layoutContentView(bounds: bounds) } - 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: 实现重试逻辑 + override class func heightForContent( + for viewModel: any ChatCellViewModel, + width: CGFloat + ) -> CGFloat { + _ = viewModel + _ = width + return 0 } } 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 index e6256fe801..c6bbac8c35 100644 --- 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 @@ -5,85 +5,30 @@ // Created by 秋星桥 on 6/27/25. // +import Litext import SnapKit import Then import UIKit class LoadingCell: ChatBaseCell { - // MARK: - UI Components - - private lazy var activityIndicator = UIActivityIndicatorView().then { - $0.style = .medium - $0.hidesWhenStopped = false + override func prepareContentView(inside contentView: UIView) { + super.prepareContentView(inside: contentView) } - private lazy var messageLabel = UILabel().then { - $0.numberOfLines = 0 - $0.font = .systemFont(ofSize: 14) - $0.textColor = .secondaryLabel - $0.textAlignment = .center + override func prepareForReuse() { + super.prepareForReuse() } - private lazy var progressView = UIProgressView().then { - $0.progressViewStyle = .default - $0.trackTintColor = .systemGray5 - $0.progressTintColor = .systemBlue + override func layoutContentView(bounds: CGRect) { + super.layoutContentView(bounds: bounds) } - 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: any 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 - } + override class func heightForContent( + for viewModel: any ChatCellViewModel, + width: CGFloat + ) -> CGFloat { + _ = viewModel + _ = width + return 0 } } 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 index fe928c19bd..64acfb6219 100644 --- 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 @@ -5,90 +5,76 @@ // Created by 秋星桥 on 6/27/25. // +import Litext import SnapKit import Then import UIKit +private let labelForSizeCalculation = LTXLabel() + 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 + let contentLabel = LTXLabel().then { + $0.isSelectable = false } - private lazy var messageLabel = UILabel().then { - $0.numberOfLines = 0 - $0.font = .systemFont(ofSize: 14, weight: .medium) - $0.textColor = .label + override func prepareContentView(inside contentView: UIView) { + super.prepareContentView(inside: contentView) + contentView.addSubview(contentLabel) } - private lazy var timestampLabel = UILabel().then { - $0.font = .systemFont(ofSize: 12) - $0.textColor = .secondaryLabel - $0.textAlignment = .right + override func prepareForReuse() { + super.prepareForReuse() + contentLabel.attributedText = .init() } - 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: any 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 + super.configure(with: viewModel) + guard let vm = viewModel as? SystemMessageCellViewModel else { + assertionFailure("") + return } + contentLabel.attributedText = Self.prepareAttributeText(vm.content) } - // MARK: - Helpers + override func layoutContentView(bounds: CGRect) { + super.layoutContentView(bounds: bounds) + let textMaxWidth = bounds.width * 0.8 + contentLabel.preferredMaxLayoutWidth = textMaxWidth + let textSize = contentLabel.intrinsicContentSize + let labelWidth = textSize.width + let labelHeight = textSize.height + contentLabel.frame = .init( + x: (bounds.width - labelWidth) / 2, + y: 0, + width: labelWidth, + height: labelHeight + ) + } - private func formatTimestamp(_ timestamp: Date) -> String { - let formatter = DateFormatter() - formatter.timeStyle = .short - return formatter.string(from: timestamp) + class func prepareAttributeText(_ text: String) -> NSAttributedString { + .init(string: text, attributes: [ + .font: UIFont.preferredFont(forTextStyle: .footnote), + .foregroundColor: UIColor.affineTextSecondary, + .paragraphStyle: NSMutableParagraphStyle().then { + $0.lineBreakMode = .byWordWrapping + $0.alignment = .center + $0.lineSpacing = 2 + $0.paragraphSpacing = 4 + }, + ]) + } + + override class func heightForContent( + for viewModel: any ChatCellViewModel, + width: CGFloat + ) -> CGFloat { + guard let vm = viewModel as? SystemMessageCellViewModel else { + assertionFailure() + return 0 + } + labelForSizeCalculation.attributedText = prepareAttributeText(vm.content) + labelForSizeCalculation.preferredMaxLayoutWidth = width * 0.8 + let textSize = labelForSizeCalculation.intrinsicContentSize + return textSize.height } } diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/UserHintCell.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/UserHintCell.swift new file mode 100644 index 0000000000..f9836dbe17 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/UserHintCell.swift @@ -0,0 +1,102 @@ +// +// UserHintCell.swift +// Intelligents +// +// Created by 秋星桥 on 7/4/25. +// + +import Litext +import UIKit + +private let labelForSizeCalculation = LTXLabel() +private let formatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .none + formatter.timeStyle = .short + formatter.locale = .current + return formatter +}() + +class UserHintCell: ChatBaseCell { + let contentLabel = LTXLabel().then { + $0.isSelectable = true + } + + override func prepareContentView(inside contentView: UIView) { + super.prepareContentView(inside: contentView) + + contentView.addSubview(contentLabel) + } + + override func prepareForReuse() { + super.prepareForReuse() + contentLabel.attributedText = .init() + } + + override func configure(with viewModel: any ChatCellViewModel) { + super.configure(with: viewModel) + guard let vm = viewModel as? UserHintCellViewModel else { + assertionFailure("") + return + } + contentLabel.attributedText = Self.prepareAttributeText(Self.prepareText(vm)) + } + + override func layoutContentView(bounds: CGRect) { + super.layoutContentView(bounds: bounds) + + contentLabel.preferredMaxLayoutWidth = bounds.width + let textSize = contentLabel.intrinsicContentSize + contentLabel.frame = CGRect( + x: bounds.width - textSize.width, + y: 0, + width: textSize.width, + height: bounds.height + ) + } + + class func prepareText(_ vm: UserHintCellViewModel) -> String { + let attachmentsCount = [ + vm.docAttachments.count, + vm.imageAttachments.count, + vm.fileAttachments.count, + ].reduce(0, +) + let text: [String] = [ + formatter.string(from: vm.timestamp), + { + if attachmentsCount > 0 { + String(localized: "\(attachmentsCount) attachments") + } else { + "" + } + }(), + ].filter { !$0.isEmpty } + return text.joined(separator: " ") + } + + class func prepareAttributeText(_ text: String) -> NSAttributedString { + .init(string: text, attributes: [ + .font: UIFont.preferredFont(forTextStyle: .footnote), + .foregroundColor: UIColor.affineTextSecondary, + .paragraphStyle: NSMutableParagraphStyle().then { + $0.lineBreakMode = .byWordWrapping + $0.alignment = .natural + $0.lineSpacing = 2 + $0.paragraphSpacing = 4 + }, + ]) + } + + override class func heightForContent( + for viewModel: any ChatCellViewModel, + width: CGFloat + ) -> CGFloat { + guard let vm = viewModel as? UserHintCellViewModel else { + assertionFailure() + return 0 + } + labelForSizeCalculation.attributedText = prepareAttributeText(prepareText(vm)) + labelForSizeCalculation.preferredMaxLayoutWidth = width + return labelForSizeCalculation.intrinsicContentSize.height + } +} 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 index f4a40c0d76..e8f1335a16 100644 --- 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 @@ -5,90 +5,88 @@ // Created by 秋星桥 on 6/27/25. // +import Litext import SnapKit import Then import UIKit +private let labelForSizeCalculation = LTXLabel() + class UserMessageCell: ChatBaseCell { - // MARK: - UI Components - - private lazy var messageLabel = UILabel().then { - $0.numberOfLines = 0 - $0.font = .systemFont(ofSize: 16) - $0.textColor = .label + let backgroundView = UIView().then { + $0.backgroundColor = .gray.withAlphaComponent(0.05) + $0.layer.cornerRadius = 8 } - private lazy var timestampLabel = UILabel().then { - $0.font = .systemFont(ofSize: 12) - $0.textColor = .secondaryLabel - $0.textAlignment = .right + let contentLabel = LTXLabel().then { + $0.isSelectable = true } - private lazy var retryIndicator = UIActivityIndicatorView().then { - $0.style = .medium - $0.hidesWhenStopped = true + override func prepareContentView(inside contentView: UIView) { + super.prepareContentView(inside: contentView) + + contentView.addSubview(backgroundView) + backgroundView.addSubview(contentLabel) } - private lazy var stackView = UIStackView().then { - $0.axis = .vertical - $0.spacing = 8 - $0.alignment = .fill + override func prepareForReuse() { + super.prepareForReuse() + contentLabel.attributedText = .init() } - // 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 + super.configure(with: viewModel) + guard let vm = viewModel as? UserMessageCellViewModel else { + assertionFailure("") + return + } + contentLabel.attributedText = Self.prepareAttributeText(vm.content) } - // MARK: - Helpers + override func layoutContentView(bounds: CGRect) { + super.layoutContentView(bounds: bounds) - private func formatTimestamp(_ timestamp: Date) -> String { - let formatter = DateFormatter() - formatter.timeStyle = .short - return formatter.string(from: timestamp) + let inset: CGFloat = 8 + let textMaxWidth = bounds.width * 0.8 - inset * 2 + contentLabel.preferredMaxLayoutWidth = textMaxWidth + let textSize = contentLabel.intrinsicContentSize + let backgroundWidth = textSize.width + inset * 2 + + backgroundView.frame = .init( + x: bounds.width - backgroundWidth, // right aligned + y: 0, + width: backgroundWidth, + height: bounds.height + ) + contentLabel.frame = backgroundView.bounds.insetBy(dx: inset, dy: inset) + } + + class func prepareAttributeText(_ text: String) -> NSAttributedString { + .init(string: text, attributes: [ + .font: UIFont.preferredFont(forTextStyle: .body), + .foregroundColor: UIColor.affineTextPrimary, + .paragraphStyle: NSMutableParagraphStyle().then { + $0.lineBreakMode = .byWordWrapping + $0.alignment = .natural + $0.lineSpacing = 2 + $0.paragraphSpacing = 4 + }, + ]) + } + + override class func heightForContent( + for viewModel: any ChatCellViewModel, + width: CGFloat + ) -> CGFloat { + guard let vm = viewModel as? UserMessageCellViewModel else { + assertionFailure() + return 0 + } + labelForSizeCalculation.attributedText = prepareAttributeText(vm.content) + + let inset: CGFloat = 8 + labelForSizeCalculation.preferredMaxLayoutWidth = width * 0.8 - inset * 2 + let textSize = labelForSizeCalculation.intrinsicContentSize + return textSize.height + inset * 2 } } 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 deleted file mode 100644 index d687067894..0000000000 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/AssistantMessageCellViewModel.swift +++ /dev/null @@ -1,44 +0,0 @@ -// -// 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/CCVM+Assistant.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/CCVM+Assistant.swift new file mode 100644 index 0000000000..4cf2ff9392 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/CCVM+Assistant.swift @@ -0,0 +1,109 @@ +// +// CCVM+Assistant.swift +// Intelligents +// +// Created by 秋星桥 on 6/27/25. +// + +import Foundation +import MarkdownParser +import MarkdownView + +struct AssistantMessageCellViewModel: ChatCellViewModel { + static func == (lhs: AssistantMessageCellViewModel, rhs: AssistantMessageCellViewModel) -> Bool { + lhs.hashValue == rhs.hashValue + } + + func hash(into hasher: inout Hasher) { + hasher.combine(cellType) + hasher.combine(id) + hasher.combine(content) + hasher.combine(timestamp) + hasher.combine(isStreaming) + hasher.combine(model) + hasher.combine(tokens) + hasher.combine(canRetry) + hasher.combine(citations) + hasher.combine(actions) + } + + var cellType: ChatCellType = .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]? + + var documentBlocks: [MarkdownBlockNode] + var documentRenderedContent: RenderContext + + init( + id: UUID, + content: String, + timestamp: Date, + isStreaming: Bool = false, + model: String? = nil, + tokens: Int? = nil, + canRetry: Bool = false, + citations: [CitationViewModel]? = nil, + actions: [MessageActionViewModel]? = nil + ) { + // time expensive rendering should not happen here + assert(!Thread.isMainThread || content.isEmpty) + + self.id = id + self.content = content + self.timestamp = timestamp + self.isStreaming = isStreaming + self.model = model + self.tokens = tokens + self.canRetry = canRetry + self.citations = citations + self.actions = actions + + let parser = MarkdownParser() + let parserResult = parser.parse(content) + documentBlocks = parserResult.document + var renderedContexts: [String: RenderedItem] = [:] + for (key, value) in parserResult.mathContext { + let image = MathRenderer.renderToImage( + latex: value, + fontSize: MarkdownTheme.default.fonts.body.pointSize, + textColor: MarkdownTheme.default.colors.body + )?.withRenderingMode(.alwaysTemplate) + let renderedContext = RenderedItem( + image: image, + text: value + ) + renderedContexts["math://\(key)"] = renderedContext + } + documentRenderedContent = renderedContexts + } +} + +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/ErrorCellViewModel.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/CCVM+Error.swift similarity index 68% rename from packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/ErrorCellViewModel.swift rename to packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/CCVM+Error.swift index 66bf6ee71a..bfe4e49e8e 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/CCVM+Error.swift @@ -1,5 +1,5 @@ // -// ErrorCellViewModel.swift +// CCVM+Error.swift // Intelligents // // Created by 秋星桥 on 6/26/25. @@ -8,7 +8,8 @@ import Foundation struct ErrorCellViewModel: ChatCellViewModel { - var cellType: CellType = .error + var cellType: ChatCellType = .error var id: UUID var errorMessage: String + var timestamp: Date } 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/CCVM+Loading.swift similarity index 69% rename from packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/LoadingCellViewModel.swift rename to packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/CCVM+Loading.swift index caed2ad612..8c0d3fb21a 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/CCVM+Loading.swift @@ -1,5 +1,5 @@ // -// LoadingCellViewModel.swift +// CCVM+Loading.swift // Intelligents // // Created by 秋星桥 on 6/26/25. @@ -8,8 +8,9 @@ import Foundation struct LoadingCellViewModel: ChatCellViewModel { - var cellType: CellType = .loading + var cellType: ChatCellType = .loading var id: UUID + var timestamp: Date 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/CCVM+System.swift similarity index 63% rename from packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/SystemMessageCellViewModel.swift rename to packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/CCVM+System.swift index 06c1fc31c4..3fba9002fb 100644 --- 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/CCVM+System.swift @@ -1,5 +1,5 @@ // -// SystemMessageCellViewModel.swift +// CCVM+System.swift // Intelligents // // Created by 秋星桥 on 6/27/25. @@ -8,8 +8,8 @@ import Foundation struct SystemMessageCellViewModel: ChatCellViewModel { - var cellType: CellType = .systemMessage + var cellType: ChatCellType = .systemMessage var id: UUID var content: String - var timestamp: Date? + var timestamp: Date } diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/CCVM+User.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/CCVM+User.swift new file mode 100644 index 0000000000..7f9c0a86b2 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/CCVM+User.swift @@ -0,0 +1,24 @@ +// +// CCVM+User.swift +// Intelligents +// +// Created by 秋星桥 on 6/27/25. +// + +import Foundation + +struct UserMessageCellViewModel: ChatCellViewModel { + var cellType: ChatCellType = .userMessage + var id: UUID + var content: String + var timestamp: Date +} + +struct UserHintCellViewModel: ChatCellViewModel { + var cellType: ChatCellType = .userAttachmentsHint + var id: UUID + var timestamp: Date + var imageAttachments: [ImageAttachment] + var fileAttachments: [FileAttachment] + var docAttachments: [DocumentAttachment] +} 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/ChatCellType.swift similarity index 64% rename from packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/CellType.swift rename to packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/ChatCellType.swift index feadca0e57..54543a1f13 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/ChatCellType.swift @@ -1,5 +1,5 @@ // -// CellType.swift +// ChatCellType.swift // Intelligents // // Created by 秋星桥 on 6/26/25. @@ -7,8 +7,9 @@ import Foundation -public enum CellType: String, Codable, CaseIterable { +public enum ChatCellType: String, CaseIterable { case userMessage + case userAttachmentsHint case assistantMessage case systemMessage case loading 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 index 24a43bf153..e2414d6eeb 100644 --- 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 @@ -7,7 +7,8 @@ import Foundation -public protocol ChatCellViewModel: Codable, Identifiable, Equatable, Hashable { +public protocol ChatCellViewModel: Identifiable, Equatable, Hashable { var id: UUID { get } - var cellType: CellType { get } + var cellType: ChatCellType { get } + var timestamp: Date { get } } 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 deleted file mode 100644 index 8f9a7d98ff..0000000000 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/UserMessageCellViewModel.swift +++ /dev/null @@ -1,16 +0,0 @@ -// -// 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/ChatList/ChatItemEntity.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatList/ChatItemEntity.swift new file mode 100644 index 0000000000..7b7cbd2246 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatList/ChatItemEntity.swift @@ -0,0 +1,24 @@ +// +// ChatItemEntity.swift +// Intelligents +// +// Created by 秋星桥 on 7/2/25. +// + +import Foundation +import UIKit + +struct ChatItemEntity: Identifiable, Hashable, Equatable { + var id: UUID + var object: any ChatCellViewModel + + static func == (lhs: ChatItemEntity, rhs: ChatItemEntity) -> Bool { + lhs.id == rhs.id && lhs.object.cellType == rhs.object.cellType && lhs.object.hashValue == rhs.object.hashValue + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + hasher.combine(object.cellType) + hasher.combine(object.hashValue) + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatList/ChatListView+Adapter.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatList/ChatListView+Adapter.swift new file mode 100644 index 0000000000..0e3e33042f --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatList/ChatListView+Adapter.swift @@ -0,0 +1,88 @@ +// +// ChatListView+Adapter.swift +// Intelligents +// +// Created by 秋星桥 on 7/2/25. +// + +import ListViewKit +import UIKit + +private let dayDateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + return formatter +}() + +extension ChatListView: ListViewAdapter { + func fill(viewModels: [any ChatCellViewModel]) { + assert(!Thread.isMainThread) + var items = viewModels.map { ChatItemEntity(id: $0.id, object: $0) } + items = preprocessItems(items) + DispatchQueue.main.asyncAndWait { [self] in + dataSource.applySnapshot(using: items, animatingDifferences: true) + } + } + + private func preprocessItems(_ items: [ChatItemEntity]) -> [ChatItemEntity] { + var ans = [ChatItemEntity]() + + // prepend a date hint for each day + let calendar = Calendar.current + var currentDayAnchor: Date? + for item in items { + defer { ans.append(item) } + + guard item.object.cellType == .userMessage, + let userMessage = item.object as? UserMessageCellViewModel + else { continue } + let messageDate = userMessage.timestamp + let dayAnchor = calendar.startOfDay(for: messageDate) + if currentDayAnchor == nil || dayAnchor > currentDayAnchor! { + currentDayAnchor = dayAnchor + let dateHint = SystemMessageCellViewModel( + id: .init(), + content: dayDateFormatter.string(from: dayAnchor), + timestamp: .init() + ) + ans.append(ChatItemEntity(id: dateHint.id, object: dateHint)) + } + } + + return ans + } + + func listView(_: ListViewKit.ListView, rowKindFor item: ItemType, at _: Int) -> RowKind { + let item = item as! ChatItemEntity + return item.object.cellType + } + + func listViewMakeRow(for kind: RowKind) -> ListViewKit.ListRowView { + switch kind as! ChatCellType { + case .userMessage: UserMessageCell() + case .userAttachmentsHint: UserHintCell() + case .assistantMessage: AssistantMessageCell() + case .systemMessage: SystemMessageCell() + case .loading: LoadingCell() + case .error: ErrorCell() + } + } + + func listView(_ list: ListViewKit.ListView, heightFor item: ItemType, at _: Int) -> CGFloat { + let item = item as! ChatItemEntity + return switch item.object.cellType { + case .userMessage: UserMessageCell.heightForCell(for: item.object, width: list.bounds.width) + case .userAttachmentsHint: UserHintCell.heightForCell(for: item.object, width: list.bounds.width) + case .assistantMessage: AssistantMessageCell.heightForCell(for: item.object, width: list.bounds.width) + case .systemMessage: SystemMessageCell.heightForCell(for: item.object, width: list.bounds.width) + case .loading: LoadingCell.heightForCell(for: item.object, width: list.bounds.width) + case .error: ErrorCell.heightForCell(for: item.object, width: list.bounds.width) + } + } + + func listView(_: ListViewKit.ListView, configureRowView rowView: ListViewKit.ListRowView, for item: ItemType, at _: Int) { + let base = rowView as! ChatBaseCell + let item = item as! ChatItemEntity + base.configure(with: item.object) + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatList/ChatListView.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatList/ChatListView.swift new file mode 100644 index 0000000000..99838d1805 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatList/ChatListView.swift @@ -0,0 +1,86 @@ +// +// ChatListView.swift +// Intelligents +// +// Created by 秋星桥 on 7/2/25. +// + +import Combine +import ListViewKit +import MarkdownView +import UIKit + +class ChatListView: UIView { + private(set) lazy var listView = ListView() + private(set) lazy var dataSource = ListViewDiffableDataSource(listView: listView) + + var cancellables: Set = [] + + init() { + super.init(frame: .zero) + + listView.topInset = 8 + listView.bottomInset = 64 + listView.adapter = self + addSubview(listView) + listView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + + let dataSourceQueue = DispatchQueue(label: "com.affine.intelligents.chat.list.dataSource", qos: .userInteractive) + + Publishers.CombineLatest( + IntelligentContext.shared.$currentSession + .map { $0?.id ?? "default_session" } + .removeDuplicates(), + ChatManager.shared.$viewModels + ) + .receive(on: dataSourceQueue) + .map { sessionIdentifier, viewModels in + .init(viewModels[sessionIdentifier]?.map(\.value) ?? []) + } + .sink { [weak self] viewModels in + guard let self else { return } + fill(viewModels: viewModels) + } + .store(in: &cancellables) + + Publishers.CombineLatest( + IntelligentContext.shared.$currentSession + .map { $0?.id ?? "default_session" } + .removeDuplicates(), + ChatManager.shared.scrollToBottomPublisher + ) + .receive(on: dataSourceQueue) + .filter { $0 == $1 } + .map { _ in () } + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self else { return } + scrollToBottom() + } + .store(in: &cancellables) + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError() + } + + deinit { + cancellables.forEach { $0.cancel() } + cancellables.removeAll() + } + + func scrollToBottom() { + if listView.contentSize.height <= listView.bounds.height { + // If the content size is smaller than the bounds, no need to scroll. + return + } + let contentOffset = CGPoint( + x: 0, + y: listView.contentSize.height - listView.bounds.height + ) + listView.scroll(to: contentOffset) + } +} 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 deleted file mode 100644 index bd8c20ad45..0000000000 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatTableView/ChatTableView.swift +++ /dev/null @@ -1,154 +0,0 @@ -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) - } -}