mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-15 05:37:32 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"] }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"] }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,使用系统消息cell作为fallback
|
||||
let fallbackCell = tableView.dequeueReusableCell(
|
||||
withIdentifier: CellType.systemMessage.rawValue,
|
||||
for: indexPath
|
||||
) as! SystemMessageCell
|
||||
|
||||
// 创建一个fallback的ViewModel
|
||||
let fallbackViewModel = SystemMessageCellViewModel(
|
||||
id: viewModel.id,
|
||||
content: "不支持的消息类型: \\(viewModel.cellType.rawValue)",
|
||||
timestamp: Date()
|
||||
)
|
||||
fallbackCell.configure(with: fallbackViewModel)
|
||||
return fallbackCell
|
||||
}
|
||||
|
||||
cell.configure(with: viewModel)
|
||||
return cell
|
||||
}
|
||||
|
||||
// MARK: - Height Estimation
|
||||
|
||||
static func estimatedHeight(for viewModel: ChatCellViewModel) -> CGFloat {
|
||||
switch viewModel.cellType {
|
||||
case .userMessage,
|
||||
.assistantMessage:
|
||||
80
|
||||
case .systemMessage:
|
||||
60
|
||||
case .loading:
|
||||
100
|
||||
case .error:
|
||||
120
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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: 实现重试逻辑
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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?
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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?
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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?
|
||||
}
|
||||
|
||||
@@ -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?
|
||||
}
|
||||
|
||||
@@ -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?
|
||||
}
|
||||
@@ -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] = []
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user