chore: define view model (#12949)

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

## Summary by CodeRabbit

* **New Features**
* Introduced a chat interface with message list, empty state view, and
support for user, assistant, and system messages.
* Added a chat manager for session and message handling, including
session creation, message sending, and error management.
* Implemented various chat cell types (attachments, context references,
workflow status, loading, and error cells) with corresponding data
models and view models.
* Enabled asynchronous message sending from the input box with error
alerts and automatic session creation.
* Added workflow and context-related models for advanced chat features.

* **Enhancements**
* Improved UI responsiveness with table view updates and dynamic empty
state handling.
  * Provided a method to clear all attachments in the input box.

* **Bug Fixes / Style**
* Refined code formatting, access control, and minor stylistic
improvements across multiple files for consistency.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Lakr
2025-06-27 14:36:37 +08:00
committed by GitHub
parent f80b69273f
commit 9a1ce2ba3c
33 changed files with 1182 additions and 155 deletions

View File

@@ -39,4 +39,3 @@ public enum CustomJSON: CustomScalarType, Hashable {
hasher.combine(_jsonValue)
}
}

View File

@@ -0,0 +1,125 @@
//
// 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

@@ -0,0 +1,44 @@
//
// 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,74 @@
//
// 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

@@ -0,0 +1,227 @@
//
// ChatManager.swift
// Intelligents
//
// Created by on 6/26/25.
//
import AffineGraphQL
import Apollo
import Combine
import Foundation
// MARK: - ChatManager
public class ChatManager: ObservableObject {
public static let shared = ChatManager()
// MARK: - Properties
@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?
private var cancellables = Set<AnyCancellable>()
private let apolloClient: ApolloClient
// MARK: - Initialization
private init(apolloClient: ApolloClient = QLService.shared.client) {
self.apolloClient = apolloClient
}
// MARK: - Public Methods
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)
}
}
}
} 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
}
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())
)
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)"
}
}
}

View File

@@ -0,0 +1,95 @@
//
// ChatMessage.swift
// Intelligents
//
// Created by on 6/26/25.
//
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 var id: String
public var workspaceId: String
public var docId: String?
public var promptName: String
public var model: String?
public var pinned: Bool
public var tokens: Int
public var createdAt: DateTime?
public var updatedAt: DateTime?
public var parentSessionId: String?
public var createdDate: Date? {
createdAt?.decoded
}
public var updatedDate: Date? {
updatedAt?.decoded
}
public init(
id: String,
workspaceId: String,
docId: String? = nil,
promptName: String,
model: String? = nil,
pinned: Bool,
tokens: Int,
createdAt: DateTime? = nil,
updatedAt: DateTime? = nil,
parentSessionId: String? = nil
) {
self.id = id
self.workspaceId = workspaceId
self.docId = docId
self.promptName = promptName
self.model = model
self.pinned = pinned
self.tokens = tokens
self.createdAt = createdAt
self.updatedAt = updatedAt
self.parentSessionId = parentSessionId
}
}

View File

@@ -11,7 +11,6 @@ import ApolloAPI
import Foundation
/// A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date-time format.
extension DateTime {
extension DateTime {
private static let formatter: DateFormatter = {
let fmt = DateFormatter()
@@ -20,8 +19,11 @@ extension DateTime {
return fmt
}()
init(date: Date) {
self.init(Self.formatter.string(from: date))
}
var decoded: Date? {
return Self.formatter.date(from: self)
Self.formatter.date(from: self)
}
}
}

View File

@@ -5,7 +5,7 @@ import Foundation
public final class QLService {
public static let shared = QLService()
private var endpointURL: URL
public private(set) var client: ApolloClient
public var client: ApolloClient
private init() {
let store = ApolloStore()

View File

@@ -75,7 +75,48 @@ extension MainViewController: InputBoxDelegate {
}
func inputBoxDidSend(_ inputBox: InputBox) {
print(#function, inputBox, inputBox.viewModel)
let inputData = inputBox.inputBoxData
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)
}
}
}
private func showAlert(title: String, message: String) {
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "OK", style: .default))
present(alert, animated: true)
}
func inputBoxTextDidChange(_ text: String) {

View File

@@ -10,6 +10,27 @@ class MainViewController: UIViewController {
$0.delegate = self
}
lazy var tableView = UITableView().then {
$0.backgroundColor = .clear
$0.separatorStyle = .none
$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 {
$0.delegate = self
}
@@ -28,8 +49,10 @@ 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 terminateEditGesture: UITapGestureRecognizer!
// MARK: - Lifecycle
@@ -38,21 +61,46 @@ class MainViewController: UIViewController {
super.viewDidLoad()
view.backgroundColor = .affineLayerBackgroundPrimary
let inputBox = InputBox().then {
$0.delegate = self
}
self.inputBox = inputBox
setupUI()
setupBindings()
view.isUserInteractionEnabled = true
terminateEditGesture = UITapGestureRecognizer(target: self, action: #selector(terminateEditing))
view.addGestureRecognizer(terminateEditGesture)
}
// MARK: - Setup
private func setupUI() {
view.addSubview(headerView)
view.addSubview(tableView)
view.addSubview(emptyStateView)
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
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()
}
inputBox.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview()
make.bottom.equalTo(view.keyboardLayoutGuide.snp.top)
@@ -67,10 +115,24 @@ class MainViewController: UIViewController {
make.leading.trailing.equalToSuperview()
make.height.equalTo(500)
}
}
view.isUserInteractionEnabled = true
terminateEditGesture = UITapGestureRecognizer(target: self, action: #selector(terminateEditing))
view.addGestureRecognizer(terminateEditGesture)
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) {
@@ -90,4 +152,66 @@ class MainViewController: UIViewController {
@objc func terminateEditing() {
view.endEditing(true)
}
// 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
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
}
}

View File

@@ -0,0 +1,155 @@
//
// 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,23 @@
//
// 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

@@ -0,0 +1,20 @@
//
// CellType.swift
// Intelligents
//
// Created by on 6/26/25.
//
import Foundation
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,41 @@
//
// 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

@@ -0,0 +1,15 @@
//
// 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

@@ -0,0 +1,16 @@
//
// ErrorCellViewModel.swift
// Intelligents
//
// Created by on 6/26/25.
//
import Foundation
struct ErrorCellViewModel: ChatCellViewModel {
var cellType: CellType = .error
var id: String
var errorMessage: String
var canRetry: Bool
var retryAction: String?
}

View File

@@ -0,0 +1,15 @@
//
// LoadingCellViewModel.swift
// Intelligents
//
// Created by on 6/26/25.
//
import Foundation
struct LoadingCellViewModel: ChatCellViewModel {
var cellType: CellType = .loading
var id: String
var message: String?
var progress: Double?
}

View File

@@ -0,0 +1,15 @@
//
// 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

@@ -66,6 +66,12 @@ public class InputBoxViewModel: ObservableObject {
.assign(to: \.canSend, on: self)
.store(in: &cancellables)
}
public func clearAllAttachments() {
imageAttachments.removeAll()
fileAttachments.removeAll()
documentAttachments.removeAll()
}
}
// MARK: - Text Management