mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-15 05:37:32 +00:00
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:
@@ -39,4 +39,3 @@ public enum CustomJSON: CustomScalarType, Hashable {
|
||||
hasher.combine(_jsonValue)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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?
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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?
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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?
|
||||
}
|
||||
@@ -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?
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user