chore: created first ai stream (#12968)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Introduced a redesigned chat interface with new cell types for user,
assistant, system, loading, and error messages.
  * Added streaming chat responses and improved session management.
* Enhanced input box behavior, allowing sending messages with the return
key and inserting new lines via the edit menu.
* Added new GraphQL queries for fetching recent and latest chat
sessions.

* **Refactor**
* Replaced previous chat message and session management with a new, more
structured view model system.
* Updated chat view to use a custom table view component for better
message rendering and empty state handling.
* Simplified and improved error and loading state handling in the chat
UI.

* **Bug Fixes**
  * Improved error reporting and retry options for failed chat messages.
  * Fixed inconsistent property types for message and error identifiers.

* **Style**
* Updated UI components for chat cells with modern layouts and
consistent styling.

* **Chores**
  * Added a new package dependency for event streaming support.
* Renamed various internal properties and classes for clarity and
consistency.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Lakr
2025-06-30 15:51:00 +09:00
committed by GitHub
parent 32787bc88b
commit 29ae6afe71
45 changed files with 1665 additions and 932 deletions

View File

@@ -9,6 +9,15 @@
"version" : "1.22.0"
}
},
{
"identity" : "eventsource",
"kind" : "remoteSourceControl",
"location" : "https://github.com/loopwork-ai/eventsource.git",
"state" : {
"revision" : "07957602bb99a5355c810187e66e6ce378a1057d",
"version" : "1.1.1"
}
},
{
"identity" : "snapkit",
"kind" : "remoteSourceControl",

View File

@@ -10,24 +10,26 @@ import UIKit
extension AFFiNEViewController: IntelligentsButtonDelegate {
func onIntelligentsButtonTapped(_ button: IntelligentsButton) {
IntelligentContext.shared.webView = webView!
button.beginProgress()
IntelligentContext.shared.preparePresent { result in
button.stopProgress()
switch result {
case .success:
let controller = IntelligentsController()
self.present(controller, animated: true)
case let .failure(failure):
let alert = UIAlertController(
title: "Error",
message: failure.localizedDescription,
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "OK", style: .default))
self.present(alert, animated: true)
}
}
// if it shows up then we are ready to go
let controller = IntelligentsController()
self.present(controller, animated: true)
// IntelligentContext.shared.webView = webView
// button.beginProgress()
// IntelligentContext.shared.preparePresent { result in
// DispatchQueue.main.async {
// button.stopProgress()
// switch result {
// case .success:
// case let .failure(failure):
// let alert = UIAlertController(
// title: "Error",
// message: failure.localizedDescription,
// preferredStyle: .alert
// )
// alert.addAction(UIAlertAction(title: "OK", style: .default))
// self.present(alert, animated: true)
// }
// }
// }
}
}

View File

@@ -3,6 +3,8 @@ import Intelligents
import UIKit
class AFFiNEViewController: CAPBridgeViewController {
var intelligentsButton: IntelligentsButton?
override func viewDidLoad() {
super.viewDidLoad()
webView?.allowsBackForwardNavigationGestures = true
@@ -11,6 +13,7 @@ class AFFiNEViewController: CAPBridgeViewController {
edgesForExtendedLayout = []
let intelligentsButton = installIntelligentsButton()
intelligentsButton.delegate = self
self.intelligentsButton = intelligentsButton
dismissIntelligentsButton()
}
@@ -35,11 +38,39 @@ class AFFiNEViewController: CAPBridgeViewController {
plugins.forEach { bridge?.registerPluginInstance($0) }
}
private var intelligentsButtonTimer: Timer?
private var isCheckingIntelligentEligibility = false
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
IntelligentContext.shared.webView = webView
navigationController?.setNavigationBarHidden(false, animated: animated)
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
self.presentIntelligentsButton()
let timer = Timer.scheduledTimer(withTimeInterval: 3, repeats: true) { [weak self] _ in
self?.checkEligibilityOfIntelligent()
}
intelligentsButtonTimer = timer
RunLoop.main.add(timer, forMode: .common)
}
private func checkEligibilityOfIntelligent() {
guard !isCheckingIntelligentEligibility else { return }
assert(intelligentsButton != nil)
guard intelligentsButton?.isHidden ?? false else { return } // already eligible
isCheckingIntelligentEligibility = true
IntelligentContext.shared.webView = webView
IntelligentContext.shared.preparePresent { [self] result in
DispatchQueue.main.async {
defer { self.isCheckingIntelligentEligibility = false }
switch result {
case .failure: break
case .success: self.presentIntelligentsButton()
}
}
}
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
intelligentsButtonTimer?.invalidate()
}
}

View File

@@ -0,0 +1,141 @@
// @generated
// This file was automatically generated and should not be edited.
@_exported import ApolloAPI
public class GetCopilotLatestDocSessionQuery: GraphQLQuery {
public static let operationName: String = "getCopilotLatestDocSession"
public static let operationDocument: ApolloAPI.OperationDocument = .init(
definition: .init(
#"query getCopilotLatestDocSession($workspaceId: String!, $docId: String!) { currentUser { __typename copilot(workspaceId: $workspaceId) { __typename histories( docId: $docId options: { limit: 1, sessionOrder: desc, action: false, fork: false } ) { __typename sessionId workspaceId docId pinned action tokens createdAt updatedAt messages { __typename id role content attachments params createdAt } } } } }"#
))
public var workspaceId: String
public var docId: String
public init(
workspaceId: String,
docId: String
) {
self.workspaceId = workspaceId
self.docId = docId
}
public var __variables: Variables? { [
"workspaceId": workspaceId,
"docId": docId
] }
public struct Data: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Query }
public static var __selections: [ApolloAPI.Selection] { [
.field("currentUser", CurrentUser?.self),
] }
/// Get current user
public var currentUser: CurrentUser? { __data["currentUser"] }
/// CurrentUser
///
/// Parent Type: `UserType`
public struct CurrentUser: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.UserType }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("copilot", Copilot.self, arguments: ["workspaceId": .variable("workspaceId")]),
] }
public var copilot: Copilot { __data["copilot"] }
/// CurrentUser.Copilot
///
/// Parent Type: `Copilot`
public struct Copilot: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Copilot }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("histories", [History].self, arguments: [
"docId": .variable("docId"),
"options": [
"limit": 1,
"sessionOrder": "desc",
"action": false,
"fork": false
]
]),
] }
public var histories: [History] { __data["histories"] }
/// CurrentUser.Copilot.History
///
/// Parent Type: `CopilotHistories`
public struct History: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.CopilotHistories }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("sessionId", String.self),
.field("workspaceId", String.self),
.field("docId", String?.self),
.field("pinned", Bool.self),
.field("action", String?.self),
.field("tokens", Int.self),
.field("createdAt", AffineGraphQL.DateTime.self),
.field("updatedAt", AffineGraphQL.DateTime.self),
.field("messages", [Message].self),
] }
public var sessionId: String { __data["sessionId"] }
public var workspaceId: String { __data["workspaceId"] }
public var docId: String? { __data["docId"] }
public var pinned: Bool { __data["pinned"] }
/// An mark identifying which view to use to display the session
public var action: String? { __data["action"] }
/// The number of tokens used in the session
public var tokens: Int { __data["tokens"] }
public var createdAt: AffineGraphQL.DateTime { __data["createdAt"] }
public var updatedAt: AffineGraphQL.DateTime { __data["updatedAt"] }
public var messages: [Message] { __data["messages"] }
/// CurrentUser.Copilot.History.Message
///
/// Parent Type: `ChatMessage`
public struct Message: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.ChatMessage }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("id", AffineGraphQL.ID?.self),
.field("role", String.self),
.field("content", String.self),
.field("attachments", [String]?.self),
.field("params", AffineGraphQL.JSON?.self),
.field("createdAt", AffineGraphQL.DateTime.self),
] }
public var id: AffineGraphQL.ID? { __data["id"] }
public var role: String { __data["role"] }
public var content: String { __data["content"] }
public var attachments: [String]? { __data["attachments"] }
public var params: AffineGraphQL.JSON? { __data["params"] }
public var createdAt: AffineGraphQL.DateTime { __data["createdAt"] }
}
}
}
}
}
}

View File

@@ -0,0 +1,108 @@
// @generated
// This file was automatically generated and should not be edited.
@_exported import ApolloAPI
public class GetCopilotRecentSessionsQuery: GraphQLQuery {
public static let operationName: String = "getCopilotRecentSessions"
public static let operationDocument: ApolloAPI.OperationDocument = .init(
definition: .init(
#"query getCopilotRecentSessions($workspaceId: String!, $limit: Int = 10) { currentUser { __typename copilot(workspaceId: $workspaceId) { __typename histories(options: { limit: $limit, sessionOrder: desc }) { __typename sessionId workspaceId docId pinned action tokens createdAt updatedAt } } } }"#
))
public var workspaceId: String
public var limit: GraphQLNullable<Int>
public init(
workspaceId: String,
limit: GraphQLNullable<Int> = 10
) {
self.workspaceId = workspaceId
self.limit = limit
}
public var __variables: Variables? { [
"workspaceId": workspaceId,
"limit": limit
] }
public struct Data: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Query }
public static var __selections: [ApolloAPI.Selection] { [
.field("currentUser", CurrentUser?.self),
] }
/// Get current user
public var currentUser: CurrentUser? { __data["currentUser"] }
/// CurrentUser
///
/// Parent Type: `UserType`
public struct CurrentUser: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.UserType }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("copilot", Copilot.self, arguments: ["workspaceId": .variable("workspaceId")]),
] }
public var copilot: Copilot { __data["copilot"] }
/// CurrentUser.Copilot
///
/// Parent Type: `Copilot`
public struct Copilot: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Copilot }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("histories", [History].self, arguments: ["options": [
"limit": .variable("limit"),
"sessionOrder": "desc"
]]),
] }
public var histories: [History] { __data["histories"] }
/// CurrentUser.Copilot.History
///
/// Parent Type: `CopilotHistories`
public struct History: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.CopilotHistories }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("sessionId", String.self),
.field("workspaceId", String.self),
.field("docId", String?.self),
.field("pinned", Bool.self),
.field("action", String?.self),
.field("tokens", Int.self),
.field("createdAt", AffineGraphQL.DateTime.self),
.field("updatedAt", AffineGraphQL.DateTime.self),
] }
public var sessionId: String { __data["sessionId"] }
public var workspaceId: String { __data["workspaceId"] }
public var docId: String? { __data["docId"] }
public var pinned: Bool { __data["pinned"] }
/// An mark identifying which view to use to display the session
public var action: String? { __data["action"] }
/// The number of tokens used in the session
public var tokens: Int { __data["tokens"] }
public var createdAt: AffineGraphQL.DateTime { __data["createdAt"] }
public var updatedAt: AffineGraphQL.DateTime { __data["updatedAt"] }
}
}
}
}
}

View File

@@ -19,6 +19,7 @@ let package = Package(
.package(url: "https://github.com/devxoul/Then", from: "3.0.0"),
.package(url: "https://github.com/SnapKit/SnapKit.git", from: "5.7.1"),
.package(url: "https://github.com/SwifterSwift/SwifterSwift.git", from: "6.0.0"),
.package(url: "https://github.com/loopwork-ai/eventsource.git", from: "1.1.1"),
],
targets: [
.target(name: "Intelligents", dependencies: [
@@ -28,6 +29,7 @@ let package = Package(
"SwifterSwift",
.product(name: "Apollo", package: "apollo-ios"),
.product(name: "OrderedCollections", package: "swift-collections"),
.product(name: "EventSource", package: "eventsource"),
], resources: [
.process("Interface/View/InputBox/InputBox.xcassets"),
.process("Interface/Controller/AttachmentManagementController/AttachmentIcon.xcassets"),

View File

@@ -0,0 +1,31 @@
//
// ChatError.swift
// Intelligents
//
// Created by on 6/30/25.
//
import Foundation
public enum ChatError: LocalizedError {
case invalidServerConfiguration
case invalidStreamURL
case invalidResponse
case networkError(Error)
case unknownError
public var errorDescription: String? {
switch self {
case .invalidServerConfiguration:
"Invalid server configuration"
case .invalidStreamURL:
"Invalid stream URL"
case .invalidResponse:
"Invalid response from server"
case let .networkError(error):
"Network error: \(error.localizedDescription)"
case .unknownError:
"An unknown error occurred"
}
}
}

View File

@@ -0,0 +1,13 @@
//
// ChatManager+Closable.swift
// Intelligents
//
// Created by on 6/30/25.
//
import EventSource
import Foundation
protocol Closable { func close() }
extension EventSource: @preconcurrency Closable {}

View File

@@ -1,125 +0,0 @@
//
// ChatManager+ContextModels.swift
// Intelligents
//
// Created by on 6/26/25.
//
import AffineGraphQL
import Foundation
// MARK: - ChatManager Context Models Extension
extension ChatManager {
// MARK: - Context Models
struct ContextReference: Codable, Identifiable, Equatable, Hashable {
var id: String
var fileId: String?
var docId: String?
var chunk: Int
var content: String
var distance: Double
var highlightedContent: String?
init(fileId: String? = nil, docId: String? = nil, chunk: Int, content: String, distance: Double, highlightedContent: String? = nil) {
id = UUID().uuidString
self.fileId = fileId
self.docId = docId
self.chunk = chunk
self.content = content
self.distance = distance
self.highlightedContent = highlightedContent
}
}
struct CopilotContext: Codable, Identifiable, Equatable, Hashable {
var id: String
var sessionId: String
var workspaceId: String
var files: [ContextFile]
var docs: [ContextDoc]
var categories: [ContextCategory]
init(id: String, sessionId: String, workspaceId: String, files: [ContextFile] = [], docs: [ContextDoc] = [], categories: [ContextCategory] = []) {
self.id = id
self.sessionId = sessionId
self.workspaceId = workspaceId
self.files = files
self.docs = docs
self.categories = categories
}
}
struct ContextFile: Codable, Identifiable, Equatable, Hashable {
var id: String
var contextId: String
var blobId: String
var fileName: String?
var fileSize: Int?
var mimeType: String?
var embeddingStatus: ContextEmbedStatus?
var createdAt: DateTime?
var createdDate: Date? {
createdAt?.decoded
}
}
struct ContextDoc: Codable, Identifiable, Equatable, Hashable {
var id: String
var contextId: String
var docId: String
var title: String?
var embeddingStatus: ContextEmbedStatus?
var createdAt: DateTime?
var createdDate: Date? {
createdAt?.decoded
}
}
struct ContextCategory: Codable, Identifiable, Equatable, Hashable {
var id: String
var contextId: String
var type: ContextCategoryType
var docs: [String]
var name: String?
var createdAt: DateTime?
var createdDate: Date? {
createdAt?.decoded
}
}
enum ContextEmbedStatus: String, Codable, CaseIterable {
case pending = "Pending"
case failed = "Failed"
case completed = "Completed"
}
enum ContextCategoryType: String, Codable, CaseIterable {
case tag = "TAG"
case collection = "COLLECTION"
}
struct MatchContextResult: Codable, Identifiable, Equatable, Hashable {
var id: String
var fileId: String?
var docId: String?
var chunk: Int
var content: String
var distance: Double
var highlightedContent: String?
init(fileId: String? = nil, docId: String? = nil, chunk: Int, content: String, distance: Double, highlightedContent: String? = nil) {
id = UUID().uuidString
self.fileId = fileId
self.docId = docId
self.chunk = chunk
self.content = content
self.distance = distance
self.highlightedContent = highlightedContent
}
}
}

View File

@@ -1,44 +0,0 @@
//
// ChatManager+InputModels.swift
// Intelligents
//
// Created by on 6/26/25.
//
import Foundation
// MARK: - ChatManager Input Models Extension
extension ChatManager {
// MARK: - Input Models
struct AddContextFileInput: Codable, Equatable, Hashable {
var contextId: String
var blobId: String
}
struct RemoveContextFileInput: Codable, Equatable, Hashable {
var contextId: String
var fileId: String
}
struct AddContextDocInput: Codable, Equatable, Hashable {
var contextId: String
var docId: String
}
struct RemoveContextDocInput: Codable, Equatable, Hashable {
var contextId: String
var docId: String
}
struct AddContextCategoryInput: Codable, Equatable, Hashable {
var contextId: String
var docs: [String]
}
struct RemoveContextCategoryInput: Codable, Equatable, Hashable {
var contextId: String
var categoryId: String
}
}

View File

@@ -0,0 +1,138 @@
//
// ChatManager+Stream.swift
// Intelligents
//
// Created by on 6/30/25.
//
import AffineGraphQL
import Apollo
import ApolloAPI
import EventSource
import Foundation
extension ChatManager {
public func startUserRequest(
content: String,
inputBoxData: InputBoxData,
sessionId: String
) {
append(sessionId: sessionId, UserMessageCellViewModel(
id: .init(),
content: inputBoxData.text,
timestamp: .init(),
attachments: [],
))
let messageParameters: [String: AnyHashable] = [
// packages/frontend/core/src/blocksuite/ai/provider/setup-provider.tsx
"docs": inputBoxData.documentAttachments.map(\.documentID), // affine doc
"files": [String](), // attachment in context, keep nil for now
"searchMode": inputBoxData.isSearchEnabled ? "MUST" : "AUTO",
]
let uploadableAttachments: [GraphQLFile] = [
inputBoxData.fileAttachments.map { file -> GraphQLFile in
.init(
fieldName: file.name,
originalName: file.name,
data: file.data ?? .init()
)
},
inputBoxData.imageAttachments.map { image -> GraphQLFile in
.init(
fieldName: image.hashValue.description,
originalName: "image.jpg",
data: image.imageData
)
},
].flatMap(\.self)
assert(uploadableAttachments.allSatisfy { !($0.data?.isEmpty ?? true) })
guard let input = try? CreateChatMessageInput(
content: .some(content),
params: .some(AffineGraphQL.JSON(_jsonValue: messageParameters)),
sessionId: sessionId
) else {
assertionFailure() // very unlikely to happen
return
}
let mutation = CreateCopilotMessageMutation(options: input)
QLService.shared.client.upload(operation: mutation, files: uploadableAttachments) { result in
DispatchQueue.main.async {
switch result {
case let .success(graphQLResult):
guard let messageIdentifier = graphQLResult.data?.createCopilotMessage else {
self.report(sessionId, ChatError.invalidResponse)
return
}
let viewModelId = self.append(sessionId: sessionId, AssistantMessageCellViewModel(
id: .init(),
content: .init(),
timestamp: .init()
))
self.startStreamingResponse(
sessionId: sessionId,
messageId: messageIdentifier,
applyingTo: viewModelId
)
case let .failure(error):
self.report(sessionId, error)
}
}
}
}
private func startStreamingResponse(sessionId: String, messageId: String, applyingTo vmId: UUID) {
let base = IntelligentContext.shared.webViewMetadata[.currentServerBaseUrl] as? String
guard let base, let url = URL(string: base) else {
report(sessionId, ChatError.invalidServerConfiguration)
return
}
let streamUrl = url
.appendingPathComponent("api")
.appendingPathComponent("copilot")
.appendingPathComponent("chat")
.appendingPathComponent(sessionId)
.appendingPathComponent("stream")
var comps = URLComponents(url: streamUrl, resolvingAgainstBaseURL: false)
comps?.queryItems = [
.init(name: "messageId", value: messageId),
.init(name: "retry", value: "false"), // TODO: IMPL FROM UI
]
guard let finalUrl = comps?.url else {
report(sessionId, ChatError.invalidStreamURL)
return
}
let eventSource = EventSource(
request: .init(
url: finalUrl,
cachePolicy: .reloadIgnoringLocalAndRemoteCacheData,
timeoutInterval: 10
),
configuration: .default
)
eventSource.onOpen = {
print("[*] \(messageId): connection established")
}
eventSource.onError = {
self.report(sessionId, $0 ?? ChatError.unknownError)
}
var document = ""
let queue = DispatchQueue(label: "com.affine.chat.stream.\(sessionId)")
eventSource.onMessage = { event in
queue.async {
print("[*] \(messageId): \(event.event ?? "?") received message: \(event.data)")
switch event.event {
case "message":
document += event.data
self.with(sessionId: sessionId, vmId: vmId) { (viewModel: inout AssistantMessageCellViewModel) in
viewModel.content = document
}
default:
break
}
}
}
closable.append(eventSource)
}
}

View File

@@ -1,74 +0,0 @@
//
// ChatManager+WorkflowModels.swift
// Intelligents
//
// Created by on 6/26/25.
//
import AffineGraphQL
import Foundation
// MARK: - ChatManager Workflow Models Extension
extension ChatManager {
// MARK: - Workflow Models
struct WorkflowEventData: Codable, Identifiable, Equatable, Hashable {
var id: String
var status: String
var type: String
var progress: Double?
var message: String?
init(status: String, type: String, progress: Double? = nil, message: String? = nil) {
id = UUID().uuidString
self.status = status
self.type = type
self.progress = progress
self.message = message
}
}
struct WorkspaceEmbeddingStatus: Codable, Identifiable, Equatable, Hashable {
var id: String
var workspaceId: String
var total: Int
var embedded: Int
var progress: Double {
total > 0 ? Double(embedded) / Double(total) : 0.0
}
init(workspaceId: String, total: Int, embedded: Int) {
id = workspaceId
self.workspaceId = workspaceId
self.total = total
self.embedded = embedded
}
}
struct ChatEvent: Codable, Identifiable, Equatable, Hashable {
var id: String
var type: ChatEventType
var data: String
var timestamp: DateTime?
var timestampDate: Date? {
timestamp?.decoded
}
init(type: ChatEventType, data: String, timestamp: DateTime? = nil) {
id = UUID().uuidString
self.type = type
self.data = data
self.timestamp = timestamp
}
}
enum ChatEventType: String, Codable, CaseIterable {
case message
case attachment
case event
case ping
}
}

View File

@@ -7,221 +7,76 @@
import AffineGraphQL
import Apollo
import ApolloAPI
import Combine
import EventSource
import Foundation
import OrderedCollections
// MARK: - ChatManager
public class ChatManager: ObservableObject {
public class ChatManager: ObservableObject, @unchecked Sendable {
public static let shared = ChatManager()
// MARK: - Properties
public typealias SessionID = String
public typealias MessageID = UUID // ChatCellViewModel ID
@Published public private(set) var viewModels: OrderedDictionary<
SessionID,
OrderedDictionary<MessageID, any ChatCellViewModel>
> = [:]
@Published public private(set) var sessions: [SessionViewModel] = []
@Published public private(set) var currentSession: SessionViewModel?
@Published public private(set) var messages: [String: [ChatMessage]] = [:]
@Published public private(set) var isLoading = false
@Published public private(set) var error: Error?
var closable: [Closable] = []
private var cancellables = Set<AnyCancellable>()
private let apolloClient: ApolloClient
private init() {}
// MARK: - Initialization
private init(apolloClient: ApolloClient = QLService.shared.client) {
self.apolloClient = apolloClient
public func closeAll() {
closable.forEach { $0.close() }
closable.removeAll()
}
// MARK: - Public Methods
public func with(sessionId: String, _ action: (inout OrderedDictionary<MessageID, any ChatCellViewModel>) -> Void) {
if Thread.isMainThread {
if var sessionViewModels = viewModels[sessionId] {
action(&sessionViewModels)
viewModels[sessionId] = sessionViewModels
} else {
var sessionViewModels = OrderedDictionary<MessageID, any ChatCellViewModel>()
action(&sessionViewModels)
viewModels[sessionId] = sessionViewModels
}
} else {
DispatchQueue.main.asyncAndWait {
self.with(sessionId: sessionId, action)
}
}
}
public func createSession(
workspaceId: String,
promptName: String = "",
docId: String? = nil,
pinned: Bool = false
) async throws -> SessionViewModel {
isLoading = true
error = nil
do {
let input = CreateChatSessionInput(
docId: docId.map { .some($0) } ?? .null,
pinned: .some(pinned),
promptName: promptName,
workspaceId: workspaceId
)
let mutation = CreateCopilotSessionMutation(options: input)
return try await withCheckedThrowingContinuation { continuation in
apolloClient.perform(mutation: mutation) { result in
switch result {
case let .success(graphQLResult):
guard let sessionId = graphQLResult.data?.createCopilotSession else {
continuation.resume(throwing: ChatError.invalidResponse)
return
}
let session = SessionViewModel(
id: sessionId,
workspaceId: workspaceId,
docId: docId,
promptName: promptName,
model: nil,
pinned: pinned,
tokens: 0,
createdAt: DateTime(date: Date()),
updatedAt: DateTime(date: Date()),
parentSessionId: nil
)
Task { @MainActor in
self.sessions.append(session)
self.currentSession = session
self.messages[sessionId] = []
self.isLoading = false
}
continuation.resume(returning: session)
case let .failure(error):
Task { @MainActor in
self.error = error
self.isLoading = false
}
continuation.resume(throwing: error)
}
public func with<T>(sessionId: String, vmId: UUID, _ action: (inout T) -> Void) {
with(sessionId: sessionId) { sessionViewModels in
if let read = sessionViewModels[vmId], var convert = read as? T {
action(&convert)
guard let vm = convert as? any ChatCellViewModel else {
assertionFailure()
return
}
sessionViewModels[vmId] = vm
} else {
assertionFailure()
}
} catch {
await MainActor.run {
self.error = error
self.isLoading = false
}
throw error
}
}
public func sendMessage(
content: String,
attachments: [String] = [],
sessionId: String? = nil
) async throws {
guard let targetSessionId = sessionId ?? currentSession?.id else {
throw ChatError.noActiveSession
}
@discardableResult
public func append(sessionId: String, _ viewModel: any ChatCellViewModel) -> UUID {
with(sessionId: sessionId) { $0.updateValue(viewModel, forKey: viewModel.id) }
return viewModel.id
}
isLoading = true
error = nil
// Add user message immediately
let userMessage = ChatMessage(
id: UUID().uuidString,
role: .user,
content: content,
attachments: attachments.isEmpty ? nil : attachments,
params: nil,
createdAt: DateTime(date: Date())
@discardableResult
public func report(_ sessionID: String, _ error: Error) -> UUID {
let model = ErrorCellViewModel(
id: .init(),
errorMessage: error.localizedDescription
)
await MainActor.run {
var sessionMessages = self.messages[targetSessionId] ?? []
sessionMessages.append(userMessage)
self.messages[targetSessionId] = sessionMessages
}
do {
let input = CreateChatMessageInput(
attachments: attachments.isEmpty ? .null : .some(attachments),
blobs: .null,
content: .some(content),
params: .null,
sessionId: targetSessionId
)
let mutation = CreateCopilotMessageMutation(options: input)
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
apolloClient.perform(mutation: mutation) { result in
switch result {
case let .success(graphQLResult):
guard let messageId = graphQLResult.data?.createCopilotMessage else {
continuation.resume(throwing: ChatError.invalidResponse)
return
}
// Add assistant message placeholder
let assistantMessage = ChatMessage(
id: messageId,
role: .assistant,
content: "Thinking...",
attachments: nil,
params: nil,
createdAt: DateTime(date: Date())
)
Task { @MainActor in
var sessionMessages = self.messages[targetSessionId] ?? []
sessionMessages.append(assistantMessage)
self.messages[targetSessionId] = sessionMessages
self.isLoading = false
}
continuation.resume()
// TODO: Implement streaming response handling
case let .failure(error):
Task { @MainActor in
self.error = error
self.isLoading = false
}
continuation.resume(throwing: error)
}
}
}
} catch {
await MainActor.run {
self.error = error
self.isLoading = false
}
throw error
}
}
public func switchToSession(_ session: SessionViewModel) {
currentSession = session
}
public func deleteSession(sessionId: String) {
sessions.removeAll { $0.id == sessionId }
messages.removeValue(forKey: sessionId)
if currentSession?.id == sessionId {
currentSession = sessions.first
}
}
public func clearError() {
error = nil
}
}
// MARK: - ChatError
public enum ChatError: LocalizedError {
case noActiveSession
case invalidResponse
case networkError(Error)
public var errorDescription: String? {
switch self {
case .noActiveSession:
"No active chat session"
case .invalidResponse:
"Invalid response from server"
case let .networkError(error):
"Network error: \(error.localizedDescription)"
}
append(sessionId: sessionID, model)
return model.id
}
}

View File

@@ -1,5 +1,5 @@
//
// ChatMessage.swift
// ChatSessionObject.swift
// Intelligents
//
// Created by on 6/26/25.
@@ -8,48 +8,7 @@
import AffineGraphQL
import Foundation
public struct ChatMessage: Codable, Identifiable, Equatable, Hashable {
public var id: String?
public var role: MessageRole
public var content: String
public var attachments: [String]?
public var params: [String: String]?
public var createdAt: DateTime?
public var createdDate: Date? {
createdAt?.decoded
}
public var messageId: String {
id ?? UUID().uuidString
}
public init(
id: String? = nil,
role: MessageRole,
content: String,
attachments: [String]? = nil,
params: [String: String]? = nil,
createdAt: DateTime? = nil
) {
self.id = id
self.role = role
self.content = content
self.attachments = attachments
self.params = params
self.createdAt = createdAt
}
}
public extension ChatMessage {
enum MessageRole: String, Codable, CaseIterable {
case user
case assistant
case system
}
}
public struct SessionViewModel: Codable, Identifiable, Equatable, Hashable {
public struct ChatSessionObject: Codable, Identifiable, Equatable, Hashable {
public var id: String
public var workspaceId: String
public var docId: String?

View File

@@ -0,0 +1,29 @@
//
// PromptName.swift
// Intelligents
//
// Created by on 6/30/25.
//
import Foundation
public enum PromptName: String, Codable {
case summary = "Summary"
case summaryAsTitle = "Summary as title"
case explainThis = "Explain this"
case writeAnArticleAboutThis = "Write an article about this"
case writeATwitterAboutThis = "Write a twitter about this"
case writeAPoemAboutThis = "Write a poem about this"
case writeABlogPostAboutThis = "Write a blog post about this"
case writeOutline = "Write outline"
case changeToneTo = "Change tone to"
case improveWritingForIt = "Improve writing for it"
case improveGrammarForIt = "Improve grammar for it"
case fixSpellingForIt = "Fix spelling for it"
case createHeadings = "Create headings"
case makeItLonger = "Make it longer"
case makeItShorter = "Make it shorter"
case continueWriting = "Continue writing"
case chatWithAffineAI = "Chat With AFFiNE AI"
case searchWithAffineAI = "Search With AFFiNE AI"
}

View File

@@ -0,0 +1,57 @@
//
// IntelligentContext+CreateSession.swift
// Intelligents
//
// Created by on 6/27/25.
//
import AffineGraphQL
import Apollo
import ApolloAPI
import Foundation
public extension IntelligentContext {
func createSession(
workspaceId: String,
promptName: PromptName = .chatWithAffineAI,
docId: String? = nil,
pinned: Bool = false,
completion: @escaping (Result<ChatSessionObject, Error>) -> Void
) {
let input = CreateChatSessionInput(
docId: docId.map { .some($0) } ?? .null,
pinned: .some(pinned),
promptName: promptName.rawValue,
workspaceId: workspaceId
)
let mutation = CreateCopilotSessionMutation(options: input)
QLService.shared.client.perform(mutation: mutation) { result in
switch result {
case let .success(graphQLResult):
guard let sessionId = graphQLResult.data?.createCopilotSession else {
completion(.failure(IntelligentError.sessionCreationFailed("No session ID returned.")))
return
}
let session = ChatSessionObject(
id: sessionId,
workspaceId: workspaceId,
docId: docId,
promptName: promptName.rawValue,
model: nil,
pinned: pinned,
tokens: 0,
createdAt: DateTime(date: Date()),
updatedAt: DateTime(date: Date()),
parentSessionId: nil
)
completion(.success(session))
case let .failure(error):
completion(.failure(IntelligentError.sessionCreationFailed(error.localizedDescription)))
}
}
}
}

View File

@@ -42,6 +42,9 @@ public class IntelligentContext {
case currentI18nLocale
}
public private(set) var currentSession: ChatSessionObject?
public private(set) var currentWorkspaceId: String?
public lazy var temporaryDirectory: URL = {
let tempDir = FileManager.default.temporaryDirectory
return tempDir.appendingPathComponent("IntelligentContext")
@@ -49,11 +52,14 @@ public class IntelligentContext {
public enum IntelligentError: Error, LocalizedError {
case loginRequired(String)
case sessionCreationFailed(String)
public var errorDescription: String? {
switch self {
case let .loginRequired(reason):
"Login required: \(reason)"
case let .sessionCreationFailed(reason):
"Session creation failed: \(reason)"
}
}
}
@@ -61,6 +67,7 @@ public class IntelligentContext {
private init() {}
public func preparePresent(_ completion: @escaping (Result<Void, Error>) -> Void) {
assert(webView != nil)
DispatchQueue.global(qos: .userInitiated).async { [self] in
prepareTemporaryDirectory()
@@ -79,21 +86,18 @@ public class IntelligentContext {
!baseUrlString.isEmpty,
let url = URL(string: baseUrlString)
else {
DispatchQueue.main.async {
completion(.failure(IntelligentError.loginRequired("Missing server base URL")))
}
completion(.failure(IntelligentError.loginRequired("Missing server base URL")))
return
}
guard let workspaceId = webViewMetadataResult[.currentWorkspaceId] as? String,
!workspaceId.isEmpty
else {
DispatchQueue.main.async {
completion(.failure(IntelligentError.loginRequired("Missing workspace ID")))
}
completion(.failure(IntelligentError.loginRequired("Missing workspace ID")))
return
}
currentWorkspaceId = workspaceId
QLService.shared.setEndpoint(base: url)
let gqlGroup = DispatchGroup()
@@ -106,20 +110,28 @@ public class IntelligentContext {
gqlGroup.wait()
qlMetadata = gqlMetadataResult
// Check required QL metadata
guard let userIdentifier = gqlMetadataResult[.userIdentifierKey] as? String,
!userIdentifier.isEmpty
else {
DispatchQueue.main.async {
completion(.failure(IntelligentError.loginRequired("Missing user identifier")))
}
completion(.failure(IntelligentError.loginRequired("Missing user identifier")))
return
}
let currentDocumentId: String? = webViewMetadata[.currentDocId] as? String
dumpMetadataContents()
DispatchQueue.main.async {
completion(.success(()))
createSession(
workspaceId: workspaceId,
docId: currentDocumentId
) { result in
switch result {
case let .success(session):
self.currentSession = session
completion(.success(()))
case let .failure(error):
completion(.failure(error))
}
}
}
}

View File

@@ -45,7 +45,7 @@ private class _AttachmentManagementController: UIViewController {
Section,
Item
> = .init(tableView: tableView) { [weak self] tableView, indexPath, item in
let cell = tableView.dequeueReusableCell(withIdentifier: "AttachmentCell", for: indexPath) as! AttachmentCell
let cell = tableView.dequeueReusableCell(withIdentifier: "AttachmentManagementCell", for: indexPath) as! AttachmentManagementCell
cell.configure(with: item)
cell.onDelete = { [weak self] in
guard let delegateController = self?.delegateController else { return }
@@ -119,7 +119,7 @@ private class _AttachmentManagementController: UIViewController {
tableView.backgroundColor = .clear
tableView.separatorStyle = .none
tableView.register(AttachmentCell.self, forCellReuseIdentifier: "AttachmentCell")
tableView.register(AttachmentManagementCell.self, forCellReuseIdentifier: "AttachmentManagementCell")
tableView.clipsToBounds = true
tableView.rowHeight = UITableView.automaticDimension
view.addSubview(tableView)
@@ -156,7 +156,7 @@ private class _AttachmentManagementController: UIViewController {
}
}
private class AttachmentCell: UITableViewCell {
private class AttachmentManagementCell: UITableViewCell {
let container = UIView().then {
$0.layer.cornerRadius = 4
$0.layer.borderWidth = 0.5

View File

@@ -76,41 +76,20 @@ extension MainViewController: InputBoxDelegate {
func inputBoxDidSend(_ inputBox: InputBox) {
let inputData = inputBox.inputBoxData
inputBox.text = ""
inputBox.viewModel.clearAllAttachments()
Task { @MainActor in
do {
let chatManager = ChatManager.shared
if let currentSession = chatManager.currentSession {
try await chatManager.sendMessage(
content: inputData.text,
attachments: [], // TODO: Handle attachments
sessionId: currentSession.id
)
} else {
guard let workspaceId = IntelligentContext.shared.webViewMetadata[.currentWorkspaceId] as? String,
!workspaceId.isEmpty
else {
showAlert(title: "Error", message: "No workspace available")
return
}
let session = try await chatManager.createSession(workspaceId: workspaceId)
try await chatManager.sendMessage(
content: inputData.text,
attachments: [], // TODO: Handle attachments
sessionId: session.id
)
}
inputBox.text = ""
inputBox.viewModel.clearAllAttachments()
} catch {
showAlert(title: "Error", message: error.localizedDescription)
}
guard let currentSession = IntelligentContext.shared.currentSession else {
showAlert(title: "Error", message: "No active session available")
return
}
ChatManager.shared.closeAll()
ChatManager.shared.startUserRequest(
content: inputData.text,
inputBoxData: inputData,
sessionId: currentSession.id
)
}
private func showAlert(title: String, message: String) {

View File

@@ -10,25 +10,8 @@ class MainViewController: UIViewController {
$0.delegate = self
}
lazy var tableView = UITableView().then {
$0.backgroundColor = .clear
$0.separatorStyle = .none
lazy var chatTableView = ChatTableView().then {
$0.delegate = self
$0.dataSource = self
$0.register(ChatCell.self, forCellReuseIdentifier: "ChatCell")
$0.keyboardDismissMode = .interactive
$0.contentInsetAdjustmentBehavior = .never
}
lazy var emptyStateView = UIView().then {
$0.isHidden = true
}
lazy var emptyStateLabel = UILabel().then {
$0.text = "Start a conversation..."
$0.font = .systemFont(ofSize: 18, weight: .medium)
$0.textColor = .systemGray
$0.textAlignment = .center
}
lazy var inputBox = InputBox().then {
@@ -49,10 +32,9 @@ class MainViewController: UIViewController {
// MARK: - Properties
private var messages: [ChatMessage] = []
private var cancellables = Set<AnyCancellable>()
private let intelligentContext = IntelligentContext.shared
private let chatManager = ChatManager.shared
var cancellables = Set<AnyCancellable>()
let intelligentContext = IntelligentContext.shared
let chatManager = ChatManager.shared
var terminateEditGesture: UITapGestureRecognizer!
// MARK: - Lifecycle
@@ -62,7 +44,6 @@ class MainViewController: UIViewController {
view.backgroundColor = .affineLayerBackgroundPrimary
setupUI()
setupBindings()
view.isUserInteractionEnabled = true
terminateEditGesture = UITapGestureRecognizer(target: self, action: #selector(terminateEditing))
@@ -73,32 +54,20 @@ class MainViewController: UIViewController {
private func setupUI() {
view.addSubview(headerView)
view.addSubview(tableView)
view.addSubview(emptyStateView)
view.addSubview(chatTableView)
view.addSubview(inputBox)
view.addSubview(documentPickerHideDetector)
view.addSubview(documentPickerView)
emptyStateView.addSubview(emptyStateLabel)
headerView.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide)
make.leading.trailing.equalToSuperview()
}
tableView.snp.makeConstraints { make in
chatTableView.snp.makeConstraints { make in
make.top.equalTo(headerView.snp.bottom)
make.leading.trailing.equalToSuperview()
make.bottom.equalTo(inputBox.snp.top)
}
emptyStateView.snp.makeConstraints { make in
make.center.equalTo(tableView)
make.width.lessThanOrEqualTo(tableView).inset(32)
}
emptyStateLabel.snp.makeConstraints { make in
make.edges.equalToSuperview()
make.left.right.equalToSuperview()
make.bottom.equalToSuperview()
}
inputBox.snp.makeConstraints { make in
@@ -117,24 +86,6 @@ class MainViewController: UIViewController {
}
}
private func setupBindings() {
chatManager.$currentSession
.receive(on: DispatchQueue.main)
.sink { [weak self] session in
self?.updateMessages(for: session?.id)
}
.store(in: &cancellables)
chatManager.$messages
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
if let sessionId = self?.chatManager.currentSession?.id {
self?.updateMessages(for: sessionId)
}
}
.store(in: &cancellables)
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
navigationController!.setNavigationBarHidden(true, animated: animated)
@@ -154,64 +105,12 @@ class MainViewController: UIViewController {
}
// MARK: - Chat Methods
private func updateMessages(for sessionId: String?) {
guard let sessionId else {
messages = []
updateEmptyState()
tableView.reloadData()
return
}
messages = chatManager.messages[sessionId] ?? []
updateEmptyState()
tableView.reloadData()
if !messages.isEmpty {
let indexPath = IndexPath(row: messages.count - 1, section: 0)
tableView.scrollToRow(at: indexPath, at: .bottom, animated: true)
}
}
private func updateEmptyState() {
emptyStateView.isHidden = !messages.isEmpty
tableView.isHidden = messages.isEmpty
}
// MARK: - Internal Methods for Preview/Testing
#if DEBUG
func setMessagesForPreview(_ previewMessages: [ChatMessage]) {
messages = previewMessages
updateEmptyState()
tableView.reloadData()
}
#endif
}
// MARK: - UITableViewDataSource
// MARK: - ChatTableViewDelegate
extension MainViewController: UITableViewDataSource {
func tableView(_: UITableView, numberOfRowsInSection _: Int) -> Int {
messages.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "ChatCell", for: indexPath) as! ChatCell
let message = messages[indexPath.row]
cell.configure(with: message)
return cell
}
}
// MARK: - UITableViewDelegate
extension MainViewController: UITableViewDelegate {
func tableView(_: UITableView, heightForRowAt _: IndexPath) -> CGFloat {
UITableView.automaticDimension
}
func tableView(_: UITableView, estimatedHeightForRowAt _: IndexPath) -> CGFloat {
60
extension MainViewController: ChatTableViewDelegate {
func chatTableView(_: ChatTableView, didSelectRowAt _: IndexPath) {
// Handle cell selection if needed
}
}

View File

@@ -0,0 +1,139 @@
//
// AssistantMessageCell.swift
// Intelligents
//
// Created by on 6/27/25.
//
import SnapKit
import Then
import UIKit
class AssistantMessageCell: ChatBaseCell {
// MARK: - UI Components
private lazy var messageLabel = UILabel().then {
$0.numberOfLines = 0
$0.font = .systemFont(ofSize: 16)
$0.textColor = .label
}
private lazy var metadataStackView = UIStackView().then {
$0.axis = .horizontal
$0.spacing = 8
$0.alignment = .center
}
private lazy var modelLabel = UILabel().then {
$0.font = .systemFont(ofSize: 12, weight: .medium)
$0.textColor = .secondaryLabel
}
private lazy var tokensLabel = UILabel().then {
$0.font = .systemFont(ofSize: 12)
$0.textColor = .secondaryLabel
}
private lazy var timestampLabel = UILabel().then {
$0.font = .systemFont(ofSize: 12)
$0.textColor = .secondaryLabel
}
private lazy var streamingIndicator = UIActivityIndicatorView().then {
$0.style = .medium
$0.hidesWhenStopped = true
}
private lazy var retryButton = UIButton(type: .system).then {
$0.setTitle("重试", for: .normal)
$0.titleLabel?.font = .systemFont(ofSize: 12)
$0.setTitleColor(.systemBlue, for: .normal)
}
private lazy var mainStackView = UIStackView().then {
$0.axis = .vertical
$0.spacing = 8
$0.alignment = .fill
}
// MARK: - Properties
private var viewModel: AssistantMessageCellViewModel?
// MARK: - Setup
override func setupContentView() {
containerView.addSubview(mainStackView)
mainStackView.addArrangedSubview(messageLabel)
mainStackView.addArrangedSubview(metadataStackView)
metadataStackView.addArrangedSubview(modelLabel)
metadataStackView.addArrangedSubview(tokensLabel)
metadataStackView.addArrangedSubview(UIView()) // Spacer
metadataStackView.addArrangedSubview(streamingIndicator)
metadataStackView.addArrangedSubview(retryButton)
metadataStackView.addArrangedSubview(timestampLabel)
mainStackView.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(contentInsets)
}
retryButton.addTarget(self, action: #selector(retryButtonTapped), for: .touchUpInside)
}
// MARK: - Configuration
override func configure(with viewModel: ChatCellViewModel) {
guard let assistantViewModel = viewModel as? AssistantMessageCellViewModel else { return }
self.viewModel = assistantViewModel
messageLabel.text = assistantViewModel.content
configureContainer(backgroundColor: backgroundColor(for: assistantViewModel.cellType))
//
if let model = assistantViewModel.model {
modelLabel.text = model
modelLabel.isHidden = false
} else {
modelLabel.isHidden = true
}
// tokens
if let tokens = assistantViewModel.tokens {
tokensLabel.text = "\(tokens) tokens"
tokensLabel.isHidden = false
} else {
tokensLabel.isHidden = true
}
//
let timestamp = assistantViewModel.timestamp
timestampLabel.text = formatTimestamp(timestamp)
timestampLabel.isHidden = false
//
if assistantViewModel.isStreaming {
streamingIndicator.startAnimating()
} else {
streamingIndicator.stopAnimating()
}
//
retryButton.isHidden = !assistantViewModel.canRetry
}
// MARK: - Actions
@objc private func retryButtonTapped() {
// TODO:
}
// MARK: - Helpers
private func formatTimestamp(_ timestamp: Date) -> String {
let formatter = DateFormatter()
formatter.timeStyle = .short
return formatter.string(from: timestamp)
}
}

View File

@@ -0,0 +1,112 @@
//
// ChatBaseCell.swift
// Intelligents
//
// Created by on 6/27/25.
//
import SnapKit
import Then
import UIKit
class ChatBaseCell: UITableViewCell {
// MARK: - UI Components
///
lazy var containerView = UIView().then {
$0.layer.cornerRadius = 8
$0.layer.cornerCurve = .continuous
}
// MARK: - Properties
///
var containerInsets: UIEdgeInsets {
UIEdgeInsets(top: 8, left: 16, bottom: 8, right: 16)
}
///
var contentInsets: UIEdgeInsets {
UIEdgeInsets(top: 12, left: 12, bottom: 12, right: 12)
}
// MARK: - Initialization
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
setupBaseUI()
setupContentView()
}
@available(*, unavailable)
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: - Setup
private func setupBaseUI() {
backgroundColor = .clear
selectionStyle = .none
contentView.addSubview(containerView)
containerView.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(containerInsets)
}
}
///
func setupContentView() {
//
}
// MARK: - Configuration
///
func configureContainer(backgroundColor: UIColor?, borderColor: UIColor? = nil, borderWidth: CGFloat = 0) {
containerView.backgroundColor = backgroundColor
if let borderColor {
containerView.layer.borderColor = borderColor.cgColor
containerView.layer.borderWidth = borderWidth
} else {
containerView.layer.borderColor = nil
containerView.layer.borderWidth = 0
}
}
/// ViewModel
func configure(with _: ChatCellViewModel) {
//
}
// MARK: - Helpers
///
func textColor(for cellType: CellType) -> UIColor {
switch cellType {
case .userMessage, .assistantMessage, .systemMessage:
.label
case .error:
.systemRed
case .loading:
.secondaryLabel
default:
.label
}
}
///
func backgroundColor(for cellType: CellType) -> UIColor? {
switch cellType {
case .userMessage, .assistantMessage:
.clear
case .systemMessage:
.systemYellow.withAlphaComponent(0.2)
case .error:
.systemRed.withAlphaComponent(0.1)
case .loading:
.systemGray6
}
}
}

View File

@@ -1,155 +0,0 @@
//
// ChatCell.swift
// Intelligents
//
// Created by on 6/26/25.
//
import SnapKit
import Then
import UIKit
class ChatCell: UITableViewCell {
// MARK: - UI Components
private lazy var avatarImageView = UIImageView().then {
$0.contentMode = .scaleAspectFit
$0.layer.cornerRadius = 16
$0.layer.cornerCurve = .continuous
$0.clipsToBounds = true
$0.backgroundColor = .systemGray5
}
private lazy var messageContainerView = UIView().then {
$0.layer.cornerRadius = 12
$0.layer.cornerCurve = .continuous
}
private lazy var messageLabel = UILabel().then {
$0.numberOfLines = 0
$0.font = .systemFont(ofSize: 16)
$0.textColor = .label
}
private lazy var timestampLabel = UILabel().then {
$0.font = .systemFont(ofSize: 12)
$0.textColor = .systemGray
$0.textAlignment = .right
}
private lazy var stackView = UIStackView().then {
$0.axis = .horizontal
$0.spacing = 12
$0.alignment = .top
}
private lazy var messageStackView = UIStackView().then {
$0.axis = .vertical
$0.spacing = 4
}
// MARK: - Properties
private var message: ChatMessage?
// MARK: - Initialization
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
setupUI()
}
@available(*, unavailable)
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: - Setup
private func setupUI() {
backgroundColor = .clear
selectionStyle = .none
contentView.addSubview(stackView)
messageStackView.addArrangedSubview(messageContainerView)
messageStackView.addArrangedSubview(timestampLabel)
messageContainerView.addSubview(messageLabel)
stackView.addArrangedSubview(avatarImageView)
stackView.addArrangedSubview(messageStackView)
stackView.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(16)
}
avatarImageView.snp.makeConstraints { make in
make.size.equalTo(32)
}
messageLabel.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(12)
}
messageStackView.snp.makeConstraints { make in
make.width.lessThanOrEqualTo(250)
}
}
// MARK: - Configuration
func configure(with message: ChatMessage) {
self.message = message
messageLabel.text = message.content
if let createdDate = message.createdDate {
let formatter = DateFormatter()
formatter.dateStyle = .none
formatter.timeStyle = .short
timestampLabel.text = formatter.string(from: createdDate)
} else {
timestampLabel.text = ""
}
switch message.role {
case .user:
configureUserMessage()
case .assistant:
configureAssistantMessage()
case .system:
configureSystemMessage()
}
}
private func configureUserMessage() {
// User message - align to right
stackView.semanticContentAttribute = .forceRightToLeft
messageContainerView.backgroundColor = .systemBlue
messageLabel.textColor = .white
avatarImageView.image = UIImage(systemName: "person.circle.fill")
avatarImageView.tintColor = .systemBlue
timestampLabel.textAlignment = .left
}
private func configureAssistantMessage() {
// Assistant message - align to left
stackView.semanticContentAttribute = .forceLeftToRight
messageContainerView.backgroundColor = .systemGray6
messageLabel.textColor = .label
avatarImageView.image = UIImage(systemName: "brain.head.profile")
avatarImageView.tintColor = .systemPurple
timestampLabel.textAlignment = .right
}
private func configureSystemMessage() {
// System message - center aligned
stackView.semanticContentAttribute = .forceLeftToRight
messageContainerView.backgroundColor = .systemYellow.withAlphaComponent(0.3)
messageLabel.textColor = .label
avatarImageView.image = UIImage(systemName: "gear")
avatarImageView.tintColor = .systemOrange
timestampLabel.textAlignment = .center
}
}

View File

@@ -0,0 +1,69 @@
//
// ChatCellFactory.swift
// Intelligents
//
// Created by on 6/27/25.
//
import UIKit
class ChatCellFactory {
// MARK: - Cell Registration
static func registerCells(for tableView: UITableView) {
tableView.register(UserMessageCell.self, forCellReuseIdentifier: CellType.userMessage.rawValue)
tableView.register(AssistantMessageCell.self, forCellReuseIdentifier: CellType.assistantMessage.rawValue)
tableView.register(SystemMessageCell.self, forCellReuseIdentifier: CellType.systemMessage.rawValue)
tableView.register(LoadingCell.self, forCellReuseIdentifier: CellType.loading.rawValue)
tableView.register(ErrorCell.self, forCellReuseIdentifier: CellType.error.rawValue)
}
// MARK: - Cell Creation
static func dequeueCell(
for tableView: UITableView,
at indexPath: IndexPath,
with viewModel: ChatCellViewModel
) -> ChatBaseCell {
let identifier = viewModel.cellType.rawValue
guard let cell = tableView.dequeueReusableCell(
withIdentifier: identifier,
for: indexPath
) as? ChatBaseCell else {
// cell使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: ChatCellViewModel) -> CGFloat {
switch viewModel.cellType {
case .userMessage,
.assistantMessage:
80
case .systemMessage:
60
case .loading:
100
case .error:
120
}
}
}

View File

@@ -0,0 +1,97 @@
//
// ErrorCell.swift
// Intelligents
//
// Created by on 6/27/25.
//
import SnapKit
import Then
import UIKit
class ErrorCell: ChatBaseCell {
// MARK: - UI Components
private lazy var iconView = UIImageView().then {
$0.image = UIImage(systemName: "exclamationmark.triangle.fill")
$0.tintColor = .systemRed
$0.contentMode = .scaleAspectFit
}
private lazy var errorLabel = UILabel().then {
$0.numberOfLines = 0
$0.font = .systemFont(ofSize: 14, weight: .medium)
$0.textColor = .systemRed
}
private lazy var retryButton = UIButton(type: .system).then {
$0.setTitle("Retry", for: .normal)
$0.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium)
$0.setTitleColor(.systemBlue, for: .normal)
$0.backgroundColor = .systemBlue.withAlphaComponent(0.1)
$0.layer.cornerRadius = 8
$0.layer.cornerCurve = .continuous
$0.contentEdgeInsets = UIEdgeInsets(top: 8, left: 16, bottom: 8, right: 16)
}
private lazy var contentStackView = UIStackView().then {
$0.axis = .horizontal
$0.spacing = 12
$0.alignment = .top
}
private lazy var textStackView = UIStackView().then {
$0.axis = .vertical
$0.spacing = 12
$0.alignment = .fill
}
// MARK: - Properties
private var viewModel: ErrorCellViewModel?
// MARK: - Setup
override func setupContentView() {
containerView.addSubview(contentStackView)
contentStackView.addArrangedSubview(iconView)
contentStackView.addArrangedSubview(textStackView)
textStackView.addArrangedSubview(errorLabel)
textStackView.addArrangedSubview(retryButton)
contentStackView.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(contentInsets)
}
iconView.snp.makeConstraints { make in
make.width.height.equalTo(24)
}
retryButton.addTarget(self, action: #selector(retryButtonTapped), for: .touchUpInside)
}
// MARK: - Configuration
override func configure(with viewModel: any ChatCellViewModel) {
guard let errorViewModel = viewModel as? ErrorCellViewModel else {
assertionFailure()
return
}
self.viewModel = errorViewModel
errorLabel.text = errorViewModel.errorMessage
configureContainer(
backgroundColor: backgroundColor(for: errorViewModel.cellType),
borderColor: .systemRed.withAlphaComponent(0.3),
borderWidth: 1
)
}
// MARK: - Actions
@objc private func retryButtonTapped() {
// TODO:
}
}

View File

@@ -0,0 +1,89 @@
//
// LoadingCell.swift
// Intelligents
//
// Created by on 6/27/25.
//
import SnapKit
import Then
import UIKit
class LoadingCell: ChatBaseCell {
// MARK: - UI Components
private lazy var activityIndicator = UIActivityIndicatorView().then {
$0.style = .medium
$0.hidesWhenStopped = false
}
private lazy var messageLabel = UILabel().then {
$0.numberOfLines = 0
$0.font = .systemFont(ofSize: 14)
$0.textColor = .secondaryLabel
$0.textAlignment = .center
}
private lazy var progressView = UIProgressView().then {
$0.progressViewStyle = .default
$0.trackTintColor = .systemGray5
$0.progressTintColor = .systemBlue
}
private lazy var stackView = UIStackView().then {
$0.axis = .vertical
$0.spacing = 12
$0.alignment = .center
}
// MARK: - Properties
private var viewModel: LoadingCellViewModel?
// MARK: - Setup
override func setupContentView() {
containerView.addSubview(stackView)
stackView.addArrangedSubview(activityIndicator)
stackView.addArrangedSubview(messageLabel)
stackView.addArrangedSubview(progressView)
stackView.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(contentInsets)
}
progressView.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview()
make.height.equalTo(4)
}
activityIndicator.startAnimating()
}
// MARK: - Configuration
override func configure(with viewModel: ChatCellViewModel) {
guard let loadingViewModel = viewModel as? LoadingCellViewModel else { return }
self.viewModel = loadingViewModel
configureContainer(backgroundColor: backgroundColor(for: loadingViewModel.cellType))
//
if let message = loadingViewModel.message {
messageLabel.text = message
messageLabel.isHidden = false
} else {
messageLabel.text = "Processing..."
messageLabel.isHidden = false
}
//
if let progress = loadingViewModel.progress {
progressView.progress = Float(progress)
progressView.isHidden = false
} else {
progressView.isHidden = true
}
}
}

View File

@@ -0,0 +1,94 @@
//
// SystemMessageCell.swift
// Intelligents
//
// Created by on 6/27/25.
//
import SnapKit
import Then
import UIKit
class SystemMessageCell: ChatBaseCell {
// MARK: - UI Components
private lazy var iconView = UIImageView().then {
$0.image = UIImage(systemName: "info.circle.fill")
$0.tintColor = .systemOrange
$0.contentMode = .scaleAspectFit
}
private lazy var messageLabel = UILabel().then {
$0.numberOfLines = 0
$0.font = .systemFont(ofSize: 14, weight: .medium)
$0.textColor = .label
}
private lazy var timestampLabel = UILabel().then {
$0.font = .systemFont(ofSize: 12)
$0.textColor = .secondaryLabel
$0.textAlignment = .right
}
private lazy var contentStackView = UIStackView().then {
$0.axis = .horizontal
$0.spacing = 12
$0.alignment = .top
}
private lazy var textStackView = UIStackView().then {
$0.axis = .vertical
$0.spacing = 4
$0.alignment = .fill
}
// MARK: - Properties
private var viewModel: SystemMessageCellViewModel?
// MARK: - Setup
override func setupContentView() {
containerView.addSubview(contentStackView)
contentStackView.addArrangedSubview(iconView)
contentStackView.addArrangedSubview(textStackView)
textStackView.addArrangedSubview(messageLabel)
textStackView.addArrangedSubview(timestampLabel)
contentStackView.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(contentInsets)
}
iconView.snp.makeConstraints { make in
make.width.height.equalTo(20)
}
}
// MARK: - Configuration
override func configure(with viewModel: ChatCellViewModel) {
guard let systemViewModel = viewModel as? SystemMessageCellViewModel else { return }
self.viewModel = systemViewModel
messageLabel.text = systemViewModel.content
configureContainer(backgroundColor: backgroundColor(for: systemViewModel.cellType))
//
if let timestamp = systemViewModel.timestamp {
timestampLabel.text = formatTimestamp(timestamp)
timestampLabel.isHidden = false
} else {
timestampLabel.isHidden = true
}
}
// MARK: - Helpers
private func formatTimestamp(_ timestamp: Date) -> String {
let formatter = DateFormatter()
formatter.timeStyle = .short
return formatter.string(from: timestamp)
}
}

View File

@@ -0,0 +1,94 @@
//
// UserMessageCell.swift
// Intelligents
//
// Created by on 6/27/25.
//
import SnapKit
import Then
import UIKit
class UserMessageCell: ChatBaseCell {
// MARK: - UI Components
private lazy var messageLabel = UILabel().then {
$0.numberOfLines = 0
$0.font = .systemFont(ofSize: 16)
$0.textColor = .label
}
private lazy var timestampLabel = UILabel().then {
$0.font = .systemFont(ofSize: 12)
$0.textColor = .secondaryLabel
$0.textAlignment = .right
}
private lazy var retryIndicator = UIActivityIndicatorView().then {
$0.style = .medium
$0.hidesWhenStopped = true
}
private lazy var stackView = UIStackView().then {
$0.axis = .vertical
$0.spacing = 8
$0.alignment = .fill
}
// MARK: - Properties
private var viewModel: UserMessageCellViewModel?
// MARK: - Setup
override func setupContentView() {
containerView.addSubview(stackView)
stackView.addArrangedSubview(messageLabel)
let bottomContainer = UIView()
stackView.addArrangedSubview(bottomContainer)
bottomContainer.addSubview(retryIndicator)
bottomContainer.addSubview(timestampLabel)
stackView.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(contentInsets)
}
retryIndicator.snp.makeConstraints { make in
make.leading.centerY.equalToSuperview()
make.width.height.equalTo(16)
}
timestampLabel.snp.makeConstraints { make in
make.trailing.top.bottom.equalToSuperview()
make.leading.greaterThanOrEqualTo(retryIndicator.snp.trailing).offset(8)
}
bottomContainer.snp.makeConstraints { make in
make.height.equalTo(16)
}
}
// MARK: - Configuration
override func configure(with viewModel: any ChatCellViewModel) {
guard let userViewModel = viewModel as? UserMessageCellViewModel else { return }
self.viewModel = userViewModel
messageLabel.text = userViewModel.content
configureContainer(backgroundColor: backgroundColor(for: userViewModel.cellType))
let timestamp = userViewModel.timestamp
timestampLabel.text = formatTimestamp(timestamp)
timestampLabel.isHidden = false
}
// MARK: - Helpers
private func formatTimestamp(_ timestamp: Date) -> String {
let formatter = DateFormatter()
formatter.timeStyle = .short
return formatter.string(from: timestamp)
}
}

View File

@@ -0,0 +1,44 @@
//
// AssistantMessageCellViewModel.swift
// Intelligents
//
// Created by on 6/27/25.
//
import Foundation
struct AssistantMessageCellViewModel: ChatCellViewModel {
var cellType: CellType = .assistantMessage
var id: UUID
var content: String
var timestamp: Date
var isStreaming: Bool = false
var model: String?
var tokens: Int?
var canRetry: Bool = false
var citations: [CitationViewModel]?
var actions: [MessageActionViewModel]?
}
struct CitationViewModel: Codable, Identifiable, Equatable, Hashable {
var id: String
var title: String
var url: String?
var snippet: String?
}
struct MessageActionViewModel: Codable, Identifiable, Equatable, Hashable {
var id: String
var title: String
var actionType: ActionType
var data: [String: String]?
enum ActionType: String, Codable {
case copy
case regenerate
case like
case dislike
case share
case edit
}
}

View File

@@ -1,23 +0,0 @@
//
// AttachmentCellViewModel.swift
// Intelligents
//
// Created by on 6/26/25.
//
import Foundation
struct AttachmentCellViewModel: ChatCellViewModel {
var cellType: CellType = .attachment
var id: String
var attachments: [AttachmentViewModel]
var parentMessageId: String
}
struct AttachmentViewModel: Codable, Identifiable, Equatable, Hashable {
var id: String
var url: String
var mimeType: String?
var fileName: String?
var size: Int64?
}

View File

@@ -7,14 +7,10 @@
import Foundation
enum CellType: String, Codable, CaseIterable {
public enum CellType: String, Codable, CaseIterable {
case userMessage
case assistantMessage
case systemMessage
case attachment
case contextReference
case workflowStatus
case transcription
case loading
case error
}

View File

@@ -0,0 +1,13 @@
//
// ChatCellViewModel.swift
// Intelligents
//
// Created by on 6/26/25.
//
import Foundation
public protocol ChatCellViewModel: Codable, Identifiable, Equatable, Hashable {
var id: UUID { get }
var cellType: CellType { get }
}

View File

@@ -1,41 +0,0 @@
//
// ChatCellViewModels.swift
// Intelligents
//
// Created by on 6/26/25.
//
import Foundation
protocol ChatCellViewModel: Codable, Identifiable, Equatable, Hashable {
var cellType: CellType { get }
var id: String { get }
}
struct UserMessageCellViewModel: ChatCellViewModel {
var cellType: CellType = .userMessage
var id: String
var content: String
var attachments: [AttachmentViewModel]
var timestamp: Date?
var isRetrying: Bool
}
struct AssistantMessageCellViewModel: ChatCellViewModel {
var cellType: CellType = .assistantMessage
var id: String
var content: String
var attachments: [AttachmentViewModel]
var timestamp: Date?
var isStreaming: Bool
var model: String?
var tokens: Int?
var canRetry: Bool
}
struct SystemMessageCellViewModel: ChatCellViewModel {
var cellType: CellType = .systemMessage
var id: String
var content: String
var timestamp: Date?
}

View File

@@ -1,15 +0,0 @@
//
// ContextReferenceCellViewModel.swift
// Intelligents
//
// Created by on 6/26/25.
//
import Foundation
struct ContextReferenceCellViewModel: ChatCellViewModel {
var cellType: CellType = .contextReference
var id: String
var references: [ChatManager.ContextReference]
var parentMessageId: String
}

View File

@@ -9,8 +9,6 @@ import Foundation
struct ErrorCellViewModel: ChatCellViewModel {
var cellType: CellType = .error
var id: String
var id: UUID
var errorMessage: String
var canRetry: Bool
var retryAction: String?
}

View File

@@ -9,7 +9,7 @@ import Foundation
struct LoadingCellViewModel: ChatCellViewModel {
var cellType: CellType = .loading
var id: String
var id: UUID
var message: String?
var progress: Double?
}

View File

@@ -0,0 +1,15 @@
//
// SystemMessageCellViewModel.swift
// Intelligents
//
// Created by on 6/27/25.
//
import Foundation
struct SystemMessageCellViewModel: ChatCellViewModel {
var cellType: CellType = .systemMessage
var id: UUID
var content: String
var timestamp: Date?
}

View File

@@ -0,0 +1,16 @@
//
// UserMessageCellViewModel.swift
// Intelligents
//
// Created by on 6/27/25.
//
import Foundation
struct UserMessageCellViewModel: ChatCellViewModel {
var cellType: CellType = .userMessage
var id: UUID
var content: String
var timestamp: Date
var attachments: [String] = []
}

View File

@@ -1,15 +0,0 @@
//
// WorkflowStatusCellViewModel.swift
// Intelligents
//
// Created by on 6/26/25.
//
import Foundation
struct WorkflowStatusCellViewModel: ChatCellViewModel {
var cellType: CellType = .workflowStatus
var id: String
var workflow: ChatManager.WorkflowEventData
var parentMessageId: String
}

View File

@@ -0,0 +1,154 @@
import Combine
import OrderedCollections
import SnapKit
import Then
import UIKit
protocol ChatTableViewDelegate: AnyObject {
func chatTableView(_ tableView: ChatTableView, didSelectRowAt indexPath: IndexPath)
}
class ChatTableView: UIView {
// MARK: - UI Components
lazy var tableView = UITableView().then {
$0.backgroundColor = .clear
$0.separatorStyle = .none
$0.delegate = self
$0.dataSource = self
$0.keyboardDismissMode = .interactive
$0.contentInsetAdjustmentBehavior = .never
$0.tableFooterView = UIView(frame: .init(x: 0, y: 0, width: 100, height: 500))
}
lazy var emptyStateView = UIView().then {
$0.isHidden = true
}
lazy var emptyStateLabel = UILabel().then {
$0.text = "Start a conversation..."
$0.font = .systemFont(ofSize: 18, weight: .medium)
$0.textColor = .systemGray
$0.textAlignment = .center
}
// MARK: - Properties
weak var delegate: ChatTableViewDelegate?
var sessionId: String? {
didSet {
if let sessionId {
bindToSession(sessionId)
}
}
}
private var cancellables = Set<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)
}
}

View File

@@ -85,4 +85,20 @@ extension InputBox: UITextViewDelegate {
updatePlaceholderVisibility()
updateTextViewHeight()
}
func textView(_: UITextView, shouldChangeTextIn _: NSRange, replacementText text: String) -> Bool {
if text == "\n" {
delegate?.inputBoxDidSend(self)
return false
}
return true
}
func textView(_ textView: UITextView, editMenuForTextIn _: NSRange, suggestedActions: [UIMenuElement]) -> UIMenu? {
let insertNewLineAction = UIAction(title: "Insert New Line") { _ in
textView.insertText("\n")
}
return UIMenu(children: suggestedActions + [insertNewLineAction])
}
}

View File

@@ -30,6 +30,7 @@ class InputBox: UIView {
$0.textContainerInset = .zero
$0.delegate = self
$0.text = ""
$0.returnKeyType = .send
}
lazy var placeholderLabel = UILabel().then {
@@ -147,7 +148,7 @@ class InputBox: UIView {
}
.store(in: &cancellables)
viewModel.$isNetworkEnabled
viewModel.$isSearchEnabled
.removeDuplicates()
.sink { [weak self] enabled in
self?.functionBar.updateNetworkState(isEnabled: enabled)

View File

@@ -5,6 +5,16 @@ import UIKit
private let unselectedColor: UIColor = .affineIconPrimary
private let selectedColor: UIColor = .affineIconActivated
private let configurableOptions: [ConfigurableOptions] = [
.networking,
.reasoning,
]
enum ConfigurableOptions {
case tool
case networking
case reasoning
}
class InputBoxFunctionBar: UIView {
weak var delegate: InputBoxFunctionBarDelegate?
@@ -23,6 +33,7 @@ class InputBoxFunctionBar: UIView {
$0.tintColor = unselectedColor
$0.imageView?.contentMode = .scaleAspectFit
$0.addTarget(self, action: #selector(toolButtonTapped), for: .touchUpInside)
$0.isHidden = !configurableOptions.contains(.tool)
}
lazy var networkButton = UIButton(type: .system).then {
@@ -30,6 +41,7 @@ class InputBoxFunctionBar: UIView {
$0.tintColor = unselectedColor
$0.imageView?.contentMode = .scaleAspectFit
$0.addTarget(self, action: #selector(networkButtonTapped), for: .touchUpInside)
$0.isHidden = !configurableOptions.contains(.networking)
}
lazy var deepThinkingButton = UIButton(type: .system).then {
@@ -37,6 +49,7 @@ class InputBoxFunctionBar: UIView {
$0.tintColor = unselectedColor
$0.imageView?.contentMode = .scaleAspectFit
$0.addTarget(self, action: #selector(deepThinkingButtonTapped), for: .touchUpInside)
$0.isHidden = !configurableOptions.contains(.reasoning)
}
lazy var sendButton = UIButton(type: .system).then {

View File

@@ -16,16 +16,16 @@ public struct InputBoxData {
public var fileAttachments: [FileAttachment] = []
public var documentAttachments: [DocumentAttachment] = []
public var isToolEnabled: Bool
public var isNetworkEnabled: Bool
public var isSearchEnabled: Bool
public var isDeepThinkingEnabled: Bool
public init(text: String, imageAttachments: [ImageAttachment], fileAttachments: [FileAttachment], documentAttachments: [DocumentAttachment], isToolEnabled: Bool, isNetworkEnabled: Bool, isDeepThinkingEnabled: Bool) {
public init(text: String, imageAttachments: [ImageAttachment], fileAttachments: [FileAttachment], documentAttachments: [DocumentAttachment], isToolEnabled: Bool, isSearchEnabled: Bool, isDeepThinkingEnabled: Bool) {
self.text = text
self.imageAttachments = imageAttachments
self.fileAttachments = fileAttachments
self.documentAttachments = documentAttachments
self.isToolEnabled = isToolEnabled
self.isNetworkEnabled = isNetworkEnabled
self.isSearchEnabled = isSearchEnabled
self.isDeepThinkingEnabled = isDeepThinkingEnabled
}
}
@@ -37,7 +37,7 @@ public class InputBoxViewModel: ObservableObject {
@Published public var inputText: String = ""
@Published public var isToolEnabled: Bool = false
@Published public var isNetworkEnabled: Bool = false
@Published public var isSearchEnabled: Bool = false
@Published public var isDeepThinkingEnabled: Bool = false
@Published public var imageAttachments: [ImageAttachment] = []
@Published public var fileAttachments: [FileAttachment] = []
@@ -90,7 +90,7 @@ public extension InputBoxViewModel {
}
func toggleNetwork() {
isNetworkEnabled.toggle()
isSearchEnabled.toggle()
}
func toggleDeepThinking() {
@@ -148,7 +148,7 @@ public extension InputBoxViewModel {
fileAttachments: fileAttachments,
documentAttachments: documentAttachments,
isToolEnabled: isToolEnabled,
isNetworkEnabled: isNetworkEnabled,
isSearchEnabled: isSearchEnabled,
isDeepThinkingEnabled: isDeepThinkingEnabled
)
}
@@ -159,7 +159,7 @@ public extension InputBoxViewModel {
fileAttachments.removeAll()
documentAttachments.removeAll()
isToolEnabled = false
isNetworkEnabled = false
isSearchEnabled = false
isDeepThinkingEnabled = false
}
}