mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-19 07:17:00 +08:00
feat: basic chat implementation completed (#13023)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Introduced a new chat list view with improved grouping of messages by date and support for rich markdown rendering, including math expressions. * Added support for displaying user message attachments and hints within the chat interface. * **Improvements** * Enhanced chat cell designs for user, assistant, and system messages, providing clearer layouts and better text rendering. * Streamlined chat message streaming with incremental markdown updates and improved scrolling behavior. * Updated chat view models to include timestamps and refined message typing. * **Bug Fixes** * Improved handling of streaming responses and error reporting with more accurate timestamps. * **Refactor** * Replaced the legacy table-based chat UI with a modern list-based implementation. * Simplified and unified chat cell view models and cell rendering logic. * **Chores** * Updated and added several third-party dependencies to support new UI components and markdown features. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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<Void, Never>
|
||||
init(detachedTask: Task<Void, Never>) {
|
||||
self.detachedTask = detachedTask
|
||||
}
|
||||
|
||||
func close() {
|
||||
detachedTask.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<MessageID, any ChatCellViewModel>
|
||||
> = [:]
|
||||
public let scrollToBottomPublisher = PassthroughSubject<SessionID, Never>()
|
||||
|
||||
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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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] = [:]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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?
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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] = []
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<ChatItemEntity>(listView: listView)
|
||||
|
||||
var cancellables: Set<AnyCancellable> = []
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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<AnyCancellable>()
|
||||
|
||||
var cellViewModels: OrderedDictionary<UUID, any ChatCellViewModel> = [:] {
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user