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:
Lakr
2025-07-04 14:27:18 +09:00
committed by GitHub
parent 2b0b20cdd4
commit ed6adcf4d9
30 changed files with 884 additions and 821 deletions

View File

@@ -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",

View File

@@ -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"),

View File

@@ -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()
}
}

View File

@@ -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)
}
}

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -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] = [:]

View File

@@ -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
}
}

View File

@@ -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)
}
}

View File

@@ -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
}
}

View File

@@ -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使cellfallback
let fallbackCell = tableView.dequeueReusableCell(
withIdentifier: CellType.systemMessage.rawValue,
for: indexPath
) as! SystemMessageCell
// fallbackViewModel
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
}
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
}

View File

@@ -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?
}

View File

@@ -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
}

View File

@@ -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]
}

View File

@@ -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

View File

@@ -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 }
}

View File

@@ -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] = []
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}